+ +
diff --git a/backend/internal/db/migrations/003_modules_extra.sql b/backend/internal/db/migrations/003_modules_extra.sql new file mode 100644 index 0000000..4c0397d --- /dev/null +++ b/backend/internal/db/migrations/003_modules_extra.sql @@ -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); diff --git a/backend/main.go b/backend/main.go index e29892d..a104893 100644 --- a/backend/main.go +++ b/backend/main.go @@ -17,6 +17,8 @@ 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" @@ -68,8 +70,8 @@ func main() { // ── 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.RegisterModule(services.New(database, sshPool, encryptor)) + loader.RegisterModule(logs.New(database, sshPool, encryptor)) if err := loader.LoadActive(); err != nil { log.Fatalf("Erreur chargement modules : %v", err) } diff --git a/backend/modules/logs/logs.go b/backend/modules/logs/logs.go new file mode 100644 index 0000000..a8431e3 --- /dev/null +++ b/backend/modules/logs/logs.go @@ -0,0 +1,206 @@ +// 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/services/services.go b/backend/modules/services/services.go new file mode 100644 index 0000000..0269926 --- /dev/null +++ b/backend/modules/services/services.go @@ -0,0 +1,198 @@ +// 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 f1aae25..5083b79 100644 --- a/frontend/css/pages.css +++ b/frontend/css/pages.css @@ -826,6 +826,131 @@ .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 { diff --git a/frontend/js/app.js b/frontend/js/app.js index 86e7df1..57792e5 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -328,6 +328,8 @@ document.addEventListener('alpine:init', () => { { id: 'proxmox', iconClass: 'lnid-server-1', iconColor: '#22c55e', labelKey: 'nav.proxmox', href: '/proxmox.html' }, { id: 'updates', iconClass: 'lnid-arrow-upward', iconColor: '#f59e0b', labelKey: 'nav.updates', href: '/updates.html' }, { id: 'terminal', iconClass: 'lnid-terminal', iconColor: '#a78bfa', labelKey: 'nav.terminal', href: '/terminal.html' }, + { id: 'services', iconClass: 'lnid-gear-loading', iconColor: '#fb923c', labelKey: 'nav.services', href: '/services.html' }, + { id: 'logs', iconClass: 'lnid-scroll-document-1', iconColor: '#38bdf8', labelKey: 'nav.logs', href: '/logs.html' }, { 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' }, ], @@ -1083,6 +1085,169 @@ 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() { + 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 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}`) + await this.load() + } else { + const b = await res.json().catch(() => ({})) + Alpine.store('toasts').error(b.error || `Erreur ${act}`) + } + } catch(e) { + Alpine.store('toasts').error(e.message) + } finally { + this.actioning[name] = 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) }, + })) + }) // end alpine:init // ── DOMContentLoaded : init stores + Swup ───────────────────────────────── diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 7f15a7f..4ef7bee 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -115,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", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index af76ff5..5bf2b82 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -115,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", diff --git a/frontend/logs.html b/frontend/logs.html new file mode 100644 index 0000000..abbb63b --- /dev/null +++ b/frontend/logs.html @@ -0,0 +1,110 @@ + + +
+ + + ++ +
| + | + | + | + | Actions | +
|---|---|---|---|---|
| + + + | ++ | + | + | + + + + | +
| + | ||||