46 Commits

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

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
API_SECRET_KEY=
DISCORD_BOT_TOKEN=
DISCORD_CHANNEL_ID=
BACKUPS_REMOTE_HOST=
BACKUPS_REMOTE_ROOT=
BACKUPS_MAX_FILES=
DISK_COMMAND_REMOTE=
DISK_COMMAND_LOCAL=

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

128
README.md
View File

@@ -1,75 +1,93 @@
# Nuxt Minimal Starter
# Projet Monitoring
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Installation du projet
## Setup
### Windows
Sur Windows, installer WSL2, Ubuntu, Docker et nvm.
Suivre la documentation suivante :
https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows
Make sure to install dependencies:
### 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.
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
Lancer ensuite le serveur de développement.
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Lapplication sera accessible sur :
http://localhost:3000
Build the application for production:
Si une erreur liée à la version de Node apparaît, vérifier que Node ≥ 20 est utilisé via nvm.
```bash
# npm
nvm install 20
nvm use 20
## Utilisation du projet
### Frontend
Lancer le serveur de développement.
```
npm run dev
```
Compilation pour la production.
```
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
Prévisualisation du build de production.
```
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
## Commandes utiles
Installation des dépendances.
```
npm install
```
Lancer le serveur de développement.
```
npm run dev
```
Build de production.
```
npm run build
```
Prévisualisation du build.
```
npm run preview
```
Supprimer les dépendances et réinstaller proprement.
```
rm -rf node_modules package-lock.json
npm install
Déploiement
```
Construire lapplication.
```
npm run build
```
Les fichiers générés se trouvent dans :
.output/
Le serveur peut ensuite être lancé avec :
```
node .output/server/index.mjs
```
Il est recommandé dutiliser un reverse proxy comme Nginx en production.
### Notes
Les accès SSH ou les chemins système utilisés par les endpoints doivent rester côté serveur.
Ne jamais exposer de credentials dans le frontend.
Les variables sensibles doivent être stockées dans un fichier .env.

View File

@@ -13,15 +13,155 @@
--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));
color: rgb(var(--m-text));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color 0.4s ease, color 0.4s ease;
}
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-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));
}

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

@@ -1,87 +1,99 @@
<template>
<div class="bg-m-secondary w-[250px] h-[259px] rounded-md mx-4 shadow-md/50 shadow-black">
<p class="font-bold text-3xl text-m-tertiary my-1 mx-3">
Backup
</p>
<button
type="button"
class="bg-m-tertiary w-[200px] h-[32px] rounded-md shadow-md/50 shadow-m-black mx-3 mb-[10px] flex items-center justify-between cursor-pointer"
@click="select('bitwarden')"
>
<p class="font-bold uppercase text-xl ml-[24px]">
bitwarden
</p>
<IconifyIcon
icon="mdi:eye"
class="text-black text-2xl mr-[24px]"
/>
</button>
<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>
<button
<div class="backup-list">
<button
v-for="item in folders"
:key="item.name"
type="button"
class="bg-m-tertiary w-[200px] h-[32px] rounded-md shadow-md/50 shadow-m-black mx-3 mb-[10px] flex items-center justify-between cursor-pointer"
@click="select('inventory')"
>
<p class="font-bold uppercase text-xl ml-[24px]">
inventory
</p>
<IconifyIcon
icon="mdi:eye"
class="text-black text-2xl mr-[24px]"
/>
</button>
<button
type="button"
class="bg-m-tertiary w-[200px] h-[32px] rounded-md shadow-md/50 shadow-m-black mx-3 mb-[10px] flex items-center justify-between cursor-pointer"
@click="select('sirh')"
>
<p class="font-bold uppercase text-xl ml-[24px]">
sirh
</p>
<IconifyIcon
icon="mdi:eye"
class="text-black text-2xl mr-[24px]"
/>
</button>
<button
type="button"
class="bg-m-tertiary w-[200px] h-[32px] rounded-md shadow-md/50 shadow-m-black mx-3 mb-[10px] flex items-center justify-between cursor-pointer"
@click="select('ferme')"
>
<p class="font-bold uppercase text-xl ml-[24px]">
ferme
</p>
<IconifyIcon
icon="mdi:eye"
class="text-black text-2xl mr-[24px]"
/>
</button>
<button
type="button"
class="bg-m-tertiary w-[200px] h-[32px] rounded-md shadow-md/50 shadow-m-black mx-3 mb-[10px] flex items-center justify-between cursor-pointer"
@click="select('user')"
>
<p class="font-bold uppercase text-xl ml-[24px]">
user
</p>
<IconifyIcon
icon="mdi:eye"
class="text-black text-2xl mr-[24px]"
/>
</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>

View File

@@ -1,46 +1,68 @@
<template>
<div class="bg-m-secondary w-[507px] h-[367px] rounded-md mx-4 shadow-md/50 shadow-black">
<p class="font-bold text-3xl text-m-tertiary my-1 mx-3">
{{ title }}
</p>
<div class="backup-list-card card-glow">
<div class="card-header">
<h2 class="card-title">{{ title }}</h2>
</div>
<div v-if="loading">
<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="relative w-[483px] h-[39px] mx-3 mb-[10px]"
class="file-row animate-shimmer"
>
<ButtonSkeleton custom-class="h-full w-full" />
<div class="absolute inset-0 flex items-center justify-between px-3">
<TextSkeleton custom-class="h-5 w-[260px]" />
<CircleSkeleton custom-class="h-6 w-6 rounded-md" />
</div>
<TextSkeleton custom-class="h-4 w-48" />
<CircleSkeleton custom-class="h-5 w-5 rounded" />
</div>
</div>
<button
v-else
<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="bg-m-tertiary w-[483px] h-[39px] rounded-md shadow-md/50 shadow-m-black mx-3 mb-[10px] flex items-center justify-between cursor-pointer"
class="file-row"
@click="downloadBackup(file)"
>
<p class="text-xl ml-3 truncate max-w-[400px]">
{{ file }}
</p>
<IconifyIcon
>
<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-black text-2xl mr-3"
/>
</button>
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 ButtonSkeleton from "~/components/skeleton/ButtonSkeleton.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
@@ -48,33 +70,109 @@ const props = defineProps<{
const backups = ref<string[]>([])
const loading = ref(false)
const errorMessage = ref("")
const title = computed(() => {
if (!props.folder) return "Backup"
return `Liste des backup de ${props.folder.toUpperCase()}`
if (!props.folder) return "Fichiers"
return `Backup ${props.folder.toUpperCase()}`
})
const downloadBackup = (file: string) => {
const downloadBackup = async (file: string) => {
if (!props.folder) return
const url = `/api/download?folder=${encodeURIComponent(props.folder)}&file=${encodeURIComponent(file)}`
window.location.href = url
errorMessage.value = ""
try {
await downloadApiFile(url, file)
} catch (error) {
console.error("Erreur telechargement backup:", error)
errorMessage.value = "Erreur lors de l'opération"
}
}
watch(() => props.folder, async (folder) => {
if (!folder) {
loading.value = false
backups.value = []
errorMessage.value = ""
return
}
loading.value = true
errorMessage.value = ""
try {
const data = await $fetch<string[]>(`/api/backups?folder=${folder}`)
backups.value = data.slice(0, 6)
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>

296
components/BackupRun.vue Normal file
View File

@@ -0,0 +1,296 @@
<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 lors de l'opération"
message.value = error?.data?.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,35 +1,48 @@
<template>
<section class="flex flex-col items-center p-4">
<template v-if="loading">
<TextSkeleton custom-class="h-7 w-40" />
<CircleSkeleton custom-class="mt-2 h-[140px] w-[140px]" />
<BlockSkeleton custom-class="mt-2 h-5 w-36" />
<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>
<template v-else>
<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">
<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="fill-none stroke-[rgba(255,255,255,0.22)] [stroke-width:10]"
cx="60"
cy="60"
:r="chartRadius"
class="track"
cx="60" cy="60"
:r="item.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}` }"
class="progress"
cx="60" cy="60"
:r="item.chartRadius"
:style="{
strokeDasharray: `${item.chartCircumference}`,
strokeDashoffset: `${item.chartOffset}`
}"
/>
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<strong class="text-2xl leading-none">{{ remainingPercentText }}</strong>
<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="mt-1 text-center text-sm font-semibold">{{ usedText }} / {{ totalText }}</p>
<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>
@@ -40,24 +53,69 @@ import BlockSkeleton from "~/components/skeleton/BlockSkeleton.vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
defineProps<{
loading: boolean
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

@@ -1,19 +1,126 @@
<script setup>
const { data: messages } = await useFetch('/api/discord/messages')
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="bg-m-secondary w-auto h-auto 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
<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 v-if="messages">
<div v-for="m in messages" :key="m.id">
<strong>{{ m.author.username }}</strong>
<p>{{ m.content }}</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>
</div>
</div>
</template>
</template>
<style scoped>
.discord-card {
background: rgb(var(--m-secondary));
border-radius: 12px;
padding: 1.25rem;
max-height: calc(100vh - 7rem);
overflow: hidden;
transition: background-color 0.4s 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;
padding: 2rem 1rem;
}
.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.5rem;
max-height: calc(100vh - 12rem);
overflow-y: auto;
}
.message-row {
display: flex;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 8px;
background: rgb(var(--m-tertiary));
border: 1px solid rgb(var(--m-accent) / 0.04);
}
.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;
}
</style>

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

