Replaces separate IPMI FRU and SAA DMI cards with a single FRU / Elabel card that reads all available sources in parallel and shows each field with a color-coded source chip (IPMI FRU / Huawei iBMC / SAA DMI). Huawei elabel fields are read/written via OEM IPMI raw commands (NetFn 0x30, cmd 0x90) with 19-byte chunking protocol, matching the FusionServer ElabelTool V511 wire format. Covers DeviceName, DeviceSerialNumber, ProductName, ProductSerialNumber, ProductAssetTag, ProductManufacturer, MainboardManufacturer, BoardProductName, ChassisPartnumber, ChassisType (read-only), IOChassisSerial, IOChassisAssetTag, and GUID (read-only via standard 0x06 0x08). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
614 lines
25 KiB
Go
614 lines
25 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">USB Black-Box
|
|
<button class="btn btn-sm btn-secondary" onclick="blackboxRefresh()" 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">Marks removable USB devices as black-box targets. The dedicated bee-blackbox service mirrors export files and system logs into a boot-scoped folder and resumes automatically after restart.</p>
|
|
<div id="usb-status" style="font-size:13px;color:var(--muted)">Scanning for USB devices...</div>
|
|
<div id="blackbox-summary" style="margin-top:8px;font-size:13px;color:var(--muted)">Loading black-box status...</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 blackboxRefresh() {
|
|
document.getElementById('usb-status').textContent = 'Scanning...';
|
|
document.getElementById('blackbox-summary').textContent = 'Loading black-box status...';
|
|
document.getElementById('usb-targets').innerHTML = '';
|
|
document.getElementById('usb-msg').textContent = '';
|
|
Promise.all([
|
|
fetch('/api/export/usb').then(r=>r.json()),
|
|
fetch('/api/blackbox/status').then(r=>r.json())
|
|
]).then(function(values) {
|
|
const targets = Array.isArray(values[0]) ? values[0] : [];
|
|
const state = values[1] || {};
|
|
const active = Array.isArray(state.targets) ? state.targets : [];
|
|
window._usbTargets = targets;
|
|
window._blackboxTargets = active;
|
|
const st = document.getElementById('usb-status');
|
|
const ct = document.getElementById('usb-targets');
|
|
const summary = document.getElementById('blackbox-summary');
|
|
if (state.boot_folder) {
|
|
summary.textContent = 'Service state: ' + (state.status || 'unknown') + '. Boot folder: ' + state.boot_folder + '.';
|
|
} else {
|
|
summary.textContent = 'Service state: ' + (state.status || 'disabled') + '.';
|
|
}
|
|
if (!targets || targets.length === 0) {
|
|
st.textContent = 'No removable USB devices found.';
|
|
} else {
|
|
st.textContent = targets.length + ' device(s) found:';
|
|
}
|
|
const byDevice = {};
|
|
active.forEach(function(item) { byDevice[item.device] = item; });
|
|
ct.innerHTML = '<table><tr><th>Device</th><th>FS</th><th>Size</th><th>Label</th><th>Model</th><th>Black-Box</th><th>Actions</th></tr>' +
|
|
targets.map((t, idx) => {
|
|
const dev = t.device || '';
|
|
const label = t.label || '';
|
|
const model = t.model || '';
|
|
const state = byDevice[dev];
|
|
const status = state ? (state.status + (state.flush_period ? ', flush ' + state.flush_period : '')) : 'not enrolled';
|
|
const detail = state && state.last_error ? ('<div style="font-size:12px;color:var(--err,red)">'+state.last_error+'</div>') : '';
|
|
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="font-size:12px">'+status+detail+'</td>' +
|
|
'<td style="white-space:nowrap">' +
|
|
(state
|
|
? '<button class="btn btn-sm btn-secondary" onclick="blackboxDisable('+idx+',this)">Disable</button>'
|
|
: '<button class="btn btn-sm btn-primary" onclick="blackboxEnable('+idx+',this)">Enable</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.blackboxEnable = function(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 = 'Enabling...';
|
|
}
|
|
if (rowMsg) {
|
|
rowMsg.style.color = 'var(--muted)';
|
|
rowMsg.textContent = 'Working...';
|
|
}
|
|
msg.style.color = 'var(--muted)';
|
|
msg.textContent = 'Enabling black-box on ' + (target.device||'') + '...';
|
|
fetch('/api/blackbox/enable', {
|
|
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;
|
|
}
|
|
setTimeout(blackboxRefresh, 300);
|
|
});
|
|
};
|
|
window.blackboxDisable = function(targetIndex, btn) {
|
|
const target = (window._usbTargets || [])[targetIndex];
|
|
const active = (window._blackboxTargets || []).find(function(item){ return item.device === (target && target.device); });
|
|
if (!target || !active) {
|
|
const msg = document.getElementById('usb-msg');
|
|
msg.style.color = 'var(--err,red)';
|
|
msg.textContent = 'Error: black-box 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 = 'Disabling...';
|
|
}
|
|
if (rowMsg) {
|
|
rowMsg.style.color = 'var(--muted)';
|
|
rowMsg.textContent = 'Working...';
|
|
}
|
|
msg.style.color = 'var(--muted)';
|
|
msg.textContent = 'Disabling black-box on ' + (target.device||'') + '...';
|
|
fetch('/api/blackbox/disable', {
|
|
method:'POST',
|
|
headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({device: target.device, enrollment_id: active.enrollment_id})
|
|
}).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;
|
|
}
|
|
setTimeout(blackboxRefresh, 300);
|
|
});
|
|
};
|
|
window.blackboxRefresh = blackboxRefresh;
|
|
blackboxRefresh();
|
|
})();
|
|
</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 renderNVMeFormatCard() + `
|
|
|
|
` + renderFRUEditorCard() + `
|
|
|
|
` + renderRAIDMgmtCard()
|
|
}
|
|
|
|
func renderFRUEditorCard() string {
|
|
return `<div class="card"><div class="card-head card-head-actions">FRU / Elabel<div class="card-head-buttons"><button class="btn btn-sm btn-secondary" onclick="fruAllRead()">Read All</button></div></div><div class="card-body">
|
|
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Reads and edits hardware identity fields from all available sources. Each field shows its source method.</p>
|
|
<div id="fru-all-status" style="font-size:13px;color:var(--muted);margin-bottom:8px"></div>
|
|
<div id="fru-all-table"></div>
|
|
</div></div>
|
|
<style>
|
|
.fru-chip{display:inline-block;font-size:10px;font-weight:600;letter-spacing:.02em;padding:1px 6px;border-radius:3px;vertical-align:middle;white-space:nowrap;margin-right:8px;flex-shrink:0}
|
|
.fru-chip-ipmi{background:#e8e8e8;color:#555}
|
|
.fru-chip-huawei{background:#fff0e6;color:#b83}
|
|
.fru-chip-saa{background:#e6f0ff;color:#557}
|
|
.fru-inp-wrap{display:flex;align-items:center;gap:0}
|
|
</style>
|
|
<script>
|
|
(function(){
|
|
var _actBtn='width:22px;height:22px;padding:0;font-size:13px;line-height:1;border:1px solid var(--line);border-radius:3px;background:var(--surface);cursor:pointer;vertical-align:middle;';
|
|
var _inp='width:100%;padding:3px 6px;border:1.5px solid #888;border-radius:3px;font-size:13px;font-family:monospace;background:var(--surface);color:var(--ink);';
|
|
|
|
var SOURCES = [
|
|
{
|
|
id: 'ipmi-fru',
|
|
label: 'IPMI FRU',
|
|
chipClass: 'fru-chip-ipmi',
|
|
url: '/api/tools/ipmi-fru',
|
|
writeUrl: '/api/tools/ipmi-fru/write',
|
|
rowAttrs: function(f) {
|
|
return 'data-source="ipmi-fru" data-area="'+esc(f.area||'')+'" data-index="'+(f.index||0)+'" data-name="'+esc(f.name)+'"';
|
|
},
|
|
writeBody: function(inp) {
|
|
return JSON.stringify({changes:[{area:inp.dataset.area,index:parseInt(inp.dataset.index,10),name:inp.dataset.name,value:inp.value}]});
|
|
},
|
|
fieldName: function(f) { return f.name; },
|
|
fieldValue: function(f) { return f.value||''; },
|
|
readOnly: function(f) { return false; },
|
|
},
|
|
{
|
|
id: 'huawei',
|
|
label: 'Huawei iBMC',
|
|
chipClass: 'fru-chip-huawei',
|
|
url: '/api/tools/huawei-elabel',
|
|
writeUrl: '/api/tools/huawei-elabel/write',
|
|
rowAttrs: function(f) {
|
|
return 'data-source="huawei" data-key="'+esc(f.key)+'"';
|
|
},
|
|
writeBody: function(inp) {
|
|
return JSON.stringify({changes:[{key:inp.dataset.key,value:inp.value}]});
|
|
},
|
|
fieldName: function(f) { return f.name; },
|
|
fieldValue: function(f) { return f.value||''; },
|
|
readOnly: function(f) { return !!f.read_only; },
|
|
},
|
|
{
|
|
id: 'saa-dmi',
|
|
label: 'SAA DMI',
|
|
chipClass: 'fru-chip-saa',
|
|
url: '/api/tools/saa-dmi',
|
|
writeUrl: '/api/tools/saa-dmi/write',
|
|
rowAttrs: function(f) {
|
|
return 'data-source="saa-dmi" data-shn="'+esc(f.shn)+'"';
|
|
},
|
|
writeBody: function(inp) {
|
|
return JSON.stringify({changes:[{shn:inp.dataset.shn,value:inp.value}]});
|
|
},
|
|
fieldName: function(f) { return f.name; },
|
|
fieldValue: function(f) { return f.value||''; },
|
|
readOnly: function(f) { return false; },
|
|
},
|
|
];
|
|
|
|
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
|
|
window.fruAllRead = function() {
|
|
var status = document.getElementById('fru-all-status');
|
|
var table = document.getElementById('fru-all-table');
|
|
status.textContent = 'Reading…'; status.style.color = 'var(--muted)';
|
|
table.innerHTML = '';
|
|
|
|
var fetches = SOURCES.map(function(src) {
|
|
return fetch(src.url, {cache:'no-store'})
|
|
.then(function(r){ return r.json().then(function(d){ if(!r.ok) throw new Error(d.error||r.statusText); return d; }); });
|
|
});
|
|
|
|
Promise.allSettled(fetches).then(function(results) {
|
|
var rows = '';
|
|
var totalFields = 0;
|
|
var failedSources = [];
|
|
|
|
results.forEach(function(res, i) {
|
|
var src = SOURCES[i];
|
|
if (res.status === 'rejected' || !Array.isArray(res.value) || res.value.length === 0) {
|
|
failedSources.push(src.label + (res.reason ? ': ' + res.reason.message : ''));
|
|
return;
|
|
}
|
|
res.value.forEach(function(f) {
|
|
var val = esc(src.fieldValue(f));
|
|
var ro = src.readOnly(f);
|
|
var attrs = ro ? '' : (' '+src.rowAttrs(f));
|
|
rows += '<tr>'
|
|
+ '<td style="white-space:nowrap;padding-right:4px;vertical-align:middle">'
|
|
+ '<span class="fru-chip '+src.chipClass+'">'+src.label+'</span>'
|
|
+ '</td>'
|
|
+ '<td style="color:var(--muted);white-space:nowrap;padding-right:16px;vertical-align:middle;font-size:13px">'+esc(src.fieldName(f))+'</td>'
|
|
+ '<td style="vertical-align:middle">'
|
|
+ (ro
|
|
? '<span style="font-family:monospace;font-size:13px;color:var(--muted)">'+val+'</span>'
|
|
: '<input class="fru-uni-inp" style="'+_inp+'" value="'+val+'" data-original="'+val+'"'+attrs+' oninput="fruUniChanged(this)">')
|
|
+ '</td>'
|
|
+ '<td class="fru-uni-act" style="display:none;white-space:nowrap;padding-left:6px;vertical-align:middle">'
|
|
+ '<button style="'+_actBtn+'color:var(--ok-fg,green);margin-right:3px" title="Save" onclick="fruUniSave(this)">✓</button>'
|
|
+ '<button style="'+_actBtn+'color:var(--crit-fg,#9f3a38)" title="Cancel" onclick="fruUniCancel(this)">✗</button>'
|
|
+ '<span class="fru-uni-msg" style="font-size:11px;margin-left:5px;color:var(--muted)"></span>'
|
|
+ '</td>'
|
|
+ '</tr>';
|
|
totalFields++;
|
|
});
|
|
});
|
|
|
|
if (totalFields === 0 && failedSources.length > 0) {
|
|
status.textContent = 'No sources available: ' + failedSources.join('; ');
|
|
status.style.color = 'var(--crit-fg,#9f3a38)';
|
|
return;
|
|
}
|
|
|
|
table.innerHTML = '<table style="width:100%;border-collapse:collapse">'+rows+'</table>';
|
|
var msg = totalFields + ' field(s) loaded';
|
|
if (failedSources.length > 0) msg += ' (skipped: ' + failedSources.join(', ') + ')';
|
|
status.textContent = msg;
|
|
status.style.color = 'var(--muted)';
|
|
});
|
|
};
|
|
|
|
window.fruUniChanged = function(inp) {
|
|
var row = inp.closest('tr');
|
|
row.querySelector('.fru-uni-act').style.display = inp.value !== inp.dataset.original ? '' : 'none';
|
|
row.querySelector('.fru-uni-msg').textContent = '';
|
|
};
|
|
|
|
window.fruUniCancel = function(btn) {
|
|
var row = btn.closest('tr');
|
|
var inp = row.querySelector('.fru-uni-inp');
|
|
inp.value = inp.dataset.original;
|
|
row.querySelector('.fru-uni-act').style.display = 'none';
|
|
row.querySelector('.fru-uni-msg').textContent = '';
|
|
};
|
|
|
|
window.fruUniSave = function(btn) {
|
|
var row = btn.closest('tr');
|
|
var inp = row.querySelector('.fru-uni-inp');
|
|
var msg = row.querySelector('.fru-uni-msg');
|
|
var cancelBtn = row.querySelectorAll('.fru-uni-act button')[1];
|
|
var src = SOURCES.find(function(s){ return s.id === inp.dataset.source; });
|
|
if (!src) { msg.textContent = 'Unknown source'; msg.style.color='var(--crit-fg)'; return; }
|
|
|
|
btn.disabled = true; cancelBtn.disabled = true;
|
|
msg.textContent = '…'; msg.style.color = 'var(--muted)';
|
|
|
|
fetch(src.writeUrl, {method:'POST', headers:{'Content-Type':'application/json'}, body:src.writeBody(inp)})
|
|
.then(function(r){ return r.json().then(function(d){ if(!r.ok) throw new Error(d.error||r.statusText); return d; }); })
|
|
.then(function(d) {
|
|
var poll = setInterval(function() {
|
|
fetch('/api/tasks',{cache:'no-store'}).then(function(r){return r.json();}).then(function(tasks){
|
|
var t = Array.isArray(tasks) ? tasks.find(function(x){return x.id===d.task_id;}) : null;
|
|
if (!t) return;
|
|
if (t.status==='done') {
|
|
clearInterval(poll);
|
|
inp.dataset.original = inp.value;
|
|
row.querySelector('.fru-uni-act').style.display = 'none';
|
|
msg.textContent = ''; msg.style.color = '';
|
|
} else if (t.status==='failed'||t.status==='cancelled') {
|
|
clearInterval(poll);
|
|
msg.textContent = t.error||t.status; msg.style.color = 'var(--crit-fg)';
|
|
btn.disabled = false; cancelBtn.disabled = false;
|
|
}
|
|
});
|
|
}, 1500);
|
|
})
|
|
.catch(function(e) {
|
|
msg.textContent = 'Error: '+e.message; msg.style.color = 'var(--crit-fg)';
|
|
btn.disabled = false; cancelBtn.disabled = false;
|
|
});
|
|
};
|
|
})();
|
|
</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
|
|
}
|