- Replace global Save button with per-row ✓ (save) / ✗ (cancel) buttons that appear only when a field is changed - All fields shown as editable inputs; server rejects unknown fields with a clear error message instead of hiding them in the UI - Monospace font and 1.5px border for all value inputs - Server-side name→area/index lookup for fields sent without area - SAA DMI card: same per-row UX, confirm dialog kept (requires reboot) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
298 lines
12 KiB
Go
298 lines
12 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}$`)
|
|
dmiSectionRE = regexp.MustCompile(`^\[(.+?)\]$`)
|
|
// Item Name {SHN} = value // comment
|
|
// SHN may contain parentheses, e.g. {PS(4)LC} for power supply fields
|
|
dmiItemRE = regexp.MustCompile(`^(.+?)\s+\{([A-Za-z0-9_()\-]{1,24})\}\s*=\s*(.*)$`)
|
|
dmiVersionRE = regexp.MustCompile(`(?i)^version\s*=`)
|
|
)
|
|
|
|
|
|
// parseDMIFile parses the DMI.txt produced by "saa GetDmiInfo".
|
|
// Real format (from SAA User Guide 4.8.1):
|
|
//
|
|
// [System]
|
|
// Version {SYVS} = "A Version" // string value
|
|
// Serial Number {SYSN} = $DEFAULT$ // string value
|
|
// UUID {SYUU} = 00112233-... // hex value
|
|
func parseDMIFile(content string) []dmiField {
|
|
var fields []dmiField
|
|
currentSection := ""
|
|
for _, line := range strings.Split(content, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
if dmiVersionRE.MatchString(line) {
|
|
continue
|
|
}
|
|
if m := dmiSectionRE.FindStringSubmatch(line); m != nil {
|
|
currentSection = strings.TrimSpace(m[1])
|
|
continue
|
|
}
|
|
m := dmiItemRE.FindStringSubmatch(line)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
itemName := strings.TrimSpace(m[1])
|
|
shn := m[2]
|
|
rawValue := strings.TrimSpace(m[3])
|
|
// strip trailing comment (space + //)
|
|
if idx := strings.LastIndex(rawValue, " //"); idx >= 0 {
|
|
rawValue = strings.TrimSpace(rawValue[:idx])
|
|
}
|
|
// strip surrounding double quotes from string values
|
|
if len(rawValue) >= 2 && rawValue[0] == '"' && rawValue[len(rawValue)-1] == '"' {
|
|
rawValue = rawValue[1 : len(rawValue)-1]
|
|
}
|
|
displayName := itemName
|
|
if currentSection != "" {
|
|
displayName = currentSection + " / " + itemName
|
|
}
|
|
fields = append(fields, dmiField{Name: displayName, Shn: shn, Value: rawValue})
|
|
}
|
|
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")
|
|
cmd := exec.CommandContext(ctx, "saa", "-c", "GetDmiInfo", "--file", dmiFile, "--overwrite")
|
|
cmd.Dir = "/usr/local/bin"
|
|
out, err := cmd.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...")
|
|
getCmd := exec.CommandContext(ctx, "saa", "-c", "GetDmiInfo", "--file", dmiFile, "--overwrite")
|
|
getCmd.Dir = "/usr/local/bin"
|
|
if err := streamCmdJob(j, getCmd); 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)
|
|
cmd.Dir = "/usr/local/bin"
|
|
if err := streamCmdJob(j, cmd); err != nil {
|
|
return fmt.Errorf("EditDmiInfo %s: %w", c.Shn, err)
|
|
}
|
|
}
|
|
|
|
j.append("Applying changes to hardware...")
|
|
changeCmd := exec.CommandContext(ctx, "saa", "-c", "ChangeDmiInfo", "--file", dmiFile)
|
|
changeCmd.Dir = "/usr/local/bin"
|
|
if err := streamCmdJob(j, changeCmd); 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 card-head-actions">Supermicro — DMI<div class="card-head-buttons"><button class="btn btn-sm btn-secondary" onclick="saaDMIRead()">Read</button></div></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).</p>
|
|
<div id="saa-dmi-status" style="font-size:13px;color:var(--muted);margin-bottom:8px"></div>
|
|
<div id="saa-dmi-table"></div>
|
|
</div></div>
|
|
<script>
|
|
var _dmiActBtnStyle = '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 _dmiInputStyle = '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);';
|
|
function dmiEsc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
function saaDMIRead() {
|
|
var status = document.getElementById('saa-dmi-status');
|
|
status.textContent = 'Reading...'; status.style.color = 'var(--muted)';
|
|
document.getElementById('saa-dmi-table').innerHTML = '';
|
|
fetch('/api/tools/saa-dmi', {cache:'no-store'})
|
|
.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){
|
|
var val = dmiEsc(f.value||'');
|
|
return '<tr>'
|
|
+ '<td style="font-size:13px;color:var(--muted);white-space:nowrap;padding-right:8px;vertical-align:middle">'+dmiEsc(f.name)+'</td>'
|
|
+ '<td style="font-family:monospace;font-size:12px;color:var(--muted);white-space:nowrap;padding-right:8px;vertical-align:middle">'+dmiEsc(f.shn)+'</td>'
|
|
+ '<td style="vertical-align:middle"><input class="dmi-inp" type="text" style="'+_dmiInputStyle+'"'
|
|
+ ' data-shn="'+dmiEsc(f.shn)+'" data-original="'+val+'" value="'+val+'" oninput="dmiChanged(this)"></td>'
|
|
+ '<td class="dmi-act" style="display:none;white-space:nowrap;padding-left:6px;vertical-align:middle">'
|
|
+ '<button style="'+_dmiActBtnStyle+'color:var(--ok-fg,green);margin-right:3px" title="Save" onclick="dmiSave(this)">✓</button>'
|
|
+ '<button style="'+_dmiActBtnStyle+'color:var(--crit-fg,#9f3a38)" title="Cancel" onclick="dmiCancel(this)">✗</button>'
|
|
+ '<span class="dmi-msg" style="font-size:11px;margin-left:5px;color:var(--muted)"></span>'
|
|
+ '</td></tr>';
|
|
}).join('');
|
|
document.getElementById('saa-dmi-table').innerHTML =
|
|
'<table style="width:100%;border-collapse:collapse">'
|
|
+ '<tr><th style="text-align:left;font-size:12px;color:var(--muted);padding-bottom:6px;font-weight:normal">Field</th>'
|
|
+ '<th style="text-align:left;font-size:12px;color:var(--muted);padding-bottom:6px;font-weight:normal">SHN</th>'
|
|
+ '<th style="text-align:left;font-size:12px;color:var(--muted);padding-bottom:6px;font-weight:normal">Value</th><th></th></tr>'
|
|
+ rows + '</table>';
|
|
})
|
|
.catch(function(e){ status.textContent='Error: '+e.message; status.style.color='var(--crit-fg,#9f3a38)'; });
|
|
}
|
|
function dmiChanged(inp) {
|
|
inp.closest('tr').querySelector('.dmi-act').style.display = inp.value !== inp.dataset.original ? '' : 'none';
|
|
}
|
|
function dmiCancel(btn) {
|
|
var row = btn.closest('tr');
|
|
var inp = row.querySelector('.dmi-inp');
|
|
inp.value = inp.dataset.original;
|
|
row.querySelector('.dmi-act').style.display = 'none';
|
|
row.querySelector('.dmi-msg').textContent = '';
|
|
}
|
|
function dmiSave(btn) {
|
|
var row = btn.closest('tr');
|
|
var inp = row.querySelector('.dmi-inp');
|
|
var msg = row.querySelector('.dmi-msg');
|
|
var cancelBtn = row.querySelectorAll('.dmi-act button')[1];
|
|
if(!window.confirm('Apply DMI change for '+inp.dataset.shn+'?\nServer will need to reboot for changes to take effect.'))return;
|
|
btn.disabled=true; cancelBtn.disabled=true;
|
|
msg.textContent='…'; msg.style.color='var(--muted)';
|
|
fetch('/api/tools/saa-dmi/write',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({changes:[{shn:inp.dataset.shn,value:inp.value}]})})
|
|
.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){
|
|
var poll=setInterval(function(){
|
|
fetch('/api/tasks',{cache:'no-store'}).then(function(r){return r.json();}).then(function(tasks){
|
|
var t=(tasks||[]).find(function(x){return x.id===d.task_id;});
|
|
if(!t)return;
|
|
if(t.status==='done'){
|
|
clearInterval(poll);
|
|
inp.dataset.original=inp.value;
|
|
row.querySelector('.dmi-act').style.display='none';
|
|
msg.textContent='Saved. Reboot to apply.'; msg.style.color='var(--ok-fg,green)';
|
|
} 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>`
|
|
}
|