@@ -0,0 +1,222 @@
<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: rgb(var(--m-secondary));
border-radius: 12px;
padding: 1.25rem;
transition: background-color 0.4s 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:hover:not(:disabled) {
background: rgb(var(--m-accent) / 0.12);
border-color: rgb(var(--m-accent) / 0.25);
}
.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));
border-radius: 10px;
padding: 1rem;
border: 1px solid rgb(var(--m-accent) / 0.06);
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: 8px;
border: 1px solid rgb(var(--m-error) / 0.12);
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));
}
</style>

View File

@@ -1,148 +0,0 @@
<template>
<div class="bg-m-secondary w-[507px] 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">
<template v-if="isTesting">
<TextSkeleton custom-class="h-10 w-16 mb-1" />
</template>
<span v-else 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">
<template v-if="isTesting">
<TextSkeleton custom-class="h-10 w-16 mb-1" />
</template>
<span v-else 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">
<template v-if="isTesting">
<TextSkeleton custom-class="h-10 w-16 mb-1" />
</template>
<span v-else 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 {ref} from "vue";
import {Icon as IconifyIcon} from "@iconify/vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
const ping = ref<number | null>(null)
const download = ref<number | null>(null)
const upload = ref<number | null>(null)
const isTesting = ref(false)
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() {
isTesting.value = true
download.value = null
upload.value = null
ping.value = null
try {
await testDownload()
await testUpload()
await testPing()
} finally {
isTesting.value = false
}
}
</script>

