webui: new page, services table

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-14 22:07:34 +02:00
parent e36aac736e
commit 7ac15796e3
6 changed files with 264 additions and 0 deletions
+170
View File
@@ -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)
}
}
+6
View File
@@ -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)
+25
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="4" rx="1"/>
<rect x="2" y="10" width="20" height="4" rx="1"/>
<rect x="2" y="17" width="20" height="4" rx="1"/>
</svg>

After

Width:  |  Height:  |  Size: 344 B

+10
View File
@@ -122,6 +122,16 @@
<div class="nav-section-label">Services</div>
<ul class="nav-group-items">
<li>
<a href="/services"
hx-get="/services"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "services"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/services.svg')"></span>
Services
</a>
</li>
<li>
<a href="/dhcp"
hx-get="/dhcp"
+48
View File
@@ -0,0 +1,48 @@
{{define "services.html"}}
{{template "base" .}}
{{end}}
{{define "content"}}
{{if .Error}}
<div class="alert alert-error">{{.Error}}</div>
{{end}}
{{if .Services}}
<section class="info-card">
<div class="card-header">Services</div>
<div class="data-table-wrap">
<table class="data-table svc-table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th class="num">PID</th>
<th class="num">Mem</th>
<th class="num">Up</th>
<th class="num">Rst</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{{range .Services}}
<tr>
<td class="svc-name">{{.Name}}</td>
<td><span class="svc-status {{.StatusClass}}">{{.Status}}</span></td>
<td class="num svc-num">{{.PID}}</td>
<td class="num svc-num">{{.Memory}}</td>
<td class="num svc-num">{{.Uptime}}</td>
<td class="num svc-num">{{if .RestartCount}}{{.RestartCount}}{{end}}</td>
<td class="svc-desc">{{.Description}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</section>
{{else if not .Error}}
<section class="info-card">
<div class="card-header">Services</div>
<p class="empty-message">No services found.</p>
</section>
{{end}}
{{end}}