1 Commits

24 changed files with 526 additions and 7095 deletions

View File

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

View File

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

213
AGENTS.md
View File

@@ -1,159 +1,54 @@
# AGENTS.md # Repository Guidelines
## Objectif ## Project Structure & Module Organization
This repository is a Nuxt 4 application.
Ce fichier définit les règles que tout agent de code doit respecter dans ce dépôt. - `pages/`: route pages (for example `pages/index.vue`).
- `components/`: reusable Vue components (for example `DiskSidebarWidget.vue`, `ApiStatusBubble.vue`).
Les objectifs sont : - `layouts/`: shared page shells (`layouts/default.vue`).
- maintenir un code clair et cohérent - `server/api/`: Nitro server endpoints (for example `disk.get.ts`, `version-status.get.ts`).
- éviter la complexité inutile - `assets/css/`: global styles and theme tokens (`main.css`, `malio.css`).
- empêcher la génération de code mort - `public/`: static files served as-is.
- garantir des modifications prévisibles
Keep feature logic close to usage: UI in `components/`, page composition in `pages/`, backend checks in `server/api/`.
Ce fichier est la source de vérité concernant le comportement de lagent.
## Build, Test, and Development Commands
Use npm scripts from `package.json`:
## Principes généraux - `npm run dev`: start local dev server with hot reload.
- `npm run build`: production build (client + server).
Toujours produire un code clair, simple et minimal. - `npm run preview`: run the built app locally.
- `npm run generate`: static generation when needed.
Privilégier les implémentations explicites plutôt que les abstractions.
There is no dedicated test script currently. At minimum, run `npm run build` before opening a PR.
Ne jamais introduire de nouveaux patterns si un pattern existant résout déjà le problème.
## Coding Style & Naming Conventions
Ne jamais générer : - Language: TypeScript + Vue SFCs (`<script setup lang="ts">`).
- du code mort - Indentation: 2 spaces.
- du code inutilisé - Prefer double quotes in TS files to match current codebase.
- du code spéculatif - Components: PascalCase file names (`ApiStatusBubble.vue`).
- des fonctionnalités non demandées - 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`).
## Lecture obligatoire avant modification ## Testing Guidelines
No automated framework is configured yet. Use these checks:
Avant toute modification de code, lagent doit : - Build validation: `npm run build`.
- Manual smoke test: open `/`, verify API status cards refresh and disk sidebar renders.
Lire entièrement le fichier à modifier. - For new endpoints, validate response shape in browser/network tab or `curl`.
Comprendre la structure et les conventions existantes. ## Commit & Pull Request Guidelines
Git history is not available in this workspace snapshot, so use this convention:
Respecter : - Commit format: `type(scope): short summary`.
- les conventions de nommage - Example: `feat(api): add labeled multi-endpoint status check`.
- le style du fichier - Types: `feat`, `fix`, `refactor`, `style`, `docs`, `chore`.
- larchitecture déjà utilisée
PRs should include:
Vérifier les fichiers liés pouvant être impactés. - What changed and why.
- Affected paths (e.g. `server/api/version-status.get.ts`).
Ne jamais proposer une modification sur du code qui na pas été lu. - UI screenshots for visual changes.
- Verification steps (commands run, expected result).
## Règles de modification ## Security & Configuration Notes
- Do not hardcode secrets in source.
Lors de lédition de code : - Prefer environment variables for private endpoints/tokens.
- Keep external API checks server-side (`server/api/*`) to avoid exposing sensitive details in the client.
Respecter larchitecture existante.
Ne lance pas de build si je le dit pas
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

128
README.md
View File

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

View File

@@ -13,147 +13,15 @@
--color-m-bg: rgb(var(--m-bg)); --color-m-bg: rgb(var(--m-bg));
--color-m-error: rgb(var(--m-error)); --color-m-error: rgb(var(--m-error));
--color-m-success: rgb(var(--m-success)); --color-m-success: rgb(var(--m-success));
--color-m-accent: rgb(var(--m-accent)); /* Alias pour la faute de frappe courante "m-succes" */
--color-m-warning: rgb(var(--m-warning));
--color-m-succes: rgb(var(--m-success)); --color-m-succes: rgb(var(--m-success));
--font-display: "Outfit", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
}
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700;800;900&display=swap');
@layer base {
* {
box-sizing: border-box;
}
body {
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;
}
} }
@layer utilities { @layer utilities {
.bg-grid { .bg-grid {
background-image: background-image:
linear-gradient(to right, rgb(var(--m-accent) / 0.04) 1px, transparent 1px), linear-gradient(to right, rgba(148, 163, 184, 0.12) 1px, transparent 1px),
linear-gradient(to bottom, rgb(var(--m-accent) / 0.04) 1px, transparent 1px); linear-gradient(to bottom, rgba(148, 163, 184, 0.12) 1px, transparent 1px);
background-size: 32px 32px; 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

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

View File

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

View File

@@ -1,58 +1,44 @@
<template> <template>
<div class="backup-list-card card-glow"> <div class="bg-m-secondary w-[507px] h-[367px] rounded-md mx-4 shadow-md/50 shadow-black">
<div class="card-header"> <p class="font-bold text-3xl text-m-tertiary my-1 mx-3">
<h2 class="card-title">{{ title }}</h2> {{ title }}
</div> </p>
<div v-if="!folder" class="empty-state"> <div v-if="loading">
<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 <div
v-for="n in 6" v-for="n in 6"
:key="`backup-skeleton-${n}`" :key="`backup-skeleton-${n}`"
class="file-row animate-shimmer" class="relative w-[483px] h-[39px] mx-3 mb-[10px]"
> >
<TextSkeleton custom-class="h-4 w-48" /> <ButtonSkeleton custom-class="h-full w-full" />
<CircleSkeleton custom-class="h-5 w-5 rounded" /> <div class="absolute inset-0 flex items-center justify-between px-3">
<TextSkeleton custom-class="h-5 w-[260px]" />
<CircleSkeleton custom-class="h-6 w-6 rounded-md" />
</div>
</div> </div>
</div> </div>
<div v-else-if="backups.length === 0" class="empty-state"> <button
<IconifyIcon icon="mdi:file-hidden" class="text-3xl text-m-muted/40" /> v-else
<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" v-for="file in backups"
:key="file" :key="file"
class="file-row" class="bg-m-tertiary w-[483px] h-[39px] rounded-md shadow-md/50 shadow-m-black mx-3 mb-[10px] flex items-center justify-between cursor-pointer"
@click="downloadBackup(file)" @click="downloadBackup(file)"
> >
<div class="flex min-w-0 items-center gap-2.5"> <p class="text-xl ml-3 truncate max-w-[400px]">
<IconifyIcon icon="mdi:file-document-outline" class="text-base text-m-accent flex-shrink-0" /> {{ file }}
<span class="truncate font-mono text-xs text-m-text"> </p>
{{ file }}
</span> <IconifyIcon
</div>
<IconifyIcon
icon="mdi:download" icon="mdi:download"
class="text-base text-m-muted flex-shrink-0 transition-colors duration-200" class="text-black text-2xl mr-3"
/> />
</button> </button>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {Icon as IconifyIcon} from "@iconify/vue" import {Icon as IconifyIcon} from "@iconify/vue"
import ButtonSkeleton from "~/components/skeleton/ButtonSkeleton.vue"
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue" import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue" import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
@@ -63,8 +49,8 @@ const props = defineProps<{
const backups = ref<string[]>([]) const backups = ref<string[]>([])
const loading = ref(false) const loading = ref(false)
const title = computed(() => { const title = computed(() => {
if (!props.folder) return "Fichiers" if (!props.folder) return "Backup"
return `Backup ${props.folder.toUpperCase()}` return `Liste des backup de ${props.folder.toUpperCase()}`
}) })
const downloadBackup = (file: string) => { const downloadBackup = (file: string) => {
@@ -92,59 +78,3 @@ watch(() => props.folder, async (folder) => {
} }
}) })
</script> </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>

View File

@@ -1,48 +1,35 @@
<template> <template>
<section class="storage-card"> <section class="flex flex-col items-center p-4">
<template v-if="item.loading"> <template v-if="loading">
<TextSkeleton custom-class="h-5 w-28" /> <TextSkeleton custom-class="h-7 w-40" />
<CircleSkeleton custom-class="mt-3 h-[120px] w-[120px]" /> <CircleSkeleton custom-class="mt-2 h-[140px] w-[140px]" />
<BlockSkeleton custom-class="mt-3 h-4 w-32" /> <BlockSkeleton custom-class="mt-2 h-5 w-36" />
</template> </template>
<template v-else> <template v-else>
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.2em] text-white/60"> <p class="text-center text-xl font-semibold uppercase">{{ hostName }}</p>
{{ item.hostName }} <div class="relative h-[140px] w-[140px]" :class="statusColorClass">
</p> <svg class="h-full w-full -rotate-90" viewBox="0 0 120 120" aria-label="Pourcentage restant">
<div class="chart-wrapper" :class="item.statusColorClass">
<svg class="chart-svg" viewBox="0 0 120 120" aria-label="Pourcentage restant">
<circle <circle
class="track" class="fill-none stroke-[rgba(255,255,255,0.22)] [stroke-width:10]"
cx="60" cy="60" cx="60"
:r="item.chartRadius" cy="60"
:r="chartRadius"
/> />
<circle <circle
class="progress" class="fill-none stroke-[currentColor] [stroke-linecap:round] [stroke-width:10] transition-[stroke-dashoffset] duration-300"
cx="60" cy="60" cx="60"
:r="item.chartRadius" cy="60"
:style="{ :r="chartRadius"
strokeDasharray: `${item.chartCircumference}`, :style="{ strokeDasharray: `${chartCircumference}`, strokeDashoffset: `${chartOffset}` }"
strokeDashoffset: `${item.chartOffset}`
}"
/> />
</svg> </svg>
<div class="chart-label"> <div class="absolute inset-0 flex flex-col items-center justify-center">
<strong class="font-mono text-2xl font-bold leading-none"> <strong class="text-2xl leading-none">{{ remainingPercentText }}</strong>
{{ item.remainingPercentText }}
</strong>
<span class="mt-1 font-mono text-[9px] uppercase tracking-widest text-m-muted">
libre
</span>
</div> </div>
</div> </div>
<p class="font-mono text-xs font-medium text-m-muted/80"> <p class="mt-1 text-center text-sm font-semibold">{{ usedText }} / {{ totalText }}</p>
{{ item.usedText }}
<span class="mx-0.5 text-m-muted/40">/</span>
{{ item.totalText }}
</p>
</template> </template>
</section> </section>
</template> </template>
@@ -53,69 +40,24 @@ import BlockSkeleton from "~/components/skeleton/BlockSkeleton.vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue" import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
defineProps<{ defineProps<{
item: { loading: boolean
loading: boolean hostName: string
hostName: string statusColorClass: string
statusColorClass: string chartRadius: number
chartRadius: number chartCircumference: number
chartCircumference: number chartOffset: number
chartOffset: number remainingPercentText: string
remainingPercentText: string usedText: string
usedText: string totalText: string
totalText: string
}
}>() }>()
</script> </script>
<style scoped> <style scoped>
.storage-card { .m-success {
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)); color: rgb(var(--m-success));
} }
.status-error { .m-error {
color: rgb(var(--m-error)); color: rgb(var(--m-error));
} }
</style> </style>

