mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
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:
@@ -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 })
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
@@ -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… 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 →</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">—</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}} · {{$c.Band}}{{end}}
|
||||
{{else if $c.IsGPS}}
|
||||
<span class="status-dot status-up"></span>configured
|
||||
{{else}}<span class="text-muted">—</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}}
|
||||
@@ -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 →</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">—</span>{{end}}</td>
|
||||
<td>{{if .Description}}{{.Description}}{{else}}<span class="text-muted">—</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 →</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">—</span>{{end}}</td>
|
||||
<td>{{if .Bands}}{{.Bands}}{{else}}<span class="text-muted">—</span>{{end}}</td>
|
||||
<td>{{if .Standards}}{{.Standards}}{{else}}<span class="text-muted">—</span>{{end}}</td>
|
||||
<td class="num">{{if .MaxAP}}{{.MaxAP}}{{else}}<span class="text-muted">—</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">—</span>{{end}}</td>
|
||||
<td>{{if .ModelName}}{{.ModelName}}{{else}}<span class="text-muted">—</span>{{end}}</td>
|
||||
<td>{{if .HardwareRev}}{{.HardwareRev}}{{else}}<span class="text-muted">—</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">—</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">—</span>{{end}}</td>
|
||||
<td>{{if .Manufacturer}}{{.Manufacturer}}{{else}}<span class="text-muted">—</span>{{end}}</td>
|
||||
<td>{{if .ModelName}}{{.ModelName}}{{else}}<span class="text-muted">—</span>{{end}}</td>
|
||||
<td>{{if .SerialNum}}{{.SerialNum}}{{else}}<span class="text-muted">—</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}}
|
||||
Reference in New Issue
Block a user