Compare commits

..

38 commits

Author SHA1 Message Date
365165c13b fix: RegisterPublicRoute pour pages HTML modules + masquer installés dans store
- RouteEntry.Public bool : routes sans auth (pages HTML navigables par Swup)
- RegisterPublicRoute() ajouté à Registry interface + coreRegistry
- main.go : switch public/auth/admin pour les routes modules
- modules.html : masque les modules déjà installés dans le store

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:44:10 +01:00
3c8a1a6b58 fix: disabled buttons — comparaison stricte === true (proxy Alpine) 2026-03-22 18:35:31 +01:00
18d060461c fix: signature auditLogger.Log dans ResetModule 2026-03-22 18:30:20 +01:00
3bc55a4c6f feat: onglet Réparation dans paramètres — gestion modules fantômes
- GET /api/repair/modules : liste les modules non-core en DB
- DELETE /api/repair/modules/{id} : supprime un module de la DB
- settings.html : onglet Réparation avec liste + bouton Supprimer
- app.js : loadRepair() + resetModule() dans settingsPage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:27:37 +01:00
ab834600ba fix: icône refresh store + bouton Réinstaller pour modules déjà en DB 2026-03-22 18:21:22 +01:00
de4af0ee26 revert: supprimer extra_hosts du code 2026-03-22 18:08:00 +01:00
e55a69e42f fix: extra_hosts git.geronzi.fr→Traefik (NAT loopback Docker/LXC) 2026-03-22 18:06:07 +01:00
bfc630da2e revert: supprimer extra_hosts spécifique au serveur 2026-03-22 17:24:18 +01:00
bc02076d97 fix: extra_hosts git.geronzi.fr→10.0.0.2 pour Docker dans LXC
Le hairpin NAT fonctionne pour le LXC host mais pas pour les containers
Docker (réseau isolé). Même pattern que NextCloud/GlitchTip dans l'infra.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 17:20:53 +01:00
c97a524195 revert: store listing repassé par le backend (/api/registry/modules)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 17:17:14 +01:00
4b083f6fa5 fix: store fetch côté navigateur (backend sans internet)
Le container backend n'a pas accès sortant vers git.geronzi.fr.
loadStore() appelle maintenant directement l'API Forgejo depuis le
navigateur (qui a internet). Le backend n'est plus sollicité que pour
l'installation (POST /api/registry/modules/:id/install).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 17:08:43 +01:00
22a5fed8cc fix: store 502, refresh button, rebuild UX pour modules
- GetRegistryModules: endpoint /api/v1/orgs/{org}/repos + timeout 10s
  répond toujours HTTP 200 avec {modules, error} au lieu de 502
- InstallRegistryModule: essaie branche master puis main pour module.json
- Ajouter FORGEJO_URL / FORGEJO_ORG dans docker-compose.yml
- Frontend: bouton rafraîchir store + affichage erreur
- Frontend: bannière rebuild en cours + bannière rebuild terminé
- Frontend: polling /api/health toutes les 3s après rebuild/restart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 17:03:42 +01:00
a61f805cd0 feat: système de rebuild Docker pour installation de modules has_backend
- internal/docker/client.go : client HTTP brut sur socket Unix
  - BuildImage() : build depuis repo git avec ARG MODULES
  - RebuildAndRestart() : rebuild async + remplacement de container
  - HandleReplacement() : le container successeur arrête et renomme l'ancien
  - Restart() : redémarrage simple (enable/disable sans rebuild)
- cmd/gen-modules/main.go : générateur de registered_modules.go
  Lit MODULES env var, génère imports + appels RegisterModules()
- registered_modules.go : version par défaut (aucun module)
- main.go : appel RegisterModules(loader) + HandleReplacement() au démarrage
- settings.go : inject DockerClient, has_backend dans moduleResp/moduleJSON,
  trigger rebuild à l'install, restart à l'enable/disable
- migrations/006 : colonne has_backend sur table modules
- Dockerfile : ARG MODULES, git clone modules, go run ./cmd/gen-modules
- docker-compose.yml : socket Docker, group_add, env vars CONTAINER_NAME/GIT_REPO/GIT_BRANCH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 16:54:21 +01:00
dcf3b937fa revert: supprimer les dépendances externes viewLogs/viewServices du CORE
Le CORE est autonome — les modules externes ne doivent pas être
des dépendances Go compilées dans le binaire. Retour au Dockerfile
et docker-compose.yml originaux (context: ./backend).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 16:05:11 +01:00
4b1a0a09a8 fix: Dockerfile auto-suffisant — clone viewLogs/viewServices depuis Forgejo
Supprime la dépendance au contexte parent (context: ..).
Le Dockerfile clone viewLogs et viewServices depuis git pendant le build,
dans /workspace/ pour correspondre aux replace directives go.mod (../../).
docker-compose.yml revient à context: ./backend — fonctionne quelle que
soit la structure de répertoires sur le serveur.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 16:01:33 +01:00
cbf87a87fc feat: intégrer viewLogs et viewServices comme dépendances Go compilées dans CORE
- go.mod/go.sum : require + replace pour viewLogs et viewServices (chemins locaux)
- main.go : enregistrement loader.RegisterModule(viewlogs.New()) + viewservices.New()
- Dockerfile : build context parent proxmoxPanel/ pour accéder aux 3 repos
- docker-compose.yml : context: .. + dockerfile: core/backend/Dockerfile
- nginx.conf : locations /viewLogs/ et /viewServices/ proxyfiées vers backend:3001

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 04:35:27 +01:00
ec7d120ef6 refactor: architecture modules indépendants — nettoyage CORE, registry enrichi, page modules dynamique
- Supprimer les modules services et logs du CORE (déplacés dans viewServices et viewLogs)
- Enrichir modules/module.go : interface Registry avec NavItemDef, RunOnTarget, StreamOnTarget
- Réécrire modules/loader.go : NewLoader accepte *db.DB, *sshpool.Pool, *crypto.Encryptor
- Ajouter migration 005 : colonnes nav_* sur la table modules + suppression services/logs DB
- Mettre à jour db.go (repairSchema) pour ajout idempotent des colonnes nav_*
- Mettre à jour settings.go : GetModules retourne les champs nav, ajout GetRegistryModules et InstallRegistryModule
- Mettre à jour main.go : NewLoader avec les bons arguments, ajout routes /api/registry/modules
- Mettre à jour modules.html : section Store avec liste des modules Forgejo
- Mettre à jour app.js : sidebar dynamique (nav_href depuis DB), modulesPage avec store
- Mettre à jour pages.css : styles pour store de modules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 03:34:17 +01:00
91cf788221 refactor: sidebar nav items modules dynamiques selon is_enabled
Les items CORE (dashboard, proxmox, updates, settings, modules) sont
toujours affichés. Les modules optionnels (terminal, files, services,
logs) n'apparaissent dans la sidebar que si leur module est activé
en base de données — conformément à l'instruction.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 02:55:39 +01:00
c9ba6755b8 fix: migration 004 — force enable logs and services modules
INSERT OR IGNORE in migration 003 was silently skipped because
logs/services rows already existed in the DB with is_enabled=0.
Migration 004 uses ON CONFLICT DO UPDATE to ensure is_enabled=1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 02:45:32 +01:00
6666d931c9 fix: use valid LineIcons class names for services and logs nav items
lnid-gear-loading and lnid-scroll-document-1 don't exist in the font.
Replace with lnid-gear-2 (services) and lnid-scroll-angular-1 (logs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 02:34:57 +01:00
5836f2201a feat: add Services and Logs modules (systemctl + journalctl via SSH)
Backend:
- modules/services: list, status, start/stop/restart systemctl services
  with pct exec support for LXC targets
- modules/logs: journalctl unit listing + WebSocket live streaming
  (direct SSH connection, journalctl -f, graceful teardown on WS close)
- migrations/003: seed services and logs modules in DB
- main.go: register services.New() and logs.New() in module loader

Frontend:
- services.html: target selector, search/filter, services table with
  active state indicators and start/stop/restart buttons
- logs.html: target + unit selectors, live follow toggle, scrollable
  terminal output with 3000-line cap
- app.js: servicePage() and logsPage() Alpine components + navItems
- locales: services and logs i18n keys (fr + en)
- pages.css: services table, state dots, logs output styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 02:03:55 +01:00
98cdabf3e1 feat: label session actuelle + fix bouton révoquer
- GetSessions: retourne is_current=true pour la session correspondant au cookie courant
- GetSessions: select token_hash pour la comparaison (non exposé dans le JSON)
- profile.html: badge "Session actuelle" + désactive révoquer pour la session courante
  (utiliser le bouton Déconnexion à la place)
- app.js: revokeSession utilise finally pour reset + isRevoking() helper
- pages.css: styles .badge-current + .session-current

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 01:45:09 +01:00
1cbd7e9d17 fix: GetSessions scan robuste (sql.NullString) + formatDate adaptatif
- Scan des colonnes datetime via sql.NullString au lieu de time.Time pour éviter
  les échecs silencieux dus aux formats mixtes SQLite (CURRENT_TIMESTAMP vs RFC3339)
- Log explicite si un scan échoue (au lieu du continue silencieux)
- formatDate frontend adapte le format SQLite "YYYY-MM-DD HH:MM:SS" en ISO pour new Date()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 01:39:37 +01:00
95757124de fix: corriger bug multi-sessions (upsertUser wrong ID + schema repair + logs refresh)
- auth.go: upsertUser utilise toujours SELECT explicite au lieu de LastInsertId()
  qui retournait un rowid obsolète pour ON CONFLICT DO UPDATE sur ligne existante
- auth.go: vérifier l'erreur de l'INSERT refresh_tokens (était silencieusement ignorée)
- auth.go: logs détaillés dans Refresh handler pour diagnostiquer les 401
- db.go: repairSchema() ajoute les colonnes manquantes (ip, last_used_at) dans les
  bases où migration 002 était partiellement appliquée (ancien bug multi-statements)
- app.js: tryRefresh et fetchMe affichent le vrai message d'erreur du backend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 01:32:01 +01:00
dc0c67b89c fix: multi-sessions + système de toasts pour erreurs visibles
Sessions:
- Cookie pxp_refresh path: /api/auth/refresh → /api/auth/ pour être envoyé au logout
- Logout supprime uniquement le token de la session courante (via cookie hash)
  si pas de cookie = fallback suppression globale (rétro-compat)

Toasts:
- Store Alpine.store('toasts') avec error/success/warn/info + auto-dismiss
- Conteneur #pxp-toasts injecté dans <body>, persiste à travers les navigations Swup
- fetchMe(): HTTP non-401 → toast explicite (ex: HTTP 404 backend down)
- tryRefresh(): session expirée → sessionStorage → toast orange sur la page login
- logout(): message "Déconnexion réussie" sur la page login
- proxmoxPage.action(): toast succès/erreur pour start/stop LXC
- profilePage.revokeSession(): toast confirmation révocation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 01:04:37 +01:00
21e1e0ed1e feat: sync DB prefs, update history tab, configurable dashboard shortcuts
- auth store fetchMe(): sync theme/sidebar_position/lang from DB to localStorage+stores on login/refresh
- profilePage setters: PATCH /api/auth/preferences on every preference change
- updatesPage: add history tab (GET /api/updates/history) with job list, click to view output
- dashboardPage: load shortcuts from settings API, fall back to defaults if none configured
- settingsPage: new Raccourcis tab to add/remove/configure dashboard shortcuts (saved as JSON)
- settings.go: expose dashboard_shortcuts in publicSettings for GET/PUT access
- pages.css: add .history-table, .shortcut-row styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:35:24 +01:00
780e5ec81d fix: auth redirect bug + cookie Secure + migration multi-statements
- fetchMe: handle ALL non-ok responses (not just 401) by calling tryRefresh
  → avoids user=null when backend returns 404/500/any error
- DOMContentLoaded guard: check isAuthenticated instead of localStorage token
  → immediate redirect if fetchMe+tryRefresh both fail, no more flash of dashboard
- Cookie Secure flag: check X-Forwarded-Proto header for Traefik/proxy setup
  → cookie gets Secure=true when behind TLS-terminating reverse proxy
- db.go migrate(): split SQL by ; and exec each statement separately
  → fixes SQLite multi-statement limitation (only first stmt was executed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:29:22 +01:00
97212b7ffa feat: sessions management, web manifest, square icon-only buttons, remove lang select
- Backend: migration 002 adds user_agent/ip/last_used_at to refresh_tokens
- Backend: GET /api/auth/sessions + DELETE /api/auth/sessions/{id} endpoints
- Frontend: profile page — sessions section (browser, IP, datetime, revoke)
- Frontend: web manifest + SVG icon for PWA support
- Frontend: remove language selector from all navbars (moved to profile page)
- Frontend: neu-btn--icon-sm class for square icon-only buttons (theme/logout/edit)
- Frontend: manifest link added to all 9 HTML pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 20:14:11 +01:00
b851dc61af fix: couleurs icônes, boutons carrés, sidebar collapsée, langue, SW scope, LXC arrêté
Icônes :
- Sidebar navItems : couleur distincte par section (iconStyle via data-binding)
- Sidebar footer user : couleur primary
- Navbar : logout → danger, soleil → amber, lune → blue
- Panel widgets : œil visible → success, caché → muted

Boutons :
- `.neu-btn--sm:has(> i:only-child)` → carré 2rem×2rem automatiquement
  (theme, logout, mode édition) sans modifier le HTML

Sidebar :
- --sidebar-width-collapsed : 64px → 52px
- Sidebar réduite : icônes centrées (justify-content center)

Langue :
- Setter vide sur `lang` dans navbar() pour corriger x-model avec getter
  (le @change gère la vraie mise à jour du store)

Service Worker :
- Enregistrement depuis /ws.sw.js (scope /) au lieu de /js/ws.sw.js (scope /js/)
- build.mjs : copie ws.sw.js vers dist/ root en plus de dist/js/

LXC arrêté :
- checkTarget() : skip si target.status !== 'running' → évite les 502 SSH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 19:50:58 +01:00
7c57b0ff84 feat(dashboard): resize souris, DnD live, panel widgets, icônes corrigées
- Resize widget via drag sur le coin bas-droit (mousedown → mousemove →
  snap à taille 1 ou 2 colonnes selon distance)
- DnD live : les autres widgets se déplacent pendant le drag (onDragEnter
  réordonne le tableau + onDragEnd restaure si annulé)
- Mode édition : panel latéral avec tous les widgets (œil toggle visible/masqué),
  survol d'une entrée met en avant (outline) le widget correspondant dans la grille
- Bouton mode édition : icône seule (lnid-pencil-1)
- Correction noms d'icônes LineIcons (lnid-pencil-1, lnid-eye, lnid-eye-closed,
  lnid-cross → supprimé en faveur de toggles dans le panel)
- Suppression des classes CSS obsolètes (edit-mode-banner, widget-add-btn, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 19:30:35 +01:00
cbfb20505d feat(frontend): Service Worker WS, mode édition dashboard, sidebar click-to-toggle
- WebSocket via Service Worker (ws.sw.js) : connexions persistantes entre navigations
  Swup, reconnexion exponentielle, protocole WS_SUBSCRIBE/WS_UNSUBSCRIBE/WS_SEND
- WsProxy dans app.js : abstraction SW + fallback WebSocket direct
- proxmoxPage migré vers WsProxy (identique au dashboardPage)
- Dashboard : mode édition toggle — DnD, resize (x1/x2), masquer/afficher widget
  uniquement actifs en mode édition ; preview drag (is-dragging/drag-over)
- Sidebar : suppression bouton hamburger, clic sur sidebar-header pour replier
- pages.css : targets-grid 350px, styles edit mode, widget-size-2, drag preview
- neu.css : sidebar-header cursor pointer, suppression .sidebar-toggle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 19:09:58 +01:00
b6d6355c6c fix: correction sélecteur CSS LineIcons Duotone (position absolute sur éléments)
Le CSS vendor avait un bug de sélecteur : [class^="lnid-"] sans pseudo-élément
recevait position:absolute + width/height:100%, faisant que les icônes
recouvraient tout l'écran. Correction : sélecteurs ::before et ::after explicites.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:49:56 +01:00
5f6681dd17 feat: LineIcons Duotone, page profil, widgets dashboard, sidebar gauche/droite
- Intégration LineIcons Duotone (css/ + toutes les pages)
- Remplacement de tous les symboles Unicode par des icônes lnid-*
- Page profile.html : préférences thème, position sidebar, langue
- Dashboard : système de widgets add/remove + drag-and-drop natif
- Sidebar gauche/droite configurable per-user (data-sidebar CSS + FOUC script)
- Store ui : sidebarPosition, applySidebarPosition(), setSidebarPosition()
- Composant profilePage() dans app.js
- nav.profile ajouté dans fr.json et en.json
- SUIVI.md mis à jour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:38:48 +01:00
9739dbaee8 Correction CSS Swup, types WS et création fichier de suivi
- Extraction de tous les styles inline en css/pages.css (chargé globalement)
  pour corriger le CSS cassé lors des navigations Swup
- Correction types WebSocket : proxmox_resources → resources_update
  et msg.data → msg.payload (format réel du hub Go)
- Ajout d'un fetch HTTP immédiat dans dashboardPage/proxmoxPage
  pour éviter l'attente du premier tick (10s) du polling WS
- Correction msg.payload pour les updates (update_output/done/error)
- Ajout class terminal-wrapper sur .main-layout de terminal.html
  pour le fullscreen height sans affecter les autres pages
- Création SUIVI.md : état d'implémentation vs instruction.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 17:49:33 +01:00
a4b5b06f04 fix: CSS reset, settings API, modules champs, proxmox token
- CSS: ajout reset (box-sizing, margin, font-family, body background)
- Settings: save par PUT /api/settings/{key} (pas bulk), un appel par clé
- Settings: proxmox_token champ unique (format user@realm!id=secret)
- Modules: is_enabled/is_core (champs backend réels, pas enabled/core)
- Proxmox: supprime bouton reboot (route inexistante)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 17:28:55 +01:00
65c8bf332f fix: access_token (pas token) dans la réponse login/refresh
Le backend retourne { access_token: "...", user: {...} } pas { token: "..." }.
Le store Alpine lisait data.token → undefined → stockait "undefined" en localStorage
→ toutes les requêtes API échouaient avec 401.

Corrigé dans login() et tryRefresh().
Ajout d'un guard synchrone immédiat (pas de token → redirect login sans attendre fetchMe).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 16:50:52 +01:00
562eff8863 fix: CSS variables neu-*, WebSocket token, thème initial
- CSS: remplace var(--bg-*)/var(--text-*)/var(--accent-*)/var(--color-*)
  par les vraies variables --neu-* (neu-bg, neu-surface, neu-text, neu-primary…)
- CSS: supprime body{overflow:hidden} qui bloquait le scroll
- CSS: .auth-layout déplacé dans neu.css pour login/install
- WS: ajoute ?token= aux connexions /ws/proxmox (dashboardPage + proxmoxPage)
- HTML: script inline pour appliquer data-theme avant Alpine (évite FOUC)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 16:39:23 +01:00
2098c80ec1 feat: réécriture frontend Alpine.js + HTMX + Swup (branche frontend/alpine)
Remplace Vue 3 / Vite / TypeScript par une stack légère statique :
- Alpine.js v3 : réactivité inline, stores auth/ui/i18n, composants par page
- HTMX v2 : interactions serveur via attributs HTML
- Swup v4 : transitions de page (bundlé via esbuild, IIFE browser-loadable)
- xterm.js v5 : terminal PTY (bundlé via esbuild)

Structure : HTML statiques + js/app.js + js/terminal.js + css/ + locales/
Build : esbuild (bundle Swup + xterm seulement) → dist/ → Nginx
Dockerfile simplifié : node:22-alpine build → nginx:1.27-alpine serve

Pages : index, install, login, dashboard, proxmox, updates, terminal, settings, modules
URLs propres via nginx try_files $uri.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 16:19:24 +01:00
76 changed files with 17090 additions and 5520 deletions

149
SUIVI.md Normal file
View file

@ -0,0 +1,149 @@
# Suivi d'implémentation — ProxmoxPanel CORE
Référence : `instruction.md` | Mis à jour : 2026-03-21
---
## Stack technique actuelle
| Composant | Choix | Statut |
|-----------|-------|--------|
| Backend | Go 1.23 + chi v5 + gorilla/websocket | ✅ Implémenté |
| Base de données | SQLite via modernc.org/sqlite (sans CGO) | ✅ Implémenté |
| Auth | PAM via SSH + JWT RS256 | ✅ Implémenté |
| Chiffrement | AES-256-GCM (master.key) | ✅ Implémenté |
| Frontend | Alpine.js v3 + HTMX v2 + Swup v4 | ✅ Branche `frontend/alpine` |
| Icônes | **LineIcons Duotone** | ⚠️ Non intégré (symboles Unicode utilisés) |
| Terminal | xterm.js v5 + addon-fit + WS PTY | ✅ Implémenté |
| Build frontend | esbuild (bundler Swup ESM → IIFE) | ✅ Implémenté |
| Serveur statique | Nginx 1.27 | ✅ Dockerfile simplifié |
---
## Fonctionnalités CORE — État d'avancement
### 1. Page d'installation ✅
- Wizard 4 étapes (Alpine.js)
- Pré-remplissage URL automatique depuis `window.location.origin`
- Test SSH (fetch POST /api/install/test-ssh)
- Configuration Proxmox API (token unique format `user@realm!tokenid=secret`)
- **Manque** : pré-remplissage du port depuis la requête entrante
### 2. Gestion des comptes utilisateurs ✅
- Création automatique au premier login (PAM SSH → upsert SQLite)
- JWT access (15min) + refresh cookie httpOnly (7j)
- Deux niveaux : Admin (groupe sudo/wheel) et Utilisateur
- **Manque** : page profil utilisateur, préférences per-user en DB
### 3. Détection LXC/VM ✅
- API Proxmox REST (client Go, InsecureSkipVerify)
- WS `/ws/proxmox` — polling 10s, type `resources_update`, payload `[...]`
- Affichage en temps réel dans les pages Dashboard et Proxmox
- **Fix appliqué** : données immédiates via HTTP + WS pour live updates
### 4. Dashboard ⚠️ Partiel
- Compteurs LXC running/stopped ✅
- Liste LXC avec CPU/RAM ✅
- WebSocket live ✅
- **Manque** : widgets configurables (add/remove/drag-drop) ← requis par instruction.md
- **Manque** : raccourcis vers services (Grafana, Proxmox, Traefik…)
- **Manque** : personnalisation per-user sauvegardée en DB
### 5. Thème Neumorphism ✅
- neu.css (dark/light mode via CSS custom properties) ✅
- Toggle dark/light dans la navbar ✅
- Sidebar repliable ✅
- Responsive (media queries) ✅
- **Manque** : sidebar gauche/droite per-user ← requis par instruction.md
- **Manque** : LineIcons Duotone (actuellement Unicode symbols) ← requis par instruction.md
### 6. Gestion des langues ✅
- fr.json / en.json via Alpine store i18n ✅
- Sélecteur dans la navbar ✅
- Sauvegarde dans localStorage ✅
- **Manque** : sauvegarde per-user en DB
### 7. Mises à jour paquets ✅
- Liste par cible (host + LXC) ✅
- `apt update + apt full-upgrade -y` via SSH ✅
- Streaming WS live (type `update_output` / `update_done` / `update_error`) ✅
- **Fix appliqué** : `msg.payload` (pas `msg.data`)
- **Manque** : historique des mises à jour
### 8. Système de paramètres ✅
- Onglets Général / SSH / Proxmox API ✅
- `PUT /api/settings/{key}` per-key ✅
- Secrets chiffrés AES-256-GCM en DB ✅
- **Manque** : onglet Apparence (sidebar position, thème par défaut)
- **Manque** : onglet Langues disponibles
### 9. Mises à jour CORE/modules ❌ Non implémenté
- Vérification nouvelles versions Forgejo
- Affichage changelog
- Détection migrations post-update
### 10. Store de modules ⚠️ Partiel
- Liste des modules (CORE + optionnels stubs) ✅
- Toggle enable/disable ✅
- **Manque** : installation/désinstallation de modules externes
- **Manque** : registre officiel + registres supplémentaires
### 11. Page post-installation/migration ❌ Non implémenté
- Blocage total de l'accès si migration en attente
---
## Bugs résolus dans cette session
| Bug | Fix |
|-----|-----|
| CSS cassé au changement de page via Swup | Extraction de tous les styles inline en `css/pages.css` global |
| Dashboard/Proxmox WS bloqué à "⌛ Connexion…" | Type WS était `proxmox_resources` → corrigé en `resources_update` |
| WS lit `msg.data` au lieu de `msg.payload` | Corrigé pour dashboard, proxmox, updates |
| Délai 10s avant 1er affichage données | Ajout fetch HTTP immédiat + WS pour updates live |
| `access_token` null au login | Corrigé en session précédente |
| CSS variables incorrectes (`--bg-primary`) | Corrigé en session précédente |
---
## Non-conformités instruction.md à corriger
| Règle | État | Priorité |
|-------|------|----------|
| Icônes LineIcons Duotone uniquement | ✅ Intégrés (css/ + toutes les pages) | Haute |
| Dashboard widgets add/remove/drag-drop | ✅ Widget system + DnD natif HTML5 | Haute |
| Sidebar gauche/droite per-user | ✅ `data-sidebar` CSS + profilePage | Moyenne |
| Page profil préférences (thème/sidebar/langue) | ✅ profile.html créée | Moyenne |
| Préférences utilisateur en DB | ❌ localStorage uniquement | Basse |
| Historique mises à jour | ❌ Non affiché | Basse |
| Mises à jour CORE/modules depuis interface | ❌ Non implémenté | Basse |
| Page blocage migration | ❌ Non implémenté | Basse |
---
## Architecture Docker
```
core/
├── backend/ (Go — port 3001 interne)
├── frontend/ (Alpine.js — build → Nginx)
│ ├── js/app.js (stores + composants Alpine)
│ ├── css/neu.css (neumorphism + layout)
│ ├── css/pages.css (styles spécifiques pages)
│ └── *.html (pages statiques)
└── docker-compose.yml
```
## Commandes utiles
```bash
# Build frontend (sur LXC ou machine dev)
cd core/frontend && npm run build
# Déploiement sur LXC 112
docker compose pull && docker compose up -d --build
# Logs
docker compose logs -f backend
docker compose logs -f frontend
```

View file

@ -1,18 +1,36 @@
# ── Étape 1 : Build du binaire Go ──────────────────────────────────────────
# Build context = core/backend/ (context: ./backend dans docker-compose.yml)
# ARG MODULES : IDs des modules à compiler, séparés par des virgules (ex: "viewLogs,viewServices")
FROM golang:1.26-alpine AS builder
# Dépendances de compilation (git pour les modules Go)
RUN apk add --no-cache git
RUN apk add --no-cache git ca-certificates
WORKDIR /build
ARG MODULES=""
ENV MODULES=${MODULES}
# Copier les fichiers de dépendances en premier (optimise le cache Docker)
WORKDIR /workspace/core/backend
# Copier les sources du CORE (build context = backend/)
COPY go.mod go.sum ./
RUN go mod download
# Copier tout le code source
COPY . .
# Cloner les modules demandés et les ajouter au go.mod.
# Les replace directives utilisent ../../{module} ce qui correspond à /workspace/{module} ✓
RUN if [ -n "$MODULES" ]; then \
for mod in $(echo "$MODULES" | tr ',' ' '); do \
echo "→ Clonage du module $mod..." && \
git clone "https://git.geronzi.fr/proxmoxPanel/$mod" "/workspace/$mod" && \
printf "\nrequire git.geronzi.fr/proxmoxPanel/$mod v0.0.0\n" >> go.mod && \
printf "\nreplace git.geronzi.fr/proxmoxPanel/$mod => ../../$mod\n" >> go.mod; \
done; \
fi
# Générer registered_modules.go avec les imports et appels RegisterModule corrects
RUN go run ./cmd/gen-modules
# Résoudre et télécharger toutes les dépendances (modules inclus)
RUN go mod tidy && go mod download
# Compiler le binaire de façon statique
# -ldflags="-s -w" : supprime les infos de debug pour réduire la taille
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
@ -21,26 +39,21 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
# ── Étape 2 : Image finale minimale ────────────────────────────────────────
FROM alpine:3.20
# Certificats CA pour les requêtes HTTPS vers l'API Proxmox
RUN apk add --no-cache ca-certificates tzdata
# Créer un utilisateur non-root pour la sécurité
# Utilisateur non-root pour la sécurité
RUN addgroup -g 1001 pxp && adduser -u 1001 -G pxp -s /bin/sh -D pxp
WORKDIR /app
# Copier le binaire compilé
COPY --from=builder /bin/proxmoxpanel /app/proxmoxpanel
# Créer les répertoires de données avec les bonnes permissions
RUN mkdir -p /app/data && chown -R pxp:pxp /app
USER pxp
# Port d'écoute du backend
EXPOSE 3001
# Variables d'environnement par défaut
ENV DATA_DIR=/app/data \
LISTEN_ADDR=:3001 \
APP_ENV=production

View file

@ -0,0 +1,104 @@
// cmd/gen-modules génère registered_modules.go à partir de la variable d'env MODULES.
// Appelé pendant le build Docker : go run ./cmd/gen-modules
// MODULES = liste d'IDs séparés par des virgules (ex: "viewLogs,viewServices")
// Le fichier généré déclare RegisterModules(*modules.Loader) qui enregistre chaque module.
package main
import (
"fmt"
"os"
"strings"
"text/template"
)
const outputFile = "registered_modules.go"
// moduleDef décrit un module à importer.
type moduleDef struct {
ID string // ex: "viewLogs"
Alias string // ex: "viewlogs" (alias Go valide)
Pkg string // ex: "git.geronzi.fr/proxmoxPanel/viewLogs"
}
// tplNoModules : fichier généré quand aucun module n'est installé.
const tplNoModules = `// Code généré automatiquement par cmd/gen-modules — ne pas modifier manuellement.
// Régénéré lors du build Docker avec la liste des modules compilés.
package main
import "git.geronzi.fr/proxmoxPanel/core/backend/modules"
// RegisterModules enregistre les modules compilés dans le binaire.
func RegisterModules(l *modules.Loader) {}
`
// tplWithModules : fichier généré avec des modules.
const tplWithModules = `// Code généré automatiquement par cmd/gen-modules — ne pas modifier manuellement.
// Régénéré lors du build Docker avec la liste des modules compilés.
package main
import (
"git.geronzi.fr/proxmoxPanel/core/backend/modules"
{{- range .}}
{{.Alias}} "{{.Pkg}}"
{{- end}}
)
// RegisterModules enregistre les modules compilés dans le binaire.
func RegisterModules(l *modules.Loader) {
{{- range .}}
l.RegisterModule({{.Alias}}.New())
{{- end}}
}
`
func main() {
modulesEnv := strings.TrimSpace(os.Getenv("MODULES"))
// Pas de modules : générer la version vide
if modulesEnv == "" {
if err := os.WriteFile(outputFile, []byte(tplNoModules), 0644); err != nil {
fmt.Fprintf(os.Stderr, "Erreur écriture %s : %v\n", outputFile, err)
os.Exit(1)
}
fmt.Printf("Généré %s (aucun module)\n", outputFile)
return
}
// Parser la liste des modules
ids := strings.Split(modulesEnv, ",")
defs := make([]moduleDef, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
// Alias Go = ID en minuscules sans tirets ni underscores
alias := strings.ToLower(strings.NewReplacer("-", "", "_", "").Replace(id))
defs = append(defs, moduleDef{
ID: id,
Alias: alias,
Pkg: "git.geronzi.fr/proxmoxPanel/" + id,
})
}
// Générer le fichier avec les imports et les appels RegisterModule
tmpl, err := template.New("modules").Parse(tplWithModules)
if err != nil {
fmt.Fprintf(os.Stderr, "Erreur template : %v\n", err)
os.Exit(1)
}
f, err := os.Create(outputFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Erreur création %s : %v\n", outputFile, err)
os.Exit(1)
}
defer f.Close()
if err := tmpl.Execute(f, defs); err != nil {
fmt.Fprintf(os.Stderr, "Erreur génération : %v\n", err)
os.Exit(1)
}
fmt.Printf("Généré %s avec %d module(s) : %s\n", outputFile, len(defs), modulesEnv)
}

View file

@ -5,6 +5,7 @@ import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"log"
"net/http"
"time"
@ -12,6 +13,7 @@ import (
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/auth"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
"github.com/go-chi/chi/v5"
)
// AuthHandler contient les handlers d'authentification.
@ -106,20 +108,28 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
// Stocker le hash du refresh token en base pour permettre la révocation
tokenHash := hashToken(refreshToken)
expiry := time.Now().Add(auth.RefreshTokenDuration())
h.db.Exec(`
INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)
`, userID, tokenHash, expiry)
if _, err := h.db.Exec(`
INSERT INTO refresh_tokens (user_id, token_hash, expires_at, user_agent, ip, last_used_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`, userID, tokenHash, expiry, r.UserAgent(), clientIP(r)); err != nil {
log.Printf("[auth/login] ERREUR stockage refresh token — user=%s userID=%d err=%v", body.Username, userID, err)
JSONError(w, "Erreur création session", http.StatusInternalServerError)
return
}
// Mettre à jour la date de dernier login
h.db.Exec(`UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = ?`, userID)
// Cookie httpOnly pour le refresh token
// Secure=true si TLS direct ou si derrière un proxy (Traefik) qui a terminé TLS
isHTTPS := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
// Path élargi à /api/auth/ pour que le cookie soit envoyé au logout aussi
http.SetCookie(w, &http.Cookie{
Name: "pxp_refresh",
Value: refreshToken,
Path: "/api/auth/refresh",
Path: "/api/auth/",
HttpOnly: true,
Secure: r.TLS != nil,
Secure: isHTTPS,
SameSite: http.SameSiteStrictMode,
Expires: expiry,
})
@ -137,14 +147,22 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
})
}
// Logout invalide la session de l'utilisateur.
// Logout invalide la session courante de l'utilisateur.
// POST /api/auth/logout
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r)
// Supprimer tous les refresh tokens de cet utilisateur
if claims != nil {
// Supprimer uniquement le token de CETTE session (via cookie pxp_refresh)
// Le cookie a Path=/api/auth/ donc il est bien envoyé sur ce endpoint.
if cookie, err := r.Cookie("pxp_refresh"); err == nil {
tokenHash := hashToken(cookie.Value)
h.db.Exec(`DELETE FROM refresh_tokens WHERE token_hash = ? AND user_id = ?`,
tokenHash, claims.UserID)
} else {
// Pas de cookie (session dégradée ou ancien cookie path) → supprimer toutes les sessions
h.db.Exec(`DELETE FROM refresh_tokens WHERE user_id = ?`, claims.UserID)
}
h.auditLogger.Log(&claims.UserID, claims.Username, "logout", "", nil, clientIP(r))
}
@ -152,7 +170,7 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "pxp_refresh",
Value: "",
Path: "/api/auth/refresh",
Path: "/api/auth/",
HttpOnly: true,
Expires: time.Unix(0, 0),
MaxAge: -1,
@ -164,14 +182,18 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
// Refresh renouvelle l'access token via le refresh token (cookie httpOnly).
// POST /api/auth/refresh
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
cookie, err := r.Cookie("pxp_refresh")
if err != nil {
log.Printf("[auth/refresh] cookie absent — ip=%s err=%v", ip, err)
JSONError(w, "Refresh token manquant", http.StatusUnauthorized)
return
}
userID, err := h.jwtManager.ValidateRefreshToken(cookie.Value)
if err != nil {
log.Printf("[auth/refresh] JWT invalide — ip=%s err=%v", ip, err)
JSONError(w, "Refresh token invalide ou expiré", http.StatusUnauthorized)
return
}
@ -181,15 +203,26 @@ func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
var count int
h.db.QueryRow(`SELECT COUNT(*) FROM refresh_tokens WHERE user_id = ? AND token_hash = ? AND expires_at > CURRENT_TIMESTAMP`, userID, tokenHash).Scan(&count)
if count == 0 {
// Diagnostic : vérifier si le token existe mais avec un mauvais user_id
var anyCount int
h.db.QueryRow(`SELECT COUNT(*) FROM refresh_tokens WHERE token_hash = ?`, tokenHash).Scan(&anyCount)
log.Printf("[auth/refresh] token non trouvé en base — userID=%d tokenHash=%s anyMatch=%d ip=%s",
userID, tokenHash[:8], anyCount, ip)
JSONError(w, "Session expirée ou révoquée", http.StatusUnauthorized)
return
}
log.Printf("[auth/refresh] token valide — userID=%d ip=%s", userID, ip)
// Mettre à jour la date de dernière utilisation
h.db.Exec(`UPDATE refresh_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?`, tokenHash)
// Récupérer les infos utilisateur
var username string
var isAdmin int
err = h.db.QueryRow(`SELECT username, is_admin FROM users WHERE id = ?`, userID).Scan(&username, &isAdmin)
if err != nil {
log.Printf("[auth/refresh] utilisateur introuvable — userID=%d ip=%s err=%v", userID, ip, err)
JSONError(w, "Utilisateur introuvable", http.StatusUnauthorized)
return
}
@ -288,21 +321,104 @@ func (h *AuthHandler) upsertUser(info *auth.UserInfo) (int64, error) {
}
// Mise à jour du statut admin à chaque connexion (peut changer côté Linux)
result, err := h.db.Exec(`
if _, err := h.db.Exec(`
INSERT INTO users (username, is_admin) VALUES (?, ?)
ON CONFLICT(username) DO UPDATE SET is_admin = excluded.is_admin
`, info.Username, isAdmin)
if err != nil {
`, info.Username, isAdmin); err != nil {
return 0, err
}
// Tenter de récupérer l'ID (insertions ou update)
id, err := result.LastInsertId()
if err != nil || id == 0 {
// En cas de ON CONFLICT DO UPDATE, LastInsertId peut retourner 0
err = h.db.QueryRow(`SELECT id FROM users WHERE username = ?`, info.Username).Scan(&id)
// Toujours faire un SELECT explicite : avec ON CONFLICT DO UPDATE sur une ligne
// existante, LastInsertId() peut retourner un rowid obsolète (comportement SQLite).
var id int64
if err := h.db.QueryRow(`SELECT id FROM users WHERE username = ?`, info.Username).Scan(&id); err != nil {
return 0, fmt.Errorf("utilisateur introuvable après upsert: %w", err)
}
return id, err
return id, nil
}
// GetSessions retourne les sessions actives de l'utilisateur connecté.
// GET /api/auth/sessions
func (h *AuthHandler) GetSessions(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r)
if claims == nil {
JSONError(w, "Non authentifié", http.StatusUnauthorized)
return
}
rows, err := h.db.Query(`
SELECT id, user_agent, ip, created_at, last_used_at, expires_at, token_hash
FROM refresh_tokens
WHERE user_id = ? AND expires_at > CURRENT_TIMESTAMP
ORDER BY COALESCE(last_used_at, created_at) DESC
`, claims.UserID)
if err != nil {
JSONError(w, "Erreur récupération sessions", http.StatusInternalServerError)
return
}
defer rows.Close()
// Hash du cookie courant pour marquer "session actuelle"
currentHash := ""
if cookie, err := r.Cookie("pxp_refresh"); err == nil {
currentHash = hashToken(cookie.Value)
}
type Session struct {
ID int64 `json:"id"`
UserAgent string `json:"user_agent"`
IP string `json:"ip"`
CreatedAt string `json:"created_at"`
LastUsedAt *string `json:"last_used_at"`
ExpiresAt string `json:"expires_at"`
IsCurrent bool `json:"is_current"`
}
sessions := []Session{}
for rows.Next() {
var s Session
var tokenHash string
var createdAt, expiresAt sql.NullString
var lastUsedAt sql.NullString
if err := rows.Scan(&s.ID, &s.UserAgent, &s.IP, &createdAt, &lastUsedAt, &expiresAt, &tokenHash); err != nil {
log.Printf("[GetSessions] scan error userID=%d: %v", claims.UserID, err)
continue
}
s.CreatedAt = createdAt.String
s.ExpiresAt = expiresAt.String
if lastUsedAt.Valid && lastUsedAt.String != "" {
s.LastUsedAt = &lastUsedAt.String
}
s.IsCurrent = currentHash != "" && tokenHash == currentHash
sessions = append(sessions, s)
}
JSONResponse(w, http.StatusOK, sessions)
}
// RevokeSession révoque une session (refresh token) de l'utilisateur connecté.
// DELETE /api/auth/sessions/{id}
func (h *AuthHandler) RevokeSession(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r)
if claims == nil {
JSONError(w, "Non authentifié", http.StatusUnauthorized)
return
}
sessionID := chi.URLParam(r, "id")
res, err := h.db.Exec(`DELETE FROM refresh_tokens WHERE id = ? AND user_id = ?`, sessionID, claims.UserID)
if err != nil {
JSONError(w, "Erreur révocation session", http.StatusInternalServerError)
return
}
n, _ := res.RowsAffected()
if n == 0 {
JSONError(w, "Session introuvable", http.StatusNotFound)
return
}
h.auditLogger.Log(&claims.UserID, claims.Username, "session_revoked", sessionID, nil, clientIP(r))
JSONResponse(w, http.StatusOK, map[string]string{"message": "Session révoquée"})
}
// hashToken crée un hash SHA-256 d'un token pour le stockage en base.

