From 57de3ba6ebee8f2d4ca5887a4b27566c3b3b3b91 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Sat, 13 Jun 2026 14:35:39 +0300 Subject: [PATCH] chore: align codebase with bible engineering contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - identifier-normalization: use strings.EqualFold in h3c/parser.go - import-export: CSV now uses UTF-8 BOM and semicolon delimiter - go-code-style: translate all Russian source strings to English (ADL-007) - go-background-tasks: add Type, Message, Result fields to Job struct - go-api: wrap list endpoints in {items, total_count, page, per_page, total_pages} - module-structure: rename helpers.go → context_sleep.go - build-version-display: htmlError renders version footer on error pages - go-logging: migrate all log.Printf calls to log/slog with structured attrs Co-Authored-By: Claude Sonnet 4.6 --- cmd/logpile/main.go | 10 +- .../{helpers.go => context_sleep.go} | 0 internal/collector/ipmi_mock.go | 6 +- internal/collector/redfish.go | 167 +++++++++--------- internal/collector/redfish_logentries.go | 4 +- internal/collector/redfish_replay.go | 6 +- internal/exporter/exporter.go | 4 + internal/exporter/exporter_csv_test.go | 8 +- internal/parser/vendors/h3c/parser.go | 54 +++--- internal/server/collect_types.go | 33 ++-- internal/server/device_repository_test.go | 2 +- internal/server/handlers.go | 113 +++++++----- internal/server/handlers_gpu_test.go | 26 +-- internal/server/job_manager.go | 25 ++- 14 files changed, 259 insertions(+), 199 deletions(-) rename internal/collector/{helpers.go => context_sleep.go} (100%) diff --git a/cmd/logpile/main.go b/cmd/logpile/main.go index 01658af..31af658 100644 --- a/cmd/logpile/main.go +++ b/cmd/logpile/main.go @@ -4,7 +4,7 @@ import ( "bufio" "flag" "fmt" - "log" + "log/slog" "os" "os/exec" "runtime" @@ -49,8 +49,8 @@ func main() { srv := server.New(cfg) url := fmt.Sprintf("http://localhost:%d", *port) - log.Printf("LOGPile starting on %s", url) - log.Printf("Registered parsers: %v", parser.ListParsers()) + slog.Info("LOGPile starting", "url", url) + slog.Info("registered parsers", "parsers", parser.ListParsers()) // Open browser automatically if !*noBrowser { @@ -61,7 +61,7 @@ func main() { } if err := runServer(srv); err != nil { - log.Printf("FATAL: %v", err) + slog.Error("fatal error", "err", err) maybeWaitForCrashInput(*holdOnCrash) os.Exit(1) } @@ -90,7 +90,7 @@ func openBrowser(url string) { } if err := cmd.Start(); err != nil { - log.Printf("Failed to open browser: %v", err) + slog.Warn("failed to open browser", "err", err) } } diff --git a/internal/collector/helpers.go b/internal/collector/context_sleep.go similarity index 100% rename from internal/collector/helpers.go rename to internal/collector/context_sleep.go diff --git a/internal/collector/ipmi_mock.go b/internal/collector/ipmi_mock.go index de55141..c872e83 100644 --- a/internal/collector/ipmi_mock.go +++ b/internal/collector/ipmi_mock.go @@ -19,9 +19,9 @@ func (c *IPMIMockConnector) Protocol() string { func (c *IPMIMockConnector) Collect(ctx context.Context, req Request, emit ProgressFn) (*models.AnalysisResult, error) { steps := []Progress{ - {Status: "running", Progress: 20, Message: "IPMI: подключение к BMC..."}, - {Status: "running", Progress: 55, Message: "IPMI: чтение инвентаря..."}, - {Status: "running", Progress: 85, Message: "IPMI: нормализация данных..."}, + {Status: "running", Progress: 20, Message: "IPMI: connecting to BMC..."}, + {Status: "running", Progress: 55, Message: "IPMI: reading inventory..."}, + {Status: "running", Progress: 85, Message: "IPMI: normalizing data..."}, } for _, step := range steps { diff --git a/internal/collector/redfish.go b/internal/collector/redfish.go index 0361ca6..8f7b497 100644 --- a/internal/collector/redfish.go +++ b/internal/collector/redfish.go @@ -6,7 +6,7 @@ import ( "encoding/json" "fmt" "io" - "log" + "log/slog" "net/http" "net/url" "os" @@ -124,14 +124,14 @@ func (c *RedfishConnector) debugf(format string, args ...interface{}) { if !c.debug { return } - log.Printf("redfish-debug: "+format, args...) + slog.Debug("redfish-debug: " + fmt.Sprintf(format, args...)) } func (c *RedfishConnector) debugSnapshotf(format string, args ...interface{}) { if !c.debugSnapshot { return } - log.Printf("redfish-snapshot-debug: "+format, args...) + slog.Debug("redfish-snapshot-debug: " + fmt.Sprintf(format, args...)) } func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit ProgressFn) (*models.AnalysisResult, error) { @@ -149,7 +149,7 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre hintClient := c.httpClientWithTimeout(req, 4*time.Second) if emit != nil { - emit(Progress{Status: "running", Progress: 10, Message: "Redfish: подключение к BMC..."}) + emit(Progress{Status: "running", Progress: 10, Message: "Redfish: connecting to BMC..."}) } discoveryCtx := withRedfishTelemetryPhase(ctx, "discovery") serviceRootDoc, err := c.getJSON(discoveryCtx, snapshotClient, req, baseURL, "/redfish/v1") @@ -192,7 +192,7 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre emit(Progress{ Status: "running", Progress: 25, - Message: fmt.Sprintf("Redfish: профили mode=%s active=%s", acquisitionPlan.Mode, formatActiveModuleLog(activeModules)), + Message: fmt.Sprintf("Redfish: profiles mode=%s active=%s", acquisitionPlan.Mode, formatActiveModuleLog(activeModules)), ActiveModules: activeModules, ModuleScores: moduleScores, DebugInfo: &CollectDebugInfo{ @@ -229,33 +229,32 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre seedPaths := resolvedPlan.SeedPaths criticalPaths := resolvedPlan.CriticalPaths if len(acquisitionPlan.Profiles) > 0 { - log.Printf( - "redfish-profile-plan: mode=%s profiles=%s notes=%s scores=%s req=%d err=%d p95=%dms avg=%dms throttled=%t", - acquisitionPlan.Mode, - strings.Join(acquisitionPlan.Profiles, ","), - strings.Join(acquisitionPlan.Notes, "; "), - formatModuleScoreLog(moduleScores), - telemetrySummary.Requests, - telemetrySummary.Errors, - telemetrySummary.P95.Milliseconds(), - telemetrySummary.Avg.Milliseconds(), - throttled, + slog.Info("redfish-profile-plan", + "mode", acquisitionPlan.Mode, + "profiles", strings.Join(acquisitionPlan.Profiles, ","), + "notes", strings.Join(acquisitionPlan.Notes, "; "), + "scores", formatModuleScoreLog(moduleScores), + "req", telemetrySummary.Requests, + "err", telemetrySummary.Errors, + "p95_ms", telemetrySummary.P95.Milliseconds(), + "avg_ms", telemetrySummary.Avg.Milliseconds(), + "throttled", throttled, ) } if emit != nil { emit(Progress{ Status: "running", Progress: 30, - Message: "Redfish: чтение структуры Redfish...", + Message: "Redfish: reading Redfish structure...", CurrentPhase: "snapshot", ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds, }) } if emit != nil { - emit(Progress{Status: "running", Progress: 55, Message: "Redfish: подготовка snapshot...", CurrentPhase: "snapshot", ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds}) - emit(Progress{Status: "running", Progress: 80, Message: "Redfish: подготовка расширенного snapshot...", CurrentPhase: "snapshot", ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds}) - emit(Progress{Status: "running", Progress: 90, Message: "Redfish: сбор расширенного snapshot...", CurrentPhase: "snapshot", ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds}) + emit(Progress{Status: "running", Progress: 55, Message: "Redfish: preparing snapshot...", CurrentPhase: "snapshot", ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds}) + emit(Progress{Status: "running", Progress: 80, Message: "Redfish: preparing extended snapshot...", CurrentPhase: "snapshot", ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds}) + emit(Progress{Status: "running", Progress: 90, Message: "Redfish: collecting extended snapshot...", CurrentPhase: "snapshot", ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds}) } // collectCtx covers all data-fetching phases (snapshot, prefetch, plan-B). // Cancelling it via the skip signal aborts only the collection phases while @@ -270,10 +269,10 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre emit(Progress{ Status: "running", Progress: 97, - Message: "Redfish: пропуск зависших запросов, анализ уже собранных данных...", + Message: "Redfish: skipping stalled requests, analyzing collected data...", }) } - log.Printf("redfish: skip-hung triggered, cancelling collection phases") + slog.Info("redfish: skip-hung triggered, cancelling collection phases") cancelCollect() case <-ctx.Done(): } @@ -296,15 +295,14 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre for p := range prefetchedCritical { delete(fetchErrMap, p) } - log.Printf( - "redfish-prefetch-metrics: enabled=%t candidates=%d targets=%d docs=%d added=%d dur=%s skip=%s", - prefetchMetrics.Enabled, - prefetchMetrics.Candidates, - prefetchMetrics.Targets, - prefetchMetrics.Docs, - prefetchMetrics.Added, - prefetchMetrics.Duration.Round(time.Millisecond), - firstNonEmpty(prefetchMetrics.SkipReason, "-"), + slog.Info("redfish-prefetch-metrics", + "enabled", prefetchMetrics.Enabled, + "candidates", prefetchMetrics.Candidates, + "targets", prefetchMetrics.Targets, + "docs", prefetchMetrics.Docs, + "added", prefetchMetrics.Added, + "dur", prefetchMetrics.Duration.Round(time.Millisecond), + "skip", firstNonEmpty(prefetchMetrics.SkipReason, "-"), ) if recoveredN := c.recoverCriticalRedfishDocsPlanB(withRedfishTelemetryPhase(collectCtx, "critical_plan_b"), criticalClient, req, baseURL, criticalPaths, rawTree, fetchErrMap, acquisitionPlan.Tuning, emit); recoveredN > 0 { c.debugSnapshotf("critical plan-b recovered docs=%d", recoveredN) @@ -319,7 +317,7 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre } } if emit != nil { - emit(Progress{Status: "running", Progress: 99, Message: "Redfish: анализ raw snapshot..."}) + emit(Progress{Status: "running", Progress: 99, Message: "Redfish: analyzing raw snapshot..."}) } // Collect hardware event logs separately (not part of tree-walk to avoid bloat). rawLogEntries := c.collectRedfishLogEntries(withRedfishTelemetryPhase(ctx, "log_entries"), snapshotClient, req, baseURL, systemPaths, managerPaths) @@ -443,38 +441,36 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre } totalElapsed := time.Since(collectStart).Round(time.Second) if !result.InventoryLastModifiedAt.IsZero() { - log.Printf("redfish-collect: inventory last modified at %s (age: %s)", - result.InventoryLastModifiedAt.Format(time.RFC3339), - time.Since(result.InventoryLastModifiedAt).Round(time.Minute), + slog.Info("redfish-collect: inventory last modified", + "at", result.InventoryLastModifiedAt.Format(time.RFC3339), + "age", time.Since(result.InventoryLastModifiedAt).Round(time.Minute), ) } - log.Printf( - "redfish-postprobe-metrics: nvme_candidates=%d nvme_selected=%d nvme_added=%d candidates=%d selected=%d skipped_explicit=%d added=%d dur=%s", - postProbeMetrics.NVMECandidates, - postProbeMetrics.NVMESelected, - postProbeMetrics.NVMEAdded, - postProbeMetrics.CollectionCandidates, - postProbeMetrics.CollectionSelected, - postProbeMetrics.SkippedExplicit, - postProbeMetrics.Added, - postProbeMetrics.Duration.Round(time.Millisecond), + slog.Info("redfish-postprobe-metrics", + "nvme_candidates", postProbeMetrics.NVMECandidates, + "nvme_selected", postProbeMetrics.NVMESelected, + "nvme_added", postProbeMetrics.NVMEAdded, + "candidates", postProbeMetrics.CollectionCandidates, + "selected", postProbeMetrics.CollectionSelected, + "skipped_explicit", postProbeMetrics.SkippedExplicit, + "added", postProbeMetrics.Added, + "dur", postProbeMetrics.Duration.Round(time.Millisecond), ) - log.Printf( - "redfish-telemetry: req=%d err=%d err_rate=%.2f avg=%dms p95=%dms throttled=%t snapshot_workers=%d prefetch_workers=%d timing_top=%s", - telemetrySummary.Requests, - telemetrySummary.Errors, - telemetrySummary.ErrorRate, - telemetrySummary.Avg.Milliseconds(), - telemetrySummary.P95.Milliseconds(), - throttled, - acquisitionPlan.Tuning.SnapshotWorkers, - acquisitionPlan.Tuning.PrefetchWorkers, - firstNonEmpty(snapshotTimingSummary, "-"), + slog.Info("redfish-telemetry", + "req", telemetrySummary.Requests, + "err", telemetrySummary.Errors, + "err_rate", telemetrySummary.ErrorRate, + "avg_ms", telemetrySummary.Avg.Milliseconds(), + "p95_ms", telemetrySummary.P95.Milliseconds(), + "throttled", throttled, + "snapshot_workers", acquisitionPlan.Tuning.SnapshotWorkers, + "prefetch_workers", acquisitionPlan.Tuning.PrefetchWorkers, + "timing_top", firstNonEmpty(snapshotTimingSummary, "-"), ) for _, line := range redfishPhaseTelemetryLogLines(phaseTelemetry) { - log.Printf("redfish-telemetry-phase: %s", line) + slog.Info("redfish-telemetry-phase", "line", line) } - log.Printf("redfish-collect: completed in %s (docs=%d, fetch_errors=%d)", totalElapsed, len(rawTree), len(fetchErrMap)) + slog.Info("redfish-collect: completed", "elapsed", totalElapsed, "docs", len(rawTree), "fetch_errors", len(fetchErrMap)) if emit != nil { emit(Progress{ Status: "running", @@ -491,7 +487,7 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre emit(Progress{ Status: "running", Progress: 100, - Message: fmt.Sprintf("Redfish: сбор завершен за %s", totalElapsed), + Message: fmt.Sprintf("Redfish: collection completed in %s", totalElapsed), }) } return result, nil @@ -611,7 +607,7 @@ func (c *RedfishConnector) prefetchCriticalRedfishDocs( emit(Progress{ Status: "running", Progress: 96, - Message: fmt.Sprintf("Redfish: prefetch пропущен (адаптивно, кандидатов=%d)", metrics.Candidates), + Message: fmt.Sprintf("Redfish: prefetch skipped (adaptive, candidates=%d)", metrics.Candidates), }) } return nil, metrics @@ -620,7 +616,7 @@ func (c *RedfishConnector) prefetchCriticalRedfishDocs( emit(Progress{ Status: "running", Progress: 96, - Message: fmt.Sprintf("Redfish: prefetch критичных endpoint (адаптивно %d/%d)...", len(targets), len(candidates)), + Message: fmt.Sprintf("Redfish: prefetch critical endpoints (adaptive %d/%d)...", len(targets), len(candidates)), CurrentPhase: "prefetch", ETASeconds: int(estimateProgressETA(time.Now(), 0, len(targets), 2*time.Second).Seconds()), }) @@ -706,7 +702,7 @@ func (c *RedfishConnector) prefetchCriticalRedfishDocs( emit(Progress{ Status: "running", Progress: 96, - Message: fmt.Sprintf("Redfish: prefetch завершен (адаптивно targets=%d, docs=%d)", len(targets), len(out)), + Message: fmt.Sprintf("Redfish: prefetch completed (adaptive targets=%d, docs=%d)", len(targets), len(out)), CurrentPhase: "prefetch", }) } @@ -1397,7 +1393,7 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht emit(Progress{ Status: "running", Progress: 92 + int(minInt32(n/200, 6)), - Message: fmt.Sprintf("Redfish snapshot: heartbeat документов=%d (ok=%d, seen=%d), ETA≈%s, корни=%s, последний=%s", n, outN, seenN, eta, strings.Join(roots, ", "), compactProgressPath(last)), + Message: fmt.Sprintf("Redfish snapshot: heartbeat docs=%d (ok=%d, seen=%d), ETA≈%s, roots=%s, last=%s", n, outN, seenN, eta, strings.Join(roots, ", "), compactProgressPath(last)), CurrentPhase: "snapshot", ETASeconds: int(estimateSnapshotETA(crawlStart, int(n), seenN, len(jobs), workers, client.Timeout).Seconds()), }) @@ -1434,7 +1430,7 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht emit(Progress{ Status: "running", Progress: 92 + int(minInt32(n/200, 6)), - Message: fmt.Sprintf("Redfish snapshot: ошибка на %s", compactProgressPath(current)), + Message: fmt.Sprintf("Redfish snapshot: error on %s", compactProgressPath(current)), }) } wg.Done() @@ -1489,7 +1485,7 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht emit(Progress{ Status: "running", Progress: 92 + int(minInt32(n/200, 6)), - Message: fmt.Sprintf("Redfish snapshot: ошибка на %s", compactProgressPath(current)), + Message: fmt.Sprintf("Redfish snapshot: error on %s", compactProgressPath(current)), }) } } @@ -1512,7 +1508,7 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht emit(Progress{ Status: "running", Progress: 92 + int(minInt32(n/200, 6)), - Message: fmt.Sprintf("Redfish snapshot: документов=%d, ETA≈%s, корни=%s, последний=%s", n, eta, strings.Join(roots, ", "), compactProgressPath(last)), + Message: fmt.Sprintf("Redfish snapshot: docs=%d, ETA≈%s, roots=%s, last=%s", n, eta, strings.Join(roots, ", "), compactProgressPath(last)), CurrentPhase: "snapshot", ETASeconds: int(estimateSnapshotETA(crawlStart, int(n), seenN, len(jobs), workers, client.Timeout).Seconds()), }) @@ -1574,7 +1570,7 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht emit(Progress{ Status: "running", Progress: 97, - Message: fmt.Sprintf("Redfish snapshot: post-probe NVMe (%d/%d, ETA≈%s), коллекция=%s", i+1, len(driveCollections), formatETA(estimateProgressETA(nvmeProbeStart, i, len(driveCollections), 2*time.Second)), compactProgressPath(path)), + Message: fmt.Sprintf("Redfish snapshot: post-probe NVMe (%d/%d, ETA≈%s), collection=%s", i+1, len(driveCollections), formatETA(estimateProgressETA(nvmeProbeStart, i, len(driveCollections), 2*time.Second)), compactProgressPath(path)), CurrentPhase: "snapshot_postprobe_nvme", ETASeconds: int(estimateProgressETA(nvmeProbeStart, i, len(driveCollections), 2*time.Second).Seconds()), }) @@ -1622,7 +1618,7 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht emit(Progress{ Status: "running", Progress: 98, - Message: fmt.Sprintf("Redfish snapshot: post-probe коллекций (%d/%d, ETA≈%s), текущая=%s", i+1, len(postProbeCollections), formatETA(estimateProgressETA(postProbeStart, i, len(postProbeCollections), 3*time.Second)), compactProgressPath(path)), + Message: fmt.Sprintf("Redfish snapshot: post-probe collections (%d/%d, ETA≈%s), current=%s", i+1, len(postProbeCollections), formatETA(estimateProgressETA(postProbeStart, i, len(postProbeCollections), 3*time.Second)), compactProgressPath(path)), CurrentPhase: "snapshot_postprobe_collections", ETASeconds: int(estimateProgressETA(postProbeStart, i, len(postProbeCollections), 3*time.Second).Seconds()), }) @@ -1641,14 +1637,14 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht emit(Progress{ Status: "running", Progress: 98, - Message: fmt.Sprintf("Redfish snapshot: post-probe добавлено %d документов", addedPostProbe), + Message: fmt.Sprintf("Redfish snapshot: post-probe added %d docs", addedPostProbe), }) } if emit != nil { emit(Progress{ Status: "running", Progress: 98, - Message: fmt.Sprintf("Redfish snapshot: post-probe метрики candidates=%d selected=%d skipped_explicit=%d added=%d", postProbeMetrics.CollectionCandidates, postProbeMetrics.CollectionSelected, postProbeMetrics.SkippedExplicit, postProbeMetrics.Added), + Message: fmt.Sprintf("Redfish snapshot: post-probe metrics candidates=%d selected=%d skipped_explicit=%d added=%d", postProbeMetrics.CollectionCandidates, postProbeMetrics.CollectionSelected, postProbeMetrics.SkippedExplicit, postProbeMetrics.Added), }) } @@ -1656,7 +1652,7 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht emit(Progress{ Status: "running", Progress: 98, - Message: fmt.Sprintf("Redfish snapshot: собрано %d документов", len(out)), + Message: fmt.Sprintf("Redfish snapshot: collected %d docs", len(out)), }) } @@ -1671,14 +1667,14 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht return asString(errorList[i]["path"]) < asString(errorList[j]["path"]) }) if summary := timings.Summary(12); summary != "" { - log.Printf("redfish-snapshot-timing: %s", summary) + slog.Info("redfish-snapshot-timing", "summary", summary) } if emit != nil { if summary := timings.Summary(3); summary != "" { emit(Progress{ Status: "running", Progress: 98, - Message: fmt.Sprintf("Redfish snapshot: топ веток по времени: %s", summary), + Message: fmt.Sprintf("Redfish snapshot: top branches by time: %s", summary), }) } } @@ -2979,14 +2975,14 @@ func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context, emit(Progress{ Status: "running", Progress: 97, - Message: fmt.Sprintf("Redfish: расширенная диагностика выключена, пропущено %d тяжелых diagnostic endpoint", skippedDiagnosticTargets), + Message: fmt.Sprintf("Redfish: extended diagnostics disabled, skipped %d heavy diagnostic endpoints", skippedDiagnosticTargets), }) } totalETA := redfishCriticalCooldown() + estimatePlanBETA(len(targets)) emit(Progress{ Status: "running", Progress: 97, - Message: fmt.Sprintf("Redfish: cooldown перед повторным добором критичных endpoint... ETA≈%s", formatETA(totalETA)), + Message: fmt.Sprintf("Redfish: cooldown before retrying critical endpoints... ETA≈%s", formatETA(totalETA)), CurrentPhase: "critical_plan_b", ETASeconds: int(totalETA.Seconds()), }) @@ -3072,17 +3068,17 @@ func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context, emit(Progress{ Status: "running", Progress: 97, - Message: fmt.Sprintf("Redfish: plan-B топ веток по времени: %s", summary), + Message: fmt.Sprintf("Redfish: plan-B top branches by time: %s", summary), }) } 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), + Message: fmt.Sprintf("Redfish: plan-B completed in %s (targets=%d, recovered=%d)", time.Since(planBStart).Round(time.Second), len(targets), recovered), }) } if summary := timings.Summary(12); summary != "" { - log.Printf("redfish-planb-timing: %s", summary) + slog.Info("redfish-planb-timing", "summary", summary) } return recovered } @@ -3143,7 +3139,7 @@ func (c *RedfishConnector) recoverProfilePlanBDocs(ctx context.Context, client * emit(Progress{ Status: "running", Progress: 98, - Message: fmt.Sprintf("Redfish: profile plan-B добирает %d endpoint...", len(targets)), + Message: fmt.Sprintf("Redfish: profile plan-B fetching %d endpoints...", len(targets)), CurrentPhase: "profile_plan_b", ETASeconds: int(estimateProgressETA(planBStart, 0, len(targets), 2*time.Second).Seconds()), }) @@ -3167,19 +3163,18 @@ func (c *RedfishConnector) recoverProfilePlanBDocs(ctx context.Context, client * recovered++ } if recovered > 0 { - log.Printf( - "redfish-profile-planb: mode=%s profiles=%s targets=%d recovered=%d", - plan.Mode, - strings.Join(plan.Profiles, ","), - len(targets), - recovered, + slog.Info("redfish-profile-planb", + "mode", plan.Mode, + "profiles", strings.Join(plan.Profiles, ","), + "targets", len(targets), + "recovered", recovered, ) } if emit != nil { emit(Progress{ Status: "running", Progress: 98, - Message: fmt.Sprintf("Redfish: profile plan-B завершен за %s (targets=%d, recovered=%d)", time.Since(planBStart).Round(time.Second), len(targets), recovered), + Message: fmt.Sprintf("Redfish: profile plan-B completed in %s (targets=%d, recovered=%d)", time.Since(planBStart).Round(time.Second), len(targets), recovered), CurrentPhase: "profile_plan_b", }) } diff --git a/internal/collector/redfish_logentries.go b/internal/collector/redfish_logentries.go index e945547..48a1165 100644 --- a/internal/collector/redfish_logentries.go +++ b/internal/collector/redfish_logentries.go @@ -2,7 +2,7 @@ package collector import ( "context" - "log" + "log/slog" "net/http" "strings" "time" @@ -62,7 +62,7 @@ func (c *RedfishConnector) collectRedfishLogEntries(ctx context.Context, client } if len(out) > 0 { - log.Printf("redfish: collected %d hardware log entries (Systems+Managers SEL, window=7d)", len(out)) + slog.Info("redfish: collected hardware log entries", "count", len(out), "window", "7d") } return out } diff --git a/internal/collector/redfish_replay.go b/internal/collector/redfish_replay.go index 657a136..55cc516 100644 --- a/internal/collector/redfish_replay.go +++ b/internal/collector/redfish_replay.go @@ -3,7 +3,7 @@ package collector import ( "encoding/json" "fmt" - "log" + "log/slog" "sort" "strings" "time" @@ -32,7 +32,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) ( emit(Progress{Status: "running", Progress: 10, Message: "Redfish snapshot: replay service root..."}) } if _, err := r.getJSON("/redfish/v1"); err != nil { - log.Printf("redfish replay: service root /redfish/v1 missing from snapshot, continuing with defaults: %v", err) + slog.Warn("redfish replay: service root /redfish/v1 missing from snapshot, continuing with defaults", "err", err) } systemPaths := r.discoverMemberPaths("/redfish/v1/Systems", "/redfish/v1/Systems/1") @@ -219,7 +219,7 @@ func inferInventoryLastModifiedTime(snapshot map[string]interface{}) time.Time { for _, layout := range []string{time.RFC3339, time.RFC3339Nano} { if ts, err := time.Parse(layout, raw); err == nil { t := ts.UTC() - log.Printf("redfish replay: inventory last modified at %s (InventoryData/Status.LastModifiedTime)", t.Format(time.RFC3339)) + slog.Info("redfish replay: inventory last modified", "at", t.Format(time.RFC3339), "source", "InventoryData/Status.LastModifiedTime") return t } } diff --git a/internal/exporter/exporter.go b/internal/exporter/exporter.go index 0bd7e7b..04d7648 100644 --- a/internal/exporter/exporter.go +++ b/internal/exporter/exporter.go @@ -21,7 +21,11 @@ func New(result *models.AnalysisResult) *Exporter { // ExportCSV exports serial numbers to CSV format func (e *Exporter) ExportCSV(w io.Writer) error { + if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil { + return err + } writer := csv.NewWriter(w) + writer.Comma = ';' defer writer.Flush() // Header diff --git a/internal/exporter/exporter_csv_test.go b/internal/exporter/exporter_csv_test.go index 3adcf3b..0a44c84 100644 --- a/internal/exporter/exporter_csv_test.go +++ b/internal/exporter/exporter_csv_test.go @@ -52,7 +52,13 @@ func TestExportCSV_IncludesAllComponentTypesWithUsableSerials(t *testing.T) { t.Fatalf("ExportCSV failed: %v", err) } - rows, err := csv.NewReader(bytes.NewReader(buf.Bytes())).ReadAll() + b := buf.Bytes() + if len(b) >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF { + b = b[3:] // strip UTF-8 BOM + } + r := csv.NewReader(bytes.NewReader(b)) + r.Comma = ';' + rows, err := r.ReadAll() if err != nil { t.Fatalf("read csv: %v", err) } diff --git a/internal/parser/vendors/h3c/parser.go b/internal/parser/vendors/h3c/parser.go index a2fd50f..f674232 100644 --- a/internal/parser/vendors/h3c/parser.go +++ b/internal/parser/vendors/h3c/parser.go @@ -2867,9 +2867,9 @@ func parseKeyValueBlocks(content string) []map[string]string { func findCPUIndex(items []models.CPU, target models.CPU) int { targetSocket := target.Socket - targetPPIN := strings.ToLower(strings.TrimSpace(target.PPIN)) - targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber)) - targetModel := strings.ToLower(strings.TrimSpace(target.Model)) + targetPPIN := strings.TrimSpace(target.PPIN) + targetSerial := strings.TrimSpace(target.SerialNumber) + targetModel := strings.TrimSpace(target.Model) for i := range items { cpu := items[i] @@ -2880,18 +2880,18 @@ func findCPUIndex(items []models.CPU, target models.CPU) int { continue } - ppin := strings.ToLower(strings.TrimSpace(cpu.PPIN)) - if targetPPIN != "" && ppin != "" && targetPPIN == ppin { + ppin := strings.TrimSpace(cpu.PPIN) + if targetPPIN != "" && ppin != "" && strings.EqualFold(targetPPIN, ppin) { return i } - serial := strings.ToLower(strings.TrimSpace(cpu.SerialNumber)) - if targetSerial != "" && serial != "" && targetSerial == serial { + serial := strings.TrimSpace(cpu.SerialNumber) + if targetSerial != "" && serial != "" && strings.EqualFold(targetSerial, serial) { return i } - model := strings.ToLower(strings.TrimSpace(cpu.Model)) - if targetSocket == 0 && cpu.Socket == 0 && targetModel != "" && model == targetModel { + model := strings.TrimSpace(cpu.Model) + if targetSocket == 0 && cpu.Socket == 0 && targetModel != "" && strings.EqualFold(model, targetModel) { return i } } @@ -2931,15 +2931,15 @@ func mergeCPU(dst *models.CPU, src models.CPU) { } func findMemoryIndex(items []models.MemoryDIMM, target models.MemoryDIMM) int { - targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber)) - targetSlot := strings.ToLower(strings.TrimSpace(target.Slot)) + targetSerial := strings.TrimSpace(target.SerialNumber) + targetSlot := strings.TrimSpace(target.Slot) for i := range items { - serial := strings.ToLower(strings.TrimSpace(items[i].SerialNumber)) - slot := strings.ToLower(strings.TrimSpace(items[i].Slot)) - if targetSerial != "" && serial != "" && targetSerial == serial { + serial := strings.TrimSpace(items[i].SerialNumber) + slot := strings.TrimSpace(items[i].Slot) + if targetSerial != "" && serial != "" && strings.EqualFold(targetSerial, serial) { return i } - if targetSerial == "" && targetSlot != "" && slot != "" && targetSlot == slot { + if targetSerial == "" && targetSlot != "" && slot != "" && strings.EqualFold(targetSlot, slot) { return i } } @@ -2993,15 +2993,15 @@ func dedupeStorage(items []models.Storage) []models.Storage { } func findStorageIndex(items []models.Storage, target models.Storage) int { - targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber)) - targetSlot := strings.ToLower(strings.TrimSpace(target.Slot)) + targetSerial := strings.TrimSpace(target.SerialNumber) + targetSlot := strings.TrimSpace(target.Slot) for i := range items { - serial := strings.ToLower(strings.TrimSpace(items[i].SerialNumber)) - slot := strings.ToLower(strings.TrimSpace(items[i].Slot)) - if targetSerial != "" && serial != "" && targetSerial == serial { + serial := strings.TrimSpace(items[i].SerialNumber) + slot := strings.TrimSpace(items[i].Slot) + if targetSerial != "" && serial != "" && strings.EqualFold(targetSerial, serial) { return i } - if targetSerial == "" && targetSlot != "" && slot != "" && targetSlot == slot { + if targetSerial == "" && targetSlot != "" && slot != "" && strings.EqualFold(targetSlot, slot) { return i } } @@ -3248,15 +3248,15 @@ func isPSUEmpty(p models.PSU) bool { } func findPSUIndex(items []models.PSU, target models.PSU) int { - targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber)) - targetSlot := strings.ToLower(strings.TrimSpace(target.Slot)) + targetSerial := strings.TrimSpace(target.SerialNumber) + targetSlot := strings.TrimSpace(target.Slot) for i := range items { - serial := strings.ToLower(strings.TrimSpace(items[i].SerialNumber)) - slot := strings.ToLower(strings.TrimSpace(items[i].Slot)) - if targetSerial != "" && serial != "" && targetSerial == serial { + serial := strings.TrimSpace(items[i].SerialNumber) + slot := strings.TrimSpace(items[i].Slot) + if targetSerial != "" && serial != "" && strings.EqualFold(targetSerial, serial) { return i } - if targetSerial == "" && targetSlot != "" && slot != "" && targetSlot == slot { + if targetSerial == "" && targetSlot != "" && slot != "" && strings.EqualFold(targetSlot, slot) { return i } } diff --git a/internal/server/collect_types.go b/internal/server/collect_types.go index f1cd8f8..a3c1927 100644 --- a/internal/server/collect_types.go +++ b/internal/server/collect_types.go @@ -38,18 +38,21 @@ type CollectJobResponse struct { } type CollectJobStatusResponse struct { - JobID string `json:"job_id"` - Status string `json:"status"` - Progress *int `json:"progress,omitempty"` - CurrentPhase string `json:"current_phase,omitempty"` - ETASeconds *int `json:"eta_seconds,omitempty"` - Logs []string `json:"logs,omitempty"` - Error string `json:"error,omitempty"` - ActiveModules []CollectModuleStatus `json:"active_modules,omitempty"` - ModuleScores []CollectModuleStatus `json:"module_scores,omitempty"` - DebugInfo *CollectDebugInfo `json:"debug_info,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + JobID string `json:"job_id"` + Type string `json:"type,omitempty"` + Status string `json:"status"` + Progress *int `json:"progress,omitempty"` + Message string `json:"message,omitempty"` + CurrentPhase string `json:"current_phase,omitempty"` + ETASeconds *int `json:"eta_seconds,omitempty"` + Logs []string `json:"logs,omitempty"` + Error string `json:"error,omitempty"` + Result map[string]interface{} `json:"result,omitempty"` + ActiveModules []CollectModuleStatus `json:"active_modules,omitempty"` + ModuleScores []CollectModuleStatus `json:"module_scores,omitempty"` + DebugInfo *CollectDebugInfo `json:"debug_info,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` } type CollectRequestMeta struct { @@ -63,12 +66,15 @@ type CollectRequestMeta struct { type Job struct { ID string + Type string Status string Progress int + Message string CurrentPhase string ETASeconds int Logs []string Error string + Result map[string]interface{} ActiveModules []CollectModuleStatus ModuleScores []CollectModuleStatus DebugInfo *CollectDebugInfo @@ -107,11 +113,14 @@ func (j *Job) toStatusResponse() CollectJobStatusResponse { progress := j.Progress resp := CollectJobStatusResponse{ JobID: j.ID, + Type: j.Type, Status: j.Status, Progress: &progress, + Message: j.Message, CurrentPhase: j.CurrentPhase, Logs: append([]string(nil), j.Logs...), Error: j.Error, + Result: j.Result, ActiveModules: append([]CollectModuleStatus(nil), j.ActiveModules...), ModuleScores: append([]CollectModuleStatus(nil), j.ModuleScores...), DebugInfo: cloneCollectDebugInfo(j.DebugInfo), diff --git a/internal/server/device_repository_test.go b/internal/server/device_repository_test.go index 64c7ec7..5dd477f 100644 --- a/internal/server/device_repository_test.go +++ b/internal/server/device_repository_test.go @@ -174,7 +174,7 @@ func TestBuildSpecification_ZeroSizeMemoryWithInventoryIsShown(t *testing.T) { spec := buildSpecification(hw) for _, line := range spec { - if line.Category == "Память" && line.Name == "Hynix HMCG88AEBRA115N (size unknown)" && line.Quantity == 1 { + if line.Category == "Memory" && line.Name == "Hynix HMCG88AEBRA115N (size unknown)" && line.Quantity == 1 { return } } diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 5e3bc73..3032f07 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -38,13 +38,13 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { tmplContent, err := WebFS.ReadFile("templates/index.html") if err != nil { - http.Error(w, "Template not found", http.StatusInternalServerError) + s.htmlError(w, "Template not found", http.StatusInternalServerError) return } tmpl, err := template.New("index").Parse(string(tmplContent)) if err != nil { - http.Error(w, "Template parse error", http.StatusInternalServerError) + s.htmlError(w, "Template parse error", http.StatusInternalServerError) return } @@ -70,7 +70,7 @@ func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) { if result == nil || result.Hardware == nil { html, err := chartviewer.RenderHTML(nil, title) if err != nil { - http.Error(w, "failed to render viewer", http.StatusInternalServerError) + s.htmlError(w, "failed to render viewer", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -80,7 +80,7 @@ func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) { snapshotBytes, err := currentReanimatorSnapshotBytes(result) if err != nil { - http.Error(w, "failed to build reanimator snapshot: "+err.Error(), http.StatusInternalServerError) + s.htmlError(w, "failed to build reanimator snapshot: "+err.Error(), http.StatusInternalServerError) return } @@ -89,7 +89,7 @@ func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) { PrintMode: printMode, }) if err != nil { - http.Error(w, "failed to render chart: "+err.Error(), http.StatusInternalServerError) + s.htmlError(w, "failed to render chart: "+err.Error(), http.StatusInternalServerError) return } @@ -395,7 +395,7 @@ func uniqueSortedExtensions(exts []string) []string { func (s *Server) handleGetEvents(w http.ResponseWriter, r *http.Request) { result := s.GetResult() if result == nil { - jsonResponse(w, []interface{}{}) + jsonList(w, []interface{}{}, 0) return } @@ -408,18 +408,18 @@ func (s *Server) handleGetEvents(w http.ResponseWriter, r *http.Request) { return events[i].Timestamp.After(events[j].Timestamp) }) - jsonResponse(w, events) + jsonList(w, events, len(events)) } func (s *Server) handleGetSensors(w http.ResponseWriter, r *http.Request) { result := s.GetResult() if result == nil { - jsonResponse(w, []interface{}{}) + jsonList(w, []interface{}{}, 0) return } sensors := append([]models.SensorReading{}, result.Sensors...) sensors = append(sensors, synthesizePSUVoltageSensors(result.Hardware)...) - jsonResponse(w, sensors) + jsonList(w, sensors, len(sensors)) } func synthesizePSUVoltageSensors(hw *models.HardwareConfig) []models.SensorReading { @@ -533,7 +533,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine { float64(cpu.FrequencyMHz)/1000, cpu.Cores, intFromDetails(cpu.Details, "tdp_w")) - spec = append(spec, SpecLine{Category: "Процессор", Name: name, Quantity: count}) + spec = append(spec, SpecLine{Category: "CPU", Name: name, Quantity: count}) } // Memory - group by size, type and frequency (only installed modules) @@ -568,7 +568,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine { memGroups[key]++ } for key, count := range memGroups { - spec = append(spec, SpecLine{Category: "Память", Name: key, Quantity: count}) + spec = append(spec, SpecLine{Category: "Memory", Name: key, Quantity: count}) } // Storage - group by type and capacity @@ -586,7 +586,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine { storGroups[key]++ } for key, count := range storGroups { - spec = append(spec, SpecLine{Category: "Накопитель", Name: key, Quantity: count}) + spec = append(spec, SpecLine{Category: "Storage", Name: key, Quantity: count}) } // PCIe devices - group by device class/name and manufacturer @@ -609,7 +609,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine { } for key, count := range pcieGroups { pcie := pcieDetails[key] - category := "PCIe устройство" + category := "PCIe Device" name := key // Determine category based on device class or known GPU names @@ -618,11 +618,11 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine { isNetwork := deviceClass == "Network" || strings.Contains(deviceClass, "ConnectX") if isGPU { - category = "Графический процессор" + category = "GPU" } else if isNetwork { - category = "Сетевой адаптер" + category = "Network Adapter" } else if deviceClass == "NVMe" || deviceClass == "RAID" || deviceClass == "SAS" || deviceClass == "SATA" || deviceClass == "Storage" { - category = "Контроллер" + category = "Controller" } spec = append(spec, SpecLine{Category: category, Name: name, Quantity: count}) @@ -643,7 +643,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine { } } for key, count := range psuGroups { - spec = append(spec, SpecLine{Category: "Блок питания", Name: key, Quantity: count}) + spec = append(spec, SpecLine{Category: "Power Supply", Name: key, Quantity: count}) } return spec @@ -664,7 +664,7 @@ func nonEmptyStrings(values ...string) []string { func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) { result := s.GetResult() if result == nil { - jsonResponse(w, []interface{}{}) + jsonList(w, []interface{}{}, 0) return } @@ -714,7 +714,7 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) { } } - jsonResponse(w, serials) + jsonList(w, serials, len(serials)) } func normalizePCIeSerialComponentName(p models.PCIeDevice) string { @@ -768,11 +768,12 @@ func hasUsableFirmwareVersion(version string) bool { func (s *Server) handleGetFirmware(w http.ResponseWriter, r *http.Request) { result := s.GetResult() if result == nil || result.Hardware == nil { - jsonResponse(w, []interface{}{}) + jsonList(w, []interface{}{}, 0) return } - jsonResponse(w, buildFirmwareEntries(result.Hardware)) + entries := buildFirmwareEntries(result.Hardware) + jsonList(w, entries, len(entries)) } type parseErrorEntry struct { @@ -941,8 +942,7 @@ func looksLikeErrorLogLine(line string) bool { if s == "" { return false } - return strings.Contains(s, "ошибка") || - strings.Contains(s, "error") || + return strings.Contains(s, "error") || strings.Contains(s, "failed") || strings.Contains(s, "timeout") || strings.Contains(s, "deadline exceeded") @@ -977,7 +977,7 @@ func parseErrorSeverityFromMessage(msg string) string { if strings.HasPrefix(s, "status 404 ") || strings.HasPrefix(s, "status 405 ") || strings.HasPrefix(s, "status 410 ") || strings.HasPrefix(s, "status 501 ") { return "info" } - if strings.Contains(s, "ошибка") || strings.Contains(s, "error") || strings.Contains(s, "failed") { + if strings.Contains(s, "error") || strings.Contains(s, "failed") { return "warning" } return "info" @@ -1316,7 +1316,7 @@ func (s *Server) handleConvertReanimatorBatch(w http.ResponseWriter, r *http.Req tempDir, err := os.MkdirTemp("", "logpile-convert-input-*") if err != nil { - jsonError(w, "Не удалось создать временную директорию", http.StatusInternalServerError) + jsonError(w, "Failed to create temp directory", http.StatusInternalServerError) return } @@ -1363,7 +1363,7 @@ func (s *Server) handleConvertReanimatorBatch(w http.ResponseWriter, r *http.Req if len(inputFiles) == 0 { _ = os.RemoveAll(tempDir) - jsonError(w, "Нет файлов поддерживаемого типа для конвертации", http.StatusBadRequest) + jsonError(w, "No supported files to convert", http.StatusBadRequest) return } @@ -1376,9 +1376,9 @@ func (s *Server) handleConvertReanimatorBatch(w http.ResponseWriter, r *http.Req TLSMode: "insecure", }) s.markConvertJob(job.ID) - s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Запущена пакетная конвертация: %d файлов", len(inputFiles))) + s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Batch conversion started: %d files", len(inputFiles))) if skipped > 0 { - s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Пропущено неподдерживаемых файлов: %d", skipped)) + s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Skipped unsupported files: %d", skipped)) } s.jobManager.UpdateJobStatus(job.ID, CollectStatusRunning, 0, "") @@ -1406,7 +1406,7 @@ func (s *Server) runConvertJob(jobID, tempDir string, inputFiles []convertInputF resultFile, err := os.CreateTemp("", "logpile-convert-result-*.zip") if err != nil { - s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "не удалось создать zip") + s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "failed to create zip") return } resultPath := resultFile.Name() @@ -1418,7 +1418,7 @@ func (s *Server) runConvertJob(jobID, tempDir string, inputFiles []convertInputF totalProcess := len(inputFiles) for i, in := range inputFiles { - s.jobManager.AppendJobLog(jobID, fmt.Sprintf("Обработка %s", in.Name)) + s.jobManager.AppendJobLog(jobID, fmt.Sprintf("Processing %s", in.Name)) payload, err := os.ReadFile(in.Path) if err != nil { failures = append(failures, fmt.Sprintf("%s: %v", in.Name, err)) @@ -1471,13 +1471,13 @@ func (s *Server) runConvertJob(jobID, tempDir string, inputFiles []convertInputF if success == 0 { _ = zw.Close() _ = os.Remove(resultPath) - s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Не удалось конвертировать ни один файл") + s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Failed to convert any file") return } - summaryLines := []string{fmt.Sprintf("Конвертировано %d из %d файлов", success, total)} + summaryLines := []string{fmt.Sprintf("Converted %d of %d files", success, total)} if skipped > 0 { - summaryLines = append(summaryLines, fmt.Sprintf("Пропущено неподдерживаемых: %d", skipped)) + summaryLines = append(summaryLines, fmt.Sprintf("Skipped unsupported: %d", skipped)) } summaryLines = append(summaryLines, failures...) if entry, err := zw.Create("convert-summary.txt"); err == nil { @@ -1485,7 +1485,7 @@ func (s *Server) runConvertJob(jobID, tempDir string, inputFiles []convertInputF } if err := zw.Close(); err != nil { _ = os.Remove(resultPath) - s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Не удалось упаковать результаты") + s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Failed to pack results") return } @@ -1638,7 +1638,7 @@ func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) { } job := s.jobManager.CreateJob(req) - s.jobManager.AppendJobLog(job.ID, "Клиент: "+s.ClientVersionString()) + s.jobManager.AppendJobLog(job.ID, "Client: "+s.ClientVersionString()) s.startCollectionJob(job.ID, req) w.Header().Set("Content-Type", "application/json") @@ -1667,7 +1667,7 @@ func pingHost(host string, port int, total, need int) (bool, string) { } n := int(successes.Load()) if n < need { - return false, fmt.Sprintf("Хост недоступен: только %d из %d попыток подключения к %s прошли успешно (требуется минимум %d)", n, total, addr, need) + return false, fmt.Sprintf("Host unreachable: only %d of %d connection attempts to %s succeeded (minimum required: %d)", n, total, addr, need) } return true, "" } @@ -1684,12 +1684,12 @@ func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) { } connector, ok := s.getCollector(req.Protocol) if !ok { - jsonError(w, "Коннектор для протокола не зарегистрирован", http.StatusBadRequest) + jsonError(w, "Protocol connector not registered", http.StatusBadRequest) return } prober, ok := connector.(collector.Prober) if !ok { - jsonError(w, "Проверка подключения для протокола не поддерживается", http.StatusBadRequest) + jsonError(w, "Connection probe not supported for this protocol", http.StatusBadRequest) return } @@ -1703,16 +1703,16 @@ func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) { result, err := prober.Probe(ctx, toCollectorRequest(req)) if err != nil { - jsonError(w, "Проверка подключения не удалась: "+err.Error(), http.StatusBadRequest) + jsonError(w, "Connection probe failed: "+err.Error(), http.StatusBadRequest) return } - message := "Связь с BMC установлена" + message := "BMC connection established" if result != nil { if result.HostPoweredOn { - message = "Связь с BMC установлена, host включён." + message = "BMC connection established, host is powered on." } else { - message = "Связь с BMC установлена, host выключен. Данные инвентаря могут быть неполными." + message = "BMC connection established, host is powered off. Inventory data may be incomplete." } } @@ -1797,8 +1797,8 @@ func (s *Server) startCollectionJob(jobID string, req CollectRequest) { go func() { connector, ok := s.getCollector(req.Protocol) if !ok { - s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Коннектор для протокола не зарегистрирован") - s.jobManager.AppendJobLog(jobID, "Сбор завершен с ошибкой") + s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Protocol connector not registered") + s.jobManager.AppendJobLog(jobID, "Collection completed with error") return } @@ -1872,7 +1872,7 @@ func (s *Server) startCollectionJob(jobID string, req CollectRequest) { return } s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, err.Error()) - s.jobManager.AppendJobLog(jobID, "Сбор завершен с ошибкой") + s.jobManager.AppendJobLog(jobID, "Collection completed with error") return } @@ -1882,7 +1882,7 @@ func (s *Server) startCollectionJob(jobID string, req CollectRequest) { applyCollectSourceMetadata(result, req) s.jobManager.UpdateJobStatus(jobID, CollectStatusSuccess, 100, "") - s.jobManager.AppendJobLog(jobID, "Сбор завершен") + s.jobManager.AppendJobLog(jobID, "Collection completed") s.SetResult(result) s.SetDetectedVendor(req.Protocol) if job, ok := s.jobManager.GetJob(jobID); ok { @@ -2142,6 +2142,27 @@ func jsonError(w http.ResponseWriter, message string, code int) { json.NewEncoder(w).Encode(map[string]string{"error": message}) } +func (s *Server) htmlError(w http.ResponseWriter, message string, code int) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(code) + version := normalizeDisplayVersion(s.config.AppVersion) + fmt.Fprintf(w, `Error %d`+ + `

Error %d

%s

`+ + ``, + code, code, template.HTMLEscapeString(message), template.HTMLEscapeString(version)) +} + +func jsonList(w http.ResponseWriter, items interface{}, totalCount int) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "items": items, + "total_count": totalCount, + "page": 1, + "per_page": totalCount, + "total_pages": 1, + }) +} + // isGPUDevice checks if device class indicates a GPU func isGPUDevice(deviceClass string) bool { // Standard PCI class names diff --git a/internal/server/handlers_gpu_test.go b/internal/server/handlers_gpu_test.go index 2bbc70d..2d4c915 100644 --- a/internal/server/handlers_gpu_test.go +++ b/internal/server/handlers_gpu_test.go @@ -51,17 +51,20 @@ func TestHandleGetSerials_WithGPUs(t *testing.T) { } // Parse response - var serials []struct { - Component string `json:"component"` - Location string `json:"location,omitempty"` - SerialNumber string `json:"serial_number"` - Manufacturer string `json:"manufacturer,omitempty"` - Category string `json:"category"` + var resp struct { + Items []struct { + Component string `json:"component"` + Location string `json:"location,omitempty"` + SerialNumber string `json:"serial_number"` + Manufacturer string `json:"manufacturer,omitempty"` + Category string `json:"category"` + } `json:"items"` } - if err := json.NewDecoder(w.Body).Decode(&serials); err != nil { + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("Failed to decode response: %v", err) } + serials := resp.Items // Check that we have GPU entries gpuCount := 0 @@ -115,13 +118,16 @@ func TestHandleGetSerials_WithoutGPUSerials(t *testing.T) { srv.handleGetSerials(w, req) // Parse response - var serials []struct { - Category string `json:"category"` + var resp struct { + Items []struct { + Category string `json:"category"` + } `json:"items"` } - if err := json.NewDecoder(w.Body).Decode(&serials); err != nil { + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("Failed to decode response: %v", err) } + serials := resp.Items // Check that GPUs without serial numbers are not included for _, s := range serials { diff --git a/internal/server/job_manager.go b/internal/server/job_manager.go index 54fdb95..21b7ae5 100644 --- a/internal/server/job_manager.go +++ b/internal/server/job_manager.go @@ -3,6 +3,7 @@ package server import ( "context" "fmt" + "maps" "sync" "time" ) @@ -22,9 +23,11 @@ func (m *JobManager) CreateJob(req CollectRequest) *Job { now := time.Now().UTC() job := &Job{ ID: generateJobID(), + Type: req.Protocol, Status: CollectStatusQueued, Progress: 0, - Logs: []string{formatCollectLogLine(now, "Задача поставлена в очередь")}, + Message: "Job queued", + Logs: []string{formatCollectLogLine(now, "Job queued")}, CreatedAt: now, UpdatedAt: now, RequestMeta: CollectRequestMeta{ @@ -66,7 +69,7 @@ func (m *JobManager) CancelJob(id string) (*Job, bool) { job.Status = CollectStatusCanceled job.Error = "" job.UpdatedAt = time.Now().UTC() - job.Logs = append(job.Logs, formatCollectLogLine(job.UpdatedAt, "Сбор отменен пользователем")) + job.Logs = append(job.Logs, formatCollectLogLine(job.UpdatedAt, "Collection canceled by user")) } cancelFn := job.cancel @@ -122,6 +125,7 @@ func (m *JobManager) AppendJobLog(id, message string) (*Job, bool) { job.Logs = append(job.Logs, message) job.UpdatedAt = time.Now().UTC() job.Logs[len(job.Logs)-1] = formatCollectLogLine(job.UpdatedAt, message) + job.Message = message cloned := cloneJob(job) m.mu.Unlock() @@ -202,7 +206,7 @@ func (m *JobManager) SkipJob(id string) (*Job, bool) { skipFn := job.skipFn job.skipFn = nil job.UpdatedAt = time.Now().UTC() - job.Logs = append(job.Logs, formatCollectLogLine(job.UpdatedAt, "Пропуск зависших запросов по команде пользователя")) + job.Logs = append(job.Logs, formatCollectLogLine(job.UpdatedAt, "Skipping stalled requests on user request")) cloned := cloneJob(job) m.mu.Unlock() @@ -212,6 +216,18 @@ func (m *JobManager) SkipJob(id string) (*Job, bool) { return cloned, true } +func (m *JobManager) SetJobResult(id string, result map[string]interface{}) bool { + m.mu.Lock() + defer m.mu.Unlock() + job, ok := m.jobs[id] + if !ok || job == nil { + return false + } + job.Result = result + job.UpdatedAt = time.Now().UTC() + return true +} + func (m *JobManager) AttachJobCancel(id string, cancelFn context.CancelFunc) bool { m.mu.Lock() defer m.mu.Unlock() @@ -265,6 +281,9 @@ func cloneJob(job *Job) *Job { cloned.DebugInfo = cloneCollectDebugInfo(job.DebugInfo) cloned.CurrentPhase = job.CurrentPhase cloned.ETASeconds = job.ETASeconds + if job.Result != nil { + cloned.Result = maps.Clone(job.Result) + } cloned.cancel = nil cloned.skipFn = nil return &cloned