Improve Redfish recovery flow and raw export timing diagnostics

This commit is contained in:
2026-02-28 16:55:58 +03:00
parent 9a30705c9a
commit e0146adfff
10 changed files with 1437 additions and 58 deletions

View File

@@ -538,6 +538,10 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
const workers = 6
const heartbeatInterval = 5 * time.Second
crawlStart := time.Now()
memoryClient := c.httpClientWithTimeout(req, redfishSnapshotMemoryRequestTimeout())
memoryGate := make(chan struct{}, redfishSnapshotMemoryConcurrency())
branchLimiter := newRedfishSnapshotBranchLimiter(redfishSnapshotBranchConcurrency())
branchRetryPause := redfishSnapshotBranchRequeueBackoff()
out := make(map[string]interface{}, maxDocuments)
fetchErrors := make(map[string]string)
@@ -619,9 +623,59 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
for i := 0; i < workers; i++ {
go func(workerID int) {
for current := range jobs {
if !branchLimiter.tryAcquire(current) {
select {
case jobs <- current:
c.debugSnapshotf("worker=%d requeue branch-busy path=%s branch=%s queue_len=%d", workerID, current, redfishSnapshotBranchKey(current), len(jobs))
select {
case <-time.After(branchRetryPause):
case <-ctx.Done():
}
continue
default:
}
if !branchLimiter.waitAcquire(ctx, current, branchRetryPause) {
n := atomic.AddInt32(&processed, 1)
mu.Lock()
if _, ok := fetchErrors[current]; !ok && ctx.Err() != nil {
fetchErrors[current] = ctx.Err().Error()
}
mu.Unlock()
if emit != nil && ctx.Err() != nil {
emit(Progress{
Status: "running",
Progress: 92 + int(minInt32(n/200, 6)),
Message: fmt.Sprintf("Redfish snapshot: ошибка на %s", compactProgressPath(current)),
})
}
wg.Done()
continue
}
}
lastPath.Store(current)
c.debugSnapshotf("worker=%d fetch start path=%s queue_len=%d", workerID, current, len(jobs))
doc, err := c.getJSON(ctx, client, req, baseURL, current)
doc, err := func() (map[string]interface{}, error) {
defer branchLimiter.release(current)
if !isRedfishMemoryMemberPath(current) {
return c.getJSON(ctx, client, req, baseURL, current)
}
select {
case memoryGate <- struct{}{}:
case <-ctx.Done():
return nil, ctx.Err()
}
defer func() { <-memoryGate }()
return c.getJSONWithRetry(
ctx,
memoryClient,
req,
baseURL,
current,
redfishSnapshotMemoryRetryAttempts(),
redfishSnapshotMemoryRetryBackoff(),
)
}()
if err == nil {
mu.Lock()
out[current] = doc
@@ -1018,6 +1072,7 @@ func redfishCriticalEndpoints(systemPaths, chassisPaths, managerPaths []string)
add(joinPath(p, "/Oem/Public/ThermalConfig"))
add(joinPath(p, "/ThermalConfig"))
add(joinPath(p, "/Processors"))
add(joinPath(p, "/Memory"))
add(joinPath(p, "/Storage"))
add(joinPath(p, "/SimpleStorage"))
add(joinPath(p, "/PCIeDevices"))
@@ -1122,6 +1177,24 @@ func redfishCriticalPlanBAttempts() int {
return 3
}
func redfishCriticalMemberRetryAttempts() int {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_CRITICAL_MEMBER_RETRIES")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 6 {
return n
}
}
return 1
}
func redfishCriticalMemberRecoveryMax() int {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_CRITICAL_MEMBER_RECOVERY_MAX")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 1024 {
return n
}
}
return 48
}
func redfishCriticalRetryBackoff() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_CRITICAL_BACKOFF")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
@@ -1149,6 +1222,128 @@ func redfishCriticalSlowGap() time.Duration {
return 1200 * time.Millisecond
}
func redfishSnapshotMemoryRequestTimeout() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
}
return 25 * time.Second
}
func redfishSnapshotMemoryRetryAttempts() int {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_RETRIES")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 8 {
return n
}
}
return 2
}
func redfishSnapshotMemoryRetryBackoff() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_BACKOFF")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
return d
}
}
return 800 * time.Millisecond
}
func redfishSnapshotMemoryConcurrency() int {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_WORKERS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 8 {
return n
}
}
return 1
}
func redfishSnapshotBranchConcurrency() int {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_BRANCH_WORKERS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 8 {
return n
}
}
return 1
}
func redfishSnapshotBranchRequeueBackoff() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_BRANCH_BACKOFF")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
return d
}
}
return 35 * time.Millisecond
}
type redfishSnapshotBranchLimiter struct {
limit int
mu sync.Mutex
inFlight map[string]int
}
func newRedfishSnapshotBranchLimiter(limit int) *redfishSnapshotBranchLimiter {
if limit < 1 {
limit = 1
}
return &redfishSnapshotBranchLimiter{
limit: limit,
inFlight: make(map[string]int),
}
}
func (l *redfishSnapshotBranchLimiter) tryAcquire(path string) bool {
branch := redfishSnapshotBranchKey(path)
if branch == "" {
return true
}
l.mu.Lock()
defer l.mu.Unlock()
if l.inFlight[branch] >= l.limit {
return false
}
l.inFlight[branch]++
return true
}
func (l *redfishSnapshotBranchLimiter) waitAcquire(ctx context.Context, path string, backoff time.Duration) bool {
branch := redfishSnapshotBranchKey(path)
if branch == "" {
return true
}
if backoff < 0 {
backoff = 0
}
for {
if l.tryAcquire(path) {
return true
}
if ctx.Err() != nil {
return false
}
select {
case <-time.After(backoff):
case <-ctx.Done():
return false
}
}
}
func (l *redfishSnapshotBranchLimiter) release(path string) {
branch := redfishSnapshotBranchKey(path)
if branch == "" {
return
}
l.mu.Lock()
defer l.mu.Unlock()
switch n := l.inFlight[branch]; {
case n <= 1:
delete(l.inFlight, branch)
default:
l.inFlight[branch] = n - 1
}
}
func redfishLinkRefs(doc map[string]interface{}, topKey, nestedKey string) []string {
top, ok := doc[topKey].(map[string]interface{})
if !ok {
@@ -1238,6 +1433,54 @@ func shouldCrawlPath(path string) bool {
return true
}
func isRedfishMemoryMemberPath(path string) bool {
normalized := normalizeRedfishPath(path)
if !strings.Contains(normalized, "/Systems/") {
return false
}
if !strings.Contains(normalized, "/Memory/") {
return false
}
if strings.Contains(normalized, "/MemoryMetrics") || strings.Contains(normalized, "/Assembly") {
return false
}
after := strings.SplitN(normalized, "/Memory/", 2)
if len(after) != 2 {
return false
}
suffix := strings.TrimSpace(after[1])
if suffix == "" || strings.Contains(suffix, "/") {
return false
}
return true
}
func redfishCollectionHasExplicitMembers(doc map[string]interface{}) bool {
return len(redfishCollectionMemberRefs(doc)) > 0
}
func redfishSnapshotBranchKey(path string) string {
normalized := normalizeRedfishPath(path)
if normalized == "" || normalized == "/redfish/v1" {
return ""
}
parts := strings.Split(strings.Trim(normalized, "/"), "/")
if len(parts) < 3 {
return normalized
}
if parts[0] != "redfish" || parts[1] != "v1" {
return normalized
}
// Keep subsystem branches independent, e.g. Systems/1/Memory vs Systems/1/PCIeDevices.
if len(parts) >= 5 && (parts[2] == "Systems" || parts[2] == "Chassis" || parts[2] == "Managers") {
return "/" + strings.Join(parts[:5], "/")
}
if len(parts) >= 4 {
return "/" + strings.Join(parts[:4], "/")
}
return "/" + strings.Join(parts[:3], "/")
}
func (c *RedfishConnector) getLinkedPCIeFunctions(ctx context.Context, client *http.Client, req Request, baseURL string, doc map[string]interface{}) []map[string]interface{} {
// Newer Redfish payloads often keep function references in Links.PCIeFunctions.
if links, ok := doc["Links"].(map[string]interface{}); ok {
@@ -1381,14 +1624,51 @@ func (c *RedfishConnector) getJSONWithRetry(ctx context.Context, client *http.Cl
return nil, lastErr
}
func (c *RedfishConnector) collectCriticalCollectionMembersSequential(ctx context.Context, client *http.Client, req Request, baseURL, collectionPath string, collectionDoc map[string]interface{}) (map[string]interface{}, bool) {
func (c *RedfishConnector) collectCriticalCollectionMembersSequential(
ctx context.Context,
client *http.Client,
req Request,
baseURL string,
collectionDoc map[string]interface{},
rawTree map[string]interface{},
fetchErrs map[string]string,
) (map[string]interface{}, bool) {
memberPaths := redfishCollectionMemberRefs(collectionDoc)
if len(memberPaths) == 0 {
return nil, false
}
out := make(map[string]interface{})
retryableMissing := make([]string, 0, len(memberPaths))
unknownMissing := make([]string, 0, len(memberPaths))
for _, memberPath := range memberPaths {
doc, err := c.getJSONWithRetry(ctx, client, req, baseURL, memberPath, redfishCriticalRetryAttempts(), redfishCriticalRetryBackoff())
memberPath = normalizeRedfishPath(memberPath)
if memberPath == "" {
continue
}
if _, exists := rawTree[memberPath]; exists {
continue
}
if msg, hasErr := fetchErrs[memberPath]; hasErr {
if !isRetryableRedfishFetchError(fmt.Errorf("%s", msg)) {
continue
}
retryableMissing = append(retryableMissing, memberPath)
continue
}
unknownMissing = append(unknownMissing, memberPath)
}
candidates := append(retryableMissing, unknownMissing...)
if len(candidates) == 0 {
return nil, false
}
if maxMembers := redfishCriticalMemberRecoveryMax(); maxMembers > 0 && len(candidates) > maxMembers {
candidates = candidates[:maxMembers]
}
out := make(map[string]interface{}, len(candidates))
for _, memberPath := range candidates {
doc, err := c.getJSONWithRetry(ctx, client, req, baseURL, memberPath, redfishCriticalMemberRetryAttempts(), redfishCriticalRetryBackoff())
if err != nil {
continue
}
@@ -1398,6 +1678,7 @@ func (c *RedfishConnector) collectCriticalCollectionMembersSequential(ctx contex
}
func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context, client *http.Client, req Request, baseURL string, criticalPaths []string, rawTree map[string]interface{}, fetchErrs map[string]string, emit ProgressFn) int {
planBStart := time.Now()
var targets []string
seenTargets := make(map[string]struct{})
addTarget := func(path string) {
@@ -1493,15 +1774,20 @@ func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context,
rawTree[p] = doc
delete(fetchErrs, p)
recovered++
if members, ok := c.collectCriticalCollectionMembersSequential(ctx, client, req, baseURL, p, doc); ok {
if members, ok := c.collectCriticalCollectionMembersSequential(ctx, client, req, baseURL, doc, rawTree, fetchErrs); ok {
for mp, md := range members {
if _, exists := rawTree[mp]; !exists {
rawTree[mp] = md
recovered++
if _, exists := rawTree[mp]; exists {
continue
}
rawTree[mp] = md
delete(fetchErrs, mp)
recovered++
}
}
if shouldSlowProbeCriticalCollection(p) {
// Numeric slow-probe is expensive; skip it when collection already advertises explicit members.
if shouldSlowProbeCriticalCollection(p) && !redfishCollectionHasExplicitMembers(doc) {
if children := c.probeDirectRedfishCollectionChildrenSlow(ctx, client, req, baseURL, p); len(children) > 0 {
for cp, cd := range children {
if _, exists := rawTree[cp]; exists {
@@ -1514,6 +1800,7 @@ func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context,
}
continue
}
fetchErrs[p] = err.Error()
// If collection endpoint times out, still try direct child probing for common numeric paths.
if shouldSlowProbeCriticalCollection(p) {
@@ -1529,6 +1816,13 @@ func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context,
}
}
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 97,
Message: fmt.Sprintf("Redfish: plan-B завершен за %s (targets=%d, recovered=%d)", time.Since(planBStart).Round(time.Second), len(targets), recovered),
})
}
return recovered
}

View File

@@ -75,8 +75,11 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
nics := r.collectNICs(chassisPaths)
r.enrichNICsFromNetworkInterfaces(&nics, systemPaths)
thresholdSensors := r.collectThresholdSensors(chassisPaths)
thermalSensors := r.collectThermalSensors(chassisPaths)
powerSensors := r.collectPowerSensors(chassisPaths)
discreteEvents := r.collectDiscreteSensorEvents(chassisPaths)
healthEvents := r.collectHealthSummaryEvents(chassisPaths)
driveFetchWarningEvents := buildDriveFetchWarningEvents(rawPayloads)
managerDoc, _ := r.getJSON(primaryManager)
networkProtocolDoc, _ := r.getJSON(joinPath(primaryManager, "/NetworkProtocol"))
firmware := parseFirmware(systemDoc, biosDoc, managerDoc, secureBootDoc, networkProtocolDoc)
@@ -85,9 +88,9 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
applyBoardInfoFallbackFromDocs(&boardInfo, boardFallbackDocs)
result := &models.AnalysisResult{
Events: append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+1), healthEvents...), discreteEvents...),
Events: append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...),
FRU: make([]models.FRUInfo, 0),
Sensors: thresholdSensors,
Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)),
RawPayloads: cloneRawPayloads(rawPayloads),
Hardware: &models.HardwareConfig{
BoardInfo: boardInfo,
@@ -168,6 +171,55 @@ func redfishFetchErrorsFromRawPayloads(rawPayloads map[string]any) map[string]st
}
}
func buildDriveFetchWarningEvents(rawPayloads map[string]any) []models.Event {
errs := redfishFetchErrorsFromRawPayloads(rawPayloads)
if len(errs) == 0 {
return nil
}
paths := make([]string, 0, len(errs))
timeoutCount := 0
for path, msg := range errs {
normalizedPath := normalizeRedfishPath(path)
if !strings.Contains(strings.ToLower(normalizedPath), "/drives/") {
continue
}
paths = append(paths, normalizedPath)
low := strings.ToLower(msg)
if strings.Contains(low, "timeout") || strings.Contains(low, "deadline exceeded") {
timeoutCount++
}
}
if len(paths) == 0 {
return nil
}
sort.Strings(paths)
preview := paths
const maxPreview = 8
if len(preview) > maxPreview {
preview = preview[:maxPreview]
}
rawData := strings.Join(preview, ", ")
if len(paths) > len(preview) {
rawData = fmt.Sprintf("%s (+%d more)", rawData, len(paths)-len(preview))
}
if timeoutCount > 0 {
rawData = fmt.Sprintf("timeouts=%d; paths=%s", timeoutCount, rawData)
}
return []models.Event{
{
Timestamp: time.Now(),
Source: "Redfish",
EventType: "Collection Warning",
Severity: models.SeverityWarning,
Description: fmt.Sprintf("%d drive documents were unavailable; storage details may be incomplete", len(paths)),
RawData: rawData,
},
}
}
func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo {
docs, err := r.getCollectionMembers("/redfish/v1/UpdateService/FirmwareInventory")
if err != nil || len(docs) == 0 {
@@ -219,9 +271,12 @@ func (r redfishSnapshotReader) collectThresholdSensors(chassisPaths []string) []
out := make([]models.SensorReading, 0)
seen := make(map[string]struct{})
for _, chassisPath := range chassisPaths {
docs, err := r.getCollectionMembers(joinPath(chassisPath, "/ThresholdSensors"))
if err != nil || len(docs) == 0 {
continue
thresholdPath := joinPath(chassisPath, "/ThresholdSensors")
docs, _ := r.getCollectionMembers(thresholdPath)
if len(docs) == 0 {
if thresholdDoc, err := r.getJSON(thresholdPath); err == nil {
docs = append(docs, redfishInlineSensors(thresholdDoc)...)
}
}
for _, doc := range docs {
sensor, ok := parseThresholdSensor(doc)
@@ -293,37 +348,235 @@ func parseThresholdSensor(doc map[string]interface{}) (models.SensorReading, boo
func (r redfishSnapshotReader) collectDiscreteSensorEvents(chassisPaths []string) []models.Event {
out := make([]models.Event, 0)
for _, chassisPath := range chassisPaths {
docs, err := r.getCollectionMembers(joinPath(chassisPath, "/DiscreteSensors"))
if err != nil || len(docs) == 0 {
continue
discretePath := joinPath(chassisPath, "/DiscreteSensors")
docs, _ := r.getCollectionMembers(discretePath)
if len(docs) == 0 {
if discreteDoc, err := r.getJSON(discretePath); err == nil {
docs = append(docs, redfishInlineSensors(discreteDoc)...)
}
}
for _, doc := range docs {
name := firstNonEmpty(asString(doc["Name"]), asString(doc["Id"]))
status := mapStatus(doc["Status"])
if status == "" {
status = firstNonEmpty(asString(doc["Health"]), asString(doc["State"]))
}
if name == "" || status == "" {
ev, ok := parseDiscreteSensorEvent(doc)
if !ok {
continue
}
normalized := strings.ToLower(strings.TrimSpace(status))
if normalized == "ok" || normalized == "enabled" || normalized == "normal" || normalized == "present" {
continue
}
out = append(out, models.Event{
Timestamp: time.Now(),
Source: "Redfish",
SensorName: name,
EventType: "Discrete Sensor Status",
Severity: models.SeverityWarning,
Description: fmt.Sprintf("%s reports %s", name, status),
RawData: firstNonEmpty(asString(doc["Description"]), status),
})
out = append(out, ev)
}
}
return out
}
func parseDiscreteSensorEvent(doc map[string]interface{}) (models.Event, bool) {
name := firstNonEmpty(asString(doc["Name"]), asString(doc["Id"]))
status := mapStatus(doc["Status"])
if status == "" {
status = firstNonEmpty(asString(doc["Health"]), asString(doc["State"]))
}
if name == "" || status == "" {
return models.Event{}, false
}
normalized := strings.ToLower(strings.TrimSpace(status))
if normalized == "ok" || normalized == "enabled" || normalized == "normal" || normalized == "present" {
return models.Event{}, false
}
return models.Event{
Timestamp: time.Now(),
Source: "Redfish",
SensorName: name,
EventType: "Discrete Sensor Status",
Severity: models.SeverityWarning,
Description: fmt.Sprintf("%s reports %s", name, status),
RawData: firstNonEmpty(asString(doc["Description"]), status),
}, true
}
func (r redfishSnapshotReader) collectThermalSensors(chassisPaths []string) []models.SensorReading {
out := make([]models.SensorReading, 0)
for _, chassisPath := range chassisPaths {
doc, err := r.getJSON(joinPath(chassisPath, "/Thermal"))
if err != nil || len(doc) == 0 {
continue
}
for _, fanDoc := range redfishArrayObjects(doc["Fans"]) {
out = append(out, parseThermalFanSensor(fanDoc))
}
for _, tempDoc := range redfishArrayObjects(doc["Temperatures"]) {
out = append(out, parseThermalTemperatureSensor(tempDoc))
}
}
return out
}
func (r redfishSnapshotReader) collectPowerSensors(chassisPaths []string) []models.SensorReading {
out := make([]models.SensorReading, 0)
for _, chassisPath := range chassisPaths {
doc, err := r.getJSON(joinPath(chassisPath, "/Power"))
if err != nil || len(doc) == 0 {
continue
}
out = append(out, parsePowerOemPublicSensors(doc)...)
for _, controlDoc := range redfishArrayObjects(doc["PowerControl"]) {
if sensor, ok := parsePowerControlSensor(controlDoc); ok {
out = append(out, sensor)
}
}
for _, psuDoc := range redfishArrayObjects(doc["PowerSupplies"]) {
out = append(out, parsePowerSupplySensors(psuDoc)...)
}
}
return out
}
func parseThermalFanSensor(doc map[string]interface{}) models.SensorReading {
name := firstNonEmpty(asString(doc["Name"]), asString(doc["MemberId"]), "Fan")
unit := firstNonEmpty(asString(doc["ReadingUnits"]), "RPM")
value := asFloat(doc["Reading"])
raw := firstNonEmpty(asString(doc["Reading"]), asString(doc["Name"]))
status := firstNonEmpty(mapStatus(doc["Status"]), asString(doc["State"]), asString(doc["Health"]), "unknown")
return models.SensorReading{
Name: name,
Type: "fan_speed",
Value: value,
Unit: unit,
RawValue: raw,
Status: status,
}
}
func parseThermalTemperatureSensor(doc map[string]interface{}) models.SensorReading {
name := firstNonEmpty(asString(doc["Name"]), asString(doc["MemberId"]), "Temperature")
reading := asFloat(doc["ReadingCelsius"])
raw := asString(doc["ReadingCelsius"])
if raw == "" {
reading = asFloat(doc["Reading"])
raw = asString(doc["Reading"])
}
status := firstNonEmpty(mapStatus(doc["Status"]), asString(doc["State"]), asString(doc["Health"]), "unknown")
return models.SensorReading{
Name: name,
Type: "temperature",
Value: reading,
Unit: "C",
RawValue: raw,
Status: status,
}
}
func parsePowerOemPublicSensors(doc map[string]interface{}) []models.SensorReading {
oem, ok := doc["Oem"].(map[string]interface{})
if !ok {
return nil
}
public, ok := oem["Public"].(map[string]interface{})
if !ok {
return nil
}
var out []models.SensorReading
add := func(name, key string) {
raw := asString(public[key])
if strings.TrimSpace(raw) == "" {
return
}
out = append(out, models.SensorReading{
Name: name,
Type: "power",
Value: asFloat(public[key]),
Unit: "W",
RawValue: raw,
Status: "OK",
})
}
add("Total_Power", "TotalPower")
add("CPU_Power", "CurrentCPUPowerWatts")
add("Memory_Power", "CurrentMemoryPowerWatts")
add("Fan_Power", "CurrentFANPowerWatts")
return out
}
func parsePowerControlSensor(doc map[string]interface{}) (models.SensorReading, bool) {
raw := asString(doc["PowerConsumedWatts"])
if strings.TrimSpace(raw) == "" {
return models.SensorReading{}, false
}
name := firstNonEmpty(asString(doc["Name"]), asString(doc["MemberId"]), "PowerControl")
status := firstNonEmpty(mapStatus(doc["Status"]), asString(doc["State"]), asString(doc["Health"]), "unknown")
return models.SensorReading{
Name: name + "_Consumed",
Type: "power",
Value: asFloat(doc["PowerConsumedWatts"]),
Unit: "W",
RawValue: raw,
Status: status,
}, true
}
func parsePowerSupplySensors(doc map[string]interface{}) []models.SensorReading {
name := firstNonEmpty(asString(doc["Name"]), "PSU")
status := firstNonEmpty(mapStatus(doc["Status"]), asString(doc["State"]), asString(doc["Health"]), "unknown")
var out []models.SensorReading
add := func(suffix, key, unit string) {
raw := asString(doc[key])
if strings.TrimSpace(raw) == "" {
return
}
out = append(out, models.SensorReading{
Name: fmt.Sprintf("%s_%s", name, suffix),
Type: strings.ToLower(suffix),
Value: asFloat(doc[key]),
Unit: unit,
RawValue: raw,
Status: status,
})
}
add("InputPower", "PowerInputWatts", "W")
add("OutputPower", "LastPowerOutputWatts", "W")
add("InputVoltage", "LineInputVoltage", "V")
return out
}
func redfishArrayObjects(v any) []map[string]interface{} {
list, ok := v.([]interface{})
if !ok || len(list) == 0 {
return nil
}
out := make([]map[string]interface{}, 0, len(list))
for _, item := range list {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
out = append(out, m)
}
return out
}
func redfishInlineSensors(doc map[string]interface{}) []map[string]interface{} {
return redfishArrayObjects(doc["Sensors"])
}
func dedupeSensorReadings(items []models.SensorReading) []models.SensorReading {
if len(items) <= 1 {
return items
}
out := make([]models.SensorReading, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, s := range items {
key := strings.ToLower(strings.TrimSpace(s.Name) + "|" + strings.TrimSpace(s.Type))
if strings.TrimSpace(key) == "|" {
key = strings.ToLower(strings.TrimSpace(s.RawValue))
}
if strings.TrimSpace(key) == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, s)
}
return out
}
func (r redfishSnapshotReader) collectHealthSummaryEvents(chassisPaths []string) []models.Event {
out := make([]models.Event, 0)
for _, chassisPath := range chassisPaths {

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
@@ -303,6 +304,210 @@ func TestReplayRedfishFromRawPayloads_FallbackCollectionMembersByPrefix(t *testi
}
}
func TestReplayRedfishFromRawPayloads_ParsesInlineThresholdAndDiscreteSensors(t *testing.T) {
raw := map[string]any{
"redfish_tree": map[string]interface{}{
"/redfish/v1": map[string]interface{}{
"Systems": map[string]interface{}{"@odata.id": "/redfish/v1/Systems"},
"Chassis": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis"},
"Managers": map[string]interface{}{"@odata.id": "/redfish/v1/Managers"},
},
"/redfish/v1/Systems": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
},
},
"/redfish/v1/Systems/1": map[string]interface{}{
"Id": "1",
"Manufacturer": "Inspur",
"Model": "NF5688M7",
"SerialNumber": "23E100051",
},
"/redfish/v1/Chassis": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"},
},
},
"/redfish/v1/Chassis/1": map[string]interface{}{
"Id": "1",
},
"/redfish/v1/Managers": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"},
},
},
"/redfish/v1/Managers/1": map[string]interface{}{
"Id": "1",
},
"/redfish/v1/Chassis/1/ThresholdSensors": map[string]interface{}{
"Sensors": []interface{}{
map[string]interface{}{
"Name": "Inlet_Temp",
"Reading": 16,
"ReadingUnits": "deg_c",
"State": "Enabled",
},
},
},
"/redfish/v1/Chassis/1/DiscreteSensors": map[string]interface{}{
"Sensors": []interface{}{
map[string]interface{}{
"Name": "PSU_Redundant",
"State": "Disabled",
},
},
},
},
}
got, err := ReplayRedfishFromRawPayloads(raw, nil)
if err != nil {
t.Fatalf("replay failed: %v", err)
}
if len(got.Sensors) == 0 {
t.Fatalf("expected sensors from inline ThresholdSensors")
}
foundSensor := false
for _, s := range got.Sensors {
if s.Name == "Inlet_Temp" {
foundSensor = true
break
}
}
if !foundSensor {
t.Fatalf("expected Inlet_Temp sensor in replay output")
}
foundEvent := false
for _, ev := range got.Events {
if ev.EventType == "Discrete Sensor Status" && ev.SensorName == "PSU_Redundant" {
foundEvent = true
break
}
}
if !foundEvent {
t.Fatalf("expected discrete sensor warning event from inline DiscreteSensors")
}
}
func TestReplayRedfishFromRawPayloads_CollectsThermalAndPowerSensors(t *testing.T) {
raw := map[string]any{
"redfish_tree": map[string]interface{}{
"/redfish/v1": map[string]interface{}{
"Systems": map[string]interface{}{"@odata.id": "/redfish/v1/Systems"},
"Chassis": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis"},
"Managers": map[string]interface{}{"@odata.id": "/redfish/v1/Managers"},
},
"/redfish/v1/Systems": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
},
},
"/redfish/v1/Systems/1": map[string]interface{}{
"Id": "1",
"Manufacturer": "Inspur",
"Model": "NF5688M7",
"SerialNumber": "23E100051",
},
"/redfish/v1/Chassis": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"},
},
},
"/redfish/v1/Chassis/1": map[string]interface{}{
"Id": "1",
},
"/redfish/v1/Managers": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"},
},
},
"/redfish/v1/Managers/1": map[string]interface{}{
"Id": "1",
},
"/redfish/v1/Chassis/1/Thermal": map[string]interface{}{
"Fans": []interface{}{
map[string]interface{}{
"Name": "FAN0_F_Speed",
"Reading": 9279,
"ReadingUnits": "RPM",
"Status": map[string]interface{}{
"Health": "OK",
"State": "Enabled",
},
},
},
"Temperatures": []interface{}{
map[string]interface{}{
"Name": "CPU0_Temp",
"ReadingCelsius": 44,
"Status": map[string]interface{}{
"Health": "OK",
"State": "Enabled",
},
},
},
},
"/redfish/v1/Chassis/1/Power": map[string]interface{}{
"Oem": map[string]interface{}{
"Public": map[string]interface{}{
"TotalPower": 1836,
"CurrentCPUPowerWatts": 304,
"CurrentMemoryPowerWatts": 75,
"CurrentFANPowerWatts": 180,
},
},
"PowerControl": []interface{}{
map[string]interface{}{
"Name": "System Power Control 1",
"PowerConsumedWatts": 1836,
"Status": map[string]interface{}{
"Health": "OK",
"State": "Enabled",
},
},
},
"PowerSupplies": []interface{}{
map[string]interface{}{
"Name": "Power Supply 1",
"PowerInputWatts": 180,
"LastPowerOutputWatts": 155,
"LineInputVoltage": 223.25,
"Status": map[string]interface{}{
"Health": "OK",
"State": "Enabled",
},
},
},
},
},
}
got, err := ReplayRedfishFromRawPayloads(raw, nil)
if err != nil {
t.Fatalf("replay failed: %v", err)
}
if len(got.Sensors) == 0 {
t.Fatalf("expected non-empty sensors")
}
expected := map[string]bool{
"FAN0_F_Speed": false,
"CPU0_Temp": false,
"Total_Power": false,
"System Power Control 1_Consumed": false,
"Power Supply 1_InputPower": false,
}
for _, s := range got.Sensors {
if _, ok := expected[s.Name]; ok {
expected[s.Name] = true
}
}
for name, found := range expected {
if !found {
t.Fatalf("expected sensor %q in replay output", name)
}
}
}
func TestEnrichNICFromPCIeFunctions(t *testing.T) {
nic := parseNIC(map[string]interface{}{
"Id": "1",
@@ -502,6 +707,79 @@ func TestRecoverCriticalRedfishDocsPlanB_RetriesMembersFromExistingCollection(t
}
}
func TestRecoverCriticalRedfishDocsPlanB_RetriesMembersFromSystemMemoryCollection(t *testing.T) {
t.Setenv("LOGPILE_REDFISH_CRITICAL_COOLDOWN", "0s")
t.Setenv("LOGPILE_REDFISH_CRITICAL_SLOW_GAP", "0s")
t.Setenv("LOGPILE_REDFISH_CRITICAL_PLANB_RETRIES", "1")
t.Setenv("LOGPILE_REDFISH_CRITICAL_RETRIES", "1")
t.Setenv("LOGPILE_REDFISH_CRITICAL_BACKOFF", "0s")
const systemPath = "/redfish/v1/Systems/1"
const memoryPath = "/redfish/v1/Systems/1/Memory"
const dimmPath = "/redfish/v1/Systems/1/Memory/CPU1_C1D1"
mux := http.NewServeMux()
mux.HandleFunc(dimmPath, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"Id": "CPU1_C1D1",
"Name": "CPU1_C1D1",
"DeviceLocator": "CPU1_C1D1",
"CapacityMiB": 65536,
"MemoryDeviceType": "DDR5",
"Status": map[string]interface{}{"State": "Enabled", "Health": "OK"},
"SerialNumber": "DIMM-SN-001",
"PartNumber": "DIMM-PN-001",
})
})
ts := httptest.NewServer(mux)
defer ts.Close()
rawTree := map[string]interface{}{
memoryPath: map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": dimmPath},
},
},
}
fetchErrs := map[string]string{
dimmPath: `Get "https://example/redfish/v1/Systems/1/Memory/CPU1_C1D1": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`,
}
criticalPaths := redfishCriticalEndpoints([]string{systemPath}, nil, nil)
hasMemoryPath := false
for _, p := range criticalPaths {
if p == memoryPath {
hasMemoryPath = true
break
}
}
if !hasMemoryPath {
t.Fatalf("expected critical endpoints to include %s", memoryPath)
}
c := NewRedfishConnector()
recovered := c.recoverCriticalRedfishDocsPlanB(
context.Background(),
ts.Client(),
Request{},
ts.URL,
criticalPaths,
rawTree,
fetchErrs,
nil,
)
if recovered == 0 {
t.Fatalf("expected plan-B to recover at least one DIMM document")
}
if _, ok := rawTree[dimmPath]; !ok {
t.Fatalf("expected recovered DIMM doc for %s", dimmPath)
}
if _, ok := fetchErrs[dimmPath]; ok {
t.Fatalf("expected DIMM fetch error for %s to be cleared", dimmPath)
}
}
func TestReplayCollectStorage_ProbesSupermicroNVMeDiskBayWhenCollectionEmpty(t *testing.T) {
r := redfishSnapshotReader{tree: map[string]interface{}{
"/redfish/v1/Systems": map[string]interface{}{
@@ -828,6 +1106,70 @@ func TestReplayRedfishFromRawPayloads_AddsMissingServerModelWarning(t *testing.T
}
}
func TestReplayRedfishFromRawPayloads_AddsDriveFetchWarning(t *testing.T) {
raw := map[string]any{
"redfish_tree": map[string]interface{}{
"/redfish/v1": map[string]interface{}{
"Systems": map[string]interface{}{"@odata.id": "/redfish/v1/Systems"},
"Chassis": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis"},
"Managers": map[string]interface{}{"@odata.id": "/redfish/v1/Managers"},
},
"/redfish/v1/Systems": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
},
},
"/redfish/v1/Systems/1": map[string]interface{}{
"Manufacturer": "Inspur",
"Model": "NF5688M7",
"SerialNumber": "23E100051",
},
"/redfish/v1/Chassis": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"},
},
},
"/redfish/v1/Chassis/1": map[string]interface{}{
"Id": "1",
"Manufacturer": "Inspur",
"Model": "NF5688M7",
},
"/redfish/v1/Managers": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"},
},
},
"/redfish/v1/Managers/1": map[string]interface{}{
"Id": "1",
},
},
"redfish_fetch_errors": []map[string]interface{}{
{
"path": "/redfish/v1/Chassis/1/Drives/FP00HDD00",
"error": `Get "...": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`,
},
},
}
got, err := ReplayRedfishFromRawPayloads(raw, nil)
if err != nil {
t.Fatalf("replay failed: %v", err)
}
found := false
for _, ev := range got.Events {
if ev.Source == "Redfish" &&
ev.EventType == "Collection Warning" &&
strings.Contains(strings.ToLower(ev.Description), "drive documents were unavailable") {
found = true
break
}
}
if !found {
t.Fatalf("expected collection warning event for drive fetch errors")
}
}
func TestReplayCollectGPUs_SkipsModelOnlyDuplicateFromGraphicsControllers(t *testing.T) {
r := redfishSnapshotReader{tree: map[string]interface{}{
"/redfish/v1/Systems/1/PCIeDevices": map[string]interface{}{
@@ -1008,6 +1350,48 @@ func TestShouldCrawlPath_MemorySubresourcesAreSkipped(t *testing.T) {
}
}
func TestIsRedfishMemoryMemberPath(t *testing.T) {
cases := []struct {
path string
want bool
}{
{path: "/redfish/v1/Systems/1/Memory", want: false},
{path: "/redfish/v1/Systems/1/Memory/CPU0_C0D0", want: true},
{path: "/redfish/v1/Systems/1/Memory/CPU0_C0D0/Assembly", want: false},
{path: "/redfish/v1/Systems/1/Memory/CPU0_C0D0/MemoryMetrics", want: false},
{path: "/redfish/v1/Chassis/1/Memory/CPU0_C0D0", want: false},
}
for _, tc := range cases {
got := isRedfishMemoryMemberPath(tc.path)
if got != tc.want {
t.Fatalf("isRedfishMemoryMemberPath(%q) = %v, want %v", tc.path, got, tc.want)
}
}
}
func TestRedfishSnapshotBranchKey(t *testing.T) {
cases := []struct {
path string
want string
}{
{path: "", want: ""},
{path: "/redfish/v1", want: ""},
{path: "/redfish/v1/Systems", want: "/redfish/v1/Systems"},
{path: "/redfish/v1/Systems/1", want: "/redfish/v1/Systems/1"},
{path: "/redfish/v1/Systems/1/Memory", want: "/redfish/v1/Systems/1/Memory"},
{path: "/redfish/v1/Systems/1/Memory/CPU0_C0D0", want: "/redfish/v1/Systems/1/Memory"},
{path: "/redfish/v1/Systems/1/PCIeDevices/GPU1", want: "/redfish/v1/Systems/1/PCIeDevices"},
{path: "/redfish/v1/Chassis/1/Sensors/1", want: "/redfish/v1/Chassis/1/Sensors"},
{path: "/redfish/v1/UpdateService/FirmwareInventory/BIOS", want: "/redfish/v1/UpdateService/FirmwareInventory"},
}
for _, tc := range cases {
got := redfishSnapshotBranchKey(tc.path)
if got != tc.want {
t.Fatalf("redfishSnapshotBranchKey(%q) = %q, want %q", tc.path, got, tc.want)
}
}
}
func TestShouldPostProbeCollectionPath(t *testing.T) {
if !shouldPostProbeCollectionPath("/redfish/v1/Chassis/1/Sensors") {
t.Fatalf("expected sensors collection to be post-probed")

View File

@@ -58,6 +58,7 @@ type AssetJSON struct {
} `json:"MemInfo"`
HddInfo []struct {
PresentBitmap []int `json:"PresentBitmap"`
SerialNumber string `json:"SerialNumber"`
Manufacturer string `json:"Manufacturer"`
ModelName string `json:"ModelName"`
@@ -163,6 +164,18 @@ func ParseAssetJSON(content []byte) (*models.HardwareConfig, error) {
// Parse storage info
seenHDDFW := make(map[string]bool)
for _, hdd := range asset.HddInfo {
slot := normalizeAssetHDDSlot(hdd.LocationString, hdd.Location, hdd.DiskInterfaceType)
modelName := strings.TrimSpace(hdd.ModelName)
serial := normalizeRedisValue(hdd.SerialNumber)
present := bitmapHasAnyValue(hdd.PresentBitmap)
if !present && (slot != "" || modelName != "" || serial != "" || hdd.Capacity > 0) {
present = true
}
if !present && slot == "" && modelName == "" && serial == "" && hdd.Capacity == 0 {
continue
}
storageType := "HDD"
if hdd.DiskInterfaceType == 5 {
storageType = "NVMe"
@@ -171,30 +184,30 @@ func ParseAssetJSON(content []byte) (*models.HardwareConfig, error) {
}
// Resolve manufacturer: try vendor ID first, then model name extraction
modelName := strings.TrimSpace(hdd.ModelName)
manufacturer := resolveManufacturer(hdd.Manufacturer, modelName)
config.Storage = append(config.Storage, models.Storage{
Slot: hdd.LocationString,
Slot: slot,
Type: storageType,
Model: modelName,
SizeGB: hdd.Capacity,
SerialNumber: hdd.SerialNumber,
SerialNumber: serial,
Manufacturer: manufacturer,
Firmware: hdd.FirmwareVersion,
Interface: diskInterfaceToString(hdd.DiskInterfaceType),
Present: present,
})
// Add HDD firmware to firmware list (deduplicated by model+version)
if hdd.FirmwareVersion != "" {
fwKey := modelName + ":" + hdd.FirmwareVersion
if !seenHDDFW[fwKey] {
slot := hdd.LocationString
if slot == "" {
slot = fmt.Sprintf("%s %dGB", storageType, hdd.Capacity)
fwSlot := slot
if fwSlot == "" {
fwSlot = fmt.Sprintf("%s %dGB", storageType, hdd.Capacity)
}
config.Firmware = append(config.Firmware, models.FirmwareInfo{
DeviceName: fmt.Sprintf("%s (%s)", modelName, slot),
DeviceName: fmt.Sprintf("%s (%s)", modelName, fwSlot),
Version: hdd.FirmwareVersion,
})
seenHDDFW[fwKey] = true
@@ -323,6 +336,29 @@ func diskInterfaceToString(ifType int) string {
}
}
func normalizeAssetHDDSlot(locationString string, location int, diskInterfaceType int) string {
slot := strings.TrimSpace(locationString)
if slot != "" {
return slot
}
if location < 0 {
return ""
}
if diskInterfaceType == 5 {
return fmt.Sprintf("OB%02d", location+1)
}
return fmt.Sprintf("%d", location)
}
func bitmapHasAnyValue(values []int) bool {
for _, v := range values {
if v != 0 {
return true
}
}
return false
}
func pcieLinkSpeedToString(speed int) string {
switch speed {
case 1:

View File

@@ -221,15 +221,19 @@ func parseHDDInfo(text string, hw *models.HardwareConfig) {
})
for _, hdd := range hddInfo {
if hdd.Present == 1 {
hddMap[hdd.LocationString] = struct {
slot := strings.TrimSpace(hdd.LocationString)
if slot == "" {
slot = fmt.Sprintf("HDD%d", hdd.ID)
}
hddMap[slot] = struct {
SN string
Model string
Firmware string
Mfr string
}{
SN: strings.TrimSpace(hdd.SN),
SN: normalizeRedisValue(hdd.SN),
Model: strings.TrimSpace(hdd.Model),
Firmware: strings.TrimSpace(hdd.Firmware),
Firmware: normalizeRedisValue(hdd.Firmware),
Mfr: strings.TrimSpace(hdd.Manufacture),
}
}
@@ -245,18 +249,19 @@ func parseHDDInfo(text string, hw *models.HardwareConfig) {
if !ok {
continue
}
if hw.Storage[i].SerialNumber == "" {
if normalizeRedisValue(hw.Storage[i].SerialNumber) == "" {
hw.Storage[i].SerialNumber = detail.SN
}
if hw.Storage[i].Model == "" {
hw.Storage[i].Model = detail.Model
}
if hw.Storage[i].Firmware == "" {
if normalizeRedisValue(hw.Storage[i].Firmware) == "" {
hw.Storage[i].Firmware = detail.Firmware
}
if hw.Storage[i].Manufacturer == "" {
hw.Storage[i].Manufacturer = detail.Mfr
}
hw.Storage[i].Present = true
}
// If storage is empty, populate from HDD info
@@ -275,16 +280,21 @@ func parseHDDInfo(text string, hw *models.HardwareConfig) {
if hdd.CapableSpeed == 12 {
iface = "SAS"
}
slot := strings.TrimSpace(hdd.LocationString)
if slot == "" {
slot = fmt.Sprintf("HDD%d", hdd.ID)
}
hw.Storage = append(hw.Storage, models.Storage{
Slot: hdd.LocationString,
Slot: slot,
Type: storType,
Model: model,
SizeGB: hdd.Capacity,
SerialNumber: strings.TrimSpace(hdd.SN),
SerialNumber: normalizeRedisValue(hdd.SN),
Manufacturer: extractStorageManufacturer(model),
Firmware: strings.TrimSpace(hdd.Firmware),
Firmware: normalizeRedisValue(hdd.Firmware),
Interface: iface,
Present: true,
})
}
}
@@ -732,28 +742,88 @@ func parseDiskBackplaneInfo(text string, hw *models.HardwareConfig) {
return
}
// Create storage entries based on backplane info
presentByBackplane := make(map[int]int)
totalPresent := 0
for _, bp := range backplaneInfo {
if bp.Present != 1 {
continue
}
if bp.DriverCount <= 0 {
continue
}
limit := bp.DriverCount
if bp.PortCount > 0 && limit > bp.PortCount {
limit = bp.PortCount
}
presentByBackplane[bp.BackplaneIndex] = limit
totalPresent += limit
}
if totalPresent == 0 {
return
}
existingPresent := countPresentStorage(hw.Storage)
remaining := totalPresent - existingPresent
if remaining <= 0 {
return
}
for _, bp := range backplaneInfo {
if bp.Present != 1 || remaining <= 0 {
continue
}
driveCount := presentByBackplane[bp.BackplaneIndex]
if driveCount <= 0 {
continue
}
location := "Rear"
if bp.Front == 1 {
location = "Front"
}
// Create entries for each port (disk slot)
for i := 0; i < bp.PortCount; i++ {
isPresent := i < bp.DriverCount
for i := 0; i < driveCount && remaining > 0; i++ {
slot := fmt.Sprintf("BP%d:%d", bp.BackplaneIndex, i)
if hasStorageSlot(hw.Storage, slot) {
continue
}
hw.Storage = append(hw.Storage, models.Storage{
Slot: fmt.Sprintf("%d", i),
Present: isPresent,
Slot: slot,
Present: true,
Location: location,
BackplaneID: bp.BackplaneIndex,
Type: "HDD",
})
remaining--
}
}
}
func countPresentStorage(storage []models.Storage) int {
count := 0
for _, dev := range storage {
if dev.Present {
count++
continue
}
if strings.TrimSpace(dev.Slot) != "" && (normalizeRedisValue(dev.Model) != "" || normalizeRedisValue(dev.SerialNumber) != "" || dev.SizeGB > 0) {
count++
}
}
return count
}
func hasStorageSlot(storage []models.Storage, slot string) bool {
slot = strings.ToLower(strings.TrimSpace(slot))
if slot == "" {
return false
}
for _, dev := range storage {
if strings.ToLower(strings.TrimSpace(dev.Slot)) == slot {
return true
}
}
return false
}

View File

@@ -15,7 +15,7 @@ import (
// parserVersion - version of this parser module
// IMPORTANT: Increment this version when making changes to parser logic!
const parserVersion = "1.3.0"
const parserVersion = "1.4.0"
func init() {
parser.Register(&Parser{})
@@ -178,6 +178,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
// Mark problematic GPUs from IDL errors like "BIOS miss F_GPU6".
if result.Hardware != nil {
applyGPUStatusFromEvents(result.Hardware, result.Events)
enrichStorageFromSerialFallbackFiles(files, result.Hardware)
}
return result, nil

View File

@@ -0,0 +1,148 @@
package inspur
import (
"regexp"
"sort"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
var bpHDDSerialTokenRegex = regexp.MustCompile(`[A-Za-z0-9]{8,32}`)
func enrichStorageFromSerialFallbackFiles(files []parser.ExtractedFile, hw *models.HardwareConfig) {
if hw == nil {
return
}
f := parser.FindFileByName(files, "BpHDDSerialNumber.info")
if f == nil {
return
}
serials := extractBPHDDSerials(f.Content)
if len(serials) == 0 {
return
}
applyStorageSerialFallback(hw, serials)
}
func extractBPHDDSerials(content []byte) []string {
if len(content) == 0 {
return nil
}
matches := bpHDDSerialTokenRegex.FindAllString(string(content), -1)
if len(matches) == 0 {
return nil
}
out := make([]string, 0, len(matches))
seen := make(map[string]struct{}, len(matches))
for _, m := range matches {
v := normalizeRedisValue(m)
if !looksLikeStorageSerial(v) {
continue
}
key := strings.ToLower(v)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, v)
}
return out
}
func looksLikeStorageSerial(v string) bool {
if len(v) < 8 {
return false
}
hasLetter := false
hasDigit := false
for _, r := range v {
switch {
case r >= 'A' && r <= 'Z':
hasLetter = true
case r >= 'a' && r <= 'z':
hasLetter = true
case r >= '0' && r <= '9':
hasDigit = true
default:
return false
}
}
return hasLetter && hasDigit
}
func applyStorageSerialFallback(hw *models.HardwareConfig, serials []string) {
if hw == nil || len(hw.Storage) == 0 || len(serials) == 0 {
return
}
existing := make(map[string]struct{}, len(hw.Storage))
for _, dev := range hw.Storage {
if sn := normalizeRedisValue(dev.SerialNumber); sn != "" {
existing[strings.ToLower(sn)] = struct{}{}
}
}
filtered := make([]string, 0, len(serials))
for _, sn := range serials {
key := strings.ToLower(sn)
if _, ok := existing[key]; ok {
continue
}
filtered = append(filtered, sn)
}
if len(filtered) == 0 {
return
}
type target struct {
index int
rank int
slot string
}
targets := make([]target, 0, len(hw.Storage))
for i := range hw.Storage {
dev := hw.Storage[i]
if normalizeRedisValue(dev.SerialNumber) != "" {
continue
}
if !dev.Present && strings.TrimSpace(dev.Slot) == "" {
continue
}
rank := 0
if !dev.Present {
rank += 10
}
if strings.EqualFold(strings.TrimSpace(dev.Type), "NVMe") {
rank += 5
}
if strings.TrimSpace(dev.Slot) == "" {
rank += 4
}
targets = append(targets, target{
index: i,
rank: rank,
slot: strings.ToLower(strings.TrimSpace(dev.Slot)),
})
}
if len(targets) == 0 {
return
}
sort.Slice(targets, func(i, j int) bool {
if targets[i].rank != targets[j].rank {
return targets[i].rank < targets[j].rank
}
return targets[i].slot < targets[j].slot
})
for i := 0; i < len(targets) && i < len(filtered); i++ {
dev := &hw.Storage[targets[i].index]
dev.SerialNumber = filtered[i]
if !dev.Present {
dev.Present = true
}
}
}

View File

@@ -0,0 +1,106 @@
package inspur
import (
"strings"
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
func TestParseAssetJSON_HddSlotFallbackAndPresence(t *testing.T) {
content := []byte(`{
"HddInfo": [
{
"PresentBitmap": [1],
"SerialNumber": "",
"Manufacturer": "",
"ModelName": "",
"FirmwareVersion": "",
"Capacity": 0,
"Location": 2,
"DiskInterfaceType": 5,
"MediaType": 1,
"LocationString": ""
}
]
}`)
hw, err := ParseAssetJSON(content)
if err != nil {
t.Fatalf("ParseAssetJSON failed: %v", err)
}
if len(hw.Storage) != 1 {
t.Fatalf("expected 1 storage entry, got %d", len(hw.Storage))
}
if hw.Storage[0].Slot != "OB03" {
t.Fatalf("expected OB03 slot fallback, got %q", hw.Storage[0].Slot)
}
if !hw.Storage[0].Present {
t.Fatalf("expected fallback storage entry marked present")
}
if hw.Storage[0].Type != "NVMe" {
t.Fatalf("expected NVMe type, got %q", hw.Storage[0].Type)
}
}
func TestParseDiskBackplaneInfo_PopulatesOnlyMissingPresentDrives(t *testing.T) {
text := `RESTful diskbackplane info:
[
{ "port_count": 8, "driver_count": 4, "front": 1, "backplane_index": 0, "present": 1, "cpld_version": "3.1", "temperature": 18 },
{ "port_count": 8, "driver_count": 3, "front": 1, "backplane_index": 1, "present": 1, "cpld_version": "3.1", "temperature": 17 }
]
BMC`
hw := &models.HardwareConfig{
Storage: []models.Storage{
{Slot: "OB01", Type: "NVMe", Present: true},
{Slot: "OB02", Type: "NVMe", Present: true},
{Slot: "OB03", Type: "NVMe", Present: true},
{Slot: "OB04", Type: "NVMe", Present: true},
},
}
parseDiskBackplaneInfo(text, hw)
if len(hw.Storage) != 7 {
t.Fatalf("expected total storage count 7 after backplane merge, got %d", len(hw.Storage))
}
bpCount := 0
for _, dev := range hw.Storage {
if strings.HasPrefix(dev.Slot, "BP0:") || strings.HasPrefix(dev.Slot, "BP1:") {
bpCount++
}
}
if bpCount != 3 {
t.Fatalf("expected 3 synthetic backplane rows, got %d", bpCount)
}
}
func TestEnrichStorageFromSerialFallbackFiles_AssignsSerials(t *testing.T) {
files := []parser.ExtractedFile{
{
Path: "onekeylog/configuration/conf/BpHDDSerialNumber.info",
Content: []byte{
0xA0, 0xA1, 0xA2, 0xA3,
'S', '6', 'K', 'N', 'N', 'G', '0', 'W', '4', '2', '8', '5', '5', '2',
0x00,
'P', 'H', 'Y', 'I', '5', '2', '7', '1', '0', '0', '4', 'B', '1', 'P', '9', 'D', 'G', 'N',
},
},
}
hw := &models.HardwareConfig{
Storage: []models.Storage{
{Slot: "BP0:0", Type: "HDD", Present: true},
{Slot: "BP0:1", Type: "HDD", Present: true},
{Slot: "OB01", Type: "NVMe", Present: true},
},
}
enrichStorageFromSerialFallbackFiles(files, hw)
if hw.Storage[0].SerialNumber == "" || hw.Storage[1].SerialNumber == "" {
t.Fatalf("expected serials assigned to present storage entries, got %#v", hw.Storage)
}
}

View File

@@ -197,6 +197,11 @@ func buildHumanReadableCollectionLog(pkg *RawExportPackage, result *models.Analy
if pkg.Source.Filename != "" {
fmt.Fprintf(&b, "Source Filename: %s\n", pkg.Source.Filename)
}
if startedAt, finishedAt, ok := collectLogTimeBounds(pkg.Source.CollectLogs); ok {
fmt.Fprintf(&b, "Collection Started: %s\n", startedAt.Format(time.RFC3339Nano))
fmt.Fprintf(&b, "Collection Finished: %s\n", finishedAt.Format(time.RFC3339Nano))
fmt.Fprintf(&b, "Collection Duration: %s\n", formatRawExportDuration(finishedAt.Sub(startedAt)))
}
}
if pkg != nil && len(pkg.Source.CollectLogs) > 0 {
@@ -279,6 +284,42 @@ func buildHumanReadableCollectionLog(pkg *RawExportPackage, result *models.Analy
return b.String()
}
func collectLogTimeBounds(lines []string) (time.Time, time.Time, bool) {
var first time.Time
var last time.Time
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
tsToken := line
if idx := strings.IndexByte(line, ' '); idx > 0 {
tsToken = line[:idx]
}
ts, err := time.Parse(time.RFC3339Nano, tsToken)
if err != nil {
continue
}
if first.IsZero() || ts.Before(first) {
first = ts
}
if last.IsZero() || ts.After(last) {
last = ts
}
}
if first.IsZero() || last.IsZero() || last.Before(first) {
return time.Time{}, time.Time{}, false
}
return first, last, true
}
func formatRawExportDuration(d time.Duration) string {
if d < 0 {
d = 0
}
return d.Round(time.Second).String()
}
func buildParserFieldSummary(result *models.AnalysisResult) map[string]any {
out := map[string]any{
"generated_at": time.Now().UTC(),

View File

@@ -0,0 +1,46 @@
package server
import (
"strings"
"testing"
)
func TestCollectLogTimeBounds(t *testing.T) {
lines := []string{
"2026-02-28T13:10:13.7442032Z Задача поставлена в очередь",
"not-a-timestamp line",
"2026-02-28T13:31:00.5077486Z Сбор завершен",
}
startedAt, finishedAt, ok := collectLogTimeBounds(lines)
if !ok {
t.Fatalf("expected bounds to be parsed")
}
if got := formatRawExportDuration(finishedAt.Sub(startedAt)); got != "20m47s" {
t.Fatalf("unexpected duration: %s", got)
}
}
func TestBuildHumanReadableCollectionLog_IncludesDurationHeader(t *testing.T) {
pkg := &RawExportPackage{
Format: rawExportFormatV1,
Source: RawExportSource{
Kind: "live_redfish",
CollectLogs: []string{
"2026-02-28T13:10:13.7442032Z Redfish: подключение к BMC...",
"2026-02-28T13:31:00.5077486Z Сбор завершен",
},
},
}
logText := buildHumanReadableCollectionLog(pkg, nil, "LOGPile test")
for _, token := range []string{
"Collection Started:",
"Collection Finished:",
"Collection Duration:",
} {
if !strings.Contains(logText, token) {
t.Fatalf("expected %q in log header", token)
}
}
}