Unify Redfish analysis through raw replay and add storage volumes

This commit is contained in:
Mikhail Chusavitin
2026-02-24 18:34:13 +03:00
parent 7a1285db99
commit 9477b93b20
5 changed files with 294 additions and 109 deletions

View File

@@ -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/<id>/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/<id>/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

View File

@@ -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
}

View File

@@ -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"`

View File

@@ -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,

View File

@@ -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 += '<div class="config-tab-content" id="config-storage">';
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) {
<div class="stat-box"><span class="stat-value">${storTotal}</span><span class="stat-label">Всего слотов</span></div>
<div class="stat-box"><span class="stat-value">${storage.filter(s => s.present).length}</span><span class="stat-label">Установлено</span></div>
<div class="stat-box"><span class="stat-value">${totalTB > 0 ? totalTB + ' TB' : '-'}</span><span class="stat-label">Объём</span></div>
<div class="stat-box"><span class="stat-value">${volumes.length}</span><span class="stat-label">Логических томов</span></div>
<div class="stat-box model-box"><span class="stat-value">${typesSummary.join(', ') || '-'}</span><span class="stat-label">По типам</span></div>
</div>
<table class="config-table"><thead><tr><th>NO.</th><th>Статус</th><th>Расположение</th><th>Backplane ID</th><th>Тип</th><th>Модель</th><th>Размер</th><th>Серийный номер</th></tr></thead><tbody>`;
@@ -880,6 +882,21 @@ function renderConfig(data) {
</tr>`;
});
html += '</tbody></table>';
if (volumes.length > 0) {
html += `<h3 style="margin-top:16px;">Логические тома (RAID/VROC)</h3>
<table class="config-table"><thead><tr><th>ID</th><th>Имя</th><th>Контроллер</th><th>RAID</th><th>Размер</th><th>Статус</th></tr></thead><tbody>`;
volumes.forEach(v => {
html += `<tr>
<td>${escapeHtml(v.id || '-')}</td>
<td>${escapeHtml(v.name || '-')}</td>
<td>${escapeHtml(v.controller || '-')}</td>
<td>${escapeHtml(v.raid_level || '-')}</td>
<td>${v.size_gb > 0 ? `${v.size_gb} GB` : '-'}</td>
<td>${escapeHtml(v.status || '-')}</td>
</tr>`;
});
html += '</tbody></table>';
}
} else {
html += '<p class="no-data">Нет данных о накопителях</p>';
}