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"