- nvidia-smi underlines the topo -m header with ANSI CSI codes even when writing to a file; both NVLink matrix parsers failed to find the header and /topo showed "No NVLink-bonded GPU pairs found" on bonded systems. - raid-lsi-create-mirror failed with "resources already in use" (exit 11) on JBOD drives; the task now reads drive states and auto-converts JBOD/UBad (set good force), releases hotspares, refuses Frgn/Onln with actionable messages, and dumps preservedcache info when add vd fails. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1093 lines
38 KiB
Go
1093 lines
38 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 map[string]json.RawMessage `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) {
|
|
ctrl.AllDrives, ctrl.ForeignDrives, ctrl.FreeDrives = classifyStorcliDrives(
|
|
parseStorcliResponseDataDrives(driveDoc.Controllers[i].ResponseData))
|
|
}
|
|
|
|
controllers = append(controllers, ctrl)
|
|
}
|
|
return controllers
|
|
}
|
|
|
|
// storcliDriveJSON is the per-drive record shape storcli64/storcli2 emit,
|
|
// whether nested in a "Drive Information" array or a per-slot "Drive
|
|
// /cX/eY/sZ" key (see parseStorcliResponseDataDrives).
|
|
type storcliDriveJSON struct {
|
|
EIDSlt string `json:"EID:Slt"`
|
|
State string `json:"State"`
|
|
Size string `json:"Size"`
|
|
Model string `json:"Model"`
|
|
SN string `json:"SN"`
|
|
}
|
|
|
|
// storcliDrivePerSlotKeyRe matches "Response Data" keys like "Drive /c0/e69/s0".
|
|
// Real storcli64/storcli2 "eall/sall show all J" output nests each drive under
|
|
// its own dynamically-named key instead of a shared "Drive Information" array
|
|
// — confirmed against a live SAS3808-iMR dump that has no "Drive Information"
|
|
// key at all. Must not match the paired "... - Detailed Information" key.
|
|
var storcliDrivePerSlotKeyRe = regexp.MustCompile(`^Drive /c\d+/e\d+/s\d+$`)
|
|
|
|
// parseStorcliResponseDataDrives extracts drive records from one controller's
|
|
// "Response Data" object, supporting both the legacy "Drive Information"
|
|
// array shape and the per-slot dynamic-key shape.
|
|
func parseStorcliResponseDataDrives(responseData map[string]json.RawMessage) []storcliDriveJSON {
|
|
var drives []storcliDriveJSON
|
|
appendList := func(raw json.RawMessage) {
|
|
var list []storcliDriveJSON
|
|
if err := json.Unmarshal(raw, &list); err != nil {
|
|
return
|
|
}
|
|
drives = append(drives, list...)
|
|
}
|
|
if raw, ok := responseData["Drive Information"]; ok {
|
|
appendList(raw)
|
|
}
|
|
for key, raw := range responseData {
|
|
if storcliDrivePerSlotKeyRe.MatchString(key) {
|
|
appendList(raw)
|
|
}
|
|
}
|
|
return drives
|
|
}
|
|
|
|
func classifyStorcliDrives(drives []storcliDriveJSON) (all, foreign, free []raidDriveInfo) {
|
|
for _, d := range drives {
|
|
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),
|
|
}
|
|
all = append(all, info)
|
|
switch strings.TrimSpace(d.State) {
|
|
case "Frgn":
|
|
foreign = append(foreign, info)
|
|
case "UGood", "JBOD":
|
|
free = append(free, info)
|
|
}
|
|
}
|
|
return all, foreign, free
|
|
}
|
|
|
|
// --- LSI/storcli2 detection (Tri-Mode controllers, e.g. SAS3808-iMR/9500) ---
|
|
//
|
|
// storcli2 is Broadcom's separate tool/JSON schema for the Tri-Mode MegaRAID
|
|
// line; on hardware where it works, storcli64 can still enumerate the same
|
|
// controller at a basic level (hence it may also show up via
|
|
// detectLSIControllers), so this is run as an additional source alongside
|
|
// storcli64/VROC rather than a replacement. On at least one confirmed
|
|
// SAS3808-iMR system, storcli2 itself reports zero controllers even though
|
|
// storcli64 sees the controller and its drives fine — a separate,
|
|
// tool-side gap this webui can't work around beyond reporting nothing here
|
|
// and relying on detectLSIControllers.
|
|
func detectStorcli2Controllers() []raidControllerInfo {
|
|
sysOut, err := exec.Command("storcli2", "show", "all", "J").Output()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
var sysDoc struct {
|
|
Controllers []struct {
|
|
ResponseData struct {
|
|
SystemOverview []struct {
|
|
Ctrl int `json:"Ctrl"`
|
|
ProductName string `json:"Product Name"`
|
|
} `json:"System Overview"`
|
|
} `json:"Response Data"`
|
|
} `json:"Controllers"`
|
|
}
|
|
if err := json.Unmarshal(sysOut, &sysDoc); err != nil || len(sysDoc.Controllers) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var controllers []raidControllerInfo
|
|
for _, entry := range sysDoc.Controllers {
|
|
for _, ov := range entry.ResponseData.SystemOverview {
|
|
ctrl := raidControllerInfo{
|
|
ID: fmt.Sprintf("lsi2-%d", ov.Ctrl),
|
|
Type: "lsi",
|
|
Index: ov.Ctrl,
|
|
Model: strings.TrimSpace(ov.ProductName),
|
|
ForeignDrives: []raidDriveInfo{},
|
|
FreeDrives: []raidDriveInfo{},
|
|
AllDrives: []raidDriveInfo{},
|
|
}
|
|
if ctrl.Model == "" {
|
|
ctrl.Model = fmt.Sprintf("LSI Controller %d", ctrl.Index)
|
|
}
|
|
|
|
driveOut, _ := exec.Command("storcli2", fmt.Sprintf("/c%d/eall/sall", ov.Ctrl), "show", "all", "J").Output()
|
|
ctrl.AllDrives, ctrl.ForeignDrives, ctrl.FreeDrives = parseStorcli2DriveInformation(driveOut)
|
|
|
|
controllers = append(controllers, ctrl)
|
|
}
|
|
}
|
|
return controllers
|
|
}
|
|
|
|
// parseStorcli2DriveInformation parses a single controller's "storcli2
|
|
// /cX/eall/sall show all J" output, which uses the same "Response Data"
|
|
// shape as storcli64 (see parseStorcliResponseDataDrives).
|
|
func parseStorcli2DriveInformation(raw []byte) (all, foreign, free []raidDriveInfo) {
|
|
if len(raw) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
var doc struct {
|
|
Controllers []struct {
|
|
ResponseData map[string]json.RawMessage `json:"Response Data"`
|
|
} `json:"Controllers"`
|
|
}
|
|
if err := json.Unmarshal(raw, &doc); err != nil || len(doc.Controllers) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
return classifyStorcliDrives(parseStorcliResponseDataDrives(doc.Controllers[0].ResponseData))
|
|
}
|
|
|
|
// --- 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{}}
|
|
|
|
lsi2 := detectStorcli2Controllers()
|
|
if lsi := detectLSIControllers(); len(lsi) > 0 {
|
|
// storcli64 can enumerate a Tri-Mode controller (SAS3808-iMR/9500
|
|
// series) at a basic level but its drive-listing JSON parser finds
|
|
// no "Drive Information" for these — a zero-drives entry that
|
|
// storcli2 (run above) already covers correctly. Only drop it when
|
|
// storcli2 actually found something, so a genuinely drive-populated
|
|
// classic controller elsewhere in a mixed setup is never hidden.
|
|
for _, c := range lsi {
|
|
if len(c.AllDrives) == 0 && len(lsi2) > 0 {
|
|
continue
|
|
}
|
|
resp.Controllers = append(resp.Controllers, c)
|
|
}
|
|
}
|
|
if len(lsi2) > 0 {
|
|
resp.Controllers = append(resp.Controllers, lsi2...)
|
|
}
|
|
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)
|
|
}
|
|
|
|
// raidPrepareAction says what (if anything) must be done to a drive in the
|
|
// given storcli state before it can join a new VD. Derived from the Broadcom
|
|
// StorCLI drive-state matrix: only UGood drives are accepted by "add vd";
|
|
// JBOD/UBad convert with "set good force"; hotspares must be released first;
|
|
// Frgn/Onln hold configuration data and must not be silently destroyed.
|
|
type raidPrepareAction int
|
|
|
|
const (
|
|
raidPrepNone raidPrepareAction = iota // UGood or unknown — try add vd as-is
|
|
raidPrepSetGood // JBOD, UBad — "set good force"
|
|
raidPrepHotspare // GHS, DHS — "delete hotsparedrive", then set good
|
|
raidPrepBlockedFrgn
|
|
raidPrepBlockedOnln
|
|
)
|
|
|
|
func classifyRAIDPrepareAction(state string) raidPrepareAction {
|
|
switch strings.TrimSpace(state) {
|
|
case "JBOD", "UBad":
|
|
return raidPrepSetGood
|
|
case "GHS", "DHS":
|
|
return raidPrepHotspare
|
|
case "Frgn":
|
|
return raidPrepBlockedFrgn
|
|
case "Onln", "Offln":
|
|
return raidPrepBlockedOnln
|
|
default: // "UGood", "" (state unknown — let add vd decide)
|
|
return raidPrepNone
|
|
}
|
|
}
|
|
|
|
// raidLSIDriveStates returns EID:Slt -> State for one controller, or nil if
|
|
// storcli/parsing fails (callers then fall back to unconditional prepare).
|
|
func raidLSIDriveStates(ctx context.Context, ctrl int) map[string]string {
|
|
out, err := exec.CommandContext(ctx, "storcli64",
|
|
fmt.Sprintf("/c%d/eall/sall", ctrl), "show", "all", "J").Output()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var doc struct {
|
|
Controllers []struct {
|
|
ResponseData map[string]json.RawMessage `json:"Response Data"`
|
|
} `json:"Controllers"`
|
|
}
|
|
if err := json.Unmarshal(out, &doc); err != nil || len(doc.Controllers) == 0 {
|
|
return nil
|
|
}
|
|
states := map[string]string{}
|
|
for _, c := range doc.Controllers {
|
|
for _, d := range parseStorcliResponseDataDrives(c.ResponseData) {
|
|
states[strings.TrimSpace(d.EIDSlt)] = strings.TrimSpace(d.State)
|
|
}
|
|
}
|
|
return states
|
|
}
|
|
|
|
func runRAIDLSICreateMirrorTask(ctx context.Context, j *jobState, ctrl int, drives []string) error {
|
|
driveList := strings.Join(drives, ",")
|
|
states := raidLSIDriveStates(ctx, ctrl)
|
|
|
|
// Non-UGood drives cannot be added to a VD directly — storcli fails with
|
|
// "resources already in use" (exit 11) or similar. Fix what is safely
|
|
// fixable (JBOD/UBad/hotspare), refuse what holds data (Frgn/Onln).
|
|
for _, drive := range drives {
|
|
eid, slt, ok := parseRAIDSlot(drive)
|
|
if !ok {
|
|
return fmt.Errorf("invalid drive slot %q", drive)
|
|
}
|
|
state := states[drive]
|
|
slotPath := fmt.Sprintf("/c%d/e%d/s%d", ctrl, eid, slt)
|
|
|
|
switch classifyRAIDPrepareAction(state) {
|
|
case raidPrepBlockedFrgn:
|
|
return fmt.Errorf("drive %s carries a foreign configuration; run the RAID Foreign Clear (or Import) task first, then retry", drive)
|
|
case raidPrepBlockedOnln:
|
|
return fmt.Errorf("drive %s is part of an existing virtual drive (state %s); delete that VD first", drive, state)
|
|
case raidPrepHotspare:
|
|
j.append(fmt.Sprintf("Drive %s is a hotspare (%s); releasing it...", drive, state))
|
|
rel := exec.CommandContext(ctx, "storcli64", slotPath, "delete", "hotsparedrive")
|
|
if err := streamCmdJob(j, rel); err != nil {
|
|
return fmt.Errorf("release hotspare %s: %w", drive, err)
|
|
}
|
|
case raidPrepSetGood:
|
|
j.append(fmt.Sprintf("Drive %s is %s; converting to Unconfigured Good (set good force)...", drive, state))
|
|
prep := exec.CommandContext(ctx, "storcli64", slotPath, "set", "good", "force")
|
|
if err := streamCmdJob(j, prep); err != nil {
|
|
return fmt.Errorf("set good on %s: %w", drive, err)
|
|
}
|
|
case raidPrepNone:
|
|
if state == "" {
|
|
// Drive state unknown (storcli query failed) — attempt the
|
|
// conversion anyway; harmless on an already-UGood drive with
|
|
// force, and add vd below is the real verdict.
|
|
j.append(fmt.Sprintf("Preparing drive %s (set good, force)...", drive))
|
|
prep := exec.CommandContext(ctx, "storcli64", slotPath, "set", "good", "force")
|
|
if err := streamCmdJob(j, prep); err != nil {
|
|
j.append(fmt.Sprintf("note: set good on %s: %v (continuing)", drive, err))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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",
|
|
)
|
|
if err := streamCmdJob(j, cmd); err != nil {
|
|
// A blocked add vd is often preserved cache from a dead VD
|
|
// ("controller has data in cache for offline or missing virtual
|
|
// drives"). Surface it so the log is actionable.
|
|
j.append("add vd failed; checking for preserved cache...")
|
|
pc := exec.CommandContext(ctx, "storcli64", fmt.Sprintf("/c%d", ctrl), "show", "preservedcache")
|
|
_ = streamCmdJob(j, pc)
|
|
j.append(fmt.Sprintf("hint: if preserved cache is listed above, clear it with: storcli64 /c%d/vall delete preservedcache (invalidates cached data of dead VDs), then retry", ctrl))
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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>`
|
|
}
|