View file

@ -2,12 +2,19 @@
package api
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
dockerclient "git.geronzi.fr/proxmoxPanel/core/backend/internal/docker"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/logbuffer"
"github.com/go-chi/chi/v5"
)
@ -17,11 +24,12 @@ type SettingsHandler struct {
db *db.DB
auditLogger *audit.Logger
encryptor *crypto.Encryptor
docker *dockerclient.Client
}
// NewSettingsHandler crée un SettingsHandler.
func NewSettingsHandler(database *db.DB, auditLog *audit.Logger, enc *crypto.Encryptor) *SettingsHandler {
return &SettingsHandler{db: database, auditLogger: auditLog, encryptor: enc}
func NewSettingsHandler(database *db.DB, auditLog *audit.Logger, enc *crypto.Encryptor, docker *dockerclient.Client) *SettingsHandler {
return &SettingsHandler{db: database, auditLogger: auditLog, encryptor: enc, docker: docker}
}
// paramètres publics (non-sensibles) accessibles par les admins.
@ -32,6 +40,7 @@ var publicSettings = []string{
"proxmox_url",
"ssh_host",
"ssh_username",
"dashboard_shortcuts",
}
// paramètres sensibles : modifiables en écriture seule, stockés chiffrés.
@ -122,11 +131,27 @@ func (h *SettingsHandler) UpdateSetting(w http.ResponseWriter, r *http.Request)
JSONResponse(w, http.StatusOK, map[string]string{"message": "Paramètre mis à jour"})
}
// moduleResp représente un module dans les réponses API, incluant les champs de navigation.
type moduleResp struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
IsCore bool `json:"is_core"`
IsEnabled bool `json:"is_enabled"`
HasBackend bool `json:"has_backend"`
NavHref string `json:"nav_href"`
NavIcon string `json:"nav_icon"`
NavColor string `json:"nav_color"`
NavLabelKey string `json:"nav_label_key"`
}
// GetModules retourne la liste de tous les modules et leur état.
// GET /api/modules
func (h *SettingsHandler) GetModules(w http.ResponseWriter, r *http.Request) {
rows, err := h.db.Query(`
SELECT id, name, description, version, is_core, is_enabled, installed_at
SELECT id, name, description, version, is_core, is_enabled, has_backend,
COALESCE(nav_href,''), COALESCE(nav_icon,''), COALESCE(nav_color,''), COALESCE(nav_label_key,'')
FROM modules ORDER BY is_core DESC, name ASC
`)
if err != nil {
@ -135,25 +160,15 @@ func (h *SettingsHandler) GetModules(w http.ResponseWriter, r *http.Request) {
}
defer rows.Close()
type module struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
IsCore bool `json:"is_core"`
IsEnabled bool `json:"is_enabled"`
InstalledAt *string `json:"installed_at,omitempty"`
}
var modules []module
var modules []moduleResp
for rows.Next() {
var m module
var isCore, isEnabled int
var installedAt *string
rows.Scan(&m.ID, &m.Name, &m.Description, &m.Version, &isCore, &isEnabled, &installedAt)
var m moduleResp
var isCore, isEnabled, hasBackend int
rows.Scan(&m.ID, &m.Name, &m.Description, &m.Version, &isCore, &isEnabled, &hasBackend,
&m.NavHref, &m.NavIcon, &m.NavColor, &m.NavLabelKey)
m.IsCore = isCore == 1
m.IsEnabled = isEnabled == 1
m.InstalledAt = installedAt
m.HasBackend = hasBackend == 1
modules = append(modules, m)
}
@ -178,7 +193,10 @@ func (h *SettingsHandler) EnableModule(w http.ResponseWriter, r *http.Request) {
}
h.auditLogger.Log(&claims.UserID, claims.Username, "module_enable", id, nil, clientIP(r))
JSONResponse(w, http.StatusOK, map[string]string{"message": "Module activé (redémarrage requis pour prendre effet)"})
// Si le module a du code backend, redémarrer le container pour que LoadActive() le prenne en compte.
restart := h.triggerRestartIfBackend(id)
JSONResponse(w, http.StatusOK, map[string]interface{}{"message": "Module activé", "restarting": restart})
}
// DisableModule désactive un module (ne peut pas désactiver les modules CORE).
@ -200,7 +218,43 @@ func (h *SettingsHandler) DisableModule(w http.ResponseWriter, r *http.Request)
h.db.Exec(`UPDATE modules SET is_enabled = 0 WHERE id = ?`, id)
h.auditLogger.Log(&claims.UserID, claims.Username, "module_disable", id, nil, clientIP(r))
JSONResponse(w, http.StatusOK, map[string]string{"message": "Module désactivé"})
restart := h.triggerRestartIfBackend(id)
JSONResponse(w, http.StatusOK, map[string]interface{}{"message": "Module désactivé", "restarting": restart})
}
// triggerRestartIfBackend déclenche un redémarrage Docker si le module a du code backend.
// Retourne true si un redémarrage a été déclenché.
func (h *SettingsHandler) triggerRestartIfBackend(moduleID string) bool {
if h.docker == nil || !h.docker.Available() {
return false
}
var hasBackend int
if err := h.db.QueryRow(`SELECT has_backend FROM modules WHERE id = ?`, moduleID).Scan(&hasBackend); err != nil {
return false
}
if hasBackend == 1 {
h.docker.Restart()
return true
}
return false
}
// enabledBackendModuleIDs retourne les IDs de tous les modules installés avec has_backend=1.
// Ces modules doivent être compilés dans le binaire lors d'un rebuild.
func (h *SettingsHandler) enabledBackendModuleIDs() ([]string, error) {
rows, err := h.db.Query(`SELECT id FROM modules WHERE has_backend = 1`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
rows.Scan(&id)
ids = append(ids, id)
}
return ids, nil
}
// GetLogs retourne les dernières lignes du log applicatif (tampon mémoire).
@ -255,3 +309,277 @@ func (h *SettingsHandler) GetAuditLog(w http.ResponseWriter, r *http.Request) {
JSONResponse(w, http.StatusOK, entries)
}
// RegistryModule représente un module disponible dans le store Forgejo.
type RegistryModule struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
RepoURL string `json:"repo_url"`
Installed bool `json:"installed"`
}
// registryResp est la réponse unifiée de /api/registry/modules.
type registryResp struct {
Modules []RegistryModule `json:"modules"`
Error string `json:"error,omitempty"`
}
// forgejoClient est un client HTTP avec timeout pour l'API Forgejo.
var forgejoClient = &http.Client{Timeout: 10 * time.Second}
// GetRegistryModules liste les repos de l'organisation sur Forgejo.
// Utilise GET /api/v1/orgs/{org}/repos (retourne un tableau JSON direct).
// Répond toujours 200 — l'erreur éventuelle est dans le champ "error".
// GET /api/registry/modules
func (h *SettingsHandler) GetRegistryModules(w http.ResponseWriter, r *http.Request) {
// Récupérer les modules déjà installés en DB
rows, err := h.db.Query(`SELECT id FROM modules`)
if err != nil {
JSONResponse(w, http.StatusOK, registryResp{Modules: []RegistryModule{}, Error: "Erreur lecture DB : " + err.Error()})
return
}
installed := make(map[string]bool)
for rows.Next() {
var id string
rows.Scan(&id)
installed[id] = true
}
rows.Close()
// Base URL et organisation configurables via variables d'environnement
forgejoURL := envOr("FORGEJO_URL", "https://git.geronzi.fr")
forgejoOrg := envOr("FORGEJO_ORG", "proxmoxPanel")
// Appel à l'API Forgejo : /api/v1/orgs/{org}/repos retourne un tableau JSON direct
apiURL := fmt.Sprintf("%s/api/v1/orgs/%s/repos?limit=50", forgejoURL, forgejoOrg)
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
resp, err := forgejoClient.Do(req)
if err != nil {
JSONResponse(w, http.StatusOK, registryResp{
Modules: []RegistryModule{},
Error: fmt.Sprintf("Impossible de joindre le store (%s) : %v", forgejoURL, err),
})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
JSONResponse(w, http.StatusOK, registryResp{
Modules: []RegistryModule{},
Error: fmt.Sprintf("Store a répondu HTTP %d : %s", resp.StatusCode, string(body)),
})
return
}
body, _ := io.ReadAll(resp.Body)
// /api/v1/orgs/{org}/repos retourne directement un tableau []repo
var repos []struct {
Name string `json:"name"`
Description string `json:"description"`
HTMLURL string `json:"html_url"`
}
if err := json.Unmarshal(body, &repos); err != nil {
JSONResponse(w, http.StatusOK, registryResp{
Modules: []RegistryModule{},
Error: fmt.Sprintf("Réponse store invalide : %v", err),
})
return
}
modules := make([]RegistryModule, 0, len(repos))
for _, repo := range repos {
if repo.Name == "core" {
continue
}
modules = append(modules, RegistryModule{
ID: repo.Name,
Name: repo.Name,
Description: repo.Description,
RepoURL: repo.HTMLURL,
Installed: installed[repo.Name],
})
}
JSONResponse(w, http.StatusOK, registryResp{Modules: modules})
}
// envOr retourne la valeur de la variable d'environnement ou la valeur par défaut.
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// moduleJSON représente le fichier module.json d'un module.
type moduleJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
NavHref string `json:"nav_href"`
NavIcon string `json:"nav_icon"`
NavColor string `json:"nav_color"`
NavLabelKey string `json:"nav_label_key"`
CoreMinVersion string `json:"core_min_version"`
HasBackend bool `json:"has_backend"` // true = nécessite un rebuild Docker pour compilation
}
// InstallRegistryModule installe un module depuis le store Forgejo.
// POST /api/registry/modules/{id}/install
func (h *SettingsHandler) InstallRegistryModule(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r)
id := chi.URLParam(r, "id")
// Récupérer module.json depuis Forgejo
forgejoURL := envOr("FORGEJO_URL", "https://git.geronzi.fr")
forgejoOrg := envOr("FORGEJO_ORG", "proxmoxPanel")
// Essayer d'abord la branche master puis main
moduleJSONURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/module.json?ref=master", forgejoURL, forgejoOrg, id)
reqCtx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(reqCtx, "GET", moduleJSONURL, nil)
resp, err := forgejoClient.Do(req)
if err == nil && resp.StatusCode == http.StatusNotFound {
// Retenter sur la branche main
resp.Body.Close()
moduleJSONURL = fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/module.json?ref=main", forgejoURL, forgejoOrg, id)
req2, _ := http.NewRequestWithContext(reqCtx, "GET", moduleJSONURL, nil)
resp, err = forgejoClient.Do(req2)
}
if err != nil {
JSONError(w, fmt.Sprintf("Impossible d'accéder au module %s : %v", id, err), http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
JSONError(w, fmt.Sprintf("module.json introuvable pour %s", id), http.StatusNotFound)
return
}
if resp.StatusCode != http.StatusOK {
JSONError(w, fmt.Sprintf("Erreur récupération module.json : HTTP %d", resp.StatusCode), http.StatusBadGateway)
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
JSONError(w, "Erreur lecture module.json", http.StatusBadGateway)
return
}
var mod moduleJSON
if err := json.Unmarshal(body, &mod); err != nil {
JSONError(w, "Erreur parsing module.json", http.StatusBadGateway)
return
}
// Valider l'ID
if mod.ID == "" {
mod.ID = id
}
if mod.Version == "" {
mod.Version = "1.0.0"
}
// URL du repo
repoURL := fmt.Sprintf("%s/%s/%s", envOr("FORGEJO_URL", "https://git.geronzi.fr"), envOr("FORGEJO_ORG", "proxmoxPanel"), id)
hasBackend := 0
if mod.HasBackend {
hasBackend = 1
}
// Insérer ou remplacer en DB
_, err = h.db.Exec(`
INSERT INTO modules (id, name, description, version, is_core, is_enabled, has_backend,
nav_href, nav_icon, nav_color, nav_label_key, repo_url, installed_at)
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name, description=excluded.description, version=excluded.version,
has_backend=excluded.has_backend,
nav_href=excluded.nav_href, nav_icon=excluded.nav_icon, nav_color=excluded.nav_color,
nav_label_key=excluded.nav_label_key, repo_url=excluded.repo_url,
installed_at=CURRENT_TIMESTAMP
`, mod.ID, mod.Name, mod.Description, mod.Version, hasBackend,
mod.NavHref, mod.NavIcon, mod.NavColor, mod.NavLabelKey, repoURL)
if err != nil {
JSONError(w, fmt.Sprintf("Erreur installation module : %v", err), http.StatusInternalServerError)
return
}
h.auditLogger.Log(&claims.UserID, claims.Username, "module_install", mod.ID,
map[string]string{"version": mod.Version, "has_backend": fmt.Sprintf("%v", mod.HasBackend)}, clientIP(r))
// Si le module a du code backend : déclencher un rebuild Docker.
// Le rebuild compile le nouveau module dans le binaire et recrée le container.
rebuilding := false
if mod.HasBackend && h.docker != nil && h.docker.Available() {
moduleIDs, err := h.enabledBackendModuleIDs()
if err == nil {
h.docker.RebuildAndRestart(moduleIDs)
rebuilding = true
}
}
JSONResponse(w, http.StatusAccepted, map[string]interface{}{
"message": fmt.Sprintf("Module %s installé", mod.ID),
"rebuilding": rebuilding,
})
}
// ── Réparation ────────────────────────────────────────────────────────────
// repairModule est une entrée retournée par GetRepairStatus.
type repairModule struct {
ID string `json:"id"`
Name string `json:"name"`
IsEnabled bool `json:"is_enabled"`
HasBackend bool `json:"has_backend"`
InstalledAt string `json:"installed_at"`
}
// GetRepairStatus retourne les modules non-core présents en DB (potentiellement fantômes).
// GET /api/repair/modules
func (h *SettingsHandler) GetRepairStatus(w http.ResponseWriter, r *http.Request) {
rows, err := h.db.Query(
`SELECT id, name, is_enabled, has_backend, COALESCE(installed_at,'') FROM modules WHERE is_core = 0`)
if err != nil {
JSONResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
defer rows.Close()
modules := []repairModule{}
for rows.Next() {
var m repairModule
rows.Scan(&m.ID, &m.Name, &m.IsEnabled, &m.HasBackend, &m.InstalledAt)
modules = append(modules, m)
}
JSONResponse(w, http.StatusOK, map[string]interface{}{"modules": modules})
}
// ResetModule supprime un module non-core de la DB (permet de le réinstaller proprement).
// DELETE /api/repair/modules/{id}
func (h *SettingsHandler) ResetModule(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
res, err := h.db.Exec(`DELETE FROM modules WHERE id = ? AND is_core = 0`, id)
if err != nil {
JSONResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
n, _ := res.RowsAffected()
if n == 0 {
JSONResponse(w, http.StatusNotFound, map[string]string{"error": "module introuvable ou module core"})
return
}
claims := GetClaims(r)
h.auditLogger.Log(&claims.UserID, claims.Username, "repair.reset_module", id, nil, clientIP(r))
JSONResponse(w, http.StatusOK, map[string]string{"message": "Module supprimé de la DB"})
}

View file

@ -53,6 +53,11 @@ func Open(dataDir string) (*DB, error) {
return nil, fmt.Errorf("migrations : %w", err)
}
// Réparer les colonnes manquantes (bases créées avant le fix multi-statements)
if err := db.repairSchema(); err != nil {
return nil, fmt.Errorf("réparation schéma : %w", err)
}
return db, nil
}
@ -126,10 +131,18 @@ func (db *DB) migrate() error {
return fmt.Errorf("transaction migration %s : %w", m.name, err)
}
if _, err := tx.Exec(string(content)); err != nil {
// Splitter par ";" pour exécuter chaque statement séparément
// (SQLite / database/sql n'exécute qu'un seul statement par Exec)
for _, stmt := range strings.Split(string(content), ";") {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err := tx.Exec(stmt); err != nil {
tx.Rollback()
return fmt.Errorf("exécution migration %s : %w", m.name, err)
}
}
// Mettre à jour la version (la migration 001 l'insère elle-même, pas besoin de le refaire)
if m.version > 1 {
@ -183,3 +196,57 @@ func (db *DB) IsInstalled() (bool, error) {
}
return v == "true", nil
}
// repairSchema ajoute les colonnes manquantes dans les bases créées avant le fix
// multi-statements des migrations. Migration 002 était partiellement appliquée
// (seul user_agent ajouté) sur les bases existantes.
func (db *DB) repairSchema() error {
type col struct {
table, name, def string
}
needed := []col{
{"refresh_tokens", "user_agent", "TEXT NOT NULL DEFAULT ''"},
{"refresh_tokens", "ip", "TEXT NOT NULL DEFAULT ''"},
{"refresh_tokens", "last_used_at", "DATETIME"},
// Migration 005 : colonnes de navigation des modules (ajout idempotent)
{"modules", "nav_href", "TEXT NOT NULL DEFAULT ''"},
{"modules", "nav_icon", "TEXT NOT NULL DEFAULT ''"},
{"modules", "nav_color", "TEXT NOT NULL DEFAULT ''"},
{"modules", "nav_label_key", "TEXT NOT NULL DEFAULT ''"},
{"modules", "repo_url", "TEXT NOT NULL DEFAULT ''"},
}
for _, c := range needed {
if err := db.ensureColumn(c.table, c.name, c.def); err != nil {
return err
}
}
return nil
}
// ensureColumn ajoute une colonne à une table si elle n'existe pas déjà.
func (db *DB) ensureColumn(table, column, definition string) error {
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return fmt.Errorf("PRAGMA table_info(%s) : %w", table, err)
}
defer rows.Close()
for rows.Next() {
var cid int
var name, colType string
var notNull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &colType, &notNull, &dflt, &pk); err != nil {
return err
}
if name == column {
return nil // déjà présente
}
}
if err := rows.Err(); err != nil {
return err
}
_, err = db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition))
return err
}

View file

@ -0,0 +1,6 @@
-- Migration 002 : Infos de session dans refresh_tokens
-- Ajout user_agent, ip, last_used_at pour la gestion des sessions
ALTER TABLE refresh_tokens ADD COLUMN user_agent TEXT NOT NULL DEFAULT '';
ALTER TABLE refresh_tokens ADD COLUMN ip TEXT NOT NULL DEFAULT '';
ALTER TABLE refresh_tokens ADD COLUMN last_used_at DATETIME;

View file

@ -0,0 +1,4 @@
-- Migration 003 : Ajout des modules logs et services
INSERT OR IGNORE INTO modules (id, name, description, version, is_core, is_enabled) VALUES
('logs', 'Journaux', 'Consultation des journaux système via journalctl', '1.0.0', 0, 1),
('services', 'Services', 'Gestion des services systemd (start/stop/restart)', '1.0.0', 0, 1);

View file

@ -0,0 +1,6 @@
-- Migration 004 : Activation des modules logs et services
-- Les entrées peuvent exister avec is_enabled=0 (INSERT OR IGNORE de la 003 ignoré)
INSERT INTO modules (id, name, description, version, is_core, is_enabled) VALUES
('logs', 'Journaux', 'Consultation des journaux système via journalctl', '1.0.0', 0, 1),
('services', 'Services', 'Gestion des services systemd (start/stop/restart)', '1.0.0', 0, 1)
ON CONFLICT(id) DO UPDATE SET is_enabled = 1;

View file

@ -0,0 +1,10 @@
-- Migration 005 : colonnes de navigation pour les modules + nettoyage services/logs
-- Supprimer les modules services et logs (maintenant dans des repos séparés)
DELETE FROM modules WHERE id IN ('services', 'logs');
-- Ajouter les colonnes de navigation
ALTER TABLE modules ADD COLUMN nav_href TEXT NOT NULL DEFAULT '';
ALTER TABLE modules ADD COLUMN nav_icon TEXT NOT NULL DEFAULT '';
ALTER TABLE modules ADD COLUMN nav_color TEXT NOT NULL DEFAULT '';
ALTER TABLE modules ADD COLUMN nav_label_key TEXT NOT NULL DEFAULT '';
ALTER TABLE modules ADD COLUMN repo_url TEXT NOT NULL DEFAULT ''

View file

@ -0,0 +1,3 @@
-- Migration 006 : ajout colonne has_backend aux modules
-- Indique si le module contient du code Go backend (nécessite un rebuild Docker pour installation).
ALTER TABLE modules ADD COLUMN has_backend INTEGER NOT NULL DEFAULT 0;

View file

@ -0,0 +1,416 @@
// Package docker fournit un client HTTP léger pour l'API Docker via socket Unix.
// Utilisé pour reconstruire et redémarrer le container backend lors de l'installation d'un module.
package docker
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync/atomic"
"time"
)
// IsRebuilding est vrai lorsqu'un rebuild est en cours.
var IsRebuilding atomic.Bool
// Client communique avec le daemon Docker via le socket Unix monté en volume.
type Client struct {
http *http.Client
socketPath string
}
// New crée un Client Docker. Utilise /var/run/docker.sock par défaut.
func New() *Client {
socketPath := envOr("DOCKER_SOCKET", "/var/run/docker.sock")
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
},
}
return &Client{
http: &http.Client{Transport: transport, Timeout: 15 * time.Minute},
socketPath: socketPath,
}
}
// Available indique si le socket Docker est accessible.
func (c *Client) Available() bool {
_, err := os.Stat(c.socketPath)
return err == nil
}
// ---- Structures Docker API ----
// buildEvent est une ligne du stream JSON retourné par l'API build.
type buildEvent struct {
Stream string `json:"stream"`
Error string `json:"error"`
}
// inspectResult contient les champs utiles retournés par /containers/{id}/json.
type inspectResult struct {
Name string `json:"Name"`
Config struct {
Image string `json:"Image"`
Env []string `json:"Env"`
Cmd []string `json:"Cmd"`
Entrypoint []string `json:"Entrypoint"`
WorkingDir string `json:"WorkingDir"`
ExposedPorts map[string]struct{} `json:"ExposedPorts"`
} `json:"Config"`
HostConfig struct {
Binds []string `json:"Binds"`
RestartPolicy struct {
Name string `json:"Name"`
} `json:"RestartPolicy"`
GroupAdd []string `json:"GroupAdd"`
NetworkMode string `json:"NetworkMode"`
} `json:"HostConfig"`
NetworkSettings struct {
Networks map[string]struct {
NetworkID string `json:"NetworkID"`
} `json:"Networks"`
} `json:"NetworkSettings"`
}
// containerCreateBody correspond au corps de POST /containers/create.
type containerCreateBody struct {
Image string `json:"Image"`
Env []string `json:"Env"`
Cmd []string `json:"Cmd,omitempty"`
Entrypoint []string `json:"Entrypoint,omitempty"`
WorkingDir string `json:"WorkingDir,omitempty"`
ExposedPorts map[string]struct{} `json:"ExposedPorts,omitempty"`
HostConfig struct {
Binds []string `json:"Binds"`
RestartPolicy struct {
Name string `json:"Name"`
} `json:"RestartPolicy"`
GroupAdd []string `json:"GroupAdd,omitempty"`
NetworkMode string `json:"NetworkMode,omitempty"`
} `json:"HostConfig"`
NetworkingConfig struct {
EndpointsConfig map[string]map[string]string `json:"EndpointsConfig"`
} `json:"NetworkingConfig"`
}
// ---- Opérations Docker ----
// BuildImage construit une nouvelle image depuis le sous-répertoire d'un repo git.
// Le build arg MODULES contient les IDs de modules séparés par des virgules.
func (c *Client) BuildImage(ctx context.Context, imageTag, gitRepo, gitBranch, subdir string, moduleIDs []string) error {
modulesStr := strings.Join(moduleIDs, ",")
// Format Docker : repo.git#branch:sous-répertoire
remoteCtx := fmt.Sprintf("%s#%s:%s", gitRepo, gitBranch, subdir)
buildArgsJSON, _ := json.Marshal(map[string]string{"MODULES": modulesStr})
q := url.Values{}
q.Set("remote", remoteCtx)
q.Set("dockerfile", "Dockerfile")
q.Set("t", imageTag)
q.Set("buildargs", string(buildArgsJSON))
q.Set("rm", "1")
resp, err := c.do(ctx, "POST", "/v1.47/build?"+q.Encode(), nil)
if err != nil {
return fmt.Errorf("appel API build : %w", err)
}
defer resp.Body.Close()
// Lire le stream de build ligne par ligne — retourner dès qu'une erreur est détectée.
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
var ev buildEvent
if err := json.Unmarshal(scanner.Bytes(), &ev); err != nil {
continue
}
if ev.Error != "" {
return fmt.Errorf("erreur build Docker : %s", ev.Error)
}
if ev.Stream != "" {
log.Printf("[docker-build] %s", strings.TrimRight(ev.Stream, "\n"))
}
}
return scanner.Err()
}
// InspectContainer retourne la configuration du container (pour le recréer).
func (c *Client) InspectContainer(ctx context.Context, name string) (*inspectResult, error) {
resp, err := c.do(ctx, "GET", "/v1.47/containers/"+name+"/json", nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var info inspectResult
if err := json.Unmarshal(body, &info); err != nil {
return nil, fmt.Errorf("parse inspect : %w", err)
}
return &info, nil
}
// CreateContainer crée un nouveau container et retourne son ID.
func (c *Client) CreateContainer(ctx context.Context, name string, body *containerCreateBody) (string, error) {
bodyJSON, _ := json.Marshal(body)
resp, err := c.do(ctx, "POST", "/v1.47/containers/create?name="+url.QueryEscape(name), bytes.NewReader(bodyJSON))
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var result struct {
ID string `json:"Id"`
}
json.Unmarshal(raw, &result)
if result.ID == "" {
return "", fmt.Errorf("création container échouée : %s", string(raw))
}
return result.ID, nil
}
// StartContainer démarre un container par son ID.
func (c *Client) StartContainer(ctx context.Context, id string) error {
_, err := c.do(ctx, "POST", "/v1.47/containers/"+id+"/start", nil)
return err
}
// StopContainer arrête un container (timeout 15s).
func (c *Client) StopContainer(ctx context.Context, name string) error {
_, err := c.do(ctx, "POST", "/v1.47/containers/"+name+"/stop?t=15", nil)
return err
}
// RemoveContainer supprime un container arrêté.
func (c *Client) RemoveContainer(ctx context.Context, name string) error {
_, err := c.do(ctx, "DELETE", "/v1.47/containers/"+name, nil)
return err
}
// RenameContainer renomme un container.
func (c *Client) RenameContainer(ctx context.Context, current, newName string) error {
_, err := c.do(ctx, "POST",
fmt.Sprintf("/v1.47/containers/%s/rename?name=%s", current, url.QueryEscape(newName)), nil)
return err
}
// ---- Orchestration rebuild ----
// RebuildAndRestart lance un rebuild en arrière-plan :
// 1. Construit la nouvelle image avec les modules demandés.
// 2. Crée un container de remplacement qui, à son démarrage, arrêtera l'ancien.
//
// Cette méthode est non-bloquante : elle retourne immédiatement.
// La réponse HTTP doit être envoyée AVANT d'appeler cette méthode.
func (c *Client) RebuildAndRestart(moduleIDs []string) {
if !IsRebuilding.CompareAndSwap(false, true) {
log.Println("[docker] Rebuild déjà en cours, ignoré")
return
}
containerName := envOr("CONTAINER_NAME", "proxmoxpanel-backend")
gitRepo := envOr("GIT_REPO", "https://git.geronzi.fr/proxmoxPanel/core.git")
gitBranch := envOr("GIT_BRANCH", "frontend/alpine")
imageTag := envOr("IMAGE_TAG", "proxmoxpanel-backend:latest")
nextName := containerName + "-next"
go func() {
defer IsRebuilding.Store(false)
log.Printf("[docker] Rebuild démarré — modules : %v", moduleIDs)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
// Étape 1 — Build de la nouvelle image
if err := c.BuildImage(ctx, imageTag, gitRepo, gitBranch, "backend", moduleIDs); err != nil {
log.Printf("[docker] Erreur build image : %v", err)
return
}
log.Println("[docker] Nouvelle image construite")
// Étape 2 — Inspecter le container courant pour récupérer sa config
info, err := c.InspectContainer(ctx, containerName)
if err != nil {
log.Printf("[docker] Erreur inspection container : %v", err)
return
}
// Étape 3 — Préparer la config du container de remplacement
body := buildReplaceBody(info, imageTag, nextName, containerName)
bodyJSON, _ := json.Marshal(body)
log.Printf("[docker] Config container next : %s", string(bodyJSON))
// Supprimer le container "next" s'il existe déjà (rebuild précédent raté)
c.StopContainer(ctx, nextName)
c.RemoveContainer(ctx, nextName)
// Étape 4 — Créer le container de remplacement
nextID, err := c.CreateContainer(ctx, nextName, body)
if err != nil {
log.Printf("[docker] Erreur création container next : %v", err)
return
}
// Étape 5 — Démarrer le container de remplacement
// Il stoppera et remplacera l'actuel au démarrage (voir main.go : handleReplacement).
if err := c.StartContainer(ctx, nextID); err != nil {
log.Printf("[docker] Erreur démarrage container next : %v", err)
return
}
log.Printf("[docker] Container %s démarré — remplacement de %s en cours", nextName, containerName)
}()
}
// Restart redémarre le container courant en arrière-plan (même image).
// Utilisé lors de l'activation/désactivation d'un module has_backend.
func (c *Client) Restart() {
containerName := envOr("CONTAINER_NAME", "proxmoxpanel-backend")
go func() {
time.Sleep(500 * time.Millisecond)
log.Printf("[docker] Redémarrage du container %s...", containerName)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := c.StopContainer(ctx, containerName); err != nil {
log.Printf("[docker] Erreur stop container : %v", err)
}
// La restart policy unless-stopped ne redémarre pas après un stop explicite.
// On recrée le container à partir de son inspect.
info, err := c.InspectContainer(ctx, containerName)
if err != nil {
log.Printf("[docker] Erreur inspect pour restart : %v", err)
return
}
c.RemoveContainer(ctx, containerName)
body := buildSameBody(info)
id, err := c.CreateContainer(ctx, containerName, body)
if err != nil {
log.Printf("[docker] Erreur recréation container : %v", err)
return
}
if err := c.StartContainer(ctx, id); err != nil {
log.Printf("[docker] Erreur démarrage après restart : %v", err)
}
}()
}
// HandleReplacement gère le cas où ce container est un remplacement.
// À appeler au tout début de main() si REPLACING_CONTAINER est défini.
// Arrête l'ancien container, le supprime, se renomme.
func (c *Client) HandleReplacement() {
old := os.Getenv("REPLACING_CONTAINER")
myName := envOr("CONTAINER_NAME", "proxmoxpanel-backend-next")
if old == "" || !c.Available() {
return
}
log.Printf("[docker] Mode remplacement : arrêt du container %s...", old)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Petit délai pour que l'ancien container finisse d'envoyer ses réponses HTTP en cours
time.Sleep(2 * time.Second)
if err := c.StopContainer(ctx, old); err != nil {
log.Printf("[docker] Avertissement : impossible d'arrêter %s : %v", old, err)
}
if err := c.RemoveContainer(ctx, old); err != nil {
log.Printf("[docker] Avertissement : impossible de supprimer %s : %v", old, err)
}
// Se renommer pour prendre l'identité du container remplacé
if err := c.RenameContainer(ctx, myName, old); err != nil {
log.Printf("[docker] Avertissement : impossible de renommer %s → %s : %v", myName, old, err)
} else {
log.Printf("[docker] Container renommé %s → %s", myName, old)
}
}
// ---- Helpers internes ----
// buildReplaceBody construit la config du container de remplacement.
// Ajoute REPLACING_CONTAINER et met à jour CONTAINER_NAME dans les env vars.
func buildReplaceBody(info *inspectResult, newImage, newContainerName, replacingName string) *containerCreateBody {
body := buildSameBody(info)
body.Image = newImage
// Mettre à jour les env vars de remplacement
newEnv := make([]string, 0, len(info.Config.Env)+2)
for _, e := range info.Config.Env {
if strings.HasPrefix(e, "CONTAINER_NAME=") || strings.HasPrefix(e, "REPLACING_CONTAINER=") {
continue
}
newEnv = append(newEnv, e)
}
newEnv = append(newEnv,
"CONTAINER_NAME="+newContainerName,
"REPLACING_CONTAINER="+replacingName,
)
body.Env = newEnv
return body
}
// buildSameBody construit la config d'un container identique (même image).
func buildSameBody(info *inspectResult) *containerCreateBody {
body := &containerCreateBody{}
body.Image = info.Config.Image
body.Env = info.Config.Env
body.Cmd = info.Config.Cmd
body.Entrypoint = info.Config.Entrypoint
body.WorkingDir = info.Config.WorkingDir
body.ExposedPorts = info.Config.ExposedPorts
body.HostConfig.Binds = info.HostConfig.Binds
body.HostConfig.RestartPolicy.Name = info.HostConfig.RestartPolicy.Name
body.HostConfig.GroupAdd = info.HostConfig.GroupAdd
body.HostConfig.NetworkMode = info.HostConfig.NetworkMode
// Réseaux
endpoints := make(map[string]map[string]string)
for netName, netInfo := range info.NetworkSettings.Networks {
endpoints[netName] = map[string]string{"NetworkID": netInfo.NetworkID}
}
body.NetworkingConfig.EndpointsConfig = endpoints
return body
}
// do effectue une requête HTTP vers l'API Docker (http://docker = socket Unix).
func (c *Client) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, "http://docker"+path, body)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
// Les codes 2xx et 304 sont considérés comme succès
if resp.StatusCode >= 300 && resp.StatusCode != 304 {
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("Docker API %s %s → HTTP %d : %s", method, path, resp.StatusCode, string(raw))
}
return resp, nil
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}

