From 4f76e1de21d91d8f42c51064e976a4c100b2660a Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Thu, 16 Apr 2026 06:54:13 +0300 Subject: [PATCH] Dashboard: per-device status chips with hover tooltips Replace single aggregated badge per hardware category with individual colored chips (O/W/F/?) for each ComponentStatusRecord. Added helper functions: matchedRecords, firstNonEmpty. CSS classes: chip-ok/warn/fail/unknown. Co-Authored-By: Claude Sonnet 4.6 --- audit/internal/webui/pages.go | 104 ++++++++++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 12 deletions(-) diff --git a/audit/internal/webui/pages.go b/audit/internal/webui/pages.go index 740f036..1b6ab2b 100644 --- a/audit/internal/webui/pages.go +++ b/audit/internal/webui/pages.go @@ -72,6 +72,13 @@ tbody tr:hover td{background:rgba(0,0,0,.03)} .badge-warn{background:var(--warn-bg);color:var(--warn-fg);border:1px solid #c9ba9b} .badge-err{background:var(--crit-bg);color:var(--crit-fg);border:1px solid var(--crit-border)} .badge-unknown{background:var(--surface-2);color:var(--muted);border:1px solid var(--border)} +/* Component chips — one small square per device */ +.chips{display:inline-flex;flex-wrap:wrap;gap:3px;align-items:center;vertical-align:middle} +.chip{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:3px;font-size:10px;font-weight:800;cursor:default;font-family:monospace;letter-spacing:0;user-select:none} +.chip-ok{background:var(--ok-bg);color:var(--ok-fg);border:1px solid #a3c293} +.chip-warn{background:var(--warn-bg);color:var(--warn-fg);border:1px solid #c9ba9b} +.chip-fail{background:var(--crit-bg);color:var(--crit-fg);border:1px solid var(--crit-border)} +.chip-unknown{background:var(--surface-2);color:var(--muted);border:1px solid var(--border)} /* Output terminal */ .terminal{background:#1b1c1d;border:1px solid rgba(0,0,0,.2);border-radius:4px;padding:14px;font-family:monospace;font-size:12px;color:#b5cea8;max-height:400px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;user-select:text;-webkit-user-select:text} .terminal-wrap{position:relative}.terminal-copy{position:absolute;top:6px;right:6px;background:#2d2f30;border:1px solid #444;color:#aaa;font-size:11px;padding:2px 8px;border-radius:3px;cursor:pointer;opacity:.7}.terminal-copy:hover{opacity:1} @@ -363,23 +370,25 @@ func renderHardwareSummaryCard(opts HandlerOptions) string { html.EscapeString(label), html.EscapeString(value), badgeHTML)) } - cpuRow := aggregateComponentStatus("CPU", records, []string{"cpu:all"}, nil) - writeRow("CPU", hwDescribeCPU(hw), runtimeStatusBadge(cpuRow.Status)) + writeRow("CPU", hwDescribeCPU(hw), + renderComponentChips(matchedRecords(records, []string{"cpu:all"}, nil))) - memRow := aggregateComponentStatus("Memory", records, []string{"memory:all"}, []string{"memory:"}) - writeRow("Memory", hwDescribeMemory(hw), runtimeStatusBadge(memRow.Status)) + writeRow("Memory", hwDescribeMemory(hw), + renderComponentChips(matchedRecords(records, []string{"memory:all"}, []string{"memory:"}))) - storageRow := aggregateComponentStatus("Storage", records, []string{"storage:all"}, []string{"storage:"}) - writeRow("Storage", hwDescribeStorage(hw), runtimeStatusBadge(storageRow.Status)) + writeRow("Storage", hwDescribeStorage(hw), + renderComponentChips(matchedRecords(records, []string{"storage:all"}, []string{"storage:"}))) - gpuRow := aggregateComponentStatus("GPU", records, nil, []string{"pcie:gpu:"}) - writeRow("GPU", hwDescribeGPU(hw), runtimeStatusBadge(gpuRow.Status)) + writeRow("GPU", hwDescribeGPU(hw), + renderComponentChips(matchedRecords(records, nil, []string{"pcie:gpu:"}))) - psuRow := aggregateComponentStatus("PSU", records, nil, []string{"psu:"}) - if psuRow.Status == "UNKNOWN" && len(hw.PowerSupplies) > 0 { - psuRow.Status = hwPSUStatus(hw.PowerSupplies) + psuMatched := matchedRecords(records, nil, []string{"psu:"}) + if len(psuMatched) == 0 && len(hw.PowerSupplies) > 0 { + // No PSU records yet — synthesise a single chip from IPMI status. + psuStatus := hwPSUStatus(hw.PowerSupplies) + psuMatched = []app.ComponentStatusRecord{{ComponentKey: "psu:ipmi", Status: psuStatus}} } - writeRow("PSU", hwDescribePSU(hw), runtimeStatusBadge(psuRow.Status)) + writeRow("PSU", hwDescribePSU(hw), renderComponentChips(psuMatched)) if nicDesc := hwDescribeNIC(hw); nicDesc != "" { writeRow("Network", nicDesc, "") @@ -892,6 +901,31 @@ func buildHardwareComponentRows(exportDir string) []runtimeHealthRow { } } +// matchedRecords returns all ComponentStatusRecord entries whose key matches +// any exact key or any of the given prefixes. Used for per-device chip rendering. +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +func matchedRecords(records []app.ComponentStatusRecord, exact []string, prefixes []string) []app.ComponentStatusRecord { + var matched []app.ComponentStatusRecord + for _, rec := range records { + key := strings.TrimSpace(rec.ComponentKey) + if key == "" { + continue + } + if containsExactKey(key, exact) || hasAnyPrefix(key, prefixes) { + matched = append(matched, rec) + } + } + return matched +} + func aggregateComponentStatus(title string, records []app.ComponentStatusRecord, exact []string, prefixes []string) runtimeHealthRow { matched := make([]app.ComponentStatusRecord, 0) for _, rec := range records { @@ -1034,6 +1068,52 @@ func runtimeIssueDescriptions(issues []schema.RuntimeIssue, codes ...string) str return strings.Join(messages, "; ") } +// chipLetterClass maps a component status to a single display letter and CSS class. +func chipLetterClass(status string) (letter, cls string) { + switch strings.ToUpper(strings.TrimSpace(status)) { + case "OK": + return "O", "chip-ok" + case "WARNING", "WARN", "PARTIAL": + return "W", "chip-warn" + case "CRITICAL", "FAIL", "FAILED", "ERROR": + return "F", "chip-fail" + default: + return "?", "chip-unknown" + } +} + +// renderComponentChips renders one 20×20 chip per ComponentStatusRecord. +// Hover tooltip shows component key, status, error summary and last check time. +// Falls back to a single unknown chip when no records are available. +func renderComponentChips(matched []app.ComponentStatusRecord) string { + if len(matched) == 0 { + return `?` + } + sort.Slice(matched, func(i, j int) bool { + return matched[i].ComponentKey < matched[j].ComponentKey + }) + var b strings.Builder + b.WriteString(``) + for _, rec := range matched { + letter, cls := chipLetterClass(rec.Status) + var tooltip strings.Builder + tooltip.WriteString(rec.ComponentKey) + tooltip.WriteString(": ") + tooltip.WriteString(firstNonEmpty(rec.Status, "UNKNOWN")) + if rec.ErrorSummary != "" { + tooltip.WriteString(" — ") + tooltip.WriteString(rec.ErrorSummary) + } + if !rec.LastCheckedAt.IsZero() { + fmt.Fprintf(&tooltip, " (checked %s)", rec.LastCheckedAt.Format("15:04:05")) + } + fmt.Fprintf(&b, `%s`, + cls, html.EscapeString(tooltip.String()), letter) + } + b.WriteString(``) + return b.String() +} + func runtimeStatusBadge(status string) string { status = strings.ToUpper(strings.TrimSpace(status)) badge := "badge-unknown"