From 2c80c659620cd8f2da66d1300382fa4e947d6c64 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 24 May 2026 11:53:55 +0200 Subject: [PATCH] webui: add Status > Hardware page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../internal/handlers/configure_hardware.go | 451 ++++++++++++++++++ src/webui/internal/handlers/dashboard.go | 8 + src/webui/internal/handlers/hardware.go | 206 ++++++++ src/webui/internal/handlers/wifi.go | 9 +- src/webui/internal/server/server.go | 17 + src/webui/static/img/icons/hardware.svg | 5 + src/webui/templates/layouts/sidebar.html | 20 + .../templates/pages/configure-hardware.html | 275 +++++++++++ src/webui/templates/pages/hardware.html | 206 ++++++++ 9 files changed, 1193 insertions(+), 4 deletions(-) create mode 100644 src/webui/internal/handlers/configure_hardware.go create mode 100644 src/webui/internal/handlers/hardware.go create mode 100644 src/webui/static/img/icons/hardware.svg create mode 100644 src/webui/templates/pages/configure-hardware.html create mode 100644 src/webui/templates/pages/hardware.html diff --git a/src/webui/internal/handlers/configure_hardware.go b/src/webui/internal/handlers/configure_hardware.go new file mode 100644 index 00000000..ab798037 --- /dev/null +++ b/src/webui/internal/handlers/configure_hardware.go @@ -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 1–196") + } + 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 }) +} diff --git a/src/webui/internal/handlers/dashboard.go b/src/webui/internal/handlers/dashboard.go index 0660cd56..a8876e7e 100644 --- a/src/webui/internal/handlers/dashboard.go +++ b/src/webui/internal/handlers/dashboard.go @@ -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"` diff --git a/src/webui/internal/handlers/hardware.go b/src/webui/internal/handlers/hardware.go new file mode 100644 index 00000000..be1aeb77 --- /dev/null +++ b/src/webui/internal/handlers/hardware.go @@ -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 +} diff --git a/src/webui/internal/handlers/wifi.go b/src/webui/internal/handlers/wifi.go index 4a59c066..8c1ddafc 100644 --- a/src/webui/internal/handlers/wifi.go +++ b/src/webui/internal/handlers/wifi.go @@ -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"` diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 351eaa10..b255896b 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -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) diff --git a/src/webui/static/img/icons/hardware.svg b/src/webui/static/img/icons/hardware.svg new file mode 100644 index 00000000..8701c064 --- /dev/null +++ b/src/webui/static/img/icons/hardware.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html index 2c68affb..8df5e4a9 100644 --- a/src/webui/templates/layouts/sidebar.html +++ b/src/webui/templates/layouts/sidebar.html @@ -17,6 +17,16 @@ Overview +
  • + + + Hardware + +
  • {{if and .Capabilities (.Capabilities.Has "containers")}}
  • +
  • + + + Hardware + +
  • +{{if .Loading}} +
    + Downloading YANG schema from device… this takes a moment on first login. +
    +{{template "configure-toolbar" .}} + +{{else}} +{{if .Error}} +
    {{.Error}}
    +{{end}} + +
    +
    + +
    + + + + + + + + + + + + {{range $i, $c := .Configured}} + + + + + + + + + + + {{end}} + + {{if not .Configured}} + + {{end}} + + {{if .HasAvailable}} + {{/* Hidden add row */}} + + + + {{end}} + +
    NameClassDescriptionState
    + + {{$c.ClassDisplay}}{{if $c.Description}}{{$c.Description}}{{else}}{{end}} + {{if $c.IsUSB}} + {{if $c.Unlocked}}unlocked + {{else}}locked{{end}} + {{else if $c.IsWiFi}} + {{$c.CountryCode}}{{if $c.Band}} · {{$c.Band}}{{end}} + {{else if $c.IsGPS}} + configured + {{else}}{{end}} + + +
    +
    + {{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}} +
    +
    No hardware components configured yet{{if .HasAvailable}} — use Add hardware below to configure detected devices{{end}}.
    +
    + + {{if .HasAvailable}} + + {{end}} +
    +
    + +{{template "configure-toolbar" .}} + +{{end}}{{/* else schema ready */}} + +{{end}} + +{{define "hw-usb-form"}} +{{$p := printf "/ietf-hardware:hardware/component=%s" .Name}} +
    + + + + + + + + + + + +
    Description{{template "field-info" .DescDescription}} + + + +
    State{{template "field-info" .DescAdminState}} + + + +
    + +
    +{{end}} + +{{define "wifi-radio-form"}} +{{$p := printf "/ietf-hardware:hardware/component=%s" .Name}} +{{$rp := printf "%s/infix-hardware:wifi-radio" $p}} +
    + + + + + + + + + + + + + + + + + + + + + +
    Description{{template "field-info" .DescDescription}} + + + +
    Country code{{template "field-info" .DescCountry}} + +
    Band{{template "field-info" .DescBand}} + + + +
    Channel{{template "field-info" .DescChannel}} + + + +
    + +
    +{{end}} + +{{define "hw-gps-form"}} +{{$p := printf "/ietf-hardware:hardware/component=%s" .Name}} +
    + + + + + + +
    Description{{template "field-info" .DescDescription}} + + + +
    + +
    +{{end}} + +{{define "field-info"}}{{if .}} {{end}}{{end}} diff --git a/src/webui/templates/pages/hardware.html b/src/webui/templates/pages/hardware.html new file mode 100644 index 00000000..9c014a2c --- /dev/null +++ b/src/webui/templates/pages/hardware.html @@ -0,0 +1,206 @@ +{{define "hardware.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
    {{.Error}}
    +{{end}} + +
    + + {{/* ── Board ─────────────────────────────────────────────────────────── */}} + {{if .Board.Model}} +
    +
    Board
    +
    + + + {{if .Board.Manufacturer}}{{end}} + {{if .Board.SerialNum}}{{end}} + {{if .Board.HardwareRev}}{{end}} + {{if .Board.BaseMAC}}{{end}} +
    Model{{.Board.Model}}
    Manufacturer{{.Board.Manufacturer}}
    Serial Number{{.Board.SerialNum}}
    Hardware Rev{{.Board.HardwareRev}}
    Base MAC{{.Board.BaseMAC}}
    +
    +
    + {{end}} + + {{/* ── USB Ports ────────────────────────────────────────────────────── */}} + {{if .USBPorts}} +
    +
    USB Ports + Configure → +
    +
    + + + + + + + + + + + {{range .USBPorts}} + + + + + + + {{end}} + +
    NameStateOperationalDescription
    {{.Name}} + {{if eq .AdminState "unlocked"}} + unlocked + {{else if eq .AdminState "locked"}} + locked + {{else}} + {{.AdminState}} + {{end}} + {{if .OperState}}{{.OperState}}{{else}}{{end}}{{if .Description}}{{.Description}}{{else}}{{end}}
    +
    +
    + {{end}} + + {{/* ── WiFi Radios ──────────────────────────────────────────────────── */}} + {{if .WiFiRadios}} +
    +
    WiFi Radios + Configure → +
    +
    + + + + + + + + + + + + {{range .WiFiRadios}} + + + + + + + + {{end}} + +
    NameManufacturerBandsStandardsMax AP
    {{.Name}}{{if .Manufacturer}}{{.Manufacturer}}{{else}}{{end}}{{if .Bands}}{{.Bands}}{{else}}{{end}}{{if .Standards}}{{.Standards}}{{else}}{{end}}{{if .MaxAP}}{{.MaxAP}}{{else}}{{end}}
    +
    +
    + {{end}} + + {{/* ── GPS Receivers ────────────────────────────────────────────────── */}} + {{if .GPSReceivers}} +
    +
    GPS Receivers
    +
    + + + + + + + + + + + {{range .GPSReceivers}} + + + + + + + {{end}} + +
    NameManufacturerModelHW Rev
    {{.Name}}{{if .Manufacturer}}{{.Manufacturer}}{{else}}{{end}}{{if .ModelName}}{{.ModelName}}{{else}}{{end}}{{if .HardwareRev}}{{.HardwareRev}}{{else}}{{end}}
    +
    +
    + {{end}} + + {{/* ── Sensors ──────────────────────────────────────────────────────── */}} + {{if .SensorGroups}} +
    +
    Sensors
    +
    + + {{range $g := .SensorGroups}} + {{if $g.Parent}}{{end}} + {{range $g.Sensors}} + + + + + + {{end}} + {{end}} +
    {{$g.Parent}}
    {{.Name}}{{.Value}} + {{if eq .OperStatus "ok"}} + + {{else if eq .OperStatus ""}} + + {{else}} + {{.OperStatus}} + {{end}} +
    +
    +
    + {{end}} + + {{/* ── Other Components ─────────────────────────────────────────────── */}} + {{if .OtherComps}} +
    +
    Other Components
    +
    + + + + + + + + + + + + + {{range .OtherComps}} + + + + + + + + + {{end}} + +
    NameClassParentManufacturerModelSerial
    {{.Name}}{{.Class}}{{if .Parent}}{{.Parent}}{{else}}{{end}}{{if .Manufacturer}}{{.Manufacturer}}{{else}}{{end}}{{if .ModelName}}{{.ModelName}}{{else}}{{end}}{{if .SerialNum}}{{.SerialNum}}{{else}}{{end}}
    +
    +
    + {{end}} + + {{if and (not .Board.Model) (not .USBPorts) (not .WiFiRadios) (not .GPSReceivers) (not .SensorGroups) (not .OtherComps) (not .Error)}} +
    +

    No hardware information available.

    +
    + {{end}} + +
    +{{end}}