View File

@@ -1,52 +1,49 @@
<template>
<div class="bg-m-secondary w-[250px] h-[292px] rounded-md mx-4 shadow-md/50 shadow-black">
<p class="font-bold text-3xl text-m-tertiary my-1 mx-3">
Status
</p>
<div class="status-card card-glow">
<div class="card-header">
<h2 class="card-title">Status</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="relative w-[200px] h-[68px] rounded-md mx-[25px] mb-3"
class="status-row animate-shimmer"
>
<ButtonSkeleton custom-class="h-full w-full" />
<div class="absolute inset-0 p-2">
<TextSkeleton custom-class="h-5 w-24 mb-2" />
<div class="flex items-center gap-2">
<CircleSkeleton custom-class="h-6 w-6" />
<TextSkeleton custom-class="h-5 w-20" />
</div>
<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-else
class="bg-m-tertiary w-[200px] h-[68px] rounded-md shadow-md/50 shadow-m-black mx-[25px] mb-3"
v-for="row in rows"
v-else
:key="`${row.label}-${row.url}`"
class="status-row"
:class="row.status === 200 ? 'row-ok' : 'row-error'"
>
<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) }}
<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>
</template>
<script setup lang="ts">
import ButtonSkeleton from "~/components/skeleton/ButtonSkeleton.vue"
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
@@ -77,16 +74,10 @@ 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 () => {
@@ -94,7 +85,7 @@ const checkStatus = async () => {
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 = [
@@ -125,3 +116,67 @@ onBeforeUnmount(() => {
}
})
</script>
<style scoped>
.status-card {
background: rgb(var(--m-secondary));
border-radius: 12px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
transition: background-color 0.4s 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;
padding: 0.75rem 1rem;
border-radius: 8px;
background: rgb(var(--m-tertiary));
border: 1px solid transparent;
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,190 @@
<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: rgb(var(--m-secondary));
border-radius: 12px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
transition: background-color 0.4s 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.875rem 1rem;
border-radius: 10px;
background: rgb(var(--m-tertiary));
border: 1px solid rgb(var(--m-accent) / 0.06);
}
.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));
}
</style>

