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>
This commit is contained in:
Mikhail Chusavitin
2026-06-19 09:30:39 +03:00
parent 13e6324853
commit cf29131116
2 changed files with 138 additions and 168 deletions

View File

@@ -94,7 +94,8 @@ func fruFieldMeta(name string) (editable bool, area string, index int) {
if e, ok := fruEditableFields[name]; ok { if e, ok := fruEditableFields[name]; ok {
return true, e.Area, e.Index return true, e.Area, e.Index
} }
return false, "", 0 // All fields are shown as editable; server will reject unknown fields.
return true, "", 0
} }
func (h *handler) handleAPIIPMIFRURead(w http.ResponseWriter, r *http.Request) { func (h *handler) handleAPIIPMIFRURead(w http.ResponseWriter, r *http.Request) {
@@ -128,7 +129,17 @@ func (h *handler) handleAPIIPMIFRUWrite(w http.ResponseWriter, r *http.Request)
return return
} }
validAreas := map[string]bool{"c": true, "b": true, "p": true} validAreas := map[string]bool{"c": true, "b": true, "p": true}
for _, c := range req.Changes { 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] { if !validAreas[c.Area] {
writeError(w, http.StatusUnprocessableEntity, "invalid area: "+c.Area) writeError(w, http.StatusUnprocessableEntity, "invalid area: "+c.Area)
return return
@@ -196,106 +207,78 @@ func renderIPMIFRUCard() string {
<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> <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-status" style="font-size:13px;color:var(--muted);margin-bottom:8px"></div>
<div id="fru-table"></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> </div></div>
<script> <script>
var fruOriginal = {}; 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() { function fruRead() {
document.getElementById('fru-status').textContent = 'Reading...'; var status = document.getElementById('fru-status');
status.textContent = 'Reading...'; status.style.color = 'var(--muted)';
document.getElementById('fru-table').innerHTML = ''; document.getElementById('fru-table').innerHTML = '';
document.getElementById('fru-save-row').style.display = 'none';
fetch('/api/tools/ipmi-fru', {cache:'no-store'}) fetch('/api/tools/ipmi-fru', {cache:'no-store'})
.then(function(r) { .then(function(r) { return r.json().then(function(d){if(!r.ok)throw new Error(d.error||r.statusText);return d;}); })
if (!r.ok) return r.json().then(function(e) { throw new Error(e.error || r.statusText); });
return r.json();
})
.then(function(fields) { .then(function(fields) {
fruOriginal = {}; if (!fields || !fields.length) { status.textContent = 'No FRU fields returned.'; return; }
if (!fields || !fields.length) { status.textContent = '';
document.getElementById('fru-status').textContent = 'No FRU fields returned.';
return;
}
document.getElementById('fru-status').textContent = '';
var rows = fields.map(function(f) { var rows = fields.map(function(f) {
var val = f.value || ''; var val = escHtml(f.value || '');
if (f.editable) { return '<tr>'
fruOriginal[f.area + '_' + f.index] = val; + '<td style="color:var(--muted);white-space:nowrap;padding-right:16px;vertical-align:middle;font-size:13px">' + escHtml(f.name) + '</td>'
return '<tr><td style="color:var(--muted);white-space:nowrap;padding-right:16px">' + escHtml(f.name) + '</td>' + '<td style="vertical-align:middle"><input class="fru-inp" style="' + _fruInputStyle + '"'
+ '<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||0) + '" data-name="' + escHtml(f.name) + '"'
+ ' data-area="' + escHtml(f.area) + '" data-index="' + f.index + '" data-name="' + escHtml(f.name) + '"' + ' data-original="' + val + '" value="' + val + '" oninput="fruChanged(this)"></td>'
+ ' data-original="' + escHtml(val) + '" value="' + escHtml(val) + '" oninput="fruDirtyCheck()"></td></tr>'; + '<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>'
return '<tr><td style="color:var(--muted);white-space:nowrap;padding-right:16px">' + escHtml(f.name) + '</td>' + '<button style="' + _fruActBtnStyle + 'color:var(--crit-fg,#9f3a38)" title="Cancel" onclick="fruCancel(this)">✗</button>'
+ '<td style="color:var(--ink)">' + escHtml(val || '—') + '</td></tr>'; + '<span class="fru-msg" style="font-size:11px;margin-left:5px;color:var(--muted)"></span>'
+ '</td></tr>';
}).join(''); }).join('');
document.getElementById('fru-table').innerHTML = '<table style="width:100%">' + rows + '</table>'; document.getElementById('fru-table').innerHTML = '<table style="width:100%;border-collapse:collapse">' + rows + '</table>';
fruDirtyCheck();
}) })
.catch(function(e) { .catch(function(e) { status.textContent = 'Error: '+e.message; status.style.color='var(--crit-fg)'; });
document.getElementById('fru-status').textContent = 'Error: ' + e.message;
document.getElementById('fru-status').style.color = 'var(--crit-fg)';
});
} }
function escHtml(s) { function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
} }
function fruDirtyCheck() { function fruChanged(inp) {
var inputs = document.querySelectorAll('.fru-input'); inp.closest('tr').querySelector('.fru-act').style.display = inp.value !== inp.dataset.original ? '' : 'none';
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() { function fruCancel(btn) {
var inputs = document.querySelectorAll('.fru-input'); var row = btn.closest('tr');
var changes = []; var inp = row.querySelector('.fru-inp');
inputs.forEach(function(el) { inp.value = inp.dataset.original;
if (el.value !== el.dataset.original) { row.querySelector('.fru-act').style.display = 'none';
changes.push({area: el.dataset.area, index: parseInt(el.dataset.index, 10), name: el.dataset.name, value: el.value}); row.querySelector('.fru-msg').textContent = '';
} }
}); function fruSave(btn) {
if (!changes.length) return; var row = btn.closest('tr');
document.getElementById('fru-save-btn').disabled = true; var inp = row.querySelector('.fru-inp');
document.getElementById('fru-save-msg').textContent = 'Saving...'; var msg = row.querySelector('.fru-msg');
fetch('/api/tools/ipmi-fru/write', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({changes: changes})}) var cancelBtn = row.querySelectorAll('.fru-act button')[1];
.then(function(r) { btn.disabled = true; cancelBtn.disabled = true;
if (!r.ok) return r.json().then(function(e) { throw new Error(e.error || r.statusText); }); msg.textContent = '…'; msg.style.color = 'var(--muted)';
return r.json(); 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(d) { .then(function(r){return r.json().then(function(d){if(!r.ok)throw new Error(d.error||r.statusText);return d;});})
var taskId = d.task_id; .then(function(d){
document.getElementById('fru-save-msg').textContent = 'Task ' + taskId + ' queued…'; var poll = setInterval(function(){
var poll = setInterval(function() { fetch('/api/tasks',{cache:'no-store'}).then(function(r){return r.json();}).then(function(tasks){
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;
var t = Array.isArray(tasks) ? tasks.find(function(x) { return x.id === taskId; }) : null; if(!t) return;
if (!t) return; if(t.status==='done'){
if (t.status === 'done') {
clearInterval(poll); clearInterval(poll);
document.getElementById('fru-save-msg').textContent = 'Done — backup saved to fru-backups/.'; inp.dataset.original = inp.value;
document.getElementById('fru-save-btn').disabled = false; row.querySelector('.fru-act').style.display = 'none';
inputs.forEach(function(el) { el.dataset.original = el.value; }); msg.textContent = '';
fruDirtyCheck(); } else if(t.status==='failed'||t.status==='cancelled'){
} else if (t.status === 'failed') {
clearInterval(poll); clearInterval(poll);
document.getElementById('fru-save-msg').textContent = 'Failed: ' + (t.error || 'unknown error'); msg.textContent = t.error||t.status; msg.style.color='var(--crit-fg)';
document.getElementById('fru-save-btn').disabled = false; btn.disabled=false; cancelBtn.disabled=false;
} }
}); });
}, 1500); },1500);
}) })
.catch(function(e) { .catch(function(e){ msg.textContent='Error: '+e.message; msg.style.color='var(--crit-fg)'; btn.disabled=false; cancelBtn.disabled=false; });
document.getElementById('fru-save-msg').textContent = 'Error: ' + e.message;
document.getElementById('fru-save-btn').disabled = false;
});
} }
</script>` </script>`
} }

View File

@@ -213,98 +213,85 @@ func runSAADMIWriteTask(ctx context.Context, j *jobState, exportDir string, p ta
} }
func renderSAADMICard() string { func renderSAADMICard() string {
return `<div class="card"><div class="card-head">Supermicro &#8212; DMI <button class="btn btn-sm btn-secondary" onclick="saaDMIRead()" style="margin-left:auto">Read</button></div><div class="card-body"> return `<div class="card"><div class="card-head card-head-actions">Supermicro &#8212; 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> <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-status" style="font-size:13px;color:var(--muted);margin-bottom:8px"></div>
<div id="saa-dmi-table"></div> <div id="saa-dmi-table"></div>
<div id="saa-dmi-save-row" style="display:none;margin-top:12px"> </div></div>
<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> <script>
function saaDMIEsc(s) { 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;';
return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];}); 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
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() { function saaDMIRead() {
var status = document.getElementById('saa-dmi-status'); var status = document.getElementById('saa-dmi-status');
var table = document.getElementById('saa-dmi-table'); status.textContent = 'Reading...'; status.style.color = 'var(--muted)';
var saveRow = document.getElementById('saa-dmi-save-row'); document.getElementById('saa-dmi-table').innerHTML = '';
status.textContent = 'Reading...'; fetch('/api/tools/saa-dmi', {cache:'no-store'})
status.style.color = 'var(--muted)'; .then(function(r){return r.json().then(function(d){if(!r.ok)throw new Error(d.error||('HTTP '+r.status));return d;});})
table.innerHTML = ''; .then(function(fields){
saveRow.style.display = 'none'; status.textContent = fields.length + ' field(s) loaded.';
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){ var rows = fields.map(function(f){
status.textContent = fields.length+' field(s) loaded.'; var val = dmiEsc(f.value||'');
var rows = fields.map(function(f){ return '<tr>'
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-size:13px;white-space:nowrap;padding-right:8px">'+saaDMIEsc(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="font-family:monospace;font-size:13px;white-space:nowrap;padding-right:8px">'+saaDMIEsc(f.shn)+'</td>' + '<td style="vertical-align:middle"><input class="dmi-inp" type="text" style="'+_dmiInputStyle+'"'
+'<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>' + ' data-shn="'+dmiEsc(f.shn)+'" data-original="'+val+'" value="'+val+'" oninput="dmiChanged(this)"></td>'
+'<td id="saa-dmi-dirty-'+saaDMIEsc(f.shn)+'" style="font-size:12px;color:var(--warn,#b45309);width:50px;padding-left:6px"></td>' + '<td class="dmi-act" style="display:none;white-space:nowrap;padding-left:6px;vertical-align:middle">'
+'</tr>'; + '<button style="'+_dmiActBtnStyle+'color:var(--ok-fg,green);margin-right:3px" title="Save" onclick="dmiSave(this)">✓</button>'
}).join(''); + '<button style="'+_dmiActBtnStyle+'color:var(--crit-fg,#9f3a38)" title="Cancel" onclick="dmiCancel(this)">✗</button>'
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>'; + '<span class="dmi-msg" style="font-size:11px;margin-left:5px;color:var(--muted)"></span>'
}).catch(function(e){ + '</td></tr>';
status.textContent = 'Error: '+e.message; }).join('');
status.style.color = 'var(--crit-fg,#9f3a38)'; 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 saaDMIMarkDirty(inp) { function dmiChanged(inp) {
var shn = inp.dataset.shn; inp.closest('tr').querySelector('.dmi-act').style.display = inp.value !== inp.dataset.original ? '' : 'none';
var cell = document.getElementById('saa-dmi-dirty-'+shn);
if(cell)cell.textContent = inp.value!==inp.dataset.original?'changed':'';
saaDMIUpdateSaveBtn();
} }
function saaDMIWaitTask(taskID) { function dmiCancel(btn) {
var msg = document.getElementById('saa-dmi-save-msg'); var row = btn.closest('tr');
msg.textContent = 'Task '+taskID+' queued...'; var inp = row.querySelector('.dmi-inp');
msg.style.color = 'var(--muted)'; inp.value = inp.dataset.original;
var timer = setInterval(function(){ row.querySelector('.dmi-act').style.display = 'none';
fetch('/api/tasks').then(function(r){return r.json();}).then(function(tasks){ row.querySelector('.dmi-msg').textContent = '';
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() { function dmiSave(btn) {
var inputs = document.querySelectorAll('#saa-dmi-table input[data-original]'); var row = btn.closest('tr');
var changes = []; var inp = row.querySelector('.dmi-inp');
inputs.forEach(function(inp){if(inp.value!==inp.dataset.original)changes.push({shn:inp.dataset.shn,value:inp.value});}); var msg = row.querySelector('.dmi-msg');
if(!changes.length)return; var cancelBtn = row.querySelectorAll('.dmi-act button')[1];
var names = changes.map(function(c){return c.shn;}).join(', '); if(!window.confirm('Apply DMI change for '+inp.dataset.shn+'?\nServer will need to reboot for changes to take effect.'))return;
if(!window.confirm('Apply DMI changes for: '+names+'?\n\nThe server will need to be rebooted for changes to take effect.'))return; btn.disabled=true; cancelBtn.disabled=true;
var btn = document.getElementById('saa-dmi-save-btn'); msg.textContent='…'; msg.style.color='var(--muted)';
var msg = document.getElementById('saa-dmi-save-msg'); fetch('/api/tools/saa-dmi/write',{method:'POST',headers:{'Content-Type':'application/json'},
btn.disabled = true; body:JSON.stringify({changes:[{shn:inp.dataset.shn,value:inp.value}]})})
msg.textContent = 'Submitting...'; .then(function(r){return r.json().then(function(d){if(!r.ok)throw new Error(d.error||('HTTP '+r.status));return d;});})
msg.style.color = 'var(--muted)'; .then(function(d){
fetch('/api/tools/saa-dmi/write',{ var poll=setInterval(function(){
method:'POST', fetch('/api/tasks',{cache:'no-store'}).then(function(r){return r.json();}).then(function(tasks){
headers:{'Content-Type':'application/json'}, var t=(tasks||[]).find(function(x){return x.id===d.task_id;});
body:JSON.stringify({changes:changes}) if(!t)return;
}).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){ if(t.status==='done'){
saaDMIWaitTask(d.task_id); clearInterval(poll);
}).catch(function(e){ inp.dataset.original=inp.value;
msg.textContent = 'Error: '+e.message; row.querySelector('.dmi-act').style.display='none';
msg.style.color = 'var(--crit-fg,#9f3a38)'; msg.textContent='Saved. Reboot to apply.'; msg.style.color='var(--ok-fg,green)';
btn.disabled = false; } 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> </script>`
</div></div>`
} }