706 lines
24 KiB
Go
706 lines
24 KiB
Go
package webui
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"html"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"bee/audit/internal/app"
|
||
"bee/audit/internal/schema"
|
||
)
|
||
|
||
// renderPage dispatches to the appropriate page renderer.
|
||
func renderPage(page string, opts HandlerOptions) string {
|
||
var pageID, title, body string
|
||
switch page {
|
||
case "dashboard", "":
|
||
pageID = "dashboard"
|
||
title = "Dashboard"
|
||
body = renderDashboard(opts)
|
||
case "audit":
|
||
pageID = "audit"
|
||
title = "1. Audit"
|
||
body = renderAudit()
|
||
case "check":
|
||
pageID = "check"
|
||
title = "2. Check"
|
||
body = renderCheck(opts)
|
||
case "load":
|
||
pageID = "load"
|
||
title = "3. Load"
|
||
body = renderValidateStress(opts)
|
||
case "burn":
|
||
pageID = "burn"
|
||
title = "4. Burn"
|
||
body = renderBurn()
|
||
case "benchmark":
|
||
pageID = "benchmark"
|
||
title = "5. Benchmark"
|
||
body = renderBenchmark(opts)
|
||
case "scenario":
|
||
pageID = "scenario"
|
||
title = "6. Scenario"
|
||
body = renderScenario(opts)
|
||
case "tools":
|
||
pageID = "tools"
|
||
title = "Tools"
|
||
body = renderTools()
|
||
case "topo":
|
||
pageID = "topo"
|
||
title = "Topology"
|
||
body = renderTopo(opts)
|
||
case "settings":
|
||
pageID = "settings"
|
||
title = "Settings"
|
||
body = renderSettings(opts)
|
||
// Legacy routes (redirected at HTTP level in handlePage; these are fallbacks)
|
||
case "validate", "tests":
|
||
pageID = "load"
|
||
title = "3. Load"
|
||
body = renderValidate(opts)
|
||
case "burn-in":
|
||
pageID = "burn"
|
||
title = "4. Burn"
|
||
body = renderBurn()
|
||
case "speed", "endurance":
|
||
pageID = "benchmark"
|
||
title = "5. Benchmark"
|
||
body = renderBenchmark(opts)
|
||
case "tasks":
|
||
pageID = "tasks"
|
||
title = "Tasks"
|
||
body = renderTasks()
|
||
// Hidden pages (not in nav, accessible by direct URL)
|
||
case "metrics":
|
||
pageID = "metrics"
|
||
title = "Live Metrics"
|
||
body = renderMetrics()
|
||
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() +
|
||
`<dialog id="component-detail-dialog" style="min-width:600px;max-width:900px;width:90vw;padding:0;border:1px solid var(--border);border-radius:8px;background:var(--surface)"><div id="component-detail-body" style="padding-bottom:20px"></div></dialog>` +
|
||
`<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);
|
||
});
|
||
function openComponentDetail(type) {
|
||
var dlg = document.getElementById('component-detail-dialog');
|
||
var body = document.getElementById('component-detail-body');
|
||
body.innerHTML = '<div style="padding:20px;color:var(--muted)">Loading…</div>';
|
||
dlg.showModal();
|
||
fetch('/api/components/' + type).then(function(r){ return r.text(); }).then(function(html){
|
||
body.innerHTML = html;
|
||
}).catch(function(){
|
||
body.innerHTML = '<div style="padding:20px;color:var(--crit-fg)">Error loading details.</div>';
|
||
});
|
||
}
|
||
</script>` +
|
||
`</body></html>`
|
||
}
|
||
|
||
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
||
|
||
func renderDashboard(opts HandlerOptions) string {
|
||
var b strings.Builder
|
||
b.WriteString(renderAuditStatusBanner(opts))
|
||
b.WriteString(renderTimeSyncCard())
|
||
b.WriteString(renderHardwareSummaryCard(opts))
|
||
b.WriteString(renderHealthCard(opts))
|
||
b.WriteString(renderMetrics())
|
||
b.WriteString(`<script>
|
||
setInterval(function(){
|
||
fetch('/api/hardware-summary').then(function(r){return r.text();}).then(function(html){
|
||
var el=document.getElementById('hw-summary-card');
|
||
if(el){el.outerHTML=html;}
|
||
}).catch(function(){});
|
||
},30000);
|
||
</script>`)
|
||
return b.String()
|
||
}
|
||
|
||
// renderTimeSyncCard shows the server's current clock and timezone next to the
|
||
// browser's own, highlights any drift, and offers a button that syncs the host
|
||
// clock and timezone to whatever the client's browser reports.
|
||
func renderTimeSyncCard() string {
|
||
return `<div class="card" style="margin-bottom:16px">
|
||
<div class="card-head card-head-actions">
|
||
<span>System Time</span>
|
||
<div class="card-head-buttons">
|
||
<button id="time-sync-btn" class="btn btn-primary btn-sm" onclick="timeSyncRun()">↻ Sync with this browser</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<table style="font-size:13px;border-collapse:collapse">
|
||
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Server</td>
|
||
<td style="padding:2px 24px 2px 0" id="time-server-clock">—</td>
|
||
<td style="padding:2px 0" id="time-server-tz">—</td></tr>
|
||
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Browser</td>
|
||
<td style="padding:2px 24px 2px 0" id="time-browser-clock">—</td>
|
||
<td style="padding:2px 0" id="time-browser-tz">—</td></tr>
|
||
</table>
|
||
<div id="time-drift-note" style="font-size:13px;margin-top:8px"></div>
|
||
<span id="time-sync-status" style="font-size:13px;color:var(--muted)"></span>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
(function(){
|
||
var CRIT = 'var(--crit-fg,#9f3a38)';
|
||
var OK = 'var(--ok-fg,#2c662d)';
|
||
var refreshPending = false;
|
||
|
||
function refreshTime() {
|
||
var browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||
document.getElementById('time-browser-clock').textContent = new Date().toLocaleString();
|
||
document.getElementById('time-browser-tz').textContent = browserTz;
|
||
|
||
if (refreshPending) return Promise.resolve();
|
||
refreshPending = true;
|
||
return fetch('/api/system/time', {cache: 'no-store'})
|
||
.then(function(r){ if(!r.ok) throw new Error(r.statusText); return r.json(); })
|
||
.then(function(d){
|
||
var serverMs = d.epoch_ms;
|
||
var serverTz = d.timezone || '';
|
||
var skewMs = Math.abs(Date.now() - serverMs);
|
||
document.getElementById('time-server-clock').textContent = new Date(serverMs).toLocaleString(undefined, serverTz ? {timeZone: serverTz} : undefined);
|
||
document.getElementById('time-server-tz').textContent = serverTz;
|
||
|
||
var clockBad = skewMs > 60000;
|
||
var tzBad = serverTz !== browserTz;
|
||
document.getElementById('time-server-clock').style.color = clockBad ? CRIT : '';
|
||
document.getElementById('time-browser-clock').style.color = clockBad ? CRIT : '';
|
||
document.getElementById('time-server-tz').style.color = tzBad ? CRIT : '';
|
||
document.getElementById('time-browser-tz').style.color = tzBad ? CRIT : '';
|
||
|
||
var note = document.getElementById('time-drift-note');
|
||
if (clockBad || tzBad) {
|
||
var parts = [];
|
||
if (clockBad) parts.push('clock differs by ' + Math.round(skewMs/1000) + 's');
|
||
if (tzBad) parts.push('timezone mismatch');
|
||
note.style.color = CRIT;
|
||
note.textContent = '⚠ ' + parts.join(', ') + ' — click "Sync with this browser"';
|
||
} else {
|
||
note.style.color = OK;
|
||
note.textContent = '✓ server clock and timezone match this browser';
|
||
}
|
||
})
|
||
.catch(function(){
|
||
document.getElementById('time-server-clock').textContent = 'unavailable';
|
||
})
|
||
.finally(function(){ refreshPending = false; });
|
||
}
|
||
|
||
window.timeSyncRun = function() {
|
||
var btn = document.getElementById('time-sync-btn');
|
||
var status = document.getElementById('time-sync-status');
|
||
btn.disabled = true;
|
||
var tz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||
fetch('/api/system/time-sync', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({timezone: tz, epoch_ms: Date.now()})
|
||
})
|
||
.then(function(r) { if (!r.ok) return r.text().then(function(t){throw new Error(t || r.statusText);}); return r.json(); })
|
||
.then(function(d) {
|
||
status.style.color = OK;
|
||
status.textContent = '✓ Synced to ' + tz + ' at ' + new Date().toLocaleString();
|
||
refreshTime();
|
||
})
|
||
.catch(function(err) {
|
||
status.style.color = CRIT;
|
||
status.textContent = '✗ Sync failed: ' + err.message;
|
||
})
|
||
.finally(function() { btn.disabled = false; });
|
||
};
|
||
|
||
refreshTime();
|
||
setInterval(refreshTime, 5000);
|
||
})();
|
||
</script>`
|
||
}
|
||
|
||
// 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 {
|
||
const cardID = ` id="hw-summary-card"`
|
||
data, err := loadSnapshot(opts.AuditPath)
|
||
if err != nil {
|
||
return `<div class="card"` + cardID + `><div class="card-head card-head-actions"><span>Hardware Summary</span><div class="card-head-buttons"><button class="btn btn-primary btn-sm" onclick="auditModalRun()">Run audit</button></div></div><div class="card-body"></div></div>`
|
||
}
|
||
var ingest schema.HardwareIngestRequest
|
||
if err := json.Unmarshal(data, &ingest); err != nil {
|
||
return `<div class="card"` + cardID + `><div class="card-head">Hardware Summary</div><div class="card-body"><span class="badge badge-err">Parse error</span></div></div>`
|
||
}
|
||
hw := ingest.Hardware
|
||
|
||
var records []app.ComponentStatusRecord
|
||
if db, err := app.OpenComponentStatusDB(filepath.Join(opts.ExportDir, "component-status.json")); err == nil {
|
||
records = db.All()
|
||
}
|
||
gpuRecords := matchedRecords(records, nil, []string{"pcie:gpu:"})
|
||
|
||
var b strings.Builder
|
||
b.WriteString(`<div class="card"` + cardID + `><div class="card-head">Hardware Summary</div><div class="card-body">`)
|
||
|
||
if reason, needsReboot := gpuNeedsPhysicalReboot(gpuRecords); needsReboot {
|
||
fmt.Fprintf(&b, `<div class="alert alert-crit">⚠ GPU needs a physical reboot/power-cycle — further GPU tests will keep failing until then: %s</div>`,
|
||
html.EscapeString(reason))
|
||
}
|
||
|
||
// Server identity block above the component table.
|
||
{
|
||
var model, serial string
|
||
parts := []string{}
|
||
if hw.Board.Manufacturer != nil && strings.TrimSpace(*hw.Board.Manufacturer) != "" {
|
||
parts = append(parts, strings.TrimSpace(*hw.Board.Manufacturer))
|
||
}
|
||
if hw.Board.ProductName != nil && strings.TrimSpace(*hw.Board.ProductName) != "" {
|
||
parts = append(parts, strings.TrimSpace(*hw.Board.ProductName))
|
||
}
|
||
if len(parts) > 0 {
|
||
model = strings.Join(parts, " ")
|
||
}
|
||
serial = strings.TrimSpace(hw.Board.SerialNumber)
|
||
if model != "" || serial != "" {
|
||
b.WriteString(`<div style="margin-bottom:14px">`)
|
||
if model != "" {
|
||
fmt.Fprintf(&b, `<div style="font-size:16px;font-weight:700;margin-bottom:2px">%s</div>`, html.EscapeString(model))
|
||
}
|
||
if serial != "" {
|
||
fmt.Fprintf(&b, `<div style="font-size:12px;color:var(--muted)">S/N: %s</div>`, html.EscapeString(serial))
|
||
}
|
||
b.WriteString(`</div>`)
|
||
}
|
||
}
|
||
|
||
b.WriteString(`<table style="width:auto">`)
|
||
// writeRow renders one component row. compType is the URL path segment for the detail
|
||
// endpoint (e.g. "cpu"). Pass "" for rows that have no detail view.
|
||
writeRow := func(label, value, badgeHTML, compType string) {
|
||
var labelHTML string
|
||
if compType != "" {
|
||
labelHTML = fmt.Sprintf(
|
||
`<span style="cursor:pointer;text-decoration:underline dotted;text-underline-offset:3px" onclick="openComponentDetail('%s')">%s</span>`,
|
||
compType, html.EscapeString(label))
|
||
} else {
|
||
labelHTML = html.EscapeString(label)
|
||
}
|
||
fmt.Fprintf(&b, `<tr><td style="padding:6px 14px 6px 0;font-weight:700;white-space:nowrap">%s</td><td style="padding:6px 0;color:var(--muted);font-size:13px">%s</td><td style="padding:6px 0 6px 12px">%s</td></tr>`,
|
||
labelHTML, html.EscapeString(value), badgeHTML)
|
||
}
|
||
|
||
writeRow("CPU", hwDescribeCPU(hw),
|
||
renderComponentChips(matchedRecords(records, []string{"cpu:all"}, nil)), "cpu")
|
||
|
||
writeRow("Memory", hwDescribeMemory(hw),
|
||
renderComponentChips(matchedRecords(records, []string{"memory:all"}, []string{"memory:"})), "memory")
|
||
|
||
writeRow("Storage", hwDescribeStorage(hw),
|
||
renderComponentChips(matchedRecords(records, []string{"storage:all"}, []string{"storage:"})), "storage")
|
||
|
||
writeRow("GPU", hwDescribeGPU(hw), renderComponentChips(gpuRecords), "gpu")
|
||
|
||
psuMatched := matchedRecords(records, nil, []string{"psu:"})
|
||
if len(psuMatched) == 0 && len(hw.PowerSupplies) > 0 {
|
||
// No PSU records yet — synthesise a single chip from IPMI status.
|
||
psuStatus := hwPSUStatus(hw.PowerSupplies)
|
||
psuMatched = []app.ComponentStatusRecord{{ComponentKey: "psu:ipmi", Status: psuStatus}}
|
||
}
|
||
writeRow("PSU", hwDescribePSU(hw), renderComponentChips(psuMatched), "psu")
|
||
|
||
if nicDesc := hwDescribeNIC(hw); nicDesc != "" {
|
||
writeRow("Network", nicDesc, "", "")
|
||
}
|
||
|
||
b.WriteString(`</table>`)
|
||
b.WriteString(`</div></div>`)
|
||
return b.String()
|
||
}
|
||
|
||
// hwDescribeCPU returns a human-readable CPU summary, e.g. "2× Intel Xeon Gold 6338".
|
||
func hwDescribeCPU(hw schema.HardwareSnapshot) string {
|
||
counts := map[string]int{}
|
||
order := []string{}
|
||
for _, cpu := range hw.CPUs {
|
||
model := "Unknown CPU"
|
||
if cpu.Model != nil && *cpu.Model != "" {
|
||
model = *cpu.Model
|
||
}
|
||
if counts[model] == 0 {
|
||
order = append(order, model)
|
||
}
|
||
counts[model]++
|
||
}
|
||
if len(order) == 0 {
|
||
return "—"
|
||
}
|
||
parts := make([]string, 0, len(order))
|
||
for _, m := range order {
|
||
if counts[m] > 1 {
|
||
parts = append(parts, fmt.Sprintf("%d× %s", counts[m], m))
|
||
} else {
|
||
parts = append(parts, m)
|
||
}
|
||
}
|
||
return strings.Join(parts, ", ")
|
||
}
|
||
|
||
// hwDescribeMemory returns a summary like "16× 32 GB DDR4".
|
||
func hwDescribeMemory(hw schema.HardwareSnapshot) string {
|
||
type key struct {
|
||
sizeMB int
|
||
typ string
|
||
}
|
||
counts := map[key]int{}
|
||
order := []key{}
|
||
for _, dimm := range hw.Memory {
|
||
if dimm.SizeMB == nil || *dimm.SizeMB == 0 {
|
||
continue
|
||
}
|
||
t := ""
|
||
if dimm.Type != nil {
|
||
t = *dimm.Type
|
||
}
|
||
k := key{*dimm.SizeMB, t}
|
||
if counts[k] == 0 {
|
||
order = append(order, k)
|
||
}
|
||
counts[k]++
|
||
}
|
||
if len(order) == 0 {
|
||
return "—"
|
||
}
|
||
parts := make([]string, 0, len(order))
|
||
for _, k := range order {
|
||
gb := k.sizeMB / 1024
|
||
desc := fmt.Sprintf("%d× %d GB", counts[k], gb)
|
||
if k.typ != "" {
|
||
desc += " " + k.typ
|
||
}
|
||
parts = append(parts, desc)
|
||
}
|
||
return strings.Join(parts, ", ")
|
||
}
|
||
|
||
// hwDescribeStorage returns a summary like "4× 3.84 TB NVMe, 2× 1.92 TB SATA".
|
||
func hwDescribeStorage(hw schema.HardwareSnapshot) string {
|
||
type key struct {
|
||
sizeGB int
|
||
iface string
|
||
}
|
||
counts := map[key]int{}
|
||
order := []key{}
|
||
for _, disk := range hw.Storage {
|
||
sz := 0
|
||
if disk.SizeGB != nil {
|
||
sz = *disk.SizeGB
|
||
}
|
||
iface := ""
|
||
if disk.Interface != nil {
|
||
iface = *disk.Interface
|
||
} else if disk.Type != nil {
|
||
iface = *disk.Type
|
||
}
|
||
k := key{sz, iface}
|
||
if counts[k] == 0 {
|
||
order = append(order, k)
|
||
}
|
||
counts[k]++
|
||
}
|
||
if len(order) == 0 {
|
||
return "—"
|
||
}
|
||
parts := make([]string, 0, len(order))
|
||
for _, k := range order {
|
||
var sizeStr string
|
||
if k.sizeGB >= 1000 {
|
||
sizeStr = fmt.Sprintf("%.2g TB", float64(k.sizeGB)/1000)
|
||
} else if k.sizeGB > 0 {
|
||
sizeStr = fmt.Sprintf("%d GB", k.sizeGB)
|
||
} else {
|
||
sizeStr = "?"
|
||
}
|
||
desc := fmt.Sprintf("%d× %s", counts[k], sizeStr)
|
||
if k.iface != "" {
|
||
desc += " " + k.iface
|
||
}
|
||
parts = append(parts, desc)
|
||
}
|
||
return strings.Join(parts, ", ")
|
||
}
|
||
|
||
// hwDescribeGPU returns a summary like "8× NVIDIA H100 80GB".
|
||
func hwDescribeGPU(hw schema.HardwareSnapshot) string {
|
||
counts := map[string]int{}
|
||
order := []string{}
|
||
for _, dev := range hw.PCIeDevices {
|
||
if dev.DeviceClass == nil {
|
||
continue
|
||
}
|
||
if !isGPUDeviceClass(*dev.DeviceClass) {
|
||
continue
|
||
}
|
||
model := "Unknown GPU"
|
||
if dev.Model != nil && *dev.Model != "" {
|
||
model = *dev.Model
|
||
}
|
||
if counts[model] == 0 {
|
||
order = append(order, model)
|
||
}
|
||
counts[model]++
|
||
}
|
||
if len(order) == 0 {
|
||
return "—"
|
||
}
|
||
parts := make([]string, 0, len(order))
|
||
for _, m := range order {
|
||
if counts[m] > 1 {
|
||
parts = append(parts, fmt.Sprintf("%d× %s", counts[m], m))
|
||
} else {
|
||
parts = append(parts, m)
|
||
}
|
||
}
|
||
return strings.Join(parts, ", ")
|
||
}
|
||
|
||
// hwPSUStatus returns "OK", "CRITICAL", "WARNING", or "UNKNOWN" based on
|
||
// PSU statuses from the audit snapshot. Used as fallback when component-status.json
|
||
// has no psu: records yet (e.g. first boot before audit writes them).
|
||
func hwPSUStatus(psus []schema.HardwarePowerSupply) string {
|
||
worst := "UNKNOWN"
|
||
for _, psu := range psus {
|
||
if psu.Status == nil {
|
||
continue
|
||
}
|
||
switch strings.ToUpper(strings.TrimSpace(*psu.Status)) {
|
||
case "CRITICAL":
|
||
return "CRITICAL"
|
||
case "WARNING":
|
||
if worst != "CRITICAL" {
|
||
worst = "WARNING"
|
||
}
|
||
case "OK":
|
||
if worst == "UNKNOWN" {
|
||
worst = "OK"
|
||
}
|
||
}
|
||
}
|
||
return worst
|
||
}
|
||
|
||
// hwDescribePSU returns a summary like "2× 1600 W" or "2× PSU".
|
||
func hwDescribePSU(hw schema.HardwareSnapshot) string {
|
||
n := len(hw.PowerSupplies)
|
||
if n == 0 {
|
||
return "—"
|
||
}
|
||
// Try to get a consistent wattage
|
||
watt := 0
|
||
consistent := true
|
||
for _, psu := range hw.PowerSupplies {
|
||
if psu.WattageW == nil {
|
||
consistent = false
|
||
break
|
||
}
|
||
if watt == 0 {
|
||
watt = *psu.WattageW
|
||
} else if *psu.WattageW != watt {
|
||
consistent = false
|
||
break
|
||
}
|
||
}
|
||
if consistent && watt > 0 {
|
||
return fmt.Sprintf("%d× %d W", n, watt)
|
||
}
|
||
return fmt.Sprintf("%d× PSU", n)
|
||
}
|
||
|
||
// isNICDeviceClass reports whether a normalized PCIe DeviceClass string
|
||
// (see collector.mapPCIeDeviceClass) identifies a NIC/HBA-class device.
|
||
// webui does not import collector (see the "Classification helpers" note
|
||
// in page_topo.go), so this mirrors collector.isNICClass locally.
|
||
func isNICDeviceClass(class string) bool {
|
||
c := strings.ToLower(strings.TrimSpace(class))
|
||
return c == "ethernetcontroller" || c == "networkcontroller" || strings.Contains(c, "fibrechannel")
|
||
}
|
||
|
||
// hwDescribeNIC returns a summary like "2× Mellanox ConnectX-6".
|
||
func hwDescribeNIC(hw schema.HardwareSnapshot) string {
|
||
counts := map[string]int{}
|
||
order := []string{}
|
||
for _, dev := range hw.PCIeDevices {
|
||
isNIC := dev.DeviceClass != nil && isNICDeviceClass(*dev.DeviceClass)
|
||
if !isNIC && len(dev.MacAddresses) == 0 {
|
||
continue
|
||
}
|
||
model := ""
|
||
if dev.Model != nil && *dev.Model != "" {
|
||
model = *dev.Model
|
||
} else if dev.Manufacturer != nil && *dev.Manufacturer != "" {
|
||
model = *dev.Manufacturer + " NIC"
|
||
} else {
|
||
model = "NIC"
|
||
}
|
||
if counts[model] == 0 {
|
||
order = append(order, model)
|
||
}
|
||
counts[model]++
|
||
}
|
||
if len(order) == 0 {
|
||
return ""
|
||
}
|
||
parts := make([]string, 0, len(order))
|
||
for _, m := range order {
|
||
if counts[m] > 1 {
|
||
parts = append(parts, fmt.Sprintf("%d× %s", counts[m], m))
|
||
} else {
|
||
parts = append(parts, m)
|
||
}
|
||
}
|
||
return strings.Join(parts, ", ")
|
||
}
|
||
|
||
func isGPUDeviceClass(class string) bool {
|
||
switch strings.TrimSpace(class) {
|
||
case "VideoController", "DisplayController", "ProcessingAccelerator":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func renderAuditModal() string {
|
||
return `<div id="audit-modal-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;align-items:center;justify-content:center">
|
||
<div style="background:#fff;border-radius:6px;padding:24px;min-width:480px;max-width:1100px;width:min(1100px,92vw);max-height:92vh;overflow:auto;position:relative">
|
||
<div style="font-weight:700;font-size:16px;margin-bottom:16px">Audit</div>
|
||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||
<button class="btn btn-primary" onclick="auditModalRun()">▶ Re-run Audit</button>
|
||
<a class="btn btn-secondary" href="/audit.json" download>↓ Download</a>
|
||
</div>
|
||
<div id="audit-modal-terminal" class="terminal" style="display:none;max-height:220px;margin-bottom:12px"></div>
|
||
<iframe class="viewer-frame" src="/viewer" title="Audit viewer in modal" style="height:min(70vh,720px)"></iframe>
|
||
<button class="btn btn-secondary btn-sm" onclick="closeAuditModal()" style="position:absolute;top:12px;right:12px">✕</button>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
function openAuditModal() {
|
||
document.getElementById('audit-modal-overlay').style.display='flex';
|
||
}
|
||
function closeAuditModal() {
|
||
document.getElementById('audit-modal-overlay').style.display='none';
|
||
}
|
||
function auditModalRun() {
|
||
const term = document.getElementById('audit-modal-terminal');
|
||
term.style.display='block'; term.textContent='Starting...\n';
|
||
fetch('/api/audit/run',{method:'POST'}).then(r=>r.json()).then(d=>{
|
||
const es=new EventSource('/api/tasks/'+d.task_id+'/stream');
|
||
es.onmessage=e=>{term.textContent+=e.data+'\n';term.scrollTop=term.scrollHeight;};
|
||
es.addEventListener('done',e=>{es.close();term.textContent+=(e.data?'\nERROR: '+e.data:'\nDone.')+'\n';});
|
||
});
|
||
}
|
||
</script>`
|
||
}
|