diff --git a/src/webui/internal/handlers/dashboard.go b/src/webui/internal/handlers/dashboard.go index f11a4457..0660cd56 100644 --- a/src/webui/internal/handlers/dashboard.go +++ b/src/webui/internal/handlers/dashboard.go @@ -145,6 +145,18 @@ type filesystemFS struct { // RESTCONF JSON structures for ietf-hardware:hardware. +// Short forms of hardware-class identities — see shortClass(). Kept here so +// the dashboard and Status > Hardware handlers route the same way. +const ( + classChassis = "chassis" // ietf-hardware:chassis + classUSB = "usb" // infix-hardware:usb + classWiFi = "wifi" // infix-hardware:wifi + classGPS = "gps" // infix-hardware:gps +) + +// sensorStatusOK matches the ietf-hardware sensor-status "ok" enum value. +const sensorStatusOK = "ok" + type hardwareWrapper struct { Hardware struct { Component []hwComponentJSON `json:"component"` @@ -161,7 +173,7 @@ type hwComponentJSON struct { SerialNum string `json:"serial-num"` HardwareRev string `json:"hardware-rev"` PhysAddress string `json:"infix-hardware:phys-address"` - WiFiRadio *wifiRadioJSON `json:"infix-hardware:wifi-radio"` + WiFiRadio *wifiRadioHWJSON `json:"infix-hardware:wifi-radio"` SensorData *struct { ValueType string `json:"value-type"` Value yangInt64 `json:"value"` @@ -178,10 +190,13 @@ type hwComponentJSON struct { type dashboardData struct { PageData - Hostname string + Hostname string + Contact string + Location string OSName string OSVersion string Machine string + CurrentTime string Firmware string Uptime string MemTotal int64 @@ -194,7 +209,7 @@ type dashboardData struct { CPUClass string Disks []diskEntry Board boardInfo - Sensors []sensorEntry + KeyVitals []sensorEntry // Overview's at-a-glance subset: CPU/SoC + wifi-radio temperatures and fan RPMs. Status > Hardware has the full inventory. Error string } @@ -212,12 +227,20 @@ type sensorEntry struct { Type string // "temperature", "fan", "voltage", etc. } +type wifiEntry struct { + Name string + Manufacturer string + Bands string // all supported bands, e.g. "2.4 GHz, 5 GHz" + Standards string + MaxAP int +} + type diskEntry struct { Mount string Size string - Used string Available string Percent int + Class string // "" / "is-warn" / "is-crit" } // DashboardHandler serves the main dashboard page. @@ -229,7 +252,7 @@ type DashboardHandler struct { // Index renders the dashboard (GET /). func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { data := dashboardData{ - PageData: newPageData(r, "dashboard", "Dashboard"), + PageData: newPageData(r, "dashboard", "Overview"), } // Detach from the request context so that RESTCONF calls survive @@ -242,6 +265,8 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { sysConf struct { System struct { Hostname string `json:"hostname"` + Contact string `json:"contact"` + Location string `json:"location"` } `json:"ietf-system:system"` } stateErr, hwErr, confErr error @@ -276,6 +301,7 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { } data.Firmware = firmwareVersion(ss.Software) data.Uptime = computeUptime(ss.Clock.BootDatetime, ss.Clock.CurrentDatetime) + data.CurrentTime = formatCurrentTime(ss.Clock.CurrentDatetime) total := int64(ss.Resource.Memory.Total) avail := int64(ss.Resource.Memory.Available) @@ -312,12 +338,19 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { if size > 0 { pct = int(float64(used) / float64(size) * 100) } + diskClass := "" + switch { + case pct >= 90: + diskClass = "is-crit" + case pct >= 70: + diskClass = "is-warn" + } data.Disks = append(data.Disks, diskEntry{ Mount: fs.MountPoint, Size: humanKiB(size), - Used: humanKiB(used), Available: humanKiB(int64(fs.Available)), Percent: pct, + Class: diskClass, }) } } @@ -325,9 +358,15 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { if hwErr != nil { log.Printf("restconf hardware: %v", hwErr) } else { + // Two passes: first build a name → class map so keyVital can tell + // whether a celsius sensor lives on a wifi component (and thus + // belongs in Key Vitals). + classByName := make(map[string]string, len(hw.Hardware.Component)) for _, c := range hw.Hardware.Component { - class := shortClass(c.Class) - if class == "chassis" { + classByName[c.Name] = shortClass(c.Class) + } + for _, c := range hw.Hardware.Component { + if shortClass(c.Class) == classChassis { data.Board = boardInfo{ Model: c.ModelName, Manufacturer: c.MfgName, @@ -336,12 +375,8 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { BaseMAC: c.PhysAddress, } } - if c.SensorData != nil && c.SensorData.OperStatus == "ok" { - data.Sensors = append(data.Sensors, sensorEntry{ - Name: c.Name, - Value: formatSensor(c.SensorData.ValueType, int64(c.SensorData.Value), c.SensorData.ValueScale), - Type: c.SensorData.ValueType, - }) + if v, ok := keyVital(c, classByName); ok { + data.KeyVitals = append(data.KeyVitals, v) } } } @@ -350,6 +385,8 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { log.Printf("restconf system config: %v", confErr) } else { data.Hostname = sysConf.System.Hostname + data.Contact = sysConf.System.Contact + data.Location = sysConf.System.Location } tmplName := "dashboard.html" @@ -398,6 +435,102 @@ func computeUptime(boot, now string) string { } } +// formatCurrentTime formats an RFC3339 timestamp as "2006-01-02 15:04:05 +00:00". +func formatCurrentTime(s string) string { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return "" + } + return t.UTC().Format("2006-01-02 15:04:05 +00:00") +} + +// keyVital picks the dashboard's "Key Vitals" rows out of the hardware +// component stream — the small at-a-glance subset for the Overview page: +// CPU/SoC/core temperatures, wifi-radio temperatures, SFP temperatures, +// and any fan RPM. Everything else (board temps, voltages, per-port +// sensors, …) lives on Status > Hardware. classByName maps every +// component's Name → short class so we can identify a celsius sensor's +// parent without a second scan per call. +func keyVital(c hwComponentJSON, classByName map[string]string) (sensorEntry, bool) { + if c.SensorData == nil || c.SensorData.OperStatus != sensorStatusOK { + return sensorEntry{}, false + } + switch c.SensorData.ValueType { + case "celsius": + switch { + case c.Name == "cpu", c.Name == "soc", c.Name == "core": + // CPU / SoC / core temperatures. + case classByName[c.Parent] == classWiFi: + // WiFi radio temperatures whose sensor-data lives under + // the radio component as a child. + case strings.HasPrefix(c.Name, "radio"), + strings.HasPrefix(c.Name, "phy"): + // WiFi radio temperatures whose sensor-data is a + // standalone iana-hardware:sensor (yanger labels these + // radio0, phy0, … per its normaliser). The parent-class + // branch above won't catch them. + case strings.HasPrefix(c.Name, "sfp"): + // SFP module temperatures (per ietf_hardware.py + // normalisation, names canonicalise to sfp0, sfp1, ...). + default: + return sensorEntry{}, false + } + case "rpm": + // every fan qualifies + default: + return sensorEntry{}, false + } + return sensorEntry{ + Name: c.Name, + Value: formatSensor(c.SensorData.ValueType, int64(c.SensorData.Value), c.SensorData.ValueScale), + Type: c.SensorData.ValueType, + }, true +} + +// summarizeWiFiRadio collapses an ietf-hardware component's wifi-radio +// container into the row-shaped wifiEntry used by both the dashboard and +// the Status > Hardware page. Detail beyond this summary (per-channel, +// per-band capabilities) lives on the dedicated /wifi page. +func summarizeWiFiRadio(c hwComponentJSON) wifiEntry { + e := wifiEntry{Name: c.Name, Manufacturer: c.MfgName} + if c.WiFiRadio == nil { + return e + } + var bandNames []string + var ht, vht, he bool + for _, b := range c.WiFiRadio.Bands { + ht = ht || b.HTCapable + vht = vht || b.VHTCapable + he = he || b.HECapable + name := b.Name + if name == "" { + name = b.Band + } + if name != "" { + bandNames = append(bandNames, name) + } + } + if len(bandNames) == 0 && c.WiFiRadio.Band != "" { + bandNames = append(bandNames, c.WiFiRadio.Band) + } + e.Bands = strings.Join(bandNames, ", ") + var stds []string + if ht { + stds = append(stds, "11n") + } + if vht { + stds = append(stds, "11ac") + } + if he { + stds = append(stds, "11ax") + } + e.Standards = strings.Join(stds, "/") + if c.WiFiRadio.MaxInterfaces != nil { + e.MaxAP = c.WiFiRadio.MaxInterfaces.AP + } + return e +} + // shortClass strips the YANG module prefix from a hardware class identity. func shortClass(full string) string { if i := strings.LastIndex(full, ":"); i >= 0 { @@ -447,17 +580,17 @@ func humanBytes(b int64) string { return fmt.Sprintf("%.1f PiB", v) } -// humanKiB converts KiB to a human-readable string (K, M, G, T). +// humanKiB converts KiB to a human-readable string using binary (IEC) units. func humanKiB(kib int64) string { v := float64(kib) - for _, unit := range []string{"K", "M", "G", "T"} { - if v < 1024 || unit == "T" { + for _, unit := range []string{"KiB", "MiB", "GiB", "TiB"} { + if v < 1024 || unit == "TiB" { if v == math.Trunc(v) { - return fmt.Sprintf("%.0f%s", v, unit) + return fmt.Sprintf("%.0f %s", v, unit) } - return fmt.Sprintf("%.1f%s", v, unit) + return fmt.Sprintf("%.1f %s", v, unit) } v /= 1024 } - return fmt.Sprintf("%.1fP", v) + return fmt.Sprintf("%.1f PiB", v) } diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 15909695..77d9da5b 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -405,9 +405,21 @@ details[open].nav-group-top > summary.nav-group-summary-top::after { .info-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; - margin-bottom: 2rem; + margin-bottom: 1.5rem; + align-items: stretch; +} + +/* Cards inside a grid don't need their own bottom margin — the grid gap handles spacing */ +.info-grid > .info-card { margin-bottom: 0; } + +/* Full-width card spanning all grid columns */ +.info-grid-full { grid-column: 1 / -1; } + +/* Two-column card for wide tables — only kicks in once the grid has ≥2 columns */ +@media (min-width: 680px) { + .info-grid-span-2 { grid-column: span 2; } } .info-card { @@ -449,6 +461,7 @@ details[open].nav-group-top > summary.nav-group-summary-top::after { padding: 1.25rem; } + /* ========================================================================== Tables ========================================================================== */ @@ -466,6 +479,60 @@ details[open].nav-group-top > summary.nav-group-summary-top::after { border-bottom: 1px solid var(--border); } +.info-table th { + width: 1%; + white-space: nowrap; + color: var(--fg-muted); + font-weight: 500; + text-align: left; +} + +/* Disk usage widget (one entry per mount point) */ +.disk-item { + padding: 0.7rem 1.25rem; + border-bottom: 1px solid var(--border); +} +.disk-item:last-child { border-bottom: none; } +.disk-item-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 0.4rem; +} +.disk-mount { + font-family: var(--font-mono); + font-size: 0.85rem; + font-weight: 500; + color: var(--fg); +} +.disk-pct { font-size: 0.8rem; color: var(--fg-muted); } +.disk-stats { + font-size: 0.78rem; + color: var(--fg-muted); + margin-top: 0.3rem; + font-variant-numeric: tabular-nums; +} + +.sensor-group-hdr th { + padding: 0.35rem 1.25rem 0.2rem; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fg-muted); + background: var(--bg); + border-bottom: none; +} +.sensor-child th { + padding-left: 2rem; +} + +.data-table th.num, +.data-table td.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + .info-table tr:last-child th, .info-table tr:last-child td, .disk-table tbody tr:last-child td, .counters-table tbody tr:last-child td { @@ -504,6 +571,22 @@ details[open].nav-group-top > summary.nav-group-summary-top::after { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); + /* Scroll-shadow: a right-edge shadow appears automatically when the table + overflows, fading away once scrolled to the end. Pure CSS, no JS needed. + The `local` gradients act as white masks pinned to the scroll content; + the `scroll` gradients are the actual shadows fixed to the viewport edge. */ + background: + linear-gradient(to right, var(--surface), var(--surface)) left / 24px 100% no-repeat local, + linear-gradient(to left, var(--surface), var(--surface)) right / 24px 100% no-repeat local, + linear-gradient(to right, rgba(0,0,0,0.07), transparent) left / 8px 100% no-repeat scroll, + linear-gradient(to left, rgba(0,0,0,0.07), transparent) right / 8px 100% no-repeat scroll; + background-color: var(--surface); +} + +/* Remove the nested border when a table lives inside a card — the card already provides the frame */ +.info-card .data-table-wrap { + border: none; + border-radius: 0; } .data-table { @@ -531,9 +614,11 @@ details[open].nav-group-top > summary.nav-group-summary-top::after { background-color: var(--zebra-stripe, rgba(0,0,0,0.025)); } -.data-table tbody tr:hover { - background-color: rgba(0,0,0,0.05); +.data-table tbody tr:hover { background-color: var(--slate-100); } +@media (prefers-color-scheme: dark) { + .data-table tbody tr:hover { background-color: rgba(255,255,255,0.06); } } +.dark .data-table tbody tr:hover { background-color: rgba(255,255,255,0.06); } /* ========================================================================== Forms & Inputs diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index ee8cf7e1..300b380d 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -432,31 +432,6 @@ }); })(); -// Card collapsible — "Show more / Show less" toggle injected when content overflows -(function() { - function initCollapsibles() { - document.querySelectorAll('.card-collapsible:not([data-ci])').forEach(function(card) { - card.setAttribute('data-ci', '1'); - var body = card.querySelector('.card-collapsible-body'); - if (!body || body.scrollHeight <= body.clientHeight + 4) return; - - var btn = document.createElement('button'); - btn.className = 'card-expand-btn'; - btn.type = 'button'; - btn.textContent = 'Show more \u25be'; - btn.style.display = 'block'; - card.appendChild(btn); - - btn.addEventListener('click', function() { - var expanded = card.classList.toggle('is-expanded'); - btn.textContent = expanded ? 'Show less \u25b4' : 'Show more \u25be'; - }); - }); - } - document.addEventListener('DOMContentLoaded', initCollapsibles); - document.addEventListener('htmx:afterSettle', initCollapsibles); -})(); - // Sidebar toggle (mobile) (function() { var MOBILE_BP = 1024; diff --git a/src/webui/templates/pages/dashboard.html b/src/webui/templates/pages/dashboard.html index 3a68b52b..09e89142 100644 --- a/src/webui/templates/pages/dashboard.html +++ b/src/webui/templates/pages/dashboard.html @@ -13,18 +13,21 @@
{{if .Hostname}}{{end}} + {{if .Contact}}{{end}} + {{if .Location}}{{end}} {{if .OSName}}{{end}} {{if .Machine}}{{end}} - {{if .Firmware}}{{end}} - {{if .Uptime}}{{end}} + {{if .Firmware}}{{end}}
Hostname{{.Hostname}}
Contact{{.Contact}}
Location{{.Location}}
OS{{.OSName}} {{.OSVersion}}
Architecture{{.Machine}}
Firmware{{.Firmware}}
Uptime{{.Uptime}}
Boot Partition{{.Firmware}}
-
Resources
+
Runtime
+ {{if .Uptime}}{{end}} + {{if .CurrentTime}}{{end}} {{if .MemTotal}} @@ -39,9 +42,7 @@ {{if .Load1}} - + {{end}}
Uptime{{.Uptime}}
Current Time{{.CurrentTime}}
Memory
Load Average - {{.Load1}} · {{.Load5}} · {{.Load15}} - {{.Load1}} · {{.Load5}} · {{.Load15}}
@@ -50,54 +51,55 @@ {{if .Board.Model}}
-
Hardware
+
Board
- {{if .Board.Model}}{{end}} + {{if .Board.Manufacturer}}{{end}} {{if .Board.SerialNum}}{{end}} {{if .Board.HardwareRev}}{{end}} {{if .Board.BaseMAC}}{{end}} - {{if .Sensors}} - {{range .Sensors}} - - {{end}} +
Model{{.Board.Model}}
Model{{.Board.Model}}
Manufacturer{{.Board.Manufacturer}}
Serial Number{{.Board.SerialNum}}
Hardware Rev{{.Board.HardwareRev}}
Base MAC{{.Board.BaseMAC}}
{{.Name}}{{.Value}}
+
+
+ {{end}} + + + {{if .KeyVitals}} +
+
Key Vitals + All sensors → +
+
+ + {{range .KeyVitals}} + {{end}}
{{.Name}}{{.Value}}
{{end}} - -{{if .Disks}} -
-
Disk Usage
-
-
- - - - - - - - - - - - {{range .Disks}} - - - - - - - - {{end}} - -
MountSizeUsedAvailableUse%
{{.Mount}}{{.Size}}{{.Used}}{{.Available}}{{.Percent}}%
+ {{if .Disks}} +
+
Disk Usage
+ {{range .Disks}} +
+
+ {{.Mount}} + {{.Percent}}% +
+
+
+
+
{{.Available}} free of {{.Size}}
+ {{end}}
+ {{end}}
{{end}} -{{end}}