24 Commits

Author SHA1 Message Date
semantic-release-bot
d0a3f73989 chore(release): 1.4.2 2026-03-18 12:49:19 +00:00
659f22f15b Merge pull request 'fix/status-recette-log' (#20) from fix/status-recette-log into develop
All checks were successful
Release / release (push) Successful in 33s
Reviewed-on: #20
2026-03-18 12:48:49 +00:00
403bc91f33 fix: systeme metrics chart 2026-03-18 11:36:59 +01:00
0a73c5cb37 fix: use recette status log 2026-03-18 10:33:18 +01:00
c12387ac94 fix: t-005 a t-0029 correctif 2026-03-18 09:00:11 +01:00
bdb65a09ff fix: t-001 a t-005 correctif 2026-03-17 15:33:36 +01:00
semantic-release-bot
f07ca784b1 chore(release): 1.4.1 2026-03-17 13:27:36 +00:00
13457ceb5a fix: readme
All checks were successful
Release / release (push) Successful in 26s
2026-03-17 14:26:49 +01:00
f30d75141d Merge pull request 'fix: resolve production runtime issues' (#19) from fix/prod-runtime-issues into develop
Some checks failed
Release / release (push) Has been cancelled
Reviewed-on: #19
2026-03-17 12:52:21 +00:00
8886e8b7df fix: resolve production runtime issues 2026-03-17 13:50:13 +01:00
semantic-release-bot
99a5758f05 chore(release): 1.4.0 2026-03-17 09:27:06 +00:00
7261f9f0e9 Merge pull request 'feat: add check backup' (#18) from feat/440-add-section-check-backup into develop
All checks were successful
Release / release (push) Successful in 27s
Reviewed-on: #18
2026-03-17 09:26:41 +00:00
5184e26293 fix: readme 2026-03-17 08:54:33 +01:00
829ac07d38 fix: use env only 2026-03-16 15:05:48 +01:00
e13e1eb3dd fix: use env only 2026-03-16 14:43:55 +01:00
69c192c35a fix: lint 2026-03-16 14:37:42 +01:00
f7ac255820 fix: use env 2026-03-16 14:28:01 +01:00
5495e18173 feat: add check backup 2026-03-16 11:30:34 +01:00
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
40 changed files with 2204 additions and 821 deletions

View File

@@ -1,8 +1,25 @@
# API_SECRET_KEy sert à sécuriser l'accès à l'API de votre application.
API_SECRET_KEY=
# DISCORD_BOT_TOKEN & DISCORD_CHANNEL_ID pour le bot discord
DISCORD_BOT_TOKEN=
DISCORD_CHANNEL_ID=
# BACKUPS_REMOTE_HOST, BACKUPS_REMOTE_ROOT et BACKUPS_MAX_FILES pour la gestion des backups
BACKUPS_REMOTE_HOST=
BACKUPS_REMOTE_ROOT=
BACKUPS_MAX_FILES=
DISK_COMMAND_REMOTE=
DISK_COMMAND_LOCAL=
# Paramètres utilisés pour construire les commandes disque et backup
DISK_REMOTE_HOST=malio-b
DISK_LOCAL_SCRIPT_DIR=/home/malio/Malio-ops/CheckStorage
DISK_REMOTE_SCRIPT_DIR=/home/malio-b/Malio-ops/CheckStorage
RECETTE_SCRIPTS_DIR=/home/malio/Malio-ops/RecetteScripts
VAULTWARDEN_SSH_HOST=bitwarden
VAULTWARDEN_SCRIPTS_DIR=/home/matt/vaultwarden/Malio-ops/BackupVaultWarden
# A quelle heure les backups doivent être effectués (format 24h)
BACKUPS_HOUR=19
#Mettre à true pour que les cookies d'authentification soient sécurisés (HTTPS uniquement)
AUTH_COOKIE_SECURE=

View File

@@ -1,3 +1,47 @@
## [1.4.2](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.4.1...v1.4.2) (2026-03-18)
### Bug Fixes
* systeme metrics chart ([403bc91](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/403bc91f33ca2253d698531e8c6bf0c28b40f5c8))
* t-001 a t-005 correctif ([bdb65a0](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/bdb65a09ff247f8fb3d22913a3426a89fad1d177))
* t-005 a t-0029 correctif ([c12387a](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/c12387ac947cde677e78fe77d914a904795d404c))
* use recette status log ([0a73c5c](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/0a73c5cb37c557568647684382440d95de7bf3ab))
## [1.4.1](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.4.0...v1.4.1) (2026-03-17)
### Bug Fixes
* readme ([13457ce](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/13457ceb5a74686cd7a5e4180a87f130d1e2f73d))
* resolve production runtime issues ([8886e8b](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/8886e8b7dfe4fb6c9f90f3be7f2a64e23dd7cb3c))
# [1.4.0](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.3.1...v1.4.0) (2026-03-17)
### Bug Fixes
* lint ([69c192c](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/69c192c35ad2a743d01b96d834f509b2b1f0b4e6))
* readme ([5184e26](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/5184e26293ef23944e874f4e938f1cc89ec85f82))
* use env ([f7ac255](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/f7ac255820ca5a1fded47a6b0071d85c7d3c4214))
* use env only ([829ac07](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/829ac07d38e81225017b3c6a33c3f34882ca02d1))
* use env only ([e13e1eb](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/e13e1eb3dd48c1b5a6f2fe0347e43dea60e4406f))
### Features
* add check backup ([5495e18](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/5495e18173c0778c6eaba4ae1eb8c30ea46bbef7))
## [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)

159
README.md
View File

@@ -1,4 +1,14 @@
# Projet Monitoring
# Supervisor
`Supervisor` est une application Nuxt qui centralise plusieurs besoins d'exploitation dans une interface web unique :
- suivi de l'état general d'applications distantes
- consultation de l'espace disque local et distant
- visualisation de métriques système de la machine qui execute l'application
- contrôle et téléchargement de sauvegardes via SSH
- lecture de messages Discord depuis un canal configure
Le nom du package npm visible dans le depot est `disk-monitor`, mais l'interface et la structure du projet exposent clairement le nom `Supervisor`.
## Installation du projet
@@ -15,79 +25,118 @@ https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux
### Installation du projet
Une fois les prérequis installés, cloner le dépôt puis installer les dépendances.
Les étapes ci-dessous sont celles qui sont réellement supportées par le depot.
### 1. Cloner le depot
```bash
git clone gitea@gitea.malio.fr:MALIO-DEV/Supervisor.git
cd Supervisor
```
### 2. Preparer le fichier d'environnement
Le depot fournit un exemple dans `.env.example`.
```bash
cp .env.example .env
```
### 3. Renseigner les variables necessaires
#### Generation d'une valeur pour `API_SECRET_KEY`
Le depot impose la presence d'un secret, mais ne fournit pas de commande officielle pour le générer.
Exemple de commande compatible :
```bash
openssl rand -hex 32
```
Cette commande sert simplement à produire une valeur aléatoire facile à placer dans `.env`.
Les variables visibles dans le depot sont :
- `API_SECRET_KEY` : secret attendu par le middleware d'authentification pour toutes les routes `/api/*` sauf `/api/ping`
- `DISCORD_BOT_TOKEN` : token du bot utilise par endpoint Discord
- `DISCORD_CHANNEL_ID` : identifiant du canal Discord a lire
- `BACKUPS_REMOTE_HOST` : hôte SSH cible pour les operations distantes
- `BACKUPS_REMOTE_ROOT` : dossier racine des sauvegardes sur l'hôte distant
- `BACKUPS_MAX_FILES` : nombre maximal de fichiers retournés par dossier de backup
- `DISK_COMMAND_REMOTE` : commande shell utilisée pour la verification disque distante
- `DISK_COMMAND_LOCAL` : commande shell utilisée pour la verification disque locale
- `BACKUP_SCRIPT_COMMAND_BACKUP_BDD_RECETTE` : commande a exécuter pour le script "Backup BDD recette"
- `BACKUP_SCRIPT_COMMAND_CHECK_STATUT_RECETTE` : commande à exécuter pour le script "Check statut recette"
- `BACKUP_SCRIPT_COMMAND_BACKUP_VAULTWARDEN` : commande à exécuter pour le script "Backup vault warden"
- `BACKUPS_HOUR` : heure attendue des sauvegardes pour le contrôle de fraicheur
### 4. Installer les dépendances
```bash
npm install
```
Lancer ensuite le serveur de développement.
### 5. Lancer le serveur de développement
```bash
npm run dev
```
Lapplication sera accessible sur :
http://localhost:3000
Par défaut, l'application Nuxt sera accessible sûr <http://localhost:3000>.
Si une erreur liée à la version de Node apparaît, vérifier que Node ≥ 20 est utilisé via nvm.
## Configuration necessaire
nvm install 20
nvm use 20
### Authentification API
## Utilisation du projet
### Frontend
Le middleware `server/middleware/auth.ts` protege toutes les routes `/api/*`, sauf `/api/ping`.
Lancer le serveur de développement.
```
npm run dev
```
Compilation pour la production.
```
npm run build
```
Prévisualisation du build de production.
```
npm run preview
```
Consequence visible :
- si `API_SECRET_KEY` est vide, les appels API sont refusés avec `401 Unauthorized`
- l'application web pose aussi un cookie HTTP-only via `server/middleware/auth-cookie.ts` pour réutiliser ce secret coté navigateur
## Securite
Le comportement actuel du projet repose sur une hypothèse d'exposition très forte.
- `server/middleware/auth-cookie.ts` pose automatiquement le cookie `api_auth_token` à tout visiteur qui charge l'interface web
- ce cookie permet ensuite d'accéder aux routes `/api/*` protégées par `API_SECRET_KEY`
- il n'existe pas de login utilisateur ni de contrôle d'identité distinct dans le dépôt
Conséquence :
- `Supervisor` doit être déployé uniquement sur un réseau de confiance, derrière un VPN, une restriction d'IP, un proxy d'authentification ou un autre contrôle d'accès externe
- si l'application est exposée publiquement sans protection supplémentaire, ce mécanisme ne constitue pas une authentification suffisante
### SSH pour les backups
Les fonctionnalités de backup utilisent `ssh` avec les options `BatchMode=yes` et `ConnectTimeout=5` dans `server/utils/ssh.ts`. Cela implique un accès sans saisie interactive de mot de passe.
Elements a preparer cote SSH :
- une cle privée disponible sur la machine qui execute `Supervisor`
- une clé ssh pour les différentes machines cibles, si necessaire pour les différents usages (backup BDD, backup Vault warden, check statut recette)
Le depot ne fixe pas de noms de fichiers de clés SSH ni de chemin obligatoire. Les noms exacts ne sont donc pas vérifiables dans le code.
## Commandes utiles
Installation des dépendances.
```
npm install
```
Lancer le serveur de développement.
```
Commandes déclarées dans `package.json` :
```bash
npm run dev
```
Build de production.
```
npm run build
```
Prévisualisation du build.
```
npm run generate
npm run preview
npm run lint
npm run lint:fix
```
Supprimer les dépendances et réinstaller proprement.
```
rm -rf node_modules package-lock.json
npm install
Déploiement
```
Construire lapplication.
```
npm run build
```
Les fichiers générés se trouvent dans :
.output/
Le serveur peut ensuite être lancé avec :
```
node .output/server/index.mjs
```
Il est recommandé dutiliser un reverse proxy comme Nginx en production.
Usage :
### Notes
Les accès SSH ou les chemins système utilisés par les endpoints doivent rester côté serveur.
Ne jamais exposer de credentials dans le frontend.
Les variables sensibles doivent être stockées dans un fichier .env.
- `npm run dev` : lance l'application en développement
- `npm run build` : construit l'application pour la production
- `npm run generate` : généré une sortie statique si ce mode est compatible avec votre usage
- `npm run preview` : prévisualisé le build Nuxt
- `npm run lint` : execute ESLint
- `npm run lint:fix` : applique les corrections ESLint automatiques : collecte périodique CPU, mémoire et réseau

View File

@@ -15,7 +15,6 @@
--color-m-success: rgb(var(--m-success));
--color-m-accent: rgb(var(--m-accent));
--color-m-warning: rgb(var(--m-warning));
--color-m-succes: rgb(var(--m-success));
--font-display: "Outfit", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
}
@@ -34,12 +33,35 @@
min-height: 100vh;
font-family: var(--font-display);
background: rgb(var(--m-bg));
background-image:
radial-gradient(circle at top left, rgb(var(--m-accent) / 0.1), transparent 24%),
radial-gradient(circle at top right, rgb(var(--m-success) / 0.08), transparent 18%);
color: rgb(var(--m-text));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color 0.4s ease, color 0.4s ease;
}
::selection {
background: rgb(var(--m-accent) / 0.28);
color: rgb(var(--m-text));
}
a,
button {
transition:
color 0.2s ease,
background-color 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
:focus-visible {
outline: 2px solid rgb(var(--m-accent) / 0.85);
outline-offset: 2px;
}
img {
display: block;
}
@@ -75,6 +97,13 @@
transition: box-shadow 0.3s ease;
}
.card-glow:hover {
box-shadow:
0 0 0 1px rgb(var(--m-accent) / calc(var(--m-card-border-opacity) + 0.04)),
0 10px 30px -10px rgba(0, 0, 0, calc(var(--m-shadow-opacity) + 0.08)),
0 0 56px -14px rgb(var(--m-accent) / 0.1);
}
.card-glow-success {
box-shadow:
0 0 0 1px rgb(var(--m-success) / 0.15),
@@ -165,3 +194,14 @@
::-webkit-scrollbar-thumb:hover {
background: rgb(var(--m-muted));
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -77,10 +77,8 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue"
import { Icon as IconifyIcon } from "@iconify/vue"
import { apiFetch } from "~/composables/useApiAuth"
import { useApiAuthHeader } from "~/composables/useApiAuth"
type BackupScript = {
key: string
@@ -109,6 +107,12 @@ type ScriptResult = {
downloadFolders: string[]
}
type ApiErrorLike = {
data?: {
statusMessage?: string
}
}
const emit = defineEmits<{
result: [payload: ScriptResult]
}>()
@@ -117,17 +121,14 @@ const active = ref<string | null>(null)
const loading = ref(true)
const runningKey = ref<string | null>(null)
const scripts = ref<BackupScript[]>([])
const output = ref<string>("")
const message = ref<string>("")
const isError = ref(false)
const apiAuthHeader = useApiAuthHeader()
const statusClass = computed(() => (isError.value ? "status-error" : "status-success"))
const loadScripts = async () => {
loading.value = true
message.value = ""
output.value = ""
isError.value = false
emit("result", {
key: null,
@@ -158,7 +159,6 @@ const loadScripts = async () => {
const runScript = async (key: string) => {
active.value = key
runningKey.value = key
output.value = ""
message.value = ""
isError.value = false
@@ -167,31 +167,25 @@ const runScript = async (key: string) => {
method: "POST",
body: { key }
})
const resultOutput = data.output || "Aucune sortie retournee."
message.value = `${data.label} execute avec succes`
output.value = data.output || "Aucune sortie retournee."
emit("result", {
key: data.key,
label: data.label,
output: output.value,
output: resultOutput,
isError: false,
downloadFolders: data.downloadFolders || []
})
} catch (error: unknown) {
isError.value = true
const statusMessage =
const errorMessage =
typeof error === "object" &&
error !== null &&
"data" in error &&
typeof error.data === "object" &&
error.data !== null &&
"statusMessage" in error.data &&
typeof error.data.statusMessage === "string"
? error.data.statusMessage
typeof (error as ApiErrorLike).data?.statusMessage === "string"
? (error as ApiErrorLike).data?.statusMessage
: null
message.value = statusMessage || "Erreur lors de l'opération"
message.value = error?.data?.statusMessage || "Erreur execution script"
output.value = ""
message.value = errorMessage || "Erreur execution script"
emit("result", {
key,
label: scripts.value.find((item) => item.key === key)?.label || key,

View File

@@ -56,12 +56,15 @@ const { data: messages, error } = await useFetch('/api/discord/messages', {
<style scoped>
.discord-card {
background: rgb(var(--m-secondary));
border-radius: 12px;
background:
linear-gradient(180deg, rgb(var(--m-secondary) / 0.78), rgb(var(--m-secondary) / 0.92));
border-radius: 20px;
padding: 1.25rem;
border: 1px solid rgb(var(--m-border) / 0.32);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.03);
max-height: calc(100vh - 7rem);
overflow: hidden;
transition: background-color 0.4s ease;
transition: background-color 0.4s ease, border-color 0.2s ease;
}
.card-header {
@@ -83,7 +86,11 @@ const { data: messages, error } = await useFetch('/api/discord/messages', {
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 220px;
padding: 2rem 1rem;
border-radius: 14px;
background: rgb(var(--m-tertiary) / 0.28);
text-align: center;
}
.error-state {
@@ -95,7 +102,7 @@ const { data: messages, error } = await useFetch('/api/discord/messages', {
.message-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.65rem;
max-height: calc(100vh - 12rem);
overflow-y: auto;
}
@@ -103,10 +110,10 @@ const { data: messages, error } = await useFetch('/api/discord/messages', {
.message-row {
display: flex;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 8px;
background: rgb(var(--m-tertiary));
border: 1px solid rgb(var(--m-accent) / 0.04);
padding: 0.85rem;
border-radius: 14px;
background: rgb(var(--m-tertiary) / 0.74);
border: 1px solid rgb(var(--m-border) / 0.22);
}
.message-avatar {
@@ -123,4 +130,20 @@ const { data: messages, error } = await useFetch('/api/discord/messages', {
color: rgb(var(--m-accent));
flex-shrink: 0;
}
@media (max-width: 1180px) {
.discord-card {
max-height: none;
}
.message-list {
max-height: 28rem;
}
}
@media (max-width: 820px) {
.discord-card {
padding: 1rem;
}
}
</style>

View File

@@ -62,7 +62,7 @@ const metrics = computed(() => [
async function testDownload() {
const start = performance.now()
const res = await apiRequest('/api/download')
const res = await apiRequest('/api/speedtest')
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
@@ -118,10 +118,13 @@ async function runTests() {
<style scoped>
.speedtest-card {
background: rgb(var(--m-secondary));
border-radius: 12px;
background:
linear-gradient(180deg, rgb(var(--m-secondary) / 0.78), rgb(var(--m-secondary) / 0.92));
border-radius: 20px;
padding: 1.25rem;
transition: background-color 0.4s ease;
border: 1px solid rgb(var(--m-border) / 0.32);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.03);
transition: background-color 0.4s ease, border-color 0.2s ease;
}
.card-header {
@@ -152,9 +155,15 @@ async function runTests() {
transition: all 0.2s ease;
}
.reload-btn:focus-visible {
outline: 2px solid rgb(var(--m-accent) / 0.8);
outline-offset: 2px;
}
.reload-btn:hover:not(:disabled) {
background: rgb(var(--m-accent) / 0.12);
border-color: rgb(var(--m-accent) / 0.25);
transform: translateY(-1px);
}
.reload-btn:disabled {
@@ -169,10 +178,10 @@ async function runTests() {
}
.metric-card {
background: rgb(var(--m-tertiary));
border-radius: 10px;
background: rgb(var(--m-tertiary) / 0.72);
border-radius: 14px;
padding: 1rem;
border: 1px solid rgb(var(--m-accent) / 0.06);
border: 1px solid rgb(var(--m-border) / 0.22);
transition: border-color 0.2s ease;
}
@@ -211,12 +220,22 @@ async function runTests() {
.error-text {
margin-top: 0.75rem;
border-radius: 8px;
border: 1px solid rgb(var(--m-error) / 0.12);
border-radius: 14px;
border: 1px solid rgb(var(--m-error) / 0.16);
background: rgb(var(--m-error) / 0.06);
padding: 0.75rem 0.875rem;
font-family: var(--font-mono);
font-size: 0.75rem;
color: rgb(var(--m-error));
}
@media (max-width: 820px) {
.speedtest-card {
padding: 1rem;
}
.metrics-grid {
grid-template-columns: 1fr;
}
}
</style>

224
components/StatusBackup.vue Normal file
View File

@@ -0,0 +1,224 @@
<template>
<div class="status-card card-glow">
<div class="card-header">
<h2 class="card-title">Status Backup</h2>
<span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Services</span>
</div>
<template v-if="loading">
<div
v-for="n in 3"
:key="`skeleton-${n}`"
class="status-row animate-shimmer"
>
<div class="flex items-center gap-3">
<CircleSkeleton custom-class="h-3 w-3" />
<TextSkeleton custom-class="h-4 w-20" />
</div>
<TextSkeleton custom-class="h-4 w-16" />
</div>
</template>
<div
v-for="row in rows"
v-else
:key="`${row.label}-${row.folder}`"
class="status-row"
:class="row.status === 200 ? 'row-ok' : 'row-error'"
>
<div class="flex items-center gap-3">
<span class="status-dot" :class="row.status === 200 ? 'dot-ok' : 'dot-error'" />
<span class="font-display text-sm font-semibold text-m-text">
{{ row.label }}
</span>
</div>
<div class="flex flex-col items-end gap-1 text-right">
<span class="font-mono text-xs" :class="row.status === 200 ? 'text-m-success' : 'text-m-error'">
{{ statusLabel(row.status) }}
</span>
<span class="font-mono text-[10px] text-m-muted">
{{ formatBackupLabel(row) }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
import {onBeforeUnmount, onMounted, ref} from "vue"
import { apiFetch } from "~/composables/useApiAuth"
interface StatusRow {
label: string
folder: string
ok: boolean
status: number
checkedAt: string
latestBackup: string | null
latestBackupAt: string | null
backupDate: string | null
expectedBackupDate: string
error?: string
}
interface StatusResponse {
results: StatusRow[]
}
const props = withDefaults(
defineProps<{
endpoint?: string
refreshMs?: number
}>(),
{
endpoint: "/api/check-backup",
refreshMs: 30000
}
)
const rows = ref<StatusRow[]>([])
const loading = ref(true)
const initialized = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
const statusLabel = (status: number) => {
if (status === 200) return "Backup OK"
if (status === 0) return "Backup KO"
return `KO (${status})`
}
const formatBackupLabel = (row: StatusRow) => {
if (!row.ok && row.backupDate) {
return `Trouve ${row.backupDate} · attendu ${row.expectedBackupDate}`
}
if (row.latestBackupAt) {
const backupDate = new Date(row.latestBackupAt)
if (!Number.isNaN(backupDate.getTime())) {
return backupDate.toLocaleString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
})
}
}
if (row.backupDate) {
return row.backupDate
}
return row.error || "Aucun backup"
}
const checkStatus = async () => {
if (!initialized.value) {
loading.value = true
}
try {
const data = await apiFetch<StatusResponse>(props.endpoint)
rows.value = data.results
} catch (error) {
rows.value = [
{
label: "Erreur",
folder: "error",
ok: false,
status: 0,
checkedAt: new Date().toISOString(),
latestBackup: null,
latestBackupAt: null,
backupDate: null,
expectedBackupDate: "",
error: error instanceof Error ? error.message : String(error)
}
]
} finally {
initialized.value = true
loading.value = false
}
}
onMounted(() => {
checkStatus()
timer = setInterval(checkStatus, props.refreshMs)
})
onBeforeUnmount(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
</script>
<style scoped>
.status-card {
background:
linear-gradient(180deg, rgb(var(--m-secondary) / 0.78), rgb(var(--m-secondary) / 0.92));
border-radius: 20px;
padding: 1.25rem;
border: 1px solid rgb(var(--m-border) / 0.32);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.03);
display: flex;
flex-direction: column;
gap: 0.75rem;
transition: background-color 0.4s ease, border-color 0.2s ease;
}
.card-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.card-title {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 3.2rem;
padding: 0.85rem 1rem;
border-radius: 14px;
background: rgb(var(--m-tertiary) / 0.75);
border: 1px solid rgb(var(--m-border) / 0.2);
transition: all 0.2s ease;
}
.row-ok {
border-color: rgb(var(--m-success) / 0.08);
}
.row-error {
border-color: rgb(var(--m-error) / 0.1);
background: rgb(var(--m-error) / 0.04);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-ok {
background: rgb(var(--m-success));
box-shadow: 0 0 6px rgb(var(--m-success) / 0.5);
}
.dot-error {
background: rgb(var(--m-error));
box-shadow: 0 0 6px rgb(var(--m-error) / 0.5);
animation: pulse-glow 2s ease-in-out infinite;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="status-card card-glow">
<div class="card-header">
<h2 class="card-title">Status</h2>
<h2 class="card-title">Status App</h2>
<span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Services</span>
</div>
@@ -24,17 +24,27 @@
v-else
:key="`${row.label}-${row.url}`"
class="status-row"
:class="row.status === 200 ? 'row-ok' : 'row-error'"
:class="row.ok ? 'row-ok' : 'row-error'"
>
<div class="flex items-center gap-3">
<span class="status-dot" :class="row.status === 200 ? 'dot-ok' : 'dot-error'" />
<span class="font-display text-sm font-semibold text-m-text">
{{ row.label }}
<div class="status-copy">
<div class="flex items-center gap-3">
<span class="status-dot" :class="row.ok ? 'dot-ok' : 'dot-error'" />
<span class="font-display text-sm font-semibold text-m-text">
{{ row.label }}
</span>
</div>
<p class="status-detail">
{{ row.detail }}
</p>
</div>
<div class="status-meta">
<span class="font-mono text-xs" :class="row.ok ? 'text-m-success' : 'text-m-error'">
{{ statusLabel(row) }}
</span>
<span class="status-time">
{{ formatCheckedAt(row.checkedAt) }}
</span>
</div>
<span class="font-mono text-xs" :class="row.status === 200 ? 'text-m-success' : 'text-m-error'">
{{ statusLabel(row.status) }}
</span>
</div>
</div>
</template>
@@ -51,6 +61,7 @@ interface StatusRow {
ok: boolean
status: number
checkedAt: string
detail: string
error?: string
}
@@ -74,10 +85,24 @@ const loading = ref(true)
const initialized = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
const statusLabel = (status: number) => {
if (status === 200) return "HTTP 200"
if (status === 0) return "Injoignable"
return `KO (${status})`
const statusLabel = (row: StatusRow) => {
if (row.ok) return "OK"
if (row.status === 0) return "DOWN"
return `KO (${row.status})`
}
const formatCheckedAt = (checkedAt: string) => {
const date = new Date(checkedAt)
if (Number.isNaN(date.getTime())) {
return checkedAt
}
return date.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
})
}
const checkStatus = async () => {
@@ -95,6 +120,7 @@ const checkStatus = async () => {
ok: false,
status: 0,
checkedAt: new Date().toISOString(),
detail: "Lecture du statut impossible",
error: error instanceof Error ? error.message : String(error)
}
]
@@ -119,13 +145,16 @@ onBeforeUnmount(() => {
<style scoped>
.status-card {
background: rgb(var(--m-secondary));
border-radius: 12px;
background:
linear-gradient(180deg, rgb(var(--m-secondary) / 0.78), rgb(var(--m-secondary) / 0.92));
border-radius: 20px;
padding: 1.25rem;
border: 1px solid rgb(var(--m-border) / 0.32);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.03);
display: flex;
flex-direction: column;
gap: 0.625rem;
transition: background-color 0.4s ease;
gap: 0.75rem;
transition: background-color 0.4s ease, border-color 0.2s ease;
}
.card-header {
@@ -146,13 +175,39 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-radius: 8px;
background: rgb(var(--m-tertiary));
border: 1px solid transparent;
gap: 1rem;
min-height: 3.2rem;
padding: 0.85rem 1rem;
border-radius: 14px;
background: rgb(var(--m-tertiary) / 0.75);
border: 1px solid rgb(var(--m-border) / 0.2);
transition: all 0.2s ease;
}
.status-copy {
min-width: 0;
}
.status-detail {
margin: 0.35rem 0 0;
color: rgb(var(--m-muted));
font-size: 0.78rem;
line-height: 1.4;
}
.status-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
flex-shrink: 0;
}
.status-time {
font-size: 0.72rem;
color: rgb(var(--m-muted));
}
.row-ok {
border-color: rgb(var(--m-success) / 0.08);
}

View File

@@ -3,47 +3,70 @@
<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 et RAM 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="group" aria-label="Metriques affichees">
<button
v-for="option in options"
:key="option.value"
type="button"
class="toggle-pill"
:class="{ 'toggle-pill-active': isMetricActive(option.value) }"
@click="toggleMetric(option.value)"
>
<span class="toggle-dot" :style="{ backgroundColor: option.color }" />
<span>{{ option.label }}</span>
</label>
<span class="toggle-dot" :style="{ backgroundColor: option.color }" />
<span>{{ option.label }}</span>
</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 class="chart-shell">
<template v-if="loading && points.length === 0">
<template v-if="loading && visibleHistory.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
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>
<svg
@@ -53,18 +76,61 @@
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"
v-for="option in displayedOptions"
:key="option.value"
:points="polylinePoints(option.value)"
class="chart-line"
:style="{ stroke: activeOption.color }"
:style="{ stroke: option.color }"
/>
</svg>
</template>
@@ -73,44 +139,131 @@
</template>
<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 SystemMetrics = {
cpuPercent: number
memoryPercent: number
totalMemory: number
usedMemory: number
incomingMbps: number
outgoingMbps: number
sampledAt: string
}
type MetricKey = "cpu" | "ram"
type WindowKey = "day" | "hour" | "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 activeMetrics = ref<MetricKey[]>(["cpu", "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" }
]
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(
() => props.metrics?.sampledAt,
() => {
@@ -118,82 +271,147 @@ 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 activeWindow = computed(() => {
return windowOptions.find((option) => option.value === selectedWindow.value) || windowOptions[0]
})
const points = computed(() => history.value.map((point) => point[selectedMetric.value]))
const peakValue = computed(() => {
return points.value.reduce((max, value) => Math.max(max, value), 0)
const displayedOptions = computed(() => {
return options.filter((option) => activeMetrics.value.includes(option.value))
})
const scaleMax = computed(() => {
if (selectedMetric.value === "cpu" || selectedMetric.value === "ram") {
return 100
const visibleHistory = computed(() => {
if (activeWindow.value.durationMs === null) {
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) => {
if (metric === "cpu" || metric === "ram") {
return `${Math.round(value)}%`
}
const scaleMax = computed(() => 100)
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 formatValue = (value: number) => `${Math.round(value)}%`
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(() => {
if (points.value.length === 0) {
return "0,280"
const isMetricActive = (metric: MetricKey) => activeMetrics.value.includes(metric)
const toggleMetric = (metric: MetricKey) => {
if (isMetricActive(metric)) {
activeMetrics.value = activeMetrics.value.filter((value) => value !== metric)
return
}
if (points.value.length === 1) {
const normalizedValue = points.value[0] / scaleMax.value
const y = 280 - normalizedValue * 240
return `0,${y} 960,${y}`
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 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) => {
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 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(" ")
})
}
</script>
<style scoped>
@@ -231,6 +449,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;
@@ -252,6 +477,7 @@ const polylinePoints = computed(() => {
letter-spacing: 0.12em;
color: rgb(var(--m-muted));
cursor: pointer;
appearance: none;
transition: border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
@@ -268,6 +494,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;
@@ -278,11 +551,17 @@ 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;
}
.meta-metric {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.meta-label {
display: block;
margin-bottom: 0.25rem;
@@ -295,23 +574,54 @@ const polylinePoints = computed(() => {
.meta-value {
font-family: var(--font-display);
font-size: 1.1rem;
font-size: 1.35rem;
font-weight: 700;
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 {
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;
@@ -326,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,16 +46,7 @@
<script setup lang="ts">
import {computed} from "vue"
type SystemMetrics = {
cpuPercent: number
memoryPercent: number
totalMemory: number
usedMemory: number
incomingMbps: number
outgoingMbps: number
sampledAt: string
}
import type { SystemMetrics } from "~/types/system";
const props = defineProps<{
metrics: SystemMetrics | null
@@ -96,13 +87,16 @@ const metrics = computed(() => [
<style scoped>
.resources-card {
background: rgb(var(--m-secondary));
border-radius: 12px;
background:
linear-gradient(180deg, rgb(var(--m-secondary) / 0.78), rgb(var(--m-secondary) / 0.92));
border-radius: 20px;
padding: 1.25rem;
border: 1px solid rgb(var(--m-border) / 0.32);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.03);
display: flex;
flex-direction: column;
gap: 1rem;
transition: background-color 0.4s ease;
transition: background-color 0.4s ease, border-color 0.2s ease;
}
.card-header {
@@ -130,10 +124,10 @@ const metrics = computed(() => [
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
padding: 0.875rem 1rem;
border-radius: 10px;
background: rgb(var(--m-tertiary));
border: 1px solid rgb(var(--m-accent) / 0.06);
padding: 0.95rem 1rem;
border-radius: 14px;
background: rgb(var(--m-tertiary) / 0.72);
border: 1px solid rgb(var(--m-border) / 0.22);
}
.metric-copy {
@@ -196,4 +190,18 @@ const metrics = computed(() => [
.tone-error {
background: rgb(var(--m-error));
}
@media (max-width: 820px) {
.resources-card {
padding: 1rem;
}
.metric-row {
grid-template-columns: 1fr;
}
.metric-value-area {
justify-content: flex-start;
}
}
</style>

View File

@@ -42,7 +42,6 @@ export function useApiAuthHeader() {
// Tous les appels frontend vers /api/* reutilisent ce header commun.
return {
Authorization: `Bearer ${token}`
}
}

View File

@@ -18,34 +18,43 @@
<slot name="sidebar"/>
<nav class="sidebar-nav" aria-label="Sections">
<p class="nav-label">Navigation</p>
<div class="flex flex-col gap-2">
<div class="bg-m-tertiary rounded-lg border border-m-accent/6">
<NuxtLink
to="/"
class="flex items-center gap-3 px-4 py-2 rounded-lg text-white hover:bg-m-tertiary/80 transition-colors"
>
<IconifyIcon
icon="mdi:home"
class="text-lg"/>
<p>Home</p>
</NuxtLink>
</div>
<div class="bg-m-tertiary rounded-lg border border-m-accent/6">
<NuxtLink
to="/backup"
class="flex items-center gap-3 px-4 py-2 rounded-lg text-white hover:bg-m-tertiary/80 transition-colors"
>
<IconifyIcon
icon="mdi:data"
class="text-lg"/>
<p>Backup</p>
</NuxtLink>
</div>
</div>
<NuxtLink
v-for="item in navItems"
:key="`desktop-${item.to}`"
v-slot="{ href, navigate, isExactActive }"
:to="item.to"
custom
>
<a
:href="href"
class="nav-link"
:class="{ 'nav-link-active': isExactActive }"
:aria-current="isExactActive ? 'page' : undefined"
@click="navigate"
>
<span class="nav-link-main">
<span class="nav-icon">
<IconifyIcon :icon="item.icon" class="text-lg"/>
</span>
<span>
<span class="nav-title">{{ item.label }}</span>
<span class="nav-caption">{{ item.caption }}</span>
</span>
</span>
<span class="nav-pill">{{ item.short }}</span>
</a>
</NuxtLink>
</nav>
</div>
<div class="sidebar-footer">
<div class="sidebar-divider"/>
<div class="status-card">
<p class="status-label">Environnement</p>
<p class="status-value">Production</p>
<p class="status-description">
Acces rapide au monitoring, aux sauvegardes et aux cartes systeme.
</p>
</div>
<div class="footer-row">
<p class="font-mono text-[10px] tracking-widest uppercase text-white/40">
Supervisor {{ appVersion }}
@@ -137,7 +146,6 @@
</template>
<script setup lang="ts">
import {ref} from "vue"
import {Icon as IconifyIcon} from "@iconify/vue"
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
@@ -224,6 +232,10 @@ const navItems = [
letter-spacing: -0.02em;
}
.sidebar .brand-title {
margin-top: 0;
}
.brand-description {
margin: 0.55rem 0 0;
color: rgb(255 255 255 / 0.58);
@@ -245,7 +257,7 @@ const navItems = [
.sidebar-content {
flex: 1;
padding: 0.5rem 1rem 1rem;
padding: 0.75rem 1rem 1rem;
}
.sidebar-footer {
@@ -274,7 +286,7 @@ const navItems = [
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.625rem;
}
.nav-label {
@@ -321,6 +333,16 @@ const navItems = [
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04);
}
.nav-link-active .nav-icon {
background: rgb(var(--m-accent) / 0.18);
color: white;
}
.nav-link-active .nav-pill {
background: rgb(var(--m-accent) / 0.18);
color: white;
}
.nav-link-main {
display: flex;
align-items: center;
@@ -403,6 +425,9 @@ const navItems = [
.content {
background: rgb(var(--m-bg));
background-image:
linear-gradient(180deg, rgb(255 255 255 / 0.01), transparent 18%),
radial-gradient(circle at top right, rgb(var(--m-accent) / 0.08), transparent 20%);
overflow-y: auto;
min-height: 100vh;
transition: background-color 0.4s ease;

View File

@@ -4,12 +4,12 @@ import tailwindcss from "@tailwindcss/vite"
const getRepoVersion = () => {
try {
const tags = execSync(
"git for-each-ref --sort=-version:refname --format='%(refname:short)' refs/tags",
{ encoding: "utf8" }
"git for-each-ref --sort=-version:refname --format='%(refname:short)' refs/tags",
{ encoding: "utf8" }
)
.split("\n")
.map((tag) => tag.trim())
.filter(Boolean)
.split("\n")
.map((tag) => tag.trim())
.filter(Boolean)
return tags[0] || "dev"
} catch {
@@ -26,7 +26,7 @@ export default defineNuxtConfig({
head: {
link: [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{ rel: "preconnect", href: "https://fonts.gstatic.com ", crossorigin: "" },
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" },
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700;800;900&display=swap"
@@ -36,8 +36,11 @@ export default defineNuxtConfig({
},
runtimeConfig: {
apiSecretKey: process.env.API_SECRET_KEY,
discordBotToken: process.env.DISCORD_BOT_TOKEN,
discordChannelId: process.env.DISCORD_CHANNEL_ID,
public: {
appVersion: getRepoVersion()
appVersion: getRepoVersion(),
apiKey: process.env.API_SECRET_KEY
}
},
vite: {

8
package-lock.json generated
View File

@@ -1,10 +1,10 @@
{
"name": "disk-monitor",
"name": "supervisor",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "disk-monitor",
"name": "supervisor",
"hasInstallScript": true,
"dependencies": {
"@iconify/vue": "^5.0.0",
@@ -15,11 +15,13 @@
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.6",
"@semantic-release/release-notes-generator": "^14.1.0",
"@tailwindcss/vite": "^4.2.1",
"semantic-release": "^25.0.3",
"tailwindcss": "^4.2.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@actions/core": {

View File

@@ -1,7 +1,10 @@
{
"name": "disk-monitor",
"name": "supervisor",
"type": "module",
"private": true,
"engines": {
"node": ">=20"
},
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
@@ -20,7 +23,6 @@
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.6",
"@semantic-release/release-notes-generator": "^14.1.0",
"@tailwindcss/vite": "^4.2.1",
"semantic-release": "^25.0.3",

View File

@@ -13,10 +13,19 @@
</p>
</div>
</header>
<div class="dashboard-grid">
<section class="grid-left" aria-label="Commandes de sauvegarde">
<section
class="status-strip animate-fade-in-up"
style="animation-delay: 100ms"
aria-label="Statut des sauvegardes"
>
<StatusBackup />
</section>
<div class="workspace-grid">
<section class="workspace-sidebar" aria-label="Commandes de sauvegarde">
<BackupButtonSee
class="animate-fade-in-up backup-selector"
class="animate-fade-in-up"
style="animation-delay: 120ms"
@select="selectedBackup = $event"
/>
@@ -27,24 +36,27 @@
/>
</section>
<section class="grid-middle" aria-labelledby="backup-files-title">
<section class="workspace-main" aria-labelledby="backup-files-title">
<div class="files-panel animate-fade-in-up" style="animation-delay: 240ms">
<div class="files-panel-header">
<div>
<div class="files-panel-copy">
<p class="section-kicker">Fichiers</p>
<h2 id="backup-files-title" class="files-panel-title">
Historique des sauvegardes
</h2>
<p class="files-panel-description">
Consultez les archives disponibles et telechargez le dernier backup du dossier selectionne.
</p>
</div>
<p class="files-panel-meta">
{{ selectedBackup ? `Source ${selectedBackup}` : "En attente de selection" }}
</p>
<span
class="selection-pill"
:class="{ 'selection-pill-active': selectedBackup }"
>
{{ selectedBackup ? `Source ${selectedBackup}` : "Selection requise" }}
</span>
</div>
<BackupList
class="backup-list-mobile"
:folder="selectedBackup"
/>
<BackupList :folder="selectedBackup" />
</div>
<section
@@ -53,9 +65,12 @@
aria-labelledby="backup-output-title"
>
<div class="files-panel-header">
<div>
<div class="files-panel-copy">
<p class="section-kicker">Execution</p>
<h2 id="backup-output-title" class="files-panel-title">Resultat du script</h2>
<p class="files-panel-description">
Le retour du script apparait ici apres execution avec un etat clair en succes ou en erreur.
</p>
</div>
<span
class="panel-badge"
@@ -170,15 +185,12 @@ const handleScriptResult = async (payload: ScriptResult) => {
}
.dashboard-header {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 320px);
gap: 1.5rem;
align-items: end;
margin-bottom: 1.5rem;
}
.header-copy {
min-width: 0;
max-width: 70ch;
}
.section-kicker {
@@ -197,26 +209,37 @@ const handleScriptResult = async (payload: ScriptResult) => {
line-height: 1.65;
}
.dashboard-grid {
.status-strip {
margin-bottom: 1.5rem;
}
.workspace-grid {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
grid-template-columns: minmax(280px, 320px) minmax(0, 1fr);
gap: 1.5rem;
align-items: start;
}
.grid-left,
.grid-middle {
.workspace-sidebar,
.workspace-main {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-width: 0;
}
.workspace-sidebar {
position: sticky;
top: 2rem;
}
.files-panel {
padding: 1.25rem;
border-radius: 20px;
background: rgb(var(--m-secondary) / 0.4);
border: 1px solid rgb(var(--m-accent) / 0.08);
background:
linear-gradient(180deg, rgb(var(--m-secondary) / 0.76), rgb(var(--m-secondary) / 0.92));
border: 1px solid rgb(var(--m-border) / 0.32);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.03);
}
.output-panel {
@@ -225,12 +248,16 @@ const handleScriptResult = async (payload: ScriptResult) => {
.files-panel-header {
display: flex;
align-items: end;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.files-panel-copy {
min-width: 0;
}
.files-panel-title {
margin: 0;
font-family: var(--font-display);
@@ -239,19 +266,41 @@ const handleScriptResult = async (payload: ScriptResult) => {
color: rgb(var(--m-text));
}
.files-panel-meta {
margin: 0;
.files-panel-description {
margin: 0.5rem 0 0;
max-width: 54ch;
color: rgb(var(--m-muted));
line-height: 1.6;
}
.selection-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.25rem;
border-radius: 999px;
border: 1px solid rgb(var(--m-border) / 0.36);
background: rgb(var(--m-tertiary) / 0.45);
padding: 0.45rem 0.8rem;
font-family: var(--font-mono);
font-size: 0.75rem;
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgb(var(--m-muted));
text-align: right;
text-align: center;
}
.selection-pill-active {
border-color: rgb(var(--m-accent) / 0.2);
background: rgb(var(--m-accent) / 0.08);
color: rgb(var(--m-accent));
}
.panel-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.25rem;
border-radius: 999px;
padding: 0.35rem 0.7rem;
font-family: var(--font-mono);
@@ -327,18 +376,22 @@ const handleScriptResult = async (payload: ScriptResult) => {
}
@media (max-width: 1180px) {
.dashboard-header,
.dashboard-grid {
.workspace-grid {
grid-template-columns: 1fr;
}
.workspace-sidebar {
position: static;
}
.files-panel-header {
align-items: flex-start;
flex-direction: column;
}
.files-panel-meta {
text-align: left;
.selection-pill,
.panel-badge {
width: 100%;
}
}
@@ -350,5 +403,9 @@ const handleScriptResult = async (payload: ScriptResult) => {
.files-panel {
padding: 1rem;
}
.files-panel-title {
font-size: 1.2rem;
}
}
</style>

View File

@@ -3,9 +3,13 @@
<div class="dashboard-container">
<header class="dashboard-header">
<div>
<p class="section-kicker">Operations</p>
<h1 class="font-display text-3xl font-bold tracking-tight text-m-text">
Monitoring
</h1>
<p class="header-description">
Visualisez l'etat des applications, des sauvegardes et des ressources systeme depuis une vue unique.
</p>
</div>
</header>
@@ -62,9 +66,10 @@
</template>
<script setup lang="ts">
import {computed, onMounted, ref} from "vue"
definePageMeta({layout: false})
import { apiFetch } from "~/composables/useApiAuth"
import type { SystemMetrics } from "~/types/system";
definePageMeta({layout: false})
type DiskSourceResult = {
key: string
@@ -90,16 +95,6 @@ type DiagramItem = {
totalText: string
}
type SystemMetrics = {
cpuPercent: number
memoryPercent: number
totalMemory: number
usedMemory: number
incomingMbps: number
outgoingMbps: number
sampledAt: string
}
const rawResults = ref<DiskSourceResult[]>([])
const loading = ref(false)
const systemMetrics = ref<SystemMetrics | null>(null)
@@ -181,7 +176,7 @@ const runScript = async () => {
try {
const output = await apiFetch<DiskApiResponse>("/api/disk")
rawResults.value = output.results
} catch (error) {
} catch {
rawResults.value = [
{
key: "error",
@@ -229,8 +224,24 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid rgba(80, 140, 255, 0.08);
padding-bottom: 1.25rem;
border-bottom: 1px solid rgba(80, 140, 255, 0.1);
}
.section-kicker {
margin: 0 0 0.45rem;
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgb(var(--m-accent));
}
.header-description {
max-width: 62ch;
margin-top: 0.9rem;
color: rgb(var(--m-muted));
line-height: 1.65;
}
.storage-section {
@@ -248,9 +259,11 @@ onBeforeUnmount(() => {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
border-radius: 12px;
background: rgb(var(--m-secondary));
padding: 0.75rem;
border-radius: 18px;
background:
linear-gradient(180deg, rgb(var(--m-secondary) / 0.78), rgb(var(--m-secondary) / 0.92));
border: 1px solid rgb(var(--m-border) / 0.32);
padding: 0.85rem;
}
.content-grid {
@@ -289,6 +302,7 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-width: 0;
}
@media (max-width: 1180px) {
@@ -319,14 +333,6 @@ onBeforeUnmount(() => {
grid-template-columns: 1fr;
}
.backup-selector {
order: 2;
}
.backup-list-mobile {
order: 3;
}
.speedtest-card-mobile {
order: 4;
}

View File

@@ -1,2 +1,2 @@
User-Agent: *
Disallow:
Disallow: /

View File

@@ -1,16 +1,8 @@
import scripts from "../config/backup-script.json"
type BackupScript = {
key: string
label: string
icon?: string
downloadFolders?: string[]
command: string
}
import { backupScripts } from "../utils/backup-scripts"
export default defineEventHandler(() => {
return {
scripts: (scripts as BackupScript[]).map(({ key, label, icon, downloadFolders }) => ({
scripts: backupScripts.map(({ key, label, icon, downloadFolders }) => ({
key,
label,
icon: icon || "mdi:play-circle-outline",

View File

@@ -1,17 +1,9 @@
import { execFile } from "node:child_process"
import scripts from "../config/backup-script.json"
import { exec } from "node:child_process"
import { backupScripts, getBackupScriptCommand } from "../utils/backup-scripts"
type BackupScript = {
key: string
label: string
downloadFolders?: string[]
command: string
args?: string[]
}
function runCommand(command: string, args: string[] = []): Promise<string> {
function runCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => {
execFile(command, args, { timeout: 10 * 60 * 1000 }, (error, stdout, stderr) => {
exec(command, { timeout: 10 * 60 * 1000 }, (error, stdout, stderr) => {
if (error) {
reject(stderr || error.message)
return
@@ -32,7 +24,7 @@ export default defineEventHandler(async (event) => {
})
}
const script = (scripts as BackupScript[]).find((item) => item.key === key)
const script = backupScripts.find((item) => item.key === key)
if (!script) {
throw createError({
statusCode: 404,
@@ -41,7 +33,15 @@ export default defineEventHandler(async (event) => {
}
try {
const output = await runCommand(script.command, script.args || [])
const command = getBackupScriptCommand(script.key)
if (!command) {
throw createError({
statusCode: 500,
statusMessage: "Commande de script manquante"
})
}
const output = await runCommand(command)
return {
ok: true,
key: script.key,
@@ -52,6 +52,15 @@ export default defineEventHandler(async (event) => {
} catch (error) {
console.error("Erreur execution script:", error)
if (
typeof error === "object" &&
error !== null &&
"statusCode" in error &&
"statusMessage" in error
) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: "Erreur lors de l'opération"

View File

@@ -1,29 +1,13 @@
import { execFile } from "node:child_process"
import folderMap from "../config/backup-folders.json"
import {
runSsh,
shellQuote,
resolveFolderRemoteDir,
REMOTE_ROOT,
isSafeFolder,
} from "../utils/ssh.ts"
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 isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
const FOLDER_MAP = folderMap as Record<string, string>
const MAX_FILES_PER_FOLDER = Math.max(1, Number(process.env.BACKUPS_MAX_FILES) || 50)
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 {
const message = String(error || "").toLowerCase()
@@ -72,30 +56,6 @@ async function getLatestRemoteFile(remoteDir: string): Promise<string | 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) => {
const { folder } = getQuery(event)
const folderName = typeof folder === "string" ? folder : null
@@ -120,7 +80,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[] = []
try {
dirs = await listRemoteDirs(REMOTE_ROOT)

View File

@@ -0,0 +1,151 @@
import {
runSsh,
shellQuote,
resolveFolderRemoteDir
} from "../utils/ssh.ts"
import {process} from "std-env";
import backupOptions from "../config/backup-options.json"
export const BACKUP_HOUR = process.env.BACKUPS_HOUR
type BackupTarget = {
name: string
}
type LatestBackupInfo = {
fileName: string | null
modifiedAt: string | null
}
const backupTargets = backupOptions as BackupTarget[]
function toLabel(name: string) {
if (name === "sirh") return "SIRH"
return name.charAt(0).toUpperCase() + name.slice(1)
}
function pad(value: number) {
return String(value).padStart(2, "0")
}
function formatDateKey(date: Date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function getExpectedBackupDate(now: Date) {
const expected = new Date(now)
if (now.getHours() < BACKUP_HOUR) {
expected.setDate(expected.getDate() - 1)
}
expected.setHours(BACKUP_HOUR, 0, 0, 0)
return expected
}
function extractBackupDate(fileName: string | null) {
if (!fileName) return null
const normalized = fileName.replace(/[^0-9]/g, "")
const yearFirst = normalized.match(/(20\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])/)
if (yearFirst) {
return `${yearFirst[1]}-${yearFirst[2]}-${yearFirst[3]}`
}
const dayFirst = normalized.match(/(0[1-9]|[12]\d|3[01])(0[1-9]|1[0-2])(20\d{2})/)
if (dayFirst) {
return `${dayFirst[3]}-${dayFirst[2]}-${dayFirst[1]}`
}
return null
}
function parseRemoteTimestamp(value: string) {
const timestamp = Number(value)
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return null
}
return new Date(timestamp * 1000).toISOString()
}
async function getLatestBackupInfo(remoteDir: string): Promise<LatestBackupInfo> {
const output = await runSsh(
`cd ${shellQuote(remoteDir)} && for file in *; do [ -e "$file" ] || continue; printf '%s\\t%s\\n' "$(stat -c '%Y' "$file")" "$file"; done | sort -rn | head -n 1`
)
const line = output.trim()
if (!line) {
return { fileName: null, modifiedAt: null }
}
const [timestamp, ...nameParts] = line.split("\t")
const fileName = nameParts.join("\t").trim() || null
return {
fileName,
modifiedAt: parseRemoteTimestamp(timestamp)
}
}
export default defineEventHandler(async () => {
const now = new Date()
const expectedBackupDate = getExpectedBackupDate(now)
const expectedDateKey = formatDateKey(expectedBackupDate)
const checkedAt = now.toISOString()
const results = await Promise.all(
backupTargets.map(async (target) => {
try {
const remoteDir = await resolveFolderRemoteDir(target.name)
if (!remoteDir) {
return {
label: toLabel(target.name),
folder: target.name,
ok: false,
status: 0,
checkedAt,
latestBackup: null,
latestBackupAt: null,
backupDate: null,
expectedBackupDate: expectedDateKey,
error: "Dossier de backup introuvable"
}
}
const latestBackupInfo = await getLatestBackupInfo(remoteDir)
const backupDate = extractBackupDate(latestBackupInfo.fileName)
const ok = backupDate === expectedDateKey
return {
label: toLabel(target.name),
folder: target.name,
ok,
status: ok ? 200 : 0,
checkedAt,
latestBackup: latestBackupInfo.fileName,
latestBackupAt: latestBackupInfo.modifiedAt,
backupDate,
expectedBackupDate: expectedDateKey,
error: latestBackupInfo.fileName ? undefined : "Aucun backup trouve"
}
} catch (error) {
return {
label: toLabel(target.name),
folder: target.name,
ok: false,
status: 0,
checkedAt,
latestBackup: null,
latestBackupAt: null,
backupDate: null,
expectedBackupDate: expectedDateKey,
error: error instanceof Error ? error.message : String(error)
}
}
})
)
return { results }
})

View File

@@ -1,6 +1,7 @@
export default defineEventHandler(async () => {
const token = process.env.DISCORD_BOT_TOKEN
const channel = process.env.DISCORD_CHANNEL_ID
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const token = config.discordBotToken
const channel = config.discordChannelId
if (!token || !channel) {
throw createError({

View File

@@ -1,36 +1,49 @@
import { exec, execFile } from "child_process"
import diskSources from "../config/disk-commands.json"
import { execFile } from "node:child_process"
type DiskSource = {
key: string
key: "remote" | "local"
label: string
}
type CommandSpec = {
command: string
args?: string[]
args: string[]
cwd?: string
}
function getEnvCommand(source: DiskSource) {
const envKey = `DISK_COMMAND_${source.key.toUpperCase()}`
const legacyEnvKey =
source.key === "remote" ? "DISK_REMOTE_COMMAND" : source.key === "local" ? "DISK_LOCAL_COMMAND" : ""
const diskSources: DiskSource[] = [
{
key: "remote",
label: "Serveur distant"
},
{
key: "local",
label: "Machine locale"
}
]
return process.env[envKey] || (legacyEnvKey ? process.env[legacyEnvKey] : undefined) || null
function getCommand(source: DiskSource): CommandSpec {
const localScriptDir = process.env.DISK_LOCAL_SCRIPT_DIR || "/home/malio/Malio-ops/CheckStorage"
const remoteHost = process.env.DISK_REMOTE_HOST || "malio-b"
const remoteScriptDir = process.env.DISK_REMOTE_SCRIPT_DIR || "/home/malio-b/Malio-ops/CheckStorage"
if (source.key === "local") {
return {
command: "bash",
args: ["check-storage.sh"],
cwd: localScriptDir
}
}
return {
command: "ssh",
args: [remoteHost, `cd ${remoteScriptDir} && ./check-storage.sh`]
}
}
function runCommand(command: string, args: string[] = []): Promise<string> {
function runCommand({ command, args, cwd }: CommandSpec): Promise<string> {
return new Promise((resolve, reject) => {
execFile(command, args, (error, stdout, stderr) => {
if (error) {
reject(stderr || error.message)
return
}
resolve(stdout)
})
})
}
function runShellCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => {
execFile(command, args, { cwd }, (error, stdout, stderr) => {
if (error) {
reject(stderr || error.message)
return
@@ -42,12 +55,9 @@ function runShellCommand(command: string): Promise<string> {
export default defineEventHandler(async () => {
const results = await Promise.all(
(diskSources as DiskSource[]).map(async (source) => {
diskSources.map(async (source) => {
try {
const envCommand = getEnvCommand(source)
const output = envCommand
? await runShellCommand(envCommand)
: await runCommand(source.command, source.args || [])
const output = await runCommand(getCommand(source))
return {
key: source.key,
label: source.label,

View File

@@ -1,53 +1,11 @@
import { execFile, spawn } from "node:child_process"
import folderMap from "../config/backup-folders.json"
const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST || "malio-b"
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 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
}
import {
runSsh,
shellQuote,
resolveFolderRemoteDir,
REMOTE_HOST,
isSafeFolder
} from "../utils/ssh.ts"
import { spawn } from "node:child_process"
async function getLatestRemoteFile(remoteDir: string): Promise<string | null> {
const output = await runSsh(`cd ${shellQuote(remoteDir)} && ls -1A | sort -r | head -n 1`)
@@ -86,6 +44,9 @@ export default defineEventHandler(async (event) => {
}
const fileName = await getLatestRemoteFile(remoteDir)
if (!fileName || !isSafeFolder(fileName)) {
continue
}
if (!fileName) {
continue
}
@@ -134,6 +95,6 @@ export default defineEventHandler(async (event) => {
console.error(`Erreur archive SSH (${code}): ${stderr}`)
}
})
event.node.res.on("close", () => child.kill())
return sendStream(event, child.stdout)
})

View File

@@ -1,92 +1,25 @@
import { execFile, spawn } from "node:child_process"
import { Readable } from "node:stream"
import folderMap from "../config/backup-folders.json"
const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST || "malio-b"
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 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
}
import {
runSsh,
shellQuote,
resolveFolderRemoteDir,
REMOTE_HOST,
isSafeFolder,
isSafeFile
} from "../utils/ssh.ts"
import { spawn } from "node:child_process"
function buildContentDisposition(fileName: string) {
const asciiName = fileName.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_")
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) => {
const { folder, file } = getQuery(event)
const folderName = typeof folder === "string" ? folder : null
const fileName = typeof file === "string" ? file : null
// Compat mode: utilisé par le test de débit.
if (!folderName || !fileName) {
return speedtestStream(event)
throw createError({ statusCode: 400, statusMessage: "Paramètres manquants" })
}
if (!isSafeFolder(folderName) || !isSafeFile(fileName)) {
@@ -126,6 +59,6 @@ export default defineEventHandler(async (event) => {
console.error(`Erreur téléchargement SSH (${code}): ${stderr}`)
}
})
event.node.res.on("close", () => child.kill())
return sendStream(event, child.stdout)
})

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

View File

@@ -1,9 +1,10 @@
export default defineEventHandler(async (event) => {
const req = event.node.req
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024 // 100MB
let received = 0
for await (const chunk of req) {
if (received > MAX_UPLOAD_BYTES) throw createError({ statusCode: 413, statusMessage: "Fichier trop volumineux" })
received += chunk.length
}

View File

@@ -1,33 +1,160 @@
import targets from "../config/version-status-targets.json"
import { readFile } from "node:fs/promises"
import { join } from "node:path"
type StatusEntry = {
checkedAt: string
status: "OK" | "DOWN"
host: string
detail: string
}
type StatusResult = {
label: string
url: string
ok: boolean
status: number
checkedAt: string
detail: string
}
const DEFAULT_RECETTE_SCRIPTS_DIR = "/home/malio/Malio-ops/RecetteScripts"
function parseEnvFile(content: string) {
const values: Record<string, string> = {}
for (const rawLine of content.split("\n")) {
const line = rawLine.trim()
if (!line || line.startsWith("#")) {
continue
}
const separatorIndex = line.indexOf("=")
if (separatorIndex === -1) {
continue
}
const key = line.slice(0, separatorIndex).trim()
const value = line.slice(separatorIndex + 1).trim()
if (!key) {
continue
}
values[key] = value.replace(/^['"]|['"]$/g, "")
}
return values
}
function getLogFileName(date: Date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
return `app_health_${year}-${month}-${day}.log`
}
function parseStatusLine(line: string): StatusEntry | null {
const parts = line.split(" | ")
if (parts.length < 4) {
return null
}
const [checkedAt, status, host, ...detailParts] = parts
if ((status !== "OK" && status !== "DOWN") || !host) {
return null
}
return {
checkedAt,
status,
host,
detail: detailParts.join(" | ")
}
}
function buildStatusResult(entry: StatusEntry): StatusResult {
return {
label: entry.host,
url: `http://${entry.host}/`,
ok: entry.status === "OK",
status: entry.status === "OK" ? 200 : 0,
checkedAt: entry.checkedAt,
detail: entry.detail
}
}
export default defineEventHandler(async () => {
const results = await Promise.all(
targets.map(async (target) => {
try {
const response = await fetch(target.url, {
method: "GET",
headers: { Accept: "application/json" }
})
const recetteScriptsDir = process.env.RECETTE_SCRIPTS_DIR || DEFAULT_RECETTE_SCRIPTS_DIR
const envFilePath = join(recetteScriptsDir, ".env")
return {
label: target.label,
url: target.url,
ok: response.status === 200,
status: response.status,
checkedAt: new Date().toISOString()
}
} catch (error) {
return {
label: target.label,
url: target.url,
ok: false,
status: 0,
checkedAt: new Date().toISOString(),
error: error instanceof Error ? error.message : String(error)
}
try {
const envFileContent = await readFile(envFilePath, "utf8")
const envValues = parseEnvFile(envFileContent)
const logDir = envValues.APP_LOG_DIR
if (!logDir) {
throw createError({
statusCode: 500,
statusMessage: "Variable APP_LOG_DIR manquante"
})
}
const logFilePath = join(logDir, getLogFileName(new Date()))
const logFileContent = await readFile(logFilePath, "utf8")
const latestEntriesByHost = new Map<string, StatusEntry>()
for (const line of logFileContent.split("\n")) {
const entry = parseStatusLine(line)
if (!entry) {
continue
}
})
)
return { results }
latestEntriesByHost.set(entry.host, entry)
}
const configuredHosts = (envValues.APP_URLS || "")
.split(/\s+/)
.map((host) => host.trim())
.filter(Boolean)
const orderedResults = configuredHosts
.map((host) => latestEntriesByHost.get(host))
.filter((entry): entry is StatusEntry => Boolean(entry))
.map(buildStatusResult)
const remainingResults = Array.from(latestEntriesByHost.entries())
.filter(([host]) => !configuredHosts.includes(host))
.map(([, entry]) => buildStatusResult(entry))
const results = [...orderedResults, ...remainingResults]
if (results.length === 0) {
throw createError({
statusCode: 503,
statusMessage: "Aucun statut disponible"
})
}
return {
results
}
} catch (error) {
console.error("Erreur lecture status recette:", error)
if (
typeof error === "object" &&
error !== null &&
"statusCode" in error &&
"statusMessage" in error
) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: "Erreur lors de l'opération"
})
}
})

