diff --git a/src/webui/internal/handlers/services.go b/src/webui/internal/handlers/services.go new file mode 100644 index 00000000..bdeb398e --- /dev/null +++ b/src/webui/internal/handlers/services.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "sort" + "strconv" + + "github.com/kernelkit/webui/internal/restconf" +) + +// ─── RESTCONF JSON types ────────────────────────────────────────────────────── + +type servicesWrapper struct { + SystemState struct { + Services struct { + Service []serviceJSON `json:"service"` + } `json:"infix-system:services"` + } `json:"ietf-system:system-state"` +} + +type serviceJSON struct { + Name string `json:"name"` + PID uint32 `json:"pid"` + Description string `json:"description"` + Status string `json:"status"` + Statistics serviceStatsJSON `json:"statistics"` +} + +// memory-usage and uptime are marshalled as strings by the statd Python layer. +type serviceStatsJSON struct { + MemoryUsage json.Number `json:"memory-usage"` + Uptime json.Number `json:"uptime"` + RestartCount uint32 `json:"restart-count"` +} + +func (s serviceStatsJSON) memoryBytes() uint64 { + v, _ := strconv.ParseUint(s.MemoryUsage.String(), 10, 64) + return v +} + +func (s serviceStatsJSON) uptimeSeconds() uint64 { + v, _ := strconv.ParseUint(s.Uptime.String(), 10, 64) + return v +} + +// ─── Template data ──────────────────────────────────────────────────────────── + +type servicesPageData struct { + PageData + Services []serviceEntry + Error string +} + +type serviceEntry struct { + Name string + Status string + StatusClass string // "svc-running", "svc-stopped", "svc-error", "svc-done" + PID string + Memory string + Uptime string + RestartCount uint32 + Description string +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// ServicesHandler serves the system services page. +type ServicesHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the services page (GET /services). +func (h *ServicesHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := servicesPageData{ + PageData: newPageData(r, "services", "Services"), + } + + var raw servicesWrapper + if err := h.RC.Get(r.Context(), "/data/ietf-system:system-state/infix-system:services", &raw); err != nil { + log.Printf("restconf services: %v", err) + data.Error = "Could not fetch services data" + } else { + for _, svc := range raw.SystemState.Services.Service { + data.Services = append(data.Services, buildServiceEntry(svc)) + } + sort.Slice(data.Services, func(i, j int) bool { + return data.Services[i].Name < data.Services[j].Name + }) + } + + tmplName := "services.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +func buildServiceEntry(svc serviceJSON) serviceEntry { + pid := "" + if svc.PID > 0 { + pid = fmt.Sprintf("%d", svc.PID) + } + + return serviceEntry{ + Name: svc.Name, + Status: svc.Status, + StatusClass: serviceStatusClass(svc.Status), + PID: pid, + Memory: formatServiceMemory(svc.Statistics.memoryBytes()), + Uptime: formatServiceUptime(svc.Statistics.uptimeSeconds()), + RestartCount: svc.Statistics.RestartCount, + Description: svc.Description, + } +} + +func serviceStatusClass(status string) string { + switch status { + case "running", "active", "done": + return "svc-running" // green + case "crashed", "failed", "halted", "missing", "dead", "conflict": + return "svc-error" // red + default: + // stopped, paused, and anything else → yellow + return "svc-stopped" + } +} + +// formatServiceMemory mirrors the CLI's format_memory_bytes. +func formatServiceMemory(b uint64) string { + switch { + case b == 0: + return "" + case b < 1024: + return fmt.Sprintf("%dB", b) + case b < 1024*1024: + return fmt.Sprintf("%dK", b/1024) + case b < 1024*1024*1024: + return fmt.Sprintf("%.1fM", float64(b)/float64(1024*1024)) + default: + return fmt.Sprintf("%.1fG", float64(b)/float64(1024*1024*1024)) + } +} + +// formatServiceUptime mirrors the CLI's format_uptime_seconds. +func formatServiceUptime(s uint64) string { + switch { + case s == 0: + return "" + case s < 60: + return fmt.Sprintf("%ds", s) + case s < 3600: + return fmt.Sprintf("%dm", s/60) + case s < 86400: + return fmt.Sprintf("%dh", s/3600) + default: + return fmt.Sprintf("%dd", s/86400) + } +} diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 613cccce..987919c5 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -81,6 +81,10 @@ func New( if err != nil { return nil, err } + servicesTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/services.html") + if err != nil { + return nil, err + } containersTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/containers.html") if err != nil { return nil, err @@ -126,6 +130,7 @@ func New( ntp := &handlers.NTPHandler{Template: ntpTmpl, RC: rc} lldp := &handlers.LLDPHandler{Template: lldpTmpl, RC: rc} mdns := &handlers.MDNSHandler{Template: mdnsTmpl, RC: rc} + services := &handlers.ServicesHandler{Template: servicesTmpl, RC: rc} containers := &handlers.ContainersHandler{Template: containersTmpl, RC: rc} mux := http.NewServeMux() @@ -159,6 +164,7 @@ func New( mux.HandleFunc("GET /ntp", ntp.Overview) mux.HandleFunc("GET /lldp", lldp.Overview) mux.HandleFunc("GET /mdns", mdns.Overview) + mux.HandleFunc("GET /services", services.Overview) mux.HandleFunc("GET /containers", containers.Overview) handler := authMiddleware(store, mux) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index d0ccc698..ffb756c3 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -1136,6 +1136,31 @@ details[open].nav-group-top > summary.nav-group-summary-top::after { .mdns-lastseen { white-space: nowrap; } +/* Services table */ +.svc-name { font-family: var(--font-mono); font-size: 0.85rem; white-space: nowrap; } +.svc-num { white-space: nowrap; color: var(--fg-muted); font-size: 0.85rem; } +.svc-desc { color: var(--fg-muted); font-size: 0.875rem; } +.svc-status { + font-size: 0.75rem; + font-weight: 600; + text-transform: lowercase; + white-space: nowrap; +} +.svc-running { color: #16a34a; } +.svc-stopped { color: #ca8a04; } +.svc-error { color: #dc2626; } +@media (prefers-color-scheme: dark) { + .svc-running { color: #86efac; } + .svc-stopped { color: #fde047; } + .svc-error { color: #fca5a5; } +} +.dark .svc-running { color: #86efac; } +.dark .svc-stopped { color: #fde047; } +.dark .svc-error { color: #fca5a5; } +.light .svc-running { color: #16a34a; } +.light .svc-stopped { color: #ca8a04; } +.light .svc-error { color: #dc2626; } + /* Sub-heading within a card body (e.g. "Active Leases" in DHCP) */ .section-subtitle { font-size: 0.75rem; diff --git a/src/webui/static/img/icons/services.svg b/src/webui/static/img/icons/services.svg new file mode 100644 index 00000000..1385c730 --- /dev/null +++ b/src/webui/static/img/icons/services.svg @@ -0,0 +1,5 @@ + diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html index efbe51ed..534b61b5 100644 --- a/src/webui/templates/layouts/sidebar.html +++ b/src/webui/templates/layouts/sidebar.html @@ -122,6 +122,16 @@