viewServices/backend/services.go
enzo f1d475c7e5 feat: module viewServices — gestion services systemd via systemctl
- module.json : métadonnées du module (nav_href, nav_icon, nav_color, nav_label_key)
- backend/go.mod : dépendance sur core/backend via replace directive
- backend/services.go : implémente modules.Module, routes /api/services et /api/services/{name}/*
  - Utilise r.RunOnTarget du Registry (pas d'accès internal)
  - Liste services, statut détaillé, actions start/stop/restart/reload/enable/disable
  - Enregistrement du nav item via r.RegisterNavItem
- frontend/services.html : page de gestion des services systemd
  - Composant servicePage Alpine.js inline (autonome, indépendant de core app.js)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 04:01:59 +01:00

163 lines
5.1 KiB
Go

// Module viewServices — gestion des services systemd via systemctl.
// Dépôt indépendant : https://git.geronzi.fr/proxmoxPanel/viewServices
package viewservices
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"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{}
// New crée un ServicesModule.
func New() *ServicesModule { return &ServicesModule{} }
func (m *ServicesModule) ID() string { return "viewServices" }
// Register enregistre les routes du module dans le CORE.
func (m *ServicesModule) Register(r modules.Registry) error {
r.RegisterNavItem(modules.NavItemDef{
ID: "viewServices",
Href: "/viewServices/services.html",
Icon: "lnid-gear-2",
Color: "#fb923c",
LabelKey: "nav.services",
})
r.RegisterRoute("GET", "/api/services", m.listServices(r), false)
r.RegisterRoute("GET", "/api/services/{name}/status", m.serviceStatus(r), false)
r.RegisterRoute("POST", "/api/services/{name}/{action}", m.serviceAction(r), true)
return nil
}
// 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(r modules.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
target := req.URL.Query().Get("target")
if target == "" {
target = "host"
}
out, err := r.RunOnTarget(target, "systemctl list-units --type=service --all --no-legend --plain --no-pager 2>/dev/null")
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "Erreur commande SSH : " + err.Error()})
return
}
services := parseServiceList(out)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(services)
}
}
// parseServiceList parse la sortie de systemctl list-units.
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
}
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(r modules.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
name := chi.URLParam(req, "name")
target := req.URL.Query().Get("target")
if target == "" {
target = "host"
}
out, _ := r.RunOnTarget(target, fmt.Sprintf("systemctl status %s --no-pager 2>&1", name))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(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(r modules.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
name := chi.URLParam(req, "name")
action := chi.URLParam(req, "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] {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Action invalide"})
return
}
var body struct {
Target string `json:"target"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.Target == "" {
body.Target = "host"
}
out, err := r.RunOnTarget(body.Target, fmt.Sprintf("systemctl %s %s 2>&1", action, name))
w.Header().Set("Content-Type", "application/json")
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": fmt.Sprintf("Erreur action %s sur %s : %v", action, name, err),
"output": out,
})
return
}
json.NewEncoder(w).Encode(map[string]string{
"message": fmt.Sprintf("Action « %s » exécutée sur %s", action, name),
"output": out,
})
}
}