90
composables/useApiAuth.ts Normal file
View File

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

3
eslint.config.mjs Normal file
View File

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

View File

@@ -1,41 +1,484 @@
<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"
/>
<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>
<div class="flex flex-col gap-2">
<div class="bg-m-tertiary rounded-lg border border-m-accent/6">
<NuxtLink
to="/"
class="flex items-center gap-3 px-4 py-2 rounded-lg text-white hover:bg-m-tertiary/80 transition-colors"
>
<IconifyIcon
icon="mdi:home"
class="text-lg"/>
<p>Home</p>
</NuxtLink>
</div>
<div class="bg-m-tertiary rounded-lg border border-m-accent/6">
<NuxtLink
to="/backup"
class="flex items-center gap-3 px-4 py-2 rounded-lg text-white hover:bg-m-tertiary/80 transition-colors"
>
<IconifyIcon
icon="mdi:data"
class="text-lg"/>
<p>Backup</p>
</NuxtLink>
</div>
</div>
</nav>
</div>
<div class="sidebar-footer">
<div class="sidebar-divider"/>
<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;
}
.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.5rem 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.5rem;
}
.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-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));
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"
}
}

354
pages/backup.vue Normal file
View File

@@ -0,0 +1,354 @@
<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>
<div class="dashboard-grid">
<section class="grid-left" aria-label="Commandes de sauvegarde">
<BackupButtonSee
class="animate-fade-in-up backup-selector"
style="animation-delay: 120ms"
@select="selectedBackup = $event"
/>
<BackupRun
class="animate-fade-in-up"
style="animation-delay: 180ms"
@result="handleScriptResult"
/>
</section>
<section class="grid-middle" aria-labelledby="backup-files-title">
<div class="files-panel animate-fade-in-up" style="animation-delay: 240ms">
<div class="files-panel-header">
<div>
<p class="section-kicker">Fichiers</p>
<h2 id="backup-files-title" class="files-panel-title">
Historique des sauvegardes
</h2>
</div>
<p class="files-panel-meta">
{{ selectedBackup ? `Source ${selectedBackup}` : "En attente de selection" }}
</p>
</div>
<BackupList
class="backup-list-mobile"
: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>
<p class="section-kicker">Execution</p>
<h2 id="backup-output-title" class="files-panel-title">Resultat du script</h2>
</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 {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 320px);
gap: 1.5rem;
align-items: end;
margin-bottom: 1.5rem;
}
.header-copy {
min-width: 0;
}
.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;
}
.dashboard-grid {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 1.5rem;
align-items: start;
}
.grid-left,
.grid-middle {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-width: 0;
}
.files-panel {
padding: 1.25rem;
border-radius: 20px;
background: rgb(var(--m-secondary) / 0.4);
border: 1px solid rgb(var(--m-accent) / 0.08);
}
.output-panel {
min-height: 220px;
}
.files-panel-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.files-panel-title {
margin: 0;
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.files-panel-meta {
margin: 0;
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgb(var(--m-muted));
text-align: right;
}
.panel-badge {
display: inline-flex;
align-items: center;
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) {
.dashboard-header,
.dashboard-grid {
grid-template-columns: 1fr;
}
.files-panel-header {
align-items: flex-start;
flex-direction: column;
}
.files-panel-meta {
text-align: left;
}
}
@media (max-width: 820px) {
.dashboard-container {
padding: 4.5rem 1.25rem 1.25rem;
}
.files-panel {
padding: 1rem;
}
}
</style>

View File

@@ -1,59 +1,104 @@
<template>
<NuxtLayout name="default">
<template #sidebar>
<div class="flex flex-col">
<DiagramStorage
v-for="item in diagramItems"
:key="item.key"
:loading="loading"
: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>
<h1 class="font-display text-3xl font-bold tracking-tight text-m-text">
Monitoring
</h1>
</div>
</header>
<p class="font-bold text-4xl my-6 mx-4">Écran de monitoring</p>
<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="flex">
<div class="flex flex-col gap-4">
<StatusSite />
<BackupButtonSee @select="selectedBackup = $event" />
</div>
<div class="dashboard-grid">
<div class="grid-left">
<StatusSite class="animate-fade-in-up" style="animation-delay: 100ms" />
</div>
<div class="flex flex-col gap-4">
<Speedtest />
<BackupList :folder="selectedBackup" />
<MessageDiscord/>
<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">
definePageMeta({layout: false})
import {computed, onMounted, ref} from "vue"
import { apiFetch } from "~/composables/useApiAuth"
import type { SystemMetrics } from "~/types/system";
type SourceKey = "remote" | "local"
type DiskCommandResult = { ok: boolean; output: string }
type DiskApiResponse = {
remote?: string | DiskCommandResult
local?: string | DiskCommandResult
definePageMeta({layout: false})
type DiskSourceResult = {
key: string
label: string
ok: boolean
output: string
}
const selectedBackup = ref<string | null>(null)
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)
@@ -93,21 +138,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
@@ -116,15 +149,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` : "--",
@@ -133,40 +166,166 @@ 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.5rem;
border-bottom: 1px solid rgba(80, 140, 255, 0.08);
}
.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: 12px;
background: rgb(var(--m-secondary));
padding: 0.75rem;
}
.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;
}
@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,20 @@
import scripts from "../config/backup-script.json"
type BackupScript = {
key: string
label: string
icon?: string
downloadFolders?: string[]
command: string
}
export default defineEventHandler(() => {
return {
scripts: (scripts as BackupScript[]).map(({ key, label, icon, downloadFolders }) => ({
key,
label,
icon: icon || "mdi:play-circle-outline",
downloadFolders: downloadFolders || []
}))
}
})

View File

@@ -0,0 +1,60 @@
import { execFile } from "node:child_process"
import scripts from "../config/backup-script.json"
type BackupScript = {
key: string
label: string
downloadFolders?: string[]
command: string
args?: string[]
}
function runCommand(command: string, args: string[] = []): Promise<string> {
return new Promise((resolve, reject) => {
execFile(command, args, { 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 = (scripts as BackupScript[]).find((item) => item.key === key)
if (!script) {
throw createError({
statusCode: 404,
statusMessage: "Script introuvable"
})
}
try {
const output = await runCommand(script.command, script.args || [])
return {
ok: true,
key: script.key,
label: script.label,
downloadFolders: script.downloadFolders || [],
output: output.trim()
}
} catch (error) {
console.error("Erreur execution script:", error)
throw createError({
statusCode: 500,
statusMessage: "Erreur lors de l'opération"
})
}
})

View File

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

View File

@@ -1,15 +1,31 @@
export default defineEventHandler(async () => {
const token = process.env.DISCORD_BOT_TOKEN
const channel = process.env.DISCORD_CHANNEL_ID
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}`
}
`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,34 @@
import { exec } from "child_process"
import { exec, execFile } from "child_process"
import diskSources from "../config/disk-commands.json"
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"
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" : ""
function runCommand(command: string): Promise<string> {
return process.env[envKey] || (legacyEnvKey ? process.env[legacyEnvKey] : undefined) || null
}
function runCommand(command: string, args: string[] = []): Promise<string> {
return new Promise((resolve, reject) => {
execFile(command, args, (error, stdout, stderr) => {
if (error) {
reject(stderr || error.message)
return
}
resolve(stdout)
})
})
}
function runShellCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => {
if (error) {
@@ -21,25 +41,30 @@ 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 as DiskSource[]).map(async (source) => {
try {
const envCommand = getEnvCommand(source)
const output = envCommand
? await runShellCommand(envCommand)
: await runCommand(source.command, source.args || [])
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 {
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)}`
}
}
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,97 +1,27 @@
import { execFile, spawn } from "node:child_process"
import { Readable } from "node:stream"
const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST || "malio-b@192.168.0.179"
const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups"
const FOLDER_MAP: Record<string, string> = {
ferme: "bdd_recette/ferme",
inventory: "bdd_recette/inventory",
sirh: "bdd_recette/sirh",
user: "bdd_recette/user",
bitwarden: "bitwarden"
}
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)
const isSafeFile = (value: string) => /^[^/\\]+$/.test(value)
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
const isSafeFile = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
function runSsh(command: string): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
"ssh",
["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", REMOTE_HOST, command],
{ maxBuffer: 5 * 1024 * 1024 },
(error, stdout, stderr) => {
if (error) {
reject(stderr || error.message)
return
}
resolve(stdout)
}
)
})
}
async function remoteDirExists(remoteDir: string): Promise<boolean> {
const output = await runSsh(`[ -d ${shellQuote(remoteDir)} ] && echo yes || echo no`)
return output.trim() === "yes"
}
async function resolveFolderRemoteDir(folderName: string): Promise<string | null> {
const mapped = FOLDER_MAP[folderName]
if (mapped) {
return `${REMOTE_ROOT}/${mapped}`
}
const direct = `${REMOTE_ROOT}/${folderName}`
if (await remoteDirExists(direct)) {
return direct
}
const nested = `${REMOTE_ROOT}/bdd_recette/${folderName}`
if (await remoteDirExists(nested)) {
return nested
}
return null
}
function buildContentDisposition(fileName: string) {
const asciiName = fileName.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_")
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`
}
function speedtestStream(event: any) {
const size = 128 * 1024 * 1024
let sent = 0
const stream = new Readable({
read(chunkSize) {
if (sent >= size) {
this.push(null)
return
}
const remaining = size - sent
const chunk = Buffer.alloc(Math.min(chunkSize, remaining), "a")
sent += chunk.length
this.push(chunk)
}
})
setHeader(event, "Content-Type", "application/octet-stream")
setHeader(event, "Content-Length", size)
return stream
}
export default defineEventHandler(async (event) => {
const { folder, file } = getQuery(event)
const folderName = typeof folder === "string" ? folder : null
const fileName = typeof file === "string" ? file : null
// Compat mode: utilisé par le test de débit.
if (!folderName || !fileName) {
return speedtestStream(event)
throw createError({ statusCode: 400, statusMessage: "Paramètres manquants" })
}
if (!isSafeFolder(folderName) || !isSafeFile(fileName)) {

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

View File

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

View File

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

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
}