feat : new ui et message discord

This commit is contained in:
2026-03-09 15:27:18 +01:00
parent db738715c3
commit f5cc79f510
20 changed files with 1399 additions and 522 deletions

201
AGENTS.md
View File

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

View File

@@ -1,75 +1,42 @@
# Nuxt Minimal Starter ---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
## Setup The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
Make sure to install dependencies: ## Design Thinking
```bash Before coding, understand the context and commit to a BOLD aesthetic direction:
# npm - **Purpose**: What problem does this interface solve? Who uses it?
npm install - **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
# pnpm **CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
pnpm install
# yarn Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
yarn install - Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
# bun ## Frontend Aesthetics Guidelines
bun install
```
## Development Server Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
Start the development server on `http://localhost:3000`: NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
```bash Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
# npm
npm run dev
# pnpm **IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
pnpm dev
# yarn Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

View File

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

View File

@@ -1,87 +1,99 @@
<template> <template>
<div class="bg-m-secondary w-[250px] h-[259px] rounded-md mx-4 shadow-md/50 shadow-black"> <div class="backup-card card-glow">
<p class="font-bold text-3xl text-m-tertiary my-1 mx-3"> <div class="card-header">
Backup <h2 class="card-title">Backup</h2>
</p> <span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Dossiers</span>
<button </div>
type="button"
class="bg-m-tertiary w-[200px] h-[32px] rounded-md shadow-md/50 shadow-m-black mx-3 mb-[10px] flex items-center justify-between cursor-pointer"
@click="select('bitwarden')"
>
<p class="font-bold uppercase text-xl ml-[24px]">
bitwarden
</p>
<IconifyIcon
icon="mdi:eye"
class="text-black text-2xl mr-[24px]"
/>
</button>
<button <div class="backup-list">
<button
v-for="item in folders"
:key="item.name"
type="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" class="backup-btn"
@click="select('inventory')" :class="{ 'backup-btn-active': active === item.name }"
> @click="select(item.name)"
<p class="font-bold uppercase text-xl ml-[24px]"> >
inventory <div class="flex items-center gap-2.5">
</p> <IconifyIcon :icon="item.icon" class="text-base text-m-accent" />
<IconifyIcon <span class="font-display text-sm font-semibold uppercase tracking-wide">
icon="mdi:eye" {{ item.name }}
class="text-black text-2xl mr-[24px]" </span>
/> </div>
</button> <IconifyIcon
icon="mdi:chevron-right"
<button class="text-lg text-m-muted transition-transform duration-200"
type="button" :class="{ 'translate-x-0.5 !text-m-accent': active === item.name }"
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')" </button>
> </div>
<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,44 +1,58 @@
<template> <template>
<div class="bg-m-secondary w-[507px] h-[367px] rounded-md mx-4 shadow-md/50 shadow-black"> <div class="backup-list-card card-glow">
<p class="font-bold text-3xl text-m-tertiary my-1 mx-3"> <div class="card-header">
{{ title }} <h2 class="card-title">{{ title }}</h2>
</p> </div>
<div v-if="loading"> <div v-if="!folder" class="empty-state">
<IconifyIcon icon="mdi:folder-open-outline" class="text-3xl text-m-muted/40" />
<p class="mt-2 font-mono text-xs text-m-muted/50">
Selectionnez un dossier
</p>
</div>
<div v-else-if="loading" class="file-list">
<div <div
v-for="n in 6" v-for="n in 6"
:key="`backup-skeleton-${n}`" :key="`backup-skeleton-${n}`"
class="relative w-[483px] h-[39px] mx-3 mb-[10px]" class="file-row animate-shimmer"
> >
<ButtonSkeleton custom-class="h-full w-full" /> <TextSkeleton custom-class="h-4 w-48" />
<div class="absolute inset-0 flex items-center justify-between px-3"> <CircleSkeleton custom-class="h-5 w-5 rounded" />
<TextSkeleton custom-class="h-5 w-[260px]" />
<CircleSkeleton custom-class="h-6 w-6 rounded-md" />
</div>
</div> </div>
</div> </div>
<button <div v-else-if="backups.length === 0" class="empty-state">
v-else <IconifyIcon icon="mdi:file-hidden" class="text-3xl text-m-muted/40" />
<p class="mt-2 font-mono text-xs text-m-muted/50">
Aucun backup trouve
</p>
</div>
<div v-else class="file-list">
<button
v-for="file in backups" v-for="file in backups"
:key="file" :key="file"
class="bg-m-tertiary w-[483px] h-[39px] rounded-md shadow-md/50 shadow-m-black mx-3 mb-[10px] flex items-center justify-between cursor-pointer" class="file-row"
@click="downloadBackup(file)" @click="downloadBackup(file)"
> >
<p class="text-xl ml-3 truncate max-w-[400px]"> <div class="flex min-w-0 items-center gap-2.5">
{{ file }} <IconifyIcon icon="mdi:file-document-outline" class="text-base text-m-accent flex-shrink-0" />
</p> <span class="truncate font-mono text-xs text-m-text">
{{ file }}
<IconifyIcon </span>
</div>
<IconifyIcon
icon="mdi:download" icon="mdi:download"
class="text-black text-2xl mr-3" class="text-base text-m-muted flex-shrink-0 transition-colors duration-200"
/> />
</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"
@@ -49,8 +63,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 "Backup" if (!props.folder) return "Fichiers"
return `Liste des backup de ${props.folder.toUpperCase()}` return `Backup ${props.folder.toUpperCase()}`
}) })
const downloadBackup = (file: string) => { const downloadBackup = (file: string) => {
@@ -78,3 +92,59 @@ 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,35 +1,48 @@
<template> <template>
<section class="flex flex-col items-center p-4"> <section class="storage-card">
<template v-if="loading"> <template v-if="item.loading">
<TextSkeleton custom-class="h-7 w-40" /> <TextSkeleton custom-class="h-5 w-28" />
<CircleSkeleton custom-class="mt-2 h-[140px] w-[140px]" /> <CircleSkeleton custom-class="mt-3 h-[120px] w-[120px]" />
<BlockSkeleton custom-class="mt-2 h-5 w-36" /> <BlockSkeleton custom-class="mt-3 h-4 w-32" />
</template> </template>
<template v-else> <template v-else>
<p class="text-center text-xl font-semibold uppercase">{{ hostName }}</p> <p class="font-mono text-[11px] font-medium uppercase tracking-[0.2em] text-white/60">
<div class="relative h-[140px] w-[140px]" :class="statusColorClass"> {{ item.hostName }}
<svg class="h-full w-full -rotate-90" viewBox="0 0 120 120" aria-label="Pourcentage restant"> </p>
<div class="chart-wrapper" :class="item.statusColorClass">
<svg class="chart-svg" viewBox="0 0 120 120" aria-label="Pourcentage restant">
<circle <circle
class="fill-none stroke-[rgba(255,255,255,0.22)] [stroke-width:10]" class="track"
cx="60" cx="60" cy="60"
cy="60" :r="item.chartRadius"
:r="chartRadius"
/> />
<circle <circle
class="fill-none stroke-[currentColor] [stroke-linecap:round] [stroke-width:10] transition-[stroke-dashoffset] duration-300" class="progress"
cx="60" cx="60" cy="60"
cy="60" :r="item.chartRadius"
:r="chartRadius" :style="{
:style="{ strokeDasharray: `${chartCircumference}`, strokeDashoffset: `${chartOffset}` }" strokeDasharray: `${item.chartCircumference}`,
strokeDashoffset: `${item.chartOffset}`
}"
/> />
</svg> </svg>
<div class="absolute inset-0 flex flex-col items-center justify-center"> <div class="chart-label">
<strong class="text-2xl leading-none">{{ remainingPercentText }}</strong> <strong class="font-mono text-2xl font-bold leading-none">
{{ item.remainingPercentText }}
</strong>
<span class="mt-1 font-mono text-[9px] uppercase tracking-widest text-m-muted">
libre
</span>
</div> </div>
</div> </div>
<p class="mt-1 text-center text-sm font-semibold">{{ usedText }} / {{ totalText }}</p> <p class="font-mono text-xs font-medium text-m-muted/80">
{{ item.usedText }}
<span class="mx-0.5 text-m-muted/40">/</span>
{{ item.totalText }}
</p>
</template> </template>
</section> </section>
</template> </template>
@@ -40,24 +53,69 @@ import BlockSkeleton from "~/components/skeleton/BlockSkeleton.vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue" import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
defineProps<{ defineProps<{
loading: boolean item: {
hostName: string loading: boolean
statusColorClass: string hostName: string
chartRadius: number statusColorClass: string
chartCircumference: number chartRadius: number
chartOffset: number chartCircumference: number
remainingPercentText: string chartOffset: number
usedText: string remainingPercentText: string
totalText: string usedText: string
totalText: string
}
}>() }>()
</script> </script>
<style scoped> <style scoped>
.m-success { .storage-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 1.25rem 1rem;
gap: 0.25rem;
}
.chart-wrapper {
position: relative;
width: 120px;
height: 120px;
}
.chart-svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.track {
fill: none;
stroke: rgba(255, 255, 255, 0.06);
stroke-width: 8;
}
.progress {
fill: none;
stroke: currentColor;
stroke-width: 8;
stroke-linecap: round;
transition: stroke-dashoffset 0.8s cubic-bezier(0.4, 0, 0.2, 1);
}
.chart-label {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.status-success {
color: rgb(var(--m-success)); color: rgb(var(--m-success));
} }
.m-error { .status-error {
color: rgb(var(--m-error)); color: rgb(var(--m-error));
} }
</style> </style>

View File

@@ -1,19 +1,108 @@
<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="bg-m-secondary w-auto h-auto mx-4 rounded-md shadow-md/50 shadow-black p-2"> <div class="discord-card card-glow">
<div class="mb-2 flex items-center justify-between"> <div class="card-header">
<p class="font-bold text-3xl text-m-tertiary"> <div class="flex items-center gap-2.5">
Speedtest <IconifyIcon icon="mdi:message-text" class="text-lg text-m-accent" />
</p> <h2 class="card-title">Discord</h2>
<div v-if="messages"> </div>
<div v-for="m in messages" :key="m.id"> <span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Messages</span>
<strong>{{ m.author.username }}</strong>
<p>{{ m.content }}</p>
</div> </div>
</div>
<div v-if="!messages || messages.length === 0" class="empty-state">
<IconifyIcon icon="mdi:chat-outline" class="text-3xl text-m-muted/40" />
<p class="mt-2 font-mono text-xs text-m-muted/50">
Aucun message
</p>
</div>
<div v-else class="message-list">
<div
v-for="m in messages"
:key="m.id"
class="message-row"
>
<div class="message-avatar">
{{ m.author.username.charAt(0).toUpperCase() }}
</div>
<div class="min-w-0 flex-1">
<span class="font-display text-xs font-semibold text-m-accent">
{{ m.author.username }}
</span>
<p class="mt-0.5 break-words font-display text-sm leading-relaxed text-m-text/80">
{{ m.content }}
</p>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.discord-card {
background: rgb(var(--m-secondary));
border-radius: 12px;
padding: 1.25rem;
max-height: calc(100vh - 7rem);
overflow: hidden;
transition: background-color 0.4s ease;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.card-title {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
.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,103 +1,64 @@
<template> <template>
<div class="bg-m-secondary w-[507px] h-[184px] mx-4 rounded-md shadow-md/50 shadow-black p-2"> <div class="speedtest-card card-glow">
<div class="mb-2 flex items-center justify-between"> <div class="card-header">
<p class="font-bold text-3xl text-m-tertiary"> <h2 class="card-title">Speedtest</h2>
Speedtest <button
</p> class="reload-btn"
<IconifyIcon @click="runTests"
:disabled="isTesting"
>
<IconifyIcon
icon="mdi:reload" icon="mdi:reload"
class="bg-m-tertiary text-2xl text-black rounded-md shadow-md/50 mr-1 cursor-pointer" class="text-lg"
@click="runTests" :class="{ 'animate-spin': isTesting }"
/> />
</button>
</div> </div>
<div class="grid grid-cols-3 gap-3">
<div class="bg-m-tertiary w-[153px] h-[120px] rounded-md shadow-md/50 shadow-m-black"> <div class="metrics-grid">
<div class="flex items-center justify-center"> <div v-for="metric in metrics" :key="metric.label" class="metric-card">
<IconifyIcon <div class="metric-header">
icon="mdi:download" <IconifyIcon :icon="metric.icon" class="text-lg text-m-accent" />
class="text-m-primary text-2xl mt-2 ml-1" <span class="font-mono text-[10px] font-medium uppercase tracking-[0.15em] text-m-muted">
/> {{ metric.label }}
<p class="font-bold uppercase text-xl text-m-text mt-2 mr-1">
download
</p>
</div>
<div class="mx-2 flex flex-col items-center justify-center">
<template v-if="isTesting">
<TextSkeleton custom-class="h-10 w-16 mb-1" />
</template>
<span v-else class="text-4xl">
{{ download !== null ? `${download}` : "--" }}
</span> </span>
<p class="font-bold text-xl leading-tight">
Mbps
</p>
</div> </div>
</div> <div class="metric-value-area">
<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"> <template v-if="isTesting">
<TextSkeleton custom-class="h-10 w-16 mb-1" /> <div class="h-10 w-16 animate-shimmer rounded" />
</template> </template>
<span v-else class="text-4xl"> <template v-else>
{{ upload !== null ? `${upload}` : "--" }} <span class="metric-value font-mono">
</span> {{ metric.value !== null ? metric.value : "--" }}
<p class="font-bold text-xl leading-tight"> </span>
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> </template>
<span v-else class="text-4xl"> <span class="metric-unit font-mono">{{ metric.unit }}</span>
{{ 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 {ref} from "vue"; import {computed, 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)
@@ -106,28 +67,17 @@ 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)
} }
@@ -146,3 +96,97 @@ 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,49 +1,45 @@
<template> <template>
<div class="bg-m-secondary w-[250px] h-[292px] rounded-md mx-4 shadow-md/50 shadow-black"> <div class="status-card card-glow">
<p class="font-bold text-3xl text-m-tertiary my-1 mx-3"> <div class="card-header">
Status <h2 class="card-title">Status</h2>
</p> <span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Services</span>
</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="relative w-[200px] h-[68px] rounded-md mx-[25px] mb-3" class="status-row animate-shimmer"
> >
<ButtonSkeleton custom-class="h-full w-full" /> <div class="flex items-center gap-3">
<div class="absolute inset-0 p-2"> <CircleSkeleton custom-class="h-3 w-3" />
<TextSkeleton custom-class="h-5 w-24 mb-2" /> <TextSkeleton custom-class="h-4 w-20" />
<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'"
> >
<p class="font-bold text-xl text-m-text mt-2 mx-2 mb-1"> <div class="flex items-center gap-3">
{{ row.label }} <span class="status-dot" :class="row.status === 200 ? 'dot-ok' : 'dot-error'" />
</p> <span class="font-display text-sm font-semibold text-m-text">
<div class="mx-2 flex items-center"> {{ row.label }}
<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"
@@ -77,16 +73,10 @@ 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 (HTTP ${status})` return `KO (${status})`
} }
const checkStatus = async () => { const checkStatus = async () => {
@@ -125,3 +115,67 @@ 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,41 +1,223 @@
<template> <template>
<div class="page-layout"> <div class="page-layout">
<aside class="sidebar"> <aside class="sidebar">
<div class="flex items-center gap-3 px-14 py-4"> <div class="sidebar-header">
<div class="avatar"> <div class="logo-container">
<div class="h-[155px] w-[155px]"> <img
<img :src="logoSrc"
:src="logoSrc" alt="Logo Malio"
alt="Logo Malio" class="logo"
class="h-full w-full object-contain" />
/> </div>
</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>
</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: 280px 1fr; grid-template-columns: 260px 1fr;
min-height: 100vh; min-height: 100vh;
} }
.sidebar { .sidebar,
background-color: rgb(var(--m-primary)); .mobile-sidebar {
background: linear-gradient(
180deg,
rgb(var(--m-sidebar-from)) 0%,
rgb(var(--m-sidebar-to)) 100%
);
color: white; color: white;
display: flex;
flex-direction: column;
border-right: 1px solid var(--m-sidebar-border);
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
transition: background 0.4s ease, border-color 0.4s ease;
}
.sidebar-header {
padding: 1.5rem 2rem 0;
flex-shrink: 0;
}
.logo-container {
display: flex;
justify-content: center;
padding: 0.5rem 0;
}
.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-color: rgb(var(--m-tertiary)); background: rgb(var(--m-bg));
overflow-y: auto;
min-height: 100vh;
transition: background-color 0.4s ease;
}
.mobile-menu-button,
.mobile-menu-backdrop,
.mobile-sidebar {
display: none;
}
@media (max-width: 820px) {
.page-layout {
grid-template-columns: 1fr;
}
.sidebar {
display: none;
}
.mobile-menu-button {
position: fixed;
top: 1rem;
left: 1rem;
z-index: 40;
display: flex;
height: 44px;
width: 44px;
align-items: center;
justify-content: center;
border-radius: 10px;
border: 1px solid rgb(var(--m-accent) / 0.18);
background: rgb(var(--m-secondary));
color: rgb(var(--m-text));
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.24);
}
.mobile-menu-backdrop {
position: fixed;
inset: 0;
z-index: 45;
display: block;
background: rgba(3, 8, 20, 0.55);
backdrop-filter: blur(3px);
}
.mobile-sidebar {
position: fixed;
top: 0;
left: 0;
z-index: 50;
display: flex;
height: 100vh;
width: min(84vw, 320px);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.38);
}
.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>

