Files
thePlug/frontend/src/components/HealthDetailsModal.vue
T
2026-05-07 09:21:45 +02:00

275 lines
9.4 KiB
Vue

<script setup lang="ts">
import { computed } from "vue";
import { useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext();
const latestTimestamp = computed(() => {
if (!plane.health?.timestamp) return "No checks yet";
return new Date(plane.health.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
});
const targetRows = computed(() => {
const checks = plane.health?.checks ?? [];
return [
checks.find((check) => check.target === "proxy") ?? null,
checks.find((check) => check.target === "upstream") ?? null,
];
});
const benchmarkEnabled = computed(
() =>
plane.selectedProxy?.healthcheck.benchmark_enabled ??
plane.draft.healthcheck.benchmark_enabled,
);
function benchmarkSeries(target: "proxy" | "upstream") {
return plane.healthHistory
.map((sample, index) => {
const check = sample.checks.find((item) => item.target === target);
const duration =
typeof check?.duration_ms === "number" ? check.duration_ms : null;
if (duration === null) return null;
return {
index,
duration,
};
})
.filter((item): item is { index: number; duration: number } => item !== null);
}
function latestBenchmark(target: "proxy" | "upstream") {
const check = plane.health?.checks.find((item) => item.target === target);
return typeof check?.duration_ms === "number" ? check.duration_ms : null;
}
function averageBenchmark(target: "proxy" | "upstream") {
const values = benchmarkSeries(target).map((item) => item.duration);
if (!values.length) return null;
const total = values.reduce((sum, value) => sum + value, 0);
return Math.round((total / values.length) * 100) / 100;
}
const benchmarkSummary = computed(() => ({
proxyLast: latestBenchmark("proxy"),
proxyAvg: averageBenchmark("proxy"),
upstreamLast: latestBenchmark("upstream"),
upstreamAvg: averageBenchmark("upstream"),
}));
const chartPoints = computed(() => {
const history = plane.healthHistory;
const width = 220;
const height = 54;
const build = (target: "proxy" | "upstream") => {
const points = history
.map((sample, index) => {
const check = sample.checks.find((item) => item.target === target);
if (!check) return null;
const x = history.length === 1 ? width / 2 : (index / (history.length - 1)) * width;
const y = check.healthy ? 10 : height - 10;
return `${x},${y}`;
})
.filter(Boolean);
return points.join(" ");
};
return {
proxy: build("proxy"),
upstream: build("upstream"),
width,
height,
};
});
const benchmarkChart = computed(() => {
const width = 220;
const height = 72;
const proxySeries = benchmarkSeries("proxy");
const upstreamSeries = benchmarkSeries("upstream");
const allValues = [...proxySeries, ...upstreamSeries].map((item) => item.duration);
const maxDuration = allValues.length ? Math.max(...allValues) : 0;
const domainMax = maxDuration > 0 ? maxDuration : 1;
const toPoints = (series: { index: number; duration: number }[]) =>
series
.map((item) => {
const x =
plane.healthHistory.length <= 1
? width / 2
: (item.index / (plane.healthHistory.length - 1)) * width;
const y = height - 10 - (item.duration / domainMax) * (height - 20);
return `${x},${y}`;
})
.join(" ");
return {
width,
height,
proxy: toPoints(proxySeries),
upstream: toPoints(upstreamSeries),
hasData: allValues.length > 0,
maxDuration: Math.round(domainMax * 100) / 100,
};
});
function formatDuration(value: number | null) {
return value === null ? "n/a" : `${value.toFixed(2)} ms`;
}
</script>
<template>
<div v-if="plane.healthModalOpen" class="modal-backdrop" @click.self="plane.closeHealthModal">
<section class="modal-shell health-modal-shell">
<div class="modal-head">
<div>
<h2>Health</h2>
<p class="modal-step-copy">
Runtime checks for the live proxy path and the real upstream service.
</p>
</div>
<div class="modal-head-actions">
<button class="ghost button-icon" :disabled="plane.loading" @click="plane.checkHealth">
<UiIcon name="pulse" />
<span>Run now</span>
</button>
<button class="ghost button-icon" @click="plane.closeHealthModal">
<UiIcon name="close" />
<span>Close</span>
</button>
</div>
</div>
<div class="modal-body health-modal-body">
<div
class="health-runtime-overview"
:class="plane.health?.overall ?? 'disabled'"
>
<strong>{{ plane.health?.overall ?? "pending" }}</strong>
<span>Last check {{ latestTimestamp }}</span>
</div>
<div class="health-runtime-list">
<article
v-for="(row, index) in targetRows"
:key="row?.target ?? `pending-${index}`"
class="health-runtime-card"
:class="row ? (row.healthy ? 'healthy' : 'unhealthy') : 'pending'"
>
<div class="health-runtime-card-head">
<strong>{{ row?.target ?? "pending" }}</strong>
<span>{{ row ? (row.healthy ? "ok" : "issue") : "waiting" }}</span>
</div>
<p class="muted small compact-copy">
{{ row ? `${row.host}:${row.port} · ${row.protocol}` : "No data yet." }}
</p>
<p
v-if="benchmarkEnabled && typeof row?.duration_ms === 'number'"
class="muted small compact-copy"
>
Last response {{ formatDuration(row.duration_ms) }}
</p>
<p v-if="row?.error" class="error compact health-inline-error">
{{ row.error }}
</p>
</article>
</div>
<div class="health-chart-card">
<div class="panel-title">
<h3>Recent timeline</h3>
<span class="muted small">{{ plane.healthHistory.length }} samples</span>
</div>
<div v-if="plane.healthHistory.length" class="health-chart-grid">
<div class="health-chart-row">
<span>Proxy</span>
<svg
class="health-chart"
:viewBox="`0 0 ${chartPoints.width} ${chartPoints.height}`"
preserveAspectRatio="none"
>
<polyline class="health-chart-line proxy" :points="chartPoints.proxy" />
</svg>
</div>
<div class="health-chart-row">
<span>Upstream</span>
<svg
class="health-chart"
:viewBox="`0 0 ${chartPoints.width} ${chartPoints.height}`"
preserveAspectRatio="none"
>
<polyline class="health-chart-line upstream" :points="chartPoints.upstream" />
</svg>
</div>
</div>
<p v-else class="muted small">
Health history will appear after the first scheduled runs.
</p>
</div>
<div v-if="benchmarkEnabled" class="health-chart-card">
<div class="panel-title">
<h3>Benchmark</h3>
<span class="muted small">Response latency</span>
</div>
<div class="benchmark-summary-grid">
<article class="metric-card">
<span class="metric-label">Proxy last</span>
<strong>{{ formatDuration(benchmarkSummary.proxyLast) }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Proxy avg</span>
<strong>{{ formatDuration(benchmarkSummary.proxyAvg) }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Upstream last</span>
<strong>{{ formatDuration(benchmarkSummary.upstreamLast) }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Upstream avg</span>
<strong>{{ formatDuration(benchmarkSummary.upstreamAvg) }}</strong>
</article>
</div>
<div v-if="benchmarkChart.hasData" class="health-chart-grid">
<div class="health-chart-row">
<span>Proxy</span>
<svg
class="health-chart benchmark-chart"
:viewBox="`0 0 ${benchmarkChart.width} ${benchmarkChart.height}`"
preserveAspectRatio="none"
>
<polyline class="health-chart-line proxy" :points="benchmarkChart.proxy" />
</svg>
</div>
<div class="health-chart-row">
<span>Upstream</span>
<svg
class="health-chart benchmark-chart"
:viewBox="`0 0 ${benchmarkChart.width} ${benchmarkChart.height}`"
preserveAspectRatio="none"
>
<polyline class="health-chart-line upstream" :points="benchmarkChart.upstream" />
</svg>
</div>
<p class="muted small compact-copy">
Scale 0 → {{ benchmarkChart.maxDuration.toFixed(2) }} ms
</p>
</div>
<p v-else class="muted small">
Collecting benchmark data from scheduled health runs.
</p>
</div>
</div>
</section>
</div>
</template>