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 <noreply@anthropic.com>
858 lines
29 KiB
Go
858 lines
29 KiB
Go
package webui
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// --- Response types ---
|
|
|
|
type raidDriveInfo struct {
|
|
Slot string `json:"slot,omitempty"`
|
|
Device string `json:"device,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
SizeGB float64 `json:"size_gb,omitempty"`
|
|
Serial string `json:"serial,omitempty"`
|
|
State string `json:"state,omitempty"`
|
|
}
|
|
|
|
type raidArrayInfo struct {
|
|
Name string `json:"name"`
|
|
Level string `json:"level,omitempty"`
|
|
Members []string `json:"members"`
|
|
Degraded bool `json:"degraded"`
|
|
}
|
|
|
|
type raidControllerInfo struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Index int `json:"index"`
|
|
Model string `json:"model"`
|
|
ForeignDrives []raidDriveInfo `json:"foreign_drives"`
|
|
FreeDrives []raidDriveInfo `json:"free_drives"`
|
|
AllDrives []raidDriveInfo `json:"all_drives"`
|
|
Arrays []raidArrayInfo `json:"arrays,omitempty"`
|
|
}
|
|
|
|
type raidStatusResp struct {
|
|
Controllers []raidControllerInfo `json:"controllers"`
|
|
}
|
|
|
|
// --- LSI/storcli detection ---
|
|
|
|
func detectLSIControllers() []raidControllerInfo {
|
|
ctrlOut, err := exec.Command("storcli64", "/call", "show", "J").Output()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
var ctrlDoc struct {
|
|
Controllers []struct {
|
|
ResponseData struct {
|
|
Basics struct {
|
|
Controller int `json:"Controller"`
|
|
Model string `json:"Model"`
|
|
} `json:"Basics"`
|
|
} `json:"Response Data"`
|
|
} `json:"Controllers"`
|
|
}
|
|
if err := json.Unmarshal(ctrlOut, &ctrlDoc); err != nil || len(ctrlDoc.Controllers) == 0 {
|
|
return nil
|
|
}
|
|
|
|
driveOut, _ := exec.Command("storcli64", "/call/eall/sall", "show", "all", "J").Output()
|
|
|
|
var driveDoc struct {
|
|
Controllers []struct {
|
|
ResponseData struct {
|
|
DriveInformation []struct {
|
|
EIDSlt string `json:"EID:Slt"`
|
|
State string `json:"State"`
|
|
Size string `json:"Size"`
|
|
Intf string `json:"Intf"`
|
|
Med string `json:"Med"`
|
|
Model string `json:"Model"`
|
|
SN string `json:"SN"`
|
|
} `json:"Drive Information"`
|
|
} `json:"Response Data"`
|
|
} `json:"Controllers"`
|
|
}
|
|
if len(driveOut) > 0 {
|
|
json.Unmarshal(driveOut, &driveDoc) //nolint:errcheck
|
|
}
|
|
|
|
var controllers []raidControllerInfo
|
|
for i, c := range ctrlDoc.Controllers {
|
|
ctrl := raidControllerInfo{
|
|
ID: fmt.Sprintf("lsi-%d", c.ResponseData.Basics.Controller),
|
|
Type: "lsi",
|
|
Index: c.ResponseData.Basics.Controller,
|
|
Model: c.ResponseData.Basics.Model,
|
|
ForeignDrives: []raidDriveInfo{},
|
|
FreeDrives: []raidDriveInfo{},
|
|
AllDrives: []raidDriveInfo{},
|
|
}
|
|
if ctrl.Model == "" {
|
|
ctrl.Model = fmt.Sprintf("LSI Controller %d", ctrl.Index)
|
|
}
|
|
|
|
if i < len(driveDoc.Controllers) {
|
|
for _, d := range driveDoc.Controllers[i].ResponseData.DriveInformation {
|
|
info := raidDriveInfo{
|
|
Slot: strings.TrimSpace(d.EIDSlt),
|
|
Model: strings.TrimSpace(d.Model),
|
|
State: strings.TrimSpace(d.State),
|
|
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)
|
|
case "UGood", "JBOD":
|
|
ctrl.FreeDrives = append(ctrl.FreeDrives, info)
|
|
}
|
|
}
|
|
}
|
|
|
|
controllers = append(controllers, ctrl)
|
|
}
|
|
return controllers
|
|
}
|
|
|
|
// --- VROC/mdadm detection ---
|
|
|
|
var raidMDStatDegradedRx = regexp.MustCompile(`\[[U_]+\]`)
|
|
|
|
type mdStatEntry struct {
|
|
Name string
|
|
Level string
|
|
Members []string
|
|
Degraded bool
|
|
}
|
|
|
|
func parseRAIDMDStat(raw string) []mdStatEntry {
|
|
var entries []mdStatEntry
|
|
var cur *mdStatEntry
|
|
for _, line := range strings.Split(raw, "\n") {
|
|
if strings.HasPrefix(line, "Personalities") || strings.HasPrefix(line, "unused devices") {
|
|
continue
|
|
}
|
|
if idx := strings.Index(line, " : "); idx > 0 {
|
|
name := strings.TrimSpace(line[:idx])
|
|
rest := line[idx+3:]
|
|
entry := mdStatEntry{Name: name}
|
|
for _, tok := range strings.Fields(rest) {
|
|
if strings.HasPrefix(tok, "raid") || strings.HasPrefix(tok, "linear") {
|
|
entry.Level = tok
|
|
}
|
|
if bk := strings.Index(tok, "["); bk > 0 && strings.HasSuffix(tok, "]") {
|
|
entry.Members = append(entry.Members, tok[:bk])
|
|
}
|
|
}
|
|
entries = append(entries, entry)
|
|
cur = &entries[len(entries)-1]
|
|
continue
|
|
}
|
|
if cur != nil {
|
|
if m := raidMDStatDegradedRx.FindString(line); m != "" && strings.Contains(m, "_") {
|
|
cur.Degraded = true
|
|
}
|
|
}
|
|
}
|
|
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 {
|
|
return nil
|
|
}
|
|
hasVROC := false
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
lower := strings.ToLower(line)
|
|
if strings.Contains(lower, "license") || strings.Contains(lower, "intel") || strings.Contains(lower, "platform") {
|
|
hasVROC = true
|
|
break
|
|
}
|
|
}
|
|
if !hasVROC {
|
|
return nil
|
|
}
|
|
|
|
ctrl := &raidControllerInfo{
|
|
ID: "vroc-0",
|
|
Type: "vroc",
|
|
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 {
|
|
for _, arr := range parseRAIDMDStat(string(raw)) {
|
|
ctrl.Arrays = append(ctrl.Arrays, raidArrayInfo{
|
|
Name: arr.Name,
|
|
Level: arr.Level,
|
|
Members: arr.Members,
|
|
Degraded: arr.Degraded,
|
|
})
|
|
for _, m := range arr.Members {
|
|
inArray[m] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
lsblkOut, err := exec.Command("lsblk", "-J", "-d", "-o", "NAME,SIZE,TYPE,MODEL,SERIAL").Output()
|
|
if err == nil {
|
|
var lsblkDoc struct {
|
|
BlockDevices []struct {
|
|
Name string `json:"name"`
|
|
Size string `json:"size"`
|
|
Type string `json:"type"`
|
|
Model string `json:"model"`
|
|
Serial string `json:"serial"`
|
|
} `json:"blockdevices"`
|
|
}
|
|
if json.Unmarshal(lsblkOut, &lsblkDoc) == nil {
|
|
for _, d := range lsblkDoc.BlockDevices {
|
|
// 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
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return ctrl
|
|
}
|
|
|
|
// --- API handlers ---
|
|
|
|
func (h *handler) handleAPIRAIDStatus(w http.ResponseWriter, r *http.Request) {
|
|
resp := raidStatusResp{Controllers: []raidControllerInfo{}}
|
|
|
|
if lsi := detectLSIControllers(); len(lsi) > 0 {
|
|
resp.Controllers = append(resp.Controllers, lsi...)
|
|
}
|
|
if vroc := detectVROCController(); vroc != nil {
|
|
resp.Controllers = append(resp.Controllers, *vroc)
|
|
}
|
|
|
|
writeJSON(w, resp)
|
|
}
|
|
|
|
func (h *handler) handleAPIRAIDForeignAction(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
ControllerID string `json:"controller_id"`
|
|
Action string `json:"action"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
if req.Action != "import" && req.Action != "clear" {
|
|
writeError(w, http.StatusBadRequest, "action must be 'import' or 'clear'")
|
|
return
|
|
}
|
|
ctrlIdx, ok := parseLSIControllerIndex(req.ControllerID)
|
|
if !ok {
|
|
writeError(w, http.StatusBadRequest, "invalid controller_id")
|
|
return
|
|
}
|
|
|
|
target := "raid-foreign-clear"
|
|
name := fmt.Sprintf("RAID Foreign Clear (ctrl %d)", ctrlIdx)
|
|
if req.Action == "import" {
|
|
target = "raid-foreign-import"
|
|
name = fmt.Sprintf("RAID Foreign Import (ctrl %d)", ctrlIdx)
|
|
}
|
|
|
|
t := &Task{
|
|
ID: newJobID(target),
|
|
Name: name,
|
|
Target: target,
|
|
Priority: defaultTaskPriority(target, taskParams{}),
|
|
Status: TaskPending,
|
|
CreatedAt: time.Now(),
|
|
params: taskParams{RAIDController: ctrlIdx},
|
|
}
|
|
globalQueue.enqueue(t)
|
|
writeJSON(w, map[string]string{"task_id": t.ID})
|
|
}
|
|
|
|
func (h *handler) handleAPIRAIDCreateMirror(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
ControllerID string `json:"controller_id"`
|
|
Devices []string `json:"devices"`
|
|
ArrayName string `json:"array_name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
if len(req.Devices) < 2 {
|
|
writeError(w, http.StatusBadRequest, "at least 2 devices required")
|
|
return
|
|
}
|
|
|
|
var target, name string
|
|
var params taskParams
|
|
|
|
switch {
|
|
case strings.HasPrefix(req.ControllerID, "lsi-"):
|
|
ctrlIdx, ok := parseLSIControllerIndex(req.ControllerID)
|
|
if !ok {
|
|
writeError(w, http.StatusBadRequest, "invalid controller_id")
|
|
return
|
|
}
|
|
target = "raid-lsi-create-mirror"
|
|
name = fmt.Sprintf("Create RAID 1 Mirror (LSI ctrl %d)", ctrlIdx)
|
|
params = taskParams{RAIDController: ctrlIdx, RAIDDevices: req.Devices}
|
|
|
|
case req.ControllerID == "vroc-0":
|
|
arrayName := strings.TrimSpace(req.ArrayName)
|
|
if arrayName == "" {
|
|
arrayName = "bee-mirror0"
|
|
}
|
|
target = "raid-vroc-create-mirror"
|
|
name = fmt.Sprintf("Create VROC RAID 1 (%s)", arrayName)
|
|
params = taskParams{RAIDDevices: req.Devices, RAIDArrayName: arrayName}
|
|
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "unknown controller_id")
|
|
return
|
|
}
|
|
|
|
t := &Task{
|
|
ID: newJobID(target),
|
|
Name: name,
|
|
Target: target,
|
|
Priority: defaultTaskPriority(target, taskParams{}),
|
|
Status: TaskPending,
|
|
CreatedAt: time.Now(),
|
|
params: params,
|
|
}
|
|
globalQueue.enqueue(t)
|
|
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
|
|
}
|
|
n, err := strconv.Atoi(strings.TrimPrefix(id, "lsi-"))
|
|
if err != nil || n < 0 {
|
|
return 0, false
|
|
}
|
|
return n, true
|
|
}
|
|
|
|
// --- Task runner functions ---
|
|
|
|
func runRAIDForeignClearTask(ctx context.Context, j *jobState, ctrl int) error {
|
|
j.append(fmt.Sprintf("Clearing foreign configuration on controller %d...", ctrl))
|
|
cmd := exec.CommandContext(ctx, "storcli64", fmt.Sprintf("/c%d/fall", ctrl), "del", "noprompt")
|
|
return streamCmdJob(j, cmd)
|
|
}
|
|
|
|
func runRAIDForeignImportTask(ctx context.Context, j *jobState, ctrl int) error {
|
|
j.append(fmt.Sprintf("Importing foreign configuration on controller %d...", ctrl))
|
|
cmd := exec.CommandContext(ctx, "storcli64", fmt.Sprintf("/c%d/fall", ctrl), "import", "noprompt")
|
|
return streamCmdJob(j, cmd)
|
|
}
|
|
|
|
func runRAIDLSICreateMirrorTask(ctx context.Context, j *jobState, ctrl int, drives []string) error {
|
|
driveList := strings.Join(drives, ",")
|
|
j.append(fmt.Sprintf("Creating RAID 1 on controller %d with drives: %s", ctrl, driveList))
|
|
cmd := exec.CommandContext(ctx, "storcli64",
|
|
fmt.Sprintf("/c%d", ctrl),
|
|
"add", "vd", "type=raid1",
|
|
fmt.Sprintf("drives=%s", driveList),
|
|
"pdperarray=2",
|
|
)
|
|
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"
|
|
}
|
|
devPath := "/dev/md/" + arrayName
|
|
args := []string{
|
|
"--create", devPath,
|
|
"--level=1",
|
|
fmt.Sprintf("--raid-devices=%d", len(devices)),
|
|
"--run",
|
|
}
|
|
args = append(args, devices...)
|
|
j.append(fmt.Sprintf("Creating VROC RAID 1 array %s with: %s", devPath, strings.Join(devices, " ")))
|
|
cmd := exec.CommandContext(ctx, "mdadm", args...)
|
|
return streamCmdJob(j, cmd)
|
|
}
|
|
|
|
// raidParseHumanSizeGB parses storcli size strings like "1.818 TB", "745.211 GB".
|
|
func raidParseHumanSizeGB(s string) float64 {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
upper := strings.ToUpper(s)
|
|
var mul float64
|
|
var numStr string
|
|
switch {
|
|
case strings.Contains(upper, " TB"):
|
|
mul = 1024
|
|
numStr = strings.TrimSpace(strings.SplitN(upper, " T", 2)[0])
|
|
case strings.Contains(upper, " GB"):
|
|
mul = 1
|
|
numStr = strings.TrimSpace(strings.SplitN(upper, " G", 2)[0])
|
|
case strings.Contains(upper, " MB"):
|
|
mul = 1.0 / 1024
|
|
numStr = strings.TrimSpace(strings.SplitN(upper, " M", 2)[0])
|
|
default:
|
|
return 0
|
|
}
|
|
v, err := strconv.ParseFloat(numStr, 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return v * mul
|
|
}
|
|
|
|
// --- UI card ---
|
|
|
|
func renderRAIDMgmtCard() string {
|
|
return `<div class="card"><div class="card-head card-head-actions">RAID Controller Management<div class="card-head-buttons"><button class="btn btn-sm btn-secondary" onclick="raidLoad()">↻ Refresh</button></div></div><div class="card-body">
|
|
<div id="raid-status" style="font-size:13px;color:var(--muted);margin-bottom:8px">Loading...</div>
|
|
<div id="raid-content"></div>
|
|
<div id="raid-out-wrap" style="display:none;margin-top:14px">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
|
|
<span id="raid-out-label" style="font-size:12px;font-weight:600;color:var(--muted)">Output</span>
|
|
<span id="raid-out-status" style="font-size:12px"></span>
|
|
</div>
|
|
<div id="raid-terminal" class="terminal" style="max-height:260px;width:100%;box-sizing:border-box"></div>
|
|
</div>
|
|
</div></div>
|
|
<script>
|
|
(function(){
|
|
function escHtml(s) {
|
|
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
var _raidControllers = [];
|
|
|
|
function raidLoad() {
|
|
var status = document.getElementById('raid-status');
|
|
var content = document.getElementById('raid-content');
|
|
status.textContent = 'Detecting RAID controllers...';
|
|
status.style.color = 'var(--muted)';
|
|
content.innerHTML = '';
|
|
fetch('/api/tools/raid/status', {cache:'no-store'})
|
|
.then(function(r) {
|
|
if (!r.ok) return r.json().then(function(e) { throw new Error(e.error || r.statusText); });
|
|
return r.json();
|
|
})
|
|
.then(function(data) {
|
|
_raidControllers = data.controllers || [];
|
|
if (_raidControllers.length === 0) {
|
|
status.textContent = 'No RAID controllers detected.';
|
|
return;
|
|
}
|
|
status.textContent = _raidControllers.length + ' controller(s) detected.';
|
|
content.innerHTML = _raidControllers.map(function(c, i) {
|
|
return raidRenderController(c, i);
|
|
}).join('<hr style="margin:16px 0;border:none;border-top:1px solid var(--border)">');
|
|
})
|
|
.catch(function(e) {
|
|
status.textContent = 'Error: ' + e.message;
|
|
status.style.color = 'var(--crit-fg)';
|
|
});
|
|
}
|
|
|
|
function raidRenderController(c, idx) {
|
|
var html = '';
|
|
var typeLabel = c.type === 'lsi' ? 'LSI / Broadcom' : 'Intel VROC';
|
|
html += '<div style="font-weight:600;font-size:13px;margin-bottom:10px">' + typeLabel + ' — ' + escHtml(c.model) + '</div>';
|
|
|
|
if (c.type === 'lsi') {
|
|
var foreign = c.foreign_drives || [];
|
|
if (foreign.length > 0) {
|
|
html += '<div style="background:var(--warn-bg,rgba(240,192,0,0.1));border:1px solid var(--warn-border,#c8a800);border-radius:4px;padding:10px 12px;margin-bottom:12px">';
|
|
html += '<div style="font-weight:600;font-size:13px;margin-bottom:6px">⚠︎ Foreign Configuration Detected (' + foreign.length + ' drive(s))</div>';
|
|
html += '<table style="margin-bottom:10px"><tr><th>Slot</th><th>Model</th><th>Size</th><th>State</th></tr>';
|
|
foreign.forEach(function(d) {
|
|
html += '<tr>'
|
|
+ '<td style="font-family:monospace">' + escHtml(d.slot) + '</td>'
|
|
+ '<td>' + escHtml(d.model||'—') + '</td>'
|
|
+ '<td>' + (d.size_gb > 0 ? Math.round(d.size_gb) + ' GB' : '—') + '</td>'
|
|
+ '<td><span class="badge badge-warn">' + escHtml(d.state) + '</span></td>'
|
|
+ '</tr>';
|
|
});
|
|
html += '</table>';
|
|
html += '<div style="display:flex;gap:8px;flex-wrap:wrap">';
|
|
html += '<button class="btn btn-sm btn-primary" onclick="raidForeignAction(\'' + escHtml(c.id) + '\',\'import\',this)">Import Foreign Config</button>';
|
|
html += '<button class="btn btn-sm btn-secondary" style="color:var(--crit-fg)" onclick="raidForeignAction(\'' + escHtml(c.id) + '\',\'clear\',this)">Clear Foreign Config</button>';
|
|
html += '</div></div>';
|
|
}
|
|
|
|
html += raidRenderAllDrives(c, idx);
|
|
html += raidRenderMirrorSection(c, idx, 'lsi');
|
|
}
|
|
|
|
if (c.type === 'vroc') {
|
|
var arrays = c.arrays || [];
|
|
if (arrays.length > 0) {
|
|
html += '<div style="font-size:12px;font-weight:600;color:var(--muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:.04em">Active Arrays</div>';
|
|
html += '<table style="margin-bottom:14px"><tr><th>Name</th><th>Level</th><th>Members</th><th>Status</th></tr>';
|
|
arrays.forEach(function(a) {
|
|
var badge = a.degraded
|
|
? '<span class="badge badge-err">Degraded</span>'
|
|
: '<span class="badge badge-ok">OK</span>';
|
|
html += '<tr>'
|
|
+ '<td style="font-family:monospace">' + escHtml(a.name) + '</td>'
|
|
+ '<td>' + escHtml(a.level||'—') + '</td>'
|
|
+ '<td style="font-family:monospace;font-size:12px">' + (a.members||[]).map(escHtml).join(', ') + '</td>'
|
|
+ '<td>' + badge + '</td>'
|
|
+ '</tr>';
|
|
});
|
|
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>';
|
|
|
|
if (free.length < 2) {
|
|
html += '<p style="font-size:13px;color:var(--muted)">No unconfigured drives available (need at least 2).</p>';
|
|
return html;
|
|
}
|
|
|
|
html += '<p style="font-size:13px;color:var(--muted);margin-bottom:8px">Select exactly 2 drives:</p>';
|
|
html += '<div>';
|
|
free.forEach(function(d) {
|
|
var val = kind === 'lsi' ? d.slot : d.device;
|
|
var label = kind === 'lsi'
|
|
? escHtml(d.slot) + (d.model ? ' — ' + escHtml(d.model) : '') + (d.size_gb > 0 ? ' (' + Math.round(d.size_gb) + ' GB)' : '')
|
|
: escHtml(d.device) + (d.model ? ' — ' + escHtml(d.model) : '') + (d.serial ? ' [' + escHtml(d.serial) + ']' : '');
|
|
html += '<label style="display:block;margin-bottom:4px;font-size:13px;cursor:pointer">'
|
|
+ '<input type="checkbox" class="raid-mirror-check-' + idx + '" value="' + escHtml(val) + '"> '
|
|
+ label + '</label>';
|
|
});
|
|
html += '</div>';
|
|
|
|
if (kind === 'vroc') {
|
|
html += '<div style="margin-top:10px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
|
|
+ '<label style="font-size:13px">Array name: <input type="text" id="vroc-arrayname-' + idx + '" value="bee-mirror0" style="font-family:monospace;padding:2px 6px;width:140px"></label>';
|
|
} else {
|
|
html += '<div style="margin-top:10px;display:flex;gap:8px">';
|
|
}
|
|
|
|
html += '<button class="btn btn-sm btn-primary raid-mirror-btn-' + idx + '" onclick="raidCreateMirror(\'' + escHtml(c.id) + '\',' + idx + ',\'' + kind + '\',this)">Create Mirror</button>';
|
|
html += '</div>';
|
|
|
|
return html;
|
|
}
|
|
|
|
function raidForeignAction(ctrlID, action, btn) {
|
|
if (action === 'clear' && !confirm('Clear foreign configuration on ' + ctrlID + '?\n\nThis will DELETE the foreign RAID metadata. Data on those drives may become inaccessible.')) {
|
|
return;
|
|
}
|
|
var original = btn ? btn.textContent : '';
|
|
if (btn) { btn.disabled = true; btn.textContent = action === 'import' ? 'Importing...' : 'Clearing...'; }
|
|
raidShowOutput('RAID foreign ' + action, '', '');
|
|
fetch('/api/tools/raid/foreign', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({controller_id: ctrlID, action: action})
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(d) {
|
|
if (d.error) throw new Error(d.error);
|
|
var actionLabel = action === 'import' ? 'Import foreign config' : 'Clear foreign config';
|
|
raidStreamTask(d.task_id, actionLabel, 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 raidCreateMirror(ctrlID, idx, kind, btn) {
|
|
var checks = document.querySelectorAll('.raid-mirror-check-' + idx + ':checked');
|
|
if (checks.length !== 2) {
|
|
alert('Select exactly 2 drives.');
|
|
return;
|
|
}
|
|
var devices = Array.from(checks).map(function(c) { return c.value; });
|
|
var arrayName = '';
|
|
if (kind === 'vroc') {
|
|
var nameEl = document.getElementById('vroc-arrayname-' + idx);
|
|
arrayName = nameEl ? nameEl.value.trim() : 'bee-mirror0';
|
|
if (!arrayName) arrayName = 'bee-mirror0';
|
|
}
|
|
var original = btn ? btn.textContent : '';
|
|
if (btn) { btn.disabled = true; btn.textContent = 'Creating...'; }
|
|
raidShowOutput('Create RAID 1', '', '');
|
|
fetch('/api/tools/raid/create-mirror', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({controller_id: ctrlID, devices: devices, array_name: arrayName})
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(d) {
|
|
if (d.error) throw new Error(d.error);
|
|
raidStreamTask(d.task_id, 'Create RAID 1 mirror', 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 raidShowOutput(label, status, text) {
|
|
var wrap = document.getElementById('raid-out-wrap');
|
|
var labelEl = document.getElementById('raid-out-label');
|
|
var statusEl = document.getElementById('raid-out-status');
|
|
var term = document.getElementById('raid-terminal');
|
|
wrap.style.display = 'block';
|
|
labelEl.textContent = label;
|
|
if (status === 'ok') {
|
|
statusEl.textContent = '✓ done';
|
|
statusEl.style.color = 'var(--ok-fg)';
|
|
} else if (status === 'failed') {
|
|
statusEl.textContent = '✗ failed';
|
|
statusEl.style.color = 'var(--crit-fg)';
|
|
} else {
|
|
statusEl.textContent = status;
|
|
statusEl.style.color = 'var(--muted)';
|
|
}
|
|
if (text !== undefined) {
|
|
term.textContent = text;
|
|
term.scrollTop = term.scrollHeight;
|
|
}
|
|
}
|
|
|
|
function raidStreamTask(taskID, taskName, onDone) {
|
|
var term = document.getElementById('raid-terminal');
|
|
term.textContent = '';
|
|
raidShowOutput(taskName || 'Running…', 'running…', undefined);
|
|
var es = new EventSource('/api/tasks/' + taskID + '/stream');
|
|
es.onmessage = function(e) {
|
|
term.textContent += e.data + '\n';
|
|
term.scrollTop = term.scrollHeight;
|
|
};
|
|
es.addEventListener('done', function(e) {
|
|
es.close();
|
|
if (!e.data) {
|
|
raidShowOutput(taskName, 'ok', undefined);
|
|
} else {
|
|
raidShowOutput(taskName, 'failed', undefined);
|
|
term.textContent += '\nFailed: ' + e.data;
|
|
term.scrollTop = term.scrollHeight;
|
|
}
|
|
if (onDone) onDone();
|
|
});
|
|
es.onerror = function() {
|
|
es.close();
|
|
raidShowOutput(taskName, 'failed', undefined);
|
|
if (onDone) onDone();
|
|
};
|
|
}
|
|
|
|
window.raidLoad = raidLoad;
|
|
window.raidForeignAction = raidForeignAction;
|
|
window.raidCreateMirror = raidCreateMirror;
|
|
window.raidPrepareDrive = raidPrepareDrive;
|
|
raidLoad();
|
|
})();
|
|
</script>`
|
|
}
|