21 Commits

Author SHA1 Message Date
semantic-release-bot
8bd78a610f chore(release): 1.2.2 2026-03-10 12:52:57 +00:00
975b0f9718 Merge pull request 'fix/backup-ui-download' (#8) from fix/backup-ui-download into develop
All checks were successful
Release / release (push) Successful in 25s
Reviewed-on: #8
2026-03-10 12:52:33 +00:00
889d723e81 refactor: simplify backup result handling 2026-03-10 13:51:21 +01:00
4757c766f6 fix: align backup ui and downloads 2026-03-10 13:48:55 +01:00
acee6d471c fix: ssh connection correctif 2026-03-10 11:47:37 +01:00
semantic-release-bot
60c2fb2d7e chore(release): 1.2.1 2026-03-10 09:44:09 +00:00
e372505120 Merge pull request 'fix/style-loading-flash' (#7) from fix/style-loading-flash into develop
All checks were successful
Release / release (push) Successful in 29s
Reviewed-on: #7
2026-03-10 09:43:42 +00:00
4e393dd5e9 fix: reduce style loading flash 2026-03-10 10:40:46 +01:00
8fd4aba63e Merge branch 'develop' into feat/app-version 2026-03-10 10:14:46 +01:00
semantic-release-bot
d63b02fc4e chore(release): 1.2.0 2026-03-10 09:08:55 +00:00
7ed9382e73 Merge pull request 'feat: show git tag version' (#6) from feat/app-version into develop
All checks were successful
Release / release (push) Successful in 25s
Reviewed-on: #6
2026-03-10 09:08:32 +00:00
539cbdd2f1 feat: show git tag version 2026-03-10 10:06:55 +01:00
semantic-release-bot
0d985b62b1 chore(release): 1.1.0 2026-03-10 08:53:59 +00:00
4d76d2697b Merge pull request 'feat(backup): add backup scripts workflow' (#5) from feat/backup-release into develop
All checks were successful
Release / release (push) Successful in 37s
Reviewed-on: #5
2026-03-10 08:53:25 +00:00
0863dfad2e feat(backup): add backup scripts workflow 2026-03-10 09:51:26 +01:00
440fffc605 Merge pull request 'chore/configure-semantic-release' (#4) from chore/configure-semantic-release into develop
Reviewed-on: #4
2026-03-10 07:20:27 +00:00
fb96cc3c32 chore(release) : configure semantic-release 2026-03-10 08:18:20 +01:00
aace834dd4 feat : readme et agents 2026-03-09 15:42:13 +01:00
f5cc79f510 feat : new ui et message discord 2026-03-09 15:27:18 +01:00
aad7a0a928 Merge pull request 'feat : ajout download backup' (#2) from feat/387-affichage-download-backup into develop
Reviewed-on: #2
2026-03-09 09:51:57 +00:00
db738715c3 feat : ajout download backup 2026-03-09 10:50:41 +01:00
37 changed files with 9399 additions and 406 deletions

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

28
CHANGELOG.md Normal file
View File

@@ -0,0 +1,28 @@
## [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.

BIN
assets/LOGO_CARRE_BLANC.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

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

@@ -0,0 +1,99 @@
<template>
<div class="backup-card card-glow">
<div class="card-header">
<h2 class="card-title">Backup</h2>
<span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Dossiers</span>
</div>
<div class="backup-list">
<button
v-for="item in folders"
:key="item.name"
type="button"
class="backup-btn"
:class="{ 'backup-btn-active': active === item.name }"
@click="select(item.name)"
>
<div class="flex items-center gap-2.5">
<IconifyIcon :icon="item.icon" class="text-base text-m-accent" />
<span class="font-display text-sm font-semibold uppercase tracking-wide">
{{ item.name }}
</span>
</div>
<IconifyIcon
icon="mdi:chevron-right"
class="text-lg text-m-muted transition-transform duration-200"
:class="{ 'translate-x-0.5 !text-m-accent': active === item.name }"
/>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { Icon as IconifyIcon } from "@iconify/vue"
import backupOptions from "~/server/config/backup-options.json"
const emit = defineEmits(["select"])
const active = ref<string | null>(null)
const folders = backupOptions as Array<{ name: string; icon: string }>
const select = (name: string) => {
active.value = name
emit("select", name)
}
</script>
<style scoped>
.backup-card {
background: rgb(var(--m-secondary));
border-radius: 12px;
padding: 1.25rem;
transition: background-color 0.4s ease;
}
.card-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.card-title {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.backup-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.backup-btn {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0.875rem;
border-radius: 8px;
background: rgb(var(--m-tertiary));
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s ease;
color: rgb(var(--m-text));
}
.backup-btn:hover {
border-color: rgb(var(--m-accent) / 0.15);
background: rgb(var(--m-accent) / 0.06);
}
.backup-btn-active {
border-color: rgb(var(--m-accent) / 0.25);
background: rgb(var(--m-accent) / 0.08);
box-shadow: 0 0 12px -4px rgb(var(--m-accent) / 0.15);
}
</style>

150
components/BackupList.vue Normal file
View File

@@ -0,0 +1,150 @@
<template>
<div class="backup-list-card card-glow">
<div class="card-header">
<h2 class="card-title">{{ title }}</h2>
</div>
<div v-if="!folder" class="empty-state">
<IconifyIcon icon="mdi:folder-open-outline" class="text-3xl text-m-muted/40" />
<p class="mt-2 font-mono text-xs text-m-muted/50">
Selectionnez un dossier
</p>
</div>
<div v-else-if="loading" class="file-list">
<div
v-for="n in 6"
:key="`backup-skeleton-${n}`"
class="file-row animate-shimmer"
>
<TextSkeleton custom-class="h-4 w-48" />
<CircleSkeleton custom-class="h-5 w-5 rounded" />
</div>
</div>
<div v-else-if="backups.length === 0" class="empty-state">
<IconifyIcon icon="mdi:file-hidden" class="text-3xl text-m-muted/40" />
<p class="mt-2 font-mono text-xs text-m-muted/50">
Aucun backup trouve
</p>
</div>
<div v-else class="file-list">
<button
v-for="file in backups"
:key="file"
class="file-row"
@click="downloadBackup(file)"
>
<div class="flex min-w-0 items-center gap-2.5">
<IconifyIcon icon="mdi:file-document-outline" class="text-base text-m-accent flex-shrink-0" />
<span class="truncate font-mono text-xs text-m-text">
{{ file }}
</span>
</div>
<IconifyIcon
icon="mdi:download"
class="text-base text-m-muted flex-shrink-0 transition-colors duration-200"
/>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import {Icon as IconifyIcon} from "@iconify/vue"
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
const props = defineProps<{
folder: string | null
}>()
const backups = ref<string[]>([])
const loading = ref(false)
const title = computed(() => {
if (!props.folder) return "Fichiers"
return `Backup — ${props.folder.toUpperCase()}`
})
const downloadBackup = (file: string) => {
if (!props.folder) return
const url = `/api/download?folder=${encodeURIComponent(props.folder)}&file=${encodeURIComponent(file)}`
window.location.href = url
}
watch(() => props.folder, async (folder) => {
if (!folder) {
loading.value = false
backups.value = []
return
}
loading.value = true
try {
const data = await $fetch<string[]>(`/api/backups?folder=${folder}`)
backups.value = data.slice(0, 6)
} catch (error) {
console.error("Erreur récupération backups:", error)
backups.value = []
} 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;
}
.file-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.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>

283
components/BackupRun.vue Normal file
View File

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

View File

@@ -0,0 +1,108 @@
<script setup>
import {Icon as IconifyIcon} from "@iconify/vue"
const { data: messages } = await useFetch('/api/discord/messages')
</script>
<template>
<div class="discord-card card-glow">
<div class="card-header">
<div class="flex items-center gap-2.5">
<IconifyIcon icon="mdi:message-text" class="text-lg text-m-accent" />
<h2 class="card-title">Discord</h2>
</div>
<span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Messages</span>
</div>
<div v-if="!messages || messages.length === 0" class="empty-state">
<IconifyIcon icon="mdi:chat-outline" class="text-3xl text-m-muted/40" />
<p class="mt-2 font-mono text-xs text-m-muted/50">
Aucun message
</p>
</div>
<div v-else class="message-list">
<div
v-for="m in messages"
:key="m.id"
class="message-row"
>
<div class="message-avatar">
{{ m.author.username.charAt(0).toUpperCase() }}
</div>
<div class="min-w-0 flex-1">
<span class="font-display text-xs font-semibold text-m-accent">
{{ m.author.username }}
</span>
<p class="mt-0.5 break-words font-display text-sm leading-relaxed text-m-text/80">
{{ m.content }}
</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.discord-card {
background: 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;
}
.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>

View File

@@ -1,92 +1,64 @@
<template>
<div class="bg-m-secondary w-[509px] h-[184px] mx-4 rounded-md shadow-md/50 shadow-black p-2">
<div class="mb-2 flex items-center justify-between">
<p class="font-bold text-3xl text-m-tertiary">
Speedtest
</p>
<IconifyIcon
<div class="speedtest-card card-glow">
<div class="card-header">
<h2 class="card-title">Speedtest</h2>
<button
class="reload-btn"
@click="runTests"
:disabled="isTesting"
>
<IconifyIcon
icon="mdi:reload"
class="bg-m-tertiary text-2xl text-black rounded-md shadow-md/50 mr-1 cursor-pointer"
@Click="runTests"
/>
class="text-lg"
:class="{ 'animate-spin': isTesting }"
/>
</button>
</div>
<div class="grid grid-cols-3 gap-3">
<div class="bg-m-tertiary w-[153px] h-[120px] rounded-md shadow-md/50 shadow-m-black">
<div class="flex items-center justify-center">
<IconifyIcon
icon="mdi:download"
class="text-m-primary text-2xl mt-2 ml-1"
/>
<p class="font-bold uppercase text-xl text-m-text mt-2 mr-1">
download
</p>
</div>
<div class="mx-2 flex flex-col items-center justify-center">
<span class="text-4xl">
{{ download !== null ? `${download}` : "..." }}
<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>
<p class="font-bold text-xl leading-tight">
Mbps
</p>
</div>
</div>
<div class="bg-m-tertiary w-[153px] h-[120px] rounded-md shadow-md/50 shadow-m-black">
<div class="flex items-center justify-center">
<IconifyIcon
icon="mdi:upload"
class="text-m-primary text-2xl mt-2 ml-1"
/>
<p class="font-bold uppercase text-xl text-m-text mt-2 mr-1">
upload
</p>
</div>
<div class="mx-2 flex flex-col items-center justify-center">
<span class="text-4xl">
{{ upload !== null ? `${upload}` : "..." }}
</span>
<p class="font-bold text-xl leading-tight">
Mbps
</p>
</div>
</div>
<div class="bg-m-tertiary w-[153px] h-[120px] rounded-md shadow-md/50 shadow-m-black">
<div class="flex items-center justify-center">
<IconifyIcon
icon="mdi:wifi"
class="text-m-primary text-2xl mt-2 ml-1"
/>
<p class="font-bold uppercase text-xl text-m-text mt-2 mr-1">
ping
</p>
</div>
<div class="mx-2 flex flex-col items-center justify-center">
<span class="text-4xl">
{{ ping !== null ? `${ping}` : "..." }}
</span>
<p class="font-bold text-xl leading-tight">
Ms
</p>
<div 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>
</div>
</template>
<script setup lang="ts">
import {onMounted, ref} from "vue";
import {computed, ref} from "vue"
import {Icon as IconifyIcon} from "@iconify/vue"
const ping = ref<number | null>(null)
const download = ref<number | null>(null)
const upload = ref<number | null>(null)
const isTesting = ref(false)
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 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)
@@ -95,37 +67,126 @@ async function testDownload() {
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
})
await fetch('/api/upload', { method: 'POST', body: data })
const end = performance.now()
const seconds = (end - start) / 1000
upload.value = Math.round((size * 8) / seconds / 1000000)
}
async function testPing() {
const start = performance.now()
await fetch('/api/ping')
const end = performance.now()
ping.value = Math.round(end - start)
}
async function runTests() {
await testDownload()
await testUpload()
await testPing()
isTesting.value = true
download.value = null
upload.value = null
ping.value = null
try {
await testDownload()
await testUpload()
await testPing()
} finally {
isTesting.value = false
}
}
onMounted(() => {
runTests()
})
</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));
}
</style>

View File

@@ -1,30 +1,47 @@
<template>
<div class="bg-m-secondary w-[250px] h-auto rounded-md mx-4 shadow-md/50 shadow-black pb-4">
<p class="font-bold text-3xl text-m-tertiary my-1 mx-3">
Status
</p>
<div
class="bg-m-tertiary w-[200px] h-auto rounded-md shadow-md/50 shadow-m-black mx-[25px] mb-3"
<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="status-row animate-shimmer"
>
<div class="flex items-center gap-3">
<CircleSkeleton custom-class="h-3 w-3" />
<TextSkeleton custom-class="h-4 w-20" />
</div>
<TextSkeleton custom-class="h-4 w-16" />
</div>
</template>
<div
v-else
v-for="row in rows"
:key="`${row.label}-${row.url}`"
>
<p class="font-bold text-xl text-m-text mt-2 mx-2 mb-1">
{{ row.label }}
</p>
<div class="mx-2 flex items-center">
<span
class="inline-block h-[24px] w-[24px] rounded-full mr-2"
:class="statusClass(row.status)"
/>
<span class="font-semibold text-lg">
{{ statusLabel(row.status) }}
</span>
class="status-row"
:class="row.status === 200 ? 'row-ok' : 'row-error'"
>
<div class="flex items-center gap-3">
<span class="status-dot" :class="row.status === 200 ? 'dot-ok' : 'dot-error'" />
<span class="font-display text-sm font-semibold text-m-text">
{{ row.label }}
</span>
</div>
<span class="font-mono text-xs" :class="row.status === 200 ? 'text-m-success' : 'text-m-error'">
{{ statusLabel(row.status) }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
import {onBeforeUnmount, onMounted, ref} from "vue"
interface StatusRow {
@@ -52,21 +69,20 @@ const props = withDefaults(
)
const rows = ref<StatusRow[]>([])
const loading = ref(true)
const initialized = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
const statusClass = (status: number) => {
if (status === 200) return "bg-m-success"
if (status === 0) return "bg-m-error"
return "bg-m-error"
}
const statusLabel = (status: number) => {
if (status === 200) return "HTTP 200"
if (status === 0) return "Injoignable"
return `KO (HTTP ${status})`
return `KO (${status})`
}
const checkStatus = async () => {
if (!initialized.value) {
loading.value = true
}
try {
const data = await $fetch<StatusResponse>(props.endpoint)
rows.value = data.results
@@ -81,6 +97,9 @@ const checkStatus = async () => {
error: error instanceof Error ? error.message : String(error)
}
]
} finally {
initialized.value = true
loading.value = false
}
}
@@ -96,3 +115,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,14 @@
<template>
<div class="animate-pulse rounded-md bg-m-tertiary/70" :class="customClass" />
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
customClass?: string
}>(),
{
customClass: "h-4 w-full"
}
)
</script>

View File

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

View File

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

View File

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

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,44 @@
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: {
public: {
appVersion: getRepoVersion()
}
},
vite: {
plugins: [tailwindcss()]
}

6382
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,12 +11,19 @@
},
"dependencies": {
"@iconify/vue": "^5.0.0",
"iconify": "^1.4.0",
"nuxt": "^4.3.1",
"vue": "^3.5.29",
"vue-router": "^4.6.4"
},
"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"
}
}