View File

@@ -1,34 +0,0 @@
[
{
"key": "backup-bdd-recette",
"label": "Backup BDD recette",
"icon": "mdi:database-export",
"downloadFolders": ["ferme", "inventory", "sirh", "user"],
"command": "ssh",
"args": [
"ferme",
"cd /home/malio/Malio-ops/RecetteScripts && bash backup-bdd-recette.sh"
]
},
{
"key": "check-statut-recette",
"label": "Check statut recette",
"icon": "mdi:server-network",
"command": "ssh",
"args": [
"ferme",
"cd /home/malio/Malio-ops/RecetteScripts && bash check-statut-recette.sh"
]
},
{
"key": "backup-vaultwarden",
"label": "Backup vaultwarden",
"icon": "mdi:data",
"downloadFolders": ["bitwarden"],
"command": "ssh",
"args": [
"bitwarden",
"cd /home/matt/vaultwarden/Malio-ops/BackupVaultWarden && bash backup-vaultwarden.sh"
]
}
]

View File

@@ -1,19 +0,0 @@
[
{
"key": "remote",
"label": "Serveur distant",
"command": "ssh",
"args": [
"malio-b",
"cd /home/malio-b/Malio-ops/CheckStorage && bash check-storage.sh"
]
},
{
"key": "local",
"label": "Machine locale",
"command": "bash",
"args": [
"/home/kevin/check_storage.sh"
]
}
]

