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:
parent
5e0b5fc09b
commit
bc094caacb
9 changed files with 420 additions and 25 deletions
41
web/package-lock.json
generated
41
web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
198
web/src/lib/components/checkers/CheckMetricsChart.svelte
Normal file
198
web/src/lib/components/checkers/CheckMetricsChart.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue