mirror of
https://github.com/tnb-labs/panel.git
synced 2026-08-31 01:12:17 +08:00
feat: 添加SMART和RAID状态查看
This commit is contained in:
@@ -612,6 +612,9 @@ func (route *Http) Register(r *chi.Mux) {
|
||||
r.Post("/lvm/lv", route.toolboxDisk.CreateLV)
|
||||
r.Delete("/lvm/lv", route.toolboxDisk.RemoveLV)
|
||||
r.Post("/lvm/lv/extend", route.toolboxDisk.ExtendLV)
|
||||
r.Get("/smart/disks", route.toolboxDisk.GetSmartDisks)
|
||||
r.Get("/smart/info", route.toolboxDisk.GetSmartInfo)
|
||||
r.Get("/raid/info", route.toolboxDisk.GetRaidInfo)
|
||||
})
|
||||
|
||||
r.Route("/toolbox_log", func(r chi.Router) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/leonelquinteros/gotext"
|
||||
"github.com/libtnb/chix"
|
||||
@@ -565,3 +566,605 @@ func (s *ToolboxDiskService) DeleteFstab(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
// GetSmartDisks 获取支持 SMART 的磁盘列表
|
||||
func (s *ToolboxDiskService) GetSmartDisks(w http.ResponseWriter, r *http.Request) {
|
||||
// 检查 smartctl 是否安装
|
||||
if _, err := shell.ExecfWithTimeout(5*time.Second, "which smartctl"); err != nil {
|
||||
Success(w, chix.M{
|
||||
"available": false,
|
||||
"message": s.t.Get("smartmontools is not installed, please install it first (e.g., apt install smartmontools or dnf install smartmontools)"),
|
||||
"disks": []any{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取磁盘列表
|
||||
scanOutput, err := shell.ExecfWithTimeout(10*time.Second, "smartctl --scan -j")
|
||||
if err != nil {
|
||||
Success(w, chix.M{
|
||||
"available": true,
|
||||
"message": "",
|
||||
"disks": []any{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var scanData struct {
|
||||
Devices []struct {
|
||||
Name string `json:"name"`
|
||||
InfoName string `json:"info_name"`
|
||||
Type string `json:"type"`
|
||||
Protocol string `json:"protocol"`
|
||||
} `json:"devices"`
|
||||
}
|
||||
if err = json.Unmarshal([]byte(scanOutput), &scanData); err != nil {
|
||||
Error(w, http.StatusInternalServerError, s.t.Get("failed to parse smartctl output: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
type smartDisk struct {
|
||||
Name string `json:"name"`
|
||||
Model string `json:"model"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
disks := make([]smartDisk, 0)
|
||||
for _, dev := range scanData.Devices {
|
||||
// 获取设备名(去掉 /dev/ 前缀)
|
||||
name := strings.TrimPrefix(dev.Name, "/dev/")
|
||||
// 获取 model 信息
|
||||
model, _ := shell.ExecfWithTimeout(5*time.Second, "lsblk -ndo MODEL '/dev/%s' 2>/dev/null", name)
|
||||
disks = append(disks, smartDisk{
|
||||
Name: name,
|
||||
Model: strings.TrimSpace(model),
|
||||
Type: dev.Type,
|
||||
})
|
||||
}
|
||||
|
||||
Success(w, chix.M{
|
||||
"available": true,
|
||||
"message": "",
|
||||
"disks": disks,
|
||||
})
|
||||
}
|
||||
|
||||
// GetSmartInfo 获取指定磁盘的 SMART 详细信息
|
||||
func (s *ToolboxDiskService) GetSmartInfo(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.ToolboxDiskDevice](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// smartctl 在磁盘有预警时返回非零退出码,但仍有有效 JSON 输出
|
||||
output, _ := shell.ExecfWithTimeout(30*time.Second, "smartctl -j -a '/dev/%s'", req.Device)
|
||||
if output == "" {
|
||||
Error(w, http.StatusInternalServerError, s.t.Get("failed to get SMART info for device %s", req.Device))
|
||||
return
|
||||
}
|
||||
|
||||
// 解析为结构化数据
|
||||
var result any
|
||||
if err = json.Unmarshal([]byte(output), &result); err != nil {
|
||||
Error(w, http.StatusInternalServerError, s.t.Get("failed to parse SMART info: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, result)
|
||||
}
|
||||
|
||||
// GetRaidInfo 获取 RAID 阵列状态
|
||||
func (s *ToolboxDiskService) GetRaidInfo(w http.ResponseWriter, r *http.Request) {
|
||||
// 按优先级检测 RAID 类型
|
||||
// 1. 软件 RAID (mdadm)
|
||||
if info := s.detectMdadm(); info != nil {
|
||||
Success(w, info)
|
||||
return
|
||||
}
|
||||
// 2. MegaRAID (LSI/Broadcom)
|
||||
if info := s.detectMegaRAID(); info != nil {
|
||||
Success(w, info)
|
||||
return
|
||||
}
|
||||
// 3. HP Smart Array
|
||||
if info := s.detectHPSA(); info != nil {
|
||||
Success(w, info)
|
||||
return
|
||||
}
|
||||
// 4. Adaptec
|
||||
if info := s.detectAdaptec(); info != nil {
|
||||
Success(w, info)
|
||||
return
|
||||
}
|
||||
|
||||
// 未检测到任何 RAID
|
||||
Success(w, chix.M{
|
||||
"available": false,
|
||||
"message": s.t.Get("no RAID configuration detected"),
|
||||
"type": "",
|
||||
"controllers": []any{},
|
||||
"arrays": []any{},
|
||||
})
|
||||
}
|
||||
|
||||
// raidArray RAID 阵列信息
|
||||
type raidArray struct {
|
||||
Name string `json:"name"`
|
||||
RaidLevel string `json:"raid_level"`
|
||||
Size string `json:"size"`
|
||||
State string `json:"state"`
|
||||
StripSize string `json:"strip_size"`
|
||||
ActiveDevices int `json:"active_devices"`
|
||||
TotalDevices int `json:"total_devices"`
|
||||
RebuildPct string `json:"rebuild_pct,omitempty"`
|
||||
Devices []raidDevice `json:"devices"`
|
||||
}
|
||||
|
||||
// raidDevice RAID 物理磁盘信息
|
||||
type raidDevice struct {
|
||||
Name string `json:"name"`
|
||||
Slot string `json:"slot"`
|
||||
Size string `json:"size"`
|
||||
State string `json:"state"`
|
||||
Model string `json:"model"`
|
||||
Serial string `json:"serial"`
|
||||
}
|
||||
|
||||
// raidController RAID 控制器信息
|
||||
type raidController struct {
|
||||
Model string `json:"model"`
|
||||
Serial string `json:"serial"`
|
||||
Firmware string `json:"firmware"`
|
||||
Cache string `json:"cache_size"`
|
||||
}
|
||||
|
||||
// detectMdadm 检测软件 RAID (mdadm)
|
||||
func (s *ToolboxDiskService) detectMdadm() chix.M {
|
||||
mdstat, err := shell.ExecfWithTimeout(5*time.Second, "cat /proc/mdstat 2>/dev/null")
|
||||
if err != nil || !strings.Contains(mdstat, " : ") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 获取 md 设备列表
|
||||
var mdDevices []string
|
||||
for _, line := range strings.Split(mdstat, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.Contains(line, " : ") {
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
if len(parts) > 0 {
|
||||
mdDevices = append(mdDevices, parts[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(mdDevices) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var arrays []raidArray
|
||||
for _, md := range mdDevices {
|
||||
detail, _ := shell.ExecfWithTimeout(10*time.Second, "mdadm --detail '/dev/%s' 2>/dev/null", md)
|
||||
if detail == "" {
|
||||
continue
|
||||
}
|
||||
arrays = append(arrays, s.parseMdadm(md, detail))
|
||||
}
|
||||
|
||||
return chix.M{
|
||||
"available": true,
|
||||
"message": "",
|
||||
"type": "mdadm",
|
||||
"controllers": []any{},
|
||||
"arrays": arrays,
|
||||
}
|
||||
}
|
||||
|
||||
// parseMdadm 解析 mdadm --detail 输出
|
||||
func (s *ToolboxDiskService) parseMdadm(name, detail string) raidArray {
|
||||
arr := raidArray{Name: name}
|
||||
|
||||
for _, line := range strings.Split(detail, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Raid Level :") {
|
||||
arr.RaidLevel = strings.TrimSpace(strings.TrimPrefix(line, "Raid Level :"))
|
||||
} else if strings.HasPrefix(line, "Array Size :") {
|
||||
arr.Size = strings.TrimSpace(strings.TrimPrefix(line, "Array Size :"))
|
||||
} else if strings.HasPrefix(line, "State :") {
|
||||
arr.State = strings.TrimSpace(strings.TrimPrefix(line, "State :"))
|
||||
} else if strings.HasPrefix(line, "Active Devices :") {
|
||||
fmt.Sscanf(strings.TrimPrefix(line, "Active Devices :"), "%d", &arr.ActiveDevices)
|
||||
} else if strings.HasPrefix(line, "Total Devices :") {
|
||||
fmt.Sscanf(strings.TrimPrefix(line, "Total Devices :"), "%d", &arr.TotalDevices)
|
||||
} else if strings.HasPrefix(line, "Chunk Size :") {
|
||||
arr.StripSize = strings.TrimSpace(strings.TrimPrefix(line, "Chunk Size :"))
|
||||
} else if strings.HasPrefix(line, "Rebuild Status :") {
|
||||
arr.RebuildPct = strings.TrimSpace(strings.TrimPrefix(line, "Rebuild Status :"))
|
||||
}
|
||||
}
|
||||
|
||||
// 解析磁盘列表(在 Number Major Minor RaidDevice State 之后的行)
|
||||
inDevSection := false
|
||||
for _, line := range strings.Split(detail, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Number") && strings.Contains(line, "RaidDevice") {
|
||||
inDevSection = true
|
||||
continue
|
||||
}
|
||||
if !inDevSection {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 7 {
|
||||
state := fields[4]
|
||||
// 有时状态由多个词组成(如 "active sync")
|
||||
if len(fields) >= 8 && (fields[4] == "active" || fields[4] == "spare") {
|
||||
state = fields[4] + " " + fields[5]
|
||||
// 设备路径在最后一个字段
|
||||
arr.Devices = append(arr.Devices, raidDevice{
|
||||
Name: fields[len(fields)-1],
|
||||
Slot: fields[3],
|
||||
State: state,
|
||||
})
|
||||
} else {
|
||||
arr.Devices = append(arr.Devices, raidDevice{
|
||||
Name: fields[len(fields)-1],
|
||||
Slot: fields[3],
|
||||
State: state,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return arr
|
||||
}
|
||||
|
||||
// detectMegaRAID 检测 MegaRAID (LSI/Broadcom)
|
||||
func (s *ToolboxDiskService) detectMegaRAID() chix.M {
|
||||
// 检测 storcli64 或 storcli
|
||||
storcli := ""
|
||||
if _, err := shell.ExecfWithTimeout(5*time.Second, "which storcli64"); err == nil {
|
||||
storcli = "storcli64"
|
||||
} else if _, err := shell.ExecfWithTimeout(5*time.Second, "which storcli"); err == nil {
|
||||
storcli = "storcli"
|
||||
}
|
||||
if storcli == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
output, err := shell.ExecfWithTimeout(30*time.Second, "%s /cALL show all J", storcli)
|
||||
if err != nil || output == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
controllers, arrays := s.parseMegaRAID(output)
|
||||
if len(controllers) == 0 && len(arrays) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return chix.M{
|
||||
"available": true,
|
||||
"message": "",
|
||||
"type": "megaraid",
|
||||
"controllers": controllers,
|
||||
"arrays": arrays,
|
||||
}
|
||||
}
|
||||
|
||||
// parseMegaRAID 解析 storcli JSON 输出
|
||||
func (s *ToolboxDiskService) parseMegaRAID(output string) ([]raidController, []raidArray) {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(output), &data); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var controllers []raidController
|
||||
var arrays []raidArray
|
||||
|
||||
// storcli JSON 结构: Controllers[].Response Data
|
||||
ctrlList, _ := data["Controllers"].([]any)
|
||||
for _, ctrl := range ctrlList {
|
||||
ctrlMap, _ := ctrl.(map[string]any)
|
||||
respData, _ := ctrlMap["Response Data"].(map[string]any)
|
||||
if respData == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 控制器基本信息
|
||||
if basics, ok := respData["Basics"].(map[string]any); ok {
|
||||
controllers = append(controllers, raidController{
|
||||
Model: fmt.Sprintf("%v", basics["Model"]),
|
||||
Serial: fmt.Sprintf("%v", basics["Serial Number"]),
|
||||
Firmware: fmt.Sprintf("%v", basics["FW Package Build"]),
|
||||
})
|
||||
}
|
||||
|
||||
// 虚拟磁盘(阵列)
|
||||
if vdList, ok := respData["VD LIST"].([]any); ok {
|
||||
for _, vd := range vdList {
|
||||
vdMap, _ := vd.(map[string]any)
|
||||
if vdMap == nil {
|
||||
continue
|
||||
}
|
||||
arr := raidArray{
|
||||
Name: fmt.Sprintf("%v", vdMap["DG/VD"]),
|
||||
RaidLevel: fmt.Sprintf("%v", vdMap["TYPE"]),
|
||||
Size: fmt.Sprintf("%v", vdMap["Size"]),
|
||||
State: fmt.Sprintf("%v", vdMap["State"]),
|
||||
}
|
||||
arrays = append(arrays, arr)
|
||||
}
|
||||
}
|
||||
|
||||
// 物理磁盘
|
||||
if pdList, ok := respData["PD LIST"].([]any); ok {
|
||||
for _, pd := range pdList {
|
||||
pdMap, _ := pd.(map[string]any)
|
||||
if pdMap == nil {
|
||||
continue
|
||||
}
|
||||
dev := raidDevice{
|
||||
Slot: fmt.Sprintf("%v", pdMap["EID:Slt"]),
|
||||
Size: fmt.Sprintf("%v", pdMap["Size"]),
|
||||
State: fmt.Sprintf("%v", pdMap["State"]),
|
||||
Model: fmt.Sprintf("%v", pdMap["Model"]),
|
||||
Serial: fmt.Sprintf("%v", pdMap["SN"]),
|
||||
}
|
||||
// 将物理磁盘分配到对应的阵列
|
||||
if dgStr, ok := pdMap["DG"].(float64); ok && int(dgStr) < len(arrays) {
|
||||
arrays[int(dgStr)].Devices = append(arrays[int(dgStr)].Devices, dev)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return controllers, arrays
|
||||
}
|
||||
|
||||
// detectHPSA 检测 HP Smart Array
|
||||
func (s *ToolboxDiskService) detectHPSA() chix.M {
|
||||
// 检测 ssacli 或 hpssacli
|
||||
ssacli := ""
|
||||
if _, err := shell.ExecfWithTimeout(5*time.Second, "which ssacli"); err == nil {
|
||||
ssacli = "ssacli"
|
||||
} else if _, err := shell.ExecfWithTimeout(5*time.Second, "which hpssacli"); err == nil {
|
||||
ssacli = "hpssacli"
|
||||
}
|
||||
if ssacli == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
output, err := shell.ExecfWithTimeout(30*time.Second, "%s ctrl all show config detail", ssacli)
|
||||
if err != nil || output == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
controllers, arrays := s.parseHPSA(output)
|
||||
if len(controllers) == 0 && len(arrays) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return chix.M{
|
||||
"available": true,
|
||||
"message": "",
|
||||
"type": "hpsa",
|
||||
"controllers": controllers,
|
||||
"arrays": arrays,
|
||||
}
|
||||
}
|
||||
|
||||
// parseHPSA 解析 ssacli 文本输出
|
||||
func (s *ToolboxDiskService) parseHPSA(output string) ([]raidController, []raidArray) {
|
||||
var controllers []raidController
|
||||
var arrays []raidArray
|
||||
|
||||
var currentCtrl *raidController
|
||||
var currentArray *raidArray
|
||||
var currentDev *raidDevice
|
||||
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// 控制器
|
||||
if strings.Contains(trimmed, "Smart Array") || strings.Contains(trimmed, "Smart HBA") {
|
||||
if currentCtrl != nil {
|
||||
controllers = append(controllers, *currentCtrl)
|
||||
}
|
||||
currentCtrl = &raidController{Model: trimmed}
|
||||
}
|
||||
|
||||
if currentCtrl != nil {
|
||||
if strings.HasPrefix(trimmed, "Serial Number:") {
|
||||
currentCtrl.Serial = strings.TrimSpace(strings.TrimPrefix(trimmed, "Serial Number:"))
|
||||
} else if strings.HasPrefix(trimmed, "Firmware Version:") {
|
||||
currentCtrl.Firmware = strings.TrimSpace(strings.TrimPrefix(trimmed, "Firmware Version:"))
|
||||
} else if strings.HasPrefix(trimmed, "Cache Board Present:") || strings.HasPrefix(trimmed, "Total Cache Size:") {
|
||||
currentCtrl.Cache = strings.TrimSpace(strings.SplitN(trimmed, ":", 2)[1])
|
||||
}
|
||||
}
|
||||
|
||||
// 阵列
|
||||
if strings.HasPrefix(trimmed, "Array:") || (strings.HasPrefix(trimmed, "array") && strings.Contains(trimmed, "Array")) {
|
||||
if currentArray != nil {
|
||||
if currentDev != nil {
|
||||
currentArray.Devices = append(currentArray.Devices, *currentDev)
|
||||
currentDev = nil
|
||||
}
|
||||
arrays = append(arrays, *currentArray)
|
||||
}
|
||||
currentArray = &raidArray{Name: trimmed}
|
||||
}
|
||||
|
||||
if currentArray != nil {
|
||||
if strings.HasPrefix(trimmed, "Fault Tolerance:") {
|
||||
currentArray.RaidLevel = strings.TrimSpace(strings.TrimPrefix(trimmed, "Fault Tolerance:"))
|
||||
} else if strings.HasPrefix(trimmed, "Size:") {
|
||||
currentArray.Size = strings.TrimSpace(strings.TrimPrefix(trimmed, "Size:"))
|
||||
} else if strings.HasPrefix(trimmed, "Status:") {
|
||||
currentArray.State = strings.TrimSpace(strings.TrimPrefix(trimmed, "Status:"))
|
||||
} else if strings.HasPrefix(trimmed, "Strip Size:") {
|
||||
currentArray.StripSize = strings.TrimSpace(strings.TrimPrefix(trimmed, "Strip Size:"))
|
||||
}
|
||||
|
||||
// 物理磁盘
|
||||
if strings.HasPrefix(trimmed, "physicaldrive") {
|
||||
if currentDev != nil {
|
||||
currentArray.Devices = append(currentArray.Devices, *currentDev)
|
||||
}
|
||||
currentDev = &raidDevice{Name: trimmed}
|
||||
}
|
||||
if currentDev != nil {
|
||||
if strings.HasPrefix(trimmed, "Port:") || strings.HasPrefix(trimmed, "Bay:") {
|
||||
currentDev.Slot = strings.TrimSpace(strings.SplitN(trimmed, ":", 2)[1])
|
||||
} else if strings.HasPrefix(trimmed, "Size:") {
|
||||
currentDev.Size = strings.TrimSpace(strings.TrimPrefix(trimmed, "Size:"))
|
||||
} else if strings.HasPrefix(trimmed, "Status:") {
|
||||
currentDev.State = strings.TrimSpace(strings.TrimPrefix(trimmed, "Status:"))
|
||||
} else if strings.HasPrefix(trimmed, "Model:") {
|
||||
currentDev.Model = strings.TrimSpace(strings.TrimPrefix(trimmed, "Model:"))
|
||||
} else if strings.HasPrefix(trimmed, "Serial Number:") {
|
||||
currentDev.Serial = strings.TrimSpace(strings.TrimPrefix(trimmed, "Serial Number:"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 收尾
|
||||
if currentDev != nil && currentArray != nil {
|
||||
currentArray.Devices = append(currentArray.Devices, *currentDev)
|
||||
}
|
||||
if currentArray != nil {
|
||||
arrays = append(arrays, *currentArray)
|
||||
}
|
||||
if currentCtrl != nil {
|
||||
controllers = append(controllers, *currentCtrl)
|
||||
}
|
||||
|
||||
return controllers, arrays
|
||||
}
|
||||
|
||||
// detectAdaptec 检测 Adaptec RAID
|
||||
func (s *ToolboxDiskService) detectAdaptec() chix.M {
|
||||
if _, err := shell.ExecfWithTimeout(5*time.Second, "which arcconf"); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
output, err := shell.ExecfWithTimeout(30*time.Second, "arcconf GETCONFIG 1")
|
||||
if err != nil || output == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
controllers, arrays := s.parseAdaptec(output)
|
||||
if len(controllers) == 0 && len(arrays) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return chix.M{
|
||||
"available": true,
|
||||
"message": "",
|
||||
"type": "adaptec",
|
||||
"controllers": controllers,
|
||||
"arrays": arrays,
|
||||
}
|
||||
}
|
||||
|
||||
// parseAdaptec 解析 arcconf GETCONFIG 输出
|
||||
func (s *ToolboxDiskService) parseAdaptec(output string) ([]raidController, []raidArray) {
|
||||
var controllers []raidController
|
||||
var arrays []raidArray
|
||||
|
||||
var currentCtrl *raidController
|
||||
var currentArray *raidArray
|
||||
var currentDev *raidDevice
|
||||
inLogicalDev := false
|
||||
inPhysicalDev := false
|
||||
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// 控制器信息
|
||||
if strings.Contains(trimmed, "Controller Model") {
|
||||
val := s.extractAdaptecValue(trimmed)
|
||||
currentCtrl = &raidController{Model: val}
|
||||
}
|
||||
if currentCtrl != nil {
|
||||
if strings.Contains(trimmed, "Controller Serial Number") {
|
||||
currentCtrl.Serial = s.extractAdaptecValue(trimmed)
|
||||
} else if strings.Contains(trimmed, "Firmware") && strings.Contains(trimmed, "Version") {
|
||||
currentCtrl.Firmware = s.extractAdaptecValue(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
// 逻辑设备段
|
||||
if strings.HasPrefix(trimmed, "Logical Device number") || strings.HasPrefix(trimmed, "Logical device number") {
|
||||
if currentArray != nil {
|
||||
if currentDev != nil {
|
||||
currentArray.Devices = append(currentArray.Devices, *currentDev)
|
||||
currentDev = nil
|
||||
}
|
||||
arrays = append(arrays, *currentArray)
|
||||
}
|
||||
currentArray = &raidArray{Name: trimmed}
|
||||
inLogicalDev = true
|
||||
inPhysicalDev = false
|
||||
continue
|
||||
}
|
||||
|
||||
if inLogicalDev && currentArray != nil {
|
||||
if strings.HasPrefix(trimmed, "RAID level") {
|
||||
currentArray.RaidLevel = s.extractAdaptecValue(trimmed)
|
||||
} else if strings.HasPrefix(trimmed, "Size") {
|
||||
currentArray.Size = s.extractAdaptecValue(trimmed)
|
||||
} else if strings.HasPrefix(trimmed, "Status of Logical Device") || strings.HasPrefix(trimmed, "Status of logical device") {
|
||||
currentArray.State = s.extractAdaptecValue(trimmed)
|
||||
} else if strings.HasPrefix(trimmed, "Stripe-size") || strings.HasPrefix(trimmed, "Strip Size") {
|
||||
currentArray.StripSize = s.extractAdaptecValue(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
// 物理设备段
|
||||
if strings.Contains(trimmed, "Device #") || strings.HasPrefix(trimmed, "Physical Device") {
|
||||
if currentDev != nil && currentArray != nil {
|
||||
currentArray.Devices = append(currentArray.Devices, *currentDev)
|
||||
}
|
||||
currentDev = &raidDevice{Name: trimmed}
|
||||
inPhysicalDev = true
|
||||
}
|
||||
|
||||
if inPhysicalDev && currentDev != nil {
|
||||
if strings.HasPrefix(trimmed, "State") {
|
||||
currentDev.State = s.extractAdaptecValue(trimmed)
|
||||
} else if strings.HasPrefix(trimmed, "Size") {
|
||||
currentDev.Size = s.extractAdaptecValue(trimmed)
|
||||
} else if strings.HasPrefix(trimmed, "Model") {
|
||||
currentDev.Model = s.extractAdaptecValue(trimmed)
|
||||
} else if strings.HasPrefix(trimmed, "Serial number") || strings.HasPrefix(trimmed, "Serial Number") {
|
||||
currentDev.Serial = s.extractAdaptecValue(trimmed)
|
||||
} else if strings.HasPrefix(trimmed, "Reported Channel,Device") {
|
||||
currentDev.Slot = s.extractAdaptecValue(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 收尾
|
||||
if currentDev != nil && currentArray != nil {
|
||||
currentArray.Devices = append(currentArray.Devices, *currentDev)
|
||||
}
|
||||
if currentArray != nil {
|
||||
arrays = append(arrays, *currentArray)
|
||||
}
|
||||
if currentCtrl != nil {
|
||||
controllers = append(controllers, *currentCtrl)
|
||||
}
|
||||
|
||||
return controllers, arrays
|
||||
}
|
||||
|
||||
// extractAdaptecValue 从 "Key : Value" 格式中提取值
|
||||
func (s *ToolboxDiskService) extractAdaptecValue(line string) string {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
@@ -42,5 +42,12 @@ export default {
|
||||
removeLV: (path: string): any => http.Delete('/toolbox_disk/lvm/lv', { path }),
|
||||
// 扩容逻辑卷
|
||||
extendLV: (path: string, size: number, resize: boolean): any =>
|
||||
http.Post('/toolbox_disk/lvm/lv/extend', { path, size, resize })
|
||||
http.Post('/toolbox_disk/lvm/lv/extend', { path, size, resize }),
|
||||
// 获取 SMART 磁盘列表
|
||||
smartDisks: (): any => http.Get('/toolbox_disk/smart/disks'),
|
||||
// 获取 SMART 详细信息
|
||||
smartInfo: (device: string): any =>
|
||||
http.Get('/toolbox_disk/smart/info', { params: { device } }),
|
||||
// 获取 RAID 状态
|
||||
raidInfo: (): any => http.Get('/toolbox_disk/raid/info')
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ import { useGettext } from 'vue3-gettext'
|
||||
import disk from '@/api/panel/toolbox-disk'
|
||||
import { formatBytes } from '@/utils'
|
||||
|
||||
import SmartView from './disk/SmartView.vue'
|
||||
import RaidView from './disk/RaidView.vue'
|
||||
|
||||
// lsblk JSON 输出的数据结构
|
||||
interface BlockDevice {
|
||||
name: string
|
||||
@@ -1010,6 +1013,16 @@ const handleDeleteFstab = (mountPoint: string) => {
|
||||
</n-card>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- SMART 状态标签页 -->
|
||||
<n-tab-pane name="smart" tab="SMART">
|
||||
<smart-view />
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- RAID 状态标签页 -->
|
||||
<n-tab-pane name="raid" tab="RAID">
|
||||
<raid-view />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { useRequest } from 'alova/client'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { NTag } from 'naive-ui'
|
||||
import { h } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import disk from '@/api/panel/toolbox-disk'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
interface RaidDevice {
|
||||
name: string
|
||||
slot: string
|
||||
size: string
|
||||
state: string
|
||||
model: string
|
||||
serial: string
|
||||
}
|
||||
|
||||
interface RaidArray {
|
||||
name: string
|
||||
raid_level: string
|
||||
size: string
|
||||
state: string
|
||||
strip_size: string
|
||||
active_devices: number
|
||||
total_devices: number
|
||||
rebuild_pct: string
|
||||
devices: RaidDevice[]
|
||||
}
|
||||
|
||||
interface RaidController {
|
||||
model: string
|
||||
serial: string
|
||||
firmware: string
|
||||
cache_size: string
|
||||
}
|
||||
|
||||
const available = ref(false)
|
||||
const unavailableMessage = ref('')
|
||||
const raidType = ref('')
|
||||
const controllers = ref<RaidController[]>([])
|
||||
const arrays = ref<RaidArray[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
const loadRaidInfo = () => {
|
||||
loading.value = true
|
||||
useRequest(disk.raidInfo()).onSuccess(({ data }) => {
|
||||
loading.value = false
|
||||
available.value = data.available
|
||||
unavailableMessage.value = data.message || ''
|
||||
raidType.value = data.type || ''
|
||||
controllers.value = data.controllers || []
|
||||
arrays.value = data.arrays || []
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRaidInfo()
|
||||
})
|
||||
|
||||
// 阵列状态颜色
|
||||
const getStateType = (state: string): 'success' | 'warning' | 'error' | 'info' => {
|
||||
if (!state) return 'info'
|
||||
const s = state.toLowerCase()
|
||||
if (s.includes('clean') || s.includes('active') || s.includes('optimal') || s === 'ok') {
|
||||
return 'success'
|
||||
}
|
||||
if (s.includes('degrad') || s.includes('rebuild') || s.includes('recover')) {
|
||||
return 'warning'
|
||||
}
|
||||
if (s.includes('fail') || s.includes('offline') || s.includes('error')) {
|
||||
return 'error'
|
||||
}
|
||||
return 'info'
|
||||
}
|
||||
|
||||
// RAID 类型标签
|
||||
const raidTypeLabel = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
mdadm: 'Linux Software RAID (mdadm)',
|
||||
megaraid: 'MegaRAID (LSI/Broadcom)',
|
||||
hpsa: 'HP Smart Array',
|
||||
adaptec: 'Adaptec'
|
||||
}
|
||||
return labels[raidType.value] || raidType.value
|
||||
})
|
||||
|
||||
// 物理磁盘表格列
|
||||
const deviceColumns = computed<DataTableColumns<RaidDevice>>(() => {
|
||||
const cols: DataTableColumns<RaidDevice> = [
|
||||
{ title: $gettext('Device'), key: 'name', width: 160 },
|
||||
{ title: $gettext('Slot'), key: 'slot', width: 80 },
|
||||
{ title: $gettext('Size'), key: 'size', width: 120 },
|
||||
{
|
||||
title: $gettext('Status'),
|
||||
key: 'state',
|
||||
width: 120,
|
||||
render(row) {
|
||||
return h(
|
||||
NTag,
|
||||
{ type: getStateType(row.state), size: 'small' },
|
||||
{ default: () => row.state || '-' }
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
// MegaRAID / HPSA / Adaptec 有 model 和 serial
|
||||
if (raidType.value !== 'mdadm') {
|
||||
cols.push({ title: $gettext('Model'), key: 'model', width: 160 })
|
||||
cols.push({ title: $gettext('Serial'), key: 'serial', width: 160 })
|
||||
}
|
||||
return cols
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-spin :show="loading">
|
||||
<!-- 不可用时 -->
|
||||
<n-result
|
||||
v-if="!loading && !available"
|
||||
status="info"
|
||||
:title="$gettext('No RAID Detected')"
|
||||
:description="unavailableMessage"
|
||||
/>
|
||||
|
||||
<!-- 可用时 -->
|
||||
<n-flex v-if="!loading && available" vertical :size="16">
|
||||
<!-- RAID 类型标识 -->
|
||||
<n-flex align="center" :size="8">
|
||||
<span style="font-weight: 500">{{ $gettext('RAID Type') }}:</span>
|
||||
<n-tag type="info">{{ raidTypeLabel }}</n-tag>
|
||||
</n-flex>
|
||||
|
||||
<!-- 控制器信息 -->
|
||||
<n-card
|
||||
v-for="(ctrl, i) in controllers"
|
||||
:key="i"
|
||||
:title="$gettext('Controller') + ` #${i + 1}`"
|
||||
size="small"
|
||||
>
|
||||
<n-descriptions bordered :column="2" label-placement="left" size="small">
|
||||
<n-descriptions-item v-if="ctrl.model" :label="$gettext('Model')">
|
||||
{{ ctrl.model }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item v-if="ctrl.serial" :label="$gettext('Serial Number')">
|
||||
{{ ctrl.serial }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item v-if="ctrl.firmware" :label="$gettext('Firmware')">
|
||||
{{ ctrl.firmware }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item v-if="ctrl.cache_size" :label="$gettext('Cache Size')">
|
||||
{{ ctrl.cache_size }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
|
||||
<!-- 阵列信息 -->
|
||||
<n-card v-for="(arr, i) in arrays" :key="'arr-' + i" size="small">
|
||||
<template #header>
|
||||
<n-flex align="center" :size="8">
|
||||
<span style="font-weight: 600">{{ arr.name }}</span>
|
||||
<n-tag :type="getStateType(arr.state)" size="small">{{ arr.state || '-' }}</n-tag>
|
||||
</n-flex>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-flex align="center" :size="16">
|
||||
<span v-if="arr.raid_level">{{ arr.raid_level }}</span>
|
||||
<span v-if="arr.size">{{ arr.size }}</span>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<n-flex vertical :size="12">
|
||||
<n-descriptions bordered :column="3" label-placement="left" size="small">
|
||||
<n-descriptions-item v-if="arr.raid_level" :label="$gettext('RAID Level')">
|
||||
{{ arr.raid_level }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item v-if="arr.size" :label="$gettext('Size')">
|
||||
{{ arr.size }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item v-if="arr.strip_size" :label="$gettext('Strip Size')">
|
||||
{{ arr.strip_size }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item
|
||||
v-if="arr.active_devices || arr.total_devices"
|
||||
:label="$gettext('Devices')"
|
||||
>
|
||||
{{ arr.active_devices }} / {{ arr.total_devices }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item v-if="arr.rebuild_pct" :label="$gettext('Rebuild Progress')">
|
||||
<n-tag type="warning" size="small">{{ arr.rebuild_pct }}</n-tag>
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<!-- 物理磁盘列表 -->
|
||||
<n-data-table
|
||||
v-if="arr.devices && arr.devices.length > 0"
|
||||
:columns="deviceColumns"
|
||||
:data="arr.devices"
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
size="small"
|
||||
:row-key="(row: RaidDevice) => row.name + row.slot"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
|
||||
<n-empty v-if="arrays.length === 0" :description="$gettext('No RAID arrays found')" />
|
||||
</n-flex>
|
||||
</n-spin>
|
||||
</template>
|
||||
@@ -0,0 +1,346 @@
|
||||
<script setup lang="ts">
|
||||
import { useRequest } from 'alova/client'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { NTag } from 'naive-ui'
|
||||
import { h } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import disk from '@/api/panel/toolbox-disk'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
// SMART 磁盘列表
|
||||
interface SmartDisk {
|
||||
name: string
|
||||
model: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const available = ref(false)
|
||||
const unavailableMessage = ref('')
|
||||
const diskOptions = ref<{ label: string; value: string }[]>([])
|
||||
const selectedDisk = ref('')
|
||||
const smartData = ref<any>(null)
|
||||
const loadingDisks = ref(true)
|
||||
const loadingInfo = ref(false)
|
||||
|
||||
// 加载 SMART 磁盘列表
|
||||
const loadSmartDisks = () => {
|
||||
loadingDisks.value = true
|
||||
useRequest(disk.smartDisks()).onSuccess(({ data }) => {
|
||||
loadingDisks.value = false
|
||||
available.value = data.available
|
||||
unavailableMessage.value = data.message || ''
|
||||
if (data.available && data.disks) {
|
||||
diskOptions.value = data.disks.map((d: SmartDisk) => ({
|
||||
label: d.model ? `${d.name} (${d.model})` : d.name,
|
||||
value: d.name
|
||||
}))
|
||||
// 自动选中第一个
|
||||
if (diskOptions.value.length > 0) {
|
||||
selectedDisk.value = diskOptions.value[0]!.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 加载 SMART 详情
|
||||
const loadSmartInfo = () => {
|
||||
if (!selectedDisk.value) return
|
||||
loadingInfo.value = true
|
||||
smartData.value = null
|
||||
useRequest(disk.smartInfo(selectedDisk.value)).onSuccess(({ data }) => {
|
||||
loadingInfo.value = false
|
||||
smartData.value = data
|
||||
})
|
||||
}
|
||||
|
||||
// 监听磁盘选择变化
|
||||
watch(selectedDisk, () => {
|
||||
if (selectedDisk.value) {
|
||||
loadSmartInfo()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadSmartDisks()
|
||||
})
|
||||
|
||||
// 提取温度
|
||||
const temperature = computed(() => {
|
||||
if (!smartData.value) return null
|
||||
// ATA
|
||||
if (smartData.value.temperature?.current != null) {
|
||||
return smartData.value.temperature.current
|
||||
}
|
||||
// NVMe
|
||||
if (smartData.value.nvme_smart_health_information_log?.temperature != null) {
|
||||
return smartData.value.nvme_smart_health_information_log.temperature
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// 温度颜色
|
||||
const temperatureColor = computed(() => {
|
||||
const temp = temperature.value
|
||||
if (temp == null) return '#18a058'
|
||||
if (temp <= 40) return '#18a058'
|
||||
if (temp <= 50) return '#f0a020'
|
||||
return '#d03050'
|
||||
})
|
||||
|
||||
// 提取健康状态
|
||||
const healthStatus = computed(() => {
|
||||
if (!smartData.value?.smart_status) return null
|
||||
return smartData.value.smart_status.passed
|
||||
})
|
||||
|
||||
// 提取设备信息
|
||||
const deviceInfo = computed(() => {
|
||||
if (!smartData.value) return []
|
||||
const d = smartData.value
|
||||
const items: { label: string; value: string }[] = []
|
||||
|
||||
if (d.model_name) items.push({ label: $gettext('Model'), value: d.model_name })
|
||||
if (d.serial_number) items.push({ label: $gettext('Serial Number'), value: d.serial_number })
|
||||
if (d.firmware_version) items.push({ label: $gettext('Firmware'), value: d.firmware_version })
|
||||
if (d.user_capacity?.bytes) {
|
||||
items.push({
|
||||
label: $gettext('Capacity'),
|
||||
value: formatCapacity(d.user_capacity.bytes)
|
||||
})
|
||||
}
|
||||
if (d.device_type?.name) {
|
||||
items.push({ label: $gettext('Interface'), value: d.device_type.name })
|
||||
}
|
||||
if (d.rotation_rate != null) {
|
||||
items.push({
|
||||
label: $gettext('Rotation Rate'),
|
||||
value: d.rotation_rate === 0 ? 'SSD' : `${d.rotation_rate} RPM`
|
||||
})
|
||||
}
|
||||
if (d.power_on_time?.hours != null) {
|
||||
items.push({ label: $gettext('Power On Hours'), value: `${d.power_on_time.hours} h` })
|
||||
}
|
||||
if (d.power_cycle_count != null) {
|
||||
items.push({ label: $gettext('Power Cycle Count'), value: `${d.power_cycle_count}` })
|
||||
}
|
||||
|
||||
// NVMe 特有信息
|
||||
const nvme = d.nvme_smart_health_information_log
|
||||
if (nvme) {
|
||||
if (nvme.percentage_used != null) {
|
||||
items.push({ label: $gettext('Percentage Used'), value: `${nvme.percentage_used}%` })
|
||||
}
|
||||
if (nvme.data_units_read != null) {
|
||||
items.push({
|
||||
label: $gettext('Data Read'),
|
||||
value: formatCapacity(nvme.data_units_read * 512000)
|
||||
})
|
||||
}
|
||||
if (nvme.data_units_written != null) {
|
||||
items.push({
|
||||
label: $gettext('Data Written'),
|
||||
value: formatCapacity(nvme.data_units_written * 512000)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
// ATA SMART 属性表格
|
||||
const ataAttributes = computed(() => {
|
||||
if (!smartData.value?.ata_smart_attributes?.table) return []
|
||||
return smartData.value.ata_smart_attributes.table
|
||||
})
|
||||
|
||||
// NVMe SMART 信息
|
||||
const nvmeAttributes = computed(() => {
|
||||
const nvme = smartData.value?.nvme_smart_health_information_log
|
||||
if (!nvme) return []
|
||||
const items: { key: string; name: string; value: string }[] = []
|
||||
const mapping: Record<string, string> = {
|
||||
critical_warning: $gettext('Critical Warning'),
|
||||
temperature: $gettext('Temperature'),
|
||||
available_spare: $gettext('Available Spare'),
|
||||
available_spare_threshold: $gettext('Available Spare Threshold'),
|
||||
percentage_used: $gettext('Percentage Used'),
|
||||
data_units_read: $gettext('Data Units Read'),
|
||||
data_units_written: $gettext('Data Units Written'),
|
||||
host_reads: $gettext('Host Read Commands'),
|
||||
host_writes: $gettext('Host Write Commands'),
|
||||
controller_busy_time: $gettext('Controller Busy Time'),
|
||||
power_cycles: $gettext('Power Cycles'),
|
||||
power_on_hours: $gettext('Power On Hours'),
|
||||
unsafe_shutdowns: $gettext('Unsafe Shutdowns'),
|
||||
media_errors: $gettext('Media Errors'),
|
||||
num_err_log_entries: $gettext('Error Log Entries')
|
||||
}
|
||||
for (const [key, label] of Object.entries(mapping)) {
|
||||
if (nvme[key] != null) {
|
||||
let val = String(nvme[key])
|
||||
if (key === 'temperature') val += ' °C'
|
||||
else if (key === 'available_spare' || key === 'available_spare_threshold' || key === 'percentage_used')
|
||||
val += '%'
|
||||
items.push({ key, name: label, value: val })
|
||||
}
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
// 是否为 NVMe 设备
|
||||
const isNVMe = computed(() => {
|
||||
return !!smartData.value?.nvme_smart_health_information_log
|
||||
})
|
||||
|
||||
// ATA 属性表格列
|
||||
const ataColumns = computed<DataTableColumns>(() => [
|
||||
{ title: 'ID', key: 'id', width: 60 },
|
||||
{ title: $gettext('Attribute'), key: 'name', width: 220 },
|
||||
{ title: $gettext('Value'), key: 'value', width: 80 },
|
||||
{ title: $gettext('Worst'), key: 'worst', width: 80 },
|
||||
{ title: $gettext('Threshold'), key: 'thresh', width: 80 },
|
||||
{
|
||||
title: $gettext('Raw Value'),
|
||||
key: 'raw',
|
||||
width: 150,
|
||||
render(row: any) {
|
||||
return String(row.raw?.value ?? '')
|
||||
}
|
||||
},
|
||||
{
|
||||
title: $gettext('Status'),
|
||||
key: 'when_failed',
|
||||
width: 100,
|
||||
render(row: any) {
|
||||
if (row.when_failed && row.when_failed !== '') {
|
||||
return h(NTag, { type: 'error', size: 'small' }, { default: () => row.when_failed })
|
||||
}
|
||||
return h(NTag, { type: 'success', size: 'small' }, { default: () => 'OK' })
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
// NVMe 属性表格列
|
||||
const nvmeColumns = computed<DataTableColumns>(() => [
|
||||
{ title: $gettext('Attribute'), key: 'name', width: 220 },
|
||||
{ title: $gettext('Value'), key: 'value' }
|
||||
])
|
||||
|
||||
// 格式化容量
|
||||
const formatCapacity = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
const units = ['KB', 'MB', 'GB', 'TB', 'PB']
|
||||
let i = -1
|
||||
let val = bytes
|
||||
do {
|
||||
val /= 1024
|
||||
i++
|
||||
} while (val >= 1024 && i < units.length - 1)
|
||||
return val.toFixed(2) + ' ' + units[i]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-spin :show="loadingDisks">
|
||||
<!-- 不可用时 -->
|
||||
<n-result
|
||||
v-if="!loadingDisks && !available"
|
||||
status="warning"
|
||||
:title="$gettext('SMART Not Available')"
|
||||
:description="unavailableMessage"
|
||||
/>
|
||||
|
||||
<!-- 可用时 -->
|
||||
<n-flex v-if="!loadingDisks && available" vertical :size="16">
|
||||
<!-- 磁盘选择 -->
|
||||
<n-flex align="center" :size="12">
|
||||
<span style="font-weight: 500">{{ $gettext('Select Disk') }}:</span>
|
||||
<n-select
|
||||
v-model:value="selectedDisk"
|
||||
:options="diskOptions"
|
||||
style="width: 300px"
|
||||
:placeholder="$gettext('Select a disk')"
|
||||
/>
|
||||
</n-flex>
|
||||
|
||||
<!-- 无磁盘 -->
|
||||
<n-empty
|
||||
v-if="diskOptions.length === 0"
|
||||
:description="$gettext('No SMART-capable disks found')"
|
||||
/>
|
||||
|
||||
<!-- SMART 数据 -->
|
||||
<n-spin v-if="selectedDisk" :show="loadingInfo">
|
||||
<n-tabs v-if="smartData" type="line" animated>
|
||||
<!-- 基本信息 -->
|
||||
<n-tab-pane name="info" :tab="$gettext('Basic Info')">
|
||||
<n-flex :size="24">
|
||||
<!-- 温度圆形进度条 -->
|
||||
<n-flex v-if="temperature != null" vertical align="center" :size="8">
|
||||
<n-progress
|
||||
type="circle"
|
||||
:percentage="Math.min(temperature, 100)"
|
||||
:color="temperatureColor"
|
||||
:rail-color="temperatureColor + '20'"
|
||||
:stroke-width="10"
|
||||
style="width: 120px"
|
||||
>
|
||||
<span style="font-size: 24px; font-weight: 600">{{ temperature }}°C</span>
|
||||
</n-progress>
|
||||
<span style="color: var(--text-color-3)">{{ $gettext('Temperature') }}</span>
|
||||
</n-flex>
|
||||
|
||||
<!-- 设备详情 -->
|
||||
<n-flex vertical :size="12" style="flex: 1">
|
||||
<!-- 健康状态 -->
|
||||
<n-flex v-if="healthStatus != null" align="center" :size="8">
|
||||
<span style="font-weight: 500">{{ $gettext('Health Status') }}:</span>
|
||||
<n-tag :type="healthStatus ? 'success' : 'error'" size="small">
|
||||
{{ healthStatus ? $gettext('PASSED') : $gettext('FAILED') }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
|
||||
<n-descriptions bordered :column="2" label-placement="left" size="small">
|
||||
<n-descriptions-item
|
||||
v-for="item in deviceInfo"
|
||||
:key="item.label"
|
||||
:label="item.label"
|
||||
>
|
||||
{{ item.value }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- SMART 属性 -->
|
||||
<n-tab-pane name="attributes" :tab="$gettext('SMART Attributes')">
|
||||
<!-- ATA 设备 -->
|
||||
<n-data-table
|
||||
v-if="!isNVMe && ataAttributes.length > 0"
|
||||
:columns="ataColumns"
|
||||
:data="ataAttributes"
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
size="small"
|
||||
:row-key="(row: any) => row.id"
|
||||
/>
|
||||
<!-- NVMe 设备 -->
|
||||
<n-data-table
|
||||
v-else-if="isNVMe && nvmeAttributes.length > 0"
|
||||
:columns="nvmeColumns"
|
||||
:data="nvmeAttributes"
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
size="small"
|
||||
:row-key="(row: any) => row.key"
|
||||
/>
|
||||
<n-empty v-else :description="$gettext('No SMART attributes available')" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-spin>
|
||||
</n-flex>
|
||||
</n-spin>
|
||||
</template>
|
||||
Reference in New Issue
Block a user