Unify Redfish analysis through raw replay and add storage volumes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user