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 = '| ' + (isLSI ? 'Slot' : 'Device') + ' | Model | Size | State | ' + (isLSI ? '' : '') + ' | '; + } else if (isLSI) { + actionCell = ' | '; + } + html += ' |
|---|---|---|---|---|
| ' + 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||'—') + ' | ' + + actionCell + + '