View File

@@ -1,5 +0,0 @@
[
{ "label": "Ferme", "url": "http://ferme.malio-dev.fr/api/version" },
{ "label": "SIRH", "url": "http://sirh.malio-dev.fr/api/version" },
{ "label": "Inventory", "url": "http://inventory.malio-dev.fr/api/health" }
]

View File

@@ -1,3 +1,9 @@
// SECURITE:
// Ce middleware pose automatiquement le cookie d'authentification pour tout
// visiteur de l'interface web. Ce comportement repose sur l'hypothèse que
// Supervisor n'est exposé qu'à un réseau de confiance ou derrière un contrôle
// d'accès externe. Si l'application devient publiquement accessible, ce
// mécanisme ne constitue pas une authentification utilisateur.
export default defineEventHandler((event) => {
const path = event.path || event.node.req.url || ""
@@ -16,10 +22,12 @@ export default defineEventHandler((event) => {
return
}
const secureCookie = process.env.AUTH_COOKIE_SECURE === "true"
setCookie(event, "api_auth_token", expectedToken, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
secure: secureCookie,
path: "/"
})
})

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

View File

@@ -0,0 +1,45 @@
export type BackupScript = {
key: string
label: string
icon?: string
downloadFolders?: string[]
}
export const backupScripts: BackupScript[] = [
{
key: "backup-bdd-recette",
label: "Backup BDD recette",
icon: "mdi:database-export",
downloadFolders: ["ferme", "inventory", "sirh", "user"]
},
{
key: "check-statut-recette",
label: "Check statut recette",
icon: "mdi:server-network"
},
{
key: "backup-vaultwarden",
label: "Backup vaultwarden",
icon: "mdi:data",
downloadFolders: ["bitwarden"]
}
]
const getDefaultBackupScriptCommands = (): Record<string, string> => {
const recetteScriptsDir = process.env.RECETTE_SCRIPTS_DIR || "/home/malio/Malio-ops/RecetteScripts"
const vaultwardenHost = process.env.VAULTWARDEN_SSH_HOST || "bitwarden"
const vaultwardenScriptsDir =
process.env.VAULTWARDEN_SCRIPTS_DIR || "/home/matt/vaultwarden/Malio-ops/BackupVaultWarden"
return {
"backup-bdd-recette": `cd ${recetteScriptsDir} && bash backup-bdd-recette.sh`,
"check-statut-recette": `cd ${recetteScriptsDir} && bash check-statut-recette.sh`,
"backup-vaultwarden":
`ssh ${vaultwardenHost} "cd ${vaultwardenScriptsDir} && bash backup-vaultwarden.sh"`
}
}
export function getBackupScriptCommand(key: string) {
const envKey = `BACKUP_SCRIPT_COMMAND_${key.toUpperCase().replace(/-/g, "_")}`
return process.env[envKey] || getDefaultBackupScriptCommands()[key] || null
}

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

