6 Commits

Author SHA1 Message Date
semantic-release-bot
3f00c229cb chore(release): 1.3.1 2026-03-16 09:47:56 +00:00
f4f38cf6d1 Merge pull request 'fix/arch-03 et arch-04' (#17) from fix/arch-03-worker-system-metric into develop
All checks were successful
Release / release (push) Successful in 28s
Reviewed-on: #17
2026-03-16 09:47:30 +00:00
6eddc11253 fix: arch-03 worker system metric 2026-03-13 13:40:30 +01:00
c6d5843022 fix: arch-03 worker system metric 2026-03-13 11:45:09 +01:00
ffb84b41a9 fix: arch-02 make type file 2026-03-13 11:11:31 +01:00
7c3467d85f fix: extract shared ssh utilities 2026-03-13 11:05:34 +01:00
14 changed files with 749 additions and 463 deletions

View File

@@ -1,3 +1,13 @@
## [1.3.1](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.3.0...v1.3.1) (2026-03-16)
### Bug Fixes
* arch-02 make type file ([ffb84b4](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/ffb84b41a9e15b2edc98378c94050c74c9d200c6))
* arch-03 worker system metric ([6eddc11](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/6eddc1125316bb0d77a7c71cfc4674cd99c6e296))
* arch-03 worker system metric ([c6d5843](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/c6d5843022bbdcf909662c2c9ce47fb14f88b5a2))
* extract shared ssh utilities ([7c3467d](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/7c3467d85f987f1d9b8fe2546a13c2d23ea841b4))
# [1.3.0](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.2.4...v1.3.0) (2026-03-13) # [1.3.0](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.2.4...v1.3.0) (2026-03-13)

View File

@@ -80,7 +80,6 @@
import { computed, onMounted, ref } from "vue" import { computed, onMounted, ref } from "vue"
import { Icon as IconifyIcon } from "@iconify/vue" import { Icon as IconifyIcon } from "@iconify/vue"
import { apiFetch } from "~/composables/useApiAuth" import { apiFetch } from "~/composables/useApiAuth"
import { useApiAuthHeader } from "~/composables/useApiAuth"
type BackupScript = { type BackupScript = {
key: string key: string
@@ -120,7 +119,6 @@ const scripts = ref<BackupScript[]>([])
const output = ref<string>("") const output = ref<string>("")
const message = ref<string>("") const message = ref<string>("")
const isError = ref(false) const isError = ref(false)
const apiAuthHeader = useApiAuthHeader()
const statusClass = computed(() => (isError.value ? "status-error" : "status-success")) const statusClass = computed(() => (isError.value ? "status-error" : "status-success"))

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,47 +3,70 @@
<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 sur les derniers releves</p> <p class="card-copy">CPU et RAM avec cache journalier local</p>
</div> </div>
<div class="toggle-group" role="radiogroup" aria-label="Metrique affichee"> <div class="controls">
<label <div class="toggle-group" role="group" aria-label="Metriques affichees">
v-for="option in options" <button
:key="option.value" v-for="option in options"
class="toggle-pill" :key="option.value"
:class="{ 'toggle-pill-active': selectedMetric === option.value }" type="button"
> class="toggle-pill"
<input :class="{ 'toggle-pill-active': isMetricActive(option.value) }"
v-model="selectedMetric" @click="toggleMetric(option.value)"
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 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> </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>
</div> </div>
<div>
<span class="meta-label">Periode</span>
<strong class="meta-value">{{ activeWindowLabel }}</strong>
</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
@@ -53,18 +76,61 @@
aria-label="Graphique des ressources" aria-label="Graphique des ressources"
> >
<line <line
v-for="line in gridLines" :x1="chartLeft"
:key="line" :y1="chartBottom"
x1="0" :x2="chartRight"
:y1="line" :y2="chartBottom"
x2="960" class="axis-line"
:y2="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" 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 <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>
@@ -73,44 +139,131 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref, watch} from "vue" import { computed, onMounted, ref, watch } from "vue"
import type { SystemMetrics } from "~/types/system"
type MetricKey = "cpu" | "ram" | "incoming" | "outgoing" type MetricKey = "cpu" | "ram"
type WindowKey = "day" | "hour" | "5m" | "1m" | "30s"
type SystemMetrics = {
cpuPercent: number
memoryPercent: number
totalMemory: number
usedMemory: number
incomingMbps: number
outgoingMbps: number
sampledAt: string
}
type HistoryPoint = { type HistoryPoint = {
sampledAt: string sampledAt: number
cpu: number cpu: number
ram: number ram: number
incoming: number
outgoing: number
} }
const HISTORY_STORAGE_KEY = "supervisor-system-history"
const props = defineProps<{ const props = defineProps<{
metrics: SystemMetrics | null metrics: SystemMetrics | null
loading: boolean loading: boolean
}>() }>()
const selectedMetric = ref<MetricKey>("ram") const activeMetrics = ref<MetricKey[]>(["cpu", "ram"])
const selectedWindow = ref<WindowKey>("hour")
const history = ref<HistoryPoint[]>([]) const history = ref<HistoryPoint[]>([])
const maxPoints = 40
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 }> = [
{ value: "day", label: "Journee", durationMs: null },
{ value: "hour", label: "1 h", durationMs: 60 * 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
}
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)
)
})
)
persistHistory()
} catch {
localStorage.removeItem(HISTORY_STORAGE_KEY)
}
})
watch( watch(
() => props.metrics?.sampledAt, () => props.metrics?.sampledAt,
() => { () => {
@@ -118,82 +271,147 @@ watch(
return return
} }
history.value = [ appendHistoryPoint(props.metrics)
...history.value,
{
sampledAt: props.metrics.sampledAt,
cpu: props.metrics.cpuPercent,
ram: props.metrics.memoryPercent,
incoming: props.metrics.incomingMbps,
outgoing: props.metrics.outgoingMbps
}
].slice(-maxPoints)
}, },
{immediate: true} { immediate: true }
) )
const activeOption = computed(() => { const activeWindow = computed(() => {
return options.find((option) => option.value === selectedMetric.value) || options[0] return windowOptions.find((option) => option.value === selectedWindow.value) || windowOptions[0]
}) })
const points = computed(() => history.value.map((point) => point[selectedMetric.value])) const displayedOptions = computed(() => {
return options.filter((option) => activeMetrics.value.includes(option.value))
const peakValue = computed(() => {
return points.value.reduce((max, value) => Math.max(max, value), 0)
}) })
const scaleMax = computed(() => { const visibleHistory = computed(() => {
if (selectedMetric.value === "cpu" || selectedMetric.value === "ram") { if (activeWindow.value.durationMs === null) {
return 100 return history.value
} }
return Math.max(1, Math.ceil(peakValue.value)) const minTimestamp = Date.now() - activeWindow.value.durationMs
return history.value.filter((point) => point.sampledAt >= minTimestamp)
}) })
const formatValue = (value: number, metric: MetricKey) => { const scaleMax = computed(() => 100)
if (metric === "cpu" || metric === "ram") {
return `${Math.round(value)}%`
}
return `${value.toFixed(2)} Mbps` const formatValue = (value: number) => `${Math.round(value)}%`
}
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 gridLines = [40, 120, 200, 280] const activeWindowLabel = computed(() => activeWindow.value.label)
const polylinePoints = computed(() => { const isMetricActive = (metric: MetricKey) => activeMetrics.value.includes(metric)
if (points.value.length === 0) {
return "0,280" const toggleMetric = (metric: MetricKey) => {
if (isMetricActive(metric)) {
activeMetrics.value = activeMetrics.value.filter((value) => value !== metric)
return
} }
if (points.value.length === 1) { activeMetrics.value = [...activeMetrics.value, metric]
const normalizedValue = points.value[0] / scaleMax.value }
const y = 280 - normalizedValue * 240
return `0,${y} 960,${y}` 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 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)
}
})
})
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 []
} }
return points.value 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 = (metric: MetricKey) => {
const points = visibleHistory.value.map((point) => point[metric])
if (points.length === 0) {
return `${chartLeft},${chartBottom}`
}
if (points.length === 1) {
const normalizedValue = points[0] / scaleMax.value
const y = chartBottom - normalizedValue * chartHeight
return `${chartLeft},${y} ${chartRight},${y}`
}
return points
.map((value, index) => { .map((value, index) => {
const x = (index / (points.value.length - 1)) * 960 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 = 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(" ") .join(" ")
}) }
</script> </script>
<style scoped> <style scoped>
@@ -231,6 +449,13 @@ const polylinePoints = computed(() => {
letter-spacing: 0.12em; letter-spacing: 0.12em;
} }
.controls {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.75rem;
}
.toggle-group { .toggle-group {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -252,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;
} }
@@ -268,6 +494,53 @@ const polylinePoints = computed(() => {
flex-shrink: 0; 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 { .chart-shell {
border-radius: 12px; border-radius: 12px;
padding: 1rem; padding: 1rem;
@@ -278,11 +551,17 @@ const polylinePoints = computed(() => {
.chart-meta { .chart-meta {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem; gap: 0.75rem;
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;
@@ -295,23 +574,54 @@ 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;
display: block; display: block;
} }
.axis-line {
stroke: rgb(var(--m-border) / 0.55);
stroke-width: 1.5;
}
.grid-line { .grid-line {
stroke: rgb(var(--m-border) / 0.35); stroke: rgb(var(--m-border) / 0.35);
stroke-width: 1; stroke-width: 1;
stroke-dasharray: 6 10; 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 { .chart-line {
fill: none; fill: none;
stroke-width: 4; stroke-width: 4;
@@ -326,6 +636,15 @@ const polylinePoints = computed(() => {
} }
@media (max-width: 820px) { @media (max-width: 820px) {
.controls {
width: 100%;
align-items: stretch;
}
.history-toolbar {
justify-content: space-between;
}
.chart-meta { .chart-meta {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -46,16 +46,7 @@
<script setup lang="ts"> <script setup lang="ts">
import {computed} from "vue" import {computed} from "vue"
import type { SystemMetrics } from "~/types/system";
type SystemMetrics = {
cpuPercent: number
memoryPercent: number
totalMemory: number
usedMemory: number
incomingMbps: number
outgoingMbps: number
sampledAt: string
}
const props = defineProps<{ const props = defineProps<{
metrics: SystemMetrics | null metrics: SystemMetrics | null

View File

@@ -63,8 +63,10 @@
<script setup lang="ts"> <script setup lang="ts">
import {computed, onMounted, ref} from "vue" import {computed, onMounted, ref} from "vue"
definePageMeta({layout: false})
import { apiFetch } from "~/composables/useApiAuth" import { apiFetch } from "~/composables/useApiAuth"
import type { SystemMetrics } from "~/types/system";
definePageMeta({layout: false})
type DiskSourceResult = { type DiskSourceResult = {
key: string key: string
@@ -90,16 +92,6 @@ type DiagramItem = {
totalText: string totalText: string
} }
type SystemMetrics = {
cpuPercent: number
memoryPercent: number
totalMemory: number
usedMemory: number
incomingMbps: number
outgoingMbps: number
sampledAt: string
}
const rawResults = ref<DiskSourceResult[]>([]) const rawResults = ref<DiskSourceResult[]>([])
const loading = ref(false) const loading = ref(false)
const systemMetrics = ref<SystemMetrics | null>(null) const systemMetrics = ref<SystemMetrics | null>(null)
@@ -181,7 +173,7 @@ const runScript = async () => {
try { try {
const output = await apiFetch<DiskApiResponse>("/api/disk") const output = await apiFetch<DiskApiResponse>("/api/disk")
rawResults.value = output.results rawResults.value = output.results
} catch (error) { } catch {
rawResults.value = [ rawResults.value = [
{ {
key: "error", key: "error",

View File

@@ -1,29 +1,15 @@
import { execFile } from "node:child_process" import {
import folderMap from "../config/backup-folders.json" runSsh,
shellQuote,
resolveFolderRemoteDir,
REMOTE_ROOT,
} from "../utils/ssh.ts"
import {process} from "std-env";
const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST
const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT
const MAX_FILES_PER_FOLDER = Number(process.env.BACKUPS_MAX_FILES) const MAX_FILES_PER_FOLDER = Number(process.env.BACKUPS_MAX_FILES)
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value) const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
const FOLDER_MAP = folderMap as Record<string, string>
function runSsh(command: string): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
"ssh",
["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", REMOTE_HOST, command],
{ maxBuffer: 10 * 1024 * 1024 },
(error, stdout, stderr) => {
if (error) {
reject(stderr || error.message)
return
}
resolve(stdout)
}
)
})
}
function isMissingPathError(error: unknown): boolean { function isMissingPathError(error: unknown): boolean {
const message = String(error || "").toLowerCase() const message = String(error || "").toLowerCase()
@@ -72,30 +58,6 @@ async function getLatestRemoteFile(remoteDir: string): Promise<string | null> {
return files[0] || null return files[0] || null
} }
async function remoteDirExists(remoteDir: string): Promise<boolean> {
const output = await runSsh(`[ -d ${quoteDir(remoteDir)} ] && echo yes || echo no`)
return output.trim() === "yes"
}
async function resolveFolderRemoteDir(folderName: string): Promise<string | null> {
const mapped = FOLDER_MAP[folderName]
if (mapped) {
return `${REMOTE_ROOT}/${mapped}`
}
const direct = `${REMOTE_ROOT}/${folderName}`
if (await remoteDirExists(direct)) {
return direct
}
const nested = `${REMOTE_ROOT}/bdd_recette/${folderName}`
if (await remoteDirExists(nested)) {
return nested
}
return null
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const { folder } = getQuery(event) const { folder } = getQuery(event)
const folderName = typeof folder === "string" ? folder : null const folderName = typeof folder === "string" ? folder : null
@@ -120,7 +82,7 @@ export default defineEventHandler(async (event) => {
} }
} }
// Sinon on récupère le dernier backup de chaque dossier distant. // Sinon, on récupère le dernier backup de chaque dossier distant.
let dirs: string[] = [] let dirs: string[] = []
try { try {
dirs = await listRemoteDirs(REMOTE_ROOT) dirs = await listRemoteDirs(REMOTE_ROOT)

View File

@@ -1,53 +1,13 @@
import { execFile, spawn } from "node:child_process" import {
import folderMap from "../config/backup-folders.json" runSsh,
shellQuote,
resolveFolderRemoteDir,
REMOTE_HOST,
} from "../utils/ssh.ts"
const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST || "malio-b" import {spawn} from "unenv/node/child_process";
const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups"
const FOLDER_MAP = folderMap as Record<string, string>
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value) const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
function runSsh(command: string): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
"ssh",
["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", REMOTE_HOST, command],
{ maxBuffer: 10 * 1024 * 1024 },
(error, stdout, stderr) => {
if (error) {
reject(stderr || error.message)
return
}
resolve(stdout)
}
)
})
}
async function remoteDirExists(remoteDir: string): Promise<boolean> {
const output = await runSsh(`[ -d ${shellQuote(remoteDir)} ] && echo yes || echo no`)
return output.trim() === "yes"
}
async function resolveFolderRemoteDir(folderName: string): Promise<string | null> {
const mapped = FOLDER_MAP[folderName]
if (mapped) {
return `${REMOTE_ROOT}/${mapped}`
}
const direct = `${REMOTE_ROOT}/${folderName}`
if (await remoteDirExists(direct)) {
return direct
}
const nested = `${REMOTE_ROOT}/bdd_recette/${folderName}`
if (await remoteDirExists(nested)) {
return nested
}
return null
}
async function getLatestRemoteFile(remoteDir: string): Promise<string | null> { async function getLatestRemoteFile(remoteDir: string): Promise<string | null> {
const output = await runSsh(`cd ${shellQuote(remoteDir)} && ls -1A | sort -r | head -n 1`) const output = await runSsh(`cd ${shellQuote(remoteDir)} && ls -1A | sort -r | head -n 1`)

View File

@@ -1,92 +1,27 @@
import { execFile, spawn } from "node:child_process" import {
import { Readable } from "node:stream" runSsh,
import folderMap from "../config/backup-folders.json" shellQuote,
resolveFolderRemoteDir,
const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST || "malio-b" REMOTE_HOST,
const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups" } from "../utils/ssh.ts"
const FOLDER_MAP = folderMap as Record<string, string> import {spawn} from "unenv/node/child_process";
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value) const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
const isSafeFile = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value) const isSafeFile = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
function runSsh(command: string): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
"ssh",
["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", REMOTE_HOST, command],
{ maxBuffer: 10 * 1024 * 1024 },
(error, stdout, stderr) => {
if (error) {
reject(stderr || error.message)
return
}
resolve(stdout)
}
)
})
}
async function remoteDirExists(remoteDir: string): Promise<boolean> {
const output = await runSsh(`[ -d ${shellQuote(remoteDir)} ] && echo yes || echo no`)
return output.trim() === "yes"
}
async function resolveFolderRemoteDir(folderName: string): Promise<string | null> {
const mapped = FOLDER_MAP[folderName]
if (mapped) {
return `${REMOTE_ROOT}/${mapped}`
}
const direct = `${REMOTE_ROOT}/${folderName}`
if (await remoteDirExists(direct)) {
return direct
}
const nested = `${REMOTE_ROOT}/bdd_recette/${folderName}`
if (await remoteDirExists(nested)) {
return nested
}
return null
}
function buildContentDisposition(fileName: string) { function buildContentDisposition(fileName: string) {
const asciiName = fileName.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_") const asciiName = fileName.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_")
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

@@ -1,165 +1,5 @@
import fs from "node:fs" import { getSystemMetricsSnapshot } from "../plugins/metrics-worker"
import os from "node:os"
type CpuTimesSnapshot = { export default defineEventHandler(() => {
idle: number return getSystemMetricsSnapshot()
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()
}
}) })

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)
})

51
server/utils/ssh.ts Normal file
View File

@@ -0,0 +1,51 @@
import { execFile } from "node:child_process"
import {process} from "std-env";
import folderMap from "#server/config/backup-folders.json";
export const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST
export const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups"
export const FOLDER_MAP = folderMap as Record<string, string>
export const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
export function runSsh(command: string): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
"ssh",
["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", REMOTE_HOST, command],
{ maxBuffer: 10 * 1024 * 1024 },
(error, stdout, stderr) => {
if (error) {
reject(stderr || error.message)
return
}
resolve(stdout)
}
)
})
}
async function remoteDirExists(remoteDir: string): Promise<boolean> {
const output = await runSsh(`[ -d ${shellQuote(remoteDir)} ] && echo yes || echo no`)
return output.trim() === "yes"
}
export async function resolveFolderRemoteDir(folderName: string): Promise<string | null> {
const mapped = FOLDER_MAP[folderName]
if (mapped) {
return `${REMOTE_ROOT}/${mapped}`
}
const direct = `${REMOTE_ROOT}/${folderName}`
if (await remoteDirExists(direct)) {
return direct
}
const nested = `${REMOTE_ROOT}/bdd_recette/${folderName}`
if (await remoteDirExists(nested)) {
return nested
}
return null
}

9
types/system.ts Normal file
View File

@@ -0,0 +1,9 @@
export type SystemMetrics = {
cpuPercent: number
memoryPercent: number
totalMemory: number
usedMemory: number
incomingMbps: number
outgoingMbps: number
sampledAt: string
}