View file

@ -13,6 +13,7 @@ import (
"git.geronzi.fr/proxmoxPanel/core/backend/internal/auth"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
dockerclient "git.geronzi.fr/proxmoxPanel/core/backend/internal/docker"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/logbuffer"
sshpool "git.geronzi.fr/proxmoxPanel/core/backend/internal/ssh"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/websocket"
@ -26,6 +27,14 @@ func main() {
// Brancher le buffer de logs (stderr + mémoire) avant tout autre log
log.SetOutput(io.MultiWriter(os.Stderr, logbuffer.Global))
// ── Gestion du remplacement de container (rebuild module) ──────────────
// Si REPLACING_CONTAINER est défini, ce container est un successeur issu d'un rebuild.
// Il arrête l'ancien container et se renomme avant de continuer le démarrage normal.
docker := dockerclient.New()
if docker.Available() {
docker.HandleReplacement()
}
// Répertoire de données persistantes (volume Docker)
dataDir := getEnv("DATA_DIR", "/app/data")
@ -67,9 +76,8 @@ func main() {
auditLogger := audit.New(database.DB)
// ── Chargement des modules actifs ──────────────────────────────────────
loader := modules.NewLoader(database.DB)
// Les modules sont enregistrés ici (compilés dans le binaire)
// loader.RegisterModule(dashboard.New(...)) ← à décommenter quand implémentés
loader := modules.NewLoader(database, sshPool, encryptor)
RegisterModules(loader) // généré par cmd/gen-modules selon les modules compilés
if err := loader.LoadActive(); err != nil {
log.Fatalf("Erreur chargement modules : %v", err)
}
@ -79,7 +87,7 @@ func main() {
authHandler := api.NewAuthHandler(database, jwtManager, sshAuthenticator, auditLogger)
proxmoxHandler := api.NewProxmoxHandler(database, hub, auditLogger, encryptor)
updatesHandler := api.NewUpdatesHandler(database, sshPool, hub, auditLogger, encryptor)
settingsHandler := api.NewSettingsHandler(database, auditLogger, encryptor)
settingsHandler := api.NewSettingsHandler(database, auditLogger, encryptor, docker)
terminalHandler := api.NewTerminalHandler(database, auditLogger, encryptor)
// Démarrer le polling Proxmox en arrière-plan
@ -130,6 +138,8 @@ func main() {
r.Post("/api/auth/logout", authHandler.Logout)
r.Get("/api/auth/me", authHandler.Me)
r.Patch("/api/auth/preferences", authHandler.UpdatePreferences)
r.Get("/api/auth/sessions", authHandler.GetSessions)
r.Delete("/api/auth/sessions/{id}", authHandler.RevokeSession)
// Proxmox
r.Get("/api/proxmox/resources", proxmoxHandler.GetResources)
@ -168,6 +178,20 @@ func main() {
r.Post("/api/modules/{id}/disable", settingsHandler.DisableModule)
})
// Registry store — admin uniquement
r.Group(func(r chi.Router) {
r.Use(api.RequireAdmin)
r.Get("/api/registry/modules", settingsHandler.GetRegistryModules)
r.Post("/api/registry/modules/{id}/install", settingsHandler.InstallRegistryModule)
})
// Réparation DB — admin uniquement
r.Group(func(r chi.Router) {
r.Use(api.RequireAdmin)
r.Get("/api/repair/modules", settingsHandler.GetRepairStatus)
r.Delete("/api/repair/modules/{id}", settingsHandler.ResetModule)
})
// WebSocket — les routes WS extraient le token via query param
r.Get("/ws/proxmox", proxmoxHandler.WebSocket)
r.Get("/ws/updates/{jobId}", updatesHandler.WebSocketUpdate)
@ -177,9 +201,12 @@ func main() {
// Routes enregistrées par les modules actifs
for _, route := range loader.Registry().GetRoutes() {
routeCopy := route // Capturer la variable pour la closure
if routeCopy.RequireAdmin {
switch {
case routeCopy.Public:
r.MethodFunc(routeCopy.Method, routeCopy.Path, routeCopy.Handler)
case routeCopy.RequireAdmin:
r.With(api.RequireAuth(jwtManager), api.RequireAdmin).MethodFunc(routeCopy.Method, routeCopy.Path, routeCopy.Handler)
} else {
default:
r.With(api.RequireAuth(jwtManager)).MethodFunc(routeCopy.Method, routeCopy.Path, routeCopy.Handler)
}
}

View file

@ -1,6 +1,3 @@
// Package modules — Loader de modules.
// Découvre les modules disponibles, vérifie leur état en DB, et les initialise si activés.
// Un module désactivé ne fait appel à aucune de ses méthodes Register().
package modules
import (
@ -8,25 +5,34 @@ import (
"fmt"
"log"
"net/http"
"strconv"
"strings"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
sshpool "git.geronzi.fr/proxmoxPanel/core/backend/internal/ssh"
)
// Loader charge et gère les modules actifs.
type Loader struct {
db *sql.DB
db *db.DB
pool *sshpool.Pool
enc *crypto.Encryptor
registry *coreRegistry
modules []Module
}
// NewLoader crée un Loader avec le router et la DB fournis.
func NewLoader(db *sql.DB) *Loader {
// NewLoader crée un Loader avec les services du CORE nécessaires aux modules.
func NewLoader(database *db.DB, pool *sshpool.Pool, enc *crypto.Encryptor) *Loader {
return &Loader{
db: db,
registry: newCoreRegistry(db),
db: database,
pool: pool,
enc: enc,
registry: newCoreRegistry(database.DB, pool, enc),
}
}
// RegisterModule enregistre un module disponible (appelé à l'init, depuis main.go).
// Le module sera initialisé seulement s'il est activé en base.
func (l *Loader) RegisterModule(m Module) {
l.modules = append(l.modules, m)
}
@ -57,24 +63,24 @@ func (l *Loader) isEnabled(id string) (bool, error) {
var enabled int
err := l.db.QueryRow(`SELECT is_enabled FROM modules WHERE id = ?`, id).Scan(&enabled)
if err == sql.ErrNoRows {
return false, nil // Module inconnu = désactivé
return false, nil
}
return enabled == 1, err
}
// Registry retourne le registry partagé (pour accès par le serveur HTTP).
// Registry retourne le registry partagé.
func (l *Loader) Registry() *coreRegistry {
return l.registry
}
// ---- Implémentation interne du Registry ----
// RouteEntry décrit une route HTTP enregistrée par un module.
type RouteEntry struct {
Method string
Path string
Handler http.HandlerFunc
RequireAdmin bool
Public bool // true = pas d'authentification requise (ex: pages HTML)
}
type migrationEntry struct {
@ -90,24 +96,35 @@ type translationEntry struct {
// coreRegistry implémente l'interface Registry.
type coreRegistry struct {
db *sql.DB
sqlDB *sql.DB
pool *sshpool.Pool
enc *crypto.Encryptor
routes []RouteEntry
wsChannels map[string]WSHandler
widgets []WidgetDef
settingsTabs []SettingsTabDef
migrations []migrationEntry
translations []translationEntry
navItems map[string]NavItemDef // nav items en mémoire (clé = module ID)
}
func newCoreRegistry(db *sql.DB) *coreRegistry {
func newCoreRegistry(sqlDB *sql.DB, pool *sshpool.Pool, enc *crypto.Encryptor) *coreRegistry {
return &coreRegistry{
db: db,
sqlDB: sqlDB,
pool: pool,
enc: enc,
wsChannels: make(map[string]WSHandler),
navItems: make(map[string]NavItemDef),
}
}
func (r *coreRegistry) RegisterRoute(method, path string, handler http.HandlerFunc, requireAdmin bool) {
r.routes = append(r.routes, RouteEntry{method, path, handler, requireAdmin})
r.routes = append(r.routes, RouteEntry{Method: method, Path: path, Handler: handler, RequireAdmin: requireAdmin})
}
// RegisterPublicRoute enregistre une route sans authentification (ex: page HTML d'un module).
func (r *coreRegistry) RegisterPublicRoute(method, path string, handler http.HandlerFunc) {
r.routes = append(r.routes, RouteEntry{Method: method, Path: path, Handler: handler, Public: true})
}
func (r *coreRegistry) RegisterWSChannel(channel string, handler WSHandler) {
@ -130,8 +147,69 @@ func (r *coreRegistry) RegisterMigration(version int, sqlStr string, fn Migratio
r.migrations = append(r.migrations, migrationEntry{version, sqlStr, fn})
}
// RegisterNavItem enregistre l'entrée de navigation d'un module en mémoire et en DB.
func (r *coreRegistry) RegisterNavItem(item NavItemDef) {
r.navItems[item.ID] = item
// Persister en DB pour que le frontend puisse le récupérer via /api/modules
r.sqlDB.Exec(
`UPDATE modules SET nav_href=?, nav_icon=?, nav_color=?, nav_label_key=? WHERE id=?`,
item.Href, item.Icon, item.Color, item.LabelKey, item.ID,
)
}
// DB retourne la connexion SQLite brute.
func (r *coreRegistry) DB() *sql.DB {
return r.db
return r.sqlDB
}
// RunOnTarget exécute une commande SSH sur la cible (host ou lxc:VMID).
// La commande est wrappée via pct exec pour les cibles LXC.
func (r *coreRegistry) RunOnTarget(target, command string) (string, error) {
host, user, pass, err := r.sshCreds()
if err != nil {
return "", err
}
cmd := buildTargetCmd(target, command)
return r.pool.RunCommand(host, user, pass, cmd)
}
// StreamOnTarget exécute une commande SSH en streaming sur la cible.
func (r *coreRegistry) StreamOnTarget(target, command string, output chan<- string) error {
host, user, pass, err := r.sshCreds()
if err != nil {
return err
}
cmd := buildTargetCmd(target, command)
return r.pool.StreamCommand(host, user, pass, cmd, output)
}
// sshCreds récupère et déchiffre les credentials SSH depuis la configuration.
func (r *coreRegistry) sshCreds() (host, user, pass string, err error) {
var h, u, ep string
r.sqlDB.QueryRow(`SELECT value FROM settings WHERE key='ssh_host'`).Scan(&h)
r.sqlDB.QueryRow(`SELECT value FROM settings WHERE key='ssh_username'`).Scan(&u)
r.sqlDB.QueryRow(`SELECT value FROM settings WHERE key='ssh_password'`).Scan(&ep)
if ep != "" {
pass, err = r.enc.Decrypt(ep)
if err != nil {
return "", "", "", fmt.Errorf("impossible de déchiffrer le mot de passe SSH")
}
}
if h == "" || u == "" || pass == "" {
return "", "", "", fmt.Errorf("SSH non configuré")
}
return h, u, pass, nil
}
// buildTargetCmd construit la commande pour la cible (host ou lxc:VMID).
func buildTargetCmd(target, command string) string {
if strings.HasPrefix(target, "lxc:") {
vmid := strings.TrimPrefix(target, "lxc:")
if _, err := strconv.Atoi(vmid); err == nil {
return fmt.Sprintf("pct exec %s -- sh -c %q", vmid, command)
}
}
return command
}
// GetRoutes retourne les routes enregistrées par les modules.
@ -139,6 +217,11 @@ func (r *coreRegistry) GetRoutes() []RouteEntry {
return r.routes
}
// GetNavItems retourne les nav items enregistrés en mémoire.
func (r *coreRegistry) GetNavItems() map[string]NavItemDef {
return r.navItems
}
// GetWidgets retourne les types de widgets disponibles.
func (r *coreRegistry) GetWidgets() []WidgetDef {
return r.widgets

View file

@ -1,45 +0,0 @@
# Module — Logs
**Type**: Optional (disabled by default)
Stream and browse system logs from the Proxmox host or LXC containers in real time via WebSocket (`tail -f` equivalent).
## Planned Features
- Real-time log streaming via WebSocket
- Common log sources: `syslog`, `auth.log`, `kern.log`, journald
- Filter by log level (error, warning, info)
- Stop/start streaming on demand
- LXC log access via `pct exec`
## Planned API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/logs/sources` | JWT | List available log sources |
## Planned WebSocket Endpoint
`GET /ws/logs/{source}?token=<jwt>&host=<optional>`
Where `source` is a log name such as `syslog`, `auth`, or `journal`.
Message types:
| Type | Payload | Description |
|------|---------|-------------|
| `log_line` | `{ "line": "...", "level": "info" }` | New log line |
| `log_end` | — | Stream closed (e.g. SSH disconnected) |
## Status
> This module is currently a stub. The UI view is implemented (shows a "module not enabled" placeholder). Full implementation is planned for a future release.
## Requirements
- SSH access to the target host
- Read permissions on the log files (root or appropriate group)
## License
MIT — see [LICENSE](../../LICENSE)

View file

@ -1,5 +1,4 @@
// Package modules définit le contrat d'interface pour les modules ProxmoxPanel.
// Chaque module implémente l'interface Module et s'enregistre auprès du ModuleRegistry.
package modules
import (
@ -9,36 +8,51 @@ import (
// Module est l'interface que chaque module doit implémenter.
type Module interface {
// ID retourne l'identifiant unique du module (doit correspondre à la table modules en DB).
ID() string
// Register est appelé au chargement du module actif.
// Il reçoit le registry pour enregistrer ses routes, widgets, etc.
Register(registry Registry) error
}
// Registry est l'interface exposée aux modules pour s'enregistrer dans le CORE.
type Registry interface {
// RegisterRoute enregistre une route HTTP dans le router principal.
RegisterRoute(method, path string, handler http.HandlerFunc, requireAdmin bool)
// NavItemDef décrit l'entrée de navigation d'un module dans la sidebar.
type NavItemDef struct {
ID string `json:"id"`
Href string `json:"href"`
Icon string `json:"icon"`
Color string `json:"color"`
LabelKey string `json:"label_key"`
}
// RegisterWSChannel enregistre un handler WebSocket pour un channel nommé.
// Registry est l'interface exposée aux modules pour s'enregistrer dans le CORE.
// Seuls des types de la bibliothèque standard sont exposés — aucun type internal.
type Registry interface {
// Enregistrement de routes HTTP (avec authentification)
RegisterRoute(method, path string, handler http.HandlerFunc, requireAdmin bool)
// Enregistrement d'une route publique (sans auth — pour les pages HTML)
RegisterPublicRoute(method, path string, handler http.HandlerFunc)
// Enregistrement du canal WebSocket
RegisterWSChannel(channel string, handler WSHandler)
// RegisterWidget déclare un type de widget disponible pour le dashboard.
// Widgets et onglets
RegisterWidget(widget WidgetDef)
// RegisterSettingsTab ajoute un onglet dans la page paramètres.
RegisterSettingsTab(tab SettingsTabDef)
// RegisterTranslations fusionne des clés de traduction pour une langue donnée.
// Traductions et migrations
RegisterTranslations(lang string, keys map[string]string)
// RegisterMigration déclare une migration de base de données propre au module.
RegisterMigration(version int, sql string, fn MigrationFn)
// DB retourne un accès à SQLite avec isolation par module (préfixe de tables).
// Entrée de navigation dans la sidebar
RegisterNavItem(item NavItemDef)
// Accès à la base SQLite (isolation par module possible via préfixe)
DB() *sql.DB
// Service SSH — exécute une commande sur la cible (host ou lxc:VMID)
// La cible "host" exécute directement, "lxc:101" wrappe via pct exec
RunOnTarget(target, command string) (string, error)
// Service SSH — streaming de la sortie ligne par ligne
// Le channel est fermé à la fin de la commande
StreamOnTarget(target, command string, output chan<- string) error
}
// WSHandler est un handler WebSocket pour un channel nommé.
@ -58,9 +72,8 @@ type SettingsTabDef struct {
ID string `json:"id"`
Label string `json:"label"`
Icon string `json:"icon"`
// Path est le chemin frontend du composant Vue à charger (lazy import).
ComponentPath string `json:"component_path"`
}
// MigrationFn est une fonction de migration optionnelle (pour les migrations non-SQL).
// MigrationFn est une fonction de migration optionnelle.
type MigrationFn func(db *sql.DB) error

View file

@ -1,55 +0,0 @@
# Module — Services
**Type**: Optional (disabled by default)
Manage systemd services on the Proxmox host and LXC containers. Check status, start, stop, and restart services directly from the web interface.
## Planned Features
- List systemd services with current status (active/inactive/failed)
- Start, stop, restart, reload actions
- View service logs (last N lines via `journalctl -u <service>`)
- Filter by status or name
- LXC service management via `pct exec`
## Planned API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/services` | JWT | List services and their status |
| POST | `/api/services/{name}/start` | JWT+Admin | Start a service |
| POST | `/api/services/{name}/stop` | JWT+Admin | Stop a service |
| POST | `/api/services/{name}/restart` | JWT+Admin | Restart a service |
| POST | `/api/services/{name}/reload` | JWT+Admin | Reload a service |
| GET | `/api/services/{name}/logs` | JWT | Last 100 log lines |
Query parameter: `host=<optional>` to target a specific LXC.
## How It Works
Commands are executed over SSH using `systemctl`:
```bash
systemctl status nginx
systemctl restart nginx
journalctl -u nginx -n 100 --no-pager
```
For LXC containers:
```bash
pct exec <vmid> -- systemctl restart nginx
```
## Status
> This module is currently a stub. The UI view is implemented (shows a "module not enabled" placeholder). Full implementation is planned for a future release.
## Requirements
- SSH access with sufficient privileges to run `systemctl` commands
- `systemd` on the target host/containers
## License
MIT — see [LICENSE](../../LICENSE)

View file

@ -0,0 +1,8 @@
// Code généré automatiquement par cmd/gen-modules — ne pas modifier manuellement.
// Régénéré lors du build Docker avec la liste des modules compilés.
package main
import "git.geronzi.fr/proxmoxPanel/core/backend/modules"
// RegisterModules enregistre les modules compilés dans le binaire.
func RegisterModules(l *modules.Loader) {}

View file

@ -9,6 +9,7 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
container_name: proxmoxpanel-backend
restart: unless-stopped
expose:
@ -16,10 +17,28 @@ services:
volumes:
# Volume persistant pour SQLite, clés JWT, clé maître AES
- panel-data:/app/data
# Socket Docker — permet au backend de reconstruire son propre container lors d'un install de module
- /var/run/docker.sock:/var/run/docker.sock
group_add:
# GID du groupe docker sur l'hôte (docker group = accès au socket).
# Trouver avec : getent group docker | cut -d: -f3
# Surcharger avec : DOCKER_GID=xxx docker compose up -d
- "${DOCKER_GID:-999}"
environment:
- DATA_DIR=/app/data
- LISTEN_ADDR=:3001
- APP_ENV=production
# Identité de ce container (utilisé pour l'auto-rebuild des modules)
- CONTAINER_NAME=proxmoxpanel-backend
# Repo git du CORE (pour docker build --remote lors d'un install module)
- GIT_REPO=https://git.geronzi.fr/proxmoxPanel/core.git
# Branche git à utiliser pour le rebuild
- GIT_BRANCH=frontend/alpine
# Tag de l'image Docker construite
- IMAGE_TAG=proxmoxpanel-backend:latest
# Registry Forgejo — surcharger si auto-hébergé ailleurs
- FORGEJO_URL=https://git.geronzi.fr
- FORGEJO_ORG=proxmoxPanel
# Pas de réseau host — le container reste isolé
# Les connexions SSH sortantes vers le host Proxmox sont autorisées via le réseau Docker
networks:

2
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
dist/

View file

@ -1,26 +1,21 @@
# ── Étape 1 : Build du frontend Vue 3 + Vite ───────────────────────────────
# ── Étape 1 : Build (bundle Swup + xterm via esbuild) ─────────────────────
FROM node:22-alpine AS builder
WORKDIR /build
# Copier les fichiers de dépendances en premier
COPY package.json package-lock.json* ./
RUN npm install --frozen-lockfile
COPY package.json package-lock.json ./
RUN npm ci
# Copier le code source et compiler
COPY . .
RUN npm run build
# ── Étape 2 : Image Nginx pour servir le frontend ──────────────────────────
# ── Étape 2 : Nginx pour servir les fichiers statiques ─────────────────────
FROM nginx:1.27-alpine
# Supprimer la config Nginx par défaut
RUN rm /etc/nginx/conf.d/default.conf
# Copier notre config Nginx personnalisée
COPY nginx.conf /etc/nginx/nginx.conf
# Copier les fichiers compilés par Vite
COPY --from=builder /build/dist /usr/share/nginx/html
EXPOSE 80

111
frontend/build.mjs Normal file
View file

@ -0,0 +1,111 @@
/**
* Build script for ProxmoxPanel Alpine frontend.
* - Bundles Swup into an IIFE (browser-loadable)
* - Bundles xterm.js + addon-fit into IIFEs
* - Copies all static assets to dist/
*/
import * as esbuild from 'esbuild'
import * as fs from 'fs'
import * as path from 'path'
const dist = 'dist'
// Clean dist
fs.rmSync(dist, { recursive: true, force: true })
fs.mkdirSync(`${dist}/js/vendors`, { recursive: true })
fs.mkdirSync(`${dist}/js`, { recursive: true })
fs.mkdirSync(`${dist}/css`, { recursive: true })
fs.mkdirSync(`${dist}/locales`, { recursive: true })
// 1. Bundle Swup into IIFE
console.log('Bundling Swup...')
await esbuild.build({
entryPoints: ['swup-bundle.entry.mjs'],
bundle: true,
format: 'iife',
globalName: '_swupExports',
outfile: `${dist}/js/vendors/swup.iife.js`,
minify: true,
})
// Expose Swup on window
const swupOut = fs.readFileSync(`${dist}/js/vendors/swup.iife.js`, 'utf8')
fs.writeFileSync(`${dist}/js/vendors/swup.iife.js`,
swupOut + '\nwindow.Swup=_swupExports.Swup;')
// 2. Bundle xterm.js
console.log('Bundling xterm...')
await esbuild.build({
entryPoints: ['xterm-bundle.entry.mjs'],
bundle: true,
format: 'iife',
globalName: '_xtermExports',
outfile: `${dist}/js/vendors/xterm.iife.js`,
minify: true,
})
const xtermOut = fs.readFileSync(`${dist}/js/vendors/xterm.iife.js`, 'utf8')
fs.writeFileSync(`${dist}/js/vendors/xterm.iife.js`,
xtermOut + '\nwindow.Terminal=_xtermExports.Terminal;window.FitAddon=_xtermExports.FitAddon;')
// xterm CSS
const xtermCss = 'node_modules/@xterm/xterm/css/xterm.css'
if (fs.existsSync(xtermCss)) {
fs.copyFileSync(xtermCss, `${dist}/css/xterm.css`)
}
// 3. Copy pre-downloaded vendors (Alpine, HTMX)
for (const f of ['alpine.min.js', 'htmx.min.js']) {
const src = `vendors/${f}`
if (fs.existsSync(src)) {
fs.copyFileSync(src, `${dist}/js/vendors/${f}`)
console.log(`Copied ${f}`)
} else {
console.warn(`WARN: ${src} not found`)
}
}
// 4. Copy app JS files
for (const f of fs.readdirSync('js')) {
if (f.endsWith('.js')) {
fs.mkdirSync(`${dist}/js`, { recursive: true })
fs.copyFileSync(`js/${f}`, `${dist}/js/${f}`)
}
}
// Service Worker doit être servi depuis la racine pour avoir le bon scope
if (fs.existsSync('js/ws.sw.js')) {
fs.copyFileSync('js/ws.sw.js', `${dist}/ws.sw.js`)
}
// 5. Copy CSS
for (const f of fs.readdirSync('css')) {
fs.copyFileSync(`css/${f}`, `${dist}/css/${f}`)
}
// 6. Copy locales
for (const f of fs.readdirSync('locales')) {
fs.copyFileSync(`locales/${f}`, `${dist}/locales/${f}`)
}
// 7. Copy HTML pages
for (const f of fs.readdirSync('.')) {
if (f.endsWith('.html')) {
fs.copyFileSync(f, `${dist}/${f}`)
}
}
// 8. Copy manifest.json
if (fs.existsSync('manifest.json')) {
fs.copyFileSync('manifest.json', `${dist}/manifest.json`)
console.log('Copied manifest.json')
}
// 9. Copy icons/
if (fs.existsSync('icons')) {
fs.mkdirSync(`${dist}/icons`, { recursive: true })
for (const f of fs.readdirSync('icons')) {
fs.copyFileSync(`icons/${f}`, `${dist}/icons/${f}`)
}
console.log('Copied icons/')
}
console.log('✓ Build complete → dist/')

File diff suppressed because it is too large Load diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -4,6 +4,32 @@
Utilisé par tous les composants et les modules.
============================================================================= */
/* ── Reset / Base ───────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Inter', sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
body {
min-height: 100vh;
background-color: var(--neu-bg);
color: var(--neu-text);
}
h1, h2, h3, h4, h5, h6 { margin: 0; }
p { margin: 0; }
a { color: inherit; }
button { font-family: inherit; font-size: inherit; }
input, select, textarea { font-family: inherit; font-size: inherit; }
/* ── Variables CSS (surchargées par dark.css et light.css) ─────────────────── */
:root {
/* Couleurs de base */
@ -52,7 +78,7 @@
/* Sidebar */
--sidebar-width: 240px;
--sidebar-width-collapsed: 64px;
--sidebar-width-collapsed: 52px;
/* Z-index */
--z-sidebar: 100;
@ -189,6 +215,13 @@
border-radius: var(--neu-radius-md);
}
/* Bouton icône carré (taille sm) */
.neu-btn.neu-btn--icon-sm {
width: 2rem;
height: 2rem;
padding: 0;
}
.neu-btn--ghost {
background: transparent;
box-shadow: none;
@ -370,3 +403,242 @@
--sidebar-width: 100%;
}
}
/* ── Layout Alpine (sidebar + navbar + page-content) ───────────────────────── */
:root {
--navbar-height: 56px;
}
/* Sidebar (position:fixed, hors flux) */
.sidebar {
width: var(--sidebar-width);
height: 100vh;
background: var(--neu-surface);
border-right: 1px solid var(--neu-border);
display: flex;
flex-direction: column;
transition: width 0.2s ease;
position: fixed;
top: 0;
left: 0;
z-index: var(--z-sidebar);
overflow: hidden;
}
.sidebar.collapsed {
width: var(--sidebar-width-collapsed);
}
.sidebar.collapsed .sidebar-link {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-bottom: 1px solid var(--neu-border);
min-height: var(--navbar-height);
flex-shrink: 0;
cursor: pointer;
user-select: none;
}
.sidebar-logo {
font-size: 1.5rem;
color: var(--neu-primary);
flex-shrink: 0;
}
.sidebar-title {
font-weight: 700;
font-size: 1rem;
white-space: nowrap;
overflow: hidden;
color: var(--neu-text);
}
.sidebar-nav {
flex: 1;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: var(--neu-radius-sm);
text-decoration: none;
color: var(--neu-text-muted);
font-size: 0.875rem;
font-weight: 500;
transition: all 0.15s;
white-space: nowrap;
overflow: hidden;
}
.sidebar-link:hover {
background: rgba(108, 142, 244, 0.08);
color: var(--neu-text);
}
.sidebar-link.active {
background: rgba(108, 142, 244, 0.15);
color: var(--neu-primary);
}
.sidebar-icon {
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
width: 1.25rem;
line-height: 1;
}
.sidebar-label {
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-footer {
padding: 0.75rem;
border-top: 1px solid var(--neu-border);
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.sidebar-user {
flex: 1;
font-size: 0.8rem;
color: var(--neu-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Main layout (marge pour compenser le sidebar fixe) */
.main-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
margin-left: var(--sidebar-width);
transition: margin-left 0.2s ease;
}
.sidebar.collapsed ~ .main-layout {
margin-left: var(--sidebar-width-collapsed);
}
/* Navbar */
.navbar {
height: var(--navbar-height);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
border-bottom: 1px solid var(--neu-border);
background: var(--neu-bg);
flex-shrink: 0;
position: sticky;
top: 0;
z-index: var(--z-navbar);
}
.navbar-title {
font-size: 1rem;
font-weight: 700;
margin: 0;
color: var(--neu-text);
}
.navbar-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Page content */
.page-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
background: var(--neu-bg);
color: var(--neu-text);
}
/* Auth layout (login, install) */
.auth-layout {
min-height: 100vh;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: var(--neu-bg);
}
/* Swup fade transition */
.transition-fade {
transition: opacity 0.2s ease;
}
html.is-animating .transition-fade {
opacity: 0;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar { width: var(--sidebar-width-collapsed); }
.main-layout { margin-left: var(--sidebar-width-collapsed); }
}
[x-cloak] { display: none !important; }
/* ── Sidebar droite ─────────────────────────────────────────────────────────── */
[data-sidebar="right"] .sidebar {
left: auto;
right: 0;
border-right: none;
border-left: 1px solid var(--neu-border);
}
[data-sidebar="right"] .main-layout {
margin-left: 0;
margin-right: var(--sidebar-width);
transition: margin-right 0.2s ease;
}
[data-sidebar="right"] .sidebar.collapsed ~ .main-layout {
margin-right: var(--sidebar-width-collapsed);
}
@media (max-width: 768px) {
[data-sidebar="right"] .main-layout {
margin-right: var(--sidebar-width-collapsed);
}
}
/* ── Couleurs icônes contextuelles ─────────────────────────────────────────── */
/* Navbar : logout, thème, édition */
.navbar .lnid-power-button { color: var(--neu-danger); }
.navbar .lnid-sun-1 { color: #f59e0b; }
.navbar .lnid-moon-half-left-1 { color: #60a5fa; }
/* Sidebar footer : utilisateur */
.sidebar-footer .sidebar-icon { color: var(--neu-primary); }

986
frontend/css/pages.css Normal file
View file

@ -0,0 +1,986 @@
/* =============================================================================
ProxmoxPanel Styles spécifiques aux pages
Chargé sur toutes les pages. Persiste à travers les navigations Swup.
============================================================================= */
/* ── Spinners ────────────────────────────────────────────────────────────────── */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.spinner-sm {
display: inline-block;
width: .875rem;
height: .875rem;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin .6s linear infinite;
}
.spinner-lg {
width: 2rem;
height: 2rem;
border: 3px solid transparent;
border-top-color: var(--neu-primary);
border-radius: 50%;
animation: spin .6s linear infinite;
}
/* ── États communs ───────────────────────────────────────────────────────────── */
.loading-state {
display: flex;
align-items: center;
gap: .75rem;
padding: 2rem;
color: var(--neu-text-muted);
}
.empty-state {
color: var(--neu-text-muted);
font-size: .875rem;
}
.loading {
color: var(--neu-text-muted);
font-size: .875rem;
}
/* ── Badge statut ────────────────────────────────────────────────────────────── */
.badge {
font-size: .7rem;
padding: .2rem .5rem;
border-radius: .25rem;
text-transform: uppercase;
font-weight: 600;
}
.badge.running, .resource-badge.running {
background: rgba(34,197,94,.15);
color: var(--neu-success);
}
.badge.stopped, .resource-badge.stopped {
background: rgba(239,68,68,.1);
color: var(--neu-danger);
}
.resource-badge {
font-size: .7rem;
padding: .2rem .5rem;
border-radius: .25rem;
text-transform: uppercase;
}
/* ── Formulaires ─────────────────────────────────────────────────────────────── */
.form-group {
display: flex;
flex-direction: column;
gap: .4rem;
}
.form-label {
font-size: .8rem;
font-weight: 600;
color: var(--neu-text-muted);
}
.form-error {
background: rgba(239,68,68,.1);
border: 1px solid var(--neu-danger);
border-radius: .5rem;
padding: .75rem;
font-size: .875rem;
color: var(--neu-danger);
}
.form-hint {
font-size: .75rem;
color: var(--neu-text-muted);
margin: 0;
}
/* ── Sections ────────────────────────────────────────────────────────────────── */
.section {
margin-bottom: 2rem;
}
.section-title {
font-size: .875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--neu-text-muted);
margin-bottom: .75rem;
}
/* ── WebSocket status ────────────────────────────────────────────────────────── */
.ws-status {
padding: .5rem 1rem;
border-radius: .5rem;
font-size: .8rem;
margin-bottom: 1rem;
background: var(--neu-surface);
}
.ws-status.ok { color: var(--neu-success); }
.ws-status.disconnected,
.ws-status.error { color: var(--neu-warning); }
/* ── Métriques ───────────────────────────────────────────────────────────────── */
.resource-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.resource-card { padding: 1rem; }
.resource-header {
display: flex;
align-items: center;
gap: .5rem;
margin-bottom: .75rem;
}
.resource-name {
font-weight: 600;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resource-id {
font-size: .75rem;
color: var(--neu-text-muted);
}
.resource-metrics { margin-bottom: .75rem; }
.resource-actions { display: flex; gap: .5rem; flex-wrap: wrap; }
.metric { display: flex; align-items: center; gap: .5rem; margin-bottom: .4rem; }
.metric-label { font-size: .7rem; color: var(--neu-text-muted); min-width: 30px; }
.metric-bar {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--neu-surface);
overflow: hidden;
}
.metric-fill {
height: 100%;
border-radius: 3px;
background: var(--neu-primary);
transition: width .5s;
}
.metric-val { font-size: .7rem; min-width: 36px; text-align: right; }
/* ── Dashboard — Stats ───────────────────────────────────────────────────────── */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card { padding: 1.25rem; text-align: center; }
.stat-icon { font-size: 1.5rem; margin-bottom: .5rem; color: var(--neu-primary); }
.stat-value { font-size: 2rem; font-weight: 700; }
.stat-label { font-size: .8rem; color: var(--neu-text-muted); }
/* ── Updates page ────────────────────────────────────────────────────────────── */
.page-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
gap: 1rem;
flex-wrap: wrap;
}
.page-actions-left,
.page-actions-right { display: flex; align-items: center; gap: .75rem; }
.total-badge { font-size: .875rem; font-weight: 600; color: var(--neu-primary); }
.targets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1rem;
}
.target-card { padding: 1rem; display: flex; flex-direction: column; gap: .75rem; }
.target-header { display: flex; justify-content: space-between; align-items: flex-start; }
.target-info { display: flex; flex-direction: column; gap: .2rem; }
.target-name { font-weight: 700; font-size: .95rem; }
.target-id { font-size: .7rem; color: var(--neu-text-muted); font-family: monospace; }
.target-status {}
.target-actions { display: flex; gap: .5rem; margin-top: auto; }
.package-summary { font-size: .875rem; }
.muted { color: var(--neu-text-muted); }
.up-to-date { color: var(--neu-success); }
.has-updates { color: var(--neu-warning); font-weight: 600; }
.checking-text { display: flex; align-items: center; gap: .4rem; color: var(--neu-text-muted); }
.package-list {
max-height: 160px;
overflow-y: auto;
border-top: 1px solid var(--neu-border);
padding-top: .5rem;
display: flex;
flex-direction: column;
gap: .25rem;
}
.package-row { display: flex; justify-content: space-between; gap: .5rem; font-size: .75rem; }
.pkg-name { font-weight: 600; font-family: monospace; color: var(--neu-primary); }
.pkg-version { display: flex; align-items: center; gap: .25rem; color: var(--neu-text-muted); }
.old-ver { text-decoration: line-through; opacity: .6; }
.arrow { color: var(--neu-primary); }
.new-ver { color: var(--neu-success); font-weight: 600; }
.output-panel { margin-top: 1.5rem; border-radius: .75rem; overflow: hidden; }
.output-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: .75rem 1rem;
border-bottom: 1px solid var(--neu-border);
}
.output-title { font-weight: 600; font-size: .875rem; font-family: monospace; }
.job-status { font-size: .75rem; padding: .2rem .5rem; border-radius: .25rem; text-transform: uppercase; }
.job-status.running { background: rgba(99,102,241,.15); color: var(--neu-primary); }
.job-status.success { background: rgba(34,197,94,.15); color: var(--neu-success); }
.job-status.error { background: rgba(239,68,68,.1); color: var(--neu-danger); }
.output-content {
padding: 1rem;
font-family: monospace;
font-size: .75rem;
white-space: pre-wrap;
word-break: break-all;
max-height: 400px;
overflow-y: auto;
margin: 0;
color: var(--neu-text);
}
/* ── Settings page ───────────────────────────────────────────────────────────── */
.tabs {
display: flex;
gap: .5rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--neu-border);
padding-bottom: .5rem;
}
.tab-btn {
padding: .5rem 1rem;
border-radius: .375rem .375rem 0 0;
font-size: .875rem;
font-weight: 600;
color: var(--neu-text-muted);
background: transparent;
border: none;
cursor: pointer;
transition: all .15s;
}
.tab-btn.active {
color: var(--neu-primary);
background: var(--neu-surface);
border-bottom: 2px solid var(--neu-primary);
}
.tab-btn:hover:not(.active) { color: var(--neu-text); }
.tab-panel { animation: fadeIn .15s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.form-grid {
display: grid;
gap: 1.25rem;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
margin-bottom: 1.5rem;
}
.save-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--neu-border);
}
.save-feedback { flex: 1; }
.save-success { color: var(--neu-success); font-size: .875rem; }
.save-error { color: var(--neu-danger); font-size: .875rem; }
/* ── Modules page ────────────────────────────────────────────────────────────── */
.modules-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.module-card { padding: 1rem; transition: opacity .2s; }
.module-card.disabled { opacity: .6; }
.module-header { display: flex; align-items: center; gap: .75rem; }
.module-icon { font-size: 1.75rem; flex-shrink: 0; }
.module-info { flex: 1; }
.module-name { font-weight: 700; display: block; margin-bottom: .2rem; }
.module-desc { font-size: .8rem; color: var(--neu-text-muted); display: block; }
.core-badge {
font-size: .65rem;
padding: .15rem .4rem;
border-radius: .2rem;
background: rgba(99,102,241,.15);
color: var(--neu-primary);
font-weight: 700;
text-transform: uppercase;
}
.toggle-btn {
display: flex;
align-items: center;
gap: .4rem;
background: none;
border: none;
cursor: pointer;
color: var(--neu-text-muted);
font-size: .8rem;
}
.toggle-btn:disabled { cursor: not-allowed; opacity: .5; }
.toggle-track {
width: 2.5rem;
height: 1.25rem;
background: var(--neu-surface);
border-radius: .625rem;
position: relative;
transition: background .2s;
border: 1px solid var(--neu-border);
}
.toggle-thumb {
position: absolute;
top: .125rem;
left: .125rem;
width: .875rem;
height: .875rem;
background: var(--neu-text-muted);
border-radius: 50%;
transition: transform .2s, background .2s;
}
.toggle-btn.on .toggle-track { background: var(--neu-primary); border-color: var(--neu-primary); }
.toggle-btn.on .toggle-thumb { transform: translateX(1.25rem); background: #fff; }
.module-toggle { margin-left: auto; }
/* ── Terminal page ───────────────────────────────────────────────────────────── */
.main-layout.terminal-wrapper {
height: 100vh;
overflow: hidden;
}
.terminal-layout {
flex: 1;
display: flex;
flex-direction: column;
padding: 0 !important;
overflow: hidden;
}
.terminal-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: .5rem 1rem;
border-bottom: 1px solid var(--neu-border);
background: var(--neu-surface);
flex-shrink: 0;
}
.terminal-status { font-size: .8rem; font-family: monospace; }
.terminal-status.connected { color: var(--neu-success); }
.terminal-status.disconnected,
.terminal-status.error { color: var(--neu-danger); }
.terminal-status.connecting { color: var(--neu-text-muted); }
.terminal-container {
flex: 1;
overflow: hidden;
background: #1a1a2e;
padding: .5rem;
}
.terminal-container .xterm { height: 100%; }
/* ── Auth pages (login + install) ────────────────────────────────────────────── */
.auth-card {
width: 100%;
max-width: 400px;
padding: 2rem;
}
.install-card {
width: 100%;
max-width: 560px;
padding: 2rem;
}
.auth-logo { text-align: center; margin-bottom: 2rem; }
.logo-icon { font-size: 3rem; color: var(--neu-primary); }
.auth-title { font-size: 1.5rem; font-weight: 700; margin: .5rem 0 .25rem; }
.auth-subtitle { font-size: .875rem; color: var(--neu-text-muted); margin: 0; }
.auth-form { display: flex; flex-direction: column; gap: 1.25rem; }
.auth-submit { width: 100%; padding: .875rem; margin-top: .5rem; }
/* ── Install wizard ──────────────────────────────────────────────────────────── */
.stepper { display: flex; gap: .5rem; justify-content: center; margin: 1.5rem 0; }
.step { display: flex; flex-direction: column; align-items: center; gap: .25rem; flex: 1; max-width: 100px; }
.step-dot {
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 2px solid var(--neu-border);
display: flex;
align-items: center;
justify-content: center;
font-size: .8rem;
font-weight: 700;
transition: all .2s;
}
.step.active .step-dot { border-color: var(--neu-primary); background: var(--neu-primary); color: #fff; }
.step.done .step-dot { border-color: var(--neu-success); background: var(--neu-success); color: #fff; }
.step-label { font-size: .7rem; color: var(--neu-text-muted); text-align: center; }
.step-content { display: flex; flex-direction: column; gap: 1rem; min-height: 200px; }
.step-content h2 { margin: 0; font-size: 1.125rem; }
.step-desc { margin: 0; font-size: .875rem; color: var(--neu-text-muted); }
.step-nav { display: flex; justify-content: flex-end; gap: .75rem; margin-top: 1.5rem; }
.status-ok {
padding: .5rem .75rem;
border-radius: .375rem;
background: rgba(34,197,94,.1);
border: 1px solid var(--neu-success);
color: var(--neu-success);
font-size: .875rem;
}
.status-error {
padding: .5rem .75rem;
border-radius: .375rem;
background: rgba(239,68,68,.1);
border: 1px solid var(--neu-danger);
color: var(--neu-danger);
font-size: .875rem;
}
.confirm-summary { padding: 1rem; display: flex; flex-direction: column; gap: .5rem; border-radius: .5rem; }
.confirm-row { display: flex; gap: 1rem; font-size: .875rem; }
.confirm-label { font-weight: 600; min-width: 80px; color: var(--neu-text-muted); }
/* ── Page header ─────────────────────────────────────────────────────────────── */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
gap: 1rem;
}
.page-title { font-size: 1.25rem; font-weight: 700; }
/* ── Dashboard widgets ───────────────────────────────────────────────────────── */
.widgets-grid {
display: grid;
gap: 1.25rem;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
}
.widget {
min-height: 160px;
cursor: grab;
user-select: none;
transition: var(--neu-transition);
}
.widget:active { cursor: grabbing; }
.widget:hover { transform: translateY(-2px); }
.widget-title {
font-size: .875rem;
font-weight: 700;
color: var(--neu-text-muted);
text-transform: uppercase;
letter-spacing: .5px;
margin: 0 0 .75rem 0;
}
/* Widget config panel */
.widget-config {
margin-bottom: 1.25rem;
padding: 1rem;
}
.widget-config-title {
font-weight: 700;
margin: 0 0 .75rem 0;
font-size: .9rem;
}
.widget-config-row {
display: flex;
align-items: center;
gap: .75rem;
padding: .5rem .25rem;
border-radius: .375rem;
cursor: grab;
transition: background .15s;
}
.widget-config-row:hover { background: rgba(108, 142, 244, 0.06); }
.widget-toggle-label {
display: flex;
align-items: center;
gap: .5rem;
margin-left: auto;
font-size: .8rem;
color: var(--neu-text-muted);
cursor: pointer;
}
.drag-handle { color: var(--neu-text-muted); font-size: 1rem; }
/* Widget: status (stats) */
.stat-rows { display: flex; flex-direction: column; gap: .5rem; }
.stat-row {
display: flex;
align-items: center;
gap: .75rem;
font-size: .9rem;
}
.stat-icon { font-size: 1.1rem; width: 1.25rem; flex-shrink: 0; }
.stat-num { font-size: 1.5rem; font-weight: 700; min-width: 2rem; }
.stat-label { color: var(--neu-text-muted); }
/* Widget: lxc-list */
.lxc-row {
display: flex;
align-items: center;
gap: .5rem;
padding: .375rem 0;
border-bottom: 1px solid var(--neu-border);
font-size: .85rem;
}
.lxc-row:last-child { border-bottom: none; }
.lxc-name { flex: 1; font-weight: 500; }
.lxc-cpu, .lxc-ram { color: var(--neu-text-muted); font-size: .8rem; min-width: 3.5rem; text-align: right; }
/* Widget: links */
.links-grid {
display: flex;
flex-wrap: wrap;
gap: .75rem;
}
.link-btn {
flex: 1;
min-width: 80px;
justify-content: center;
gap: .375rem;
}
/* Widget edit mode */
.widget-size-2 { grid-column: span 2; }
@media (max-width: 720px) { .widget-size-2 { grid-column: span 1; } }
.widget { position: relative; }
.widget.is-dragging { opacity: 0.35; transform: scale(.97); }
.widget-highlighted { outline: 2px solid var(--neu-primary) !important; outline-offset: 2px; }
/* Edit mode layout : grid + panel côte à côte */
.edit-mode-layout {
display: flex;
gap: 1.25rem;
align-items: flex-start;
}
.edit-mode-layout .widgets-grid {
flex: 1;
min-width: 0;
}
/* Widget panel (liste de tous les widgets en mode édition) */
.widget-panel {
width: 200px;
flex-shrink: 0;
position: sticky;
top: 1rem;
padding: 1rem;
}
.widget-panel-title {
display: flex;
align-items: center;
gap: .5rem;
font-size: .875rem;
font-weight: 700;
margin: 0 0 .75rem 0;
padding-bottom: .5rem;
border-bottom: 1px solid var(--neu-border);
}
.widget-panel-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: .2rem;
}
.widget-panel-item {
display: flex;
align-items: center;
gap: .5rem;
padding: .4rem .375rem;
border-radius: var(--neu-radius-sm);
cursor: default;
transition: background .15s;
}
.widget-panel-item:hover { background: rgba(108, 142, 244, 0.08); }
.widget-panel-label {
flex: 1;
font-size: .85rem;
color: var(--neu-text-muted);
transition: color .15s;
}
.widget-panel-item.is-visible .widget-panel-label { color: var(--neu-text); }
.widget-panel-toggle {
background: none;
border: none;
cursor: pointer;
color: var(--neu-text-muted);
padding: .2rem;
border-radius: .2rem;
line-height: 1;
transition: color .15s;
}
.widget-panel-toggle:hover { color: var(--neu-primary); }
.widget-panel-item.is-visible .widget-panel-toggle { color: var(--neu-success); }
/* Resize handle (coin bas-droit de la tuile, edit mode) */
.widget-resize-handle {
position: absolute;
bottom: 0;
right: 0;
width: 22px;
height: 22px;
cursor: ew-resize;
border-radius: 0 0 var(--neu-radius-sm) 0;
background: linear-gradient(135deg, transparent 50%, var(--neu-border) 50%);
transition: background .15s;
}
.widget-resize-handle:hover {
background: linear-gradient(135deg, transparent 50%, var(--neu-primary) 50%);
}
/* ── Profil / préférences ────────────────────────────────────────────────────── */
.settings-section {
margin-bottom: 1.25rem;
}
.settings-section h3 {
display: flex;
align-items: center;
gap: .5rem;
font-size: 1rem;
font-weight: 700;
margin: 0 0 1rem 0;
padding-bottom: .75rem;
border-bottom: 1px solid var(--neu-border);
}
.profile-info { display: flex; flex-direction: column; gap: .5rem; }
.profile-row {
display: flex;
align-items: center;
gap: 1rem;
font-size: .9rem;
padding: .375rem 0;
}
.profile-label {
font-weight: 600;
color: var(--neu-text-muted);
min-width: 100px;
}
.profile-value { color: var(--neu-text); }
.badge-admin {
background: rgba(108, 142, 244, 0.15);
color: var(--neu-primary);
padding: 2px 8px;
border-radius: 9999px;
font-size: .75rem;
font-weight: 600;
}
.badge-user {
background: rgba(127, 136, 153, 0.15);
color: var(--neu-text-muted);
padding: 2px 8px;
border-radius: 9999px;
font-size: .75rem;
font-weight: 600;
}
/* Groupe de boutons (thème, sidebar position) */
.btn-group {
display: flex;
gap: .5rem;
flex-wrap: wrap;
}
/* ── Sessions ───────────────────────────────────────────────────────────────── */
.session-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
padding: .6rem 0;
border-bottom: 1px solid var(--neu-border);
}
.session-row:last-child { border-bottom: none; }
.session-info { display: flex; flex-direction: column; gap: .2rem; min-width: 0; }
.session-browser {
display: flex;
align-items: center;
gap: .4rem;
font-size: .9rem;
color: var(--neu-text);
font-weight: 500;
}
.session-meta {
display: flex;
align-items: center;
gap: .3rem;
font-size: .75rem;
color: var(--neu-text-muted);
flex-wrap: wrap;
}
.session-sep { opacity: .4; }
.session-id { font-family: monospace; opacity: .6; }
.session-current { background: rgba(var(--neu-primary-rgb, 108,142,244), .05); border-radius: var(--neu-radius); padding-left: .5rem; padding-right: .5rem; }
.badge-current {
font-size: .65rem;
padding: .15rem .45rem;
border-radius: 99px;
background: rgba(34,197,94,.15);
color: var(--neu-success);
font-weight: 600;
letter-spacing: .02em;
}
/* ── Logo auth LineIcons ─────────────────────────────────────────────────────── */
.logo-icon {
font-size: 2.5rem;
color: var(--neu-primary);
display: block;
line-height: 1;
}
/* ── Historique mises à jour ─────────────────────────────────────────────────── */
.history-table { display: flex; flex-direction: column; gap: 0; }
.history-header, .history-row {
display: grid;
grid-template-columns: 6rem 1fr 6rem 10rem 5rem;
gap: .5rem;
align-items: center;
padding: .5rem .75rem;
}
.history-header {
font-size: .75rem;
font-weight: 600;
color: var(--neu-text-muted);
text-transform: uppercase;
letter-spacing: .04em;
border-bottom: 1px solid var(--neu-border);
}
.history-row {
font-size: .85rem;
cursor: pointer;
border-bottom: 1px solid var(--neu-border);
transition: background .15s;
}
.history-row:last-child { border-bottom: none; }
.history-row:hover { background: var(--neu-bg-hover); border-radius: var(--neu-radius); }
.history-job { font-family: monospace; opacity: .7; }
.history-status.running { color: var(--neu-info); }
.history-status.success { color: var(--neu-success); }
.history-status.error { color: var(--neu-danger); }
/* ── Toasts ──────────────────────────────────────────────────────────────────── */
#pxp-toasts {
position: fixed;
bottom: 1.25rem;
right: 1.25rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: .5rem;
pointer-events: none;
max-width: 24rem;
}
.toast {
display: flex;
align-items: flex-start;
gap: .6rem;
padding: .75rem 1rem;
border-radius: var(--neu-radius);
background: var(--neu-surface);
box-shadow: 0 4px 16px rgba(0,0,0,.35);
border-left: 3px solid transparent;
font-size: .875rem;
pointer-events: all;
animation: toast-in .2s ease;
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(1rem); }
to { opacity: 1; transform: translateX(0); }
}
.toast > i:first-child { margin-top: .05rem; flex-shrink: 0; font-size: 1rem; }
.toast-msg { flex: 1; color: var(--neu-text); line-height: 1.4; }
.toast-close {
background: none;
border: none;
cursor: pointer;
padding: 0;
color: var(--neu-text-muted);
line-height: 1;
flex-shrink: 0;
margin-top: .05rem;
}
.toast-close:hover { color: var(--neu-text); }
.toast--error { border-left-color: var(--neu-danger); }
.toast--error > i:first-child { color: var(--neu-danger); }
.toast--success { border-left-color: var(--neu-success); }
.toast--success > i:first-child { color: var(--neu-success); }
.toast--warn { border-left-color: var(--neu-warning); }
.toast--warn > i:first-child { color: var(--neu-warning); }
.toast--info { border-left-color: var(--neu-info); }
.toast--info > i:first-child { color: var(--neu-info); }
/* ── Page Services ───────────────────────────────────────────────────────────── */
.services-toolbar {
display: flex;
align-items: center;
gap: .75rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.services-target-sel { min-width: 12rem; }
.services-search {
display: flex;
align-items: center;
gap: .5rem;
flex: 1;
min-width: 10rem;
background: var(--neu-bg);
border: 1px solid var(--neu-border);
border-radius: var(--neu-radius);
padding: 0 .75rem;
}
.services-search i { color: var(--neu-text-muted); font-size: .9rem; flex-shrink: 0; }
.services-search .neu-input {
border: none;
background: transparent;
padding: .45rem 0;
flex: 1;
box-shadow: none;
min-width: 0;
}
.services-table-wrap { padding: 0; overflow-x: auto; }
.services-table {
width: 100%;
border-collapse: collapse;
font-size: .875rem;
}
.services-table thead tr {
border-bottom: 1px solid var(--neu-border);
}
.services-table th {
padding: .6rem 1rem;
text-align: left;
font-weight: 600;
color: var(--neu-text-muted);
white-space: nowrap;
}
.services-table td {
padding: .5rem 1rem;
border-bottom: 1px solid var(--neu-border);
vertical-align: middle;
}
.services-table tbody tr:last-child td { border-bottom: none; }
.services-table tbody tr:hover { background: var(--neu-bg-raised); }
.svc-state-dot {
display: inline-block;
width: .5rem;
height: .5rem;
border-radius: 50%;
margin-right: .4rem;
vertical-align: middle;
}
.state-active .svc-state-dot { background: var(--neu-success); }
.state-failed .svc-state-dot { background: var(--neu-danger); }
.state-inactive .svc-state-dot { background: var(--neu-text-muted); }
.state-other .svc-state-dot { background: var(--neu-warning); }
.state-active .svc-state-label { color: var(--neu-success); }
.state-failed .svc-state-label { color: var(--neu-danger); }
.state-inactive .svc-state-label { color: var(--neu-text-muted); }
.svc-name { font-weight: 500; font-family: var(--neu-font-mono, monospace); white-space: nowrap; }
.svc-sub { color: var(--neu-text-muted); font-size: .8rem; white-space: nowrap; }
.svc-desc { color: var(--neu-text-muted); font-size: .8rem; max-width: 22rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.svc-actions { display: flex; gap: .35rem; white-space: nowrap; }
/* ── Page Logs ───────────────────────────────────────────────────────────────── */
.logs-toolbar {
display: flex;
align-items: center;
gap: .75rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.logs-sel { min-width: 10rem; }
.logs-sel-sm { min-width: 5rem; width: 5rem; }
.logs-status {
display: flex;
align-items: center;
gap: .5rem;
font-size: .8rem;
color: var(--neu-success);
margin-bottom: .5rem;
}
.logs-status-dot {
width: .45rem;
height: .45rem;
border-radius: 50%;
background: var(--neu-success);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: .35; }
}
.logs-output-wrap { padding: 0; min-height: 22rem; display: flex; flex-direction: column; }
.logs-output {
flex: 1;
padding: 1rem;
margin: 0;
font-family: var(--neu-font-mono, 'Courier New', monospace);
font-size: .78rem;
line-height: 1.5;
color: var(--neu-text);
overflow-y: auto;
max-height: 65vh;
white-space: pre-wrap;
word-break: break-all;
}
.logs-empty {
padding: 2rem;
text-align: center;
}
/* ── Éditeur de raccourcis ───────────────────────────────────────────────────── */
.shortcuts-editor { display: flex; flex-direction: column; gap: .5rem; }
.shortcut-row {
display: grid;
grid-template-columns: 9rem 1fr 1fr 2rem;
gap: .5rem;
align-items: center;
}
.shortcut-icon-sel { padding: .4rem .5rem; font-size: .85rem; }
.shortcut-label, .shortcut-href { font-size: .85rem; }
/* ── Store de modules ────────────────────────────────────────────────────────── */
.section-desc { color: var(--neu-text-muted); font-size: .85rem; margin: -.25rem 0 .75rem; }
.section-desc a { color: var(--neu-primary); text-decoration: none; }
.section-desc a:hover { text-decoration: underline; }
.module-version { font-size: .75rem; color: var(--neu-text-muted); margin-left: .5rem; }
.module-repo-link { font-size: .75rem; color: var(--neu-text-muted); text-decoration: none; display: block; margin-top: .2rem; }
.module-repo-link:hover { color: var(--neu-primary); }
.installed-badge {
padding: .2rem .6rem;
border-radius: 1rem;
font-size: .75rem;
font-weight: 600;
background: color-mix(in srgb, var(--neu-success) 15%, transparent);
color: var(--neu-success);
}
.rebuild-notice {
display: flex;
align-items: center;
gap: .75rem;
padding: 1rem;
font-size: .875rem;
}

193
frontend/dashboard.html Normal file
View file

@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<script>(function(){document.documentElement.setAttribute("data-theme",localStorage.getItem("pxp_theme")||"dark");document.documentElement.setAttribute("data-sidebar",localStorage.getItem("pxp_sidebar_pos")||"left")})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxmoxPanel — Dashboard</title>
<link rel="stylesheet" href="/css/neu.css" />
<link rel="stylesheet" href="/css/dark.css" />
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/pages.css" />
<link rel="stylesheet" href="/css/lineicons-duotone.css" />
<link rel="manifest" href="/manifest.json" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header" @click="toggle()">
<i class="sidebar-logo lnid-layout-1"></i>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">
<a class="sidebar-link" :class="{ active: isActive(item.id) }" :href="item.href" @click.prevent="navigate(item.href)">
<i class="sidebar-icon" :class="item.iconClass" :style="iconStyle(item)"></i>
<span class="sidebar-label" x-show="!collapsed" x-text="t(item.labelKey)"></span>
</a>
</template>
</nav>
<div class="sidebar-footer">
<a class="sidebar-link" href="/profile.html" @click.prevent="navigate('/profile.html')">
<i class="sidebar-icon lnid-user-circle-1"></i>
<span class="sidebar-label" x-show="!collapsed" x-text="$store.auth.user?.username || t('nav.profile')"></span>
</a>
</div>
</aside>
<div class="main-layout">
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.dashboard')"></h2>
<div class="navbar-actions">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="toggleTheme()"
:title="theme==='dark' ? t('navbar.lightMode') : t('navbar.darkMode')">
<i :class="theme==='dark' ? 'lnid-sun-1' : 'lnid-moon-half-left-1'"></i>
</button>
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="logout()" :title="t('navbar.logout')">
<i class="lnid-power-button"></i>
</button>
</div>
</nav>
<main id="swup" class="page-content transition-fade" x-data="dashboardPage()" x-cloak>
<div class="page-header">
<h2 class="page-title" x-text="t('dashboard.welcome').replace('{name}', $store.auth.user?.username || '')"></h2>
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" :class="{ 'neu-btn--primary': editMode }"
@click="toggleEdit()" title="Mode édition">
<i class="lnid-pencil-1"></i>
</button>
</div>
<!-- WS status -->
<div class="ws-status" :class="wsStatus" x-show="wsStatus !== 'connected'" x-transition>
<span x-show="wsStatus === 'connecting'">⌛ Connexion…</span>
<span x-show="wsStatus === 'disconnected'">⚠ Déconnecté (reconnexion…)</span>
<span x-show="wsStatus === 'error'">✗ Erreur WebSocket</span>
</div>
<!-- Layout : grid (+ panel en mode édition) -->
<div :class="editMode ? 'edit-mode-layout' : ''">
<!-- Widgets grid -->
<div class="widgets-grid">
<template x-for="w in visibleWidgets" :key="w.id">
<div class="neu-card widget"
:class="{
'widget-size-2': w.size === 2,
'is-dragging': dragSrcIdx === widgets.indexOf(w),
'widget-highlighted': hoveredWidgetId === w.id,
}"
:draggable="editMode ? 'true' : 'false'"
@dragstart="editMode && onDragStart(widgets.indexOf(w))"
@dragenter="editMode && onDragEnter(widgets.indexOf(w))"
@dragover.prevent
@dragend="onDragEnd()"
@drop.prevent="onDrop()">
<!-- Widget: status -->
<template x-if="w.id === 'status'">
<div class="widget-status">
<h4 class="widget-title" x-text="t('dashboard.lxcStatus')"></h4>
<div class="stat-rows">
<div class="stat-row">
<i class="lnid-play stat-icon" style="color:var(--neu-success)"></i>
<span class="stat-num" x-text="running.length"></span>
<span class="stat-label" x-text="t('dashboard.running')"></span>
</div>
<div class="stat-row">
<i class="lnid-stop stat-icon" style="color:var(--neu-danger)"></i>
<span class="stat-num" x-text="stopped.length"></span>
<span class="stat-label">Arrêtés</span>
</div>
<div class="stat-row">
<i class="lnid-server-1 stat-icon" style="color:var(--neu-info)"></i>
<span class="stat-num" x-text="lxc.length"></span>
<span class="stat-label" x-text="t('dashboard.lxcCount')"></span>
</div>
</div>
</div>
</template>
<!-- Widget: lxc-list -->
<template x-if="w.id === 'lxc-list'">
<div class="widget-lxc">
<h4 class="widget-title" x-text="t('dashboard.lxcStatus')"></h4>
<div class="loading-state" x-show="wsStatus === 'connecting'">
<div class="spinner-lg"></div>
</div>
<div x-show="wsStatus !== 'connecting'">
<template x-for="item in lxc" :key="item.vmid">
<div class="lxc-row">
<i :class="item.status === 'running' ? 'lnid-play' : 'lnid-stop'"
:style="'color:' + (item.status === 'running' ? 'var(--neu-success)' : 'var(--neu-danger)')"></i>
<span class="lxc-name" x-text="item.name || 'CT' + item.vmid"></span>
<span class="lxc-cpu" x-text="Math.round((item.cpu || 0) * 100) + '%'"></span>
<span class="lxc-ram" x-text="Math.round((item.mem || 0) / 1048576) + 'M'"></span>
</div>
</template>
<p class="empty-state" x-show="lxc.length === 0" x-text="t('dashboard.noData')"></p>
</div>
</div>
</template>
<!-- Widget: links -->
<template x-if="w.id === 'links'">
<div class="widget-links">
<h4 class="widget-title" x-text="t('dashboard.widgetShortcut')"></h4>
<div class="links-grid">
<template x-for="s in displayShortcuts" :key="s.href">
<a class="neu-btn link-btn" :href="s.href" @click.prevent="navigate(s.href)">
<i :class="s.icon"></i>
<span x-text="s.label"></span>
</a>
</template>
</div>
</div>
</template>
<!-- Handle de redimensionnement (coin bas-droit, mode édition) -->
<div class="widget-resize-handle"
x-show="editMode"
@mousedown.prevent.stop="startResize($event, w)"></div>
</div>
</template>
<p class="empty-state" x-show="visibleWidgets.length === 0 && !editMode">
Aucun widget actif — activez le mode édition pour en ajouter.
</p>
</div>
<!-- Panel widgets (mode édition uniquement) -->
<div class="widget-panel neu-card" x-show="editMode" x-transition>
<h4 class="widget-panel-title">
<i class="lnid-layout-1"></i> Widgets
</h4>
<ul class="widget-panel-list">
<template x-for="w in widgets" :key="w.id">
<li class="widget-panel-item"
:class="{ 'is-visible': w.visible }"
@mouseenter="hoveredWidgetId = w.id"
@mouseleave="hoveredWidgetId = null">
<span class="widget-panel-label" x-text="w.label"></span>
<button class="widget-panel-toggle"
@click="w.visible ? hideWidget(w) : showWidget(w.id)"
:title="w.visible ? 'Masquer' : 'Afficher'">
<i :class="w.visible ? 'lnid-eye' : 'lnid-eye-closed'"></i>
</button>
</li>
</template>
</ul>
</div>
</div><!-- /edit-mode-layout -->
</main>
</div>
</body>
</html>

24
frontend/icons/icon.svg Normal file
View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a1a2e"/>
<stop offset="100%" style="stop-color:#16213e"/>
</linearGradient>
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6c8ef4"/>
<stop offset="100%" style="stop-color:#a78bfa"/>
</linearGradient>
</defs>
<!-- Background rounded square -->
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
<!-- Server rack lines -->
<rect x="96" y="140" width="320" height="64" rx="12" fill="none" stroke="url(#accent)" stroke-width="10" opacity="0.4"/>
<rect x="96" y="224" width="320" height="64" rx="12" fill="none" stroke="url(#accent)" stroke-width="10" opacity="0.6"/>
<rect x="96" y="308" width="320" height="64" rx="12" fill="none" stroke="url(#accent)" stroke-width="10" opacity="0.4"/>
<!-- Status dots -->
<circle cx="142" cy="172" r="8" fill="#22c55e"/>
<circle cx="142" cy="256" r="8" fill="url(#accent)"/>
<circle cx="142" cy="340" r="8" fill="#22c55e"/>
<!-- P letter -->
<text x="256" y="300" text-anchor="middle" font-family="system-ui, sans-serif" font-weight="800" font-size="200" fill="url(#accent)" opacity="0.12">P</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -3,13 +3,16 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="ProxmoxPanel — Interface de gestion d'infrastructure" />
<title>ProxmoxPanel</title>
<!-- Pas de CDN — tous les assets sont locaux -->
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<!-- La logique de redirection est dans app.js (DOMContentLoaded) -->
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:sans-serif;color:#94a3b8">
Chargement…
</div>
</body>
</html>

150
frontend/install.html Normal file
View file

@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<script>(function(){document.documentElement.setAttribute("data-theme",localStorage.getItem("pxp_theme")||"dark")})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxmoxPanel — Installation</title>
<link rel="stylesheet" href="/css/neu.css" />
<link rel="stylesheet" href="/css/dark.css" />
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/pages.css" />
<link rel="stylesheet" href="/css/lineicons-duotone.css" />
<link rel="manifest" href="/manifest.json" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body x-data>
<div class="auth-layout" x-data="installPage()" x-cloak>
<div class="install-card neu-card">
<div class="auth-logo">
<i class="logo-icon lnid-layout-1"></i>
<h1 class="auth-title">ProxmoxPanel</h1>
<p class="auth-subtitle" x-text="t('install.subtitle')"></p>
</div>
<!-- Stepper -->
<div class="stepper">
<template x-for="i in totalSteps" :key="i">
<div class="step" :class="{ active: step === i, done: step > i }">
<div class="step-dot" x-text="step > i ? '✓' : i"></div>
<div class="step-label" x-text="t('install.step' + i + '.label')"></div>
</div>
</template>
</div>
<!-- Step 1: Général -->
<div x-show="step === 1" class="step-content">
<h2 x-text="t('install.step1.title')"></h2>
<p class="step-desc" x-text="t('install.step1.desc')"></p>
<div class="form-group">
<label class="form-label" x-text="t('install.instanceName')"></label>
<input class="neu-input" type="text" x-model="form.instance_name"
:placeholder="t('install.instanceNamePlaceholder')" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.publicUrl')"></label>
<input class="neu-input" type="url" x-model="form.public_url" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.defaultLang')"></label>
<select class="neu-input" x-model="form.default_lang">
<option value="fr">Français</option>
<option value="en">English</option>
</select>
</div>
</div>
<!-- Step 2: SSH -->
<div x-show="step === 2" class="step-content">
<h2 x-text="t('install.step2.title')"></h2>
<p class="step-desc" x-text="t('install.step2.desc')"></p>
<div class="form-group">
<label class="form-label" x-text="t('install.sshHost')"></label>
<input class="neu-input" type="text" x-model="form.ssh_host"
placeholder="10.0.0.1:22" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.sshUsername')"></label>
<input class="neu-input" type="text" x-model="form.ssh_username"
placeholder="enzo" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.sshPassword')"></label>
<input class="neu-input" type="password" x-model="form.ssh_password" />
</div>
<button class="neu-btn" type="button" @click="testSSH" :disabled="sshTesting">
<span x-show="!sshTesting" x-text="t('install.testSSH')"></span>
<span x-show="sshTesting">Test en cours…</span>
</button>
<div x-show="sshStatus === 'ok'" class="status-ok" x-text="t('install.sshSuccess')"></div>
<div x-show="sshStatus === 'error'" class="status-error" x-text="t('install.sshFailed')"></div>
</div>
<!-- Step 3: Proxmox API -->
<div x-show="step === 3" class="step-content">
<h2 x-text="t('install.step3.title')"></h2>
<p class="step-desc" x-text="t('install.step3.desc')"></p>
<div class="form-group">
<label class="form-label" x-text="t('install.proxmoxUrl')"></label>
<input class="neu-input" type="url" x-model="form.proxmox_url"
placeholder="https://proxmox.example.com:8006" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.proxmoxTokenId')"></label>
<input class="neu-input" type="text" x-model="form.proxmox_token_id"
placeholder="enzo@pam!panel" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.proxmoxTokenSecret')"></label>
<input class="neu-input" type="text" x-model="form.proxmox_token_secret"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
</div>
<p class="form-hint" x-text="t('install.proxmoxTokenHint')"></p>
</div>
<!-- Step 4: Confirmation -->
<div x-show="step === 4" class="step-content">
<h2 x-text="t('install.step4.title')"></h2>
<div class="confirm-summary neu-inset">
<div class="confirm-row">
<span class="confirm-label">Instance</span>
<span x-text="form.instance_name"></span>
</div>
<div class="confirm-row">
<span class="confirm-label">URL</span>
<span x-text="form.public_url"></span>
</div>
<div class="confirm-row">
<span class="confirm-label">SSH</span>
<span x-text="form.ssh_username + '@' + form.ssh_host"></span>
</div>
<div class="confirm-row" x-show="form.proxmox_url">
<span class="confirm-label">Proxmox</span>
<span x-text="form.proxmox_url"></span>
</div>
</div>
<div x-show="error" class="status-error" x-text="error"></div>
</div>
<!-- Navigation -->
<div class="step-nav">
<button class="neu-btn" type="button" @click="prevStep"
x-show="step > 1" :disabled="loading">
<span x-text="t('install.back')"></span>
</button>
<button class="neu-btn neu-btn--primary" type="button"
@click="step < totalSteps ? nextStep() : finish()"
:disabled="loading">
<span x-show="!loading && step < totalSteps" x-text="t('install.next')"></span>
<span x-show="!loading && step === totalSteps" x-text="t('install.finish')"></span>
<span x-show="loading" class="spinner"></span>
</button>
</div>
</div>
</div>
</body>
</html>

1317
frontend/js/app.js Normal file

File diff suppressed because it is too large Load diff

110
frontend/js/terminal.js Normal file
View file

@ -0,0 +1,110 @@
/**
* ProxmoxPanel Terminal page logic
* xterm.js + WebSocket PTY
* Chargé uniquement sur terminal.html
*/
document.addEventListener('DOMContentLoaded', () => {
// Les classes Terminal et FitAddon sont exposées par xterm.iife.js
if (typeof Terminal === 'undefined' || typeof FitAddon === 'undefined') {
console.error('xterm.js not loaded')
return
}
const term = new Terminal({
cursorBlink: true,
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace',
fontSize: 14,
theme: {
background: 'var(--bg-secondary, #1a1a2e)',
foreground: 'var(--text-primary, #e2e8f0)',
cursor: 'var(--accent-primary, #6366f1)',
},
})
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
const container = document.getElementById('terminal-container')
if (!container) return
term.open(container)
fitAddon.fit()
// Connexion WebSocket PTY
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const token = localStorage.getItem('pxp_token')
const ws = new WebSocket(`${proto}://${location.host}/ws/terminal?token=${encodeURIComponent(token || '')}`)
ws.binaryType = 'arraybuffer'
const statusEl = document.getElementById('terminal-status')
function setStatus(text, cls) {
if (statusEl) {
statusEl.textContent = text
statusEl.className = 'terminal-status ' + (cls || '')
}
}
setStatus('Connexion…', 'connecting')
ws.onopen = () => {
setStatus('Connecté', 'connected')
// Envoyer la taille initiale du terminal
sendResize()
}
ws.onmessage = (e) => {
if (e.data instanceof ArrayBuffer) {
term.write(new Uint8Array(e.data))
} else {
term.write(e.data)
}
}
ws.onclose = () => {
setStatus('Déconnecté', 'disconnected')
term.writeln('\r\n\x1b[31m[Connexion terminée]\x1b[0m')
}
ws.onerror = () => {
setStatus('Erreur', 'error')
}
// Envoyer l'input utilisateur au serveur
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data)
}
})
// Envoyer la taille du terminal lors du redimensionnement
function sendResize() {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'resize',
cols: term.cols,
rows: term.rows,
}))
}
}
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit()
sendResize()
})
resizeObserver.observe(container)
// Nettoyage quand Swup navigue hors de la page
document.addEventListener('swup:page:view', () => {
if (!document.getElementById('terminal-container')) {
ws.close()
resizeObserver.disconnect()
}
})
// Bouton "Effacer"
const clearBtn = document.getElementById('terminal-clear')
if (clearBtn) {
clearBtn.addEventListener('click', () => term.clear())
}
})

86
frontend/js/ws.sw.js Normal file
View file

@ -0,0 +1,86 @@
/**
* ProxmoxPanel WebSocket Service Worker
* Maintient les connexions WS en vie entre les navigations Swup.
* Les pages s'abonnent/désabonnent via postMessage.
*/
'use strict'
// channel → { ws: WebSocket|null, url: string, clientIds: Set<string>, retryDelay: number }
const connections = new Map()
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', e =>
e.waitUntil(self.clients.claim())
)
self.addEventListener('message', event => {
const { type, channel, url, payload } = event.data || {}
if (!channel) return
const clientId = event.source?.id
if (!clientId) return
switch (type) {
case 'WS_SUBSCRIBE': {
let conn = connections.get(channel)
if (!conn) {
conn = { ws: null, url: null, clientIds: new Set(), retryDelay: 2000 }
connections.set(channel, conn)
}
conn.clientIds.add(clientId)
if (url) conn.url = url
ensureWS(channel)
break
}
case 'WS_UNSUBSCRIBE': {
const conn = connections.get(channel)
if (conn) conn.clientIds.delete(clientId)
break
}
case 'WS_SEND': {
const conn = connections.get(channel)
if (conn?.ws?.readyState === 1) conn.ws.send(payload)
break
}
}
})
function ensureWS(channel) {
const conn = connections.get(channel)
if (!conn?.url) return
if (conn.ws && conn.ws.readyState < 2) return // CONNECTING (0) ou OPEN (1)
const ws = new WebSocket(conn.url)
conn.ws = ws
conn.retryDelay = 2000
notify(channel, 'WS_STATUS', { status: 'connecting' })
ws.onopen = () => {
conn.retryDelay = 2000
notify(channel, 'WS_STATUS', { status: 'connected' })
}
ws.onmessage = e => notify(channel, 'WS_MESSAGE', { data: e.data })
ws.onerror = () => notify(channel, 'WS_STATUS', { status: 'error' })
ws.onclose = () => {
notify(channel, 'WS_STATUS', { status: 'disconnected' })
// Backoff exponentiel plafonné à 30s
const delay = conn.retryDelay
conn.retryDelay = Math.min(conn.retryDelay * 1.5, 30000)
setTimeout(() => ensureWS(channel), delay)
}
}
async function notify(channel, type, extra) {
const conn = connections.get(channel)
if (!conn || conn.clientIds.size === 0) return
const allClients = await self.clients.matchAll({ type: 'window' })
for (const client of allClients) {
if (conn.clientIds.has(client.id)) {
client.postMessage({ channel, type, ...extra })
}
}
}

