feat: add check backup #18

Merged
kevin merged 6 commits from feat/440-add-section-check-backup into develop 2026-03-17 09:26:42 +00:00
19 changed files with 866 additions and 248 deletions

View File

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

146
README.md
View File

@@ -1,4 +1,14 @@
# Projet Monitoring # Supervisor
`Supervisor` est une application Nuxt qui centralise plusieurs besoins d'exploitation dans une interface web unique :
- suivi de l'état general d'applications distantes
- consultation de l'espace disque local et distant
- visualisation de métriques système de la machine qui execute l'application
- contrôle et téléchargement de sauvegardes via SSH
- lecture de messages Discord depuis un canal configure
Le nom du package npm visible dans le depot est `disk-monitor`, mais l'interface et la structure du projet exposent clairement le nom `Supervisor`.
## Installation du projet ## Installation du projet
@@ -15,79 +25,105 @@ https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux
### Installation du projet ### Installation du projet
Une fois les prérequis installés, cloner le dépôt puis installer les dépendances. Une fois les prérequis installés, cloner le dépôt puis installer les dépendances.
Les étapes ci-dessous sont celles qui sont réellement supportées par le depot.
### 1. Cloner le depot
```bash
git clone gitea@gitea.malio.fr:MALIO-DEV/Supervisor.git
cd Supervisor
```
### 2. Preparer le fichier d'environnement
Le depot fournit un exemple dans `.env.example`.
```bash
cp .env.example .env
```
### 3. Renseigner les variables necessaires
#### Generation d'une valeur pour `API_SECRET_KEY`
Le depot impose la presence d'un secret, mais ne fournit pas de commande officielle pour le générer.
Exemple de commande compatible :
```bash
openssl rand -hex 32
```
Cette commande sert simplement à produire une valeur aléatoire facile à placer dans `.env`.
Les variables visibles dans le depot sont :
- `API_SECRET_KEY` : secret attendu par le middleware d'authentification pour toutes les routes `/api/*` sauf `/api/ping`
- `DISCORD_BOT_TOKEN` : token du bot utilise par endpoint Discord
- `DISCORD_CHANNEL_ID` : identifiant du canal Discord a lire
- `BACKUPS_REMOTE_HOST` : hôte SSH cible pour les operations distantes
- `BACKUPS_REMOTE_ROOT` : dossier racine des sauvegardes sur l'hôte distant
- `BACKUPS_MAX_FILES` : nombre maximal de fichiers retournés par dossier de backup
- `DISK_COMMAND_REMOTE` : commande shell utilisée pour la verification disque distante
- `DISK_COMMAND_LOCAL` : commande shell utilisée pour la verification disque locale
- `BACKUP_SCRIPT_COMMAND_BACKUP_BDD_RECETTE` : commande a exécuter pour le script "Backup BDD recette"
- `BACKUP_SCRIPT_COMMAND_CHECK_STATUT_RECETTE` : commande à exécuter pour le script "Check statut recette"
- `BACKUP_SCRIPT_COMMAND_BACKUP_VAULTWARDEN` : commande à exécuter pour le script "Backup vault warden"
- `BACKUPS_HOUR` : heure attendue des sauvegardes pour le contrôle de fraicheur
### 4. Installer les dépendances
```bash ```bash
npm install npm install
``` ```
Lancer ensuite le serveur de développement. ### 5. Lancer le serveur de développement
```bash ```bash
npm run dev npm run dev
``` ```
Lapplication sera accessible sur : Par défaut, l'application Nuxt sera accessible sûr <http://localhost:3000>.
http://localhost:3000
Si une erreur liée à la version de Node apparaît, vérifier que Node ≥ 20 est utilisé via nvm. ## Configuration necessaire
nvm install 20 ### Authentification API
nvm use 20
## Utilisation du projet Le middleware `server/middleware/auth.ts` protege toutes les routes `/api/*`, sauf `/api/ping`.
### Frontend
Lancer le serveur de développement. Consequence visible :
```
npm run dev - si `API_SECRET_KEY` est vide, les appels API sont refusés avec `401 Unauthorized`
``` - l'application web pose aussi un cookie HTTP-only via `server/middleware/auth-cookie.ts` pour réutiliser ce secret coté navigateur
Compilation pour la production.
``` ### SSH pour les backups
npm run build
``` Les fonctionnalités de backup utilisent `ssh` avec les options `BatchMode=yes` et `ConnectTimeout=5` dans `server/utils/ssh.ts`. Cela implique un accès sans saisie interactive de mot de passe.
Prévisualisation du build de production.
``` Elements a preparer cote SSH :
npm run preview
``` - une cle privée disponible sur la machine qui execute `Supervisor`
- une clé ssh pour les différentes machines cibles, si necessaire pour les différents usages (backup BDD, backup Vault warden, check statut recette)
Le depot ne fixe pas de noms de fichiers de clés SSH ni de chemin obligatoire. Les noms exacts ne sont donc pas vérifiables dans le code.
## Commandes utiles ## Commandes utiles
Installation des dépendances. Commandes déclarées dans `package.json` :
```
npm install ```bash
```
Lancer le serveur de développement.
```
npm run dev npm run dev
```
Build de production.
```
npm run build npm run build
``` npm run generate
Prévisualisation du build.
```
npm run preview npm run preview
npm run lint
npm run lint:fix
``` ```
Supprimer les dépendances et réinstaller proprement.
```
rm -rf node_modules package-lock.json
npm install
Déploiement
```
Construire lapplication.
```
npm run build
```
Les fichiers générés se trouvent dans :
.output/
Le serveur peut ensuite être lancé avec : Usage :
```
node .output/server/index.mjs
```
Il est recommandé dutiliser un reverse proxy comme Nginx en production.
### Notes - `npm run dev` : lance l'application en développement
- `npm run build` : construit l'application pour la production
Les accès SSH ou les chemins système utilisés par les endpoints doivent rester côté serveur. - `npm run generate` : généré une sortie statique si ce mode est compatible avec votre usage
Ne jamais exposer de credentials dans le frontend. - `npm run preview` : prévisualisé le build Nuxt
Les variables sensibles doivent être stockées dans un fichier .env. - `npm run lint` : execute ESLint
- `npm run lint:fix` : applique les corrections ESLint automatiques : collecte périodique CPU, mémoire et réseau

