54 Commits

Author SHA1 Message Date
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
fb96cc3c32 chore(release) : configure semantic-release 2026-03-10 08:18:20 +01:00
aace834dd4 feat : readme et agents 2026-03-09 15:42:13 +01:00
f5cc79f510 feat : new ui et message discord 2026-03-09 15:27:18 +01: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
db738715c3 feat : ajout download backup 2026-03-09 10:50:41 +01:00
52 changed files with 13263 additions and 467 deletions

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# API_SECRET_KEy sert à sécuriser l'accès à l'API de votre application.
API_SECRET_KEY=
# DISCORD_BOT_TOKEN & DISCORD_CHANNEL_ID pour le bot discord
DISCORD_BOT_TOKEN=
DISCORD_CHANNEL_ID=
# BACKUPS_REMOTE_HOST, BACKUPS_REMOTE_ROOT et BACKUPS_MAX_FILES pour la gestion des backups
BACKUPS_REMOTE_HOST=
BACKUPS_REMOTE_ROOT=
BACKUPS_MAX_FILES=
# DISK_COMMAND_REMOTE et DISK_COMMAND_LOCAL pour les commandes de vérification de l'espace disque
DISK_COMMAND_REMOTE=
DISK_COMMAND_LOCAL=
# BACKUP_SCRIPT_COMMAND_BACKUP_BDD_RECETTE, BACKUP_SCRIPT_COMMAND_CHECK_STATUT_RECETTE et BACKUP_SCRIPT_COMMAND_BACKUP_VAULTWARDEN pour les commandes de backup et de vérification des statuts
BACKUP_SCRIPT_COMMAND_BACKUP_BDD_RECETTE=
BACKUP_SCRIPT_COMMAND_CHECK_STATUT_RECETTE=
BACKUP_SCRIPT_COMMAND_BACKUP_VAULTWARDEN=
# A quelle heure les backups doivent être effectués (format 24h)
BACKUPS_HOUR=19

24
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: Release
on:
push:
branches:
- develop
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: npm
- run: npm ci
- run: npx semantic-release

19
.releaserc.json Normal file
View File

@@ -0,0 +1,19 @@
{
"branches": [
"develop"
],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
[
"@semantic-release/git",
{
"assets": [
"CHANGELOG.md"
],
"message": "chore(release): ${nextRelease.version}"
}
]
]
}

193
AGENTS.md
View File

@@ -1,54 +1,159 @@
# Repository Guidelines
# AGENTS.md
## Project Structure & Module Organization
This repository is a Nuxt 4 application.
- `pages/`: route pages (for example `pages/index.vue`).
- `components/`: reusable Vue components (for example `DiskSidebarWidget.vue`, `ApiStatusBubble.vue`).
- `layouts/`: shared page shells (`layouts/default.vue`).
- `server/api/`: Nitro server endpoints (for example `disk.get.ts`, `version-status.get.ts`).
- `assets/css/`: global styles and theme tokens (`main.css`, `malio.css`).
- `public/`: static files served as-is.
## Objectif
Keep feature logic close to usage: UI in `components/`, page composition in `pages/`, backend checks in `server/api/`.
Ce fichier définit les règles que tout agent de code doit respecter dans ce dépôt.
## Build, Test, and Development Commands
Use npm scripts from `package.json`:
- `npm run dev`: start local dev server with hot reload.
- `npm run build`: production build (client + server).
- `npm run preview`: run the built app locally.
- `npm run generate`: static generation when needed.
Les objectifs sont :
- maintenir un code clair et cohérent
- éviter la complexité inutile
- empêcher la génération de code mort
- garantir des modifications prévisibles
There is no dedicated test script currently. At minimum, run `npm run build` before opening a PR.
Ce fichier est la source de vérité concernant le comportement de lagent.
## Coding Style & Naming Conventions
- Language: TypeScript + Vue SFCs (`<script setup lang="ts">`).
- Indentation: 2 spaces.
- Prefer double quotes in TS files to match current codebase.
- Components: PascalCase file names (`ApiStatusBubble.vue`).
- API handlers: kebab-case + HTTP suffix (`version-status.get.ts`).
- Use Tailwind utility classes and project color tokens (`text-m-error`, `bg-m-success`, `text-m-primary`).
## Testing Guidelines
No automated framework is configured yet. Use these checks:
- Build validation: `npm run build`.
- Manual smoke test: open `/`, verify API status cards refresh and disk sidebar renders.
- For new endpoints, validate response shape in browser/network tab or `curl`.
## Principes généraux
## Commit & Pull Request Guidelines
Git history is not available in this workspace snapshot, so use this convention:
- Commit format: `type(scope): short summary`.
- Example: `feat(api): add labeled multi-endpoint status check`.
- Types: `feat`, `fix`, `refactor`, `style`, `docs`, `chore`.
Toujours produire un code clair, simple et minimal.
PRs should include:
- What changed and why.
- Affected paths (e.g. `server/api/version-status.get.ts`).
- UI screenshots for visual changes.
- Verification steps (commands run, expected result).
Privilégier les implémentations explicites plutôt que les abstractions.
## Security & Configuration Notes
- Do not hardcode secrets in source.
- Prefer environment variables for private endpoints/tokens.
- Keep external API checks server-side (`server/api/*`) to avoid exposing sensitive details in the client.
Ne jamais introduire de nouveaux patterns si un pattern existant résout déjà le problème.
Ne lance pas de build si je le dit pas
Ne jamais générer :
- du code mort
- du code inutilisé
- du code spéculatif
- des fonctionnalités non demandées
## Lecture obligatoire avant modification
Avant toute modification de code, lagent doit :
Lire entièrement le fichier à modifier.
Comprendre la structure et les conventions existantes.
Respecter :
- les conventions de nommage
- le style du fichier
- larchitecture déjà utilisée
Vérifier les fichiers liés pouvant être impactés.
Ne jamais proposer une modification sur du code qui na pas été lu.
## Règles de modification
Lors de lédition de code :
Respecter larchitecture existante.
Réutiliser les utilitaires et fonctions déjà présents lorsque cest possible.
Ne pas ajouter de dépendances sauf nécessité réelle.
Ne pas refactoriser du code non lié à la demande.
Faire des modifications minimales et ciblées.
Toujours préférer modifier du code existant plutôt que créer une logique parallèle.
## Qualité du code
Le code généré doit garantir :
Aucun code mort.
Aucune variable inutilisée.
Aucune branche inaccessible.
Aucune duplication inutile.
Aucune abstraction prématurée.
Si une logique nest pas utilisée immédiatement, elle ne doit pas être écrite.
## Cohérence architecturale
Toujours suivre les conventions du dépôt.
Respecter notamment :
les conventions de nommage
lorganisation des fichiers
les patterns architecturaux existants
les composants ou modules déjà présents
## Sécurité Git
Lagent ne doit jamais :
créer un commit sans demande explicite
push des modifications
force push
modifier la configuration git
réécrire lhistorique
## Sécurité
Ne jamais introduire dans le code :
des secrets
des identifiants
des tokens
des variables denvironnement sensibles
## Prise de décision
Si plusieurs implémentations sont possibles :
choisir la solution la plus simple
choisir la solution la plus cohérente avec le code existant
éviter dintroduire un nouveau pattern architectural
## Actions interdites
Lagent ne doit jamais :
ajouter des fonctionnalités non demandées
introduire du code mort
introduire des abstractions prématurées
modifier des fichiers non liés
réécrire de larges parties du projet sans instruction explicite
## Résultat attendu
Le code généré doit être :
lisible
minimal
cohérent avec le dépôt
immédiatement utilisable

60
CHANGELOG.md Normal file
View File

@@ -0,0 +1,60 @@
## [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))

164
README.md
View File

