|
|
|
|
@@ -0,0 +1,689 @@
|
|
|
|
|
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"`
|
|
|
|
|
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{},
|
|
|
|
|
}
|
|
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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{},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
if d.Type != "disk" || inArray[d.Name] {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
ctrl.FreeDrives = append(ctrl.FreeDrives, raidDriveInfo{
|
|
|
|
|
Device: "/dev/" + d.Name,
|
|
|
|
|
Model: strings.TrimSpace(d.Model),
|
|
|
|
|
Serial: strings.TrimSpace(d.Serial),
|
|
|
|
|
State: "available",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 += 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 += raidRenderMirrorSection(c, idx, 'vroc');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return html;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
raidLoad();
|
|
|
|
|
})();
|
|
|
|
|
</script>`
|
|
|
|
|
}
|