ipmitool fru print on some BMC implementations returns short names
("Chassis Serial", "Board Mfg", "Board Product", "Board Serial",
"Product Serial") instead of the full names in the vendor doc.
Add both variants to fruEditableFields so all fields are editable
regardless of which naming convention the BMC uses.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
302 lines
11 KiB
Go
302 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
|
|
}
|
|
return false, "", 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 _, c := range req.Changes {
|
|
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 — 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 id="fru-save-row" style="display:none;margin-top:12px">
|
|
<button class="btn btn-primary" id="fru-save-btn" onclick="fruSave()">Save</button>
|
|
<span id="fru-save-msg" style="font-size:13px;color:var(--muted);margin-left:10px"></span>
|
|
</div>
|
|
</div></div>
|
|
<script>
|
|
var fruOriginal = {};
|
|
function fruRead() {
|
|
document.getElementById('fru-status').textContent = 'Reading...';
|
|
document.getElementById('fru-table').innerHTML = '';
|
|
document.getElementById('fru-save-row').style.display = 'none';
|
|
fetch('/api/tools/ipmi-fru', {cache:'no-store'})
|
|
.then(function(r) {
|
|
if (!r.ok) return r.json().then(function(e) { throw new Error(e.error || r.statusText); });
|
|
return r.json();
|
|
})
|
|
.then(function(fields) {
|
|
fruOriginal = {};
|
|
if (!fields || !fields.length) {
|
|
document.getElementById('fru-status').textContent = 'No FRU fields returned.';
|
|
return;
|
|
}
|
|
document.getElementById('fru-status').textContent = '';
|
|
var rows = fields.map(function(f) {
|
|
var val = f.value || '';
|
|
if (f.editable) {
|
|
fruOriginal[f.area + '_' + f.index] = val;
|
|
return '<tr><td style="color:var(--muted);white-space:nowrap;padding-right:16px">' + escHtml(f.name) + '</td>'
|
|
+ '<td><input class="fru-input" style="width:100%;padding:4px 6px;border:1px solid var(--border);border-radius:3px;font-size:13px;font-family:inherit;background:var(--surface);color:var(--ink)"'
|
|
+ ' data-area="' + escHtml(f.area) + '" data-index="' + f.index + '" data-name="' + escHtml(f.name) + '"'
|
|
+ ' data-original="' + escHtml(val) + '" value="' + escHtml(val) + '" oninput="fruDirtyCheck()"></td></tr>';
|
|
}
|
|
return '<tr><td style="color:var(--muted);white-space:nowrap;padding-right:16px">' + escHtml(f.name) + '</td>'
|
|
+ '<td style="color:var(--ink)">' + escHtml(val || '—') + '</td></tr>';
|
|
}).join('');
|
|
document.getElementById('fru-table').innerHTML = '<table style="width:100%">' + rows + '</table>';
|
|
fruDirtyCheck();
|
|
})
|
|
.catch(function(e) {
|
|
document.getElementById('fru-status').textContent = 'Error: ' + e.message;
|
|
document.getElementById('fru-status').style.color = 'var(--crit-fg)';
|
|
});
|
|
}
|
|
function escHtml(s) {
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
function fruDirtyCheck() {
|
|
var inputs = document.querySelectorAll('.fru-input');
|
|
var changed = 0;
|
|
inputs.forEach(function(el) { if (el.value !== el.dataset.original) changed++; });
|
|
var row = document.getElementById('fru-save-row');
|
|
var btn = document.getElementById('fru-save-btn');
|
|
if (changed > 0) {
|
|
row.style.display = '';
|
|
btn.textContent = 'Save (' + changed + ' changed)';
|
|
} else {
|
|
row.style.display = 'none';
|
|
}
|
|
}
|
|
function fruSave() {
|
|
var inputs = document.querySelectorAll('.fru-input');
|
|
var changes = [];
|
|
inputs.forEach(function(el) {
|
|
if (el.value !== el.dataset.original) {
|
|
changes.push({area: el.dataset.area, index: parseInt(el.dataset.index, 10), name: el.dataset.name, value: el.value});
|
|
}
|
|
});
|
|
if (!changes.length) return;
|
|
document.getElementById('fru-save-btn').disabled = true;
|
|
document.getElementById('fru-save-msg').textContent = 'Saving...';
|
|
fetch('/api/tools/ipmi-fru/write', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({changes: changes})})
|
|
.then(function(r) {
|
|
if (!r.ok) return r.json().then(function(e) { throw new Error(e.error || r.statusText); });
|
|
return r.json();
|
|
})
|
|
.then(function(d) {
|
|
var taskId = d.task_id;
|
|
document.getElementById('fru-save-msg').textContent = 'Task ' + taskId + ' queued…';
|
|
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 === taskId; }) : null;
|
|
if (!t) return;
|
|
if (t.status === 'done') {
|
|
clearInterval(poll);
|
|
document.getElementById('fru-save-msg').textContent = 'Done — backup saved to fru-backups/.';
|
|
document.getElementById('fru-save-btn').disabled = false;
|
|
inputs.forEach(function(el) { el.dataset.original = el.value; });
|
|
fruDirtyCheck();
|
|
} else if (t.status === 'failed') {
|
|
clearInterval(poll);
|
|
document.getElementById('fru-save-msg').textContent = 'Failed: ' + (t.error || 'unknown error');
|
|
document.getElementById('fru-save-btn').disabled = false;
|
|
}
|
|
});
|
|
}, 1500);
|
|
})
|
|
.catch(function(e) {
|
|
document.getElementById('fru-save-msg').textContent = 'Error: ' + e.message;
|
|
document.getElementById('fru-save-btn').disabled = false;
|
|
});
|
|
}
|
|
</script>`
|
|
}
|