fix: arch-03 worker system metric

This commit is contained in:
2026-03-13 11:45:09 +01:00
parent ffb84b41a9
commit c6d5843022
4 changed files with 385 additions and 217 deletions

View File

@@ -3,26 +3,47 @@
<div class="card-header">
<div>
<h2 class="card-title">Historique systeme</h2>
<p class="card-copy">CPU, RAM et debit reseau sur les derniers releves</p>
<p class="card-copy">CPU, RAM et debit reseau avec cache journalier local</p>
</div>
<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"
<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 }"
>
<span class="toggle-dot" :style="{ backgroundColor: option.color }" />
<span>{{ option.label }}</span>
</label>
<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>
@@ -44,6 +65,10 @@
<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
@@ -53,14 +78,55 @@
aria-label="Graphique des ressources"
>
<line
v-for="line in gridLines"
:key="line"
x1="0"
:y1="line"
x2="960"
:y2="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"
@@ -73,35 +139,140 @@
</template>
<script setup lang="ts">
import {computed, ref, watch} from "vue"
import { SystemMetrics } from "~/types/system";
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: string
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 maxPoints = 40
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 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,
() => {
@@ -109,25 +280,29 @@ watch(
return
}
history.value = [
...history.value,
{
sampledAt: props.metrics.sampledAt,
cpu: props.metrics.cpuPercent,
ram: props.metrics.memoryPercent,
incoming: props.metrics.incomingMbps,
outgoing: props.metrics.outgoingMbps
}
].slice(-maxPoints)
appendHistoryPoint(props.metrics)
},
{immediate: true}
{ immediate: true }
)
const activeOption = computed(() => {
return options.find((option) => option.value === selectedMetric.value) || options[0]
})
const points = computed(() => history.value.map((point) => point[selectedMetric.value]))
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)
@@ -162,26 +337,93 @@ const scaleLabel = computed(() => {
return formatValue(scaleMax.value, selectedMetric.value)
})
const gridLines = [40, 120, 200, 280]
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 "0,280"
return `${chartLeft},${chartBottom}`
}
if (points.value.length === 1) {
const normalizedValue = points.value[0] / scaleMax.value
const y = 280 - normalizedValue * 240
return `0,${y} 960,${y}`
const y = chartBottom - normalizedValue * chartHeight
return `${chartLeft},${y} ${chartRight},${y}`
}
return points.value
.map((value, index) => {
const x = (index / (points.value.length - 1)) * 960
const x = chartLeft + (index / (points.value.length - 1)) * chartWidth
const normalizedValue = scaleMax.value > 0 ? value / scaleMax.value : 0
const y = 280 - normalizedValue * 240
const y = chartBottom - normalizedValue * chartHeight
return `${x},${Math.max(24, Math.min(280, y))}`
return `${x},${Math.max(chartTop, Math.min(chartBottom, y))}`
})
.join(" ")
})
@@ -222,6 +464,13 @@ const polylinePoints = computed(() => {
letter-spacing: 0.12em;
}
.controls {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.75rem;
}
.toggle-group {
display: flex;
align-items: center;
@@ -259,6 +508,53 @@ const polylinePoints = computed(() => {
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;
@@ -269,7 +565,7 @@ const polylinePoints = computed(() => {
.chart-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
@@ -297,12 +593,35 @@ const polylinePoints = computed(() => {
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;
@@ -317,6 +636,15 @@ const polylinePoints = computed(() => {
}
@media (max-width: 820px) {
.controls {
width: 100%;
align-items: stretch;
}
.history-toolbar {
justify-content: space-between;
}
.chart-meta {
grid-template-columns: 1fr;
}

View File

@@ -46,7 +46,7 @@
<script setup lang="ts">
import {computed} from "vue"
import { SystemMetrics } from "~/types/system";
import type { SystemMetrics } from "~/types/system";
const props = defineProps<{
metrics: SystemMetrics | null

View File

@@ -64,7 +64,7 @@
<script setup lang="ts">
import {computed, onMounted, ref} from "vue"
import { apiFetch } from "~/composables/useApiAuth"
import { SystemMetrics } from "~/types/system";
import type { SystemMetrics } from "~/types/system";
definePageMeta({layout: false})

View File

@@ -1,165 +1,5 @@
import fs from "node:fs"
import os from "node:os"
import { getSystemMetricsSnapshot } from "../plugins/metrics-worker"
type CpuTimesSnapshot = {
idle: number
total: number
}
type NetworkSnapshot = {
rxBytes: number
txBytes: number
timestamp: number
}
let previousNetworkSnapshot: NetworkSnapshot | null = null
function getCpuSnapshot(): CpuTimesSnapshot {
const cpus = os.cpus()
return cpus.reduce(
(snapshot, cpu) => {
const total = Object.values(cpu.times).reduce((sum, value) => sum + value, 0)
snapshot.idle += cpu.times.idle
snapshot.total += total
return snapshot
},
{idle: 0, total: 0}
)
}
function wait(durationMs: number) {
return new Promise((resolve) => {
setTimeout(resolve, durationMs)
})
}
async function getCpuUsagePercent(sampleMs: number) {
const start = getCpuSnapshot()
await wait(sampleMs)
const end = getCpuSnapshot()
const idleDelta = end.idle - start.idle
const totalDelta = end.total - start.total
if (totalDelta <= 0) {
return 0
}
return Math.max(0, Math.min(100, Math.round((1 - idleDelta / totalDelta) * 100)))
}
function getNetworkTotals() {
try {
const content = fs.readFileSync("/proc/net/dev", "utf8")
const totals = content
.split("\n")
.slice(2)
.map((line) => line.trim())
.filter(Boolean)
.reduce(
(accumulator, line) => {
const [namePart, valuesPart] = line.split(":")
const interfaceName = namePart?.trim()
if (!interfaceName || interfaceName === "lo" || !valuesPart) {
return accumulator
}
const values = valuesPart.trim().split(/\s+/)
const rxBytes = Number.parseInt(values[0] || "0", 10)
const txBytes = Number.parseInt(values[8] || "0", 10)
if (Number.isFinite(rxBytes)) {
accumulator.rxBytes += rxBytes
}
if (Number.isFinite(txBytes)) {
accumulator.txBytes += txBytes
}
return accumulator
},
{rxBytes: 0, txBytes: 0}
)
return totals
} catch {
return null
}
}
function getNetworkRatesMbps() {
const totals = getNetworkTotals()
const now = Date.now()
if (!totals) {
previousNetworkSnapshot = null
return {
incomingMbps: 0,
outgoingMbps: 0
}
}
const currentSnapshot: NetworkSnapshot = {
...totals,
timestamp: now
}
if (!previousNetworkSnapshot) {
previousNetworkSnapshot = currentSnapshot
return {
incomingMbps: 0,
outgoingMbps: 0
}
}
const elapsedSeconds = (currentSnapshot.timestamp - previousNetworkSnapshot.timestamp) / 1000
if (elapsedSeconds <= 0) {
previousNetworkSnapshot = currentSnapshot
return {
incomingMbps: 0,
outgoingMbps: 0
}
}
const incomingMbps = Math.max(
0,
Number((((currentSnapshot.rxBytes - previousNetworkSnapshot.rxBytes) * 8) / elapsedSeconds / 1000000).toFixed(2))
)
const outgoingMbps = Math.max(
0,
Number((((currentSnapshot.txBytes - previousNetworkSnapshot.txBytes) * 8) / elapsedSeconds / 1000000).toFixed(2))
)
previousNetworkSnapshot = currentSnapshot
return {
incomingMbps,
outgoingMbps
}
}
export default defineEventHandler(async () => {
const totalMemory = os.totalmem()
const freeMemory = os.freemem()
const usedMemory = totalMemory - freeMemory
const memoryPercent = totalMemory > 0 ? Math.round((usedMemory / totalMemory) * 100) : 0
const cpuPercent = await getCpuUsagePercent(200)
const {incomingMbps, outgoingMbps} = getNetworkRatesMbps()
return {
cpuPercent,
memoryPercent,
totalMemory,
usedMemory,
incomingMbps,
outgoingMbps,
sampledAt: new Date().toISOString()
}
export default defineEventHandler(() => {
return getSystemMetricsSnapshot()
})