Introduce checker returning metrics

This commit is contained in:
nemunaire 2026-03-12 20:09:21 +07:00
commit 0991df069a
19 changed files with 577 additions and 20 deletions

View file

@ -82,3 +82,14 @@ func GetHTMLReport(checker happydns.Checker, raw json.RawMessage) (string, bool,
html, err := hr.GetHTMLReport(raw)
return html, true, err
}
// GetMetrics extracts time-series metrics from a slice of check results.
// Returns (report, true, nil) if the checker supports metrics, or (nil, false, nil) if not.
func GetMetrics(checker happydns.Checker, results []*happydns.CheckResult) (*happydns.MetricsReport, bool, error) {
mr, ok := checker.(happydns.CheckerMetricsReporter)
if !ok {
return nil, false, nil
}
report, err := mr.ExtractMetrics(results)
return report, true, err
}

1
go.mod
View file

@ -21,6 +21,7 @@ require (
github.com/mileusna/useragent v1.3.5
github.com/oracle/nosql-go-sdk v1.4.7
github.com/ovh/go-ovh v1.9.0
github.com/prometheus-community/pro-bing v0.8.0
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1

2
go.sum
View file

@ -509,6 +509,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=

View file

@ -80,6 +80,16 @@ func getDomainAndServiceIDFromContext(c *gin.Context) (domainID *happydns.Identi
// GetCheckerOptionsWithScope retrieves all options for a check with the given scope.
func (bc *BaseCheckerController) GetCheckerOptionsWithScope(c *gin.Context, cname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
opts, err := bc.checkerService.GetCheckerOptions(cname, userId, domainId, serviceId)
// For non-basic type, stringify
if opts != nil {
for i, opt := range *opts {
if svc, ok := opt.(*happydns.ServiceMessage); ok {
(*opts)[i] = svc.Type + ": " + svc.Comment
}
}
}
happydns.ApiResponse(c, opts, err)
}

View file

@ -432,6 +432,122 @@ func (tc *CheckResultController) GetCheckResultHTMLReport(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlContent))
}
// GetCheckResultMetrics returns time-series metrics extracted from check results
//
// @Summary Get check result metrics
// @Description Returns time-series metrics suitable for charting, extracted from recent check results. Only available for checkers that implement metrics reporting.
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param cname path string true "Check plugin name"
// @Param limit query int false "Maximum number of results to extract metrics from (default: 100)"
// @Success 200 {object} happydns.MetricsReport
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/metrics [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/metrics [get]
func (tc *CheckResultController) GetCheckResultMetrics(c *gin.Context) {
user := middleware.MyUser(c)
checkName := c.Param("cname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
limit := 100
if limitStr := c.Query("limit"); limitStr != "" {
fmt.Sscanf(limitStr, "%d", &limit)
}
checker, err := tc.checkerUC.GetChecker(checkName)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
insideScope, insideID := tc.getInsideFromContext(c)
results, err := tc.checkResultUC.ListCheckResultsByTarget(checkName, tc.scope, targetID, insideScope, insideID, user.Id, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
report, supported, err := checks.GetMetrics(checker, results)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if !supported {
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("checker %q does not support metrics", checkName))
return
}
c.JSON(http.StatusOK, report)
}
// GetSingleCheckResultMetrics returns metrics extracted from a single check result
//
// @Summary Get single check result metrics
// @Description Returns metrics extracted from a single check result. Only available for checkers that implement metrics reporting.
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param cname path string true "Check plugin name"
// @Param result_id path string true "Result ID"
// @Success 200 {object} happydns.MetricsReport
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/results/{result_id}/metrics [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results/{result_id}/metrics [get]
func (tc *CheckResultController) GetSingleCheckResultMetrics(c *gin.Context) {
user := middleware.MyUser(c)
checkName := c.Param("cname")
resultIDStr := c.Param("result_id")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
resultID, err := happydns.NewIdentifierFromString(resultIDStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID"))
return
}
insideScope, insideID := tc.getInsideFromContext(c)
result, err := tc.checkResultUC.GetCheckResult(checkName, tc.scope, targetID, resultID, insideScope, insideID, user.Id)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
checker, err := tc.checkerUC.GetChecker(checkName)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
report, supported, err := checks.GetMetrics(checker, []*happydns.CheckResult{result})
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if !supported {
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("checker %q does not support metrics", checkName))
return
}
c.JSON(http.StatusOK, report)
}
// DropCheckResult deletes a specific check result
//
// @Summary Delete check result

View file

@ -29,6 +29,9 @@ import (
// DeclareScopedCheckResultRoutes declares test result routes for a specific scope (domain, zone, or service)
func DeclareScopedCheckResultRoutes(apiChecksRoutes *gin.RouterGroup, tc *controller.CheckResultController) {
// Check metrics route
apiChecksRoutes.GET("/metrics", tc.GetCheckResultMetrics)
// Check results routes
apiChecksRoutes.GET("/results", tc.ListCheckResults)
apiChecksRoutes.DELETE("/results", tc.DropCheckResults)
@ -38,5 +41,6 @@ func DeclareScopedCheckResultRoutes(apiChecksRoutes *gin.RouterGroup, tc *contro
apiCheckResultsRoutes.GET("", tc.GetCheckResult)
apiCheckResultsRoutes.DELETE("", tc.DropCheckResult)
apiCheckResultsRoutes.GET("/report", tc.GetCheckResultHTMLReport)
apiCheckResultsRoutes.GET("/metrics", tc.GetSingleCheckResultMetrics)
}
}

View file

@ -95,6 +95,34 @@ type CheckerHTMLReporter interface {
GetHTMLReport(raw json.RawMessage) (string, error)
}
// MetricPoint represents a single data point in a time series.
type MetricPoint struct {
Timestamp time.Time `json:"timestamp"`
Value float64 `json:"value"`
}
// MetricSeries represents a named time series with a display label and unit.
type MetricSeries struct {
Name string `json:"name"`
Label string `json:"label"`
Unit string `json:"unit"`
Points []MetricPoint `json:"points"`
}
// MetricsReport contains all metric series extracted from check results.
type MetricsReport struct {
Series []MetricSeries `json:"series"`
}
// CheckerMetricsReporter is an optional interface checkers can implement
// to signal that they produce time-series metrics suitable for charting.
// Detect support with a type assertion: _, ok := checker.(CheckerMetricsReporter)
type CheckerMetricsReporter interface {
// ExtractMetrics builds time series from a slice of check results.
// Each result's ExecutedAt becomes the timestamp for that data point.
ExtractMetrics(results []*CheckResult) (*MetricsReport, error)
}
type CheckerResponse struct {
ID string `json:"id"`
Name string `json:"name"`
@ -102,6 +130,7 @@ type CheckerResponse struct {
Options CheckerOptionsDocumentation `json:"options"`
Interval *CheckIntervalSpec `json:"interval"`
HasHTMLReport bool `json:"has_html_report,omitempty"`
HasMetrics bool `json:"has_metrics,omitempty"`
}
type SetCheckerOptionsRequest struct {

43
web/package-lock.json generated
View file

@ -13,6 +13,9 @@
"@sveltestrap/sveltestrap": "^7.0.0",
"bootstrap": "^5.3.0",
"bootstrap-icons": "^1.13.0",
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"highlight.js": "^11.11.1",
"html-escaper": "^3.0.0",
"sass": "^1.97.0",
@ -864,6 +867,12 @@
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"license": "MIT"
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
@ -2374,6 +2383,29 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chartjs-adapter-date-fns": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
"license": "MIT",
"peerDependencies": {
"chart.js": ">=2.8.0",
"date-fns": ">=2.0.0"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@ -2512,6 +2544,17 @@
"node": ">=4"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

View file

@ -45,6 +45,9 @@
"@sveltestrap/sveltestrap": "^7.0.0",
"bootstrap": "^5.3.0",
"bootstrap-icons": "^1.13.0",
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"highlight.js": "^11.11.1",
"html-escaper": "^3.0.0",
"sass": "^1.97.0",

View file

@ -51,6 +51,10 @@ import {
deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultId,
deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResults,
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultIdReport,
getDomainsByDomainChecksByCnameMetrics,
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameMetrics,
getDomainsByDomainChecksByCnameResultsByResultIdMetrics,
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultIdMetrics,
} from "$lib/api-base/sdk.gen";
import { unwrapSdkResponse } from "./errors";
import type {
@ -62,6 +66,7 @@ import type {
CheckResult,
CheckExecution,
CheckScopeType,
MetricsReport,
} from "$lib/model/checker";
export async function listCheckers(): Promise<CheckerList> {
@ -502,3 +507,76 @@ export async function getServiceCheckResultHTMLReport(
),
) as string;
}
// --- Metrics API functions ---
export async function getCheckMetrics(
domainId: string,
checkName: string,
limit: number = 100,
): Promise<MetricsReport> {
return unwrapSdkResponse(
await getDomainsByDomainChecksByCnameMetrics({
path: { domain: domainId, cname: checkName },
query: { limit },
}),
) as unknown as MetricsReport;
}
export async function getServiceCheckMetrics(
domainId: string,
zoneId: string,
subdomain: string,
serviceid: string,
checkName: string,
limit: number = 100,
): Promise<MetricsReport> {
return unwrapSdkResponse(
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameMetrics({
path: {
domain: domainId,
zoneid: zoneId,
subdomain,
serviceid,
cname: checkName,
} as any,
query: { limit },
}),
) as unknown as MetricsReport;
}
export async function getCheckResultMetrics(
domainId: string,
checkName: string,
resultId: string,
): Promise<MetricsReport> {
return unwrapSdkResponse(
await getDomainsByDomainChecksByCnameResultsByResultIdMetrics({
path: { domain: domainId, cname: checkName, result_id: resultId },
}),
) as unknown as MetricsReport;
}
export async function getServiceCheckResultMetrics(
domainId: string,
zoneId: string,
subdomain: string,
serviceid: string,
checkName: string,
resultId: string,
): Promise<MetricsReport> {
return unwrapSdkResponse(
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultIdMetrics(
{
path: {
domain: domainId,
zoneid: zoneId,
subdomain,
serviceid,
cname: checkName,
result_id: resultId,
} as any,
},
),
) as unknown as MetricsReport;
}

View file

@ -0,0 +1,158 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import {
Chart,
LineController,
LineElement,
PointElement,
LinearScale,
TimeScale,
Legend,
Tooltip,
Filler,
} from "chart.js";
import "chartjs-adapter-date-fns";
import type { MetricsReport } from "$lib/model/checker";
Chart.register(
LineController,
LineElement,
PointElement,
LinearScale,
TimeScale,
Legend,
Tooltip,
Filler,
);
interface Props {
report: MetricsReport;
}
let { report }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
const COLORS = [
"#0d6efd",
"#dc3545",
"#198754",
"#ffc107",
"#6610f2",
"#0dcaf0",
"#fd7e14",
"#d63384",
];
function buildChart() {
if (chart) {
chart.destroy();
chart = null;
}
if (!canvas || !report?.series?.length) return;
const units = [...new Set(report.series.map((s) => s.unit))];
const hasRightAxis = units.length > 1;
const rightUnit = hasRightAxis ? units[1] : null;
const datasets = report.series.map((series, i) => ({
label: series.label,
data: series.points.map((p) => ({
x: new Date(p.timestamp).getTime(),
y: p.value,
})),
borderColor: COLORS[i % COLORS.length],
backgroundColor: COLORS[i % COLORS.length] + "20",
borderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
tension: 0.3,
yAxisID: hasRightAxis && series.unit === rightUnit ? "y1" : "y",
}));
const scales: Record<string, any> = {
x: {
type: "time" as const,
time: { tooltipFormat: "PPpp" },
title: { display: false },
},
y: {
type: "linear" as const,
position: "left" as const,
title: { display: true, text: units[0] || "" },
beginAtZero: true,
},
};
if (hasRightAxis && rightUnit) {
scales.y1 = {
type: "linear" as const,
position: "right" as const,
title: { display: true, text: rightUnit },
beginAtZero: true,
grid: { drawOnChartArea: false },
};
}
chart = new Chart(canvas, {
type: "line",
data: { datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
scales,
plugins: {
legend: { position: "bottom" },
tooltip: { mode: "index", intersect: false },
},
},
});
}
onMount(() => {
buildChart();
});
$effect(() => {
// Re-build chart when report changes
if (report && canvas) {
buildChart();
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
chart = null;
}
});
</script>
<div class="chart-container" style="position: relative; height: 350px; width: 100%;">
<canvas bind:this={canvas}></canvas>
</div>

View file

@ -39,7 +39,7 @@
} from "@sveltestrap/sveltestrap";
// Store imports
import { currentCheckResult, currentCheckInfo, showHTMLReport } from "$lib/stores/checkers";
import { currentCheckResult, currentCheckInfo, showHTMLReport, reportViewMode } from "$lib/stores/checkers";
import { toasts } from "$lib/stores/toasts";
// API imports
@ -267,25 +267,39 @@
<div class="my-3 flex-fill"></div>
{#if $currentCheckInfo?.has_html_report || $currentCheckResult.report != null}
{#if $currentCheckInfo?.has_html_report}
{#if $currentCheckInfo?.has_html_report || $currentCheckInfo?.has_metrics || $currentCheckResult.report != null}
{#if $currentCheckInfo?.has_metrics || $currentCheckInfo?.has_html_report}
<ButtonGroup class="w-100 mb-2">
{#if $currentCheckInfo?.has_metrics}
<Button
size="sm"
color="secondary"
outline
active={$reportViewMode === "metrics"}
onclick={() => { reportViewMode.set("metrics"); showHTMLReport.set(false); }}
>
<Icon name="graph-up"></Icon>
{$t("checkers.result.view-metrics")}
</Button>
{/if}
{#if $currentCheckInfo?.has_html_report}
<Button
size="sm"
color="secondary"
outline
active={$reportViewMode === "html" || (!$currentCheckInfo?.has_metrics && $showHTMLReport)}
onclick={() => { reportViewMode.set("html"); showHTMLReport.set(true); }}
>
<Icon name="file-earmark-richtext"></Icon>
{$t("checkers.result.view-html")}
</Button>
{/if}
<Button
size="sm"
color="secondary"
outline
active={$showHTMLReport}
onclick={() => showHTMLReport.set(true)}
>
<Icon name="file-earmark-richtext"></Icon>
{$t("checkers.result.view-html")}
</Button>
<Button
size="sm"
color="secondary"
outline
active={!$showHTMLReport}
onclick={() => showHTMLReport.set(false)}
active={$reportViewMode === "json" || (!$currentCheckInfo?.has_metrics && !$currentCheckInfo?.has_html_report)}
onclick={() => { reportViewMode.set("json"); showHTMLReport.set(false); }}
>
<Icon name="braces"></Icon>
{$t("checkers.result.view-json")}

View file

@ -26,7 +26,7 @@
import { onDestroy } from "svelte";
import { t } from "$lib/translations";
import type { CheckerInfo, CheckResult } from "$lib/model/checker";
import type { CheckerInfo, CheckResult, MetricsReport } from "$lib/model/checker";
import {
currentCheckResult,
currentCheckInfo,
@ -38,14 +38,30 @@
resultPromise: Promise<CheckResult>;
checkPromise: Promise<CheckerInfo>;
htmlReportPromise: Promise<string>;
getMetrics: () => Promise<MetricsReport>;
}
let { resultPromise, checkPromise, htmlReportPromise }: Props = $props();
let { resultPromise, checkPromise, htmlReportPromise, getMetrics }: Props = $props();
let metricsReport = $state<MetricsReport | null>(null);
$effect(() => {
resultPromise.then((r) => currentCheckResult.set(r));
});
$effect(() => {
metricsReport = null;
checkPromise.then((c) => {
currentCheckInfo.set(c);
if (c.has_metrics) {
reportViewMode.set("metrics");
getMetrics()
.then((r) => (metricsReport = r))
.catch(() => {});
}
});
});
onDestroy(() => {
currentCheckResult.set(null);
currentCheckInfo.set(null);
@ -62,7 +78,34 @@
</div>
{:then [result, check]}
{#if result.report || check.has_html_report || check.has_metrics}
{#if check.has_html_report && ($reportViewMode === "html" || ($showHTMLReport && $reportViewMode !== "json"))}
{#if check.has_metrics && $reportViewMode === "metrics"}
<div class="p-3 flex-fill">
{#if metricsReport}
<Table size="sm" hover striped>
<thead>
<tr>
<th>Metric</th>
<th class="text-end">Value</th>
<th>Unit</th>
</tr>
</thead>
<tbody>
{#each metricsReport.series as series}
{#each series.points as point}
<tr>
<td>{series.label}</td>
<td class="text-end font-monospace">{point.value}</td>
<td class="text-muted">{series.unit}</td>
</tr>
{/each}
{/each}
</tbody>
</Table>
{:else}
<div class="text-center p-4"><Spinner /></div>
{/if}
</div>
{:else if check.has_html_report && ($reportViewMode === "html" || ($showHTMLReport && $reportViewMode !== "json"))}
{#await htmlReportPromise}
<div class="text-center p-4"><Spinner /></div>
{:then html}

View file

@ -39,9 +39,10 @@
import { getCheckStatus } from "$lib/api/checkers";
import PageTitle from "$lib/components/PageTitle.svelte";
import type { Domain } from "$lib/model/domain";
import type { CheckExecution, CheckResult } from "$lib/model/checker";
import type { CheckExecution, CheckResult, MetricsReport } from "$lib/model/checker";
import { CheckExecutionStatus, CheckScopeType } from "$lib/model/checker";
import RunCheckModal from "$lib/components/modals/RunCheckModal.svelte";
import CheckMetricsChart from "$lib/components/checkers/CheckMetricsChart.svelte";
import { getStatusColor, getStatusKey, formatDuration, formatCheckDate } from "$lib/utils";
interface Props {
@ -55,6 +56,7 @@
getExecution: (executionId: string) => Promise<CheckExecution>;
deleteResult: (resultId: string) => Promise<void>;
deleteAllResults: () => Promise<void>;
loadMetrics?: () => Promise<MetricsReport>;
zoneId?: string;
subdomain?: string;
serviceid?: string;
@ -71,6 +73,7 @@
getExecution,
deleteResult,
deleteAllResults,
loadMetrics,
zoneId,
subdomain,
serviceid,
@ -79,8 +82,18 @@
let resultsPromise = $state(loadResults());
let checkerPromise = $derived(getCheckStatus(checkerName));
let checkerDisplayName = $state(checkerName);
let metricsReport = $state<MetricsReport | null>(null);
$effect(() => {
checkerPromise.then((c) => (checkerDisplayName = c.name || checkerName)).catch(() => {});
checkerPromise
.then((c) => {
checkerDisplayName = c.name || checkerName;
if (c.has_metrics && loadMetrics) {
loadMetrics()
.then((r) => (metricsReport = r))
.catch(() => {});
}
})
.catch(() => {});
});
let runCheckModal: RunCheckModal;
let errorMessage = $state<string | null>(null);
@ -193,6 +206,13 @@
<p>{$t("checkers.results.loading")}</p>
</div>
{:then results}
{#if metricsReport}
<Card class="mb-3">
<div class="card-body">
<CheckMetricsChart report={metricsReport} />
</div>
</Card>
{/if}
{#if (!results || results.length === 0) && pendingExecutions.length === 0}
<Card body>
<p class="text-center text-muted mb-0">

View file

@ -725,6 +725,7 @@
"status-message": "Message:",
"error": "Error:"
},
"view-metrics": "Metrics",
"view-html": "HTML Report",
"view-json": "Raw JSON",
"download-html": "Download HTML",

View file

@ -69,6 +69,22 @@ export enum CheckExecutionStatus {
CheckExecutionFailed = 3,
}
export interface MetricPoint {
timestamp: string;
value: number;
}
export interface MetricSeries {
name: string;
label: string;
unit: string;
points: MetricPoint[];
}
export interface MetricsReport {
series: MetricSeries[];
}
export interface AvailableChecker {
checker_name: string;
enabled: boolean;

View file

@ -34,3 +34,6 @@ export async function refreshCheckers() {
export const currentCheckResult: Writable<CheckResult | null> = writable(null);
export const currentCheckInfo: Writable<CheckerInfo | null> = writable(null);
export const showHTMLReport: Writable<boolean> = writable(true);
export type ReportViewMode = "metrics" | "html" | "json";
export const reportViewMode: Writable<ReportViewMode> = writable("html");

View file

@ -28,6 +28,7 @@
deleteServiceCheckResult,
deleteAllServiceCheckResults,
getServiceCheckExecution,
getServiceCheckMetrics,
} from "$lib/api/checkers";
import type { Domain } from "$lib/model/domain";
import { CheckScopeType } from "$lib/model/checker";
@ -66,6 +67,8 @@
deleteServiceCheckResult(data.domain.id, data.zoneId, data.subdomain, data.serviceid, checkerName, id)}
deleteAllResults={() =>
deleteAllServiceCheckResults(data.domain.id, data.zoneId, data.subdomain, data.serviceid, checkerName)}
loadMetrics={() =>
getServiceCheckMetrics(data.domain.id, data.zoneId, data.subdomain, data.serviceid, checkerName, 50)}
zoneId={data.zoneId}
subdomain={data.subdomain}
serviceid={data.serviceid}

View file

@ -28,6 +28,7 @@
deleteCheckResult,
deleteAllCheckResults,
getCheckExecution,
getCheckMetrics,
} from "$lib/api/checkers";
import type { Domain } from "$lib/model/domain";
import { CheckScopeType } from "$lib/model/checker";
@ -55,4 +56,5 @@
getExecution={(id) => getCheckExecution(data.domain.id, checkName, id)}
deleteResult={(id) => deleteCheckResult(data.domain.id, checkName, id)}
deleteAllResults={() => deleteAllCheckResults(data.domain.id, checkName)}
loadMetrics={() => getCheckMetrics(data.domain.id, checkName, 50)}
/>