View File

@@ -1,35 +1,56 @@
<template> <template>
<NuxtLayout name="default"> <NuxtLayout name="default">
<template #sidebar> <div class="dashboard-container">
<div class="flex flex-col"> <header class="dashboard-header">
<DiagramStorage <div>
v-for="item in diagramItems" <h1 class="font-display text-3xl font-bold tracking-tight text-m-text">
:key="item.key" Monitoring
:loading="loading" </h1>
:host-name="item.hostName" </div>
:status-color-class="item.statusColorClass" </header>
: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>
<p class="font-bold text-4xl my-6 mx-4">Écran de monitoring</p> <div class="content-grid">
<div class="content-main">
<section class="storage-section">
<div class="storage-section-header">
<h2 class="font-display text-xl font-semibold text-m-text">Stockage</h2>
<span class="font-mono text-[10px] uppercase tracking-widest text-m-muted">Volumes</span>
</div>
<div class="storage-grid">
<DiagramStorage
v-for="(item, idx) in diagramItems"
:key="item.key"
:item="item"
:style="{ animationDelay: `${idx * 150}ms` }"
class="animate-fade-in-up"
/>
</div>
</section>
<div class="flex"> <div class="dashboard-grid">
<div class="flex flex-col gap-4"> <div class="grid-left">
<StatusSite /> <StatusSite class="animate-fade-in-up" style="animation-delay: 100ms" />
<BackupButtonSee @select="selectedBackup = $event" /> <BackupButtonSee
</div> class="animate-fade-in-up backup-selector"
style="animation-delay: 200ms"
@select="selectedBackup = $event"
/>
</div>
<div class="flex flex-col gap-4"> <div class="grid-middle">
<Speedtest /> <Speedtest class="animate-fade-in-up speedtest-card-mobile" style="animation-delay: 150ms" />
<BackupList :folder="selectedBackup" /> <BackupList
<MessageDiscord/> class="animate-fade-in-up backup-list-mobile"
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>
@@ -39,18 +60,32 @@
definePageMeta({layout: false}) definePageMeta({layout: false})
import {computed, onMounted, ref} from "vue" import {computed, onMounted, ref} from "vue"
type SourceKey = "remote" | "local" type DiskSourceResult = {
type DiskCommandResult = { ok: boolean; output: string } key: string
label: string
ok: boolean
output: string
}
type DiskApiResponse = { type DiskApiResponse = {
remote?: string | DiskCommandResult results: DiskSourceResult[]
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<Record<SourceKey, string>>({ const rawResults = ref<DiskSourceResult[]>([])
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
@@ -93,21 +128,9 @@ const getDiskValues = (output: string) => {
return {availableGb, usedGb, totalGb} return {availableGb, usedGb, totalGb}
} }
const getOutputText = (entry: unknown) => { const diagramItems = computed<DiagramItem[]>(() => {
if (typeof entry === "string") return entry return rawResults.value.map((result) => {
if (entry && typeof entry === "object" && "output" in entry) { const diskValues = getDiskValues(result.output)
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
@@ -116,15 +139,15 @@ const diagramItems = computed(() => {
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: item.key, key: result.key,
hostName: getHostName(item.output, item.fallbackHost), loading: loading.value,
statusColorClass, hostName: getHostName(result.output, result.label),
chartOffset, statusColorClass:
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` : "--",
@@ -133,40 +156,139 @@ const diagramItems = computed(() => {
}) })
}) })
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 | string>("/api/disk") const output = await $fetch<DiskApiResponse>("/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, {
local: message key: "error",
} 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,17 +1,12 @@
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: Record<string, string> = { const FOLDER_MAP = folderMap as 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,12 +1,19 @@
import { exec } from "child_process" import { exec } from "child_process"
import diskSources from "../config/disk-commands.json"
const remoteCommand = type DiskSource = {
process.env.DISK_REMOTE_COMMAND || key: string
"ssh malio-b@192.168.0.179 'cd /home/malio-b/Scripts-Serveur && bash check_storage.sh && exit'" label: string
command: string
}
const localCommand = function getCommand(source: DiskSource) {
process.env.DISK_LOCAL_COMMAND || const envKey = `DISK_COMMAND_${source.key.toUpperCase()}`
"bash /home/kevin/check_storage.sh" const legacyEnvKey =
source.key === "remote" ? "DISK_REMOTE_COMMAND" : source.key === "local" ? "DISK_LOCAL_COMMAND" : ""
return process.env[envKey] || (legacyEnvKey ? process.env[legacyEnvKey] : undefined) || source.command
}
function runCommand(command: string): Promise<string> { function runCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -21,25 +28,26 @@ function runCommand(command: string): Promise<string> {
} }
export default defineEventHandler(async () => { export default defineEventHandler(async () => {
const [remoteResult, localResult] = await Promise.allSettled([ const results = await Promise.all(
runCommand(remoteCommand), (diskSources as DiskSource[]).map(async (source) => {
runCommand(localCommand) try {
]) const output = await runCommand(getCommand(source))
return {
key: source.key,
label: source.label,
ok: true,
output
}
} catch (error) {
return {
key: source.key,
label: source.label,
ok: false,
output: `Erreur: ${String(error)}`
}
}
})
)
return { return { results }
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,15 +1,10 @@
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: Record<string, string> = { const FOLDER_MAP = folderMap as 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)
@@ -20,7 +15,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: 5 * 1024 * 1024 }, { maxBuffer: 10 * 1024 * 1024 },
(error, stdout, stderr) => { (error, stdout, stderr) => {
if (error) { if (error) {
reject(stderr || error.message) reject(stderr || error.message)

View File

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

View File

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

View File

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

View File

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

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