347
pages/backup.vue Normal file
View File

@@ -0,0 +1,347 @@
<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"
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 $fetch<string[]>(`/api/backups?folder=${encodeURIComponent(folder)}`)
return files[0] || null
}
const triggerDownload = (folder: string, file: string) => {
const link = document.createElement("a")
link.href = `/api/download?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(file)}`
link.style.display = "none"
document.body.appendChild(link)
link.click()
link.remove()
}
const downloadLatestBackup = async (folder: string) => {
const latestFile = await fetchLatestBackup(folder)
if (latestFile) {
triggerDownload(folder, latestFile)
}
}
const handleScriptResult = async (payload: ScriptResult) => {
scriptResult.value = { ...emptyScriptResult(), ...payload }
if (payload.isError || payload.downloadFolders.length === 0) {
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,47 +1,81 @@
<template>
<NuxtLayout name="default">
<template #sidebar>
<div class="flex flex-col gap-">
<DiagramStorage
v-for="item in diagramItems"
:key="item.key"
:host-name="item.hostName"
:status-color-class="item.statusColorClass"
:chart-radius="chartRadius"
:chart-circumference="chartCircumference"
:chart-offset="item.chartOffset"
:remaining-percent-text="item.remainingPercentText"
:used-text="item.usedText"
:total-text="item.totalText"
/>
</div>
</template>
<div class="dashboard-container">
<header class="dashboard-header">
<div>
<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="flex">
<StatusSite/>
<Speedtest/>
<div class="content-grid">
<div class="content-main">
<section class="storage-section">
<div class="storage-section-header">
<h2 class="font-display text-xl font-semibold text-m-text">Stockage</h2>
<span class="font-mono text-[10px] uppercase tracking-widest text-m-muted">Volumes</span>
</div>
<div class="storage-grid">
<DiagramStorage
v-for="(item, idx) in diagramItems"
:key="item.key"
:item="item"
:style="{ animationDelay: `${idx * 150}ms` }"
class="animate-fade-in-up"
/>
</div>
</section>
<div class="dashboard-grid">
<div class="grid-left">
<StatusSite class="animate-fade-in-up" style="animation-delay: 100ms" />
</div>
<div class="grid-middle">
<Speedtest class="animate-fade-in-up speedtest-card-mobile" style="animation-delay: 150ms" />
</div>
</div>
</div>
<div class="content-aside">
<MessageDiscord class="animate-fade-in-up" style="animation-delay: 300ms" />
</div>
</div>
</div>
</NuxtLayout>
</template>
<script setup lang="ts">
import Speedtest from "~/components/Speedtest.vue";
definePageMeta({layout: false})
import {computed, onMounted, ref} from "vue"
type SourceKey = "remote" | "local"
type DiskCommandResult = { ok: boolean; output: string }
type DiskApiResponse = {
remote?: string | DiskCommandResult
local?: string | DiskCommandResult
type DiskSourceResult = {
key: string
label: string
ok: boolean
output: string
}
const rawResults = ref<Record<SourceKey, string>>({
remote: "",
local: ""
})
type DiskApiResponse = {
results: DiskSourceResult[]
}
type DiagramItem = {
key: string
loading: boolean
hostName: string
statusColorClass: string
chartRadius: number
chartCircumference: number
chartOffset: number
remainingPercentText: string
usedText: string
totalText: string
}
const selectedBackup = ref<string | null>(null)
const rawResults = ref<DiskSourceResult[]>([])
const loading = ref(false)
const chartRadius = 52
const chartCircumference = 2 * Math.PI * chartRadius
@@ -84,21 +118,9 @@ const getDiskValues = (output: string) => {
return {availableGb, usedGb, totalGb}
}
const getOutputText = (entry: unknown) => {
if (typeof entry === "string") return entry
if (entry && typeof entry === "object" && "output" in entry) {
const output = (entry as DiskCommandResult).output
return typeof output === "string" ? output : String(output)
}
return ""
}
const diagramItems = computed(() => {
return [
{ key: "remote" as const, fallbackHost: "Serveur distant", output: rawResults.value.remote },
{ key: "local" as const, fallbackHost: "Machine locale", output: rawResults.value.local }
].map((item) => {
const diskValues = getDiskValues(item.output)
const diagramItems = computed<DiagramItem[]>(() => {
return rawResults.value.map((result) => {
const diskValues = getDiskValues(result.output)
const remainingPercent =
diskValues === null
? null
@@ -107,15 +129,15 @@ const diagramItems = computed(() => {
Math.min(100, Math.round((diskValues.availableGb / diskValues.totalGb) * 100))
)
const chartOffset = chartCircumference - ((remainingPercent ?? 0) / 100) * chartCircumference
const statusColorClass =
remainingPercent !== null && remainingPercent <= 30 ? "m-error" : "m-success"
return {
key: item.key,
hostName: getHostName(item.output, item.fallbackHost),
statusColorClass,
chartOffset,
key: result.key,
loading: loading.value,
hostName: getHostName(result.output, result.label),
statusColorClass:
remainingPercent !== null && remainingPercent <= 30 ? "status-error" : "status-success",
chartRadius,
chartCircumference,
chartOffset: chartCircumference - ((remainingPercent ?? 0) / 100) * chartCircumference,
remainingPercentText:
loading.value ? "..." : remainingPercent === null ? "--%" : `${remainingPercent}%`,
usedText: loading.value ? "..." : diskValues ? `${diskValues.usedGb.toFixed(2)} GB` : "--",
@@ -124,40 +146,139 @@ 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"
}
return
}
rawResults.value = {
remote: getOutputText(output.remote),
local: getOutputText(output.local)
}
const output = await $fetch<DiskApiResponse>("/api/disk")
rawResults.value = output.results
} catch (error) {
const message = `Erreur: ${error instanceof Error ? error.message : String(error)}`
rawResults.value = {
remote: message,
local: message
}
rawResults.value = [
{
key: "error",
label: "Source indisponible",
ok: false,
output: message
}
]
} finally {
loading.value = false
}
}
onMounted(() => {
runScript()
})
</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;
}
.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 {
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,57 @@
import { exec } from "node:child_process"
import scripts from "../config/backup-script.json"
type BackupScript = {
key: string
label: string
downloadFolders?: string[]
command: string
}
function runCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => {
exec(command, { timeout: 10 * 60 * 1000 }, (error, stdout, stderr) => {
if (error) {
reject(stderr || error.message)
return
}
resolve(stdout || stderr)
})
})
}
export default defineEventHandler(async (event) => {
const body = await readBody<{ key?: string }>(event)
const key = typeof body?.key === "string" ? body.key : null
if (!key) {
throw createError({
statusCode: 400,
statusMessage: "Clé de script manquante"
})
}
const script = (scripts as BackupScript[]).find((item) => item.key === key)
if (!script) {
throw createError({
statusCode: 404,
statusMessage: "Script introuvable"
})
}
try {
const output = await runCommand(script.command)
return {
ok: true,
key: script.key,
label: script.label,
downloadFolders: script.downloadFolders || [],
output: output.trim()
}
} catch (error) {
throw createError({
statusCode: 500,
statusMessage: `Erreur execution script: ${String(error)}`
})
}
})

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

@@ -0,0 +1,151 @@
import { execFile } from "node:child_process"
import folderMap from "../config/backup-folders.json"
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")
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
const FOLDER_MAP = folderMap as Record<string, string>
function runSsh(command: string): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
"ssh",
["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", REMOTE_HOST, command],
{ maxBuffer: 10 * 1024 * 1024 },
(error, stdout, stderr) => {
if (error) {
reject(stderr || error.message)
return
}
resolve(stdout)
}
)
})
}
function isMissingPathError(error: unknown): boolean {
const message = String(error || "").toLowerCase()
return message.includes("no such file or directory") || message.includes("cannot access")
}
function toServerError(error: unknown) {
return createError({
statusCode: 500,
statusMessage: `Erreur SSH backups: ${String(error)}`
})
}
function parseLines(output: string): string[] {
return output
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
}
function quoteDir(pathValue: string) {
return shellQuote(pathValue)
}
async function listRemoteFiles(remoteDir: string): Promise<string[]> {
const output = await runSsh(
`cd ${quoteDir(remoteDir)} && ls -1A | sort -r | head -n ${MAX_FILES_PER_FOLDER}`
)
return parseLines(output)
}
async function listRemoteDirs(remoteRoot: string): Promise<string[]> {
const output = await runSsh(
`cd ${quoteDir(remoteRoot)} && for d in */; do [ -d "$d" ] && printf '%s\n' "\${d%/}"; done`
)
return parseLines(output)
}
async function getLatestRemoteFile(remoteDir: string): Promise<string | null> {
const output = await runSsh(
`cd ${quoteDir(remoteDir)} && ls -1A | sort -r | head -n 1`
)
const files = parseLines(output)
return files[0] || null
}
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
// Si un dossier est demandé, on retourne sa liste de fichiers.
if (folderName) {
if (!isSafeFolder(folderName)) {
throw createError({
statusCode: 400,
statusMessage: "Paramètre folder invalide"
})
}
const remoteDir = await resolveFolderRemoteDir(folderName)
if (!remoteDir) return []
try {
return await listRemoteFiles(remoteDir)
} catch (error) {
if (isMissingPathError(error)) return []
throw toServerError(error)
}
}
// Sinon on récupère le dernier backup de chaque dossier distant.
let dirs: string[] = []
try {
dirs = await listRemoteDirs(REMOTE_ROOT)
} catch (error) {
throw toServerError(error)
}
const result: Array<{ folder: string; last: string | null }> = []
for (const dirName of dirs) {
const remoteDir = `${REMOTE_ROOT}/${dirName}`
try {
result.push({
folder: dirName,
last: await getLatestRemoteFile(remoteDir)
})
} catch (error) {
if (isMissingPathError(error)) {
result.push({
folder: dirName,
last: null
})
continue
}
throw toServerError(error)
}
}
return result
})

View File

@@ -0,0 +1,15 @@
export default defineEventHandler(async () => {
const token = process.env.DISCORD_BOT_TOKEN
const channel = process.env.DISCORD_CHANNEL_ID
const messages = await $fetch(
`https://discord.com/api/v10/channels/${channel}/messages?limit=20`,
{
headers: {
Authorization: `Bot ${token}`
}
}
)
return messages
})

View File

@@ -1,12 +1,19 @@
import { exec } 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
}
const localCommand =
process.env.DISK_LOCAL_COMMAND ||
"bash /home/kevin/check_storage.sh"
function getCommand(source: DiskSource) {
const envKey = `DISK_COMMAND_${source.key.toUpperCase()}`
const legacyEnvKey =
source.key === "remote" ? "DISK_REMOTE_COMMAND" : source.key === "local" ? "DISK_LOCAL_COMMAND" : ""
return process.env[envKey] || (legacyEnvKey ? process.env[legacyEnvKey] : undefined) || source.command
}
function runCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => {
@@ -21,25 +28,26 @@ 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 output = await runCommand(getCommand(source))
return {
key: source.key,
label: source.label,
ok: true,
output
}
} catch (error) {
return {
key: source.key,
label: source.label,
ok: false,
output: `Erreur: ${String(error)}`
}
}
})
)
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