View File

@@ -1,108 +1,19 @@
<script setup> <script setup>
import {Icon as IconifyIcon} from "@iconify/vue"
const { data: messages } = await useFetch('/api/discord/messages') const { data: messages } = await useFetch('/api/discord/messages')
</script> </script>
<template> <template>
<div class="discord-card card-glow"> <div class="bg-m-secondary w-auto h-auto mx-4 rounded-md shadow-md/50 shadow-black p-2">
<div class="card-header"> <div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-2.5"> <p class="font-bold text-3xl text-m-tertiary">
<IconifyIcon icon="mdi:message-text" class="text-lg text-m-accent" /> Speedtest
<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> </p>
</div> <div v-if="messages">
<div v-for="m in messages" :key="m.id">
<div v-else class="message-list"> <strong>{{ m.author.username }}</strong>
<div <p>{{ m.content }}</p>
v-for="m in messages"
:key="m.id"
class="message-row"
>
<div class="message-avatar">
{{ m.author.username.charAt(0).toUpperCase() }}
</div>
<div class="min-w-0 flex-1">
<span class="font-display text-xs font-semibold text-m-accent">
{{ m.author.username }}
</span>
<p class="mt-0.5 break-words font-display text-sm leading-relaxed text-m-text/80">
{{ m.content }}
</p>
</div>
</div>
</div> </div>
</div> </div>
</template> </div>
</div>
<style scoped> </template>
.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,64 +1,103 @@
<template> <template>
<div class="speedtest-card card-glow"> <div class="bg-m-secondary w-[507px] h-[184px] mx-4 rounded-md shadow-md/50 shadow-black p-2">
<div class="card-header"> <div class="mb-2 flex items-center justify-between">
<h2 class="card-title">Speedtest</h2> <p class="font-bold text-3xl text-m-tertiary">
<button Speedtest
class="reload-btn" </p>
@click="runTests" <IconifyIcon
:disabled="isTesting"
>
<IconifyIcon
icon="mdi:reload" icon="mdi:reload"
class="text-lg" class="bg-m-tertiary text-2xl text-black rounded-md shadow-md/50 mr-1 cursor-pointer"
:class="{ 'animate-spin': isTesting }" @click="runTests"
/> />
</button>
</div> </div>
<div class="grid grid-cols-3 gap-3">
<div class="metrics-grid"> <div class="bg-m-tertiary w-[153px] h-[120px] rounded-md shadow-md/50 shadow-m-black">
<div v-for="metric in metrics" :key="metric.label" class="metric-card"> <div class="flex items-center justify-center">
<div class="metric-header"> <IconifyIcon
<IconifyIcon :icon="metric.icon" class="text-lg text-m-accent" /> icon="mdi:download"
<span class="font-mono text-[10px] font-medium uppercase tracking-[0.15em] text-m-muted"> class="text-m-primary text-2xl mt-2 ml-1"
{{ metric.label }} />
</span> <p class="font-bold uppercase text-xl text-m-text mt-2 mr-1">
download
</p>
</div> </div>
<div class="metric-value-area"> <div class="mx-2 flex flex-col items-center justify-center">
<template v-if="isTesting"> <template v-if="isTesting">
<div class="h-10 w-16 animate-shimmer rounded" /> <TextSkeleton custom-class="h-10 w-16 mb-1" />
</template> </template>
<template v-else> <span v-else class="text-4xl">
<span class="metric-value font-mono"> {{ download !== null ? `${download}` : "--" }}
{{ metric.value !== null ? metric.value : "--" }} </span>
</span> <p class="font-bold text-xl leading-tight">
Mbps
</p>
</div>
</div>
<div class="bg-m-tertiary w-[153px] h-[120px] rounded-md shadow-md/50 shadow-m-black">
<div class="flex items-center justify-center">
<IconifyIcon
icon="mdi:upload"
class="text-m-primary text-2xl mt-2 ml-1"
/>
<p class="font-bold uppercase text-xl text-m-text mt-2 mr-1">
upload
</p>
</div>
<div class="mx-2 flex flex-col items-center justify-center">
<template v-if="isTesting">
<TextSkeleton custom-class="h-10 w-16 mb-1" />
</template> </template>
<span class="metric-unit font-mono">{{ metric.unit }}</span> <span v-else class="text-4xl">
{{ upload !== null ? `${upload}` : "--" }}
</span>
<p class="font-bold text-xl leading-tight">
Mbps
</p>
</div>
</div>
<div class="bg-m-tertiary w-[153px] h-[120px] rounded-md shadow-md/50 shadow-m-black">
<div class="flex items-center justify-center">
<IconifyIcon
icon="mdi:wifi"
class="text-m-primary text-2xl mt-2 ml-1"
/>
<p class="font-bold uppercase text-xl text-m-text mt-2 mr-1">
ping
</p>
</div>
<div class="mx-2 flex flex-col items-center justify-center">
<template v-if="isTesting">
<TextSkeleton custom-class="h-10 w-16 mb-1" />
</template>
<span v-else class="text-4xl">
{{ ping !== null ? `${ping}` : "--" }}
</span>
<p class="font-bold text-xl leading-tight">
Ms
</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref} from "vue" import {ref} from "vue";
import {Icon as IconifyIcon} from "@iconify/vue" import {Icon as IconifyIcon} from "@iconify/vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
const ping = ref<number | null>(null) const ping = ref<number | null>(null)
const download = ref<number | null>(null) const download = ref<number | null>(null)
const upload = ref<number | null>(null) const upload = ref<number | null>(null)
const isTesting = ref(false) const isTesting = ref(false)
const 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() { async function testDownload() {
const start = performance.now() const start = performance.now()
const res = await fetch('/api/download') const res = await fetch('/api/download')
const blob = await res.blob() const blob = await res.blob()
const end = performance.now() const end = performance.now()
const size = blob.size const size = blob.size
const seconds = (end - start) / 1000 const seconds = (end - start) / 1000
download.value = Math.round((size * 8) / seconds / 1000000) download.value = Math.round((size * 8) / seconds / 1000000)
@@ -67,17 +106,28 @@ async function testDownload() {
async function testUpload() { async function testUpload() {
const size = 5 * 1024 * 1024 const size = 5 * 1024 * 1024
const data = new Uint8Array(size) const data = new Uint8Array(size)
const start = performance.now() const start = performance.now()
await fetch('/api/upload', { method: 'POST', body: data })
await fetch('/api/upload', {
method: 'POST',
body: data
})
const end = performance.now() const end = performance.now()
const seconds = (end - start) / 1000 const seconds = (end - start) / 1000
upload.value = Math.round((size * 8) / seconds / 1000000) upload.value = Math.round((size * 8) / seconds / 1000000)
} }
async function testPing() { async function testPing() {
const start = performance.now() const start = performance.now()
await fetch('/api/ping') await fetch('/api/ping')
const end = performance.now() const end = performance.now()
ping.value = Math.round(end - start) ping.value = Math.round(end - start)
} }
@@ -96,97 +146,3 @@ async function runTests() {
} }
} }
</script> </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,45 +1,49 @@
<template> <template>
<div class="status-card card-glow"> <div class="bg-m-secondary w-[250px] h-[292px] rounded-md mx-4 shadow-md/50 shadow-black">
<div class="card-header"> <p class="font-bold text-3xl text-m-tertiary my-1 mx-3">
<h2 class="card-title">Status</h2> Status
<span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Services</span> </p>
</div>
<template v-if="loading"> <template v-if="loading">
<div <div
v-for="n in 3" v-for="n in 3"
:key="`skeleton-${n}`" :key="`skeleton-${n}`"
class="status-row animate-shimmer" class="relative w-[200px] h-[68px] rounded-md mx-[25px] mb-3"
> >
<div class="flex items-center gap-3"> <ButtonSkeleton custom-class="h-full w-full" />
<CircleSkeleton custom-class="h-3 w-3" /> <div class="absolute inset-0 p-2">
<TextSkeleton custom-class="h-4 w-20" /> <TextSkeleton custom-class="h-5 w-24 mb-2" />
<div class="flex items-center gap-2">
<CircleSkeleton custom-class="h-6 w-6" />
<TextSkeleton custom-class="h-5 w-20" />
</div>
</div> </div>
<TextSkeleton custom-class="h-4 w-16" />
</div> </div>
</template> </template>
<div <div
v-else v-else
class="bg-m-tertiary w-[200px] h-[68px] rounded-md shadow-md/50 shadow-m-black mx-[25px] mb-3"
v-for="row in rows" v-for="row in rows"
:key="`${row.label}-${row.url}`" :key="`${row.label}-${row.url}`"
class="status-row"
:class="row.status === 200 ? 'row-ok' : 'row-error'"
> >
<div class="flex items-center gap-3"> <p class="font-bold text-xl text-m-text mt-2 mx-2 mb-1">
<span class="status-dot" :class="row.status === 200 ? 'dot-ok' : 'dot-error'" /> {{ row.label }}
<span class="font-display text-sm font-semibold text-m-text"> </p>
{{ row.label }} <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> </span>
</div> </div>
<span class="font-mono text-xs" :class="row.status === 200 ? 'text-m-success' : 'text-m-error'">
{{ statusLabel(row.status) }}
</span>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ButtonSkeleton from "~/components/skeleton/ButtonSkeleton.vue"
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue" import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue" import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
import {onBeforeUnmount, onMounted, ref} from "vue" import {onBeforeUnmount, onMounted, ref} from "vue"
@@ -73,10 +77,16 @@ const loading = ref(true)
const initialized = ref(false) const initialized = ref(false)
let timer: ReturnType<typeof setInterval> | null = null 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) => { const statusLabel = (status: number) => {
if (status === 200) return "HTTP 200" if (status === 200) return "HTTP 200"
if (status === 0) return "Injoignable" if (status === 0) return "Injoignable"
return `KO (${status})` return `KO (HTTP ${status})`
} }
const checkStatus = async () => { const checkStatus = async () => {
@@ -115,67 +125,3 @@ onBeforeUnmount(() => {
} }
}) })
</script> </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