@@ -1,75 +1,129 @@
# Nuxt Minimal Starter
# Supervisor
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
`Supervisor` est une application Nuxt qui centralise plusieurs besoins d'exploitation dans une interface web unique :
## Setup
- 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
Make sure to install dependencies:
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
### Windows
Sur Windows, installer WSL2, Ubuntu, Docker et nvm.
Suivre la documentation suivante :
https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows
### Linux
Sur Linux, installer Docker et nvm.
Suivre la documentation suivante :
https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux
### Installation du projet
Une fois les prérequis installés, cloner le dépôt puis installer les dépendances.
Les étapes ci-dessous sont celles qui sont réellement supportées par le depot.
### 1. Cloner le depot
```bash
git clone gitea@gitea.malio.fr:MALIO-DEV/Supervisor.git
cd Supervisor
```
### 2. Preparer le fichier d'environnement
Le depot fournit un exemple dans `.env.example`.
```bash
cp .env.example .env
```
### 3. Renseigner les variables necessaires
#### Generation d'une valeur pour `API_SECRET_KEY`
Le depot impose la presence d'un secret, mais ne fournit pas de commande officielle pour le générer.
Exemple de commande compatible :
```bash
openssl rand -hex 32
```
Cette commande sert simplement à produire une valeur aléatoire facile à placer dans `.env`.
Les variables visibles dans le depot sont :
- `API_SECRET_KEY` : secret attendu par le middleware d'authentification pour toutes les routes `/api/*` sauf `/api/ping`
- `DISCORD_BOT_TOKEN` : token du bot utilise par endpoint Discord
- `DISCORD_CHANNEL_ID` : identifiant du canal Discord a lire
- `BACKUPS_REMOTE_HOST` : hôte SSH cible pour les operations distantes
- `BACKUPS_REMOTE_ROOT` : dossier racine des sauvegardes sur l'hôte distant
- `BACKUPS_MAX_FILES` : nombre maximal de fichiers retournés par dossier de backup
- `DISK_COMMAND_REMOTE` : commande shell utilisée pour la verification disque distante
- `DISK_COMMAND_LOCAL` : commande shell utilisée pour la verification disque locale
- `BACKUP_SCRIPT_COMMAND_BACKUP_BDD_RECETTE` : commande a exécuter pour le script "Backup BDD recette"
- `BACKUP_SCRIPT_COMMAND_CHECK_STATUT_RECETTE` : commande à exécuter pour le script "Check statut recette"
- `BACKUP_SCRIPT_COMMAND_BACKUP_VAULTWARDEN` : commande à exécuter pour le script "Backup vault warden"
- `BACKUPS_HOUR` : heure attendue des sauvegardes pour le contrôle de fraicheur
### 4. Installer les dépendances
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
### 5. Lancer le serveur de développement
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Par défaut, l'application Nuxt sera accessible sûr <http://localhost:3000>.
Build the application for production:
## Configuration necessaire
### Authentification API
Le middleware `server/middleware/auth.ts` protege toutes les routes `/api/*`, sauf `/api/ping`.
Consequence visible :
- si `API_SECRET_KEY` est vide, les appels API sont refusés avec `401 Unauthorized`
- l'application web pose aussi un cookie HTTP-only via `server/middleware/auth-cookie.ts` pour réutiliser ce secret coté navigateur
### 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 déclarées dans `package.json` :
```bash
# npm
npm run dev
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run generate
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
npm run lint
npm run lint:fix
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
Usage :
- `npm run dev` : lance l'application en développement
- `npm run build` : construit l'application pour la production
- `npm run generate` : généré une sortie statique si ce mode est compatible avec votre usage
- `npm run preview` : prévisualisé le build Nuxt
- `npm run lint` : execute ESLint
- `npm run lint:fix` : applique les corrections ESLint automatiques : collecte périodique CPU, mémoire et réseau

BIN
assets/LOGO_CARRE_BLANC.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -13,15 +13,196 @@
--color-m-bg: rgb(var(--m-bg));
--color-m-error: rgb(var(--m-error));
--color-m-success: rgb(var(--m-success));
/* Alias pour la faute de frappe courante "m-succes" */
--color-m-accent: rgb(var(--m-accent));
--color-m-warning: rgb(var(--m-warning));
--color-m-succes: rgb(var(--m-success));
--font-display: "Outfit", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
}
@layer base {
html {
background: rgb(var(--m-bg));
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
font-family: var(--font-display);
background: rgb(var(--m-bg));
background-image:
radial-gradient(circle at top left, rgb(var(--m-accent) / 0.1), transparent 24%),
radial-gradient(circle at top right, rgb(var(--m-success) / 0.08), transparent 18%);
color: rgb(var(--m-text));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color 0.4s ease, color 0.4s ease;
}
::selection {
background: rgb(var(--m-accent) / 0.28);
color: rgb(var(--m-text));
}
a,
button {
transition:
color 0.2s ease,
background-color 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
:focus-visible {
outline: 2px solid rgb(var(--m-accent) / 0.85);
outline-offset: 2px;
}
img {
display: block;
}
}
@layer utilities {
.bg-grid {
background-image:
linear-gradient(to right, rgba(148, 163, 184, 0.12) 1px, transparent 1px),
linear-gradient(to bottom, rgba(148, 163, 184, 0.12) 1px, transparent 1px);
linear-gradient(to right, rgb(var(--m-accent) / 0.04) 1px, transparent 1px),
linear-gradient(to bottom, rgb(var(--m-accent) / 0.04) 1px, transparent 1px);
background-size: 32px 32px;
}
.bg-noise {
position: relative;
}
.bg-noise::before {
content: "";
position: absolute;
inset: 0;
opacity: 0.025;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 0;
}
.card-glow {
box-shadow:
0 0 0 1px rgb(var(--m-accent) / var(--m-card-border-opacity)),
0 4px 24px -4px rgba(0, 0, 0, var(--m-shadow-opacity)),
0 0 48px -12px rgb(var(--m-accent) / 0.06);
transition: box-shadow 0.3s ease;
}
.card-glow:hover {
box-shadow:
0 0 0 1px rgb(var(--m-accent) / calc(var(--m-card-border-opacity) + 0.04)),
0 10px 30px -10px rgba(0, 0, 0, calc(var(--m-shadow-opacity) + 0.08)),
0 0 56px -14px rgb(var(--m-accent) / 0.1);
}
.card-glow-success {
box-shadow:
0 0 0 1px rgb(var(--m-success) / 0.15),
0 4px 24px -4px rgba(0, 0, 0, var(--m-shadow-opacity));
}
.card-glow-error {
box-shadow:
0 0 0 1px rgb(var(--m-error) / 0.15),
0 4px 24px -4px rgba(0, 0, 0, var(--m-shadow-opacity));
}
.text-gradient {
background: linear-gradient(135deg, rgb(var(--m-accent)), rgb(var(--m-success)));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse-glow {
0%,
100% {
box-shadow: 0 0 4px currentColor, 0 0 8px currentColor;
opacity: 1;
}
50% {
box-shadow: 0 0 8px currentColor, 0 0 16px currentColor;
opacity: 0.7;
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.animate-fade-in-up {
animation: fade-in-up 0.5s ease-out both;
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
.animate-shimmer {
background: linear-gradient(
90deg,
var(--m-shimmer-from) 0%,
var(--m-shimmer-to) 50%,
var(--m-shimmer-from) 100%
);
background-size: 200% 100%;
animation: shimmer 2s ease-in-out infinite;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: rgb(var(--m-bg));
}
::-webkit-scrollbar-thumb {
background: rgb(var(--m-border));
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(var(--m-muted));
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -1,19 +1,24 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Couleurs en RGB “space separated” pour Tailwind */
--m-primary: 34 39 131; /* Couleur principal*/
--m-secondary: 48 73 152; /* Couleur secondaire */
--m-tertiary: 243 244 248; /* Couleur tertiaire (background) */
--m-border: 203 213 225; /* Couleur des bordures */
--m-text: 15 23 42; /* Couleur du texte */
--m-muted: 100 116 139; /* Couleur pour les éléments désactivés ou secondaires */
--m-bg: 243 244 248; /* Couleur de fond générale */
--m-error: 212 0 21; /* rouge pour les erreurs */
--m-success: 15 200 73; /* vert pour les succès */
}
:root {
--m-primary: 15 20 40;
--m-secondary: 22 30 55;
--m-tertiary: 30 41 72;
--m-border: 55 70 110;
--m-text: 220 225 240;
--m-muted: 130 145 175;
--m-bg: 10 14 30;
--m-error: 255 70 70;
--m-success: 0 220 180;
--m-accent: 80 140 255;
--m-warning: 255 180 50;
--m-shadow-opacity: 0.4;
--m-glow-opacity: 0.1;
--m-card-border-opacity: 0.1;
--m-shimmer-from: rgba(80, 140, 255, 0.05);
--m-shimmer-to: rgba(80, 140, 255, 0.12);
--m-sidebar-from: 15 20 40;
--m-sidebar-to: 15 18 35;
--m-sidebar-border: rgba(80, 140, 255, 0.08);
--m-sidebar-divider: rgba(80, 140, 255, 0.2);
}
}

View File

@@ -0,0 +1,99 @@
<template>
<div class="backup-card card-glow">
<div class="card-header">
<h2 class="card-title">Backup</h2>
<span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Dossiers</span>
</div>
<div class="backup-list">
<button
v-for="item in folders"
:key="item.name"
type="button"
class="backup-btn"
:class="{ 'backup-btn-active': active === item.name }"
@click="select(item.name)"
>
<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.name }}
</span>
</div>
<IconifyIcon
icon="mdi:chevron-right"
class="text-lg text-m-muted transition-transform duration-200"
:class="{ 'translate-x-0.5 !text-m-accent': active === item.name }"
/>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { Icon as IconifyIcon } from "@iconify/vue"
import backupOptions from "~/server/config/backup-options.json"
const emit = defineEmits(["select"])
const active = ref<string | null>(null)
const folders = backupOptions as Array<{ name: string; icon: string }>
const select = (name: string) => {
active.value = name
emit("select", name)
}
</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: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);
}
</style>

178
components/BackupList.vue Normal file
View File

@@ -0,0 +1,178 @@
<template>
<div class="backup-list-card card-glow">
<div class="card-header">
<h2 class="card-title">{{ title }}</h2>
</div>
<div v-if="!folder" class="empty-state">
<IconifyIcon icon="mdi:folder-open-outline" class="text-3xl text-m-muted/40" />
<p class="mt-2 font-mono text-xs text-m-muted/50">
Selectionnez un dossier
</p>
</div>
<div v-else-if="loading" class="file-list">
<div
v-for="n in 6"
:key="`backup-skeleton-${n}`"
class="file-row animate-shimmer"
>
<TextSkeleton custom-class="h-4 w-48" />
<CircleSkeleton custom-class="h-5 w-5 rounded" />
</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">
<IconifyIcon icon="mdi:file-hidden" class="text-3xl text-m-muted/40" />
<p class="mt-2 font-mono text-xs text-m-muted/50">
Aucun backup trouve
</p>
</div>
<div v-else class="file-list">
<button
v-for="file in backups"
:key="file"
class="file-row"
@click="downloadBackup(file)"
>
<div class="flex min-w-0 items-center gap-2.5">
<IconifyIcon icon="mdi:file-document-outline" class="text-base text-m-accent flex-shrink-0" />
<span class="truncate font-mono text-xs text-m-text">
{{ file }}
</span>
</div>
<IconifyIcon
icon="mdi:download"
class="text-base text-m-muted flex-shrink-0 transition-colors duration-200"
/>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import {Icon as IconifyIcon} from "@iconify/vue"
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
import { apiFetch, downloadApiFile } from "~/composables/useApiAuth"
const props = defineProps<{
folder: string | null
}>()
const backups = ref<string[]>([])
const loading = ref(false)
const errorMessage = ref("")
const title = computed(() => {
if (!props.folder) return "Fichiers"
return `Backup — ${props.folder.toUpperCase()}`
})
const downloadBackup = async (file: string) => {
if (!props.folder) return
const url = `/api/download?folder=${encodeURIComponent(props.folder)}&file=${encodeURIComponent(file)}`
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) => {
if (!folder) {
loading.value = false
backups.value = []
errorMessage.value = ""
return
}
loading.value = true
errorMessage.value = ""
try {
const data = await apiFetch<string[]>(`/api/backups?folder=${encodeURIComponent(folder)}`)
backups.value = data
} catch (error) {
console.error("Erreur récupération backups:", error)
backups.value = []
errorMessage.value = "Erreur lors de l'opération"
} finally {
loading.value = false
}
})
</script>
<style scoped>
.backup-list-card {
background: rgb(var(--m-secondary));
border-radius: 12px;
padding: 1.25rem;
transition: background-color 0.4s ease;
}
.card-header {
margin-bottom: 0.75rem;
}
.card-title {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
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 {
display: flex;
flex-direction: column;
gap: 0.375rem;
max-height: calc((2.875rem * 5) + (0.375rem * 4));
overflow-y: auto;
padding-right: 0.25rem;
}
.file-row {
display: flex;
width: 100%;
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;
}
.file-row:hover {
border-color: rgb(var(--m-accent) / 0.15);
background: rgb(var(--m-accent) / 0.06);
}
.file-row:hover .text-m-muted {
color: rgb(var(--m-accent));
}
</style>

295
components/BackupRun.vue Normal file
View File

@@ -0,0 +1,295 @@
<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 { computed, onMounted, ref } from "vue"
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 output = ref<string>("")
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 = ""
output.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
output.value = ""
message.value = ""
isError.value = false
try {
const data = await apiFetch<BackupScriptRunResponse>("/api/backup-script", {
method: "POST",
body: { key }
})
message.value = `${data.label} execute avec succes`
output.value = data.output || "Aucune sortie retournee."
emit("result", {
key: data.key,
label: data.label,
output: output.value,
isError: false,
downloadFolders: data.downloadFolders || []
})
} catch (error: unknown) {
isError.value = true
const statusMessage =
typeof error === "object" &&
error !== null &&
"data" in error &&
typeof error.data === "object" &&
error.data !== null &&
"statusMessage" in error.data &&
typeof error.data.statusMessage === "string"
? error.data.statusMessage
: null
message.value = statusMessage || "Erreur execution script"
output.value = ""
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,50 +1,121 @@
<template>
<section class="flex flex-col items-center p-4">
<p class="text-center text-xl font-semibold uppercase">{{ hostName }}</p>
<div class="relative h-[140px] w-[140px]" :class="statusColorClass">
<svg class="h-full w-full -rotate-90" viewBox="0 0 120 120" aria-label="Pourcentage restant">
<circle
class="fill-none stroke-[rgba(255,255,255,0.22)] [stroke-width:10]"
cx="60"
cy="60"
:r="chartRadius"
/>
<circle
class="fill-none stroke-[currentColor] [stroke-linecap:round] [stroke-width:10] transition-[stroke-dashoffset] duration-300"
cx="60"
cy="60"
:r="chartRadius"
:style="{ strokeDasharray: `${chartCircumference}`, strokeDashoffset: `${chartOffset}` }"
/>
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<strong class="text-2xl leading-none">{{ remainingPercentText }}</strong>
</div>
</div>
<section class="storage-card">
<template v-if="item.loading">
<TextSkeleton custom-class="h-5 w-28" />
<CircleSkeleton custom-class="mt-3 h-[120px] w-[120px]" />
<BlockSkeleton custom-class="mt-3 h-4 w-32" />
</template>
<p class="mt-1 text-center text-sm font-semibold">{{ usedText }} / {{ totalText }}</p>
<template v-else>
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.2em] text-white/60">
{{ item.hostName }}
</p>
<div class="chart-wrapper" :class="item.statusColorClass">
<svg class="chart-svg" viewBox="0 0 120 120" aria-label="Pourcentage restant">
<circle
class="track"
cx="60" cy="60"
:r="item.chartRadius"
/>
<circle
class="progress"
cx="60" cy="60"
:r="item.chartRadius"
:style="{
strokeDasharray: `${item.chartCircumference}`,
strokeDashoffset: `${item.chartOffset}`
}"
/>
</svg>
<div class="chart-label">
<strong class="font-mono text-2xl font-bold leading-none">
{{ item.remainingPercentText }}
</strong>
<span class="mt-1 font-mono text-[9px] uppercase tracking-widest text-m-muted">
libre
</span>
</div>
</div>
<p class="font-mono text-xs font-medium text-m-muted/80">
{{ item.usedText }}
<span class="mx-0.5 text-m-muted/40">/</span>
{{ item.totalText }}
</p>
</template>
</section>
</template>
<script setup lang="ts">
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
import BlockSkeleton from "~/components/skeleton/BlockSkeleton.vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
defineProps<{
hostName: string
statusColorClass: string
chartRadius: number
chartCircumference: number
chartOffset: number
remainingPercentText: string
usedText: string
totalText: string
item: {
loading: boolean
hostName: string
statusColorClass: string
chartRadius: number
chartCircumference: number
chartOffset: number
remainingPercentText: string
usedText: string
totalText: string
}
}>()
</script>
<style scoped>
.m-success {
.storage-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 1.25rem 1rem;
gap: 0.25rem;
}
.chart-wrapper {
position: relative;
width: 120px;
height: 120px;
}
.chart-svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.track {
fill: none;
stroke: rgba(255, 255, 255, 0.06);
stroke-width: 8;
}
.progress {
fill: none;
stroke: currentColor;
stroke-width: 8;
stroke-linecap: round;
transition: stroke-dashoffset 0.8s cubic-bezier(0.4, 0, 0.2, 1);
}
.chart-label {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.status-success {
color: rgb(var(--m-success));
}
.m-error {
.status-error {
color: rgb(var(--m-error));
}
</style>

View File

@@ -0,0 +1,149 @@
<script setup>
import {Icon as IconifyIcon} from "@iconify/vue"
import { apiFetch } from "~/composables/useApiAuth"
const { data: messages, error } = await useFetch('/api/discord/messages', {
$fetch: apiFetch,
server: false
})
</script>
<template>
<div class="discord-card card-glow">
<div class="card-header">
<div class="flex items-center gap-2.5">
<IconifyIcon icon="mdi:message-text" class="text-lg text-m-accent" />
<h2 class="card-title">Discord</h2>
</div>
<span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Messages</span>
</div>
<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" />
<p class="mt-2 font-mono text-xs text-m-muted/50">
Aucun message
</p>
</div>
<div v-else class="message-list">
<div
v-for="m in messages"
:key="m.id"
class="message-row"
>
<div class="message-avatar">
{{ m.author.username.charAt(0).toUpperCase() }}
</div>
<div class="min-w-0 flex-1">
<span class="font-display text-xs font-semibold text-m-accent">
{{ m.author.username }}
</span>
<p class="mt-0.5 break-words font-display text-sm leading-relaxed text-m-text/80">
{{ m.content }}
</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.discord-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);
max-height: calc(100vh - 7rem);
overflow: hidden;
transition: background-color 0.4s ease, border-color 0.2s ease;
}
.card-header {
display: flex;
align-items: center;
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));
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 220px;
padding: 2rem 1rem;
border-radius: 14px;
background: rgb(var(--m-tertiary) / 0.28);
text-align: center;
}
.error-state {
border-radius: 8px;
border: 1px solid rgb(var(--m-error) / 0.12);
background: rgb(var(--m-error) / 0.06);
}
.message-list {
display: flex;
flex-direction: column;
gap: 0.65rem;
max-height: calc(100vh - 12rem);
overflow-y: auto;
}
.message-row {
display: flex;
gap: 0.75rem;
padding: 0.85rem;
border-radius: 14px;
background: rgb(var(--m-tertiary) / 0.74);
border: 1px solid rgb(var(--m-border) / 0.22);
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: 8px;
background: linear-gradient(135deg, rgb(var(--m-accent) / 0.2), rgb(var(--m-success) / 0.15));
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 700;
color: rgb(var(--m-accent));
flex-shrink: 0;
}
@media (max-width: 1180px) {
.discord-card {
max-height: none;
}
.message-list {
max-height: 28rem;
}
}
@media (max-width: 820px) {
.discord-card {
padding: 1rem;
}
}
</style>

241
components/Speed-test.vue Normal file
View File

@@ -0,0 +1,241 @@
<template>
<div class="speedtest-card card-glow">
<div class="card-header">
<h2 class="card-title">Speedtest</h2>
<button
class="reload-btn"
:disabled="isTesting"
@click="runTests"
>
<IconifyIcon
icon="mdi:reload"
class="text-lg"
:class="{ 'animate-spin': isTesting }"
/>
</button>
</div>
<div class="metrics-grid">
<div v-for="metric in metrics" :key="metric.label" class="metric-card">
<div class="metric-header">
<IconifyIcon :icon="metric.icon" class="text-lg text-m-accent" />
<span class="font-mono text-[10px] font-medium uppercase tracking-[0.15em] text-m-muted">
{{ metric.label }}
</span>
</div>
<div class="metric-value-area">
<template v-if="isTesting">
<div class="h-10 w-16 animate-shimmer rounded" />
</template>
<template v-else>
<span class="metric-value font-mono">
{{ metric.value !== null ? metric.value : "--" }}
</span>
</template>
<span class="metric-unit font-mono">{{ metric.unit }}</span>
</div>
</div>
</div>
<p v-if="errorMessage" class="error-text" role="status" aria-live="polite">
{{ errorMessage }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, ref} from "vue"
import {Icon as IconifyIcon} from "@iconify/vue"
import { apiRequest } from "~/composables/useApiAuth"
const ping = ref<number | null>(null)
const download = ref<number | null>(null)
const upload = ref<number | null>(null)
const isTesting = ref(false)
const errorMessage = ref("")
const metrics = computed(() => [
{ label: "Download", icon: "mdi:arrow-down-bold", value: download.value, unit: "Mbps" },
{ label: "Upload", icon: "mdi:arrow-up-bold", value: upload.value, unit: "Mbps" },
{ label: "Ping", icon: "mdi:signal", value: ping.value, unit: "ms" },
])
async function testDownload() {
const start = performance.now()
const res = await apiRequest('/api/speedtest')
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const blob = await res.blob()
const end = performance.now()
const size = blob.size
const seconds = (end - start) / 1000
download.value = Math.round((size * 8) / seconds / 1000000)
}
async function testUpload() {
const size = 5 * 1024 * 1024
const data = new Uint8Array(size)
const start = performance.now()
const response = await apiRequest('/api/upload', { method: 'POST', body: data })
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const end = performance.now()
const seconds = (end - start) / 1000
upload.value = Math.round((size * 8) / seconds / 1000000)
}
async function testPing() {
const start = performance.now()
const response = await fetch('/api/ping')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const end = performance.now()
ping.value = Math.round(end - start)
}
async function runTests() {
isTesting.value = true
download.value = null
upload.value = null
ping.value = null
errorMessage.value = ""
try {
await testDownload()
await testUpload()
await testPing()
} catch (error) {
console.error("Erreur speedtest:", error)
errorMessage.value = "Erreur lors de l'opération"
} finally {
isTesting.value = false
}
}
</script>
<style scoped>
.speedtest-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);
transition: background-color 0.4s ease, border-color 0.2s ease;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.card-title {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.reload-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: rgb(var(--m-tertiary));
color: rgb(var(--m-accent));
border: 1px solid rgb(var(--m-accent) / 0.12);
cursor: pointer;
transition: all 0.2s ease;
}
.reload-btn:focus-visible {
outline: 2px solid rgb(var(--m-accent) / 0.8);
outline-offset: 2px;
}
.reload-btn:hover:not(:disabled) {
background: rgb(var(--m-accent) / 0.12);
border-color: rgb(var(--m-accent) / 0.25);
transform: translateY(-1px);
}
.reload-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
.metric-card {
background: rgb(var(--m-tertiary) / 0.72);
border-radius: 14px;
padding: 1rem;
border: 1px solid rgb(var(--m-border) / 0.22);
transition: border-color 0.2s ease;
}
.metric-card:hover {
border-color: rgb(var(--m-accent) / 0.15);
}
.metric-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.metric-value-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
}
.metric-value {
font-size: 2rem;
font-weight: 700;
line-height: 1;
color: rgb(var(--m-text));
}
.metric-unit {
font-size: 0.65rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
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>

View File

@@ -1,131 +0,0 @@
<template>
<div class="bg-m-secondary w-[509px] h-[184px] mx-4 rounded-md shadow-md/50 shadow-black p-2">
<div class="mb-2 flex items-center justify-between">
<p class="font-bold text-3xl text-m-tertiary">
Speedtest
</p>
<IconifyIcon
icon="mdi:reload"
class="bg-m-tertiary text-2xl text-black rounded-md shadow-md/50 mr-1 cursor-pointer"
@Click="runTests"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<div class="bg-m-tertiary w-[153px] h-[120px] rounded-md shadow-md/50 shadow-m-black">
<div class="flex items-center justify-center">
<IconifyIcon
icon="mdi:download"
class="text-m-primary text-2xl mt-2 ml-1"
/>
<p class="font-bold uppercase text-xl text-m-text mt-2 mr-1">
download
</p>
</div>
<div class="mx-2 flex flex-col items-center justify-center">
<span class="text-4xl">
{{ download !== null ? `${download}` : "..." }}
</span>
<p class="font-bold text-xl leading-tight">
Mbps
</p>
</div>
</div>
<div class="bg-m-tertiary w-[153px] h-[120px] rounded-md shadow-md/50 shadow-m-black">
<div class="flex items-center justify-center">
<IconifyIcon
icon="mdi:upload"
class="text-m-primary text-2xl mt-2 ml-1"
/>
<p class="font-bold uppercase text-xl text-m-text mt-2 mr-1">
upload
</p>
</div>
<div class="mx-2 flex flex-col items-center justify-center">
<span class="text-4xl">
{{ upload !== null ? `${upload}` : "..." }}
</span>
<p class="font-bold text-xl leading-tight">
Mbps
</p>
</div>
</div>
<div class="bg-m-tertiary w-[153px] h-[120px] rounded-md shadow-md/50 shadow-m-black">
<div class="flex items-center justify-center">
<IconifyIcon
icon="mdi:wifi"
class="text-m-primary text-2xl mt-2 ml-1"
/>
<p class="font-bold uppercase text-xl text-m-text mt-2 mr-1">
ping
</p>
</div>
<div class="mx-2 flex flex-col items-center justify-center">
<span class="text-4xl">
{{ ping !== null ? `${ping}` : "..." }}
</span>
<p class="font-bold text-xl leading-tight">
Ms
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {onMounted, ref} from "vue";
import {Icon as IconifyIcon} from "@iconify/vue"
const ping = ref<number | null>(null)
const download = ref<number | null>(null)
const upload = ref<number | null>(null)
async function testDownload() {
const start = performance.now()
const res = await fetch('/api/download')
const blob = await res.blob()
const end = performance.now()
const size = blob.size
const seconds = (end - start) / 1000
download.value = Math.round((size * 8) / seconds / 1000000)
}
async function testUpload() {
const size = 5 * 1024 * 1024
const data = new Uint8Array(size)
const start = performance.now()
await fetch('/api/upload', {
method: 'POST',
body: data
})
const end = performance.now()
const seconds = (end - start) / 1000
upload.value = Math.round((size * 8) / seconds / 1000000)
}
async function testPing() {
const start = performance.now()
await fetch('/api/ping')
const end = performance.now()
ping.value = Math.round(end - start)
}
async function runTests() {
await testDownload()
await testUpload()
await testPing()
}
onMounted(() => {
runTests()
})
</script>

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,31 +1,49 @@
<template>
<div class="bg-m-secondary w-[250px] h-auto rounded-md mx-4 shadow-md/50 shadow-black pb-4">
<p class="font-bold text-3xl text-m-tertiary my-1 mx-3">
Status
</p>
<div
class="bg-m-tertiary w-[200px] h-auto rounded-md shadow-md/50 shadow-m-black mx-[25px] mb-3"
v-for="row in rows"
:key="`${row.label}-${row.url}`"
>
<p class="font-bold text-xl text-m-text mt-2 mx-2 mb-1">
{{ row.label }}
</p>
<div class="mx-2 flex items-center">
<span
class="inline-block h-[24px] w-[24px] rounded-full mr-2"
:class="statusClass(row.status)"
/>
<span class="font-semibold text-lg">
{{ statusLabel(row.status) }}
</span>
<div class="status-card card-glow">
<div class="card-header">
<h2 class="card-title">Status App</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.url}`"
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>
<span class="font-mono text-xs" :class="row.status === 200 ? 'text-m-success' : 'text-m-error'">
{{ statusLabel(row.status) }}
</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
@@ -52,23 +70,22 @@ const props = withDefaults(
)
const rows = ref<StatusRow[]>([])
const loading = ref(true)
const initialized = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
const statusClass = (status: number) => {
if (status === 200) return "bg-m-success"
if (status === 0) return "bg-m-error"
return "bg-m-error"
}
const statusLabel = (status: number) => {
if (status === 200) return "HTTP 200"
if (status === 0) return "Injoignable"
return `KO (HTTP ${status})`
return `KO (${status})`
}
const checkStatus = async () => {
if (!initialized.value) {
loading.value = true
}
try {
const data = await $fetch<StatusResponse>(props.endpoint)
const data = await apiFetch<StatusResponse>(props.endpoint)
rows.value = data.results
} catch (error) {
rows.value = [
@@ -81,6 +98,9 @@ const checkStatus = async () => {
error: error instanceof Error ? error.message : String(error)
}
]
} finally {
initialized.value = true
loading.value = false
}
}
@@ -96,3 +116,71 @@ onBeforeUnmount(() => {
}
})
</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

