From ec7d120ef654aa19610f973beee66ac8702ca758 Mon Sep 17 00:00:00 2001 From: enzo Date: Sun, 22 Mar 2026 03:34:17 +0100 Subject: [PATCH] =?UTF-8?q?refactor:=20architecture=20modules=20ind=C3=A9p?= =?UTF-8?q?endants=20=E2=80=94=20nettoyage=20CORE,=20registry=20enrichi,?= =?UTF-8?q?=20page=20modules=20dynamique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Supprimer les modules services et logs du CORE (déplacés dans viewServices et viewLogs) - Enrichir modules/module.go : interface Registry avec NavItemDef, RunOnTarget, StreamOnTarget - Réécrire modules/loader.go : NewLoader accepte *db.DB, *sshpool.Pool, *crypto.Encryptor - Ajouter migration 005 : colonnes nav_* sur la table modules + suppression services/logs DB - Mettre à jour db.go (repairSchema) pour ajout idempotent des colonnes nav_* - Mettre à jour settings.go : GetModules retourne les champs nav, ajout GetRegistryModules et InstallRegistryModule - Mettre à jour main.go : NewLoader avec les bons arguments, ajout routes /api/registry/modules - Mettre à jour modules.html : section Store avec liste des modules Forgejo - Mettre à jour app.js : sidebar dynamique (nav_href depuis DB), modulesPage avec store - Mettre à jour pages.css : styles pour store de modules Co-Authored-By: Claude Sonnet 4.6 --- backend/internal/api/settings.go | 199 +++++++++++++-- backend/internal/db/db.go | 6 + .../db/migrations/005_module_nav_store.sql | 10 + backend/main.go | 13 +- backend/modules/loader.go | 109 +++++++-- backend/modules/logs/README.md | 45 ---- backend/modules/logs/logs.go | 206 ---------------- backend/modules/module.go | 49 ++-- backend/modules/services/README.md | 55 ----- backend/modules/services/services.go | 198 --------------- frontend/css/pages.css | 23 ++ frontend/js/app.js | 228 +++++------------- frontend/logs.html | 110 --------- frontend/modules.html | 62 ++++- frontend/services.html | 144 ----------- 15 files changed, 460 insertions(+), 997 deletions(-) create mode 100644 backend/internal/db/migrations/005_module_nav_store.sql delete mode 100644 backend/modules/logs/README.md delete mode 100644 backend/modules/logs/logs.go delete mode 100644 backend/modules/services/README.md delete mode 100644 backend/modules/services/services.go delete mode 100644 frontend/logs.html delete mode 100644 frontend/services.html diff --git a/backend/internal/api/settings.go b/backend/internal/api/settings.go index 3c54c7f..789768f 100644 --- a/backend/internal/api/settings.go +++ b/backend/internal/api/settings.go @@ -2,6 +2,9 @@ package api import ( + "encoding/json" + "fmt" + "io" "net/http" "strconv" @@ -123,11 +126,26 @@ 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"` + 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, + 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 { @@ -136,25 +154,14 @@ 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 m moduleResp var isCore, isEnabled int - var installedAt *string - rows.Scan(&m.ID, &m.Name, &m.Description, &m.Version, &isCore, &isEnabled, &installedAt) + rows.Scan(&m.ID, &m.Name, &m.Description, &m.Version, &isCore, &isEnabled, + &m.NavHref, &m.NavIcon, &m.NavColor, &m.NavLabelKey) m.IsCore = isCore == 1 m.IsEnabled = isEnabled == 1 - m.InstalledAt = installedAt modules = append(modules, m) } @@ -256,3 +263,163 @@ 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"` +} + +// GetRegistryModules liste les repos de l'organisation proxmoxPanel sur Forgejo. +// 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 { + JSONError(w, "Erreur lecture modules", http.StatusInternalServerError) + return + } + installed := make(map[string]bool) + for rows.Next() { + var id string + rows.Scan(&id) + installed[id] = true + } + rows.Close() + + // Appel à l'API Forgejo + resp, err := http.Get("https://git.geronzi.fr/api/v1/repos/search?q=&owner=proxmoxPanel&limit=50") + if err != nil { + JSONError(w, fmt.Sprintf("Erreur accès store : %v", err), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + JSONError(w, "Erreur lecture réponse store", http.StatusBadGateway) + return + } + + // Structure de réponse Gitea/Forgejo + var forgejoResp struct { + Data []struct { + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + HTMLURL string `json:"html_url"` + } `json:"data"` + } + if err := json.Unmarshal(body, &forgejoResp); err != nil { + JSONError(w, "Erreur parsing réponse store", http.StatusBadGateway) + return + } + + var modules []RegistryModule + for _, repo := range forgejoResp.Data { + // Exclure le repo "core" + 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], + }) + } + + if modules == nil { + modules = []RegistryModule{} + } + JSONResponse(w, http.StatusOK, modules) +} + +// 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"` +} + +// 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 + url := fmt.Sprintf("https://git.geronzi.fr/api/v1/repos/proxmoxPanel/%s/raw/module.json?ref=main", id) + resp, err := http.Get(url) + 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("https://git.geronzi.fr/proxmoxPanel/%s", id) + + // Insérer ou remplacer en DB + _, err = h.db.Exec(` + INSERT INTO modules (id, name, description, version, is_core, is_enabled, + 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, + 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, + 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}, clientIP(r)) + + JSONResponse(w, http.StatusOK, map[string]string{ + "message": fmt.Sprintf("Module %s installé — rebuild requis pour activation", mod.ID), + }) +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index cbdcbeb..4cf5d58 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -208,6 +208,12 @@ func (db *DB) repairSchema() error { {"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 { diff --git a/backend/internal/db/migrations/005_module_nav_store.sql b/backend/internal/db/migrations/005_module_nav_store.sql new file mode 100644 index 0000000..6457cb3 --- /dev/null +++ b/backend/internal/db/migrations/005_module_nav_store.sql @@ -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 '' diff --git a/backend/main.go b/backend/main.go index a104893..d92df0f 100644 --- a/backend/main.go +++ b/backend/main.go @@ -17,8 +17,6 @@ import ( sshpool "git.geronzi.fr/proxmoxPanel/core/backend/internal/ssh" "git.geronzi.fr/proxmoxPanel/core/backend/internal/websocket" "git.geronzi.fr/proxmoxPanel/core/backend/modules" - "git.geronzi.fr/proxmoxPanel/core/backend/modules/logs" - "git.geronzi.fr/proxmoxPanel/core/backend/modules/services" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -69,9 +67,7 @@ func main() { auditLogger := audit.New(database.DB) // ── Chargement des modules actifs ────────────────────────────────────── - loader := modules.NewLoader(database.DB) - loader.RegisterModule(services.New(database, sshPool, encryptor)) - loader.RegisterModule(logs.New(database, sshPool, encryptor)) + loader := modules.NewLoader(database, sshPool, encryptor) if err := loader.LoadActive(); err != nil { log.Fatalf("Erreur chargement modules : %v", err) } @@ -172,6 +168,13 @@ 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) + }) + // WebSocket — les routes WS extraient le token via query param r.Get("/ws/proxmox", proxmoxHandler.WebSocket) r.Get("/ws/updates/{jobId}", updatesHandler.WebSocketUpdate) diff --git a/backend/modules/loader.go b/backend/modules/loader.go index 76e7cd0..3ddc9d9 100644 --- a/backend/modules/loader.go +++ b/backend/modules/loader.go @@ -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,19 +63,18 @@ 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 @@ -90,19 +95,25 @@ 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), } } @@ -130,8 +141,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 +211,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 diff --git a/backend/modules/logs/README.md b/backend/modules/logs/README.md deleted file mode 100644 index fd9196a..0000000 --- a/backend/modules/logs/README.md +++ /dev/null @@ -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=&host=` - -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) diff --git a/backend/modules/logs/logs.go b/backend/modules/logs/logs.go deleted file mode 100644 index a8431e3..0000000 --- a/backend/modules/logs/logs.go +++ /dev/null @@ -1,206 +0,0 @@ -// Module Logs — streaming de journalctl via WebSocket + SSH. -// Expose : liste des unités journald, streaming WebSocket journalctl. -package logs - -import ( - "fmt" - "net/http" - "strconv" - "strings" - "time" - - "git.geronzi.fr/proxmoxPanel/core/backend/internal/api" - "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" - "git.geronzi.fr/proxmoxPanel/core/backend/modules" - gorillaws "github.com/gorilla/websocket" - gossh "golang.org/x/crypto/ssh" -) - -// LogsModule gère la lecture des journaux systemd via SSH. -type LogsModule struct { - db *db.DB - pool *sshpool.Pool - enc *crypto.Encryptor -} - -// New crée un LogsModule avec les dépendances nécessaires. -func New(database *db.DB, pool *sshpool.Pool, enc *crypto.Encryptor) *LogsModule { - return &LogsModule{db: database, pool: pool, enc: enc} -} - -func (m *LogsModule) ID() string { return "logs" } - -// Register enregistre les routes du module dans le registry CORE. -func (m *LogsModule) Register(r modules.Registry) error { - r.RegisterRoute("GET", "/api/logs/units", m.ListUnits, false) - r.RegisterRoute("GET", "/ws/logs", m.StreamLogs, false) - return nil -} - -// sshCreds récupère et déchiffre les credentials SSH depuis la configuration. -func (m *LogsModule) sshCreds() (host, user, pass string, err error) { - host, _, _ = m.db.GetSetting("ssh_host") - user, _, _ = m.db.GetSetting("ssh_username") - encPass, _, _ := m.db.GetSetting("ssh_password") - if encPass != "" { - pass, err = m.enc.Decrypt(encPass) - if err != nil { - return "", "", "", fmt.Errorf("impossible de déchiffrer le mot de passe SSH") - } - } - if host == "" || user == "" || pass == "" { - return "", "", "", fmt.Errorf("SSH non configuré") - } - return host, user, pass, nil -} - -// buildJournalCmd construit la commande journalctl, en la wrappant via pct exec pour les LXC. -func buildJournalCmd(target, journalArgs 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 -- journalctl %s", vmid, journalArgs) - } - } - return "journalctl " + journalArgs -} - -// sanitizeUnit valide un nom d'unité systemd pour éviter l'injection de commandes. -func sanitizeUnit(unit string) string { - for _, c := range unit { - ok := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || - (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '_' || c == '@' || c == ':' - if !ok { - return "" - } - } - return unit -} - -// ListUnits retourne la liste des unités systemd présentes dans les journaux. -// GET /api/logs/units?target=host|lxc:ID -func (m *LogsModule) ListUnits(w http.ResponseWriter, r *http.Request) { - sshHost, user, pass, err := m.sshCreds() - if err != nil { - api.JSONError(w, err.Error(), http.StatusServiceUnavailable) - return - } - - target := r.URL.Query().Get("target") - if target == "" { - target = "host" - } - - cmd := buildJournalCmd(target, "--field=_SYSTEMD_UNIT --no-pager 2>/dev/null | sort -u | head -300") - out, _ := m.pool.RunCommand(sshHost, user, pass, cmd) - - units := []string{} - for _, line := range strings.Split(out, "\n") { - line = strings.TrimSpace(line) - if line != "" { - units = append(units, line) - } - } - api.JSONResponse(w, http.StatusOK, units) -} - -// wsUpgrader est l'upgrader WebSocket pour le module logs. -var wsUpgrader = gorillaws.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, -} - -// StreamLogs ouvre un WebSocket et stream journalctl en temps réel via SSH. -// GET /ws/logs?target=host|lxc:ID&unit=sshd.service&lines=100 -func (m *LogsModule) StreamLogs(w http.ResponseWriter, r *http.Request) { - sshHost, user, pass, err := m.sshCreds() - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - - conn, err := wsUpgrader.Upgrade(w, r, nil) - if err != nil { - return - } - defer conn.Close() - - target := r.URL.Query().Get("target") - if target == "" { - target = "host" - } - - unit := r.URL.Query().Get("unit") - linesStr := r.URL.Query().Get("lines") - lines := 100 - if n, err2 := strconv.Atoi(linesStr); err2 == nil && n > 0 && n <= 2000 { - lines = n - } - - // Construction des arguments journalctl - args := fmt.Sprintf("-f --no-pager -n %d", lines) - if safe := sanitizeUnit(unit); safe != "" { - args += " -u " + safe - } - cmd := buildJournalCmd(target, args) - - // Connexion SSH directe (hors pool — session longue durée avec journalctl -f) - sshConfig := &gossh.ClientConfig{ - User: user, - Auth: []gossh.AuthMethod{gossh.Password(pass)}, - Timeout: 15 * time.Second, - HostKeyCallback: gossh.InsecureIgnoreHostKey(), - } - client, err := gossh.Dial("tcp", sshHost, sshConfig) - if err != nil { - conn.WriteMessage(gorillaws.TextMessage, []byte("Erreur SSH : "+err.Error())) - return - } - defer client.Close() - - session, err := client.NewSession() - if err != nil { - conn.WriteMessage(gorillaws.TextMessage, []byte("Erreur session SSH : "+err.Error())) - return - } - defer session.Close() - - stdout, err := session.StdoutPipe() - if err != nil { - return - } - if err := session.Start(cmd); err != nil { - conn.WriteMessage(gorillaws.TextMessage, []byte("Erreur commande : "+err.Error())) - return - } - - // Goroutine : SSH stdout → WebSocket - done := make(chan struct{}) - go func() { - defer close(done) - buf := make([]byte, 4096) - for { - n, err := stdout.Read(buf) - if n > 0 { - conn.WriteMessage(gorillaws.TextMessage, buf[:n]) - } - if err != nil { - break - } - } - }() - - // Boucle principale : attendre fermeture WebSocket (ou message de contrôle) - for { - _, _, err := conn.ReadMessage() - if err != nil { - break - } - } - - // Fermer la session SSH → interrompt journalctl -f - session.Close() - client.Close() - <-done -} diff --git a/backend/modules/module.go b/backend/modules/module.go index a624774..64e6e76 100644 --- a/backend/modules/module.go +++ b/backend/modules/module.go @@ -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,49 @@ 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 } +// 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"` +} + // 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 { - // RegisterRoute enregistre une route HTTP dans le router principal. + // Enregistrement de routes HTTP RegisterRoute(method, path string, handler http.HandlerFunc, requireAdmin bool) - // RegisterWSChannel enregistre un handler WebSocket pour un channel nommé. + // 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é. @@ -55,12 +67,11 @@ type WidgetDef struct { // SettingsTabDef décrit un onglet de paramètres fourni par un module. 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). + ID string `json:"id"` + Label string `json:"label"` + Icon string `json:"icon"` 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 diff --git a/backend/modules/services/README.md b/backend/modules/services/README.md deleted file mode 100644 index cc5d4a4..0000000 --- a/backend/modules/services/README.md +++ /dev/null @@ -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 `) -- 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=` 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 -- 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) diff --git a/backend/modules/services/services.go b/backend/modules/services/services.go deleted file mode 100644 index 0269926..0000000 --- a/backend/modules/services/services.go +++ /dev/null @@ -1,198 +0,0 @@ -// Module Services — gestion des services systemd via SSH. -// Expose : liste des services, statut détaillé, start/stop/restart (admin). -package services - -import ( - "encoding/json" - "fmt" - "net/http" - "strconv" - "strings" - - "git.geronzi.fr/proxmoxPanel/core/backend/internal/api" - "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" - "git.geronzi.fr/proxmoxPanel/core/backend/modules" - "github.com/go-chi/chi/v5" -) - -// ServicesModule gère les opérations systemctl sur le host et les LXC. -type ServicesModule struct { - db *db.DB - pool *sshpool.Pool - enc *crypto.Encryptor -} - -// New crée un ServicesModule avec les dépendances nécessaires. -func New(database *db.DB, pool *sshpool.Pool, enc *crypto.Encryptor) *ServicesModule { - return &ServicesModule{db: database, pool: pool, enc: enc} -} - -func (m *ServicesModule) ID() string { return "services" } - -// Register enregistre les routes du module dans le registry CORE. -func (m *ServicesModule) Register(r modules.Registry) error { - r.RegisterRoute("GET", "/api/services", m.ListServices, false) - r.RegisterRoute("GET", "/api/services/{name}/status", m.ServiceStatus, false) - r.RegisterRoute("POST", "/api/services/{name}/{action}", m.ServiceAction, true) - return nil -} - -// sshCreds récupère et déchiffre les credentials SSH depuis la configuration. -func (m *ServicesModule) sshCreds() (host, user, pass string, err error) { - host, _, _ = m.db.GetSetting("ssh_host") - user, _, _ = m.db.GetSetting("ssh_username") - encPass, _, _ := m.db.GetSetting("ssh_password") - if encPass != "" { - pass, err = m.enc.Decrypt(encPass) - if err != nil { - return "", "", "", fmt.Errorf("impossible de déchiffrer le mot de passe SSH") - } - } - if host == "" || user == "" || pass == "" { - return "", "", "", fmt.Errorf("SSH non configuré") - } - return host, user, pass, nil -} - -// buildCmd construit la commande systemctl, en la wrappant via pct exec pour les LXC. -func buildCmd(target, systemctlArgs 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 -- systemctl %s", vmid, systemctlArgs) - } - } - return "systemctl " + systemctlArgs -} - -// ServiceEntry représente un service systemd dans la liste. -type ServiceEntry struct { - Name string `json:"name"` - LoadState string `json:"load_state"` - ActiveState string `json:"active_state"` - SubState string `json:"sub_state"` - Description string `json:"description"` -} - -// ListServices retourne la liste des services systemd d'une cible. -// GET /api/services?target=host|lxc:ID -func (m *ServicesModule) ListServices(w http.ResponseWriter, r *http.Request) { - host, user, pass, err := m.sshCreds() - if err != nil { - api.JSONError(w, err.Error(), http.StatusServiceUnavailable) - return - } - - target := r.URL.Query().Get("target") - if target == "" { - target = "host" - } - - cmd := buildCmd(target, "list-units --type=service --all --no-legend --plain --no-pager 2>/dev/null") - out, err := m.pool.RunCommand(host, user, pass, cmd) - if err != nil { - api.JSONError(w, "Erreur commande SSH : "+err.Error(), http.StatusInternalServerError) - return - } - - services := parseServiceList(out) - api.JSONResponse(w, http.StatusOK, services) -} - -// parseServiceList parse la sortie de systemctl list-units. -// Format : "nom.service load active running Description..." -func parseServiceList(output string) []ServiceEntry { - var services []ServiceEntry - for _, line := range strings.Split(output, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "UNIT") || strings.HasPrefix(line, "Legend") || - strings.HasPrefix(line, "To show") || strings.HasPrefix(line, "Pass") { - continue - } - // Certaines lignes commencent par "●" (service failed) — on supprime ce caractère - line = strings.TrimPrefix(line, "● ") - line = strings.TrimPrefix(line, " ") - - fields := strings.Fields(line) - if len(fields) < 4 { - continue - } - name := strings.TrimSuffix(fields[0], ".service") - desc := "" - if len(fields) >= 5 { - desc = strings.Join(fields[4:], " ") - } - services = append(services, ServiceEntry{ - Name: name, - LoadState: fields[1], - ActiveState: fields[2], - SubState: fields[3], - Description: desc, - }) - } - return services -} - -// ServiceStatus retourne le statut détaillé d'un service. -// GET /api/services/{name}/status?target=host|lxc:ID -func (m *ServicesModule) ServiceStatus(w http.ResponseWriter, r *http.Request) { - host, user, pass, err := m.sshCreds() - if err != nil { - api.JSONError(w, err.Error(), http.StatusServiceUnavailable) - return - } - - name := chi.URLParam(r, "name") - target := r.URL.Query().Get("target") - if target == "" { - target = "host" - } - - cmd := buildCmd(target, fmt.Sprintf("status %s --no-pager 2>&1", name)) - out, _ := m.pool.RunCommand(host, user, pass, cmd) - - api.JSONResponse(w, http.StatusOK, map[string]string{"output": out}) -} - -// ServiceAction exécute une action (start/stop/restart/reload) sur un service. -// POST /api/services/{name}/{action} -// Body: { "target": "host" | "lxc:ID" } -func (m *ServicesModule) ServiceAction(w http.ResponseWriter, r *http.Request) { - host, user, pass, err := m.sshCreds() - if err != nil { - api.JSONError(w, err.Error(), http.StatusServiceUnavailable) - return - } - - name := chi.URLParam(r, "name") - action := chi.URLParam(r, "action") - - // Valider l'action pour éviter l'injection de commandes - allowed := map[string]bool{"start": true, "stop": true, "restart": true, "reload": true, "enable": true, "disable": true} - if !allowed[action] { - api.JSONError(w, "Action invalide", http.StatusBadRequest) - return - } - - var body struct { - Target string `json:"target"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Target == "" { - body.Target = "host" - } - - cmd := buildCmd(body.Target, fmt.Sprintf("%s %s 2>&1", action, name)) - out, err := m.pool.RunCommand(host, user, pass, cmd) - - if err != nil { - api.JSONError(w, fmt.Sprintf("Erreur action %s sur %s : %v\n%s", action, name, err, out), http.StatusInternalServerError) - return - } - - api.JSONResponse(w, http.StatusOK, map[string]string{ - "message": fmt.Sprintf("Action « %s » exécutée sur %s", action, name), - "output": out, - }) -} diff --git a/frontend/css/pages.css b/frontend/css/pages.css index 5083b79..25a7354 100644 --- a/frontend/css/pages.css +++ b/frontend/css/pages.css @@ -961,3 +961,26 @@ } .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; +} diff --git a/frontend/js/app.js b/frontend/js/app.js index dc6be99..9ae35c1 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -319,8 +319,7 @@ document.addEventListener('alpine:init', () => { }) // ── Composant: sidebar ────────────────────────────────────────────────── - // Items CORE : toujours visibles. - // Items modules : visibles seulement si le module est activé en DB. + // Items CORE toujours visibles (sidebar hardcodée pour le CORE) const _coreNavItems = [ { id: 'dashboard', iconClass: 'lnid-dashboard-square-1', iconColor: '#6c8ef4', labelKey: 'nav.dashboard', href: '/dashboard.html' }, { id: 'proxmox', iconClass: 'lnid-server-1', iconColor: '#22c55e', labelKey: 'nav.proxmox', href: '/proxmox.html' }, @@ -328,36 +327,38 @@ document.addEventListener('alpine:init', () => { { id: 'settings', iconClass: 'lnid-gear-1', iconColor: '#94a3b8', labelKey: 'nav.settings', href: '/settings.html' }, { id: 'modules', iconClass: 'lnid-puzzle', iconColor: '#f472b6', labelKey: 'nav.modules', href: '/modules.html' }, ] - // Définition des items de navigation pour les modules optionnels. - // Un module dont l'id n'est pas ici n'aura pas d'entrée dans la sidebar. - const _moduleNavDef = { - terminal: { iconClass: 'lnid-terminal', iconColor: '#a78bfa', labelKey: 'nav.terminal', href: '/terminal.html' }, - files: { iconClass: 'lnid-folder-1', iconColor: '#84cc16', labelKey: 'nav.files', href: '/files.html' }, - services: { iconClass: 'lnid-gear-2', iconColor: '#fb923c', labelKey: 'nav.services', href: '/services.html' }, - logs: { iconClass: 'lnid-scroll-angular-1', iconColor: '#38bdf8', labelKey: 'nav.logs', href: '/logs.html' }, - } Alpine.data('sidebar', () => ({ get collapsed() { return Alpine.store('ui').sidebarCollapsed }, get currentPage() { return Alpine.store('ui').currentPage }, - // Commence avec les items CORE ; init() ajoute les modules activés navItems: [..._coreNavItems], async init() { + await this.refreshNav() + }, + + async refreshNav() { try { const res = await apiFetch('/api/modules') if (!res.ok) return - const modules = await res.json() || [] - const moduleItems = modules - .filter(m => m.is_enabled && !m.is_core && _moduleNavDef[m.id]) - .map(m => ({ id: m.id, ..._moduleNavDef[m.id] })) - // Insérer les modules entre Updates et Settings - const insertAt = this.navItems.findIndex(i => i.id === 'settings') + const allModules = await res.json() || [] + // Modules optionnels activés avec nav_href défini + const moduleItems = allModules + .filter(m => !m.is_core && m.is_enabled && m.nav_href) + .map(m => ({ + id: m.id, + iconClass: m.nav_icon || 'lnid-puzzle', + iconColor: m.nav_color || '#94a3b8', + labelKey: m.nav_label_key || `nav.${m.id}`, + href: m.nav_href, + })) + // Insérer entre Updates et Settings + const insertAt = _coreNavItems.findIndex(i => i.id === 'settings') this.navItems = [ - ...this.navItems.slice(0, insertAt), + ..._coreNavItems.slice(0, insertAt), ...moduleItems, - ...this.navItems.slice(insertAt), + ..._coreNavItems.slice(insertAt), ] } catch(e) {} }, @@ -1077,31 +1078,50 @@ document.addEventListener('alpine:init', () => { modules: [], loading: true, toggling: {}, + storeModules: [], + storeLoading: true, + installing: {}, + rebuildRequired: false, async init() { - await this.load() + await Promise.all([this.load(), this.loadStore()]) }, async load() { this.loading = true try { const res = await apiFetch('/api/modules') - if (res.ok) { - this.modules = await res.json() || [] - } + if (res.ok) this.modules = await res.json() || [] } finally { this.loading = false } }, + async loadStore() { + this.storeLoading = true + try { + const res = await apiFetch('/api/registry/modules') + if (res.ok) this.storeModules = await res.json() || [] + } catch(e) { + this.storeModules = [] + } finally { + this.storeLoading = false + } + }, + async toggle(mod) { this.toggling[mod.id] = true try { - // Backend: is_enabled (pas enabled) const action = mod.is_enabled ? 'disable' : 'enable' const res = await apiFetch(`/api/modules/${mod.id}/${action}`, { method: 'POST' }) if (res.ok) { mod.is_enabled = !mod.is_enabled + // Rafraîchir la sidebar + const sb = document.querySelector('[x-data="sidebar()"]') + if (sb && sb._x_dataStack) { + const sidebarData = Alpine.$data(sb) + if (sidebarData && sidebarData.refreshNav) await sidebarData.refreshNav() + } } } catch(e) { console.error(e) @@ -1110,171 +1130,31 @@ document.addEventListener('alpine:init', () => { } }, - t(key) { return Alpine.store('i18n').t(key) }, - })) - - // ── Composant: servicePage ────────────────────────────────────────────── - Alpine.data('servicePage', () => ({ - services: [], - loading: false, - target: 'host', - targets: [{ value: 'host', label: 'Host Proxmox' }], - filter: '', - actioning: {}, - - get filtered() { - const q = this.filter.toLowerCase() - if (!q) return this.services - return this.services.filter(s => - s.name.toLowerCase().includes(q) || (s.description || '').toLowerCase().includes(q) - ) - }, - - async init() { - await this.loadTargets() - await this.load() - }, - - async loadTargets() { + async install(mod) { + this.installing[mod.id] = true try { - const res = await apiFetch('/api/proxmox/lxc') + const res = await apiFetch(`/api/registry/modules/${mod.id}/install`, { method: 'POST' }) if (res.ok) { - const lxc = await res.json() || [] - this.targets = [ - { value: 'host', label: 'Host Proxmox' }, - ...lxc.map(c => ({ value: `lxc:${c.vmid}`, label: `LXC ${c.vmid} — ${c.name || 'CT'+c.vmid}` })) - ] - } - } catch(e) {} - }, - - async load() { - this.loading = true - try { - const res = await apiFetch(`/api/services?target=${this.target}`) - if (res.ok) this.services = await res.json() || [] - } finally { - this.loading = false - } - }, - - async action(name, act) { - this.actioning[name] = act - try { - const res = await apiFetch(`/api/services/${name}/${act}`, { - method: 'POST', - body: JSON.stringify({ target: this.target }) - }) - if (res.ok) { - Alpine.store('toasts').success(`${act} ${name}`) + mod.installed = true + this.rebuildRequired = true + Alpine.store('toasts').success(`Module ${mod.id} installé — rebuild requis`) await this.load() } else { const b = await res.json().catch(() => ({})) - Alpine.store('toasts').error(b.error || `Erreur ${act}`) + Alpine.store('toasts').error(b.error || 'Erreur installation') } } catch(e) { Alpine.store('toasts').error(e.message) } finally { - this.actioning[name] = false + this.installing[mod.id] = false } }, - stateClass(svc) { - if (svc.active_state === 'active') return 'state-active' - if (svc.active_state === 'failed') return 'state-failed' - if (svc.active_state === 'inactive') return 'state-inactive' - return 'state-other' - }, - t(key) { return Alpine.store('i18n').t(key) }, })) - // ── Composant: logsPage ───────────────────────────────────────────────── - Alpine.data('logsPage', () => ({ - lines: [], - target: 'host', - targets: [{ value: 'host', label: 'Host Proxmox' }], - unit: '', - units: [], - linesCount: '100', - following: false, - ws: null, - - async init() { - await this.loadTargets() - await this.loadUnits() - }, - - async loadTargets() { - try { - const res = await apiFetch('/api/proxmox/lxc') - if (res.ok) { - const lxc = await res.json() || [] - this.targets = [ - { value: 'host', label: 'Host Proxmox' }, - ...lxc.map(c => ({ value: `lxc:${c.vmid}`, label: `LXC ${c.vmid} — ${c.name || 'CT'+c.vmid}` })) - ] - } - } catch(e) {} - }, - - async loadUnits() { - try { - const res = await apiFetch(`/api/logs/units?target=${this.target}`) - if (res.ok) this.units = await res.json() || [] - } catch(e) {} - }, - - async onTargetChange() { - this.stopFollow() - this.unit = '' - await this.loadUnits() - }, - - toggleFollow() { - if (this.following) { - this.stopFollow() - } else { - this.startFollow() - } - }, - - startFollow() { - this.lines = [] - const token = encodeURIComponent(localStorage.getItem('pxp_token') || '') - const proto = location.protocol === 'https:' ? 'wss' : 'ws' - const unit = this.unit ? `&unit=${encodeURIComponent(this.unit)}` : '' - const url = `${proto}://${location.host}/ws/logs?token=${token}&target=${this.target}&lines=${this.linesCount}${unit}` - this.ws = new WebSocket(url) - this.following = true - - this.ws.onmessage = (e) => { - const incoming = e.data.split('\n').filter(l => l !== '') - this.lines.push(...incoming) - if (this.lines.length > 3000) this.lines = this.lines.slice(-3000) - this.$nextTick(() => { - const el = this.$refs.logOutput - if (el) el.scrollTop = el.scrollHeight - }) - } - this.ws.onclose = () => { this.following = false; this.ws = null } - this.ws.onerror = () => { this.following = false; this.ws = null } - }, - - stopFollow() { - if (this.ws) { - this.ws.close() - this.ws = null - } - this.following = false - }, - - clearLog() { - this.lines = [] - }, - - t(key) { return Alpine.store('i18n').t(key) }, - })) + // Note: servicePage et logsPage ont été déplacés dans les modules + // indépendants viewServices et viewLogs. }) // end alpine:init diff --git a/frontend/logs.html b/frontend/logs.html deleted file mode 100644 index abbb63b..0000000 --- a/frontend/logs.html +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - ProxmoxPanel — Journaux - - - - - - - - - - - - - - - -
- - -
-
- - -
- - - - - - - - - -
- - -
- - -
- - -
-
-

-
- -
-
-
- - - diff --git a/frontend/modules.html b/frontend/modules.html index bb36a81..12696bf 100644 --- a/frontend/modules.html +++ b/frontend/modules.html @@ -55,9 +55,11 @@
+ +

Modules installés

+
-
- Chargement… +
Chargement…
@@ -65,29 +67,71 @@
- +
- + +
CORE
- -

Aucun module trouvé

+

Aucun module installé

+ + +

Store

+

Modules disponibles depuis git.geronzi.fr/proxmoxPanel

+ +
+
Chargement du store… +
+ +
+ +

+ Aucun module disponible dans le store +

+
+ + +
+ + Un ou plusieurs modules ont été installés. Un rebuild du container est nécessaire pour les activer. +
+
diff --git a/frontend/services.html b/frontend/services.html deleted file mode 100644 index 79dacc0..0000000 --- a/frontend/services.html +++ /dev/null @@ -1,144 +0,0 @@ - - - - - - - ProxmoxPanel — Services - - - - - - - - - - - - - - - -
- - -
-
- - -
- - - - - -
- - -
-
- Chargement… -
- - -
- - - - - - - - - - - - - - - - -
Actions
-
- -
-
-
- - -