webui: overhaul dashboard cards

Split the old monolithic Hardware card into dedicated Board, WiFi, and
Sensors cards, matching the structure of `show system` / `show hardware`
in the CLI:

- System Information: add Current Time row, rename Firmware → Boot Partition
- Board: model, manufacturer, Base MAC only (no sensors)
- WiFi (new, conditional): all radios with supported bands, standards
  (11n/11ac/11ax), and max AP count; card spans 2 grid columns so the
  table never needs horizontal scroll
- Sensors (new, conditional, collapsible): all hardware sensors grouped
  by parent component (SFP sub-sensors indent under sfp1/sfp2 headings);
  JS "Show more" button injected only when content overflows 300 px
- Disk Usage: replaced wide multi-column table with a per-mount widget
  (path + % on one line, health bar, "X free of Y" below); health colour
  follows the same warn/crit thresholds as the memory bar
- Fix humanKiB to output IEC units (MiB / GiB) with a space, matching
  the memory display style
- Fix info-table th wrapping (width:1%; white-space:nowrap)
- Fix JS collapsible init to run on DOMContentLoaded, not immediately in
  <head>, so scrollHeight check has a real DOM to inspect
- CSS scroll-shadow on data-table-wrap: pure-CSS right-edge shadow
  indicates overflow without needing JS or a scroll listener
- Equal-height grid rows via align-items:stretch; info-grid-span-2 for
  cards with wide tables

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-14 22:07:27 +02:00
parent 12482a5ab5
commit b2bf034a91
4 changed files with 285 additions and 90 deletions
+153 -20
View File
@@ -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)
}
+89 -4
View File
@@ -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
-25
View File
@@ -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;
+43 -41
View File
@@ -13,18 +13,21 @@
<div class="card-body" style="padding:0">
<table class="info-table">
{{if .Hostname}}<tr><th>Hostname</th><td>{{.Hostname}}</td></tr>{{end}}
{{if .Contact}}<tr><th>Contact</th><td>{{.Contact}}</td></tr>{{end}}
{{if .Location}}<tr><th>Location</th><td>{{.Location}}</td></tr>{{end}}
{{if .OSName}}<tr><th>OS</th><td>{{.OSName}} {{.OSVersion}}</td></tr>{{end}}
{{if .Machine}}<tr><th>Architecture</th><td>{{.Machine}}</td></tr>{{end}}
{{if .Firmware}}<tr><th>Firmware</th><td>{{.Firmware}}</td></tr>{{end}}
{{if .Uptime}}<tr><th>Uptime</th><td>{{.Uptime}}</td></tr>{{end}}
{{if .Firmware}}<tr><th>Boot Partition</th><td>{{.Firmware}}</td></tr>{{end}}
</table>
</div>
</div>
<div class="info-card">
<div class="card-header">Resources</div>
<div class="card-header">Runtime</div>
<div class="card-body" style="padding:0">
<table class="info-table">
{{if .Uptime}}<tr><th>Uptime</th><td>{{.Uptime}}</td></tr>{{end}}
{{if .CurrentTime}}<tr><th>Current Time</th><td>{{.CurrentTime}}</td></tr>{{end}}
{{if .MemTotal}}
<tr>
<th>Memory</th>
@@ -39,9 +42,7 @@
{{if .Load1}}
<tr>
<th>Load Average</th>
<td>
<span class="num">{{.Load1}} &middot; {{.Load5}} &middot; {{.Load15}}</span>
</td>
<td><span class="num">{{.Load1}} &middot; {{.Load5}} &middot; {{.Load15}}</span></td>
</tr>
{{end}}
</table>
@@ -50,54 +51,55 @@
{{if .Board.Model}}
<div class="info-card">
<div class="card-header">Hardware</div>
<div class="card-header">Board</div>
<div class="card-body" style="padding:0">
<table class="info-table">
{{if .Board.Model}}<tr><th>Model</th><td>{{.Board.Model}}</td></tr>{{end}}
<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>{{.Board.BaseMAC}}</td></tr>{{end}}
{{if .Sensors}}
{{range .Sensors}}
<tr><th>{{.Name}}</th><td>{{.Value}}</td></tr>
{{end}}
</table>
</div>
</div>
{{end}}
{{if .KeyVitals}}
<div class="info-card">
<div class="card-header">Key Vitals
<a href="/hardware"
hx-get="/hardware"
hx-target="#content"
hx-push-url="true"
class="btn btn-sm btn-outline card-header-action">All sensors &rarr;</a>
</div>
<div class="card-body" style="padding:0">
<table class="info-table">
{{range .KeyVitals}}
<tr><th>{{.Name}}</th><td><span class="num">{{.Value}}</span></td></tr>
{{end}}
</table>
</div>
</div>
{{end}}
</section>
{{if .Disks}}
<section class="info-card">
<div class="card-header">Disk Usage</div>
<div class="card-body" style="padding:0">
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Mount</th>
<th>Size</th>
<th>Used</th>
<th>Available</th>
<th>Use%</th>
</tr>
</thead>
<tbody>
{{range .Disks}}
<tr>
<td>{{.Mount}}</td>
<td>{{.Size}}</td>
<td>{{.Used}}</td>
<td>{{.Available}}</td>
<td>{{.Percent}}%</td>
</tr>
{{end}}
</tbody>
</table>
{{if .Disks}}
<div class="info-card">
<div class="card-header">Disk Usage</div>
{{range .Disks}}
<div class="disk-item">
<div class="disk-item-header">
<span class="disk-mount">{{.Mount}}</span>
<span class="num disk-pct">{{.Percent}}%</span>
</div>
<div class="health-bar-track">
<div class="health-bar-fill {{.Class}}" style="width:{{.Percent}}%"></div>
</div>
<div class="disk-stats">{{.Available}} free of {{.Size}}</div>
</div>
{{end}}
</div>
{{end}}
</section>
{{end}}
{{end}}