webui: add Status > Hardware page

Mirrors the CLI's "show hardware" command — a single page that summarises
everything ietf-hardware exposes about the device:

  - Board (chassis info: model, manufacturer, serial, base MAC)
  - USB Ports (state + description)
  - WiFi Radios (bands, standards, max-AP)
  - GPS Receivers
  - Sensors (grouped by parent component, all status values)
  - Other Components (fallback so the page is exhaustive)

This is Phase 1 of webui-roadmap-v2. Phase 2 will trim the dashboard's
Sensors card to a few key vitals with an "All sensors →" link here,
fixing the existing height-clipping regression in the process.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-15 19:45:17 +02:00
parent bfb5b14ea0
commit 2c80c65962
9 changed files with 1193 additions and 4 deletions
@@ -0,0 +1,451 @@
// SPDX-License-Identifier: MIT
package handlers
import (
"context"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"github.com/kernelkit/webui/internal/restconf"
"github.com/kernelkit/webui/internal/schema"
)
// Configure > Hardware: curated management of components in ietf-hardware /
// infix-hardware that have configurable leaves — USB ports (lock/unlock),
// WiFi radios (country code, channel, band) and GPS receivers (presence).
//
// The page mirrors the Configure > Interfaces model: the main table lists
// only components present in running/candidate config; an "Add hardware"
// picker offers detected-but-unconfigured components from operational.
// Status > Hardware shows the full detected inventory (sensors, chassis,
// VPD, …) — this page stays focused on what the user can configure.
const hwCandPath = candidatePath + "/ietf-hardware:hardware"
// hwCompCfgRow is one configured component in the main table. Per-class
// fields are populated only for the matching Class. IsUSB/IsWiFi/IsGPS
// spare the template from dispatching on stringly-typed Class slugs.
type hwCompCfgRow struct {
Name string
Class string
ClassDisplay string
Description string
IsUSB bool
IsWiFi bool
IsGPS bool
// USB-specific.
Unlocked bool // admin-state == "unlocked"
// WiFi-specific.
CountryCode string
Channel string
Band string
// Schema descriptions carried per-row so the fold-out forms are
// self-contained — Go templates can't pass extra arguments through
// {{template}}.
CountryOptions []schema.IdentityOption
BandOptions []schema.IdentityOption
DescDescription string
DescAdminState string
DescCountry string
DescBand string
DescChannel string
}
// hwAvailable is a detected-but-unconfigured component shown in the Add
// picker. Class drives which class-specific Add fields the inline form
// renders.
type hwAvailable struct {
Name string
Class string
ClassDisplay string
}
type cfgHardwarePageData struct {
PageData
Loading bool
Configured []hwCompCfgRow
AvailableUSB []hwAvailable
AvailableWiFi []hwAvailable
AvailableGPS []hwAvailable
CountryOptions []schema.IdentityOption
BandOptions []schema.IdentityOption
Desc map[string]string
Error string
}
// HasAvailable reports whether any detected component can be added. Used
// by the template to hide the "+ Add hardware" affordance entirely when
// the user has already configured everything.
func (d cfgHardwarePageData) HasAvailable() bool {
return len(d.AvailableUSB)+len(d.AvailableWiFi)+len(d.AvailableGPS) > 0
}
// ConfigureHardwareHandler serves the Configure > Hardware page.
type ConfigureHardwareHandler struct {
Template *template.Template
RC restconf.Fetcher
Schema *schema.Cache
}
// Overview renders the Configure > Hardware page.
// GET /configure/hardware
func (h *ConfigureHardwareHandler) Overview(w http.ResponseWriter, r *http.Request) {
data := cfgHardwarePageData{
PageData: newPageData(r, "configure-hardware", "Configure: Hardware"),
}
mgr := h.Schema.Manager()
data.Loading = mgr == nil
if mgr != nil {
compPath := "/ietf-hardware:hardware/component"
radioPath := compPath + "/infix-hardware:wifi-radio"
data.Desc = map[string]string{
"description": schema.DescriptionOf(mgr, compPath+"/description"),
"admin-state": schema.DescriptionOf(mgr, compPath+"/state/admin-state"),
"country-code": schema.DescriptionOf(mgr, radioPath+"/country-code"),
"channel": schema.DescriptionOf(mgr, radioPath+"/channel"),
"band": schema.DescriptionOf(mgr, radioPath+"/band"),
}
data.CountryOptions = schema.OptionsFor(mgr, radioPath+"/country-code")
data.BandOptions = schema.OptionsFor(mgr, radioPath+"/band")
}
var (
cfgWrap hardwareWrapper
operWrap hardwareWrapper
cfgErr error
operErr error
wg sync.WaitGroup
)
wg.Add(2)
go func() { defer wg.Done(); cfgErr = h.RC.Get(r.Context(), hwCandPath, &cfgWrap) }()
go func() {
defer wg.Done()
operErr = h.RC.Get(r.Context(), "/data/ietf-hardware:hardware", &operWrap)
}()
wg.Wait()
if cfgErr != nil && !restconf.IsNotFound(cfgErr) {
// 404 just means nothing is configured yet — proceed with empty list.
log.Printf("configure hardware: candidate fetch: %v", cfgErr)
data.Error = "Could not read hardware configuration"
}
if operErr != nil {
log.Printf("configure hardware: operational fetch: %v", operErr)
}
configured := make(map[string]bool, len(cfgWrap.Hardware.Component))
for _, c := range cfgWrap.Hardware.Component {
configured[c.Name] = true
class := shortClass(c.Class)
if !isConfigurableClass(class) {
continue
}
data.Configured = append(data.Configured, h.buildRow(c, class, data))
}
sort.SliceStable(data.Configured, func(i, j int) bool {
if data.Configured[i].Class != data.Configured[j].Class {
return data.Configured[i].Class < data.Configured[j].Class
}
return data.Configured[i].Name < data.Configured[j].Name
})
for _, c := range operWrap.Hardware.Component {
if configured[c.Name] {
continue
}
class := shortClass(c.Class)
if !isConfigurableClass(class) {
continue
}
avail := hwAvailable{Name: c.Name, Class: class, ClassDisplay: hwClassDisplay(class)}
switch class {
case classUSB:
data.AvailableUSB = append(data.AvailableUSB, avail)
case classWiFi:
data.AvailableWiFi = append(data.AvailableWiFi, avail)
case classGPS:
data.AvailableGPS = append(data.AvailableGPS, avail)
}
}
sortAvail(data.AvailableUSB)
sortAvail(data.AvailableWiFi)
sortAvail(data.AvailableGPS)
tmplName := "configure-hardware.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)
}
}
func (h *ConfigureHardwareHandler) buildRow(c hwComponentJSON, class string, data cfgHardwarePageData) hwCompCfgRow {
row := hwCompCfgRow{
Name: c.Name,
Class: class,
ClassDisplay: hwClassDisplay(class),
Description: c.Description,
DescDescription: data.Desc["description"],
DescAdminState: data.Desc["admin-state"],
}
switch class {
case classUSB:
row.IsUSB = true
row.Unlocked = c.State != nil && c.State.AdminState == adminStateUnlocked
case classWiFi:
row.IsWiFi = true
row.CountryOptions = data.CountryOptions
row.BandOptions = data.BandOptions
row.DescCountry = data.Desc["country-code"]
row.DescBand = data.Desc["band"]
row.DescChannel = data.Desc["channel"]
if c.WiFiRadio != nil {
row.CountryCode = c.WiFiRadio.CountryCode
row.Band = c.WiFiRadio.Band
row.Channel = wifiChannelString(c.WiFiRadio.Channel)
}
case classGPS:
row.IsGPS = true
}
return row
}
// SaveUSBPort writes description and admin-state for a USB component.
// Per-leaf PUT so the component-list entry is auto-created when it lived
// only in operational before — component-level PATCH 404s otherwise.
// POST /configure/hardware/usb/{name}
func (h *ConfigureHardwareHandler) SaveUSBPort(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.PathValue("name")
state := adminStateLocked
if r.FormValue("enabled") == "true" {
state = adminStateUnlocked
}
if err := h.saveDescription(r.Context(), name, r.FormValue("description")); err != nil {
log.Printf("configure hardware usb %s description: %v", name, err)
renderSaveError(w, err)
return
}
if err := h.putAdminState(r.Context(), name, state); err != nil {
log.Printf("configure hardware usb %s state: %v", name, err)
renderSaveError(w, err)
return
}
renderSavedRedirect(w, name+" "+state, "/configure/hardware")
}
// SaveWiFiRadio writes description and the wifi-radio container.
// POST /configure/hardware/wifi/{name}
func (h *ConfigureHardwareHandler) SaveWiFiRadio(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.PathValue("name")
radio, err := parseWiFiRadio(r)
if err != nil {
renderSaveError(w, err)
return
}
if err := h.saveDescription(r.Context(), name, r.FormValue("description")); err != nil {
log.Printf("configure hardware wifi %s description: %v", name, err)
renderSaveError(w, err)
return
}
if err := h.putWiFiRadio(r.Context(), name, radio); err != nil {
log.Printf("configure hardware wifi %s: %v", name, err)
renderSaveError(w, err)
return
}
renderSavedRedirect(w, "Radio "+name+" saved", "/configure/hardware")
}
// SaveGPS writes description for an already-configured GPS component. The
// gps-receiver presence container is the configured signal; once added it
// is left in place by Save and only removed by DeleteComponent.
// POST /configure/hardware/gps/{name}
func (h *ConfigureHardwareHandler) SaveGPS(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.PathValue("name")
if err := h.saveDescription(r.Context(), name, r.FormValue("description")); err != nil {
log.Printf("configure hardware gps %s description: %v", name, err)
renderSaveError(w, err)
return
}
renderSavedRedirect(w, "GPS "+name+" saved", "/configure/hardware")
}
// CreateHardware adds a detected-but-unconfigured component to running
// in a single PUT — name+class plus the class-specific child container —
// so the candidate never sees a half-configured component if the request
// is interrupted between writes.
// POST /configure/hardware
func (h *ConfigureHardwareHandler) CreateHardware(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
class := r.FormValue("class")
if name == "" || class == "" {
renderSaveError(w, fmt.Errorf("name and class are required"))
return
}
yangClass, ok := yangClassFromSlug(class)
if !ok {
renderSaveError(w, fmt.Errorf("unknown hardware class %q", class))
return
}
comp := map[string]any{"name": name, "class": yangClass}
switch class {
case classUSB:
comp["state"] = map[string]any{"admin-state": adminStateUnlocked}
case classWiFi:
radio, err := parseWiFiRadio(r)
if err != nil {
renderSaveError(w, err)
return
}
comp["infix-hardware:wifi-radio"] = radio
case classGPS:
comp["infix-hardware:gps-receiver"] = map[string]any{}
}
body := map[string]any{"ietf-hardware:component": []any{comp}}
if err := h.RC.Put(r.Context(), hwComponentPath(name), body); err != nil {
log.Printf("configure hardware create %s: %v", name, err)
renderSaveError(w, err)
return
}
renderSavedRedirect(w, name+" added", "/configure/hardware")
}
func (h *ConfigureHardwareHandler) putAdminState(ctx context.Context, name, state string) error {
return h.RC.Put(ctx, hwComponentPath(name)+"/state/admin-state",
map[string]any{"ietf-hardware:admin-state": state})
}
func (h *ConfigureHardwareHandler) putWiFiRadio(ctx context.Context, name string, radio map[string]any) error {
return h.RC.Put(ctx, hwComponentPath(name)+"/infix-hardware:wifi-radio",
map[string]any{"infix-hardware:wifi-radio": radio})
}
// parseWiFiRadio builds the wifi-radio body from form fields. Country
// code is mandatory; band and channel are optional and only included
// when non-empty so we don't clobber YANG defaults with empty strings.
func parseWiFiRadio(r *http.Request) (map[string]any, error) {
country := strings.TrimSpace(r.FormValue("country-code"))
if country == "" {
return nil, fmt.Errorf("country code is required")
}
radio := map[string]any{"country-code": country}
if band := strings.TrimSpace(r.FormValue("band")); band != "" {
radio["band"] = band
}
if ch := strings.TrimSpace(r.FormValue("channel")); ch != "" {
// YANG union: uint16 (1..196) or the literal "auto". Coerce
// numeric input so the RESTCONF payload type-matches the union.
if ch == "auto" {
radio["channel"] = "auto"
} else {
n, err := strconv.Atoi(ch)
if err != nil || n < 1 || n > 196 {
return nil, fmt.Errorf("channel must be 'auto' or a number 1196")
}
radio["channel"] = n
}
}
return radio, nil
}
// saveDescription PUTs or DELETEs the description leaf depending on whether
// the form value is empty — empty means "no user-supplied description", and
// PUT-ing the empty string would leave a stray empty leaf around.
func (h *ConfigureHardwareHandler) saveDescription(ctx context.Context, name, desc string) error {
desc = strings.TrimSpace(desc)
path := hwComponentPath(name) + "/description"
if desc == "" {
if err := h.RC.Delete(ctx, path); err != nil && !restconf.IsNotFound(err) {
return err
}
return nil
}
return h.RC.Put(ctx, path, map[string]any{"ietf-hardware:description": desc})
}
// DeleteComponent removes the entire running-config entry for a component,
// leaving only the discovered operational state. Reverts the component to
// YANG/operational defaults until the user adds it again.
// DELETE /configure/hardware/{name}
func (h *ConfigureHardwareHandler) DeleteComponent(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := h.RC.Delete(r.Context(), hwComponentPath(name)); err != nil && !restconf.IsNotFound(err) {
log.Printf("configure hardware delete %s: %v", name, err)
renderSaveError(w, err)
return
}
renderSavedRedirect(w, name+" configuration removed", "/configure/hardware")
}
func hwComponentPath(name string) string {
return hwCandPath + "/component=" + url.PathEscape(name)
}
func isConfigurableClass(class string) bool {
switch class {
case classUSB, classWiFi, classGPS:
return true
}
return false
}
func yangClassFromSlug(slug string) (string, bool) {
switch slug {
case classUSB:
return "infix-hardware:usb", true
case classWiFi:
return "infix-hardware:wifi", true
case classGPS:
return "infix-hardware:gps", true
}
return "", false
}
// hwClassDisplay turns the short class slug into a human-readable label.
func hwClassDisplay(class string) string {
switch class {
case classChassis:
return "Chassis"
case classUSB:
return "USB port"
case classWiFi:
return "WiFi radio"
case classGPS:
return "GPS receiver"
}
return class
}
func sortAvail(s []hwAvailable) {
sort.SliceStable(s, func(i, j int) bool { return s[i].Name < s[j].Name })
}
+8
View File
@@ -157,6 +157,13 @@ const (
// sensorStatusOK matches the ietf-hardware sensor-status "ok" enum value.
const sensorStatusOK = "ok"
// admin-state values for configurable ietf-hardware components. The infix
// deviation in infix-hardware.yang restricts the base type to these two.
const (
adminStateLocked = "locked"
adminStateUnlocked = "unlocked"
)
type hardwareWrapper struct {
Hardware struct {
Component []hwComponentJSON `json:"component"`
@@ -174,6 +181,7 @@ type hwComponentJSON struct {
HardwareRev string `json:"hardware-rev"`
PhysAddress string `json:"infix-hardware:phys-address"`
WiFiRadio *wifiRadioHWJSON `json:"infix-hardware:wifi-radio"`
GPSReceiver *struct{} `json:"infix-hardware:gps-receiver"`
SensorData *struct {
ValueType string `json:"value-type"`
Value yangInt64 `json:"value"`
+206
View File
@@ -0,0 +1,206 @@
// SPDX-License-Identifier: MIT
package handlers
import (
"context"
"html/template"
"log"
"net/http"
"sort"
"github.com/kernelkit/webui/internal/restconf"
)
// Hardware page data — populated from /ietf-hardware:hardware.
// Mirrors CLI `show hardware`, but with browser affordances: sensor groups
// keep their child rows together, USB ports get a state column, and any
// uncategorised components fall through to a "Other" section so the page
// shows everything ietf-hardware exposes.
type hardwarePageData struct {
PageData
Board boardInfo
USBPorts []usbPortEntry
WiFiRadios []wifiEntry
GPSReceivers []gpsEntry
SensorGroups []hwSensorGroup
OtherComps []otherCompEntry
Error string
}
type usbPortEntry struct {
Name string
Description string
AdminState string // locked / unlocked
OperState string // enabled / disabled / etc.
}
type gpsEntry struct {
Name string
Manufacturer string
ModelName string
HardwareRev string
}
type hwSensorGroup struct {
Parent string
Sensors []hwSensorEntry
}
type hwSensorEntry struct {
Name string
Value string
Type string // temperature / fan / volts-DC / etc.
OperStatus string // ok / unavailable / nonoperational
}
type otherCompEntry struct {
Name string
Class string
Parent string
Description string
Manufacturer string
ModelName string
SerialNum string
HardwareRev string
}
// HardwareHandler serves the Status → Hardware page (GET /hardware).
type HardwareHandler struct {
Template *template.Template
RC *restconf.Client
}
func (h *HardwareHandler) Overview(w http.ResponseWriter, r *http.Request) {
data := hardwarePageData{
PageData: newPageData(r, "hardware", "Hardware"),
}
// Detach from r.Context() so the RESTCONF call (and yanger behind it)
// survives a client disconnect mid-fetch (e.g. login redirect). The
// RESTCONF client's own 10s timeout still bounds each call.
ctx := context.WithoutCancel(r.Context())
var hw hardwareWrapper
if err := h.RC.Get(ctx, "/data/ietf-hardware:hardware", &hw); err != nil {
if !restconf.IsNotFound(err) {
log.Printf("restconf hardware: %v", err)
data.Error = "Could not fetch hardware information"
}
} else {
buildHardwarePage(&data, hw.Hardware.Component)
}
tmplName := "hardware.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)
}
}
// buildHardwarePage walks the ietf-hardware components and routes each to
// the section it belongs in. Components with sensor-data show up under
// SensorGroups regardless of class (a chassis can carry a temp sensor too);
// any component not handled by an explicit section falls through to
// OtherComps so the page is exhaustive.
//
// Unlike the dashboard's curated view, this one keeps non-ok sensors
// visible — the redesigned Overview (Phase 2) will surface those via a
// summary indicator instead of hiding them.
func buildHardwarePage(data *hardwarePageData, comps []hwComponentJSON) {
sensorMap := make(map[string][]hwSensorEntry)
var sensorParents []string
for _, c := range comps {
class := shortClass(c.Class)
handled := true
switch class {
case classChassis:
data.Board = boardInfo{
Model: c.ModelName,
Manufacturer: c.MfgName,
SerialNum: c.SerialNum,
HardwareRev: c.HardwareRev,
BaseMAC: c.PhysAddress,
}
case classUSB:
data.USBPorts = append(data.USBPorts, usbPortEntry{
Name: c.Name,
Description: c.Description,
AdminState: compAdminState(c),
OperState: compOperState(c),
})
case classWiFi:
data.WiFiRadios = append(data.WiFiRadios, summarizeWiFiRadio(c))
case classGPS:
data.GPSReceivers = append(data.GPSReceivers, gpsEntry{
Name: c.Name,
Manufacturer: c.MfgName,
ModelName: c.ModelName,
HardwareRev: c.HardwareRev,
})
default:
handled = false
}
if c.SensorData != nil {
entry := hwSensorEntry{
Name: c.Name,
Value: formatSensor(c.SensorData.ValueType, int64(c.SensorData.Value), c.SensorData.ValueScale),
Type: c.SensorData.ValueType,
OperStatus: c.SensorData.OperStatus,
}
if _, ok := sensorMap[c.Parent]; !ok {
sensorParents = append(sensorParents, c.Parent)
}
sensorMap[c.Parent] = append(sensorMap[c.Parent], entry)
handled = true
}
if !handled {
data.OtherComps = append(data.OtherComps, otherCompEntry{
Name: c.Name,
Class: class,
Parent: c.Parent,
Description: c.Description,
Manufacturer: c.MfgName,
ModelName: c.ModelName,
SerialNum: c.SerialNum,
HardwareRev: c.HardwareRev,
})
}
}
sort.Strings(sensorParents)
for _, p := range sensorParents {
data.SensorGroups = append(data.SensorGroups, hwSensorGroup{
Parent: p,
Sensors: sensorMap[p],
})
}
sort.Slice(data.USBPorts, func(i, j int) bool { return data.USBPorts[i].Name < data.USBPorts[j].Name })
sort.Slice(data.OtherComps, func(i, j int) bool {
if data.OtherComps[i].Class != data.OtherComps[j].Class {
return data.OtherComps[i].Class < data.OtherComps[j].Class
}
return data.OtherComps[i].Name < data.OtherComps[j].Name
})
}
func compAdminState(c hwComponentJSON) string {
if c.State == nil {
return ""
}
return c.State.AdminState
}
func compOperState(c hwComponentJSON) string {
if c.State == nil {
return ""
}
return c.State.OperState
}
+5 -4
View File
@@ -25,10 +25,11 @@ type wifiMaxIfJSON struct {
}
type wifiRadioHWJSON struct {
Channel interface{} `json:"channel"` // uint16 or "auto"
Band string `json:"band"`
Frequency int `json:"frequency"` // MHz, operational
Noise int `json:"noise"` // dBm, operational
CountryCode string `json:"country-code"` // ISO 3166-1, rw
Channel interface{} `json:"channel"` // uint16 or "auto", rw
Band string `json:"band"` // rw
Frequency int `json:"frequency"` // MHz, operational
Noise int `json:"noise"` // dBm, operational
Driver string `json:"driver"`
Bands []wifiBandJSON `json:"bands"`
MaxInterfaces *wifiMaxIfJSON `json:"max-interfaces"`
+17
View File
@@ -69,6 +69,10 @@ func New(
if err != nil {
return nil, err
}
hwTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/hardware.html")
if err != nil {
return nil, err
}
wifiTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/wifi.html")
if err != nil {
return nil, err
@@ -121,6 +125,10 @@ func New(
if err != nil {
return nil, err
}
cfgHwTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/icons.html", "pages/configure-hardware.html")
if err != nil {
return nil, err
}
ifFuncs := template.FuncMap{
"add": func(a, b int) int { return a + b },
"deref": func(v any) any {
@@ -220,6 +228,7 @@ func New(
routing := &handlers.RoutingHandler{Template: routingTmpl, RC: rc}
wifi := &handlers.WiFiHandler{Template: wifiTmpl, RC: rc}
hw := &handlers.HardwareHandler{Template: hwTmpl, RC: rc}
vpn := &handlers.VPNHandler{Template: vpnTmpl, RC: rc}
dhcp := &handlers.DHCPHandler{Template: dhcpTmpl, RC: rc}
ntp := &handlers.NTPHandler{Template: ntpTmpl, RC: rc}
@@ -233,6 +242,7 @@ func New(
cfgUsers := &handlers.ConfigureUsersHandler{Template: cfgUsersTmpl, RC: rc, Schema: schemaCache}
cfgRoutes := &handlers.ConfigureRoutesHandler{Template: cfgRoutesTmpl, RC: rc, Schema: schemaCache}
cfgFw := &handlers.ConfigureFirewallHandler{Template: cfgFwTmpl, RC: rc, Schema: schemaCache}
cfgHw := &handlers.ConfigureHardwareHandler{Template: cfgHwTmpl, RC: rc, Schema: schemaCache}
cfgIf := &handlers.ConfigureInterfacesHandler{Template: cfgIfTmpl, RC: rc, Schema: schemaCache}
schemaH := &handlers.SchemaHandler{Cache: schemaCache}
dataH := &handlers.DataHandler{RC: rc, Schema: schemaCache}
@@ -287,6 +297,7 @@ func New(
mux.HandleFunc("POST /maintenance/system/datetime", sys.SetDatetime)
mux.HandleFunc("GET /routing", routing.Overview)
mux.HandleFunc("GET /wifi", wifi.Overview)
mux.HandleFunc("GET /hardware", hw.Overview)
mux.HandleFunc("GET /vpn", vpn.Overview)
mux.HandleFunc("GET /dhcp", dhcp.Overview)
mux.HandleFunc("GET /ntp", ntp.Overview)
@@ -361,6 +372,12 @@ func New(
mux.HandleFunc("POST /configure/firewall/services", cfgFw.AddService)
mux.HandleFunc("POST /configure/firewall/services/{name}", cfgFw.SaveService)
mux.HandleFunc("DELETE /configure/firewall/services/{name}", cfgFw.DeleteService)
mux.HandleFunc("GET /configure/hardware", cfgHw.Overview)
mux.HandleFunc("POST /configure/hardware", cfgHw.CreateHardware)
mux.HandleFunc("POST /configure/hardware/usb/{name}", cfgHw.SaveUSBPort)
mux.HandleFunc("POST /configure/hardware/wifi/{name}", cfgHw.SaveWiFiRadio)
mux.HandleFunc("POST /configure/hardware/gps/{name}", cfgHw.SaveGPS)
mux.HandleFunc("DELETE /configure/hardware/{name}", cfgHw.DeleteComponent)
mux.HandleFunc("GET /configure/routes", cfgRoutes.Overview)
mux.HandleFunc("POST /configure/routes", cfgRoutes.AddRoute)
mux.HandleFunc("PUT /configure/routes", cfgRoutes.UpdateRoute)
+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="4" y="4" width="16" height="16" rx="2"/>
<rect x="9" y="9" width="6" height="6"/>
<path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

+20
View File
@@ -17,6 +17,16 @@
Overview
</a>
</li>
<li>
<a href="/hardware"
hx-get="/hardware"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "hardware"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/hardware.svg')"></span>
Hardware
</a>
</li>
{{if and .Capabilities (.Capabilities.Has "containers")}}
<li>
<a href="/containers"
@@ -184,6 +194,16 @@
System
</a>
</li>
<li>
<a href="/configure/hardware"
hx-get="/configure/hardware"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "configure-hardware"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/hardware.svg')"></span>
Hardware
</a>
</li>
<li>
<a href="/configure/firewall"
hx-get="/configure/firewall"
@@ -0,0 +1,275 @@
{{define "configure-hardware.html"}}
{{template "base" .}}
{{end}}
{{define "content"}}
<div class="configure-page">
{{if .Loading}}
<div class="alert alert-info"
hx-get="/configure/hardware"
hx-trigger="every 3s"
hx-target="#content"
hx-swap="innerHTML">
Downloading YANG schema from device&hellip; this takes a moment on first login.
</div>
{{template "configure-toolbar" .}}
</div>
{{else}}
{{if .Error}}
<div class="alert alert-error">{{.Error}}</div>
{{end}}
<section class="info-grid">
<section class="info-card info-grid-span-2">
<div class="card-header">Hardware
<a href="/hardware"
hx-get="/hardware"
hx-target="#content"
hx-push-url="true"
class="btn btn-sm btn-outline card-header-action">Status &rarr;</a>
</div>
<div class="data-table-wrap">
<table class="data-table cfg-table">
<thead>
<tr>
<th>Name</th>
<th>Class</th>
<th>Description</th>
<th>State</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $i, $c := .Configured}}
<tr>
<td>
<button class="key-row-toggle" type="button"
aria-expanded="false" data-target="hw-{{$i}}">
<span class="key-row-arrow" aria-hidden="true"></span>{{$c.Name}}
</button>
</td>
<td>{{$c.ClassDisplay}}</td>
<td>{{if $c.Description}}{{$c.Description}}{{else}}<span class="text-muted">&mdash;</span>{{end}}</td>
<td>
{{if $c.IsUSB}}
{{if $c.Unlocked}}<span class="status-dot status-up"></span>unlocked
{{else}}<span class="status-dot status-down"></span>locked{{end}}
{{else if $c.IsWiFi}}
<span class="status-dot status-up"></span>{{$c.CountryCode}}{{if $c.Band}} &middot; {{$c.Band}}{{end}}
{{else if $c.IsGPS}}
<span class="status-dot status-up"></span>configured
{{else}}<span class="text-muted">&mdash;</span>{{end}}
</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/hardware/{{$c.Name}}"
hx-confirm="Remove running configuration for {{$c.Name}}?"
hx-swap="none" title="Remove configuration">
{{template "icon-trash"}}
</button>
</td>
</tr>
<tr class="key-detail-row" id="key-detail-hw-{{$i}}">
<td colspan="5" class="key-detail-cell">
<div class="key-detail-body">
{{if $c.IsUSB}}{{template "hw-usb-form" $c}}{{end}}
{{if $c.IsWiFi}}{{template "wifi-radio-form" $c}}{{end}}
{{if $c.IsGPS}}{{template "hw-gps-form" $c}}{{end}}
</div>
</td>
</tr>
{{end}}
{{if not .Configured}}
<tr><td colspan="5" class="yt-table-empty">No hardware components configured yet{{if .HasAvailable}} — use <em>Add hardware</em> below to configure detected devices{{end}}.</td></tr>
{{end}}
{{if .HasAvailable}}
{{/* Hidden add row */}}
<tr id="add-hw-row" hidden>
<td colspan="5" class="key-detail-cell">
<form hx-post="/configure/hardware" hx-swap="none" id="add-hw-form" class="user-add-inline">
<input type="hidden" name="class" id="add-hw-class">
<select class="cfg-input" name="name" id="add-hw-picker" required style="min-width:14rem">
<option value="">— select hardware —</option>
{{if .AvailableUSB}}
<optgroup label="USB ports">
{{range .AvailableUSB}}<option value="{{.Name}}" data-class="usb">{{.Name}}</option>{{end}}
</optgroup>
{{end}}
{{if .AvailableWiFi}}
<optgroup label="WiFi radios">
{{range .AvailableWiFi}}<option value="{{.Name}}" data-class="wifi">{{.Name}}</option>{{end}}
</optgroup>
{{end}}
{{if .AvailableGPS}}
<optgroup label="GPS receivers">
{{range .AvailableGPS}}<option value="{{.Name}}" data-class="gps">{{.Name}}</option>{{end}}
</optgroup>
{{end}}
</select>
<span id="add-hw-wifi-fields" hidden style="display:contents">
<select class="cfg-input cfg-input-sm" name="country-code" id="add-hw-wifi-country">
<option value="">— country —</option>
{{range .CountryOptions}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
</select>
</span>
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<button type="button" class="btn btn-sm" data-hide="add-hw-row">Cancel</button>
<span class="cfg-save-status"></span>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{if .HasAvailable}}
<div class="cfg-card-footer">
<button class="btn btn-secondary" data-show="add-hw-row">+ Add hardware</button>
</div>
{{end}}
</section>
</section>
{{template "configure-toolbar" .}}
</div>
{{end}}{{/* else schema ready */}}
{{end}}
{{define "hw-usb-form"}}
{{$p := printf "/ietf-hardware:hardware/component=%s" .Name}}
<form hx-post="/configure/hardware/usb/{{.Name}}" hx-swap="none" class="cfg-radio-form">
<table class="info-table">
<tr>
<th>Description{{template "field-info" .DescDescription}}</th>
<td>
<input class="cfg-input cfg-input-sm" type="text" name="description"
value="{{.Description}}">
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path={{$p}}/description&redirect=/configure/hardware"
hx-swap="none"
hx-confirm="Reset description to its YANG default?"></button>
</td>
</tr>
<tr>
<th>State{{template "field-info" .DescAdminState}}</th>
<td>
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
<input type="checkbox" name="enabled" value="true" {{if .Unlocked}}checked{{end}}>
unlocked
</label>
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path={{$p}}/state/admin-state&redirect=/configure/hardware"
hx-swap="none"
hx-confirm="Reset state to its YANG default?"></button>
</td>
</tr>
</table>
<div class="cfg-card-footer">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
{{end}}
{{define "wifi-radio-form"}}
{{$p := printf "/ietf-hardware:hardware/component=%s" .Name}}
{{$rp := printf "%s/infix-hardware:wifi-radio" $p}}
<form hx-post="/configure/hardware/wifi/{{.Name}}" hx-swap="none" class="cfg-radio-form">
<table class="info-table">
<tr>
<th>Description{{template "field-info" .DescDescription}}</th>
<td>
<input class="cfg-input cfg-input-sm" type="text" name="description"
value="{{.Description}}">
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path={{$p}}/description&redirect=/configure/hardware"
hx-swap="none"
hx-confirm="Reset description to its YANG default?"></button>
</td>
</tr>
<tr>
<th>Country code{{template "field-info" .DescCountry}}</th>
<td>
<select class="cfg-input cfg-input-sm" name="country-code" required>
{{$selected := .CountryCode}}
{{range .CountryOptions}}<option value="{{.Value}}" {{if eq .Value $selected}}selected{{end}}>{{.Label}}</option>{{end}}
</select>
</td>
<td class="cfg-reset-col"></td>
</tr>
<tr>
<th>Band{{template "field-info" .DescBand}}</th>
<td>
<select class="cfg-input cfg-input-sm" name="band">
<option value=""></option>
{{$selectedBand := .Band}}
{{range .BandOptions}}<option value="{{.Value}}" {{if eq .Value $selectedBand}}selected{{end}}>{{.Label}}</option>{{end}}
</select>
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path={{$rp}}/band&redirect=/configure/hardware"
hx-swap="none"
hx-confirm="Reset band to its YANG default?"></button>
</td>
</tr>
<tr>
<th>Channel{{template "field-info" .DescChannel}}</th>
<td>
<input class="cfg-input cfg-input-sm" type="text" name="channel"
value="{{.Channel}}"
pattern="auto|[0-9]+" style="width:8rem">
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path={{$rp}}/channel&redirect=/configure/hardware"
hx-swap="none"
hx-confirm="Reset channel to its YANG default?"></button>
</td>
</tr>
</table>
<div class="cfg-card-footer">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
{{end}}
{{define "hw-gps-form"}}
{{$p := printf "/ietf-hardware:hardware/component=%s" .Name}}
<form hx-post="/configure/hardware/gps/{{.Name}}" hx-swap="none" class="cfg-radio-form">
<table class="info-table">
<tr>
<th>Description{{template "field-info" .DescDescription}}</th>
<td>
<input class="cfg-input cfg-input-sm" type="text" name="description"
value="{{.Description}}">
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path={{$p}}/description&redirect=/configure/hardware"
hx-swap="none"
hx-confirm="Reset description to its YANG default?"></button>
</td>
</tr>
</table>
<div class="cfg-card-footer">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
{{end}}
{{define "field-info"}}{{if .}} <span class="field-info" data-tip="{{.}}"></span>{{end}}{{end}}
+206
View File
@@ -0,0 +1,206 @@
{{define "hardware.html"}}
{{template "base" .}}
{{end}}
{{define "content"}}
{{if .Error}}
<div class="alert alert-error">{{.Error}}</div>
{{end}}
<section class="info-grid">
{{/* ── Board ─────────────────────────────────────────────────────────── */}}
{{if .Board.Model}}
<div class="info-card">
<div class="card-header">Board</div>
<div class="card-body" style="padding:0">
<table class="info-table">
<tr><th>Model</th><td>{{.Board.Model}}</td></tr>
{{if .Board.Manufacturer}}<tr><th>Manufacturer</th><td>{{.Board.Manufacturer}}</td></tr>{{end}}
{{if .Board.SerialNum}}<tr><th>Serial Number</th><td>{{.Board.SerialNum}}</td></tr>{{end}}
{{if .Board.HardwareRev}}<tr><th>Hardware Rev</th><td>{{.Board.HardwareRev}}</td></tr>{{end}}
{{if .Board.BaseMAC}}<tr><th>Base MAC</th><td><span class="num">{{.Board.BaseMAC}}</span></td></tr>{{end}}
</table>
</div>
</div>
{{end}}
{{/* ── USB Ports ────────────────────────────────────────────────────── */}}
{{if .USBPorts}}
<div class="info-card">
<div class="card-header">USB Ports
<a href="/configure/hardware"
hx-get="/configure/hardware"
hx-target="#content"
hx-push-url="true"
class="btn btn-sm btn-outline card-header-action">Configure &rarr;</a>
</div>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>State</th>
<th>Operational</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{{range .USBPorts}}
<tr>
<td>{{.Name}}</td>
<td>
{{if eq .AdminState "unlocked"}}
<span class="status-dot status-up"></span>unlocked
{{else if eq .AdminState "locked"}}
<span class="status-dot status-down"></span>locked
{{else}}
<span class="text-muted">{{.AdminState}}</span>
{{end}}
</td>
<td>{{if .OperState}}{{.OperState}}{{else}}<span class="text-muted">&mdash;</span>{{end}}</td>
<td>{{if .Description}}{{.Description}}{{else}}<span class="text-muted">&mdash;</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{/* ── WiFi Radios ──────────────────────────────────────────────────── */}}
{{if .WiFiRadios}}
<div class="info-card info-grid-span-2">
<div class="card-header">WiFi Radios
<a href="/configure/hardware"
hx-get="/configure/hardware"
hx-target="#content"
hx-push-url="true"
class="btn btn-sm btn-outline card-header-action">Configure &rarr;</a>
</div>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Manufacturer</th>
<th>Bands</th>
<th>Standards</th>
<th class="num">Max AP</th>
</tr>
</thead>
<tbody>
{{range .WiFiRadios}}
<tr>
<td>{{.Name}}</td>
<td>{{if .Manufacturer}}{{.Manufacturer}}{{else}}<span class="text-muted">&mdash;</span>{{end}}</td>
<td>{{if .Bands}}{{.Bands}}{{else}}<span class="text-muted">&mdash;</span>{{end}}</td>
<td>{{if .Standards}}{{.Standards}}{{else}}<span class="text-muted">&mdash;</span>{{end}}</td>
<td class="num">{{if .MaxAP}}{{.MaxAP}}{{else}}<span class="text-muted">&mdash;</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{/* ── GPS Receivers ────────────────────────────────────────────────── */}}
{{if .GPSReceivers}}
<div class="info-card">
<div class="card-header">GPS Receivers</div>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Manufacturer</th>
<th>Model</th>
<th>HW Rev</th>
</tr>
</thead>
<tbody>
{{range .GPSReceivers}}
<tr>
<td>{{.Name}}</td>
<td>{{if .Manufacturer}}{{.Manufacturer}}{{else}}<span class="text-muted">&mdash;</span>{{end}}</td>
<td>{{if .ModelName}}{{.ModelName}}{{else}}<span class="text-muted">&mdash;</span>{{end}}</td>
<td>{{if .HardwareRev}}{{.HardwareRev}}{{else}}<span class="text-muted">&mdash;</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{/* ── Sensors ──────────────────────────────────────────────────────── */}}
{{if .SensorGroups}}
<div class="info-card info-grid-span-2">
<div class="card-header">Sensors</div>
<div class="card-body" style="padding:0">
<table class="info-table">
{{range $g := .SensorGroups}}
{{if $g.Parent}}<tr class="sensor-group-hdr"><th colspan="3">{{$g.Parent}}</th></tr>{{end}}
{{range $g.Sensors}}
<tr{{if $g.Parent}} class="sensor-child"{{end}}>
<th>{{.Name}}</th>
<td><span class="num">{{.Value}}</span></td>
<td style="text-align:right">
{{if eq .OperStatus "ok"}}
<span class="status-dot status-up" title="ok"></span>
{{else if eq .OperStatus ""}}
<span class="text-muted">&mdash;</span>
{{else}}
<span class="status-dot status-down" title="{{.OperStatus}}"></span><span class="text-muted">{{.OperStatus}}</span>
{{end}}
</td>
</tr>
{{end}}
{{end}}
</table>
</div>
</div>
{{end}}
{{/* ── Other Components ─────────────────────────────────────────────── */}}
{{if .OtherComps}}
<div class="info-card info-grid-span-2">
<div class="card-header">Other Components</div>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Class</th>
<th>Parent</th>
<th>Manufacturer</th>
<th>Model</th>
<th>Serial</th>
</tr>
</thead>
<tbody>
{{range .OtherComps}}
<tr>
<td>{{.Name}}</td>
<td>{{.Class}}</td>
<td>{{if .Parent}}{{.Parent}}{{else}}<span class="text-muted">&mdash;</span>{{end}}</td>
<td>{{if .Manufacturer}}{{.Manufacturer}}{{else}}<span class="text-muted">&mdash;</span>{{end}}</td>
<td>{{if .ModelName}}{{.ModelName}}{{else}}<span class="text-muted">&mdash;</span>{{end}}</td>
<td>{{if .SerialNum}}{{.SerialNum}}{{else}}<span class="text-muted">&mdash;</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{if and (not .Board.Model) (not .USBPorts) (not .WiFiRadios) (not .GPSReceivers) (not .SensorGroups) (not .OtherComps) (not .Error)}}
<div class="info-card info-grid-span-2">
<p class="empty-message">No hardware information available.</p>
</div>
{{end}}
</section>
{{end}}