Files
Supervisor/components/SystemMetricsChart.vue

653 lines
16 KiB
Vue

<template>
<section class="chart-card card-glow">
<div class="card-header">
<div>
<h2 class="card-title">Historique systeme</h2>
<p class="card-copy">CPU, RAM et debit reseau avec cache journalier local</p>
</div>
<div class="controls">
<div class="toggle-group" role="radiogroup" aria-label="Metrique affichee">
<label
v-for="option in options"
:key="option.value"
class="toggle-pill"
:class="{ 'toggle-pill-active': selectedMetric === option.value }"
>
<input
v-model="selectedMetric"
type="radio"
name="system-metric"
class="sr-only"
:value="option.value"
>
<span class="toggle-dot" :style="{ backgroundColor: option.color }" />
<span>{{ option.label }}</span>
</label>
</div>
<div class="history-toolbar">
<label class="window-select">
<span>Fenetre</span>
<select v-model="selectedWindow">
<option
v-for="windowOption in windowOptions"
:key="windowOption.value"
:value="windowOption.value"
>
{{ windowOption.label }}
</option>
</select>
</label>
<button type="button" class="clear-btn" @click="clearHistory">
Vider le cache
</button>
</div>
</div>
</div>
<div class="chart-shell">
<template v-if="loading && points.length === 0">
<div class="chart-skeleton animate-shimmer" />
</template>
<template v-else>
<div class="chart-meta">
<div>
<span class="meta-label">Actuel</span>
<strong class="meta-value" :style="{ color: activeOption.color }">{{ formattedCurrentValue }}</strong>
</div>
<div>
<span class="meta-label">Pic</span>
<strong class="meta-value">{{ formattedPeakValue }}</strong>
</div>
<div>
<span class="meta-label">Echelle</span>
<strong class="meta-value">{{ scaleLabel }}</strong>
</div>
<div>
<span class="meta-label">Periode</span>
<strong class="meta-value">{{ activeWindowLabel }}</strong>
</div>
</div>
<svg
class="chart-svg"
viewBox="0 0 960 320"
preserveAspectRatio="none"
aria-label="Graphique des ressources"
>
<line
:x1="chartLeft"
:y1="chartBottom"
:x2="chartRight"
:y2="chartBottom"
class="axis-line"
/>
<line
:x1="chartLeft"
:y1="chartTop"
:x2="chartLeft"
:y2="chartBottom"
class="axis-line"
/>
<line
v-for="line in yAxisTicks"
:key="`grid-${line.y}`"
:x1="chartLeft"
:y1="line.y"
:x2="chartRight"
:y2="line.y"
class="grid-line"
/>
<text
v-for="line in yAxisTicks"
:key="`y-label-${line.y}`"
:x="chartLeft - 12"
:y="line.y + 4"
class="axis-label axis-label-y"
>
{{ line.label }}
</text>
<line
v-for="tick in xAxisTicks"
:key="`x-grid-${tick.x}`"
:x1="tick.x"
:y1="chartTop"
:x2="tick.x"
:y2="chartBottom"
class="grid-line grid-line-vertical"
/>
<text
v-for="tick in xAxisTicks"
:key="`x-label-${tick.x}`"
:x="tick.x"
:y="304"
class="axis-label axis-label-x"
>
{{ tick.label }}
</text>
<polyline
:points="polylinePoints"
class="chart-line"
:style="{ stroke: activeOption.color }"
/>
</svg>
</template>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue"
import type { SystemMetrics } from "~/types/system"
type MetricKey = "cpu" | "ram" | "incoming" | "outgoing"
type WindowKey = "day" | "hour" | "30m" | "5m" | "1m" | "30s"
type HistoryPoint = {
sampledAt: number
cpu: number
ram: number
incoming: number
outgoing: number
}
const HISTORY_STORAGE_KEY = "supervisor-system-history"
const props = defineProps<{
metrics: SystemMetrics | null
loading: boolean
}>()
const selectedMetric = ref<MetricKey>("ram")
const selectedWindow = ref<WindowKey>("hour")
const history = ref<HistoryPoint[]>([])
const options: Array<{ value: MetricKey; label: string; color: string }> = [
{ value: "cpu", label: "CPU", color: "#5aa9ff" },
{ value: "ram", label: "RAM", color: "#31c48d" },
{ value: "incoming", label: "Entrant", color: "#f59e0b" },
{ value: "outgoing", label: "Sortant", color: "#ef4444" }
]
const windowOptions: Array<{ value: WindowKey; label: string; durationMs: number | null }> = [
{ value: "day", label: "Journee", durationMs: null },
{ value: "hour", label: "1 h", durationMs: 60 * 60 * 1000 },
{ value: "30m", label: "30 min", durationMs: 30 * 60 * 1000 },
{ value: "5m", label: "5 min", durationMs: 5 * 60 * 1000 },
{ value: "1m", label: "1 min", durationMs: 60 * 1000 },
{ value: "30s", label: "30 s", durationMs: 30 * 1000 }
]
const getStartOfToday = (timestamp: number) => {
const date = new Date(timestamp)
date.setHours(0, 0, 0, 0)
return date.getTime()
}
const normalizeHistory = (points: HistoryPoint[]) => {
if (points.length === 0) {
return []
}
const startOfToday = getStartOfToday(Date.now())
return points
.filter((point) => point.sampledAt >= startOfToday)
.sort((left, right) => left.sampledAt - right.sampledAt)
}
const persistHistory = () => {
if (!import.meta.client) {
return
}
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(history.value))
}
const appendHistoryPoint = (metrics: SystemMetrics) => {
const sampledAt = new Date(metrics.sampledAt).getTime()
if (!Number.isFinite(sampledAt)) {
return
}
const nextPoint: HistoryPoint = {
sampledAt,
cpu: metrics.cpuPercent,
ram: metrics.memoryPercent,
incoming: metrics.incomingMbps,
outgoing: metrics.outgoingMbps
}
const previousPoint = history.value.at(-1)
const nextHistory = normalizeHistory(
previousPoint?.sampledAt === nextPoint.sampledAt
? [...history.value.slice(0, -1), nextPoint]
: [...history.value, nextPoint]
)
history.value = nextHistory
persistHistory()
}
const clearHistory = () => {
history.value = []
if (!import.meta.client) {
return
}
localStorage.removeItem(HISTORY_STORAGE_KEY)
}
onMounted(() => {
if (!import.meta.client) {
return
}
try {
const rawHistory = localStorage.getItem(HISTORY_STORAGE_KEY)
if (!rawHistory) {
return
}
const parsedHistory = JSON.parse(rawHistory) as HistoryPoint[]
history.value = normalizeHistory(
parsedHistory.filter((point) => {
return (
point &&
Number.isFinite(point.sampledAt) &&
Number.isFinite(point.cpu) &&
Number.isFinite(point.ram) &&
Number.isFinite(point.incoming) &&
Number.isFinite(point.outgoing)
)
})
)
persistHistory()
} catch {
localStorage.removeItem(HISTORY_STORAGE_KEY)
}
})
watch(
() => props.metrics?.sampledAt,
() => {
if (!props.metrics) {
return
}
appendHistoryPoint(props.metrics)
},
{ immediate: true }
)
const activeOption = computed(() => {
return options.find((option) => option.value === selectedMetric.value) || options[0]
})
const activeWindow = computed(() => {
return windowOptions.find((option) => option.value === selectedWindow.value) || windowOptions[0]
})
const visibleHistory = computed(() => {
if (activeWindow.value.durationMs === null) {
return history.value
}
const minTimestamp = Date.now() - activeWindow.value.durationMs
return history.value.filter((point) => point.sampledAt >= minTimestamp)
})
const points = computed(() => visibleHistory.value.map((point) => point[selectedMetric.value]))
const peakValue = computed(() => {
return points.value.reduce((max, value) => Math.max(max, value), 0)
})
const scaleMax = computed(() => {
if (selectedMetric.value === "cpu" || selectedMetric.value === "ram") {
return 100
}
return Math.max(1, Math.ceil(peakValue.value))
})
const formatValue = (value: number, metric: MetricKey) => {
if (metric === "cpu" || metric === "ram") {
return `${Math.round(value)}%`
}
return `${value.toFixed(2)} Mbps`
}
const formattedCurrentValue = computed(() => {
const currentValue = points.value.at(-1) ?? 0
return formatValue(currentValue, selectedMetric.value)
})
const formattedPeakValue = computed(() => {
return formatValue(peakValue.value, selectedMetric.value)
})
const scaleLabel = computed(() => {
return formatValue(scaleMax.value, selectedMetric.value)
})
const activeWindowLabel = computed(() => activeWindow.value.label)
const chartLeft = 72
const chartRight = 936
const chartTop = 24
const chartBottom = 280
const chartWidth = chartRight - chartLeft
const chartHeight = chartBottom - chartTop
const yAxisTicks = computed(() => {
const steps = 4
return Array.from({ length: steps + 1 }, (_, index) => {
const ratio = index / steps
const value = scaleMax.value * (1 - ratio)
const y = chartTop + chartHeight * ratio
return {
y,
label: formatValue(value, selectedMetric.value)
}
})
})
const formatTimeLabel = (timestamp: number) => {
return new Intl.DateTimeFormat("fr-FR", {
hour: "2-digit",
minute: "2-digit",
second: activeWindow.value.durationMs !== null && activeWindow.value.durationMs <= 5 * 60 * 1000
? "2-digit"
: undefined
}).format(timestamp)
}
const xAxisTicks = computed(() => {
if (visibleHistory.value.length === 0) {
return []
}
if (visibleHistory.value.length === 1) {
return [
{
x: chartLeft,
label: formatTimeLabel(visibleHistory.value[0].sampledAt)
}
]
}
const steps = 3
const firstTimestamp = visibleHistory.value[0].sampledAt
const lastTimestamp = visibleHistory.value.at(-1)?.sampledAt || firstTimestamp
const span = Math.max(1, lastTimestamp - firstTimestamp)
return Array.from({ length: steps + 1 }, (_, index) => {
const ratio = index / steps
const targetTimestamp = firstTimestamp + span * ratio
const closestPoint = visibleHistory.value.reduce((closest, point) => {
const currentDistance = Math.abs(point.sampledAt - targetTimestamp)
const closestDistance = Math.abs(closest.sampledAt - targetTimestamp)
return currentDistance < closestDistance ? point : closest
}, visibleHistory.value[0])
return {
x: chartLeft + chartWidth * ratio,
label: formatTimeLabel(closestPoint.sampledAt)
}
})
})
const polylinePoints = computed(() => {
if (points.value.length === 0) {
return `${chartLeft},${chartBottom}`
}
if (points.value.length === 1) {
const normalizedValue = points.value[0] / scaleMax.value
const y = chartBottom - normalizedValue * chartHeight
return `${chartLeft},${y} ${chartRight},${y}`
}
return points.value
.map((value, index) => {
const x = chartLeft + (index / (points.value.length - 1)) * chartWidth
const normalizedValue = scaleMax.value > 0 ? value / scaleMax.value : 0
const y = chartBottom - normalizedValue * chartHeight
return `${x},${Math.max(chartTop, Math.min(chartBottom, y))}`
})
.join(" ")
})
</script>
<style scoped>
.chart-card {
margin-top: 1.5rem;
background: rgb(var(--m-secondary));
border-radius: 12px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.card-title {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.card-copy {
margin-top: 0.25rem;
font-family: var(--font-mono);
font-size: 0.75rem;
color: rgb(var(--m-muted));
text-transform: uppercase;
letter-spacing: 0.12em;
}
.controls {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.75rem;
}
.toggle-group {
display: flex;
align-items: center;
gap: 0.625rem;
flex-wrap: wrap;
}
.toggle-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: 999px;
border: 1px solid rgb(var(--m-accent) / 0.1);
background: rgb(var(--m-tertiary));
padding: 0.55rem 0.8rem;
font-family: var(--font-mono);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgb(var(--m-muted));
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.toggle-pill-active {
border-color: rgb(var(--m-accent) / 0.28);
color: rgb(var(--m-text));
transform: translateY(-1px);
}
.toggle-dot {
width: 10px;
height: 10px;
border-radius: 999px;
flex-shrink: 0;
}
.history-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.window-select {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgb(var(--m-muted));
}
.window-select select {
border-radius: 999px;
border: 1px solid rgb(var(--m-accent) / 0.14);
background: rgb(var(--m-tertiary));
padding: 0.55rem 0.8rem;
font: inherit;
color: rgb(var(--m-text));
}
.clear-btn {
border-radius: 999px;
border: 1px solid rgb(var(--m-error) / 0.18);
background: rgb(var(--m-error) / 0.08);
padding: 0.55rem 0.85rem;
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgb(var(--m-error));
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
}
.clear-btn:hover {
border-color: rgb(var(--m-error) / 0.32);
background: rgb(var(--m-error) / 0.14);
}
.chart-shell {
border-radius: 12px;
padding: 1rem;
background:
linear-gradient(180deg, rgb(var(--m-tertiary)) 0%, rgb(var(--m-secondary)) 100%);
border: 1px solid rgb(var(--m-accent) / 0.08);
}
.chart-meta {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.meta-label {
display: block;
margin-bottom: 0.25rem;
font-family: var(--font-mono);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: rgb(var(--m-muted));
}
.meta-value {
font-family: var(--font-display);
font-size: 1.1rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.chart-svg {
width: 100%;
height: 320px;
display: block;
}
.axis-line {
stroke: rgb(var(--m-border) / 0.55);
stroke-width: 1.5;
}
.grid-line {
stroke: rgb(var(--m-border) / 0.35);
stroke-width: 1;
stroke-dasharray: 6 10;
}
.grid-line-vertical {
stroke-dasharray: 4 12;
}
.axis-label {
font-family: var(--font-mono);
font-size: 11px;
fill: rgb(var(--m-muted));
}
.axis-label-y {
text-anchor: end;
}
.axis-label-x {
text-anchor: middle;
}
.chart-line {
fill: none;
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.chart-skeleton {
width: 100%;
height: 320px;
border-radius: 12px;
}
@media (max-width: 820px) {
.controls {
width: 100%;
align-items: stretch;
}
.history-toolbar {
justify-content: space-between;
}
.chart-meta {
grid-template-columns: 1fr;
}
}
</style>