Investigating the CG480-S6053 reboot needed a way to run an ad-hoc load
(nvbandwidth across a specific GPU set) while sampling IPMI/nvidia-smi
telemetry in the background — without hardcoding a one-off test into the
SAT pack code for a single investigation.
- audit/internal/platform/scenario.go: ScenarioSpec/ScenarioJob (JSON,
no new dependency) + System.RunScenario. "command" jobs run sequential
or parallel (per-job "parallel" flag); "sampler" jobs run concurrently
in the background on their own interval until every command job
finishes or the scenario's timeout elapses. "{{gpus}}" in a command's
cmd is substituted from that job's gpu_indices. Command jobs are wired
through the same satJobBoundaryHook/satSyncBracketHook seams the SAT
job runner uses, so a scenario run gets the same durability treatment
(evidence that a risky command started/finished reaches blackbox before
a possible crash, not just whatever streamed to the RAM-backed export
dir).
- export.go: ReadScenarioFromRemovableMedia mounts each removable target
looking for scenarios/<name>.json — an air-gapped engineer can author a
scenario elsewhere, drop it under scenarios/ on the same USB stick
already plugged in for blackbox, and run it with no network path onto
the host.
- cmd/bee: new `bee run <file.json|name>` (bare name = looked up on
removable media); `bee scenario run <arg>` kept as a longer alias.
- scenarios/nvbandwidth-all-gpu-power-watch.json: the scenario that
reproduced the actual reboot (full nvbandwidth across all GPUs, which
crashed, vs. clean per-socket passes), with IPMI sensor + GPU power/temp
sampling for a power-delivery correlation check.
Also: webui/page_topo.go — the /topo page's component-status-detail modal
(GET /api/component-detail/{type}) showed "No status data recorded yet"
for any component type ComponentStatusDB has no history for yet (e.g. GPU
before a SAT run this boot), even though the topology card for the same
component already showed "N OK" from the audit inventory snapshot.
inventoryFallbackRecords now synthesizes records from that same inventory
snapshot when StatusDB is empty, using the same device classifiers
(isGPUDeviceClass etc.) and severity mapping (classifyTopoSeverity) the
topology card itself uses, so the two views never disagree.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1256 lines
40 KiB
Go
1256 lines
40 KiB
Go
package webui
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"html"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"bee/audit/internal/app"
|
||
"bee/audit/internal/schema"
|
||
)
|
||
|
||
// renderPage dispatches to the appropriate page renderer.
|
||
func renderPage(page string, opts HandlerOptions) string {
|
||
var pageID, title, body string
|
||
switch page {
|
||
case "dashboard", "":
|
||
pageID = "dashboard"
|
||
title = "Dashboard"
|
||
body = renderDashboard(opts)
|
||
case "audit":
|
||
pageID = "audit"
|
||
title = "1. Audit"
|
||
body = renderAudit()
|
||
case "check":
|
||
pageID = "check"
|
||
title = "2. Check"
|
||
body = renderCheck(opts)
|
||
case "load":
|
||
pageID = "load"
|
||
title = "3. Load"
|
||
body = renderValidateStress(opts)
|
||
case "burn":
|
||
pageID = "burn"
|
||
title = "4. Burn"
|
||
body = renderBurn()
|
||
case "benchmark":
|
||
pageID = "benchmark"
|
||
title = "5. Benchmark"
|
||
body = renderBenchmark(opts)
|
||
case "tools":
|
||
pageID = "tools"
|
||
title = "Tools"
|
||
body = renderTools()
|
||
case "topo":
|
||
pageID = "topo"
|
||
title = "Topology"
|
||
body = renderTopo(opts)
|
||
case "settings":
|
||
pageID = "settings"
|
||
title = "Settings"
|
||
body = renderSettings(opts)
|
||
// Legacy routes (redirected at HTTP level in handlePage; these are fallbacks)
|
||
case "validate", "tests":
|
||
pageID = "load"
|
||
title = "3. Load"
|
||
body = renderValidate(opts)
|
||
case "burn-in":
|
||
pageID = "burn"
|
||
title = "4. Burn"
|
||
body = renderBurn()
|
||
case "speed", "endurance":
|
||
pageID = "benchmark"
|
||
title = "5. Benchmark"
|
||
body = renderBenchmark(opts)
|
||
case "tasks":
|
||
pageID = "tasks"
|
||
title = "Tasks"
|
||
body = renderTasks()
|
||
// Hidden pages (not in nav, accessible by direct URL)
|
||
case "metrics":
|
||
pageID = "metrics"
|
||
title = "Live Metrics"
|
||
body = renderMetrics()
|
||
case "network":
|
||
pageID = "network"
|
||
title = "Network"
|
||
body = renderNetwork()
|
||
case "services":
|
||
pageID = "services"
|
||
title = "Services"
|
||
body = renderServices()
|
||
case "export":
|
||
pageID = "export"
|
||
title = "Export"
|
||
body = renderExport(opts.ExportDir)
|
||
case "install":
|
||
pageID = "install"
|
||
title = "Install to Disk"
|
||
body = renderInstall()
|
||
default:
|
||
pageID = "dashboard"
|
||
title = "Not Found"
|
||
body = `<div class="alert alert-warn">Page not found.</div>`
|
||
}
|
||
|
||
return layoutHead(opts.Title+" — "+title) +
|
||
layoutNav(pageID, opts.BuildLabel) +
|
||
`<div class="main"><div class="topbar"><h1>` + html.EscapeString(title) + `</h1></div><div class="content">` +
|
||
body +
|
||
`</div></div>` +
|
||
renderAuditModal() +
|
||
`<dialog id="component-detail-dialog" style="min-width:600px;max-width:900px;width:90vw;padding:0;border:1px solid var(--border);border-radius:8px;background:var(--surface)"><div id="component-detail-body" style="padding-bottom:20px"></div></dialog>` +
|
||
`<script>
|
||
// Add copy button to every .terminal on the page
|
||
document.querySelectorAll('.terminal').forEach(function(t){
|
||
var w=document.createElement('div');w.className='terminal-wrap';
|
||
t.parentNode.insertBefore(w,t);w.appendChild(t);
|
||
var btn=document.createElement('button');btn.className='terminal-copy';btn.textContent='Copy';
|
||
btn.onclick=function(){navigator.clipboard.writeText(t.textContent).then(function(){btn.textContent='Copied!';setTimeout(function(){btn.textContent='Copy';},1500);});};
|
||
w.appendChild(btn);
|
||
});
|
||
function openComponentDetail(type) {
|
||
var dlg = document.getElementById('component-detail-dialog');
|
||
var body = document.getElementById('component-detail-body');
|
||
body.innerHTML = '<div style="padding:20px;color:var(--muted)">Loading…</div>';
|
||
dlg.showModal();
|
||
fetch('/api/components/' + type).then(function(r){ return r.text(); }).then(function(html){
|
||
body.innerHTML = html;
|
||
}).catch(function(){
|
||
body.innerHTML = '<div style="padding:20px;color:var(--crit-fg)">Error loading details.</div>';
|
||
});
|
||
}
|
||
</script>` +
|
||
`</body></html>`
|
||
}
|
||
|
||
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
||
|
||
func renderDashboard(opts HandlerOptions) string {
|
||
var b strings.Builder
|
||
b.WriteString(renderAuditStatusBanner(opts))
|
||
b.WriteString(renderHardwareSummaryCard(opts))
|
||
b.WriteString(renderHealthCard(opts))
|
||
b.WriteString(renderMetrics())
|
||
b.WriteString(`<script>
|
||
setInterval(function(){
|
||
fetch('/api/hardware-summary').then(function(r){return r.text();}).then(function(html){
|
||
var el=document.getElementById('hw-summary-card');
|
||
if(el){el.outerHTML=html;}
|
||
}).catch(function(){});
|
||
},30000);
|
||
</script>`)
|
||
return b.String()
|
||
}
|
||
|
||
// renderAuditStatusBanner shows a live progress banner when an audit task is
|
||
// running and auto-reloads the page when it completes.
|
||
func renderAuditStatusBanner(opts HandlerOptions) string {
|
||
// If audit data already exists, no banner needed — data is fresh.
|
||
// We still inject the polling script so a newly-triggered audit also reloads.
|
||
hasData := false
|
||
if _, err := loadSnapshot(opts.AuditPath); err == nil {
|
||
hasData = true
|
||
}
|
||
_ = hasData
|
||
|
||
return `<div id="audit-banner" style="display:none" class="alert alert-warn" style="margin-bottom:16px">
|
||
<span id="audit-banner-text">▶ Hardware audit is running — page will refresh automatically when complete.</span>
|
||
<a href="/tasks" style="margin-left:12px;font-size:12px">View in Tasks</a>
|
||
</div>
|
||
<script>
|
||
(function(){
|
||
var _auditPoll = null;
|
||
var _auditSeenRunning = false;
|
||
|
||
function pollAuditTask() {
|
||
fetch('/api/tasks').then(function(r){ return r.json(); }).then(function(tasks){
|
||
if (!tasks) return;
|
||
var audit = null;
|
||
for (var i = 0; i < tasks.length; i++) {
|
||
if (tasks[i].target === 'audit') { audit = tasks[i]; break; }
|
||
}
|
||
var banner = document.getElementById('audit-banner');
|
||
var txt = document.getElementById('audit-banner-text');
|
||
if (!audit) {
|
||
if (banner) banner.style.display = 'none';
|
||
return;
|
||
}
|
||
if (audit.status === 'running' || audit.status === 'pending') {
|
||
_auditSeenRunning = true;
|
||
if (banner) {
|
||
banner.style.display = '';
|
||
var label = audit.status === 'pending' ? 'pending\u2026' : 'running\u2026';
|
||
if (txt) txt.textContent = '\u25b6 Hardware audit ' + label + ' \u2014 page will refresh when complete.';
|
||
}
|
||
} else if (audit.status === 'done' && _auditSeenRunning) {
|
||
// Audit just finished — reload to show fresh hardware data.
|
||
clearInterval(_auditPoll);
|
||
if (banner) {
|
||
if (txt) txt.textContent = '\u2713 Audit complete \u2014 reloading\u2026';
|
||
banner.style.background = 'var(--ok-bg,#fcfff5)';
|
||
banner.style.color = 'var(--ok-fg,#2c662d)';
|
||
}
|
||
setTimeout(function(){ window.location.reload(); }, 800);
|
||
} else if (audit.status === 'failed') {
|
||
_auditSeenRunning = false;
|
||
if (banner) {
|
||
banner.style.display = '';
|
||
banner.style.background = 'var(--crit-bg,#fff6f6)';
|
||
banner.style.color = 'var(--crit-fg,#9f3a38)';
|
||
if (txt) txt.textContent = '\u2717 Audit failed: ' + (audit.error||'unknown error');
|
||
clearInterval(_auditPoll);
|
||
}
|
||
} else {
|
||
if (banner) banner.style.display = 'none';
|
||
}
|
||
}).catch(function(){});
|
||
}
|
||
|
||
_auditPoll = setInterval(pollAuditTask, 3000);
|
||
pollAuditTask();
|
||
})();
|
||
</script>`
|
||
}
|
||
|
||
func renderAudit() string {
|
||
return `<div class="card"><div class="card-head">Audit Viewer <button class="btn btn-sm btn-secondary" style="margin-left:auto" onclick="openAuditModal()">Actions</button></div><div class="card-body" style="padding:0"><iframe class="viewer-frame" src="/viewer" title="Audit viewer"></iframe></div></div>`
|
||
}
|
||
|
||
func renderHardwareSummaryCard(opts HandlerOptions) string {
|
||
const cardID = ` id="hw-summary-card"`
|
||
data, err := loadSnapshot(opts.AuditPath)
|
||
if err != nil {
|
||
return `<div class="card"` + cardID + `><div class="card-head card-head-actions"><span>Hardware Summary</span><div class="card-head-buttons"><button class="btn btn-primary btn-sm" onclick="auditModalRun()">Run audit</button></div></div><div class="card-body"></div></div>`
|
||
}
|
||
var ingest schema.HardwareIngestRequest
|
||
if err := json.Unmarshal(data, &ingest); err != nil {
|
||
return `<div class="card"` + cardID + `><div class="card-head">Hardware Summary</div><div class="card-body"><span class="badge badge-err">Parse error</span></div></div>`
|
||
}
|
||
hw := ingest.Hardware
|
||
|
||
var records []app.ComponentStatusRecord
|
||
if db, err := app.OpenComponentStatusDB(filepath.Join(opts.ExportDir, "component-status.json")); err == nil {
|
||
records = db.All()
|
||
}
|
||
|
||
var b strings.Builder
|
||
b.WriteString(`<div class="card"` + cardID + `><div class="card-head">Hardware Summary</div><div class="card-body">`)
|
||
|
||
// Server identity block above the component table.
|
||
{
|
||
var model, serial string
|
||
parts := []string{}
|
||
if hw.Board.Manufacturer != nil && strings.TrimSpace(*hw.Board.Manufacturer) != "" {
|
||
parts = append(parts, strings.TrimSpace(*hw.Board.Manufacturer))
|
||
}
|
||
if hw.Board.ProductName != nil && strings.TrimSpace(*hw.Board.ProductName) != "" {
|
||
parts = append(parts, strings.TrimSpace(*hw.Board.ProductName))
|
||
}
|
||
if len(parts) > 0 {
|
||
model = strings.Join(parts, " ")
|
||
}
|
||
serial = strings.TrimSpace(hw.Board.SerialNumber)
|
||
if model != "" || serial != "" {
|
||
b.WriteString(`<div style="margin-bottom:14px">`)
|
||
if model != "" {
|
||
fmt.Fprintf(&b, `<div style="font-size:16px;font-weight:700;margin-bottom:2px">%s</div>`, html.EscapeString(model))
|
||
}
|
||
if serial != "" {
|
||
fmt.Fprintf(&b, `<div style="font-size:12px;color:var(--muted)">S/N: %s</div>`, html.EscapeString(serial))
|
||
}
|
||
b.WriteString(`</div>`)
|
||
}
|
||
}
|
||
|
||
b.WriteString(`<table style="width:auto">`)
|
||
// writeRow renders one component row. compType is the URL path segment for the detail
|
||
// endpoint (e.g. "cpu"). Pass "" for rows that have no detail view.
|
||
writeRow := func(label, value, badgeHTML, compType string) {
|
||
var labelHTML string
|
||
if compType != "" {
|
||
labelHTML = fmt.Sprintf(
|
||
`<span style="cursor:pointer;text-decoration:underline dotted;text-underline-offset:3px" onclick="openComponentDetail('%s')">%s</span>`,
|
||
compType, html.EscapeString(label))
|
||
} else {
|
||
labelHTML = html.EscapeString(label)
|
||
}
|
||
fmt.Fprintf(&b, `<tr><td style="padding:6px 14px 6px 0;font-weight:700;white-space:nowrap">%s</td><td style="padding:6px 0;color:var(--muted);font-size:13px">%s</td><td style="padding:6px 0 6px 12px">%s</td></tr>`,
|
||
labelHTML, html.EscapeString(value), badgeHTML)
|
||
}
|
||
|
||
writeRow("CPU", hwDescribeCPU(hw),
|
||
renderComponentChips(matchedRecords(records, []string{"cpu:all"}, nil)), "cpu")
|
||
|
||
writeRow("Memory", hwDescribeMemory(hw),
|
||
renderComponentChips(matchedRecords(records, []string{"memory:all"}, []string{"memory:"})), "memory")
|
||
|
||
writeRow("Storage", hwDescribeStorage(hw),
|
||
renderComponentChips(matchedRecords(records, []string{"storage:all"}, []string{"storage:"})), "storage")
|
||
|
||
writeRow("GPU", hwDescribeGPU(hw),
|
||
renderComponentChips(matchedRecords(records, nil, []string{"pcie:gpu:"})), "gpu")
|
||
|
||
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), renderComponentChips(psuMatched), "psu")
|
||
|
||
if nicDesc := hwDescribeNIC(hw); nicDesc != "" {
|
||
writeRow("Network", nicDesc, "", "")
|
||
}
|
||
|
||
b.WriteString(`</table>`)
|
||
b.WriteString(`</div></div>`)
|
||
return b.String()
|
||
}
|
||
|
||
// hwDescribeCPU returns a human-readable CPU summary, e.g. "2× Intel Xeon Gold 6338".
|
||
func hwDescribeCPU(hw schema.HardwareSnapshot) string {
|
||
counts := map[string]int{}
|
||
order := []string{}
|
||
for _, cpu := range hw.CPUs {
|
||
model := "Unknown CPU"
|
||
if cpu.Model != nil && *cpu.Model != "" {
|
||
model = *cpu.Model
|
||
}
|
||
if counts[model] == 0 {
|
||
order = append(order, model)
|
||
}
|
||
counts[model]++
|
||
}
|
||
if len(order) == 0 {
|
||
return "—"
|
||
}
|
||
parts := make([]string, 0, len(order))
|
||
for _, m := range order {
|
||
if counts[m] > 1 {
|
||
parts = append(parts, fmt.Sprintf("%d× %s", counts[m], m))
|
||
} else {
|
||
parts = append(parts, m)
|
||
}
|
||
}
|
||
return strings.Join(parts, ", ")
|
||
}
|
||
|
||
// hwDescribeMemory returns a summary like "16× 32 GB DDR4".
|
||
func hwDescribeMemory(hw schema.HardwareSnapshot) string {
|
||
type key struct {
|
||
sizeMB int
|
||
typ string
|
||
}
|
||
counts := map[key]int{}
|
||
order := []key{}
|
||
for _, dimm := range hw.Memory {
|
||
if dimm.SizeMB == nil || *dimm.SizeMB == 0 {
|
||
continue
|
||
}
|
||
t := ""
|
||
if dimm.Type != nil {
|
||
t = *dimm.Type
|
||
}
|
||
k := key{*dimm.SizeMB, t}
|
||
if counts[k] == 0 {
|
||
order = append(order, k)
|
||
}
|
||
counts[k]++
|
||
}
|
||
if len(order) == 0 {
|
||
return "—"
|
||
}
|
||
parts := make([]string, 0, len(order))
|
||
for _, k := range order {
|
||
gb := k.sizeMB / 1024
|
||
desc := fmt.Sprintf("%d× %d GB", counts[k], gb)
|
||
if k.typ != "" {
|
||
desc += " " + k.typ
|
||
}
|
||
parts = append(parts, desc)
|
||
}
|
||
return strings.Join(parts, ", ")
|
||
}
|
||
|
||
// hwDescribeStorage returns a summary like "4× 3.84 TB NVMe, 2× 1.92 TB SATA".
|
||
func hwDescribeStorage(hw schema.HardwareSnapshot) string {
|
||
type key struct {
|
||
sizeGB int
|
||
iface string
|
||
}
|
||
counts := map[key]int{}
|
||
order := []key{}
|
||
for _, disk := range hw.Storage {
|
||
sz := 0
|
||
if disk.SizeGB != nil {
|
||
sz = *disk.SizeGB
|
||
}
|
||
iface := ""
|
||
if disk.Interface != nil {
|
||
iface = *disk.Interface
|
||
} else if disk.Type != nil {
|
||
iface = *disk.Type
|
||
}
|
||
k := key{sz, iface}
|
||
if counts[k] == 0 {
|
||
order = append(order, k)
|
||
}
|
||
counts[k]++
|
||
}
|
||
if len(order) == 0 {
|
||
return "—"
|
||
}
|
||
parts := make([]string, 0, len(order))
|
||
for _, k := range order {
|
||
var sizeStr string
|
||
if k.sizeGB >= 1000 {
|
||
sizeStr = fmt.Sprintf("%.2g TB", float64(k.sizeGB)/1000)
|
||
} else if k.sizeGB > 0 {
|
||
sizeStr = fmt.Sprintf("%d GB", k.sizeGB)
|
||
} else {
|
||
sizeStr = "?"
|
||
}
|
||
desc := fmt.Sprintf("%d× %s", counts[k], sizeStr)
|
||
if k.iface != "" {
|
||
desc += " " + k.iface
|
||
}
|
||
parts = append(parts, desc)
|
||
}
|
||
return strings.Join(parts, ", ")
|
||
}
|
||
|
||
// hwDescribeGPU returns a summary like "8× NVIDIA H100 80GB".
|
||
func hwDescribeGPU(hw schema.HardwareSnapshot) string {
|
||
counts := map[string]int{}
|
||
order := []string{}
|
||
for _, dev := range hw.PCIeDevices {
|
||
if dev.DeviceClass == nil {
|
||
continue
|
||
}
|
||
if !isGPUDeviceClass(*dev.DeviceClass) {
|
||
continue
|
||
}
|
||
model := "Unknown GPU"
|
||
if dev.Model != nil && *dev.Model != "" {
|
||
model = *dev.Model
|
||
}
|
||
if counts[model] == 0 {
|
||
order = append(order, model)
|
||
}
|
||
counts[model]++
|
||
}
|
||
if len(order) == 0 {
|
||
return "—"
|
||
}
|
||
parts := make([]string, 0, len(order))
|
||
for _, m := range order {
|
||
if counts[m] > 1 {
|
||
parts = append(parts, fmt.Sprintf("%d× %s", counts[m], m))
|
||
} else {
|
||
parts = append(parts, m)
|
||
}
|
||
}
|
||
return strings.Join(parts, ", ")
|
||
}
|
||
|
||
// hwPSUStatus returns "OK", "CRITICAL", "WARNING", or "UNKNOWN" based on
|
||
// PSU statuses from the audit snapshot. Used as fallback when component-status.json
|
||
// has no psu: records yet (e.g. first boot before audit writes them).
|
||
func hwPSUStatus(psus []schema.HardwarePowerSupply) string {
|
||
worst := "UNKNOWN"
|
||
for _, psu := range psus {
|
||
if psu.Status == nil {
|
||
continue
|
||
}
|
||
switch strings.ToUpper(strings.TrimSpace(*psu.Status)) {
|
||
case "CRITICAL":
|
||
return "CRITICAL"
|
||
case "WARNING":
|
||
if worst != "CRITICAL" {
|
||
worst = "WARNING"
|
||
}
|
||
case "OK":
|
||
if worst == "UNKNOWN" {
|
||
worst = "OK"
|
||
}
|
||
}
|
||
}
|
||
return worst
|
||
}
|
||
|
||
// hwDescribePSU returns a summary like "2× 1600 W" or "2× PSU".
|
||
func hwDescribePSU(hw schema.HardwareSnapshot) string {
|
||
n := len(hw.PowerSupplies)
|
||
if n == 0 {
|
||
return "—"
|
||
}
|
||
// Try to get a consistent wattage
|
||
watt := 0
|
||
consistent := true
|
||
for _, psu := range hw.PowerSupplies {
|
||
if psu.WattageW == nil {
|
||
consistent = false
|
||
break
|
||
}
|
||
if watt == 0 {
|
||
watt = *psu.WattageW
|
||
} else if *psu.WattageW != watt {
|
||
consistent = false
|
||
break
|
||
}
|
||
}
|
||
if consistent && watt > 0 {
|
||
return fmt.Sprintf("%d× %d W", n, watt)
|
||
}
|
||
return fmt.Sprintf("%d× PSU", n)
|
||
}
|
||
|
||
// hwDescribeNIC returns a summary like "2× Mellanox ConnectX-6".
|
||
func hwDescribeNIC(hw schema.HardwareSnapshot) string {
|
||
counts := map[string]int{}
|
||
order := []string{}
|
||
for _, dev := range hw.PCIeDevices {
|
||
isNIC := false
|
||
if dev.DeviceClass != nil {
|
||
c := strings.ToLower(strings.TrimSpace(*dev.DeviceClass))
|
||
isNIC = c == "ethernetcontroller" || c == "networkcontroller" || strings.Contains(c, "fibrechannel")
|
||
}
|
||
if !isNIC && len(dev.MacAddresses) == 0 {
|
||
continue
|
||
}
|
||
model := ""
|
||
if dev.Model != nil && *dev.Model != "" {
|
||
model = *dev.Model
|
||
} else if dev.Manufacturer != nil && *dev.Manufacturer != "" {
|
||
model = *dev.Manufacturer + " NIC"
|
||
} else {
|
||
model = "NIC"
|
||
}
|
||
if counts[model] == 0 {
|
||
order = append(order, model)
|
||
}
|
||
counts[model]++
|
||
}
|
||
if len(order) == 0 {
|
||
return ""
|
||
}
|
||
parts := make([]string, 0, len(order))
|
||
for _, m := range order {
|
||
if counts[m] > 1 {
|
||
parts = append(parts, fmt.Sprintf("%d× %s", counts[m], m))
|
||
} else {
|
||
parts = append(parts, m)
|
||
}
|
||
}
|
||
return strings.Join(parts, ", ")
|
||
}
|
||
|
||
func isGPUDeviceClass(class string) bool {
|
||
switch strings.TrimSpace(class) {
|
||
case "VideoController", "DisplayController", "ProcessingAccelerator":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func renderAuditModal() string {
|
||
return `<div id="audit-modal-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;align-items:center;justify-content:center">
|
||
<div style="background:#fff;border-radius:6px;padding:24px;min-width:480px;max-width:1100px;width:min(1100px,92vw);max-height:92vh;overflow:auto;position:relative">
|
||
<div style="font-weight:700;font-size:16px;margin-bottom:16px">Audit</div>
|
||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||
<button class="btn btn-primary" onclick="auditModalRun()">▶ Re-run Audit</button>
|
||
<a class="btn btn-secondary" href="/audit.json" download>↓ Download</a>
|
||
</div>
|
||
<div id="audit-modal-terminal" class="terminal" style="display:none;max-height:220px;margin-bottom:12px"></div>
|
||
<iframe class="viewer-frame" src="/viewer" title="Audit viewer in modal" style="height:min(70vh,720px)"></iframe>
|
||
<button class="btn btn-secondary btn-sm" onclick="closeAuditModal()" style="position:absolute;top:12px;right:12px">✕</button>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
function openAuditModal() {
|
||
document.getElementById('audit-modal-overlay').style.display='flex';
|
||
}
|
||
function closeAuditModal() {
|
||
document.getElementById('audit-modal-overlay').style.display='none';
|
||
}
|
||
function auditModalRun() {
|
||
const term = document.getElementById('audit-modal-terminal');
|
||
term.style.display='block'; term.textContent='Starting...\n';
|
||
fetch('/api/audit/run',{method:'POST'}).then(r=>r.json()).then(d=>{
|
||
const es=new EventSource('/api/tasks/'+d.task_id+'/stream');
|
||
es.onmessage=e=>{term.textContent+=e.data+'\n';term.scrollTop=term.scrollHeight;};
|
||
es.addEventListener('done',e=>{es.close();term.textContent+=(e.data?'\nERROR: '+e.data:'\nDone.')+'\n';});
|
||
});
|
||
}
|
||
</script>`
|
||
}
|
||
|
||
func renderHealthCard(opts HandlerOptions) string {
|
||
data, err := loadSnapshot(filepath.Join(opts.ExportDir, "runtime-health.json"))
|
||
if err != nil {
|
||
return `<div class="card"><div class="card-head">Runtime Health</div><div class="card-body"><span class="badge badge-unknown">No data</span></div></div>`
|
||
}
|
||
var health schema.RuntimeHealth
|
||
if err := json.Unmarshal(data, &health); err != nil {
|
||
return `<div class="card"><div class="card-head">Runtime Health</div><div class="card-body"><span class="badge badge-err">Parse error</span></div></div>`
|
||
}
|
||
status := strings.TrimSpace(health.Status)
|
||
if status == "" {
|
||
status = "UNKNOWN"
|
||
}
|
||
badge := "badge-ok"
|
||
if status == "PARTIAL" {
|
||
badge = "badge-warn"
|
||
} else if status == "FAIL" || status == "FAILED" {
|
||
badge = "badge-err"
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString(`<div class="card"><div class="card-head">Runtime Health</div><div class="card-body">`)
|
||
b.WriteString(fmt.Sprintf(`<div style="margin-bottom:10px"><span class="badge %s">%s</span></div>`, badge, html.EscapeString(status)))
|
||
if checkedAt := strings.TrimSpace(health.CheckedAt); checkedAt != "" {
|
||
b.WriteString(`<div style="font-size:12px;color:var(--muted);margin-bottom:12px">Checked at: ` + html.EscapeString(checkedAt) + `</div>`)
|
||
}
|
||
rows := []runtimeHealthRow{
|
||
buildRuntimeExportRow(health),
|
||
buildRuntimeNetworkRow(health),
|
||
buildRuntimeDriverRow(health),
|
||
buildRuntimeAccelerationRow(health),
|
||
buildRuntimeToolsRow(health),
|
||
buildRuntimeServicesRow(health),
|
||
buildRuntimeUSBExportRow(health),
|
||
buildRuntimeToRAMRow(health),
|
||
}
|
||
b.WriteString(`<table><thead><tr><th>Check</th><th>Status</th><th>Source</th><th>Issue</th></tr></thead><tbody>`)
|
||
for _, row := range rows {
|
||
b.WriteString(`<tr><td>` + html.EscapeString(row.Title) + `</td><td>` + runtimeStatusBadge(row.Status) + `</td><td>` + html.EscapeString(row.Source) + `</td><td>` + rowIssueHTML(row.Issue) + `</td></tr>`)
|
||
}
|
||
b.WriteString(`</tbody></table>`)
|
||
b.WriteString(`</div></div>`)
|
||
return b.String()
|
||
}
|
||
|
||
type runtimeHealthRow struct {
|
||
Title string
|
||
Status string
|
||
Source string
|
||
Issue string
|
||
}
|
||
|
||
func buildRuntimeExportRow(health schema.RuntimeHealth) runtimeHealthRow {
|
||
issue := runtimeIssueDescriptions(health.Issues, "export_dir_unavailable")
|
||
status := "UNKNOWN"
|
||
switch {
|
||
case issue != "":
|
||
status = "FAILED"
|
||
case strings.TrimSpace(health.ExportDir) != "":
|
||
status = "OK"
|
||
}
|
||
source := "os.MkdirAll"
|
||
if dir := strings.TrimSpace(health.ExportDir); dir != "" {
|
||
source += " " + dir
|
||
}
|
||
return runtimeHealthRow{Title: "Export Directory", Status: status, Source: source, Issue: issue}
|
||
}
|
||
|
||
func buildRuntimeNetworkRow(health schema.RuntimeHealth) runtimeHealthRow {
|
||
status := strings.TrimSpace(health.NetworkStatus)
|
||
if status == "" {
|
||
status = "UNKNOWN"
|
||
}
|
||
issue := runtimeIssueDescriptions(health.Issues, "dhcp_failed")
|
||
return runtimeHealthRow{Title: "Network", Status: status, Source: "ListInterfaces / DHCP", Issue: issue}
|
||
}
|
||
|
||
func buildRuntimeDriverRow(health schema.RuntimeHealth) runtimeHealthRow {
|
||
issue := runtimeIssueDescriptions(health.Issues, "nvidia_kernel_module_missing", "nvidia_modeset_failed", "amdgpu_kernel_module_missing")
|
||
status := "UNKNOWN"
|
||
switch {
|
||
case health.DriverReady && issue == "":
|
||
status = "OK"
|
||
case health.DriverReady:
|
||
status = "PARTIAL"
|
||
case issue != "":
|
||
status = "FAILED"
|
||
}
|
||
return runtimeHealthRow{Title: "NVIDIA/AMD Driver", Status: status, Source: "lsmod / vendor probe", Issue: issue}
|
||
}
|
||
|
||
func buildRuntimeAccelerationRow(health schema.RuntimeHealth) runtimeHealthRow {
|
||
issue := runtimeIssueDescriptions(health.Issues, "cuda_runtime_not_ready", "rocm_smi_unavailable")
|
||
status := "UNKNOWN"
|
||
switch {
|
||
case health.CUDAReady && issue == "":
|
||
status = "OK"
|
||
case health.CUDAReady:
|
||
status = "PARTIAL"
|
||
case issue != "":
|
||
status = "FAILED"
|
||
}
|
||
return runtimeHealthRow{Title: "CUDA / ROCm", Status: status, Source: "bee-gpu-burn / rocm-smi", Issue: issue}
|
||
}
|
||
|
||
func buildRuntimeToolsRow(health schema.RuntimeHealth) runtimeHealthRow {
|
||
if len(health.Tools) == 0 {
|
||
return runtimeHealthRow{Title: "Required Utilities", Status: "UNKNOWN", Source: "CheckTools", Issue: "No tool status data."}
|
||
}
|
||
missing := make([]string, 0)
|
||
for _, tool := range health.Tools {
|
||
if !tool.OK {
|
||
missing = append(missing, tool.Name)
|
||
}
|
||
}
|
||
status := "OK"
|
||
issue := ""
|
||
if len(missing) > 0 {
|
||
status = "PARTIAL"
|
||
issue = "Missing: " + strings.Join(missing, ", ")
|
||
}
|
||
return runtimeHealthRow{Title: "Required Utilities", Status: status, Source: "CheckTools", Issue: issue}
|
||
}
|
||
|
||
func buildRuntimeServicesRow(health schema.RuntimeHealth) runtimeHealthRow {
|
||
if len(health.Services) == 0 {
|
||
return runtimeHealthRow{Title: "Bee Services", Status: "UNKNOWN", Source: "systemctl is-active", Issue: "No service status data."}
|
||
}
|
||
nonActive := make([]string, 0)
|
||
for _, svc := range health.Services {
|
||
state := strings.TrimSpace(strings.ToLower(svc.Status))
|
||
// "inactive" is OK for oneshot services that have completed successfully
|
||
// (bee-sshsetup, bee-preflight, bee-audit, bee-network, etc.).
|
||
// Only "failed" is a genuine problem.
|
||
switch state {
|
||
case "active", "activating", "deactivating", "reloading", "inactive":
|
||
// OK — service is running, transitioning normally, or completed successfully
|
||
default:
|
||
nonActive = append(nonActive, svc.Name+"="+svc.Status)
|
||
}
|
||
}
|
||
status := "OK"
|
||
issue := ""
|
||
if len(nonActive) > 0 {
|
||
status = "PARTIAL"
|
||
issue = strings.Join(nonActive, ", ")
|
||
}
|
||
return runtimeHealthRow{Title: "Bee Services", Status: status, Source: "ServiceState", Issue: issue}
|
||
}
|
||
|
||
func buildRuntimeUSBExportRow(health schema.RuntimeHealth) runtimeHealthRow {
|
||
path := strings.TrimSpace(health.USBExportPath)
|
||
if path != "" {
|
||
return runtimeHealthRow{
|
||
Title: "USB Export Drive",
|
||
Status: "OK",
|
||
Source: "/proc/mounts + lsblk",
|
||
Issue: path,
|
||
}
|
||
}
|
||
return runtimeHealthRow{
|
||
Title: "USB Export Drive",
|
||
Status: "WARNING",
|
||
Source: "/proc/mounts + lsblk",
|
||
Issue: "No writable USB drive mounted. Plug in a USB drive to enable log export.",
|
||
}
|
||
}
|
||
|
||
func buildRuntimeToRAMRow(health schema.RuntimeHealth) runtimeHealthRow {
|
||
switch strings.ToLower(strings.TrimSpace(health.ToRAMStatus)) {
|
||
case "ok":
|
||
return runtimeHealthRow{
|
||
Title: "LiveCD in RAM",
|
||
Status: "OK",
|
||
Source: "live-boot / /proc/mounts",
|
||
Issue: "",
|
||
}
|
||
case "partial":
|
||
return runtimeHealthRow{
|
||
Title: "LiveCD in RAM",
|
||
Status: "WARNING",
|
||
Source: "live-boot / /proc/mounts / /dev/shm/bee-live",
|
||
Issue: "Partial or staged RAM copy detected. System is not fully running from RAM; Copy to RAM can be retried.",
|
||
}
|
||
case "failed":
|
||
return runtimeHealthRow{
|
||
Title: "LiveCD in RAM",
|
||
Status: "FAILED",
|
||
Source: "live-boot / /proc/mounts",
|
||
Issue: "toram boot parameter set but ISO is not mounted from RAM. Copy may have failed.",
|
||
}
|
||
default:
|
||
// toram not active — ISO still on original boot media (USB/CD)
|
||
return runtimeHealthRow{
|
||
Title: "LiveCD in RAM",
|
||
Status: "WARNING",
|
||
Source: "live-boot / /proc/mounts",
|
||
Issue: "ISO not copied to RAM. Use \u201cCopy to RAM\u201d to free the boot drive and improve performance.",
|
||
}
|
||
}
|
||
}
|
||
|
||
func buildHardwareComponentRows(exportDir string) []runtimeHealthRow {
|
||
path := filepath.Join(exportDir, "component-status.json")
|
||
db, err := app.OpenComponentStatusDB(path)
|
||
if err != nil {
|
||
return []runtimeHealthRow{
|
||
{Title: "CPU Component Health", Status: "UNKNOWN", Source: "component-status.json", Issue: "Component status DB not available."},
|
||
{Title: "Memory Component Health", Status: "UNKNOWN", Source: "component-status.json", Issue: "Component status DB not available."},
|
||
{Title: "Storage Component Health", Status: "UNKNOWN", Source: "component-status.json", Issue: "Component status DB not available."},
|
||
{Title: "GPU Component Health", Status: "UNKNOWN", Source: "component-status.json", Issue: "Component status DB not available."},
|
||
{Title: "PSU Component Health", Status: "UNKNOWN", Source: "component-status.json", Issue: "No PSU component checks recorded."},
|
||
}
|
||
}
|
||
records := db.All()
|
||
return []runtimeHealthRow{
|
||
aggregateComponentStatus("CPU", records, []string{"cpu:all"}, nil),
|
||
aggregateComponentStatus("Memory", records, []string{"memory:all"}, []string{"memory:"}),
|
||
aggregateComponentStatus("Storage", records, []string{"storage:all"}, []string{"storage:"}),
|
||
aggregateComponentStatus("GPU", records, nil, []string{"pcie:gpu:"}),
|
||
aggregateComponentStatus("PSU", records, nil, []string{"psu:"}),
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
key := strings.TrimSpace(rec.ComponentKey)
|
||
if key == "" {
|
||
continue
|
||
}
|
||
if containsExactKey(key, exact) || hasAnyPrefix(key, prefixes) {
|
||
matched = append(matched, rec)
|
||
}
|
||
}
|
||
if len(matched) == 0 {
|
||
return runtimeHealthRow{Title: title, Status: "UNKNOWN", Source: "component-status.json", Issue: "No component status data."}
|
||
}
|
||
|
||
maxSev := -1
|
||
for _, rec := range matched {
|
||
if sev := runtimeComponentSeverity(rec.Status); sev > maxSev {
|
||
maxSev = sev
|
||
}
|
||
}
|
||
status := "UNKNOWN"
|
||
switch maxSev {
|
||
case 3:
|
||
status = "CRITICAL"
|
||
case 2:
|
||
status = "WARNING"
|
||
case 1:
|
||
status = "OK"
|
||
}
|
||
|
||
sources := make([]string, 0)
|
||
sourceSeen := map[string]struct{}{}
|
||
issues := make([]string, 0)
|
||
issueSeen := map[string]struct{}{}
|
||
for _, rec := range matched {
|
||
if runtimeComponentSeverity(rec.Status) != maxSev {
|
||
continue
|
||
}
|
||
source := latestComponentSource(rec)
|
||
if source == "" {
|
||
source = "component-status.json"
|
||
}
|
||
if _, ok := sourceSeen[source]; !ok {
|
||
sourceSeen[source] = struct{}{}
|
||
sources = append(sources, source)
|
||
}
|
||
issue := strings.TrimSpace(rec.ErrorSummary)
|
||
if issue == "" {
|
||
issue = latestComponentDetail(rec)
|
||
}
|
||
if issue == "" {
|
||
continue
|
||
}
|
||
if _, ok := issueSeen[issue]; ok {
|
||
continue
|
||
}
|
||
issueSeen[issue] = struct{}{}
|
||
issues = append(issues, issue)
|
||
}
|
||
if len(sources) == 0 {
|
||
sources = append(sources, "component-status.json")
|
||
}
|
||
issue := strings.Join(issues, "; ")
|
||
if issue == "" {
|
||
issue = "—"
|
||
}
|
||
return runtimeHealthRow{
|
||
Title: title,
|
||
Status: status,
|
||
Source: strings.Join(sources, ", "),
|
||
Issue: issue,
|
||
}
|
||
}
|
||
|
||
func containsExactKey(key string, exact []string) bool {
|
||
for _, candidate := range exact {
|
||
if key == candidate {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func hasAnyPrefix(key string, prefixes []string) bool {
|
||
for _, prefix := range prefixes {
|
||
if strings.HasPrefix(key, prefix) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func runtimeComponentSeverity(status string) int {
|
||
switch strings.TrimSpace(strings.ToLower(status)) {
|
||
case "critical":
|
||
return 3
|
||
case "warning":
|
||
return 2
|
||
case "ok":
|
||
return 1
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
func latestComponentSource(rec app.ComponentStatusRecord) string {
|
||
if len(rec.History) == 0 {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(rec.History[len(rec.History)-1].Source)
|
||
}
|
||
|
||
func latestComponentDetail(rec app.ComponentStatusRecord) string {
|
||
if len(rec.History) == 0 {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(rec.History[len(rec.History)-1].Detail)
|
||
}
|
||
|
||
func runtimeIssueDescriptions(issues []schema.RuntimeIssue, codes ...string) string {
|
||
if len(issues) == 0 || len(codes) == 0 {
|
||
return ""
|
||
}
|
||
allowed := make(map[string]struct{}, len(codes))
|
||
for _, code := range codes {
|
||
allowed[code] = struct{}{}
|
||
}
|
||
messages := make([]string, 0)
|
||
for _, issue := range issues {
|
||
if _, ok := allowed[issue.Code]; !ok {
|
||
continue
|
||
}
|
||
desc := strings.TrimSpace(issue.Description)
|
||
if desc == "" {
|
||
desc = issue.Code
|
||
}
|
||
messages = append(messages, desc)
|
||
}
|
||
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"
|
||
switch status {
|
||
case "OK":
|
||
badge = "badge-ok"
|
||
case "PARTIAL", "WARNING", "WARN":
|
||
badge = "badge-warn"
|
||
case "FAIL", "FAILED", "CRITICAL":
|
||
badge = "badge-err"
|
||
}
|
||
return `<span class="badge ` + badge + `">` + html.EscapeString(status) + `</span>`
|
||
}
|
||
|
||
func rowIssueHTML(issue string) string {
|
||
issue = strings.TrimSpace(issue)
|
||
if issue == "" {
|
||
return `<span style="color:var(--muted)">—</span>`
|
||
}
|
||
return html.EscapeString(issue)
|
||
}
|
||
|
||
var aerStatusRe = regexp.MustCompile(`aer_status:\s*0x([0-9a-fA-F]{1,8})`)
|
||
|
||
// decodeAERStatus parses an AER status hex value from a kernel error detail string
|
||
// and returns a human-readable list of set bit names with correctable/uncorrectable label,
|
||
// or "" if no AER status is found.
|
||
func decodeAERStatus(detail string) string {
|
||
m := aerStatusRe.FindStringSubmatch(detail)
|
||
if m == nil {
|
||
return ""
|
||
}
|
||
v64, err := strconv.ParseUint(m[1], 16, 32)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
val := uint32(v64)
|
||
|
||
type bitDef struct {
|
||
bit uint32
|
||
name string
|
||
}
|
||
corrBits := []bitDef{
|
||
{0, "Receiver Error"}, {6, "Replay Timer Timeout"}, {7, "Advisory Non-Fatal"},
|
||
{8, "Corrected Internal Error"}, {9, "Header Log Overflow"},
|
||
{13, "Replay Num Rollover"}, {14, "Bad DLLP"}, {15, "Bad TLP"},
|
||
}
|
||
uncorrBits := []bitDef{
|
||
{4, "Data Link Protocol Error"}, {5, "Surprise Down Error"},
|
||
{12, "Poisoned TLP Received"}, {13, "Flow Control Protocol Error"},
|
||
{14, "Completion Timeout"}, {15, "Completer Abort"}, {16, "Unexpected Completion"},
|
||
{17, "Receiver Overflow"}, {18, "Malformed TLP"}, {19, "ECRC Error"},
|
||
{20, "Unsupported Request Error"}, {21, "ACS Violation"}, {22, "Uncorrectable Internal Error"},
|
||
}
|
||
var corrNames, uncorrNames []string
|
||
for _, b := range corrBits {
|
||
if val&(1<<b.bit) != 0 {
|
||
corrNames = append(corrNames, b.name)
|
||
}
|
||
}
|
||
for _, b := range uncorrBits {
|
||
if val&(1<<b.bit) != 0 {
|
||
uncorrNames = append(uncorrNames, b.name)
|
||
}
|
||
}
|
||
if len(corrNames) >= len(uncorrNames) && len(corrNames) > 0 {
|
||
return strings.Join(corrNames, ", ") + " (correctable)"
|
||
}
|
||
if len(uncorrNames) > 0 {
|
||
return strings.Join(uncorrNames, ", ") + " (uncorrectable)"
|
||
}
|
||
return fmt.Sprintf("unknown bits: 0x%08x", val)
|
||
}
|
||
|
||
// renderSparkline returns a small inline SVG showing non-OK events over time.
|
||
// Events are positioned proportionally along the time axis; if all share the same
|
||
// timestamp they are spaced evenly. Width is always 100px.
|
||
func renderSparkline(history []app.ComponentStatusEntry) string {
|
||
const (
|
||
svgW = 100
|
||
svgH = 20
|
||
barW = 3
|
||
barH = 14
|
||
)
|
||
var events []app.ComponentStatusEntry
|
||
for _, e := range history {
|
||
if e.Status != "OK" {
|
||
events = append(events, e)
|
||
}
|
||
}
|
||
if len(events) == 0 {
|
||
return ""
|
||
}
|
||
n := len(events)
|
||
barColor := func(status string) string {
|
||
if status == "Critical" {
|
||
return "#c0392b"
|
||
}
|
||
return "#d97706"
|
||
}
|
||
yTop := (svgH - barH) / 2
|
||
|
||
var bars strings.Builder
|
||
if n == 1 {
|
||
x := (svgW - barW) / 2
|
||
fmt.Fprintf(&bars, `<rect x="%d" y="%d" width="%d" height="%d" fill="%s" rx="1"/>`,
|
||
x, yTop, barW, barH, barColor(events[0].Status))
|
||
} else {
|
||
minT := events[0].At
|
||
maxT := events[n-1].At
|
||
dur := maxT.Sub(minT).Seconds()
|
||
for i, e := range events {
|
||
var x int
|
||
if dur <= 0 {
|
||
step := svgW / n
|
||
x = i*step + (step-barW)/2
|
||
} else {
|
||
frac := e.At.Sub(minT).Seconds() / dur
|
||
x = int(frac * float64(svgW-barW))
|
||
}
|
||
fmt.Fprintf(&bars, `<rect x="%d" y="%d" width="%d" height="%d" fill="%s" rx="1"/>`,
|
||
x, yTop, barW, barH, barColor(e.Status))
|
||
}
|
||
}
|
||
return fmt.Sprintf(
|
||
`<svg width="%d" height="%d" style="display:inline-block;vertical-align:middle;margin-left:6px;flex-shrink:0" xmlns="http://www.w3.org/2000/svg">`+
|
||
`<rect x="0" y="0" width="%d" height="%d" fill="var(--surface-alt,#ebebeb)" rx="3"/>%s</svg>`,
|
||
svgW, svgH, svgW, svgH, bars.String())
|
||
}
|
||
|
||
// renderComponentDetail renders a modal content fragment for one component type.
|
||
// Called by handleAPIComponentDetail and displayed inside #component-detail-dialog.
|
||
// fromInventory marks that records were synthesized from the audit inventory
|
||
// snapshot (no ComponentStatusDB history yet) rather than real SAT/watchdog
|
||
// observations — see inventoryFallbackRecords.
|
||
func renderComponentDetail(title string, records []app.ComponentStatusRecord, fromInventory bool) string {
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, `<div style="padding:20px 24px 0">`)
|
||
fmt.Fprintf(&b, `<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">`)
|
||
fmt.Fprintf(&b, `<span style="font-size:16px;font-weight:700">%s — Status Detail</span>`, html.EscapeString(title))
|
||
b.WriteString(`<button class="btn btn-sm btn-secondary" onclick="document.getElementById('component-detail-dialog').close()">Close</button>`)
|
||
b.WriteString(`</div>`)
|
||
|
||
if len(records) == 0 {
|
||
b.WriteString(`<p style="color:var(--muted)">No status data recorded yet for this component type.</p>`)
|
||
b.WriteString(`</div>`)
|
||
return b.String()
|
||
}
|
||
|
||
if fromInventory {
|
||
b.WriteString(`<p style="color:var(--muted);font-size:12px;margin-top:-8px;margin-bottom:16px">No SAT-test history yet — showing latest inventory snapshot.</p>`)
|
||
}
|
||
|
||
sort.Slice(records, func(i, j int) bool {
|
||
return records[i].ComponentKey < records[j].ComponentKey
|
||
})
|
||
|
||
for _, rec := range records {
|
||
letter, cls := chipLetterClass(rec.Status)
|
||
|
||
// Count non-OK events across the full history for the badge + sparkline.
|
||
warnCount := 0
|
||
for _, e := range rec.History {
|
||
if e.Status != "OK" {
|
||
warnCount++
|
||
}
|
||
}
|
||
|
||
fmt.Fprintf(&b, `<div style="margin-bottom:20px">`)
|
||
fmt.Fprintf(&b, `<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap">`)
|
||
fmt.Fprintf(&b, `<span class="chip %s">%s</span>`, cls, letter)
|
||
fmt.Fprintf(&b, `<span style="font-weight:700;font-size:13px">%s</span>`, html.EscapeString(rec.ComponentKey))
|
||
if !rec.LastCheckedAt.IsZero() {
|
||
fmt.Fprintf(&b, `<span style="color:var(--muted);font-size:12px">checked %s</span>`, rec.LastCheckedAt.Format("2006-01-02 15:04:05"))
|
||
}
|
||
if warnCount > 0 {
|
||
noun := "events"
|
||
if warnCount == 1 {
|
||
noun = "event"
|
||
}
|
||
fmt.Fprintf(&b,
|
||
`<span style="font-size:11px;background:var(--warn-bg,#fffbeb);color:var(--warn-fg,#92400e);border:1px solid var(--warn-border,#fde68a);border-radius:10px;padding:1px 7px;white-space:nowrap">%d %s</span>`,
|
||
warnCount, noun)
|
||
b.WriteString(renderSparkline(rec.History))
|
||
}
|
||
b.WriteString(`</div>`)
|
||
|
||
if rec.ErrorSummary != "" {
|
||
fmt.Fprintf(&b, `<div style="font-size:12px;margin-bottom:4px;color:var(--muted)">%s</div>`, html.EscapeString(rec.ErrorSummary))
|
||
if decoded := decodeAERStatus(rec.ErrorSummary); decoded != "" {
|
||
fmt.Fprintf(&b,
|
||
`<div style="font-size:12px;margin-bottom:8px;color:var(--muted)"><span style="background:var(--surface-alt,#f5f5f5);border-radius:4px;padding:1px 6px;font-family:monospace">AER: %s</span></div>`,
|
||
html.EscapeString(decoded))
|
||
}
|
||
}
|
||
|
||
// History table — newest first, cap at 20 entries.
|
||
history := rec.History
|
||
if len(history) > 20 {
|
||
history = history[len(history)-20:]
|
||
}
|
||
b.WriteString(`<table style="width:100%;font-size:12px;border-collapse:collapse">`)
|
||
b.WriteString(`<tr style="color:var(--muted)"><th style="text-align:left;padding:2px 10px 2px 0;white-space:nowrap">Time</th><th style="text-align:left;padding:2px 10px 2px 0">Status</th><th style="text-align:left;padding:2px 10px 2px 0">Source</th><th style="text-align:left;padding:2px 0">Detail</th></tr>`)
|
||
for i := len(history) - 1; i >= 0; i-- {
|
||
e := history[i]
|
||
eLetter, eCls := chipLetterClass(e.Status)
|
||
detail := e.Detail
|
||
if detail == "" {
|
||
detail = "—"
|
||
}
|
||
fmt.Fprintf(&b,
|
||
`<tr><td style="padding:3px 10px 3px 0;white-space:nowrap;color:var(--muted)">%s</td><td style="padding:3px 10px 3px 0"><span class="chip %s" style="font-size:10px;width:16px;height:16px">%s</span></td><td style="padding:3px 10px 3px 0;white-space:nowrap">%s</td><td style="padding:3px 0;color:var(--muted)">%s</td></tr>`,
|
||
html.EscapeString(e.At.Format("2006-01-02 15:04:05")),
|
||
eCls, eLetter,
|
||
html.EscapeString(e.Source),
|
||
html.EscapeString(detail),
|
||
)
|
||
}
|
||
b.WriteString(`</table>`)
|
||
b.WriteString(`</div>`)
|
||
}
|
||
|
||
b.WriteString(`</div>`)
|
||
return b.String()
|
||
}
|