@@ -0,0 +1,652 @@
<template>
<section class="chart-card card-glow">
<div class="card-header">
<div>
<h2 class="card-title">Historique systeme</h2>
<p class="card-copy">CPU et RAM avec cache journalier local</p>
</div>
<div class="controls">
<div class="toggle-group" role="group" aria-label="Metriques affichees">
<button
v-for="option in options"
:key="option.value"
type="button"
class="toggle-pill"
:class="{ 'toggle-pill-active': isMetricActive(option.value) }"
@click="toggleMetric(option.value)"
>
<span class="toggle-dot" :style="{ backgroundColor: option.color }" />
<span>{{ option.label }}</span>
</button>
</div>
<div class="history-toolbar">
<label class="window-select">
<span>Fenetre</span>
<select v-model="selectedWindow">
<option
v-for="windowOption in windowOptions"
:key="windowOption.value"
:value="windowOption.value"
>
{{ windowOption.label }}
</option>
</select>
</label>
<button type="button" class="clear-btn" @click="clearHistory">
Vider le cache
</button>
</div>
</div>
</div>
<div class="chart-shell">
<template v-if="loading && visibleHistory.length === 0">
<div class="chart-skeleton animate-shimmer" />
</template>
<template v-else>
<div class="chart-meta">
<div>
<span class="meta-label">Echelle</span>
<strong class="meta-value">{{ scaleLabel }}</strong>
</div>
<div>
<span class="meta-label">Periode</span>
<strong class="meta-value">{{ activeWindowLabel }}</strong>
</div>
<div
v-for="option in displayedOptions"
:key="option.value"
class="meta-metric"
>
<span class="meta-label">{{ option.label }}</span>
<strong class="meta-value" :style="{ color: option.color }">
{{ formatValue(currentMetricValue(option.value)) }}
</strong>
<span class="meta-subvalue">Pic {{ formatValue(peakMetricValue(option.value)) }}</span>
</div>
</div>
<svg
class="chart-svg"
viewBox="0 0 960 320"
preserveAspectRatio="none"
aria-label="Graphique des ressources"
>
<line
:x1="chartLeft"
:y1="chartBottom"
:x2="chartRight"
:y2="chartBottom"
class="axis-line"
/>
<line
:x1="chartLeft"
:y1="chartTop"
:x2="chartLeft"
:y2="chartBottom"
class="axis-line"
/>
<line
v-for="line in yAxisTicks"
:key="`grid-${line.y}`"
:x1="chartLeft"
:y1="line.y"
:x2="chartRight"
:y2="line.y"
class="grid-line"
/>
<text
v-for="line in yAxisTicks"
:key="`y-label-${line.y}`"
:x="chartLeft - 12"
:y="line.y + 4"
class="axis-label axis-label-y"
>
{{ line.label }}
</text>
<line
v-for="tick in xAxisTicks"
:key="`x-grid-${tick.x}`"
:x1="tick.x"
:y1="chartTop"
:x2="tick.x"
:y2="chartBottom"
class="grid-line grid-line-vertical"
/>
<text
v-for="tick in xAxisTicks"
:key="`x-label-${tick.x}`"
:x="tick.x"
:y="304"
class="axis-label axis-label-x"
>
{{ tick.label }}
</text>
<polyline
v-for="option in displayedOptions"
:key="option.value"
:points="polylinePoints(option.value)"
class="chart-line"
:style="{ stroke: option.color }"
/>
</svg>
</template>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue"
import type { SystemMetrics } from "~/types/system"
type MetricKey = "cpu" | "ram"
type WindowKey = "day" | "hour" | "5m" | "1m" | "30s"
type HistoryPoint = {
sampledAt: number
cpu: number
ram: number
}
const HISTORY_STORAGE_KEY = "supervisor-system-history"
const props = defineProps<{
metrics: SystemMetrics | null
loading: boolean
}>()
const activeMetrics = ref<MetricKey[]>(["cpu", "ram"])
const selectedWindow = ref<WindowKey>("hour")
const history = ref<HistoryPoint[]>([])
const options: Array<{ value: MetricKey; label: string; color: string }> = [
{ value: "cpu", label: "CPU", color: "#5aa9ff" },
{ value: "ram", label: "RAM", color: "#31c48d" }
]
const windowOptions: Array<{ value: WindowKey; label: string; durationMs: number | null }> = [
{ value: "day", label: "Journee", durationMs: null },
{ value: "hour", label: "1 h", durationMs: 60 * 60 * 1000 },
{ value: "5m", label: "5 min", durationMs: 5 * 60 * 1000 },
{ value: "1m", label: "1 min", durationMs: 60 * 1000 },
{ value: "30s", label: "30 s", durationMs: 30 * 1000 }
]
const getStartOfToday = (timestamp: number) => {
const date = new Date(timestamp)
date.setHours(0, 0, 0, 0)
return date.getTime()
}
const normalizeHistory = (points: HistoryPoint[]) => {
if (points.length === 0) {
return []
}
const startOfToday = getStartOfToday(Date.now())
return points
.filter((point) => point.sampledAt >= startOfToday)
.sort((left, right) => left.sampledAt - right.sampledAt)
}
const persistHistory = () => {
if (!import.meta.client) {
return
}
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(history.value))
}
const appendHistoryPoint = (metrics: SystemMetrics) => {
const sampledAt = new Date(metrics.sampledAt).getTime()
if (!Number.isFinite(sampledAt)) {
return
}
const nextPoint: HistoryPoint = {
sampledAt,
cpu: metrics.cpuPercent,
ram: metrics.memoryPercent
}
const previousPoint = history.value.at(-1)
const nextHistory = normalizeHistory(
previousPoint?.sampledAt === nextPoint.sampledAt
? [...history.value.slice(0, -1), nextPoint]
: [...history.value, nextPoint]
)
history.value = nextHistory
persistHistory()
}
const clearHistory = () => {
history.value = []
if (!import.meta.client) {
return
}
localStorage.removeItem(HISTORY_STORAGE_KEY)
}
onMounted(() => {
if (!import.meta.client) {
return
}
try {
const rawHistory = localStorage.getItem(HISTORY_STORAGE_KEY)
if (!rawHistory) {
return
}
const parsedHistory = JSON.parse(rawHistory) as HistoryPoint[]
history.value = normalizeHistory(
parsedHistory.filter((point) => {
return (
point &&
Number.isFinite(point.sampledAt) &&
Number.isFinite(point.cpu) &&
Number.isFinite(point.ram)
)
})
)
persistHistory()
} catch {
localStorage.removeItem(HISTORY_STORAGE_KEY)
}
})
watch(
() => props.metrics?.sampledAt,
() => {
if (!props.metrics) {
return
}
appendHistoryPoint(props.metrics)
},
{ immediate: true }
)
const activeWindow = computed(() => {
return windowOptions.find((option) => option.value === selectedWindow.value) || windowOptions[0]
})
const displayedOptions = computed(() => {
return options.filter((option) => activeMetrics.value.includes(option.value))
})
const visibleHistory = computed(() => {
if (activeWindow.value.durationMs === null) {
return history.value
}
const minTimestamp = Date.now() - activeWindow.value.durationMs
return history.value.filter((point) => point.sampledAt >= minTimestamp)
})
const scaleMax = computed(() => 100)
const formatValue = (value: number) => `${Math.round(value)}%`
const scaleLabel = computed(() => {
return formatValue(scaleMax.value)
})
const activeWindowLabel = computed(() => activeWindow.value.label)
const isMetricActive = (metric: MetricKey) => activeMetrics.value.includes(metric)
const toggleMetric = (metric: MetricKey) => {
if (isMetricActive(metric)) {
activeMetrics.value = activeMetrics.value.filter((value) => value !== metric)
return
}
activeMetrics.value = [...activeMetrics.value, metric]
}
const currentMetricValue = (metric: MetricKey) => {
return visibleHistory.value.at(-1)?.[metric] ?? 0
}
const peakMetricValue = (metric: MetricKey) => {
return visibleHistory.value.reduce((max, point) => Math.max(max, point[metric]), 0)
}
const chartLeft = 72
const chartRight = 936
const chartTop = 24
const chartBottom = 280
const chartWidth = chartRight - chartLeft
const chartHeight = chartBottom - chartTop
const yAxisTicks = computed(() => {
const steps = 4
return Array.from({ length: steps + 1 }, (_, index) => {
const ratio = index / steps
const value = scaleMax.value * (1 - ratio)
const y = chartTop + chartHeight * ratio
return {
y,
label: formatValue(value)
}
})
})
const formatTimeLabel = (timestamp: number) => {
return new Intl.DateTimeFormat("fr-FR", {
hour: "2-digit",
minute: "2-digit",
second: activeWindow.value.durationMs !== null && activeWindow.value.durationMs <= 5 * 60 * 1000
? "2-digit"
: undefined
}).format(timestamp)
}
const xAxisTicks = computed(() => {
if (visibleHistory.value.length === 0) {
return []
}
if (visibleHistory.value.length === 1) {
return [
{
x: chartLeft,
label: formatTimeLabel(visibleHistory.value[0].sampledAt)
}
]
}
const steps = 3
const firstTimestamp = visibleHistory.value[0].sampledAt
const lastTimestamp = visibleHistory.value.at(-1)?.sampledAt || firstTimestamp
const span = Math.max(1, lastTimestamp - firstTimestamp)
return Array.from({ length: steps + 1 }, (_, index) => {
const ratio = index / steps
const targetTimestamp = firstTimestamp + span * ratio
const closestPoint = visibleHistory.value.reduce((closest, point) => {
const currentDistance = Math.abs(point.sampledAt - targetTimestamp)
const closestDistance = Math.abs(closest.sampledAt - targetTimestamp)
return currentDistance < closestDistance ? point : closest
}, visibleHistory.value[0])
return {
x: chartLeft + chartWidth * ratio,
label: formatTimeLabel(closestPoint.sampledAt)
}
})
})
const polylinePoints = (metric: MetricKey) => {
const points = visibleHistory.value.map((point) => point[metric])
if (points.length === 0) {
return `${chartLeft},${chartBottom}`
}
if (points.length === 1) {
const normalizedValue = points[0] / scaleMax.value
const y = chartBottom - normalizedValue * chartHeight
return `${chartLeft},${y} ${chartRight},${y}`
}
return points
.map((value, index) => {
const x = chartLeft + (index / (points.length - 1)) * chartWidth
const normalizedValue = scaleMax.value > 0 ? value / scaleMax.value : 0
const y = chartBottom - normalizedValue * chartHeight
return `${x},${Math.max(chartTop, Math.min(chartBottom, y))}`
})
.join(" ")
}
</script>
<style scoped>
.chart-card {
margin-top: 1.5rem;
background: rgb(var(--m-secondary));
border-radius: 12px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.card-title {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.card-copy {
margin-top: 0.25rem;
font-family: var(--font-mono);
font-size: 0.75rem;
color: rgb(var(--m-muted));
text-transform: uppercase;
letter-spacing: 0.12em;
}
.controls {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.75rem;
}
.toggle-group {
display: flex;
align-items: center;
gap: 0.625rem;
flex-wrap: wrap;
}
.toggle-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: 999px;
border: 1px solid rgb(var(--m-accent) / 0.1);
background: rgb(var(--m-tertiary));
padding: 0.55rem 0.8rem;
font-family: var(--font-mono);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgb(var(--m-muted));
cursor: pointer;
appearance: none;
transition: border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.toggle-pill-active {
border-color: rgb(var(--m-accent) / 0.28);
color: rgb(var(--m-text));
transform: translateY(-1px);
}
.toggle-dot {
width: 10px;
height: 10px;
border-radius: 999px;
flex-shrink: 0;
}
.history-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.window-select {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgb(var(--m-muted));
}
.window-select select {
border-radius: 999px;
border: 1px solid rgb(var(--m-accent) / 0.14);
background: rgb(var(--m-tertiary));
padding: 0.55rem 0.8rem;
font: inherit;
color: rgb(var(--m-text));
}
.clear-btn {
border-radius: 999px;
border: 1px solid rgb(var(--m-error) / 0.18);
background: rgb(var(--m-error) / 0.08);
padding: 0.55rem 0.85rem;
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgb(var(--m-error));
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
}
.clear-btn:hover {
border-color: rgb(var(--m-error) / 0.32);
background: rgb(var(--m-error) / 0.14);
}
.chart-shell {
border-radius: 12px;
padding: 1rem;
background:
linear-gradient(180deg, rgb(var(--m-tertiary)) 0%, rgb(var(--m-secondary)) 100%);
border: 1px solid rgb(var(--m-accent) / 0.08);
}
.chart-meta {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.meta-metric {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.meta-label {
display: block;
margin-bottom: 0.25rem;
font-family: var(--font-mono);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: rgb(var(--m-muted));
}
.meta-value {
font-family: var(--font-display);
font-size: 1.35rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.meta-subvalue {
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgb(var(--m-muted));
}
.chart-svg {
width: 100%;
height: 320px;
display: block;
}
.axis-line {
stroke: rgb(var(--m-border) / 0.55);
stroke-width: 1.5;
}
.grid-line {
stroke: rgb(var(--m-border) / 0.35);
stroke-width: 1;
stroke-dasharray: 6 10;
}
.grid-line-vertical {
stroke-dasharray: 4 12;
}
.axis-label {
font-family: var(--font-mono);
font-size: 11px;
fill: rgb(var(--m-muted));
}
.axis-label-y {
text-anchor: end;
}
.axis-label-x {
text-anchor: middle;
}
.chart-line {
fill: none;
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.chart-skeleton {
width: 100%;
height: 320px;
border-radius: 12px;
}
@media (max-width: 820px) {
.controls {
width: 100%;
align-items: stretch;
}
.history-toolbar {
justify-content: space-between;
}
.chart-meta {
grid-template-columns: 1fr;
}
}
</style>

View File

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

View File

@@ -0,0 +1,14 @@
<template>
<div class="animate-pulse rounded-md bg-m-tertiary/70" :class="customClass" />
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
customClass?: string
}>(),
{
customClass: "h-4 w-full"
}
)
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div
class="animate-pulse rounded-md bg-m-tertiary/70 shadow-md/50 shadow-m-black"
:class="customClass"
/>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
customClass?: string
}>(),
{
customClass: "h-[39px] w-full"
}
)
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div class="animate-pulse rounded-full bg-m-tertiary/70" :class="customClass" />
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
customClass?: string
}>(),
{
customClass: "h-10 w-10"
}
)
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div class="animate-pulse rounded bg-m-tertiary/70" :class="customClass" />
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
customClass?: string
}>(),
{
customClass: "h-5 w-24"
}
)
</script>