View file

@ -8,7 +8,8 @@
"logs": "Logs",
"services": "Services",
"settings": "Settings",
"modules": "Modules"
"modules": "Modules",
"profile": "Profile"
},
"navbar": {
"darkMode": "Dark mode",
@ -114,6 +115,34 @@
"desc": "SFTP file browser",
"moduleNotEnabled": "Module not enabled. Go to Settings → Modules to enable it."
},
"services": {
"desc": "systemd service management",
"target": "Target",
"filter": "Filter by name or description…",
"noServices": "No services found",
"name": "Service",
"status": "Status",
"substate": "Sub-state",
"description": "Description",
"start": "Start",
"stop": "Stop",
"restart": "Restart",
"reload": "Reload",
"enable": "Enable",
"disable": "Disable"
},
"logs": {
"desc": "System journal viewer via journalctl",
"target": "Target",
"unit": "Unit",
"unitAll": "All units",
"lines": "Lines",
"follow": "Follow live",
"stopFollow": "Stop",
"clear": "Clear",
"noLogs": "No logs — click «Follow» to start",
"connecting": "Connecting…"
},
"settings": {
"general": "General",
"infrastructure": "Infrastructure",

View file

@ -8,7 +8,8 @@
"logs": "Journaux",
"services": "Services",
"settings": "Paramètres",
"modules": "Modules"
"modules": "Modules",
"profile": "Profil"
},
"navbar": {
"darkMode": "Mode sombre",
@ -114,6 +115,34 @@
"desc": "Navigateur de fichiers SFTP",
"moduleNotEnabled": "Module non activé. Rendez-vous dans Paramètres → Modules pour l'activer."
},
"services": {
"desc": "Gestion des services systemd",
"target": "Cible",
"filter": "Filtrer par nom ou description…",
"noServices": "Aucun service trouvé",
"name": "Service",
"status": "Statut",
"substate": "Sous-état",
"description": "Description",
"start": "Démarrer",
"stop": "Arrêter",
"restart": "Redémarrer",
"reload": "Recharger",
"enable": "Activer",
"disable": "Désactiver"
},
"logs": {
"desc": "Consultation des journaux système via journalctl",
"target": "Cible",
"unit": "Unité",
"unitAll": "Toutes les unités",
"lines": "Lignes",
"follow": "Suivre en temps réel",
"stopFollow": "Arrêter",
"clear": "Effacer",
"noLogs": "Aucun journal — cliquez sur « Suivre » pour démarrer",
"connecting": "Connexion en cours…"
},
"settings": {
"general": "Général",
"infrastructure": "Infrastructure",

64
frontend/login.html Normal file
View file

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<script>(function(){document.documentElement.setAttribute("data-theme",localStorage.getItem("pxp_theme")||"dark")})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxmoxPanel — Connexion</title>
<link rel="stylesheet" href="/css/neu.css" />
<link rel="stylesheet" href="/css/dark.css" />
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/pages.css" />
<link rel="stylesheet" href="/css/lineicons-duotone.css" />
<link rel="manifest" href="/manifest.json" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body x-data>
<div class="auth-layout" x-data="loginPage()" x-cloak>
<div class="auth-card neu-card">
<div class="auth-logo">
<i class="logo-icon lnid-layout-1"></i>
<h1 class="auth-title">ProxmoxPanel</h1>
<p class="auth-subtitle" x-text="t('login.subtitle')"></p>
</div>
<form @submit.prevent="submit" class="auth-form">
<div class="form-group">
<label class="form-label" x-text="t('login.username')"></label>
<input
class="neu-input"
type="text"
x-model="username"
:placeholder="t('login.usernamePlaceholder')"
autocomplete="username"
required
/>
</div>
<div class="form-group">
<label class="form-label" x-text="t('login.password')"></label>
<input
class="neu-input"
type="password"
x-model="password"
:placeholder="t('login.passwordPlaceholder')"
autocomplete="current-password"
required
/>
</div>
<div class="form-error" x-show="error" x-text="error" role="alert"></div>
<button class="neu-btn neu-btn--primary auth-submit" type="submit" :disabled="loading">
<span x-show="!loading" x-text="t('login.submit')"></span>
<span x-show="loading" class="spinner"></span>
</button>
</form>
</div>
</div>
</body>
</html>

17
frontend/manifest.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "ProxmoxPanel",
"short_name": "PxPanel",
"description": "Interface de gestion Proxmox",
"start_url": "/dashboard.html",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#6c8ef4",
"icons": [
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

158
frontend/modules.html Normal file
View file

@ -0,0 +1,158 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<script>(function(){document.documentElement.setAttribute("data-theme",localStorage.getItem("pxp_theme")||"dark");document.documentElement.setAttribute("data-sidebar",localStorage.getItem("pxp_sidebar_pos")||"left")})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxmoxPanel — Modules</title>
<link rel="stylesheet" href="/css/neu.css" />
<link rel="stylesheet" href="/css/dark.css" />
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/pages.css" />
<link rel="stylesheet" href="/css/lineicons-duotone.css" />
<link rel="manifest" href="/manifest.json" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header" @click="toggle()">
<i class="sidebar-logo lnid-layout-1"></i>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">
<a class="sidebar-link" :class="{ active: isActive(item.id) }" :href="item.href" @click.prevent="navigate(item.href)">
<i class="sidebar-icon" :class="item.iconClass" :style="iconStyle(item)"></i>
<span class="sidebar-label" x-show="!collapsed" x-text="t(item.labelKey)"></span>
</a>
</template>
</nav>
<div class="sidebar-footer">
<a class="sidebar-link" href="/profile.html" @click.prevent="navigate('/profile.html')">
<i class="sidebar-icon lnid-user-circle-1"></i>
<span class="sidebar-label" x-show="!collapsed" x-text="$store.auth.user?.username || t('nav.profile')"></span>
</a>
</div>
</aside>
<div class="main-layout">
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.modules')"></h2>
<div class="navbar-actions">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<i :class="theme==='dark' ? 'lnid-sun-1' : 'lnid-moon-half-left-1'"></i>
</button>
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="logout()" :title="t('navbar.logout')">
<i class="lnid-power-button"></i>
</button>
</div>
</nav>
<main id="swup" class="page-content transition-fade">
<div x-data="modulesPage()" x-cloak>
<!-- Modules installés -->
<h3 class="section-title"><i class="lnid-puzzle"></i> Modules installés</h3>
<div class="loading-state" x-show="loading">
<div class="spinner-lg"></div><span>Chargement…</span>
</div>
<div class="modules-grid" x-show="!loading">
<template x-for="mod in modules" :key="mod.id">
<div class="neu-card module-card" :class="{ disabled: !mod.is_enabled }">
<div class="module-header">
<div class="module-icon">
<i :class="mod.nav_icon || 'lnid-puzzle'"></i>
</div>
<div class="module-info">
<span class="module-name" x-text="mod.name || mod.id"></span>
<span class="module-version" x-text="mod.version"></span>
<span class="module-desc" x-text="mod.description"></span>
</div>
<div class="module-toggle">
<span class="core-badge" x-show="mod.is_core">CORE</span>
<button class="toggle-btn" :class="{ on: mod.is_enabled }"
@click="toggle(mod)" :disabled="mod.is_core || toggling[mod.id] === true"
x-show="!mod.is_core">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-label" x-text="mod.is_enabled ? 'Activé' : 'Désactivé'"></span>
</button>
</div>
</div>
</div>
</template>
<p class="empty-state" x-show="modules.length === 0">Aucun module installé</p>
</div>
<!-- Store : modules disponibles -->
<div style="display:flex;align-items:center;gap:.75rem;margin-top:2rem">
<h3 class="section-title" style="margin:0"><i class="lnid-download-2"></i> Store</h3>
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="loadStore()" :disabled="storeLoading" title="Rafraîchir le store">
<i class="lnid-refresh-circle-1-clockwise" :class="{ 'spin': storeLoading }"></i>
</button>
</div>
<p class="section-desc">Modules disponibles depuis <a href="https://git.geronzi.fr/proxmoxPanel" target="_blank">git.geronzi.fr/proxmoxPanel</a></p>
<!-- Erreur store -->
<div class="neu-card" x-show="storeError && !storeLoading"
style="margin-bottom:1rem;border-left:3px solid var(--neu-danger);display:flex;align-items:center;gap:.75rem">
<i class="lnid-warning-circle-1" style="color:var(--neu-danger)"></i>
<span x-text="storeError"></span>
</div>
<div class="loading-state" x-show="storeLoading">
<div class="spinner-lg"></div><span>Chargement du store…</span>
</div>
<div class="modules-grid" x-show="!storeLoading">
<template x-for="mod in storeModules" :key="mod.id">
<div class="neu-card module-card" x-show="!mod.installed">
<div class="module-header">
<div class="module-icon"><i class="lnid-puzzle"></i></div>
<div class="module-info">
<span class="module-name" x-text="mod.id"></span>
<span class="module-desc" x-text="mod.description || '—'"></span>
<a class="module-repo-link" :href="mod.repo_url" target="_blank" x-text="mod.repo_url"></a>
</div>
<div class="module-toggle">
<button class="neu-btn neu-btn--sm neu-btn--primary"
@click="install(mod)"
:disabled="installing[mod.id] === true">
<span x-show="installing[mod.id] === true" class="spinner-sm"></span>
<i x-show="installing[mod.id] !== true" :class="mod.installed ? 'lnid-refresh-circle-1-clockwise' : 'lnid-download-2'"></i>
<span x-text="mod.installed ? 'Réinstaller' : 'Installer'"></span>
</button>
</div>
</div>
</div>
</template>
<p class="empty-state" x-show="!storeLoading && !storeError && storeModules.length === 0">
Aucun module disponible dans le store
</p>
</div>
<!-- Bannière rebuild en cours -->
<div class="neu-card rebuild-notice" x-show="rebuilding"
style="margin-top:1rem;border-left:3px solid var(--neu-accent);display:flex;align-items:center;gap:.75rem">
<div class="spinner-sm" style="flex-shrink:0"></div>
<span>Rebuild du container en cours (~1-2 min) — l'interface redémarrera automatiquement.</span>
</div>
<!-- Rebuild terminé (backend revenu) -->
<div class="neu-card rebuild-notice" x-show="rebuildDone"
style="margin-top:1rem;border-left:3px solid var(--neu-success);display:flex;align-items:center;gap:.75rem">
<i class="lnid-circle-check-1" style="color:var(--neu-success)"></i>
<span>Rebuild terminé. <button class="neu-btn neu-btn--sm" @click="window.location.reload()">Recharger la page</button></span>
</div>
</div>
</main>
</div>
</body>
</html>

View file

@ -1,6 +1,3 @@
# Configuration Nginx pour le frontend ProxmoxPanel
# Sert les fichiers statiques et proxy les requêtes API/WebSocket vers le backend Go
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
@ -14,7 +11,6 @@ http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logs au format JSON pour faciliter l'analyse
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent"';
@ -23,7 +19,6 @@ http {
sendfile on;
keepalive_timeout 65;
# Compression gzip pour les assets statiques
gzip on;
gzip_vary on;
gzip_min_length 1024;
@ -36,13 +31,13 @@ http {
root /usr/share/nginx/html;
index index.html;
# Cache agressif pour les assets avec hash dans le nom (Vite)
# Cache agressif pour JS/CSS
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Proxy des requêtes API vers le backend Go
# Proxy API backend Go
location /api/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
@ -50,10 +45,10 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s; # Timeout long pour les mises à jour apt
proxy_read_timeout 300s;
}
# Proxy des connexions WebSocket
# Proxy WebSocket backend Go
location /ws/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
@ -61,13 +56,32 @@ http {
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s; # 1 heure pour les sessions terminal
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# SPA : toutes les autres routes servent index.html (Vue Router)
# Proxy modules backend Go
location /viewLogs/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /viewServices/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# URLs propres : /dashboard /dashboard.html
location / {
try_files $uri $uri/ /index.html;
try_files $uri $uri.html $uri/ /index.html;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,37 +1,15 @@
{
"name": "proxmoxpanel-frontend",
"version": "1.0.0",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"pinia": "^2.3.0",
"vue-i18n": "^11.0.0",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-attach": "^0.11.0",
"@codemirror/state": "^6.5.1",
"@codemirror/view": "^6.36.3",
"@codemirror/commands": "^6.8.0",
"@codemirror/language": "^6.10.8",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.10",
"@codemirror/lang-python": "^6.1.7",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/theme-one-dark": "^6.1.2",
"vue-draggable-plus": "^0.6.0"
"build": "node build.mjs"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.3",
"vite": "^6.3.3",
"vue-tsc": "^2.2.10"
"swup": "^4.8.0",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0",
"esbuild": "^0.24.0"
}
}

198
frontend/profile.html Normal file
View file

@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<script>(function(){document.documentElement.setAttribute("data-theme",localStorage.getItem("pxp_theme")||"dark");document.documentElement.setAttribute("data-sidebar",localStorage.getItem("pxp_sidebar_pos")||"left")})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxmoxPanel — Profil</title>
<link rel="stylesheet" href="/css/neu.css" />
<link rel="stylesheet" href="/css/dark.css" />
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/pages.css" />
<link rel="stylesheet" href="/css/lineicons-duotone.css" />
<link rel="manifest" href="/manifest.json" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header" @click="toggle()">
<i class="sidebar-logo lnid-layout-1"></i>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">
<a class="sidebar-link" :class="{ active: isActive(item.id) }" :href="item.href" @click.prevent="navigate(item.href)">
<i class="sidebar-icon" :class="item.iconClass" :style="iconStyle(item)"></i>
<span class="sidebar-label" x-show="!collapsed" x-text="t(item.labelKey)"></span>
</a>
</template>
</nav>
<div class="sidebar-footer">
<a class="sidebar-link active" href="/profile.html" @click.prevent="navigate('/profile.html')">
<i class="sidebar-icon lnid-user-circle-1"></i>
<span class="sidebar-label" x-show="!collapsed" x-text="$store.auth.user?.username || t('nav.profile')"></span>
</a>
</div>
</aside>
<div class="main-layout">
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.profile')"></h2>
<div class="navbar-actions">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<i :class="theme==='dark' ? 'lnid-sun-1' : 'lnid-moon-half-left-1'"></i>
</button>
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="logout()" :title="t('navbar.logout')">
<i class="lnid-power-button"></i>
</button>
</div>
</nav>
<main id="swup" class="page-content transition-fade">
<div x-data="profilePage()" x-cloak>
<!-- Informations du compte -->
<div class="neu-card settings-section" x-show="user">
<h3 class="section-title">
<i class="lnid-user-circle-1"></i>
Compte
</h3>
<div class="profile-info">
<div class="profile-row">
<span class="profile-label">Utilisateur</span>
<span class="profile-value" x-text="user?.username"></span>
</div>
<div class="profile-row">
<span class="profile-label">Rôle</span>
<span class="profile-value">
<span class="badge" :class="user?.is_admin ? 'badge-admin' : 'badge-user'"
x-text="user?.is_admin ? 'Administrateur' : 'Utilisateur'"></span>
</span>
</div>
</div>
</div>
<!-- Apparence -->
<div class="neu-card settings-section">
<h3 class="section-title">
<i class="lnid-sun-1"></i>
Apparence
</h3>
<div class="form-group">
<label class="form-label">Thème</label>
<div class="btn-group">
<button class="neu-btn"
:class="{ 'neu-btn--primary': theme === 'dark' }"
@click="setTheme('dark')">
<i class="lnid-moon-half-left-1"></i> Sombre
</button>
<button class="neu-btn"
:class="{ 'neu-btn--primary': theme === 'light' }"
@click="setTheme('light')">
<i class="lnid-sun-1"></i> Clair
</button>
</div>
</div>
<div class="form-group">
<label class="form-label" x-text="t('settings.sidebarPosition')"></label>
<div class="btn-group">
<button class="neu-btn"
:class="{ 'neu-btn--primary': sidebarPosition === 'left' }"
@click="setSidebarPosition('left')">
<i class="lnid-layout-1"></i> <span x-text="t('settings.left')"></span>
</button>
<button class="neu-btn"
:class="{ 'neu-btn--primary': sidebarPosition === 'right' }"
@click="setSidebarPosition('right')">
<i class="lnid-layout-2"></i> <span x-text="t('settings.right')"></span>
</button>
</div>
</div>
</div>
<!-- Langue -->
<div class="neu-card settings-section">
<h3 class="section-title">
<i class="lnid-gear-1"></i>
Langue
</h3>
<div class="form-group">
<label class="form-label" x-text="t('settings.defaultLang')"></label>
<div class="btn-group">
<button class="neu-btn"
:class="{ 'neu-btn--primary': lang === 'fr' }"
@click="setLang('fr')">
Français
</button>
<button class="neu-btn"
:class="{ 'neu-btn--primary': lang === 'en' }"
@click="setLang('en')">
English
</button>
</div>
</div>
</div>
<!-- Sessions actives -->
<div class="neu-card settings-section">
<h3 class="section-title">
<i class="lnid-key-1"></i>
Sessions actives
</h3>
<div class="loading-state" x-show="sessionsLoading">
<div class="spinner-lg"></div>
</div>
<div x-show="!sessionsLoading">
<template x-if="sessions.length === 0">
<p class="empty-state">Aucune session active</p>
</template>
<template x-for="s in sessions" :key="s.id">
<div class="session-row" :class="{ 'session-current': s.is_current }">
<div class="session-info">
<div class="session-browser">
<i class="lnid-laptop-1"></i>
<span x-text="parseUA(s.user_agent)"></span>
<span class="badge badge-current" x-show="s.is_current">Session actuelle</span>
</div>
<div class="session-meta">
<span class="session-ip" x-text="s.ip || '—'"></span>
<span class="session-sep">·</span>
<span class="session-date" x-text="formatDate(s.last_used_at || s.created_at)"></span>
<span class="session-sep">·</span>
<span class="session-id" x-text="'#' + s.id"></span>
</div>
</div>
<button class="neu-btn neu-btn--sm neu-btn--danger neu-btn--icon-sm"
@click="revokeSession(s.id)"
:disabled="isRevoking(s.id) || s.is_current"
:title="s.is_current ? 'Utilisez le bouton Déconnexion pour cette session' : 'Révoquer cette session'">
<i x-show="!isRevoking(s.id)" class="lnid-cross"></i>
<span x-show="isRevoking(s.id)" class="spinner-sm"></span>
</button>
</div>
</template>
</div>
</div>
<!-- Déconnexion -->
<div class="neu-card settings-section">
<button class="neu-btn neu-btn--danger" @click="$store.auth.logout()">
<i class="lnid-power-button"></i>
<span x-text="t('navbar.logout')"></span>
</button>
</div>
</div>
</main>
</div>
</body>
</html>

141
frontend/proxmox.html Normal file
View file

@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<script>(function(){document.documentElement.setAttribute("data-theme",localStorage.getItem("pxp_theme")||"dark");document.documentElement.setAttribute("data-sidebar",localStorage.getItem("pxp_sidebar_pos")||"left")})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxmoxPanel — Proxmox</title>
<link rel="stylesheet" href="/css/neu.css" />
<link rel="stylesheet" href="/css/dark.css" />
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/pages.css" />
<link rel="stylesheet" href="/css/lineicons-duotone.css" />
<link rel="manifest" href="/manifest.json" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header" @click="toggle()">
<i class="sidebar-logo lnid-layout-1"></i>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">
<a class="sidebar-link" :class="{ active: isActive(item.id) }" :href="item.href" @click.prevent="navigate(item.href)">
<i class="sidebar-icon" :class="item.iconClass" :style="iconStyle(item)"></i>
<span class="sidebar-label" x-show="!collapsed" x-text="t(item.labelKey)"></span>
</a>
</template>
</nav>
<div class="sidebar-footer">
<a class="sidebar-link" href="/profile.html" @click.prevent="navigate('/profile.html')">
<i class="sidebar-icon lnid-user-circle-1"></i>
<span class="sidebar-label" x-show="!collapsed" x-text="$store.auth.user?.username || t('nav.profile')"></span>
</a>
</div>
</aside>
<div class="main-layout">
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.proxmox')"></h2>
<div class="navbar-actions">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<i :class="theme==='dark' ? 'lnid-sun-1' : 'lnid-moon-half-left-1'"></i>
</button>
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="logout()" :title="t('navbar.logout')">
<i class="lnid-power-button"></i>
</button>
</div>
</nav>
<main id="swup" class="page-content transition-fade">
<div x-data="proxmoxPage()" x-cloak>
<div class="ws-status" :class="wsStatus">
<span x-show="wsStatus==='connecting'">⌛ Connexion WebSocket…</span>
<span x-show="wsStatus==='connected'" style="color:var(--neu-success)">● Live</span>
<span x-show="wsStatus==='ok'" style="color:var(--neu-success)">● Live</span>
<span x-show="wsStatus==='disconnected'" style="color:var(--neu-warning)">⚠ Reconnexion…</span>
<span x-show="wsStatus==='error'" style="color:var(--neu-danger)">✗ Erreur WebSocket</span>
</div>
<!-- LXC -->
<div class="section">
<h3 class="section-title">Containers LXC</h3>
<div class="resource-grid">
<template x-for="r in resources.filter(r=>r.type==='lxc')" :key="r.vmid">
<div class="neu-card resource-card" :class="r.status">
<div class="resource-header">
<span class="resource-name" x-text="r.name||'LXC '+r.vmid"></span>
<span class="badge" :class="r.status" x-text="r.status"></span>
</div>
<div class="resource-metrics" x-show="r.status==='running'">
<div class="metric">
<span class="metric-label">CPU</span>
<div class="metric-bar"><div class="metric-fill" :style="'width:'+Math.round((r.cpu||0)*100)+'%;background:'+cpuColor(Math.round((r.cpu||0)*100))"></div></div>
<span class="metric-val" x-text="Math.round((r.cpu||0)*100)+'%'"></span>
</div>
<div class="metric">
<span class="metric-label">RAM</span>
<div class="metric-bar"><div class="metric-fill" :style="'width:'+Math.round((r.mem||0)/(r.maxmem||1)*100)+'%'"></div></div>
<span class="metric-val" x-text="formatMem(r.mem)"></span>
</div>
</div>
<div class="resource-actions">
<button class="neu-btn neu-btn--sm neu-btn--success" x-show="r.status!=='running'"
@click="action(r.vmid,'lxc','start')" :disabled="actionLoading[r.vmid+'-start']">
<i class="lnid-play"></i> <span x-text="t('proxmox.start')"></span>
</button>
<button class="neu-btn neu-btn--sm neu-btn--danger" x-show="r.status==='running'"
@click="action(r.vmid,'lxc','stop')" :disabled="actionLoading[r.vmid+'-stop']">
<i class="lnid-stop"></i> <span x-text="t('proxmox.stop')"></span>
</button>
</div>
</div>
</template>
</div>
<p class="empty-state" x-show="resources.filter(r=>r.type==='lxc').length===0&&wsStatus!=='connecting'">Aucun container LXC</p>
</div>
<!-- VMs -->
<div class="section">
<h3 class="section-title">Machines virtuelles</h3>
<div class="resource-grid">
<template x-for="r in resources.filter(r=>r.type==='qemu')" :key="r.vmid">
<div class="neu-card resource-card" :class="r.status">
<div class="resource-header">
<span class="resource-name" x-text="r.name||'VM '+r.vmid"></span>
<span class="badge" :class="r.status" x-text="r.status"></span>
</div>
<div class="resource-metrics" x-show="r.status==='running'">
<div class="metric">
<span class="metric-label">CPU</span>
<div class="metric-bar"><div class="metric-fill" :style="'width:'+Math.round((r.cpu||0)*100)+'%;background:'+cpuColor(Math.round((r.cpu||0)*100))"></div></div>
<span class="metric-val" x-text="Math.round((r.cpu||0)*100)+'%'"></span>
</div>
</div>
<div class="resource-actions">
<button class="neu-btn neu-btn--sm neu-btn--success" x-show="r.status!=='running'"
@click="action(r.vmid,'qemu','start')" :disabled="actionLoading[r.vmid+'-start']">
<i class="lnid-play"></i> <span x-text="t('proxmox.start')"></span>
</button>
<button class="neu-btn neu-btn--sm neu-btn--danger" x-show="r.status==='running'"
@click="action(r.vmid,'qemu','stop')" :disabled="actionLoading[r.vmid+'-stop']">
<i class="lnid-stop"></i> <span x-text="t('proxmox.stop')"></span>
</button>
</div>
</div>
</template>
</div>
<p class="empty-state" x-show="resources.filter(r=>r.type==='qemu').length===0&&wsStatus!=='connecting'">Aucune VM</p>
</div>
</div>
</main>
</div>
</body>
</html>

238
frontend/settings.html Normal file
View file

@ -0,0 +1,238 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<script>(function(){document.documentElement.setAttribute("data-theme",localStorage.getItem("pxp_theme")||"dark");document.documentElement.setAttribute("data-sidebar",localStorage.getItem("pxp_sidebar_pos")||"left")})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxmoxPanel — Paramètres</title>
<link rel="stylesheet" href="/css/neu.css" />
<link rel="stylesheet" href="/css/dark.css" />
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/pages.css" />
<link rel="stylesheet" href="/css/lineicons-duotone.css" />
<link rel="manifest" href="/manifest.json" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header" @click="toggle()">
<i class="sidebar-logo lnid-layout-1"></i>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">
<a class="sidebar-link" :class="{ active: isActive(item.id) }" :href="item.href" @click.prevent="navigate(item.href)">
<i class="sidebar-icon" :class="item.iconClass" :style="iconStyle(item)"></i>
<span class="sidebar-label" x-show="!collapsed" x-text="t(item.labelKey)"></span>
</a>
</template>
</nav>
<div class="sidebar-footer">
<a class="sidebar-link" href="/profile.html" @click.prevent="navigate('/profile.html')">
<i class="sidebar-icon lnid-user-circle-1"></i>
<span class="sidebar-label" x-show="!collapsed" x-text="$store.auth.user?.username || t('nav.profile')"></span>
</a>
</div>
</aside>
<div class="main-layout">
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.settings')"></h2>
<div class="navbar-actions">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<i :class="theme==='dark' ? 'lnid-sun-1' : 'lnid-moon-half-left-1'"></i>
</button>
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="logout()" :title="t('navbar.logout')">
<i class="lnid-power-button"></i>
</button>
</div>
</nav>
<main id="swup" class="page-content transition-fade">
<div x-data="settingsPage()" x-cloak>
<!-- Loading -->
<div class="loading-state" x-show="loading">
<div class="spinner-lg"></div>
<span>Chargement…</span>
</div>
<template x-if="!loading">
<div>
<!-- Tabs -->
<div class="tabs">
<button class="tab-btn" :class="{ active: tab === 'general' }" @click="tab = 'general'">Général</button>
<button class="tab-btn" :class="{ active: tab === 'ssh' }" @click="tab = 'ssh'">SSH</button>
<button class="tab-btn" :class="{ active: tab === 'proxmox' }" @click="tab = 'proxmox'">Proxmox API</button>
<button class="tab-btn" :class="{ active: tab === 'shortcuts' }" @click="tab = 'shortcuts'">
<i class="lnid-link-1"></i> Raccourcis
</button>
<button class="tab-btn" :class="{ active: tab === 'repair' }" @click="tab = 'repair'; loadRepair()">
<i class="lnid-wrench-1"></i> Réparation
</button>
</div>
<!-- Général -->
<div class="tab-panel" x-show="tab === 'general'">
<div class="form-grid">
<div class="form-group">
<label class="form-label" x-text="t('install.instanceName')"></label>
<input class="neu-input" type="text" x-model="settings.instance_name" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.publicUrl')"></label>
<input class="neu-input" type="url" x-model="settings.public_url" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.defaultLang')"></label>
<select class="neu-input" x-model="settings.default_lang">
<option value="fr">Français</option>
<option value="en">English</option>
</select>
</div>
</div>
</div>
<!-- SSH -->
<div class="tab-panel" x-show="tab === 'ssh'">
<div class="form-grid">
<div class="form-group">
<label class="form-label" x-text="t('install.sshHost')"></label>
<input class="neu-input" type="text" x-model="settings.ssh_host" placeholder="host:port" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.sshUsername')"></label>
<input class="neu-input" type="text" x-model="settings.ssh_username" />
</div>
<div class="form-group">
<label class="form-label" x-text="t('install.sshPassword')"></label>
<input class="neu-input" type="password" x-model="settings.ssh_password"
placeholder="Laisser vide pour ne pas modifier" />
</div>
</div>
</div>
<!-- Proxmox API -->
<div class="tab-panel" x-show="tab === 'proxmox'">
<div class="form-grid">
<div class="form-group">
<label class="form-label" x-text="t('install.proxmoxUrl')"></label>
<input class="neu-input" type="url" x-model="settings.proxmox_url" />
</div>
<div class="form-group" style="grid-column: 1 / -1">
<label class="form-label">Token API Proxmox</label>
<input class="neu-input" type="text" x-model="settings.proxmox_token"
placeholder="Laisser vide pour ne pas modifier — format: user@realm!tokenid=secret" />
<span style="font-size:.75rem;color:var(--neu-text-muted)">
Exemple : enzo@pam!panel=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
</span>
</div>
</div>
</div>
<!-- Raccourcis dashboard -->
<div class="tab-panel" x-show="tab === 'shortcuts'">
<p class="form-hint" style="margin-bottom:1rem">
Ces raccourcis apparaissent dans le widget "Raccourcis" du dashboard.
Laisser vide pour utiliser les raccourcis par défaut.
</p>
<div class="shortcuts-editor">
<template x-for="(s, idx) in shortcuts" :key="idx">
<div class="shortcut-row">
<select class="neu-input shortcut-icon-sel" x-model="s.icon">
<option value="lnid-link-1">Lien</option>
<option value="lnid-server-1">Serveur</option>
<option value="lnid-terminal">Terminal</option>
<option value="lnid-arrow-upward">Mises à jour</option>
<option value="lnid-gear-1">Paramètres</option>
<option value="lnid-dashboard-square-1">Dashboard</option>
<option value="lnid-puzzle">Module</option>
<option value="lnid-folder-1">Fichiers</option>
<option value="lnid-check-circle-1">Succès</option>
</select>
<input class="neu-input shortcut-label" type="text" x-model="s.label" placeholder="Label" />
<input class="neu-input shortcut-href" type="text" x-model="s.href" placeholder="/page.html" />
<button class="neu-btn neu-btn--sm neu-btn--danger neu-btn--icon-sm"
@click="removeShortcut(idx)" title="Supprimer">
<i class="lnid-cross"></i>
</button>
</div>
</template>
<button class="neu-btn" @click="addShortcut()">
<i class="lnid-plus"></i> Ajouter un raccourci
</button>
</div>
<div class="save-bar" style="margin-top:1rem">
<div class="save-feedback">
<span class="save-success" x-show="saved"><i class="lnid-check-circle-1"></i> Raccourcis sauvegardés</span>
<span class="save-error" x-show="error" x-text="error"></span>
</div>
<button class="neu-btn neu-btn--primary" @click="saveShortcuts()" :disabled="saving">
<span x-show="!saving"><i class="lnid-check-circle-1"></i> Sauvegarder</span>
<span x-show="saving"><span class="spinner-sm"></span></span>
</button>
</div>
</div>
<!-- Réparation -->
<div class="tab-panel" x-show="tab === 'repair'">
<p class="form-hint" style="margin-bottom:1rem">
Les <strong>modules fantômes</strong> sont des modules présents en base de données
mais dont le code n'est pas chargé. Les supprimer permet de les réinstaller proprement.
</p>
<div class="loading-state" x-show="repairLoading">
<div class="spinner-lg"></div><span>Chargement…</span>
</div>
<div x-show="!repairLoading">
<p class="empty-state" x-show="repairModules.length === 0">Aucun module non-core en base de données.</p>
<div class="modules-grid" x-show="repairModules.length > 0">
<template x-for="mod in repairModules" :key="mod.id">
<div class="neu-card module-card">
<div class="module-header">
<div class="module-icon"><i class="lnid-puzzle"></i></div>
<div class="module-info">
<span class="module-name" x-text="mod.name || mod.id"></span>
<span class="module-version" x-text="mod.is_enabled ? 'Activé' : 'Désactivé'"></span>
<span class="module-desc" x-text="mod.installed_at ? 'Installé le ' + mod.installed_at.slice(0,10) : 'Date inconnue'"></span>
</div>
<div class="module-toggle">
<button class="neu-btn neu-btn--sm neu-btn--danger"
@click="resetModule(mod)"
:disabled="resetting[mod.id]">
<span x-show="resetting[mod.id]" class="spinner-sm"></span>
<i x-show="!resetting[mod.id]" class="lnid-trash-1"></i>
Supprimer de la DB
</button>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- Save actions -->
<div class="save-bar" x-show="tab !== 'shortcuts' && tab !== 'repair'">
<div class="save-feedback">
<span class="save-success" x-show="saved">
<i class="lnid-check-circle-1"></i> Paramètres sauvegardés
</span>
<span class="save-error" x-show="error" x-text="error"></span>
</div>
<button class="neu-btn neu-btn--primary" @click="save()" :disabled="saving">
<span x-show="!saving"><i class="lnid-check-circle-1"></i> Sauvegarder</span>
<span x-show="saving"><span class="spinner-sm"></span></span>
</button>
</div>
</div>
</template>
</div>
</main>
</div>
</body>
</html>

View file

@ -1,49 +0,0 @@
<template>
<!-- Applique le thème (dark/light) et la position de la sidebar via data-attributes -->
<div
:data-theme="uiStore.theme"
:data-sidebar="uiStore.sidebarPosition"
class="app-root"
>
<RouterView />
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { useUiStore } from '@/stores/ui.store'
import { useAuthStore } from '@/stores/auth.store'
const uiStore = useUiStore()
const authStore = useAuthStore()
onMounted(async () => {
// Restaurer le thème depuis localStorage
uiStore.initTheme()
// Tenter de restaurer la session (refresh token via cookie httpOnly)
await authStore.tryRefresh()
})
</script>
<style>
/* Reset minimal — pas de framework CSS externe */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #app, .app-root {
height: 100%;
min-height: 100vh;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
font-size: 14px;
line-height: 1.5;
transition: background-color 0.3s ease, color 0.3s ease;
}
</style>

View file

@ -1,139 +0,0 @@
<template>
<!-- Layout principal : sidebar + contenu principal -->
<div class="layout" :class="[`layout--sidebar-${uiStore.sidebarPosition}`, { 'layout--collapsed': uiStore.sidebarCollapsed }]">
<!-- Sidebar -->
<Sidebar class="layout__sidebar" />
<!-- Zone principale -->
<div class="layout__main">
<!-- Navbar supérieure -->
<Navbar class="layout__navbar" />
<!-- Contenu de la page -->
<main class="layout__content">
<RouterView v-slot="{ Component }">
<Transition name="page-fade" mode="out-in">
<component :is="Component" :key="route.path" />
</Transition>
</RouterView>
</main>
</div>
<!-- Overlay mobile -->
<div
v-if="uiStore.mobileMenuOpen"
class="layout__overlay"
@click="uiStore.mobileMenuOpen = false"
/>
</div>
</template>
<script setup lang="ts">
import { RouterView, useRoute } from 'vue-router'
import { useUiStore } from '@/stores/ui.store'
import Sidebar from './Sidebar.vue'
import Navbar from './Navbar.vue'
const uiStore = useUiStore()
const route = useRoute()
</script>
<style scoped>
.layout {
display: flex;
height: 100vh;
overflow: hidden;
background: var(--neu-bg);
}
/* Sidebar à gauche (défaut) */
.layout--sidebar-left {
flex-direction: row;
}
/* Sidebar à droite */
.layout--sidebar-right {
flex-direction: row-reverse;
}
.layout__sidebar {
flex-shrink: 0;
width: var(--sidebar-width);
transition: width 0.3s ease;
z-index: var(--z-sidebar);
}
.layout--collapsed .layout__sidebar {
width: var(--sidebar-width-collapsed);
}
.layout__main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0; /* Empêche l'overflow en flex */
}
.layout__navbar {
flex-shrink: 0;
z-index: var(--z-navbar);
}
.layout__content {
flex: 1;
overflow-y: auto;
padding: var(--neu-space-lg);
}
.layout__overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: calc(var(--z-sidebar) - 1);
}
/* Animations de transition entre pages */
.page-fade-enter-active,
.page-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.page-fade-enter-from {
opacity: 0;
transform: translateY(8px);
}
.page-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
/* Responsive mobile */
@media (max-width: 768px) {
.layout__overlay {
display: block;
}
.layout__sidebar {
position: fixed;
top: 0;
left: 0;
height: 100%;
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.layout--sidebar-right .layout__sidebar {
left: auto;
right: 0;
transform: translateX(100%);
}
.layout__content {
padding: var(--neu-space-md);
}
}
</style>

View file

@ -1,121 +0,0 @@
<template>
<header class="navbar">
<!-- Bouton hamburger (mobile) -->
<button class="neu-btn neu-btn--icon neu-btn--ghost navbar__mobile-menu" @click="uiStore.mobileMenuOpen = !uiStore.mobileMenuOpen">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<!-- Titre de la page courante -->
<h1 class="navbar__title">{{ currentPageTitle }}</h1>
<!-- Actions navbar -->
<div class="navbar__actions">
<!-- Toggle thème sombre/clair -->
<button
class="neu-btn neu-btn--icon neu-btn--ghost"
:title="uiStore.theme === 'dark' ? t('navbar.lightMode') : t('navbar.darkMode')"
@click="uiStore.toggleTheme()"
>
<!-- Icône soleil (mode clair) / lune (mode sombre) -->
<svg v-if="uiStore.theme === 'dark'" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
<svg v-else viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
<!-- Langue -->
<select class="neu-input navbar__lang-select" :value="locale" @change="changeLang(($event.target as HTMLSelectElement).value)">
<option value="fr">FR</option>
<option value="en">EN</option>
</select>
<!-- Déconnexion -->
<button class="neu-btn neu-btn--icon neu-btn--ghost" :title="t('navbar.logout')" @click="handleLogout">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useUiStore } from '@/stores/ui.store'
import { useAuthStore } from '@/stores/auth.store'
const { t, locale } = useI18n()
const uiStore = useUiStore()
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
// Titre de la page courante selon la route
const currentPageTitle = computed(() => {
const name = route.name as string
return t(`nav.${name}`, name || 'Dashboard')
})
async function handleLogout() {
await authStore.logout()
router.push('/login')
}
function changeLang(lang: string) {
locale.value = lang
localStorage.setItem('pxp_locale', lang)
authStore.updatePreferences({ lang })
}
</script>
<style scoped>
.navbar {
display: flex;
align-items: center;
gap: var(--neu-space-md);
padding: 0 var(--neu-space-lg);
height: 64px;
background: var(--neu-surface);
border-bottom: 1px solid var(--neu-border);
flex-shrink: 0;
}
.navbar__title {
flex: 1;
font-size: var(--neu-font-xl);
font-weight: 600;
color: var(--neu-text);
}
.navbar__actions {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
}
.navbar__lang-select {
width: auto;
padding: 4px 8px;
font-size: var(--neu-font-sm);
cursor: pointer;
}
.navbar__mobile-menu {
display: none;
}
@media (max-width: 768px) {
.navbar__mobile-menu {
display: flex;
}
}
</style>

View file

@ -1,292 +0,0 @@
<template>
<aside class="sidebar">
<!-- En-tête sidebar avec logo + nom instance -->
<div class="sidebar__header">
<div class="sidebar__logo">
<div class="sidebar__logo-icon neu-card">PX</div>
<Transition name="fade">
<span v-if="!uiStore.sidebarCollapsed" class="sidebar__logo-text">
{{ instanceName || 'ProxmoxPanel' }}
</span>
</Transition>
</div>
<!-- Bouton réduction sidebar -->
<button class="neu-btn neu-btn--icon neu-btn--ghost sidebar__collapse-btn" @click="uiStore.toggleSidebarCollapse()">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path v-if="!uiStore.sidebarCollapsed" d="M15 18l-6-6 6-6"/>
<path v-else d="M9 18l6-6-6-6"/>
</svg>
</button>
</div>
<!-- Navigation principale -->
<nav class="sidebar__nav">
<RouterLink
v-for="item in navItems"
:key="item.name"
:to="item.path"
class="sidebar__nav-item"
:class="{ 'sidebar__nav-item--active': isActive(item.path) }"
:title="uiStore.sidebarCollapsed ? t(item.label) : undefined"
>
<span class="sidebar__nav-icon" v-html="item.icon" />
<Transition name="fade">
<span v-if="!uiStore.sidebarCollapsed" class="sidebar__nav-label">
{{ t(item.label) }}
</span>
</Transition>
</RouterLink>
</nav>
<!-- Bas de la sidebar : infos utilisateur -->
<div class="sidebar__footer">
<div class="sidebar__user">
<div class="sidebar__user-avatar neu-card">
{{ authStore.user?.username?.[0]?.toUpperCase() || '?' }}
</div>
<Transition name="fade">
<div v-if="!uiStore.sidebarCollapsed" class="sidebar__user-info">
<div class="sidebar__user-name">{{ authStore.user?.username }}</div>
<div class="sidebar__user-role">
<span v-if="authStore.user?.is_admin" class="neu-badge neu-badge--primary">Admin</span>
<span v-else class="neu-badge neu-badge--info">User</span>
</div>
</div>
</Transition>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useUiStore } from '@/stores/ui.store'
import { useAuthStore } from '@/stores/auth.store'
const { t } = useI18n()
const uiStore = useUiStore()
const authStore = useAuthStore()
const route = useRoute()
// Récupérer le nom de l'instance depuis le localStorage (chargé au démarrage)
const instanceName = computed(() => localStorage.getItem('pxp_instance_name') || '')
// Items de navigation les modules désactivés sont filtrés par le router
const navItems = computed(() => {
const items = [
{
name: 'dashboard',
path: '/',
label: 'nav.dashboard',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>`,
},
{
name: 'proxmox',
path: '/proxmox',
label: 'nav.proxmox',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>`,
},
{
name: 'updates',
path: '/updates',
label: 'nav.updates',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>`,
},
{
name: 'terminal',
path: '/terminal',
label: 'nav.terminal',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`,
},
{
name: 'settings',
path: '/settings',
label: 'nav.settings',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
},
]
// Afficher uniquement les routes admin si l'utilisateur est admin
if (authStore.user?.is_admin) {
items.push({
name: 'modules',
path: '/modules',
label: 'nav.modules',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><path d="M14 21h7v-7h-7z"/></svg>`,
})
}
return items
})
function isActive(path: string): boolean {
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
</script>
<style scoped>
.sidebar {
height: 100%;
display: flex;
flex-direction: column;
background: var(--neu-surface);
border-right: 1px solid var(--neu-border);
overflow: hidden;
}
[data-sidebar="right"] .sidebar {
border-right: none;
border-left: 1px solid var(--neu-border);
}
.sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--neu-space-md);
height: 64px;
flex-shrink: 0;
border-bottom: 1px solid var(--neu-border);
}
.sidebar__logo {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
overflow: hidden;
}
.sidebar__logo-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12px;
color: var(--neu-primary);
flex-shrink: 0;
padding: 0;
border-radius: var(--neu-radius-sm);
}
.sidebar__logo-text {
font-size: var(--neu-font-md);
font-weight: 600;
color: var(--neu-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__collapse-btn {
flex-shrink: 0;
opacity: 0.6;
}
.sidebar__collapse-btn:hover { opacity: 1; }
.sidebar__nav {
flex: 1;
padding: var(--neu-space-sm);
overflow-y: auto;
overflow-x: hidden;
}
.sidebar__nav-item {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
padding: 10px var(--neu-space-sm);
border-radius: var(--neu-radius-md);
color: var(--neu-text-muted);
text-decoration: none;
transition: var(--neu-transition);
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
}
.sidebar__nav-item:hover {
background: var(--neu-bg);
color: var(--neu-text);
box-shadow:
3px 3px 6px var(--neu-shadow-dark),
-2px -2px 4px var(--neu-shadow-light);
}
.sidebar__nav-item--active {
background: var(--neu-bg);
color: var(--neu-primary);
box-shadow:
inset 2px 2px 5px var(--neu-shadow-dark),
inset -1px -1px 3px var(--neu-shadow-light);
}
.sidebar__nav-icon {
flex-shrink: 0;
display: flex;
align-items: center;
width: 18px;
}
.sidebar__nav-label {
font-size: var(--neu-font-md);
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__footer {
padding: var(--neu-space-sm) var(--neu-space-md);
border-top: 1px solid var(--neu-border);
}
.sidebar__user {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
overflow: hidden;
}
.sidebar__user-avatar {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
color: var(--neu-primary);
flex-shrink: 0;
padding: 0;
border-radius: 50%;
}
.sidebar__user-info {
overflow: hidden;
min-width: 0;
}
.sidebar__user-name {
font-size: var(--neu-font-sm);
font-weight: 600;
color: var(--neu-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__user-role {
margin-top: 2px;
}
/* Transitions */
.fade-enter-active,
.fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style>

View file

@ -1,37 +0,0 @@
// Point d'entrée de l'application ProxmoxPanel Frontend.
// Initialise Vue 3, Pinia, Vue Router et vue-i18n.
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import App from './App.vue'
import router from './router/index'
// Imports des fichiers de traduction (locaux, pas de CDN)
import fr from './locales/fr.json'
import en from './locales/en.json'
// Styles Neumorphism — chargés globalement
import './styles/neu.css'
import './styles/dark.css'
import './styles/light.css'
// Déterminer la locale initiale (localStorage > défaut 'fr')
const savedLocale = localStorage.getItem('pxp_locale') || 'fr'
// Initialisation vue-i18n
const i18n = createI18n({
legacy: false, // Utiliser la Composition API
locale: savedLocale,
fallbackLocale: 'en',
messages: { fr, en },
})
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.use(router)
app.use(i18n)
app.mount('#app')

View file

@ -1,129 +0,0 @@
// Configuration du routeur Vue — gère la navigation et la protection des routes.
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
const router = createRouter({
history: createWebHistory(),
routes: [
// Page d'installation (premier lancement)
{
path: '/install',
name: 'install',
component: () => import('@/views/Install.vue'),
meta: { public: true, hideLayout: true },
},
// Authentification
{
path: '/login',
name: 'login',
component: () => import('@/views/Login.vue'),
meta: { public: true, hideLayout: true },
},
// Application principale (protégée)
{
path: '/',
component: () => import('@/components/Layout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'dashboard',
component: () => import('@/views/Dashboard.vue'),
},
{
path: 'proxmox',
name: 'proxmox',
component: () => import('@/views/Proxmox.vue'),
},
{
path: 'updates',
name: 'updates',
component: () => import('@/views/Updates.vue'),
},
{
path: 'files',
name: 'files',
component: () => import('@/views/Files.vue'),
meta: { module: 'files' },
},
{
path: 'terminal',
name: 'terminal',
component: () => import('@/views/Terminal.vue'),
meta: { module: 'terminal' },
},
{
path: 'logs',
name: 'logs',
component: () => import('@/views/Logs.vue'),
meta: { module: 'logs' },
},
{
path: 'services',
name: 'services',
component: () => import('@/views/Services.vue'),
meta: { module: 'services' },
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/Settings.vue'),
},
{
path: 'modules',
name: 'modules',
component: () => import('@/views/Modules.vue'),
meta: { requiresAdmin: true },
},
],
},
// Redirection 404
{
path: '/:pathMatch(.*)*',
redirect: '/',
},
],
})
// Guard de navigation : vérification authentification et installation
router.beforeEach(async (to) => {
const authStore = useAuthStore()
// Au premier chargement : vérifier l'installation ET restaurer la session
if (!authStore.installChecked) {
await authStore.checkInstallation()
await authStore.restoreSession()
}
// Rediriger vers l'installation si pas encore configuré
if (!authStore.isInstalled && to.name !== 'install') {
return { name: 'install' }
}
// Si installé et route d'install → rediriger vers le dashboard
if (authStore.isInstalled && to.name === 'install') {
return { name: 'dashboard' }
}
// Routes publiques — passer directement
if (to.meta.public) return true
// Routes protégées — vérifier l'authentification
if (to.meta.requiresAuth || to.matched.some(r => r.meta.requiresAuth)) {
if (!authStore.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } }
}
}
// Routes admin uniquement
if (to.meta.requiresAdmin && !authStore.user?.is_admin) {
return { name: 'dashboard' }
}
return true
})
export default router

View file

@ -1,218 +0,0 @@
// Store d'authentification — gère la session JWT, le profil utilisateur et l'état d'installation.
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface User {
id: number
username: string
is_admin: boolean
lang: string
theme: string
sidebar_position: string
}
export const useAuthStore = defineStore('auth', () => {
// État
const user = ref<User | null>(null)
const accessToken = ref<string | null>(localStorage.getItem('pxp_token'))
const isInstalled = ref(false)
const installChecked = ref(false)
// Computed
const isAuthenticated = computed(() => !!accessToken.value && !!user.value)
// ── Actions ──────────────────────────────────────────────────────────────
/**
* Vérifie si l'application est installée via l'API.
* Appelé une seule fois au démarrage par le router guard.
*/
async function checkInstallation(): Promise<void> {
try {
const res = await fetch('/api/install/check')
if (res.ok) {
const data = await res.json()
isInstalled.value = data.installed
}
} catch {
// En cas d'erreur réseau, on suppose installé pour éviter une boucle
isInstalled.value = true
} finally {
installChecked.value = true
}
}
/**
* Authentifie l'utilisateur avec ses credentials Linux.
*/
async function login(username: string, password: string): Promise<void> {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
const contentType = res.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
const err = await res.json()
throw new Error(err.error || 'Erreur d\'authentification')
}
throw new Error(`Erreur ${res.status} — réponse inattendue du serveur`)
}
const data = await res.json()
accessToken.value = data.access_token
localStorage.setItem('pxp_token', data.access_token)
user.value = data.user
// Planifier le renouvellement automatique avant expiration (14 min)
scheduleRefresh(14 * 60 * 1000)
}
/**
* Restaure la session au démarrage de l'application (après F5).
* 1. Essaie fetchMe() avec le token existant (marche si < 15 min)
* 2. Si le token est expiré, tente le refresh via le cookie httpOnly
*/
async function restoreSession(): Promise<void> {
if (!accessToken.value) return
// Le token est peut-être encore valide : évite d'avoir besoin du cookie
await fetchMe()
if (user.value) {
scheduleRefresh(14 * 60 * 1000)
return
}
// Token expiré — tenter le refresh via le cookie httpOnly
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
})
if (res.ok) {
const data = await res.json()
accessToken.value = data.access_token
localStorage.setItem('pxp_token', data.access_token)
await fetchMe()
if (user.value) scheduleRefresh(14 * 60 * 1000)
} else {
// Le refresh a explicitement échoué (cookie absent ou expiré)
clearSession()
}
} catch {
// Erreur réseau transitoire — ne pas effacer le token, laisser le guard rediriger
}
}
/**
* Tente de renouveler le token via le cookie httpOnly (pxp_refresh).
* Utilisé par le timer automatique (14 min après login).
*/
async function tryRefresh(): Promise<void> {
const token = localStorage.getItem('pxp_token')
if (!token) return
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
})
if (res.ok) {
const data = await res.json()
accessToken.value = data.access_token
localStorage.setItem('pxp_token', data.access_token)
await fetchMe()
scheduleRefresh(14 * 60 * 1000)
} else {
clearSession()
}
} catch {
clearSession()
}
}
/**
* Charge le profil de l'utilisateur connecté.
*/
async function fetchMe(): Promise<void> {
if (!accessToken.value) return
const res = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${accessToken.value}` },
})
if (res.ok) {
user.value = await res.json()
}
}
/**
* Déconnecte l'utilisateur.
*/
async function logout(): Promise<void> {
try {
await fetch('/api/auth/logout', {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken.value}` },
credentials: 'include',
})
} finally {
clearSession()
}
}
/**
* Met à jour les préférences de l'utilisateur (thème, langue, sidebar).
*/
async function updatePreferences(prefs: Partial<Pick<User, 'lang' | 'theme' | 'sidebar_position'>>): Promise<void> {
if (!accessToken.value) return
await fetch('/api/auth/preferences', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken.value}`,
},
body: JSON.stringify(prefs),
})
// Mettre à jour localement
if (user.value) {
Object.assign(user.value, prefs)
}
}
// ── Helpers privés ────────────────────────────────────────────────────────
let refreshTimer: ReturnType<typeof setTimeout> | null = null
function scheduleRefresh(delayMs: number): void {
if (refreshTimer) clearTimeout(refreshTimer)
refreshTimer = setTimeout(() => tryRefresh(), delayMs)
}
function clearSession(): void {
user.value = null
accessToken.value = null
localStorage.removeItem('pxp_token')
if (refreshTimer) clearTimeout(refreshTimer)
}
return {
user,
accessToken,
isInstalled,
installChecked,
isAuthenticated,
checkInstallation,
login,
logout,
restoreSession,
tryRefresh,
fetchMe,
updatePreferences,
}
})

