435 lines
17 KiB
Go
435 lines
17 KiB
Go
package webui
|
|
|
|
import (
|
|
"fmt"
|
|
"html"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
func renderExport(exportDir string) string {
|
|
entries, _ := listExportFiles(exportDir)
|
|
var rows strings.Builder
|
|
for _, e := range entries {
|
|
rows.WriteString(fmt.Sprintf(`<tr><td><a href="/export/file?path=%s" target="_blank">%s</a></td></tr>`,
|
|
url.QueryEscape(e), html.EscapeString(e)))
|
|
}
|
|
if len(entries) == 0 {
|
|
rows.WriteString(`<tr><td style="color:var(--muted)">No export files found.</td></tr>`)
|
|
}
|
|
return `<div class="grid2">
|
|
<div class="card"><div class="card-head">Support Bundle</div><div class="card-body">
|
|
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Creates a tar.gz archive of all audit files, SAT results, and logs.</p>
|
|
` + renderSupportBundleInline() + `
|
|
</div></div>
|
|
<div class="card"><div class="card-head">Export Files</div><div class="card-body">
|
|
<table><tr><th>File</th></tr>` + rows.String() + `</table>
|
|
</div></div>
|
|
</div>
|
|
|
|
` + renderUSBExportCard()
|
|
}
|
|
|
|
func listExportFiles(exportDir string) ([]string, error) {
|
|
var entries []string
|
|
err := filepath.Walk(strings.TrimSpace(exportDir), func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel(exportDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entries = append(entries, rel)
|
|
return nil
|
|
})
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
sort.Strings(entries)
|
|
return entries, nil
|
|
}
|
|
|
|
func renderSupportBundleInline() string {
|
|
return `<button id="support-bundle-btn" class="btn btn-primary" onclick="supportBundleDownload()">↓ Download Support Bundle</button>
|
|
<div id="support-bundle-status" style="margin-top:10px;font-size:13px;color:var(--muted)"></div>
|
|
<script>
|
|
window.supportBundleDownload = function() {
|
|
var btn = document.getElementById('support-bundle-btn');
|
|
var status = document.getElementById('support-bundle-status');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Building...';
|
|
status.textContent = 'Collecting logs and export data\u2026';
|
|
status.style.color = 'var(--muted)';
|
|
var filename = 'bee-support.tar.gz';
|
|
fetch('/export/support.tar.gz')
|
|
.then(function(r) {
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
var cd = r.headers.get('Content-Disposition') || '';
|
|
var m = cd.match(/filename="?([^";]+)"?/);
|
|
if (m) filename = m[1];
|
|
return r.blob();
|
|
})
|
|
.then(function(blob) {
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
status.textContent = 'Download started.';
|
|
status.style.color = 'var(--ok-fg)';
|
|
})
|
|
.catch(function(e) {
|
|
status.textContent = 'Error: ' + e.message;
|
|
status.style.color = 'var(--crit-fg)';
|
|
})
|
|
.finally(function() {
|
|
btn.disabled = false;
|
|
btn.textContent = '\u2195 Download Support Bundle';
|
|
});
|
|
};
|
|
</script>`
|
|
}
|
|
|
|
func renderUSBExportCard() string {
|
|
return `<div class="card" style="margin-top:16px">
|
|
<div class="card-head">Export to USB
|
|
<button class="btn btn-sm btn-secondary" onclick="usbRefresh()" style="margin-left:auto">↻ Refresh</button>
|
|
</div>
|
|
<div class="card-body">` + renderUSBExportInline() + `</div>
|
|
</div>`
|
|
}
|
|
|
|
func renderUSBExportInline() string {
|
|
return `<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Write audit JSON or support bundle directly to a removable USB drive.</p>
|
|
<div id="usb-status" style="font-size:13px;color:var(--muted)">Scanning for USB devices...</div>
|
|
<div id="usb-targets" style="margin-top:12px"></div>
|
|
<div id="usb-msg" style="margin-top:10px;font-size:13px"></div>
|
|
<script>
|
|
(function(){
|
|
function usbRefresh() {
|
|
document.getElementById('usb-status').textContent = 'Scanning...';
|
|
document.getElementById('usb-targets').innerHTML = '';
|
|
document.getElementById('usb-msg').textContent = '';
|
|
fetch('/api/export/usb').then(r=>r.json()).then(targets => {
|
|
window._usbTargets = Array.isArray(targets) ? targets : [];
|
|
const st = document.getElementById('usb-status');
|
|
const ct = document.getElementById('usb-targets');
|
|
if (!targets || targets.length === 0) {
|
|
st.textContent = 'No removable USB devices found.';
|
|
return;
|
|
}
|
|
st.textContent = targets.length + ' device(s) found:';
|
|
ct.innerHTML = '<table><tr><th>Device</th><th>FS</th><th>Size</th><th>Label</th><th>Model</th><th>Actions</th></tr>' +
|
|
targets.map((t, idx) => {
|
|
const dev = t.device || '';
|
|
const label = t.label || '';
|
|
const model = t.model || '';
|
|
return '<tr>' +
|
|
'<td style="font-family:monospace">'+dev+'</td>' +
|
|
'<td>'+t.fs_type+'</td>' +
|
|
'<td>'+t.size+'</td>' +
|
|
'<td>'+label+'</td>' +
|
|
'<td style="font-size:12px;color:var(--muted)">'+model+'</td>' +
|
|
'<td style="white-space:nowrap">' +
|
|
'<button class="btn btn-sm btn-primary" onclick="usbExport(\'audit\','+idx+',this)">Audit JSON</button> ' +
|
|
'<button class="btn btn-sm btn-secondary" onclick="usbExport(\'bundle\','+idx+',this)">Support Bundle</button>' +
|
|
'<div class="usb-row-msg" style="margin-top:6px;font-size:12px;color:var(--muted)"></div>' +
|
|
'</td></tr>';
|
|
}).join('') + '</table>';
|
|
}).catch(e => {
|
|
document.getElementById('usb-status').textContent = 'Error: ' + e;
|
|
});
|
|
}
|
|
window.usbExport = function(type, targetIndex, btn) {
|
|
const target = (window._usbTargets || [])[targetIndex];
|
|
if (!target) {
|
|
const msg = document.getElementById('usb-msg');
|
|
msg.style.color = 'var(--err,red)';
|
|
msg.textContent = 'Error: USB target not found. Refresh and try again.';
|
|
return;
|
|
}
|
|
const msg = document.getElementById('usb-msg');
|
|
const row = btn ? btn.closest('td') : null;
|
|
const rowMsg = row ? row.querySelector('.usb-row-msg') : null;
|
|
const originalText = btn ? btn.textContent : '';
|
|
if (btn) {
|
|
btn.disabled = true;
|
|
btn.textContent = 'Exporting...';
|
|
}
|
|
if (rowMsg) {
|
|
rowMsg.style.color = 'var(--muted)';
|
|
rowMsg.textContent = 'Working...';
|
|
}
|
|
msg.style.color = 'var(--muted)';
|
|
msg.textContent = 'Exporting ' + (type === 'bundle' ? 'support bundle' : 'audit JSON') + ' to ' + (target.device||'') + '...';
|
|
fetch('/api/export/usb/'+type, {
|
|
method: 'POST',
|
|
headers: {'Content-Type':'application/json'},
|
|
body: JSON.stringify(target)
|
|
}).then(async r => {
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status));
|
|
return d;
|
|
}).then(d => {
|
|
msg.style.color = 'var(--ok,green)';
|
|
msg.textContent = d.message || 'Done.';
|
|
if (rowMsg) {
|
|
rowMsg.style.color = 'var(--ok,green)';
|
|
rowMsg.textContent = d.message || 'Done.';
|
|
}
|
|
}).catch(e => {
|
|
msg.style.color = 'var(--err,red)';
|
|
msg.textContent = 'Error: '+e;
|
|
if (rowMsg) {
|
|
rowMsg.style.color = 'var(--err,red)';
|
|
rowMsg.textContent = 'Error: ' + e;
|
|
}
|
|
}).finally(() => {
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
btn.textContent = originalText;
|
|
}
|
|
});
|
|
};
|
|
window.usbRefresh = usbRefresh;
|
|
usbRefresh();
|
|
})();
|
|
</script>`
|
|
}
|
|
|
|
func renderNvidiaSelfHealInline() string {
|
|
return `<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Inspect NVIDIA GPU health, restart the bee-nvidia driver service, and issue a per-GPU reset when the driver reports reset required.</p>
|
|
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px">
|
|
<button id="nvidia-restart-btn" class="btn btn-secondary" onclick="nvidiaRestartDrivers()">Restart GPU Drivers</button>
|
|
<button class="btn btn-sm btn-secondary" onclick="loadNvidiaSelfHeal()">↻ Refresh</button>
|
|
</div>
|
|
<div id="nvidia-self-heal-status" style="font-size:13px;color:var(--muted);margin-bottom:12px">Loading NVIDIA GPU status...</div>
|
|
<div id="nvidia-self-heal-table"><p style="color:var(--muted);font-size:13px">Loading...</p></div>
|
|
<div id="nvidia-self-heal-out" style="display:none;margin-top:12px">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
|
|
<span id="nvidia-self-heal-out-label" style="font-size:12px;font-weight:600;color:var(--muted)">Output</span>
|
|
<span id="nvidia-self-heal-out-status" style="font-size:12px"></span>
|
|
</div>
|
|
<div id="nvidia-self-heal-terminal" class="terminal" style="max-height:220px;width:100%;box-sizing:border-box"></div>
|
|
</div>
|
|
<script>
|
|
function nvidiaSelfHealShowResult(label, status, output) {
|
|
var out = document.getElementById('nvidia-self-heal-out');
|
|
var term = document.getElementById('nvidia-self-heal-terminal');
|
|
var statusEl = document.getElementById('nvidia-self-heal-out-status');
|
|
var labelEl = document.getElementById('nvidia-self-heal-out-label');
|
|
out.style.display = 'block';
|
|
labelEl.textContent = label;
|
|
term.textContent = output || '(no output)';
|
|
term.scrollTop = term.scrollHeight;
|
|
if (status === 'ok') {
|
|
statusEl.textContent = '✓ done';
|
|
statusEl.style.color = 'var(--ok-fg, #2c662d)';
|
|
} else {
|
|
statusEl.textContent = '✗ failed';
|
|
statusEl.style.color = 'var(--crit-fg, #9f3a38)';
|
|
}
|
|
}
|
|
function nvidiaRestartDrivers() {
|
|
var btn = document.getElementById('nvidia-restart-btn');
|
|
var original = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.textContent = 'Restarting...';
|
|
nvidiaSelfHealShowResult('restart bee-nvidia', 'ok', 'Running...');
|
|
fetch('/api/services/action', {
|
|
method:'POST',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({name:'bee-nvidia', action:'restart'})
|
|
}).then(r=>r.json()).then(d => {
|
|
nvidiaSelfHealShowResult('restart bee-nvidia', d.status || 'error', d.output || d.error || '(no output)');
|
|
setTimeout(function() {
|
|
loadServices();
|
|
loadNvidiaSelfHeal();
|
|
}, 800);
|
|
}).catch(e => {
|
|
nvidiaSelfHealShowResult('restart bee-nvidia', 'error', 'Request failed: ' + e);
|
|
}).finally(() => {
|
|
btn.disabled = false;
|
|
btn.textContent = original;
|
|
});
|
|
}
|
|
function nvidiaResetGPU(index, btn) {
|
|
var original = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.textContent = 'Resetting...';
|
|
nvidiaSelfHealShowResult('reset gpu ' + index, 'ok', 'Running...');
|
|
fetch('/api/gpu/nvidia-reset', {
|
|
method:'POST',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({index:index})
|
|
}).then(r=>r.json()).then(d => {
|
|
nvidiaSelfHealShowResult('reset gpu ' + index, d.status || 'error', d.output || '(no output)');
|
|
setTimeout(loadNvidiaSelfHeal, 1000);
|
|
}).catch(e => {
|
|
nvidiaSelfHealShowResult('reset gpu ' + index, 'error', 'Request failed: ' + e);
|
|
}).finally(() => {
|
|
btn.disabled = false;
|
|
btn.textContent = original;
|
|
});
|
|
}
|
|
function loadNvidiaSelfHeal() {
|
|
var status = document.getElementById('nvidia-self-heal-status');
|
|
var table = document.getElementById('nvidia-self-heal-table');
|
|
status.textContent = 'Loading NVIDIA GPU status...';
|
|
status.style.color = 'var(--muted)';
|
|
table.innerHTML = '<p style="color:var(--muted);font-size:13px">Loading...</p>';
|
|
fetch('/api/gpu/nvidia-status').then(r=>r.json()).then(gpus => {
|
|
if (!Array.isArray(gpus) || gpus.length === 0) {
|
|
status.textContent = 'No NVIDIA GPUs detected or nvidia-smi is unavailable.';
|
|
table.innerHTML = '';
|
|
return;
|
|
}
|
|
status.textContent = gpus.length + ' NVIDIA GPU(s) detected.';
|
|
const rows = gpus.map(g => {
|
|
const serial = g.serial || '';
|
|
const bdf = g.bdf || '';
|
|
const id = serial || bdf || ('gpu-' + g.index);
|
|
const badge = g.status === 'OK' ? 'badge-ok' : g.status === 'RESET_REQUIRED' ? 'badge-err' : 'badge-warn';
|
|
const details = [];
|
|
if (serial) details.push('serial ' + serial);
|
|
if (bdf) details.push('bdf ' + bdf);
|
|
if (g.parse_failure && g.raw_line) details.push(g.raw_line);
|
|
return '<tr>'
|
|
+ '<td style="white-space:nowrap">' + g.index + '</td>'
|
|
+ '<td>' + (g.name || 'unknown') + '</td>'
|
|
+ '<td style="font-family:monospace">' + id + '</td>'
|
|
+ '<td><span class="badge ' + badge + '">' + (g.status || 'UNKNOWN') + '</span>'
|
|
+ (details.length ? '<div style="margin-top:4px;font-size:12px;color:var(--muted)">' + details.join(' | ') + '</div>' : '')
|
|
+ '</td>'
|
|
+ '<td style="white-space:nowrap"><button class="btn btn-sm btn-secondary" onclick="nvidiaResetGPU(' + g.index + ', this)">Reset GPU</button></td>'
|
|
+ '</tr>';
|
|
}).join('');
|
|
table.innerHTML = '<table><tr><th>GPU</th><th>Model</th><th>ID</th><th>Status</th><th>Action</th></tr>' + rows + '</table>';
|
|
}).catch(e => {
|
|
status.textContent = 'Error loading NVIDIA GPU status: ' + e;
|
|
status.style.color = 'var(--crit-fg, #9f3a38)';
|
|
table.innerHTML = '';
|
|
});
|
|
}
|
|
loadNvidiaSelfHeal();
|
|
</script>`
|
|
}
|
|
|
|
func renderTools() string {
|
|
return `<div class="card" style="margin-bottom:16px">
|
|
<div class="card-head">System Install</div>
|
|
<div class="card-body">
|
|
<div style="margin-bottom:20px">
|
|
<div style="font-weight:600;margin-bottom:8px">Install to RAM</div>
|
|
<p id="boot-source-text" style="color:var(--muted);font-size:13px;margin-bottom:8px">Detecting boot source...</p>
|
|
<p id="ram-status-text" style="color:var(--muted);font-size:13px;margin-bottom:8px">Checking...</p>
|
|
<button id="ram-install-btn" class="btn btn-primary" onclick="installToRAM()" style="display:none">▶ Copy to RAM</button>
|
|
</div>
|
|
<div style="border-top:1px solid var(--line);padding-top:20px">
|
|
<div style="font-weight:600;margin-bottom:8px">Install to Disk</div>` +
|
|
renderInstallInline() + `
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
fetch('/api/system/ram-status').then(r=>r.json()).then(d=>{
|
|
const boot = document.getElementById('boot-source-text');
|
|
const txt = document.getElementById('ram-status-text');
|
|
const btn = document.getElementById('ram-install-btn');
|
|
let source = d.device || d.source || 'unknown source';
|
|
let kind = d.kind || 'unknown';
|
|
let label = source;
|
|
if (kind === 'ram') label = 'RAM';
|
|
else if (kind === 'usb') label = 'USB (' + source + ')';
|
|
else if (kind === 'cdrom') label = 'CD-ROM (' + source + ')';
|
|
else if (kind === 'disk') label = 'disk (' + source + ')';
|
|
else label = source;
|
|
boot.textContent = 'Current boot source: ' + label + '.';
|
|
txt.textContent = d.message || 'Checking...';
|
|
if (d.status === 'ok' || d.in_ram) {
|
|
txt.style.color = 'var(--ok, green)';
|
|
} else if (d.status === 'failed') {
|
|
txt.style.color = 'var(--err, #b91c1c)';
|
|
} else {
|
|
txt.style.color = 'var(--muted)';
|
|
}
|
|
if (d.can_start_task) {
|
|
btn.style.display = '';
|
|
btn.disabled = false;
|
|
} else {
|
|
btn.style.display = 'none';
|
|
}
|
|
});
|
|
function installToRAM() {
|
|
document.getElementById('ram-install-btn').disabled = true;
|
|
fetch('/api/system/install-to-ram', {method:'POST'}).then(r=>r.json()).then(d=>{
|
|
window.location.href = '/tasks#' + d.task_id;
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<div class="card"><div class="card-head">Support Bundle</div><div class="card-body">
|
|
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Downloads a tar.gz archive of all audit files, SAT results, and logs.</p>
|
|
` + renderSupportBundleInline() + `
|
|
<div style="border-top:1px solid var(--border);margin-top:16px;padding-top:16px">
|
|
<div style="font-weight:600;margin-bottom:8px">Export to USB</div>
|
|
` + renderUSBExportInline() + `
|
|
</div>
|
|
</div></div>
|
|
|
|
<div class="card"><div class="card-head">Tool Check <button class="btn btn-sm btn-secondary" onclick="checkTools()" style="margin-left:auto">↻ Check</button></div>
|
|
<div class="card-body"><div id="tools-table"><p style="color:var(--muted);font-size:13px">Checking...</p></div></div></div>
|
|
|
|
<div class="card"><div class="card-head">NVIDIA Self Heal</div><div class="card-body">` +
|
|
renderNvidiaSelfHealInline() + `</div></div>
|
|
|
|
<div class="card"><div class="card-head">Network</div><div class="card-body">` +
|
|
renderNetworkInline() + `</div></div>
|
|
|
|
<div class="card"><div class="card-head">Services</div><div class="card-body">` +
|
|
renderServicesInline() + `</div></div>
|
|
|
|
|
|
<script>
|
|
function checkTools() {
|
|
document.getElementById('tools-table').innerHTML = '<p style="color:var(--muted);font-size:13px">Checking...</p>';
|
|
fetch('/api/tools/check').then(r=>r.json()).then(tools => {
|
|
const rows = tools.map(t =>
|
|
'<tr><td>'+t.Name+'</td><td><span class="badge '+(t.OK ? 'badge-ok' : 'badge-err')+'">'+(t.OK ? '✓ '+t.Path : '✗ missing')+'</span></td></tr>'
|
|
).join('');
|
|
document.getElementById('tools-table').innerHTML =
|
|
'<table><tr><th>Tool</th><th>Status</th></tr>'+rows+'</table>';
|
|
});
|
|
}
|
|
checkTools();
|
|
</script>`
|
|
}
|
|
|
|
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
|
|
}
|