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}}| Hostname | {{.Hostname}} |
{{end}}
+ {{if .Contact}}| Contact | {{.Contact}} |
{{end}}
+ {{if .Location}}| Location | {{.Location}} |
{{end}}
{{if .OSName}}| OS | {{.OSName}} {{.OSVersion}} |
{{end}}
{{if .Machine}}| Architecture | {{.Machine}} |
{{end}}
- {{if .Firmware}}| Firmware | {{.Firmware}} |
{{end}}
- {{if .Uptime}}| Uptime | {{.Uptime}} |
{{end}}
+ {{if .Firmware}}| Boot Partition | {{.Firmware}} |
{{end}}
-
+
+ {{if .Uptime}}| Uptime | {{.Uptime}} |
{{end}}
+ {{if .CurrentTime}}| Current Time | {{.CurrentTime}} |
{{end}}
{{if .MemTotal}}
| Memory |
@@ -39,9 +42,7 @@
{{if .Load1}}
| Load Average |
-
- {{.Load1}} · {{.Load5}} · {{.Load15}}
- |
+ {{.Load1}} · {{.Load5}} · {{.Load15}} |
{{end}}
@@ -50,54 +51,55 @@
{{if .Board.Model}}
-
+
- {{if .Board.Model}}| Model | {{.Board.Model}} |
{{end}}
+ | 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}}
- {{if .Sensors}}
- {{range .Sensors}}
- | {{.Name}} | {{.Value}} |
- {{end}}
+
+
+
+ {{end}}
+
+
+ {{if .KeyVitals}}
+
+
+
+
+ {{range .KeyVitals}}
+ | {{.Name}} | {{.Value}} |
{{end}}
{{end}}
-
-{{if .Disks}}
-
-
-
-
-
-
-
- | Mount |
- Size |
- Used |
- Available |
- Use% |
-
-
-
- {{range .Disks}}
-
- | {{.Mount}} |
- {{.Size}} |
- {{.Used}} |
- {{.Available}} |
- {{.Percent}}% |
-
- {{end}}
-
-
+ {{if .Disks}}
+
+
+ {{range .Disks}}
+
+
+
+
{{.Available}} free of {{.Size}}
+ {{end}}
+ {{end}}
{{end}}
-{{end}}