@@ -1,25 +1,131 @@
import { execFile, spawn } from "node:child_process"
import { Readable } from "node:stream"
import folderMap from "../config/backup-folders.json"
export default defineEventHandler((event) => {
const size = 128 * 1024 * 1024
let sent = 0
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 = folderMap as Record<string, string>
const stream = new Readable({
read(chunkSize) {
if (sent >= size) {
this.push(null)
return
}
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
const isSafeFile = (value: string) => /^[^/\\]+$/.test(value)
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
const remaining = size - sent
const chunk = Buffer.alloc(Math.min(chunkSize, remaining), "a")
sent += chunk.length
this.push(chunk)
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)
}
)
})
}
setHeader(event, "Content-Type", "application/octet-stream")
setHeader(event, "Content-Length", size)
async function remoteDirExists(remoteDir: string): Promise<boolean> {
const output = await runSsh(`[ -d ${shellQuote(remoteDir)} ] && echo yes || echo no`)
return output.trim() === "yes"
}
return stream
})
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)
}
if (!isSafeFolder(folderName) || !isSafeFile(fileName)) {
throw createError({ statusCode: 400, statusMessage: "Paramètres invalides" })
}
const remoteDir = await resolveFolderRemoteDir(folderName)
if (!remoteDir) {
throw createError({ statusCode: 404, statusMessage: "Dossier introuvable" })
}
const remotePath = `${remoteDir}/${fileName}`
const existsOutput = await runSsh(`[ -f ${shellQuote(remotePath)} ] && echo yes || echo no`)
if (existsOutput.trim() !== "yes") {
throw createError({ statusCode: 404, statusMessage: "Fichier introuvable" })
}
setHeader(event, "Content-Type", "application/octet-stream")
setHeader(event, "Content-Disposition", buildContentDisposition(fileName))
const child = spawn("ssh", [
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=5",
REMOTE_HOST,
`cat ${shellQuote(remotePath)}`
])
let stderr = ""
child.stderr.on("data", (chunk) => {
stderr += chunk.toString()
})
child.on("close", (code) => {
if (code !== 0) {
console.error(`Erreur téléchargement SSH (${code}): ${stderr}`)
}
})
return sendStream(event, child.stdout)
})

View File

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

View File

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

View File

@@ -0,0 +1,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" }
]