27 Commits

Author SHA1 Message Date
8886e8b7df fix: resolve production runtime issues 2026-03-17 13:50:13 +01: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
semantic-release-bot
a2f2e8f255 chore(release): 1.3.0 2026-03-13 09:34:20 +00:00
5cfafa88cf Merge pull request 'feat/system-metrics' (#14) from feat/system-metrics into develop
All checks were successful
Release / release (push) Successful in 30s
Reviewed-on: #14
2026-03-13 09:33:53 +00:00
656917c776 Merge branch 'develop' into feat/system-metrics
# Conflicts:
#	pages/index.vue
2026-03-13 10:24:14 +01:00
90fd395a26 Merge pull request 'fix : securite regex et message erreur et endpoint' (#13) from feat/add-module-lint into develop
All checks were successful
Release / release (push) Successful in 30s
Reviewed-on: #13
Reviewed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-03-13 09:19:02 +00:00
35cfcb1bcf fix : merge develop 2026-03-13 10:18:10 +01:00
92ed9b040f Merge branch 'develop' into feat/add-module-lint
# Conflicts:
#	components/BackupRun.vue
#	composables/useApiAuth.ts
#	pages/index.vue
#	server/api/disk.get.ts
2026-03-13 10:03:18 +01:00
e52fbaf799 Merge pull request 'fix/correctif-sec' (#12) from fix/correctif-sec into develop
All checks were successful
Release / release (push) Successful in 27s
Reviewed-on: #12
Reviewed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-03-13 08:48:26 +00:00
00dc2daa3d fix : correctif mr 2026-03-13 09:47:09 +01:00
7643600196 fix : env 2026-03-12 09:42:28 +01:00
b6375b4242 fix : clean package json et exemple env 2026-03-12 09:28:06 +01:00
9393abc8df fix : securite regex et message erreur et endpoint 2026-03-12 09:20:07 +01:00
b3fc6f77b1 fix : securite regex et message erreur et endpoint 2026-03-12 08:58:58 +01:00
47bc8ba966 fix : securite middle et execfile 2026-03-12 08:37:53 +01:00
31e101abbd feat: add system metrics dashboard 2026-03-10 15:54:45 +01:00
39 changed files with 5041 additions and 1172 deletions

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# 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 et DISK_COMMAND_LOCAL pour les commandes de vérification de l'espace disque
DISK_COMMAND_REMOTE=
DISK_COMMAND_LOCAL=
# BACKUP_SCRIPT_COMMAND_BACKUP_BDD_RECETTE, BACKUP_SCRIPT_COMMAND_CHECK_STATUT_RECETTE et BACKUP_SCRIPT_COMMAND_BACKUP_VAULTWARDEN pour les commandes de backup et de vérification des statuts
BACKUP_SCRIPT_COMMAND_BACKUP_BDD_RECETTE=
BACKUP_SCRIPT_COMMAND_CHECK_STATUT_RECETTE=
BACKUP_SCRIPT_COMMAND_BACKUP_VAULTWARDEN=
# A quelle heure les backups doivent être effectués (format 24h)
BACKUPS_HOUR=19

View File

@@ -1,3 +1,20 @@
## [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)
### Features
* add system metrics dashboard ([31e101a](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/31e101abbd3ed82c22770d840a4f7fd20de1c936))
## [1.2.4](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.2.3...v1.2.4) (2026-03-10) ## [1.2.4](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.2.3...v1.2.4) (2026-03-10)

146
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 ## Installation du projet
@@ -15,79 +25,105 @@ https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux
### Installation du projet ### Installation du projet
Une fois les prérequis installés, cloner le dépôt puis installer les dépendances. 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 ```bash
npm install npm install
``` ```
Lancer ensuite le serveur de développement. ### 5. Lancer le serveur de développement
```bash ```bash
npm run dev npm run dev
``` ```
Lapplication sera accessible sur : Par défaut, l'application Nuxt sera accessible sûr <http://localhost:3000>.
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 ### Authentification API
nvm use 20
## Utilisation du projet Le middleware `server/middleware/auth.ts` protege toutes les routes `/api/*`, sauf `/api/ping`.
### Frontend
Lancer le serveur de développement. Consequence visible :
```
npm run dev - 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
Compilation pour la production.
``` ### SSH pour les backups
npm run build
``` 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.
Prévisualisation du build de production.
``` Elements a preparer cote SSH :
npm run preview
``` - 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 ## Commandes utiles
Installation des dépendances. Commandes déclarées dans `package.json` :
```
npm install ```bash
```
Lancer le serveur de développement.
```
npm run dev npm run dev
```
Build de production.
```
npm run build npm run build
``` npm run generate
Prévisualisation du build.
```
npm run preview 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 : Usage :
```
node .output/server/index.mjs
```
Il est recommandé dutiliser un reverse proxy comme Nginx en production.
### Notes - `npm run dev` : lance l'application en développement
- `npm run build` : construit l'application pour la production
Les accès SSH ou les chemins système utilisés par les endpoints doivent rester côté serveur. - `npm run generate` : généré une sortie statique si ce mode est compatible avec votre usage
Ne jamais exposer de credentials dans le frontend. - `npm run preview` : prévisualisé le build Nuxt
Les variables sensibles doivent être stockées dans un fichier .env. - `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

@@ -34,12 +34,35 @@
min-height: 100vh; min-height: 100vh;
font-family: var(--font-display); font-family: var(--font-display);
background: rgb(var(--m-bg)); 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)); color: rgb(var(--m-text));
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
transition: background-color 0.4s ease, color 0.4s ease; 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 { img {
display: block; display: block;
} }
@@ -75,6 +98,13 @@
transition: box-shadow 0.3s ease; 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 { .card-glow-success {
box-shadow: box-shadow:
0 0 0 1px rgb(var(--m-success) / 0.15), 0 0 0 1px rgb(var(--m-success) / 0.15),
@@ -165,3 +195,14 @@
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgb(var(--m-muted)); 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

@@ -22,6 +22,13 @@
</div> </div>
</div> </div>
<div v-else-if="errorMessage" class="empty-state error-state">
<IconifyIcon icon="mdi:alert-circle-outline" class="text-3xl text-m-error/70" />
<p class="mt-2 font-mono text-xs text-m-error/80">
{{ errorMessage }}
</p>
</div>
<div v-else-if="backups.length === 0" class="empty-state"> <div v-else-if="backups.length === 0" class="empty-state">
<IconifyIcon icon="mdi:file-hidden" class="text-3xl text-m-muted/40" /> <IconifyIcon icon="mdi:file-hidden" class="text-3xl text-m-muted/40" />
<p class="mt-2 font-mono text-xs text-m-muted/50"> <p class="mt-2 font-mono text-xs text-m-muted/50">
@@ -55,6 +62,7 @@
import {Icon as IconifyIcon} from "@iconify/vue" import {Icon as IconifyIcon} from "@iconify/vue"
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue" import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue" import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
import { apiFetch, downloadApiFile } from "~/composables/useApiAuth"
const props = defineProps<{ const props = defineProps<{
folder: string | null folder: string | null
@@ -62,31 +70,42 @@ const props = defineProps<{
const backups = ref<string[]>([]) const backups = ref<string[]>([])
const loading = ref(false) const loading = ref(false)
const errorMessage = ref("")
const title = computed(() => { const title = computed(() => {
if (!props.folder) return "Fichiers" if (!props.folder) return "Fichiers"
return `Backup — ${props.folder.toUpperCase()}` return `Backup — ${props.folder.toUpperCase()}`
}) })
const downloadBackup = (file: string) => { const downloadBackup = async (file: string) => {
if (!props.folder) return if (!props.folder) return
const url = `/api/download?folder=${encodeURIComponent(props.folder)}&file=${encodeURIComponent(file)}` const url = `/api/download?folder=${encodeURIComponent(props.folder)}&file=${encodeURIComponent(file)}`
window.location.href = url errorMessage.value = ""
try {
await downloadApiFile(url, file)
} catch (error) {
console.error("Erreur telechargement backup:", error)
errorMessage.value = "Erreur lors de l'opération"
}
} }
watch(() => props.folder, async (folder) => { watch(() => props.folder, async (folder) => {
if (!folder) { if (!folder) {
loading.value = false loading.value = false
backups.value = [] backups.value = []
errorMessage.value = ""
return return
} }
loading.value = true loading.value = true
errorMessage.value = ""
try { try {
const data = await $fetch<string[]>(`/api/backups?folder=${encodeURIComponent(folder)}`) const data = await apiFetch<string[]>(`/api/backups?folder=${encodeURIComponent(folder)}`)
backups.value = data backups.value = data
} catch (error) { } catch (error) {
console.error("Erreur récupération backups:", error) console.error("Erreur récupération backups:", error)
backups.value = [] backups.value = []
errorMessage.value = "Erreur lors de l'opération"
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -120,6 +139,12 @@ watch(() => props.folder, async (folder) => {
padding: 2.5rem 1rem; padding: 2.5rem 1rem;
} }
.error-state {
border-radius: 8px;
border: 1px solid rgb(var(--m-error) / 0.12);
background: rgb(var(--m-error) / 0.06);
}
.file-list { .file-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -79,6 +79,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue" import { computed, onMounted, ref } from "vue"
import { Icon as IconifyIcon } from "@iconify/vue" import { Icon as IconifyIcon } from "@iconify/vue"
import { apiFetch } from "~/composables/useApiAuth"
type BackupScript = { type BackupScript = {
key: string key: string
@@ -134,12 +135,12 @@ const loadScripts = async () => {
downloadFolders: [] downloadFolders: []
}) })
try { try {
const data = await $fetch<BackupScriptListResponse>("/api/backup-script") const data = await apiFetch<BackupScriptListResponse>("/api/backup-script")
scripts.value = data.scripts scripts.value = data.scripts
} catch (error) { } catch {
scripts.value = [] scripts.value = []
isError.value = true isError.value = true
message.value = `Erreur chargement scripts: ${error instanceof Error ? error.message : String(error)}` message.value = "Erreur lors de l'opération"
emit("result", { emit("result", {
key: null, key: null,
label: "", label: "",
@@ -160,7 +161,7 @@ const runScript = async (key: string) => {
isError.value = false isError.value = false
try { try {
const data = await $fetch<BackupScriptRunResponse>("/api/backup-script", { const data = await apiFetch<BackupScriptRunResponse>("/api/backup-script", {
method: "POST", method: "POST",
body: { key } body: { key }
}) })
@@ -173,9 +174,20 @@ const runScript = async (key: string) => {
isError: false, isError: false,
downloadFolders: data.downloadFolders || [] downloadFolders: data.downloadFolders || []
}) })
} catch (error: any) { } catch (error: unknown) {
isError.value = true isError.value = true
message.value = error?.data?.statusMessage || "Erreur execution script" const statusMessage =
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
: null
message.value = statusMessage || "Erreur execution script"
output.value = "" output.value = ""
emit("result", { emit("result", {
key, key,

View File

@@ -1,6 +1,11 @@
<script setup> <script setup>
import {Icon as IconifyIcon} from "@iconify/vue" import {Icon as IconifyIcon} from "@iconify/vue"
const { data: messages } = await useFetch('/api/discord/messages') import { apiFetch } from "~/composables/useApiAuth"
const { data: messages, error } = await useFetch('/api/discord/messages', {
$fetch: apiFetch,
server: false
})
</script> </script>
<template> <template>
@@ -13,7 +18,14 @@ const { data: messages } = await useFetch('/api/discord/messages')
<span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Messages</span> <span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Messages</span>
</div> </div>
<div v-if="!messages || messages.length === 0" class="empty-state"> <div v-if="error" class="empty-state error-state">
<IconifyIcon icon="mdi:alert-circle-outline" class="text-3xl text-m-error/70" />
<p class="mt-2 font-mono text-xs text-m-error/80">
Erreur lors de l'opération
</p>
</div>
<div v-else-if="!messages || messages.length === 0" class="empty-state">
<IconifyIcon icon="mdi:chat-outline" class="text-3xl text-m-muted/40" /> <IconifyIcon icon="mdi:chat-outline" class="text-3xl text-m-muted/40" />
<p class="mt-2 font-mono text-xs text-m-muted/50"> <p class="mt-2 font-mono text-xs text-m-muted/50">
Aucun message Aucun message
@@ -44,12 +56,15 @@ const { data: messages } = await useFetch('/api/discord/messages')
<style scoped> <style scoped>
.discord-card { .discord-card {
background: rgb(var(--m-secondary)); background:
border-radius: 12px; linear-gradient(180deg, rgb(var(--m-secondary) / 0.78), rgb(var(--m-secondary) / 0.92));
border-radius: 20px;
padding: 1.25rem; 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); max-height: calc(100vh - 7rem);
overflow: hidden; overflow: hidden;
transition: background-color 0.4s ease; transition: background-color 0.4s ease, border-color 0.2s ease;
} }
.card-header { .card-header {
@@ -71,13 +86,23 @@ const { data: messages } = await useFetch('/api/discord/messages')
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 220px;
padding: 2rem 1rem; padding: 2rem 1rem;
border-radius: 14px;
background: rgb(var(--m-tertiary) / 0.28);
text-align: center;
}
.error-state {
border-radius: 8px;
border: 1px solid rgb(var(--m-error) / 0.12);
background: rgb(var(--m-error) / 0.06);
} }
.message-list { .message-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.65rem;
max-height: calc(100vh - 12rem); max-height: calc(100vh - 12rem);
overflow-y: auto; overflow-y: auto;
} }
@@ -85,10 +110,10 @@ const { data: messages } = await useFetch('/api/discord/messages')
.message-row { .message-row {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem; padding: 0.85rem;
border-radius: 8px; border-radius: 14px;
background: rgb(var(--m-tertiary)); background: rgb(var(--m-tertiary) / 0.74);
border: 1px solid rgb(var(--m-accent) / 0.04); border: 1px solid rgb(var(--m-border) / 0.22);
} }
.message-avatar { .message-avatar {
@@ -105,4 +130,20 @@ const { data: messages } = await useFetch('/api/discord/messages')
color: rgb(var(--m-accent)); color: rgb(var(--m-accent));
flex-shrink: 0; 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> </style>

View File

@@ -4,8 +4,8 @@
<h2 class="card-title">Speedtest</h2> <h2 class="card-title">Speedtest</h2>
<button <button
class="reload-btn" class="reload-btn"
@click="runTests"
:disabled="isTesting" :disabled="isTesting"
@click="runTests"
> >
<IconifyIcon <IconifyIcon
icon="mdi:reload" icon="mdi:reload"
@@ -36,17 +36,23 @@
</div> </div>
</div> </div>
</div> </div>
<p v-if="errorMessage" class="error-text" role="status" aria-live="polite">
{{ errorMessage }}
</p>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref} from "vue" import {computed, ref} from "vue"
import {Icon as IconifyIcon} from "@iconify/vue" import {Icon as IconifyIcon} from "@iconify/vue"
import { apiRequest } from "~/composables/useApiAuth"
const ping = ref<number | null>(null) const ping = ref<number | null>(null)
const download = ref<number | null>(null) const download = ref<number | null>(null)
const upload = ref<number | null>(null) const upload = ref<number | null>(null)
const isTesting = ref(false) const isTesting = ref(false)
const errorMessage = ref("")
const metrics = computed(() => [ const metrics = computed(() => [
{ label: "Download", icon: "mdi:arrow-down-bold", value: download.value, unit: "Mbps" }, { label: "Download", icon: "mdi:arrow-down-bold", value: download.value, unit: "Mbps" },
@@ -56,7 +62,10 @@ const metrics = computed(() => [
async function testDownload() { async function testDownload() {
const start = performance.now() const start = performance.now()
const res = await fetch('/api/download') const res = await apiRequest('/api/speedtest')
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const blob = await res.blob() const blob = await res.blob()
const end = performance.now() const end = performance.now()
const size = blob.size const size = blob.size
@@ -68,7 +77,10 @@ async function testUpload() {
const size = 5 * 1024 * 1024 const size = 5 * 1024 * 1024
const data = new Uint8Array(size) const data = new Uint8Array(size)
const start = performance.now() const start = performance.now()
await fetch('/api/upload', { method: 'POST', body: data }) const response = await apiRequest('/api/upload', { method: 'POST', body: data })
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const end = performance.now() const end = performance.now()
const seconds = (end - start) / 1000 const seconds = (end - start) / 1000
upload.value = Math.round((size * 8) / seconds / 1000000) upload.value = Math.round((size * 8) / seconds / 1000000)
@@ -76,7 +88,10 @@ async function testUpload() {
async function testPing() { async function testPing() {
const start = performance.now() const start = performance.now()
await fetch('/api/ping') const response = await fetch('/api/ping')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const end = performance.now() const end = performance.now()
ping.value = Math.round(end - start) ping.value = Math.round(end - start)
} }
@@ -86,11 +101,15 @@ async function runTests() {
download.value = null download.value = null
upload.value = null upload.value = null
ping.value = null ping.value = null
errorMessage.value = ""
try { try {
await testDownload() await testDownload()
await testUpload() await testUpload()
await testPing() await testPing()
} catch (error) {
console.error("Erreur speedtest:", error)
errorMessage.value = "Erreur lors de l'opération"
} finally { } finally {
isTesting.value = false isTesting.value = false
} }
@@ -99,10 +118,13 @@ async function runTests() {
<style scoped> <style scoped>
.speedtest-card { .speedtest-card {
background: rgb(var(--m-secondary)); background:
border-radius: 12px; linear-gradient(180deg, rgb(var(--m-secondary) / 0.78), rgb(var(--m-secondary) / 0.92));
border-radius: 20px;
padding: 1.25rem; 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 { .card-header {
@@ -133,9 +155,15 @@ async function runTests() {
transition: all 0.2s ease; 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) { .reload-btn:hover:not(:disabled) {
background: rgb(var(--m-accent) / 0.12); background: rgb(var(--m-accent) / 0.12);
border-color: rgb(var(--m-accent) / 0.25); border-color: rgb(var(--m-accent) / 0.25);
transform: translateY(-1px);
} }
.reload-btn:disabled { .reload-btn:disabled {
@@ -150,10 +178,10 @@ async function runTests() {
} }
.metric-card { .metric-card {
background: rgb(var(--m-tertiary)); background: rgb(var(--m-tertiary) / 0.72);
border-radius: 10px; border-radius: 14px;
padding: 1rem; 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; transition: border-color 0.2s ease;
} }
@@ -189,4 +217,25 @@ async function runTests() {
letter-spacing: 0.1em; letter-spacing: 0.1em;
color: rgb(var(--m-muted)); color: rgb(var(--m-muted));
} }
.error-text {
margin-top: 0.75rem;
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> </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> <template>
<div class="status-card card-glow"> <div class="status-card card-glow">
<div class="card-header"> <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> <span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Services</span>
</div> </div>
@@ -20,8 +20,8 @@
</template> </template>
<div <div
v-else
v-for="row in rows" v-for="row in rows"
v-else
:key="`${row.label}-${row.url}`" :key="`${row.label}-${row.url}`"
class="status-row" class="status-row"
:class="row.status === 200 ? 'row-ok' : 'row-error'" :class="row.status === 200 ? 'row-ok' : 'row-error'"
@@ -43,6 +43,7 @@
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue" import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue" import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
import {onBeforeUnmount, onMounted, ref} from "vue" import {onBeforeUnmount, onMounted, ref} from "vue"
import { apiFetch } from "~/composables/useApiAuth"
interface StatusRow { interface StatusRow {
label: string label: string
@@ -84,7 +85,7 @@ const checkStatus = async () => {
loading.value = true loading.value = true
} }
try { try {
const data = await $fetch<StatusResponse>(props.endpoint) const data = await apiFetch<StatusResponse>(props.endpoint)
rows.value = data.results rows.value = data.results
} catch (error) { } catch (error) {
rows.value = [ rows.value = [
@@ -118,13 +119,16 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
.status-card { .status-card {
background: rgb(var(--m-secondary)); background:
border-radius: 12px; linear-gradient(180deg, rgb(var(--m-secondary) / 0.78), rgb(var(--m-secondary) / 0.92));
border-radius: 20px;
padding: 1.25rem; 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; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.625rem; gap: 0.75rem;
transition: background-color 0.4s ease; transition: background-color 0.4s ease, border-color 0.2s ease;
} }
.card-header { .card-header {
@@ -145,10 +149,11 @@ onBeforeUnmount(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0.75rem 1rem; min-height: 3.2rem;
border-radius: 8px; padding: 0.85rem 1rem;
background: rgb(var(--m-tertiary)); border-radius: 14px;
border: 1px solid transparent; background: rgb(var(--m-tertiary) / 0.75);
border: 1px solid rgb(var(--m-border) / 0.2);
transition: all 0.2s ease; transition: all 0.2s ease;
} }

View File

@@ -0,0 +1,652 @@
<template>
<section class="chart-card card-glow">
<div class="card-header">
<div>
<h2 class="card-title">Historique systeme</h2>
<p class="card-copy">CPU et RAM avec cache journalier local</p>
</div>
<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>
</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 && visibleHistory.length === 0">
<div class="chart-skeleton animate-shimmer" />
</template>
<template v-else>
<div class="chart-meta">
<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
class="chart-svg"
viewBox="0 0 960 320"
preserveAspectRatio="none"
aria-label="Graphique des ressources"
>
<line
:x1="chartLeft"
:y1="chartBottom"
:x2="chartRight"
:y2="chartBottom"
class="axis-line"
/>
<line
:x1="chartLeft"
:y1="chartTop"
:x2="chartLeft"
:y2="chartBottom"
class="axis-line"
/>
<line
v-for="line in yAxisTicks"
:key="`grid-${line.y}`"
:x1="chartLeft"
:y1="line.y"
:x2="chartRight"
:y2="line.y"
class="grid-line"
/>
<text
v-for="line in yAxisTicks"
:key="`y-label-${line.y}`"
:x="chartLeft - 12"
:y="line.y + 4"
class="axis-label axis-label-y"
>
{{ line.label }}
</text>
<line
v-for="tick in xAxisTicks"
:key="`x-grid-${tick.x}`"
:x1="tick.x"
:y1="chartTop"
:x2="tick.x"
:y2="chartBottom"
class="grid-line grid-line-vertical"
/>
<text
v-for="tick in xAxisTicks"
:key="`x-label-${tick.x}`"
:x="tick.x"
:y="304"
class="axis-label axis-label-x"
>
{{ tick.label }}
</text>
<polyline
v-for="option in displayedOptions"
:key="option.value"
:points="polylinePoints(option.value)"
class="chart-line"
:style="{ stroke: option.color }"
/>
</svg>
</template>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue"
import type { SystemMetrics } from "~/types/system"
type MetricKey = "cpu" | "ram"
type WindowKey = "day" | "hour" | "5m" | "1m" | "30s"
type HistoryPoint = {
sampledAt: number
cpu: number
ram: number
}
const HISTORY_STORAGE_KEY = "supervisor-system-history"
const props = defineProps<{
metrics: SystemMetrics | null
loading: boolean
}>()
const activeMetrics = ref<MetricKey[]>(["cpu", "ram"])
const selectedWindow = ref<WindowKey>("hour")
const history = ref<HistoryPoint[]>([])
const options: Array<{ value: MetricKey; label: string; color: string }> = [
{ value: "cpu", label: "CPU", color: "#5aa9ff" },
{ value: "ram", label: "RAM", color: "#31c48d" }
]
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,
() => {
if (!props.metrics) {
return
}
appendHistoryPoint(props.metrics)
},
{ immediate: true }
)
const activeWindow = computed(() => {
return windowOptions.find((option) => option.value === selectedWindow.value) || windowOptions[0]
})
const displayedOptions = computed(() => {
return options.filter((option) => activeMetrics.value.includes(option.value))
})
const visibleHistory = computed(() => {
if (activeWindow.value.durationMs === null) {
return history.value
}
const minTimestamp = Date.now() - activeWindow.value.durationMs
return history.value.filter((point) => point.sampledAt >= minTimestamp)
})
const scaleMax = computed(() => 100)
const formatValue = (value: number) => `${Math.round(value)}%`
const scaleLabel = computed(() => {
return formatValue(scaleMax.value)
})
const activeWindowLabel = computed(() => activeWindow.value.label)
const isMetricActive = (metric: MetricKey) => activeMetrics.value.includes(metric)
const toggleMetric = (metric: MetricKey) => {
if (isMetricActive(metric)) {
activeMetrics.value = activeMetrics.value.filter((value) => value !== metric)
return
}
activeMetrics.value = [...activeMetrics.value, metric]
}
const currentMetricValue = (metric: MetricKey) => {
return visibleHistory.value.at(-1)?.[metric] ?? 0
}
const peakMetricValue = (metric: MetricKey) => {
return visibleHistory.value.reduce((max, point) => Math.max(max, point[metric]), 0)
}
const chartLeft = 72
const 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 []
}
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 = chartLeft + (index / (points.length - 1)) * chartWidth
const normalizedValue = scaleMax.value > 0 ? value / scaleMax.value : 0
const y = chartBottom - normalizedValue * chartHeight
return `${x},${Math.max(chartTop, Math.min(chartBottom, y))}`
})
.join(" ")
}
</script>
<style scoped>
.chart-card {
margin-top: 1.5rem;
background: rgb(var(--m-secondary));
border-radius: 12px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.card-title {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.card-copy {
margin-top: 0.25rem;
font-family: var(--font-mono);
font-size: 0.75rem;
color: rgb(var(--m-muted));
text-transform: uppercase;
letter-spacing: 0.12em;
}
.controls {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.75rem;
}
.toggle-group {
display: flex;
align-items: center;
gap: 0.625rem;
flex-wrap: wrap;
}
.toggle-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: 999px;
border: 1px solid rgb(var(--m-accent) / 0.1);
background: rgb(var(--m-tertiary));
padding: 0.55rem 0.8rem;
font-family: var(--font-mono);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgb(var(--m-muted));
cursor: pointer;
appearance: none;
transition: border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.toggle-pill-active {
border-color: rgb(var(--m-accent) / 0.28);
color: rgb(var(--m-text));
transform: translateY(-1px);
}
.toggle-dot {
width: 10px;
height: 10px;
border-radius: 999px;
flex-shrink: 0;
}
.history-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.window-select {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgb(var(--m-muted));
}
.window-select select {
border-radius: 999px;
border: 1px solid rgb(var(--m-accent) / 0.14);
background: rgb(var(--m-tertiary));
padding: 0.55rem 0.8rem;
font: inherit;
color: rgb(var(--m-text));
}
.clear-btn {
border-radius: 999px;
border: 1px solid rgb(var(--m-error) / 0.18);
background: rgb(var(--m-error) / 0.08);
padding: 0.55rem 0.85rem;
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgb(var(--m-error));
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
}
.clear-btn:hover {
border-color: rgb(var(--m-error) / 0.32);
background: rgb(var(--m-error) / 0.14);
}
.chart-shell {
border-radius: 12px;
padding: 1rem;
background:
linear-gradient(180deg, rgb(var(--m-tertiary)) 0%, rgb(var(--m-secondary)) 100%);
border: 1px solid rgb(var(--m-accent) / 0.08);
}
.chart-meta {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.meta-metric {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.meta-label {
display: block;
margin-bottom: 0.25rem;
font-family: var(--font-mono);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: rgb(var(--m-muted));
}
.meta-value {
font-family: var(--font-display);
font-size: 1.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;
stroke-linecap: round;
stroke-linejoin: round;
}
.chart-skeleton {
width: 100%;
height: 320px;
border-radius: 12px;
}
@media (max-width: 820px) {
.controls {
width: 100%;
align-items: stretch;
}
.history-toolbar {
justify-content: space-between;
}
.chart-meta {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,207 @@
<template>
<div class="resources-card card-glow">
<div class="card-header">
<h2 class="card-title">Ressources</h2>
<span class="font-mono text-[10px] uppercase tracking-widest text-m-muted">CPU / RAM</span>
</div>
<div class="metrics-list">
<div
v-for="metric in metrics"
:key="metric.label"
class="metric-row"
>
<div class="metric-copy">
<div class="metric-head">
<span class="font-display text-sm font-semibold text-m-text">{{ metric.label }}</span>
<span class="font-mono text-xs text-m-muted">{{ metric.detail }}</span>
</div>
<template v-if="isLoading">
<div class="metric-skeleton animate-shimmer" />
</template>
<template v-else>
<div class="metric-bar">
<div
class="metric-bar-fill"
:class="metric.toneClass"
:style="{ width: `${metric.percent}%` }"
/>
</div>
</template>
</div>
<div class="metric-value-area">
<template v-if="isLoading">
<div class="value-skeleton animate-shimmer" />
</template>
<template v-else>
<span class="metric-value font-mono">{{ metric.percent }}%</span>
</template>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {computed} from "vue"
import type { SystemMetrics } from "~/types/system";
const props = defineProps<{
metrics: SystemMetrics | null
loading: boolean
}>()
const formatMemory = (value: number) => {
if (!Number.isFinite(value) || value <= 0) {
return "0 GB"
}
return `${(value / 1024 / 1024 / 1024).toFixed(1)} GB`
}
const toneClass = (percent: number) => {
if (percent >= 85) return "tone-error"
if (percent >= 65) return "tone-warning"
return "tone-success"
}
const isLoading = computed(() => props.loading || !props.metrics)
const metrics = computed(() => [
{
label: "CPU",
percent: props.metrics?.cpuPercent ?? 0,
detail: "Charge instantanee",
toneClass: toneClass(props.metrics?.cpuPercent ?? 0)
},
{
label: "RAM",
percent: props.metrics?.memoryPercent ?? 0,
detail: `${formatMemory(props.metrics?.usedMemory ?? 0)} / ${formatMemory(props.metrics?.totalMemory ?? 0)}`,
toneClass: toneClass(props.metrics?.memoryPercent ?? 0)
}
])
</script>
<style scoped>
.resources-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: 1rem;
transition: background-color 0.4s ease, border-color 0.2s ease;
}
.card-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.card-title {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.metrics-list {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.metric-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
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 {
min-width: 0;
}
.metric-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.625rem;
}
.metric-bar {
height: 10px;
border-radius: 999px;
overflow: hidden;
background: rgb(var(--m-bg) / 0.45);
}
.metric-bar-fill {
height: 100%;
border-radius: inherit;
transition: width 0.35s ease, background-color 0.35s ease;
}
.metric-value-area {
min-width: 54px;
display: flex;
justify-content: flex-end;
}
.metric-value {
font-size: 1.25rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.metric-skeleton {
height: 10px;
width: 100%;
border-radius: 999px;
}
.value-skeleton {
width: 48px;
height: 28px;
border-radius: 8px;
}
.tone-success {
background: rgb(var(--m-success));
}
.tone-warning {
background: rgb(var(--m-warning));
}
.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>

90
composables/useApiAuth.ts Normal file
View File

@@ -0,0 +1,90 @@
function toHeadersObject(headers?: HeadersInit): Record<string, string> {
if (!headers) {
return {}
}
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries())
}
if (Array.isArray(headers)) {
return Object.fromEntries(headers)
}
return { ...headers }
}
function getDownloadFileName(contentDisposition: string | null, fallback: string) {
if (!contentDisposition) {
return fallback
}
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match?.[1]) {
return decodeURIComponent(utf8Match[1])
}
const asciiMatch = contentDisposition.match(/filename="([^"]+)"/i)
if (asciiMatch?.[1]) {
return asciiMatch[1]
}
return fallback
}
export function useApiAuthHeader() {
const runtimeConfig = useRuntimeConfig()
const token = runtimeConfig.public.apiSecretKey
if (!token) {
return {}
}
// Tous les appels frontend vers /api/* reutilisent ce header commun.
return {
Authorization: `Bearer ${token}`
}
}
export const apiFetch = $fetch.create({})
export function apiRequest(input: RequestInfo | URL, init: RequestInit = {}) {
return fetch(input, withApiAuth(init))
}
export async function downloadApiFile(url: string, fileNameFallback: string) {
// Les telechargements passent aussi par fetch pour pouvoir recuperer
// le contenu et le nom de fichier renvoye par l'API.
const response = await apiRequest(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
const fileName = getDownloadFileName(
response.headers.get("content-disposition"),
fileNameFallback
)
const link = document.createElement("a")
link.href = objectUrl
link.download = fileName
link.style.display = "none"
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(objectUrl)
}
export function withApiAuth(init: RequestInit = {}) {
// Fusionne le header d'auth avec d'eventuels headers deja fournis.
return {
...init,
headers: {
...useApiAuthHeader(),
...toHeadersObject(init.headers)
}
}
}

3
eslint.config.mjs Normal file
View File

@@ -0,0 +1,3 @@
import createConfigForNuxt from "@nuxt/eslint-config"
export default createConfigForNuxt()

View File

@@ -7,7 +7,7 @@
:src="logoSrc" :src="logoSrc"
alt="Logo Malio" alt="Logo Malio"
class="logo" class="logo"
/> >
</div> </div>
<div class="brand-copy"> <div class="brand-copy">
<p class="brand-title">Supervisor</p> <p class="brand-title">Supervisor</p>
@@ -18,34 +18,43 @@
<slot name="sidebar"/> <slot name="sidebar"/>
<nav class="sidebar-nav" aria-label="Sections"> <nav class="sidebar-nav" aria-label="Sections">
<p class="nav-label">Navigation</p> <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 <NuxtLink
to="/" v-for="item in navItems"
class="flex items-center gap-3 px-4 py-2 rounded-lg text-white hover:bg-m-tertiary/80 transition-colors" :key="`desktop-${item.to}`"
v-slot="{ href, navigate, isExactActive }"
:to="item.to"
custom
> >
<IconifyIcon <a
icon="mdi:home" :href="href"
class="text-lg"/> class="nav-link"
<p>Home</p> :class="{ 'nav-link-active': isExactActive }"
</NuxtLink> :aria-current="isExactActive ? 'page' : undefined"
</div> @click="navigate"
<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 <span class="nav-link-main">
icon="mdi:data" <span class="nav-icon">
class="text-lg"/> <IconifyIcon :icon="item.icon" class="text-lg"/>
<p>Backup</p> </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> </NuxtLink>
</div>
</div>
</nav> </nav>
</div> </div>
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="sidebar-divider"/> <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"> <div class="footer-row">
<p class="font-mono text-[10px] tracking-widest uppercase text-white/40"> <p class="font-mono text-[10px] tracking-widest uppercase text-white/40">
Supervisor {{ appVersion }} Supervisor {{ appVersion }}
@@ -67,7 +76,7 @@
:src="logoSrc" :src="logoSrc"
alt="Logo Malio" alt="Logo Malio"
class="logo" class="logo"
/> >
</div> </div>
<div class="brand-copy"> <div class="brand-copy">
<p class="brand-kicker">Control Center</p> <p class="brand-kicker">Control Center</p>
@@ -224,6 +233,10 @@ const navItems = [
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.sidebar .brand-title {
margin-top: 0;
}
.brand-description { .brand-description {
margin: 0.55rem 0 0; margin: 0.55rem 0 0;
color: rgb(255 255 255 / 0.58); color: rgb(255 255 255 / 0.58);
@@ -245,7 +258,7 @@ const navItems = [
.sidebar-content { .sidebar-content {
flex: 1; flex: 1;
padding: 0.5rem 1rem 1rem; padding: 0.75rem 1rem 1rem;
} }
.sidebar-footer { .sidebar-footer {
@@ -274,7 +287,7 @@ const navItems = [
.sidebar-nav { .sidebar-nav {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.625rem;
} }
.nav-label { .nav-label {
@@ -321,6 +334,16 @@ const navItems = [
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04); 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 { .nav-link-main {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -403,6 +426,9 @@ const navItems = [
.content { .content {
background: rgb(var(--m-bg)); 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; overflow-y: auto;
min-height: 100vh; min-height: 100vh;
transition: background-color 0.4s ease; transition: background-color 0.4s ease;

View File

@@ -26,7 +26,7 @@ export default defineNuxtConfig({
head: { head: {
link: [ link: [
{ rel: "preconnect", href: "https://fonts.googleapis.com" }, { 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", 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" 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"
@@ -35,8 +35,10 @@ export default defineNuxtConfig({
} }
}, },
runtimeConfig: { runtimeConfig: {
apiSecretKey: process.env.API_SECRET_KEY,
public: { public: {
appVersion: getRepoVersion() appVersion: getRepoVersion(),
apiKey: process.env.API_SECRET_KEY
} }
}, },
vite: { vite: {

3134
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,14 +7,14 @@
"dev": "nuxt dev", "dev": "nuxt dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
}, },
"dependencies": { "dependencies": {
"@iconify/vue": "^5.0.0", "@iconify/vue": "^5.0.0",
"iconify": "^1.4.0", "@nuxt/eslint": "^1.15.2",
"nuxt": "^4.3.1", "nuxt": "^4.3.1"
"vue": "^3.5.29",
"vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@semantic-release/changelog": "^6.0.3", "@semantic-release/changelog": "^6.0.3",

View File

@@ -13,10 +13,19 @@
</p> </p>
</div> </div>
</header> </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 <BackupButtonSee
class="animate-fade-in-up backup-selector" class="animate-fade-in-up"
style="animation-delay: 120ms" style="animation-delay: 120ms"
@select="selectedBackup = $event" @select="selectedBackup = $event"
/> />
@@ -27,24 +36,27 @@
/> />
</section> </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 animate-fade-in-up" style="animation-delay: 240ms">
<div class="files-panel-header"> <div class="files-panel-header">
<div> <div class="files-panel-copy">
<p class="section-kicker">Fichiers</p> <p class="section-kicker">Fichiers</p>
<h2 id="backup-files-title" class="files-panel-title"> <h2 id="backup-files-title" class="files-panel-title">
Historique des sauvegardes Historique des sauvegardes
</h2> </h2>
</div> <p class="files-panel-description">
<p class="files-panel-meta"> Consultez les archives disponibles et telechargez le dernier backup du dossier selectionne.
{{ selectedBackup ? `Source ${selectedBackup}` : "En attente de selection" }}
</p> </p>
</div> </div>
<span
class="selection-pill"
:class="{ 'selection-pill-active': selectedBackup }"
>
{{ selectedBackup ? `Source ${selectedBackup}` : "Selection requise" }}
</span>
</div>
<BackupList <BackupList :folder="selectedBackup" />
class="backup-list-mobile"
:folder="selectedBackup"
/>
</div> </div>
<section <section
@@ -53,9 +65,12 @@
aria-labelledby="backup-output-title" aria-labelledby="backup-output-title"
> >
<div class="files-panel-header"> <div class="files-panel-header">
<div> <div class="files-panel-copy">
<p class="section-kicker">Execution</p> <p class="section-kicker">Execution</p>
<h2 id="backup-output-title" class="files-panel-title">Resultat du script</h2> <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> </div>
<span <span
class="panel-badge" class="panel-badge"
@@ -96,6 +111,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue" import { ref } from "vue"
import BackupRun from "~/components/BackupRun.vue" import BackupRun from "~/components/BackupRun.vue"
import { apiFetch, downloadApiFile } from "~/composables/useApiAuth"
definePageMeta({ layout: false }) definePageMeta({ layout: false })
@@ -119,33 +135,25 @@ const selectedBackup = ref<string | null>(null)
const scriptResult = ref<ScriptResult>(emptyScriptResult()) const scriptResult = ref<ScriptResult>(emptyScriptResult())
const fetchLatestBackup = async (folder: string) => { const fetchLatestBackup = async (folder: string) => {
const files = await $fetch<string[]>(`/api/backups?folder=${encodeURIComponent(folder)}`) const files = await apiFetch<string[]>(`/api/backups?folder=${encodeURIComponent(folder)}`)
return files[0] || null return files[0] || null
} }
const triggerDownload = (folder: string, file: string) => { const triggerDownload = async (folder: string, file: string) => {
const link = document.createElement("a") const url = `/api/download?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(file)}`
link.href = `/api/download?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(file)}` await downloadApiFile(url, file)
link.style.display = "none"
document.body.appendChild(link)
link.click()
link.remove()
} }
const triggerBatchDownload = (folders: string[]) => { const triggerBatchDownload = async (folders: string[]) => {
const link = document.createElement("a") const url = `/api/download-latest?folders=${encodeURIComponent(folders.join(","))}`
link.href = `/api/download-latest?folders=${encodeURIComponent(folders.join(","))}` await downloadApiFile(url, "backup-latest.tar.gz")
link.style.display = "none"
document.body.appendChild(link)
link.click()
link.remove()
} }
const downloadLatestBackup = async (folder: string) => { const downloadLatestBackup = async (folder: string) => {
const latestFile = await fetchLatestBackup(folder) const latestFile = await fetchLatestBackup(folder)
if (latestFile) { if (latestFile) {
triggerDownload(folder, latestFile) await triggerDownload(folder, latestFile)
} }
} }
@@ -157,7 +165,7 @@ const handleScriptResult = async (payload: ScriptResult) => {
} }
if (payload.downloadFolders.length > 1) { if (payload.downloadFolders.length > 1) {
triggerBatchDownload(payload.downloadFolders) await triggerBatchDownload(payload.downloadFolders)
return return
} }
@@ -177,15 +185,12 @@ const handleScriptResult = async (payload: ScriptResult) => {
} }
.dashboard-header { .dashboard-header {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 320px);
gap: 1.5rem;
align-items: end;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.header-copy { .header-copy {
min-width: 0; min-width: 0;
max-width: 70ch;
} }
.section-kicker { .section-kicker {
@@ -204,26 +209,37 @@ const handleScriptResult = async (payload: ScriptResult) => {
line-height: 1.65; line-height: 1.65;
} }
.dashboard-grid { .status-strip {
margin-bottom: 1.5rem;
}
.workspace-grid {
display: grid; display: grid;
grid-template-columns: 300px minmax(0, 1fr); grid-template-columns: minmax(280px, 320px) minmax(0, 1fr);
gap: 1.5rem; gap: 1.5rem;
align-items: start; align-items: start;
} }
.grid-left, .workspace-sidebar,
.grid-middle { .workspace-main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
min-width: 0; min-width: 0;
} }
.workspace-sidebar {
position: sticky;
top: 2rem;
}
.files-panel { .files-panel {
padding: 1.25rem; padding: 1.25rem;
border-radius: 20px; border-radius: 20px;
background: rgb(var(--m-secondary) / 0.4); background:
border: 1px solid rgb(var(--m-accent) / 0.08); 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 { .output-panel {
@@ -232,12 +248,16 @@ const handleScriptResult = async (payload: ScriptResult) => {
.files-panel-header { .files-panel-header {
display: flex; display: flex;
align-items: end; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.files-panel-copy {
min-width: 0;
}
.files-panel-title { .files-panel-title {
margin: 0; margin: 0;
font-family: var(--font-display); font-family: var(--font-display);
@@ -246,19 +266,41 @@ const handleScriptResult = async (payload: ScriptResult) => {
color: rgb(var(--m-text)); color: rgb(var(--m-text));
} }
.files-panel-meta { .files-panel-description {
margin: 0; 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-family: var(--font-mono);
font-size: 0.75rem; font-size: 0.68rem;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
color: rgb(var(--m-muted)); 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 { .panel-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
min-height: 2.25rem;
border-radius: 999px; border-radius: 999px;
padding: 0.35rem 0.7rem; padding: 0.35rem 0.7rem;
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -334,18 +376,22 @@ const handleScriptResult = async (payload: ScriptResult) => {
} }
@media (max-width: 1180px) { @media (max-width: 1180px) {
.dashboard-header, .workspace-grid {
.dashboard-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.workspace-sidebar {
position: static;
}
.files-panel-header { .files-panel-header {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
} }
.files-panel-meta { .selection-pill,
text-align: left; .panel-badge {
width: 100%;
} }
} }
@@ -357,5 +403,9 @@ const handleScriptResult = async (payload: ScriptResult) => {
.files-panel { .files-panel {
padding: 1rem; padding: 1rem;
} }
.files-panel-title {
font-size: 1.2rem;
}
} }
</style> </style>

View File

@@ -3,9 +3,13 @@
<div class="dashboard-container"> <div class="dashboard-container">
<header class="dashboard-header"> <header class="dashboard-header">
<div> <div>
<p class="section-kicker">Operations</p>
<h1 class="font-display text-3xl font-bold tracking-tight text-m-text"> <h1 class="font-display text-3xl font-bold tracking-tight text-m-text">
Monitoring Monitoring
</h1> </h1>
<p class="header-description">
Visualisez l'etat des applications, des sauvegardes et des ressources systeme depuis une vue unique.
</p>
</div> </div>
</header> </header>
@@ -33,9 +37,24 @@
</div> </div>
<div class="grid-middle"> <div class="grid-middle">
<Speedtest class="animate-fade-in-up speedtest-card-mobile" style="animation-delay: 150ms" /> <SpeedTest class="animate-fade-in-up speedtest-card-mobile" style="animation-delay: 150ms" />
</div> </div>
</div> </div>
<div class="metrics-row">
<SystemMetricsChart
:metrics="systemMetrics"
:loading="systemLoading"
class="animate-fade-in-up"
style="animation-delay: 200ms"
/>
<SystemResources
:metrics="systemMetrics"
:loading="systemLoading"
class="animate-fade-in-up"
style="animation-delay: 225ms"
/>
</div>
</div> </div>
<div class="content-aside"> <div class="content-aside">
@@ -47,8 +66,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({layout: false})
import {computed, onMounted, ref} from "vue" import {computed, onMounted, ref} from "vue"
import { apiFetch } from "~/composables/useApiAuth"
import type { SystemMetrics } from "~/types/system";
definePageMeta({layout: false})
type DiskSourceResult = { type DiskSourceResult = {
key: string key: string
@@ -74,11 +96,13 @@ type DiagramItem = {
totalText: string totalText: string
} }
const selectedBackup = ref<string | null>(null)
const rawResults = ref<DiskSourceResult[]>([]) const rawResults = ref<DiskSourceResult[]>([])
const loading = ref(false) const loading = ref(false)
const systemMetrics = ref<SystemMetrics | null>(null)
const systemLoading = ref(true)
const chartRadius = 52 const chartRadius = 52
const chartCircumference = 2 * Math.PI * chartRadius const chartCircumference = 2 * Math.PI * chartRadius
let systemTimer: ReturnType<typeof setInterval> | null = null
const getHostName = (output: string, fallback: string) => { const getHostName = (output: string, fallback: string) => {
const hostMatch = output.match(/Name:\s*(.+)/i) const hostMatch = output.match(/Name:\s*(.+)/i)
@@ -151,16 +175,15 @@ const runScript = async () => {
rawResults.value = [] rawResults.value = []
try { try {
const output = await $fetch<DiskApiResponse>("/api/disk") const output = await apiFetch<DiskApiResponse>("/api/disk")
rawResults.value = output.results rawResults.value = output.results
} catch (error) { } catch {
const message = `Erreur: ${error instanceof Error ? error.message : String(error)}`
rawResults.value = [ rawResults.value = [
{ {
key: "error", key: "error",
label: "Source indisponible", label: "Source indisponible",
ok: false, ok: false,
output: message output: "Erreur lors de l'opération"
} }
] ]
} finally { } finally {
@@ -168,8 +191,27 @@ const runScript = async () => {
} }
} }
const loadSystemMetrics = async () => {
try {
systemMetrics.value = await $fetch<SystemMetrics>("/api/system")
} catch {
systemMetrics.value = null
} finally {
systemLoading.value = false
}
}
onMounted(() => { onMounted(() => {
runScript() runScript()
loadSystemMetrics()
systemTimer = setInterval(loadSystemMetrics, 2000)
})
onBeforeUnmount(() => {
if (systemTimer) {
clearInterval(systemTimer)
systemTimer = null
}
}) })
</script> </script>
@@ -183,8 +225,24 @@ onMounted(() => {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 2rem; margin-bottom: 2rem;
padding-bottom: 1.5rem; padding-bottom: 1.25rem;
border-bottom: 1px solid rgba(80, 140, 255, 0.08); 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 { .storage-section {
@@ -202,9 +260,11 @@ onMounted(() => {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem; gap: 1rem;
border-radius: 12px; border-radius: 18px;
background: rgb(var(--m-secondary)); background:
padding: 0.75rem; 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 { .content-grid {
@@ -229,12 +289,21 @@ onMounted(() => {
align-items: start; align-items: start;
} }
.metrics-row {
margin-top: 1.5rem;
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 1.5rem;
align-items: start;
}
.grid-left, .grid-left,
.grid-middle, .grid-middle,
.grid-right { .grid-right {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
min-width: 0;
} }
@media (max-width: 1180px) { @media (max-width: 1180px) {
@@ -260,7 +329,8 @@ onMounted(() => {
.storage-grid, .storage-grid,
.content-grid, .content-grid,
.dashboard-grid { .dashboard-grid,
.metrics-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

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

View File

@@ -1,12 +1,5 @@
import { exec } from "node:child_process" import { exec } from "node:child_process"
import scripts from "../config/backup-script.json" import { backupScripts, getBackupScriptCommand } from "../utils/backup-scripts"
type BackupScript = {
key: string
label: string
downloadFolders?: string[]
command: string
}
function runCommand(command: string): Promise<string> { function runCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -31,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) { if (!script) {
throw createError({ throw createError({
statusCode: 404, statusCode: 404,
@@ -40,7 +33,15 @@ export default defineEventHandler(async (event) => {
} }
try { try {
const output = await runCommand(script.command) const command = getBackupScriptCommand(script.key)
if (!command) {
throw createError({
statusCode: 500,
statusMessage: "Commande de script manquante"
})
}
const output = await runCommand(command)
return { return {
ok: true, ok: true,
key: script.key, key: script.key,
@@ -49,9 +50,20 @@ export default defineEventHandler(async (event) => {
output: output.trim() output: output.trim()
} }
} catch (error) { } catch (error) {
console.error("Erreur execution script:", error)
if (
typeof error === "object" &&
error !== null &&
"statusCode" in error &&
"statusMessage" in error
) {
throw error
}
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: `Erreur execution script: ${String(error)}` statusMessage: "Erreur lors de l'opération"
}) })
} }
}) })

View File

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

View File

@@ -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

@@ -2,6 +2,14 @@ export default defineEventHandler(async () => {
const token = process.env.DISCORD_BOT_TOKEN const token = process.env.DISCORD_BOT_TOKEN
const channel = process.env.DISCORD_CHANNEL_ID const channel = process.env.DISCORD_CHANNEL_ID
if (!token || !channel) {
throw createError({
statusCode: 503,
statusMessage: "Service indisponible"
})
}
try {
const messages = await $fetch( const messages = await $fetch(
`https://discord.com/api/v10/channels/${channel}/messages?limit=20`, `https://discord.com/api/v10/channels/${channel}/messages?limit=20`,
{ {
@@ -12,4 +20,12 @@ export default defineEventHandler(async () => {
) )
return messages return messages
} catch (error) {
console.error("Erreur Discord messages:", error)
throw createError({
statusCode: 500,
statusMessage: "Erreur lors de l'opération"
})
}
}) })

View File

@@ -1,21 +1,36 @@
import { exec } from "child_process" import { exec } from "child_process"
import diskSources from "../config/disk-commands.json"
type DiskSource = { type DiskSource = {
key: string key: string
label: string label: string
command: string command: string
args?: string[]
} }
function getCommand(source: DiskSource) { const diskSources: DiskSource[] = [
{
key: "remote",
label: "Serveur distant",
command: "ssh",
args: []
},
{
key: "local",
label: "Machine locale",
command: "bash",
args: []
}
]
function getEnvCommand(source: DiskSource) {
const envKey = `DISK_COMMAND_${source.key.toUpperCase()}` const envKey = `DISK_COMMAND_${source.key.toUpperCase()}`
const legacyEnvKey = const legacyEnvKey =
source.key === "remote" ? "DISK_REMOTE_COMMAND" : source.key === "local" ? "DISK_LOCAL_COMMAND" : "" source.key === "remote" ? "DISK_REMOTE_COMMAND" : source.key === "local" ? "DISK_LOCAL_COMMAND" : ""
return process.env[envKey] || (legacyEnvKey ? process.env[legacyEnvKey] : undefined) || source.command return process.env[envKey] || (legacyEnvKey ? process.env[legacyEnvKey] : undefined) || null
} }
function runCommand(command: string): Promise<string> { function runShellCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => { exec(command, (error, stdout, stderr) => {
if (error) { if (error) {
@@ -29,9 +44,14 @@ function runCommand(command: string): Promise<string> {
export default defineEventHandler(async () => { export default defineEventHandler(async () => {
const results = await Promise.all( const results = await Promise.all(
(diskSources as DiskSource[]).map(async (source) => { diskSources.map(async (source) => {
try { try {
const output = await runCommand(getCommand(source)) const envCommand = getEnvCommand(source)
if (!envCommand) {
throw new Error(`Commande disque manquante pour ${source.key}`)
}
const output = await runShellCommand(envCommand)
return { return {
key: source.key, key: source.key,
label: source.label, label: source.label,
@@ -39,11 +59,12 @@ export default defineEventHandler(async () => {
output output
} }
} catch (error) { } catch (error) {
console.error(`Erreur disk source ${source.key}:`, error)
return { return {
key: source.key, key: source.key,
label: source.label, label: source.label,
ok: false, ok: false,
output: `Erreur: ${String(error)}` output: "Erreur lors de l'opération"
} }
} }
}) })

View File

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

View File

@@ -1,92 +1,27 @@
import { execFile, spawn } from "node:child_process" import {
import { Readable } from "node:stream" runSsh,
import folderMap from "../config/backup-folders.json" shellQuote,
resolveFolderRemoteDir,
const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST || "malio-b" REMOTE_HOST,
const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups" } from "../utils/ssh.ts"
const FOLDER_MAP = folderMap as Record<string, string> import { spawn } from "node:child_process"
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value) const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
const isSafeFile = (value: string) => /^[^/\\]+$/.test(value) const isSafeFile = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
function runSsh(command: string): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
"ssh",
["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", REMOTE_HOST, command],
{ maxBuffer: 10 * 1024 * 1024 },
(error, stdout, stderr) => {
if (error) {
reject(stderr || error.message)
return
}
resolve(stdout)
}
)
})
}
async function remoteDirExists(remoteDir: string): Promise<boolean> {
const output = await runSsh(`[ -d ${shellQuote(remoteDir)} ] && echo yes || echo no`)
return output.trim() === "yes"
}
async function resolveFolderRemoteDir(folderName: string): Promise<string | null> {
const mapped = FOLDER_MAP[folderName]
if (mapped) {
return `${REMOTE_ROOT}/${mapped}`
}
const direct = `${REMOTE_ROOT}/${folderName}`
if (await remoteDirExists(direct)) {
return direct
}
const nested = `${REMOTE_ROOT}/bdd_recette/${folderName}`
if (await remoteDirExists(nested)) {
return nested
}
return null
}
function buildContentDisposition(fileName: string) { function buildContentDisposition(fileName: string) {
const asciiName = fileName.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_") const asciiName = fileName.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_")
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fileName)}` return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`
} }
function speedtestStream(event: any) {
const size = 128 * 1024 * 1024
let sent = 0
const stream = new Readable({
read(chunkSize) {
if (sent >= size) {
this.push(null)
return
}
const remaining = size - sent
const chunk = Buffer.alloc(Math.min(chunkSize, remaining), "a")
sent += chunk.length
this.push(chunk)
}
})
setHeader(event, "Content-Type", "application/octet-stream")
setHeader(event, "Content-Length", size)
return stream
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const { folder, file } = getQuery(event) const { folder, file } = getQuery(event)
const folderName = typeof folder === "string" ? folder : null const folderName = typeof folder === "string" ? folder : null
const fileName = typeof file === "string" ? file : null const fileName = typeof file === "string" ? file : null
// Compat mode: utilisé par le test de débit.
if (!folderName || !fileName) { if (!folderName || !fileName) {
return speedtestStream(event) throw createError({ statusCode: 400, statusMessage: "Paramètres manquants" })
} }
if (!isSafeFolder(folderName) || !isSafeFile(fileName)) { if (!isSafeFolder(folderName) || !isSafeFile(fileName)) {

View File

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

5
server/api/system.get.ts Normal file
View File

@@ -0,0 +1,5 @@
import { getSystemMetricsSnapshot } from "../plugins/metrics-worker"
export default defineEventHandler(() => {
return getSystemMetricsSnapshot()
})

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
export default defineEventHandler((event) => {
const path = event.path || event.node.req.url || ""
if (path.startsWith("/api/")) {
return
}
const runtimeConfig = useRuntimeConfig(event)
const expectedToken = runtimeConfig.apiSecretKey
if (!expectedToken) {
return
}
if (getCookie(event, "api_auth_token") === expectedToken) {
return
}
const secureCookie = process.env.AUTH_COOKIE_SECURE === "true"
setCookie(event, "api_auth_token", expectedToken, {
httpOnly: true,
sameSite: "lax",
secure: secureCookie,
path: "/"
})
})

31
server/middleware/auth.ts Normal file
View File

@@ -0,0 +1,31 @@
export default defineEventHandler((event) => {
const path = event.path || event.node.req.url || ""
// Le middleware ne s'applique qu'aux routes API, sauf l'endpoint de ping
// qui reste public pour les tests de connectivite.
if (!path.startsWith("/api/") || path === "/api/ping") {
return
}
const runtimeConfig = useRuntimeConfig(event)
const authorization = getHeader(event, "authorization")
const cookieToken = getCookie(event, "api_auth_token")
const expectedToken = runtimeConfig.apiSecretKey
// Si aucun secret n'est configure cote serveur, on refuse la requete.
if (!expectedToken) {
throw createError({
statusCode: 401,
statusMessage: "Unauthorized"
})
}
// Le secret peut venir soit d'un header serveur explicite,
// soit du cookie httpOnly pose pour l'application web.
if (authorization !== `Bearer ${expectedToken}` && cookieToken !== expectedToken) {
throw createError({
statusCode: 401,
statusMessage: "Unauthorized"
})
}
})

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,31 @@
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"]
}
]
export function getBackupScriptCommand(key: string) {
const envKey = `BACKUP_SCRIPT_COMMAND_${key.toUpperCase().replace(/-/g, "_")}`
return process.env[envKey] || null
}

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

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

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
}