View file

@ -1,84 +0,0 @@
// Store UI — gère le thème (dark/light) et la position de la sidebar.
// Les préférences sont persistées localement et synchronisées avec le serveur.
import { defineStore } from 'pinia'
import { ref } from 'vue'
export type Theme = 'dark' | 'light'
export type SidebarPosition = 'left' | 'right'
export const useUiStore = defineStore('ui', () => {
const theme = ref<Theme>('dark')
const sidebarPosition = ref<SidebarPosition>('left')
const sidebarCollapsed = ref(false)
const mobileMenuOpen = ref(false)
/**
* Initialise le thème depuis les préférences locales.
* Appelé au montage de App.vue.
*/
function initTheme(): void {
const savedTheme = localStorage.getItem('pxp_theme') as Theme | null
const savedSidebar = localStorage.getItem('pxp_sidebar') as SidebarPosition | null
if (savedTheme === 'dark' || savedTheme === 'light') {
theme.value = savedTheme
}
if (savedSidebar === 'left' || savedSidebar === 'right') {
sidebarPosition.value = savedSidebar
}
applyTheme(theme.value)
}
/**
* Bascule entre thème sombre et clair.
*/
function toggleTheme(): void {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
localStorage.setItem('pxp_theme', theme.value)
applyTheme(theme.value)
}
/**
* Définit le thème explicitement.
*/
function setTheme(newTheme: Theme): void {
theme.value = newTheme
localStorage.setItem('pxp_theme', newTheme)
applyTheme(newTheme)
}
/**
* Définit la position de la sidebar.
*/
function setSidebarPosition(pos: SidebarPosition): void {
sidebarPosition.value = pos
localStorage.setItem('pxp_sidebar', pos)
}
/**
* Bascule l'état réduit de la sidebar.
*/
function toggleSidebarCollapse(): void {
sidebarCollapsed.value = !sidebarCollapsed.value
}
/**
* Applique le thème sur l'élément <html> via data-theme.
*/
function applyTheme(t: Theme): void {
document.documentElement.setAttribute('data-theme', t)
}
return {
theme,
sidebarPosition,
sidebarCollapsed,
mobileMenuOpen,
initTheme,
toggleTheme,
setTheme,
setSidebarPosition,
toggleSidebarCollapse,
}
})