@@ -0,0 +1,54 @@
import {execFile} from "node:child_process"
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 isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
export const isSafeFile = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
export const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
export function runSsh(command: string): Promise<string> {
if (!REMOTE_HOST) {
return Promise.reject(new Error("BACKUPS_REMOTE_HOST is not configured"))
}
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
}

235
solution.md Normal file
View File

@@ -0,0 +1,235 @@
# Correctifs finaux de deploiement Supervisor
Ce document resume les correctifs finaux identifies pour faire fonctionner `Supervisor` en production sur `recette`.
## 1. Lancement en production
`Supervisor` n'est pas un site statique simple. Le projet contient :
- des routes serveur dans `server/api/*`
- des middlewares dans `server/middleware/*`
- un plugin serveur dans `server/plugins/metrics-worker.ts`
Il faut donc :
```bash
npm run build
node .output/server/index.mjs
```
En production, l'application a ete lancee via `pm2`.
## 2. Configuration Nginx
Le projet doit etre expose en reverse proxy vers le serveur Node sur `127.0.0.1:3000`.
Configuration minimale valide :
```nginx
server {
listen 80;
server_name supervisor.malio-dev.fr;
client_max_body_size 200M;
client_body_timeout 300s;
send_timeout 300s;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### Pourquoi
- sans reverse proxy, les endpoints `/api/*` ne fonctionnent pas
- sans `client_max_body_size`, le speedtest d'upload retourne `413 Request Entity Too Large`
Apres modification :
```bash
nginx -t
systemctl reload nginx
```
## 3. Cookie d'authentification en HTTP
Le projet etait configure pour utiliser un cookie `Secure` en production, ce qui bloquait toutes les routes `/api/*` en HTTP avec des erreurs `401`.
Correctif applique dans `server/middleware/auth-cookie.ts` :
- le flag `secure` du cookie depend maintenant de `AUTH_COOKIE_SECURE`
Valeur a mettre en HTTP :
```env
AUTH_COOKIE_SECURE=false
```
Si un passage en HTTPS est fait plus tard :
```env
AUTH_COOKIE_SECURE=true
```
## 4. Variables d'environnement a utiliser
Exemple de `.env` fonctionnel :
```env
API_SECRET_KEY=...
DISCORD_BOT_TOKEN=...
DISCORD_CHANNEL_ID=...
BACKUPS_REMOTE_HOST=malio-b
BACKUPS_REMOTE_ROOT=/home/malio-b/backups
BACKUPS_MAX_FILES=200
DISK_COMMAND_LOCAL="cd /home/malio/Malio-ops/CheckStorage && bash check-storage.sh"
DISK_COMMAND_REMOTE="ssh malio-b \"cd /home/malio-b/Malio-ops/CheckStorage && bash check-storage.sh\""
BACKUP_SCRIPT_COMMAND_BACKUP_BDD_RECETTE="cd /home/malio/Malio-ops/RecetteScripts && bash backup-bdd-recette.sh"
BACKUP_SCRIPT_COMMAND_CHECK_STATUT_RECETTE="cd /home/malio/Malio-ops/RecetteScripts && bash check-statut-recette.sh"
BACKUP_SCRIPT_COMMAND_BACKUP_VAULTWARDEN="ssh bitwarden \"bash -lc 'cd /home/matt/vaultwarden/Malio-ops/BackupVaultWarden && ./backup-vaultwarden.sh'\""
BACKUPS_HOUR=19
AUTH_COOKIE_SECURE=false
```
### Important
Les commandes qui contiennent des espaces, `&&` ou des guillemets doivent etre entourees correctement dans le `.env`.
Le format suivant a provoque des erreurs lors d'un `source .env` :
```env
DISK_COMMAND_LOCAL=bash -lc '...'
```
Le shell l'interpretait comme une commande, pas comme une simple valeur.
## 5. PM2
Les variables ajoutees dans `.env` n'etaient pas toujours reprises par le process PM2 deja lance.
Sequence fiable :
```bash
cd /var/www/Supervisor
set -a
source .env
set +a
pm2 kill
pm2 start .output/server/index.mjs --name supervisor
pm2 save
```
Verification utile :
```bash
pm2 list
pm2 env 0 | grep DISK_COMMAND
```
## 6. Backups recette
Comme `Supervisor` tourne deja sur `ferme` / `recette`, les scripts de backup recette ne doivent pas repasser par `ssh ferme`.
Correct :
```env
BACKUP_SCRIPT_COMMAND_BACKUP_BDD_RECETTE="cd /home/malio/Malio-ops/RecetteScripts && bash backup-bdd-recette.sh"
BACKUP_SCRIPT_COMMAND_CHECK_STATUT_RECETTE="cd /home/malio/Malio-ops/RecetteScripts && bash check-statut-recette.sh"
```
La connexion SSH reste necessaire uniquement pour `vaultwarden`.
## 7. SSH vers vaultwarden
La commande distante utilisee est :
```env
BACKUP_SCRIPT_COMMAND_BACKUP_VAULTWARDEN="ssh bitwarden \"bash -lc 'cd /home/matt/vaultwarden/Malio-ops/BackupVaultWarden && ./backup-vaultwarden.sh'\""
```
Cela implique :
- une cle SSH disponible pour l'utilisateur qui lance `Supervisor`
- la cle publique autorisee sur `vault.lpc-liot.fr`
- une resolution correcte de l'alias `bitwarden` ou l'utilisation directe de `matt@vault.lpc-liot.fr`
Exemple de test :
```bash
ssh matt@vault.lpc-liot.fr "hostname"
```
## 8. Commandes disque
Les diagrammes de stockage dependent de :
- `DISK_COMMAND_LOCAL`
- `DISK_COMMAND_REMOTE`
Valeurs fonctionnelles :
```env
DISK_COMMAND_LOCAL="cd /home/malio/Malio-ops/CheckStorage && bash check-storage.sh"
DISK_COMMAND_REMOTE="ssh malio-b \"cd /home/malio-b/Malio-ops/CheckStorage && bash check-storage.sh\""
```
Le script local avait aussi un probleme de droits d'execution. Il a fallu le rendre executable.
Exemple :
```bash
chmod +x /home/malio/Malio-ops/CheckStorage/check-storage.sh
```
## 9. Commandes de verification utiles
Verifier le retour de l'API disque :
```bash
curl -s http://127.0.0.1:3000/api/disk -H "Authorization: Bearer <API_SECRET_KEY>"
```
Verifier le backup status :
```bash
curl -s http://127.0.0.1:3000/api/check-backup -H "Authorization: Bearer <API_SECRET_KEY>"
```
Verifier le process PM2 :
```bash
pm2 list
pm2 logs 0 --lines 100
```
Verifier la configuration Nginx chargee :
```bash
nginx -T
grep -R "supervisor.malio-dev.fr" /etc/nginx
```
## 10. Cause des principaux problemes rencontres
- erreurs `401` : cookie d'auth `Secure` alors que le site etait en HTTP
- erreurs `413` : absence de `client_max_body_size` dans le vhost Nginx
- `ssh undefined` : variable `BACKUPS_REMOTE_HOST` non prise en compte dans le process lance
- diagrammes vides : `DISK_COMMAND_LOCAL` et `DISK_COMMAND_REMOTE` absentes ou mal chargees
- commandes `.env` non lues correctement : quoting incorrect pour des commandes shell complexes
- stockage local vide : script local non executable
## 11. Point de securite
Des secrets ont ete affiches pendant le debug :
- `API_SECRET_KEY`
- `DISCORD_BOT_TOKEN`
Ils doivent etre consideres comme compromis et regeneres.

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
}