90
composables/useApiAuth.ts Normal file
View File

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

3
eslint.config.mjs Normal file
View File

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

View File

@@ -1,41 +1,510 @@
<template>
<div class="page-layout">
<aside class="sidebar">
<div class="flex items-center gap-3 px-14 py-4">
<div class="avatar">
<div class="h-[155px] w-[155px]">
<img
:src="logoSrc"
alt="Logo Malio"
class="h-full w-full object-contain"
/>
</div>
<aside class="sidebar" aria-label="Navigation principale">
<div class="sidebar-header">
<div class="logo-container">
<img
:src="logoSrc"
alt="Logo Malio"
class="logo"
>
</div>
<div class="brand-copy">
<p class="brand-title">Supervisor</p>
</div>
<div class="sidebar-divider"/>
</div>
<div class="sidebar-content">
<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 class="sidebar-footer">
<div class="sidebar-divider"/>
<div class="status-card">
<p class="status-label">Environnement</p>
<p class="status-value">Production</p>
<p class="status-description">
Acces rapide au monitoring, aux sauvegardes et aux cartes systeme.
</p>
</div>
<div class="footer-row">
<p class="font-mono text-[10px] tracking-widest uppercase text-white/40">
Supervisor {{ appVersion }}
</p>
</div>
</div>
<slot name="sidebar" />
</aside>
<main class="content">
<slot />
<button class="mobile-menu-button" type="button" @click="isMenuOpen = true">
<IconifyIcon icon="mdi:menu" class="text-2xl"/>
</button>
<div v-if="isMenuOpen" class="mobile-menu-backdrop" @click="isMenuOpen = false"/>
<aside v-if="isMenuOpen" class="mobile-sidebar" aria-label="Navigation mobile">
<div class="sidebar-header">
<div class="logo-container">
<img
:src="logoSrc"
alt="Logo Malio"
class="logo"
>
</div>
<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 class="sidebar-content">
<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 class="sidebar-footer">
<div class="sidebar-divider"/>
<div class="status-card">
<p class="status-label">Environnement</p>
<p class="status-value">Production</p>
<p class="status-description">
Navigation rapide vers les vues principales de supervision.
</p>
</div>
<div class="footer-row">
<p class="font-mono text-[10px] tracking-widest uppercase text-white/40">
Supervisor {{ appVersion }}
</p>
<button class="close-button" type="button" @click="isMenuOpen = false">
<IconifyIcon icon="mdi:close" class="text-xl"/>
</button>
</div>
</div>
</aside>
<main class="content bg-noise">
<slot/>
</main>
</div>
</template>
<script setup lang="ts">
import {ref} from "vue"
import {Icon as IconifyIcon} from "@iconify/vue"
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
const {
public: {appVersion}
} = useRuntimeConfig()
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>
<style scoped>
.page-layout {
display: grid;
grid-template-columns: 280px 1fr;
grid-template-columns: 260px 1fr;
min-height: 100vh;
}
.sidebar,
.mobile-sidebar {
background: linear-gradient(
180deg,
rgb(var(--m-sidebar-from)) 0%,
rgb(var(--m-sidebar-to)) 100%
);
color: white;
display: flex;
flex-direction: column;
border-right: 1px solid var(--m-sidebar-border);
}
.sidebar {
background-color: rgb(var(--m-primary));
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
transition: background 0.4s ease, border-color 0.4s ease;
}
.sidebar-header {
padding: 1.5rem 2rem 0;
flex-shrink: 0;
}
.logo-container {
display: flex;
justify-content: center;
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 {
height: 100px;
width: 100px;
object-fit: contain;
filter: drop-shadow(0 0 20px rgba(80, 140, 255, 0.15));
transition: filter 0.3s ease;
}
.logo:hover {
filter: drop-shadow(0 0 28px rgba(80, 140, 255, 0.3));
}
.sidebar-content {
flex: 1;
padding: 0.75rem 1rem 1rem;
}
.sidebar-footer {
flex-shrink: 0;
padding-bottom: 0.5rem;
}
.sidebar-divider {
height: 1px;
margin: 0.75rem 1.5rem;
background: linear-gradient(
90deg,
transparent,
var(--m-sidebar-divider),
transparent
);
}
.footer-row {
display: flex;
align-items: center;
justify-content: space-between;
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 {
background-color: rgb(var(--m-tertiary));
background: rgb(var(--m-bg));
background-image:
linear-gradient(180deg, rgb(255 255 255 / 0.01), transparent 18%),
radial-gradient(circle at top right, rgb(var(--m-accent) / 0.08), transparent 20%);
overflow-y: auto;
min-height: 100vh;
transition: background-color 0.4s ease;
}
.mobile-menu-button,
.mobile-menu-backdrop,
.mobile-sidebar {
display: none;
}
@media (max-width: 820px) {
.page-layout {
grid-template-columns: 1fr;
}
.sidebar {
display: none;
}
.mobile-menu-button {
position: fixed;
top: 1rem;
left: 1rem;
z-index: 40;
display: flex;
height: 44px;
width: 44px;
align-items: center;
justify-content: center;
border-radius: 10px;
border: 1px solid rgb(var(--m-accent) / 0.18);
background: rgb(var(--m-secondary));
color: rgb(var(--m-text));
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.24);
}
.mobile-menu-backdrop {
position: fixed;
inset: 0;
z-index: 45;
display: block;
background: rgba(3, 8, 20, 0.55);
backdrop-filter: blur(3px);
}
.mobile-sidebar {
position: fixed;
top: 0;
left: 0;
z-index: 50;
display: flex;
height: 100vh;
width: min(84vw, 320px);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.38);
}
.sidebar-content {
padding-right: 0.9rem;
padding-left: 0.9rem;
}
.close-button {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
width: 32px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: white;
}
.nav-link {
padding: 0.8rem 0.85rem;
}
}
</style>

