feat: 添加SMART和RAID状态查看

This commit is contained in:
耗子
2026-02-18 03:28:06 +08:00
parent da9d95284c
commit 67642aa475
6 changed files with 1185 additions and 1 deletions
+3
View File
@@ -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) {
+603
View File
@@ -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])
}
+8 -1
View File
@@ -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')
}
+13
View File
@@ -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>
+212
View File
@@ -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>
+346
View File
@@ -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>