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}}
+
+
+
+
+
+
+
+
+ | Name |
+ Class |
+ Description |
+ State |
+ |
+
+
+
+ {{range $i, $c := .Configured}}
+
+ |
+
+ |
+ {{$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}}
+
+ |
+
+ {{end}}
+
+ {{if not .Configured}}
+ | No hardware components configured yet{{if .HasAvailable}} — use Add hardware below to configure detected devices{{end}}. |
+ {{end}}
+
+ {{if .HasAvailable}}
+ {{/* Hidden add row */}}
+
+ |
+
+ |
+
+ {{end}}
+
+
+
+
+ {{if .HasAvailable}}
+
+ {{end}}
+
+
+
+{{template "configure-toolbar" .}}
+
+{{end}}{{/* else schema ready */}}
+
+{{end}}
+
+{{define "hw-usb-form"}}
+{{$p := printf "/ietf-hardware:hardware/component=%s" .Name}}
+
+{{end}}
+
+{{define "wifi-radio-form"}}
+{{$p := printf "/ietf-hardware:hardware/component=%s" .Name}}
+{{$rp := printf "%s/infix-hardware:wifi-radio" $p}}
+
+{{end}}
+
+{{define "hw-gps-form"}}
+{{$p := printf "/ietf-hardware:hardware/component=%s" .Name}}
+
+{{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}}
+
+
+
+
+ | Model | {{.Board.Model}} |
+ {{if .Board.Manufacturer}}| Manufacturer | {{.Board.Manufacturer}} |
{{end}}
+ {{if .Board.SerialNum}}| Serial Number | {{.Board.SerialNum}} |
{{end}}
+ {{if .Board.HardwareRev}}| Hardware Rev | {{.Board.HardwareRev}} |
{{end}}
+ {{if .Board.BaseMAC}}| Base MAC | {{.Board.BaseMAC}} |
{{end}}
+
+
+
+ {{end}}
+
+ {{/* ── USB Ports ────────────────────────────────────────────────────── */}}
+ {{if .USBPorts}}
+
+
+
+
+
+
+ | Name |
+ State |
+ Operational |
+ Description |
+
+
+
+ {{range .USBPorts}}
+
+ | {{.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}}
+
+
+
+
+ {{end}}
+
+ {{/* ── WiFi Radios ──────────────────────────────────────────────────── */}}
+ {{if .WiFiRadios}}
+
+
+
+
+
+
+ | Name |
+ Manufacturer |
+ Bands |
+ Standards |
+ Max AP |
+
+
+
+ {{range .WiFiRadios}}
+
+ | {{.Name}} |
+ {{if .Manufacturer}}{{.Manufacturer}}{{else}}—{{end}} |
+ {{if .Bands}}{{.Bands}}{{else}}—{{end}} |
+ {{if .Standards}}{{.Standards}}{{else}}—{{end}} |
+ {{if .MaxAP}}{{.MaxAP}}{{else}}—{{end}} |
+
+ {{end}}
+
+
+
+
+ {{end}}
+
+ {{/* ── GPS Receivers ────────────────────────────────────────────────── */}}
+ {{if .GPSReceivers}}
+
+
+
+
+
+
+ | Name |
+ Manufacturer |
+ Model |
+ HW Rev |
+
+
+
+ {{range .GPSReceivers}}
+
+ | {{.Name}} |
+ {{if .Manufacturer}}{{.Manufacturer}}{{else}}—{{end}} |
+ {{if .ModelName}}{{.ModelName}}{{else}}—{{end}} |
+ {{if .HardwareRev}}{{.HardwareRev}}{{else}}—{{end}} |
+
+ {{end}}
+
+
+
+
+ {{end}}
+
+ {{/* ── Sensors ──────────────────────────────────────────────────────── */}}
+ {{if .SensorGroups}}
+
+
+
+
+ {{range $g := .SensorGroups}}
+ {{if $g.Parent}}| {{$g.Parent}} |
{{end}}
+ {{range $g.Sensors}}
+
+ | {{.Name}} |
+ {{.Value}} |
+
+ {{if eq .OperStatus "ok"}}
+
+ {{else if eq .OperStatus ""}}
+ —
+ {{else}}
+ {{.OperStatus}}
+ {{end}}
+ |
+
+ {{end}}
+ {{end}}
+
+
+
+ {{end}}
+
+ {{/* ── Other Components ─────────────────────────────────────────────── */}}
+ {{if .OtherComps}}
+
+
+
+
+
+
+ | Name |
+ Class |
+ Parent |
+ Manufacturer |
+ Model |
+ Serial |
+
+
+
+ {{range .OtherComps}}
+
+ | {{.Name}} |
+ {{.Class}} |
+ {{if .Parent}}{{.Parent}}{{else}}—{{end}} |
+ {{if .Manufacturer}}{{.Manufacturer}}{{else}}—{{end}} |
+ {{if .ModelName}}{{.ModelName}}{{else}}—{{end}} |
+ {{if .SerialNum}}{{.SerialNum}}{{else}}—{{end}} |
+
+ {{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}}