Files
bee/audit/internal/webui/ipmi_fru.go
Mikhail Chusavitin cf29131116 Rework FRU and DMI editors: per-row inline save, all fields editable
- 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>
2026-06-19 09:30:39 +03:00

285 lines
11 KiB
Go

package webui
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"unicode"
)
type fruField struct {
Name string `json:"name"`
Value string `json:"value"`
Editable bool `json:"editable"`
Area string `json:"area,omitempty"`
Index int `json:"index,omitempty"`
}
type fruChange struct {
Area string `json:"area"`
Index int `json:"index"`
Name string `json:"name"`
Value string `json:"value"`
}
// fruEditableFields maps display name → area + index for ipmitool fru edit.
var fruEditableFields = map[string]struct {
Area string
Index int
}{
// Chassis — vendor doc names and ipmitool abbreviated names
"Chassis Part Number": {"c", 0},
"Chassis Serial Number": {"c", 1},
"Chassis Serial": {"c", 1},
"Chassis Extra": {"c", 2},
// Board — vendor doc names and ipmitool abbreviated names
"Board Manufacturer": {"b", 0},
"Board Mfg": {"b", 0},
"Board Product Name": {"b", 1},
"Board Product": {"b", 1},
"Board Serial Number": {"b", 2},
"Board Serial": {"b", 2},
"Board Part Number": {"b", 3},
// Product — vendor doc names and ipmitool abbreviated names
"Product Manufacturer": {"p", 0},
"Product Name": {"p", 1},
"Product Part Number": {"p", 2},
"Product Version": {"p", 3},
"Product Serial Number": {"p", 4},
"Product Serial": {"p", 4},
}
func parseFRUOutput(output string) []fruField {
var fields []fruField
for _, line := range strings.Split(output, "\n") {
// Lines look like: " Field Name : value"
trimmed := strings.TrimLeft(line, " \t")
if trimmed == "" {
continue
}
colon := strings.Index(trimmed, " : ")
if colon < 0 {
// try ": " with no leading space before colon
colon = strings.Index(trimmed, ": ")
if colon < 0 {
continue
}
name := strings.TrimSpace(trimmed[:colon])
value := strings.TrimSpace(trimmed[colon+2:])
if name == "" {
continue
}
editable, area, idx := fruFieldMeta(name)
fields = append(fields, fruField{Name: name, Value: value, Editable: editable, Area: area, Index: idx})
continue
}
name := strings.TrimSpace(trimmed[:colon])
value := strings.TrimSpace(trimmed[colon+3:])
if name == "" {
continue
}
editable, area, idx := fruFieldMeta(name)
fields = append(fields, fruField{Name: name, Value: value, Editable: editable, Area: area, Index: idx})
}
return fields
}
func fruFieldMeta(name string) (editable bool, area string, index int) {
if e, ok := fruEditableFields[name]; ok {
return true, e.Area, e.Index
}
// All fields are shown as editable; server will reject unknown fields.
return true, "", 0
}
func (h *handler) handleAPIIPMIFRURead(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "ipmitool", "fru", "print", "0").CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg == "" {
msg = err.Error()
}
writeError(w, http.StatusInternalServerError, "ipmitool fru print: "+msg)
return
}
fields := parseFRUOutput(string(out))
writeJSON(w, fields)
}
func (h *handler) handleAPIIPMIFRUWrite(w http.ResponseWriter, r *http.Request) {
var req struct {
Changes []fruChange `json:"changes"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if len(req.Changes) == 0 {
writeError(w, http.StatusUnprocessableEntity, "no changes provided")
return
}
validAreas := map[string]bool{"c": true, "b": true, "p": true}
for i, c := range req.Changes {
if c.Area == "" {
e, ok := fruEditableFields[c.Name]
if !ok {
writeError(w, http.StatusUnprocessableEntity, "field not writable via ipmitool: "+c.Name)
return
}
req.Changes[i].Area = e.Area
req.Changes[i].Index = e.Index
c = req.Changes[i]
}
if !validAreas[c.Area] {
writeError(w, http.StatusUnprocessableEntity, "invalid area: "+c.Area)
return
}
if c.Index < 0 || c.Index > 9 {
writeError(w, http.StatusUnprocessableEntity, fmt.Sprintf("invalid index %d", c.Index))
return
}
if len(c.Value) > 64 {
writeError(w, http.StatusUnprocessableEntity, "value too long (max 64 chars)")
return
}
for _, ch := range c.Value {
if ch > unicode.MaxASCII || (ch < 0x20 && ch != 0) {
writeError(w, http.StatusUnprocessableEntity, "value contains non-printable characters")
return
}
}
}
t := &Task{
ID: newJobID("ipmi-fru-write"),
Name: fmt.Sprintf("IPMI FRU Write (%d field(s))", len(req.Changes)),
Target: "ipmi-fru-write",
Priority: defaultTaskPriority("ipmi-fru-write", taskParams{}),
Status: TaskPending,
CreatedAt: time.Now(),
params: taskParams{FRUChanges: req.Changes},
}
globalQueue.enqueue(t)
writeJSON(w, map[string]string{"task_id": t.ID})
}
func runIPMIFRUWriteTask(ctx context.Context, j *jobState, exportDir string, p taskParams) error {
// Backup current FRU state
backupDir := filepath.Join(exportDir, "fru-backups")
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("mkdir fru-backups: %w", err)
}
stamp := time.Now().Format("20060102150405")
backupPath := filepath.Join(backupDir, "fru-"+stamp+".txt")
backupOut, err := exec.CommandContext(ctx, "ipmitool", "fru", "print", "0").CombinedOutput()
if err != nil {
return fmt.Errorf("backup fru print: %w", err)
}
if err := os.WriteFile(backupPath, backupOut, 0644); err != nil {
return fmt.Errorf("write backup: %w", err)
}
j.append("Backup saved to " + backupPath)
// Apply changes
for _, c := range p.FRUChanges {
j.append(fmt.Sprintf("Setting %s (%s %d) = %q", c.Name, c.Area, c.Index, c.Value))
cmd := exec.CommandContext(ctx, "ipmitool", "fru", "edit", "0", "field", c.Area, fmt.Sprintf("%d", c.Index), c.Value)
if err := streamCmdJob(j, cmd); err != nil {
return fmt.Errorf("fru edit %s %d: %w", c.Area, c.Index, err)
}
}
return nil
}
func renderIPMIFRUCard() string {
return `<div class="card"><div class="card-head card-head-actions">IPMI &#8212; FRU<div class="card-head-buttons"><button class="btn btn-sm btn-secondary" onclick="fruRead()">Read</button></div></div><div class="card-body">
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Reads and edits FRU fields via ipmitool (In-Band, device 0). Works on any server with IPMI support.</p>
<div id="fru-status" style="font-size:13px;color:var(--muted);margin-bottom:8px"></div>
<div id="fru-table"></div>
</div></div>
<script>
var _fruActBtnStyle = '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 _fruInputStyle = '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 fruRead() {
var status = document.getElementById('fru-status');
status.textContent = 'Reading...'; status.style.color = 'var(--muted)';
document.getElementById('fru-table').innerHTML = '';
fetch('/api/tools/ipmi-fru', {cache:'no-store'})
.then(function(r) { return r.json().then(function(d){if(!r.ok)throw new Error(d.error||r.statusText);return d;}); })
.then(function(fields) {
if (!fields || !fields.length) { status.textContent = 'No FRU fields returned.'; return; }
status.textContent = '';
var rows = fields.map(function(f) {
var val = escHtml(f.value || '');
return '<tr>'
+ '<td style="color:var(--muted);white-space:nowrap;padding-right:16px;vertical-align:middle;font-size:13px">' + escHtml(f.name) + '</td>'
+ '<td style="vertical-align:middle"><input class="fru-inp" style="' + _fruInputStyle + '"'
+ ' data-area="' + escHtml(f.area||'') + '" data-index="' + (f.index||0) + '" data-name="' + escHtml(f.name) + '"'
+ ' data-original="' + val + '" value="' + val + '" oninput="fruChanged(this)"></td>'
+ '<td class="fru-act" style="display:none;white-space:nowrap;padding-left:6px;vertical-align:middle">'
+ '<button style="' + _fruActBtnStyle + 'color:var(--ok-fg,green);margin-right:3px" title="Save" onclick="fruSave(this)">✓</button>'
+ '<button style="' + _fruActBtnStyle + 'color:var(--crit-fg,#9f3a38)" title="Cancel" onclick="fruCancel(this)">✗</button>'
+ '<span class="fru-msg" style="font-size:11px;margin-left:5px;color:var(--muted)"></span>'
+ '</td></tr>';
}).join('');
document.getElementById('fru-table').innerHTML = '<table style="width:100%;border-collapse:collapse">' + rows + '</table>';
})
.catch(function(e) { status.textContent = 'Error: '+e.message; status.style.color='var(--crit-fg)'; });
}
function escHtml(s) {
return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fruChanged(inp) {
inp.closest('tr').querySelector('.fru-act').style.display = inp.value !== inp.dataset.original ? '' : 'none';
}
function fruCancel(btn) {
var row = btn.closest('tr');
var inp = row.querySelector('.fru-inp');
inp.value = inp.dataset.original;
row.querySelector('.fru-act').style.display = 'none';
row.querySelector('.fru-msg').textContent = '';
}
function fruSave(btn) {
var row = btn.closest('tr');
var inp = row.querySelector('.fru-inp');
var msg = row.querySelector('.fru-msg');
var cancelBtn = row.querySelectorAll('.fru-act button')[1];
btn.disabled = true; cancelBtn.disabled = true;
msg.textContent = '…'; msg.style.color = 'var(--muted)';
fetch('/api/tools/ipmi-fru/write', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({changes:[{area:inp.dataset.area, index:parseInt(inp.dataset.index,10), name:inp.dataset.name, value:inp.value}]})})
.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-act').style.display = 'none';
msg.textContent = '';
} 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>`
}