- Add BenchmarkEstimated* constants to benchmark_types.go from _v8 logs (Standard Perf ~16 min, Standard Power Fit ~43 min, Stability Perf ~92 min) - Update benchmark profile dropdown to show Perf / Power Fit timing per profile - Add timing columns to Method Split table (Standard vs Stability per run type) - Update burn preset labels to show "N min/GPU (sequential) or N min (parallel)" - Clarify burn "one by one" description with sequential vs parallel scaling Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3995 lines
166 KiB
Go
3995 lines
166 KiB
Go
package webui
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"html"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"bee/audit/internal/app"
|
||
"bee/audit/internal/platform"
|
||
"bee/audit/internal/schema"
|
||
)
|
||
|
||
// ── Layout ────────────────────────────────────────────────────────────────────
|
||
|
||
func layoutHead(title string) string {
|
||
return `<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>` + html.EscapeString(title) + `</title>
|
||
<style>
|
||
:root{--bg:#fff;--surface:#fff;--surface-2:#f9fafb;--border:rgba(34,36,38,.15);--border-lite:rgba(34,36,38,.1);--ink:rgba(0,0,0,.87);--muted:rgba(0,0,0,.6);--accent:#2185d0;--accent-dark:#1678c2;--crit-bg:#fff6f6;--crit-fg:#9f3a38;--crit-border:#e0b4b4;--ok-bg:#fcfff5;--ok-fg:#2c662d;--warn-bg:#fffaf3;--warn-fg:#573a08}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{font:14px/1.5 Lato,"Helvetica Neue",Arial,Helvetica,sans-serif;background:var(--bg);color:var(--ink);display:flex;min-height:100vh}
|
||
a{color:var(--accent);text-decoration:none}
|
||
/* Sidebar */
|
||
.sidebar{width:210px;min-height:100vh;background:#1b1c1d;flex-shrink:0;display:flex;flex-direction:column}
|
||
.sidebar-logo{padding:18px 16px 12px;font-size:18px;font-weight:700;color:#fff;letter-spacing:-.5px}
|
||
.sidebar-logo span{color:rgba(255,255,255,.5);font-weight:400;font-size:12px;display:block;margin-top:2px}
|
||
.sidebar-version{padding:0 16px 14px;font-size:11px;color:rgba(255,255,255,.45)}
|
||
.sidebar-badge{margin:0 12px 12px;padding:5px 8px;border-radius:4px;font-size:11px;font-weight:600;text-align:center}
|
||
.sidebar-badge-warn{background:#7a4f00;color:#f6c90e}
|
||
.sidebar-badge-crit{background:#5c1a1a;color:#ff6b6b}
|
||
.nav{flex:1}
|
||
.nav-item{display:block;padding:10px 16px;color:rgba(255,255,255,.7);font-size:13px;border-left:3px solid transparent;transition:all .15s}
|
||
.nav-item:hover{color:#fff;background:rgba(255,255,255,.08)}
|
||
.nav-item.active{color:#fff;background:rgba(33,133,208,.25);border-left-color:var(--accent)}
|
||
/* Content */
|
||
.main{flex:1;display:flex;flex-direction:column;overflow:auto}
|
||
.topbar{padding:13px 24px;background:#1b1c1d;display:flex;align-items:center;gap:12px}
|
||
.topbar h1{font-size:16px;font-weight:700;color:rgba(255,255,255,.9)}
|
||
.content{padding:24px;flex:1}
|
||
/* Cards */
|
||
.card{background:var(--surface);border:1px solid var(--border);border-radius:4px;box-shadow:0 1px 2px rgba(34,36,38,.15);margin-bottom:16px;overflow:hidden}
|
||
.card-head{padding:11px 16px;background:var(--surface-2);border-bottom:1px solid var(--border);font-weight:700;font-size:13px;display:flex;align-items:center;gap:8px}
|
||
.card-head-actions{justify-content:space-between}
|
||
.card-head-buttons{display:flex;align-items:center;gap:8px;margin-left:auto;flex-wrap:wrap}
|
||
.card-body{padding:16px}
|
||
/* Buttons */
|
||
.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:4px;font-size:13px;font-weight:700;cursor:pointer;border:none;transition:background .1s;font-family:inherit}
|
||
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent-dark)}
|
||
.btn-danger{background:#db2828;color:#fff}.btn-danger:hover{background:#b91c1c}
|
||
.btn-secondary{background:var(--surface-2);color:var(--ink);border:1px solid var(--border)}.btn-secondary:hover{background:#eee}
|
||
.btn-sm{padding:5px 10px;font-size:12px}
|
||
/* Tables */
|
||
table{width:100%;border-collapse:collapse;font-size:13px;background:var(--surface)}
|
||
th{text-align:left;padding:9px 14px;color:var(--ink);font-weight:700;background:var(--surface-2);border-bottom:1px solid var(--border-lite)}
|
||
td{padding:9px 14px;border-top:1px solid var(--border-lite)}
|
||
tr:first-child td{border-top:0}
|
||
tbody tr:hover td{background:rgba(0,0,0,.03)}
|
||
/* Status badges */
|
||
.badge{display:inline-block;padding:2px 9px;border-radius:4px;font-size:11px;font-weight:700}
|
||
.badge-ok{background:var(--ok-bg);color:var(--ok-fg);border:1px solid #a3c293}
|
||
.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}
|
||
/* Forms */
|
||
.form-row{margin-bottom:14px}
|
||
.form-row label{display:block;font-size:12px;color:var(--muted);margin-bottom:5px;font-weight:700}
|
||
.form-row input,.form-row select{width:100%;padding:8px 10px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--ink);font-size:13px;outline:none;font-family:inherit}
|
||
.form-row input:focus,.form-row select:focus{border-color:var(--accent);box-shadow:0 0 0 2px rgba(33,133,208,.2)}
|
||
/* Grid */
|
||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||
.grid3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}
|
||
@media(max-width:900px){.grid2,.grid3{grid-template-columns:1fr}.card-head-actions{align-items:flex-start;flex-direction:column}.card-head-buttons{margin-left:0}}
|
||
/* iframe viewer */
|
||
.viewer-frame{width:100%;height:calc(100vh - 160px);border:0;border-radius:4px;background:var(--surface-2)}
|
||
/* Alerts */
|
||
.alert{padding:10px 14px;border-radius:4px;font-size:13px;margin-bottom:14px}
|
||
.alert-info{background:#dff0ff;border:1px solid #a9d4f5;color:#1e3a5f}
|
||
.alert-warn{background:var(--warn-bg);border:1px solid #c9ba9b;color:var(--warn-fg)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
`
|
||
}
|
||
|
||
func layoutNav(active string, buildLabel string) string {
|
||
items := []struct{ id, label, href, onclick string }{
|
||
{"dashboard", "Dashboard", "/", ""},
|
||
{"audit", "Audit", "/audit", ""},
|
||
{"validate", "Validate", "/validate", ""},
|
||
{"burn", "Burn", "/burn", ""},
|
||
{"benchmark", "Benchmark", "/benchmark", ""},
|
||
{"tasks", "Tasks", "/tasks", ""},
|
||
{"tools", "Tools", "/tools", ""},
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString(`<aside class="sidebar">`)
|
||
b.WriteString(`<div class="sidebar-logo">bee<span>hardware audit</span></div>`)
|
||
if strings.TrimSpace(buildLabel) == "" {
|
||
buildLabel = "dev"
|
||
}
|
||
b.WriteString(`<div class="sidebar-version">Version ` + html.EscapeString(buildLabel) + `</div>`)
|
||
if raw, err := os.ReadFile("/run/bee-nvidia-mode"); err == nil {
|
||
gspMode := strings.TrimSpace(string(raw))
|
||
switch gspMode {
|
||
case "gsp-off":
|
||
b.WriteString(`<div class="sidebar-badge sidebar-badge-warn">NVIDIA GSP=off</div>`)
|
||
case "gsp-stuck":
|
||
b.WriteString(`<div class="sidebar-badge sidebar-badge-crit">NVIDIA GSP stuck — reboot</div>`)
|
||
}
|
||
}
|
||
b.WriteString(`<nav class="nav">`)
|
||
for _, item := range items {
|
||
cls := "nav-item"
|
||
if item.id == active {
|
||
cls += " active"
|
||
}
|
||
if item.onclick != "" {
|
||
b.WriteString(fmt.Sprintf(`<a class="%s" href="%s" onclick="%s">%s</a>`,
|
||
cls, item.href, item.onclick, item.label))
|
||
} else {
|
||
b.WriteString(fmt.Sprintf(`<a class="%s" href="%s">%s</a>`,
|
||
cls, item.href, item.label))
|
||
}
|
||
}
|
||
b.WriteString(`</nav>`)
|
||
b.WriteString(`</aside>`)
|
||
return b.String()
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// ── Metrics ───────────────────────────────────────────────────────────────────
|
||
|
||
func renderMetrics() string {
|
||
return `<p style="color:var(--muted);font-size:13px;margin-bottom:16px">Live metrics — updated every 2 seconds.</p>
|
||
|
||
<div class="card" style="margin-bottom:16px">
|
||
<div class="card-head">Server — Load</div>
|
||
<div class="card-body" style="padding:8px">
|
||
<img id="chart-server-load" data-chart-refresh="1" src="/api/metrics/chart/server-load.svg" style="width:100%;display:block;border-radius:6px" alt="CPU/Mem load">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-bottom:16px">
|
||
<div class="card-head">Temperature — CPU</div>
|
||
<div class="card-body" style="padding:8px">
|
||
<img id="chart-server-temp-cpu" data-chart-refresh="1" src="/api/metrics/chart/server-temp-cpu.svg" style="width:100%;display:block;border-radius:6px" alt="CPU temperature">
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<div class="card" style="margin-bottom:16px">
|
||
<div class="card-head">Temperature — Ambient Sensors</div>
|
||
<div class="card-body" style="padding:8px">
|
||
<img id="chart-server-temp-ambient" data-chart-refresh="1" src="/api/metrics/chart/server-temp-ambient.svg" style="width:100%;display:block;border-radius:6px" alt="Ambient temperature sensors">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-bottom:16px">
|
||
<div class="card-head">Server — Power</div>
|
||
<div class="card-body" style="padding:8px">
|
||
<img id="chart-server-power" data-chart-refresh="1" src="/api/metrics/chart/server-power.svg" style="width:100%;display:block;border-radius:6px" alt="System power">
|
||
</div>
|
||
</div>
|
||
|
||
<div id="card-server-fans" class="card" style="margin-bottom:16px;display:none">
|
||
<div class="card-head">Server — Fan RPM</div>
|
||
<div class="card-body" style="padding:8px">
|
||
<img id="chart-server-fans" data-chart-refresh="1" src="/api/metrics/chart/server-fans.svg" style="width:100%;display:block;border-radius:6px" alt="Fan RPM">
|
||
</div>
|
||
</div>
|
||
|
||
<section id="gpu-metrics-section" style="display:none;margin-top:24px;padding:16px 16px 4px;border:1px solid #d7e0ea;border-radius:10px;background:linear-gradient(180deg,#f7fafc 0%,#eef4f8 100%)">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:14px">
|
||
<div>
|
||
<div style="font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#486581">GPU Metrics</div>
|
||
<div id="gpu-metrics-summary" style="font-size:13px;color:var(--muted);margin-top:4px">Detected GPUs are rendered in a dedicated section.</div>
|
||
</div>
|
||
<label style="display:inline-flex;align-items:center;gap:8px;font-size:13px;color:var(--ink);font-weight:700;cursor:pointer">
|
||
<input id="gpu-chart-toggle" type="checkbox">
|
||
<span>One chart per GPU</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div id="gpu-metrics-by-metric">
|
||
<div class="card" style="margin-bottom:16px">
|
||
<div class="card-head">GPU — Compute Load</div>
|
||
<div class="card-body" style="padding:8px">
|
||
<img id="chart-gpu-all-load" data-chart-refresh="1" src="/api/metrics/chart/gpu-all-load.svg" style="width:100%;display:block;border-radius:6px" alt="GPU compute load">
|
||
</div>
|
||
</div>
|
||
<div class="card" style="margin-bottom:16px">
|
||
<div class="card-head">GPU — Memory Load</div>
|
||
<div class="card-body" style="padding:8px">
|
||
<img id="chart-gpu-all-memload" data-chart-refresh="1" src="/api/metrics/chart/gpu-all-memload.svg" style="width:100%;display:block;border-radius:6px" alt="GPU memory load">
|
||
</div>
|
||
</div>
|
||
<div class="card" style="margin-bottom:16px">
|
||
<div class="card-head">GPU — Core Clock</div>
|
||
<div class="card-body" style="padding:8px">
|
||
<img id="chart-gpu-all-clock" data-chart-refresh="1" src="/api/metrics/chart/gpu-all-clock.svg" style="width:100%;display:block;border-radius:6px" alt="GPU core clock">
|
||
</div>
|
||
</div>
|
||
<div class="card" style="margin-bottom:16px">
|
||
<div class="card-head">GPU — Power</div>
|
||
<div class="card-body" style="padding:8px">
|
||
<img id="chart-gpu-all-power" data-chart-refresh="1" src="/api/metrics/chart/gpu-all-power.svg" style="width:100%;display:block;border-radius:6px" alt="GPU power">
|
||
</div>
|
||
</div>
|
||
<div class="card" style="margin-bottom:16px">
|
||
<div class="card-head">GPU — Temperature</div>
|
||
<div class="card-body" style="padding:8px">
|
||
<img id="chart-gpu-all-temp" data-chart-refresh="1" src="/api/metrics/chart/gpu-all-temp.svg" style="width:100%;display:block;border-radius:6px" alt="GPU temperature">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="gpu-metrics-by-gpu" style="display:none"></div>
|
||
</section>
|
||
|
||
<script>
|
||
let gpuChartKey = '';
|
||
const gpuChartModeStorageKey = 'bee.metrics.gpuChartMode';
|
||
let metricsNvidiaGPUsPromise = null;
|
||
|
||
function loadMetricsNvidiaGPUs() {
|
||
if (!metricsNvidiaGPUsPromise) {
|
||
metricsNvidiaGPUsPromise = fetch('/api/gpu/nvidia')
|
||
.then(function(r) {
|
||
if (!r.ok) throw new Error('Failed to load NVIDIA GPUs.');
|
||
return r.json();
|
||
})
|
||
.then(function(list) { return Array.isArray(list) ? list : []; })
|
||
.catch(function() { return []; });
|
||
}
|
||
return metricsNvidiaGPUsPromise;
|
||
}
|
||
|
||
function metricsGPUNameMap(list) {
|
||
const out = {};
|
||
(list || []).forEach(function(gpu) {
|
||
const idx = Number(gpu.index);
|
||
if (!Number.isFinite(idx) || !gpu.name) return;
|
||
out[idx] = gpu.name;
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function metricsGPUDisplayLabel(idx, names) {
|
||
const name = names && names[idx];
|
||
return name ? ('GPU ' + idx + ' — ' + name) : ('GPU ' + idx);
|
||
}
|
||
|
||
function loadGPUChartModePreference() {
|
||
try {
|
||
return sessionStorage.getItem(gpuChartModeStorageKey) === 'per-gpu';
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function saveGPUChartModePreference(perGPU) {
|
||
try {
|
||
sessionStorage.setItem(gpuChartModeStorageKey, perGPU ? 'per-gpu' : 'per-metric');
|
||
} catch (_) {}
|
||
}
|
||
|
||
function refreshChartImage(el) {
|
||
if (!el || el.dataset.loading === '1') return;
|
||
if (el.offsetParent === null) return;
|
||
const baseSrc = el.dataset.baseSrc || el.src.split('?')[0];
|
||
const nextSrc = baseSrc + '?t=' + Date.now();
|
||
const probe = new Image();
|
||
el.dataset.baseSrc = baseSrc;
|
||
el.dataset.loading = '1';
|
||
probe.onload = function() {
|
||
el.src = nextSrc;
|
||
el.dataset.loading = '0';
|
||
};
|
||
probe.onerror = function() {
|
||
el.dataset.loading = '0';
|
||
};
|
||
probe.src = nextSrc;
|
||
}
|
||
|
||
function refreshCharts() {
|
||
document.querySelectorAll('img[data-chart-refresh="1"]').forEach(refreshChartImage);
|
||
}
|
||
|
||
function gpuIndices(rows) {
|
||
const seen = {};
|
||
const out = [];
|
||
(rows || []).forEach(function(row) {
|
||
const idx = Number(row.index);
|
||
if (!Number.isFinite(idx) || seen[idx]) return;
|
||
seen[idx] = true;
|
||
out.push(idx);
|
||
});
|
||
return out.sort(function(a, b) { return a - b; });
|
||
}
|
||
|
||
function renderGPUOverviewCards(indices, names) {
|
||
const host = document.getElementById('gpu-metrics-by-gpu');
|
||
if (!host) return;
|
||
host.innerHTML = indices.map(function(idx) {
|
||
const label = metricsGPUDisplayLabel(idx, names);
|
||
return '<div class="card" style="margin-bottom:16px">' +
|
||
'<div class="card-head">' + label + ' — Overview</div>' +
|
||
'<div class="card-body" style="padding:8px">' +
|
||
'<img id="chart-gpu-' + idx + '-overview" data-chart-refresh="1" src="/api/metrics/chart/gpu/' + idx + '-overview.svg" style="width:100%;display:block;border-radius:6px" alt="' + label + ' overview">' +
|
||
'</div></div>';
|
||
}).join('');
|
||
}
|
||
|
||
function applyGPUChartMode() {
|
||
const perMetric = document.getElementById('gpu-metrics-by-metric');
|
||
const perGPU = document.getElementById('gpu-metrics-by-gpu');
|
||
const toggle = document.getElementById('gpu-chart-toggle');
|
||
const gpuModePerGPU = !!(toggle && toggle.checked);
|
||
if (perMetric) perMetric.style.display = gpuModePerGPU ? 'none' : '';
|
||
if (perGPU) perGPU.style.display = gpuModePerGPU ? '' : 'none';
|
||
}
|
||
|
||
function syncMetricsLayout(d) {
|
||
const fanCard = document.getElementById('card-server-fans');
|
||
if (fanCard) fanCard.style.display = (d.fans && d.fans.length > 0) ? '' : 'none';
|
||
const section = document.getElementById('gpu-metrics-section');
|
||
const summary = document.getElementById('gpu-metrics-summary');
|
||
const indices = gpuIndices(d.gpus);
|
||
loadMetricsNvidiaGPUs().then(function(gpus) {
|
||
const names = metricsGPUNameMap(gpus);
|
||
if (section) section.style.display = indices.length > 0 ? '' : 'none';
|
||
if (summary) {
|
||
summary.textContent = indices.length > 0
|
||
? ('Detected GPUs: ' + indices.map(function(idx) { return metricsGPUDisplayLabel(idx, names); }).join(', '))
|
||
: 'No GPUs detected in live metrics.';
|
||
}
|
||
const nextKey = indices.join(',') + '|' + indices.map(function(idx) { return names[idx] || ''; }).join(',');
|
||
if (nextKey !== gpuChartKey) {
|
||
renderGPUOverviewCards(indices, names);
|
||
gpuChartKey = nextKey;
|
||
}
|
||
applyGPUChartMode();
|
||
});
|
||
}
|
||
|
||
function loadMetricsLayout() {
|
||
fetch('/api/metrics/latest').then(function(r) { return r.json(); }).then(syncMetricsLayout).catch(function() {});
|
||
}
|
||
|
||
const gpuChartToggle = document.getElementById('gpu-chart-toggle');
|
||
if (gpuChartToggle) {
|
||
gpuChartToggle.checked = loadGPUChartModePreference();
|
||
}
|
||
applyGPUChartMode();
|
||
|
||
if (gpuChartToggle) {
|
||
gpuChartToggle.addEventListener('change', function() {
|
||
saveGPUChartModePreference(!!gpuChartToggle.checked);
|
||
applyGPUChartMode();
|
||
refreshCharts();
|
||
});
|
||
}
|
||
|
||
loadMetricsLayout();
|
||
setInterval(refreshCharts, 3000);
|
||
setInterval(loadMetricsLayout, 5000);
|
||
</script>`
|
||
}
|
||
|
||
// ── Validate (Acceptance Tests) ───────────────────────────────────────────────
|
||
|
||
type validateInventory struct {
|
||
CPU string
|
||
Memory string
|
||
Storage string
|
||
NVIDIA string
|
||
AMD string
|
||
NvidiaGPUCount int
|
||
AMDGPUCount int
|
||
}
|
||
|
||
// validateFmtDur formats a duration in seconds as a human-readable "~N min" or "~N s" string.
|
||
func validateFmtDur(secs int) string {
|
||
if secs < 120 {
|
||
return fmt.Sprintf("~%d s", secs)
|
||
}
|
||
mins := (secs + 29) / 60
|
||
return fmt.Sprintf("~%d min", mins)
|
||
}
|
||
|
||
// validateTotalValidateSec returns the estimated wall-clock duration of
|
||
// "Validate one by one" in Validate mode for n NVIDIA GPUs.
|
||
func validateTotalValidateSec(n int) int {
|
||
if n < 0 {
|
||
n = 0
|
||
}
|
||
total := platform.SATEstimatedCPUValidateSec +
|
||
platform.SATEstimatedMemoryValidateSec +
|
||
n*platform.SATEstimatedNvidiaGPUValidatePerGPUSec +
|
||
platform.SATEstimatedNvidiaInterconnectSec +
|
||
platform.SATEstimatedNvidiaBandwidthSec
|
||
return total
|
||
}
|
||
|
||
// validateTotalStressSec returns the estimated wall-clock duration of
|
||
// "Validate one by one" in Stress mode for n NVIDIA GPUs.
|
||
func validateTotalStressSec(n int) int {
|
||
if n < 0 {
|
||
n = 0
|
||
}
|
||
total := platform.SATEstimatedCPUStressSec +
|
||
platform.SATEstimatedMemoryStressSec +
|
||
n*platform.SATEstimatedNvidiaGPUStressPerGPUSec +
|
||
n*platform.SATEstimatedNvidiaTargetedStressPerGPUSec +
|
||
n*platform.SATEstimatedNvidiaTargetedPowerPerGPUSec +
|
||
platform.SATEstimatedNvidiaPulseTestSec +
|
||
platform.SATEstimatedNvidiaInterconnectSec +
|
||
platform.SATEstimatedNvidiaBandwidthSec
|
||
return total
|
||
}
|
||
|
||
func renderValidate(opts HandlerOptions) string {
|
||
inv := loadValidateInventory(opts)
|
||
n := inv.NvidiaGPUCount
|
||
validateTotalStr := validateFmtDur(validateTotalValidateSec(n))
|
||
stressTotalStr := validateFmtDur(validateTotalStressSec(n))
|
||
gpuNote := ""
|
||
if n > 0 {
|
||
gpuNote = fmt.Sprintf(" (%d GPU)", n)
|
||
}
|
||
return `<div class="alert alert-info" style="margin-bottom:16px"><strong>Non-destructive:</strong> Validate tests collect diagnostics only. They do not write to disks, do not run sustained load, and do not increment hardware wear counters.</div>
|
||
<p style="color:var(--muted);font-size:13px;margin-bottom:16px">Tasks continue in the background — view progress in <a href="/tasks">Tasks</a>.</p>
|
||
|
||
<div class="card" style="margin-bottom:16px">
|
||
<div class="card-head">Validate Profile</div>
|
||
<div class="card-body validate-profile-body">
|
||
<div class="validate-profile-col">
|
||
<div class="form-row" style="margin:12px 0 0"><label>Mode</label></div>
|
||
<label class="cb-row"><input type="radio" name="sat-mode" id="sat-mode-validate" value="validate" checked onchange="satModeChanged()"><span>Validate — quick non-destructive check</span></label>
|
||
<label class="cb-row"><input type="radio" name="sat-mode" id="sat-mode-stress" value="stress" onchange="satModeChanged()"><span>Stress — thorough load test (` + stressTotalStr + gpuNote + `)</span></label>
|
||
</div>
|
||
<div class="validate-profile-col validate-profile-action">
|
||
<p style="color:var(--muted);font-size:12px;margin:0 0 10px">Runs validate modules sequentially. Validate: ` + validateTotalStr + gpuNote + `; Stress: ` + stressTotalStr + gpuNote + `. Estimates are based on real log data and scale with GPU count.</p>
|
||
<button type="button" class="btn btn-primary" onclick="runAllSAT()">Validate one by one</button>
|
||
<div style="margin-top:12px">
|
||
<span id="sat-all-status" style="font-size:12px;color:var(--muted)"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid3">
|
||
` + renderSATCard("cpu", "CPU", "runSAT('cpu')", "", renderValidateCardBody(
|
||
inv.CPU,
|
||
`Collects CPU inventory and temperatures, then runs a bounded CPU stress pass.`,
|
||
`<code>lscpu</code>, <code>sensors</code>, <code>stress-ng</code>`,
|
||
validateFmtDur(platform.SATEstimatedCPUValidateSec)+` in Validate (stress-ng 60 s). `+validateFmtDur(platform.SATEstimatedCPUStressSec)+` in Stress (stress-ng 30 min).`,
|
||
)) +
|
||
renderSATCard("memory", "Memory", "runSAT('memory')", "", renderValidateCardBody(
|
||
inv.Memory,
|
||
`Runs a RAM validation pass and records memory state around the test.`,
|
||
`<code>free</code>, <code>memtester</code>`,
|
||
validateFmtDur(platform.SATEstimatedMemoryValidateSec)+` in Validate (256 MB × 1 pass). `+validateFmtDur(platform.SATEstimatedMemoryStressSec)+` in Stress (512 MB × 1 pass).`,
|
||
)) +
|
||
renderSATCard("storage", "Storage", "runSAT('storage')", "", renderValidateCardBody(
|
||
inv.Storage,
|
||
`Scans all storage devices and runs the matching health or self-test path for each device type.`,
|
||
`<code>lsblk</code>; NVMe: <code>nvme</code>; SATA/SAS: <code>smartctl</code>`,
|
||
`Seconds in Validate (NVMe: instant device query; SATA/SAS: short self-test). Up to ~1 h per device in Stress (extended self-test, device-dependent).`,
|
||
)) +
|
||
`</div>
|
||
<div style="height:1px;background:var(--border);margin:16px 0"></div>
|
||
<div class="card" style="margin-bottom:16px">
|
||
<div class="card-head">NVIDIA GPU Selection</div>
|
||
<div class="card-body">
|
||
<p style="font-size:12px;color:var(--muted);margin:0 0 8px">` + inv.NVIDIA + `</p>
|
||
<p style="font-size:12px;color:var(--muted);margin:0 0 10px">All NVIDIA validate tasks use only the GPUs selected here. The same selection is used by Validate one by one.</p>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||
<button class="btn btn-sm btn-secondary" type="button" onclick="satSelectAllGPUs()">Select All</button>
|
||
<button class="btn btn-sm btn-secondary" type="button" onclick="satSelectNoGPUs()">Clear</button>
|
||
</div>
|
||
<div id="sat-gpu-list" style="border:1px solid var(--border);border-radius:4px;padding:12px;min-height:88px">
|
||
<p style="color:var(--muted);font-size:13px">Loading NVIDIA GPUs...</p>
|
||
</div>
|
||
<p id="sat-gpu-selection-note" style="font-size:12px;color:var(--muted);margin:10px 0 0">Select at least one NVIDIA GPU to enable NVIDIA validate tasks.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid3">
|
||
` + renderSATCard("nvidia", "NVIDIA GPU", "runNvidiaValidateSet('nvidia')", "", renderValidateCardBody(
|
||
inv.NVIDIA,
|
||
`Runs NVIDIA diagnostics and board inventory checks.`,
|
||
`<code>nvidia-smi</code>, <code>dmidecode</code>, <code>dcgmi diag</code>`,
|
||
func() string {
|
||
perV := platform.SATEstimatedNvidiaGPUValidatePerGPUSec
|
||
perS := platform.SATEstimatedNvidiaGPUStressPerGPUSec
|
||
if n > 0 {
|
||
return fmt.Sprintf("Validate: %s/GPU × %d = %s (Level 2, sequential). Stress: %s/GPU × %d = %s (Level 3, sequential).",
|
||
validateFmtDur(perV), n, validateFmtDur(perV*n),
|
||
validateFmtDur(perS), n, validateFmtDur(perS*n))
|
||
}
|
||
return fmt.Sprintf("Validate: %s/GPU (Level 2, sequential). Stress: %s/GPU (Level 3, sequential).",
|
||
validateFmtDur(perV), validateFmtDur(perS))
|
||
}(),
|
||
)) +
|
||
`<div id="sat-card-nvidia-targeted-stress">` +
|
||
renderSATCard("nvidia-targeted-stress", "NVIDIA GPU Targeted Stress", "runNvidiaValidateSet('nvidia-targeted-stress')", "", renderValidateCardBody(
|
||
inv.NVIDIA,
|
||
`Runs a controlled NVIDIA DCGM load to check stability under moderate stress.`,
|
||
`<code>dcgmi diag targeted_stress</code>`,
|
||
func() string {
|
||
per := platform.SATEstimatedNvidiaTargetedStressPerGPUSec
|
||
s := "Skipped in Validate. "
|
||
if n > 0 {
|
||
s += fmt.Sprintf("Stress: %s/GPU × %d = %s sequential.", validateFmtDur(per), n, validateFmtDur(per*n))
|
||
} else {
|
||
s += fmt.Sprintf("Stress: %s/GPU sequential.", validateFmtDur(per))
|
||
}
|
||
return s + `<p id="sat-ts-mode-hint" style="color:var(--warn-fg);font-size:12px;margin:8px 0 0">Only runs in Stress mode. Switch mode above to enable in Run All.</p>`
|
||
}(),
|
||
)) +
|
||
`</div>` +
|
||
`<div id="sat-card-nvidia-targeted-power">` +
|
||
renderSATCard("nvidia-targeted-power", "NVIDIA Targeted Power", "runNvidiaValidateSet('nvidia-targeted-power')", "", renderValidateCardBody(
|
||
inv.NVIDIA,
|
||
`Checks that the GPU can sustain its declared power delivery envelope. Pass/fail determined by DCGM.`,
|
||
`<code>dcgmi diag targeted_power</code>`,
|
||
func() string {
|
||
per := platform.SATEstimatedNvidiaTargetedPowerPerGPUSec
|
||
s := "Skipped in Validate. "
|
||
if n > 0 {
|
||
s += fmt.Sprintf("Stress: %s/GPU × %d = %s sequential.", validateFmtDur(per), n, validateFmtDur(per*n))
|
||
} else {
|
||
s += fmt.Sprintf("Stress: %s/GPU sequential.", validateFmtDur(per))
|
||
}
|
||
return s + `<p id="sat-tp-mode-hint" style="color:var(--warn-fg);font-size:12px;margin:8px 0 0">Only runs in Stress mode. Switch mode above to enable in Run All.</p>`
|
||
}(),
|
||
)) +
|
||
`</div>` +
|
||
`<div id="sat-card-nvidia-pulse">` +
|
||
renderSATCard("nvidia-pulse", "NVIDIA PSU Pulse Test", "runNvidiaFabricValidate('nvidia-pulse')", "", renderValidateCardBody(
|
||
inv.NVIDIA,
|
||
`Tests power supply transient response by pulsing all GPUs simultaneously between idle and full load. Synchronous pulses across all GPUs create worst-case PSU load spikes — running per-GPU would miss PSU-level failures.`,
|
||
`<code>dcgmi diag pulse_test</code>`,
|
||
`Skipped in Validate. Stress: `+validateFmtDur(platform.SATEstimatedNvidiaPulseTestSec)+` (all GPUs simultaneously; measured on 8-GPU system).`+`<p id="sat-pt-mode-hint" style="color:var(--warn-fg);font-size:12px;margin:8px 0 0">Only runs in Stress mode. Switch mode above to enable in Run All.</p>`,
|
||
)) +
|
||
`</div>` +
|
||
`<div id="sat-card-nvidia-interconnect">` +
|
||
renderSATCard("nvidia-interconnect", "NVIDIA Interconnect (NCCL)", "runNvidiaFabricValidate('nvidia-interconnect')", "", renderValidateCardBody(
|
||
inv.NVIDIA,
|
||
`Verifies NVLink/NVSwitch fabric bandwidth using NCCL all_reduce_perf across all selected GPUs. Pass/fail based on achieved bandwidth vs. theoretical.`,
|
||
`<code>all_reduce_perf</code> (NCCL tests)`,
|
||
`Validate and Stress: `+validateFmtDur(platform.SATEstimatedNvidiaInterconnectSec)+` (all GPUs simultaneously, requires ≥2).`,
|
||
)) +
|
||
`</div>` +
|
||
`<div id="sat-card-nvidia-bandwidth">` +
|
||
renderSATCard("nvidia-bandwidth", "NVIDIA Bandwidth (NVBandwidth)", "runNvidiaFabricValidate('nvidia-bandwidth')", "", renderValidateCardBody(
|
||
inv.NVIDIA,
|
||
`Validates GPU memory copy and peer-to-peer bandwidth paths using NVBandwidth.`,
|
||
`<code>nvbandwidth</code>`,
|
||
`Validate and Stress: `+validateFmtDur(platform.SATEstimatedNvidiaBandwidthSec)+` (all GPUs simultaneously; nvbandwidth runs all built-in tests without a time limit — duration set by the tool).`,
|
||
)) +
|
||
`</div>` +
|
||
`</div>
|
||
<div class="grid3" style="margin-top:16px">
|
||
` + renderSATCard("amd", "AMD GPU", "runAMDValidateSet()", "", renderValidateCardBody(
|
||
inv.AMD,
|
||
`Runs the selected AMD checks only. GPU Validate collects inventory; MEM Integrity uses the RVS MEM module; MEM Bandwidth uses rocm-bandwidth-test and the RVS BABEL module.`,
|
||
`GPU Validate: <code>rocm-smi</code>, <code>dmidecode</code>; MEM Integrity: <code>rvs mem</code>; MEM Bandwidth: <code>rocm-bandwidth-test</code>, <code>rvs babel</code>`,
|
||
`<div style="display:flex;flex-direction:column;gap:4px"><label class="cb-row"><input type="checkbox" id="sat-amd-target" checked><span>GPU Validate</span></label><label class="cb-row"><input type="checkbox" id="sat-amd-mem-target" checked><span>MEM Integrity</span></label><label class="cb-row"><input type="checkbox" id="sat-amd-bandwidth-target" checked><span>MEM Bandwidth</span></label></div>`,
|
||
)) +
|
||
`</div>
|
||
<div id="sat-output" style="display:none;margin-top:16px" class="card">
|
||
<div class="card-head">Test Output <span id="sat-title"></span></div>
|
||
<div class="card-body"><div id="sat-terminal" class="terminal"></div></div>
|
||
</div>
|
||
<style>
|
||
.validate-profile-body { display:grid; grid-template-columns:1fr 1fr 1fr; gap:24px; align-items:stretch; }
|
||
.validate-profile-col { min-width:0; display:flex; flex-direction:column; }
|
||
.validate-profile-action { display:flex; flex-direction:column; align-items:center; justify-content:center; }
|
||
.validate-card-body { padding:0; }
|
||
.validate-card-section { padding:12px 16px 0; }
|
||
.validate-card-section:last-child { padding-bottom:16px; }
|
||
.sat-gpu-row { display:flex; align-items:flex-start; gap:8px; padding:6px 0; cursor:pointer; font-size:13px; }
|
||
.sat-gpu-row input[type=checkbox] { width:16px; height:16px; margin-top:2px; flex-shrink:0; }
|
||
@media(max-width:900px){ .validate-profile-body { grid-template-columns:1fr; } }
|
||
</style>
|
||
<script>
|
||
let satES = null;
|
||
function satStressMode() {
|
||
return document.querySelector('input[name="sat-mode"]:checked')?.value === 'stress';
|
||
}
|
||
function satModeChanged() {
|
||
const stress = satStressMode();
|
||
[
|
||
{card: 'sat-card-nvidia-targeted-stress', hint: 'sat-ts-mode-hint'},
|
||
{card: 'sat-card-nvidia-targeted-power', hint: 'sat-tp-mode-hint'},
|
||
{card: 'sat-card-nvidia-pulse', hint: 'sat-pt-mode-hint'},
|
||
].forEach(function(item) {
|
||
const card = document.getElementById(item.card);
|
||
if (card) {
|
||
card.style.opacity = stress ? '1' : '0.5';
|
||
const hint = document.getElementById(item.hint);
|
||
if (hint) hint.style.display = stress ? 'none' : '';
|
||
}
|
||
});
|
||
}
|
||
function satLabels() {
|
||
return {nvidia:'Validate GPU', 'nvidia-targeted-stress':'NVIDIA Targeted Stress (dcgmi diag targeted_stress)', 'nvidia-targeted-power':'NVIDIA Targeted Power (dcgmi diag targeted_power)', 'nvidia-pulse':'NVIDIA PSU Pulse Test (dcgmi diag pulse_test)', 'nvidia-interconnect':'NVIDIA Interconnect (NCCL all_reduce_perf)', 'nvidia-bandwidth':'NVIDIA Bandwidth (NVBandwidth)', memory:'Validate Memory', storage:'Validate Storage', cpu:'Validate CPU', amd:'Validate AMD GPU', 'amd-mem':'AMD GPU MEM Integrity', 'amd-bandwidth':'AMD GPU MEM Bandwidth'};
|
||
}
|
||
let satNvidiaGPUsPromise = null;
|
||
function loadSatNvidiaGPUs() {
|
||
if (!satNvidiaGPUsPromise) {
|
||
satNvidiaGPUsPromise = fetch('/api/gpu/nvidia')
|
||
.then(r => {
|
||
if (!r.ok) throw new Error('Failed to load NVIDIA GPUs.');
|
||
return r.json();
|
||
})
|
||
.then(list => Array.isArray(list) ? list : []);
|
||
}
|
||
return satNvidiaGPUsPromise;
|
||
}
|
||
function satSelectedGPUIndices() {
|
||
return Array.from(document.querySelectorAll('.sat-nvidia-checkbox'))
|
||
.filter(function(el) { return el.checked && !el.disabled; })
|
||
.map(function(el) { return parseInt(el.value, 10); })
|
||
.filter(function(v) { return !Number.isNaN(v); })
|
||
.sort(function(a, b) { return a - b; });
|
||
}
|
||
function satUpdateGPUSelectionNote() {
|
||
const note = document.getElementById('sat-gpu-selection-note');
|
||
if (!note) return;
|
||
const selected = satSelectedGPUIndices();
|
||
if (!selected.length) {
|
||
note.textContent = 'Select at least one NVIDIA GPU to enable NVIDIA validate tasks.';
|
||
return;
|
||
}
|
||
note.textContent = 'Selected GPUs: ' + selected.join(', ') + '. Multi-GPU tests will use all selected GPUs.';
|
||
}
|
||
function satRenderGPUList(gpus) {
|
||
const root = document.getElementById('sat-gpu-list');
|
||
if (!root) return;
|
||
if (!gpus || !gpus.length) {
|
||
root.innerHTML = '<p style="color:var(--muted);font-size:13px">No NVIDIA GPUs detected.</p>';
|
||
satUpdateGPUSelectionNote();
|
||
return;
|
||
}
|
||
root.innerHTML = gpus.map(function(gpu) {
|
||
const mem = gpu.memory_mb > 0 ? ' · ' + gpu.memory_mb + ' MiB' : '';
|
||
return '<label class="sat-gpu-row">'
|
||
+ '<input class="sat-nvidia-checkbox" type="checkbox" value="' + gpu.index + '" checked onchange="satUpdateGPUSelectionNote()">'
|
||
+ '<span><strong>GPU ' + gpu.index + '</strong> — ' + gpu.name + mem + '</span>'
|
||
+ '</label>';
|
||
}).join('');
|
||
satUpdateGPUSelectionNote();
|
||
}
|
||
function satSelectAllGPUs() {
|
||
document.querySelectorAll('.sat-nvidia-checkbox').forEach(function(el) { el.checked = true; });
|
||
satUpdateGPUSelectionNote();
|
||
}
|
||
function satSelectNoGPUs() {
|
||
document.querySelectorAll('.sat-nvidia-checkbox').forEach(function(el) { el.checked = false; });
|
||
satUpdateGPUSelectionNote();
|
||
}
|
||
function satLoadGPUs() {
|
||
loadSatNvidiaGPUs().then(function(gpus) {
|
||
satRenderGPUList(gpus);
|
||
}).catch(function(err) {
|
||
const root = document.getElementById('sat-gpu-list');
|
||
if (root) {
|
||
root.innerHTML = '<p style="color:var(--crit-fg);font-size:13px">Error: ' + err.message + '</p>';
|
||
}
|
||
satUpdateGPUSelectionNote();
|
||
});
|
||
}
|
||
function satGPUDisplayName(gpu) {
|
||
const idx = (gpu && Number.isFinite(Number(gpu.index))) ? Number(gpu.index) : 0;
|
||
const name = gpu && gpu.name ? gpu.name : ('GPU ' + idx);
|
||
return 'GPU ' + idx + ' — ' + name;
|
||
}
|
||
function satRequestBody(target, overrides) {
|
||
const body = {};
|
||
const labels = satLabels();
|
||
body.display_name = labels[target] || ('Validate ' + target);
|
||
body.stress_mode = satStressMode();
|
||
if (target === 'cpu') body.duration = satStressMode() ? 1800 : 60;
|
||
if (overrides) {
|
||
Object.keys(overrides).forEach(key => { body[key] = overrides[key]; });
|
||
}
|
||
return body;
|
||
}
|
||
function enqueueSATTarget(target, overrides) {
|
||
return fetch('/api/sat/'+target+'/run', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(satRequestBody(target, overrides))})
|
||
.then(r => r.json());
|
||
}
|
||
function streamSATTask(taskId, title, resetTerminal) {
|
||
if (satES) { satES.close(); satES = null; }
|
||
document.getElementById('sat-output').style.display='block';
|
||
document.getElementById('sat-title').textContent = '— ' + title;
|
||
const term = document.getElementById('sat-terminal');
|
||
if (resetTerminal) {
|
||
term.textContent = '';
|
||
}
|
||
term.textContent += 'Task ' + taskId + ' queued. Streaming log...\n';
|
||
return new Promise(function(resolve) {
|
||
satES = new EventSource('/api/tasks/' + taskId + '/stream');
|
||
satES.onmessage = function(e) { term.textContent += e.data + '\n'; term.scrollTop = term.scrollHeight; };
|
||
satES.addEventListener('done', function(e) {
|
||
satES.close();
|
||
satES = null;
|
||
term.textContent += (e.data ? '\nERROR: ' + e.data : '\nCompleted.') + '\n';
|
||
term.scrollTop = term.scrollHeight;
|
||
resolve({ok: !e.data, error: e.data || ''});
|
||
});
|
||
satES.onerror = function() {
|
||
if (satES) {
|
||
satES.close();
|
||
satES = null;
|
||
}
|
||
term.textContent += '\nERROR: stream disconnected.\n';
|
||
term.scrollTop = term.scrollHeight;
|
||
resolve({ok: false, error: 'stream disconnected'});
|
||
};
|
||
});
|
||
}
|
||
function selectedAMDValidateTargets() {
|
||
const targets = [];
|
||
const gpu = document.getElementById('sat-amd-target');
|
||
const mem = document.getElementById('sat-amd-mem-target');
|
||
const bw = document.getElementById('sat-amd-bandwidth-target');
|
||
if (gpu && gpu.checked && !gpu.disabled) targets.push('amd');
|
||
if (mem && mem.checked && !mem.disabled) targets.push('amd-mem');
|
||
if (bw && bw.checked && !bw.disabled) targets.push('amd-bandwidth');
|
||
return targets;
|
||
}
|
||
function runSAT(target) {
|
||
return runSATWithOverrides(target, null);
|
||
}
|
||
function runSATWithOverrides(target, overrides) {
|
||
const title = (overrides && overrides.display_name) || target;
|
||
const term = document.getElementById('sat-terminal');
|
||
document.getElementById('sat-output').style.display='block';
|
||
document.getElementById('sat-title').textContent = '— ' + title;
|
||
term.textContent = 'Enqueuing ' + title + ' test...\n';
|
||
return enqueueSATTarget(target, overrides)
|
||
.then(d => streamSATTask(d.task_id, title, false));
|
||
}
|
||
const nvidiaPerGPUTargets = ['nvidia', 'nvidia-targeted-stress', 'nvidia-targeted-power'];
|
||
// pulse_test and fabric tests run on all selected GPUs simultaneously
|
||
const nvidiaAllGPUTargets = ['nvidia-pulse', 'nvidia-interconnect', 'nvidia-bandwidth'];
|
||
function satAllGPUIndicesForMulti() {
|
||
// Multi-GPU tests always use the current GPU selection.
|
||
return Promise.resolve(satSelectedGPUIndices());
|
||
}
|
||
function expandSATTarget(target) {
|
||
if (nvidiaAllGPUTargets.indexOf(target) >= 0) {
|
||
return satAllGPUIndicesForMulti().then(function(indices) {
|
||
if (!indices.length) return Promise.reject(new Error('No NVIDIA GPUs available.'));
|
||
return [{target: target, overrides: {gpu_indices: indices, display_name: satLabels()[target] || target}}];
|
||
});
|
||
}
|
||
if (nvidiaPerGPUTargets.indexOf(target) < 0) {
|
||
return Promise.resolve([{target: target}]);
|
||
}
|
||
const selected = satSelectedGPUIndices();
|
||
if (!selected.length) {
|
||
return Promise.reject(new Error('Select at least one NVIDIA GPU.'));
|
||
}
|
||
return loadSatNvidiaGPUs().then(gpus => gpus.filter(gpu => selected.indexOf(Number(gpu.index)) >= 0).map(gpu => ({
|
||
target: target,
|
||
overrides: {
|
||
gpu_indices: [Number(gpu.index)],
|
||
display_name: (satLabels()[target] || ('Validate ' + target)) + ' (' + satGPUDisplayName(gpu) + ')'
|
||
},
|
||
label: satGPUDisplayName(gpu)
|
||
})));
|
||
}
|
||
function runNvidiaFabricValidate(target) {
|
||
satAllGPUIndicesForMulti().then(function(indices) {
|
||
if (!indices.length) { alert('No NVIDIA GPUs available.'); return; }
|
||
runSATWithOverrides(target, {gpu_indices: indices, display_name: satLabels()[target] || target});
|
||
});
|
||
}
|
||
function runNvidiaValidateSet(target) {
|
||
return loadSatNvidiaGPUs().then(gpus => {
|
||
const selected = satSelectedGPUIndices();
|
||
const picked = gpus.filter(gpu => selected.indexOf(Number(gpu.index)) >= 0);
|
||
if (!picked.length) {
|
||
throw new Error('Select at least one NVIDIA GPU.');
|
||
}
|
||
if (picked.length === 1) {
|
||
const gpu = picked[0];
|
||
return runSATWithOverrides(target, {
|
||
gpu_indices: [Number(gpu.index)],
|
||
display_name: (satLabels()[target] || ('Validate ' + target)) + ' (' + satGPUDisplayName(gpu) + ')'
|
||
});
|
||
}
|
||
document.getElementById('sat-output').style.display='block';
|
||
document.getElementById('sat-title').textContent = '— ' + target;
|
||
const term = document.getElementById('sat-terminal');
|
||
term.textContent = 'Running ' + target + ' one GPU at a time...\n';
|
||
const labelBase = satLabels()[target] || ('Validate ' + target);
|
||
const runNext = (idx) => {
|
||
if (idx >= picked.length) return Promise.resolve();
|
||
const gpu = picked[idx];
|
||
const gpuLabel = satGPUDisplayName(gpu);
|
||
term.textContent += '\n[' + (idx + 1) + '/' + picked.length + '] ' + gpuLabel + '\n';
|
||
return enqueueSATTarget(target, {
|
||
gpu_indices: [Number(gpu.index)],
|
||
display_name: labelBase + ' (' + gpuLabel + ')'
|
||
}).then(d => {
|
||
return streamSATTask(d.task_id, labelBase + ' (' + gpuLabel + ')', false);
|
||
}).then(function() {
|
||
return runNext(idx + 1);
|
||
});
|
||
};
|
||
return runNext(0);
|
||
});
|
||
}
|
||
function runAMDValidateSet() {
|
||
const targets = selectedAMDValidateTargets();
|
||
if (!targets.length) return;
|
||
if (targets.length === 1) return runSAT(targets[0]);
|
||
document.getElementById('sat-output').style.display='block';
|
||
document.getElementById('sat-title').textContent = '— amd';
|
||
const term = document.getElementById('sat-terminal');
|
||
term.textContent = 'Running AMD validate set one by one...\n';
|
||
const labels = satLabels();
|
||
const runNext = (idx) => {
|
||
if (idx >= targets.length) return Promise.resolve();
|
||
const target = targets[idx];
|
||
term.textContent += '\n[' + (idx + 1) + '/' + targets.length + '] ' + labels[target] + '\n';
|
||
return enqueueSATTarget(target)
|
||
.then(d => {
|
||
return streamSATTask(d.task_id, labels[target], false);
|
||
}).then(function() {
|
||
return runNext(idx + 1);
|
||
});
|
||
};
|
||
return runNext(0);
|
||
}
|
||
function runAllSAT() {
|
||
const cycles = 1;
|
||
const status = document.getElementById('sat-all-status');
|
||
status.textContent = 'Enqueuing...';
|
||
const stressOnlyTargets = ['nvidia-targeted-stress', 'nvidia-targeted-power', 'nvidia-pulse'];
|
||
const baseTargets = ['nvidia','nvidia-targeted-stress','nvidia-targeted-power','nvidia-pulse','nvidia-interconnect','nvidia-bandwidth','memory','storage','cpu'].concat(selectedAMDValidateTargets());
|
||
const activeTargets = baseTargets.filter(target => {
|
||
if (stressOnlyTargets.indexOf(target) >= 0 && !satStressMode()) return false;
|
||
const btn = document.getElementById('sat-btn-' + target);
|
||
return !(btn && btn.disabled);
|
||
});
|
||
Promise.all(activeTargets.map(expandSATTarget)).then(groups => {
|
||
const expanded = [];
|
||
for (let cycle = 0; cycle < cycles; cycle++) {
|
||
groups.forEach(group => group.forEach(item => expanded.push(item)));
|
||
}
|
||
const total = expanded.length;
|
||
let enqueued = 0;
|
||
if (!total) {
|
||
status.textContent = 'No tasks selected.';
|
||
return;
|
||
}
|
||
const runNext = (idx) => {
|
||
if (idx >= expanded.length) { status.textContent = 'Completed ' + total + ' task(s).'; return Promise.resolve(); }
|
||
const item = expanded[idx];
|
||
status.textContent = 'Running ' + (idx + 1) + '/' + total + '...';
|
||
return enqueueSATTarget(item.target, item.overrides)
|
||
.then(() => {
|
||
enqueued++;
|
||
return runNext(idx + 1);
|
||
});
|
||
};
|
||
return runNext(0);
|
||
}).catch(err => {
|
||
status.textContent = 'Error: ' + err.message;
|
||
});
|
||
}
|
||
</script>
|
||
<script>
|
||
fetch('/api/gpu/presence').then(r=>r.json()).then(gp => {
|
||
if (!gp.nvidia) disableSATCard('nvidia', 'No NVIDIA GPU detected');
|
||
if (!gp.nvidia) disableSATCard('nvidia-targeted-stress', 'No NVIDIA GPU detected');
|
||
if (!gp.nvidia) disableSATCard('nvidia-targeted-power', 'No NVIDIA GPU detected');
|
||
if (!gp.nvidia) disableSATCard('nvidia-pulse', 'No NVIDIA GPU detected');
|
||
if (!gp.nvidia) disableSATCard('nvidia-interconnect', 'No NVIDIA GPU detected');
|
||
if (!gp.nvidia) disableSATCard('nvidia-bandwidth', 'No NVIDIA GPU detected');
|
||
if (!gp.amd) disableSATCard('amd', 'No AMD GPU detected');
|
||
if (!gp.amd) disableSATAMDOptions('No AMD GPU detected');
|
||
});
|
||
satLoadGPUs();
|
||
function disableSATAMDOptions(reason) {
|
||
['sat-amd-target','sat-amd-mem-target','sat-amd-bandwidth-target'].forEach(function(id) {
|
||
const cb = document.getElementById(id);
|
||
if (!cb) return;
|
||
cb.disabled = true;
|
||
cb.checked = false;
|
||
cb.title = reason;
|
||
});
|
||
}
|
||
function disableSATCard(id, reason) {
|
||
const btn = document.getElementById('sat-btn-' + id);
|
||
if (!btn) return;
|
||
btn.disabled = true;
|
||
btn.title = reason;
|
||
btn.style.opacity = '0.4';
|
||
const card = btn.closest('.card');
|
||
if (card) {
|
||
let note = card.querySelector('.sat-unavail');
|
||
if (!note) {
|
||
note = document.createElement('p');
|
||
note.className = 'sat-unavail';
|
||
note.style.cssText = 'color:var(--muted);font-size:12px;margin:0 0 8px';
|
||
const body = card.querySelector('.card-body');
|
||
if (body) body.insertBefore(note, body.firstChild);
|
||
}
|
||
note.textContent = reason;
|
||
}
|
||
}
|
||
</script>`
|
||
}
|
||
|
||
func loadValidateInventory(opts HandlerOptions) validateInventory {
|
||
unknown := "Audit snapshot not loaded."
|
||
out := validateInventory{
|
||
CPU: unknown,
|
||
Memory: unknown,
|
||
Storage: unknown,
|
||
NVIDIA: unknown,
|
||
AMD: unknown,
|
||
}
|
||
data, err := loadSnapshot(opts.AuditPath)
|
||
if err != nil {
|
||
return out
|
||
}
|
||
var snap schema.HardwareIngestRequest
|
||
if err := json.Unmarshal(data, &snap); err != nil {
|
||
return out
|
||
}
|
||
|
||
cpuCounts := map[string]int{}
|
||
cpuTotal := 0
|
||
for _, cpu := range snap.Hardware.CPUs {
|
||
if cpu.Present != nil && !*cpu.Present {
|
||
continue
|
||
}
|
||
cpuTotal++
|
||
addValidateModel(cpuCounts, validateFirstNonEmpty(validateTrimPtr(cpu.Model), validateTrimPtr(cpu.Manufacturer), "unknown"))
|
||
}
|
||
|
||
memCounts := map[string]int{}
|
||
memTotal := 0
|
||
for _, dimm := range snap.Hardware.Memory {
|
||
if dimm.Present != nil && !*dimm.Present {
|
||
continue
|
||
}
|
||
memTotal++
|
||
addValidateModel(memCounts, validateFirstNonEmpty(validateTrimPtr(dimm.PartNumber), validateTrimPtr(dimm.Type), validateTrimPtr(dimm.Manufacturer), "unknown"))
|
||
}
|
||
|
||
storageCounts := map[string]int{}
|
||
storageTotal := 0
|
||
for _, dev := range snap.Hardware.Storage {
|
||
if dev.Present != nil && !*dev.Present {
|
||
continue
|
||
}
|
||
storageTotal++
|
||
addValidateModel(storageCounts, validateFirstNonEmpty(validateTrimPtr(dev.Model), validateTrimPtr(dev.Manufacturer), "unknown"))
|
||
}
|
||
|
||
nvidiaCounts := map[string]int{}
|
||
nvidiaTotal := 0
|
||
amdCounts := map[string]int{}
|
||
amdTotal := 0
|
||
for _, dev := range snap.Hardware.PCIeDevices {
|
||
if dev.Present != nil && !*dev.Present {
|
||
continue
|
||
}
|
||
if validateIsVendorGPU(dev, "nvidia") {
|
||
nvidiaTotal++
|
||
addValidateModel(nvidiaCounts, validateFirstNonEmpty(validateTrimPtr(dev.Model), validateTrimPtr(dev.Manufacturer), "unknown"))
|
||
}
|
||
if validateIsVendorGPU(dev, "amd") {
|
||
amdTotal++
|
||
addValidateModel(amdCounts, validateFirstNonEmpty(validateTrimPtr(dev.Model), validateTrimPtr(dev.Manufacturer), "unknown"))
|
||
}
|
||
}
|
||
|
||
out.CPU = formatValidateDeviceSummary(cpuTotal, cpuCounts, "CPU")
|
||
out.Memory = formatValidateDeviceSummary(memTotal, memCounts, "module")
|
||
out.Storage = formatValidateDeviceSummary(storageTotal, storageCounts, "device")
|
||
out.NVIDIA = formatValidateDeviceSummary(nvidiaTotal, nvidiaCounts, "GPU")
|
||
out.AMD = formatValidateDeviceSummary(amdTotal, amdCounts, "GPU")
|
||
out.NvidiaGPUCount = nvidiaTotal
|
||
out.AMDGPUCount = amdTotal
|
||
return out
|
||
}
|
||
|
||
func renderValidateCardBody(devices, description, commands, settings string) string {
|
||
return `<div class="validate-card-section"><div style="font-size:13px;color:var(--muted)">` + devices + `</div></div>` +
|
||
`<div class="validate-card-section"><div style="font-size:13px">` + description + `</div></div>` +
|
||
`<div class="validate-card-section"><div style="font-size:13px">` + commands + `</div></div>` +
|
||
`<div class="validate-card-section"><div style="font-size:13px;color:var(--muted)">` + settings + `</div></div>`
|
||
}
|
||
|
||
func formatValidateDeviceSummary(total int, models map[string]int, unit string) string {
|
||
if total == 0 {
|
||
return "0 " + unit + "s detected."
|
||
}
|
||
keys := make([]string, 0, len(models))
|
||
for key := range models {
|
||
keys = append(keys, key)
|
||
}
|
||
sort.Strings(keys)
|
||
parts := make([]string, 0, len(keys))
|
||
for _, key := range keys {
|
||
parts = append(parts, fmt.Sprintf("%d x %s", models[key], html.EscapeString(key)))
|
||
}
|
||
label := unit
|
||
if total != 1 {
|
||
label += "s"
|
||
}
|
||
// If there is only one model the leading count duplicates the per-model
|
||
// count already in parts (e.g. "4 GPU: 4 x RTX …" → "4 x RTX …").
|
||
if len(parts) == 1 {
|
||
return parts[0] + " " + label
|
||
}
|
||
return fmt.Sprintf("%d %s: %s", total, label, strings.Join(parts, ", "))
|
||
}
|
||
|
||
func addValidateModel(counts map[string]int, name string) {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
name = "unknown"
|
||
}
|
||
counts[name]++
|
||
}
|
||
|
||
func validateTrimPtr(value *string) string {
|
||
if value == nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(*value)
|
||
}
|
||
|
||
func validateFirstNonEmpty(values ...string) string {
|
||
for _, value := range values {
|
||
value = strings.TrimSpace(value)
|
||
if value != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func validateIsVendorGPU(dev schema.HardwarePCIeDevice, vendor string) bool {
|
||
model := strings.ToLower(validateTrimPtr(dev.Model))
|
||
manufacturer := strings.ToLower(validateTrimPtr(dev.Manufacturer))
|
||
class := strings.ToLower(validateTrimPtr(dev.DeviceClass))
|
||
if strings.Contains(model, "aspeed") || strings.Contains(manufacturer, "aspeed") {
|
||
return false
|
||
}
|
||
switch vendor {
|
||
case "nvidia":
|
||
return strings.Contains(model, "nvidia") || strings.Contains(manufacturer, "nvidia")
|
||
case "amd":
|
||
isGPUClass := class == "processingaccelerator" || class == "displaycontroller" || class == "videocontroller"
|
||
isAMDVendor := strings.Contains(manufacturer, "advanced micro devices") || strings.Contains(manufacturer, "amd") || strings.Contains(manufacturer, "ati")
|
||
isAMDModel := strings.Contains(model, "instinct") || strings.Contains(model, "radeon") || strings.Contains(model, "amd")
|
||
return isGPUClass && (isAMDVendor || isAMDModel)
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func renderSATCard(id, label, runAction, headerActions, body string) string {
|
||
actions := `<button id="sat-btn-` + id + `" class="btn btn-primary btn-sm" onclick="` + runAction + `">Run</button>`
|
||
if strings.TrimSpace(headerActions) != "" {
|
||
actions += headerActions
|
||
}
|
||
return fmt.Sprintf(`<div class="card"><div class="card-head card-head-actions"><span>%s</span><div class="card-head-buttons">%s</div></div><div class="card-body validate-card-body">%s</div></div>`,
|
||
label, actions, body)
|
||
}
|
||
|
||
// ── Benchmark ─────────────────────────────────────────────────────────────────
|
||
|
||
type benchmarkHistoryRun struct {
|
||
generatedAt time.Time
|
||
displayTime string
|
||
gpuScores map[int]float64 // GPU index → composite score
|
||
gpuStatuses map[int]string // GPU index → status ("OK", "WARNING", "FAILED", …)
|
||
overallStatus string
|
||
}
|
||
|
||
func renderBenchmark(opts HandlerOptions) string {
|
||
return `<p style="color:var(--muted);font-size:13px;margin-bottom:16px">Benchmark runs generate a human-readable TXT report and machine-readable result bundle. Tasks continue in the background — view progress in <a href="/tasks">Tasks</a>.</p>
|
||
|
||
<div class="grid2">
|
||
<div class="card">
|
||
<div class="card-head">Benchmark Setup</div>
|
||
<div class="card-body">
|
||
<div class="form-row">
|
||
<label>Profile</label>
|
||
<select id="benchmark-profile">
|
||
<option value="standard" selected>Standard — Perf ` + validateFmtDur(platform.BenchmarkEstimatedPerfStandardSec) + ` / Power Fit ` + validateFmtDur(platform.BenchmarkEstimatedPowerStandardSec) + `</option>
|
||
<option value="stability">Stability — Perf ` + validateFmtDur(platform.BenchmarkEstimatedPerfStabilitySec) + ` / Power Fit ` + validateFmtDur(platform.BenchmarkEstimatedPowerStabilitySec) + `</option>
|
||
<option value="overnight">Overnight — Perf ` + validateFmtDur(platform.BenchmarkEstimatedPerfOvernightSec) + ` / Power Fit ` + validateFmtDur(platform.BenchmarkEstimatedPowerOvernightSec) + `</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-row">
|
||
<label>GPU Selection</label>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||
<button class="btn btn-sm btn-secondary" type="button" onclick="benchmarkSelectAll()">Select All</button>
|
||
<button class="btn btn-sm btn-secondary" type="button" onclick="benchmarkSelectNone()">Clear</button>
|
||
</div>
|
||
<div id="benchmark-gpu-list" style="border:1px solid var(--border);border-radius:4px;padding:12px;min-height:88px">
|
||
<p style="color:var(--muted);font-size:13px">Loading NVIDIA GPUs...</p>
|
||
</div>
|
||
</div>
|
||
<label class="benchmark-cb-row">
|
||
<input type="radio" name="benchmark-mode" value="sequential" onchange="benchmarkUpdateSelectionNote()">
|
||
<span>Sequential — one GPU at a time</span>
|
||
</label>
|
||
<label class="benchmark-cb-row" id="benchmark-parallel-label">
|
||
<input type="radio" name="benchmark-mode" value="parallel" onchange="benchmarkUpdateSelectionNote()">
|
||
<span>Parallel — all selected GPUs simultaneously</span>
|
||
</label>
|
||
<label class="benchmark-cb-row" id="benchmark-ramp-label">
|
||
<input type="radio" name="benchmark-mode" value="ramp-up" checked onchange="benchmarkUpdateSelectionNote()">
|
||
<span>Ramp-up — 1 GPU → 2 → … → all selected (separate tasks)</span>
|
||
</label>
|
||
<p id="benchmark-selection-note" style="font-size:12px;color:var(--muted);margin:10px 0 14px">Select one GPU for single-card benchmarking or several GPUs for a constrained multi-GPU run.</p>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||
<button id="benchmark-run-performance-btn" class="btn btn-primary" onclick="runNvidiaBenchmark('performance')" disabled>▶ Run Performance Benchmark</button>
|
||
<button id="benchmark-run-power-fit-btn" class="btn btn-secondary" onclick="runNvidiaBenchmark('power-fit')" disabled>▶ Run Power / Thermal Fit</button>
|
||
</div>
|
||
<span id="benchmark-run-nccl" hidden>nccl-auto</span>
|
||
<span id="benchmark-run-status" style="margin-left:10px;font-size:12px;color:var(--muted)"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head">Method Split</div>
|
||
<div class="card-body">
|
||
<p style="font-size:13px;color:var(--muted);margin-bottom:10px">The benchmark page now exposes two fundamentally different test families so compute score and server power-fit are not mixed into one number.</p>
|
||
<table>
|
||
<tr><th>Run Type</th><th>Engine</th><th>Question</th><th>Standard</th><th>Stability</th></tr>
|
||
<tr><td>Performance Benchmark</td><td><code>bee-gpu-burn</code></td><td>How much isolated compute performance does the GPU realize in this server?</td><td>` + validateFmtDur(platform.BenchmarkEstimatedPerfStandardSec) + `</td><td>` + validateFmtDur(platform.BenchmarkEstimatedPerfStabilitySec) + `</td></tr>
|
||
<tr><td>Power / Thermal Fit</td><td><code>dcgmi targeted_power</code></td><td>How much power per GPU can this server sustain as GPU count ramps up?</td><td>` + validateFmtDur(platform.BenchmarkEstimatedPowerStandardSec) + `</td><td>` + validateFmtDur(platform.BenchmarkEstimatedPowerStabilitySec) + `</td></tr>
|
||
</table>
|
||
<p style="font-size:12px;color:var(--muted);margin-top:10px">Timings are per full ramp-up run (1 GPU → all selected), measured on 4–8 GPU servers. Use ramp-up mode for capacity work: it creates 1 GPU → 2 GPU → … → all selected steps so analysis software can derive server total score and watts-per-GPU curves.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
` + `<div id="benchmark-results-section">` + renderBenchmarkResultsCard(opts.ExportDir) + `</div>` + `
|
||
|
||
<div id="benchmark-output" style="display:none;margin-top:16px" class="card">
|
||
<div class="card-head">Benchmark Output <span id="benchmark-title"></span></div>
|
||
<div class="card-body"><div id="benchmark-terminal" class="terminal"></div></div>
|
||
</div>
|
||
|
||
<style>
|
||
.benchmark-cb-row { display:flex; align-items:flex-start; gap:8px; cursor:pointer; font-size:13px; }
|
||
.benchmark-cb-row input[type=checkbox] { width:16px; height:16px; margin-top:2px; flex-shrink:0; }
|
||
.benchmark-gpu-row { display:flex; align-items:flex-start; gap:8px; padding:6px 0; cursor:pointer; font-size:13px; }
|
||
.benchmark-gpu-row input[type=checkbox] { width:16px; height:16px; margin-top:2px; flex-shrink:0; }
|
||
</style>
|
||
|
||
<script>
|
||
let benchmarkES = null;
|
||
|
||
function benchmarkTaskIDs(payload) {
|
||
if (payload && Array.isArray(payload.task_ids) && payload.task_ids.length) return payload.task_ids;
|
||
if (payload && payload.task_id) return [payload.task_id];
|
||
return [];
|
||
}
|
||
|
||
function benchmarkSelectedGPUIndices() {
|
||
return Array.from(document.querySelectorAll('.benchmark-gpu-checkbox'))
|
||
.filter(function(el) { return el.checked && !el.disabled; })
|
||
.map(function(el) { return parseInt(el.value, 10); })
|
||
.filter(function(v) { return !Number.isNaN(v); })
|
||
.sort(function(a, b) { return a - b; });
|
||
}
|
||
|
||
function benchmarkMode() {
|
||
const el = document.querySelector('input[name="benchmark-mode"]:checked');
|
||
return el ? el.value : 'sequential';
|
||
}
|
||
|
||
function benchmarkUpdateSelectionNote() {
|
||
const selected = benchmarkSelectedGPUIndices();
|
||
const perfBtn = document.getElementById('benchmark-run-performance-btn');
|
||
const fitBtn = document.getElementById('benchmark-run-power-fit-btn');
|
||
const note = document.getElementById('benchmark-selection-note');
|
||
if (!selected.length) {
|
||
perfBtn.disabled = true;
|
||
fitBtn.disabled = true;
|
||
note.textContent = 'Select at least one NVIDIA GPU to run the benchmark.';
|
||
return;
|
||
}
|
||
perfBtn.disabled = false;
|
||
fitBtn.disabled = false;
|
||
const mode = benchmarkMode();
|
||
if (mode === 'ramp-up') {
|
||
note.textContent = 'Ramp-up: ' + selected.length + ' tasks (1 GPU → ' + selected.length + ' GPUs). Performance uses compute benchmark; Power / Thermal Fit uses targeted_power per step.';
|
||
} else if (mode === 'parallel') {
|
||
note.textContent = 'Parallel: all ' + selected.length + ' GPU(s) simultaneously. Only the performance benchmark supports this mode.';
|
||
} else {
|
||
note.textContent = 'Sequential: each selected GPU benchmarked separately.';
|
||
}
|
||
}
|
||
|
||
function benchmarkRenderGPUList(gpus) {
|
||
const root = document.getElementById('benchmark-gpu-list');
|
||
if (!gpus || !gpus.length) {
|
||
root.innerHTML = '<p style="color:var(--muted);font-size:13px">No NVIDIA GPUs detected.</p>';
|
||
benchmarkUpdateSelectionNote();
|
||
return;
|
||
}
|
||
root.innerHTML = gpus.map(function(gpu) {
|
||
const mem = gpu.memory_mb > 0 ? ' · ' + gpu.memory_mb + ' MiB' : '';
|
||
return '<label class="benchmark-gpu-row">'
|
||
+ '<input class="benchmark-gpu-checkbox" type="checkbox" value="' + gpu.index + '" checked onchange="benchmarkUpdateSelectionNote()">'
|
||
+ '<span><strong>GPU ' + gpu.index + '</strong> — ' + gpu.name + mem + '</span>'
|
||
+ '</label>';
|
||
}).join('');
|
||
benchmarkApplyMultiGPUState(gpus.length);
|
||
benchmarkUpdateSelectionNote();
|
||
}
|
||
|
||
// Disable radio options that require multiple GPUs when only one is present.
|
||
function benchmarkApplyMultiGPUState(gpuCount) {
|
||
var multiValues = ['parallel', 'ramp-up'];
|
||
var radios = document.querySelectorAll('input[name="benchmark-mode"]');
|
||
radios.forEach(function(el) {
|
||
var isMulti = multiValues.indexOf(el.value) >= 0;
|
||
if (gpuCount < 2 && isMulti) {
|
||
el.disabled = true;
|
||
if (el.checked) {
|
||
// fall back to sequential
|
||
var seq = document.querySelector('input[name="benchmark-mode"][value="sequential"]');
|
||
if (seq) seq.checked = true;
|
||
}
|
||
var label = el.closest('label');
|
||
if (label) label.style.opacity = '0.4';
|
||
} else {
|
||
el.disabled = false;
|
||
// restore default: ramp-up checked when ≥2 GPUs
|
||
if (gpuCount >= 2 && el.value === 'ramp-up') el.checked = true;
|
||
var label = el.closest('label');
|
||
if (label) label.style.opacity = '';
|
||
}
|
||
});
|
||
benchmarkUpdateSelectionNote();
|
||
}
|
||
|
||
function benchmarkLoadGPUs() {
|
||
const status = document.getElementById('benchmark-run-status');
|
||
status.textContent = '';
|
||
fetch('/api/gpu/nvidia').then(function(r) {
|
||
return r.json().then(function(body) {
|
||
if (!r.ok) throw new Error(body.error || ('HTTP ' + r.status));
|
||
return body;
|
||
});
|
||
}).then(function(gpus) {
|
||
benchmarkRenderGPUList(gpus);
|
||
}).catch(function(err) {
|
||
document.getElementById('benchmark-gpu-list').innerHTML = '<p style="color:var(--crit-fg);font-size:13px">Error: ' + err.message + '</p>';
|
||
benchmarkUpdateSelectionNote();
|
||
});
|
||
}
|
||
|
||
function benchmarkSelectAll() {
|
||
document.querySelectorAll('.benchmark-gpu-checkbox').forEach(function(el) { el.checked = true; });
|
||
benchmarkUpdateSelectionNote();
|
||
}
|
||
|
||
function benchmarkSelectNone() {
|
||
document.querySelectorAll('.benchmark-gpu-checkbox').forEach(function(el) { el.checked = false; });
|
||
benchmarkUpdateSelectionNote();
|
||
}
|
||
|
||
function runNvidiaBenchmark(kind) {
|
||
const selected = benchmarkSelectedGPUIndices();
|
||
const status = document.getElementById('benchmark-run-status');
|
||
if (!selected.length) {
|
||
status.textContent = 'Select at least one GPU.';
|
||
return;
|
||
}
|
||
if (benchmarkES) { benchmarkES.close(); benchmarkES = null; }
|
||
const mode = benchmarkMode();
|
||
const rampUp = mode === 'ramp-up' && selected.length > 1;
|
||
const parallelGPUs = mode === 'parallel' && kind === 'performance';
|
||
if (kind === 'power-fit' && mode === 'parallel') {
|
||
status.textContent = 'Power / Thermal Fit supports sequential or ramp-up only.';
|
||
return;
|
||
}
|
||
const body = {
|
||
profile: document.getElementById('benchmark-profile').value || 'standard',
|
||
gpu_indices: selected,
|
||
run_nccl: kind === 'performance' && selected.length > 1,
|
||
parallel_gpus: parallelGPUs,
|
||
ramp_up: rampUp,
|
||
display_name: kind === 'power-fit' ? 'NVIDIA Power / Thermal Fit' : 'NVIDIA Performance Benchmark'
|
||
};
|
||
document.getElementById('benchmark-output').style.display = 'block';
|
||
document.getElementById('benchmark-title').textContent = '— ' + body.display_name + ' · ' + body.profile + ' [' + selected.join(', ') + ']';
|
||
const term = document.getElementById('benchmark-terminal');
|
||
term.textContent = 'Enqueuing ' + body.display_name + ' for GPUs ' + selected.join(', ') + '...\n';
|
||
status.textContent = 'Queueing...';
|
||
const endpoint = kind === 'power-fit' ? '/api/bee-bench/nvidia/power/run' : '/api/bee-bench/nvidia/perf/run';
|
||
fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify(body)
|
||
}).then(function(r) {
|
||
return r.json().then(function(payload) {
|
||
if (!r.ok) throw new Error(payload.error || ('HTTP ' + r.status));
|
||
return payload;
|
||
});
|
||
}).then(function(d) {
|
||
const taskIds = benchmarkTaskIDs(d);
|
||
if (!taskIds.length) throw new Error('No benchmark task was queued.');
|
||
status.textContent = taskIds.length === 1 ? ('Task ' + taskIds[0] + ' queued.') : ('Queued ' + taskIds.length + ' tasks.');
|
||
const streamNext = function(idx, failures) {
|
||
if (idx >= taskIds.length) {
|
||
status.textContent = failures ? 'Completed with failures.' : 'Completed.';
|
||
return;
|
||
}
|
||
const taskId = taskIds[idx];
|
||
term.textContent += '\n[' + (idx + 1) + '/' + taskIds.length + '] Task ' + taskId + ' queued. Streaming log...\n';
|
||
benchmarkES = new EventSource('/api/tasks/' + taskId + '/stream');
|
||
benchmarkES.onmessage = function(e) { term.textContent += e.data + '\n'; term.scrollTop = term.scrollHeight; };
|
||
benchmarkES.addEventListener('done', function(e) {
|
||
benchmarkES.close();
|
||
benchmarkES = null;
|
||
if (e.data) failures += 1;
|
||
term.textContent += (e.data ? '\nERROR: ' + e.data : '\nCompleted.') + '\n';
|
||
term.scrollTop = term.scrollHeight;
|
||
const isLast = (idx + 1 >= taskIds.length);
|
||
streamNext(idx + 1, failures);
|
||
if (isLast) { benchmarkRefreshResults(); }
|
||
});
|
||
benchmarkES.onerror = function() {
|
||
if (benchmarkES) {
|
||
benchmarkES.close();
|
||
benchmarkES = null;
|
||
}
|
||
term.textContent += '\nERROR: stream disconnected.\n';
|
||
term.scrollTop = term.scrollHeight;
|
||
streamNext(idx + 1, failures + 1);
|
||
};
|
||
};
|
||
streamNext(0, 0);
|
||
}).catch(function(err) {
|
||
status.textContent = 'Error.';
|
||
term.textContent += 'ERROR: ' + err.message + '\n';
|
||
});
|
||
}
|
||
|
||
benchmarkLoadGPUs();
|
||
|
||
function benchmarkRefreshResults() {
|
||
fetch('/api/benchmark/results')
|
||
.then(function(r) { return r.text(); })
|
||
.then(function(html) {
|
||
const el = document.getElementById('benchmark-results-section');
|
||
if (el) el.innerHTML = html;
|
||
})
|
||
.catch(function() {});
|
||
}
|
||
</script>`
|
||
}
|
||
|
||
func renderBenchmarkResultsCard(exportDir string) string {
|
||
maxIdx, runs := loadBenchmarkHistory(exportDir)
|
||
perf := renderBenchmarkResultsCardFromRuns(
|
||
"Performance Results",
|
||
"Composite score by saved benchmark run and GPU.",
|
||
"No saved performance benchmark runs yet.",
|
||
maxIdx,
|
||
runs,
|
||
)
|
||
power := renderPowerBenchmarkResultsCard(exportDir)
|
||
return perf + "\n" + power
|
||
}
|
||
|
||
func renderBenchmarkResultsCardFromRuns(title, description, emptyMessage string, maxGPUIndex int, runs []benchmarkHistoryRun) string {
|
||
if len(runs) == 0 {
|
||
return `<div class="card"><div class="card-head">` + html.EscapeString(title) + `</div><div class="card-body"><p style="color:var(--muted);font-size:13px">` + html.EscapeString(emptyMessage) + `</p></div></div>`
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString(`<div class="card"><div class="card-head">` + html.EscapeString(title) + `</div><div class="card-body">`)
|
||
if strings.TrimSpace(description) != "" {
|
||
b.WriteString(`<p style="color:var(--muted);font-size:13px;margin-bottom:12px">` + html.EscapeString(description) + `</p>`)
|
||
}
|
||
b.WriteString(`<div style="overflow-x:auto">`)
|
||
b.WriteString(`<table><thead><tr><th>Run</th><th>Time</th><th>Status</th>`)
|
||
for i := 0; i <= maxGPUIndex; i++ {
|
||
b.WriteString(`<th>GPU ` + strconv.Itoa(i) + `</th>`)
|
||
}
|
||
b.WriteString(`</tr></thead><tbody>`)
|
||
for i, run := range runs {
|
||
b.WriteString(`<tr>`)
|
||
b.WriteString(`<td>#` + strconv.Itoa(i+1) + `</td>`)
|
||
b.WriteString(`<td>` + html.EscapeString(run.displayTime) + `</td>`)
|
||
overallColor := "var(--ok)"
|
||
overallLabel := run.overallStatus
|
||
if overallLabel == "" {
|
||
overallLabel = "OK"
|
||
}
|
||
if overallLabel == "FAILED" {
|
||
overallColor = "var(--crit-fg,#9f3a38)"
|
||
} else if overallLabel != "OK" {
|
||
overallColor = "var(--warn)"
|
||
}
|
||
b.WriteString(`<td style="color:` + overallColor + `;font-weight:600">` + html.EscapeString(overallLabel) + `</td>`)
|
||
for idx := 0; idx <= maxGPUIndex; idx++ {
|
||
score, ok := run.gpuScores[idx]
|
||
if !ok {
|
||
b.WriteString(`<td style="color:var(--muted)">-</td>`)
|
||
continue
|
||
}
|
||
gpuStatus := run.gpuStatuses[idx]
|
||
scoreColor := ""
|
||
switch gpuStatus {
|
||
case "FAILED":
|
||
scoreColor = ` style="color:var(--crit-fg,#9f3a38);font-weight:600"`
|
||
case "WARNING", "PARTIAL":
|
||
scoreColor = ` style="color:var(--warn);font-weight:600"`
|
||
case "", "OK":
|
||
// no override
|
||
default:
|
||
scoreColor = ` style="color:var(--warn);font-weight:600"`
|
||
}
|
||
b.WriteString(`<td` + scoreColor + `>` + fmt.Sprintf("%.2f", score) + `</td>`)
|
||
}
|
||
b.WriteString(`</tr>`)
|
||
}
|
||
b.WriteString(`</tbody></table></div></div></div>`)
|
||
return b.String()
|
||
}
|
||
|
||
func loadBenchmarkHistory(exportDir string) (int, []benchmarkHistoryRun) {
|
||
baseDir := app.DefaultBeeBenchPerfDir
|
||
if strings.TrimSpace(exportDir) != "" {
|
||
baseDir = filepath.Join(exportDir, "bee-bench", "perf")
|
||
}
|
||
paths, err := filepath.Glob(filepath.Join(baseDir, "perf-*", "result.json"))
|
||
if err != nil || len(paths) == 0 {
|
||
return -1, nil
|
||
}
|
||
sort.Strings(paths)
|
||
return loadBenchmarkHistoryFromPaths(paths)
|
||
}
|
||
|
||
func loadBenchmarkHistoryFromPaths(paths []string) (int, []benchmarkHistoryRun) {
|
||
runs := make([]benchmarkHistoryRun, 0, len(paths))
|
||
maxGPUIndex := -1
|
||
for _, path := range paths {
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
var result platform.NvidiaBenchmarkResult
|
||
if err := json.Unmarshal(raw, &result); err != nil {
|
||
continue
|
||
}
|
||
run := benchmarkHistoryRun{
|
||
generatedAt: result.GeneratedAt,
|
||
displayTime: result.GeneratedAt.Local().Format("2006-01-02 15:04:05"),
|
||
gpuScores: make(map[int]float64),
|
||
gpuStatuses: make(map[int]string),
|
||
overallStatus: result.OverallStatus,
|
||
}
|
||
for _, gpu := range result.GPUs {
|
||
run.gpuScores[gpu.Index] = gpu.Scores.CompositeScore
|
||
run.gpuStatuses[gpu.Index] = gpu.Status
|
||
if gpu.Index > maxGPUIndex {
|
||
maxGPUIndex = gpu.Index
|
||
}
|
||
}
|
||
runs = append(runs, run)
|
||
}
|
||
sort.Slice(runs, func(i, j int) bool {
|
||
return runs[i].generatedAt.After(runs[j].generatedAt)
|
||
})
|
||
return maxGPUIndex, runs
|
||
}
|
||
|
||
func renderPowerBenchmarkResultsCard(exportDir string) string {
|
||
baseDir := app.DefaultBeeBenchPowerDir
|
||
if strings.TrimSpace(exportDir) != "" {
|
||
baseDir = filepath.Join(exportDir, "bee-bench", "power")
|
||
}
|
||
paths, err := filepath.Glob(filepath.Join(baseDir, "power-*", "result.json"))
|
||
if err != nil || len(paths) == 0 {
|
||
return `<div class="card" style="margin-top:16px"><div class="card-head">Power / Thermal Fit Results</div><div class="card-body"><p style="color:var(--muted);font-size:13px">No saved power benchmark runs yet.</p></div></div>`
|
||
}
|
||
sort.Strings(paths)
|
||
|
||
type powerRun struct {
|
||
generatedAt time.Time
|
||
displayTime string
|
||
result platform.NvidiaPowerBenchResult
|
||
}
|
||
var runs []powerRun
|
||
for _, path := range paths {
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
var r platform.NvidiaPowerBenchResult
|
||
if err := json.Unmarshal(raw, &r); err != nil {
|
||
continue
|
||
}
|
||
runs = append(runs, powerRun{
|
||
generatedAt: r.GeneratedAt,
|
||
displayTime: r.GeneratedAt.Local().Format("2006-01-02 15:04:05"),
|
||
result: r,
|
||
})
|
||
}
|
||
sort.Slice(runs, func(i, j int) bool {
|
||
return runs[i].generatedAt.After(runs[j].generatedAt)
|
||
})
|
||
|
||
// Show only the most recent run's GPU slot table, plus a run history summary.
|
||
var b strings.Builder
|
||
b.WriteString(`<div class="card" style="margin-top:16px"><div class="card-head">Power / Thermal Fit Results</div><div class="card-body">`)
|
||
|
||
latest := runs[0].result
|
||
b.WriteString(`<p style="font-size:12px;color:var(--muted);margin-bottom:10px">Latest run: ` + html.EscapeString(runs[0].displayTime))
|
||
if latest.Hostname != "" {
|
||
b.WriteString(` — ` + html.EscapeString(latest.Hostname))
|
||
}
|
||
if latest.OverallStatus != "" {
|
||
statusColor := "var(--ok)"
|
||
if latest.OverallStatus != "OK" {
|
||
statusColor = "var(--warn)"
|
||
}
|
||
b.WriteString(` — <span style="color:` + statusColor + `;font-weight:600">` + html.EscapeString(latest.OverallStatus) + `</span>`)
|
||
}
|
||
b.WriteString(`</p>`)
|
||
|
||
if len(latest.GPUs) > 0 {
|
||
b.WriteString(`<div style="overflow-x:auto"><table><thead><tr>`)
|
||
b.WriteString(`<th>GPU</th><th>Model</th><th>Nominal W</th><th>Single-card W</th><th>Multi-GPU W</th><th>P95 Observed W</th><th>Status</th>`)
|
||
b.WriteString(`</tr></thead><tbody>`)
|
||
for _, gpu := range latest.GPUs {
|
||
// finalLimitW is the definitive TDP: multi-GPU stable limit from the ramp,
|
||
// falling back to single-card applied limit if the ramp hasn't run.
|
||
finalLimitW := gpu.StablePowerLimitW
|
||
if finalLimitW <= 0 {
|
||
finalLimitW = gpu.AppliedPowerLimitW
|
||
}
|
||
// Derate is relative to nominal (DefaultPowerLimitW), using the final limit.
|
||
derated := gpu.Derated ||
|
||
(gpu.DefaultPowerLimitW > 0 && finalLimitW > 0 && finalLimitW < gpu.DefaultPowerLimitW-1)
|
||
rowStyle := ""
|
||
finalStyle := ""
|
||
if derated {
|
||
rowStyle = ` style="background:rgba(255,180,0,0.08)"`
|
||
finalStyle = ` style="color:#e6a000;font-weight:600"`
|
||
}
|
||
statusLabel := gpu.Status
|
||
if statusLabel == "" {
|
||
statusLabel = "OK"
|
||
}
|
||
statusColor := "var(--ok)"
|
||
if statusLabel == "FAILED" {
|
||
statusColor = "var(--crit-fg,#9f3a38)"
|
||
} else if statusLabel != "OK" {
|
||
statusColor = "var(--warn)"
|
||
}
|
||
nominalStr := "-"
|
||
if gpu.DefaultPowerLimitW > 0 {
|
||
nominalStr = fmt.Sprintf("%.0f", gpu.DefaultPowerLimitW)
|
||
}
|
||
singleStr := "-"
|
||
if gpu.AppliedPowerLimitW > 0 {
|
||
singleStr = fmt.Sprintf("%.0f", gpu.AppliedPowerLimitW)
|
||
}
|
||
multiStr := "-"
|
||
if gpu.StablePowerLimitW > 0 {
|
||
multiStr = fmt.Sprintf("%.0f", gpu.StablePowerLimitW)
|
||
}
|
||
p95Str := "-"
|
||
if gpu.MaxObservedPowerW > 0 {
|
||
p95Str = fmt.Sprintf("%.0f", gpu.MaxObservedPowerW)
|
||
}
|
||
b.WriteString(`<tr` + rowStyle + `>`)
|
||
b.WriteString(`<td>` + strconv.Itoa(gpu.Index) + `</td>`)
|
||
b.WriteString(`<td>` + html.EscapeString(gpu.Name) + `</td>`)
|
||
b.WriteString(`<td>` + nominalStr + `</td>`)
|
||
b.WriteString(`<td>` + singleStr + `</td>`)
|
||
b.WriteString(`<td` + finalStyle + `>` + multiStr + `</td>`)
|
||
b.WriteString(`<td>` + p95Str + `</td>`)
|
||
b.WriteString(`<td style="color:` + statusColor + `;font-weight:600">` + html.EscapeString(statusLabel) + `</td>`)
|
||
b.WriteString(`</tr>`)
|
||
}
|
||
b.WriteString(`</tbody></table></div>`)
|
||
}
|
||
|
||
if len(runs) > 1 {
|
||
b.WriteString(`<details style="margin-top:12px"><summary style="font-size:12px;color:var(--muted);cursor:pointer">` + strconv.Itoa(len(runs)) + ` runs total</summary>`)
|
||
b.WriteString(`<div style="overflow-x:auto;margin-top:8px"><table><thead><tr><th>#</th><th>Time</th><th>GPUs</th><th>Status</th></tr></thead><tbody>`)
|
||
for i, run := range runs {
|
||
statusColor := "var(--ok)"
|
||
if run.result.OverallStatus != "OK" {
|
||
statusColor = "var(--warn)"
|
||
}
|
||
b.WriteString(`<tr>`)
|
||
b.WriteString(`<td>#` + strconv.Itoa(i+1) + `</td>`)
|
||
b.WriteString(`<td>` + html.EscapeString(run.displayTime) + `</td>`)
|
||
b.WriteString(`<td>` + strconv.Itoa(len(run.result.GPUs)) + `</td>`)
|
||
b.WriteString(`<td style="color:` + statusColor + `;font-weight:600">` + html.EscapeString(run.result.OverallStatus) + `</td>`)
|
||
b.WriteString(`</tr>`)
|
||
}
|
||
b.WriteString(`</tbody></table></div></details>`)
|
||
}
|
||
|
||
b.WriteString(`</div></div>`)
|
||
return b.String()
|
||
}
|
||
|
||
// ── Burn ──────────────────────────────────────────────────────────────────────
|
||
|
||
func renderBurn() string {
|
||
return `<div class="alert alert-warn" style="margin-bottom:16px"><strong>⚠ Warning:</strong> Stress tests on this page run hardware at high load. Repeated or prolonged use may reduce hardware lifespan. Use only when necessary.</div>
|
||
<div class="alert alert-info" style="margin-bottom:16px"><strong>Scope:</strong> Burn exposes sustained GPU compute load recipes. DCGM diagnostics (` + "targeted_stress, targeted_power, pulse_test" + `) and LINPACK remain in <a href="/validate">Validate → Stress mode</a>; NCCL and NVBandwidth are available directly from <a href="/validate">Validate</a>.</div>
|
||
<p style="color:var(--muted);font-size:13px;margin-bottom:16px">Tasks continue in the background — view progress in <a href="/tasks">Tasks</a>.</p>
|
||
|
||
<div class="card" style="margin-bottom:16px">
|
||
<div class="card-head">Burn Profile</div>
|
||
<div class="card-body burn-profile-body">
|
||
<div class="burn-profile-col">
|
||
<div class="form-row" style="margin:0 0 8px"><label>Preset</label></div>
|
||
<label class="cb-row"><input type="radio" name="burn-profile" value="smoke" checked><span>Smoke — 5 min/GPU (sequential) or 5 min (parallel)</span></label>
|
||
<label class="cb-row"><input type="radio" name="burn-profile" value="acceptance"><span>Acceptance — 1 h/GPU (sequential) or 1 h (parallel)</span></label>
|
||
<label class="cb-row"><input type="radio" name="burn-profile" value="overnight"><span>Overnight — 8 h/GPU (sequential) or 8 h (parallel)</span></label>
|
||
</div>
|
||
<div class="burn-profile-col burn-profile-action">
|
||
<button type="button" class="btn btn-primary" onclick="runAllBurnTasks()">Burn one by one</button>
|
||
<p>Runs checked tests as separate sequential tasks. In sequential GPU mode, total time = profile duration × N GPU. In parallel mode, all selected GPUs burn simultaneously for one profile duration.</p>
|
||
</div>
|
||
<div class="burn-profile-col burn-profile-action">
|
||
<button type="button" class="btn btn-secondary" onclick="runPlatformStress()">Thermal Cycling</button>
|
||
<p>Run checked core test modules (CPU, MEM, GPU). Tests start at the same time and run for a period with short cooldown phases to stress the server cooling system.</p>
|
||
</div>
|
||
</div>
|
||
<div class="card-body" style="padding-top:0;display:flex;justify-content:center">
|
||
<span id="burn-all-status" style="font-size:12px;color:var(--muted)"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-bottom:16px">
|
||
<div class="card-head">NVIDIA GPU Selection</div>
|
||
<div class="card-body">
|
||
<p style="font-size:12px;color:var(--muted);margin:0 0 10px">Official NVIDIA recipes and custom NVIDIA stressors use only the GPUs selected here. Multi-GPU interconnect tests are limited to this selection as well.</p>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||
<button class="btn btn-sm btn-secondary" type="button" onclick="burnSelectAll()">Select All</button>
|
||
<button class="btn btn-sm btn-secondary" type="button" onclick="burnSelectNone()">Clear</button>
|
||
</div>
|
||
<div id="burn-gpu-list" style="border:1px solid var(--border);border-radius:4px;padding:12px;min-height:88px">
|
||
<p style="color:var(--muted);font-size:13px">Loading NVIDIA GPUs...</p>
|
||
</div>
|
||
<p id="burn-selection-note" style="font-size:12px;color:var(--muted);margin:10px 0 0">Select at least one NVIDIA GPU to enable NVIDIA burn recipes.</p>
|
||
<div style="display:flex;flex-direction:column;gap:4px;margin-top:10px">
|
||
<label class="cb-row">
|
||
<input type="radio" name="burn-nvidia-mode" value="sequential" checked>
|
||
<span>Sequential — selected GPUs one at a time</span>
|
||
</label>
|
||
<label class="cb-row" id="burn-parallel-label">
|
||
<input type="radio" name="burn-nvidia-mode" value="parallel">
|
||
<span>Parallel — all selected GPUs simultaneously</span>
|
||
</label>
|
||
<label class="cb-row" id="burn-ramp-label">
|
||
<input type="radio" name="burn-nvidia-mode" value="ramp-up">
|
||
<span>Ramp-up — add one GPU at a time</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="burn-section">Core Burn Paths</div>
|
||
<div class="grid2 burn-grid" style="margin-bottom:16px">
|
||
<div class="card burn-card">
|
||
<div class="card-head card-head-actions"><span>GPU Max Load</span><button class="btn btn-primary btn-sm" onclick="runBurnTaskSet([{id:'burn-nvidia-compute',target:'nvidia-compute',label:'NVIDIA Max Compute Load (dcgmproftester)',nvidia:true},{id:'burn-gpu-bee',target:'nvidia-stress',label:'GPU Burn (bee-gpu-burn)',nvidia:true,extra:{loader:'builtin'}},{id:'burn-gpu-john',target:'nvidia-stress',label:'John GPU Stress (john/OpenCL)',nvidia:true,extra:{loader:'john'}},{id:'burn-gpu-rvs',target:'amd-stress',label:'AMD GPU Stress (rvs gst)'}])">Run</button></div>
|
||
<div class="card-body burn-card-body">
|
||
<p style="font-size:12px;color:var(--muted);margin:0 0 10px">Combine vendor-backed and custom GPU max-load recipes in one run set. ` + "dcgmproftester" + ` is the primary official NVIDIA path; custom stressors remain available as parallel checkbox options.</p>
|
||
<label class="cb-row"><input type="checkbox" id="burn-nvidia-compute" checked disabled><span>NVIDIA Max Compute Load (dcgmproftester) <span class="cb-note" id="note-nvidia-compute"></span></span></label>
|
||
<label class="cb-row"><input type="checkbox" id="burn-gpu-bee" checked disabled><span>GPU Burn (bee-gpu-burn) <span class="cb-note" id="note-bee"></span></span></label>
|
||
<label class="cb-row"><input type="checkbox" id="burn-gpu-john" disabled><span>John GPU Stress (john/OpenCL) <span class="cb-note" id="note-john"></span></span></label>
|
||
<label class="cb-row"><input type="checkbox" id="burn-gpu-rvs" disabled><span>AMD GPU Stress (rvs gst) <span class="cb-note" id="note-rvs"></span></span></label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card burn-card">
|
||
<div class="card-head card-head-actions"><span>Compute Stress</span><button class="btn btn-primary btn-sm" onclick="runBurnTaskSet([{id:'burn-cpu',target:'cpu',label:'CPU Burn-in'},{id:'burn-mem-stress',target:'memory-stress',label:'Memory Burn-in'},{id:'burn-sat-stress',target:'sat-stress',label:'SAT Stress (stressapptest)'}])">Run</button></div>
|
||
<div class="card-body burn-card-body">
|
||
<p style="font-size:12px;color:var(--muted);margin:0 0 10px">Select which subsystems to stress. Each checked item runs as a separate task.</p>
|
||
<label class="cb-row"><input type="checkbox" id="burn-cpu" checked><span>CPU stress (stress-ng)</span></label>
|
||
<label class="cb-row"><input type="checkbox" id="burn-mem-stress" checked><span>Memory stress (stress-ng --vm)</span></label>
|
||
<label class="cb-row"><input type="checkbox" id="burn-sat-stress"><span>stressapptest (CPU + memory bus)</span></label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="bi-output" style="display:none;margin-top:16px" class="card">
|
||
<div class="card-head">Output <span id="bi-title"></span></div>
|
||
<div class="card-body"><div id="bi-terminal" class="terminal"></div></div>
|
||
</div>
|
||
|
||
<style>
|
||
.cb-row { display:flex; align-items:flex-start; gap:8px; padding:4px 0; cursor:pointer; font-size:13px; }
|
||
.cb-row input[type=checkbox] { width:16px; height:16px; margin-top:2px; flex-shrink:0; }
|
||
.cb-row input[type=checkbox]:disabled { opacity:0.4; cursor:not-allowed; }
|
||
.cb-row input[type=checkbox]:disabled ~ span { opacity:0.45; cursor:not-allowed; }
|
||
.cb-note { font-size:11px; color:var(--muted); font-style:italic; }
|
||
.burn-gpu-row { display:flex; align-items:flex-start; gap:8px; padding:6px 0; cursor:pointer; font-size:13px; }
|
||
.burn-gpu-row input[type=checkbox] { width:16px; height:16px; margin-top:2px; flex-shrink:0; }
|
||
.burn-profile-body { display:grid; grid-template-columns:1fr 1fr 1fr; gap:24px; align-items:stretch; }
|
||
.burn-profile-col { min-width:0; }
|
||
.burn-profile-action { display:flex; flex-direction:column; align-items:center; justify-content:flex-start; gap:8px; }
|
||
.burn-profile-action p { font-size:12px; color:var(--muted); margin:0; width:100%; text-align:left; }
|
||
.burn-section { font-size:12px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); margin:0 0 10px; padding-top:4px; }
|
||
.burn-grid { align-items:stretch; }
|
||
.burn-card { height:100%; display:flex; flex-direction:column; }
|
||
.burn-card-body { flex:1; display:flex; flex-direction:column; }
|
||
.card-head-actions { justify-content:space-between; }
|
||
.card-head-buttons { display:flex; align-items:center; gap:8px; margin-left:auto; }
|
||
@media(max-width:900px){ .card-head-actions { align-items:flex-start; flex-direction:column; } .card-head-buttons { margin-left:0; } .burn-profile-body { grid-template-columns:1fr; } }
|
||
</style>
|
||
|
||
<script>
|
||
let biES = null;
|
||
|
||
function burnTaskIDs(payload) {
|
||
if (payload && Array.isArray(payload.task_ids) && payload.task_ids.length) return payload.task_ids;
|
||
if (payload && payload.task_id) return [payload.task_id];
|
||
return [];
|
||
}
|
||
|
||
function burnProfile() {
|
||
const selected = document.querySelector('input[name="burn-profile"]:checked');
|
||
return selected ? selected.value : 'smoke';
|
||
}
|
||
|
||
function burnSelectedGPUIndices() {
|
||
return Array.from(document.querySelectorAll('.burn-gpu-checkbox'))
|
||
.filter(function(el) { return el.checked && !el.disabled; })
|
||
.map(function(el) { return parseInt(el.value, 10); })
|
||
.filter(function(v) { return !Number.isNaN(v); })
|
||
.sort(function(a, b) { return a - b; });
|
||
}
|
||
|
||
function burnNvidiaMode() {
|
||
const el = document.querySelector('input[name="burn-nvidia-mode"]:checked');
|
||
return el ? el.value : 'sequential';
|
||
}
|
||
|
||
function burnApplyMultiGPUState(gpuCount) {
|
||
var multiValues = ['parallel', 'ramp-up'];
|
||
var radios = document.querySelectorAll('input[name="burn-nvidia-mode"]');
|
||
radios.forEach(function(el) {
|
||
var isMulti = multiValues.indexOf(el.value) >= 0;
|
||
if (gpuCount < 2 && isMulti) {
|
||
el.disabled = true;
|
||
if (el.checked) {
|
||
var seq = document.querySelector('input[name="burn-nvidia-mode"][value="sequential"]');
|
||
if (seq) seq.checked = true;
|
||
}
|
||
var label = el.closest('label');
|
||
if (label) label.style.opacity = '0.4';
|
||
} else {
|
||
el.disabled = false;
|
||
var label = el.closest('label');
|
||
if (label) label.style.opacity = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
function burnUpdateSelectionNote() {
|
||
const note = document.getElementById('burn-selection-note');
|
||
const selected = burnSelectedGPUIndices();
|
||
if (!selected.length) {
|
||
note.textContent = 'Select at least one NVIDIA GPU to enable NVIDIA burn recipes.';
|
||
return;
|
||
}
|
||
note.textContent = 'Selected NVIDIA GPUs: ' + selected.join(', ') + '. Official and custom NVIDIA tasks will use only these GPUs.';
|
||
}
|
||
|
||
function burnRenderGPUList(gpus) {
|
||
const root = document.getElementById('burn-gpu-list');
|
||
if (!gpus || !gpus.length) {
|
||
root.innerHTML = '<p style="color:var(--muted);font-size:13px">No NVIDIA GPUs detected.</p>';
|
||
burnUpdateSelectionNote();
|
||
return;
|
||
}
|
||
root.innerHTML = gpus.map(function(gpu) {
|
||
const mem = gpu.memory_mb > 0 ? ' · ' + gpu.memory_mb + ' MiB' : '';
|
||
return '<label class="burn-gpu-row">'
|
||
+ '<input class="burn-gpu-checkbox" type="checkbox" value="' + gpu.index + '" checked onchange="burnUpdateSelectionNote()">'
|
||
+ '<span><strong>GPU ' + gpu.index + '</strong> — ' + gpu.name + mem + '</span>'
|
||
+ '</label>';
|
||
}).join('');
|
||
burnApplyMultiGPUState(gpus.length);
|
||
burnUpdateSelectionNote();
|
||
}
|
||
|
||
function burnSelectAll() {
|
||
document.querySelectorAll('.burn-gpu-checkbox').forEach(function(el) { el.checked = true; });
|
||
burnUpdateSelectionNote();
|
||
}
|
||
|
||
function burnSelectNone() {
|
||
document.querySelectorAll('.burn-gpu-checkbox').forEach(function(el) { el.checked = false; });
|
||
burnUpdateSelectionNote();
|
||
}
|
||
|
||
function burnLoadGPUs() {
|
||
fetch('/api/gpu/nvidia').then(function(r) {
|
||
return r.json().then(function(body) {
|
||
if (!r.ok) throw new Error(body.error || ('HTTP ' + r.status));
|
||
return body;
|
||
});
|
||
}).then(function(gpus) {
|
||
burnRenderGPUList(gpus);
|
||
}).catch(function(err) {
|
||
document.getElementById('burn-gpu-list').innerHTML = '<p style="color:var(--crit-fg);font-size:13px">Error: ' + err.message + '</p>';
|
||
burnUpdateSelectionNote();
|
||
});
|
||
}
|
||
|
||
function enqueueBurnTask(target, label, extra, useSelectedNvidia) {
|
||
const body = Object.assign({ profile: burnProfile(), display_name: label }, extra || {});
|
||
if (useSelectedNvidia) {
|
||
const selected = burnSelectedGPUIndices();
|
||
if (!selected.length) {
|
||
return Promise.reject(new Error('Select at least one NVIDIA GPU.'));
|
||
}
|
||
body.gpu_indices = selected;
|
||
const bMode = burnNvidiaMode();
|
||
if (bMode === 'ramp-up' && selected.length > 1) {
|
||
body.stagger_gpu_start = true;
|
||
} else if (bMode === 'parallel' && selected.length > 1) {
|
||
body.parallel_gpus = true;
|
||
}
|
||
}
|
||
return fetch('/api/sat/' + target + '/run', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify(body)
|
||
}).then(function(r) {
|
||
return r.json().then(function(payload) {
|
||
if (!r.ok) throw new Error(payload.error || ('HTTP ' + r.status));
|
||
return payload;
|
||
});
|
||
});
|
||
}
|
||
|
||
function streamTask(taskId, label) {
|
||
if (biES) { biES.close(); biES = null; }
|
||
document.getElementById('bi-output').style.display = 'block';
|
||
document.getElementById('bi-title').textContent = '— ' + label + ' [' + burnProfile() + ']';
|
||
const term = document.getElementById('bi-terminal');
|
||
term.textContent = 'Task ' + taskId + ' queued. Streaming...\n';
|
||
biES = new EventSource('/api/tasks/' + taskId + '/stream');
|
||
biES.onmessage = function(e) { term.textContent += e.data + '\n'; term.scrollTop = term.scrollHeight; };
|
||
biES.addEventListener('done', function(e) {
|
||
biES.close();
|
||
biES = null;
|
||
term.textContent += (e.data ? '\nERROR: ' + e.data : '\nCompleted.') + '\n';
|
||
term.scrollTop = term.scrollHeight;
|
||
});
|
||
}
|
||
function streamBurnTask(taskId, label, resetTerminal) {
|
||
return streamBurnTaskSet([taskId], label, resetTerminal);
|
||
}
|
||
function streamBurnTaskSet(taskIds, label, resetTerminal) {
|
||
if (biES) { biES.close(); biES = null; }
|
||
document.getElementById('bi-output').style.display = 'block';
|
||
document.getElementById('bi-title').textContent = '— ' + label + ' [' + burnProfile() + ']';
|
||
const term = document.getElementById('bi-terminal');
|
||
if (resetTerminal) {
|
||
term.textContent = '';
|
||
}
|
||
if (!Array.isArray(taskIds) || !taskIds.length) {
|
||
term.textContent += 'ERROR: no tasks queued.\n';
|
||
return Promise.resolve({ok:false, error:'no tasks queued'});
|
||
}
|
||
const streamNext = function(idx, failures) {
|
||
if (idx >= taskIds.length) {
|
||
return Promise.resolve({ok: failures === 0, error: failures ? (failures + ' task(s) failed') : ''});
|
||
}
|
||
const taskId = taskIds[idx];
|
||
term.textContent += '[' + (idx + 1) + '/' + taskIds.length + '] Task ' + taskId + ' queued. Streaming...\n';
|
||
return new Promise(function(resolve) {
|
||
biES = new EventSource('/api/tasks/' + taskId + '/stream');
|
||
biES.onmessage = function(e) { term.textContent += e.data + '\n'; term.scrollTop = term.scrollHeight; };
|
||
biES.addEventListener('done', function(e) {
|
||
biES.close();
|
||
biES = null;
|
||
term.textContent += (e.data ? '\nERROR: ' + e.data : '\nCompleted.') + '\n';
|
||
term.scrollTop = term.scrollHeight;
|
||
resolve(failures + (e.data ? 1 : 0));
|
||
});
|
||
biES.onerror = function() {
|
||
if (biES) {
|
||
biES.close();
|
||
biES = null;
|
||
}
|
||
term.textContent += '\nERROR: stream disconnected.\n';
|
||
term.scrollTop = term.scrollHeight;
|
||
resolve(failures + 1);
|
||
};
|
||
}).then(function(nextFailures) {
|
||
return streamNext(idx + 1, nextFailures);
|
||
});
|
||
};
|
||
return streamNext(0, 0);
|
||
}
|
||
|
||
function runBurnTaskSet(tasks, statusElId) {
|
||
const enabled = tasks.filter(function(t) {
|
||
const el = document.getElementById(t.id);
|
||
return el && el.checked && !el.disabled;
|
||
});
|
||
const status = statusElId ? document.getElementById(statusElId) : null;
|
||
if (status) status.textContent = '';
|
||
if (!enabled.length) {
|
||
if (status) status.textContent = 'No tasks selected.';
|
||
return;
|
||
}
|
||
const term = document.getElementById('bi-terminal');
|
||
document.getElementById('bi-output').style.display = 'block';
|
||
document.getElementById('bi-title').textContent = '— Burn one by one [' + burnProfile() + ']';
|
||
term.textContent = '';
|
||
const runNext = function(idx) {
|
||
if (idx >= enabled.length) {
|
||
if (status) status.textContent = 'Completed ' + enabled.length + ' task(s).';
|
||
return Promise.resolve();
|
||
}
|
||
const t = enabled[idx];
|
||
term.textContent += '\n[' + (idx + 1) + '/' + enabled.length + '] ' + t.label + '\n';
|
||
if (status) status.textContent = 'Running ' + (idx + 1) + '/' + enabled.length + '...';
|
||
return enqueueBurnTask(t.target, t.label, t.extra, !!t.nvidia)
|
||
.then(function(d) {
|
||
return streamBurnTaskSet(burnTaskIDs(d), t.label, false);
|
||
})
|
||
.then(function() {
|
||
return runNext(idx + 1);
|
||
})
|
||
.catch(function(err) {
|
||
if (status) status.textContent = 'Error: ' + err.message;
|
||
document.getElementById('bi-output').style.display = 'block';
|
||
term.textContent += 'ERROR: ' + err.message + '\n';
|
||
return Promise.reject(err);
|
||
});
|
||
};
|
||
return runNext(0);
|
||
}
|
||
|
||
function runPlatformStress() {
|
||
const comps = [];
|
||
const computeIDs = ['burn-cpu', 'burn-mem-stress', 'burn-sat-stress'];
|
||
const gpuIDs = ['burn-nvidia-compute', 'burn-gpu-bee', 'burn-gpu-john', 'burn-gpu-rvs'];
|
||
const hasChecked = function(ids) {
|
||
return ids.some(function(id) {
|
||
const el = document.getElementById(id);
|
||
return el && el.checked && !el.disabled;
|
||
});
|
||
};
|
||
if (hasChecked(computeIDs)) comps.push('cpu');
|
||
if (hasChecked(gpuIDs)) comps.push('gpu');
|
||
if (!comps.length) {
|
||
const status = document.getElementById('burn-all-status');
|
||
if (status) status.textContent = 'Select at least one test in GPU Max Load or Compute Stress.';
|
||
return;
|
||
}
|
||
const extra = comps.length > 0 ? {platform_components: comps} : {};
|
||
enqueueBurnTask('platform-stress', 'Platform Thermal Cycling', extra, false).then(function(d) {
|
||
streamTask(d.task_id, 'Platform Thermal Cycling');
|
||
});
|
||
}
|
||
|
||
function runAllBurnTasks() {
|
||
const status = document.getElementById('burn-all-status');
|
||
const all = [
|
||
{id:'burn-nvidia-compute',target:'nvidia-compute',label:'NVIDIA Max Compute Load (dcgmproftester)',nvidia:true},
|
||
{id:'burn-gpu-bee',target:'nvidia-stress',label:'GPU Burn (bee-gpu-burn)',nvidia:true,extra:{loader:'builtin'}},
|
||
{id:'burn-gpu-john',target:'nvidia-stress',label:'John GPU Stress (john/OpenCL)',nvidia:true,extra:{loader:'john'}},
|
||
{id:'burn-gpu-rvs',target:'amd-stress',label:'AMD GPU Stress (rvs gst)'},
|
||
{id:'burn-cpu',target:'cpu',label:'CPU Burn-in'},
|
||
{id:'burn-mem-stress',target:'memory-stress',label:'Memory Burn-in'},
|
||
{id:'burn-sat-stress',target:'sat-stress',label:'SAT Stress (stressapptest)'},
|
||
];
|
||
status.textContent = 'Enqueuing...';
|
||
runBurnTaskSet(all, 'burn-all-status');
|
||
}
|
||
|
||
fetch('/api/gpu/tools').then(function(r) { return r.json(); }).then(function(tools) {
|
||
const map = {
|
||
'nvidia-compute': {cb:'burn-nvidia-compute', note:'note-nvidia-compute', reason:'dcgmproftester not available or NVIDIA driver not running'},
|
||
'bee-gpu-burn': {cb:'burn-gpu-bee', note:'note-bee', reason:'bee-gpu-burn not available or NVIDIA driver not running'},
|
||
'john': {cb:'burn-gpu-john', note:'note-john', reason:'bee-john-gpu-stress not available or NVIDIA driver not running'},
|
||
'rvs': {cb:'burn-gpu-rvs', note:'note-rvs', reason:'AMD driver not running'},
|
||
};
|
||
tools.forEach(function(t) {
|
||
const spec = map[t.id];
|
||
if (!spec) return;
|
||
const cb = document.getElementById(spec.cb);
|
||
const note = document.getElementById(spec.note);
|
||
if (!cb) return;
|
||
if (t.available) {
|
||
cb.disabled = false;
|
||
} else if (note) {
|
||
note.textContent = '— ' + spec.reason;
|
||
}
|
||
});
|
||
}).catch(function() {});
|
||
|
||
burnLoadGPUs();
|
||
</script>`
|
||
}
|
||
|
||
// ── Network ───────────────────────────────────────────────────────────────────
|
||
|
||
// renderNetworkInline returns the network UI without a wrapping card (for embedding in Tools).
|
||
func renderNetworkInline() string {
|
||
return `<div id="net-pending" style="display:none" class="alert alert-warn">
|
||
<strong>⚠ Network change applied.</strong> Reverting in <span id="net-countdown">60</span>s unless confirmed.
|
||
<button class="btn btn-primary btn-sm" style="margin-left:8px" onclick="confirmNetChange()">Confirm</button>
|
||
<button class="btn btn-secondary btn-sm" style="margin-left:4px" onclick="rollbackNetChange()">Rollback</button>
|
||
</div>
|
||
<div id="iface-table"><p style="color:var(--muted);font-size:13px">Loading...</p></div>
|
||
<div class="grid2" style="margin-top:16px">
|
||
<div><div style="font-weight:700;font-size:13px;margin-bottom:8px">DHCP</div>
|
||
<div class="form-row"><label>Interface (leave empty for all)</label><input type="text" id="dhcp-iface" placeholder="eth0"></div>
|
||
<button class="btn btn-primary" onclick="runDHCP()">▶ Run DHCP</button>
|
||
<div id="dhcp-out" style="margin-top:10px;font-size:12px;color:var(--ok-fg)"></div>
|
||
</div>
|
||
<div><div style="font-weight:700;font-size:13px;margin-bottom:8px">Static IPv4</div>
|
||
<div class="form-row"><label>Interface</label><input type="text" id="st-iface" placeholder="eth0"></div>
|
||
<div class="form-row"><label>Address</label><input type="text" id="st-addr" placeholder="192.168.1.100"></div>
|
||
<div class="form-row"><label>Prefix length</label><input type="text" id="st-prefix" placeholder="24"></div>
|
||
<div class="form-row"><label>Gateway</label><input type="text" id="st-gw" placeholder="192.168.1.1"></div>
|
||
<div class="form-row"><label>DNS (comma-separated)</label><input type="text" id="st-dns" placeholder="8.8.8.8,8.8.4.4"></div>
|
||
<button class="btn btn-primary" onclick="setStatic()">Apply Static IP</button>
|
||
<div id="static-out" style="margin-top:10px;font-size:12px;color:var(--ok-fg)"></div>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
var _netCountdownTimer = null;
|
||
var _netRefreshTimer = null;
|
||
const NET_ROLLBACK_SECS = 60;
|
||
function loadNetwork() {
|
||
fetch('/api/network').then(r=>r.json()).then(d => {
|
||
const rows = (d.interfaces||[]).map(i =>
|
||
'<tr><td style="cursor:pointer" onclick="selectIface(\''+i.Name+'\')" title="Use this interface in the forms below"><span style="text-decoration:underline">'+i.Name+'</span></td>' +
|
||
'<td style="cursor:pointer" onclick="toggleIface(\''+i.Name+'\',\''+i.State+'\')" title="Click to toggle"><span class="badge '+(i.State==='up'?'badge-ok':'badge-warn')+'">'+i.State+'</span></td>' +
|
||
'<td>'+(i.IPv4||[]).join(', ')+'</td></tr>'
|
||
).join('');
|
||
document.getElementById('iface-table').innerHTML =
|
||
'<table><tr><th>Interface</th><th>State (click to toggle)</th><th>Addresses</th></tr>'+rows+'</table>' +
|
||
(d.default_route ? '<p style="font-size:12px;color:var(--muted);margin-top:8px">Default route: '+d.default_route+'</p>' : '');
|
||
if (d.pending_change) showNetPending(d.rollback_in || NET_ROLLBACK_SECS);
|
||
else hideNetPending();
|
||
}).catch(function() {});
|
||
}
|
||
function selectIface(iface) {
|
||
document.getElementById('dhcp-iface').value = iface;
|
||
document.getElementById('st-iface').value = iface;
|
||
}
|
||
function toggleIface(iface, currentState) {
|
||
showNetPending(NET_ROLLBACK_SECS);
|
||
fetch('/api/network/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({iface:iface})})
|
||
.then(r=>r.json()).then(d => {
|
||
if (d.error) { hideNetPending(); alert('Error: '+d.error); return; }
|
||
loadNetwork();
|
||
showNetPending(d.rollback_in || NET_ROLLBACK_SECS);
|
||
}).catch(function() {
|
||
setTimeout(loadNetwork, 1500);
|
||
});
|
||
}
|
||
function hideNetPending() {
|
||
const el = document.getElementById('net-pending');
|
||
if (_netCountdownTimer) clearInterval(_netCountdownTimer);
|
||
_netCountdownTimer = null;
|
||
el.style.display = 'none';
|
||
}
|
||
function showNetPending(secs) {
|
||
if (!secs || secs < 1) { hideNetPending(); return; }
|
||
const el = document.getElementById('net-pending');
|
||
el.style.display = 'block';
|
||
if (_netCountdownTimer) clearInterval(_netCountdownTimer);
|
||
let remaining = secs;
|
||
document.getElementById('net-countdown').textContent = remaining;
|
||
_netCountdownTimer = setInterval(function() {
|
||
remaining--;
|
||
document.getElementById('net-countdown').textContent = remaining;
|
||
if (remaining <= 0) { hideNetPending(); loadNetwork(); }
|
||
}, 1000);
|
||
}
|
||
function confirmNetChange() {
|
||
hideNetPending();
|
||
fetch('/api/network/confirm',{method:'POST'}).then(()=>loadNetwork()).catch(()=>{});
|
||
}
|
||
function rollbackNetChange() {
|
||
hideNetPending();
|
||
fetch('/api/network/rollback',{method:'POST'}).then(()=>loadNetwork()).catch(()=>{});
|
||
}
|
||
function runDHCP() {
|
||
const iface = document.getElementById('dhcp-iface').value.trim();
|
||
showNetPending(NET_ROLLBACK_SECS);
|
||
fetch('/api/network/dhcp',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({interface:iface||'all'})})
|
||
.then(r=>r.json()).then(d => {
|
||
document.getElementById('dhcp-out').textContent = d.output || d.error || 'Done.';
|
||
if (d.error) { hideNetPending(); return; }
|
||
showNetPending(d.rollback_in || NET_ROLLBACK_SECS);
|
||
loadNetwork();
|
||
}).catch(function() {
|
||
setTimeout(loadNetwork, 1500);
|
||
});
|
||
}
|
||
function setStatic() {
|
||
const dns = document.getElementById('st-dns').value.split(',').map(s=>s.trim()).filter(Boolean);
|
||
showNetPending(NET_ROLLBACK_SECS);
|
||
fetch('/api/network/static',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
|
||
interface: document.getElementById('st-iface').value,
|
||
address: document.getElementById('st-addr').value,
|
||
prefix: document.getElementById('st-prefix').value,
|
||
gateway: document.getElementById('st-gw').value,
|
||
dns: dns,
|
||
})}).then(r=>r.json()).then(d => {
|
||
document.getElementById('static-out').textContent = d.output || d.error || 'Done.';
|
||
if (d.error) { hideNetPending(); return; }
|
||
showNetPending(d.rollback_in || NET_ROLLBACK_SECS);
|
||
loadNetwork();
|
||
}).catch(function() {
|
||
setTimeout(loadNetwork, 1500);
|
||
});
|
||
}
|
||
loadNetwork();
|
||
if (_netRefreshTimer) clearInterval(_netRefreshTimer);
|
||
_netRefreshTimer = setInterval(loadNetwork, 5000);
|
||
</script>`
|
||
}
|
||
|
||
func renderNetwork() string {
|
||
return `<div class="card"><div class="card-head">Network Interfaces</div><div class="card-body">` +
|
||
renderNetworkInline() +
|
||
`</div></div>`
|
||
}
|
||
|
||
// ── Services ──────────────────────────────────────────────────────────────────
|
||
|
||
func renderServicesInline() string {
|
||
return `<p style="font-size:13px;color:var(--muted);margin-bottom:10px">` + html.EscapeString(`bee-selfheal.timer is expected to be active; the oneshot bee-selfheal.service itself is not shown as a long-running service.`) + `</p>
|
||
<div style="display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin-bottom:8px"><button class="btn btn-sm btn-secondary" onclick="loadServices()">↻ Refresh</button></div>
|
||
<div id="svc-table"><p style="color:var(--muted);font-size:13px">Loading...</p></div>
|
||
<div id="svc-out" style="display:none;margin-top:12px">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
|
||
<span id="svc-out-label" style="font-size:12px;font-weight:600;color:var(--muted)">Output</span>
|
||
<span id="svc-out-status" style="font-size:12px"></span>
|
||
</div>
|
||
<div id="svc-terminal" class="terminal" style="max-height:220px;width:100%;box-sizing:border-box"></div>
|
||
</div>
|
||
<script>
|
||
function loadServices() {
|
||
fetch('/api/services').then(r=>r.json()).then(svcs => {
|
||
const rows = svcs.map(s => {
|
||
const st = s.state||'unknown';
|
||
const badge = st==='active' ? 'badge-ok' : st==='failed' ? 'badge-err' : 'badge-warn';
|
||
const id = 'svc-body-'+s.name.replace(/[^a-z0-9]/g,'-');
|
||
const body = (s.body||'').replace(/</g,'<').replace(/>/g,'>');
|
||
return '<tr>' +
|
||
'<td style="white-space:nowrap">'+s.name+'</td>' +
|
||
'<td style="white-space:nowrap"><span class="badge '+badge+'" style="cursor:pointer" onclick="toggleBody(\''+id+'\')">'+st+' ▾</span>' +
|
||
'<div id="'+id+'" style="display:none;margin-top:6px"><pre style="font-size:11px;white-space:pre-wrap;word-break:break-all;max-height:200px;overflow-y:auto;background:#1b1c1d;padding:8px;border-radius:4px;color:#b5cea8">'+body+'</pre></div>' +
|
||
'</td>' +
|
||
'<td style="white-space:nowrap">' +
|
||
'<button class="btn btn-sm btn-secondary" id="btn-'+s.name+'-start" onclick="svcAction(this,\''+s.name+'\',\'start\')">Start</button> ' +
|
||
'<button class="btn btn-sm btn-secondary" id="btn-'+s.name+'-stop" onclick="svcAction(this,\''+s.name+'\',\'stop\')">Stop</button> ' +
|
||
'<button class="btn btn-sm btn-secondary" id="btn-'+s.name+'-restart" onclick="svcAction(this,\''+s.name+'\',\'restart\')">Restart</button>' +
|
||
'</td></tr>';
|
||
}).join('');
|
||
document.getElementById('svc-table').innerHTML =
|
||
'<table><tr><th>Unit</th><th>Status</th><th>Actions</th></tr>'+rows+'</table>';
|
||
});
|
||
}
|
||
function toggleBody(id) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.style.display = el.style.display==='none' ? 'block' : 'none';
|
||
}
|
||
function svcAction(btn, name, action) {
|
||
var label = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = '...';
|
||
var out = document.getElementById('svc-out');
|
||
var term = document.getElementById('svc-terminal');
|
||
var statusEl = document.getElementById('svc-out-status');
|
||
var labelEl = document.getElementById('svc-out-label');
|
||
out.style.display = 'block';
|
||
labelEl.textContent = action + ' ' + name;
|
||
term.textContent = 'Running...';
|
||
statusEl.textContent = '';
|
||
statusEl.style.color = '';
|
||
fetch('/api/services/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,action})})
|
||
.then(r=>r.json()).then(d => {
|
||
term.textContent = d.output || d.error || '(no output)';
|
||
term.scrollTop = term.scrollHeight;
|
||
if (d.status === 'ok') {
|
||
statusEl.textContent = '✓ done';
|
||
statusEl.style.color = 'var(--ok-fg, #2c662d)';
|
||
} else {
|
||
statusEl.textContent = '✗ failed';
|
||
statusEl.style.color = 'var(--crit-fg, #9f3a38)';
|
||
}
|
||
btn.textContent = label;
|
||
btn.disabled = false;
|
||
setTimeout(loadServices, 800);
|
||
}).catch(e => {
|
||
term.textContent = 'Request failed: ' + e;
|
||
statusEl.textContent = '✗ error';
|
||
statusEl.style.color = 'var(--crit-fg, #9f3a38)';
|
||
btn.textContent = label;
|
||
btn.disabled = false;
|
||
});
|
||
}
|
||
loadServices();
|
||
</script>`
|
||
}
|
||
|
||
func renderServices() string {
|
||
return `<div class="card"><div class="card-head">Bee Services</div><div class="card-body">` +
|
||
renderServicesInline() +
|
||
`</div></div>`
|
||
}
|
||
|
||
// ── Export ────────────────────────────────────────────────────────────────────
|
||
|
||
func renderExport(exportDir string) string {
|
||
entries, _ := listExportFiles(exportDir)
|
||
var rows strings.Builder
|
||
for _, e := range entries {
|
||
rows.WriteString(fmt.Sprintf(`<tr><td><a href="/export/file?path=%s" target="_blank">%s</a></td></tr>`,
|
||
url.QueryEscape(e), html.EscapeString(e)))
|
||
}
|
||
if len(entries) == 0 {
|
||
rows.WriteString(`<tr><td style="color:var(--muted)">No export files found.</td></tr>`)
|
||
}
|
||
return `<div class="grid2">
|
||
<div class="card"><div class="card-head">Support Bundle</div><div class="card-body">
|
||
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Creates a tar.gz archive of all audit files, SAT results, and logs.</p>
|
||
` + renderSupportBundleInline() + `
|
||
</div></div>
|
||
<div class="card"><div class="card-head">Export Files</div><div class="card-body">
|
||
<table><tr><th>File</th></tr>` + rows.String() + `</table>
|
||
</div></div>
|
||
</div>
|
||
|
||
` + renderUSBExportCard()
|
||
}
|
||
|
||
func listExportFiles(exportDir string) ([]string, error) {
|
||
var entries []string
|
||
err := filepath.Walk(strings.TrimSpace(exportDir), func(path string, info os.FileInfo, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if info.IsDir() {
|
||
return nil
|
||
}
|
||
rel, err := filepath.Rel(exportDir, path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
entries = append(entries, rel)
|
||
return nil
|
||
})
|
||
if err != nil && !os.IsNotExist(err) {
|
||
return nil, err
|
||
}
|
||
sort.Strings(entries)
|
||
return entries, nil
|
||
}
|
||
|
||
func renderSupportBundleInline() string {
|
||
return `<button id="support-bundle-btn" class="btn btn-primary" onclick="supportBundleDownload()">↓ Download Support Bundle</button>
|
||
<div id="support-bundle-status" style="margin-top:10px;font-size:13px;color:var(--muted)"></div>
|
||
<script>
|
||
window.supportBundleDownload = function() {
|
||
var btn = document.getElementById('support-bundle-btn');
|
||
var status = document.getElementById('support-bundle-status');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Building...';
|
||
status.textContent = 'Collecting logs and export data\u2026';
|
||
status.style.color = 'var(--muted)';
|
||
var filename = 'bee-support.tar.gz';
|
||
fetch('/export/support.tar.gz')
|
||
.then(function(r) {
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
var cd = r.headers.get('Content-Disposition') || '';
|
||
var m = cd.match(/filename="?([^";]+)"?/);
|
||
if (m) filename = m[1];
|
||
return r.blob();
|
||
})
|
||
.then(function(blob) {
|
||
var url = URL.createObjectURL(blob);
|
||
var a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
status.textContent = 'Download started.';
|
||
status.style.color = 'var(--ok-fg)';
|
||
})
|
||
.catch(function(e) {
|
||
status.textContent = 'Error: ' + e.message;
|
||
status.style.color = 'var(--crit-fg)';
|
||
})
|
||
.finally(function() {
|
||
btn.disabled = false;
|
||
btn.textContent = '\u2195 Download Support Bundle';
|
||
});
|
||
};
|
||
</script>`
|
||
}
|
||
|
||
func renderUSBExportCard() string {
|
||
return `<div class="card" style="margin-top:16px">
|
||
<div class="card-head">Export to USB
|
||
<button class="btn btn-sm btn-secondary" onclick="usbRefresh()" style="margin-left:auto">↻ Refresh</button>
|
||
</div>
|
||
<div class="card-body">` + renderUSBExportInline() + `</div>
|
||
</div>`
|
||
}
|
||
|
||
func renderUSBExportInline() string {
|
||
return `<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Write audit JSON or support bundle directly to a removable USB drive.</p>
|
||
<div id="usb-status" style="font-size:13px;color:var(--muted)">Scanning for USB devices...</div>
|
||
<div id="usb-targets" style="margin-top:12px"></div>
|
||
<div id="usb-msg" style="margin-top:10px;font-size:13px"></div>
|
||
<script>
|
||
(function(){
|
||
function usbRefresh() {
|
||
document.getElementById('usb-status').textContent = 'Scanning...';
|
||
document.getElementById('usb-targets').innerHTML = '';
|
||
document.getElementById('usb-msg').textContent = '';
|
||
fetch('/api/export/usb').then(r=>r.json()).then(targets => {
|
||
window._usbTargets = Array.isArray(targets) ? targets : [];
|
||
const st = document.getElementById('usb-status');
|
||
const ct = document.getElementById('usb-targets');
|
||
if (!targets || targets.length === 0) {
|
||
st.textContent = 'No removable USB devices found.';
|
||
return;
|
||
}
|
||
st.textContent = targets.length + ' device(s) found:';
|
||
ct.innerHTML = '<table><tr><th>Device</th><th>FS</th><th>Size</th><th>Label</th><th>Model</th><th>Actions</th></tr>' +
|
||
targets.map((t, idx) => {
|
||
const dev = t.device || '';
|
||
const label = t.label || '';
|
||
const model = t.model || '';
|
||
return '<tr>' +
|
||
'<td style="font-family:monospace">'+dev+'</td>' +
|
||
'<td>'+t.fs_type+'</td>' +
|
||
'<td>'+t.size+'</td>' +
|
||
'<td>'+label+'</td>' +
|
||
'<td style="font-size:12px;color:var(--muted)">'+model+'</td>' +
|
||
'<td style="white-space:nowrap">' +
|
||
'<button class="btn btn-sm btn-primary" onclick="usbExport(\'audit\','+idx+',this)">Audit JSON</button> ' +
|
||
'<button class="btn btn-sm btn-secondary" onclick="usbExport(\'bundle\','+idx+',this)">Support Bundle</button>' +
|
||
'<div class="usb-row-msg" style="margin-top:6px;font-size:12px;color:var(--muted)"></div>' +
|
||
'</td></tr>';
|
||
}).join('') + '</table>';
|
||
}).catch(e => {
|
||
document.getElementById('usb-status').textContent = 'Error: ' + e;
|
||
});
|
||
}
|
||
window.usbExport = function(type, targetIndex, btn) {
|
||
const target = (window._usbTargets || [])[targetIndex];
|
||
if (!target) {
|
||
const msg = document.getElementById('usb-msg');
|
||
msg.style.color = 'var(--err,red)';
|
||
msg.textContent = 'Error: USB target not found. Refresh and try again.';
|
||
return;
|
||
}
|
||
const msg = document.getElementById('usb-msg');
|
||
const row = btn ? btn.closest('td') : null;
|
||
const rowMsg = row ? row.querySelector('.usb-row-msg') : null;
|
||
const originalText = btn ? btn.textContent : '';
|
||
if (btn) {
|
||
btn.disabled = true;
|
||
btn.textContent = 'Exporting...';
|
||
}
|
||
if (rowMsg) {
|
||
rowMsg.style.color = 'var(--muted)';
|
||
rowMsg.textContent = 'Working...';
|
||
}
|
||
msg.style.color = 'var(--muted)';
|
||
msg.textContent = 'Exporting ' + (type === 'bundle' ? 'support bundle' : 'audit JSON') + ' to ' + (target.device||'') + '...';
|
||
fetch('/api/export/usb/'+type, {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify(target)
|
||
}).then(async r => {
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status));
|
||
return d;
|
||
}).then(d => {
|
||
msg.style.color = 'var(--ok,green)';
|
||
msg.textContent = d.message || 'Done.';
|
||
if (rowMsg) {
|
||
rowMsg.style.color = 'var(--ok,green)';
|
||
rowMsg.textContent = d.message || 'Done.';
|
||
}
|
||
}).catch(e => {
|
||
msg.style.color = 'var(--err,red)';
|
||
msg.textContent = 'Error: '+e;
|
||
if (rowMsg) {
|
||
rowMsg.style.color = 'var(--err,red)';
|
||
rowMsg.textContent = 'Error: ' + e;
|
||
}
|
||
}).finally(() => {
|
||
if (btn) {
|
||
btn.disabled = false;
|
||
btn.textContent = originalText;
|
||
}
|
||
});
|
||
};
|
||
window.usbRefresh = usbRefresh;
|
||
usbRefresh();
|
||
})();
|
||
</script>`
|
||
}
|
||
|
||
func renderNvidiaSelfHealInline() string {
|
||
return `<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Inspect NVIDIA GPU health, restart the bee-nvidia driver service, and issue a per-GPU reset when the driver reports reset required.</p>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px">
|
||
<button id="nvidia-restart-btn" class="btn btn-secondary" onclick="nvidiaRestartDrivers()">Restart GPU Drivers</button>
|
||
<button class="btn btn-sm btn-secondary" onclick="loadNvidiaSelfHeal()">↻ Refresh</button>
|
||
</div>
|
||
<div id="nvidia-self-heal-status" style="font-size:13px;color:var(--muted);margin-bottom:12px">Loading NVIDIA GPU status...</div>
|
||
<div id="nvidia-self-heal-table"><p style="color:var(--muted);font-size:13px">Loading...</p></div>
|
||
<div id="nvidia-self-heal-out" style="display:none;margin-top:12px">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
|
||
<span id="nvidia-self-heal-out-label" style="font-size:12px;font-weight:600;color:var(--muted)">Output</span>
|
||
<span id="nvidia-self-heal-out-status" style="font-size:12px"></span>
|
||
</div>
|
||
<div id="nvidia-self-heal-terminal" class="terminal" style="max-height:220px;width:100%;box-sizing:border-box"></div>
|
||
</div>
|
||
<script>
|
||
function nvidiaSelfHealShowResult(label, status, output) {
|
||
var out = document.getElementById('nvidia-self-heal-out');
|
||
var term = document.getElementById('nvidia-self-heal-terminal');
|
||
var statusEl = document.getElementById('nvidia-self-heal-out-status');
|
||
var labelEl = document.getElementById('nvidia-self-heal-out-label');
|
||
out.style.display = 'block';
|
||
labelEl.textContent = label;
|
||
term.textContent = output || '(no output)';
|
||
term.scrollTop = term.scrollHeight;
|
||
if (status === 'ok') {
|
||
statusEl.textContent = '✓ done';
|
||
statusEl.style.color = 'var(--ok-fg, #2c662d)';
|
||
} else {
|
||
statusEl.textContent = '✗ failed';
|
||
statusEl.style.color = 'var(--crit-fg, #9f3a38)';
|
||
}
|
||
}
|
||
function nvidiaRestartDrivers() {
|
||
var btn = document.getElementById('nvidia-restart-btn');
|
||
var original = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = 'Restarting...';
|
||
nvidiaSelfHealShowResult('restart bee-nvidia', 'ok', 'Running...');
|
||
fetch('/api/services/action', {
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({name:'bee-nvidia', action:'restart'})
|
||
}).then(r=>r.json()).then(d => {
|
||
nvidiaSelfHealShowResult('restart bee-nvidia', d.status || 'error', d.output || d.error || '(no output)');
|
||
setTimeout(function() {
|
||
loadServices();
|
||
loadNvidiaSelfHeal();
|
||
}, 800);
|
||
}).catch(e => {
|
||
nvidiaSelfHealShowResult('restart bee-nvidia', 'error', 'Request failed: ' + e);
|
||
}).finally(() => {
|
||
btn.disabled = false;
|
||
btn.textContent = original;
|
||
});
|
||
}
|
||
function nvidiaResetGPU(index, btn) {
|
||
var original = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = 'Resetting...';
|
||
nvidiaSelfHealShowResult('reset gpu ' + index, 'ok', 'Running...');
|
||
fetch('/api/gpu/nvidia-reset', {
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({index:index})
|
||
}).then(r=>r.json()).then(d => {
|
||
nvidiaSelfHealShowResult('reset gpu ' + index, d.status || 'error', d.output || '(no output)');
|
||
setTimeout(loadNvidiaSelfHeal, 1000);
|
||
}).catch(e => {
|
||
nvidiaSelfHealShowResult('reset gpu ' + index, 'error', 'Request failed: ' + e);
|
||
}).finally(() => {
|
||
btn.disabled = false;
|
||
btn.textContent = original;
|
||
});
|
||
}
|
||
function loadNvidiaSelfHeal() {
|
||
var status = document.getElementById('nvidia-self-heal-status');
|
||
var table = document.getElementById('nvidia-self-heal-table');
|
||
status.textContent = 'Loading NVIDIA GPU status...';
|
||
status.style.color = 'var(--muted)';
|
||
table.innerHTML = '<p style="color:var(--muted);font-size:13px">Loading...</p>';
|
||
fetch('/api/gpu/nvidia-status').then(r=>r.json()).then(gpus => {
|
||
if (!Array.isArray(gpus) || gpus.length === 0) {
|
||
status.textContent = 'No NVIDIA GPUs detected or nvidia-smi is unavailable.';
|
||
table.innerHTML = '';
|
||
return;
|
||
}
|
||
status.textContent = gpus.length + ' NVIDIA GPU(s) detected.';
|
||
const rows = gpus.map(g => {
|
||
const serial = g.serial || '';
|
||
const bdf = g.bdf || '';
|
||
const id = serial || bdf || ('gpu-' + g.index);
|
||
const badge = g.status === 'OK' ? 'badge-ok' : g.status === 'RESET_REQUIRED' ? 'badge-err' : 'badge-warn';
|
||
const details = [];
|
||
if (serial) details.push('serial ' + serial);
|
||
if (bdf) details.push('bdf ' + bdf);
|
||
if (g.parse_failure && g.raw_line) details.push(g.raw_line);
|
||
return '<tr>'
|
||
+ '<td style="white-space:nowrap">' + g.index + '</td>'
|
||
+ '<td>' + (g.name || 'unknown') + '</td>'
|
||
+ '<td style="font-family:monospace">' + id + '</td>'
|
||
+ '<td><span class="badge ' + badge + '">' + (g.status || 'UNKNOWN') + '</span>'
|
||
+ (details.length ? '<div style="margin-top:4px;font-size:12px;color:var(--muted)">' + details.join(' | ') + '</div>' : '')
|
||
+ '</td>'
|
||
+ '<td style="white-space:nowrap"><button class="btn btn-sm btn-secondary" onclick="nvidiaResetGPU(' + g.index + ', this)">Reset GPU</button></td>'
|
||
+ '</tr>';
|
||
}).join('');
|
||
table.innerHTML = '<table><tr><th>GPU</th><th>Model</th><th>ID</th><th>Status</th><th>Action</th></tr>' + rows + '</table>';
|
||
}).catch(e => {
|
||
status.textContent = 'Error loading NVIDIA GPU status: ' + e;
|
||
status.style.color = 'var(--crit-fg, #9f3a38)';
|
||
table.innerHTML = '';
|
||
});
|
||
}
|
||
loadNvidiaSelfHeal();
|
||
</script>`
|
||
}
|
||
|
||
// ── Tools ─────────────────────────────────────────────────────────────────────
|
||
|
||
func renderTools() string {
|
||
return `<div class="card" style="margin-bottom:16px">
|
||
<div class="card-head">System Install</div>
|
||
<div class="card-body">
|
||
<div style="margin-bottom:20px">
|
||
<div style="font-weight:600;margin-bottom:8px">Install to RAM</div>
|
||
<p id="boot-source-text" style="color:var(--muted);font-size:13px;margin-bottom:8px">Detecting boot source...</p>
|
||
<p id="ram-status-text" style="color:var(--muted);font-size:13px;margin-bottom:8px">Checking...</p>
|
||
<button id="ram-install-btn" class="btn btn-primary" onclick="installToRAM()" style="display:none">▶ Copy to RAM</button>
|
||
</div>
|
||
<div style="border-top:1px solid var(--line);padding-top:20px">
|
||
<div style="font-weight:600;margin-bottom:8px">Install to Disk</div>` +
|
||
renderInstallInline() + `
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
fetch('/api/system/ram-status').then(r=>r.json()).then(d=>{
|
||
const boot = document.getElementById('boot-source-text');
|
||
const txt = document.getElementById('ram-status-text');
|
||
const btn = document.getElementById('ram-install-btn');
|
||
let source = d.device || d.source || 'unknown source';
|
||
let kind = d.kind || 'unknown';
|
||
let label = source;
|
||
if (kind === 'ram') label = 'RAM';
|
||
else if (kind === 'usb') label = 'USB (' + source + ')';
|
||
else if (kind === 'cdrom') label = 'CD-ROM (' + source + ')';
|
||
else if (kind === 'disk') label = 'disk (' + source + ')';
|
||
else label = source;
|
||
boot.textContent = 'Current boot source: ' + label + '.';
|
||
txt.textContent = d.message || 'Checking...';
|
||
if (d.status === 'ok' || d.in_ram) {
|
||
txt.style.color = 'var(--ok, green)';
|
||
} else if (d.status === 'failed') {
|
||
txt.style.color = 'var(--err, #b91c1c)';
|
||
} else {
|
||
txt.style.color = 'var(--muted)';
|
||
}
|
||
if (d.can_start_task) {
|
||
btn.style.display = '';
|
||
btn.disabled = false;
|
||
} else {
|
||
btn.style.display = 'none';
|
||
}
|
||
});
|
||
function installToRAM() {
|
||
document.getElementById('ram-install-btn').disabled = true;
|
||
fetch('/api/system/install-to-ram', {method:'POST'}).then(r=>r.json()).then(d=>{
|
||
window.location.href = '/tasks#' + d.task_id;
|
||
});
|
||
}
|
||
</script>
|
||
|
||
<div class="card"><div class="card-head">Support Bundle</div><div class="card-body">
|
||
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Downloads a tar.gz archive of all audit files, SAT results, and logs.</p>
|
||
` + renderSupportBundleInline() + `
|
||
<div style="border-top:1px solid var(--border);margin-top:16px;padding-top:16px">
|
||
<div style="font-weight:600;margin-bottom:8px">Export to USB</div>
|
||
` + renderUSBExportInline() + `
|
||
</div>
|
||
</div></div>
|
||
|
||
<div class="card"><div class="card-head">Tool Check <button class="btn btn-sm btn-secondary" onclick="checkTools()" style="margin-left:auto">↻ Check</button></div>
|
||
<div class="card-body"><div id="tools-table"><p style="color:var(--muted);font-size:13px">Checking...</p></div></div></div>
|
||
|
||
<div class="card"><div class="card-head">NVIDIA Self Heal</div><div class="card-body">` +
|
||
renderNvidiaSelfHealInline() + `</div></div>
|
||
|
||
<div class="card"><div class="card-head">Network</div><div class="card-body">` +
|
||
renderNetworkInline() + `</div></div>
|
||
|
||
<div class="card"><div class="card-head">Services</div><div class="card-body">` +
|
||
renderServicesInline() + `</div></div>
|
||
|
||
|
||
<script>
|
||
function checkTools() {
|
||
document.getElementById('tools-table').innerHTML = '<p style="color:var(--muted);font-size:13px">Checking...</p>';
|
||
fetch('/api/tools/check').then(r=>r.json()).then(tools => {
|
||
const rows = tools.map(t =>
|
||
'<tr><td>'+t.Name+'</td><td><span class="badge '+(t.OK ? 'badge-ok' : 'badge-err')+'">'+(t.OK ? '✓ '+t.Path : '✗ missing')+'</span></td></tr>'
|
||
).join('');
|
||
document.getElementById('tools-table').innerHTML =
|
||
'<table><tr><th>Tool</th><th>Status</th></tr>'+rows+'</table>';
|
||
});
|
||
}
|
||
checkTools();
|
||
</script>`
|
||
}
|
||
|
||
// ── Install to Disk ──────────────────────────────────────────────────────────
|
||
|
||
func renderInstallInline() string {
|
||
return `
|
||
<div class="alert alert-warn" style="margin-bottom:16px">
|
||
<strong>Warning:</strong> Installing will <strong>completely erase</strong> the selected
|
||
disk and write the live system onto it. All existing data on the target disk will be lost.
|
||
This operation cannot be undone.
|
||
</div>
|
||
<div id="install-loading" style="color:var(--muted);font-size:13px">Loading disk list…</div>
|
||
<div id="install-disk-section" style="display:none">
|
||
<div class="card" style="margin-bottom:0">
|
||
<table id="install-disk-table">
|
||
<thead><tr><th></th><th>Device</th><th>Model</th><th>Size</th><th>Status</th></tr></thead>
|
||
<tbody id="install-disk-tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
<div style="margin-top:12px">
|
||
<button class="btn btn-secondary btn-sm" onclick="installRefreshDisks()">↻ Refresh</button>
|
||
</div>
|
||
</div>
|
||
<div id="install-confirm-section" style="display:none;margin-top:20px">
|
||
<div id="install-confirm-warn" class="alert" style="background:#fff6f6;border:1px solid #e0b4b4;color:#9f3a38;font-size:13px"></div>
|
||
<div class="form-row" style="max-width:360px">
|
||
<label>Type the device name to confirm (e.g. /dev/sda)</label>
|
||
<input type="text" id="install-confirm-input" placeholder="/dev/..." oninput="installCheckConfirm()" autocomplete="off" spellcheck="false">
|
||
</div>
|
||
<button class="btn btn-danger" id="install-start-btn" disabled onclick="installStart()">Install to Disk</button>
|
||
<button class="btn btn-secondary" style="margin-left:8px" onclick="installDeselect()">Cancel</button>
|
||
</div>
|
||
<div id="install-progress-section" style="display:none;margin-top:20px">
|
||
<div class="card-head" style="margin-bottom:8px">Installation Progress</div>
|
||
<div id="install-terminal" class="terminal" style="max-height:500px"></div>
|
||
<div id="install-status" style="margin-top:12px;font-size:13px"></div>
|
||
</div>
|
||
|
||
<style>
|
||
#install-disk-tbody tr{cursor:pointer}
|
||
#install-disk-tbody tr.selected td{background:rgba(33,133,208,.1)}
|
||
#install-disk-tbody tr:hover td{background:rgba(33,133,208,.07)}
|
||
</style>
|
||
|
||
<script>
|
||
var _installSelected = null;
|
||
|
||
function installRefreshDisks() {
|
||
document.getElementById('install-loading').style.display = '';
|
||
document.getElementById('install-disk-section').style.display = 'none';
|
||
document.getElementById('install-confirm-section').style.display = 'none';
|
||
_installSelected = null;
|
||
fetch('/api/install/disks').then(function(r){ return r.json(); }).then(function(disks){
|
||
document.getElementById('install-loading').style.display = 'none';
|
||
var tbody = document.getElementById('install-disk-tbody');
|
||
tbody.innerHTML = '';
|
||
if (!disks || disks.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--muted);text-align:center">No installable disks found</td></tr>';
|
||
} else {
|
||
disks.forEach(function(d) {
|
||
var warnings = (d.warnings || []);
|
||
var statusHtml;
|
||
if (warnings.length === 0) {
|
||
statusHtml = '<span class="badge badge-ok">OK</span>';
|
||
} else {
|
||
var hasSmall = warnings.some(function(w){ return w.indexOf('too small') >= 0; });
|
||
statusHtml = warnings.map(function(w){
|
||
var cls = hasSmall ? 'badge-err' : 'badge-warn';
|
||
return '<span class="badge ' + cls + '" title="' + w.replace(/"/g,'"') + '">' +
|
||
(w.length > 40 ? w.substring(0,38)+'…' : w) + '</span>';
|
||
}).join(' ');
|
||
}
|
||
var mountedNote = (d.mounted_parts && d.mounted_parts.length > 0)
|
||
? ' <span style="color:var(--warn-fg);font-size:11px">(mounted)</span>' : '';
|
||
var tr = document.createElement('tr');
|
||
tr.dataset.device = d.device;
|
||
tr.dataset.model = d.model || 'Unknown';
|
||
tr.dataset.size = d.size;
|
||
tr.dataset.warnings = JSON.stringify(warnings);
|
||
tr.innerHTML =
|
||
'<td><input type="radio" name="install-disk" value="' + d.device + '"></td>' +
|
||
'<td><code>' + d.device + '</code>' + mountedNote + '</td>' +
|
||
'<td>' + (d.model || '—') + '</td>' +
|
||
'<td>' + d.size + '</td>' +
|
||
'<td>' + statusHtml + '</td>';
|
||
tr.addEventListener('click', function(){ installSelectDisk(this); });
|
||
tbody.appendChild(tr);
|
||
});
|
||
}
|
||
document.getElementById('install-disk-section').style.display = '';
|
||
}).catch(function(e){
|
||
document.getElementById('install-loading').textContent = 'Failed to load disk list: ' + e;
|
||
});
|
||
}
|
||
|
||
function installSelectDisk(tr) {
|
||
document.querySelectorAll('#install-disk-tbody tr').forEach(function(r){ r.classList.remove('selected'); });
|
||
tr.classList.add('selected');
|
||
var radio = tr.querySelector('input[type=radio]');
|
||
if (radio) radio.checked = true;
|
||
_installSelected = {
|
||
device: tr.dataset.device,
|
||
model: tr.dataset.model,
|
||
size: tr.dataset.size,
|
||
warnings: JSON.parse(tr.dataset.warnings || '[]')
|
||
};
|
||
var warnBox = document.getElementById('install-confirm-warn');
|
||
var warnLines = '<strong>⚠ DANGER:</strong> ' + _installSelected.device +
|
||
' (' + _installSelected.model + ', ' + _installSelected.size + ')' +
|
||
' will be <strong>completely erased</strong> and repartitioned. All data will be lost.<br>';
|
||
if (_installSelected.warnings.length > 0) {
|
||
warnLines += '<br>' + _installSelected.warnings.map(function(w){ return '• ' + w; }).join('<br>');
|
||
}
|
||
warnBox.innerHTML = warnLines;
|
||
document.getElementById('install-confirm-input').value = '';
|
||
document.getElementById('install-start-btn').disabled = true;
|
||
document.getElementById('install-confirm-section').style.display = '';
|
||
document.getElementById('install-progress-section').style.display = 'none';
|
||
}
|
||
|
||
function installDeselect() {
|
||
_installSelected = null;
|
||
document.querySelectorAll('#install-disk-tbody tr').forEach(function(r){ r.classList.remove('selected'); });
|
||
document.querySelectorAll('#install-disk-tbody input[type=radio]').forEach(function(r){ r.checked = false; });
|
||
document.getElementById('install-confirm-section').style.display = 'none';
|
||
}
|
||
|
||
function installCheckConfirm() {
|
||
var val = document.getElementById('install-confirm-input').value.trim();
|
||
var ok = _installSelected && val === _installSelected.device;
|
||
document.getElementById('install-start-btn').disabled = !ok;
|
||
}
|
||
|
||
function installStart() {
|
||
if (!_installSelected) return;
|
||
document.getElementById('install-confirm-section').style.display = 'none';
|
||
document.getElementById('install-disk-section').style.display = 'none';
|
||
document.getElementById('install-loading').style.display = 'none';
|
||
var prog = document.getElementById('install-progress-section');
|
||
var term = document.getElementById('install-terminal');
|
||
var status = document.getElementById('install-status');
|
||
prog.style.display = '';
|
||
term.textContent = '';
|
||
status.textContent = 'Starting installation…';
|
||
status.style.color = 'var(--muted)';
|
||
|
||
fetch('/api/install/run', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({device: _installSelected.device})
|
||
}).then(function(r){
|
||
return r.json().then(function(j){
|
||
if (!r.ok) throw new Error(j.error || r.statusText);
|
||
return j;
|
||
});
|
||
}).then(function(j){
|
||
if (!j.task_id) throw new Error('missing task id');
|
||
installStreamLog(j.task_id);
|
||
}).catch(function(e){
|
||
status.textContent = 'Error: ' + e;
|
||
status.style.color = 'var(--crit-fg)';
|
||
});
|
||
}
|
||
|
||
function installStreamLog(taskId) {
|
||
var term = document.getElementById('install-terminal');
|
||
var status = document.getElementById('install-status');
|
||
var es = new EventSource('/api/tasks/' + taskId + '/stream');
|
||
es.onmessage = function(e) {
|
||
term.textContent += e.data + '\n';
|
||
term.scrollTop = term.scrollHeight;
|
||
};
|
||
es.addEventListener('done', function(e) {
|
||
es.close();
|
||
if (!e.data) {
|
||
status.innerHTML = '<span style="color:var(--ok-fg);font-weight:700">✓ Installation complete.</span> Remove the ISO and reboot.';
|
||
var rebootBtn = document.createElement('button');
|
||
rebootBtn.className = 'btn btn-primary btn-sm';
|
||
rebootBtn.style.marginLeft = '12px';
|
||
rebootBtn.textContent = 'Reboot now';
|
||
rebootBtn.onclick = function(){
|
||
fetch('/api/services/action', {method:'POST',headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({name:'', action:'reboot'})});
|
||
};
|
||
status.appendChild(rebootBtn);
|
||
} else {
|
||
status.textContent = '✗ Installation failed: ' + e.data;
|
||
status.style.color = 'var(--crit-fg)';
|
||
}
|
||
});
|
||
es.onerror = function() {
|
||
es.close();
|
||
status.textContent = '✗ Stream disconnected.';
|
||
status.style.color = 'var(--crit-fg)';
|
||
};
|
||
}
|
||
|
||
// Auto-load on page open.
|
||
installRefreshDisks();
|
||
</script>
|
||
`
|
||
}
|
||
|
||
func renderInstall() string {
|
||
return `<div class="card"><div class="card-head">Install Live System to Disk</div><div class="card-body">` +
|
||
renderInstallInline() +
|
||
`</div></div>`
|
||
}
|
||
|
||
// ── Tasks ─────────────────────────────────────────────────────────────────────
|
||
|
||
func renderTasks() string {
|
||
return `<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;flex-wrap:wrap">
|
||
<button class="btn btn-danger btn-sm" onclick="cancelAll()">Cancel All</button>
|
||
<button class="btn btn-sm" style="background:#b45309;color:#fff" onclick="killWorkers()" title="Send SIGKILL to all running test processes (bee-gpu-burn, stress-ng, stressapptest, memtester)">Kill Workers</button>
|
||
<span id="kill-toast" style="font-size:12px;color:var(--muted);display:none"></span>
|
||
<span style="font-size:12px;color:var(--muted)">Open a task to view its saved logs and charts.</span>
|
||
</div>
|
||
<div class="card">
|
||
<div id="tasks-table"><p style="color:var(--muted);font-size:13px;padding:16px">Loading...</p></div>
|
||
</div>
|
||
<script>
|
||
var _taskRefreshTimer = null;
|
||
var _tasksAll = [];
|
||
var _taskPage = 1;
|
||
var _taskPageSize = 50;
|
||
|
||
function loadTasks() {
|
||
fetch('/api/tasks').then(r=>r.json()).then(tasks => {
|
||
_tasksAll = Array.isArray(tasks) ? tasks : [];
|
||
if (_tasksAll.length === 0) {
|
||
_taskPage = 1;
|
||
document.getElementById('tasks-table').innerHTML = '<p style="color:var(--muted);font-size:13px;padding:16px">No tasks.</p>';
|
||
return;
|
||
}
|
||
const totalPages = Math.max(1, Math.ceil(_tasksAll.length / _taskPageSize));
|
||
if (_taskPage > totalPages) _taskPage = totalPages;
|
||
if (_taskPage < 1) _taskPage = 1;
|
||
const start = (_taskPage - 1) * _taskPageSize;
|
||
const pageTasks = _tasksAll.slice(start, start + _taskPageSize);
|
||
const rows = pageTasks.map(t => {
|
||
const dur = t.elapsed_sec ? formatDurSec(t.elapsed_sec) : '';
|
||
const statusClass = {running:'badge-ok',pending:'badge-unknown',done:'badge-ok',failed:'badge-err',cancelled:'badge-unknown'}[t.status]||'badge-unknown';
|
||
const statusLabel = {running:'▶ running',pending:'pending',done:'✓ done',failed:'✗ failed',cancelled:'cancelled'}[t.status]||t.status;
|
||
let actions = '<a class="btn btn-sm btn-secondary" href="/tasks/'+encodeURIComponent(t.id)+'">Open</a>';
|
||
if (t.status === 'running' || t.status === 'pending') {
|
||
actions += ' <button class="btn btn-sm btn-danger" onclick="cancelTask(\''+t.id+'\')">Cancel</button>';
|
||
}
|
||
if (t.status === 'pending') {
|
||
actions += ' <button class="btn btn-sm btn-secondary" onclick="setPriority(\''+t.id+'\',1)" title="Increase priority">⇧</button>';
|
||
actions += ' <button class="btn btn-sm btn-secondary" onclick="setPriority(\''+t.id+'\',-1)" title="Decrease priority">⇩</button>';
|
||
}
|
||
return '<tr><td><a href="/tasks/'+encodeURIComponent(t.id)+'">'+escHtml(t.name)+'</a></td>' +
|
||
'<td><span class="badge '+statusClass+'">'+statusLabel+'</span></td>' +
|
||
'<td style="font-size:12px;color:var(--muted)">'+fmtTime(t.created_at)+'</td>' +
|
||
'<td style="font-size:12px;color:var(--muted)">'+dur+'</td>' +
|
||
'<td>'+t.priority+'</td>' +
|
||
'<td>'+actions+'</td></tr>';
|
||
}).join('');
|
||
const showingFrom = start + 1;
|
||
const showingTo = Math.min(start + pageTasks.length, _tasksAll.length);
|
||
const pager =
|
||
'<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:12px 14px;border-top:1px solid var(--border-lite);background:var(--surface-2)">' +
|
||
'<div style="font-size:12px;color:var(--muted)">Showing '+showingFrom+'-'+showingTo+' of '+_tasksAll.length+' tasks</div>' +
|
||
'<div style="display:flex;align-items:center;gap:8px">' +
|
||
'<button class="btn btn-sm btn-secondary" onclick="setTaskPage('+(_taskPage-1)+')" '+(_taskPage <= 1 ? 'disabled' : '')+'>Previous</button>' +
|
||
'<span style="font-size:12px;color:var(--muted)">Page '+_taskPage+' / '+totalPages+'</span>' +
|
||
'<button class="btn btn-sm btn-secondary" onclick="setTaskPage('+(_taskPage+1)+')" '+(_taskPage >= totalPages ? 'disabled' : '')+'>Next</button>' +
|
||
'</div>' +
|
||
'</div>';
|
||
document.getElementById('tasks-table').innerHTML =
|
||
'<table><tr><th>Name</th><th>Status</th><th>Created</th><th>Duration</th><th>Priority</th><th>Actions</th></tr>'+rows+'</table>' + pager;
|
||
});
|
||
}
|
||
|
||
function escHtml(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
function fmtTime(s) { if (!s) return ''; try { return new Date(s).toLocaleTimeString(); } catch(e){ return s; } }
|
||
function formatDurSec(sec) {
|
||
sec = Math.max(0, Math.round(sec||0));
|
||
if (sec < 60) return sec+'s';
|
||
const m = Math.floor(sec/60), ss = sec%60;
|
||
return m+'m '+ss+'s';
|
||
}
|
||
function setTaskPage(page) {
|
||
const totalPages = Math.max(1, Math.ceil(_tasksAll.length / _taskPageSize));
|
||
_taskPage = Math.min(totalPages, Math.max(1, page));
|
||
loadTasks();
|
||
}
|
||
|
||
function cancelTask(id) {
|
||
fetch('/api/tasks/'+id+'/cancel',{method:'POST'}).then(()=>loadTasks());
|
||
}
|
||
function cancelAll() {
|
||
fetch('/api/tasks/cancel-all',{method:'POST'}).then(()=>loadTasks());
|
||
}
|
||
function killWorkers() {
|
||
if (!confirm('Send SIGKILL to all running test workers (bee-gpu-burn, stress-ng, stressapptest, memtester)?\n\nThis will also cancel all queued and running tasks.')) return;
|
||
fetch('/api/tasks/kill-workers',{method:'POST'})
|
||
.then(r=>r.json())
|
||
.then(d=>{
|
||
loadTasks();
|
||
var toast = document.getElementById('kill-toast');
|
||
var parts = [];
|
||
if (d.cancelled > 0) parts.push(d.cancelled+' task'+(d.cancelled===1?'':'s')+' cancelled');
|
||
if (d.killed > 0) parts.push(d.killed+' process'+(d.killed===1?'':'es')+' killed');
|
||
toast.textContent = parts.length ? parts.join(', ')+'.' : 'No processes found.';
|
||
toast.style.display = '';
|
||
setTimeout(()=>{ toast.style.display='none'; }, 5000);
|
||
});
|
||
}
|
||
function setPriority(id, delta) {
|
||
fetch('/api/tasks/'+id+'/priority',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({delta:delta})})
|
||
.then(()=>loadTasks());
|
||
}
|
||
|
||
loadTasks();
|
||
_taskRefreshTimer = setInterval(loadTasks, 2000);
|
||
</script>`
|
||
}
|
||
|
||
func renderExportIndex(exportDir string) (string, error) {
|
||
entries, err := listExportFiles(exportDir)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
var body strings.Builder
|
||
body.WriteString(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Bee Export Files</title></head><body>`)
|
||
body.WriteString(`<h1>Bee Export Files</h1><ul>`)
|
||
for _, entry := range entries {
|
||
body.WriteString(`<li><a href="/export/file?path=` + url.QueryEscape(entry) + `">` + html.EscapeString(entry) + `</a></li>`)
|
||
}
|
||
if len(entries) == 0 {
|
||
body.WriteString(`<li>No export files found.</li>`)
|
||
}
|
||
body.WriteString(`</ul></body></html>`)
|
||
return body.String(), nil
|
||
}
|