1463 lines
68 KiB
Go
1463 lines
68 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"><div class="card-head">Burn Profile</div><div class="card-body">
|
|
<div class="form-row" style="max-width:320px"><label>Preset</label><select id="burn-profile"><option value="smoke" selected>Smoke: quick check (~5 min CPU / DCGM level 1)</option><option value="acceptance">Acceptance: 1 hour (DCGM level 3)</option><option value="overnight">Overnight: 8 hours (DCGM level 4)</option></select></div>
|
|
<p style="color:var(--muted);font-size:12px">Applied to all tests on this page. NVIDIA SAT on the Validate page still uses DCGM. NVIDIA GPU Stress on this page uses the selected stress loader for the preset duration.</p>
|
|
</div></div>
|
|
<div class="grid3">
|
|
<div class="card"><div class="card-head">NVIDIA GPU Stress</div><div class="card-body">
|
|
<div class="form-row"><label>Load Tool</label><select id="nvidia-stress-loader"><option value="builtin" selected>bee-gpu-burn</option><option value="nccl">NCCL all_reduce_perf</option><option value="john">John the Ripper jumbo (OpenCL)</option></select></div>
|
|
<div class="form-row"><label>Exclude GPU indices</label><input type="text" id="nvidia-stress-exclude" placeholder="e.g. 1,3"></div>
|
|
<p style="color:var(--muted);font-size:12px;margin-bottom:8px"><code>bee-gpu-burn</code> runs on all detected NVIDIA GPUs by default. <code>NCCL all_reduce_perf</code> is useful for multi-GPU / interconnect load. Use exclusions only when one or more cards must be skipped.</p>
|
|
<button id="sat-btn-nvidia-stress" class="btn btn-primary" onclick="runBurnIn('nvidia-stress')">▶ Start NVIDIA Stress</button>
|
|
</div></div>
|
|
<div class="card"><div class="card-head">CPU Stress</div><div class="card-body">
|
|
<button class="btn btn-primary" onclick="runBurnIn('cpu')">▶ Start CPU Stress</button>
|
|
</div></div>
|
|
<div class="card"><div class="card-head">AMD GPU Stress</div><div class="card-body">
|
|
<p style="color:var(--muted);font-size:12px;margin-bottom:8px">Runs ROCm compute stress together with VRAM copy/load activity via RVS GST and records a separate <code>rocm-bandwidth-test</code> snapshot. Missing tools reported as UNSUPPORTED.</p>
|
|
<button id="sat-btn-amd-stress" class="btn btn-primary" onclick="runBurnIn('amd-stress')">▶ Start AMD Stress</button>
|
|
</div></div>
|
|
<div class="card"><div class="card-head">Memory Stress</div><div class="card-body">
|
|
<p style="color:var(--muted);font-size:12px;margin-bottom:8px">stress-ng --vm writes and verifies memory patterns across all of RAM. Env: <code>BEE_VM_STRESS_SECONDS</code> (default 300), <code>BEE_VM_STRESS_SIZE_MB</code> (default 80%).</p>
|
|
<button class="btn btn-primary" onclick="runBurnIn('memory-stress')">▶ Start Memory Stress</button>
|
|
</div></div>
|
|
<div class="card"><div class="card-head">SAT Stress (stressapptest)</div><div class="card-body">
|
|
<p style="color:var(--muted);font-size:12px;margin-bottom:8px">Google stressapptest saturates CPU, memory and cache buses simultaneously. Env: <code>BEE_SAT_STRESS_SECONDS</code> (default 300), <code>BEE_SAT_STRESS_MB</code> (default auto).</p>
|
|
<button class="btn btn-primary" onclick="runBurnIn('sat-stress')">▶ Start SAT Stress</button>
|
|
</div></div>
|
|
<div class="card"><div class="card-head">Platform Thermal Cycling</div><div class="card-body">
|
|
<p style="color:var(--muted);font-size:12px;margin-bottom:8px">Runs CPU + GPU stress simultaneously across multiple load/idle cycles with varying durations. Detects cooling systems that fail to recover under repeated load cycles. Smoke: 2 cycles ~5 min. Acceptance: 4 cycles ~25 min.</p>
|
|
<button class="btn btn-primary" onclick="runBurnIn('platform-stress')">▶ Start 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>
|
|
<script>
|
|
let biES = null;
|
|
function parseGPUIndexList(raw) {
|
|
return (raw || '')
|
|
.split(',')
|
|
.map(v => v.trim())
|
|
.filter(v => v !== '')
|
|
.map(v => Number(v))
|
|
.filter(v => Number.isInteger(v) && v >= 0);
|
|
}
|
|
function runBurnIn(target) {
|
|
if (biES) { biES.close(); biES = null; }
|
|
const body = { profile: document.getElementById('burn-profile').value || 'smoke' };
|
|
if (target === 'nvidia-stress') {
|
|
body.loader = document.getElementById('nvidia-stress-loader').value || 'builtin';
|
|
body.exclude_gpu_indices = parseGPUIndexList(document.getElementById('nvidia-stress-exclude').value);
|
|
}
|
|
document.getElementById('bi-output').style.display='block';
|
|
const loaderLabel = body.loader ? ' / ' + body.loader : '';
|
|
document.getElementById('bi-title').textContent = '— ' + target + loaderLabel + ' [' + body.profile + ']';
|
|
const term = document.getElementById('bi-terminal');
|
|
term.textContent = 'Enqueuing ' + target + ' stress...\n';
|
|
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.\n';
|
|
biES = new EventSource('/api/tasks/'+d.task_id+'/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'; });
|
|
});
|
|
}
|
|
</script>
|
|
<script>
|
|
fetch('/api/gpu/presence').then(r=>r.json()).then(gp => {
|
|
if (!gp.nvidia) disableSATCard('nvidia-stress', 'No NVIDIA GPU detected');
|
|
if (!gp.amd) disableSATCard('amd-stress', '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>`
|
|
}
|
|
|
|
// ── 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>
|
|
<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>
|
|
|
|
<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
|
|
}
|
|
|
|
// ── 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>
|
|
<a class="btn btn-primary" href="/export/support.tar.gz">↓ Download Support Bundle</a>
|
|
</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){
|
|
if (r.status === 204) {
|
|
installStreamLog();
|
|
} else {
|
|
return r.json().then(function(j){ throw new Error(j.error || r.statusText); });
|
|
}
|
|
}).catch(function(e){
|
|
status.textContent = 'Error: ' + e;
|
|
status.style.color = 'var(--crit-fg)';
|
|
});
|
|
}
|
|
|
|
function installStreamLog() {
|
|
var term = document.getElementById('install-terminal');
|
|
var status = document.getElementById('install-status');
|
|
var es = new EventSource('/api/install/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
|
|
}
|