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")
|
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")
|
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")
|
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 {
|
if emit != nil {
|
||||||
emit(Progress{Status: "running", Progress: 30, Message: "Redfish: чтение данных системы..."})
|
emit(Progress{Status: "running", Progress: 30, Message: "Redfish: чтение структуры Redfish..."})
|
||||||
}
|
emit(Progress{Status: "running", Progress: 55, Message: "Redfish: подготовка snapshot..."})
|
||||||
systemDoc, err := c.getJSON(ctx, client, req, baseURL, primarySystem)
|
emit(Progress{Status: "running", Progress: 80, Message: "Redfish: подготовка расширенного snapshot..."})
|
||||||
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: 90, 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)
|
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)
|
rawTree := c.collectRawRedfishTree(ctx, client, req, baseURL, redfishSnapshotPrioritySeeds(systemPaths, chassisPaths, managerPaths), emit)
|
||||||
c.debugSnapshotf("snapshot crawl done docs=%d", len(rawTree))
|
c.debugSnapshotf("snapshot crawl done docs=%d", len(rawTree))
|
||||||
|
if emit != nil {
|
||||||
result := &models.AnalysisResult{
|
emit(Progress{Status: "running", Progress: 99, Message: "Redfish: анализ raw snapshot..."})
|
||||||
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),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
// Unified tunnel: live collection and raw import go through the same analyzer over redfish_tree.
|
||||||
return result, nil
|
return ReplayRedfishFromRawPayloads(map[string]any{
|
||||||
|
"redfish_tree": rawTree,
|
||||||
|
}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *RedfishConnector) httpClient(req Request) *http.Client {
|
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.
|
// Fallback for platforms that expose disks in SimpleStorage.
|
||||||
simpleStorageMembers, _ := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(systemPath, "/SimpleStorage"))
|
simpleStorageMembers, _ := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(systemPath, "/SimpleStorage"))
|
||||||
for _, member := range simpleStorageMembers {
|
for _, member := range simpleStorageMembers {
|
||||||
@@ -284,6 +254,39 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
return out
|
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 {
|
func (c *RedfishConnector) collectNICs(ctx context.Context, client *http.Client, req Request, baseURL string, chassisPaths []string) []models.NetworkAdapter {
|
||||||
var nics []models.NetworkAdapter
|
var nics []models.NetworkAdapter
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
@@ -321,7 +324,15 @@ func (c *RedfishConnector) collectPSUs(ctx context.Context, client *http.Client,
|
|||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
idx := 1
|
idx := 1
|
||||||
for _, chassisPath := range chassisPaths {
|
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 powerDoc, err := c.getJSON(ctx, client, req, baseURL, joinPath(chassisPath, "/Power")); err == nil {
|
||||||
if members, ok := powerDoc["PowerSupplies"].([]interface{}); ok && len(members) > 0 {
|
if members, ok := powerDoc["PowerSupplies"].([]interface{}); ok && len(members) > 0 {
|
||||||
for _, item := range members {
|
for _, item := range members {
|
||||||
@@ -329,43 +340,47 @@ func (c *RedfishConnector) collectPSUs(ctx context.Context, client *http.Client,
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
psu := parsePSU(doc, idx)
|
idx = appendPSU(&out, seen, parsePSU(doc, idx), 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
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 {
|
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))
|
collections := make([]string, 0, len(systemPaths)*2+len(chassisPaths))
|
||||||
for _, systemPath := range systemPaths {
|
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 {
|
func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
|
||||||
vendorID := asHexOrInt(doc["VendorId"])
|
vendorID := asHexOrInt(doc["VendorId"])
|
||||||
deviceID := asHexOrInt(doc["DeviceId"])
|
deviceID := asHexOrInt(doc["DeviceId"])
|
||||||
@@ -1362,6 +1407,16 @@ func classifyStorageType(doc map[string]interface{}) string {
|
|||||||
return firstNonEmpty(asString(doc["Type"]), "Storage")
|
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 {
|
func dedupeStorage(items []models.Storage) []models.Storage {
|
||||||
if len(items) <= 1 {
|
if len(items) <= 1 {
|
||||||
return items
|
return items
|
||||||
@@ -1382,6 +1437,34 @@ func dedupeStorage(items []models.Storage) []models.Storage {
|
|||||||
return out
|
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 {
|
func parseFirmware(system, bios, manager, secureBoot, networkProtocol map[string]interface{}) []models.FirmwareInfo {
|
||||||
var out []models.FirmwareInfo
|
var out []models.FirmwareInfo
|
||||||
|
|
||||||
@@ -1483,6 +1566,17 @@ func asInt64(v interface{}) int64 {
|
|||||||
return 0
|
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 {
|
func asFloat(v interface{}) float64 {
|
||||||
switch value := v.(type) {
|
switch value := v.(type) {
|
||||||
case nil:
|
case nil:
|
||||||
@@ -1736,17 +1830,32 @@ func redfishSnapshotPrioritySeeds(systemPaths, chassisPaths, managerPaths []stri
|
|||||||
add("/redfish/v1/Fabrics")
|
add("/redfish/v1/Fabrics")
|
||||||
|
|
||||||
for _, p := range systemPaths {
|
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, "/PCIeDevices"))
|
||||||
add(joinPath(p, "/PCIeFunctions"))
|
add(joinPath(p, "/PCIeFunctions"))
|
||||||
add(joinPath(p, "/Accelerators"))
|
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 {
|
for _, p := range chassisPaths {
|
||||||
|
add(p)
|
||||||
add(joinPath(p, "/PCIeDevices"))
|
add(joinPath(p, "/PCIeDevices"))
|
||||||
add(joinPath(p, "/PCIeSlots"))
|
add(joinPath(p, "/PCIeSlots"))
|
||||||
add(joinPath(p, "/NetworkAdapters"))
|
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"))
|
add(joinPath(p, "/Power"))
|
||||||
}
|
}
|
||||||
for _, p := range managerPaths {
|
for _, p := range managerPaths {
|
||||||
|
add(p)
|
||||||
add(joinPath(p, "/NetworkProtocol"))
|
add(joinPath(p, "/NetworkProtocol"))
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
|
|||||||
processors, _ := r.getCollectionMembers(joinPath(primarySystem, "/Processors"))
|
processors, _ := r.getCollectionMembers(joinPath(primarySystem, "/Processors"))
|
||||||
memory, _ := r.getCollectionMembers(joinPath(primarySystem, "/Memory"))
|
memory, _ := r.getCollectionMembers(joinPath(primarySystem, "/Memory"))
|
||||||
storageDevices := r.collectStorage(primarySystem)
|
storageDevices := r.collectStorage(primarySystem)
|
||||||
|
storageVolumes := r.collectStorageVolumes(primarySystem)
|
||||||
|
|
||||||
if emit != nil {
|
if emit != nil {
|
||||||
emit(Progress{Status: "running", Progress: 80, Message: "Redfish snapshot: replay network/BMC..."})
|
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),
|
CPUs: parseCPUs(processors),
|
||||||
Memory: parseMemory(memory),
|
Memory: parseMemory(memory),
|
||||||
Storage: storageDevices,
|
Storage: storageDevices,
|
||||||
|
Volumes: storageVolumes,
|
||||||
PCIeDevices: pcieDevices,
|
PCIeDevices: pcieDevices,
|
||||||
GPUs: gpus,
|
GPUs: gpus,
|
||||||
PowerSupply: psus,
|
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"))
|
simpleStorageMembers, _ := r.getCollectionMembers(joinPath(systemPath, "/SimpleStorage"))
|
||||||
for _, member := range simpleStorageMembers {
|
for _, member := range simpleStorageMembers {
|
||||||
devices, ok := member["Devices"].([]interface{})
|
devices, ok := member["Devices"].([]interface{})
|
||||||
@@ -298,6 +309,49 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
|
|||||||
return dedupeStorage(out)
|
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{} {
|
func (r redfishSnapshotReader) probeSupermicroNVMeDiskBays(backplanePath string) []map[string]interface{} {
|
||||||
return r.probeDirectDiskBayChildren(joinPath(backplanePath, "/Drives"))
|
return r.probeDirectDiskBayChildren(joinPath(backplanePath, "/Drives"))
|
||||||
}
|
}
|
||||||
@@ -351,6 +405,12 @@ func (r redfishSnapshotReader) collectPSUs(chassisPaths []string) []models.PSU {
|
|||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
idx := 1
|
idx := 1
|
||||||
for _, chassisPath := range chassisPaths {
|
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 powerDoc, err := r.getJSON(joinPath(chassisPath, "/Power")); err == nil {
|
||||||
if members, ok := powerDoc["PowerSupplies"].([]interface{}); ok && len(members) > 0 {
|
if members, ok := powerDoc["PowerSupplies"].([]interface{}); ok && len(members) > 0 {
|
||||||
for _, item := range members {
|
for _, item := range members {
|
||||||
@@ -358,37 +418,10 @@ func (r redfishSnapshotReader) collectPSUs(chassisPaths []string) []models.PSU {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
psu := parsePSU(doc, idx)
|
idx = appendPSU(&out, seen, parsePSU(doc, idx), 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ type HardwareConfig struct {
|
|||||||
CPUs []CPU `json:"cpus,omitempty"`
|
CPUs []CPU `json:"cpus,omitempty"`
|
||||||
Memory []MemoryDIMM `json:"memory,omitempty"`
|
Memory []MemoryDIMM `json:"memory,omitempty"`
|
||||||
Storage []Storage `json:"storage,omitempty"`
|
Storage []Storage `json:"storage,omitempty"`
|
||||||
|
Volumes []StorageVolume `json:"volumes,omitempty"`
|
||||||
PCIeDevices []PCIeDevice `json:"pcie_devices,omitempty"`
|
PCIeDevices []PCIeDevice `json:"pcie_devices,omitempty"`
|
||||||
GPUs []GPU `json:"gpus,omitempty"`
|
GPUs []GPU `json:"gpus,omitempty"`
|
||||||
NetworkCards []NIC `json:"network_cards,omitempty"`
|
NetworkCards []NIC `json:"network_cards,omitempty"`
|
||||||
@@ -245,6 +246,19 @@ type Storage struct {
|
|||||||
ErrorDescription string `json:"error_description,omitempty"`
|
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
|
// PCIeDevice represents a PCIe device
|
||||||
type PCIeDevice struct {
|
type PCIeDevice struct {
|
||||||
Slot string `json:"slot"`
|
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)
|
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 {
|
if len(hw.PCIeDevices) > 0 {
|
||||||
b.WriteString("\n[PCIe Devices]\n")
|
b.WriteString("\n[PCIe Devices]\n")
|
||||||
for _, d := range hw.PCIeDevices {
|
for _, d := range hw.PCIeDevices {
|
||||||
@@ -295,6 +305,7 @@ func buildParserFieldSummary(result *models.AnalysisResult) map[string]any {
|
|||||||
"cpus": len(hw.CPUs),
|
"cpus": len(hw.CPUs),
|
||||||
"memory": len(hw.Memory),
|
"memory": len(hw.Memory),
|
||||||
"storage": len(hw.Storage),
|
"storage": len(hw.Storage),
|
||||||
|
"volumes": len(hw.Volumes),
|
||||||
"pcie": len(hw.PCIeDevices),
|
"pcie": len(hw.PCIeDevices),
|
||||||
"gpus": len(hw.GPUs),
|
"gpus": len(hw.GPUs),
|
||||||
"nics": len(hw.NetworkAdapters),
|
"nics": len(hw.NetworkAdapters),
|
||||||
@@ -307,6 +318,7 @@ func buildParserFieldSummary(result *models.AnalysisResult) map[string]any {
|
|||||||
"cpus": hw.CPUs,
|
"cpus": hw.CPUs,
|
||||||
"memory": hw.Memory,
|
"memory": hw.Memory,
|
||||||
"storage": hw.Storage,
|
"storage": hw.Storage,
|
||||||
|
"volumes": hw.Volumes,
|
||||||
"pcie_devices": hw.PCIeDevices,
|
"pcie_devices": hw.PCIeDevices,
|
||||||
"gpus": hw.GPUs,
|
"gpus": hw.GPUs,
|
||||||
"network_adapters": hw.NetworkAdapters,
|
"network_adapters": hw.NetworkAdapters,
|
||||||
|
|||||||
@@ -647,6 +647,7 @@ function renderConfig(data) {
|
|||||||
const config = data.hardware || data;
|
const config = data.hardware || data;
|
||||||
const spec = data.specification;
|
const spec = data.specification;
|
||||||
const devices = Array.isArray(config.devices) ? config.devices : [];
|
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 cpus = devices.filter(d => d.kind === 'cpu');
|
||||||
const memory = devices.filter(d => d.kind === 'memory');
|
const memory = devices.filter(d => d.kind === 'memory');
|
||||||
@@ -847,7 +848,7 @@ function renderConfig(data) {
|
|||||||
|
|
||||||
// Storage tab
|
// Storage tab
|
||||||
html += '<div class="config-tab-content" id="config-storage">';
|
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 storTotal = storage.length;
|
||||||
const storHDD = storage.filter(s => s.type === 'HDD').length;
|
const storHDD = storage.filter(s => s.type === 'HDD').length;
|
||||||
const storSSD = storage.filter(s => s.type === 'SSD').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">${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">${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">${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 class="stat-box model-box"><span class="stat-value">${typesSummary.join(', ') || '-'}</span><span class="stat-label">По типам</span></div>
|
||||||
</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>`;
|
<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>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
html += '</tbody></table>';
|
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 {
|
} else {
|
||||||
html += '<p class="no-data">Нет данных о накопителях</p>';
|
html += '<p class="no-data">Нет данных о накопителях</p>';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user