View file

@ -1,393 +0,0 @@
<template>
<div class="dashboard">
<!-- En-tête avec bouton d'ajout de widget -->
<div class="dashboard__header flex items-center justify-between">
<div>
<h2>{{ t('nav.dashboard') }}</h2>
<p class="text-muted">{{ t('dashboard.welcome', { name: authStore.user?.username }) }}</p>
</div>
<button class="neu-btn neu-btn--primary" @click="showAddWidget = true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ t('dashboard.addWidget') }}
</button>
</div>
<!-- Grille de widgets drag-and-drop -->
<VueDraggable
v-model="widgets"
class="dashboard__grid"
item-key="id"
handle=".widget-drag-handle"
@end="saveLayout"
>
<div
v-for="widget in widgets"
:key="widget.id"
class="widget-wrapper"
:style="{ gridColumn: `span ${widget.width}`, gridRow: `span ${widget.height}` }"
>
<!-- Widget raccourci service -->
<div v-if="widget.type === 'shortcut'" class="neu-card widget widget--shortcut">
<div class="widget__header">
<span class="widget-drag-handle"></span>
<span class="widget__title">{{ widget.title }}</span>
<button class="neu-btn neu-btn--icon neu-btn--ghost widget__remove" @click="removeWidget(widget.id)">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<a :href="widget.config.url" target="_blank" rel="noopener" class="shortcut-link">
<div class="shortcut-icon">{{ widget.config.icon || '🔗' }}</div>
<div class="shortcut-url">{{ widget.config.url }}</div>
</a>
</div>
<!-- Widget statut LXC -->
<div v-else-if="widget.type === 'lxc_status'" class="neu-card widget">
<div class="widget__header">
<span class="widget-drag-handle"></span>
<span class="widget__title">{{ t('dashboard.lxcStatus') }}</span>
<button class="neu-btn neu-btn--icon neu-btn--ghost widget__remove" @click="removeWidget(widget.id)">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="lxc-list">
<div v-for="lxc in proxmoxResources.filter(r => r.type === 'lxc').slice(0, 6)" :key="lxc.vmid" class="lxc-item">
<span :class="['neu-badge', lxc.status === 'running' ? 'neu-badge--success' : 'neu-badge--danger']">
{{ lxc.status === 'running' ? '●' : '○' }}
</span>
<span class="lxc-name">{{ lxc.name || `LXC ${lxc.vmid}` }}</span>
<span class="lxc-id text-muted">{{ lxc.vmid }}</span>
</div>
<p v-if="proxmoxResources.length === 0" class="text-muted text-sm">
{{ t('dashboard.noData') }}
</p>
</div>
</div>
<!-- Widget métriques système -->
<div v-else-if="widget.type === 'metrics'" class="neu-card widget">
<div class="widget__header">
<span class="widget-drag-handle"></span>
<span class="widget__title">{{ t('dashboard.metrics') }}</span>
<button class="neu-btn neu-btn--icon neu-btn--ghost widget__remove" @click="removeWidget(widget.id)">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="metrics-grid">
<div class="metric-item">
<div class="metric-label">{{ t('dashboard.lxcCount') }}</div>
<div class="metric-value">{{ proxmoxResources.filter(r => r.type === 'lxc').length }}</div>
</div>
<div class="metric-item">
<div class="metric-label">{{ t('dashboard.running') }}</div>
<div class="metric-value text-success">{{ proxmoxResources.filter(r => r.status === 'running').length }}</div>
</div>
</div>
</div>
</div>
</VueDraggable>
<!-- Modal ajout de widget -->
<div v-if="showAddWidget" class="modal-overlay" @click.self="showAddWidget = false">
<div class="neu-card modal">
<h3>{{ t('dashboard.addWidget') }}</h3>
<div class="widget-types">
<button
v-for="type in availableWidgetTypes"
:key="type.id"
class="neu-btn widget-type-btn"
@click="addWidget(type)"
>
<span class="widget-type-icon">{{ type.icon }}</span>
<span>{{ t(type.label) }}</span>
</button>
</div>
<button class="neu-btn w-full" @click="showAddWidget = false">{{ t('common.cancel') }}</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { VueDraggable } from 'vue-draggable-plus'
import { useAuthStore } from '@/stores/auth.store'
const { t } = useI18n()
const authStore = useAuthStore()
interface Widget {
id: number
type: string
title: string
config: Record<string, string>
width: number
height: number
}
const widgets = ref<Widget[]>([
{ id: 1, type: 'lxc_status', title: 'LXC Status', config: {}, width: 2, height: 2 },
{ id: 2, type: 'metrics', title: 'Métriques', config: {}, width: 1, height: 1 },
{ id: 3, type: 'shortcut', title: 'Proxmox', config: { url: 'https://proxmox.geronzi.fr', icon: '🖥️' }, width: 1, height: 1 },
{ id: 4, type: 'shortcut', title: 'Grafana', config: { url: 'https://grafana.geronzi.fr', icon: '📊' }, width: 1, height: 1 },
])
const proxmoxResources = ref<any[]>([])
const showAddWidget = ref(false)
const availableWidgetTypes = [
{ id: 'shortcut', icon: '🔗', label: 'dashboard.widgetShortcut' },
{ id: 'lxc_status', icon: '🖥️', label: 'dashboard.widgetLXC' },
{ id: 'metrics', icon: '📊', label: 'dashboard.widgetMetrics' },
]
let wsConnection: WebSocket | null = null
onMounted(async () => {
// Charger les données Proxmox
await loadProxmoxData()
// Connecter le WebSocket pour les mises à jour temps réel
connectWebSocket()
})
onUnmounted(() => {
wsConnection?.close()
})
async function loadProxmoxData() {
try {
const res = await fetch('/api/proxmox/resources', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) {
proxmoxResources.value = await res.json() || []
}
} catch { /* Silencieux — affiché via le widget */ }
}
function connectWebSocket() {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const token = authStore.accessToken
wsConnection = new WebSocket(`${proto}//${window.location.host}/ws/proxmox?token=${token}`)
wsConnection.onopen = () => {
wsConnection?.send(JSON.stringify({ type: 'subscribe', channel: 'proxmox' }))
}
wsConnection.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'resources_update' && msg.payload) {
proxmoxResources.value = msg.payload
}
} catch { /* Ignorer les messages invalides */ }
}
wsConnection.onerror = () => {
setTimeout(() => connectWebSocket(), 5000) // Reconnexion après 5s
}
}
function addWidget(type: { id: string; icon: string; label: string }) {
const newId = Date.now()
widgets.value.push({
id: newId,
type: type.id,
title: t(type.label),
config: type.id === 'shortcut' ? { url: 'https://example.com', icon: type.icon } : {},
width: 1,
height: 1,
})
showAddWidget.value = false
saveLayout()
}
function removeWidget(id: number) {
widgets.value = widgets.value.filter(w => w.id !== id)
saveLayout()
}
function saveLayout() {
// Sauvegarder via API (implémentation future avec endpoint dédié)
localStorage.setItem('pxp_dashboard_layout', JSON.stringify(widgets.value))
}
</script>
<style scoped>
.dashboard {
max-width: 1400px;
}
.dashboard__header {
margin-bottom: var(--neu-space-xl);
}
.dashboard__header h2 {
font-size: var(--neu-font-xl);
color: var(--neu-text);
}
.text-muted { color: var(--neu-text-muted); }
.text-success { color: var(--neu-success); }
.text-sm { font-size: var(--neu-font-sm); }
.dashboard__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--neu-space-md);
align-items: start;
}
.widget {
height: 100%;
}
.widget__header {
display: flex;
align-items: center;
gap: var(--neu-space-xs);
margin-bottom: var(--neu-space-sm);
}
.widget-drag-handle {
cursor: grab;
color: var(--neu-text-muted);
font-size: 16px;
padding: 4px;
}
.widget-drag-handle:active { cursor: grabbing; }
.widget__title {
flex: 1;
font-weight: 600;
font-size: var(--neu-font-sm);
color: var(--neu-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.widget__remove {
opacity: 0;
width: 24px;
height: 24px;
transition: opacity 0.15s;
}
.widget:hover .widget__remove { opacity: 1; }
.shortcut-link {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
text-decoration: none;
color: var(--neu-text);
padding: var(--neu-space-sm);
border-radius: var(--neu-radius-md);
transition: var(--neu-transition);
}
.shortcut-link:hover {
background: var(--neu-bg);
}
.shortcut-icon { font-size: 24px; }
.shortcut-url {
font-size: var(--neu-font-sm);
color: var(--neu-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lxc-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.lxc-item {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
font-size: var(--neu-font-sm);
}
.lxc-name { flex: 1; color: var(--neu-text); }
.lxc-id { color: var(--neu-text-muted); font-family: monospace; }
.metrics-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--neu-space-sm);
}
.metric-item {
text-align: center;
padding: var(--neu-space-sm);
background: var(--neu-bg);
border-radius: var(--neu-radius-md);
}
.metric-label {
font-size: var(--neu-font-xs);
color: var(--neu-text-muted);
text-transform: uppercase;
margin-bottom: 4px;
}
.metric-value {
font-size: var(--neu-font-xl);
font-weight: 700;
color: var(--neu-text);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
padding: var(--neu-space-lg);
}
.modal {
width: 100%;
max-width: 360px;
}
.modal h3 {
margin-bottom: var(--neu-space-md);
color: var(--neu-text);
}
.widget-types {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--neu-space-sm);
margin-bottom: var(--neu-space-md);
}
.widget-type-btn {
flex-direction: column;
gap: 4px;
height: 70px;
font-size: var(--neu-font-sm);
}
.widget-type-icon { font-size: 24px; }
</style>

View file

@ -1,25 +0,0 @@
<template>
<div class="files-page">
<div class="page-header">
<h2>{{ t('nav.files') }}</h2>
<p class="text-muted">{{ t('files.desc') }}</p>
</div>
<div class="neu-card">
<p class="text-muted" style="text-align:center; padding: var(--neu-space-xl)">
{{ t('files.moduleNotEnabled') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<style scoped>
.files-page { max-width: 1400px; }
.page-header { margin-bottom: var(--neu-space-lg); }
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
.text-muted { color: var(--neu-text-muted); }
</style>

View file

@ -1,487 +0,0 @@
<template>
<!-- Page d'installation wizard multi-étapes -->
<div class="install-page">
<div class="install-container">
<!-- En-tête -->
<div class="install-header">
<div class="install-logo">PX</div>
<h1>ProxmoxPanel</h1>
<p class="install-subtitle">{{ t('install.subtitle') }}</p>
</div>
<!-- Indicateur de progression -->
<div class="install-steps">
<div
v-for="(step, i) in steps"
:key="i"
class="install-step"
:class="{
'install-step--active': currentStep === i,
'install-step--done': currentStep > i,
}"
>
<div class="install-step__dot">
<svg v-if="currentStep > i" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12"/>
</svg>
<span v-else>{{ i + 1 }}</span>
</div>
<span class="install-step__label">{{ t(step.label) }}</span>
</div>
</div>
<!-- Contenu des étapes -->
<div class="neu-card install-card">
<!-- Étape 1 : Configuration générale -->
<div v-if="currentStep === 0">
<h2>{{ t('install.step1.title') }}</h2>
<p class="step-desc">{{ t('install.step1.desc') }}</p>
<div class="form-group">
<label>{{ t('install.instanceName') }}</label>
<input v-model="form.instanceName" class="neu-input" :placeholder="t('install.instanceNamePlaceholder')" />
</div>
<div class="form-group">
<label>{{ t('install.publicUrl') }}</label>
<input v-model="form.publicUrl" class="neu-input" :placeholder="detectedURL" />
<small>{{ t('install.publicUrlHint', { url: detectedURL }) }}</small>
</div>
<div class="form-group">
<label>{{ t('install.defaultLang') }}</label>
<select v-model="form.defaultLang" class="neu-input">
<option value="fr">Français</option>
<option value="en">English</option>
</select>
</div>
</div>
<!-- Étape 2 : Configuration SSH -->
<div v-if="currentStep === 1">
<h2>{{ t('install.step2.title') }}</h2>
<p class="step-desc">{{ t('install.step2.desc') }}</p>
<div class="form-group">
<label>{{ t('install.sshHost') }}</label>
<input v-model="form.sshHost" class="neu-input" placeholder="10.0.0.1:2244" />
</div>
<div class="form-group">
<label>{{ t('install.sshUsername') }}</label>
<input v-model="form.sshUsername" class="neu-input" placeholder="enzo" />
</div>
<div class="form-group">
<label>{{ t('install.sshPassword') }}</label>
<input v-model="form.sshPassword" type="password" class="neu-input" />
</div>
<!-- Bouton test SSH -->
<button
class="neu-btn neu-btn--primary"
:disabled="testingSSH || !form.sshHost || !form.sshUsername || !form.sshPassword"
@click="testSSH"
>
<span v-if="testingSSH" class="neu-loading"></span>
{{ t('install.testSSH') }}
</button>
<div v-if="sshTestResult" :class="['install-result', sshTestResult.success ? 'install-result--success' : 'install-result--error']">
{{ sshTestResult.message }}
</div>
</div>
<!-- Étape 3 : Token Proxmox -->
<div v-if="currentStep === 2">
<h2>{{ t('install.step3.title') }}</h2>
<p class="step-desc">{{ t('install.step3.desc') }}</p>
<div class="form-group">
<label>{{ t('install.proxmoxUrl') }}</label>
<input v-model="form.proxmoxUrl" class="neu-input" placeholder="https://10.0.0.1:8006" />
</div>
<div class="form-group">
<label>{{ t('install.proxmoxTokenId') }}</label>
<input v-model="form.proxmoxTokenId" class="neu-input" placeholder="enzo@pam!panel" />
</div>
<div class="form-group">
<label>{{ t('install.proxmoxTokenSecret') }}</label>
<input v-model="form.proxmoxTokenSecret" type="password" class="neu-input" placeholder="ed57ea62-cadc-4ddd-..." />
<small>{{ t('install.proxmoxTokenHint') }}</small>
</div>
</div>
<!-- Étape 4 : Confirmation -->
<div v-if="currentStep === 3">
<h2>{{ t('install.step4.title') }}</h2>
<div class="install-summary">
<div class="summary-item">
<span class="summary-key">{{ t('install.instanceName') }}</span>
<span class="summary-value">{{ form.instanceName }}</span>
</div>
<div class="summary-item">
<span class="summary-key">{{ t('install.sshHost') }}</span>
<span class="summary-value">{{ form.sshHost }}</span>
</div>
<div class="summary-item">
<span class="summary-key">{{ t('install.defaultLang') }}</span>
<span class="summary-value">{{ form.defaultLang === 'fr' ? 'Français' : 'English' }}</span>
</div>
</div>
</div>
<!-- Erreur globale -->
<div v-if="error" class="install-result install-result--error">{{ error }}</div>
<!-- Actions navigation -->
<div class="install-actions">
<button v-if="currentStep > 0" class="neu-btn" @click="currentStep--">
{{ t('install.back') }}
</button>
<div class="spacer" />
<button
v-if="currentStep < steps.length - 1"
class="neu-btn neu-btn--primary"
:disabled="!canProceed"
@click="nextStep"
>
{{ t('install.next') }}
</button>
<button
v-else
class="neu-btn neu-btn--success"
:disabled="installing"
@click="finalize"
>
<span v-if="installing" class="neu-loading"></span>
{{ t('install.finish') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
const { t } = useI18n()
const router = useRouter()
const authStore = useAuthStore()
const currentStep = ref(0)
const detectedURL = ref('')
const testingSSH = ref(false)
const installing = ref(false)
const error = ref('')
const sshTestResult = ref<{ success: boolean; message: string } | null>(null)
const steps = [
{ label: 'install.step1.label' },
{ label: 'install.step2.label' },
{ label: 'install.step3.label' },
{ label: 'install.step4.label' },
]
const form = ref({
instanceName: 'ProxmoxPanel',
publicUrl: '',
defaultLang: 'fr',
sshHost: '10.0.0.1:2244',
sshUsername: 'enzo',
sshPassword: '',
proxmoxUrl: 'https://10.0.0.1:8006',
proxmoxTokenId: '',
proxmoxTokenSecret: '',
})
const canProceed = computed(() => {
switch (currentStep.value) {
case 0: return !!form.value.instanceName
case 1: return sshTestResult.value?.success === true
case 2: return true // Token Proxmox optionnel
default: return true
}
})
onMounted(async () => {
// Récupérer les valeurs pré-remplies depuis l'API
try {
const res = await fetch('/api/install/status')
if (res.ok) {
const data = await res.json()
detectedURL.value = data.detected_url || window.location.origin
form.value.publicUrl = detectedURL.value
}
} catch {
detectedURL.value = window.location.origin
form.value.publicUrl = window.location.origin
}
})
async function testSSH() {
testingSSH.value = true
sshTestResult.value = null
try {
const res = await fetch('/api/install/test-ssh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
host: form.value.sshHost,
username: form.value.sshUsername,
password: form.value.sshPassword,
}),
})
const data = await res.json()
sshTestResult.value = {
success: data.success,
message: data.success ? t('install.sshSuccess') : (data.error || t('install.sshFailed')),
}
} catch (e) {
sshTestResult.value = { success: false, message: t('install.networkError') }
} finally {
testingSSH.value = false
}
}
function nextStep() {
if (currentStep.value < steps.length - 1) {
currentStep.value++
sshTestResult.value = null
error.value = ''
}
}
async function finalize() {
installing.value = true
error.value = ''
try {
const res = await fetch('/api/install/configure', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
instance_name: form.value.instanceName,
public_url: form.value.publicUrl || detectedURL.value,
default_lang: form.value.defaultLang,
ssh_host: form.value.sshHost,
ssh_username: form.value.sshUsername,
ssh_password: form.value.sshPassword,
proxmox_url: form.value.proxmoxUrl,
proxmox_token: (form.value.proxmoxTokenId && form.value.proxmoxTokenSecret)
? `PVEAPIToken=${form.value.proxmoxTokenId}=${form.value.proxmoxTokenSecret}`
: '',
}),
})
if (!res.ok) {
const data = await res.json()
error.value = data.error || t('install.error')
return
}
// Marquer comme installé et rediriger vers le login
authStore.isInstalled = true
localStorage.setItem('pxp_instance_name', form.value.instanceName)
router.push('/login')
} catch (e) {
error.value = t('install.networkError')
} finally {
installing.value = false
}
}
</script>
<style scoped>
.install-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--neu-bg);
padding: var(--neu-space-lg);
}
.install-container {
width: 100%;
max-width: 520px;
}
.install-header {
text-align: center;
margin-bottom: var(--neu-space-xl);
}
.install-logo {
width: 64px;
height: 64px;
background: var(--neu-primary);
color: #fff;
font-size: 24px;
font-weight: 700;
border-radius: var(--neu-radius-lg);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto var(--neu-space-md);
box-shadow: 4px 4px 12px rgba(108, 142, 244, 0.4);
}
.install-header h1 {
font-size: var(--neu-font-2xl);
color: var(--neu-text);
margin-bottom: var(--neu-space-xs);
}
.install-subtitle {
color: var(--neu-text-muted);
font-size: var(--neu-font-md);
}
.install-steps {
display: flex;
justify-content: space-between;
margin-bottom: var(--neu-space-lg);
position: relative;
}
.install-steps::before {
content: '';
position: absolute;
top: 14px;
left: 14px;
right: 14px;
height: 2px;
background: var(--neu-border);
z-index: 0;
}
.install-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
z-index: 1;
}
.install-step__dot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
background: var(--neu-surface);
border: 2px solid var(--neu-border);
color: var(--neu-text-muted);
transition: var(--neu-transition);
}
.install-step--active .install-step__dot {
border-color: var(--neu-primary);
color: var(--neu-primary);
}
.install-step--done .install-step__dot {
background: var(--neu-success);
border-color: var(--neu-success);
color: #fff;
}
.install-step__label {
font-size: var(--neu-font-xs);
color: var(--neu-text-muted);
}
.install-step--active .install-step__label {
color: var(--neu-primary);
font-weight: 600;
}
.install-card {
padding: var(--neu-space-xl);
}
.install-card h2 {
font-size: var(--neu-font-xl);
color: var(--neu-text);
margin-bottom: var(--neu-space-xs);
}
.step-desc {
color: var(--neu-text-muted);
margin-bottom: var(--neu-space-lg);
}
.form-group {
margin-bottom: var(--neu-space-md);
}
.form-group label {
display: block;
font-size: var(--neu-font-sm);
font-weight: 600;
color: var(--neu-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: var(--neu-space-xs);
}
.form-group small {
display: block;
font-size: var(--neu-font-xs);
color: var(--neu-text-muted);
margin-top: 4px;
}
.install-result {
margin: var(--neu-space-md) 0;
padding: var(--neu-space-sm) var(--neu-space-md);
border-radius: var(--neu-radius-md);
font-size: var(--neu-font-sm);
}
.install-result--success {
background: rgba(76, 187, 138, 0.1);
color: var(--neu-success);
border: 1px solid rgba(76, 187, 138, 0.3);
}
.install-result--error {
background: rgba(240, 92, 107, 0.1);
color: var(--neu-danger);
border: 1px solid rgba(240, 92, 107, 0.3);
}
.install-summary {
background: var(--neu-bg);
border-radius: var(--neu-radius-md);
padding: var(--neu-space-md);
margin-bottom: var(--neu-space-lg);
}
.summary-item {
display: flex;
justify-content: space-between;
padding: var(--neu-space-xs) 0;
border-bottom: 1px solid var(--neu-border);
font-size: var(--neu-font-sm);
}
.summary-item:last-child { border-bottom: none; }
.summary-key { color: var(--neu-text-muted); }
.summary-value { color: var(--neu-text); font-weight: 500; }
.install-actions {
display: flex;
align-items: center;
margin-top: var(--neu-space-xl);
gap: var(--neu-space-sm);
}
.spacer { flex: 1; }
</style>

View file

@ -1,185 +0,0 @@
<template>
<div class="login-page">
<div class="login-container">
<!-- Logo + titre -->
<div class="login-header">
<div class="login-logo">PX</div>
<h1>{{ instanceName || 'ProxmoxPanel' }}</h1>
<p>{{ t('login.subtitle') }}</p>
</div>
<!-- Formulaire -->
<form class="neu-card login-card" @submit.prevent="handleLogin">
<div class="form-group">
<label>{{ t('login.username') }}</label>
<input
v-model="username"
class="neu-input"
:placeholder="t('login.usernamePlaceholder')"
autocomplete="username"
autofocus
:disabled="loading"
/>
</div>
<div class="form-group">
<label>{{ t('login.password') }}</label>
<input
v-model="password"
type="password"
class="neu-input"
:placeholder="t('login.passwordPlaceholder')"
autocomplete="current-password"
:disabled="loading"
/>
</div>
<div v-if="error" class="login-error">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
{{ error }}
</div>
<button type="submit" class="neu-btn neu-btn--primary neu-btn--lg w-full" :disabled="loading || !username || !password">
<span v-if="loading" class="neu-loading"></span>
{{ loading ? t('login.loading') : t('login.submit') }}
</button>
<p class="login-hint">{{ t('login.hint') }}</p>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
import { useUiStore } from '@/stores/ui.store'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const uiStore = useUiStore()
const username = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
const instanceName = computed(() => localStorage.getItem('pxp_instance_name') || '')
async function handleLogin() {
loading.value = true
error.value = ''
try {
await authStore.login(username.value, password.value)
// Appliquer les préférences de l'utilisateur
const user = authStore.user
if (user) {
uiStore.setTheme(user.theme as 'dark' | 'light')
uiStore.setSidebarPosition(user.sidebar_position as 'left' | 'right')
}
// Rediriger vers la page demandée ou le dashboard
const redirect = route.query.redirect as string || '/'
router.push(redirect)
} catch (e) {
error.value = e instanceof Error ? e.message : t('login.error')
password.value = ''
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--neu-bg);
padding: var(--neu-space-lg);
}
.login-container {
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: var(--neu-space-xl);
}
.login-logo {
width: 72px;
height: 72px;
background: var(--neu-primary);
color: #fff;
font-size: 28px;
font-weight: 700;
border-radius: var(--neu-radius-xl);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto var(--neu-space-md);
box-shadow:
6px 6px 14px rgba(108, 142, 244, 0.4),
-3px -3px 8px rgba(255,255,255,0.05);
}
.login-header h1 {
font-size: var(--neu-font-2xl);
color: var(--neu-text);
margin-bottom: var(--neu-space-xs);
}
.login-header p {
color: var(--neu-text-muted);
}
.login-card {
padding: var(--neu-space-xl);
}
.form-group {
margin-bottom: var(--neu-space-md);
}
.form-group label {
display: block;
font-size: var(--neu-font-sm);
font-weight: 600;
color: var(--neu-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.login-error {
display: flex;
align-items: center;
gap: var(--neu-space-xs);
padding: var(--neu-space-sm) var(--neu-space-md);
background: rgba(240, 92, 107, 0.1);
color: var(--neu-danger);
border: 1px solid rgba(240, 92, 107, 0.3);
border-radius: var(--neu-radius-md);
font-size: var(--neu-font-sm);
margin-bottom: var(--neu-space-md);
}
.login-hint {
text-align: center;
font-size: var(--neu-font-xs);
color: var(--neu-text-muted);
margin-top: var(--neu-space-md);
}
</style>

View file

@ -1,24 +0,0 @@
<template>
<div class="logs-page">
<div class="page-header">
<h2>{{ t('nav.logs') }}</h2>
</div>
<div class="neu-card">
<p class="text-muted" style="text-align:center; padding: var(--neu-space-xl)">
{{ t('files.moduleNotEnabled') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<style scoped>
.logs-page { max-width: 1200px; }
.page-header { margin-bottom: var(--neu-space-lg); }
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
.text-muted { color: var(--neu-text-muted); }
</style>

View file

@ -1,107 +0,0 @@
<template>
<div class="modules-page">
<div class="page-header">
<h2>{{ t('nav.modules') }}</h2>
<p class="text-muted">{{ t('modules.desc') }}</p>
</div>
<div class="modules-grid">
<div v-for="mod in modules" :key="mod.id" class="neu-card module-card">
<div class="module-header">
<div class="module-title-row flex items-center gap-sm">
<span class="module-name">{{ mod.name }}</span>
<span v-if="mod.is_core" class="neu-badge neu-badge--info">CORE</span>
<span :class="['neu-badge', mod.is_enabled ? 'neu-badge--success' : 'neu-badge--danger']">
{{ mod.is_enabled ? t('modules.enabled') : t('modules.disabled') }}
</span>
</div>
<div class="module-version text-muted">v{{ mod.version }}</div>
</div>
<p class="module-description text-muted">{{ mod.description }}</p>
<div class="module-actions flex gap-sm">
<button
v-if="!mod.is_core"
:class="['neu-btn neu-btn--sm', mod.is_enabled ? 'neu-btn--danger' : 'neu-btn--success']"
:disabled="actionLoading === mod.id"
@click="toggleModule(mod)"
>
{{ mod.is_enabled ? t('modules.disable') : t('modules.enable') }}
</button>
<span v-else class="text-muted" style="font-size:11px">{{ t('modules.coreProtected') }}</span>
</div>
</div>
</div>
<div v-if="restartNeeded" class="neu-card restart-notice">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
{{ t('modules.restartNotice') }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth.store'
const { t } = useI18n()
const authStore = useAuthStore()
const modules = ref<any[]>([])
const actionLoading = ref<string | null>(null)
const restartNeeded = ref(false)
onMounted(loadModules)
async function loadModules() {
const res = await fetch('/api/modules', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) modules.value = await res.json() || []
}
async function toggleModule(mod: any) {
actionLoading.value = mod.id
const action = mod.is_enabled ? 'disable' : 'enable'
const res = await fetch(`/api/modules/${mod.id}/${action}`, {
method: 'POST',
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) {
mod.is_enabled = !mod.is_enabled
restartNeeded.value = true
}
actionLoading.value = null
}
</script>
<style scoped>
.modules-page { max-width: 1000px; }
.page-header { margin-bottom: var(--neu-space-lg); }
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
.text-muted { color: var(--neu-text-muted); }
.modules-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--neu-space-md); margin-bottom: var(--neu-space-lg); }
.module-header { margin-bottom: var(--neu-space-sm); }
.module-name { font-weight: 600; color: var(--neu-text); }
.module-version { font-size: var(--neu-font-xs); margin-top: 2px; }
.module-description { font-size: var(--neu-font-sm); margin-bottom: var(--neu-space-md); }
.module-actions { border-top: 1px solid var(--neu-border); padding-top: var(--neu-space-sm); }
.restart-notice {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
color: var(--neu-warning);
border-color: var(--neu-warning);
font-size: var(--neu-font-sm);
}
</style>

View file

@ -1,290 +0,0 @@
<template>
<div class="proxmox-page">
<div class="page-header flex items-center justify-between">
<h2>{{ t('nav.proxmox') }}</h2>
<button class="neu-btn" @click="loadResources">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" :class="{ 'neu-loading': loading }">
<path d="M23 4v6h-6"/><path d="M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
{{ t('common.refresh') }}
</button>
</div>
<!-- Filtre par type -->
<div class="filter-bar flex gap-sm">
<button
v-for="f in filters"
:key="f.value"
:class="['neu-btn neu-btn--sm', activeFilter === f.value ? 'neu-btn--primary' : '']"
@click="activeFilter = f.value"
>
{{ t(f.label) }}
</button>
</div>
<!-- Loader -->
<div v-if="loading" class="loading-state">
<span class="neu-loading" style="font-size:32px"></span>
</div>
<!-- Erreur -->
<div v-else-if="error" class="neu-card error-card">
<p class="error-msg">{{ error }}</p>
</div>
<!-- Grille des ressources -->
<div v-else class="resources-grid">
<div
v-for="resource in filteredResources"
:key="`${resource.type}-${resource.vmid}`"
class="neu-card resource-card neu-card--hover"
>
<!-- En-tête -->
<div class="resource-header flex items-center gap-sm">
<span :class="['status-dot', resource.status === 'running' ? 'status-dot--running' : 'status-dot--stopped']" />
<div class="resource-title">
<div class="resource-name">{{ resource.name || `${resource.type.toUpperCase()} ${resource.vmid}` }}</div>
<div class="resource-meta">
<span class="neu-badge neu-badge--info">{{ resource.type.toUpperCase() }}</span>
<span class="resource-id">#{{ resource.vmid }}</span>
</div>
</div>
<span :class="['neu-badge', resource.status === 'running' ? 'neu-badge--success' : 'neu-badge--danger']">
{{ resource.status === 'running' ? t('proxmox.running') : t('proxmox.stopped') }}
</span>
</div>
<!-- Métriques -->
<div v-if="resource.status === 'running'" class="resource-metrics">
<div class="metric">
<div class="metric-bar">
<div class="metric-bar-fill" :style="{ width: `${Math.round(resource.cpu * 100)}%` }" />
</div>
<div class="metric-label">CPU {{ Math.round(resource.cpu * 100) }}%</div>
</div>
<div class="metric">
<div class="metric-bar">
<div class="metric-bar-fill metric-bar-fill--mem" :style="{ width: `${Math.round((resource.mem / resource.maxmem) * 100)}%` }" />
</div>
<div class="metric-label">RAM {{ formatBytes(resource.mem) }} / {{ formatBytes(resource.maxmem) }}</div>
</div>
</div>
<!-- Actions (admin uniquement) -->
<div v-if="authStore.user?.is_admin" class="resource-actions flex gap-sm">
<button
v-if="resource.status === 'stopped'"
class="neu-btn neu-btn--sm neu-btn--success"
:disabled="actionLoading === resource.vmid"
@click="startResource(resource)"
>
{{ t('proxmox.start') }}
</button>
<button
v-else
class="neu-btn neu-btn--sm neu-btn--danger"
:disabled="actionLoading === resource.vmid"
@click="stopResource(resource)"
>
{{ t('proxmox.stop') }}
</button>
</div>
</div>
</div>
<!-- Connexion WebSocket indicator -->
<div class="ws-indicator">
<span :class="['ws-dot', wsConnected ? 'ws-dot--connected' : 'ws-dot--disconnected']" />
<span class="text-muted" style="font-size:11px">
{{ wsConnected ? t('proxmox.liveUpdates') : t('proxmox.disconnected') }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth.store'
const { t } = useI18n()
const authStore = useAuthStore()
interface Resource {
vmid: number
name: string
node: string
type: string
status: string
cpu: number
maxcpu: number
mem: number
maxmem: number
}
const resources = ref<Resource[]>([])
const loading = ref(false)
const error = ref('')
const activeFilter = ref('all')
const actionLoading = ref<number | null>(null)
const wsConnected = ref(false)
let wsConnection: WebSocket | null = null
const filters = [
{ value: 'all', label: 'proxmox.all' },
{ value: 'lxc', label: 'proxmox.lxc' },
{ value: 'qemu', label: 'proxmox.vm' },
]
const filteredResources = computed(() => {
if (activeFilter.value === 'all') return resources.value.filter(r => r.type === 'lxc' || r.type === 'qemu')
return resources.value.filter(r => r.type === activeFilter.value)
})
onMounted(() => {
loadResources()
connectWebSocket()
})
onUnmounted(() => {
wsConnection?.close()
})
async function loadResources() {
loading.value = true
error.value = ''
try {
const res = await fetch('/api/proxmox/resources', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) {
resources.value = await res.json() || []
} else {
const data = await res.json()
error.value = data.error || t('proxmox.error')
}
} catch {
error.value = t('common.networkError')
} finally {
loading.value = false
}
}
async function startResource(resource: Resource) {
actionLoading.value = resource.vmid
const path = resource.type === 'lxc' ? 'lxc' : 'vm'
await fetch(`/api/proxmox/${path}/${resource.vmid}/start?node=${resource.node}`, {
method: 'POST',
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
actionLoading.value = null
}
async function stopResource(resource: Resource) {
actionLoading.value = resource.vmid
const path = resource.type === 'lxc' ? 'lxc' : 'vm'
await fetch(`/api/proxmox/${path}/${resource.vmid}/stop?node=${resource.node}`, {
method: 'POST',
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
actionLoading.value = null
}
function connectWebSocket() {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
wsConnection = new WebSocket(`${proto}//${window.location.host}/ws/proxmox?token=${authStore.accessToken}`)
wsConnection.onopen = () => {
wsConnected.value = true
wsConnection?.send(JSON.stringify({ type: 'subscribe', channel: 'proxmox' }))
}
wsConnection.onclose = () => {
wsConnected.value = false
setTimeout(() => connectWebSocket(), 5000)
}
wsConnection.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'resources_update' && msg.payload) {
resources.value = msg.payload
}
}
}
function formatBytes(bytes: number): string {
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(0)} MB`
return `${bytes} B`
}
</script>
<style scoped>
.proxmox-page { max-width: 1400px; }
.page-header { margin-bottom: var(--neu-space-lg); }
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
.filter-bar { margin-bottom: var(--neu-space-lg); flex-wrap: wrap; }
.loading-state { display: flex; justify-content: center; padding: var(--neu-space-xl); color: var(--neu-primary); }
.error-card { border-color: var(--neu-danger); }
.error-msg { color: var(--neu-danger); }
.resources-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--neu-space-md);
}
.resource-card { cursor: default; }
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot--running { background: var(--neu-success); box-shadow: 0 0 6px var(--neu-success); }
.status-dot--stopped { background: var(--neu-text-muted); }
.resource-header { margin-bottom: var(--neu-space-sm); }
.resource-name { font-weight: 600; color: var(--neu-text); }
.resource-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
.resource-id { font-size: var(--neu-font-xs); color: var(--neu-text-muted); font-family: monospace; }
.resource-metrics { margin: var(--neu-space-sm) 0; display: flex; flex-direction: column; gap: 6px; }
.metric-bar {
height: 6px;
background: var(--neu-bg);
border-radius: 3px;
overflow: hidden;
box-shadow: inset 1px 1px 3px var(--neu-shadow-dark);
}
.metric-bar-fill {
height: 100%;
background: var(--neu-primary);
border-radius: 3px;
transition: width 0.5s ease;
}
.metric-bar-fill--mem { background: var(--neu-info); }
.metric-label { font-size: 10px; color: var(--neu-text-muted); margin-top: 2px; }
.resource-actions { margin-top: var(--neu-space-sm); border-top: 1px solid var(--neu-border); padding-top: var(--neu-space-sm); }
.ws-indicator {
display: flex;
align-items: center;
gap: 6px;
margin-top: var(--neu-space-lg);
}
.ws-dot { width: 8px; height: 8px; border-radius: 50%; }
.ws-dot--connected { background: var(--neu-success); animation: neu-pulse 2s infinite; }
.ws-dot--disconnected { background: var(--neu-text-muted); }
</style>

View file

@ -1,24 +0,0 @@
<template>
<div class="services-page">
<div class="page-header">
<h2>{{ t('nav.services') }}</h2>
</div>
<div class="neu-card">
<p class="text-muted" style="text-align:center; padding: var(--neu-space-xl)">
{{ t('files.moduleNotEnabled') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<style scoped>
.services-page { max-width: 1000px; }
.page-header { margin-bottom: var(--neu-space-lg); }
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
.text-muted { color: var(--neu-text-muted); }
</style>

View file

@ -1,403 +0,0 @@
<template>
<div class="settings-page">
<div class="page-header">
<h2>{{ t('nav.settings') }}</h2>
</div>
<div class="settings-layout">
<!-- Onglets -->
<div class="settings-tabs neu-card">
<button
v-for="tab in tabs"
:key="tab.id"
:class="['settings-tab', activeTab === tab.id ? 'settings-tab--active' : '']"
@click="activeTab = tab.id"
>
<span v-html="tab.icon" class="tab-icon" />
{{ t(tab.label) }}
</button>
</div>
<!-- Contenu -->
<div class="settings-content neu-card">
<!-- Général -->
<div v-if="activeTab === 'general'">
<h3>{{ t('settings.general') }}</h3>
<div class="settings-form">
<div class="form-row">
<label>{{ t('settings.instanceName') }}</label>
<input v-model="settings.instance_name" class="neu-input" />
</div>
<div class="form-row">
<label>{{ t('settings.publicUrl') }}</label>
<input v-model="settings.public_url" class="neu-input" />
</div>
<div class="form-row">
<label>{{ t('settings.defaultLang') }}</label>
<select v-model="settings.default_lang" class="neu-input">
<option value="fr">Français</option>
<option value="en">English</option>
</select>
</div>
</div>
</div>
<!-- SSH / Proxmox -->
<div v-if="activeTab === 'infrastructure'">
<h3>{{ t('settings.infrastructure') }}</h3>
<div class="settings-form">
<div class="form-row">
<label>{{ t('settings.sshHost') }}</label>
<input v-model="settings.ssh_host" class="neu-input" placeholder="10.0.0.1:2244" />
</div>
<div class="form-row">
<label>{{ t('settings.sshUsername') }}</label>
<input v-model="settings.ssh_username" class="neu-input" />
</div>
<div class="form-row">
<label>{{ t('settings.sshPassword') }}</label>
<input v-model="secrets.ssh_password" type="password" class="neu-input" :placeholder="t('settings.secretPlaceholder')" autocomplete="new-password" />
</div>
<div class="form-row">
<label>{{ t('settings.proxmoxUrl') }}</label>
<input v-model="settings.proxmox_url" class="neu-input" placeholder="https://10.0.0.1:8006" />
</div>
<div class="form-row">
<label>{{ t('settings.proxmoxTokenId') }}</label>
<input v-model="secrets.proxmox_token_id" class="neu-input" :placeholder="t('settings.proxmoxTokenIdPlaceholder')" autocomplete="off" />
</div>
<div class="form-row">
<label>{{ t('settings.proxmoxTokenSecret') }}</label>
<input v-model="secrets.proxmox_token_secret" type="password" class="neu-input" :placeholder="t('settings.secretPlaceholder')" autocomplete="new-password" />
</div>
</div>
</div>
<!-- Apparence -->
<div v-if="activeTab === 'appearance'">
<h3>{{ t('settings.appearance') }}</h3>
<div class="settings-form">
<div class="form-row form-row--toggle">
<label>{{ t('settings.darkMode') }}</label>
<label class="neu-toggle">
<input type="checkbox" :checked="uiStore.theme === 'dark'" @change="uiStore.toggleTheme()" />
<span class="neu-toggle__slider" />
</label>
</div>
<div class="form-row">
<label>{{ t('settings.sidebarPosition') }}</label>
<div class="flex gap-sm">
<button :class="['neu-btn neu-btn--sm', uiStore.sidebarPosition === 'left' ? 'neu-btn--primary' : '']" @click="setSidebar('left')">
{{ t('settings.left') }}
</button>
<button :class="['neu-btn neu-btn--sm', uiStore.sidebarPosition === 'right' ? 'neu-btn--primary' : '']" @click="setSidebar('right')">
{{ t('settings.right') }}
</button>
</div>
</div>
</div>
</div>
<!-- Audit log -->
<div v-if="activeTab === 'audit'">
<h3>{{ t('settings.audit') }}</h3>
<div class="audit-list">
<div v-for="entry in auditLog" :key="entry.id" class="audit-entry">
<span class="audit-action neu-badge neu-badge--info">{{ entry.action }}</span>
<span class="audit-user">{{ entry.username }}</span>
<span v-if="entry.resource" class="audit-resource text-muted">{{ entry.resource }}</span>
<span class="audit-date text-muted">{{ formatDate(entry.created_at) }}</span>
</div>
<p v-if="auditLog.length === 0" class="text-muted">{{ t('settings.noAuditLog') }}</p>
</div>
</div>
<!-- Logs applicatifs -->
<div v-if="activeTab === 'logs'">
<div class="logs-header">
<h3>{{ t('settings.logs') }}</h3>
<div class="logs-controls">
<label class="text-muted">{{ t('settings.logsRefresh') }}</label>
<select v-model="logsRefreshInterval" class="neu-input neu-input--sm" @change="resetLogsRefresh">
<option :value="5000">5s</option>
<option :value="10000">10s</option>
<option :value="30000">30s</option>
<option :value="60000">60s</option>
<option :value="0">{{ t('settings.logsNoRefresh') }}</option>
</select>
<button class="neu-btn neu-btn--sm" @click="loadLogs">{{ t('common.refresh') }}</button>
</div>
</div>
<div class="logs-viewer neu-inset" ref="logsViewerRef">
<p v-if="logLines.length === 0" class="text-muted logs-empty">{{ t('settings.noLogs') }}</p>
<div v-else class="logs-lines">
<span v-for="(line, i) in logLines" :key="i" class="log-line">{{ line }}</span>
</div>
</div>
</div>
<!-- Bouton sauvegarder (sauf audit et logs) -->
<div v-if="activeTab !== 'audit' && activeTab !== 'logs'" class="settings-actions">
<button class="neu-btn neu-btn--primary" :disabled="saving" @click="saveSettings">
{{ saving ? t('common.saving') : t('common.save') }}
</button>
<span v-if="saveSuccess" class="neu-badge neu-badge--success">{{ t('common.saved') }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth.store'
import { useUiStore } from '@/stores/ui.store'
const { t } = useI18n()
const authStore = useAuthStore()
const uiStore = useUiStore()
const activeTab = ref('general')
const saving = ref(false)
const saveSuccess = ref(false)
const auditLog = ref<any[]>([])
const logLines = ref<string[]>([])
const logsRefreshInterval = ref(10000)
const logsViewerRef = ref<HTMLElement | null>(null)
let logsTimer: ReturnType<typeof setInterval> | null = null
const tabs = [
{ id: 'general', label: 'settings.general', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/></svg>` },
{ id: 'infrastructure', label: 'settings.infrastructure', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/></svg>` },
{ id: 'appearance', label: 'settings.appearance', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/></svg>` },
{ id: 'audit', label: 'settings.audit', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12"/></svg>` },
{ id: 'logs', label: 'settings.logs', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>` },
]
const settings = ref({
instance_name: '',
public_url: '',
default_lang: 'fr',
ssh_host: '',
ssh_username: '',
proxmox_url: '',
})
// Champs sensibles write-only, jamais retournés par l'API
const secrets = ref({
ssh_password: '',
proxmox_token_id: '',
proxmox_token_secret: '',
})
onMounted(async () => {
await loadSettings()
await loadAuditLog()
})
// Charger les logs quand on active l'onglet + gérer le timer
watch(activeTab, (tab) => {
if (tab === 'logs') {
loadLogs()
startLogsRefresh()
} else {
stopLogsRefresh()
}
})
onUnmounted(() => stopLogsRefresh())
async function loadSettings() {
const res = await fetch('/api/settings', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) {
const data = await res.json()
Object.assign(settings.value, data)
}
}
async function loadAuditLog() {
const res = await fetch('/api/settings/audit', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) auditLog.value = await res.json() || []
}
async function saveSettings() {
saving.value = true
saveSuccess.value = false
const entries: [string, string][] = [...Object.entries(settings.value)]
// Mot de passe SSH envoyé seulement si non-vide
if (secrets.value.ssh_password) {
entries.push(['ssh_password', secrets.value.ssh_password])
}
// Token Proxmox assemblé si les deux champs sont remplis
if (secrets.value.proxmox_token_id && secrets.value.proxmox_token_secret) {
entries.push([
'proxmox_token',
`PVEAPIToken=${secrets.value.proxmox_token_id}=${secrets.value.proxmox_token_secret}`,
])
}
for (const [key, value] of entries) {
await fetch(`/api/settings/${key}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.accessToken}`,
},
body: JSON.stringify({ value }),
})
}
// Vider les champs secrets après sauvegarde
secrets.value.ssh_password = ''
secrets.value.proxmox_token_id = ''
secrets.value.proxmox_token_secret = ''
saving.value = false
saveSuccess.value = true
setTimeout(() => (saveSuccess.value = false), 3000)
}
function setSidebar(pos: 'left' | 'right') {
uiStore.setSidebarPosition(pos)
authStore.updatePreferences({ sidebar_position: pos })
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString()
}
async function loadLogs() {
const res = await fetch('/api/settings/logs?lines=300', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) {
logLines.value = await res.json() || []
// Scroll vers le bas après rendu
await nextTick()
if (logsViewerRef.value) {
logsViewerRef.value.scrollTop = logsViewerRef.value.scrollHeight
}
}
}
function startLogsRefresh() {
stopLogsRefresh()
if (logsRefreshInterval.value > 0) {
logsTimer = setInterval(loadLogs, logsRefreshInterval.value)
}
}
function stopLogsRefresh() {
if (logsTimer) {
clearInterval(logsTimer)
logsTimer = null
}
}
function resetLogsRefresh() {
stopLogsRefresh()
startLogsRefresh()
}
</script>
<style scoped>
.settings-page { max-width: 1000px; }
.page-header { margin-bottom: var(--neu-space-lg); }
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
.settings-layout { display: grid; grid-template-columns: 200px 1fr; gap: var(--neu-space-md); }
.settings-tabs { display: flex; flex-direction: column; gap: 2px; padding: var(--neu-space-sm); align-self: start; }
.settings-tab {
display: flex;
align-items: center;
gap: var(--neu-space-sm);
padding: 10px var(--neu-space-sm);
border-radius: var(--neu-radius-md);
background: none;
border: none;
cursor: pointer;
color: var(--neu-text-muted);
font-size: var(--neu-font-sm);
text-align: left;
transition: var(--neu-transition);
}
.settings-tab:hover {
background: var(--neu-bg);
color: var(--neu-text);
}
.settings-tab--active {
background: var(--neu-bg);
color: var(--neu-primary);
box-shadow: inset 2px 2px 5px var(--neu-shadow-dark);
}
.settings-content h3 { margin-bottom: var(--neu-space-lg); color: var(--neu-text); }
.settings-form { display: flex; flex-direction: column; gap: var(--neu-space-md); }
.form-row { display: grid; grid-template-columns: 200px 1fr; align-items: center; gap: var(--neu-space-md); }
.form-row label { font-size: var(--neu-font-sm); color: var(--neu-text-muted); }
.form-row--toggle { grid-template-columns: 200px auto; }
.settings-actions { margin-top: var(--neu-space-xl); display: flex; align-items: center; gap: var(--neu-space-sm); border-top: 1px solid var(--neu-border); padding-top: var(--neu-space-lg); }
.audit-list { display: flex; flex-direction: column; gap: var(--neu-space-xs); }
.audit-entry { display: flex; align-items: center; gap: var(--neu-space-sm); padding: 6px 0; border-bottom: 1px solid var(--neu-border); font-size: var(--neu-font-sm); flex-wrap: wrap; }
.audit-entry:last-child { border-bottom: none; }
.audit-user { font-weight: 600; color: var(--neu-text); }
.audit-resource { font-family: monospace; }
.audit-date { margin-left: auto; }
.text-muted { color: var(--neu-text-muted); }
.logs-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--neu-space-md); flex-wrap: wrap; gap: var(--neu-space-sm); }
.logs-header h3 { margin-bottom: 0; }
.logs-controls { display: flex; align-items: center; gap: var(--neu-space-sm); }
.neu-input--sm { padding: 4px 8px; font-size: var(--neu-font-sm); height: auto; }
.logs-viewer {
height: 420px;
overflow-y: auto;
padding: var(--neu-space-sm);
font-family: monospace;
font-size: 12px;
line-height: 1.5;
}
.logs-empty { padding: var(--neu-space-sm); }
.logs-lines {
display: flex;
flex-direction: column;
gap: 1px;
}
.log-line {
white-space: pre-wrap;
word-break: break-all;
color: var(--neu-text);
padding: 1px 0;
border-bottom: 1px solid var(--neu-border);
}
.log-line:last-child { border-bottom: none; }
@media (max-width: 640px) {
.settings-layout { grid-template-columns: 1fr; }
.settings-tabs { flex-direction: row; flex-wrap: wrap; }
.form-row { grid-template-columns: 1fr; }
}
</style>

View file

@ -1,162 +0,0 @@
<template>
<div class="terminal-page">
<div class="page-header flex items-center justify-between">
<h2>{{ t('nav.terminal') }}</h2>
<div class="flex gap-sm">
<input v-model="customHost" class="neu-input" placeholder="host:port (défaut: config)" style="width:200px" />
<button class="neu-btn neu-btn--primary" @click="reconnect">
{{ connected ? t('terminal.reconnect') : t('terminal.connect') }}
</button>
</div>
</div>
<div class="neu-card terminal-container">
<div class="terminal-status flex items-center gap-sm">
<span :class="['ws-dot', connected ? 'ws-dot--connected' : 'ws-dot--disconnected']" />
<span class="text-muted" style="font-size:11px">
{{ connected ? t('terminal.connected', { host: currentHost }) : t('terminal.disconnected') }}
</span>
</div>
<!-- Conteneur xterm.js -->
<div ref="terminalContainer" class="terminal-xterm" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { useAuthStore } from '@/stores/auth.store'
import '@xterm/xterm/css/xterm.css'
const { t } = useI18n()
const authStore = useAuthStore()
const terminalContainer = ref<HTMLElement | null>(null)
const customHost = ref('')
const connected = ref(false)
const currentHost = ref('')
let terminal: Terminal | null = null
let fitAddon: FitAddon | null = null
let ws: WebSocket | null = null
onMounted(() => {
// Initialiser xterm.js
terminal = new Terminal({
theme: {
background: 'var(--neu-bg, #1a1d2e)',
foreground: '#e2e6f6',
cursor: '#6c8ef4',
},
fontFamily: '"Courier New", Courier, monospace',
fontSize: 13,
cursorBlink: true,
scrollback: 1000,
})
fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.open(terminalContainer.value!)
fitAddon.fit()
// Observer le redimensionnement
const ro = new ResizeObserver(() => fitAddon?.fit())
if (terminalContainer.value) ro.observe(terminalContainer.value)
// Connexion automatique
connect()
})
onUnmounted(() => {
ws?.close()
terminal?.dispose()
})
function connect() {
ws?.close()
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const hostParam = customHost.value ? `&host=${encodeURIComponent(customHost.value)}` : ''
const url = `${proto}//${window.location.host}/ws/terminal?token=${authStore.accessToken}${hostParam}`
ws = new WebSocket(url)
ws.binaryType = 'arraybuffer'
ws.onopen = () => {
connected.value = true
currentHost.value = customHost.value || 'ssh_host configuré'
terminal?.write('\r\n\x1b[32mConnecté au terminal SSH\x1b[0m\r\n\r\n')
}
ws.onclose = () => {
connected.value = false
terminal?.write('\r\n\x1b[31mDéconnecté\x1b[0m\r\n')
}
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
terminal?.write(new Uint8Array(event.data))
} else {
terminal?.write(event.data)
}
}
ws.onerror = () => {
terminal?.write('\r\n\x1b[31mErreur de connexion WebSocket\x1b[0m\r\n')
}
// Envoyer les frappes clavier au serveur SSH
terminal?.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(data)
}
})
// Envoyer le resize au serveur
terminal?.onResize(({ cols, rows }) => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols, rows }))
}
})
}
function reconnect() {
terminal?.clear()
connect()
}
</script>
<style scoped>
.terminal-page { height: 100%; display: flex; flex-direction: column; max-width: 1200px; }
.page-header { margin-bottom: var(--neu-space-lg); flex-shrink: 0; }
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
.text-muted { color: var(--neu-text-muted); }
.terminal-container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding: var(--neu-space-sm);
}
.terminal-status {
padding: var(--neu-space-xs) var(--neu-space-sm);
border-bottom: 1px solid var(--neu-border);
margin-bottom: var(--neu-space-sm);
flex-shrink: 0;
}
.terminal-xterm {
flex: 1;
min-height: 400px;
}
.ws-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.ws-dot--connected { background: var(--neu-success); animation: neu-pulse 2s infinite; }
.ws-dot--disconnected { background: var(--neu-text-muted); }
</style>

