Add unified FRU/Elabel card with Huawei iBMC OEM IPMI support
Replaces separate IPMI FRU and SAA DMI cards with a single FRU / Elabel card that reads all available sources in parallel and shows each field with a color-coded source chip (IPMI FRU / Huawei iBMC / SAA DMI). Huawei elabel fields are read/written via OEM IPMI raw commands (NetFn 0x30, cmd 0x90) with 19-byte chunking protocol, matching the FusionServer ElabelTool V511 wire format. Covers DeviceName, DeviceSerialNumber, ProductName, ProductSerialNumber, ProductAssetTag, ProductManufacturer, MainboardManufacturer, BoardProductName, ChassisPartnumber, ChassisType (read-only), IOChassisSerial, IOChassisAssetTag, and GUID (read-only via standard 0x06 0x08). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
280
audit/internal/webui/huawei_elabel.go
Normal file
280
audit/internal/webui/huawei_elabel.go
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type huaweiField struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
ReadOnly bool `json:"read_only,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type huaweiChange struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type huaweiFieldDef struct {
|
||||||
|
Name string
|
||||||
|
Key string
|
||||||
|
FruID byte
|
||||||
|
TypeID byte
|
||||||
|
FieldID byte
|
||||||
|
Special string // "chassis-type" | "guid"
|
||||||
|
}
|
||||||
|
|
||||||
|
var huaweiElabelDefs = []huaweiFieldDef{
|
||||||
|
{"Device Name", "DeviceName", 0x00, 0x06, 0x01, ""},
|
||||||
|
{"Device Serial Number", "DeviceSerialNumber", 0x00, 0x06, 0x03, ""},
|
||||||
|
{"Product Name", "ProductName", 0x00, 0x03, 0x01, ""},
|
||||||
|
{"Product Serial Number", "ProductSerialNumber", 0x00, 0x03, 0x04, ""},
|
||||||
|
{"Product Asset Tag", "ProductAssetTag", 0x00, 0x03, 0x05, ""},
|
||||||
|
{"Product Manufacturer", "ProductManufacturer", 0x00, 0x03, 0x00, ""},
|
||||||
|
{"Mainboard Manufacturer", "MainboardManufacturer", 0x00, 0x02, 0x01, ""},
|
||||||
|
{"Board Product Name", "BoardProductName", 0x00, 0x02, 0x02, ""},
|
||||||
|
{"Chassis Part Number", "ChassisPartnumber", 0x00, 0x01, 0x01, ""},
|
||||||
|
{"Chassis Type", "ChassisType", 0x00, 0x01, 0x00, "chassis-type"},
|
||||||
|
{"IO Chassis Serial", "IOChassisSerialNumber", 0x01, 0x03, 0x04, ""},
|
||||||
|
{"IO Chassis Asset Tag", "IOChassisAssetTag", 0x01, 0x03, 0x05, ""},
|
||||||
|
{"GUID", "GUID", 0x00, 0x00, 0x00, "guid"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// huaweiGetRaw reads a string elabel field via OEM IPMI raw command.
|
||||||
|
// Protocol: ipmitool raw 0x30 0x90 0x05 <fru_id> <type_id> <field_id> 0x00 0x30
|
||||||
|
// Response: <length_byte> <ascii_byte1> ... (null-terminated)
|
||||||
|
func huaweiGetRaw(ctx context.Context, def huaweiFieldDef) (string, error) {
|
||||||
|
if def.Special == "guid" {
|
||||||
|
return huaweiGetGUID(ctx)
|
||||||
|
}
|
||||||
|
args := []string{
|
||||||
|
"0x30", "0x90", "0x05",
|
||||||
|
fmt.Sprintf("0x%02x", def.FruID),
|
||||||
|
fmt.Sprintf("0x%02x", def.TypeID),
|
||||||
|
fmt.Sprintf("0x%02x", def.FieldID),
|
||||||
|
"0x00", "0x30",
|
||||||
|
}
|
||||||
|
out, err := exec.CommandContext(ctx, "ipmitool", append([]string{"raw"}, args...)...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return huaweiParseStringResponse(strings.TrimSpace(string(out)), def.Special), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// huaweiParseStringResponse decodes the OEM IPMI response bytes to a string.
|
||||||
|
// Format: <length_byte> <byte1> <byte2> ...
|
||||||
|
func huaweiParseStringResponse(hexOut, special string) string {
|
||||||
|
parts := strings.Fields(hexOut)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if special == "chassis-type" {
|
||||||
|
// Response: <length=1> <type_byte>
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
n, err := strconv.ParseUint(parts[1], 16, 8)
|
||||||
|
if err == nil {
|
||||||
|
return fmt.Sprintf("0x%02x", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, p := range parts[1:] {
|
||||||
|
b, err := strconv.ParseUint(p, 16, 8)
|
||||||
|
if err != nil || b == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sb.WriteByte(byte(b))
|
||||||
|
}
|
||||||
|
return strings.TrimRight(sb.String(), "\x00")
|
||||||
|
}
|
||||||
|
|
||||||
|
// huaweiGetGUID reads the system GUID via standard IPMI Get System GUID (0x06 0x08).
|
||||||
|
func huaweiGetGUID(ctx context.Context) (string, error) {
|
||||||
|
out, err := exec.CommandContext(ctx, "ipmitool", "raw", "0x06", "0x08").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
parts := strings.Fields(strings.TrimSpace(string(out)))
|
||||||
|
if len(parts) != 16 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
// Format as UUID: 4-2-2-2-6 byte groups
|
||||||
|
// iBMC returns bytes in reversed order; re-reverse to get canonical UUID.
|
||||||
|
var bytes [16]string
|
||||||
|
for i, p := range parts {
|
||||||
|
bytes[15-i] = p
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s%s%s%s-%s%s-%s%s-%s%s-%s%s%s%s%s%s",
|
||||||
|
bytes[0], bytes[1], bytes[2], bytes[3],
|
||||||
|
bytes[4], bytes[5],
|
||||||
|
bytes[6], bytes[7],
|
||||||
|
bytes[8], bytes[9],
|
||||||
|
bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// huaweiChunks splits a value into 19-byte chunks for the OEM IPMI SET protocol.
|
||||||
|
// Key byte: bit7=1 means more chunks follow; bits 0-6 = offset into string.
|
||||||
|
func huaweiChunks(value string) [][]string {
|
||||||
|
if len(value) == 0 {
|
||||||
|
return [][]string{{"0x00", "0x01", "0x00"}}
|
||||||
|
}
|
||||||
|
const maxLen = 63
|
||||||
|
if len(value) > maxLen {
|
||||||
|
value = value[:maxLen]
|
||||||
|
}
|
||||||
|
const chunkSize = 19
|
||||||
|
var chunks [][]string
|
||||||
|
for offset := 0; offset < len(value); {
|
||||||
|
end := offset + chunkSize
|
||||||
|
if end > len(value) {
|
||||||
|
end = len(value)
|
||||||
|
}
|
||||||
|
isLast := end >= len(value)
|
||||||
|
key := byte(offset)
|
||||||
|
if !isLast {
|
||||||
|
key |= 0x80
|
||||||
|
}
|
||||||
|
args := []string{
|
||||||
|
fmt.Sprintf("0x%02x", key),
|
||||||
|
fmt.Sprintf("0x%02x", end-offset),
|
||||||
|
}
|
||||||
|
for _, b := range []byte(value[offset:end]) {
|
||||||
|
args = append(args, fmt.Sprintf("0x%02x", b))
|
||||||
|
}
|
||||||
|
chunks = append(chunks, args)
|
||||||
|
offset = end
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleAPIHuaweiElabelRead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var fields []huaweiField
|
||||||
|
for _, def := range huaweiElabelDefs {
|
||||||
|
val, err := huaweiGetRaw(ctx, def)
|
||||||
|
if err != nil {
|
||||||
|
// First field failure likely means no Huawei BMC — abort with error.
|
||||||
|
if len(fields) == 0 {
|
||||||
|
msg := strings.TrimSpace(err.Error())
|
||||||
|
writeError(w, http.StatusInternalServerError, "huawei elabel not available: "+msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val = ""
|
||||||
|
}
|
||||||
|
fields = append(fields, huaweiField{
|
||||||
|
Name: def.Name,
|
||||||
|
Key: def.Key,
|
||||||
|
Value: val,
|
||||||
|
ReadOnly: def.Special == "guid" || def.Special == "chassis-type",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleAPIHuaweiElabelWrite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Changes []huaweiChange `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
|
||||||
|
}
|
||||||
|
|
||||||
|
defByKey := make(map[string]huaweiFieldDef, len(huaweiElabelDefs))
|
||||||
|
for _, d := range huaweiElabelDefs {
|
||||||
|
defByKey[d.Key] = d
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range req.Changes {
|
||||||
|
def, ok := defByKey[c.Key]
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnprocessableEntity, "unknown field key: "+c.Key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if def.Special == "guid" || def.Special == "chassis-type" {
|
||||||
|
writeError(w, http.StatusUnprocessableEntity, "field is read-only: "+c.Key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(c.Value) > 63 {
|
||||||
|
writeError(w, http.StatusUnprocessableEntity, "value too long (max 63 chars): "+c.Key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, ch := range c.Value {
|
||||||
|
if ch < 0x20 || ch > 0x7E {
|
||||||
|
writeError(w, http.StatusUnprocessableEntity, "non-printable character in value for: "+c.Key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t := &Task{
|
||||||
|
ID: newJobID("huawei-elabel-write"),
|
||||||
|
Name: fmt.Sprintf("Huawei Elabel Write (%d field(s))", len(req.Changes)),
|
||||||
|
Target: "huawei-elabel-write",
|
||||||
|
Priority: defaultTaskPriority("huawei-elabel-write", taskParams{}),
|
||||||
|
Status: TaskPending,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
params: taskParams{HuaweiElabelChanges: req.Changes},
|
||||||
|
}
|
||||||
|
globalQueue.enqueue(t)
|
||||||
|
writeJSON(w, map[string]string{"task_id": t.ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runHuaweiElabelWriteTask(ctx context.Context, j *jobState, p taskParams) error {
|
||||||
|
defByKey := make(map[string]huaweiFieldDef, len(huaweiElabelDefs))
|
||||||
|
for _, d := range huaweiElabelDefs {
|
||||||
|
defByKey[d.Key] = d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable device name effective flag before writing.
|
||||||
|
enableCmd := exec.CommandContext(ctx, "ipmitool", "raw", "0x30", "0x90", "0x21", "0x04", "0x01")
|
||||||
|
if out, err := enableCmd.CombinedOutput(); err != nil {
|
||||||
|
j.append("Warning: enable flag: " + strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range p.HuaweiElabelChanges {
|
||||||
|
def := defByKey[c.Key]
|
||||||
|
setPrefix := []string{
|
||||||
|
"0x30", "0x90", "0x04",
|
||||||
|
fmt.Sprintf("0x%02x", def.FruID),
|
||||||
|
fmt.Sprintf("0x%02x", def.TypeID),
|
||||||
|
fmt.Sprintf("0x%02x", def.FieldID),
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks := huaweiChunks(c.Value)
|
||||||
|
j.append(fmt.Sprintf("Setting %s = %q (%d chunk(s))", c.Key, c.Value, len(chunks)))
|
||||||
|
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
args := append([]string{"raw"}, setPrefix...)
|
||||||
|
args = append(args, chunk...)
|
||||||
|
cmd := exec.CommandContext(ctx, "ipmitool", args...)
|
||||||
|
if err := streamCmdJob(j, cmd); err != nil {
|
||||||
|
return fmt.Errorf("set %s: %w", c.Key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit after each field.
|
||||||
|
commitCmd := exec.CommandContext(ctx, "ipmitool", "raw", "0x30", "0x90", "0x06", "0x00", "0xAA")
|
||||||
|
if out, err := commitCmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("commit after %s: %w (output: %s)", c.Key, err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
j.append("Committed " + c.Key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -202,83 +202,3 @@ func runIPMIFRUWriteTask(ctx context.Context, j *jobState, exportDir string, p t
|
|||||||
return nil
|
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></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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
||||||
}
|
|
||||||
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>`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -404,13 +404,196 @@ loadNvidiaSelfHeal();
|
|||||||
func renderTools() string {
|
func renderTools() string {
|
||||||
return renderNVMeFormatCard() + `
|
return renderNVMeFormatCard() + `
|
||||||
|
|
||||||
` + renderSAADMICard() + `
|
` + renderFRUEditorCard() + `
|
||||||
|
|
||||||
` + renderIPMIFRUCard() + `
|
|
||||||
|
|
||||||
` + renderRAIDMgmtCard()
|
` + renderRAIDMgmtCard()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderFRUEditorCard() string {
|
||||||
|
return `<div class="card"><div class="card-head card-head-actions">FRU / Elabel<div class="card-head-buttons"><button class="btn btn-sm btn-secondary" onclick="fruAllRead()">Read All</button></div></div><div class="card-body">
|
||||||
|
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Reads and edits hardware identity fields from all available sources. Each field shows its source method.</p>
|
||||||
|
<div id="fru-all-status" style="font-size:13px;color:var(--muted);margin-bottom:8px"></div>
|
||||||
|
<div id="fru-all-table"></div>
|
||||||
|
</div></div>
|
||||||
|
<style>
|
||||||
|
.fru-chip{display:inline-block;font-size:10px;font-weight:600;letter-spacing:.02em;padding:1px 6px;border-radius:3px;vertical-align:middle;white-space:nowrap;margin-right:8px;flex-shrink:0}
|
||||||
|
.fru-chip-ipmi{background:#e8e8e8;color:#555}
|
||||||
|
.fru-chip-huawei{background:#fff0e6;color:#b83}
|
||||||
|
.fru-chip-saa{background:#e6f0ff;color:#557}
|
||||||
|
.fru-inp-wrap{display:flex;align-items:center;gap:0}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var _actBtn='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 _inp='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);';
|
||||||
|
|
||||||
|
var SOURCES = [
|
||||||
|
{
|
||||||
|
id: 'ipmi-fru',
|
||||||
|
label: 'IPMI FRU',
|
||||||
|
chipClass: 'fru-chip-ipmi',
|
||||||
|
url: '/api/tools/ipmi-fru',
|
||||||
|
writeUrl: '/api/tools/ipmi-fru/write',
|
||||||
|
rowAttrs: function(f) {
|
||||||
|
return 'data-source="ipmi-fru" data-area="'+esc(f.area||'')+'" data-index="'+(f.index||0)+'" data-name="'+esc(f.name)+'"';
|
||||||
|
},
|
||||||
|
writeBody: function(inp) {
|
||||||
|
return JSON.stringify({changes:[{area:inp.dataset.area,index:parseInt(inp.dataset.index,10),name:inp.dataset.name,value:inp.value}]});
|
||||||
|
},
|
||||||
|
fieldName: function(f) { return f.name; },
|
||||||
|
fieldValue: function(f) { return f.value||''; },
|
||||||
|
readOnly: function(f) { return false; },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'huawei',
|
||||||
|
label: 'Huawei iBMC',
|
||||||
|
chipClass: 'fru-chip-huawei',
|
||||||
|
url: '/api/tools/huawei-elabel',
|
||||||
|
writeUrl: '/api/tools/huawei-elabel/write',
|
||||||
|
rowAttrs: function(f) {
|
||||||
|
return 'data-source="huawei" data-key="'+esc(f.key)+'"';
|
||||||
|
},
|
||||||
|
writeBody: function(inp) {
|
||||||
|
return JSON.stringify({changes:[{key:inp.dataset.key,value:inp.value}]});
|
||||||
|
},
|
||||||
|
fieldName: function(f) { return f.name; },
|
||||||
|
fieldValue: function(f) { return f.value||''; },
|
||||||
|
readOnly: function(f) { return !!f.read_only; },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'saa-dmi',
|
||||||
|
label: 'SAA DMI',
|
||||||
|
chipClass: 'fru-chip-saa',
|
||||||
|
url: '/api/tools/saa-dmi',
|
||||||
|
writeUrl: '/api/tools/saa-dmi/write',
|
||||||
|
rowAttrs: function(f) {
|
||||||
|
return 'data-source="saa-dmi" data-shn="'+esc(f.shn)+'"';
|
||||||
|
},
|
||||||
|
writeBody: function(inp) {
|
||||||
|
return JSON.stringify({changes:[{shn:inp.dataset.shn,value:inp.value}]});
|
||||||
|
},
|
||||||
|
fieldName: function(f) { return f.name; },
|
||||||
|
fieldValue: function(f) { return f.value||''; },
|
||||||
|
readOnly: function(f) { return false; },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||||||
|
|
||||||
|
window.fruAllRead = function() {
|
||||||
|
var status = document.getElementById('fru-all-status');
|
||||||
|
var table = document.getElementById('fru-all-table');
|
||||||
|
status.textContent = 'Reading…'; status.style.color = 'var(--muted)';
|
||||||
|
table.innerHTML = '';
|
||||||
|
|
||||||
|
var fetches = SOURCES.map(function(src) {
|
||||||
|
return fetch(src.url, {cache:'no-store'})
|
||||||
|
.then(function(r){ return r.json().then(function(d){ if(!r.ok) throw new Error(d.error||r.statusText); return d; }); });
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.allSettled(fetches).then(function(results) {
|
||||||
|
var rows = '';
|
||||||
|
var totalFields = 0;
|
||||||
|
var failedSources = [];
|
||||||
|
|
||||||
|
results.forEach(function(res, i) {
|
||||||
|
var src = SOURCES[i];
|
||||||
|
if (res.status === 'rejected' || !Array.isArray(res.value) || res.value.length === 0) {
|
||||||
|
failedSources.push(src.label + (res.reason ? ': ' + res.reason.message : ''));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.value.forEach(function(f) {
|
||||||
|
var val = esc(src.fieldValue(f));
|
||||||
|
var ro = src.readOnly(f);
|
||||||
|
var attrs = ro ? '' : (' '+src.rowAttrs(f));
|
||||||
|
rows += '<tr>'
|
||||||
|
+ '<td style="white-space:nowrap;padding-right:4px;vertical-align:middle">'
|
||||||
|
+ '<span class="fru-chip '+src.chipClass+'">'+src.label+'</span>'
|
||||||
|
+ '</td>'
|
||||||
|
+ '<td style="color:var(--muted);white-space:nowrap;padding-right:16px;vertical-align:middle;font-size:13px">'+esc(src.fieldName(f))+'</td>'
|
||||||
|
+ '<td style="vertical-align:middle">'
|
||||||
|
+ (ro
|
||||||
|
? '<span style="font-family:monospace;font-size:13px;color:var(--muted)">'+val+'</span>'
|
||||||
|
: '<input class="fru-uni-inp" style="'+_inp+'" value="'+val+'" data-original="'+val+'"'+attrs+' oninput="fruUniChanged(this)">')
|
||||||
|
+ '</td>'
|
||||||
|
+ '<td class="fru-uni-act" style="display:none;white-space:nowrap;padding-left:6px;vertical-align:middle">'
|
||||||
|
+ '<button style="'+_actBtn+'color:var(--ok-fg,green);margin-right:3px" title="Save" onclick="fruUniSave(this)">✓</button>'
|
||||||
|
+ '<button style="'+_actBtn+'color:var(--crit-fg,#9f3a38)" title="Cancel" onclick="fruUniCancel(this)">✗</button>'
|
||||||
|
+ '<span class="fru-uni-msg" style="font-size:11px;margin-left:5px;color:var(--muted)"></span>'
|
||||||
|
+ '</td>'
|
||||||
|
+ '</tr>';
|
||||||
|
totalFields++;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (totalFields === 0 && failedSources.length > 0) {
|
||||||
|
status.textContent = 'No sources available: ' + failedSources.join('; ');
|
||||||
|
status.style.color = 'var(--crit-fg,#9f3a38)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.innerHTML = '<table style="width:100%;border-collapse:collapse">'+rows+'</table>';
|
||||||
|
var msg = totalFields + ' field(s) loaded';
|
||||||
|
if (failedSources.length > 0) msg += ' (skipped: ' + failedSources.join(', ') + ')';
|
||||||
|
status.textContent = msg;
|
||||||
|
status.style.color = 'var(--muted)';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.fruUniChanged = function(inp) {
|
||||||
|
var row = inp.closest('tr');
|
||||||
|
row.querySelector('.fru-uni-act').style.display = inp.value !== inp.dataset.original ? '' : 'none';
|
||||||
|
row.querySelector('.fru-uni-msg').textContent = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.fruUniCancel = function(btn) {
|
||||||
|
var row = btn.closest('tr');
|
||||||
|
var inp = row.querySelector('.fru-uni-inp');
|
||||||
|
inp.value = inp.dataset.original;
|
||||||
|
row.querySelector('.fru-uni-act').style.display = 'none';
|
||||||
|
row.querySelector('.fru-uni-msg').textContent = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.fruUniSave = function(btn) {
|
||||||
|
var row = btn.closest('tr');
|
||||||
|
var inp = row.querySelector('.fru-uni-inp');
|
||||||
|
var msg = row.querySelector('.fru-uni-msg');
|
||||||
|
var cancelBtn = row.querySelectorAll('.fru-uni-act button')[1];
|
||||||
|
var src = SOURCES.find(function(s){ return s.id === inp.dataset.source; });
|
||||||
|
if (!src) { msg.textContent = 'Unknown source'; msg.style.color='var(--crit-fg)'; return; }
|
||||||
|
|
||||||
|
btn.disabled = true; cancelBtn.disabled = true;
|
||||||
|
msg.textContent = '…'; msg.style.color = 'var(--muted)';
|
||||||
|
|
||||||
|
fetch(src.writeUrl, {method:'POST', headers:{'Content-Type':'application/json'}, body:src.writeBody(inp)})
|
||||||
|
.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-uni-act').style.display = 'none';
|
||||||
|
msg.textContent = ''; msg.style.color = '';
|
||||||
|
} 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>`
|
||||||
|
}
|
||||||
|
|
||||||
func renderExportIndex(exportDir string) (string, error) {
|
func renderExportIndex(exportDir string) (string, error) {
|
||||||
entries, err := listExportFiles(exportDir)
|
entries, err := listExportFiles(exportDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -212,86 +212,3 @@ func runSAADMIWriteTask(ctx context.Context, j *jobState, exportDir string, p ta
|
|||||||
return nil
|
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>`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -318,6 +318,8 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
mux.HandleFunc("POST /api/tools/saa-dmi/write", h.handleAPISAADMIWrite)
|
mux.HandleFunc("POST /api/tools/saa-dmi/write", h.handleAPISAADMIWrite)
|
||||||
mux.HandleFunc("GET /api/tools/ipmi-fru", h.handleAPIIPMIFRURead)
|
mux.HandleFunc("GET /api/tools/ipmi-fru", h.handleAPIIPMIFRURead)
|
||||||
mux.HandleFunc("POST /api/tools/ipmi-fru/write", h.handleAPIIPMIFRUWrite)
|
mux.HandleFunc("POST /api/tools/ipmi-fru/write", h.handleAPIIPMIFRUWrite)
|
||||||
|
mux.HandleFunc("GET /api/tools/huawei-elabel", h.handleAPIHuaweiElabelRead)
|
||||||
|
mux.HandleFunc("POST /api/tools/huawei-elabel/write", h.handleAPIHuaweiElabelWrite)
|
||||||
mux.HandleFunc("GET /api/tools/raid/status", h.handleAPIRAIDStatus)
|
mux.HandleFunc("GET /api/tools/raid/status", h.handleAPIRAIDStatus)
|
||||||
mux.HandleFunc("POST /api/tools/raid/foreign", h.handleAPIRAIDForeignAction)
|
mux.HandleFunc("POST /api/tools/raid/foreign", h.handleAPIRAIDForeignAction)
|
||||||
mux.HandleFunc("POST /api/tools/raid/create-mirror", h.handleAPIRAIDCreateMirror)
|
mux.HandleFunc("POST /api/tools/raid/create-mirror", h.handleAPIRAIDCreateMirror)
|
||||||
|
|||||||
@@ -394,6 +394,12 @@ func executeTaskWithOptions(opts *HandlerOptions, t *Task, j *jobState, ctx cont
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
err = runIPMIFRUWriteTask(ctx, j, opts.ExportDir, t.params)
|
err = runIPMIFRUWriteTask(ctx, j, opts.ExportDir, t.params)
|
||||||
|
case "huawei-elabel-write":
|
||||||
|
if len(t.params.HuaweiElabelChanges) == 0 {
|
||||||
|
err = fmt.Errorf("no changes provided")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
err = runHuaweiElabelWriteTask(ctx, j, t.params)
|
||||||
case "raid-foreign-clear":
|
case "raid-foreign-clear":
|
||||||
err = runRAIDForeignClearTask(ctx, j, t.params.RAIDController)
|
err = runRAIDForeignClearTask(ctx, j, t.params.RAIDController)
|
||||||
case "raid-foreign-import":
|
case "raid-foreign-import":
|
||||||
|
|||||||
@@ -140,8 +140,9 @@ type taskParams struct {
|
|||||||
Device string `json:"device,omitempty"` // for install
|
Device string `json:"device,omitempty"` // for install
|
||||||
LBAF int `json:"lbaf,omitempty"`
|
LBAF int `json:"lbaf,omitempty"`
|
||||||
PlatformComponents []string `json:"platform_components,omitempty"`
|
PlatformComponents []string `json:"platform_components,omitempty"`
|
||||||
SAADmiChanges []saaChange `json:"saa_dmi_changes,omitempty"`
|
SAADmiChanges []saaChange `json:"saa_dmi_changes,omitempty"`
|
||||||
FRUChanges []fruChange `json:"fru_changes,omitempty"`
|
FRUChanges []fruChange `json:"fru_changes,omitempty"`
|
||||||
|
HuaweiElabelChanges []huaweiChange `json:"huawei_elabel_changes,omitempty"`
|
||||||
RAIDController int `json:"raid_controller,omitempty"`
|
RAIDController int `json:"raid_controller,omitempty"`
|
||||||
RAIDDevices []string `json:"raid_devices,omitempty"`
|
RAIDDevices []string `json:"raid_devices,omitempty"`
|
||||||
RAIDArrayName string `json:"raid_array_name,omitempty"`
|
RAIDArrayName string `json:"raid_array_name,omitempty"`
|
||||||
|
|||||||
Reference in New Issue
Block a user