View File

@@ -1,10 +1,45 @@
import { execSync } from "node:child_process"
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
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
compatibilityDate: "2025-07-15",
devtools: { enabled: true },
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,
public: {
appVersion: getRepoVersion()
}
},
vite: {
plugins: [tailwindcss()]
}

8060
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,16 +7,23 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
"postinstall": "nuxt prepare",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@iconify/vue": "^5.0.0",
"nuxt": "^4.3.1",
"vue": "^3.5.29",
"vue-router": "^4.6.4"
"@nuxt/eslint": "^1.15.2",
"nuxt": "^4.3.1"
},
"devDependencies": {
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.6",
"@semantic-release/release-notes-generator": "^14.1.0",
"@tailwindcss/vite": "^4.2.1",
"semantic-release": "^25.0.3",
"tailwindcss": "^4.2.1"
}
}

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

@@ -1,50 +1,108 @@
<template>
<NuxtLayout name="default">
<template #sidebar>
<div class="flex flex-col gap-">
<DiagramStorage
v-for="item in diagramItems"
:key="item.key"
:host-name="item.hostName"
:status-color-class="item.statusColorClass"
:chart-radius="chartRadius"
:chart-circumference="chartCircumference"
:chart-offset="item.chartOffset"
:remaining-percent-text="item.remainingPercentText"
:used-text="item.usedText"
:total-text="item.totalText"
/>
</div>
</template>
<div class="dashboard-container">
<header class="dashboard-header">
<div>
<p class="section-kicker">Operations</p>
<h1 class="font-display text-3xl font-bold tracking-tight text-m-text">
Monitoring
</h1>
<p class="header-description">
Visualisez l'etat des applications, des sauvegardes et des ressources systeme depuis une vue unique.
</p>
</div>
</header>
<p class="font-bold text-4xl my-6 mx-4">Écran de monitoring</p>
<div class="flex">
<StatusSite/>
<Speedtest/>
<div class="content-grid">
<div class="content-main">
<section class="storage-section">
<div class="storage-section-header">
<h2 class="font-display text-xl font-semibold text-m-text">Stockage</h2>
<span class="font-mono text-[10px] uppercase tracking-widest text-m-muted">Volumes</span>
</div>
<div class="storage-grid">
<DiagramStorage
v-for="(item, idx) in diagramItems"
:key="item.key"
:item="item"
:style="{ animationDelay: `${idx * 150}ms` }"
class="animate-fade-in-up"
/>
</div>
</section>
<div class="dashboard-grid">
<div class="grid-left">
<StatusSite class="animate-fade-in-up" style="animation-delay: 100ms" />
</div>
<div class="grid-middle">
<SpeedTest class="animate-fade-in-up speedtest-card-mobile" style="animation-delay: 150ms" />
</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 class="content-aside">
<MessageDiscord class="animate-fade-in-up" style="animation-delay: 300ms" />
</div>
</div>
</div>
</NuxtLayout>
</template>
<script setup lang="ts">
import Speedtest from "~/components/Speedtest.vue";
import {computed, onMounted, ref} from "vue"
import { apiFetch } from "~/composables/useApiAuth"
import type { SystemMetrics } from "~/types/system";
definePageMeta({layout: false})
import {computed, onMounted, ref} from "vue"
type SourceKey = "remote" | "local"
type DiskCommandResult = { ok: boolean; output: string }
type DiskApiResponse = {
remote?: string | DiskCommandResult
local?: string | DiskCommandResult
type DiskSourceResult = {
key: string
label: string
ok: boolean
output: string
}
const rawResults = ref<Record<SourceKey, string>>({
remote: "",
local: ""
})
type DiskApiResponse = {
results: DiskSourceResult[]
}
type DiagramItem = {
key: string
loading: boolean
hostName: string
statusColorClass: string
chartRadius: number
chartCircumference: number
chartOffset: number
remainingPercentText: string
usedText: string
totalText: string
}
const rawResults = ref<DiskSourceResult[]>([])
const loading = ref(false)
const systemMetrics = ref<SystemMetrics | null>(null)
const systemLoading = ref(true)
const chartRadius = 52
const chartCircumference = 2 * Math.PI * chartRadius
let systemTimer: ReturnType<typeof setInterval> | null = null
const getHostName = (output: string, fallback: string) => {
const hostMatch = output.match(/Name:\s*(.+)/i)
@@ -84,21 +142,9 @@ const getDiskValues = (output: string) => {
return {availableGb, usedGb, totalGb}
}
const getOutputText = (entry: unknown) => {
if (typeof entry === "string") return entry
if (entry && typeof entry === "object" && "output" in entry) {
const output = (entry as DiskCommandResult).output
return typeof output === "string" ? output : String(output)
}
return ""
}
const diagramItems = computed(() => {
return [
{ key: "remote" as const, fallbackHost: "Serveur distant", output: rawResults.value.remote },
{ key: "local" as const, fallbackHost: "Machine locale", output: rawResults.value.local }
].map((item) => {
const diskValues = getDiskValues(item.output)
const diagramItems = computed<DiagramItem[]>(() => {
return rawResults.value.map((result) => {
const diskValues = getDiskValues(result.output)
const remainingPercent =
diskValues === null
? null
@@ -107,15 +153,15 @@ const diagramItems = computed(() => {
Math.min(100, Math.round((diskValues.availableGb / diskValues.totalGb) * 100))
)
const chartOffset = chartCircumference - ((remainingPercent ?? 0) / 100) * chartCircumference
const statusColorClass =
remainingPercent !== null && remainingPercent <= 30 ? "m-error" : "m-success"
return {
key: item.key,
hostName: getHostName(item.output, item.fallbackHost),
statusColorClass,
chartOffset,
key: result.key,
loading: loading.value,
hostName: getHostName(result.output, result.label),
statusColorClass:
remainingPercent !== null && remainingPercent <= 30 ? "status-error" : "status-success",
chartRadius,
chartCircumference,
chartOffset: chartCircumference - ((remainingPercent ?? 0) / 100) * chartCircumference,
remainingPercentText:
loading.value ? "..." : remainingPercent === null ? "--%" : `${remainingPercent}%`,
usedText: loading.value ? "..." : diskValues ? `${diskValues.usedGb.toFixed(2)} GB` : "--",
@@ -124,40 +170,185 @@ const diagramItems = computed(() => {
})
})
const runScript = async () => {
loading.value = true
rawResults.value = {
remote: "",
local: ""
}
rawResults.value = []
try {
const output = await $fetch<DiskApiResponse | string>("/api/disk")
if (typeof output === "string") {
rawResults.value = {
remote: output,
local: "Erreur: sortie locale indisponible"
const output = await apiFetch<DiskApiResponse>("/api/disk")
rawResults.value = output.results
} catch {
rawResults.value = [
{
key: "error",
label: "Source indisponible",
ok: false,
output: "Erreur lors de l'opération"
}
return
}
rawResults.value = {
remote: getOutputText(output.remote),
local: getOutputText(output.local)
}
} catch (error) {
const message = `Erreur: ${error instanceof Error ? error.message : String(error)}`
rawResults.value = {
remote: message,
local: message
}
]
} finally {
loading.value = false
}
}
const loadSystemMetrics = async () => {
try {
systemMetrics.value = await $fetch<SystemMetrics>("/api/system")
} catch {
systemMetrics.value = null
} finally {
systemLoading.value = false
}
}
onMounted(() => {
runScript()
loadSystemMetrics()
systemTimer = setInterval(loadSystemMetrics, 2000)
})
onBeforeUnmount(() => {
if (systemTimer) {
clearInterval(systemTimer)
systemTimer = null
}
})
</script>
<style scoped>
.dashboard-container {
padding: 2rem 2.5rem;
}
.dashboard-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid rgba(80, 140, 255, 0.1);
}
.section-kicker {
margin: 0 0 0.45rem;
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgb(var(--m-accent));
}
.header-description {
max-width: 62ch;
margin-top: 0.9rem;
color: rgb(var(--m-muted));
line-height: 1.65;
}
.storage-section {
margin-bottom: 1.5rem;
}
.storage-section-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.storage-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
border-radius: 18px;
background:
linear-gradient(180deg, rgb(var(--m-secondary) / 0.78), rgb(var(--m-secondary) / 0.92));
border: 1px solid rgb(var(--m-border) / 0.32);
padding: 0.85rem;
}
.content-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 1.5rem;
align-items: start;
}
.content-main {
min-width: 0;
}
.content-aside {
min-width: 0;
}
.dashboard-grid {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 1.5rem;
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-middle,
.grid-right {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-width: 0;
}
@media (max-width: 1180px) {
.content-grid {
grid-template-columns: 1fr;
}
.content-aside {
grid-column: 1 / -1;
}
}
@media (max-width: 820px) {
.dashboard-container {
padding: 4.5rem 1.25rem 1.25rem;
}
.dashboard-header {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.storage-grid,
.content-grid,
.dashboard-grid,
.metrics-row {
grid-template-columns: 1fr;
}
.backup-selector {
order: 2;
}
.backup-list-mobile {
order: 3;
}
.speedtest-card-mobile {
order: 4;
}
.content-aside {
grid-column: auto;
order: 5;
}
}
</style>

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

115
server/api/backups.get.ts Normal file
View File

@@ -0,0 +1,115 @@
import {
runSsh,
shellQuote,
resolveFolderRemoteDir,
REMOTE_ROOT,
} from "../utils/ssh.ts"
import {process} from "std-env";
const MAX_FILES_PER_FOLDER = Number(process.env.BACKUPS_MAX_FILES)
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
function isMissingPathError(error: unknown): boolean {
const message = String(error || "").toLowerCase()
return message.includes("no such file or directory") || message.includes("cannot access")
}
function toServerError(error: unknown) {
console.error("Erreur backups:", error)
return createError({
statusCode: 500,
statusMessage: "Erreur lors de l'opération"
})
}
function parseLines(output: string): string[] {
return output
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
}
function quoteDir(pathValue: string) {
return shellQuote(pathValue)
}
async function listRemoteFiles(remoteDir: string): Promise<string[]> {
const output = await runSsh(
`cd ${quoteDir(remoteDir)} && ls -1A | sort -r | head -n ${MAX_FILES_PER_FOLDER}`
)
return parseLines(output)
}
async function listRemoteDirs(remoteRoot: string): Promise<string[]> {
const output = await runSsh(
`cd ${quoteDir(remoteRoot)} && for d in */; do [ -d "$d" ] && printf '%s\n' "\${d%/}"; done`
)
return parseLines(output)
}
async function getLatestRemoteFile(remoteDir: string): Promise<string | null> {
const output = await runSsh(
`cd ${quoteDir(remoteDir)} && ls -1A | sort -r | head -n 1`
)
const files = parseLines(output)
return files[0] || null
}
export default defineEventHandler(async (event) => {
const { folder } = getQuery(event)
const folderName = typeof folder === "string" ? folder : null
// Si un dossier est demandé, on retourne sa liste de fichiers.
if (folderName) {
if (!isSafeFolder(folderName)) {
throw createError({
statusCode: 400,
statusMessage: "Paramètre folder invalide"
})
}
const remoteDir = await resolveFolderRemoteDir(folderName)
if (!remoteDir) return []
try {
return await listRemoteFiles(remoteDir)
} catch (error) {
if (isMissingPathError(error)) return []
throw toServerError(error)
}
}
// Sinon, on récupère le dernier backup de chaque dossier distant.
let dirs: string[] = []
try {
dirs = await listRemoteDirs(REMOTE_ROOT)
} catch (error) {
throw toServerError(error)
}
const result: Array<{ folder: string; last: string | null }> = []
for (const dirName of dirs) {
const remoteDir = `${REMOTE_ROOT}/${dirName}`
try {
result.push({
folder: dirName,
last: await getLatestRemoteFile(remoteDir)
})
} catch (error) {
if (isMissingPathError(error)) {
result.push({
folder: dirName,
last: null
})
continue
}
throw toServerError(error)
}
}
return result
})

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

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

View File

@@ -1,14 +1,36 @@
import { exec } from "child_process"
const remoteCommand =
process.env.DISK_REMOTE_COMMAND ||
"ssh malio-b@192.168.0.179 'cd /home/malio-b/Scripts-Serveur && bash check_storage.sh && exit'"
type DiskSource = {
key: string
label: string
command: string
args?: string[]
}
const localCommand =
process.env.DISK_LOCAL_COMMAND ||
"bash /home/kevin/check_storage.sh"
const diskSources: DiskSource[] = [
{
key: "remote",
label: "Serveur distant",
command: "ssh",
args: []
},
{
key: "local",
label: "Machine locale",
command: "bash",
args: []
}
]
function runCommand(command: string): Promise<string> {
function getEnvCommand(source: DiskSource) {
const envKey = `DISK_COMMAND_${source.key.toUpperCase()}`
const legacyEnvKey =
source.key === "remote" ? "DISK_REMOTE_COMMAND" : source.key === "local" ? "DISK_LOCAL_COMMAND" : ""
return process.env[envKey] || (legacyEnvKey ? process.env[legacyEnvKey] : undefined) || null
}
function runShellCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => {
if (error) {
@@ -21,25 +43,32 @@ function runCommand(command: string): Promise<string> {
}
export default defineEventHandler(async () => {
const [remoteResult, localResult] = await Promise.allSettled([
runCommand(remoteCommand),
runCommand(localCommand)
])
const results = await Promise.all(
diskSources.map(async (source) => {
try {
const envCommand = getEnvCommand(source)
if (!envCommand) {
throw new Error(`Commande disque manquante pour ${source.key}`)
}
return {
remote: {
ok: remoteResult.status === "fulfilled",
output:
remoteResult.status === "fulfilled"
? remoteResult.value
: `Erreur: ${String(remoteResult.reason)}`
},
local: {
ok: localResult.status === "fulfilled",
output:
localResult.status === "fulfilled"
? localResult.value
: `Erreur: ${String(localResult.reason)}`
}
}
const output = await runShellCommand(envCommand)
return {
key: source.key,
label: source.label,
ok: true,
output
}
} catch (error) {
console.error(`Erreur disk source ${source.key}:`, error)
return {
key: source.key,
label: source.label,
ok: false,
output: "Erreur lors de l'opération"
}
}
})
)
return { results }
})

View File

@@ -0,0 +1,99 @@
import {
runSsh,
shellQuote,
resolveFolderRemoteDir,
REMOTE_HOST,
} from "../utils/ssh.ts"
import {spawn} from "unenv/node/child_process";
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
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) {
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}`)
}
})
return sendStream(event, child.stdout)
})

View File

@@ -1,25 +1,66 @@
import { Readable } from "node:stream"
import {
runSsh,
shellQuote,
resolveFolderRemoteDir,
REMOTE_HOST,
} from "../utils/ssh.ts"
import {spawn} from "unenv/node/child_process";
export default defineEventHandler((event) => {
const size = 128 * 1024 * 1024
let sent = 0
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
const isSafeFile = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
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)
}
})
function buildContentDisposition(fileName: string) {
const asciiName = fileName.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_")
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`
}
setHeader(event, "Content-Type", "application/octet-stream")
setHeader(event, "Content-Length", size)
export default defineEventHandler(async (event) => {
const { folder, file } = getQuery(event)
const folderName = typeof folder === "string" ? folder : null
const fileName = typeof file === "string" ? file : null
return stream
})
if (!folderName || !fileName) {
throw createError({ statusCode: 400, statusMessage: "Paramètres manquants" })
}
if (!isSafeFolder(folderName) || !isSafeFile(fileName)) {
throw createError({ statusCode: 400, statusMessage: "Paramètres invalides" })
}
const remoteDir = await resolveFolderRemoteDir(folderName)
if (!remoteDir) {
throw createError({ statusCode: 404, statusMessage: "Dossier introuvable" })
}
const remotePath = `${remoteDir}/${fileName}`
const existsOutput = await runSsh(`[ -f ${shellQuote(remotePath)} ] && echo yes || echo no`)
if (existsOutput.trim() !== "yes") {
throw createError({ statusCode: 404, statusMessage: "Fichier introuvable" })
}
setHeader(event, "Content-Type", "application/octet-stream")
setHeader(event, "Content-Disposition", buildContentDisposition(fileName))
const child = spawn("ssh", [
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=5",
REMOTE_HOST,
`cat ${shellQuote(remotePath)}`
])
let stderr = ""
child.stderr.on("data", (chunk) => {
stderr += chunk.toString()
})
child.on("close", (code) => {
if (code !== 0) {
console.error(`Erreur téléchargement SSH (${code}): ${stderr}`)
}
})
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,10 +1,6 @@
export default defineEventHandler(async () => {
const targets = [
{ label: "Ferme", url: "http://ferme.malio-dev.fr/api/version" },
{ label: "SIRH", url: "http://sirh.malio-dev.fr/api/version" },
{ label: "Inventory", url: "http://inventory.malio-dev.fr/api/health" },
]
import targets from "../config/version-status-targets.json"
export default defineEventHandler(async () => {
const results = await Promise.all(
targets.map(async (target) => {
try {

View File

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

View File

@@ -0,0 +1,7 @@
[
{ "name": "bitwarden", "icon": "mdi:shield-key" },
{ "name": "inventory", "icon": "mdi:package-variant-closed" },
{ "name": "sirh", "icon": "mdi:account-group" },
{ "name": "ferme", "icon": "mdi:barn" },
{ "name": "user", "icon": "mdi:account" }
]

View File

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

View File

@@ -0,0 +1,25 @@
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
}
setCookie(event, "api_auth_token", expectedToken, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/"
})
})

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

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

View File

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

View File

@@ -0,0 +1,31 @@
export type BackupScript = {
key: string
label: string
icon?: string
downloadFolders?: string[]
}
export const backupScripts: BackupScript[] = [
{
key: "backup-bdd-recette",
label: "Backup BDD recette",
icon: "mdi:database-export",
downloadFolders: ["ferme", "inventory", "sirh", "user"]
},
{
key: "check-statut-recette",
label: "Check statut recette",
icon: "mdi:server-network"
},
{
key: "backup-vaultwarden",
label: "Backup vaultwarden",
icon: "mdi:data",
downloadFolders: ["bitwarden"]
}
]
export function getBackupScriptCommand(key: string) {
const envKey = `BACKUP_SCRIPT_COMMAND_${key.toUpperCase().replace(/-/g, "_")}`
return process.env[envKey] || null
}

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

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

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
}