275 lines
9.4 KiB
Vue
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>
|