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 {