View file

@ -1,424 +0,0 @@
<template>
<div class="updates-page">
<div class="page-header">
<div>
<h2>{{ t('nav.updates') }}</h2>
<p class="text-muted">{{ t('updates.desc') }}</p>
</div>
<div class="header-actions">
<button class="neu-btn neu-btn--sm" :disabled="checkingAll || loadingTargets" @click="checkAll">
<span v-if="checkingAll" class="neu-loading"></span>
{{ t('updates.checkAll') }}
</button>
<button class="neu-btn neu-btn--success" :disabled="anyRunning" @click="updateAllTargets">
{{ anyRunning ? t('updates.running') : t('updates.updateAll') }}
</button>
</div>
</div>
<!-- Loading -->
<div v-if="loadingTargets" class="neu-card loading-card">
<p class="text-muted"> {{ t('updates.loadingTargets') }}</p>
</div>
<!-- Target cards -->
<div v-else class="targets-grid">
<div
v-for="target in targets"
:key="target.id"
class="target-card neu-card"
>
<!-- Header -->
<div class="target-header">
<div class="target-title">
<span class="target-name">{{ target.name }}</span>
<span v-if="target.vmid" class="target-vmid">LXC {{ target.vmid }}</span>
</div>
<span :class="['neu-badge', target.status === 'running' ? 'neu-badge--success' : 'neu-badge--warning']">
{{ target.status === 'running' ? t('proxmox.running') : t('updates.stopped') }}
</span>
</div>
<!-- Package status -->
<div class="target-status">
<template v-if="target.checking">
<span class="text-muted text-xs"> {{ t('updates.checking') }}</span>
</template>
<template v-else-if="target.error">
<span class="neu-badge neu-badge--danger" :title="target.error"> Erreur</span>
</template>
<template v-else-if="target.packages === null">
<span class="not-checked text-muted">{{ t('updates.notChecked') }}</span>
</template>
<template v-else-if="target.packages.length === 0">
<span class="neu-badge neu-badge--success"> {{ t('updates.upToDate') }}</span>
</template>
<template v-else>
<button class="pkg-count-btn" @click="target.showPackages = !target.showPackages">
<span class="neu-badge neu-badge--warning">{{ target.packages.length }} {{ t('updates.packagesToUpdate') }}</span>
<span class="pkg-toggle">{{ target.showPackages ? '▲' : '▼' }}</span>
</button>
</template>
</div>
<!-- Package list (expandable) -->
<div v-if="target.showPackages && target.packages?.length" class="package-list neu-inset">
<div v-for="pkg in target.packages" :key="pkg.name" class="package-item">
<span class="pkg-name">{{ pkg.name }}</span>
<span class="pkg-versions text-muted">{{ pkg.old_version }} <strong>{{ pkg.version }}</strong></span>
</div>
</div>
<!-- Actions -->
<div class="target-actions">
<button
class="neu-btn neu-btn--sm"
:disabled="target.checking || target.status !== 'running'"
@click="checkTarget(target)"
>
{{ t('updates.checkUpdates') }}
</button>
<button
class="neu-btn neu-btn--sm neu-btn--success"
:disabled="target.updating || target.status !== 'running'"
@click="updateTarget(target)"
>
<span v-if="target.updating" class="neu-loading"></span>
{{ target.updating ? t('updates.running') : t('updates.updateTarget') }}
</button>
</div>
</div>
</div>
<!-- Output terminal -->
<div v-if="activeJob" class="neu-card output-card">
<div class="output-header flex items-center justify-between">
<h3>{{ t('updates.output') }} {{ activeJob.target }}</h3>
<span :class="['neu-badge', activeJob.status === 'success' ? 'neu-badge--success' : activeJob.status === 'error' ? 'neu-badge--danger' : 'neu-badge--warning']">
{{ t(`updates.status.${activeJob.status}`) }}
</span>
</div>
<div class="output-terminal neu-inset" ref="terminalEl">
<pre class="output-text">{{ activeJob.output }}</pre>
</div>
</div>
<!-- History -->
<div class="neu-card">
<h3>{{ t('updates.history') }}</h3>
<div v-if="history.length === 0" class="empty-state">
<p class="text-muted">{{ t('updates.noHistory') }}</p>
</div>
<div v-else class="history-list">
<div v-for="entry in history" :key="entry.job_id" class="history-item">
<div class="flex items-center gap-sm">
<span :class="['neu-badge', entry.status === 'success' ? 'neu-badge--success' : entry.status === 'error' ? 'neu-badge--danger' : 'neu-badge--warning']">
{{ t(`updates.status.${entry.status}`) }}
</span>
<span class="history-target">{{ entry.target }}</span>
<span class="text-muted history-date">{{ formatDate(entry.started_at) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth.store'
const { t } = useI18n()
const authStore = useAuthStore()
interface Package {
name: string
version: string
old_version: string
}
interface Target {
id: string
name: string
status: string
vmid?: number
packages: Package[] | null
checking: boolean
updating: boolean
showPackages: boolean
error: string | null
}
interface ActiveJob {
jobId: string
target: string
output: string
status: 'running' | 'success' | 'error'
}
const targets = ref<Target[]>([])
const loadingTargets = ref(true)
const activeJob = ref<ActiveJob | null>(null)
const history = ref<any[]>([])
const terminalEl = ref<HTMLElement | null>(null)
let wsConnection: WebSocket | null = null
const checkingAll = computed(() => targets.value.some(t => t.checking))
const anyRunning = computed(() => targets.value.some(t => t.updating))
onMounted(async () => {
await loadTargets()
await loadHistory()
checkAll()
})
onUnmounted(() => wsConnection?.close())
async function loadTargets() {
loadingTargets.value = true
try {
const list: Target[] = [
{ id: 'host', name: 'Proxmox Host', status: 'running', packages: null, checking: false, updating: false, showPackages: false, error: null },
]
const res = await fetch('/api/proxmox/resources', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) {
const resources: any[] = await res.json() || []
for (const r of resources.filter((r: any) => r.type === 'lxc')) {
list.push({
id: `lxc:${r.vmid}`,
name: r.name || `LXC ${r.vmid}`,
status: r.status,
vmid: r.vmid,
packages: null,
checking: false,
updating: false,
showPackages: false,
error: null,
})
}
}
targets.value = list
} finally {
loadingTargets.value = false
}
}
async function checkTarget(target: Target) {
target.checking = true
target.error = null
try {
const res = await fetch(`/api/updates/packages?target=${encodeURIComponent(target.id)}`, {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) {
target.packages = await res.json() || []
} else {
target.error = 'Erreur lors de la vérification'
target.packages = null
}
} catch {
target.error = 'Erreur réseau'
target.packages = null
} finally {
target.checking = false
}
}
async function checkAll() {
// Séquentiel pour ne pas saturer le SSH pool
for (const target of targets.value) {
if (target.status === 'running') {
await checkTarget(target)
}
}
}
async function updateTarget(target: Target) {
target.updating = true
target.error = null
const res = await fetch('/api/updates/run', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.accessToken}`,
},
body: JSON.stringify({ target: target.id }),
})
if (!res.ok) {
target.updating = false
return
}
const data = await res.json()
connectJobWS(data.job_id, target.name, () => {
target.updating = false
checkTarget(target)
loadHistory()
})
}
async function updateAllTargets() {
const res = await fetch('/api/updates/run', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.accessToken}`,
},
body: JSON.stringify({ target: 'all' }),
})
if (!res.ok) return
const data = await res.json()
// Marquer tous les LXC running comme "updating"
targets.value.forEach(t => { if (t.status === 'running') t.updating = true })
connectJobWS(data.job_id, 'all LXC', () => {
targets.value.forEach(t => { t.updating = false })
loadHistory()
checkAll()
})
}
function connectJobWS(jobId: string, targetName: string, onDone: () => void) {
wsConnection?.close()
activeJob.value = {
jobId,
target: targetName,
output: '',
status: 'running',
}
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
wsConnection = new WebSocket(`${proto}//${window.location.host}/ws/updates/${jobId}?token=${authStore.accessToken}`)
wsConnection.onmessage = async (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'update_output' && msg.payload?.chunk && activeJob.value) {
activeJob.value.output += msg.payload.chunk
await nextTick()
if (terminalEl.value) terminalEl.value.scrollTop = terminalEl.value.scrollHeight
} else if (msg.type === 'update_done') {
if (activeJob.value) activeJob.value.status = 'success'
wsConnection?.close()
onDone()
} else if (msg.type === 'update_error') {
if (activeJob.value) activeJob.value.status = 'error'
wsConnection?.close()
onDone()
}
}
}
async function loadHistory() {
const res = await fetch('/api/updates/history', {
headers: { Authorization: `Bearer ${authStore.accessToken}` },
})
if (res.ok) history.value = await res.json() || []
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString()
}
</script>
<style scoped>
.updates-page { max-width: 1200px; display: flex; flex-direction: column; gap: var(--neu-space-lg); }
.page-header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--neu-space-md); }
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
.text-muted { color: var(--neu-text-muted); }
.text-xs { font-size: var(--neu-font-xs); }
.header-actions { display: flex; gap: var(--neu-space-sm); flex-shrink: 0; align-items: flex-start; }
.loading-card { text-align: center; padding: var(--neu-space-xl); }
.targets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--neu-space-md);
}
.target-card { display: flex; flex-direction: column; gap: var(--neu-space-sm); }
.target-header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--neu-space-sm); }
.target-title { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.target-name { font-weight: 600; color: var(--neu-text); font-size: var(--neu-font-md); }
.target-vmid { font-size: var(--neu-font-xs); color: var(--neu-text-muted); font-family: monospace; }
.target-status { min-height: 26px; display: flex; align-items: center; gap: var(--neu-space-xs); }
.not-checked { font-size: var(--neu-font-xs); }
.pkg-count-btn {
display: flex;
align-items: center;
gap: var(--neu-space-xs);
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.pkg-toggle { font-size: 10px; color: var(--neu-text-muted); }
.package-list {
max-height: 200px;
overflow-y: auto;
padding: var(--neu-space-xs) var(--neu-space-sm);
display: flex;
flex-direction: column;
gap: 2px;
}
.package-item {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 2px 0;
font-size: 11px;
border-bottom: 1px solid var(--neu-border);
gap: var(--neu-space-sm);
}
.package-item:last-child { border-bottom: none; }
.pkg-name { font-family: monospace; color: var(--neu-text); font-weight: 500; flex-shrink: 0; }
.pkg-versions { font-family: monospace; text-align: right; }
.pkg-versions strong { color: var(--neu-text); }
.target-actions {
display: flex;
gap: var(--neu-space-xs);
margin-top: auto;
padding-top: var(--neu-space-sm);
border-top: 1px solid var(--neu-border);
}
.target-actions .neu-btn { flex: 1; font-size: var(--neu-font-xs); }
.output-card { }
.output-header { margin-bottom: var(--neu-space-sm); }
.output-terminal {
height: 400px;
overflow-y: auto;
padding: var(--neu-space-md);
font-family: 'Courier New', monospace;
font-size: 12px;
}
.output-text {
color: var(--neu-success);
white-space: pre-wrap;
word-break: break-all;
margin: 0;
}
.empty-state { padding: var(--neu-space-md) 0; }
.history-list { display: flex; flex-direction: column; gap: var(--neu-space-xs); }
.history-item { padding: var(--neu-space-xs) 0; border-bottom: 1px solid var(--neu-border); }
.history-item:last-child { border-bottom: none; }
.history-target { font-size: var(--neu-font-sm); color: var(--neu-text); font-family: monospace; }
.history-date { font-size: var(--neu-font-xs); margin-left: auto; }
@media (max-width: 640px) {
.page-header { flex-direction: column; }
.targets-grid { grid-template-columns: 1fr; }
}
</style>

View file

@ -0,0 +1 @@
export { default as Swup } from 'swup'

70
frontend/terminal.html Normal file
View file

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<script>(function(){document.documentElement.setAttribute("data-theme",localStorage.getItem("pxp_theme")||"dark");document.documentElement.setAttribute("data-sidebar",localStorage.getItem("pxp_sidebar_pos")||"left")})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxmoxPanel — Terminal</title>
<link rel="stylesheet" href="/css/neu.css" />
<link rel="stylesheet" href="/css/dark.css" />
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/pages.css" />
<link rel="stylesheet" href="/css/lineicons-duotone.css" />
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/css/xterm.css" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/vendors/xterm.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/terminal.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header" @click="toggle()">
<i class="sidebar-logo lnid-layout-1"></i>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">
<a class="sidebar-link" :class="{ active: isActive(item.id) }" :href="item.href" @click.prevent="navigate(item.href)">
<i class="sidebar-icon" :class="item.iconClass" :style="iconStyle(item)"></i>
<span class="sidebar-label" x-show="!collapsed" x-text="t(item.labelKey)"></span>
</a>
</template>
</nav>
<div class="sidebar-footer">
<a class="sidebar-link" href="/profile.html" @click.prevent="navigate('/profile.html')">
<i class="sidebar-icon lnid-user-circle-1"></i>
<span class="sidebar-label" x-show="!collapsed" x-text="$store.auth.user?.username || t('nav.profile')"></span>
</a>
</div>
</aside>
<div class="main-layout terminal-wrapper">
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.terminal')"></h2>
<div class="navbar-actions">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<i :class="theme==='dark' ? 'lnid-sun-1' : 'lnid-moon-half-left-1'"></i>
</button>
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="logout()" :title="t('navbar.logout')">
<i class="lnid-power-button"></i>
</button>
</div>
</nav>
<main id="swup" class="page-content transition-fade terminal-layout">
<div class="terminal-toolbar">
<span id="terminal-status" class="terminal-status">⌛ Connexion…</span>
<button id="terminal-clear" class="neu-btn neu-btn--sm">
<i class="lnid-trash-1"></i> Effacer
</button>
</div>
<div id="terminal-container" class="terminal-container"></div>
</main>
</div>
</body>
</html>

View file

@ -1,24 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -1,11 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

201
frontend/updates.html Normal file
View file

@ -0,0 +1,201 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<script>(function(){document.documentElement.setAttribute("data-theme",localStorage.getItem("pxp_theme")||"dark");document.documentElement.setAttribute("data-sidebar",localStorage.getItem("pxp_sidebar_pos")||"left")})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProxmoxPanel — Mises à jour</title>
<link rel="stylesheet" href="/css/neu.css" />
<link rel="stylesheet" href="/css/dark.css" />
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/pages.css" />
<link rel="stylesheet" href="/css/lineicons-duotone.css" />
<link rel="manifest" href="/manifest.json" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
<script src="/js/vendors/alpine.min.js" defer></script>
</head>
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header" @click="toggle()">
<i class="sidebar-logo lnid-layout-1"></i>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">
<a class="sidebar-link" :class="{ active: isActive(item.id) }" :href="item.href" @click.prevent="navigate(item.href)">
<i class="sidebar-icon" :class="item.iconClass" :style="iconStyle(item)"></i>
<span class="sidebar-label" x-show="!collapsed" x-text="t(item.labelKey)"></span>
</a>
</template>
</nav>
<div class="sidebar-footer">
<a class="sidebar-link" href="/profile.html" @click.prevent="navigate('/profile.html')">
<i class="sidebar-icon lnid-user-circle-1"></i>
<span class="sidebar-label" x-show="!collapsed" x-text="$store.auth.user?.username || t('nav.profile')"></span>
</a>
</div>
</aside>
<div class="main-layout">
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.updates')"></h2>
<div class="navbar-actions">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<i :class="theme==='dark' ? 'lnid-sun-1' : 'lnid-moon-half-left-1'"></i>
</button>
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="logout()" :title="t('navbar.logout')">
<i class="lnid-power-button"></i>
</button>
</div>
</nav>
<main id="swup" class="page-content transition-fade">
<div x-data="updatesPage()" x-cloak>
<!-- Tabs -->
<div class="tabs">
<button class="tab-btn" :class="{ active: activeTab === 'targets' }" @click="activeTab = 'targets'">Cibles</button>
<button class="tab-btn" :class="{ active: activeTab === 'history' }" @click="activeTab = 'history'; loadHistory()">
<i class="lnid-clock-1"></i> Historique
</button>
</div>
<!-- Tab: Cibles -->
<div x-show="activeTab === 'targets'">
<!-- Actions globales -->
<div class="page-actions">
<div class="page-actions-left">
<span class="total-badge" x-show="!loading">
<span x-text="totalPackages"></span> paquets à mettre à jour
</span>
</div>
<div class="page-actions-right">
<button class="neu-btn" @click="checkAll()" :disabled="loading || targets.some(t=>t.checking)">
<i class="lnid-refresh-circle-1-clockwise"></i> Tout vérifier
</button>
<button class="neu-btn neu-btn--primary" @click="updateAll()"
:disabled="loading || jobStatus === 'running' || totalPackages === 0">
<i class="lnid-arrow-upward"></i> Tout mettre à jour
</button>
</div>
</div>
<!-- Loading -->
<div class="loading-state" x-show="loading">
<div class="spinner-lg"></div>
<span>Chargement des cibles…</span>
</div>
<!-- Target cards -->
<div class="targets-grid" x-show="!loading">
<template x-for="target in targets" :key="target.id">
<div class="neu-card target-card">
<div class="target-header">
<div class="target-info">
<span class="target-name" x-text="target.name"></span>
<span class="target-id" x-text="target.id"></span>
</div>
<div class="target-status">
<span class="badge" :class="target.status" x-text="target.status" x-show="target.status"></span>
</div>
</div>
<!-- Package count -->
<div class="package-summary">
<span x-show="target.checking" class="checking-text">
<span class="spinner-sm"></span> Vérification…
</span>
<span x-show="!target.checking && target.packages === null" class="muted">
Non vérifié
</span>
<span x-show="!target.checking && target.packages !== null && target.packages.length === 0"
class="up-to-date">
<i class="lnid-check-circle-1"></i> À jour
</span>
<span x-show="!target.checking && target.packages !== null && target.packages.length > 0"
class="has-updates">
<span x-text="target.packages.length"></span> paquet(s) à mettre à jour
</span>
</div>
<!-- Package list -->
<div class="package-list" x-show="target.packages && target.packages.length > 0">
<template x-for="pkg in target.packages" :key="pkg.name">
<div class="package-row">
<span class="pkg-name" x-text="pkg.name"></span>
<span class="pkg-version">
<span class="old-ver" x-text="pkg.old_version"></span>
<span class="arrow"></span>
<span class="new-ver" x-text="pkg.version"></span>
</span>
</div>
</template>
</div>
<!-- Card actions -->
<div class="target-actions">
<button class="neu-btn neu-btn--sm" @click="checkTarget(target)" :disabled="target.checking">
<i class="lnid-refresh-circle-1-clockwise"></i> Vérifier
</button>
<button class="neu-btn neu-btn--sm neu-btn--primary"
@click="updateTarget(target)"
:disabled="target.updating || jobStatus === 'running' || !target.packages || target.packages.length === 0"
x-show="target.status !== 'stopped'">
<span x-show="!target.updating"><i class="lnid-arrow-upward"></i> Mettre à jour</span>
<span x-show="target.updating"><span class="spinner-sm"></span> En cours…</span>
</button>
</div>
</div>
</template>
</div>
<!-- Output streaming -->
<div class="output-panel neu-inset" x-show="currentJob">
<div class="output-header">
<span class="output-title">Job <span x-text="currentJob"></span></span>
<span class="job-status" :class="jobStatus" x-text="jobStatus"></span>
</div>
<pre class="output-content" x-text="output" x-ref="output"></pre>
</div>
</div><!-- /tab targets -->
<!-- Tab: Historique -->
<div x-show="activeTab === 'history'">
<div class="loading-state" x-show="historyLoading">
<div class="spinner-lg"></div>
<span>Chargement…</span>
</div>
<div x-show="!historyLoading">
<p class="empty-state" x-show="history.length === 0">Aucune mise à jour effectuée</p>
<div class="history-table" x-show="history.length > 0">
<div class="history-header">
<span>Job</span>
<span>Cible</span>
<span>Statut</span>
<span>Date</span>
<span>Durée</span>
</div>
<template x-for="h in history" :key="h.job_id">
<div class="history-row" @click="output = h.output; currentJob = h.job_id; jobStatus = h.status; activeTab = 'targets'">
<span class="history-job" x-text="h.job_id.slice(0,8)"></span>
<span class="history-target" x-text="h.target"></span>
<span class="history-status badge" :class="h.status" x-text="h.status"></span>
<span class="history-date" x-text="formatDate(h.started_at)"></span>
<span class="history-dur" x-text="h.finished_at ? '' : '—'"></span>
</div>
</template>
</div>
</div>
</div><!-- /tab history -->
</div>
</main>
</div>
</body>
</html>

5
frontend/vendors/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
frontend/vendors/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,48 +0,0 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
// Proxy vers le backend Go en développement
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:3001',
ws: true,
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
// Optimiser les chunks pour de meilleures performances
rollupOptions: {
output: {
manualChunks: {
'vue-core': ['vue', 'vue-router', 'pinia'],
'i18n': ['vue-i18n'],
'terminal': ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-attach'],
'editor': [
'@codemirror/state',
'@codemirror/view',
'@codemirror/commands',
'@codemirror/language',
],
},
},
},
},
})

View file

@ -0,0 +1,2 @@
export { Terminal } from '@xterm/xterm'
export { FitAddon } from '@xterm/addon-fit'