|
|
|
|
@@ -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 += '</div></div>';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
html += raidRenderAllDrives(c, idx);
|
|
|
|
|
html += raidRenderMirrorSection(c, idx, 'lsi');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -529,12 +635,71 @@ function raidRenderController(c, idx) {
|
|
|
|
|
html += '</table>';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 '<p style="font-size:13px;color:var(--muted);margin-bottom:12px">No drives detected on this controller.</p>';
|
|
|
|
|
}
|
|
|
|
|
var html = '<div style="font-size:12px;font-weight:600;color:var(--muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:.04em">All Drives on This Controller</div>';
|
|
|
|
|
html += '<table style="margin-bottom:14px"><tr><th>' + (isLSI ? 'Slot' : 'Device') + '</th><th>Model</th><th>Size</th><th>State</th>' + (isLSI ? '<th></th>' : '') + '</tr>';
|
|
|
|
|
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 = '<td><button class="btn btn-sm btn-secondary" onclick="raidPrepareDrive(\'' + escHtml(c.id) + '\',\'' + escHtml(d.slot) + '\',this)">Prepare</button></td>';
|
|
|
|
|
} else if (isLSI) {
|
|
|
|
|
actionCell = '<td></td>';
|
|
|
|
|
}
|
|
|
|
|
html += '<tr>'
|
|
|
|
|
+ '<td style="font-family:monospace">' + escHtml(isLSI ? d.slot : d.device) + '</td>'
|
|
|
|
|
+ '<td>' + escHtml(d.model||'—') + (d.serial ? ' [' + escHtml(d.serial) + ']' : '') + '</td>'
|
|
|
|
|
+ '<td>' + (d.size_gb > 0 ? Math.round(d.size_gb) + ' GB' : '—') + '</td>'
|
|
|
|
|
+ '<td><span class="badge ' + badgeClass + '">' + escHtml(d.state||'—') + '</span></td>'
|
|
|
|
|
+ actionCell
|
|
|
|
|
+ '</tr>';
|
|
|
|
|
});
|
|
|
|
|
html += '</table>';
|
|
|
|
|
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 = '<div style="font-size:12px;font-weight:600;color:var(--muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:.04em">Create RAID 1 Mirror</div>';
|
|
|
|
|
@@ -683,6 +848,9 @@ function raidStreamTask(taskID, taskName, onDone) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.raidLoad = raidLoad;
|
|
|
|
|
window.raidForeignAction = raidForeignAction;
|
|
|
|
|
window.raidCreateMirror = raidCreateMirror;
|
|
|
|
|
window.raidPrepareDrive = raidPrepareDrive;
|
|
|
|
|
raidLoad();
|
|
|
|
|
})();
|
|
|
|
|
</script>`
|
|
|
|
|
|