mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-31 04:53:01 +02:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}} · {{.Load5}} · {{.Load15}}</span>
|
||||
</td>
|
||||
<td><span class="num">{{.Load1}} · {{.Load5}} · {{.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 →</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}}
|
||||
|
||||
Reference in New Issue
Block a user