diff --git a/internal/collector/redfish.go b/internal/collector/redfish.go index e9a1805..f94a2d9 100644 --- a/internal/collector/redfish.go +++ b/internal/collector/redfish.go @@ -80,63 +80,23 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre systemPaths := c.discoverMemberPaths(ctx, client, req, baseURL, "/redfish/v1/Systems", "/redfish/v1/Systems/1") chassisPaths := c.discoverMemberPaths(ctx, client, req, baseURL, "/redfish/v1/Chassis", "/redfish/v1/Chassis/1") managerPaths := c.discoverMemberPaths(ctx, client, req, baseURL, "/redfish/v1/Managers", "/redfish/v1/Managers/1") - primarySystem := firstPathOrDefault(systemPaths, "/redfish/v1/Systems/1") - primaryManager := firstPathOrDefault(managerPaths, "/redfish/v1/Managers/1") if emit != nil { - emit(Progress{Status: "running", Progress: 30, Message: "Redfish: чтение данных системы..."}) - } - systemDoc, err := c.getJSON(ctx, client, req, baseURL, primarySystem) - if err != nil { - return nil, fmt.Errorf("system info: %w", err) - } - biosDoc, _ := c.getJSON(ctx, client, req, baseURL, joinPath(primarySystem, "/Bios")) - secureBootDoc, _ := c.getJSON(ctx, client, req, baseURL, joinPath(primarySystem, "/SecureBoot")) - - if emit != nil { - emit(Progress{Status: "running", Progress: 55, Message: "Redfish: чтение CPU/RAM/Storage..."}) - } - processors, _ := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(primarySystem, "/Processors")) - memory, _ := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(primarySystem, "/Memory")) - storageDevices := c.collectStorage(ctx, client, req, baseURL, primarySystem) - - if emit != nil { - emit(Progress{Status: "running", Progress: 80, Message: "Redfish: чтение сетевых и BMC настроек..."}) - } - psus := c.collectPSUs(ctx, client, req, baseURL, chassisPaths) - pcieDevices := c.collectPCIeDevices(ctx, client, req, baseURL, systemPaths, chassisPaths) - gpus := c.collectGPUs(ctx, client, req, baseURL, systemPaths, chassisPaths) - nics := c.collectNICs(ctx, client, req, baseURL, chassisPaths) - managerDoc, _ := c.getJSON(ctx, client, req, baseURL, primaryManager) - networkProtocolDoc, _ := c.getJSON(ctx, client, req, baseURL, joinPath(primaryManager, "/NetworkProtocol")) - if emit != nil { + emit(Progress{Status: "running", Progress: 30, Message: "Redfish: чтение структуры Redfish..."}) + emit(Progress{Status: "running", Progress: 55, Message: "Redfish: подготовка snapshot..."}) + emit(Progress{Status: "running", Progress: 80, Message: "Redfish: подготовка расширенного snapshot..."}) emit(Progress{Status: "running", Progress: 90, Message: "Redfish: сбор расширенного snapshot..."}) } c.debugSnapshotf("snapshot crawl start host=%s port=%d", req.Host, req.Port) rawTree := c.collectRawRedfishTree(ctx, client, req, baseURL, redfishSnapshotPrioritySeeds(systemPaths, chassisPaths, managerPaths), emit) c.debugSnapshotf("snapshot crawl done docs=%d", len(rawTree)) - - result := &models.AnalysisResult{ - Events: make([]models.Event, 0), - FRU: make([]models.FRUInfo, 0), - Sensors: make([]models.SensorReading, 0), - RawPayloads: map[string]any{ - "redfish_tree": rawTree, - }, - Hardware: &models.HardwareConfig{ - BoardInfo: parseBoardInfo(systemDoc), - CPUs: parseCPUs(processors), - Memory: parseMemory(memory), - Storage: storageDevices, - PCIeDevices: pcieDevices, - GPUs: gpus, - PowerSupply: psus, - NetworkAdapters: nics, - Firmware: parseFirmware(systemDoc, biosDoc, managerDoc, secureBootDoc, networkProtocolDoc), - }, + if emit != nil { + emit(Progress{Status: "running", Progress: 99, Message: "Redfish: анализ raw snapshot..."}) } - - return result, nil + // Unified tunnel: live collection and raw import go through the same analyzer over redfish_tree. + return ReplayRedfishFromRawPayloads(map[string]any{ + "redfish_tree": rawTree, + }, nil) } func (c *RedfishConnector) httpClient(req Request) *http.Client { @@ -238,6 +198,16 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie } } + // IntelVROC often exposes rich drive inventory via dedicated child collections. + for _, driveDoc := range c.collectKnownStorageMembers(ctx, client, req, baseURL, systemPath, []string{ + "/Storage/IntelVROC/Drives", + "/Storage/IntelVROC/Controllers/1/Drives", + }) { + if looksLikeDrive(driveDoc) { + out = append(out, parseDrive(driveDoc)) + } + } + // Fallback for platforms that expose disks in SimpleStorage. simpleStorageMembers, _ := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(systemPath, "/SimpleStorage")) for _, member := range simpleStorageMembers { @@ -284,6 +254,39 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie return out } +func (c *RedfishConnector) collectStorageVolumes(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string) []models.StorageVolume { + var out []models.StorageVolume + storageMembers, _ := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(systemPath, "/Storage")) + for _, member := range storageMembers { + controller := firstNonEmpty(asString(member["Id"]), asString(member["Name"])) + volumeCollectionPath := redfishLinkedPath(member, "Volumes") + if volumeCollectionPath == "" { + continue + } + volumeDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, volumeCollectionPath) + if err != nil { + continue + } + for _, volDoc := range volumeDocs { + if !looksLikeVolume(volDoc) { + continue + } + out = append(out, parseStorageVolume(volDoc, controller)) + } + } + for _, volDoc := range c.collectKnownStorageMembers(ctx, client, req, baseURL, systemPath, []string{ + "/Storage/IntelVROC/Volumes", + "/Storage/HA-RAID/Volumes", + "/Storage/MRVL.HA-RAID/Volumes", + }) { + if !looksLikeVolume(volDoc) { + continue + } + out = append(out, parseStorageVolume(volDoc, storageControllerFromPath(asString(volDoc["@odata.id"])))) + } + return dedupeStorageVolumes(out) +} + func (c *RedfishConnector) collectNICs(ctx context.Context, client *http.Client, req Request, baseURL string, chassisPaths []string) []models.NetworkAdapter { var nics []models.NetworkAdapter seen := make(map[string]struct{}) @@ -321,7 +324,15 @@ func (c *RedfishConnector) collectPSUs(ctx context.Context, client *http.Client, seen := make(map[string]struct{}) idx := 1 for _, chassisPath := range chassisPaths { - // Most implementations expose PSU info in Chassis//Power as an embedded array. + // Redfish 2022+/X14+ commonly uses PowerSubsystem as the primary source. + if memberDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(chassisPath, "/PowerSubsystem/PowerSupplies")); err == nil && len(memberDocs) > 0 { + for _, doc := range memberDocs { + idx = appendPSU(&out, seen, parsePSU(doc, idx), idx) + } + continue + } + + // Legacy source: embedded array in Chassis//Power. if powerDoc, err := c.getJSON(ctx, client, req, baseURL, joinPath(chassisPath, "/Power")); err == nil { if members, ok := powerDoc["PowerSupplies"].([]interface{}); ok && len(members) > 0 { for _, item := range members { @@ -329,43 +340,47 @@ func (c *RedfishConnector) collectPSUs(ctx context.Context, client *http.Client, if !ok { continue } - psu := parsePSU(doc, idx) - idx++ - key := firstNonEmpty(psu.SerialNumber, psu.Slot+"|"+psu.Model) - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - out = append(out, psu) + idx = appendPSU(&out, seen, parsePSU(doc, idx), idx) } } } - - // Redfish 2022+ may expose PSU collection via PowerSubsystem. - memberDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(chassisPath, "/PowerSubsystem/PowerSupplies")) - if err != nil || len(memberDocs) == 0 { - continue - } - for _, doc := range memberDocs { - psu := parsePSU(doc, idx) - idx++ - key := firstNonEmpty(psu.SerialNumber, psu.Slot+"|"+psu.Model) - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - out = append(out, psu) - } } return out } +func appendPSU(out *[]models.PSU, seen map[string]struct{}, psu models.PSU, currentIdx int) int { + nextIdx := currentIdx + 1 + key := firstNonEmpty(psu.SerialNumber, psu.Slot+"|"+psu.Model) + if key == "" { + return nextIdx + } + if _, ok := seen[key]; ok { + return nextIdx + } + seen[key] = struct{}{} + *out = append(*out, psu) + return len(*out) + 1 +} + +func (c *RedfishConnector) collectKnownStorageMembers(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, relativeCollections []string) []map[string]interface{} { + var out []map[string]interface{} + for _, rel := range relativeCollections { + docs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(systemPath, rel)) + if err != nil || len(docs) == 0 { + continue + } + out = append(out, docs...) + } + return out +} + +func redfishLinkedPath(doc map[string]interface{}, key string) string { + if v, ok := doc[key].(map[string]interface{}); ok { + return asString(v["@odata.id"]) + } + return "" +} + func (c *RedfishConnector) collectGPUs(ctx context.Context, client *http.Client, req Request, baseURL string, systemPaths, chassisPaths []string) []models.GPU { collections := make([]string, 0, len(systemPaths)*2+len(chassisPaths)) for _, systemPath := range systemPaths { @@ -952,6 +967,36 @@ func parseDrive(doc map[string]interface{}) models.Storage { } } +func parseStorageVolume(doc map[string]interface{}, controller string) models.StorageVolume { + sizeGB := 0 + capBytes := asInt64(doc["CapacityBytes"]) + if capBytes > 0 { + sizeGB = int(capBytes / (1024 * 1024 * 1024)) + } + if sizeGB == 0 { + sizeGB = asInt(doc["CapacityGB"]) + } + raidLevel := firstNonEmpty(asString(doc["RAIDType"]), asString(doc["VolumeType"])) + if raidLevel == "" { + if v, ok := doc["Oem"].(map[string]interface{}); ok { + if smc, ok := v["Supermicro"].(map[string]interface{}); ok { + raidLevel = firstNonEmpty(raidLevel, asString(smc["RAIDType"]), asString(smc["VolumeType"])) + } + } + } + return models.StorageVolume{ + ID: asString(doc["Id"]), + Name: firstNonEmpty(asString(doc["Name"]), asString(doc["Id"])), + Controller: strings.TrimSpace(controller), + RAIDLevel: raidLevel, + SizeGB: sizeGB, + CapacityBytes: capBytes, + Status: mapStatus(doc["Status"]), + Bootable: asBool(doc["Bootable"]), + Encrypted: asBool(doc["Encrypted"]), + } +} + func parseNIC(doc map[string]interface{}) models.NetworkAdapter { vendorID := asHexOrInt(doc["VendorId"]) deviceID := asHexOrInt(doc["DeviceId"]) @@ -1362,6 +1407,16 @@ func classifyStorageType(doc map[string]interface{}) string { return firstNonEmpty(asString(doc["Type"]), "Storage") } +func looksLikeVolume(doc map[string]interface{}) bool { + if asString(doc["RAIDType"]) != "" || asString(doc["VolumeType"]) != "" { + return true + } + if strings.Contains(strings.ToLower(asString(doc["@odata.type"])), "volume") && (asInt64(doc["CapacityBytes"]) > 0 || asString(doc["Name"]) != "") { + return true + } + return false +} + func dedupeStorage(items []models.Storage) []models.Storage { if len(items) <= 1 { return items @@ -1382,6 +1437,34 @@ func dedupeStorage(items []models.Storage) []models.Storage { return out } +func dedupeStorageVolumes(items []models.StorageVolume) []models.StorageVolume { + seen := make(map[string]struct{}, len(items)) + out := make([]models.StorageVolume, 0, len(items)) + for _, v := range items { + key := firstNonEmpty(strings.TrimSpace(v.ID), strings.TrimSpace(v.Name), strings.TrimSpace(v.Controller)+"|"+fmt.Sprintf("%d", v.CapacityBytes)) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, v) + } + return out +} + +func storageControllerFromPath(path string) string { + p := normalizeRedfishPath(path) + parts := strings.Split(p, "/") + for i := 0; i < len(parts)-1; i++ { + if parts[i] == "Storage" && i+1 < len(parts) { + return parts[i+1] + } + } + return "" +} + func parseFirmware(system, bios, manager, secureBoot, networkProtocol map[string]interface{}) []models.FirmwareInfo { var out []models.FirmwareInfo @@ -1483,6 +1566,17 @@ func asInt64(v interface{}) int64 { return 0 } +func asBool(v interface{}) bool { + switch t := v.(type) { + case bool: + return t + case string: + return strings.EqualFold(strings.TrimSpace(t), "true") + default: + return false + } +} + func asFloat(v interface{}) float64 { switch value := v.(type) { case nil: @@ -1736,17 +1830,32 @@ func redfishSnapshotPrioritySeeds(systemPaths, chassisPaths, managerPaths []stri add("/redfish/v1/Fabrics") for _, p := range systemPaths { + add(p) + add(joinPath(p, "/Bios")) + add(joinPath(p, "/SecureBoot")) + add(joinPath(p, "/Processors")) + add(joinPath(p, "/Memory")) add(joinPath(p, "/PCIeDevices")) add(joinPath(p, "/PCIeFunctions")) add(joinPath(p, "/Accelerators")) + add(joinPath(p, "/Storage")) + add(joinPath(p, "/Storage/IntelVROC")) + add(joinPath(p, "/Storage/IntelVROC/Drives")) + add(joinPath(p, "/Storage/IntelVROC/Volumes")) } for _, p := range chassisPaths { + add(p) add(joinPath(p, "/PCIeDevices")) add(joinPath(p, "/PCIeSlots")) add(joinPath(p, "/NetworkAdapters")) + add(joinPath(p, "/PowerSubsystem")) + add(joinPath(p, "/PowerSubsystem/PowerSupplies")) + add(joinPath(p, "/ThermalSubsystem")) + add(joinPath(p, "/ThermalSubsystem/Fans")) add(joinPath(p, "/Power")) } for _, p := range managerPaths { + add(p) add(joinPath(p, "/NetworkProtocol")) } return out diff --git a/internal/collector/redfish_replay.go b/internal/collector/redfish_replay.go index cb29f1e..b8f7b0e 100644 --- a/internal/collector/redfish_replay.go +++ b/internal/collector/redfish_replay.go @@ -51,6 +51,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) ( processors, _ := r.getCollectionMembers(joinPath(primarySystem, "/Processors")) memory, _ := r.getCollectionMembers(joinPath(primarySystem, "/Memory")) storageDevices := r.collectStorage(primarySystem) + storageVolumes := r.collectStorageVolumes(primarySystem) if emit != nil { emit(Progress{Status: "running", Progress: 80, Message: "Redfish snapshot: replay network/BMC..."}) @@ -74,6 +75,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) ( CPUs: parseCPUs(processors), Memory: parseMemory(memory), Storage: storageDevices, + Volumes: storageVolumes, PCIeDevices: pcieDevices, GPUs: gpus, PowerSupply: psus, @@ -256,6 +258,15 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag } } + for _, driveDoc := range r.collectKnownStorageMembers(systemPath, []string{ + "/Storage/IntelVROC/Drives", + "/Storage/IntelVROC/Controllers/1/Drives", + }) { + if looksLikeDrive(driveDoc) { + out = append(out, parseDrive(driveDoc)) + } + } + simpleStorageMembers, _ := r.getCollectionMembers(joinPath(systemPath, "/SimpleStorage")) for _, member := range simpleStorageMembers { devices, ok := member["Devices"].([]interface{}) @@ -298,6 +309,49 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag return dedupeStorage(out) } +func (r redfishSnapshotReader) collectStorageVolumes(systemPath string) []models.StorageVolume { + var out []models.StorageVolume + storageMembers, _ := r.getCollectionMembers(joinPath(systemPath, "/Storage")) + for _, member := range storageMembers { + controller := firstNonEmpty(asString(member["Id"]), asString(member["Name"])) + volumeCollectionPath := redfishLinkedPath(member, "Volumes") + if volumeCollectionPath == "" { + continue + } + volumeDocs, err := r.getCollectionMembers(volumeCollectionPath) + if err != nil { + continue + } + for _, volDoc := range volumeDocs { + if looksLikeVolume(volDoc) { + out = append(out, parseStorageVolume(volDoc, controller)) + } + } + } + for _, volDoc := range r.collectKnownStorageMembers(systemPath, []string{ + "/Storage/IntelVROC/Volumes", + "/Storage/HA-RAID/Volumes", + "/Storage/MRVL.HA-RAID/Volumes", + }) { + if looksLikeVolume(volDoc) { + out = append(out, parseStorageVolume(volDoc, storageControllerFromPath(asString(volDoc["@odata.id"])))) + } + } + return dedupeStorageVolumes(out) +} + +func (r redfishSnapshotReader) collectKnownStorageMembers(systemPath string, relativeCollections []string) []map[string]interface{} { + var out []map[string]interface{} + for _, rel := range relativeCollections { + docs, err := r.getCollectionMembers(joinPath(systemPath, rel)) + if err != nil || len(docs) == 0 { + continue + } + out = append(out, docs...) + } + return out +} + func (r redfishSnapshotReader) probeSupermicroNVMeDiskBays(backplanePath string) []map[string]interface{} { return r.probeDirectDiskBayChildren(joinPath(backplanePath, "/Drives")) } @@ -351,6 +405,12 @@ func (r redfishSnapshotReader) collectPSUs(chassisPaths []string) []models.PSU { seen := make(map[string]struct{}) idx := 1 for _, chassisPath := range chassisPaths { + if memberDocs, err := r.getCollectionMembers(joinPath(chassisPath, "/PowerSubsystem/PowerSupplies")); err == nil && len(memberDocs) > 0 { + for _, doc := range memberDocs { + idx = appendPSU(&out, seen, parsePSU(doc, idx), idx) + } + continue + } if powerDoc, err := r.getJSON(joinPath(chassisPath, "/Power")); err == nil { if members, ok := powerDoc["PowerSupplies"].([]interface{}); ok && len(members) > 0 { for _, item := range members { @@ -358,37 +418,10 @@ func (r redfishSnapshotReader) collectPSUs(chassisPaths []string) []models.PSU { if !ok { continue } - psu := parsePSU(doc, idx) - idx++ - key := firstNonEmpty(psu.SerialNumber, psu.Slot+"|"+psu.Model) - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - out = append(out, psu) + idx = appendPSU(&out, seen, parsePSU(doc, idx), idx) } } } - memberDocs, err := r.getCollectionMembers(joinPath(chassisPath, "/PowerSubsystem/PowerSupplies")) - if err != nil || len(memberDocs) == 0 { - continue - } - for _, doc := range memberDocs { - psu := parsePSU(doc, idx) - idx++ - key := firstNonEmpty(psu.SerialNumber, psu.Slot+"|"+psu.Model) - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - out = append(out, psu) - } } return out } diff --git a/internal/models/models.go b/internal/models/models.go index f348a4f..6d1ea3d 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -88,6 +88,7 @@ type HardwareConfig struct { CPUs []CPU `json:"cpus,omitempty"` Memory []MemoryDIMM `json:"memory,omitempty"` Storage []Storage `json:"storage,omitempty"` + Volumes []StorageVolume `json:"volumes,omitempty"` PCIeDevices []PCIeDevice `json:"pcie_devices,omitempty"` GPUs []GPU `json:"gpus,omitempty"` NetworkCards []NIC `json:"network_cards,omitempty"` @@ -245,6 +246,19 @@ type Storage struct { ErrorDescription string `json:"error_description,omitempty"` } +// StorageVolume represents a logical storage volume (RAID/VROC/etc.). +type StorageVolume struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Controller string `json:"controller,omitempty"` + RAIDLevel string `json:"raid_level,omitempty"` + SizeGB int `json:"size_gb,omitempty"` + CapacityBytes int64 `json:"capacity_bytes,omitempty"` + Status string `json:"status,omitempty"` + Bootable bool `json:"bootable,omitempty"` + Encrypted bool `json:"encrypted,omitempty"` +} + // PCIeDevice represents a PCIe device type PCIeDevice struct { Slot string `json:"slot"` diff --git a/internal/server/raw_export.go b/internal/server/raw_export.go index 363bd0b..2712eaa 100644 --- a/internal/server/raw_export.go +++ b/internal/server/raw_export.go @@ -236,6 +236,16 @@ func buildHumanReadableCollectionLog(pkg *RawExportPackage, result *models.Analy fmt.Fprintf(&b, "- slot=%s type=%s model=%s size_gb=%d serial=%s\n", s.Slot, s.Type, s.Model, s.SizeGB, s.SerialNumber) } } + if len(hw.Volumes) > 0 { + b.WriteString("\n[Volumes]\n") + for _, v := range hw.Volumes { + name := v.Name + if name == "" { + name = v.ID + } + fmt.Fprintf(&b, "- controller=%s name=%s raid=%s size_gb=%d status=%s\n", v.Controller, name, v.RAIDLevel, v.SizeGB, v.Status) + } + } if len(hw.PCIeDevices) > 0 { b.WriteString("\n[PCIe Devices]\n") for _, d := range hw.PCIeDevices { @@ -295,6 +305,7 @@ func buildParserFieldSummary(result *models.AnalysisResult) map[string]any { "cpus": len(hw.CPUs), "memory": len(hw.Memory), "storage": len(hw.Storage), + "volumes": len(hw.Volumes), "pcie": len(hw.PCIeDevices), "gpus": len(hw.GPUs), "nics": len(hw.NetworkAdapters), @@ -307,6 +318,7 @@ func buildParserFieldSummary(result *models.AnalysisResult) map[string]any { "cpus": hw.CPUs, "memory": hw.Memory, "storage": hw.Storage, + "volumes": hw.Volumes, "pcie_devices": hw.PCIeDevices, "gpus": hw.GPUs, "network_adapters": hw.NetworkAdapters, diff --git a/web/static/js/app.js b/web/static/js/app.js index 4af7f91..2d327ef 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -647,6 +647,7 @@ function renderConfig(data) { const config = data.hardware || data; const spec = data.specification; const devices = Array.isArray(config.devices) ? config.devices : []; + const volumes = Array.isArray(config.volumes) ? config.volumes : []; const cpus = devices.filter(d => d.kind === 'cpu'); const memory = devices.filter(d => d.kind === 'memory'); @@ -847,7 +848,7 @@ function renderConfig(data) { // Storage tab html += '
'; - if (storage.length > 0) { + if (storage.length > 0 || volumes.length > 0) { const storTotal = storage.length; const storHDD = storage.filter(s => s.type === 'HDD').length; const storSSD = storage.filter(s => s.type === 'SSD').length; @@ -862,6 +863,7 @@ function renderConfig(data) {
${storTotal}Всего слотов
${storage.filter(s => s.present).length}Установлено
${totalTB > 0 ? totalTB + ' TB' : '-'}Объём
+
${volumes.length}Логических томов
${typesSummary.join(', ') || '-'}По типам
`; @@ -880,6 +882,21 @@ function renderConfig(data) { `; }); html += '
NO.СтатусРасположениеBackplane IDТипМодельРазмерСерийный номер
'; + if (volumes.length > 0) { + html += `

Логические тома (RAID/VROC)

+ `; + volumes.forEach(v => { + html += ` + + + + + + + `; + }); + html += '
IDИмяКонтроллерRAIDРазмерСтатус
${escapeHtml(v.id || '-')}${escapeHtml(v.name || '-')}${escapeHtml(v.controller || '-')}${escapeHtml(v.raid_level || '-')}${v.size_gb > 0 ? `${v.size_gb} GB` : '-'}${escapeHtml(v.status || '-')}
'; + } } else { html += '

Нет данных о накопителях

'; }