57 Commits

Author SHA1 Message Date
c12387ac94 fix: t-005 a t-0029 correctif 2026-03-18 09:00:11 +01:00
bdb65a09ff fix: t-001 a t-005 correctif 2026-03-17 15:33:36 +01:00
13457ceb5a fix: readme
All checks were successful
Release / release (push) Successful in 26s
2026-03-17 14:26:49 +01:00
f30d75141d Merge pull request 'fix: resolve production runtime issues' (#19) from fix/prod-runtime-issues into develop
Some checks failed
Release / release (push) Has been cancelled
Reviewed-on: #19
2026-03-17 12:52:21 +00:00
8886e8b7df fix: resolve production runtime issues 2026-03-17 13:50:13 +01:00
semantic-release-bot
99a5758f05 chore(release): 1.4.0 2026-03-17 09:27:06 +00:00
7261f9f0e9 Merge pull request 'feat: add check backup' (#18) from feat/440-add-section-check-backup into develop
All checks were successful
Release / release (push) Successful in 27s
Reviewed-on: #18
2026-03-17 09:26:41 +00:00
5184e26293 fix: readme 2026-03-17 08:54:33 +01:00
829ac07d38 fix: use env only 2026-03-16 15:05:48 +01:00
e13e1eb3dd fix: use env only 2026-03-16 14:43:55 +01:00
69c192c35a fix: lint 2026-03-16 14:37:42 +01:00
f7ac255820 fix: use env 2026-03-16 14:28:01 +01:00
5495e18173 feat: add check backup 2026-03-16 11:30:34 +01:00
semantic-release-bot
3f00c229cb chore(release): 1.3.1 2026-03-16 09:47:56 +00:00
f4f38cf6d1 Merge pull request 'fix/arch-03 et arch-04' (#17) from fix/arch-03-worker-system-metric into develop
All checks were successful
Release / release (push) Successful in 28s
Reviewed-on: #17
2026-03-16 09:47:30 +00:00
6eddc11253 fix: arch-03 worker system metric 2026-03-13 13:40:30 +01:00
c6d5843022 fix: arch-03 worker system metric 2026-03-13 11:45:09 +01:00
ffb84b41a9 fix: arch-02 make type file 2026-03-13 11:11:31 +01:00
7c3467d85f fix: extract shared ssh utilities 2026-03-13 11:05:34 +01:00
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
semantic-release-bot
126d6b505a chore(release): 1.2.4 2026-03-10 14:05:53 +00:00
c758c4d904 Merge pull request 'fix: bundle latest backup downloads' (#10) from fix/backup-history into develop
All checks were successful
Release / release (push) Successful in 27s
Reviewed-on: #10
2026-03-10 14:05:29 +00:00
ffe463e130 fix: bundle latest backup downloads 2026-03-10 15:02:43 +01:00
semantic-release-bot
a8447d6ee1 chore(release): 1.2.3 2026-03-10 13:19:55 +00:00
91d429c4dd Merge pull request 'fix/backup-history' (#9) from fix/backup-history into develop
All checks were successful
Release / release (push) Successful in 25s
Reviewed-on: #9
2026-03-10 13:19:32 +00:00
505ebd9325 fix: add scroll to backup history 2026-03-10 14:18:14 +01:00
d0e39c92b2 fix: restore backup history listing 2026-03-10 14:16:44 +01:00
semantic-release-bot
8bd78a610f chore(release): 1.2.2 2026-03-10 12:52:57 +00:00
975b0f9718 Merge pull request 'fix/backup-ui-download' (#8) from fix/backup-ui-download into develop
All checks were successful
Release / release (push) Successful in 25s
Reviewed-on: #8
2026-03-10 12:52:33 +00:00
889d723e81 refactor: simplify backup result handling 2026-03-10 13:51:21 +01:00
4757c766f6 fix: align backup ui and downloads 2026-03-10 13:48:55 +01:00
acee6d471c fix: ssh connection correctif 2026-03-10 11:47:37 +01:00
semantic-release-bot
60c2fb2d7e chore(release): 1.2.1 2026-03-10 09:44:09 +00:00
e372505120 Merge pull request 'fix/style-loading-flash' (#7) from fix/style-loading-flash into develop
All checks were successful
Release / release (push) Successful in 29s
Reviewed-on: #7
2026-03-10 09:43:42 +00:00
4e393dd5e9 fix: reduce style loading flash 2026-03-10 10:40:46 +01:00
8fd4aba63e Merge branch 'develop' into feat/app-version 2026-03-10 10:14:46 +01:00
semantic-release-bot
d63b02fc4e chore(release): 1.2.0 2026-03-10 09:08:55 +00:00
7ed9382e73 Merge pull request 'feat: show git tag version' (#6) from feat/app-version into develop
All checks were successful
Release / release (push) Successful in 25s
Reviewed-on: #6
2026-03-10 09:08:32 +00:00
539cbdd2f1 feat: show git tag version 2026-03-10 10:06:55 +01:00
semantic-release-bot
0d985b62b1 chore(release): 1.1.0 2026-03-10 08:53:59 +00:00
4d76d2697b Merge pull request 'feat(backup): add backup scripts workflow' (#5) from feat/backup-release into develop
All checks were successful
Release / release (push) Successful in 37s
Reviewed-on: #5
2026-03-10 08:53:25 +00:00
0863dfad2e feat(backup): add backup scripts workflow 2026-03-10 09:51:26 +01:00
440fffc605 Merge pull request 'chore/configure-semantic-release' (#4) from chore/configure-semantic-release into develop
Reviewed-on: #4
2026-03-10 07:20:27 +00:00
aad7a0a928 Merge pull request 'feat : ajout download backup' (#2) from feat/387-affichage-download-backup into develop
Reviewed-on: #2
2026-03-09 09:51:57 +00:00
45 changed files with 6285 additions and 371 deletions

25
.env.example Normal file
View File

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

View File

@@ -1,25 +1,24 @@
name: Release
on: on:
push: push:
branches: branches:
- develop - develop
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22.x
cache: npm
- run: npm install - run: npm ci
- run: npx semantic-release - run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -6,7 +6,6 @@
"@semantic-release/commit-analyzer", "@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator", "@semantic-release/release-notes-generator",
"@semantic-release/changelog", "@semantic-release/changelog",
"@semantic-release/github",
[ [
"@semantic-release/git", "@semantic-release/git",
{ {
@@ -17,4 +16,4 @@
} }
] ]
] ]
} }

76
CHANGELOG.md Normal file
View File

@@ -0,0 +1,76 @@
# [1.4.0](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.3.1...v1.4.0) (2026-03-17)
### Bug Fixes
* lint ([69c192c](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/69c192c35ad2a743d01b96d834f509b2b1f0b4e6))
* readme ([5184e26](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/5184e26293ef23944e874f4e938f1cc89ec85f82))
* use env ([f7ac255](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/f7ac255820ca5a1fded47a6b0071d85c7d3c4214))
* use env only ([829ac07](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/829ac07d38e81225017b3c6a33c3f34882ca02d1))
* use env only ([e13e1eb](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/e13e1eb3dd48c1b5a6f2fe0347e43dea60e4406f))
### Features
* add check backup ([5495e18](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/5495e18173c0778c6eaba4ae1eb8c30ea46bbef7))
## [1.3.1](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.3.0...v1.3.1) (2026-03-16)
### Bug Fixes
* arch-02 make type file ([ffb84b4](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/ffb84b41a9e15b2edc98378c94050c74c9d200c6))
* arch-03 worker system metric ([6eddc11](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/6eddc1125316bb0d77a7c71cfc4674cd99c6e296))
* arch-03 worker system metric ([c6d5843](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/c6d5843022bbdcf909662c2c9ce47fb14f88b5a2))
* extract shared ssh utilities ([7c3467d](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/7c3467d85f987f1d9b8fe2546a13c2d23ea841b4))
# [1.3.0](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.2.4...v1.3.0) (2026-03-13)
### 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)
### Bug Fixes
* bundle latest backup downloads ([ffe463e](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/ffe463e13034601843446514abbd7c69cbaee081))
## [1.2.3](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.2.2...v1.2.3) (2026-03-10)
### Bug Fixes
* add scroll to backup history ([505ebd9](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/505ebd9325c0aa54adb034c012c45c913bb36d73))
* restore backup history listing ([d0e39c9](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/d0e39c92b270993c99cde0eed8577c6dde817fdd))
## [1.2.2](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.2.1...v1.2.2) (2026-03-10)
### Bug Fixes
* align backup ui and downloads ([4757c76](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/4757c766f613a1888b62716a2c6852c8d92e3f6e))
* ssh connection correctif ([acee6d4](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/acee6d471c63671bfb9bdef62a3b6e2ebe40ba55))
## [1.2.1](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.2.0...v1.2.1) (2026-03-10)
### Bug Fixes
* reduce style loading flash ([4e393dd](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/4e393dd5e92a28d91e49affb9437687ac8de3817))
# [1.2.0](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.1.0...v1.2.0) (2026-03-10)
### Features
* show git tag version ([539cbdd](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/539cbdd2f1fa73eddab8adb6e2cc0683e6c424aa))
# [1.1.0](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.0.0...v1.1.0) (2026-03-10)
### Features
* **backup:** add backup scripts workflow ([0863dfa](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/0863dfad2e3c6272a012c30820381a2610e22d1b))

159
README.md
View File

@@ -1,4 +1,14 @@
# Projet Monitoring # Supervisor
`Supervisor` est une application Nuxt qui centralise plusieurs besoins d'exploitation dans une interface web unique :
- suivi de l'état general d'applications distantes
- consultation de l'espace disque local et distant
- visualisation de métriques système de la machine qui execute l'application
- contrôle et téléchargement de sauvegardes via SSH
- lecture de messages Discord depuis un canal configure
Le nom du package npm visible dans le depot est `disk-monitor`, mais l'interface et la structure du projet exposent clairement le nom `Supervisor`.
## Installation du projet ## Installation du projet
@@ -15,79 +25,118 @@ 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.
``` ## Securite
npm run build
``` Le comportement actuel du projet repose sur une hypothèse d'exposition très forte.
Prévisualisation du build de production.
``` - `server/middleware/auth-cookie.ts` pose automatiquement le cookie `api_auth_token` à tout visiteur qui charge l'interface web
npm run preview - ce cookie permet ensuite d'accéder aux routes `/api/*` protégées par `API_SECRET_KEY`
``` - il n'existe pas de login utilisateur ni de contrôle d'identité distinct dans le dépôt
Conséquence :
- `Supervisor` doit être déployé uniquement sur un réseau de confiance, derrière un VPN, une restriction d'IP, un proxy d'authentification ou un autre contrôle d'accès externe
- si l'application est exposée publiquement sans protection supplémentaire, ce mécanisme ne constitue pas une authentification suffisante
### SSH pour les backups
Les fonctionnalités de backup utilisent `ssh` avec les options `BatchMode=yes` et `ConnectTimeout=5` dans `server/utils/ssh.ts`. Cela implique un accès sans saisie interactive de mot de passe.
Elements a preparer cote SSH :
- une cle privée disponible sur la machine qui execute `Supervisor`
- une clé ssh pour les différentes machines cibles, si necessaire pour les différents usages (backup BDD, backup Vault warden, check statut recette)
Le depot ne fixe pas de noms de fichiers de clés SSH ni de chemin obligatoire. Les noms exacts ne sont donc pas vérifiables dans le code.
## Commandes utiles ## 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

@@ -15,26 +15,56 @@
--color-m-success: rgb(var(--m-success)); --color-m-success: rgb(var(--m-success));
--color-m-accent: rgb(var(--m-accent)); --color-m-accent: rgb(var(--m-accent));
--color-m-warning: rgb(var(--m-warning)); --color-m-warning: rgb(var(--m-warning));
--color-m-succes: rgb(var(--m-success));
--font-display: "Outfit", system-ui, sans-serif; --font-display: "Outfit", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace; --font-mono: "JetBrains Mono", "Fira Code", monospace;
} }
@import url('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');
@layer base { @layer base {
html {
background: rgb(var(--m-bg));
color-scheme: dark;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
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 {
display: block;
}
} }
@layer utilities { @layer utilities {
@@ -67,6 +97,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),
@@ -157,3 +194,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

@@ -1,7 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base { @layer base {
:root { :root {
--m-primary: 15 20 40; --m-primary: 15 20 40;

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=${folder}`) const data = await apiFetch<string[]>(`/api/backups?folder=${encodeURIComponent(folder)}`)
backups.value = data.slice(0, 6) 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,10 +139,19 @@ 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;
gap: 0.375rem; gap: 0.375rem;
max-height: calc((2.875rem * 5) + (0.375rem * 4));
overflow-y: auto;
padding-right: 0.25rem;
} }
.file-row { .file-row {

278
components/BackupRun.vue Normal file
View File

@@ -0,0 +1,278 @@
<template>
<div
class="backup-card card-glow"
:class="{
'card-glow-success': message && !isError,
'card-glow-error': message && isError
}"
>
<div class="card-header">
<h2 class="card-title">Run Script</h2>
<span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Scripts</span>
</div>
<div
v-if="loading"
class="status-box"
role="status"
aria-live="polite"
aria-busy="true"
>
Chargement des scripts...
</div>
<div
v-else-if="scripts.length"
class="backup-list"
:aria-busy="runningKey !== null"
>
<button
v-for="item in scripts"
:key="item.key"
type="button"
class="backup-btn"
:class="{ 'backup-btn-active': active === item.key }"
:disabled="runningKey !== null"
:aria-pressed="active === item.key"
:aria-label="`Executer ${item.label}`"
@click="runScript(item.key)"
>
<div class="flex items-center gap-2.5">
<IconifyIcon :icon="item.icon" class="text-base text-m-accent" />
<span class="font-display text-sm font-semibold uppercase tracking-wide">
{{ item.label }}
</span>
</div>
<IconifyIcon
:icon="runningKey === item.key ? 'mdi:loading' : 'mdi:play'"
class="text-lg text-m-muted transition-transform duration-200"
:class="{
'translate-x-0.5 !text-m-accent': active === item.key,
'animate-spin': runningKey === item.key
}"
/>
</button>
</div>
<div
v-else
class="status-box status-empty"
role="status"
aria-live="polite"
>
Aucun script disponible.
</div>
<div
v-if="message"
class="status-box"
:class="statusClass"
role="status"
:aria-live="isError ? 'assertive' : 'polite'"
>
<p class="status-title">{{ message }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { Icon as IconifyIcon } from "@iconify/vue"
import { apiFetch } from "~/composables/useApiAuth"
type BackupScript = {
key: string
label: string
icon: string
downloadFolders?: string[]
}
type BackupScriptListResponse = {
scripts: BackupScript[]
}
type BackupScriptRunResponse = {
ok: boolean
key: string
label: string
downloadFolders?: string[]
output: string
}
type ScriptResult = {
key: string | null
label: string
output: string
isError: boolean
downloadFolders: string[]
}
const emit = defineEmits<{
result: [payload: ScriptResult]
}>()
const active = ref<string | null>(null)
const loading = ref(true)
const runningKey = ref<string | null>(null)
const scripts = ref<BackupScript[]>([])
const message = ref<string>("")
const isError = ref(false)
const statusClass = computed(() => (isError.value ? "status-error" : "status-success"))
const loadScripts = async () => {
loading.value = true
message.value = ""
isError.value = false
emit("result", {
key: null,
label: "",
output: "",
isError: false,
downloadFolders: []
})
try {
const data = await apiFetch<BackupScriptListResponse>("/api/backup-script")
scripts.value = data.scripts
} catch {
scripts.value = []
isError.value = true
message.value = "Erreur lors de l'opération"
emit("result", {
key: null,
label: "",
output: "",
isError: true,
downloadFolders: []
})
} finally {
loading.value = false
}
}
const runScript = async (key: string) => {
active.value = key
runningKey.value = key
message.value = ""
isError.value = false
try {
const data = await apiFetch<BackupScriptRunResponse>("/api/backup-script", {
method: "POST",
body: { key }
})
const resultOutput = data.output || "Aucune sortie retournee."
message.value = `${data.label} execute avec succes`
emit("result", {
key: data.key,
label: data.label,
output: resultOutput,
isError: false,
downloadFolders: data.downloadFolders || []
})
} catch (error: unknown) {
message.value = (error as any)?.data?.statusMessage || "Erreur execution script"
emit("result", {
key,
label: scripts.value.find((item) => item.key === key)?.label || key,
output: "",
isError: true,
downloadFolders: []
})
} finally {
runningKey.value = null
}
}
onMounted(loadScripts)
</script>
<style scoped>
.backup-card {
background: rgb(var(--m-secondary));
border-radius: 12px;
padding: 1.25rem;
transition: background-color 0.4s ease;
}
.card-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.card-title {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.backup-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.backup-btn {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0.875rem;
border-radius: 8px;
background: rgb(var(--m-tertiary));
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s ease;
color: rgb(var(--m-text));
}
.backup-btn:focus-visible {
outline: 2px solid rgb(var(--m-accent) / 0.7);
outline-offset: 2px;
}
.backup-btn:disabled {
cursor: wait;
opacity: 0.7;
}
.backup-btn:hover {
border-color: rgb(var(--m-accent) / 0.15);
background: rgb(var(--m-accent) / 0.06);
}
.backup-btn-active {
border-color: rgb(var(--m-accent) / 0.25);
background: rgb(var(--m-accent) / 0.08);
box-shadow: 0 0 12px -4px rgb(var(--m-accent) / 0.15);
}
.status-box {
margin-top: 0.75rem;
border-radius: 8px;
padding: 0.875rem;
background: rgb(var(--m-tertiary));
color: rgb(var(--m-text));
font-family: var(--font-mono);
font-size: 0.75rem;
}
.status-success {
border: 1px solid rgb(var(--m-accent) / 0.18);
}
.status-error {
border: 1px solid rgb(255 99 99 / 0.3);
}
.status-empty {
color: rgb(var(--m-muted));
}
.status-title {
margin: 0;
line-height: 1.5;
}
</style>

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

89
composables/useApiAuth.ts Normal file
View File

@@ -0,0 +1,89 @@
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 {
}
}
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

@@ -1,74 +1,174 @@
<template> <template>
<div class="page-layout"> <div class="page-layout">
<aside class="sidebar"> <aside class="sidebar" aria-label="Navigation principale">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="logo-container"> <div class="logo-container">
<img <img
:src="logoSrc" :src="logoSrc"
alt="Logo Malio" alt="Logo Malio"
class="logo" class="logo"
/> >
</div> </div>
<div class="sidebar-divider" /> <div class="brand-copy">
<p class="brand-title">Supervisor</p>
</div>
<div class="sidebar-divider"/>
</div> </div>
<div class="sidebar-content"> <div class="sidebar-content">
<slot name="sidebar" /> <slot name="sidebar"/>
<nav class="sidebar-nav" aria-label="Sections">
<p class="nav-label">Navigation</p>
<NuxtLink
v-for="item in navItems"
:key="`desktop-${item.to}`"
v-slot="{ href, navigate, isExactActive }"
:to="item.to"
custom
>
<a
:href="href"
class="nav-link"
:class="{ 'nav-link-active': isExactActive }"
:aria-current="isExactActive ? 'page' : undefined"
@click="navigate"
>
<span class="nav-link-main">
<span class="nav-icon">
<IconifyIcon :icon="item.icon" class="text-lg"/>
</span>
<span>
<span class="nav-title">{{ item.label }}</span>
<span class="nav-caption">{{ item.caption }}</span>
</span>
</span>
<span class="nav-pill">{{ item.short }}</span>
</a>
</NuxtLink>
</nav>
</div> </div>
<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 v1.0 Supervisor {{ appVersion }}
</p> </p>
</div> </div>
</div> </div>
</aside> </aside>
<button class="mobile-menu-button" type="button" @click="isMenuOpen = true"> <button class="mobile-menu-button" type="button" @click="isMenuOpen = true">
<IconifyIcon icon="mdi:menu" class="text-2xl" /> <IconifyIcon icon="mdi:menu" class="text-2xl"/>
</button> </button>
<div v-if="isMenuOpen" class="mobile-menu-backdrop" @click="isMenuOpen = false" /> <div v-if="isMenuOpen" class="mobile-menu-backdrop" @click="isMenuOpen = false"/>
<aside v-if="isMenuOpen" class="mobile-sidebar"> <aside v-if="isMenuOpen" class="mobile-sidebar" aria-label="Navigation mobile">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="logo-container"> <div class="logo-container">
<img <img
:src="logoSrc" :src="logoSrc"
alt="Logo Malio" alt="Logo Malio"
class="logo" class="logo"
/> >
</div> </div>
<div class="sidebar-divider" /> <div class="brand-copy">
<p class="brand-kicker">Control Center</p>
<p class="brand-title">Supervisor</p>
<p class="brand-description">
Tableau de bord interne pour le monitoring et les sauvegardes.
</p>
</div>
<div class="sidebar-divider"/>
</div> </div>
<div class="sidebar-content"> <div class="sidebar-content">
<slot name="sidebar" /> <slot name="sidebar"/>
<nav class="sidebar-nav" aria-label="Sections mobiles">
<p class="nav-label">Navigation</p>
<NuxtLink
v-for="item in navItems"
:key="`mobile-${item.to}`"
v-slot="{ href, navigate, isExactActive }"
:to="item.to"
custom
>
<a
:href="href"
class="nav-link"
:class="{ 'nav-link-active': isExactActive }"
:aria-current="isExactActive ? 'page' : undefined"
@click="navigate(); isMenuOpen = false"
>
<span class="nav-link-main">
<span class="nav-icon">
<IconifyIcon :icon="item.icon" class="text-lg"/>
</span>
<span>
<span class="nav-title">{{ item.label }}</span>
<span class="nav-caption">{{ item.caption }}</span>
</span>
</span>
<span class="nav-pill">{{ item.short }}</span>
</a>
</NuxtLink>
</nav>
</div> </div>
<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">
Navigation rapide vers les vues principales de supervision.
</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 v1.0 Supervisor {{ appVersion }}
</p> </p>
<button class="close-button" type="button" @click="isMenuOpen = false"> <button class="close-button" type="button" @click="isMenuOpen = false">
<IconifyIcon icon="mdi:close" class="text-xl" /> <IconifyIcon icon="mdi:close" class="text-xl"/>
</button> </button>
</div> </div>
</div> </div>
</aside> </aside>
<main class="content bg-noise"> <main class="content bg-noise">
<slot /> <slot/>
</main> </main>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue" import {Icon as IconifyIcon} from "@iconify/vue"
import { Icon as IconifyIcon } from "@iconify/vue"
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png' import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
const {
public: {appVersion}
} = useRuntimeConfig()
const isMenuOpen = ref(false) const isMenuOpen = ref(false)
const navItems = [
{
to: "/",
label: "Monitoring",
caption: "Etat global et disponibilite",
short: "MON",
icon: "mdi:chart-box-outline"
},
{
to: "/backup",
label: "Backup",
caption: "Scripts et fichiers archives",
short: "BKP",
icon: "mdi:database-arrow-up-outline"
}
]
</script> </script>
<style scoped> <style scoped>
@@ -81,9 +181,9 @@ const isMenuOpen = ref(false)
.sidebar, .sidebar,
.mobile-sidebar { .mobile-sidebar {
background: linear-gradient( background: linear-gradient(
180deg, 180deg,
rgb(var(--m-sidebar-from)) 0%, rgb(var(--m-sidebar-from)) 0%,
rgb(var(--m-sidebar-to)) 100% rgb(var(--m-sidebar-to)) 100%
); );
color: white; color: white;
display: flex; display: flex;
@@ -110,6 +210,39 @@ const isMenuOpen = ref(false)
padding: 0.5rem 0; padding: 0.5rem 0;
} }
.brand-copy {
padding: 0.5rem 0 0.75rem;
text-align: center;
}
.brand-kicker {
margin: 0;
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgb(var(--m-accent));
}
.brand-title {
margin: 0.45rem 0 0;
font-family: var(--font-display);
font-size: 1.45rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.sidebar .brand-title {
margin-top: 0;
}
.brand-description {
margin: 0.55rem 0 0;
color: rgb(255 255 255 / 0.58);
line-height: 1.6;
font-size: 0.92rem;
}
.logo { .logo {
height: 100px; height: 100px;
width: 100px; width: 100px;
@@ -124,21 +257,22 @@ const isMenuOpen = ref(false)
.sidebar-content { .sidebar-content {
flex: 1; flex: 1;
padding: 0.5rem 0; padding: 0.75rem 1rem 1rem;
} }
.sidebar-footer { .sidebar-footer {
flex-shrink: 0; flex-shrink: 0;
padding-bottom: 0.5rem;
} }
.sidebar-divider { .sidebar-divider {
height: 1px; height: 1px;
margin: 0.75rem 1.5rem; margin: 0.75rem 1.5rem;
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
transparent, transparent,
var(--m-sidebar-divider), var(--m-sidebar-divider),
transparent transparent
); );
} }
@@ -149,8 +283,151 @@ const isMenuOpen = ref(false)
padding: 0.5rem 1.5rem 0.75rem; padding: 0.5rem 1.5rem 0.75rem;
} }
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.nav-label {
margin: 0;
padding: 0 0.75rem 0.25rem;
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgb(255 255 255 / 0.38);
}
.nav-link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem 0.9rem;
border-radius: 14px;
border: 1px solid transparent;
color: white;
text-decoration: none;
transition: background-color 0.2s ease,
border-color 0.2s ease,
transform 0.2s ease,
box-shadow 0.2s ease;
}
.nav-link:hover {
background: rgb(255 255 255 / 0.06);
border-color: rgb(var(--m-accent) / 0.14);
transform: translateY(-1px);
}
.nav-link:focus-visible {
outline: 2px solid rgb(var(--m-accent));
outline-offset: 2px;
background: rgb(255 255 255 / 0.08);
}
.nav-link-active {
background: linear-gradient(135deg, rgb(var(--m-accent) / 0.16), rgb(255 255 255 / 0.04));
border-color: rgb(var(--m-accent) / 0.24);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04);
}
.nav-link-active .nav-icon {
background: rgb(var(--m-accent) / 0.18);
color: white;
}
.nav-link-active .nav-pill {
background: rgb(var(--m-accent) / 0.18);
color: white;
}
.nav-link-main {
display: flex;
align-items: center;
gap: 0.8rem;
min-width: 0;
}
.nav-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 12px;
background: rgb(255 255 255 / 0.06);
color: rgb(var(--m-accent));
flex-shrink: 0;
}
.nav-title,
.nav-caption {
display: block;
}
.nav-title {
font-size: 0.96rem;
font-weight: 600;
color: white;
}
.nav-caption {
margin-top: 0.2rem;
font-size: 0.78rem;
line-height: 1.45;
color: rgb(255 255 255 / 0.5);
}
.nav-pill {
flex-shrink: 0;
padding: 0.28rem 0.45rem;
border-radius: 999px;
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.08em;
color: rgb(255 255 255 / 0.62);
background: rgb(255 255 255 / 0.06);
}
.status-card {
margin: 0 1rem;
padding: 1rem;
border-radius: 16px;
border: 1px solid rgb(var(--m-accent) / 0.14);
background: radial-gradient(circle at top right, rgb(var(--m-accent) / 0.14), transparent 30%),
rgb(255 255 255 / 0.04);
}
.status-label {
margin: 0;
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgb(255 255 255 / 0.42);
}
.status-value {
margin: 0.5rem 0 0;
font-size: 1rem;
font-weight: 700;
color: white;
}
.status-description {
margin: 0.45rem 0 0;
font-size: 0.82rem;
line-height: 1.55;
color: rgb(255 255 255 / 0.54);
}
.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;
@@ -208,6 +485,11 @@ const isMenuOpen = ref(false)
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.38); box-shadow: 0 18px 60px rgba(0, 0, 0, 0.38);
} }
.sidebar-content {
padding-right: 0.9rem;
padding-left: 0.9rem;
}
.close-button { .close-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -219,5 +501,9 @@ const isMenuOpen = ref(false)
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
color: white; color: white;
} }
.nav-link {
padding: 0.8rem 0.85rem;
}
} }
</style> </style>

View File

@@ -1,10 +1,48 @@
import { execSync } from "node:child_process"
import tailwindcss from "@tailwindcss/vite" import tailwindcss from "@tailwindcss/vite"
const getRepoVersion = () => {
try {
const tags = execSync(
"git for-each-ref --sort=-version:refname --format='%(refname:short)' refs/tags",
{ encoding: "utf8" }
)
.split("\n")
.map((tag) => tag.trim())
.filter(Boolean)
return tags[0] || "dev"
} catch {
return "dev"
}
}
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: '2025-07-15', compatibilityDate: "2025-07-15",
devtools: { enabled: true }, devtools: { enabled: true },
css: ["~/assets/css/main.css"], css: ["~/assets/css/main.css"],
app: {
head: {
link: [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" },
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700;800;900&display=swap"
}
]
}
},
runtimeConfig: {
apiSecretKey: process.env.API_SECRET_KEY,
discordBotToken: process.env.DISCORD_BOT_TOKEN,
discordChannelId: process.env.DISCORD_CHANNEL_ID,
public: {
appVersion: getRepoVersion(),
apiKey: process.env.API_SECRET_KEY
}
},
vite: { vite: {
plugins: [tailwindcss()] plugins: [tailwindcss()]
} }

2490
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,28 @@
{ {
"name": "disk-monitor", "name": "supervisor",
"type": "module", "type": "module",
"private": true, "private": true,
"engines": {
"node": ">=20"
},
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build",
"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",
"nuxt": "^4.3.1", "@nuxt/eslint": "^1.15.2",
"vue": "^3.5.29", "nuxt": "^4.3.1"
"vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@semantic-release/changelog": "^6.0.3", "@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1", "@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.6",
"@semantic-release/release-notes-generator": "^14.1.0", "@semantic-release/release-notes-generator": "^14.1.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"semantic-release": "^25.0.3", "semantic-release": "^25.0.3",

411
pages/backup.vue Normal file
View File

@@ -0,0 +1,411 @@
<template>
<NuxtLayout name="default">
<div class="dashboard-container">
<header class="dashboard-header">
<div class="header-copy">
<p class="section-kicker">Operations</p>
<h1 class="font-display text-3xl font-bold tracking-tight text-m-text">
Backup
</h1>
<p class="header-description">
Centralisez la selection des dossiers, l'execution des scripts et le telechargement
des fichiers depuis une seule vue.
</p>
</div>
</header>
<section
class="status-strip animate-fade-in-up"
style="animation-delay: 100ms"
aria-label="Statut des sauvegardes"
>
<StatusBackup />
</section>
<div class="workspace-grid">
<section class="workspace-sidebar" aria-label="Commandes de sauvegarde">
<BackupButtonSee
class="animate-fade-in-up"
style="animation-delay: 120ms"
@select="selectedBackup = $event"
/>
<BackupRun
class="animate-fade-in-up"
style="animation-delay: 180ms"
@result="handleScriptResult"
/>
</section>
<section class="workspace-main" aria-labelledby="backup-files-title">
<div class="files-panel animate-fade-in-up" style="animation-delay: 240ms">
<div class="files-panel-header">
<div class="files-panel-copy">
<p class="section-kicker">Fichiers</p>
<h2 id="backup-files-title" class="files-panel-title">
Historique des sauvegardes
</h2>
<p class="files-panel-description">
Consultez les archives disponibles et telechargez le dernier backup du dossier selectionne.
</p>
</div>
<span
class="selection-pill"
:class="{ 'selection-pill-active': selectedBackup }"
>
{{ selectedBackup ? `Source ${selectedBackup}` : "Selection requise" }}
</span>
</div>
<BackupList :folder="selectedBackup" />
</div>
<section
class="files-panel output-panel animate-fade-in-up"
style="animation-delay: 300ms"
aria-labelledby="backup-output-title"
>
<div class="files-panel-header">
<div class="files-panel-copy">
<p class="section-kicker">Execution</p>
<h2 id="backup-output-title" class="files-panel-title">Resultat du script</h2>
<p class="files-panel-description">
Le retour du script apparait ici apres execution avec un etat clair en succes ou en erreur.
</p>
</div>
<span
class="panel-badge"
:class="{
'panel-badge-idle': !scriptResult.label,
'panel-badge-success': scriptResult.label && !scriptResult.isError,
'panel-badge-error': scriptResult.isError
}"
>
{{ scriptResult.label || "Pret" }}
</span>
</div>
<div
v-if="!scriptResult.output"
class="output-empty"
role="status"
aria-live="polite"
>
<p class="output-empty-title">Aucune sortie disponible</p>
<p class="output-empty-text">
Lancez un script depuis le panneau de gauche pour afficher son retour ici.
</p>
</div>
<pre
v-else
class="output-console"
:class="{ 'output-console-error': scriptResult.isError }"
>{{ scriptResult.output }}</pre>
</section>
</section>
</div>
</div>
</NuxtLayout>
</template>
<script setup lang="ts">
import { ref } from "vue"
import BackupRun from "~/components/BackupRun.vue"
import { apiFetch, downloadApiFile } from "~/composables/useApiAuth"
definePageMeta({ layout: false })
type ScriptResult = {
key: string | null
label: string
output: string
isError: boolean
downloadFolders: string[]
}
const emptyScriptResult = (): ScriptResult => ({
key: null,
label: "",
output: "",
isError: false,
downloadFolders: []
})
const selectedBackup = ref<string | null>(null)
const scriptResult = ref<ScriptResult>(emptyScriptResult())
const fetchLatestBackup = async (folder: string) => {
const files = await apiFetch<string[]>(`/api/backups?folder=${encodeURIComponent(folder)}`)
return files[0] || null
}
const triggerDownload = async (folder: string, file: string) => {
const url = `/api/download?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(file)}`
await downloadApiFile(url, file)
}
const triggerBatchDownload = async (folders: string[]) => {
const url = `/api/download-latest?folders=${encodeURIComponent(folders.join(","))}`
await downloadApiFile(url, "backup-latest.tar.gz")
}
const downloadLatestBackup = async (folder: string) => {
const latestFile = await fetchLatestBackup(folder)
if (latestFile) {
await triggerDownload(folder, latestFile)
}
}
const handleScriptResult = async (payload: ScriptResult) => {
scriptResult.value = { ...emptyScriptResult(), ...payload }
if (payload.isError || payload.downloadFolders.length === 0) {
return
}
if (payload.downloadFolders.length > 1) {
await triggerBatchDownload(payload.downloadFolders)
return
}
for (const folder of payload.downloadFolders) {
try {
await downloadLatestBackup(folder)
} catch (error) {
console.error(`Erreur telechargement automatique pour ${folder}:`, error)
}
}
}
</script>
<style scoped>
.dashboard-container {
padding: 2rem 2.5rem;
}
.dashboard-header {
margin-bottom: 1.5rem;
}
.header-copy {
min-width: 0;
max-width: 70ch;
}
.section-kicker {
margin: 0 0 0.5rem;
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;
}
.status-strip {
margin-bottom: 1.5rem;
}
.workspace-grid {
display: grid;
grid-template-columns: minmax(280px, 320px) minmax(0, 1fr);
gap: 1.5rem;
align-items: start;
}
.workspace-sidebar,
.workspace-main {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-width: 0;
}
.workspace-sidebar {
position: sticky;
top: 2rem;
}
.files-panel {
padding: 1.25rem;
border-radius: 20px;
background:
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 {
min-height: 220px;
}
.files-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.files-panel-copy {
min-width: 0;
}
.files-panel-title {
margin: 0;
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.files-panel-description {
margin: 0.5rem 0 0;
max-width: 54ch;
color: rgb(var(--m-muted));
line-height: 1.6;
}
.selection-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.25rem;
border-radius: 999px;
border: 1px solid rgb(var(--m-border) / 0.36);
background: rgb(var(--m-tertiary) / 0.45);
padding: 0.45rem 0.8rem;
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgb(var(--m-muted));
text-align: center;
}
.selection-pill-active {
border-color: rgb(var(--m-accent) / 0.2);
background: rgb(var(--m-accent) / 0.08);
color: rgb(var(--m-accent));
}
.panel-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.25rem;
border-radius: 999px;
padding: 0.35rem 0.7rem;
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
border: 1px solid transparent;
}
.panel-badge-idle {
color: rgb(var(--m-muted));
background: rgb(var(--m-tertiary) / 0.45);
border-color: rgb(var(--m-border) / 0.25);
}
.panel-badge-success {
color: rgb(var(--m-success));
background: rgb(var(--m-success) / 0.08);
border-color: rgb(var(--m-success) / 0.18);
}
.panel-badge-error {
color: rgb(var(--m-error));
background: rgb(var(--m-error) / 0.08);
border-color: rgb(var(--m-error) / 0.18);
}
.output-empty {
display: flex;
min-height: 132px;
flex-direction: column;
justify-content: center;
border-radius: 14px;
border: 1px dashed rgb(var(--m-border) / 0.55);
background: rgb(var(--m-tertiary) / 0.38);
padding: 1.25rem;
}
.output-empty-title {
margin: 0;
font-family: var(--font-display);
font-size: 1rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.output-empty-text {
margin: 0.5rem 0 0;
max-width: 52ch;
color: rgb(var(--m-muted));
line-height: 1.65;
}
.output-console {
margin: 0;
min-height: 132px;
overflow-x: auto;
border-radius: 14px;
border: 1px solid rgb(var(--m-border) / 0.3);
background:
linear-gradient(180deg, rgb(var(--m-bg) / 0.96), rgb(var(--m-secondary) / 0.92));
padding: 1rem 1.1rem;
font-family: var(--font-mono);
font-size: 0.78rem;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
color: rgb(var(--m-success));
}
.output-console-error {
color: rgb(var(--m-error));
}
@media (max-width: 1180px) {
.workspace-grid {
grid-template-columns: 1fr;
}
.workspace-sidebar {
position: static;
}
.files-panel-header {
align-items: flex-start;
flex-direction: column;
}
.selection-pill,
.panel-badge {
width: 100%;
}
}
@media (max-width: 820px) {
.dashboard-container {
padding: 4.5rem 1.25rem 1.25rem;
}
.files-panel {
padding: 1rem;
}
.files-panel-title {
font-size: 1.2rem;
}
}
</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>
@@ -30,22 +34,27 @@
<div class="dashboard-grid"> <div class="dashboard-grid">
<div class="grid-left"> <div class="grid-left">
<StatusSite class="animate-fade-in-up" style="animation-delay: 100ms" /> <StatusSite class="animate-fade-in-up" style="animation-delay: 100ms" />
<BackupButtonSee
class="animate-fade-in-up backup-selector"
style="animation-delay: 200ms"
@select="selectedBackup = $event"
/>
</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" />
<BackupList
class="animate-fade-in-up backup-list-mobile"
style="animation-delay: 250ms"
:folder="selectedBackup"
/>
</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">
@@ -57,8 +66,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { apiFetch } from "~/composables/useApiAuth"
import type { SystemMetrics } from "~/types/system";
definePageMeta({layout: false}) definePageMeta({layout: false})
import {computed, onMounted, ref} from "vue"
type DiskSourceResult = { type DiskSourceResult = {
key: string key: string
@@ -84,11 +95,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)
@@ -161,16 +174,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 {
@@ -178,8 +190,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>
@@ -193,8 +224,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 {
@@ -212,9 +259,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 {
@@ -239,12 +288,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) {
@@ -270,18 +328,11 @@ onMounted(() => {
.storage-grid, .storage-grid,
.content-grid, .content-grid,
.dashboard-grid { .dashboard-grid,
.metrics-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.backup-selector {
order: 2;
}
.backup-list-mobile {
order: 3;
}
.speedtest-card-mobile { .speedtest-card-mobile {
order: 4; order: 4;
} }

View File

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

View File

@@ -0,0 +1,12 @@
import { backupScripts } from "../utils/backup-scripts"
export default defineEventHandler(() => {
return {
scripts: backupScripts.map(({ key, label, icon, downloadFolders }) => ({
key,
label,
icon: icon || "mdi:play-circle-outline",
downloadFolders: downloadFolders || []
}))
}
})

View File

@@ -0,0 +1,69 @@
import { exec } from "node:child_process"
import { backupScripts, getBackupScriptCommand } from "../utils/backup-scripts"
function runCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => {
exec(command, { timeout: 10 * 60 * 1000 }, (error, stdout, stderr) => {
if (error) {
reject(stderr || error.message)
return
}
resolve(stdout || stderr)
})
})
}
export default defineEventHandler(async (event) => {
const body = await readBody<{ key?: string }>(event)
const key = typeof body?.key === "string" ? body.key : null
if (!key) {
throw createError({
statusCode: 400,
statusMessage: "Clé de script manquante"
})
}
const script = backupScripts.find((item) => item.key === key)
if (!script) {
throw createError({
statusCode: 404,
statusMessage: "Script introuvable"
})
}
try {
const command = getBackupScriptCommand(script.key)
if (!command) {
throw createError({
statusCode: 500,
statusMessage: "Commande de script manquante"
})
}
const output = await runCommand(command)
return {
ok: true,
key: script.key,
label: script.label,
downloadFolders: script.downloadFolders || [],
output: output.trim()
}
} catch (error) {
console.error("Erreur execution script:", error)
if (
typeof error === "object" &&
error !== null &&
"statusCode" in error &&
"statusMessage" in error
) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: "Erreur lors de l'opération"
})
}
})

View File

@@ -1,29 +1,13 @@
import { execFile } from "node:child_process" import {
import folderMap from "../config/backup-folders.json" runSsh,
shellQuote,
resolveFolderRemoteDir,
REMOTE_ROOT,
isSafeFolder,
} from "../utils/ssh.ts"
const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST || "malio-b@192.168.0.179" const MAX_FILES_PER_FOLDER = Math.max(1, Number(process.env.BACKUPS_MAX_FILES) || 50)
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 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 +15,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 +56,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 +80,7 @@ export default defineEventHandler(async (event) => {
} }
} }
// Sinon on récupère le dernier backup de chaque dossier distant. // Sinon, on récupère le dernier backup de chaque dossier distant.
let dirs: string[] = [] 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

@@ -1,15 +1,32 @@
export default defineEventHandler(async () => { export default defineEventHandler(async (event) => {
const token = process.env.DISCORD_BOT_TOKEN const config = useRuntimeConfig(event)
const channel = process.env.DISCORD_CHANNEL_ID const token = config.discordBotToken
const channel = config.discordChannelId
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`,
{ {
headers: { headers: {
Authorization: `Bot ${token}` Authorization: `Bot ${token}`
}
} }
}
) )
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,23 +1,49 @@
import { exec } from "child_process" import { execFile } from "node:child_process"
import diskSources from "../config/disk-commands.json"
type DiskSource = { type DiskSource = {
key: string key: "remote" | "local"
label: string label: string
}
type CommandSpec = {
command: string command: string
args: string[]
cwd?: string
} }
function getCommand(source: DiskSource) { const diskSources: DiskSource[] = [
const envKey = `DISK_COMMAND_${source.key.toUpperCase()}` {
const legacyEnvKey = key: "remote",
source.key === "remote" ? "DISK_REMOTE_COMMAND" : source.key === "local" ? "DISK_LOCAL_COMMAND" : "" label: "Serveur distant"
},
{
key: "local",
label: "Machine locale"
}
]
return process.env[envKey] || (legacyEnvKey ? process.env[legacyEnvKey] : undefined) || source.command function getCommand(source: DiskSource): CommandSpec {
const localScriptDir = process.env.DISK_LOCAL_SCRIPT_DIR || "/home/malio/Malio-ops/CheckStorage"
const remoteHost = process.env.DISK_REMOTE_HOST || "malio-b"
const remoteScriptDir = process.env.DISK_REMOTE_SCRIPT_DIR || "/home/malio-b/Malio-ops/CheckStorage"
if (source.key === "local") {
return {
command: "bash",
args: ["check-storage.sh"],
cwd: localScriptDir
}
}
return {
command: "ssh",
args: [remoteHost, `cd ${remoteScriptDir} && ./check-storage.sh`]
}
} }
function runCommand(command: string): Promise<string> { function runCommand({ command, args, cwd }: CommandSpec): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => { execFile(command, args, { cwd }, (error, stdout, stderr) => {
if (error) { if (error) {
reject(stderr || error.message) reject(stderr || error.message)
return return
@@ -29,7 +55,7 @@ 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 output = await runCommand(getCommand(source))
return { return {
@@ -39,11 +65,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

@@ -0,0 +1,100 @@
import {
runSsh,
shellQuote,
resolveFolderRemoteDir,
REMOTE_HOST,
isSafeFolder
} from "../utils/ssh.ts"
import { spawn } from "node:child_process"
async function getLatestRemoteFile(remoteDir: string): Promise<string | null> {
const output = await runSsh(`cd ${shellQuote(remoteDir)} && ls -1A | sort -r | head -n 1`)
const fileName = output.trim()
return fileName || null
}
function buildContentDisposition(fileName: string) {
const asciiName = fileName.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_")
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`
}
export default defineEventHandler(async (event) => {
const { folders } = getQuery(event)
const folderParam = typeof folders === "string" ? folders : ""
const folderNames = folderParam
.split(",")
.map((folder) => folder.trim())
.filter(Boolean)
if (folderNames.length === 0) {
throw createError({ statusCode: 400, statusMessage: "Paramètre folders invalide" })
}
if (folderNames.some((folder) => !isSafeFolder(folder))) {
throw createError({ statusCode: 400, statusMessage: "Paramètre folders invalide" })
}
const uniqueFolders = [...new Set(folderNames)]
const archiveEntries: Array<{ remoteDir: string; fileName: string; archiveName: string }> = []
for (const folderName of uniqueFolders) {
const remoteDir = await resolveFolderRemoteDir(folderName)
if (!remoteDir) {
continue
}
const fileName = await getLatestRemoteFile(remoteDir)
if (!fileName || !isSafeFolder(fileName)) {
continue
}
if (!fileName) {
continue
}
archiveEntries.push({
remoteDir,
fileName,
archiveName: `${folderName}/${fileName}`
})
}
if (archiveEntries.length === 0) {
throw createError({ statusCode: 404, statusMessage: "Aucun fichier a telecharger" })
}
const dateLabel = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-")
const archiveName = `backup-latest-${dateLabel}.tar.gz`
const tarArgs = archiveEntries.flatMap(({ remoteDir, fileName, archiveName: entryName }) => [
"--transform",
shellQuote(`s|^${fileName}$|${entryName}|`),
"-C",
shellQuote(remoteDir),
shellQuote(fileName)
])
const remoteCommand = `tar -czf - ${tarArgs.join(" ")}`
setHeader(event, "Content-Type", "application/gzip")
setHeader(event, "Content-Disposition", buildContentDisposition(archiveName))
const child = spawn("ssh", [
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=5",
REMOTE_HOST,
remoteCommand
])
let stderr = ""
child.stderr.on("data", (chunk) => {
stderr += chunk.toString()
})
child.on("close", (code) => {
if (code !== 0) {
console.error(`Erreur archive SSH (${code}): ${stderr}`)
}
})
event.node.res.on("close", () => child.kill())
return sendStream(event, child.stdout)
})

View File

@@ -1,92 +1,25 @@
import { execFile, spawn } from "node:child_process" import {
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@192.168.0.179" REMOTE_HOST,
const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups" isSafeFolder,
const FOLDER_MAP = folderMap as Record<string, string> isSafeFile
} from "../utils/ssh.ts"
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value) import { spawn } from "node:child_process"
const isSafeFile = (value: string) => /^[^/\\]+$/.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)) {
@@ -126,6 +59,6 @@ export default defineEventHandler(async (event) => {
console.error(`Erreur téléchargement SSH (${code}): ${stderr}`) console.error(`Erreur téléchargement SSH (${code}): ${stderr}`)
} }
}) })
event.node.res.on("close", () => child.kill())
return sendStream(event, child.stdout) return sendStream(event, child.stdout)
}) })

View File

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

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

View File

@@ -1,12 +1,18 @@
import targets from "../config/version-status-targets.json" import targets from "../config/version-status-targets.json"
const REQUEST_TIMEOUT_MS = 5000
export default defineEventHandler(async () => { export default defineEventHandler(async () => {
const results = await Promise.all( const results = await Promise.all(
targets.map(async (target) => { targets.map(async (target) => {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
try { try {
const response = await fetch(target.url, { const response = await fetch(target.url, {
method: "GET", method: "GET",
headers: { Accept: "application/json" } headers: { Accept: "application/json" },
signal: controller.signal
}) })
return { return {
@@ -25,6 +31,8 @@ export default defineEventHandler(async () => {
checkedAt: new Date().toISOString(), checkedAt: new Date().toISOString(),
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
} }
} finally {
clearTimeout(timeoutId)
} }
}) })
) )

View File

@@ -1,7 +1,7 @@
{ {
"ferme": "bdd_recette/ferme", "ferme": "bdd-recette/ferme",
"inventory": "bdd_recette/inventory", "inventory": "bdd-recette/inventory",
"sirh": "bdd_recette/sirh", "sirh": "bdd-recette/sirh",
"user": "bdd_recette/user", "user": "bdd-recette/user",
"bitwarden": "bitwarden" "bitwarden": "bitwarden"
} }

View File

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

View File

@@ -0,0 +1,33 @@
// SECURITE:
// Ce middleware pose automatiquement le cookie d'authentification pour tout
// visiteur de l'interface web. Ce comportement repose sur l'hypothèse que
// Supervisor n'est exposé qu'à un réseau de confiance ou derrière un contrôle
// d'accès externe. Si l'application devient publiquement accessible, ce
// mécanisme ne constitue pas une authentification utilisateur.
export default defineEventHandler((event) => {
const path = event.path || event.node.req.url || ""
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,45 @@
export type BackupScript = {
key: string
label: string
icon?: string
downloadFolders?: string[]
}
export const backupScripts: BackupScript[] = [
{
key: "backup-bdd-recette",
label: "Backup BDD recette",
icon: "mdi:database-export",
downloadFolders: ["ferme", "inventory", "sirh", "user"]
},
{
key: "check-statut-recette",
label: "Check statut recette",
icon: "mdi:server-network"
},
{
key: "backup-vaultwarden",
label: "Backup vaultwarden",
icon: "mdi:data",
downloadFolders: ["bitwarden"]
}
]
const getDefaultBackupScriptCommands = (): Record<string, string> => {
const recetteScriptsDir = process.env.RECETTE_SCRIPTS_DIR || "/home/malio/Malio-ops/RecetteScripts"
const vaultwardenHost = process.env.VAULTWARDEN_SSH_HOST || "bitwarden"
const vaultwardenScriptsDir =
process.env.VAULTWARDEN_SCRIPTS_DIR || "/home/matt/vaultwarden/Malio-ops/BackupVaultWarden"
return {
"backup-bdd-recette": `cd ${recetteScriptsDir} && bash backup-bdd-recette.sh`,
"check-statut-recette": `cd ${recetteScriptsDir} && bash check-statut-recette.sh`,
"backup-vaultwarden":
`ssh ${vaultwardenHost} "cd ${vaultwardenScriptsDir} && bash backup-vaultwarden.sh"`
}
}
export function getBackupScriptCommand(key: string) {
const envKey = `BACKUP_SCRIPT_COMMAND_${key.toUpperCase().replace(/-/g, "_")}`
return process.env[envKey] || getDefaultBackupScriptCommands()[key] || null
}

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

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

235
solution.md Normal file
View File

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

9
types/system.ts Normal file
View File

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