View File

@@ -34,12 +34,35 @@
min-height: 100vh; min-height: 100vh;
font-family: var(--font-display); font-family: var(--font-display);
background: rgb(var(--m-bg)); background: rgb(var(--m-bg));
background-image:
radial-gradient(circle at top left, rgb(var(--m-accent) / 0.1), transparent 24%),
radial-gradient(circle at top right, rgb(var(--m-success) / 0.08), transparent 18%);
color: rgb(var(--m-text)); color: rgb(var(--m-text));
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
transition: background-color 0.4s ease, color 0.4s ease; transition: background-color 0.4s ease, color 0.4s ease;
} }
::selection {
background: rgb(var(--m-accent) / 0.28);
color: rgb(var(--m-text));
}
a,
button {
transition:
color 0.2s ease,
background-color 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
:focus-visible {
outline: 2px solid rgb(var(--m-accent) / 0.85);
outline-offset: 2px;
}
img { img {
display: block; display: block;
} }
@@ -75,6 +98,13 @@
transition: box-shadow 0.3s ease; transition: box-shadow 0.3s ease;
} }
.card-glow:hover {
box-shadow:
0 0 0 1px rgb(var(--m-accent) / calc(var(--m-card-border-opacity) + 0.04)),
0 10px 30px -10px rgba(0, 0, 0, calc(var(--m-shadow-opacity) + 0.08)),
0 0 56px -14px rgb(var(--m-accent) / 0.1);
}
.card-glow-success { .card-glow-success {
box-shadow: box-shadow:
0 0 0 1px rgb(var(--m-success) / 0.15), 0 0 0 1px rgb(var(--m-success) / 0.15),
@@ -165,3 +195,14 @@
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgb(var(--m-muted)); background: rgb(var(--m-muted));
} }
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -187,8 +187,7 @@ const runScript = async (key: string) => {
? error.data.statusMessage ? error.data.statusMessage
: null : null
message.value = statusMessage || "Erreur lors de l'opération" message.value = statusMessage || "Erreur execution script"
message.value = error?.data?.statusMessage || "Erreur execution script"
output.value = "" output.value = ""
emit("result", { emit("result", {
key, key,

View File

@@ -56,12 +56,15 @@ const { data: messages, error } = await useFetch('/api/discord/messages', {
<style scoped> <style scoped>
.discord-card { .discord-card {
background: rgb(var(--m-secondary)); background:
border-radius: 12px; linear-gradient(180deg, rgb(var(--m-secondary) / 0.78), rgb(var(--m-secondary) / 0.92));
border-radius: 20px;
padding: 1.25rem; padding: 1.25rem;
border: 1px solid rgb(var(--m-border) / 0.32);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.03);
max-height: calc(100vh - 7rem); max-height: calc(100vh - 7rem);
overflow: hidden; overflow: hidden;
transition: background-color 0.4s ease; transition: background-color 0.4s ease, border-color 0.2s ease;
} }
.card-header { .card-header {
@@ -83,7 +86,11 @@ const { data: messages, error } = await useFetch('/api/discord/messages', {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 220px;
padding: 2rem 1rem; padding: 2rem 1rem;
border-radius: 14px;
background: rgb(var(--m-tertiary) / 0.28);
text-align: center;
} }
.error-state { .error-state {
@@ -95,7 +102,7 @@ const { data: messages, error } = await useFetch('/api/discord/messages', {
.message-list { .message-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.65rem;
max-height: calc(100vh - 12rem); max-height: calc(100vh - 12rem);
overflow-y: auto; overflow-y: auto;
} }
@@ -103,10 +110,10 @@ const { data: messages, error } = await useFetch('/api/discord/messages', {
.message-row { .message-row {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem; padding: 0.85rem;
border-radius: 8px; border-radius: 14px;
background: rgb(var(--m-tertiary)); background: rgb(var(--m-tertiary) / 0.74);
border: 1px solid rgb(var(--m-accent) / 0.04); border: 1px solid rgb(var(--m-border) / 0.22);
} }
.message-avatar { .message-avatar {
@@ -123,4 +130,20 @@ const { data: messages, error } = await useFetch('/api/discord/messages', {
color: rgb(var(--m-accent)); color: rgb(var(--m-accent));
flex-shrink: 0; flex-shrink: 0;
} }
@media (max-width: 1180px) {
.discord-card {
max-height: none;
}
.message-list {
max-height: 28rem;
}
}
@media (max-width: 820px) {
.discord-card {
padding: 1rem;
}
}
</style> </style>

View File

@@ -118,10 +118,13 @@ async function runTests() {
<style scoped> <style scoped>
.speedtest-card { .speedtest-card {
background: rgb(var(--m-secondary)); background:
border-radius: 12px; linear-gradient(180deg, rgb(var(--m-secondary) / 0.78), rgb(var(--m-secondary) / 0.92));
border-radius: 20px;
padding: 1.25rem; padding: 1.25rem;
transition: background-color 0.4s ease; border: 1px solid rgb(var(--m-border) / 0.32);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.03);
transition: background-color 0.4s ease, border-color 0.2s ease;
} }
.card-header { .card-header {
@@ -152,9 +155,15 @@ async function runTests() {
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.reload-btn:focus-visible {
outline: 2px solid rgb(var(--m-accent) / 0.8);
outline-offset: 2px;
}
.reload-btn:hover:not(:disabled) { .reload-btn:hover:not(:disabled) {
background: rgb(var(--m-accent) / 0.12); background: rgb(var(--m-accent) / 0.12);
border-color: rgb(var(--m-accent) / 0.25); border-color: rgb(var(--m-accent) / 0.25);
transform: translateY(-1px);
} }
.reload-btn:disabled { .reload-btn:disabled {
@@ -169,10 +178,10 @@ async function runTests() {
} }
.metric-card { .metric-card {
background: rgb(var(--m-tertiary)); background: rgb(var(--m-tertiary) / 0.72);
border-radius: 10px; border-radius: 14px;
padding: 1rem; padding: 1rem;
border: 1px solid rgb(var(--m-accent) / 0.06); border: 1px solid rgb(var(--m-border) / 0.22);
transition: border-color 0.2s ease; transition: border-color 0.2s ease;
} }
@@ -211,12 +220,22 @@ async function runTests() {
.error-text { .error-text {
margin-top: 0.75rem; margin-top: 0.75rem;
border-radius: 8px; border-radius: 14px;
border: 1px solid rgb(var(--m-error) / 0.12); border: 1px solid rgb(var(--m-error) / 0.16);
background: rgb(var(--m-error) / 0.06); background: rgb(var(--m-error) / 0.06);
padding: 0.75rem 0.875rem; padding: 0.75rem 0.875rem;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.75rem; font-size: 0.75rem;
color: rgb(var(--m-error)); color: rgb(var(--m-error));
} }
@media (max-width: 820px) {
.speedtest-card {
padding: 1rem;
}
.metrics-grid {
grid-template-columns: 1fr;
}
}
</style> </style>

224
components/StatusBackup.vue Normal file
View File

@@ -0,0 +1,224 @@
<template>
<div class="status-card card-glow">
<div class="card-header">
<h2 class="card-title">Status Backup</h2>
<span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Services</span>
</div>
<template v-if="loading">
<div
v-for="n in 3"
:key="`skeleton-${n}`"
class="status-row animate-shimmer"
>
<div class="flex items-center gap-3">
<CircleSkeleton custom-class="h-3 w-3" />
<TextSkeleton custom-class="h-4 w-20" />
</div>
<TextSkeleton custom-class="h-4 w-16" />
</div>
</template>
<div
v-for="row in rows"
v-else
:key="`${row.label}-${row.folder}`"
class="status-row"
:class="row.status === 200 ? 'row-ok' : 'row-error'"
>
<div class="flex items-center gap-3">
<span class="status-dot" :class="row.status === 200 ? 'dot-ok' : 'dot-error'" />
<span class="font-display text-sm font-semibold text-m-text">
{{ row.label }}
</span>
</div>
<div class="flex flex-col items-end gap-1 text-right">
<span class="font-mono text-xs" :class="row.status === 200 ? 'text-m-success' : 'text-m-error'">
{{ statusLabel(row.status) }}
</span>
<span class="font-mono text-[10px] text-m-muted">
{{ formatBackupLabel(row) }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
import {onBeforeUnmount, onMounted, ref} from "vue"
import { apiFetch } from "~/composables/useApiAuth"
interface StatusRow {
label: string
folder: string
ok: boolean
status: number
checkedAt: string
latestBackup: string | null
latestBackupAt: string | null
backupDate: string | null
expectedBackupDate: string
error?: string
}
interface StatusResponse {
results: StatusRow[]
}
const props = withDefaults(
defineProps<{
endpoint?: string
refreshMs?: number
}>(),
{
endpoint: "/api/check-backup",
refreshMs: 30000
}
)
const rows = ref<StatusRow[]>([])
const loading = ref(true)
const initialized = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
const statusLabel = (status: number) => {
if (status === 200) return "Backup OK"
if (status === 0) return "Backup KO"
return `KO (${status})`
}
const formatBackupLabel = (row: StatusRow) => {
if (!row.ok && row.backupDate) {
return `Trouve ${row.backupDate} · attendu ${row.expectedBackupDate}`
}
if (row.latestBackupAt) {
const backupDate = new Date(row.latestBackupAt)
if (!Number.isNaN(backupDate.getTime())) {
return backupDate.toLocaleString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
})
}
}
if (row.backupDate) {
return row.backupDate
}
return row.error || "Aucun backup"
}
const checkStatus = async () => {
if (!initialized.value) {
loading.value = true
}
try {
const data = await apiFetch<StatusResponse>(props.endpoint)
rows.value = data.results
} catch (error) {
rows.value = [
{
label: "Erreur",
folder: "error",
ok: false,
status: 0,
checkedAt: new Date().toISOString(),
latestBackup: null,
latestBackupAt: null,
backupDate: null,
expectedBackupDate: "",
error: error instanceof Error ? error.message : String(error)
}
]
} finally {
initialized.value = true
loading.value = false
}
}
onMounted(() => {
checkStatus()
timer = setInterval(checkStatus, props.refreshMs)
})
onBeforeUnmount(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
</script>
<style scoped>
.status-card {
background:
linear-gradient(180deg, rgb(var(--m-secondary) / 0.78), rgb(var(--m-secondary) / 0.92));
border-radius: 20px;
padding: 1.25rem;
border: 1px solid rgb(var(--m-border) / 0.32);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.03);
display: flex;
flex-direction: column;
gap: 0.75rem;
transition: background-color 0.4s ease, border-color 0.2s ease;
}
.card-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.card-title {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 3.2rem;
padding: 0.85rem 1rem;
border-radius: 14px;
background: rgb(var(--m-tertiary) / 0.75);
border: 1px solid rgb(var(--m-border) / 0.2);
transition: all 0.2s ease;
}
.row-ok {
border-color: rgb(var(--m-success) / 0.08);
}
.row-error {
border-color: rgb(var(--m-error) / 0.1);
background: rgb(var(--m-error) / 0.04);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-ok {
background: rgb(var(--m-success));
box-shadow: 0 0 6px rgb(var(--m-success) / 0.5);
}
.dot-error {
background: rgb(var(--m-error));
box-shadow: 0 0 6px rgb(var(--m-error) / 0.5);
animation: pulse-glow 2s ease-in-out infinite;
}
</style>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="status-card card-glow"> <div class="status-card card-glow">
<div class="card-header"> <div class="card-header">
<h2 class="card-title">Status</h2> <h2 class="card-title">Status App</h2>
<span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Services</span> <span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Services</span>
</div> </div>
@@ -119,13 +119,16 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
.status-card { .status-card {
background: rgb(var(--m-secondary)); background:
border-radius: 12px; linear-gradient(180deg, rgb(var(--m-secondary) / 0.78), rgb(var(--m-secondary) / 0.92));
border-radius: 20px;
padding: 1.25rem; padding: 1.25rem;
border: 1px solid rgb(var(--m-border) / 0.32);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.03);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.625rem; gap: 0.75rem;
transition: background-color 0.4s ease; transition: background-color 0.4s ease, border-color 0.2s ease;
} }
.card-header { .card-header {
@@ -146,10 +149,11 @@ onBeforeUnmount(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0.75rem 1rem; min-height: 3.2rem;
border-radius: 8px; padding: 0.85rem 1rem;
background: rgb(var(--m-tertiary)); border-radius: 14px;
border: 1px solid transparent; background: rgb(var(--m-tertiary) / 0.75);
border: 1px solid rgb(var(--m-border) / 0.2);
transition: all 0.2s ease; transition: all 0.2s ease;
} }

View File

@@ -87,13 +87,16 @@ const metrics = computed(() => [
<style scoped> <style scoped>
.resources-card { .resources-card {
background: rgb(var(--m-secondary)); background:
border-radius: 12px; linear-gradient(180deg, rgb(var(--m-secondary) / 0.78), rgb(var(--m-secondary) / 0.92));
border-radius: 20px;
padding: 1.25rem; padding: 1.25rem;
border: 1px solid rgb(var(--m-border) / 0.32);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.03);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
transition: background-color 0.4s ease; transition: background-color 0.4s ease, border-color 0.2s ease;
} }
.card-header { .card-header {
@@ -121,10 +124,10 @@ const metrics = computed(() => [
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem; gap: 0.75rem;
align-items: center; align-items: center;
padding: 0.875rem 1rem; padding: 0.95rem 1rem;
border-radius: 10px; border-radius: 14px;
background: rgb(var(--m-tertiary)); background: rgb(var(--m-tertiary) / 0.72);
border: 1px solid rgb(var(--m-accent) / 0.06); border: 1px solid rgb(var(--m-border) / 0.22);
} }
.metric-copy { .metric-copy {
@@ -187,4 +190,18 @@ const metrics = computed(() => [
.tone-error { .tone-error {
background: rgb(var(--m-error)); background: rgb(var(--m-error));
} }
@media (max-width: 820px) {
.resources-card {
padding: 1rem;
}
.metric-row {
grid-template-columns: 1fr;
}
.metric-value-area {
justify-content: flex-start;
}
}
</style> </style>

View File

@@ -18,34 +18,43 @@
<slot name="sidebar"/> <slot name="sidebar"/>
<nav class="sidebar-nav" aria-label="Sections"> <nav class="sidebar-nav" aria-label="Sections">
<p class="nav-label">Navigation</p> <p class="nav-label">Navigation</p>
<div class="flex flex-col gap-2">
<div class="bg-m-tertiary rounded-lg border border-m-accent/6">
<NuxtLink <NuxtLink
to="/" v-for="item in navItems"
class="flex items-center gap-3 px-4 py-2 rounded-lg text-white hover:bg-m-tertiary/80 transition-colors" :key="`desktop-${item.to}`"
v-slot="{ href, navigate, isExactActive }"
:to="item.to"
custom
> >
<IconifyIcon <a
icon="mdi:home" :href="href"
class="text-lg"/> class="nav-link"
<p>Home</p> :class="{ 'nav-link-active': isExactActive }"
</NuxtLink> :aria-current="isExactActive ? 'page' : undefined"
</div> @click="navigate"
<div class="bg-m-tertiary rounded-lg border border-m-accent/6">
<NuxtLink
to="/backup"
class="flex items-center gap-3 px-4 py-2 rounded-lg text-white hover:bg-m-tertiary/80 transition-colors"
> >
<IconifyIcon <span class="nav-link-main">
icon="mdi:data" <span class="nav-icon">
class="text-lg"/> <IconifyIcon :icon="item.icon" class="text-lg"/>
<p>Backup</p> </span>
<span>
<span class="nav-title">{{ item.label }}</span>
<span class="nav-caption">{{ item.caption }}</span>
</span>
</span>
<span class="nav-pill">{{ item.short }}</span>
</a>
</NuxtLink> </NuxtLink>
</div>
</div>
</nav> </nav>
</div> </div>
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="sidebar-divider"/> <div class="sidebar-divider"/>
<div class="status-card">
<p class="status-label">Environnement</p>
<p class="status-value">Production</p>
<p class="status-description">
Acces rapide au monitoring, aux sauvegardes et aux cartes systeme.
</p>
</div>
<div class="footer-row"> <div class="footer-row">
<p class="font-mono text-[10px] tracking-widest uppercase text-white/40"> <p class="font-mono text-[10px] tracking-widest uppercase text-white/40">
Supervisor {{ appVersion }} Supervisor {{ appVersion }}
@@ -224,6 +233,10 @@ const navItems = [
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.sidebar .brand-title {
margin-top: 0;
}
.brand-description { .brand-description {
margin: 0.55rem 0 0; margin: 0.55rem 0 0;
color: rgb(255 255 255 / 0.58); color: rgb(255 255 255 / 0.58);
@@ -245,7 +258,7 @@ const navItems = [
.sidebar-content { .sidebar-content {
flex: 1; flex: 1;
padding: 0.5rem 1rem 1rem; padding: 0.75rem 1rem 1rem;
} }
.sidebar-footer { .sidebar-footer {
@@ -274,7 +287,7 @@ const navItems = [
.sidebar-nav { .sidebar-nav {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.625rem;
} }
.nav-label { .nav-label {
@@ -321,6 +334,16 @@ const navItems = [
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04); box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04);
} }
.nav-link-active .nav-icon {
background: rgb(var(--m-accent) / 0.18);
color: white;
}
.nav-link-active .nav-pill {
background: rgb(var(--m-accent) / 0.18);
color: white;
}
.nav-link-main { .nav-link-main {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -403,6 +426,9 @@ const navItems = [
.content { .content {
background: rgb(var(--m-bg)); background: rgb(var(--m-bg));
background-image:
linear-gradient(180deg, rgb(255 255 255 / 0.01), transparent 18%),
radial-gradient(circle at top right, rgb(var(--m-accent) / 0.08), transparent 20%);
overflow-y: auto; overflow-y: auto;
min-height: 100vh; min-height: 100vh;
transition: background-color 0.4s ease; transition: background-color 0.4s ease;

View File

@@ -13,10 +13,19 @@
</p> </p>
</div> </div>
</header> </header>
<div class="dashboard-grid">
<section class="grid-left" aria-label="Commandes de sauvegarde"> <section
class="status-strip animate-fade-in-up"
style="animation-delay: 100ms"
aria-label="Statut des sauvegardes"
>
<StatusBackup />
</section>
<div class="workspace-grid">
<section class="workspace-sidebar" aria-label="Commandes de sauvegarde">
<BackupButtonSee <BackupButtonSee
class="animate-fade-in-up backup-selector" class="animate-fade-in-up"
style="animation-delay: 120ms" style="animation-delay: 120ms"
@select="selectedBackup = $event" @select="selectedBackup = $event"
/> />
@@ -27,24 +36,27 @@
/> />
</section> </section>
<section class="grid-middle" aria-labelledby="backup-files-title"> <section class="workspace-main" aria-labelledby="backup-files-title">
<div class="files-panel animate-fade-in-up" style="animation-delay: 240ms"> <div class="files-panel animate-fade-in-up" style="animation-delay: 240ms">
<div class="files-panel-header"> <div class="files-panel-header">
<div> <div class="files-panel-copy">
<p class="section-kicker">Fichiers</p> <p class="section-kicker">Fichiers</p>
<h2 id="backup-files-title" class="files-panel-title"> <h2 id="backup-files-title" class="files-panel-title">
Historique des sauvegardes Historique des sauvegardes
</h2> </h2>
</div> <p class="files-panel-description">
<p class="files-panel-meta"> Consultez les archives disponibles et telechargez le dernier backup du dossier selectionne.
{{ selectedBackup ? `Source ${selectedBackup}` : "En attente de selection" }}
</p> </p>
</div> </div>
<span
class="selection-pill"
:class="{ 'selection-pill-active': selectedBackup }"
>
{{ selectedBackup ? `Source ${selectedBackup}` : "Selection requise" }}
</span>
</div>
<BackupList <BackupList :folder="selectedBackup" />
class="backup-list-mobile"
:folder="selectedBackup"
/>
</div> </div>
<section <section
@@ -53,9 +65,12 @@
aria-labelledby="backup-output-title" aria-labelledby="backup-output-title"
> >
<div class="files-panel-header"> <div class="files-panel-header">
<div> <div class="files-panel-copy">
<p class="section-kicker">Execution</p> <p class="section-kicker">Execution</p>
<h2 id="backup-output-title" class="files-panel-title">Resultat du script</h2> <h2 id="backup-output-title" class="files-panel-title">Resultat du script</h2>
<p class="files-panel-description">
Le retour du script apparait ici apres execution avec un etat clair en succes ou en erreur.
</p>
</div> </div>
<span <span
class="panel-badge" class="panel-badge"
@@ -170,15 +185,12 @@ const handleScriptResult = async (payload: ScriptResult) => {
} }
.dashboard-header { .dashboard-header {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 320px);
gap: 1.5rem;
align-items: end;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.header-copy { .header-copy {
min-width: 0; min-width: 0;
max-width: 70ch;
} }
.section-kicker { .section-kicker {
@@ -197,26 +209,37 @@ const handleScriptResult = async (payload: ScriptResult) => {
line-height: 1.65; line-height: 1.65;
} }
.dashboard-grid { .status-strip {
margin-bottom: 1.5rem;
}
.workspace-grid {
display: grid; display: grid;
grid-template-columns: 300px minmax(0, 1fr); grid-template-columns: minmax(280px, 320px) minmax(0, 1fr);
gap: 1.5rem; gap: 1.5rem;
align-items: start; align-items: start;
} }
.grid-left, .workspace-sidebar,
.grid-middle { .workspace-main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
min-width: 0; min-width: 0;
} }
.workspace-sidebar {
position: sticky;
top: 2rem;
}
.files-panel { .files-panel {
padding: 1.25rem; padding: 1.25rem;
border-radius: 20px; border-radius: 20px;
background: rgb(var(--m-secondary) / 0.4); background:
border: 1px solid rgb(var(--m-accent) / 0.08); linear-gradient(180deg, rgb(var(--m-secondary) / 0.76), rgb(var(--m-secondary) / 0.92));
border: 1px solid rgb(var(--m-border) / 0.32);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.03);
} }
.output-panel { .output-panel {
@@ -225,12 +248,16 @@ const handleScriptResult = async (payload: ScriptResult) => {
.files-panel-header { .files-panel-header {
display: flex; display: flex;
align-items: end; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.files-panel-copy {
min-width: 0;
}
.files-panel-title { .files-panel-title {
margin: 0; margin: 0;
font-family: var(--font-display); font-family: var(--font-display);
@@ -239,19 +266,41 @@ const handleScriptResult = async (payload: ScriptResult) => {
color: rgb(var(--m-text)); color: rgb(var(--m-text));
} }
.files-panel-meta { .files-panel-description {
margin: 0; margin: 0.5rem 0 0;
max-width: 54ch;
color: rgb(var(--m-muted));
line-height: 1.6;
}
.selection-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.25rem;
border-radius: 999px;
border: 1px solid rgb(var(--m-border) / 0.36);
background: rgb(var(--m-tertiary) / 0.45);
padding: 0.45rem 0.8rem;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.75rem; font-size: 0.68rem;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
color: rgb(var(--m-muted)); color: rgb(var(--m-muted));
text-align: right; text-align: center;
}
.selection-pill-active {
border-color: rgb(var(--m-accent) / 0.2);
background: rgb(var(--m-accent) / 0.08);
color: rgb(var(--m-accent));
} }
.panel-badge { .panel-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
min-height: 2.25rem;
border-radius: 999px; border-radius: 999px;
padding: 0.35rem 0.7rem; padding: 0.35rem 0.7rem;
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -327,18 +376,22 @@ const handleScriptResult = async (payload: ScriptResult) => {
} }
@media (max-width: 1180px) { @media (max-width: 1180px) {
.dashboard-header, .workspace-grid {
.dashboard-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.workspace-sidebar {
position: static;
}
.files-panel-header { .files-panel-header {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
} }
.files-panel-meta { .selection-pill,
text-align: left; .panel-badge {
width: 100%;
} }
} }
@@ -350,5 +403,9 @@ const handleScriptResult = async (payload: ScriptResult) => {
.files-panel { .files-panel {
padding: 1rem; padding: 1rem;
} }
.files-panel-title {
font-size: 1.2rem;
}
} }
</style> </style>

View File

@@ -3,9 +3,13 @@
<div class="dashboard-container"> <div class="dashboard-container">
<header class="dashboard-header"> <header class="dashboard-header">
<div> <div>
<p class="section-kicker">Operations</p>
<h1 class="font-display text-3xl font-bold tracking-tight text-m-text"> <h1 class="font-display text-3xl font-bold tracking-tight text-m-text">
Monitoring Monitoring
</h1> </h1>
<p class="header-description">
Visualisez l'etat des applications, des sauvegardes et des ressources systeme depuis une vue unique.
</p>
</div> </div>
</header> </header>
@@ -221,8 +225,24 @@ onBeforeUnmount(() => {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 2rem; margin-bottom: 2rem;
padding-bottom: 1.5rem; padding-bottom: 1.25rem;
border-bottom: 1px solid rgba(80, 140, 255, 0.08); border-bottom: 1px solid rgba(80, 140, 255, 0.1);
}
.section-kicker {
margin: 0 0 0.45rem;
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgb(var(--m-accent));
}
.header-description {
max-width: 62ch;
margin-top: 0.9rem;
color: rgb(var(--m-muted));
line-height: 1.65;
} }
.storage-section { .storage-section {
@@ -240,9 +260,11 @@ onBeforeUnmount(() => {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem; gap: 1rem;
border-radius: 12px; border-radius: 18px;
background: rgb(var(--m-secondary)); background:
padding: 0.75rem; linear-gradient(180deg, rgb(var(--m-secondary) / 0.78), rgb(var(--m-secondary) / 0.92));
border: 1px solid rgb(var(--m-border) / 0.32);
padding: 0.85rem;
} }
.content-grid { .content-grid {
@@ -281,6 +303,7 @@ onBeforeUnmount(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
min-width: 0;
} }
@media (max-width: 1180px) { @media (max-width: 1180px) {

View File

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

View File

@@ -1,17 +1,9 @@
import { execFile } from "node:child_process" import { exec } from "node:child_process"
import scripts from "../config/backup-script.json" import { backupScripts, getBackupScriptCommand } from "../utils/backup-scripts"
type BackupScript = { function runCommand(command: string): Promise<string> {
key: string
label: string
downloadFolders?: string[]
command: string
args?: string[]
}
function runCommand(command: string, args: string[] = []): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
execFile(command, args, { timeout: 10 * 60 * 1000 }, (error, stdout, stderr) => { exec(command, { timeout: 10 * 60 * 1000 }, (error, stdout, stderr) => {
if (error) { if (error) {
reject(stderr || error.message) reject(stderr || error.message)
return return
@@ -32,7 +24,7 @@ export default defineEventHandler(async (event) => {
}) })
} }
const script = (scripts as BackupScript[]).find((item) => item.key === key) const script = backupScripts.find((item) => item.key === key)
if (!script) { if (!script) {
throw createError({ throw createError({
statusCode: 404, statusCode: 404,
@@ -41,7 +33,15 @@ export default defineEventHandler(async (event) => {
} }
try { try {
const output = await runCommand(script.command, script.args || []) const command = getBackupScriptCommand(script.key)
if (!command) {
throw createError({
statusCode: 500,
statusMessage: "Commande de script manquante"
})
}
const output = await runCommand(command)
return { return {
ok: true, ok: true,
key: script.key, key: script.key,
@@ -52,6 +52,15 @@ export default defineEventHandler(async (event) => {
} catch (error) { } catch (error) {
console.error("Erreur execution script:", error) console.error("Erreur execution script:", error)
if (
typeof error === "object" &&
error !== null &&
"statusCode" in error &&
"statusMessage" in error
) {
throw error
}
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: "Erreur lors de l'opération" statusMessage: "Erreur lors de l'opération"

View File

@@ -0,0 +1,151 @@
import {
runSsh,
shellQuote,
resolveFolderRemoteDir
} from "../utils/ssh.ts"
import {process} from "std-env";
import backupOptions from "../config/backup-options.json"
export const BACKUP_HOUR = process.env.BACKUPS_HOUR
type BackupTarget = {
name: string
}
type LatestBackupInfo = {
fileName: string | null
modifiedAt: string | null
}
const backupTargets = backupOptions as BackupTarget[]
function toLabel(name: string) {
if (name === "sirh") return "SIRH"
return name.charAt(0).toUpperCase() + name.slice(1)
}
function pad(value: number) {
return String(value).padStart(2, "0")
}
function formatDateKey(date: Date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function getExpectedBackupDate(now: Date) {
const expected = new Date(now)
if (now.getHours() < BACKUP_HOUR) {
expected.setDate(expected.getDate() - 1)
}
expected.setHours(BACKUP_HOUR, 0, 0, 0)
return expected
}
function extractBackupDate(fileName: string | null) {
if (!fileName) return null
const normalized = fileName.replace(/[^0-9]/g, "")
const yearFirst = normalized.match(/(20\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])/)
if (yearFirst) {
return `${yearFirst[1]}-${yearFirst[2]}-${yearFirst[3]}`
}
const dayFirst = normalized.match(/(0[1-9]|[12]\d|3[01])(0[1-9]|1[0-2])(20\d{2})/)
if (dayFirst) {
return `${dayFirst[3]}-${dayFirst[2]}-${dayFirst[1]}`
}
return null
}
function parseRemoteTimestamp(value: string) {
const timestamp = Number(value)
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return null
}
return new Date(timestamp * 1000).toISOString()
}
async function getLatestBackupInfo(remoteDir: string): Promise<LatestBackupInfo> {
const output = await runSsh(
`cd ${shellQuote(remoteDir)} && for file in *; do [ -e "$file" ] || continue; printf '%s\\t%s\\n' "$(stat -c '%Y' "$file")" "$file"; done | sort -rn | head -n 1`
)
const line = output.trim()
if (!line) {
return { fileName: null, modifiedAt: null }
}
const [timestamp, ...nameParts] = line.split("\t")
const fileName = nameParts.join("\t").trim() || null
return {
fileName,
modifiedAt: parseRemoteTimestamp(timestamp)
}
}
export default defineEventHandler(async () => {
const now = new Date()
const expectedBackupDate = getExpectedBackupDate(now)
const expectedDateKey = formatDateKey(expectedBackupDate)
const checkedAt = now.toISOString()
const results = await Promise.all(
backupTargets.map(async (target) => {
try {
const remoteDir = await resolveFolderRemoteDir(target.name)
if (!remoteDir) {
return {
label: toLabel(target.name),
folder: target.name,
ok: false,
status: 0,
checkedAt,
latestBackup: null,
latestBackupAt: null,
backupDate: null,
expectedBackupDate: expectedDateKey,
error: "Dossier de backup introuvable"
}
}
const latestBackupInfo = await getLatestBackupInfo(remoteDir)
const backupDate = extractBackupDate(latestBackupInfo.fileName)
const ok = backupDate === expectedDateKey
return {
label: toLabel(target.name),
folder: target.name,
ok,
status: ok ? 200 : 0,
checkedAt,
latestBackup: latestBackupInfo.fileName,
latestBackupAt: latestBackupInfo.modifiedAt,
backupDate,
expectedBackupDate: expectedDateKey,
error: latestBackupInfo.fileName ? undefined : "Aucun backup trouve"
}
} catch (error) {
return {
label: toLabel(target.name),
folder: target.name,
ok: false,
status: 0,
checkedAt,
latestBackup: null,
latestBackupAt: null,
backupDate: null,
expectedBackupDate: expectedDateKey,
error: error instanceof Error ? error.message : String(error)
}
}
})
)
return { results }
})

View File

@@ -1,5 +1,4 @@
import { exec, execFile } from "child_process" import { exec } from "child_process"
import diskSources from "../config/disk-commands.json"
type DiskSource = { type DiskSource = {
key: string key: string
@@ -8,6 +7,21 @@ type DiskSource = {
args?: string[] args?: string[]
} }
const diskSources: DiskSource[] = [
{
key: "remote",
label: "Serveur distant",
command: "ssh",
args: []
},
{
key: "local",
label: "Machine locale",
command: "bash",
args: []
}
]
function getEnvCommand(source: DiskSource) { function getEnvCommand(source: DiskSource) {
const envKey = `DISK_COMMAND_${source.key.toUpperCase()}` const envKey = `DISK_COMMAND_${source.key.toUpperCase()}`
const legacyEnvKey = const legacyEnvKey =
@@ -16,18 +30,6 @@ function getEnvCommand(source: DiskSource) {
return process.env[envKey] || (legacyEnvKey ? process.env[legacyEnvKey] : undefined) || null 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> { function runShellCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => { exec(command, (error, stdout, stderr) => {
@@ -42,12 +44,14 @@ function runShellCommand(command: string): Promise<string> {
export default defineEventHandler(async () => { export default defineEventHandler(async () => {
const results = await Promise.all( const results = await Promise.all(
(diskSources as DiskSource[]).map(async (source) => { diskSources.map(async (source) => {
try { try {
const envCommand = getEnvCommand(source) const envCommand = getEnvCommand(source)
const output = envCommand if (!envCommand) {
? await runShellCommand(envCommand) throw new Error(`Commande disque manquante pour ${source.key}`)
: await runCommand(source.command, source.args || []) }
const output = await runShellCommand(envCommand)
return { return {
key: source.key, key: source.key,
label: source.label, label: source.label,

View File

@@ -1,34 +0,0 @@
[
{
"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

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