feat: troppa roba davvero
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user