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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<span class="chips"><span class="chip chip-unknown" title="No data">?</span></span>`
|
||||
}
|
||||
sort.Slice(matched, func(i, j int) bool {
|
||||
return matched[i].ComponentKey < matched[j].ComponentKey
|
||||
})
|
||||
var b strings.Builder
|
||||
b.WriteString(`<span class="chips">`)
|
||||
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, `<span class="chip %s" title="%s">%s</span>`,
|
||||
cls, html.EscapeString(tooltip.String()), letter)
|
||||
}
|
||||
b.WriteString(`</span>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func runtimeStatusBadge(status string) string {
|
||||
status = strings.ToUpper(strings.TrimSpace(status))
|
||||
badge := "badge-unknown"
|
||||
|
||||
Reference in New Issue
Block a user