1002 lines
31 KiB
Go
1002 lines
31 KiB
Go
package webui
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"html"
|
||
"path/filepath"
|
||
"sort"
|
||
"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 = "Audit"
|
||
body = renderAudit()
|
||
case "validate":
|
||
pageID = "validate"
|
||
title = "Validate"
|
||
body = renderValidate(opts)
|
||
case "burn":
|
||
pageID = "burn"
|
||
title = "Burn"
|
||
body = renderBurn()
|
||
case "benchmark":
|
||
pageID = "benchmark"
|
||
title = "Benchmark"
|
||
body = renderBenchmark(opts)
|
||
case "tasks":
|
||
pageID = "tasks"
|
||
title = "Tasks"
|
||
body = renderTasks()
|
||
case "tools":
|
||
pageID = "tools"
|
||
title = "Tools"
|
||
body = renderTools()
|
||
// Legacy routes kept accessible but not in nav
|
||
case "metrics":
|
||
pageID = "metrics"
|
||
title = "Live Metrics"
|
||
body = renderMetrics()
|
||
case "tests":
|
||
pageID = "validate"
|
||
title = "Acceptance Tests"
|
||
body = renderValidate(opts)
|
||
case "burn-in":
|
||
pageID = "burn"
|
||
title = "Burn-in Tests"
|
||
body = renderBurn()
|
||
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() +
|
||
`<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);
|
||
});
|
||
</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())
|
||
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 {
|
||
data, err := loadSnapshot(opts.AuditPath)
|
||
if err != nil {
|
||
return `<div class="card"><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"><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"><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 := func(label, value, badgeHTML string) {
|
||
b.WriteString(fmt.Sprintf(`<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>`,
|
||
html.EscapeString(label), html.EscapeString(value), badgeHTML))
|
||
}
|
||
|
||
writeRow("CPU", hwDescribeCPU(hw),
|
||
renderComponentChips(matchedRecords(records, []string{"cpu:all"}, nil)))
|
||
|
||
writeRow("Memory", hwDescribeMemory(hw),
|
||
renderComponentChips(matchedRecords(records, []string{"memory:all"}, []string{"memory:"})))
|
||
|
||
writeRow("Storage", hwDescribeStorage(hw),
|
||
renderComponentChips(matchedRecords(records, []string{"storage:all"}, []string{"storage:"})))
|
||
|
||
writeRow("GPU", hwDescribeGPU(hw),
|
||
renderComponentChips(matchedRecords(records, nil, []string{"pcie: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))
|
||
|
||
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_partial", "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))
|
||
// "activating" and "deactivating" are transient states for oneshot services
|
||
// (RemainAfterExit=yes) — the service is running normally, not failed.
|
||
// Only "failed" and "inactive" (after services should be running) are problems.
|
||
switch state {
|
||
case "active", "activating", "deactivating", "reloading":
|
||
// OK — service is running or transitioning normally
|
||
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)
|
||
}
|