Metrics:
- Replace canvas JS charts with server-side SVG via go-analyze/charts
- Add ring buffers (120 samples) for CPU temp and power
- /api/metrics/chart/{name}.svg endpoint serves live SVG, polled every 2s
Dashboard:
- Replace custom renderViewerPage with viewer.RenderHTML() from reanimator/chart submodule
- Mount chart static assets at /chart/static/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
661 lines
30 KiB
Go
661 lines
30 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>
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:system-ui,-apple-system,sans-serif;background:#0f1117;color:#e2e8f0;display:flex;min-height:100vh}
|
|
a{color:inherit;text-decoration:none}
|
|
/* Sidebar */
|
|
.sidebar{width:200px;min-height:100vh;background:#161b25;border-right:1px solid #252d3d;flex-shrink:0;display:flex;flex-direction:column}
|
|
.sidebar-logo{padding:20px 16px 12px;font-size:20px;font-weight:700;color:#60a5fa;letter-spacing:-0.5px}
|
|
.sidebar-logo span{color:#94a3b8;font-weight:400;font-size:13px;display:block;margin-top:2px}
|
|
.nav{flex:1}
|
|
.nav-item{display:block;padding:10px 16px;color:#94a3b8;font-size:14px;border-left:3px solid transparent;transition:all .15s}
|
|
.nav-item:hover,.nav-item.active{background:#1e2535;color:#e2e8f0;border-left-color:#3b82f6}
|
|
.nav-icon{margin-right:8px;opacity:.7}
|
|
/* Content */
|
|
.main{flex:1;display:flex;flex-direction:column;overflow:auto}
|
|
.topbar{padding:16px 24px;border-bottom:1px solid #1e2535;display:flex;align-items:center;gap:12px}
|
|
.topbar h1{font-size:18px;font-weight:600}
|
|
.content{padding:24px;flex:1}
|
|
/* Cards */
|
|
.card{background:#161b25;border:1px solid #1e2535;border-radius:10px;margin-bottom:16px}
|
|
.card-head{padding:14px 18px;border-bottom:1px solid #1e2535;font-weight:600;font-size:14px;display:flex;align-items:center;gap:8px}
|
|
.card-body{padding:18px}
|
|
/* Buttons */
|
|
.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;border:none;transition:background .15s}
|
|
.btn-primary{background:#3b82f6;color:#fff}.btn-primary:hover{background:#2563eb}
|
|
.btn-danger{background:#ef4444;color:#fff}.btn-danger:hover{background:#dc2626}
|
|
.btn-secondary{background:#1e2535;color:#94a3b8;border:1px solid #252d3d}.btn-secondary:hover{background:#252d3d;color:#e2e8f0}
|
|
.btn-sm{padding:5px 10px;font-size:12px}
|
|
/* Tables */
|
|
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
th{text-align:left;padding:8px 12px;color:#64748b;font-weight:600;border-bottom:1px solid #1e2535}
|
|
td{padding:8px 12px;border-bottom:1px solid #1a2030}
|
|
tr:last-child td{border:none}
|
|
tr:hover td{background:#1a2030}
|
|
/* Status badges */
|
|
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600}
|
|
.badge-ok{background:#166534;color:#86efac}
|
|
.badge-warn{background:#713f12;color:#fde68a}
|
|
.badge-err{background:#7f1d1d;color:#fca5a5}
|
|
.badge-unknown{background:#1e293b;color:#64748b}
|
|
/* Output terminal */
|
|
.terminal{background:#0a0d14;border:1px solid #1e2535;border-radius:8px;padding:14px;font-family:monospace;font-size:12px;color:#86efac;max-height:400px;overflow-y:auto;white-space:pre-wrap;word-break:break-all}
|
|
/* Forms */
|
|
.form-row{margin-bottom:14px}
|
|
.form-row label{display:block;font-size:12px;color:#64748b;margin-bottom:5px}
|
|
.form-row input,.form-row select{width:100%;padding:8px 10px;background:#0f1117;border:1px solid #252d3d;border-radius:6px;color:#e2e8f0;font-size:13px;outline:none}
|
|
.form-row input:focus,.form-row select:focus{border-color:#3b82f6}
|
|
.chart-legend{font-size:11px;color:#64748b;padding:4px 0}
|
|
/* 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:8px;background:#1a1f2e}
|
|
/* Alerts */
|
|
.alert{padding:10px 14px;border-radius:8px;font-size:13px;margin-bottom:14px}
|
|
.alert-info{background:#1e3a5f;border:1px solid #2563eb;color:#93c5fd}
|
|
.alert-warn{background:#451a03;border:1px solid #d97706;color:#fde68a}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
`
|
|
}
|
|
|
|
func layoutNav(active string) string {
|
|
items := []struct{ id, icon, label string }{
|
|
{"dashboard", "", "Dashboard"},
|
|
{"metrics", "", "Metrics"},
|
|
{"tests", "", "Acceptance Tests"},
|
|
{"burn-in", "", "Burn-in"},
|
|
{"network", "", "Network"},
|
|
{"services", "", "Services"},
|
|
{"export", "", "Export"},
|
|
{"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"
|
|
}
|
|
href := "/"
|
|
if item.id != "dashboard" {
|
|
href = "/" + item.id
|
|
}
|
|
b.WriteString(fmt.Sprintf(`<a class="%s" href="%s">%s</a>`,
|
|
cls, href, item.label))
|
|
}
|
|
b.WriteString(`</nav></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 "metrics":
|
|
pageID = "metrics"
|
|
title = "Live Metrics"
|
|
body = renderMetrics()
|
|
case "tests":
|
|
pageID = "tests"
|
|
title = "Acceptance Tests"
|
|
body = renderTests()
|
|
case "burn-in":
|
|
pageID = "burn-in"
|
|
title = "Burn-in Tests"
|
|
body = renderBurnIn()
|
|
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 "tools":
|
|
pageID = "tools"
|
|
title = "Tools"
|
|
body = renderTools()
|
|
default:
|
|
pageID = "dashboard"
|
|
title = "Not Found"
|
|
body = `<div class="alert alert-warn">Page not found.</div>`
|
|
}
|
|
|
|
return layoutHead(opts.Title+" — "+title) +
|
|
layoutNav(pageID) +
|
|
`<div class="main"><div class="topbar"><h1>` + html.EscapeString(title) + `</h1></div><div class="content">` +
|
|
body +
|
|
`</div></div></body></html>`
|
|
}
|
|
|
|
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
|
|
|
func renderDashboard(opts HandlerOptions) string {
|
|
var b strings.Builder
|
|
b.WriteString(`<div class="grid2">`)
|
|
// Left: health summary
|
|
b.WriteString(`<div>`)
|
|
b.WriteString(renderHealthCard(opts))
|
|
b.WriteString(`</div>`)
|
|
// Right: quick actions
|
|
b.WriteString(`<div>`)
|
|
b.WriteString(`<div class="card"><div class="card-head">Quick Actions</div><div class="card-body">`)
|
|
b.WriteString(`<a class="btn btn-primary" href="/export/support.tar.gz" style="display:block;margin-bottom:10px">⬇ Download Support Bundle</a>`)
|
|
b.WriteString(`<a class="btn btn-secondary" href="/audit.json" style="display:block;margin-bottom:10px" target="_blank">📄 Open audit.json</a>`)
|
|
b.WriteString(`<a class="btn btn-secondary" href="/export/" style="display:block">📁 Browse Export Files</a>`)
|
|
b.WriteString(`<div style="margin-top:14px"><button class="btn btn-secondary" onclick="runAudit()">▶ Re-run Audit</button></div>`)
|
|
b.WriteString(`</div></div>`)
|
|
b.WriteString(`</div>`)
|
|
b.WriteString(`</div>`)
|
|
// Audit viewer iframe
|
|
b.WriteString(`<div class="card"><div class="card-head">Audit Snapshot</div><div class="card-body" style="padding:0">`)
|
|
b.WriteString(`<iframe class="viewer-frame" src="/viewer" loading="eager" referrerpolicy="same-origin"></iframe>`)
|
|
b.WriteString(`</div></div>`)
|
|
|
|
// Audit run output div
|
|
b.WriteString(`<div id="audit-output" style="display:none" class="card"><div class="card-head">Audit Output</div><div class="card-body"><div id="audit-terminal" class="terminal"></div></div></div>`)
|
|
|
|
b.WriteString(`<script>
|
|
function runAudit() {
|
|
document.getElementById('audit-output').style.display='block';
|
|
const term = document.getElementById('audit-terminal');
|
|
term.textContent = 'Starting audit...\n';
|
|
fetch('/api/audit/run', {method:'POST'})
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
const es = new EventSource('/api/audit/stream?job_id=' + d.job_id);
|
|
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'; location.reload(); });
|
|
});
|
|
}
|
|
</script>`)
|
|
return b.String()
|
|
}
|
|
|
|
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:#64748b;font-size:13px;margin-bottom:16px">Live server metrics, charts updated every 2 seconds.</p>
|
|
<div class="grid2">
|
|
<div class="card">
|
|
<div class="card-head">System</div>
|
|
<div class="card-body">
|
|
<img id="chart-cpu-temp" src="/api/metrics/chart/cpu-temp.svg" style="width:100%;border-radius:6px" alt="CPU Temp">
|
|
<img id="chart-power" src="/api/metrics/chart/power.svg" style="width:100%;border-radius:6px;margin-top:8px" alt="Power">
|
|
<div id="sys-table" style="margin-top:8px"></div>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-head">GPU</div>
|
|
<div class="card-body">
|
|
<div id="gpu-table"><p style="color:#64748b;font-size:12px">Waiting for data...</p></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
function refreshCharts() {
|
|
const t = '?t=' + Date.now();
|
|
['chart-cpu-temp','chart-power'].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.src = el.src.split('?')[0] + t;
|
|
});
|
|
}
|
|
setInterval(refreshCharts, 2000);
|
|
|
|
const es = new EventSource('/api/metrics/stream');
|
|
es.addEventListener('metrics', e => {
|
|
const d = JSON.parse(e.data);
|
|
const gpuRows = (d.gpus||[]).map(g =>
|
|
'<tr><td>GPU '+g.index+'</td><td>'+g.temp_c+'°C</td><td>'+g.usage_pct+'%</td><td>'+g.power_w+'W</td><td>'+g.clock_mhz+'MHz</td></tr>'
|
|
).join('');
|
|
document.getElementById('gpu-table').innerHTML = gpuRows ?
|
|
'<table><tr><th>GPU</th><th>Temp</th><th>Usage</th><th>Power</th><th>Clock</th></tr>'+gpuRows+'</table>' :
|
|
'<p style="color:#64748b;font-size:12px">No NVIDIA GPU detected</p>';
|
|
|
|
let sysHTML = '';
|
|
const cpuTemp = (d.temps||[]).find(t => t.name==='CPU');
|
|
if (cpuTemp) sysHTML += '<tr><td>CPU Temp</td><td>'+cpuTemp.celsius.toFixed(1)+'°C</td></tr>';
|
|
(d.fans||[]).forEach(f => sysHTML += '<tr><td>'+f.name+'</td><td>'+f.rpm+' RPM</td></tr>');
|
|
if (d.power_w) sysHTML += '<tr><td>System Power</td><td>'+d.power_w.toFixed(0)+'W</td></tr>';
|
|
document.getElementById('sys-table').innerHTML = sysHTML ?
|
|
'<table>'+sysHTML+'</table>' :
|
|
'<p style="color:#64748b;font-size:12px">No sensor data (ipmitool/sensors required)</p>';
|
|
});
|
|
es.onerror = () => {};
|
|
</script>`
|
|
}
|
|
|
|
// ── Acceptance Tests ──────────────────────────────────────────────────────────
|
|
|
|
func renderTests() string {
|
|
return `<p style="color:#64748b;font-size:13px;margin-bottom:16px">Run hardware acceptance tests and view results.</p>
|
|
<div class="grid2">
|
|
` + 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>`) +
|
|
`</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();
|
|
const body = {};
|
|
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 = 'Starting ' + target + ' test...\n';
|
|
fetch('/api/sat/'+target+'/run', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
satES = new EventSource('/api/sat/stream?job_id='+d.job_id);
|
|
satES.onmessage = e => { term.textContent += e.data+'\n'; term.scrollTop=term.scrollHeight; };
|
|
satES.addEventListener('done', e => { satES.close(); term.textContent += (e.data ? '\nERROR: '+e.data : '\nCompleted.')+'\n'; });
|
|
});
|
|
}
|
|
</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 class="btn btn-primary" onclick="runSAT('%s')">▶ Run Test</button></div></div>`,
|
|
label, extra, id)
|
|
}
|
|
|
|
// ── Burn-in ───────────────────────────────────────────────────────────────────
|
|
|
|
func renderBurnIn() string {
|
|
return `<p style="color:#64748b;font-size:13px;margin-bottom:16px">Long-running GPU and system stress tests. Check <a href="/metrics" style="color:#60a5fa">Metrics</a> page for live telemetry.</p>
|
|
<div class="grid2">
|
|
<div class="card"><div class="card-head">GPU Platform Stress</div><div class="card-body">
|
|
<div class="form-row"><label>Duration</label><select id="bi-dur"><option value="600">10 minutes</option><option value="3600">1 hour</option><option value="28800">8 hours</option><option value="86400">24 hours</option></select></div>
|
|
<button class="btn btn-primary" onclick="runBurnIn('nvidia')">▶ Start GPU Stress</button>
|
|
</div></div>
|
|
<div class="card"><div class="card-head">CPU Stress</div><div class="card-body">
|
|
<div class="form-row"><label>Duration (seconds)</label><input type="number" id="bi-cpu-dur" value="300" min="60"></div>
|
|
<button class="btn btn-primary" onclick="runBurnIn('cpu')">▶ Start CPU Stress</button>
|
|
</div></div>
|
|
</div>
|
|
<div id="bi-output" style="display:none;margin-top:16px" class="card">
|
|
<div class="card-head">Output</div>
|
|
<div class="card-body"><div id="bi-terminal" class="terminal"></div></div>
|
|
</div>
|
|
<script>
|
|
let biES = null;
|
|
function runBurnIn(target) {
|
|
if (biES) biES.close();
|
|
const body = {};
|
|
if (target === 'nvidia') body.duration = parseInt(document.getElementById('bi-dur').value)||600;
|
|
if (target === 'cpu') body.duration = parseInt(document.getElementById('bi-cpu-dur').value)||300;
|
|
document.getElementById('bi-output').style.display='block';
|
|
const term = document.getElementById('bi-terminal');
|
|
term.textContent = 'Starting ' + target + ' burn-in...\n';
|
|
fetch('/api/sat/'+target+'/run', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
biES = new EventSource('/api/sat/stream?job_id='+d.job_id);
|
|
biES.onmessage = e => { term.textContent += e.data+'\n'; term.scrollTop=term.scrollHeight; };
|
|
biES.addEventListener('done', e => { biES.close(); term.textContent += (e.data ? '\nERROR: '+e.data : '\nCompleted.')+'\n'; });
|
|
});
|
|
}
|
|
</script>`
|
|
}
|
|
|
|
// ── Network ───────────────────────────────────────────────────────────────────
|
|
|
|
func renderNetwork() string {
|
|
return `<div class="card"><div class="card-head">Network Interfaces</div><div class="card-body">
|
|
<div id="iface-table"><p style="color:#64748b;font-size:13px">Loading...</p></div>
|
|
</div></div>
|
|
<div class="grid2">
|
|
<div class="card"><div class="card-head">DHCP</div><div class="card-body">
|
|
<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:#86efac"></div>
|
|
</div></div>
|
|
<div class="card"><div class="card-head">Static IPv4</div><div class="card-body">
|
|
<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:#86efac"></div>
|
|
</div></div>
|
|
</div>
|
|
<script>
|
|
function loadNetwork() {
|
|
fetch('/api/network').then(r=>r.json()).then(d => {
|
|
const rows = (d.interfaces||[]).map(i =>
|
|
'<tr><td>'+i.Name+'</td><td><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</th><th>Addresses</th></tr>'+rows+'</table>' +
|
|
(d.default_route ? '<p style="font-size:12px;color:#64748b;margin-top:8px">Default route: '+d.default_route+'</p>' : '');
|
|
});
|
|
}
|
|
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.';
|
|
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.';
|
|
loadNetwork();
|
|
});
|
|
}
|
|
loadNetwork();
|
|
</script>`
|
|
}
|
|
|
|
// ── Services ──────────────────────────────────────────────────────────────────
|
|
|
|
func renderServices() string {
|
|
return `<div class="card"><div class="card-head">Bee Services <button class="btn btn-sm btn-secondary" onclick="loadServices()" style="margin-left:auto">↻ Refresh</button></div>
|
|
<div class="card-body">
|
|
<div id="svc-table"><p style="color:#64748b;font-size:13px">Loading...</p></div>
|
|
</div></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:#0a0d14;padding:8px;border-radius:6px;color:#94a3b8">'+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>`
|
|
}
|
|
|
|
// ── 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:#64748b">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:#94a3b8;margin-bottom:12px">Creates a tar.gz archive of all audit files, SAT results, and logs.</p>
|
|
<a class="btn btn-primary" href="/export/support.tar.gz">⬇ Download Support Bundle</a>
|
|
</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>`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ── Tools ─────────────────────────────────────────────────────────────────────
|
|
|
|
func renderTools() string {
|
|
return `<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:#64748b;font-size:13px">Click Check to verify installed tools.</p></div></div></div>
|
|
<script>
|
|
function checkTools() {
|
|
document.getElementById('tools-table').innerHTML = '<p style="color:#64748b;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>`
|
|
}
|
|
|
|
// ── Viewer (compatibility) ────────────────────────────────────────────────────
|
|
|
|
// renderViewerPage renders the audit snapshot as a styled HTML page.
|
|
// This endpoint is embedded as an iframe on the Dashboard page.
|
|
func renderViewerPage(title string, snapshot []byte) string {
|
|
var b strings.Builder
|
|
b.WriteString(`<!DOCTYPE html><html><head><meta charset="utf-8">`)
|
|
b.WriteString(`<title>` + html.EscapeString(title) + `</title>`)
|
|
b.WriteString(`<style>
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:system-ui,sans-serif;background:#0f1117;color:#e2e8f0;padding:20px}
|
|
h2{font-size:14px;color:#64748b;margin-bottom:8px;margin-top:16px;text-transform:uppercase;letter-spacing:.05em}
|
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
|
|
.card{background:#161b25;border:1px solid #1e2535;border-radius:8px;padding:14px}
|
|
.card-title{font-size:12px;color:#64748b;margin-bottom:6px}
|
|
.card-value{font-size:15px;font-weight:600}
|
|
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600}
|
|
.ok{background:#166534;color:#86efac}.warn{background:#713f12;color:#fde68a}.err{background:#7f1d1d;color:#fca5a5}
|
|
pre{background:#0a0d14;border:1px solid #1e2535;border-radius:6px;padding:12px;font-size:11px;overflow-x:auto;color:#94a3b8;white-space:pre-wrap;word-break:break-word;max-height:400px;overflow-y:auto}
|
|
</style></head><body>
|
|
`)
|
|
if len(snapshot) == 0 {
|
|
b.WriteString(`<p style="color:#64748b">No audit snapshot available yet. Re-run audit from the Dashboard.</p>`)
|
|
b.WriteString(`</body></html>`)
|
|
return b.String()
|
|
}
|
|
|
|
var data map[string]any
|
|
if err := json.Unmarshal(snapshot, &data); err != nil {
|
|
// Fallback: render raw JSON
|
|
b.WriteString(`<pre>` + html.EscapeString(string(snapshot)) + `</pre>`)
|
|
b.WriteString(`</body></html>`)
|
|
return b.String()
|
|
}
|
|
|
|
// Collected at
|
|
if t, ok := data["collected_at"].(string); ok {
|
|
b.WriteString(`<p style="font-size:12px;color:#64748b;margin-bottom:16px">Collected: ` + html.EscapeString(t) + `</p>`)
|
|
}
|
|
|
|
// Hardware section
|
|
hw, _ := data["hardware"].(map[string]any)
|
|
if hw == nil {
|
|
hw = data
|
|
}
|
|
|
|
renderHWCards(&b, hw)
|
|
|
|
// Full JSON below
|
|
b.WriteString(`<h2>Raw JSON</h2>`)
|
|
pretty, _ := json.MarshalIndent(data, "", " ")
|
|
b.WriteString(`<pre>` + html.EscapeString(string(pretty)) + `</pre>`)
|
|
b.WriteString(`</body></html>`)
|
|
return b.String()
|
|
}
|
|
|
|
func renderHWCards(b *strings.Builder, hw map[string]any) {
|
|
sections := []struct{ key, label string }{
|
|
{"board", "Board"},
|
|
{"cpus", "CPUs"},
|
|
{"memory", "Memory"},
|
|
{"storage", "Storage"},
|
|
{"gpus", "GPUs"},
|
|
{"nics", "NICs"},
|
|
{"psus", "Power Supplies"},
|
|
}
|
|
for _, s := range sections {
|
|
v, ok := hw[s.key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
b.WriteString(`<h2>` + s.label + `</h2><div class="grid">`)
|
|
renderValue(b, v)
|
|
b.WriteString(`</div>`)
|
|
}
|
|
}
|
|
|
|
func renderValue(b *strings.Builder, v any) {
|
|
switch val := v.(type) {
|
|
case []any:
|
|
for _, item := range val {
|
|
renderValue(b, item)
|
|
}
|
|
case map[string]any:
|
|
b.WriteString(`<div class="card">`)
|
|
for k, vv := range val {
|
|
b.WriteString(fmt.Sprintf(`<div class="card-title">%s</div><div class="card-value">%s</div>`,
|
|
html.EscapeString(k), html.EscapeString(fmt.Sprintf("%v", vv))))
|
|
}
|
|
b.WriteString(`</div>`)
|
|
}
|
|
}
|
|
|
|
// ── Export index (compatibility) ──────────────────────────────────────────────
|
|
|
|
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
|
|
}
|