From 8575cf06f8bfc6fe4150ffc40422b2dcb30fd880 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Wed, 1 Jul 2026 13:32:03 +0300 Subject: [PATCH] webui: show all RAID drives per controller and add drive-prepare action RAID Controller Management previously hid any LSI drive that wasn't already Frgn/UGood/JBOD, and scoped VROC "free drives" from all system disks instead of the ones actually wired to the VROC controller's ports - drives attached directly to the CPU or another HBA could leak in. Now every drive is listed per its own controller, and LSI drives not already ready for array creation get a "Prepare" button that forces them to Unconfigured Good via storcli. Co-Authored-By: Claude Sonnet 5 --- audit/internal/webui/raid_mgmt.go | 174 +++++++++++++++++++++++++++- audit/internal/webui/server.go | 1 + audit/internal/webui/task_runner.go | 6 + audit/internal/webui/tasks.go | 1 + 4 files changed, 179 insertions(+), 3 deletions(-) diff --git a/audit/internal/webui/raid_mgmt.go b/audit/internal/webui/raid_mgmt.go index 28e123f..a4ee627 100644 --- a/audit/internal/webui/raid_mgmt.go +++ b/audit/internal/webui/raid_mgmt.go @@ -38,6 +38,7 @@ type raidControllerInfo struct { Model string `json:"model"` ForeignDrives []raidDriveInfo `json:"foreign_drives"` FreeDrives []raidDriveInfo `json:"free_drives"` + AllDrives []raidDriveInfo `json:"all_drives"` Arrays []raidArrayInfo `json:"arrays,omitempty"` } @@ -97,6 +98,7 @@ func detectLSIControllers() []raidControllerInfo { Model: c.ResponseData.Basics.Model, ForeignDrives: []raidDriveInfo{}, FreeDrives: []raidDriveInfo{}, + AllDrives: []raidDriveInfo{}, } if ctrl.Model == "" { ctrl.Model = fmt.Sprintf("LSI Controller %d", ctrl.Index) @@ -111,6 +113,7 @@ func detectLSIControllers() []raidControllerInfo { SizeGB: raidParseHumanSizeGB(d.Size), Serial: strings.TrimSpace(d.SN), } + ctrl.AllDrives = append(ctrl.AllDrives, info) switch strings.TrimSpace(d.State) { case "Frgn": ctrl.ForeignDrives = append(ctrl.ForeignDrives, info) @@ -168,6 +171,30 @@ func parseRAIDMDStat(raw string) []mdStatEntry { return entries } +// raidVROCPortRx matches lines like " Port2 : /dev/sda (SERIAL123)" +// or " Port3 : - no device attached -" from `mdadm --detail-platform`. +var raidVROCPortRx = regexp.MustCompile(`^\s*Port\d+\s*:\s*(\S+)`) + +// parseVROCPorts returns the block device basenames (e.g. "sda") that are +// physically wired to the VROC I/O controller's ports, per `mdadm +// --detail-platform` output. Drives attached directly to the CPU (or to a +// separate HBA) rather than through this controller's ports are excluded. +func parseVROCPorts(raw string) map[string]bool { + ports := map[string]bool{} + for _, line := range strings.Split(raw, "\n") { + m := raidVROCPortRx.FindStringSubmatch(line) + if m == nil { + continue + } + dev := m[1] + if !strings.HasPrefix(dev, "/dev/") { + continue + } + ports[strings.TrimPrefix(dev, "/dev/")] = true + } + return ports +} + func detectVROCController() *raidControllerInfo { out, err := exec.Command("mdadm", "--detail-platform").CombinedOutput() if err != nil && len(out) == 0 { @@ -191,8 +218,16 @@ func detectVROCController() *raidControllerInfo { Model: "Intel VROC", ForeignDrives: []raidDriveInfo{}, FreeDrives: []raidDriveInfo{}, + AllDrives: []raidDriveInfo{}, } + ports := parseVROCPorts(string(out)) + // Some mdadm builds omit the "Port" lines from --detail-platform. When + // we can't determine which drives are actually wired to this + // controller, fall back to showing every disk not already in an array + // rather than hiding everything. + portsKnown := len(ports) > 0 + inArray := map[string]bool{} raw, err := os.ReadFile("/proc/mdstat") if err == nil { @@ -222,15 +257,25 @@ func detectVROCController() *raidControllerInfo { } if json.Unmarshal(lsblkOut, &lsblkDoc) == nil { for _, d := range lsblkDoc.BlockDevices { - if d.Type != "disk" || inArray[d.Name] { + // Only consider disks wired to this controller's ports - + // drives attached directly to the CPU (or another + // controller) never show up as VROC ports and are skipped. + if d.Type != "disk" || (portsKnown && !ports[d.Name]) { continue } - ctrl.FreeDrives = append(ctrl.FreeDrives, raidDriveInfo{ + info := raidDriveInfo{ Device: "/dev/" + d.Name, Model: strings.TrimSpace(d.Model), Serial: strings.TrimSpace(d.Serial), State: "available", - }) + } + if inArray[d.Name] { + info.State = "member" + } + ctrl.AllDrives = append(ctrl.AllDrives, info) + if info.State == "available" { + ctrl.FreeDrives = append(ctrl.FreeDrives, info) + } } } } @@ -348,6 +393,38 @@ func (h *handler) handleAPIRAIDCreateMirror(w http.ResponseWriter, r *http.Reque writeJSON(w, map[string]string{"task_id": t.ID}) } +func (h *handler) handleAPIRAIDPrepareDrive(w http.ResponseWriter, r *http.Request) { + var req struct { + ControllerID string `json:"controller_id"` + Slot string `json:"slot"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + ctrlIdx, ok := parseLSIControllerIndex(req.ControllerID) + if !ok { + writeError(w, http.StatusBadRequest, "invalid controller_id") + return + } + if _, _, ok := parseRAIDSlot(req.Slot); !ok { + writeError(w, http.StatusBadRequest, "invalid slot") + return + } + + t := &Task{ + ID: newJobID("raid-lsi-prepare-drive"), + Name: fmt.Sprintf("Prepare drive %s (LSI ctrl %d)", req.Slot, ctrlIdx), + Target: "raid-lsi-prepare-drive", + Priority: defaultTaskPriority("raid-lsi-prepare-drive", taskParams{}), + Status: TaskPending, + CreatedAt: time.Now(), + params: taskParams{RAIDController: ctrlIdx, RAIDSlot: req.Slot}, + } + globalQueue.enqueue(t) + writeJSON(w, map[string]string{"task_id": t.ID}) +} + func parseLSIControllerIndex(id string) (int, bool) { if !strings.HasPrefix(id, "lsi-") { return 0, false @@ -385,6 +462,34 @@ func runRAIDLSICreateMirrorTask(ctx context.Context, j *jobState, ctrl int, driv return streamCmdJob(j, cmd) } +// parseRAIDSlot splits a storcli "EID:Slt" identifier (e.g. "252:0") into +// enclosure and slot numbers. +func parseRAIDSlot(slot string) (eid int, slt int, ok bool) { + parts := strings.SplitN(strings.TrimSpace(slot), ":", 2) + if len(parts) != 2 { + return 0, 0, false + } + eid, err1 := strconv.Atoi(strings.TrimSpace(parts[0])) + slt, err2 := strconv.Atoi(strings.TrimSpace(parts[1])) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return eid, slt, true +} + +func runRAIDPrepareDriveTask(ctx context.Context, j *jobState, ctrl int, slot string) error { + eid, slt, ok := parseRAIDSlot(slot) + if !ok { + return fmt.Errorf("invalid slot %q", slot) + } + j.append(fmt.Sprintf("Preparing drive %s on controller %d (set good, force)...", slot, ctrl)) + cmd := exec.CommandContext(ctx, "storcli64", + fmt.Sprintf("/c%d/e%d/s%d", ctrl, eid, slt), + "set", "good", "force", + ) + return streamCmdJob(j, cmd) +} + func runRAIDVROCCreateMirrorTask(ctx context.Context, j *jobState, devices []string, arrayName string) error { if arrayName == "" { arrayName = "bee-mirror0" @@ -507,6 +612,7 @@ function raidRenderController(c, idx) { html += ''; } + html += raidRenderAllDrives(c, idx); html += raidRenderMirrorSection(c, idx, 'lsi'); } @@ -529,12 +635,71 @@ function raidRenderController(c, idx) { html += ''; } + html += raidRenderAllDrives(c, idx); html += raidRenderMirrorSection(c, idx, 'vroc'); } return html; } +var RAID_READY_STATES = {'UGood': true, 'JBOD': true, 'available': true}; +var RAID_NO_PREPARE_STATES = {'UGood': true, 'JBOD': true, 'Frgn': true, 'Onln': true, 'Msng': true}; + +function raidRenderAllDrives(c, idx) { + var drives = c.all_drives || []; + var isLSI = c.type === 'lsi'; + if (drives.length === 0) { + return '

No drives detected on this controller.

'; + } + var html = '
All Drives on This Controller
'; + html += '' + (isLSI ? '' : '') + ''; + drives.forEach(function(d) { + var ready = !!RAID_READY_STATES[d.state]; + var badgeClass = ready ? 'badge-ok' : 'badge-warn'; + var actionCell = ''; + if (isLSI && !RAID_NO_PREPARE_STATES[d.state]) { + actionCell = ''; + } else if (isLSI) { + actionCell = ''; + } + html += '' + + '' + + '' + + '' + + '' + + actionCell + + ''; + }); + html += '
' + (isLSI ? 'Slot' : 'Device') + 'ModelSizeState
' + escHtml(isLSI ? d.slot : d.device) + '' + escHtml(d.model||'—') + (d.serial ? ' [' + escHtml(d.serial) + ']' : '') + '' + (d.size_gb > 0 ? Math.round(d.size_gb) + ' GB' : '—') + '' + escHtml(d.state||'—') + '
'; + return html; +} + +function raidPrepareDrive(ctrlID, slot, btn) { + if (!confirm('Prepare drive ' + slot + ' on ' + ctrlID + ' for array creation?\n\nThis forces the drive into Unconfigured Good state. If it currently belongs to a virtual drive or holds data, that data will become inaccessible.')) { + return; + } + var original = btn ? btn.textContent : ''; + if (btn) { btn.disabled = true; btn.textContent = 'Preparing...'; } + raidShowOutput('Prepare drive ' + slot, '', ''); + fetch('/api/tools/raid/prepare-drive', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({controller_id: ctrlID, slot: slot}) + }) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.error) throw new Error(d.error); + raidStreamTask(d.task_id, 'Prepare drive ' + slot, function() { + if (btn) { btn.disabled = false; btn.textContent = original; } + raidLoad(); + }); + }) + .catch(function(e) { + raidShowOutput('Error', 'failed', e.message); + if (btn) { btn.disabled = false; btn.textContent = original; } + }); +} + function raidRenderMirrorSection(c, idx, kind) { var free = c.free_drives || []; var html = '
Create RAID 1 Mirror
'; @@ -683,6 +848,9 @@ function raidStreamTask(taskID, taskName, onDone) { } window.raidLoad = raidLoad; +window.raidForeignAction = raidForeignAction; +window.raidCreateMirror = raidCreateMirror; +window.raidPrepareDrive = raidPrepareDrive; raidLoad(); })(); ` diff --git a/audit/internal/webui/server.go b/audit/internal/webui/server.go index 888477f..b163f75 100644 --- a/audit/internal/webui/server.go +++ b/audit/internal/webui/server.go @@ -323,6 +323,7 @@ func NewHandler(opts HandlerOptions) http.Handler { mux.HandleFunc("GET /api/tools/raid/status", h.handleAPIRAIDStatus) 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/prepare-drive", h.handleAPIRAIDPrepareDrive) // GPU presence / tools mux.HandleFunc("GET /api/gpu/presence", h.handleAPIGPUPresence) diff --git a/audit/internal/webui/task_runner.go b/audit/internal/webui/task_runner.go index f2a1848..3347f19 100644 --- a/audit/internal/webui/task_runner.go +++ b/audit/internal/webui/task_runner.go @@ -410,6 +410,12 @@ func executeTaskWithOptions(opts *HandlerOptions, t *Task, j *jobState, ctx cont break } err = runRAIDLSICreateMirrorTask(ctx, j, t.params.RAIDController, t.params.RAIDDevices) + case "raid-lsi-prepare-drive": + if strings.TrimSpace(t.params.RAIDSlot) == "" { + err = fmt.Errorf("no drive slot provided") + break + } + err = runRAIDPrepareDriveTask(ctx, j, t.params.RAIDController, t.params.RAIDSlot) case "raid-vroc-create-mirror": if len(t.params.RAIDDevices) < 2 { err = fmt.Errorf("at least 2 devices required") diff --git a/audit/internal/webui/tasks.go b/audit/internal/webui/tasks.go index 3094e28..dce2020 100644 --- a/audit/internal/webui/tasks.go +++ b/audit/internal/webui/tasks.go @@ -146,6 +146,7 @@ type taskParams struct { RAIDController int `json:"raid_controller,omitempty"` RAIDDevices []string `json:"raid_devices,omitempty"` RAIDArrayName string `json:"raid_array_name,omitempty"` + RAIDSlot string `json:"raid_slot,omitempty"` } type persistedTask struct {