Adds a new card to the web UI Tools page for reading and editing DMI fields via SAA (In-Band). Reads current DMI configuration with GetDmiInfo, displays all fields as an editable table, and applies only the changed fields via EditDmiInfo + ChangeDmiInfo. Backs up the original DMI file to dmi-backups/ before any write, making it available in the support bundle for rollback. Also adds "saa" to the standard tool check list. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
288 lines
11 KiB
Go
288 lines
11 KiB
Go
package webui
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type dmiField struct {
|
|
Name string `json:"name"`
|
|
Shn string `json:"shn"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
type saaChange struct {
|
|
Shn string `json:"shn"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
var shnRE = regexp.MustCompile(`^[A-Za-z0-9_]{1,16}$`)
|
|
|
|
// parseDMIFile parses the DMI.txt produced by "saa GetDmiInfo".
|
|
// Supports two formats:
|
|
// - Name|Shn|Value (pipe-separated, primary)
|
|
// - Shn=Value (key=value fallback)
|
|
//
|
|
// Lines starting with '#', empty lines, "version=..." and section headers are skipped.
|
|
func parseDMIFile(content string) []dmiField {
|
|
var fields []dmiField
|
|
for _, line := range strings.Split(content, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
lower := strings.ToLower(line)
|
|
if strings.HasPrefix(lower, "version=") || strings.HasPrefix(lower, "[") {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, "|", 3)
|
|
if len(parts) == 3 {
|
|
name := strings.TrimSpace(parts[0])
|
|
shn := strings.TrimSpace(parts[1])
|
|
value := strings.TrimSpace(parts[2])
|
|
if shnRE.MatchString(shn) {
|
|
fields = append(fields, dmiField{Name: name, Shn: shn, Value: value})
|
|
continue
|
|
}
|
|
}
|
|
if idx := strings.IndexByte(line, '='); idx > 0 {
|
|
shn := strings.TrimSpace(line[:idx])
|
|
value := strings.TrimSpace(line[idx+1:])
|
|
if shnRE.MatchString(shn) {
|
|
fields = append(fields, dmiField{Name: shn, Shn: shn, Value: value})
|
|
}
|
|
}
|
|
}
|
|
return fields
|
|
}
|
|
|
|
func (h *handler) handleAPISAADMIRead(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
tmpDir, err := os.MkdirTemp("", "bee-saa-*")
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "create temp dir: "+err.Error())
|
|
return
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dmiFile := filepath.Join(tmpDir, "DMI.txt")
|
|
out, err := exec.CommandContext(ctx, "saa", "-c", "GetDmiInfo", "--file", dmiFile, "--overwrite").CombinedOutput()
|
|
if err != nil {
|
|
msg := strings.TrimSpace(string(out))
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "saa GetDmiInfo: "+msg)
|
|
return
|
|
}
|
|
|
|
raw, err := os.ReadFile(dmiFile)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "read DMI file: "+err.Error())
|
|
return
|
|
}
|
|
|
|
fields := parseDMIFile(string(raw))
|
|
if len(fields) == 0 {
|
|
writeError(w, http.StatusInternalServerError, "no DMI fields found (file may be empty — reboot the server and try again)")
|
|
return
|
|
}
|
|
writeJSON(w, fields)
|
|
}
|
|
|
|
func (h *handler) handleAPISAADMIWrite(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Changes []saaChange `json:"changes"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if len(req.Changes) == 0 {
|
|
writeError(w, http.StatusUnprocessableEntity, "no changes provided")
|
|
return
|
|
}
|
|
for _, c := range req.Changes {
|
|
if !shnRE.MatchString(c.Shn) {
|
|
writeError(w, http.StatusUnprocessableEntity, "invalid shn: "+c.Shn)
|
|
return
|
|
}
|
|
if len(c.Value) == 0 || len(c.Value) > 64 {
|
|
writeError(w, http.StatusUnprocessableEntity, "value length out of range for shn: "+c.Shn)
|
|
return
|
|
}
|
|
for _, ch := range c.Value {
|
|
if ch < 0x20 || ch > 0x7E {
|
|
writeError(w, http.StatusUnprocessableEntity, "value contains non-printable character for shn: "+c.Shn)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
t := &Task{
|
|
ID: newJobID("saa-dmi-write"),
|
|
Name: fmt.Sprintf("SAA DMI Write (%d field(s))", len(req.Changes)),
|
|
Target: "saa-dmi-write",
|
|
Priority: defaultTaskPriority("saa-dmi-write", taskParams{}),
|
|
Status: TaskPending,
|
|
CreatedAt: time.Now(),
|
|
params: taskParams{
|
|
SAADmiChanges: req.Changes,
|
|
},
|
|
}
|
|
globalQueue.enqueue(t)
|
|
writeJSON(w, map[string]string{"task_id": t.ID})
|
|
}
|
|
|
|
func runSAADMIWriteTask(ctx context.Context, j *jobState, exportDir string, p taskParams) error {
|
|
tmpDir, err := os.MkdirTemp("", "bee-saa-*")
|
|
if err != nil {
|
|
return fmt.Errorf("create temp dir: %w", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
dmiFile := filepath.Join(tmpDir, "DMI.txt")
|
|
|
|
j.append("Reading current DMI configuration...")
|
|
if err := streamCmdJob(j, exec.CommandContext(ctx, "saa", "-c", "GetDmiInfo", "--file", dmiFile, "--overwrite")); err != nil {
|
|
return fmt.Errorf("GetDmiInfo: %w", err)
|
|
}
|
|
|
|
backupDir := filepath.Join(exportDir, "dmi-backups")
|
|
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
|
return fmt.Errorf("create backup dir: %w", err)
|
|
}
|
|
backupName := "dmi-" + time.Now().UTC().Format("20060102-150405") + ".txt"
|
|
backupPath := filepath.Join(backupDir, backupName)
|
|
raw, err := os.ReadFile(dmiFile)
|
|
if err != nil {
|
|
return fmt.Errorf("read DMI file: %w", err)
|
|
}
|
|
if err := os.WriteFile(backupPath, raw, 0o644); err != nil {
|
|
return fmt.Errorf("write backup: %w", err)
|
|
}
|
|
j.append("Backup saved: dmi-backups/" + backupName)
|
|
|
|
for _, c := range p.SAADmiChanges {
|
|
j.append("Setting " + c.Shn + " = " + c.Value)
|
|
cmd := exec.CommandContext(ctx, "saa", "-c", "EditDmiInfo", "--file", dmiFile, "--shn", c.Shn, "--value", c.Value)
|
|
if err := streamCmdJob(j, cmd); err != nil {
|
|
return fmt.Errorf("EditDmiInfo %s: %w", c.Shn, err)
|
|
}
|
|
}
|
|
|
|
j.append("Applying changes to hardware...")
|
|
if err := streamCmdJob(j, exec.CommandContext(ctx, "saa", "-c", "ChangeDmiInfo", "--file", dmiFile)); err != nil {
|
|
return fmt.Errorf("ChangeDmiInfo: %w", err)
|
|
}
|
|
|
|
j.append("Done. Reboot the server for changes to take effect.")
|
|
return nil
|
|
}
|
|
|
|
func renderSAADMICard() string {
|
|
return `<div class="card"><div class="card-head">SAA — DMI <button class="btn btn-sm btn-secondary" onclick="saaDMIRead()" style="margin-left:auto">Read</button></div><div class="card-body">
|
|
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Reads and edits DMI fields via SAA (In-Band). Requires <code>saa</code> on PATH.</p>
|
|
<div id="saa-dmi-status" style="font-size:13px;color:var(--muted);margin-bottom:8px"></div>
|
|
<div id="saa-dmi-table"></div>
|
|
<div id="saa-dmi-save-row" style="display:none;margin-top:12px">
|
|
<button class="btn btn-primary" id="saa-dmi-save-btn" onclick="saaDMISave()">Save</button>
|
|
<span id="saa-dmi-save-msg" style="font-size:13px;color:var(--muted);margin-left:10px"></span>
|
|
</div>
|
|
<script>
|
|
function saaDMIEsc(s) {
|
|
return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});
|
|
}
|
|
function saaDMIUpdateSaveBtn() {
|
|
var inputs = document.querySelectorAll('#saa-dmi-table input[data-original]');
|
|
var dirty = [];
|
|
inputs.forEach(function(inp){if(inp.value!==inp.dataset.original)dirty.push(inp);});
|
|
var row = document.getElementById('saa-dmi-save-row');
|
|
var btn = document.getElementById('saa-dmi-save-btn');
|
|
if(dirty.length>0){row.style.display='';btn.textContent='Save ('+dirty.length+' changed)';}
|
|
else{row.style.display='none';}
|
|
}
|
|
function saaDMIRead() {
|
|
var status = document.getElementById('saa-dmi-status');
|
|
var table = document.getElementById('saa-dmi-table');
|
|
var saveRow = document.getElementById('saa-dmi-save-row');
|
|
status.textContent = 'Reading...';
|
|
status.style.color = 'var(--muted)';
|
|
table.innerHTML = '';
|
|
saveRow.style.display = 'none';
|
|
fetch('/api/tools/saa-dmi').then(function(r){return r.json().then(function(d){if(!r.ok)throw new Error(d.error||('HTTP '+r.status));return d;});}).then(function(fields){
|
|
status.textContent = fields.length+' field(s) loaded.';
|
|
var rows = fields.map(function(f){
|
|
return '<tr>'
|
|
+'<td style="font-size:13px;white-space:nowrap;padding-right:8px">'+saaDMIEsc(f.name)+'</td>'
|
|
+'<td style="font-family:monospace;font-size:13px;white-space:nowrap;padding-right:8px">'+saaDMIEsc(f.shn)+'</td>'
|
|
+'<td><input type="text" value="'+saaDMIEsc(f.value)+'" data-shn="'+saaDMIEsc(f.shn)+'" data-original="'+saaDMIEsc(f.value)+'" oninput="saaDMIMarkDirty(this)" style="width:100%;font-family:monospace;font-size:13px;border:1px solid var(--line);padding:3px 6px;border-radius:3px"></td>'
|
|
+'<td id="saa-dmi-dirty-'+saaDMIEsc(f.shn)+'" style="font-size:12px;color:var(--warn,#b45309);width:50px;padding-left:6px"></td>'
|
|
+'</tr>';
|
|
}).join('');
|
|
table.innerHTML = '<table style="width:100%;border-collapse:collapse"><tr><th style="text-align:left;font-size:13px;padding-bottom:6px">Field</th><th style="text-align:left;font-size:13px;padding-bottom:6px">Shn</th><th style="text-align:left;font-size:13px;padding-bottom:6px">Value</th><th></th></tr>'+rows+'</table>';
|
|
}).catch(function(e){
|
|
status.textContent = 'Error: '+e.message;
|
|
status.style.color = 'var(--crit-fg,#9f3a38)';
|
|
});
|
|
}
|
|
function saaDMIMarkDirty(inp) {
|
|
var shn = inp.dataset.shn;
|
|
var cell = document.getElementById('saa-dmi-dirty-'+shn);
|
|
if(cell)cell.textContent = inp.value!==inp.dataset.original?'changed':'';
|
|
saaDMIUpdateSaveBtn();
|
|
}
|
|
function saaDMIWaitTask(taskID) {
|
|
var msg = document.getElementById('saa-dmi-save-msg');
|
|
msg.textContent = 'Task '+taskID+' queued...';
|
|
msg.style.color = 'var(--muted)';
|
|
var timer = setInterval(function(){
|
|
fetch('/api/tasks').then(function(r){return r.json();}).then(function(tasks){
|
|
var task = (tasks||[]).find(function(t){return t.id===taskID;});
|
|
if(!task)return;
|
|
if(task.status==='done'||task.status==='failed'||task.status==='cancelled'){
|
|
clearInterval(timer);
|
|
msg.textContent = task.status==='done'?'Saved. Reboot to apply.':'Failed: '+(task.error||task.status);
|
|
msg.style.color = task.status==='done'?'var(--ok,green)':'var(--crit-fg,#9f3a38)';
|
|
document.getElementById('saa-dmi-save-btn').disabled = false;
|
|
}
|
|
}).catch(function(){});
|
|
}, 1500);
|
|
}
|
|
function saaDMISave() {
|
|
var inputs = document.querySelectorAll('#saa-dmi-table input[data-original]');
|
|
var changes = [];
|
|
inputs.forEach(function(inp){if(inp.value!==inp.dataset.original)changes.push({shn:inp.dataset.shn,value:inp.value});});
|
|
if(!changes.length)return;
|
|
var names = changes.map(function(c){return c.shn;}).join(', ');
|
|
if(!window.confirm('Apply DMI changes for: '+names+'?\n\nThe server will need to be rebooted for changes to take effect.'))return;
|
|
var btn = document.getElementById('saa-dmi-save-btn');
|
|
var msg = document.getElementById('saa-dmi-save-msg');
|
|
btn.disabled = true;
|
|
msg.textContent = 'Submitting...';
|
|
msg.style.color = 'var(--muted)';
|
|
fetch('/api/tools/saa-dmi/write',{
|
|
method:'POST',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({changes:changes})
|
|
}).then(function(r){return r.json().then(function(d){if(!r.ok)throw new Error(d.error||('HTTP '+r.status));return d;});}).then(function(d){
|
|
saaDMIWaitTask(d.task_id);
|
|
}).catch(function(e){
|
|
msg.textContent = 'Error: '+e.message;
|
|
msg.style.color = 'var(--crit-fg,#9f3a38)';
|
|
btn.disabled = false;
|
|
});
|
|
}
|
|
</script>
|
|
</div></div>`
|
|
}
|