fix: arch-03 worker system metric

This commit is contained in:
2026-03-13 13:40:30 +01:00
parent c6d5843022
commit 6eddc11253
5 changed files with 302 additions and 108 deletions

View File

@@ -62,7 +62,7 @@ const metrics = computed(() => [
async function testDownload() { async function testDownload() {
const start = performance.now() const start = performance.now()
const res = await apiRequest('/api/download') const res = await apiRequest('/api/speedtest')
if (!res.ok) { if (!res.ok) {
throw new Error(`HTTP ${res.status}`) throw new Error(`HTTP ${res.status}`)
} }

View File

@@ -3,27 +3,22 @@
<div class="card-header"> <div class="card-header">
<div> <div>
<h2 class="card-title">Historique systeme</h2> <h2 class="card-title">Historique systeme</h2>
<p class="card-copy">CPU, RAM et debit reseau avec cache journalier local</p> <p class="card-copy">CPU et RAM avec cache journalier local</p>
</div> </div>
<div class="controls"> <div class="controls">
<div class="toggle-group" role="radiogroup" aria-label="Metrique affichee"> <div class="toggle-group" role="group" aria-label="Metriques affichees">
<label <button
v-for="option in options" v-for="option in options"
:key="option.value" :key="option.value"
type="button"
class="toggle-pill" class="toggle-pill"
:class="{ 'toggle-pill-active': selectedMetric === option.value }" :class="{ 'toggle-pill-active': isMetricActive(option.value) }"
@click="toggleMetric(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 class="toggle-dot" :style="{ backgroundColor: option.color }" />
<span>{{ option.label }}</span> <span>{{ option.label }}</span>
</label> </button>
</div> </div>
<div class="history-toolbar"> <div class="history-toolbar">
@@ -48,19 +43,11 @@
</div> </div>
<div class="chart-shell"> <div class="chart-shell">
<template v-if="loading && points.length === 0"> <template v-if="loading && visibleHistory.length === 0">
<div class="chart-skeleton animate-shimmer" /> <div class="chart-skeleton animate-shimmer" />
</template> </template>
<template v-else> <template v-else>
<div class="chart-meta"> <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> <div>
<span class="meta-label">Echelle</span> <span class="meta-label">Echelle</span>
<strong class="meta-value">{{ scaleLabel }}</strong> <strong class="meta-value">{{ scaleLabel }}</strong>
@@ -69,6 +56,17 @@
<span class="meta-label">Periode</span> <span class="meta-label">Periode</span>
<strong class="meta-value">{{ activeWindowLabel }}</strong> <strong class="meta-value">{{ activeWindowLabel }}</strong>
</div> </div>
<div
v-for="option in displayedOptions"
:key="option.value"
class="meta-metric"
>
<span class="meta-label">{{ option.label }}</span>
<strong class="meta-value" :style="{ color: option.color }">
{{ formatValue(currentMetricValue(option.value)) }}
</strong>
<span class="meta-subvalue">Pic {{ formatValue(peakMetricValue(option.value)) }}</span>
</div>
</div> </div>
<svg <svg
@@ -128,9 +126,11 @@
{{ tick.label }} {{ tick.label }}
</text> </text>
<polyline <polyline
:points="polylinePoints" v-for="option in displayedOptions"
:key="option.value"
:points="polylinePoints(option.value)"
class="chart-line" class="chart-line"
:style="{ stroke: activeOption.color }" :style="{ stroke: option.color }"
/> />
</svg> </svg>
</template> </template>
@@ -142,15 +142,13 @@
import { computed, onMounted, ref, watch } from "vue" import { computed, onMounted, ref, watch } from "vue"
import type { SystemMetrics } from "~/types/system" import type { SystemMetrics } from "~/types/system"
type MetricKey = "cpu" | "ram" | "incoming" | "outgoing" type MetricKey = "cpu" | "ram"
type WindowKey = "day" | "hour" | "30m" | "5m" | "1m" | "30s" type WindowKey = "day" | "hour" | "5m" | "1m" | "30s"
type HistoryPoint = { type HistoryPoint = {
sampledAt: number sampledAt: number
cpu: number cpu: number
ram: number ram: number
incoming: number
outgoing: number
} }
const HISTORY_STORAGE_KEY = "supervisor-system-history" const HISTORY_STORAGE_KEY = "supervisor-system-history"
@@ -160,21 +158,18 @@ const props = defineProps<{
loading: boolean loading: boolean
}>() }>()
const selectedMetric = ref<MetricKey>("ram") const activeMetrics = ref<MetricKey[]>(["cpu", "ram"])
const selectedWindow = ref<WindowKey>("hour") const selectedWindow = ref<WindowKey>("hour")
const history = ref<HistoryPoint[]>([]) const history = ref<HistoryPoint[]>([])
const options: Array<{ value: MetricKey; label: string; color: string }> = [ const options: Array<{ value: MetricKey; label: string; color: string }> = [
{ value: "cpu", label: "CPU", color: "#5aa9ff" }, { value: "cpu", label: "CPU", color: "#5aa9ff" },
{ value: "ram", label: "RAM", color: "#31c48d" }, { 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 }> = [ const windowOptions: Array<{ value: WindowKey; label: string; durationMs: number | null }> = [
{ value: "day", label: "Journee", durationMs: null }, { value: "day", label: "Journee", durationMs: null },
{ value: "hour", label: "1 h", durationMs: 60 * 60 * 1000 }, { 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: "5m", label: "5 min", durationMs: 5 * 60 * 1000 },
{ value: "1m", label: "1 min", durationMs: 60 * 1000 }, { value: "1m", label: "1 min", durationMs: 60 * 1000 },
{ value: "30s", label: "30 s", durationMs: 30 * 1000 } { value: "30s", label: "30 s", durationMs: 30 * 1000 }
@@ -216,9 +211,7 @@ const appendHistoryPoint = (metrics: SystemMetrics) => {
const nextPoint: HistoryPoint = { const nextPoint: HistoryPoint = {
sampledAt, sampledAt,
cpu: metrics.cpuPercent, cpu: metrics.cpuPercent,
ram: metrics.memoryPercent, ram: metrics.memoryPercent
incoming: metrics.incomingMbps,
outgoing: metrics.outgoingMbps
} }
const previousPoint = history.value.at(-1) const previousPoint = history.value.at(-1)
@@ -261,9 +254,7 @@ onMounted(() => {
point && point &&
Number.isFinite(point.sampledAt) && Number.isFinite(point.sampledAt) &&
Number.isFinite(point.cpu) && Number.isFinite(point.cpu) &&
Number.isFinite(point.ram) && Number.isFinite(point.ram)
Number.isFinite(point.incoming) &&
Number.isFinite(point.outgoing)
) )
}) })
) )
@@ -285,14 +276,14 @@ watch(
{ immediate: true } { immediate: true }
) )
const activeOption = computed(() => {
return options.find((option) => option.value === selectedMetric.value) || options[0]
})
const activeWindow = computed(() => { const activeWindow = computed(() => {
return windowOptions.find((option) => option.value === selectedWindow.value) || windowOptions[0] return windowOptions.find((option) => option.value === selectedWindow.value) || windowOptions[0]
}) })
const displayedOptions = computed(() => {
return options.filter((option) => activeMetrics.value.includes(option.value))
})
const visibleHistory = computed(() => { const visibleHistory = computed(() => {
if (activeWindow.value.durationMs === null) { if (activeWindow.value.durationMs === null) {
return history.value return history.value
@@ -302,43 +293,35 @@ const visibleHistory = computed(() => {
return history.value.filter((point) => point.sampledAt >= minTimestamp) return history.value.filter((point) => point.sampledAt >= minTimestamp)
}) })
const points = computed(() => visibleHistory.value.map((point) => point[selectedMetric.value])) const scaleMax = computed(() => 100)
const peakValue = computed(() => { const formatValue = (value: number) => `${Math.round(value)}%`
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(() => { const scaleLabel = computed(() => {
return formatValue(scaleMax.value, selectedMetric.value) return formatValue(scaleMax.value)
}) })
const activeWindowLabel = computed(() => activeWindow.value.label) const activeWindowLabel = computed(() => activeWindow.value.label)
const isMetricActive = (metric: MetricKey) => activeMetrics.value.includes(metric)
const toggleMetric = (metric: MetricKey) => {
if (isMetricActive(metric)) {
activeMetrics.value = activeMetrics.value.filter((value) => value !== metric)
return
}
activeMetrics.value = [...activeMetrics.value, metric]
}
const currentMetricValue = (metric: MetricKey) => {
return visibleHistory.value.at(-1)?.[metric] ?? 0
}
const peakMetricValue = (metric: MetricKey) => {
return visibleHistory.value.reduce((max, point) => Math.max(max, point[metric]), 0)
}
const chartLeft = 72 const chartLeft = 72
const chartRight = 936 const chartRight = 936
const chartTop = 24 const chartTop = 24
@@ -356,7 +339,7 @@ const yAxisTicks = computed(() => {
return { return {
y, y,
label: formatValue(value, selectedMetric.value) label: formatValue(value)
} }
}) })
}) })
@@ -406,27 +389,29 @@ const xAxisTicks = computed(() => {
}) })
}) })
const polylinePoints = computed(() => { const polylinePoints = (metric: MetricKey) => {
if (points.value.length === 0) { const points = visibleHistory.value.map((point) => point[metric])
if (points.length === 0) {
return `${chartLeft},${chartBottom}` return `${chartLeft},${chartBottom}`
} }
if (points.value.length === 1) { if (points.length === 1) {
const normalizedValue = points.value[0] / scaleMax.value const normalizedValue = points[0] / scaleMax.value
const y = chartBottom - normalizedValue * chartHeight const y = chartBottom - normalizedValue * chartHeight
return `${chartLeft},${y} ${chartRight},${y}` return `${chartLeft},${y} ${chartRight},${y}`
} }
return points.value return points
.map((value, index) => { .map((value, index) => {
const x = chartLeft + (index / (points.value.length - 1)) * chartWidth const x = chartLeft + (index / (points.length - 1)) * chartWidth
const normalizedValue = scaleMax.value > 0 ? value / scaleMax.value : 0 const normalizedValue = scaleMax.value > 0 ? value / scaleMax.value : 0
const y = chartBottom - normalizedValue * chartHeight const y = chartBottom - normalizedValue * chartHeight
return `${x},${Math.max(chartTop, Math.min(chartBottom, y))}` return `${x},${Math.max(chartTop, Math.min(chartBottom, y))}`
}) })
.join(" ") .join(" ")
}) }
</script> </script>
<style scoped> <style scoped>
@@ -492,6 +477,7 @@ const polylinePoints = computed(() => {
letter-spacing: 0.12em; letter-spacing: 0.12em;
color: rgb(var(--m-muted)); color: rgb(var(--m-muted));
cursor: pointer; cursor: pointer;
appearance: none;
transition: border-color 0.2s ease, color 0.2s ease, transform 0.2s ease; transition: border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
} }
@@ -570,6 +556,12 @@ const polylinePoints = computed(() => {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.meta-metric {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.meta-label { .meta-label {
display: block; display: block;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
@@ -582,11 +574,19 @@ const polylinePoints = computed(() => {
.meta-value { .meta-value {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 1.1rem; font-size: 1.35rem;
font-weight: 700; font-weight: 700;
color: rgb(var(--m-text)); color: rgb(var(--m-text));
} }
.meta-subvalue {
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgb(var(--m-muted));
}
.chart-svg { .chart-svg {
width: 100%; width: 100%;
height: 320px; height: 320px;

View File

@@ -1,4 +1,3 @@
import { Readable } from "node:stream"
import { import {
runSsh, runSsh,
shellQuote, shellQuote,
@@ -16,37 +15,13 @@ function buildContentDisposition(fileName: string) {
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fileName)}` return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`
} }
function speedtestStream(event: H3Event) {
const size = 128 * 1024 * 1024
let sent = 0
const stream = new Readable({
read(chunkSize) {
if (sent >= size) {
this.push(null)
return
}
const remaining = size - sent
const chunk = Buffer.alloc(Math.min(chunkSize, remaining), "a")
sent += chunk.length
this.push(chunk)
}
})
setHeader(event, "Content-Type", "application/octet-stream")
setHeader(event, "Content-Length", size)
return stream
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const { folder, file } = getQuery(event) const { folder, file } = getQuery(event)
const folderName = typeof folder === "string" ? folder : null const folderName = typeof folder === "string" ? folder : null
const fileName = typeof file === "string" ? file : null const fileName = typeof file === "string" ? file : null
// Compat mode: utilisé par le test de débit.
if (!folderName || !fileName) { if (!folderName || !fileName) {
return speedtestStream(event) throw createError({ statusCode: 400, statusMessage: "Paramètres manquants" })
} }
if (!isSafeFolder(folderName) || !isSafeFile(fileName)) { if (!isSafeFolder(folderName) || !isSafeFile(fileName)) {

View File

@@ -0,0 +1,24 @@
import { Readable } from "node:stream"
export default defineEventHandler((event) => {
const size = 128 * 1024 * 1024
let sent = 0
const stream = new Readable({
read(chunkSize) {
if (sent >= size) {
this.push(null)
return
}
const remaining = size - sent
const chunk = Buffer.alloc(Math.min(chunkSize, remaining), "a")
sent += chunk.length
this.push(chunk)
}
})
setHeader(event, "Content-Type", "application/octet-stream")
setHeader(event, "Content-Length", size)
return stream
})

View File

@@ -0,0 +1,195 @@
import fs from "node:fs"
import os from "node:os"
import type { SystemMetrics } from "~/types/system"
type CpuTimesSnapshot = {
idle: number
total: number
}
type NetworkTotals = {
rxBytes: number
txBytes: number
}
type NetworkSnapshot = NetworkTotals & {
timestamp: number
}
let lastCpuSnapshot: CpuTimesSnapshot | null = null
let lastNetworkSnapshot: NetworkSnapshot | null = null
let intervalId: NodeJS.Timeout | null = null
let latestSnapshot: SystemMetrics = createSnapshot({
cpuPercent: 0,
incomingMbps: 0,
outgoingMbps: 0
})
function createSnapshot(overrides: {
cpuPercent: number
incomingMbps: number
outgoingMbps: number
}): SystemMetrics {
const totalMemory = os.totalmem()
const freeMemory = os.freemem()
const usedMemory = totalMemory - freeMemory
const memoryPercent = totalMemory > 0 ? Math.round((usedMemory / totalMemory) * 100) : 0
return {
cpuPercent: overrides.cpuPercent,
memoryPercent,
totalMemory,
usedMemory,
incomingMbps: overrides.incomingMbps,
outgoingMbps: overrides.outgoingMbps,
sampledAt: new Date().toISOString()
}
}
function getCpuSnapshot(): CpuTimesSnapshot {
return os.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 getCpuUsagePercent() {
const currentSnapshot = getCpuSnapshot()
if (!lastCpuSnapshot) {
lastCpuSnapshot = currentSnapshot
return 0
}
const idleDelta = currentSnapshot.idle - lastCpuSnapshot.idle
const totalDelta = currentSnapshot.total - lastCpuSnapshot.total
lastCpuSnapshot = currentSnapshot
if (totalDelta <= 0) {
return 0
}
return Math.max(0, Math.min(100, Math.round((1 - idleDelta / totalDelta) * 100)))
}
function getNetworkTotals(): NetworkTotals | null {
try {
const content = fs.readFileSync("/proc/net/dev", "utf8")
return content
.split("\n")
.slice(2)
.map((line) => line.trim())
.filter(Boolean)
.reduce(
(totals, line) => {
const [namePart, valuesPart] = line.split(":")
const interfaceName = namePart?.trim()
if (!interfaceName || interfaceName === "lo" || !valuesPart) {
return totals
}
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)) {
totals.rxBytes += rxBytes
}
if (Number.isFinite(txBytes)) {
totals.txBytes += txBytes
}
return totals
},
{ rxBytes: 0, txBytes: 0 }
)
} catch {
return null
}
}
function getNetworkRatesMbps() {
const totals = getNetworkTotals()
const now = Date.now()
if (!totals) {
lastNetworkSnapshot = null
return {
incomingMbps: 0,
outgoingMbps: 0
}
}
const currentSnapshot: NetworkSnapshot = {
...totals,
timestamp: now
}
if (!lastNetworkSnapshot) {
lastNetworkSnapshot = currentSnapshot
return {
incomingMbps: 0,
outgoingMbps: 0
}
}
const elapsedSeconds = (currentSnapshot.timestamp - lastNetworkSnapshot.timestamp) / 1000
if (elapsedSeconds <= 0) {
lastNetworkSnapshot = currentSnapshot
return {
incomingMbps: 0,
outgoingMbps: 0
}
}
const incomingMbps = Math.max(
0,
Number((((currentSnapshot.rxBytes - lastNetworkSnapshot.rxBytes) * 8) / elapsedSeconds / 1000000).toFixed(2))
)
const outgoingMbps = Math.max(
0,
Number((((currentSnapshot.txBytes - lastNetworkSnapshot.txBytes) * 8) / elapsedSeconds / 1000000).toFixed(2))
)
lastNetworkSnapshot = currentSnapshot
return {
incomingMbps,
outgoingMbps
}
}
function refreshSnapshot() {
const cpuPercent = getCpuUsagePercent()
const { incomingMbps, outgoingMbps } = getNetworkRatesMbps()
latestSnapshot = createSnapshot({
cpuPercent,
incomingMbps,
outgoingMbps
})
}
export function getSystemMetricsSnapshot() {
return latestSnapshot
}
export default defineNitroPlugin(() => {
if (intervalId) {
return
}
refreshSnapshot()
intervalId = setInterval(refreshSnapshot, 2000)
})