checkers: add frontend metrics chart on execution pages

Add Chart.js-based line chart for checker metrics. The chart appears
on the executions list page (aggregated) and on individual execution
detail pages. Metrics view mode is selectable via the sidebar alongside
HTML report and raw JSON views.
This commit is contained in:
nemunaire 2026-04-04 23:45:47 +07:00
commit bc094caacb
9 changed files with 420 additions and 25 deletions

41
web/package-lock.json generated
View file

@ -12,6 +12,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",
@ -434,6 +437,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/@napi-rs/wasm-runtime": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
@ -1759,6 +1768,28 @@
"node": ">=18"
}
},
"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",
"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",
@ -1869,6 +1900,16 @@
"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",
"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

@ -44,6 +44,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

@ -602,6 +602,100 @@ export async function updateScopedCheckPlan(
return updateDomainCheckPlan(scope.domainId, checkerId, planId, plan);
}
// --- Metrics types and API functions ---
export interface CheckMetric {
name: string;
value: number;
unit?: string;
labels?: Record<string, string>;
timestamp: string;
}
export async function getDomainCheckerMetrics(
domain: string,
checkerId: string,
limit: number = 100,
): Promise<CheckMetric[]> {
const url = `${base}/api/domains/${encodeURIComponent(domain)}/checkers/${encodeURIComponent(checkerId)}/metrics?limit=${limit}`;
const resp = await fetch(url, { headers: { Accept: "application/json" } });
if (!resp.ok) {
const body = await resp.text();
throw new Error(body || `HTTP ${resp.status}`);
}
return resp.json();
}
export async function getServiceCheckerMetrics(
domain: string,
zoneid: string,
subdomain: string,
serviceid: string,
checkerId: string,
limit: number = 100,
): Promise<CheckMetric[]> {
const url = `${base}/api/domains/${encodeURIComponent(domain)}/zone/${encodeURIComponent(zoneid)}/${encodeURIComponent(subdomain)}/services/${encodeURIComponent(serviceid)}/checkers/${encodeURIComponent(checkerId)}/metrics?limit=${limit}`;
const resp = await fetch(url, { headers: { Accept: "application/json" } });
if (!resp.ok) {
const body = await resp.text();
throw new Error(body || `HTTP ${resp.status}`);
}
return resp.json();
}
export async function getScopedCheckerMetrics(
scope: CheckerScope,
checkerId: string,
limit: number = 100,
): Promise<CheckMetric[]> {
if (isServiceScope(scope)) {
return getServiceCheckerMetrics(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, limit);
}
return getDomainCheckerMetrics(scope.domainId, checkerId, limit);
}
export async function getDomainExecutionMetrics(
domain: string,
checkerId: string,
executionId: string,
): Promise<CheckMetric[]> {
const url = `${base}/api/domains/${encodeURIComponent(domain)}/checkers/${encodeURIComponent(checkerId)}/executions/${encodeURIComponent(executionId)}/metrics`;
const resp = await fetch(url, { headers: { Accept: "application/json" } });
if (!resp.ok) {
const body = await resp.text();
throw new Error(body || `HTTP ${resp.status}`);
}
return resp.json();
}
export async function getServiceExecutionMetrics(
domain: string,
zoneid: string,
subdomain: string,
serviceid: string,
checkerId: string,
executionId: string,
): Promise<CheckMetric[]> {
const url = `${base}/api/domains/${encodeURIComponent(domain)}/zone/${encodeURIComponent(zoneid)}/${encodeURIComponent(subdomain)}/services/${encodeURIComponent(serviceid)}/checkers/${encodeURIComponent(checkerId)}/executions/${encodeURIComponent(executionId)}/metrics`;
const resp = await fetch(url, { headers: { Accept: "application/json" } });
if (!resp.ok) {
const body = await resp.text();
throw new Error(body || `HTTP ${resp.status}`);
}
return resp.json();
}
export async function getScopedExecutionMetrics(
scope: CheckerScope,
checkerId: string,
executionId: string,
): Promise<CheckMetric[]> {
if (isServiceScope(scope)) {
return getServiceExecutionMetrics(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, executionId);
}
return getDomainExecutionMetrics(scope.domainId, checkerId, executionId);
}
// HTML report functions (returns text/html, not JSON — uses direct fetch)
export async function getDomainExecutionHTMLReport(

View file

@ -0,0 +1,198 @@
<!--
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 { CheckMetric } from "$lib/api/checkers";
Chart.register(
LineController,
LineElement,
PointElement,
LinearScale,
TimeScale,
Legend,
Tooltip,
Filler,
);
interface Props {
metrics: CheckMetric[];
}
let { metrics }: 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 || !metrics?.length) return;
// Group metrics into series by (name, labels)
type SeriesKey = string;
interface Series {
name: string;
unit: string;
labels: Record<string, string>;
points: { x: number; y: number }[];
}
const seriesMap = new Map<SeriesKey, Series>();
const seriesOrder: SeriesKey[] = [];
for (const m of metrics) {
const labelStr = m.labels ? Object.entries(m.labels).sort().map(([k, v]) => `${k}=${v}`).join(",") : "";
const key = `${m.name}{${labelStr}}`;
if (!seriesMap.has(key)) {
seriesMap.set(key, {
name: m.name,
unit: m.unit ?? "",
labels: m.labels ?? {},
points: [],
});
seriesOrder.push(key);
}
seriesMap.get(key)!.points.push({
x: new Date(m.timestamp).getTime(),
y: m.value,
});
}
// Sort points by time within each series
for (const series of seriesMap.values()) {
series.points.sort((a, b) => a.x - b.x);
}
// Determine unique units for multi-axis support
const units = [...new Set(Array.from(seriesMap.values()).map((s) => s.unit))];
const hasRightAxis = units.length > 1;
const rightUnit = hasRightAxis ? units[1] : null;
const datasets = seriesOrder.map((key, i) => {
const series = seriesMap.get(key)!;
const labelParts = Object.entries(series.labels).map(([k, v]) => `${v}`);
const displayLabel = labelParts.length > 0
? `${series.name} (${labelParts.join(", ")})`
: series.name;
return {
label: displayLabel,
data: series.points,
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(() => {
if (metrics && 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

@ -26,10 +26,11 @@
import { Alert, Card, Container, Icon } from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import type { CheckerScope } from "$lib/api/checkers";
import type { CheckerScope, CheckMetric } from "$lib/api/checkers";
import {
getScopedExecution,
getScopedExecutionObservations,
getScopedExecutionMetrics,
getCheckStatus,
} from "$lib/api/checkers";
import { currentExecution, currentCheckInfo, currentObservations, showHTMLReport, reportViewMode } from "$lib/stores/checkers";
@ -46,10 +47,12 @@
let checkerName = $state<string>("");
let loading = $state(true);
let error = $state<string | undefined>(undefined);
let metricsData = $state<CheckMetric[] | null>(null);
$effect(() => {
loading = true;
error = undefined;
metricsData = null;
Promise.all([
getScopedExecution(scope, checkerId, execId),
@ -61,8 +64,14 @@
currentCheckInfo.set(checkerInfo);
currentObservations.set(observations);
checkerName = checkerInfo.name ?? checkerId;
// Default to HTML view if the checker supports it
if (checkerInfo.has_html_report) {
// Default to metrics view if supported, then HTML, then JSON
if (checkerInfo.has_metrics) {
reportViewMode.set("metrics");
showHTMLReport.set(false);
getScopedExecutionMetrics(scope, checkerId, execId)
.then((m) => (metricsData = m))
.catch(() => {});
} else if (checkerInfo.has_html_report) {
showHTMLReport.set(true);
reportViewMode.set("html");
} else {
@ -110,6 +119,7 @@
{:else if $currentObservations}
<ObservationReportCard
observations={$currentObservations}
metrics={metricsData}
{scope}
{checkerId}
{execId}

View file

@ -27,8 +27,8 @@
import { t } from "$lib/translations";
import { toasts } from "$lib/stores/toasts";
import type { HappydnsExecution } from "$lib/api-base/types.gen";
import type { CheckerScope } from "$lib/api/checkers";
import { listScopedExecutions, getCheckStatus, deleteScopedExecution, deleteAllScopedExecutions } from "$lib/api/checkers";
import type { CheckerScope, CheckMetric } from "$lib/api/checkers";
import { listScopedExecutions, getCheckStatus, deleteScopedExecution, deleteAllScopedExecutions, getScopedCheckerMetrics } from "$lib/api/checkers";
import {
getExecutionStatusColor,
getExecutionStatusI18nKey,
@ -38,6 +38,7 @@
} from "$lib/utils";
import PageTitle from "$lib/components/PageTitle.svelte";
import RunCheckModal from "$lib/components/modals/RunCheckModal.svelte";
import CheckMetricsChart from "$lib/components/checkers/CheckMetricsChart.svelte";
interface Props {
scope: CheckerScope;
@ -52,10 +53,16 @@
let executions = $state<HappydnsExecution[]>([]);
let executionsPromise: Promise<HappydnsExecution[]> = $derived(loadExecutions());
let runCheckModal = $state<RunCheckModal>();
let metricsData = $state<CheckMetric[] | null>(null);
$effect(() => {
getCheckStatus(checkerId).then((s) => {
resolvedName = s.name ?? checkerId;
if (s.has_metrics) {
getScopedCheckerMetrics(scope, checkerId)
.then((m) => (metricsData = m))
.catch(() => {});
}
});
});
@ -168,6 +175,13 @@
</p>
</Card>
{:then _executions}
{#if metricsData && metricsData.length > 0}
<Card class="mb-3">
<div class="card-body">
<CheckMetricsChart metrics={metricsData} />
</div>
</Card>
{/if}
{#if executions.length === 0}
<Alert color="info">
<Icon name="info-circle" />

View file

@ -204,20 +204,21 @@
<div class="my-3 flex-fill"></div>
<ButtonGroup class="w-100 mb-2">
<Button
size="sm"
color="secondary"
outline
disabled
active={$reportViewMode === "metrics"}
onclick={() => {
reportViewMode.set("metrics");
showHTMLReport.set(false);
}}
>
<Icon name="graph-up"></Icon>
{$t("checkers.result.view-metrics")}
</Button>
{#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"

View file

@ -22,22 +22,24 @@
-->
<script lang="ts">
import { Spinner } from "@sveltestrap/sveltestrap";
import { Spinner, Table } from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import type { HappydnsObservationSnapshot } from "$lib/api-base/types.gen";
import type { CheckerScope } from "$lib/api/checkers";
import type { CheckerScope, CheckMetric } from "$lib/api/checkers";
import { getScopedExecutionHTMLReport } from "$lib/api/checkers";
import { showHTMLReport } from "$lib/stores/checkers";
import { showHTMLReport, reportViewMode } from "$lib/stores/checkers";
import CheckMetricsChart from "./CheckMetricsChart.svelte";
interface Props {
observations: HappydnsObservationSnapshot;
metrics?: CheckMetric[] | null;
scope?: CheckerScope;
checkerId?: string;
execId?: string;
}
let { observations, scope, checkerId, execId }: Props = $props();
let { observations, metrics = null, scope, checkerId, execId }: Props = $props();
let htmlReportPromise = $state<Promise<string> | null>(null);
@ -56,7 +58,36 @@
class="flex-fill d-flex"
style="overflow: auto; padding: var(--bs-card-spacer-y) var(--bs-card-spacer-x)"
>
{#if $showHTMLReport && htmlReportPromise}
{#if $reportViewMode === "metrics" && metrics && metrics.length > 0}
<div style="width: 100%;">
<CheckMetricsChart {metrics} />
<Table size="sm" hover striped class="mt-3">
<thead>
<tr>
<th>{$t("checkers.result.metric-name")}</th>
<th class="text-end">{$t("checkers.result.metric-value")}</th>
<th>{$t("checkers.result.metric-unit")}</th>
</tr>
</thead>
<tbody>
{#each metrics as metric}
<tr>
<td>
{metric.name}
{#if metric.labels}
<small class="text-muted">
{Object.entries(metric.labels).map(([k, v]) => `${k}=${v}`).join(", ")}
</small>
{/if}
</td>
<td class="text-end font-monospace">{metric.value}</td>
<td class="text-muted">{metric.unit ?? ""}</td>
</tr>
{/each}
</tbody>
</Table>
</div>
{:else if $showHTMLReport && htmlReportPromise}
{#await htmlReportPromise}
<div class="text-center p-4"><Spinner /></div>
{:then html}

View file

@ -768,7 +768,10 @@
"view-html": "HTML Report",
"view-json": "Raw JSON",
"download-html": "Download HTML",
"download-json": "Download JSON"
"download-json": "Download JSON",
"metric-name": "Metric",
"metric-value": "Value",
"metric-unit": "Unit"
},
"title": "Checkers",
"description": "Configure automated checks for your domains",