feat : new ui et message discord
This commit is contained in:
201
AGENTS.md
201
AGENTS.md
@@ -1,54 +1,167 @@
|
||||
# Repository Guidelines
|
||||
# AGENTS.md
|
||||
|
||||
## Project Structure & Module Organization
|
||||
This repository is a Nuxt 4 application.
|
||||
- `pages/`: route pages (for example `pages/index.vue`).
|
||||
- `components/`: reusable Vue components (for example `DiskSidebarWidget.vue`, `ApiStatusBubble.vue`).
|
||||
- `layouts/`: shared page shells (`layouts/default.vue`).
|
||||
- `server/api/`: Nitro server endpoints (for example `disk.get.ts`, `version-status.get.ts`).
|
||||
- `assets/css/`: global styles and theme tokens (`main.css`, `malio.css`).
|
||||
- `public/`: static files served as-is.
|
||||
## Objectif
|
||||
|
||||
Keep feature logic close to usage: UI in `components/`, page composition in `pages/`, backend checks in `server/api/`.
|
||||
Ce fichier définit les règles que tout agent de code doit respecter dans ce dépôt.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
Use npm scripts from `package.json`:
|
||||
- `npm run dev`: start local dev server with hot reload.
|
||||
- `npm run build`: production build (client + server).
|
||||
- `npm run preview`: run the built app locally.
|
||||
- `npm run generate`: static generation when needed.
|
||||
Les objectifs sont :
|
||||
- maintenir un code clair et cohérent
|
||||
- éviter la complexité inutile
|
||||
- empêcher la génération de code mort
|
||||
- garantir des modifications prévisibles
|
||||
|
||||
There is no dedicated test script currently. At minimum, run `npm run build` before opening a PR.
|
||||
Ce fichier est la source de vérité concernant le comportement de l’agent.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Language: TypeScript + Vue SFCs (`<script setup lang="ts">`).
|
||||
- Indentation: 2 spaces.
|
||||
- Prefer double quotes in TS files to match current codebase.
|
||||
- Components: PascalCase file names (`ApiStatusBubble.vue`).
|
||||
- API handlers: kebab-case + HTTP suffix (`version-status.get.ts`).
|
||||
- Use Tailwind utility classes and project color tokens (`text-m-error`, `bg-m-success`, `text-m-primary`).
|
||||
|
||||
## Testing Guidelines
|
||||
No automated framework is configured yet. Use these checks:
|
||||
- Build validation: `npm run build`.
|
||||
- Manual smoke test: open `/`, verify API status cards refresh and disk sidebar renders.
|
||||
- For new endpoints, validate response shape in browser/network tab or `curl`.
|
||||
## Principes généraux
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Git history is not available in this workspace snapshot, so use this convention:
|
||||
- Commit format: `type(scope): short summary`.
|
||||
- Example: `feat(api): add labeled multi-endpoint status check`.
|
||||
- Types: `feat`, `fix`, `refactor`, `style`, `docs`, `chore`.
|
||||
Toujours produire un code clair, simple et minimal.
|
||||
|
||||
PRs should include:
|
||||
- What changed and why.
|
||||
- Affected paths (e.g. `server/api/version-status.get.ts`).
|
||||
- UI screenshots for visual changes.
|
||||
- Verification steps (commands run, expected result).
|
||||
Privilégier les implémentations explicites plutôt que les abstractions.
|
||||
|
||||
## Security & Configuration Notes
|
||||
- Do not hardcode secrets in source.
|
||||
- Prefer environment variables for private endpoints/tokens.
|
||||
- Keep external API checks server-side (`server/api/*`) to avoid exposing sensitive details in the client.
|
||||
Ne jamais introduire de nouveaux patterns si un pattern existant résout déjà le problème.
|
||||
|
||||
Ne lance pas de build si je le dit pas
|
||||
Ne jamais générer :
|
||||
- du code mort
|
||||
- du code inutilisé
|
||||
- du code spéculatif
|
||||
- des fonctionnalités non demandées
|
||||
|
||||
|
||||
## Lecture obligatoire avant modification
|
||||
|
||||
Avant toute modification de code, l’agent doit :
|
||||
|
||||
Lire entièrement le fichier à modifier.
|
||||
|
||||
Comprendre la structure et les conventions existantes.
|
||||
|
||||
Respecter :
|
||||
- les conventions de nommage
|
||||
- le style du fichier
|
||||
- l’architecture déjà utilisée
|
||||
|
||||
Vérifier les fichiers liés pouvant être impactés.
|
||||
|
||||
Ne jamais proposer une modification sur du code qui n’a pas été lu.
|
||||
|
||||
|
||||
## Règles de modification
|
||||
|
||||
Lors de l’édition de code :
|
||||
|
||||
Respecter l’architecture existante.
|
||||
|
||||
Réutiliser les utilitaires et fonctions déjà présents lorsque c’est 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 n’est 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
|
||||
l’organisation des fichiers
|
||||
les patterns architecturaux existants
|
||||
les composants ou modules déjà présents
|
||||
|
||||
|
||||
## Sécurité Git
|
||||
|
||||
L’agent ne doit jamais :
|
||||
|
||||
créer un commit sans demande explicite
|
||||
|
||||
push des modifications
|
||||
|
||||
force push
|
||||
|
||||
modifier la configuration git
|
||||
|
||||
réécrire l’historique
|
||||
|
||||
|
||||
## Sécurité
|
||||
|
||||
Ne jamais introduire dans le code :
|
||||
|
||||
des secrets
|
||||
|
||||
des identifiants
|
||||
|
||||
des tokens
|
||||
|
||||
des variables d’environnement 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 d’introduire un nouveau pattern architectural
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## Actions interdites
|
||||
|
||||
L’agent 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 n’est pas demandé explicitement ou sans validation préalable.
|
||||
93
README.md
93
README.md
@@ -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
|
||||
# npm
|
||||
npm install
|
||||
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
||||
- **Purpose**: What problem does this interface solve? Who uses it?
|
||||
- **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
|
||||
pnpm install
|
||||
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
||||
- Production-grade and functional
|
||||
- Visually striking and memorable
|
||||
- Cohesive with a clear aesthetic point-of-view
|
||||
- Meticulously refined in every detail
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
## Frontend Aesthetics Guidelines
|
||||
|
||||
## 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
|
||||
# npm
|
||||
npm run dev
|
||||
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.
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
**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.
|
||||
|
||||
# yarn
|
||||
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.
|
||||
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.
|
||||
@@ -13,15 +13,147 @@
|
||||
--color-m-bg: rgb(var(--m-bg));
|
||||
--color-m-error: rgb(var(--m-error));
|
||||
--color-m-success: rgb(var(--m-success));
|
||||
/* Alias pour la faute de frappe courante "m-succes" */
|
||||
--color-m-accent: rgb(var(--m-accent));
|
||||
--color-m-warning: rgb(var(--m-warning));
|
||||
--color-m-succes: rgb(var(--m-success));
|
||||
--font-display: "Outfit", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
||||
}
|
||||
|
||||
@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 {
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(148, 163, 184, 0.12) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(148, 163, 184, 0.12) 1px, transparent 1px);
|
||||
linear-gradient(to right, rgb(var(--m-accent) / 0.04) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgb(var(--m-accent) / 0.04) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
.bg-noise {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bg-noise::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.025;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.card-glow {
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(var(--m-accent) / var(--m-card-border-opacity)),
|
||||
0 4px 24px -4px rgba(0, 0, 0, var(--m-shadow-opacity)),
|
||||
0 0 48px -12px rgb(var(--m-accent) / 0.06);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.card-glow-success {
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(var(--m-success) / 0.15),
|
||||
0 4px 24px -4px rgba(0, 0, 0, var(--m-shadow-opacity));
|
||||
}
|
||||
|
||||
.card-glow-error {
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(var(--m-error) / 0.15),
|
||||
0 4px 24px -4px rgba(0, 0, 0, var(--m-shadow-opacity));
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, rgb(var(--m-accent)), rgb(var(--m-success)));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 4px currentColor, 0 0 8px currentColor;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 8px currentColor, 0 0 16px currentColor;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.5s ease-out both;
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--m-shimmer-from) 0%,
|
||||
var(--m-shimmer-to) 50%,
|
||||
var(--m-shimmer-from) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgb(var(--m-bg));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--m-border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(var(--m-muted));
|
||||
}
|
||||
|
||||
@@ -3,17 +3,26 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Couleurs en RGB “space separated” pour Tailwind */
|
||||
--m-primary: 34 39 131; /* Couleur principal*/
|
||||
--m-secondary: 48 73 152; /* Couleur secondaire */
|
||||
--m-tertiary: 243 244 248; /* Couleur tertiaire (background) */
|
||||
--m-border: 203 213 225; /* Couleur des bordures */
|
||||
--m-text: 15 23 42; /* Couleur du texte */
|
||||
--m-muted: 100 116 139; /* Couleur pour les éléments désactivés ou secondaires */
|
||||
--m-bg: 243 244 248; /* Couleur de fond générale */
|
||||
|
||||
--m-error: 212 0 21; /* rouge pour les erreurs */
|
||||
--m-success: 15 200 73; /* vert pour les succès */
|
||||
}
|
||||
:root {
|
||||
--m-primary: 15 20 40;
|
||||
--m-secondary: 22 30 55;
|
||||
--m-tertiary: 30 41 72;
|
||||
--m-border: 55 70 110;
|
||||
--m-text: 220 225 240;
|
||||
--m-muted: 130 145 175;
|
||||
--m-bg: 10 14 30;
|
||||
--m-error: 255 70 70;
|
||||
--m-success: 0 220 180;
|
||||
--m-accent: 80 140 255;
|
||||
--m-warning: 255 180 50;
|
||||
--m-shadow-opacity: 0.4;
|
||||
--m-glow-opacity: 0.1;
|
||||
--m-card-border-opacity: 0.1;
|
||||
--m-shimmer-from: rgba(80, 140, 255, 0.05);
|
||||
--m-shimmer-to: rgba(80, 140, 255, 0.12);
|
||||
--m-sidebar-from: 15 20 40;
|
||||
--m-sidebar-to: 15 18 35;
|
||||
--m-sidebar-border: rgba(80, 140, 255, 0.08);
|
||||
--m-sidebar-divider: rgba(80, 140, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,87 +1,99 @@
|
||||
<template>
|
||||
<div class="bg-m-secondary w-[250px] h-[259px] rounded-md mx-4 shadow-md/50 shadow-black">
|
||||
<p class="font-bold text-3xl text-m-tertiary my-1 mx-3">
|
||||
Backup
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="bg-m-tertiary w-[200px] h-[32px] rounded-md shadow-md/50 shadow-m-black mx-3 mb-[10px] flex items-center justify-between cursor-pointer"
|
||||
@click="select('bitwarden')"
|
||||
>
|
||||
<p class="font-bold uppercase text-xl ml-[24px]">
|
||||
bitwarden
|
||||
</p>
|
||||
<IconifyIcon
|
||||
icon="mdi:eye"
|
||||
class="text-black text-2xl mr-[24px]"
|
||||
/>
|
||||
</button>
|
||||
<div class="backup-card card-glow">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Backup</h2>
|
||||
<span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Dossiers</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<div class="backup-list">
|
||||
<button
|
||||
v-for="item in folders"
|
||||
:key="item.name"
|
||||
type="button"
|
||||
class="bg-m-tertiary w-[200px] h-[32px] rounded-md shadow-md/50 shadow-m-black mx-3 mb-[10px] flex items-center justify-between cursor-pointer"
|
||||
@click="select('inventory')"
|
||||
>
|
||||
<p class="font-bold uppercase text-xl ml-[24px]">
|
||||
inventory
|
||||
</p>
|
||||
<IconifyIcon
|
||||
icon="mdi:eye"
|
||||
class="text-black text-2xl mr-[24px]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="bg-m-tertiary w-[200px] h-[32px] rounded-md shadow-md/50 shadow-m-black mx-3 mb-[10px] flex items-center justify-between cursor-pointer"
|
||||
@click="select('sirh')"
|
||||
>
|
||||
<p class="font-bold uppercase text-xl ml-[24px]">
|
||||
sirh
|
||||
</p>
|
||||
<IconifyIcon
|
||||
icon="mdi:eye"
|
||||
class="text-black text-2xl mr-[24px]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="bg-m-tertiary w-[200px] h-[32px] rounded-md shadow-md/50 shadow-m-black mx-3 mb-[10px] flex items-center justify-between cursor-pointer"
|
||||
@click="select('ferme')"
|
||||
>
|
||||
<p class="font-bold uppercase text-xl ml-[24px]">
|
||||
ferme
|
||||
</p>
|
||||
<IconifyIcon
|
||||
icon="mdi:eye"
|
||||
class="text-black text-2xl mr-[24px]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="bg-m-tertiary w-[200px] h-[32px] rounded-md shadow-md/50 shadow-m-black mx-3 mb-[10px] flex items-center justify-between cursor-pointer"
|
||||
@click="select('user')"
|
||||
>
|
||||
<p class="font-bold uppercase text-xl ml-[24px]">
|
||||
user
|
||||
</p>
|
||||
<IconifyIcon
|
||||
icon="mdi:eye"
|
||||
class="text-black text-2xl mr-[24px]"
|
||||
/>
|
||||
</button>
|
||||
class="backup-btn"
|
||||
:class="{ 'backup-btn-active': active === item.name }"
|
||||
@click="select(item.name)"
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<IconifyIcon :icon="item.icon" class="text-base text-m-accent" />
|
||||
<span class="font-display text-sm font-semibold uppercase tracking-wide">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</div>
|
||||
<IconifyIcon
|
||||
icon="mdi:chevron-right"
|
||||
class="text-lg text-m-muted transition-transform duration-200"
|
||||
:class="{ 'translate-x-0.5 !text-m-accent': active === item.name }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { Icon as IconifyIcon } from "@iconify/vue"
|
||||
import backupOptions from "~/server/config/backup-options.json"
|
||||
|
||||
const emit = defineEmits(["select"])
|
||||
const active = ref<string | null>(null)
|
||||
const folders = backupOptions as Array<{ name: string; icon: string }>
|
||||
|
||||
const select = (name: string) => {
|
||||
active.value = name
|
||||
emit("select", name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.backup-card {
|
||||
background: rgb(var(--m-secondary));
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
transition: background-color 0.4s ease;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: rgb(var(--m-text));
|
||||
}
|
||||
|
||||
.backup-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.backup-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: 8px;
|
||||
background: rgb(var(--m-tertiary));
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: rgb(var(--m-text));
|
||||
}
|
||||
|
||||
.backup-btn:hover {
|
||||
border-color: rgb(var(--m-accent) / 0.15);
|
||||
background: rgb(var(--m-accent) / 0.06);
|
||||
}
|
||||
|
||||
.backup-btn-active {
|
||||
border-color: rgb(var(--m-accent) / 0.25);
|
||||
background: rgb(var(--m-accent) / 0.08);
|
||||
box-shadow: 0 0 12px -4px rgb(var(--m-accent) / 0.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,44 +1,58 @@
|
||||
<template>
|
||||
<div class="bg-m-secondary w-[507px] h-[367px] rounded-md mx-4 shadow-md/50 shadow-black">
|
||||
<p class="font-bold text-3xl text-m-tertiary my-1 mx-3">
|
||||
{{ title }}
|
||||
</p>
|
||||
<div class="backup-list-card card-glow">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">{{ title }}</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="loading">
|
||||
<div v-if="!folder" class="empty-state">
|
||||
<IconifyIcon icon="mdi:folder-open-outline" class="text-3xl text-m-muted/40" />
|
||||
<p class="mt-2 font-mono text-xs text-m-muted/50">
|
||||
Selectionnez un dossier
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="file-list">
|
||||
<div
|
||||
v-for="n in 6"
|
||||
:key="`backup-skeleton-${n}`"
|
||||
class="relative w-[483px] h-[39px] mx-3 mb-[10px]"
|
||||
class="file-row animate-shimmer"
|
||||
>
|
||||
<ButtonSkeleton custom-class="h-full w-full" />
|
||||
<div class="absolute inset-0 flex items-center justify-between px-3">
|
||||
<TextSkeleton custom-class="h-5 w-[260px]" />
|
||||
<CircleSkeleton custom-class="h-6 w-6 rounded-md" />
|
||||
</div>
|
||||
<TextSkeleton custom-class="h-4 w-48" />
|
||||
<CircleSkeleton custom-class="h-5 w-5 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else
|
||||
<div v-else-if="backups.length === 0" class="empty-state">
|
||||
<IconifyIcon icon="mdi:file-hidden" class="text-3xl text-m-muted/40" />
|
||||
<p class="mt-2 font-mono text-xs text-m-muted/50">
|
||||
Aucun backup trouve
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="file-list">
|
||||
<button
|
||||
v-for="file in backups"
|
||||
:key="file"
|
||||
class="bg-m-tertiary w-[483px] h-[39px] rounded-md shadow-md/50 shadow-m-black mx-3 mb-[10px] flex items-center justify-between cursor-pointer"
|
||||
class="file-row"
|
||||
@click="downloadBackup(file)"
|
||||
>
|
||||
<p class="text-xl ml-3 truncate max-w-[400px]">
|
||||
{{ file }}
|
||||
</p>
|
||||
|
||||
<IconifyIcon
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<IconifyIcon icon="mdi:file-document-outline" class="text-base text-m-accent flex-shrink-0" />
|
||||
<span class="truncate font-mono text-xs text-m-text">
|
||||
{{ file }}
|
||||
</span>
|
||||
</div>
|
||||
<IconifyIcon
|
||||
icon="mdi:download"
|
||||
class="text-black text-2xl mr-3"
|
||||
/>
|
||||
</button>
|
||||
class="text-base text-m-muted flex-shrink-0 transition-colors duration-200"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {Icon as IconifyIcon} from "@iconify/vue"
|
||||
import ButtonSkeleton from "~/components/skeleton/ButtonSkeleton.vue"
|
||||
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
|
||||
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
|
||||
|
||||
@@ -49,8 +63,8 @@ const props = defineProps<{
|
||||
const backups = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const title = computed(() => {
|
||||
if (!props.folder) return "Backup"
|
||||
return `Liste des backup de ${props.folder.toUpperCase()}`
|
||||
if (!props.folder) return "Fichiers"
|
||||
return `Backup — ${props.folder.toUpperCase()}`
|
||||
})
|
||||
|
||||
const downloadBackup = (file: string) => {
|
||||
@@ -78,3 +92,59 @@ watch(() => props.folder, async (folder) => {
|
||||
}
|
||||
})
|
||||
</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>
|
||||
|
||||
@@ -1,35 +1,48 @@
|
||||
<template>
|
||||
<section class="flex flex-col items-center p-4">
|
||||
<template v-if="loading">
|
||||
<TextSkeleton custom-class="h-7 w-40" />
|
||||
<CircleSkeleton custom-class="mt-2 h-[140px] w-[140px]" />
|
||||
<BlockSkeleton custom-class="mt-2 h-5 w-36" />
|
||||
<section class="storage-card">
|
||||
<template v-if="item.loading">
|
||||
<TextSkeleton custom-class="h-5 w-28" />
|
||||
<CircleSkeleton custom-class="mt-3 h-[120px] w-[120px]" />
|
||||
<BlockSkeleton custom-class="mt-3 h-4 w-32" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-center text-xl font-semibold uppercase">{{ hostName }}</p>
|
||||
<div class="relative h-[140px] w-[140px]" :class="statusColorClass">
|
||||
<svg class="h-full w-full -rotate-90" viewBox="0 0 120 120" aria-label="Pourcentage restant">
|
||||
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.2em] text-white/60">
|
||||
{{ item.hostName }}
|
||||
</p>
|
||||
|
||||
<div class="chart-wrapper" :class="item.statusColorClass">
|
||||
<svg class="chart-svg" viewBox="0 0 120 120" aria-label="Pourcentage restant">
|
||||
<circle
|
||||
class="fill-none stroke-[rgba(255,255,255,0.22)] [stroke-width:10]"
|
||||
cx="60"
|
||||
cy="60"
|
||||
:r="chartRadius"
|
||||
class="track"
|
||||
cx="60" cy="60"
|
||||
:r="item.chartRadius"
|
||||
/>
|
||||
<circle
|
||||
class="fill-none stroke-[currentColor] [stroke-linecap:round] [stroke-width:10] transition-[stroke-dashoffset] duration-300"
|
||||
cx="60"
|
||||
cy="60"
|
||||
:r="chartRadius"
|
||||
:style="{ strokeDasharray: `${chartCircumference}`, strokeDashoffset: `${chartOffset}` }"
|
||||
class="progress"
|
||||
cx="60" cy="60"
|
||||
:r="item.chartRadius"
|
||||
:style="{
|
||||
strokeDasharray: `${item.chartCircumference}`,
|
||||
strokeDashoffset: `${item.chartOffset}`
|
||||
}"
|
||||
/>
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<strong class="text-2xl leading-none">{{ remainingPercentText }}</strong>
|
||||
<div class="chart-label">
|
||||
<strong class="font-mono text-2xl font-bold leading-none">
|
||||
{{ item.remainingPercentText }}
|
||||
</strong>
|
||||
<span class="mt-1 font-mono text-[9px] uppercase tracking-widest text-m-muted">
|
||||
libre
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-1 text-center text-sm font-semibold">{{ usedText }} / {{ totalText }}</p>
|
||||
<p class="font-mono text-xs font-medium text-m-muted/80">
|
||||
{{ item.usedText }}
|
||||
<span class="mx-0.5 text-m-muted/40">/</span>
|
||||
{{ item.totalText }}
|
||||
</p>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -40,24 +53,69 @@ import BlockSkeleton from "~/components/skeleton/BlockSkeleton.vue"
|
||||
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
|
||||
|
||||
defineProps<{
|
||||
loading: boolean
|
||||
hostName: string
|
||||
statusColorClass: string
|
||||
chartRadius: number
|
||||
chartCircumference: number
|
||||
chartOffset: number
|
||||
remainingPercentText: string
|
||||
usedText: string
|
||||
totalText: string
|
||||
item: {
|
||||
loading: boolean
|
||||
hostName: string
|
||||
statusColorClass: string
|
||||
chartRadius: number
|
||||
chartCircumference: number
|
||||
chartOffset: number
|
||||
remainingPercentText: string
|
||||
usedText: string
|
||||
totalText: string
|
||||
}
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.m-success {
|
||||
.storage-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.chart-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.track {
|
||||
fill: none;
|
||||
stroke: rgba(255, 255, 255, 0.06);
|
||||
stroke-width: 8;
|
||||
}
|
||||
|
||||
.progress {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.chart-label {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
color: rgb(var(--m-success));
|
||||
}
|
||||
|
||||
.m-error {
|
||||
.status-error {
|
||||
color: rgb(var(--m-error));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,108 @@
|
||||
<script setup>
|
||||
import {Icon as IconifyIcon} from "@iconify/vue"
|
||||
const { data: messages } = await useFetch('/api/discord/messages')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-m-secondary w-auto h-auto mx-4 rounded-md shadow-md/50 shadow-black p-2">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<p class="font-bold text-3xl text-m-tertiary">
|
||||
Speedtest
|
||||
<div class="discord-card card-glow">
|
||||
<div class="card-header">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<IconifyIcon icon="mdi:message-text" class="text-lg text-m-accent" />
|
||||
<h2 class="card-title">Discord</h2>
|
||||
</div>
|
||||
<span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Messages</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!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 v-if="messages">
|
||||
<div v-for="m in messages" :key="m.id">
|
||||
<strong>{{ m.author.username }}</strong>
|
||||
<p>{{ m.content }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else 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>
|
||||
|
||||
@@ -1,103 +1,64 @@
|
||||
<template>
|
||||
<div class="bg-m-secondary w-[507px] h-[184px] mx-4 rounded-md shadow-md/50 shadow-black p-2">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<p class="font-bold text-3xl text-m-tertiary">
|
||||
Speedtest
|
||||
</p>
|
||||
<IconifyIcon
|
||||
<div class="speedtest-card card-glow">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Speedtest</h2>
|
||||
<button
|
||||
class="reload-btn"
|
||||
@click="runTests"
|
||||
:disabled="isTesting"
|
||||
>
|
||||
<IconifyIcon
|
||||
icon="mdi:reload"
|
||||
class="bg-m-tertiary text-2xl text-black rounded-md shadow-md/50 mr-1 cursor-pointer"
|
||||
@click="runTests"
|
||||
/>
|
||||
class="text-lg"
|
||||
:class="{ 'animate-spin': isTesting }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="bg-m-tertiary w-[153px] h-[120px] rounded-md shadow-md/50 shadow-m-black">
|
||||
<div class="flex items-center justify-center">
|
||||
<IconifyIcon
|
||||
icon="mdi:download"
|
||||
class="text-m-primary text-2xl mt-2 ml-1"
|
||||
/>
|
||||
<p class="font-bold uppercase text-xl text-m-text mt-2 mr-1">
|
||||
download
|
||||
</p>
|
||||
</div>
|
||||
<div class="mx-2 flex flex-col items-center justify-center">
|
||||
<template v-if="isTesting">
|
||||
<TextSkeleton custom-class="h-10 w-16 mb-1" />
|
||||
</template>
|
||||
<span v-else class="text-4xl">
|
||||
{{ download !== null ? `${download}` : "--" }}
|
||||
|
||||
<div class="metrics-grid">
|
||||
<div v-for="metric in metrics" :key="metric.label" class="metric-card">
|
||||
<div class="metric-header">
|
||||
<IconifyIcon :icon="metric.icon" class="text-lg text-m-accent" />
|
||||
<span class="font-mono text-[10px] font-medium uppercase tracking-[0.15em] text-m-muted">
|
||||
{{ metric.label }}
|
||||
</span>
|
||||
<p class="font-bold text-xl leading-tight">
|
||||
Mbps
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-m-tertiary w-[153px] h-[120px] rounded-md shadow-md/50 shadow-m-black">
|
||||
<div class="flex items-center justify-center">
|
||||
<IconifyIcon
|
||||
icon="mdi:upload"
|
||||
class="text-m-primary text-2xl mt-2 ml-1"
|
||||
/>
|
||||
<p class="font-bold uppercase text-xl text-m-text mt-2 mr-1">
|
||||
upload
|
||||
</p>
|
||||
</div>
|
||||
<div class="mx-2 flex flex-col items-center justify-center">
|
||||
<div class="metric-value-area">
|
||||
<template v-if="isTesting">
|
||||
<TextSkeleton custom-class="h-10 w-16 mb-1" />
|
||||
<div class="h-10 w-16 animate-shimmer rounded" />
|
||||
</template>
|
||||
<span v-else class="text-4xl">
|
||||
{{ upload !== null ? `${upload}` : "--" }}
|
||||
</span>
|
||||
<p class="font-bold text-xl leading-tight">
|
||||
Mbps
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-m-tertiary w-[153px] h-[120px] rounded-md shadow-md/50 shadow-m-black">
|
||||
<div class="flex items-center justify-center">
|
||||
<IconifyIcon
|
||||
icon="mdi:wifi"
|
||||
class="text-m-primary text-2xl mt-2 ml-1"
|
||||
/>
|
||||
<p class="font-bold uppercase text-xl text-m-text mt-2 mr-1">
|
||||
ping
|
||||
</p>
|
||||
</div>
|
||||
<div class="mx-2 flex flex-col items-center justify-center">
|
||||
<template v-if="isTesting">
|
||||
<TextSkeleton custom-class="h-10 w-16 mb-1" />
|
||||
<template v-else>
|
||||
<span class="metric-value font-mono">
|
||||
{{ metric.value !== null ? metric.value : "--" }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="text-4xl">
|
||||
{{ ping !== null ? `${ping}` : "--" }}
|
||||
</span>
|
||||
<p class="font-bold text-xl leading-tight">
|
||||
Ms
|
||||
</p>
|
||||
<span class="metric-unit font-mono">{{ metric.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from "vue";
|
||||
import {computed, ref} from "vue"
|
||||
import {Icon as IconifyIcon} from "@iconify/vue"
|
||||
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
|
||||
|
||||
const ping = ref<number | null>(null)
|
||||
const download = ref<number | null>(null)
|
||||
const upload = ref<number | null>(null)
|
||||
const isTesting = ref(false)
|
||||
|
||||
const metrics = computed(() => [
|
||||
{ label: "Download", icon: "mdi:arrow-down-bold", value: download.value, unit: "Mbps" },
|
||||
{ label: "Upload", icon: "mdi:arrow-up-bold", value: upload.value, unit: "Mbps" },
|
||||
{ label: "Ping", icon: "mdi:signal", value: ping.value, unit: "ms" },
|
||||
])
|
||||
|
||||
async function testDownload() {
|
||||
const start = performance.now()
|
||||
|
||||
const res = await fetch('/api/download')
|
||||
const blob = await res.blob()
|
||||
|
||||
const end = performance.now()
|
||||
|
||||
const size = blob.size
|
||||
const seconds = (end - start) / 1000
|
||||
download.value = Math.round((size * 8) / seconds / 1000000)
|
||||
@@ -106,28 +67,17 @@ async function testDownload() {
|
||||
async function testUpload() {
|
||||
const size = 5 * 1024 * 1024
|
||||
const data = new Uint8Array(size)
|
||||
|
||||
const start = performance.now()
|
||||
|
||||
await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: data
|
||||
})
|
||||
|
||||
await fetch('/api/upload', { method: 'POST', body: data })
|
||||
const end = performance.now()
|
||||
|
||||
const seconds = (end - start) / 1000
|
||||
upload.value = Math.round((size * 8) / seconds / 1000000)
|
||||
}
|
||||
|
||||
|
||||
async function testPing() {
|
||||
const start = performance.now()
|
||||
|
||||
await fetch('/api/ping')
|
||||
|
||||
const end = performance.now()
|
||||
|
||||
ping.value = Math.round(end - start)
|
||||
}
|
||||
|
||||
@@ -146,3 +96,97 @@ async function runTests() {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.speedtest-card {
|
||||
background: rgb(var(--m-secondary));
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
transition: background-color 0.4s ease;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: rgb(var(--m-text));
|
||||
}
|
||||
|
||||
.reload-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: rgb(var(--m-tertiary));
|
||||
color: rgb(var(--m-accent));
|
||||
border: 1px solid rgb(var(--m-accent) / 0.12);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.reload-btn:hover:not(:disabled) {
|
||||
background: rgb(var(--m-accent) / 0.12);
|
||||
border-color: rgb(var(--m-accent) / 0.25);
|
||||
}
|
||||
|
||||
.reload-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: rgb(var(--m-tertiary));
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgb(var(--m-accent) / 0.06);
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
border-color: rgb(var(--m-accent) / 0.15);
|
||||
}
|
||||
|
||||
.metric-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.metric-value-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: rgb(var(--m-text));
|
||||
}
|
||||
|
||||
.metric-unit {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgb(var(--m-muted));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,49 +1,45 @@
|
||||
<template>
|
||||
<div class="bg-m-secondary w-[250px] h-[292px] rounded-md mx-4 shadow-md/50 shadow-black">
|
||||
<p class="font-bold text-3xl text-m-tertiary my-1 mx-3">
|
||||
Status
|
||||
</p>
|
||||
<div class="status-card card-glow">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Status</h2>
|
||||
<span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Services</span>
|
||||
</div>
|
||||
|
||||
<template v-if="loading">
|
||||
<div
|
||||
v-for="n in 3"
|
||||
:key="`skeleton-${n}`"
|
||||
class="relative w-[200px] h-[68px] rounded-md mx-[25px] mb-3"
|
||||
class="status-row animate-shimmer"
|
||||
>
|
||||
<ButtonSkeleton custom-class="h-full w-full" />
|
||||
<div class="absolute inset-0 p-2">
|
||||
<TextSkeleton custom-class="h-5 w-24 mb-2" />
|
||||
<div class="flex items-center gap-2">
|
||||
<CircleSkeleton custom-class="h-6 w-6" />
|
||||
<TextSkeleton custom-class="h-5 w-20" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<CircleSkeleton custom-class="h-3 w-3" />
|
||||
<TextSkeleton custom-class="h-4 w-20" />
|
||||
</div>
|
||||
<TextSkeleton custom-class="h-4 w-16" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="bg-m-tertiary w-[200px] h-[68px] rounded-md shadow-md/50 shadow-m-black mx-[25px] mb-3"
|
||||
v-for="row in rows"
|
||||
:key="`${row.label}-${row.url}`"
|
||||
class="status-row"
|
||||
:class="row.status === 200 ? 'row-ok' : 'row-error'"
|
||||
>
|
||||
<p class="font-bold text-xl text-m-text mt-2 mx-2 mb-1">
|
||||
{{ row.label }}
|
||||
</p>
|
||||
<div class="mx-2 flex items-center">
|
||||
<span
|
||||
class="inline-block h-[24px] w-[24px] rounded-full mr-2"
|
||||
:class="statusClass(row.status)"
|
||||
/>
|
||||
<span class="font-semibold text-lg">
|
||||
{{ statusLabel(row.status) }}
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="status-dot" :class="row.status === 200 ? 'dot-ok' : 'dot-error'" />
|
||||
<span class="font-display text-sm font-semibold text-m-text">
|
||||
{{ row.label }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-mono text-xs" :class="row.status === 200 ? 'text-m-success' : 'text-m-error'">
|
||||
{{ statusLabel(row.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ButtonSkeleton from "~/components/skeleton/ButtonSkeleton.vue"
|
||||
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
|
||||
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
|
||||
import {onBeforeUnmount, onMounted, ref} from "vue"
|
||||
@@ -77,16 +73,10 @@ const loading = ref(true)
|
||||
const initialized = ref(false)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const statusClass = (status: number) => {
|
||||
if (status === 200) return "bg-m-success"
|
||||
if (status === 0) return "bg-m-error"
|
||||
return "bg-m-error"
|
||||
}
|
||||
|
||||
const statusLabel = (status: number) => {
|
||||
if (status === 200) return "HTTP 200"
|
||||
if (status === 0) return "Injoignable"
|
||||
return `KO (HTTP ${status})`
|
||||
return `KO (${status})`
|
||||
}
|
||||
|
||||
const checkStatus = async () => {
|
||||
@@ -125,3 +115,67 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-card {
|
||||
background: rgb(var(--m-secondary));
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
transition: background-color 0.4s ease;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: rgb(var(--m-text));
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
background: rgb(var(--m-tertiary));
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.row-ok {
|
||||
border-color: rgb(var(--m-success) / 0.08);
|
||||
}
|
||||
|
||||
.row-error {
|
||||
border-color: rgb(var(--m-error) / 0.1);
|
||||
background: rgb(var(--m-error) / 0.04);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot-ok {
|
||||
background: rgb(var(--m-success));
|
||||
box-shadow: 0 0 6px rgb(var(--m-success) / 0.5);
|
||||
}
|
||||
|
||||
.dot-error {
|
||||
background: rgb(var(--m-error));
|
||||
box-shadow: 0 0 6px rgb(var(--m-error) / 0.5);
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,41 +1,223 @@
|
||||
<template>
|
||||
<div class="page-layout">
|
||||
<aside class="sidebar">
|
||||
<div class="flex items-center gap-3 px-14 py-4">
|
||||
<div class="avatar">
|
||||
<div class="h-[155px] w-[155px]">
|
||||
<img
|
||||
:src="logoSrc"
|
||||
alt="Logo Malio"
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="sidebar" />
|
||||
</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 />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { Icon as IconifyIcon } from "@iconify/vue"
|
||||
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
|
||||
|
||||
const isMenuOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
grid-template-columns: 260px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: rgb(var(--m-primary));
|
||||
.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 {
|
||||
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 {
|
||||
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>
|
||||
|
||||
284
pages/index.vue
284
pages/index.vue
@@ -1,35 +1,56 @@
|
||||
<template>
|
||||
<NuxtLayout name="default">
|
||||
<template #sidebar>
|
||||
<div class="flex flex-col">
|
||||
<DiagramStorage
|
||||
v-for="item in diagramItems"
|
||||
:key="item.key"
|
||||
:loading="loading"
|
||||
:host-name="item.hostName"
|
||||
:status-color-class="item.statusColorClass"
|
||||
:chart-radius="chartRadius"
|
||||
:chart-circumference="chartCircumference"
|
||||
:chart-offset="item.chartOffset"
|
||||
:remaining-percent-text="item.remainingPercentText"
|
||||
:used-text="item.usedText"
|
||||
:total-text="item.totalText"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="dashboard-container">
|
||||
<header class="dashboard-header">
|
||||
<div>
|
||||
<h1 class="font-display text-3xl font-bold tracking-tight text-m-text">
|
||||
Monitoring
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="font-bold text-4xl my-6 mx-4">Écran de monitoring</p>
|
||||
<div class="content-grid">
|
||||
<div class="content-main">
|
||||
<section class="storage-section">
|
||||
<div class="storage-section-header">
|
||||
<h2 class="font-display text-xl font-semibold text-m-text">Stockage</h2>
|
||||
<span class="font-mono text-[10px] uppercase tracking-widest text-m-muted">Volumes</span>
|
||||
</div>
|
||||
<div class="storage-grid">
|
||||
<DiagramStorage
|
||||
v-for="(item, idx) in diagramItems"
|
||||
:key="item.key"
|
||||
:item="item"
|
||||
:style="{ animationDelay: `${idx * 150}ms` }"
|
||||
class="animate-fade-in-up"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex">
|
||||
<div class="flex flex-col gap-4">
|
||||
<StatusSite />
|
||||
<BackupButtonSee @select="selectedBackup = $event" />
|
||||
</div>
|
||||
<div class="dashboard-grid">
|
||||
<div class="grid-left">
|
||||
<StatusSite class="animate-fade-in-up" style="animation-delay: 100ms" />
|
||||
<BackupButtonSee
|
||||
class="animate-fade-in-up backup-selector"
|
||||
style="animation-delay: 200ms"
|
||||
@select="selectedBackup = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<Speedtest />
|
||||
<BackupList :folder="selectedBackup" />
|
||||
<MessageDiscord/>
|
||||
<div class="grid-middle">
|
||||
<Speedtest class="animate-fade-in-up speedtest-card-mobile" style="animation-delay: 150ms" />
|
||||
<BackupList
|
||||
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>
|
||||
</NuxtLayout>
|
||||
@@ -39,18 +60,32 @@
|
||||
definePageMeta({layout: false})
|
||||
import {computed, onMounted, ref} from "vue"
|
||||
|
||||
type SourceKey = "remote" | "local"
|
||||
type DiskCommandResult = { ok: boolean; output: string }
|
||||
type DiskSourceResult = {
|
||||
key: string
|
||||
label: string
|
||||
ok: boolean
|
||||
output: string
|
||||
}
|
||||
|
||||
type DiskApiResponse = {
|
||||
remote?: string | DiskCommandResult
|
||||
local?: string | DiskCommandResult
|
||||
results: DiskSourceResult[]
|
||||
}
|
||||
|
||||
type DiagramItem = {
|
||||
key: string
|
||||
loading: boolean
|
||||
hostName: string
|
||||
statusColorClass: string
|
||||
chartRadius: number
|
||||
chartCircumference: number
|
||||
chartOffset: number
|
||||
remainingPercentText: string
|
||||
usedText: string
|
||||
totalText: string
|
||||
}
|
||||
|
||||
const selectedBackup = ref<string | null>(null)
|
||||
const rawResults = ref<Record<SourceKey, string>>({
|
||||
remote: "",
|
||||
local: ""
|
||||
})
|
||||
const rawResults = ref<DiskSourceResult[]>([])
|
||||
const loading = ref(false)
|
||||
const chartRadius = 52
|
||||
const chartCircumference = 2 * Math.PI * chartRadius
|
||||
@@ -93,21 +128,9 @@ const getDiskValues = (output: string) => {
|
||||
return {availableGb, usedGb, totalGb}
|
||||
}
|
||||
|
||||
const getOutputText = (entry: unknown) => {
|
||||
if (typeof entry === "string") return entry
|
||||
if (entry && typeof entry === "object" && "output" in entry) {
|
||||
const output = (entry as DiskCommandResult).output
|
||||
return typeof output === "string" ? output : String(output)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const diagramItems = computed(() => {
|
||||
return [
|
||||
{ key: "remote" as const, fallbackHost: "Serveur distant", output: rawResults.value.remote },
|
||||
{ key: "local" as const, fallbackHost: "Machine locale", output: rawResults.value.local }
|
||||
].map((item) => {
|
||||
const diskValues = getDiskValues(item.output)
|
||||
const diagramItems = computed<DiagramItem[]>(() => {
|
||||
return rawResults.value.map((result) => {
|
||||
const diskValues = getDiskValues(result.output)
|
||||
const remainingPercent =
|
||||
diskValues === null
|
||||
? null
|
||||
@@ -116,15 +139,15 @@ const diagramItems = computed(() => {
|
||||
Math.min(100, Math.round((diskValues.availableGb / diskValues.totalGb) * 100))
|
||||
)
|
||||
|
||||
const chartOffset = chartCircumference - ((remainingPercent ?? 0) / 100) * chartCircumference
|
||||
const statusColorClass =
|
||||
remainingPercent !== null && remainingPercent <= 30 ? "m-error" : "m-success"
|
||||
|
||||
return {
|
||||
key: item.key,
|
||||
hostName: getHostName(item.output, item.fallbackHost),
|
||||
statusColorClass,
|
||||
chartOffset,
|
||||
key: result.key,
|
||||
loading: loading.value,
|
||||
hostName: getHostName(result.output, result.label),
|
||||
statusColorClass:
|
||||
remainingPercent !== null && remainingPercent <= 30 ? "status-error" : "status-success",
|
||||
chartRadius,
|
||||
chartCircumference,
|
||||
chartOffset: chartCircumference - ((remainingPercent ?? 0) / 100) * chartCircumference,
|
||||
remainingPercentText:
|
||||
loading.value ? "..." : remainingPercent === null ? "--%" : `${remainingPercent}%`,
|
||||
usedText: loading.value ? "..." : diskValues ? `${diskValues.usedGb.toFixed(2)} GB` : "--",
|
||||
@@ -133,40 +156,139 @@ const diagramItems = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
const runScript = async () => {
|
||||
loading.value = true
|
||||
rawResults.value = {
|
||||
remote: "",
|
||||
local: ""
|
||||
}
|
||||
rawResults.value = []
|
||||
|
||||
try {
|
||||
const output = await $fetch<DiskApiResponse | string>("/api/disk")
|
||||
|
||||
if (typeof output === "string") {
|
||||
rawResults.value = {
|
||||
remote: output,
|
||||
local: "Erreur: sortie locale indisponible"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
rawResults.value = {
|
||||
remote: getOutputText(output.remote),
|
||||
local: getOutputText(output.local)
|
||||
}
|
||||
const output = await $fetch<DiskApiResponse>("/api/disk")
|
||||
rawResults.value = output.results
|
||||
} catch (error) {
|
||||
const message = `Erreur: ${error instanceof Error ? error.message : String(error)}`
|
||||
rawResults.value = {
|
||||
remote: message,
|
||||
local: message
|
||||
}
|
||||
rawResults.value = [
|
||||
{
|
||||
key: "error",
|
||||
label: "Source indisponible",
|
||||
ok: false,
|
||||
output: message
|
||||
}
|
||||
]
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
runScript()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
padding: 2rem 2.5rem;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid rgba(80, 140, 255, 0.08);
|
||||
}
|
||||
|
||||
.storage-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.storage-section-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.storage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
border-radius: 12px;
|
||||
background: rgb(var(--m-secondary));
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.content-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-aside {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.grid-left,
|
||||
.grid-middle,
|
||||
.grid-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.content-aside {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.dashboard-container {
|
||||
padding: 4.5rem 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.storage-grid,
|
||||
.content-grid,
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.backup-selector {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.backup-list-mobile {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.speedtest-card-mobile {
|
||||
order: 4;
|
||||
}
|
||||
|
||||
.content-aside {
|
||||
grid-column: auto;
|
||||
order: 5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { execFile } from "node:child_process"
|
||||
import folderMap from "../config/backup-folders.json"
|
||||
|
||||
const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST || "malio-b@192.168.0.179"
|
||||
const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups"
|
||||
const MAX_FILES_PER_FOLDER = Number(process.env.BACKUPS_MAX_FILES || "200")
|
||||
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
|
||||
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
|
||||
const FOLDER_MAP: Record<string, string> = {
|
||||
ferme: "bdd_recette/ferme",
|
||||
inventory: "bdd_recette/inventory",
|
||||
sirh: "bdd_recette/sirh",
|
||||
user: "bdd_recette/user",
|
||||
bitwarden: "bitwarden"
|
||||
}
|
||||
const FOLDER_MAP = folderMap as Record<string, string>
|
||||
|
||||
function runSsh(command: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { exec } from "child_process"
|
||||
import diskSources from "../config/disk-commands.json"
|
||||
|
||||
const remoteCommand =
|
||||
process.env.DISK_REMOTE_COMMAND ||
|
||||
"ssh malio-b@192.168.0.179 'cd /home/malio-b/Scripts-Serveur && bash check_storage.sh && exit'"
|
||||
type DiskSource = {
|
||||
key: string
|
||||
label: string
|
||||
command: string
|
||||
}
|
||||
|
||||
const localCommand =
|
||||
process.env.DISK_LOCAL_COMMAND ||
|
||||
"bash /home/kevin/check_storage.sh"
|
||||
function getCommand(source: DiskSource) {
|
||||
const envKey = `DISK_COMMAND_${source.key.toUpperCase()}`
|
||||
const legacyEnvKey =
|
||||
source.key === "remote" ? "DISK_REMOTE_COMMAND" : source.key === "local" ? "DISK_LOCAL_COMMAND" : ""
|
||||
|
||||
return process.env[envKey] || (legacyEnvKey ? process.env[legacyEnvKey] : undefined) || source.command
|
||||
}
|
||||
|
||||
function runCommand(command: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -21,25 +28,26 @@ function runCommand(command: string): Promise<string> {
|
||||
}
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const [remoteResult, localResult] = await Promise.allSettled([
|
||||
runCommand(remoteCommand),
|
||||
runCommand(localCommand)
|
||||
])
|
||||
const results = await Promise.all(
|
||||
(diskSources as DiskSource[]).map(async (source) => {
|
||||
try {
|
||||
const output = await runCommand(getCommand(source))
|
||||
return {
|
||||
key: source.key,
|
||||
label: source.label,
|
||||
ok: true,
|
||||
output
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
key: source.key,
|
||||
label: source.label,
|
||||
ok: false,
|
||||
output: `Erreur: ${String(error)}`
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
remote: {
|
||||
ok: remoteResult.status === "fulfilled",
|
||||
output:
|
||||
remoteResult.status === "fulfilled"
|
||||
? remoteResult.value
|
||||
: `Erreur: ${String(remoteResult.reason)}`
|
||||
},
|
||||
local: {
|
||||
ok: localResult.status === "fulfilled",
|
||||
output:
|
||||
localResult.status === "fulfilled"
|
||||
? localResult.value
|
||||
: `Erreur: ${String(localResult.reason)}`
|
||||
}
|
||||
}
|
||||
return { results }
|
||||
})
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { execFile, spawn } from "node:child_process"
|
||||
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_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups"
|
||||
const FOLDER_MAP: Record<string, string> = {
|
||||
ferme: "bdd_recette/ferme",
|
||||
inventory: "bdd_recette/inventory",
|
||||
sirh: "bdd_recette/sirh",
|
||||
user: "bdd_recette/user",
|
||||
bitwarden: "bitwarden"
|
||||
}
|
||||
const FOLDER_MAP = folderMap as Record<string, string>
|
||||
|
||||
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
|
||||
const isSafeFile = (value: string) => /^[^/\\]+$/.test(value)
|
||||
@@ -20,7 +15,7 @@ function runSsh(command: string): Promise<string> {
|
||||
execFile(
|
||||
"ssh",
|
||||
["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", REMOTE_HOST, command],
|
||||
{ maxBuffer: 5 * 1024 * 1024 },
|
||||
{ maxBuffer: 10 * 1024 * 1024 },
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(stderr || error.message)
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
export default defineEventHandler(async () => {
|
||||
const targets = [
|
||||
{ label: "Ferme", url: "http://ferme.malio-dev.fr/api/version" },
|
||||
{ label: "SIRH", url: "http://sirh.malio-dev.fr/api/version" },
|
||||
{ label: "Inventory", url: "http://inventory.malio-dev.fr/api/health" },
|
||||
]
|
||||
import targets from "../config/version-status-targets.json"
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const results = await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
|
||||
7
server/config/backup-folders.json
Normal file
7
server/config/backup-folders.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"ferme": "bdd_recette/ferme",
|
||||
"inventory": "bdd_recette/inventory",
|
||||
"sirh": "bdd_recette/sirh",
|
||||
"user": "bdd_recette/user",
|
||||
"bitwarden": "bitwarden"
|
||||
}
|
||||
7
server/config/backup-options.json
Normal file
7
server/config/backup-options.json
Normal 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" }
|
||||
]
|
||||
12
server/config/disk-commands.json
Normal file
12
server/config/disk-commands.json
Normal 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"
|
||||
}
|
||||
]
|
||||
5
server/config/version-status-targets.json
Normal file
5
server/config/version-status-targets.json
Normal 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" }
|
||||
]
|
||||
Reference in New Issue
Block a user