Compare commits
38 commits
master
...
frontend/a
| Author | SHA1 | Date | |
|---|---|---|---|
| 365165c13b | |||
| 3c8a1a6b58 | |||
| 18d060461c | |||
| 3bc55a4c6f | |||
| ab834600ba | |||
| de4af0ee26 | |||
| e55a69e42f | |||
| bfc630da2e | |||
| bc02076d97 | |||
| c97a524195 | |||
| 4b083f6fa5 | |||
| 22a5fed8cc | |||
| a61f805cd0 | |||
| dcf3b937fa | |||
| 4b1a0a09a8 | |||
| cbf87a87fc | |||
| ec7d120ef6 | |||
| 91cf788221 | |||
| c9ba6755b8 | |||
| 6666d931c9 | |||
| 5836f2201a | |||
| 98cdabf3e1 | |||
| 1cbd7e9d17 | |||
| 95757124de | |||
| dc0c67b89c | |||
| 21e1e0ed1e | |||
| 780e5ec81d | |||
| 97212b7ffa | |||
| b851dc61af | |||
| 7c57b0ff84 | |||
| cbfb20505d | |||
| b6d6355c6c | |||
| 5f6681dd17 | |||
| 9739dbaee8 | |||
| a4b5b06f04 | |||
| 65c8bf332f | |||
| 562eff8863 | |||
| 2098c80ec1 |
76 changed files with 17090 additions and 5520 deletions
149
SUIVI.md
Normal file
149
SUIVI.md
Normal 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
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
|
|||
104
backend/cmd/gen-modules/main.go
Normal file
104
backend/cmd/gen-modules/main.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ¬Null, &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
|
||||
}
|
||||
|
|
|
|||
6
backend/internal/db/migrations/002_sessions.sql
Normal file
6
backend/internal/db/migrations/002_sessions.sql
Normal 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;
|
||||
4
backend/internal/db/migrations/003_modules_extra.sql
Normal file
4
backend/internal/db/migrations/003_modules_extra.sql
Normal 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);
|
||||
6
backend/internal/db/migrations/004_enable_modules.sql
Normal file
6
backend/internal/db/migrations/004_enable_modules.sql
Normal 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;
|
||||
10
backend/internal/db/migrations/005_module_nav_store.sql
Normal file
10
backend/internal/db/migrations/005_module_nav_store.sql
Normal 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 ''
|
||||
3
backend/internal/db/migrations/006_has_backend.sql
Normal file
3
backend/internal/db/migrations/006_has_backend.sql
Normal 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;
|
||||
416
backend/internal/docker/client.go
Normal file
416
backend/internal/docker/client.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
8
backend/registered_modules.go
Normal file
8
backend/registered_modules.go
Normal 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) {}
|
||||
|
|
@ -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
2
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
dist/
|
||||
|
|
@ -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
111
frontend/build.mjs
Normal 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/')
|
||||
11023
frontend/css/lineicons-duotone.css
Normal file
11023
frontend/css/lineicons-duotone.css
Normal file
File diff suppressed because it is too large
Load diff
BIN
frontend/css/lineicons-duotone.ttf
Normal file
BIN
frontend/css/lineicons-duotone.ttf
Normal file
Binary file not shown.
BIN
frontend/css/lineicons-duotone.woff
Normal file
BIN
frontend/css/lineicons-duotone.woff
Normal file
Binary file not shown.
BIN
frontend/css/lineicons-duotone.woff2
Normal file
BIN
frontend/css/lineicons-duotone.woff2
Normal file
Binary file not shown.
|
|
@ -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
986
frontend/css/pages.css
Normal 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
193
frontend/dashboard.html
Normal 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
24
frontend/icons/icon.svg
Normal 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 |
|
|
@ -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
150
frontend/install.html
Normal 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
1317
frontend/js/app.js
Normal file
File diff suppressed because it is too large
Load diff
110
frontend/js/terminal.js
Normal file
110
frontend/js/terminal.js
Normal 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
86
frontend/js/ws.sw.js
Normal 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
@ -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
64
frontend/login.html
Normal 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
17
frontend/manifest.json
Normal 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
158
frontend/modules.html
Normal 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1714
frontend/package-lock.json
generated
1714
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
198
frontend/profile.html
Normal 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
141
frontend/proxmox.html
Normal 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
238
frontend/settings.html
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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')
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
1
frontend/swup-bundle.entry.mjs
Normal file
1
frontend/swup-bundle.entry.mjs
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Swup } from 'swup'
|
||||
70
frontend/terminal.html
Normal file
70
frontend/terminal.html
Normal 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>
|
||||
|
|
@ -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" }]
|
||||
}
|
||||
|
|
@ -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
201
frontend/updates.html
Normal 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
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
1
frontend/vendors/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
2
frontend/xterm-bundle.entry.mjs
Normal file
2
frontend/xterm-bundle.entry.mjs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { Terminal } from '@xterm/xterm'
|
||||
export { FitAddon } from '@xterm/addon-fit'
|
||||
Loading…
Add table
Add a link
Reference in a new issue