@@ -1,223 +1,41 @@
<template> <template>
<div class="page-layout"> <div class="page-layout">
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="flex items-center gap-3 px-14 py-4">
<div class="logo-container"> <div class="avatar">
<img <div class="h-[155px] w-[155px]">
:src="logoSrc" <img
alt="Logo Malio" :src="logoSrc"
class="logo" alt="Logo Malio"
/> class="h-full w-full object-contain"
</div> />
<div class="sidebar-divider" /> </div>
</div>
<div class="sidebar-content">
<slot name="sidebar" />
</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 v1.0
</p>
</div> </div>
</div> </div>
<slot name="sidebar" />
</aside> </aside>
<main class="content">
<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">
<div class="sidebar-header">
<div class="logo-container">
<img
:src="logoSrc"
alt="Logo Malio"
class="logo"
/>
</div>
<div class="sidebar-divider" />
</div>
<div class="sidebar-content">
<slot name="sidebar" />
</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 v1.0
</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 /> <slot />
</main> </main>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"
import { Icon as IconifyIcon } from "@iconify/vue"
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png' import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
const isMenuOpen = ref(false)
</script> </script>
<style scoped> <style scoped>
.page-layout { .page-layout {
display: grid; display: grid;
grid-template-columns: 260px 1fr; grid-template-columns: 280px 1fr;
min-height: 100vh; 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 { .sidebar {
position: sticky; background-color: rgb(var(--m-primary));
top: 0; color: white;
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;
}
.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 0;
}
.sidebar-footer {
flex-shrink: 0;
}
.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;
} }
.content { .content {
background: rgb(var(--m-bg)); background-color: rgb(var(--m-tertiary));
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);
}
.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;
}
} }
</style> </style>

