- Burn tab: replace 6 flat cards with 3 grouped cards (GPU Stress, Compute Stress, Platform Thermal Cycling) + global Burn Profile - Run All button at top enqueues all enabled tests across all cards - GPU Stress: tool checkboxes enabled/disabled via new /api/gpu/tools endpoint based on driver status (/dev/nvidia0, /dev/kfd) - Compute Stress: checkboxes for cpu/memory-stress/stressapptest - Platform Thermal Cycling: component checkboxes (cpu/nvidia/amd) with platform_components param wired through to PlatformStressOptions - bee-gpu-burn: default size-mb changed from 64 to 0 (auto); script now queries nvidia-smi memory.total per GPU and uses 95% of it - platform_stress: removed hardcoded --size-mb 64; respects Components field to selectively run CPU and/or GPU load goroutines Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1650 lines
75 KiB
Go
1650 lines
75 KiB
Go
package webui
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"html"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// ── 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}
|
||
.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-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)}
|
||
/* 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}}
|
||
/* 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", ""},
|
||
{"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>`)
|
||
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))
|
||
}
|
||
}
|
||
if strings.TrimSpace(buildLabel) == "" {
|
||
buildLabel = "dev"
|
||
}
|
||
b.WriteString(`</nav>`)
|
||
b.WriteString(`<div style="padding:12px 16px;border-top:1px solid rgba(255,255,255,.08);font-size:11px;color:rgba(255,255,255,.45)">Build ` + html.EscapeString(buildLabel) + `</div>`)
|
||
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()
|
||
case "burn":
|
||
pageID = "burn"
|
||
title = "Burn"
|
||
body = renderBurn()
|
||
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()
|
||
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">Hardware Summary</div><div class="card-body"><button class="btn btn-primary" onclick="auditModalRun()">▶ Run Audit</button></div></div>`
|
||
}
|
||
// Parse just enough fields for the summary banner
|
||
var snap struct {
|
||
Summary struct {
|
||
CPU struct{ Model string }
|
||
Memory struct{ TotalGB float64 }
|
||
Storage []struct{ Device, Model, Size string }
|
||
GPUs []struct{ Model string }
|
||
PSUs []struct{ Model string }
|
||
}
|
||
Network struct {
|
||
Interfaces []struct {
|
||
Name string
|
||
IPv4 []string
|
||
State string
|
||
}
|
||
}
|
||
}
|
||
// Try to extract top-level fields loosely
|
||
var raw map[string]json.RawMessage
|
||
if err := json.Unmarshal(data, &raw); 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>`
|
||
}
|
||
_ = snap
|
||
|
||
// Also load runtime-health for badges
|
||
type componentHealth struct {
|
||
FailCount int `json:"fail_count"`
|
||
WarnCount int `json:"warn_count"`
|
||
}
|
||
type healthSummary struct {
|
||
CPU componentHealth `json:"cpu"`
|
||
Memory componentHealth `json:"memory"`
|
||
Storage componentHealth `json:"storage"`
|
||
GPU componentHealth `json:"gpu"`
|
||
PSU componentHealth `json:"psu"`
|
||
Network componentHealth `json:"network"`
|
||
}
|
||
var health struct {
|
||
HardwareHealth healthSummary `json:"hardware_health"`
|
||
}
|
||
if hdata, herr := loadSnapshot(filepath.Join(opts.ExportDir, "runtime-health.json")); herr == nil {
|
||
_ = json.Unmarshal(hdata, &health)
|
||
}
|
||
|
||
badge := func(h componentHealth) string {
|
||
if h.FailCount > 0 {
|
||
return `<span class="badge badge-err">FAIL</span>`
|
||
}
|
||
if h.WarnCount > 0 {
|
||
return `<span class="badge badge-warn">WARN</span>`
|
||
}
|
||
return `<span class="badge badge-ok">OK</span>`
|
||
}
|
||
|
||
// Extract readable strings from raw JSON
|
||
getString := func(key string) string {
|
||
v, ok := raw[key]
|
||
if !ok {
|
||
return ""
|
||
}
|
||
var s string
|
||
if err := json.Unmarshal(v, &s); err == nil {
|
||
return s
|
||
}
|
||
return ""
|
||
}
|
||
|
||
cpuModel := getString("cpu_model")
|
||
memStr := getString("memory_summary")
|
||
gpuSummary := getString("gpu_summary")
|
||
|
||
var b strings.Builder
|
||
b.WriteString(`<div class="card"><div class="card-head">Hardware Summary</div><div class="card-body">`)
|
||
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">%s</td><td style="padding:6px 0 6px 12px">%s</td></tr>`,
|
||
html.EscapeString(label), html.EscapeString(value), badgeHTML))
|
||
}
|
||
if cpuModel != "" {
|
||
writeRow("CPU", cpuModel, badge(health.HardwareHealth.CPU))
|
||
} else {
|
||
writeRow("CPU", "—", badge(health.HardwareHealth.CPU))
|
||
}
|
||
if memStr != "" {
|
||
writeRow("Memory", memStr, badge(health.HardwareHealth.Memory))
|
||
} else {
|
||
writeRow("Memory", "—", badge(health.HardwareHealth.Memory))
|
||
}
|
||
if gpuSummary != "" {
|
||
writeRow("GPU", gpuSummary, badge(health.HardwareHealth.GPU))
|
||
} else {
|
||
writeRow("GPU", "—", badge(health.HardwareHealth.GPU))
|
||
}
|
||
writeRow("Storage", "—", badge(health.HardwareHealth.Storage))
|
||
writeRow("PSU", "—", badge(health.HardwareHealth.PSU))
|
||
b.WriteString(`</table>`)
|
||
b.WriteString(`</div></div>`)
|
||
return b.String()
|
||
}
|
||
|
||
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 map[string]any
|
||
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 := fmt.Sprintf("%v", health["status"])
|
||
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 issues, ok := health["issues"].([]any); ok && len(issues) > 0 {
|
||
b.WriteString(`<div style="font-size:12px;color:#f87171">Issues:<br>`)
|
||
for _, issue := range issues {
|
||
if m, ok := issue.(map[string]any); ok {
|
||
b.WriteString(html.EscapeString(fmt.Sprintf("%v: %v", m["code"], m["message"])) + "<br>")
|
||
}
|
||
}
|
||
b.WriteString(`</div>`)
|
||
}
|
||
b.WriteString(`</div></div>`)
|
||
return b.String()
|
||
}
|
||
|
||
// ── 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" 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" 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" 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" 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" src="/api/metrics/chart/server-fans.svg" style="width:100%;display:block;border-radius:6px" alt="Fan RPM">
|
||
</div>
|
||
</div>
|
||
|
||
<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" 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" 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 — Power</div>
|
||
<div class="card-body" style="padding:8px">
|
||
<img id="chart-gpu-all-power" 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" src="/api/metrics/chart/gpu-all-temp.svg" style="width:100%;display:block;border-radius:6px" alt="GPU temperature">
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
function refreshCharts() {
|
||
const t = '?t=' + Date.now();
|
||
['chart-server-load','chart-server-temp-cpu','chart-server-temp-gpu','chart-server-temp-ambient','chart-server-power','chart-server-fans',
|
||
'chart-gpu-all-load','chart-gpu-all-memload','chart-gpu-all-power','chart-gpu-all-temp'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.src = el.src.split('?')[0] + t;
|
||
});
|
||
}
|
||
setInterval(refreshCharts, 3000);
|
||
|
||
fetch('/api/metrics/latest').then(r => r.json()).then(d => {
|
||
const fanCard = document.getElementById('card-server-fans');
|
||
if (fanCard) fanCard.style.display = (d.fans && d.fans.length > 0) ? '' : 'none';
|
||
}).catch(() => {});
|
||
</script>`
|
||
}
|
||
|
||
// ── Validate (Acceptance Tests) ───────────────────────────────────────────────
|
||
|
||
func renderValidate() string {
|
||
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">Run All Tests</div>
|
||
<div class="card-body" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||
<div class="form-row" style="margin:0"><label style="margin-right:6px">Cycles</label><input type="number" id="sat-cycles" value="1" min="1" max="100" style="width:70px;display:inline-block"></div>
|
||
<button class="btn btn-primary" onclick="runAllSAT()">▶ Run All</button>
|
||
<span id="sat-all-status" style="font-size:12px;color:var(--muted)"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid3">
|
||
` + renderSATCard("nvidia", "NVIDIA GPU", `<div class="form-row"><label>Diag Level</label><select id="sat-nvidia-level"><option value="1">Level 1 — Quick</option><option value="2">Level 2 — Standard</option><option value="3">Level 3 — Extended</option><option value="4">Level 4 — Full</option></select></div>`) +
|
||
renderSATCard("memory", "Memory", "") +
|
||
renderSATCard("storage", "Storage", "") +
|
||
renderSATCard("cpu", "CPU", `<div class="form-row"><label>Duration (seconds)</label><input type="number" id="sat-cpu-dur" value="60" min="10"></div>`) +
|
||
renderSATCard("amd", "AMD GPU", `<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||
<button id="sat-btn-amd-mem" class="btn" type="button" onclick="runSAT('amd-mem')">MEM Integrity</button>
|
||
<button id="sat-btn-amd-bandwidth" class="btn" type="button" onclick="runSAT('amd-bandwidth')">MEM Bandwidth</button>
|
||
</div>
|
||
<p style="color:var(--muted);font-size:12px;margin:0">Additional AMD memory diagnostics: RVS MEM for integrity and BABEL + rocm-bandwidth-test for memory/interconnect bandwidth.</p>`) +
|
||
`</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>
|
||
<script>
|
||
let satES = null;
|
||
function runSAT(target) {
|
||
if (satES) { satES.close(); satES = null; }
|
||
const body = {};
|
||
const labels = {nvidia:'Validate GPU', 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'};
|
||
body.display_name = labels[target] || ('Validate ' + target);
|
||
if (target === 'nvidia') body.diag_level = parseInt(document.getElementById('sat-nvidia-level').value)||1;
|
||
if (target === 'cpu') body.duration = parseInt(document.getElementById('sat-cpu-dur').value)||60;
|
||
document.getElementById('sat-output').style.display='block';
|
||
document.getElementById('sat-title').textContent = '— ' + target;
|
||
const term = document.getElementById('sat-terminal');
|
||
term.textContent = 'Enqueuing ' + target + ' test...\n';
|
||
return fetch('/api/sat/'+target+'/run', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
term.textContent += 'Task ' + d.task_id + ' queued. Streaming log...\n';
|
||
satES = new EventSource('/api/tasks/'+d.task_id+'/stream');
|
||
satES.onmessage = e => { term.textContent += e.data+'\n'; term.scrollTop=term.scrollHeight; };
|
||
satES.addEventListener('done', e => { satES.close(); satES=null; term.textContent += (e.data ? '\nERROR: '+e.data : '\nCompleted.')+'\n'; });
|
||
});
|
||
}
|
||
function runAllSAT() {
|
||
const cycles = Math.max(1, parseInt(document.getElementById('sat-cycles').value)||1);
|
||
const targets = ['nvidia','memory','storage','cpu','amd','amd-mem','amd-bandwidth'];
|
||
const total = targets.length * cycles;
|
||
let enqueued = 0;
|
||
const status = document.getElementById('sat-all-status');
|
||
status.textContent = 'Enqueuing...';
|
||
const enqueueNext = (cycle, idx) => {
|
||
if (cycle >= cycles) { status.textContent = 'Enqueued '+total+' tasks.'; return; }
|
||
if (idx >= targets.length) { enqueueNext(cycle+1, 0); return; }
|
||
const target = targets[idx];
|
||
const btn = document.getElementById('sat-btn-' + target);
|
||
if (btn && btn.disabled) { enqueueNext(cycle, idx+1); return; }
|
||
const body = {};
|
||
const labels = {nvidia:'Validate GPU', 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'};
|
||
body.display_name = labels[target] || ('Validate ' + target);
|
||
if (target === 'nvidia') body.diag_level = parseInt(document.getElementById('sat-nvidia-level').value)||1;
|
||
if (target === 'cpu') body.duration = parseInt(document.getElementById('sat-cpu-dur').value)||60;
|
||
fetch('/api/sat/'+target+'/run', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
|
||
.then(r=>r.json()).then(()=>{
|
||
enqueued++;
|
||
status.textContent = 'Enqueued '+enqueued+'/'+total+'...';
|
||
enqueueNext(cycle, idx+1);
|
||
});
|
||
};
|
||
enqueueNext(0, 0);
|
||
}
|
||
</script>
|
||
<script>
|
||
fetch('/api/gpu/presence').then(r=>r.json()).then(gp => {
|
||
if (!gp.nvidia) disableSATCard('nvidia', 'No NVIDIA GPU detected');
|
||
if (!gp.amd) disableSATCard('amd', 'No AMD GPU detected');
|
||
if (!gp.amd) disableSATCard('amd-mem', 'No AMD GPU detected');
|
||
if (!gp.amd) disableSATCard('amd-bandwidth', 'No AMD GPU detected');
|
||
});
|
||
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-top:6px';
|
||
btn.parentNode.insertBefore(note, btn.nextSibling);
|
||
}
|
||
note.textContent = reason;
|
||
}
|
||
}
|
||
</script>`
|
||
}
|
||
|
||
func renderSATCard(id, label, extra string) string {
|
||
return fmt.Sprintf(`<div class="card"><div class="card-head">%s</div><div class="card-body">%s<button id="sat-btn-%s" class="btn btn-primary" onclick="runSAT('%s')">▶ Run Test</button></div></div>`,
|
||
label, extra, id, id)
|
||
}
|
||
|
||
// ── 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 maximum load. Repeated or prolonged use may reduce hardware lifespan (storage endurance, GPU wear). Use only when necessary.</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" style="display:flex;align-items:center;gap:16px;flex-wrap:wrap">
|
||
<div class="form-row" style="margin:0;max-width:380px"><label>Preset</label><select id="burn-profile">
|
||
<option value="smoke" selected>Smoke — quick check (~5 min)</option>
|
||
<option value="acceptance">Acceptance — 1 hour</option>
|
||
<option value="overnight">Overnight — 8 hours</option>
|
||
</select></div>
|
||
<button class="btn btn-primary" onclick="runAll()">▶ Run All</button>
|
||
<span id="burn-all-status" style="font-size:12px;color:var(--muted)"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid3" style="margin-bottom:16px">
|
||
|
||
<div class="card">
|
||
<div class="card-head">GPU Stress</div>
|
||
<div class="card-body">
|
||
<p style="font-size:12px;color:var(--muted);margin:0 0 10px">Tests run on all GPUs in the system. Availability determined by driver status.</p>
|
||
<div id="gpu-tools-list">
|
||
<label class="cb-row"><input type="checkbox" id="burn-gpu-bee" value="bee-gpu-burn" disabled><span>bee-gpu-burn <span class="cb-note" id="note-bee"></span></span></label>
|
||
<label class="cb-row"><input type="checkbox" id="burn-gpu-john" value="john" disabled><span>John the Ripper (OpenCL) <span class="cb-note" id="note-john"></span></span></label>
|
||
<label class="cb-row"><input type="checkbox" id="burn-gpu-nccl" value="nccl" disabled><span>NCCL all_reduce_perf <span class="cb-note" id="note-nccl"></span></span></label>
|
||
<label class="cb-row"><input type="checkbox" id="burn-gpu-rvs" value="rvs" disabled><span>RVS GST (AMD) <span class="cb-note" id="note-rvs"></span></span></label>
|
||
</div>
|
||
<button class="btn btn-primary" style="margin-top:10px" onclick="runGPUStress()">▶ Run GPU Stress</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head">Compute Stress</div>
|
||
<div class="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>
|
||
<button class="btn btn-primary" style="margin-top:10px" onclick="runComputeStress()">▶ Run Compute Stress</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head">Platform Thermal Cycling</div>
|
||
<div class="card-body">
|
||
<p style="font-size:12px;color:var(--muted);margin:0 0 10px">Repeated load+idle cycles. Detects cooling recovery failures and GPU throttle. Smoke: 2×90s. Acceptance: 4×300s.</p>
|
||
<p style="font-size:12px;font-weight:600;margin:0 0 6px">Load components:</p>
|
||
<label class="cb-row"><input type="checkbox" id="burn-pt-cpu" checked><span>CPU (stressapptest)</span></label>
|
||
<label class="cb-row"><input type="checkbox" id="burn-pt-nvidia" disabled><span>NVIDIA GPU <span class="cb-note" id="note-pt-nvidia"></span></span></label>
|
||
<label class="cb-row"><input type="checkbox" id="burn-pt-amd" disabled><span>AMD GPU <span class="cb-note" id="note-pt-amd"></span></span></label>
|
||
<button class="btn btn-primary" style="margin-top:10px" onclick="runPlatformStress()">▶ Run Thermal Cycling</button>
|
||
</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:center; gap:8px; padding:4px 0; cursor:pointer; font-size:13px; }
|
||
.cb-row input[type=checkbox] { width:16px; height:16px; 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; }
|
||
</style>
|
||
|
||
<script>
|
||
let biES = null;
|
||
|
||
function profile() { return document.getElementById('burn-profile').value || 'smoke'; }
|
||
|
||
function enqueueTask(target, extra) {
|
||
const body = Object.assign({ profile: profile() }, extra || {});
|
||
return fetch('/api/sat/'+target+'/run', {
|
||
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body)
|
||
}).then(r => r.json());
|
||
}
|
||
|
||
function streamTask(taskId, label) {
|
||
if (biES) { biES.close(); biES = null; }
|
||
document.getElementById('bi-output').style.display = 'block';
|
||
document.getElementById('bi-title').textContent = '— ' + label + ' [' + profile() + ']';
|
||
const term = document.getElementById('bi-terminal');
|
||
term.textContent = 'Task ' + taskId + ' queued. Streaming...\n';
|
||
biES = new EventSource('/api/tasks/'+taskId+'/stream');
|
||
biES.onmessage = e => { term.textContent += e.data+'\n'; term.scrollTop = term.scrollHeight; };
|
||
biES.addEventListener('done', e => {
|
||
biES.close(); biES = null;
|
||
term.textContent += (e.data ? '\nERROR: '+e.data : '\nCompleted.')+'\n';
|
||
});
|
||
}
|
||
|
||
function runGPUStress() {
|
||
const ids = ['burn-gpu-bee','burn-gpu-john','burn-gpu-nccl','burn-gpu-rvs'];
|
||
const loaderMap = {'burn-gpu-bee':'builtin','burn-gpu-john':'john','burn-gpu-nccl':'nccl','burn-gpu-rvs':'rvs'};
|
||
const targetMap = {'burn-gpu-bee':'nvidia-stress','burn-gpu-john':'nvidia-stress','burn-gpu-nccl':'nvidia-stress','burn-gpu-rvs':'amd-stress'};
|
||
let last = null;
|
||
ids.filter(id => {
|
||
const el = document.getElementById(id);
|
||
return el && el.checked && !el.disabled;
|
||
}).forEach(id => {
|
||
const target = targetMap[id];
|
||
const extra = target === 'nvidia-stress' ? {loader: loaderMap[id]} : {};
|
||
enqueueTask(target, extra).then(d => { last = d; streamTask(d.task_id, target + ' / ' + loaderMap[id]); });
|
||
});
|
||
}
|
||
|
||
function runComputeStress() {
|
||
const tasks = [
|
||
{id:'burn-cpu', target:'cpu'},
|
||
{id:'burn-mem-stress', target:'memory-stress'},
|
||
{id:'burn-sat-stress', target:'sat-stress'},
|
||
];
|
||
let last = null;
|
||
tasks.filter(t => {
|
||
const el = document.getElementById(t.id);
|
||
return el && el.checked;
|
||
}).forEach(t => {
|
||
enqueueTask(t.target).then(d => { last = d; streamTask(d.task_id, t.target); });
|
||
});
|
||
}
|
||
|
||
function runPlatformStress() {
|
||
const comps = [];
|
||
if (document.getElementById('burn-pt-cpu').checked) comps.push('cpu');
|
||
const nv = document.getElementById('burn-pt-nvidia');
|
||
if (nv && nv.checked && !nv.disabled) comps.push('gpu');
|
||
const am = document.getElementById('burn-pt-amd');
|
||
if (am && am.checked && !am.disabled) comps.push('gpu');
|
||
const extra = comps.length > 0 ? {platform_components: comps} : {};
|
||
enqueueTask('platform-stress', extra).then(d => streamTask(d.task_id, 'platform-stress'));
|
||
}
|
||
|
||
function runAll() {
|
||
const status = document.getElementById('burn-all-status');
|
||
status.textContent = 'Enqueuing...';
|
||
let count = 0;
|
||
const done = () => { count++; status.textContent = count + ' tasks queued.'; };
|
||
|
||
// GPU tests
|
||
const gpuIds = ['burn-gpu-bee','burn-gpu-john','burn-gpu-nccl','burn-gpu-rvs'];
|
||
const loaderMap = {'burn-gpu-bee':'builtin','burn-gpu-john':'john','burn-gpu-nccl':'nccl','burn-gpu-rvs':'rvs'};
|
||
const gpuTargetMap = {'burn-gpu-bee':'nvidia-stress','burn-gpu-john':'nvidia-stress','burn-gpu-nccl':'nvidia-stress','burn-gpu-rvs':'amd-stress'};
|
||
gpuIds.filter(id => { const el = document.getElementById(id); return el && el.checked && !el.disabled; }).forEach(id => {
|
||
const target = gpuTargetMap[id];
|
||
const extra = target === 'nvidia-stress' ? {loader: loaderMap[id]} : {};
|
||
enqueueTask(target, extra).then(d => { streamTask(d.task_id, target); done(); });
|
||
});
|
||
|
||
// Compute tests
|
||
[{id:'burn-cpu',target:'cpu'},{id:'burn-mem-stress',target:'memory-stress'},{id:'burn-sat-stress',target:'sat-stress'}]
|
||
.filter(t => { const el = document.getElementById(t.id); return el && el.checked; })
|
||
.forEach(t => enqueueTask(t.target).then(d => { streamTask(d.task_id, t.target); done(); }));
|
||
|
||
// Platform
|
||
const comps = [];
|
||
if (document.getElementById('burn-pt-cpu').checked) comps.push('cpu');
|
||
const nv = document.getElementById('burn-pt-nvidia');
|
||
if (nv && nv.checked && !nv.disabled) comps.push('gpu');
|
||
const am = document.getElementById('burn-pt-amd');
|
||
if (am && am.checked && !am.disabled) comps.push('gpu');
|
||
const ptExtra = comps.length > 0 ? {platform_components: comps} : {};
|
||
enqueueTask('platform-stress', ptExtra).then(d => { streamTask(d.task_id, 'platform-stress'); done(); });
|
||
}
|
||
|
||
// Load GPU tool availability
|
||
fetch('/api/gpu/tools').then(r => r.json()).then(tools => {
|
||
const nvidiaMap = {'bee-gpu-burn':'burn-gpu-bee','john':'burn-gpu-john','nccl':'burn-gpu-nccl','rvs':'burn-gpu-rvs'};
|
||
const noteMap = {'bee-gpu-burn':'note-bee','john':'note-john','nccl':'note-nccl','rvs':'note-rvs'};
|
||
tools.forEach(t => {
|
||
const cb = document.getElementById(nvidiaMap[t.id]);
|
||
const note = document.getElementById(noteMap[t.id]);
|
||
if (!cb) return;
|
||
if (t.available) {
|
||
cb.disabled = false;
|
||
if (t.id === 'bee-gpu-burn') cb.checked = true;
|
||
} else {
|
||
const reason = t.vendor === 'nvidia' ? 'NVIDIA driver not running' : 'AMD driver not running';
|
||
if (note) note.textContent = '— ' + reason;
|
||
}
|
||
});
|
||
}).catch(() => {});
|
||
|
||
// Load GPU presence for platform thermal cycling
|
||
fetch('/api/gpu/presence').then(r => r.json()).then(gp => {
|
||
const nvCb = document.getElementById('burn-pt-nvidia');
|
||
const amCb = document.getElementById('burn-pt-amd');
|
||
const nvNote = document.getElementById('note-pt-nvidia');
|
||
const amNote = document.getElementById('note-pt-amd');
|
||
if (gp.nvidia) {
|
||
nvCb.disabled = false;
|
||
nvCb.checked = true;
|
||
} else {
|
||
if (nvNote) nvNote.textContent = '— NVIDIA driver not running';
|
||
}
|
||
if (gp.amd) {
|
||
amCb.disabled = false;
|
||
amCb.checked = true;
|
||
} else {
|
||
if (amNote) amNote.textContent = '— AMD driver not running';
|
||
}
|
||
}).catch(() => {});
|
||
</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;
|
||
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>' : '');
|
||
});
|
||
}
|
||
function selectIface(iface) {
|
||
document.getElementById('dhcp-iface').value = iface;
|
||
document.getElementById('st-iface').value = iface;
|
||
}
|
||
function toggleIface(iface, currentState) {
|
||
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) { alert('Error: '+d.error); return; }
|
||
loadNetwork();
|
||
showNetPending(d.rollback_in || 60);
|
||
});
|
||
}
|
||
function showNetPending(secs) {
|
||
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) { clearInterval(_netCountdownTimer); _netCountdownTimer=null; el.style.display='none'; loadNetwork(); }
|
||
}, 1000);
|
||
}
|
||
function confirmNetChange() {
|
||
if (_netCountdownTimer) { clearInterval(_netCountdownTimer); _netCountdownTimer=null; }
|
||
document.getElementById('net-pending').style.display='none';
|
||
fetch('/api/network/confirm',{method:'POST'});
|
||
}
|
||
function rollbackNetChange() {
|
||
if (_netCountdownTimer) { clearInterval(_netCountdownTimer); _netCountdownTimer=null; }
|
||
document.getElementById('net-pending').style.display='none';
|
||
fetch('/api/network/rollback',{method:'POST'}).then(()=>loadNetwork());
|
||
}
|
||
function runDHCP() {
|
||
const iface = document.getElementById('dhcp-iface').value.trim();
|
||
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) showNetPending(d.rollback_in || 60);
|
||
loadNetwork();
|
||
});
|
||
}
|
||
function setStatic() {
|
||
const dns = document.getElementById('st-dns').value.split(',').map(s=>s.trim()).filter(Boolean);
|
||
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) showNetPending(d.rollback_in || 60);
|
||
loadNetwork();
|
||
});
|
||
}
|
||
loadNetwork();
|
||
</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 `<div style="display:flex;justify-content:flex-end;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:8px" class="card">
|
||
<div class="card-head">Output</div>
|
||
<div class="card-body" style="padding:10px"><div id="svc-terminal" class="terminal" style="max-height:150px"></div></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" onclick="svcAction(\''+s.name+'\',\'start\')">Start</button> ' +
|
||
'<button class="btn btn-sm btn-secondary" onclick="svcAction(\''+s.name+'\',\'stop\')">Stop</button> ' +
|
||
'<button class="btn btn-sm btn-secondary" onclick="svcAction(\''+s.name+'\',\'restart\')">Restart</button>' +
|
||
'</td></tr>';
|
||
}).join('');
|
||
document.getElementById('svc-table').innerHTML =
|
||
'<table><tr><th>Service</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(name, action) {
|
||
fetch('/api/services/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,action})})
|
||
.then(r=>r.json()).then(d => {
|
||
document.getElementById('svc-out').style.display='block';
|
||
document.getElementById('svc-terminal').textContent = d.output || d.error || action+' '+name;
|
||
setTimeout(loadServices, 1000);
|
||
});
|
||
}
|
||
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>
|
||
|
||
<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">
|
||
<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>
|
||
</div>
|
||
</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 => {
|
||
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 => {
|
||
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\','+JSON.stringify(t)+')">Audit JSON</button> ' +
|
||
'<button class="btn btn-sm btn-secondary" onclick="usbExport(\'bundle\','+JSON.stringify(t)+')">Support Bundle</button>' +
|
||
'</td></tr>';
|
||
}).join('') + '</table>';
|
||
}).catch(e => {
|
||
document.getElementById('usb-status').textContent = 'Error: ' + e;
|
||
});
|
||
}
|
||
window.usbExport = function(type, target) {
|
||
const msg = document.getElementById('usb-msg');
|
||
msg.style.color = 'var(--muted)';
|
||
msg.textContent = 'Exporting to ' + (target.device||'') + '...';
|
||
fetch('/api/export/usb/'+type, {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify(target)
|
||
}).then(r=>r.json()).then(d => {
|
||
if (d.error) { msg.style.color='var(--err,red)'; msg.textContent = 'Error: '+d.error; return; }
|
||
msg.style.color = 'var(--ok,green)';
|
||
msg.textContent = d.message || 'Done.';
|
||
}).catch(e => {
|
||
msg.style.color = 'var(--err,red)';
|
||
msg.textContent = 'Error: '+e;
|
||
});
|
||
};
|
||
window.usbRefresh = usbRefresh;
|
||
usbRefresh();
|
||
})();
|
||
</script>`
|
||
}
|
||
|
||
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="supportBundleBuild()">Build Support Bundle</button>
|
||
<a id="support-bundle-download" class="btn btn-secondary" href="/export/support.tar.gz" style="display:none">↓ Download Support Bundle</a>
|
||
<div id="support-bundle-status" style="margin-top:12px;font-size:13px;color:var(--muted)">No support bundle built in this session.</div>
|
||
<div id="support-bundle-log" class="terminal" style="display:none;margin-top:12px;max-height:260px"></div>
|
||
<script>
|
||
(function(){
|
||
var _supportBundleES = null;
|
||
window.supportBundleBuild = function() {
|
||
var btn = document.getElementById('support-bundle-btn');
|
||
var status = document.getElementById('support-bundle-status');
|
||
var log = document.getElementById('support-bundle-log');
|
||
var download = document.getElementById('support-bundle-download');
|
||
if (_supportBundleES) {
|
||
_supportBundleES.close();
|
||
_supportBundleES = null;
|
||
}
|
||
btn.disabled = true;
|
||
btn.textContent = 'Building...';
|
||
status.textContent = 'Queueing support bundle task...';
|
||
status.style.color = 'var(--muted)';
|
||
log.style.display = '';
|
||
log.textContent = '';
|
||
download.style.display = 'none';
|
||
|
||
fetch('/api/export/bundle', {method:'POST'}).then(function(r){
|
||
return r.json().then(function(j){
|
||
if (!r.ok) throw new Error(j.error || r.statusText);
|
||
return j;
|
||
});
|
||
}).then(function(data){
|
||
if (!data.task_id) throw new Error('missing task id');
|
||
status.textContent = 'Building support bundle...';
|
||
_supportBundleES = new EventSource('/api/tasks/' + data.task_id + '/stream');
|
||
_supportBundleES.onmessage = function(e) {
|
||
log.textContent += e.data + '\n';
|
||
log.scrollTop = log.scrollHeight;
|
||
};
|
||
_supportBundleES.addEventListener('done', function(e) {
|
||
_supportBundleES.close();
|
||
_supportBundleES = null;
|
||
btn.disabled = false;
|
||
btn.textContent = 'Build Support Bundle';
|
||
if (e.data) {
|
||
status.textContent = 'Error: ' + e.data;
|
||
status.style.color = 'var(--crit-fg)';
|
||
return;
|
||
}
|
||
status.textContent = 'Support bundle ready.';
|
||
status.style.color = 'var(--ok-fg)';
|
||
download.style.display = '';
|
||
});
|
||
_supportBundleES.onerror = function() {
|
||
if (_supportBundleES) _supportBundleES.close();
|
||
_supportBundleES = null;
|
||
btn.disabled = false;
|
||
btn.textContent = 'Build Support Bundle';
|
||
status.textContent = 'Support bundle stream disconnected.';
|
||
status.style.color = 'var(--crit-fg)';
|
||
};
|
||
}).catch(function(e){
|
||
btn.disabled = false;
|
||
btn.textContent = 'Build Support Bundle';
|
||
status.textContent = 'Error: ' + e;
|
||
status.style.color = 'var(--crit-fg)';
|
||
});
|
||
};
|
||
})();
|
||
</script>`
|
||
}
|
||
|
||
// ── Display Resolution ────────────────────────────────────────────────────────
|
||
|
||
func renderDisplayInline() string {
|
||
return `<div id="display-status" style="color:var(--muted);font-size:13px;margin-bottom:12px">Loading displays...</div>
|
||
<div id="display-controls"></div>
|
||
<script>
|
||
(function(){
|
||
function loadDisplays() {
|
||
fetch('/api/display/resolutions').then(r=>r.json()).then(displays => {
|
||
const status = document.getElementById('display-status');
|
||
const ctrl = document.getElementById('display-controls');
|
||
if (!displays || displays.length === 0) {
|
||
status.textContent = 'No connected displays found or xrandr not available.';
|
||
return;
|
||
}
|
||
status.textContent = '';
|
||
ctrl.innerHTML = displays.map(d => {
|
||
const opts = (d.modes||[]).map(m =>
|
||
'<option value="'+m.mode+'"'+(m.current?' selected':'')+'>'+m.mode+(m.current?' (current)':'')+'</option>'
|
||
).join('');
|
||
return '<div style="margin-bottom:12px">'
|
||
+'<span style="font-weight:600;margin-right:8px">'+d.output+'</span>'
|
||
+'<span style="color:var(--muted);font-size:12px;margin-right:12px">Current: '+d.current+'</span>'
|
||
+'<select id="res-sel-'+d.output+'" style="margin-right:8px">'+opts+'</select>'
|
||
+'<button class="btn btn-sm btn-primary" onclick="applyResolution(\''+d.output+'\')">Apply</button>'
|
||
+'</div>';
|
||
}).join('');
|
||
}).catch(()=>{
|
||
document.getElementById('display-status').textContent = 'xrandr not available on this system.';
|
||
});
|
||
}
|
||
window.applyResolution = function(output) {
|
||
const sel = document.getElementById('res-sel-'+output);
|
||
if (!sel) return;
|
||
const mode = sel.value;
|
||
const btn = sel.nextElementSibling;
|
||
btn.disabled = true;
|
||
btn.textContent = 'Applying...';
|
||
fetch('/api/display/set', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({output:output,mode:mode})})
|
||
.then(r=>r.json()).then(d=>{
|
||
if (d.error) { alert('Error: '+d.error); }
|
||
loadDisplays();
|
||
}).catch(e=>{ alert('Error: '+e); })
|
||
.finally(()=>{ btn.disabled=false; btn.textContent='Apply'; });
|
||
};
|
||
loadDisplays();
|
||
})();
|
||
</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="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 txt = document.getElementById('ram-status-text');
|
||
const btn = document.getElementById('ram-install-btn');
|
||
if (d.in_ram) {
|
||
txt.textContent = '✓ Running from RAM — installation media can be safely disconnected.';
|
||
txt.style.color = 'var(--ok, green)';
|
||
} else {
|
||
txt.textContent = 'Live media is mounted from installation device. Copy to RAM to allow media removal.';
|
||
btn.style.display = '';
|
||
}
|
||
});
|
||
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></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">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>
|
||
|
||
<div class="card"><div class="card-head">Display Resolution</div><div class="card-body">` +
|
||
renderDisplayInline() + `</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">
|
||
<button class="btn btn-danger btn-sm" onclick="cancelAll()">Cancel All</button>
|
||
<span style="font-size:12px;color:var(--muted)">Tasks run one at a time. Logs persist after navigation.</span>
|
||
</div>
|
||
<div class="card">
|
||
<div id="tasks-table"><p style="color:var(--muted);font-size:13px;padding:16px">Loading...</p></div>
|
||
</div>
|
||
<div id="task-log-section" style="display:none;margin-top:16px" class="card">
|
||
<div class="card-head">Logs — <span id="task-log-title"></span>
|
||
<button class="btn btn-sm btn-secondary" onclick="closeTaskLog()" style="margin-left:auto">✕</button>
|
||
</div>
|
||
<div class="card-body"><div id="task-log-terminal" class="terminal" style="max-height:500px"></div></div>
|
||
</div>
|
||
<script>
|
||
var _taskLogES = null;
|
||
var _taskRefreshTimer = null;
|
||
|
||
function loadTasks() {
|
||
fetch('/api/tasks').then(r=>r.json()).then(tasks => {
|
||
if (!tasks || tasks.length === 0) {
|
||
document.getElementById('tasks-table').innerHTML = '<p style="color:var(--muted);font-size:13px;padding:16px">No tasks.</p>';
|
||
return;
|
||
}
|
||
const rows = tasks.map(t => {
|
||
const dur = t.started_at ? formatDur(t.started_at, t.done_at) : '';
|
||
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 = '<button class="btn btn-sm btn-secondary" onclick="viewLog(\''+t.id+'\',\''+escHtml(t.name)+'\')">Logs</button>';
|
||
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>'+escHtml(t.name)+'</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('');
|
||
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>';
|
||
});
|
||
}
|
||
|
||
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 formatDur(start, end) {
|
||
try {
|
||
const s = new Date(start), e = end ? new Date(end) : new Date();
|
||
const sec = Math.round((e-s)/1000);
|
||
if (sec < 60) return sec+'s';
|
||
const m = Math.floor(sec/60), ss = sec%60;
|
||
return m+'m '+ss+'s';
|
||
} catch(e){ return ''; }
|
||
}
|
||
|
||
function cancelTask(id) {
|
||
fetch('/api/tasks/'+id+'/cancel',{method:'POST'}).then(()=>loadTasks());
|
||
}
|
||
function cancelAll() {
|
||
fetch('/api/tasks/cancel-all',{method:'POST'}).then(()=>loadTasks());
|
||
}
|
||
function setPriority(id, delta) {
|
||
fetch('/api/tasks/'+id+'/priority',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({delta:delta})})
|
||
.then(()=>loadTasks());
|
||
}
|
||
function viewLog(id, name) {
|
||
if (_taskLogES) { _taskLogES.close(); _taskLogES = null; }
|
||
document.getElementById('task-log-section').style.display = '';
|
||
document.getElementById('task-log-title').textContent = name;
|
||
const term = document.getElementById('task-log-terminal');
|
||
term.textContent = 'Connecting...\n';
|
||
_taskLogES = new EventSource('/api/tasks/'+id+'/stream');
|
||
_taskLogES.onmessage = e => { term.textContent += e.data+'\n'; term.scrollTop=term.scrollHeight; };
|
||
_taskLogES.addEventListener('done', e => {
|
||
_taskLogES.close(); _taskLogES=null;
|
||
term.textContent += (e.data ? '\nERROR: '+e.data : '\nDone.')+'\n';
|
||
});
|
||
}
|
||
function closeTaskLog() {
|
||
if (_taskLogES) { _taskLogES.close(); _taskLogES=null; }
|
||
document.getElementById('task-log-section').style.display='none';
|
||
}
|
||
|
||
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
|
||
}
|