5598
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,13 +16,7 @@
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "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", "@tailwindcss/vite": "^4.2.1",
"semantic-release": "^25.0.3",
"tailwindcss": "^4.2.1" "tailwindcss": "^4.2.1"
} }
} }

View File

@@ -1,56 +1,35 @@
<template> <template>
<NuxtLayout name="default"> <NuxtLayout name="default">
<div class="dashboard-container"> <template #sidebar>
<header class="dashboard-header"> <div class="flex flex-col">
<div> <DiagramStorage
<h1 class="font-display text-3xl font-bold tracking-tight text-m-text"> v-for="item in diagramItems"
Monitoring :key="item.key"
</h1> :loading="loading"
</div> :host-name="item.hostName"
</header> :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="content-grid"> <p class="font-bold text-4xl my-6 mx-4">Écran de monitoring</p>
<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="flex">
<div class="grid-left"> <div class="flex flex-col gap-4">
<StatusSite class="animate-fade-in-up" style="animation-delay: 100ms" /> <StatusSite />
<BackupButtonSee <BackupButtonSee @select="selectedBackup = $event" />
class="animate-fade-in-up backup-selector" </div>
style="animation-delay: 200ms"
@select="selectedBackup = $event"
/>
</div>
<div class="grid-middle"> <div class="flex flex-col gap-4">
<Speedtest class="animate-fade-in-up speedtest-card-mobile" style="animation-delay: 150ms" /> <Speedtest />
<BackupList <BackupList :folder="selectedBackup" />
class="animate-fade-in-up backup-list-mobile" <MessageDiscord/>
style="animation-delay: 250ms"
:folder="selectedBackup"
/>
</div>
</div>
</div>
<div class="content-aside">
<MessageDiscord class="animate-fade-in-up" style="animation-delay: 300ms" />
</div>
</div> </div>
</div> </div>
</NuxtLayout> </NuxtLayout>
@@ -60,32 +39,18 @@
definePageMeta({layout: false}) definePageMeta({layout: false})
import {computed, onMounted, ref} from "vue" import {computed, onMounted, ref} from "vue"
type DiskSourceResult = { type SourceKey = "remote" | "local"
key: string type DiskCommandResult = { ok: boolean; output: string }
label: string
ok: boolean
output: string
}
type DiskApiResponse = { type DiskApiResponse = {
results: DiskSourceResult[] remote?: string | DiskCommandResult
} local?: string | DiskCommandResult
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 selectedBackup = ref<string | null>(null)
const rawResults = ref<DiskSourceResult[]>([]) const rawResults = ref<Record<SourceKey, string>>({
remote: "",
local: ""
})
const loading = ref(false) const loading = ref(false)
const chartRadius = 52 const chartRadius = 52
const chartCircumference = 2 * Math.PI * chartRadius const chartCircumference = 2 * Math.PI * chartRadius
@@ -128,9 +93,21 @@ const getDiskValues = (output: string) => {
return {availableGb, usedGb, totalGb} return {availableGb, usedGb, totalGb}
} }
const diagramItems = computed<DiagramItem[]>(() => { const getOutputText = (entry: unknown) => {
return rawResults.value.map((result) => { if (typeof entry === "string") return entry
const diskValues = getDiskValues(result.output) 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 remainingPercent = const remainingPercent =
diskValues === null diskValues === null
? null ? null
@@ -139,15 +116,15 @@ const diagramItems = computed<DiagramItem[]>(() => {
Math.min(100, Math.round((diskValues.availableGb / diskValues.totalGb) * 100)) 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 { return {
key: result.key, key: item.key,
loading: loading.value, hostName: getHostName(item.output, item.fallbackHost),
hostName: getHostName(result.output, result.label), statusColorClass,
statusColorClass: chartOffset,
remainingPercent !== null && remainingPercent <= 30 ? "status-error" : "status-success",
chartRadius,
chartCircumference,
chartOffset: chartCircumference - ((remainingPercent ?? 0) / 100) * chartCircumference,
remainingPercentText: remainingPercentText:
loading.value ? "..." : remainingPercent === null ? "--%" : `${remainingPercent}%`, loading.value ? "..." : remainingPercent === null ? "--%" : `${remainingPercent}%`,
usedText: loading.value ? "..." : diskValues ? `${diskValues.usedGb.toFixed(2)} GB` : "--", usedText: loading.value ? "..." : diskValues ? `${diskValues.usedGb.toFixed(2)} GB` : "--",
@@ -156,139 +133,40 @@ const diagramItems = computed<DiagramItem[]>(() => {
}) })
}) })
const runScript = async () => { const runScript = async () => {
loading.value = true loading.value = true
rawResults.value = [] rawResults.value = {
remote: "",
local: ""
}
try { try {
const output = await $fetch<DiskApiResponse>("/api/disk") const output = await $fetch<DiskApiResponse | string>("/api/disk")
rawResults.value = output.results
if (typeof output === "string") {
rawResults.value = {
remote: output,
local: "Erreur: sortie locale indisponible"
}
return
}
rawResults.value = {
remote: getOutputText(output.remote),
local: getOutputText(output.local)
}
} catch (error) { } catch (error) {
const message = `Erreur: ${error instanceof Error ? error.message : String(error)}` const message = `Erreur: ${error instanceof Error ? error.message : String(error)}`
rawResults.value = [ rawResults.value = {
{ remote: message,
key: "error", local: message
label: "Source indisponible", }
ok: false,
output: message
}
]
} finally { } finally {
loading.value = false loading.value = false
} }
} }
onMounted(() => { onMounted(() => {
runScript() runScript()
}) })
</script> </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

@@ -1,12 +1,17 @@
import { execFile } from "node:child_process" 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_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 REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups"
const MAX_FILES_PER_FOLDER = Number(process.env.BACKUPS_MAX_FILES || "200") const MAX_FILES_PER_FOLDER = Number(process.env.BACKUPS_MAX_FILES || "200")
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value) const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'` const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
const FOLDER_MAP = folderMap as Record<string, string> const FOLDER_MAP: Record<string, string> = {
ferme: "bdd_recette/ferme",
inventory: "bdd_recette/inventory",
sirh: "bdd_recette/sirh",
user: "bdd_recette/user",
bitwarden: "bitwarden"
}
function runSsh(command: string): Promise<string> { function runSsh(command: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View File

@@ -1,19 +1,12 @@
import { exec } from "child_process" import { exec } from "child_process"
import diskSources from "../config/disk-commands.json"
type DiskSource = { const remoteCommand =
key: string process.env.DISK_REMOTE_COMMAND ||
label: string "ssh malio-b@192.168.0.179 'cd /home/malio-b/Scripts-Serveur && bash check_storage.sh && exit'"
command: string
}
function getCommand(source: DiskSource) { const localCommand =
const envKey = `DISK_COMMAND_${source.key.toUpperCase()}` process.env.DISK_LOCAL_COMMAND ||
const legacyEnvKey = "bash /home/kevin/check_storage.sh"
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> { function runCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -28,26 +21,25 @@ function runCommand(command: string): Promise<string> {
} }
export default defineEventHandler(async () => { export default defineEventHandler(async () => {
const results = await Promise.all( const [remoteResult, localResult] = await Promise.allSettled([
(diskSources as DiskSource[]).map(async (source) => { runCommand(remoteCommand),
try { runCommand(localCommand)
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 { results } 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)}`
}
}
}) })

View File

@@ -1,10 +1,15 @@
import { execFile, spawn } from "node:child_process" import { execFile, spawn } from "node:child_process"
import { Readable } from "node:stream" import { Readable } from "node:stream"
import folderMap from "../config/backup-folders.json"
const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST || "malio-b@192.168.0.179" 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 REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups"
const FOLDER_MAP = folderMap as Record<string, string> const FOLDER_MAP: Record<string, string> = {
ferme: "bdd_recette/ferme",
inventory: "bdd_recette/inventory",
sirh: "bdd_recette/sirh",
user: "bdd_recette/user",
bitwarden: "bitwarden"
}
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value) const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
const isSafeFile = (value: string) => /^[^/\\]+$/.test(value) const isSafeFile = (value: string) => /^[^/\\]+$/.test(value)
@@ -15,7 +20,7 @@ function runSsh(command: string): Promise<string> {
execFile( execFile(
"ssh", "ssh",
["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", REMOTE_HOST, command], ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", REMOTE_HOST, command],
{ maxBuffer: 10 * 1024 * 1024 }, { maxBuffer: 5 * 1024 * 1024 },
(error, stdout, stderr) => { (error, stdout, stderr) => {
if (error) { if (error) {
reject(stderr || error.message) reject(stderr || error.message)

View File

@@ -1,6 +1,10 @@
import targets from "../config/version-status-targets.json"
export default defineEventHandler(async () => { 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" },
]
const results = await Promise.all( const results = await Promise.all(
targets.map(async (target) => { targets.map(async (target) => {
try { try {

View File

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

View File

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

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

View File

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