From aba7a549905d87330a758932cf10e8eab120ee58 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Fri, 24 Apr 2026 16:58:50 +0300 Subject: [PATCH] feat(parser): lenovo xcc vroc volume parsing - v1.2 Parse inventory_volume.log: Intel VROC (VMD) RAID volumes including RAID level, capacity (GiB/TiB support added), status and member drives. Add Drives []string to StorageVolume model. Co-Authored-By: Claude Sonnet 4.6 --- bible-local/10-decisions.md | 18 + cmd/logpile/main.go | 19 +- internal/chart | 2 +- internal/models/models.go | 19 +- internal/parser/vendors/lenovo_xcc/parser.go | 151 +- .../parser/vendors/lenovo_xcc/parser_test.go | 108 + internal/server/handlers.go | 29 +- internal/server/server.go | 9 +- web/static/css/style.css | 1810 ++++++----------- web/static/js/app.js | 1108 +--------- web/templates/index.html | 110 +- 11 files changed, 1147 insertions(+), 2236 deletions(-) diff --git a/bible-local/10-decisions.md b/bible-local/10-decisions.md index b2a606e..4d8f4a1 100644 --- a/bible-local/10-decisions.md +++ b/bible-local/10-decisions.md @@ -1180,3 +1180,21 @@ collector architecture. - The codebase avoids introducing a second generic live-ingest/replay contract for IPMI data. - Future IPMI work must be justified by concrete Redfish gaps on real hardware, not by protocol symmetry alone. + +--- + +## ADL-046 — The web shell delegates report rendering to `internal/chart` + +**Date:** 2026-04-22 +**Context:** The frontend had two competing report paths: the embedded `internal/chart` viewer and +an older client-side renderer in `web/static/js/app.js` for config, firmware, sensors, serials, +events, and parse errors. That duplication left dead controls in the shell and made the report +source of truth ambiguous. +**Decision:** The `web/` frontend shell is responsible only for data intake, job control, and +top-level actions. The report itself must be rendered exclusively through `internal/chart`. +Do not keep parallel report sections, filters, or table renderers in shell JavaScript. +**Consequences:** +- The browser UI has a single report rendering path: `/chart/current` inside the embedded viewer. +- Report-level filtering or extra report sections must be implemented in `internal/chart`, not in + `web/static/js/app.js`. +- Removing legacy DOM renderers from the shell is a correctness fix, not a behavior regression. diff --git a/cmd/logpile/main.go b/cmd/logpile/main.go index f3cd4a4..01658af 100644 --- a/cmd/logpile/main.go +++ b/cmd/logpile/main.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "runtime" + "strings" "time" "git.mchus.pro/mchus/logpile/internal/parser" @@ -38,10 +39,11 @@ func main() { server.WebFS = web.FS cfg := server.Config{ - Port: *port, - PreloadFile: *file, - AppVersion: version, - AppCommit: commit, + Port: *port, + PreloadFile: *file, + AppVersion: version, + AppCommit: commit, + ChartVersion: detectChartVersion(), } srv := server.New(cfg) @@ -92,6 +94,15 @@ func openBrowser(url string) { } } +func detectChartVersion() string { + cmd := exec.Command("git", "-C", "internal/chart", "describe", "--tags", "--always", "--dirty", "--abbrev=7") + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + func maybeWaitForCrashInput(enabled bool) { if !enabled || !isInteractiveConsole() { return diff --git a/internal/chart b/internal/chart index 2fb01d3..f651798 160000 --- a/internal/chart +++ b/internal/chart @@ -1 +1 @@ -Subproject commit 2fb01d30a622960fcd9848d636c30285cc47be95 +Subproject commit f6517987b3438ae46a5f45e7dda894d2a76a8e92 diff --git a/internal/models/models.go b/internal/models/models.go index b8768b4..28549ae 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -257,15 +257,16 @@ type Storage struct { // StorageVolume represents a logical storage volume (RAID/VROC/etc.). type StorageVolume struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Controller string `json:"controller,omitempty"` - RAIDLevel string `json:"raid_level,omitempty"` - SizeGB int `json:"size_gb,omitempty"` - CapacityBytes int64 `json:"capacity_bytes,omitempty"` - Status string `json:"status,omitempty"` - Bootable bool `json:"bootable,omitempty"` - Encrypted bool `json:"encrypted,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Controller string `json:"controller,omitempty"` + RAIDLevel string `json:"raid_level,omitempty"` + SizeGB int `json:"size_gb,omitempty"` + CapacityBytes int64 `json:"capacity_bytes,omitempty"` + Status string `json:"status,omitempty"` + Bootable bool `json:"bootable,omitempty"` + Encrypted bool `json:"encrypted,omitempty"` + Drives []string `json:"drives,omitempty"` // member drive names/labels } // PCIeDevice represents a PCIe device diff --git a/internal/parser/vendors/lenovo_xcc/parser.go b/internal/parser/vendors/lenovo_xcc/parser.go index 28c8eec..7719a03 100644 --- a/internal/parser/vendors/lenovo_xcc/parser.go +++ b/internal/parser/vendors/lenovo_xcc/parser.go @@ -9,6 +9,7 @@ package lenovo_xcc import ( "encoding/json" "fmt" + "regexp" "strconv" "strings" "time" @@ -17,7 +18,7 @@ import ( "git.mchus.pro/mchus/logpile/internal/parser" ) -const parserVersion = "1.1" +const parserVersion = "1.2" func init() { parser.Register(&Parser{}) @@ -88,6 +89,9 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er if f := findByPath(files, "tmp/inventory_disk.log"); f != nil { result.Hardware.Storage = parseDisks(f.Content) } + if f := findByPath(files, "tmp/inventory_volume.log"); f != nil { + result.Hardware.Volumes = parseVolumes(f.Content) + } if f := findByPath(files, "tmp/inventory_card.log"); f != nil { result.Hardware.PCIeDevices = parseCards(f.Content) } @@ -103,6 +107,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er for _, f := range findEventFiles(files) { result.Events = append(result.Events, parseEvents(f.Content)...) } + applyDIMMWarningsFromEvents(result) result.Protocol = "ipmi" result.SourceType = models.SourceTypeArchive @@ -297,6 +302,25 @@ type xccEventDoc struct { Items []xccEvent `json:"items"` } +type xccVolumeDoc struct { + Items []xccVolumeItem `json:"items"` +} + +type xccVolumeItem struct { + Volumes []xccVolume `json:"volumes"` + TotalCapacityStr string `json:"totalCapacityStr"` +} + +type xccVolume struct { + ID int `json:"id"` + Name string `json:"name"` + Drives string `json:"drives"` // e.g. "M.2 Drive 0, M.2 Drive 1" + RDLvlStr string `json:"rdlvlstr"` // e.g. "RAID 1" + CapacityStr string `json:"capacityStr"` // e.g. "893.750 GiB" + Status int `json:"status"` + StatusStr string `json:"statusStr"` // e.g. "Optimal" +} + type xccEvent struct { Severity string `json:"severity"` // "I", "W", "E", "C" Source string `json:"source"` @@ -462,6 +486,37 @@ func parseDisks(content []byte) []models.Storage { return out } +func parseVolumes(content []byte) []models.StorageVolume { + var doc xccVolumeDoc + if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 { + return nil + } + var out []models.StorageVolume + for _, item := range doc.Items { + for _, v := range item.Volumes { + vol := models.StorageVolume{ + ID: fmt.Sprintf("%d", v.ID), + Name: strings.TrimSpace(v.Name), + RAIDLevel: strings.TrimSpace(v.RDLvlStr), + SizeGB: parseCapacityToGB(v.CapacityStr), + Status: strings.TrimSpace(v.StatusStr), + } + drives := strings.TrimSpace(v.Drives) + if drives != "" { + for _, d := range strings.Split(drives, ",") { + vol.Drives = append(vol.Drives, strings.TrimSpace(d)) + } + // M.2 NVMe volumes are managed by Intel VROC (VMD) + if strings.Contains(strings.ToLower(drives), "m.2") { + vol.Controller = "Intel VROC" + } + } + out = append(out, vol) + } + } + return out +} + func parseCards(content []byte) []models.PCIeDevice { var doc xccCardDoc if err := json.Unmarshal(content, &doc); err != nil { @@ -613,6 +668,96 @@ func isUnqualifiedDIMM(value string) bool { return strings.Contains(strings.ToLower(strings.TrimSpace(value)), "unqualified dimm") } +var ( + unqualifiedDIMMSlotRE = regexp.MustCompile(`(?i)\bunqualified dimm\s+(\d+)\b`) + unqualifiedDIMMSerialRE = regexp.MustCompile(`(?i)\bserial number is\s+([A-Z0-9-]+)`) +) + +func applyDIMMWarningsFromEvents(result *models.AnalysisResult) { + if result == nil || result.Hardware == nil || len(result.Hardware.Memory) == 0 || len(result.Events) == 0 { + return + } + + for _, ev := range result.Events { + if !isUnqualifiedDIMM(ev.Description) { + continue + } + idx := findDIMMIndexForUnqualifiedEvent(result.Hardware.Memory, ev.Description) + if idx < 0 { + continue + } + + dimm := &result.Hardware.Memory[idx] + dimm.Status = "Warning" + dimm.ErrorDescription = ev.Description + if !ev.Timestamp.IsZero() { + ts := ev.Timestamp.UTC() + dimm.StatusChangedAt = &ts + dimm.StatusCheckedAt = &ts + } + appendDIMMStatusHistory(dimm, ev) + } +} + +func findDIMMIndexForUnqualifiedEvent(memory []models.MemoryDIMM, description string) int { + slot := extractUnqualifiedDIMMSlot(description) + serial := normalizeUnqualifiedDIMMSerial(extractUnqualifiedDIMMSerial(description)) + + for i := range memory { + if slot != "" && strings.EqualFold(strings.TrimSpace(memory[i].Slot), slot) { + return i + } + } + for i := range memory { + if serial != "" && normalizeUnqualifiedDIMMSerial(memory[i].SerialNumber) == serial { + return i + } + } + return -1 +} + +func extractUnqualifiedDIMMSlot(description string) string { + m := unqualifiedDIMMSlotRE.FindStringSubmatch(description) + if len(m) < 2 { + return "" + } + return "DIMM " + strings.TrimSpace(m[1]) +} + +func extractUnqualifiedDIMMSerial(description string) string { + m := unqualifiedDIMMSerialRE.FindStringSubmatch(description) + if len(m) < 2 { + return "" + } + return strings.TrimSpace(m[1]) +} + +func normalizeUnqualifiedDIMMSerial(serial string) string { + serial = strings.ToUpper(strings.TrimSpace(serial)) + if idx := strings.Index(serial, "-"); idx >= 0 { + serial = serial[:idx] + } + return serial +} + +func appendDIMMStatusHistory(dimm *models.MemoryDIMM, ev models.Event) { + if dimm == nil || ev.Timestamp.IsZero() { + return + } + for _, item := range dimm.StatusHistory { + if strings.EqualFold(strings.TrimSpace(item.Status), "Warning") && + item.ChangedAt.Equal(ev.Timestamp.UTC()) && + strings.TrimSpace(item.Details) == strings.TrimSpace(ev.Description) { + return + } + } + dimm.StatusHistory = append(dimm.StatusHistory, models.StatusHistoryEntry{ + Status: "Warning", + ChangedAt: ev.Timestamp.UTC(), + Details: ev.Description, + }) +} + func parseXCCTime(s string) (time.Time, error) { s = strings.TrimSpace(s) formats := []string{ @@ -674,8 +819,12 @@ func parseCapacityToGB(s string) int { switch strings.ToUpper(parts[1]) { case "TB": return int(v * 1000) + case "TIB": + return int(v * 1099.511627776) // 1 TiB = 1099.511... GB case "GB": return int(v) + case "GIB": + return int(v * 1.073741824) // 1 GiB = 1.073741824 GB case "MB": return int(v / 1024) } diff --git a/internal/parser/vendors/lenovo_xcc/parser_test.go b/internal/parser/vendors/lenovo_xcc/parser_test.go index 3e7132b..7b28dcd 100644 --- a/internal/parser/vendors/lenovo_xcc/parser_test.go +++ b/internal/parser/vendors/lenovo_xcc/parser_test.go @@ -2,6 +2,7 @@ package lenovo_xcc import ( "testing" + "time" "git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/parser" @@ -224,6 +225,75 @@ func TestParse_LenovoXCCMiniLog_Firmware(t *testing.T) { } } +func TestParse_LenovoXCCMiniLog_VROCVolumes(t *testing.T) { + files, err := parser.ExtractArchive(exampleArchive) + if err != nil { + t.Skipf("example archive not available: %v", err) + } + + p := &Parser{} + result, _ := p.Parse(files) + if result == nil || result.Hardware == nil { + t.Fatal("Parse returned nil") + } + + if len(result.Hardware.Volumes) == 0 { + t.Error("expected at least one VROC volume, got none") + } + for i, v := range result.Hardware.Volumes { + t.Logf("Volume[%d]: id=%s controller=%q raid=%s size=%dGB status=%s drives=%v", + i, v.ID, v.Controller, v.RAIDLevel, v.SizeGB, v.Status, v.Drives) + if v.RAIDLevel == "" { + t.Errorf("Volume[%d]: RAIDLevel is empty", i) + } + if v.Status == "" { + t.Errorf("Volume[%d]: Status is empty", i) + } + } +} + +func TestParseVolumes_IntelVROC(t *testing.T) { + content := []byte(`{ + "identifier": "storage.id", + "items": [{ + "volumes": [{ + "id": 1, + "name": "", + "drives": "M.2 Drive 0, M.2 Drive 1", + "rdlvlstr": "RAID 1", + "capacityStr": "893.750 GiB", + "status": 3, + "statusStr": "Optimal" + }], + "totalCapacityStr": "893.750 GiB" + }] + }`) + + vols := parseVolumes(content) + if len(vols) != 1 { + t.Fatalf("expected 1 volume, got %d", len(vols)) + } + v := vols[0] + if v.ID != "1" { + t.Errorf("expected ID=1, got %q", v.ID) + } + if v.RAIDLevel != "RAID 1" { + t.Errorf("expected RAIDLevel=RAID 1, got %q", v.RAIDLevel) + } + if v.Status != "Optimal" { + t.Errorf("expected Status=Optimal, got %q", v.Status) + } + if v.Controller != "Intel VROC" { + t.Errorf("expected Controller=Intel VROC, got %q", v.Controller) + } + if len(v.Drives) != 2 { + t.Errorf("expected 2 drives, got %d: %v", len(v.Drives), v.Drives) + } + if v.SizeGB < 900 || v.SizeGB > 1000 { + t.Errorf("expected SizeGB ~960, got %d", v.SizeGB) + } +} + func TestParseDIMMs_UnqualifiedDIMMAddsWarningEvent(t *testing.T) { content := []byte(`{ "items": [{ @@ -256,3 +326,41 @@ func TestSeverity_UnqualifiedDIMMMessageBecomesWarning(t *testing.T) { t.Fatalf("expected warning severity, got %q", got) } } + +func TestApplyDIMMWarningsFromEvents_UpdatesDIMMStatusForExport(t *testing.T) { + result := &models.AnalysisResult{ + Events: []models.Event{ + { + Timestamp: time.Date(2026, 4, 13, 11, 37, 38, 0, time.UTC), + Severity: models.SeverityWarning, + Description: "Unqualified DIMM 3 has been detected, the DIMM serial number is 80CE042328460C5D88-V20.", + }, + }, + Hardware: &models.HardwareConfig{ + Memory: []models.MemoryDIMM{ + { + Slot: "DIMM 3", + Present: true, + SerialNumber: "80CE042328460C5D88", + Status: "Normal", + }, + }, + }, + } + + applyDIMMWarningsFromEvents(result) + + dimm := result.Hardware.Memory[0] + if dimm.Status != "Warning" { + t.Fatalf("expected DIMM status Warning, got %q", dimm.Status) + } + if dimm.ErrorDescription == "" || dimm.ErrorDescription != result.Events[0].Description { + t.Fatalf("expected DIMM error description to be populated, got %q", dimm.ErrorDescription) + } + if dimm.StatusChangedAt == nil || !dimm.StatusChangedAt.Equal(result.Events[0].Timestamp) { + t.Fatalf("expected status_changed_at from event timestamp, got %#v", dimm.StatusChangedAt) + } + if len(dimm.StatusHistory) != 1 || dimm.StatusHistory[0].Status != "Warning" { + t.Fatalf("expected warning status history entry, got %#v", dimm.StatusHistory) + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 4b6cc71..35d09c3 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -50,11 +50,20 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl.Execute(w, map[string]string{ - "AppVersion": s.config.AppVersion, - "AppCommit": s.config.AppCommit, + "AppVersion": normalizeDisplayVersion(s.config.AppVersion), + "AppCommit": s.config.AppCommit, + "ChartVersion": normalizeDisplayVersion(s.config.ChartVersion), }) } +func normalizeDisplayVersion(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + return strings.TrimPrefix(v, "v") +} + func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) { result := s.GetResult() title := chartTitle(result) @@ -2045,14 +2054,14 @@ func applyCollectSourceMetadata(result *models.AnalysisResult, req CollectReques func toCollectorRequest(req CollectRequest) collector.Request { return collector.Request{ - Host: req.Host, - Protocol: req.Protocol, - Port: req.Port, - Username: req.Username, - AuthType: req.AuthType, - Password: req.Password, - Token: req.Token, - TLSMode: req.TLSMode, + Host: req.Host, + Protocol: req.Protocol, + Port: req.Port, + Username: req.Username, + AuthType: req.AuthType, + Password: req.Password, + Token: req.Token, + TLSMode: req.TLSMode, DebugPayloads: req.DebugPayloads, } } diff --git a/internal/server/server.go b/internal/server/server.go index 2fb4ebf..85a2986 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -19,10 +19,11 @@ import ( var WebFS embed.FS type Config struct { - Port int - PreloadFile string - AppVersion string - AppCommit string + Port int + PreloadFile string + AppVersion string + AppCommit string + ChartVersion string } type Server struct { diff --git a/web/static/css/style.css b/web/static/css/style.css index 917fd4a..a2f0985 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -1,208 +1,440 @@ +:root { + --bg: #ffffff; + --surface: #ffffff; + --surface-2: #f9fafb; + --surface-3: #f3f4f6; + --border: rgba(34, 36, 38, 0.15); + --border-lite: rgba(34, 36, 38, 0.1); + --ink: rgba(0, 0, 0, 0.87); + --muted: rgba(0, 0, 0, 0.6); + --accent: #2185d0; + --accent-dark: #1678c2; + --accent-bg: #dff0ff; + --ok-bg: #fcfff5; + --ok-fg: #2c662d; + --warn-bg: #fffaf3; + --warn-fg: #573a08; + --crit-bg: #fff6f6; + --crit-fg: #9f3a38; + --crit-border: #e0b4b4; + --shadow-card: 0 1px 2px 0 rgba(34, 36, 38, 0.15); + --shadow-shell: 0 20px 48px rgba(15, 23, 42, 0.06); +} + * { box-sizing: border-box; - margin: 0; - padding: 0; +} + +html { + background: linear-gradient(180deg, #f5f7fa 0%, #ffffff 240px); } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: #f5f5f5; - color: #333; - line-height: 1.6; + margin: 0; + min-height: 100vh; + background: transparent; + color: var(--ink); + font: 14px/1.5 Lato, "Helvetica Neue", Arial, Helvetica, sans-serif; } -header { - background: #2c3e50; - color: white; - padding: 1rem 2rem; +button, +input, +select, +textarea { + font: inherit; } -.app-header-row { +button { + cursor: pointer; +} + +a { + color: var(--accent); +} + +a:hover { + color: var(--accent-dark); +} + +.page-header { + background: #1b1c1d; + padding: 16px 24px; display: flex; align-items: flex-start; justify-content: space-between; - gap: 1rem 2rem; + gap: 18px; flex-wrap: wrap; } -.app-header-brand { +.page-header-brand { min-width: 0; } -header h1 { - font-size: 1.5rem; - font-weight: 600; +.page-eyebrow { + margin: 0 0 4px; + color: rgba(255, 255, 255, 0.5); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; } -header p { - font-size: 0.875rem; - opacity: 0.8; +.page-header h1 { + margin: 0; + color: rgba(255, 255, 255, 0.92); + font-size: 22px; + line-height: 1.15; +} + +.page-subtitle { + margin: 6px 0 0; + color: rgba(255, 255, 255, 0.68); + font-size: 13px; } .header-domain { - font-size: 0.9rem; + font-size: 0.72em; font-weight: 400; - opacity: 0.7; + color: rgba(255, 255, 255, 0.55); } .header-log-meta { display: flex; align-items: center; - gap: 0.75rem; - flex-wrap: wrap; - justify-content: flex-end; + gap: 12px; + margin-left: auto; } .header-actions { display: flex; - gap: 0.5rem; - flex-wrap: wrap; + align-items: center; justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; } .header-actions button { border: none; - border-radius: 6px; - padding: 0.55rem 0.9rem; - font-size: 0.85rem; - font-weight: 600; - cursor: pointer; } -main { - width: 100%; - max-width: none; - margin: 1rem 0 2rem; - padding: 0 1rem; +.header-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + border-radius: 4px; + padding: 7px 14px; + background: rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.86); + font-size: 13px; + font-weight: 700; + transition: background 0.12s ease, opacity 0.12s ease; +} + +.header-action:hover { + background: rgba(255, 255, 255, 0.2); +} + +.header-action:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.page-main { + width: min(1500px, calc(100vw - 48px)); + margin: 28px auto 56px; +} + +.control-deck, +#data-section { + display: grid; + gap: 20px; +} + +.surface-panel, +.job-status, +.viewer-panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + box-shadow: var(--shadow-card); + overflow: hidden; +} + +.surface-panel > h2, +.viewer-panel-header h2 { + margin: 0; + padding: 13px 16px; + background: var(--surface-2); + border-bottom: 1px solid var(--border); + font-size: 13px; + font-weight: 700; + color: var(--ink); +} + +.surface-panel > p, +.viewer-panel-header p { + margin: 0; + padding: 12px 16px 0; + color: var(--muted); +} + +.upload-panel { + max-width: 980px; + margin-left: auto; + margin-right: auto; } -/* Upload section */ .source-switch { display: inline-flex; - gap: 0.25rem; - background: #e9ecef; - border-radius: 8px; - padding: 0.25rem; - margin-bottom: 1rem; + gap: 4px; + width: fit-content; + margin-bottom: 4px; + margin-left: auto; + margin-right: auto; + padding: 4px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + box-shadow: var(--shadow-card); } .source-switch-btn { border: none; + border-radius: 4px; background: transparent; - color: #495057; - padding: 0.45rem 0.9rem; - border-radius: 6px; - cursor: pointer; - font-size: 0.9rem; - font-weight: 500; + color: var(--muted); + padding: 8px 14px; + font-size: 13px; + font-weight: 700; + transition: background 0.12s ease, color 0.12s ease; } .source-switch-btn:hover { - background: #dee2e6; + background: var(--surface-3); + color: var(--ink); } .source-switch-btn.active { - background: #3498db; - color: #fff; + background: var(--accent); + color: #ffffff; } .upload-area { - border: 2px dashed #ccc; - border-radius: 8px; - padding: 3rem; + margin: 12px 16px 0; + border: 1px dashed var(--border); + border-radius: 4px; + padding: 16px; text-align: center; - background: white; - transition: border-color 0.3s, background 0.3s; + background: var(--surface-2); + transition: border-color 0.12s ease, background 0.12s ease; } .upload-area.dragover { - border-color: #3498db; - background: #ebf5fb; + border-color: var(--accent); + background: var(--accent-bg); } -.upload-area button { - margin: 1rem 0; +.upload-area p { + margin: 0; } -.upload-area button:hover { - background: #2980b9; +.upload-dropzone { + display: block; + cursor: pointer; +} + +.upload-kicker { + display: block; + margin-bottom: 10px; + color: #6b7280; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.upload-dropzone strong { + display: block; + margin-bottom: 8px; + font-size: 28px; + font-weight: 700; + color: var(--ink); +} + +.upload-copy { + display: block; + color: var(--muted); + font-size: 15px; + max-width: 560px; + margin: 0 auto; +} + +.upload-actions { + margin-top: 22px; +} + +.upload-area button, +#api-connect-btn, +#api-collect-btn, +#convert-folder-btn, +#convert-run-btn, +#cancel-job-btn, +#skip-hung-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + border: none; + border-radius: 4px; + padding: 8px 18px; + font-weight: 700; + transition: background 0.12s ease, opacity 0.12s ease; +} + +.upload-area button, +#api-connect-btn, +#convert-folder-btn, +#convert-run-btn, +#cancel-job-btn { + background: var(--accent); + color: #ffffff; + margin-top: 12px; +} + +.upload-area button:hover, +#api-connect-btn:hover, +#convert-folder-btn:hover, +#convert-run-btn:hover, +#cancel-job-btn:hover { + background: var(--accent-dark); +} + +#api-collect-btn { + background: #2c662d; + color: #ffffff; +} + +#api-collect-btn:hover { + background: #245524; +} + +#skip-hung-btn { + background: #c67c0d; + color: #ffffff; +} + +#skip-hung-btn:hover { + background: #a8680c; +} + +.upload-area button:disabled, +#api-connect-btn:disabled, +#api-collect-btn:disabled, +#convert-folder-btn:disabled, +#convert-run-btn:disabled, +#cancel-job-btn:disabled, +#skip-hung-btn:disabled { + opacity: 0.55; + cursor: not-allowed; } .upload-area .hint { - font-size: 0.8rem; - color: #888; + margin-top: 18px; + color: #6b7280; + font-size: 12px; + letter-spacing: 0.01em; } -.api-placeholder { - background: #fff; - border: 1px solid #e0e0e0; - border-radius: 8px; - padding: 2rem; - color: #555; +.notice-panel { + margin-bottom: 4px; } -#api-connect-form h3 { - margin-bottom: 1rem; - color: #2c3e50; +#upload-status, +.parsers-info, +#api-connect-form, +#convert-source-content > p, +.convert-progress, +#convert-folder-summary, +#convert-status { + margin-left: 16px; + margin-right: 16px; } -#data-section { - margin: 0 -1rem; +#convert-source-content > p { + margin-top: 12px; +} + +#api-connect-form, +#convert-source-content { + padding-bottom: 16px; } .api-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 0.75rem 1rem; + gap: 12px 16px; + margin-top: 12px; } .api-form-field { display: flex; flex-direction: column; - gap: 0.35rem; - font-size: 0.875rem; - color: #2c3e50; + gap: 6px; + color: var(--ink); + font-size: 13px; + font-weight: 700; } .api-form-field input, .api-form-field select { - border: 1px solid #d0d7de; + width: 100%; + border: 1px solid var(--border); border-radius: 4px; - padding: 0.5rem 0.6rem; - font-size: 0.9rem; + padding: 10px 12px; + background: #ffffff; + color: var(--ink); +} + +.api-form-field input:focus, +.api-form-field select:focus { + outline: 2px solid var(--accent-bg); + border-color: var(--accent); } .api-form-field.has-error input, .api-form-field.has-error select { - border-color: #dc3545; + border-color: var(--crit-border); + background: var(--crit-bg); } .field-error { min-height: 1rem; - color: #dc3545; - font-size: 0.75rem; + font-size: 12px; + color: var(--crit-fg); + font-weight: 400; } .form-errors { - margin-bottom: 1rem; - border: 1px solid #f0b9bf; - background: #fff4f5; - color: #8e1f2b; - border-radius: 6px; - padding: 0.75rem 0.9rem; - font-size: 0.85rem; + margin-top: 12px; + border: 1px solid var(--crit-border); + border-radius: 4px; + padding: 10px 14px; + background: var(--crit-bg); + color: var(--crit-fg); } .form-errors ul { - margin: 0.4rem 0 0; - padding-left: 1.1rem; + margin: 6px 0 0; + padding-left: 18px; } .api-form-actions { - margin-top: 0.9rem; display: flex; flex-wrap: wrap; - gap: 0.6rem; + justify-content: center; + gap: 10px; + margin-top: 14px; } #api-connect-form.is-disabled { @@ -210,1192 +442,494 @@ main { pointer-events: none; } -#api-connect-btn, -#convert-folder-btn, -#convert-run-btn, -#cancel-job-btn, -.upload-area button { - background: #3498db; - color: #fff; - border: none; - border-radius: 6px; - padding: 0.6rem 1rem; - font-size: 0.95rem; - font-weight: 600; - cursor: pointer; - transition: background-color 0.2s ease, opacity 0.2s ease; -} - -#api-connect-btn:hover, -#convert-folder-btn:hover, -#convert-run-btn:hover, -#cancel-job-btn:hover, -.upload-area button:hover { - background: #2980b9; -} - -#convert-run-btn:disabled, -#convert-folder-btn:disabled, -#api-connect-btn:disabled, -#cancel-job-btn:disabled, -.upload-area button:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -#api-collect-btn { - background: #1f8f4c; - color: #fff; - border: none; - border-radius: 6px; - padding: 0.6rem 1.25rem; - font-size: 0.95rem; - font-weight: 600; - cursor: pointer; - transition: background-color 0.2s ease, opacity 0.2s ease; -} - -#api-collect-btn:hover { - background: #176e3a; -} - -#api-collect-btn:disabled { - opacity: 0.6; - cursor: not-allowed; -} - .api-probe-options { - margin-top: 0.9rem; - display: flex; - flex-direction: column; - gap: 0.45rem; + display: grid; + gap: 10px; + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--border-lite); } .api-form-checkbox { - display: flex; + display: inline-flex; align-items: center; - gap: 0.45rem; - font-size: 0.9rem; + gap: 10px; + color: var(--ink); + font-size: 13px; cursor: pointer; - user-select: none; } .api-form-checkbox input[type="checkbox"] { - width: 1rem; - height: 1rem; - cursor: pointer; -} - -.api-form-checkbox input[type="checkbox"]:disabled { - cursor: not-allowed; - opacity: 0.6; -} - -.api-form-checkbox span { - color: #444; -} - -.api-form-checkbox-sub { - padding-left: 0.25rem; - opacity: 0.8; -} - -.api-probe-options-separator { - margin: 0.5rem 0; - border-top: 1px solid #e2e8f0; + width: 16px; + height: 16px; + accent-color: var(--accent); } .api-host-off-warning { display: flex; align-items: center; - gap: 0.4rem; - padding: 0.5rem 0.75rem; - background: #fef3c7; - border: 1px solid #f59e0b; - border-radius: 6px; - font-size: 0.875rem; - color: #92400e; - font-weight: 500; + gap: 8px; + border: 1px solid rgba(228, 156, 52, 0.45); + border-radius: 4px; + padding: 9px 12px; + background: var(--warn-bg); + color: var(--warn-fg); + font-size: 13px; + font-weight: 700; } - .api-connect-status { - margin-top: 0.75rem; - font-size: 0.85rem; + margin-top: 12px; + border-radius: 4px; + padding: 0; + color: var(--muted); + font-size: 13px; +} + +.api-connect-status.success, +.api-connect-status.error, +.api-connect-status.info, +.api-connect-status.warning { + padding: 9px 12px; + border: 1px solid var(--border); } .api-connect-status.success { - color: #1f8f4c; + background: var(--ok-bg); + border-color: rgba(44, 102, 45, 0.25); + color: var(--ok-fg); } .api-connect-status.error { - color: #dc3545; + background: var(--crit-bg); + border-color: var(--crit-border); + color: var(--crit-fg); } .api-connect-status.info { - color: #0f4dba; + background: var(--accent-bg); + border-color: rgba(33, 133, 208, 0.2); + color: #0f4d7d; } .api-connect-status.warning { - color: #b06a00; -} - -.convert-progress { - margin-top: 0.9rem; -} - -.convert-progress-meta { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.35rem; - font-size: 0.82rem; - color: #475569; -} - -.convert-progress-track { - height: 12px; - border-radius: 999px; - border: 1px solid #cbd5e1; - background: #e2e8f0; - overflow: hidden; -} - -.convert-progress-bar { - height: 100%; - width: 0%; - background: linear-gradient(90deg, #2563eb, #0ea5e9); - transition: width 0.2s ease; + background: var(--warn-bg); + border-color: rgba(228, 156, 52, 0.45); + color: var(--warn-fg); } .job-status { - margin-top: 1rem; - border: 1px solid #d0d7de; - border-radius: 8px; - padding: 1rem; - background: #f8fafc; + margin: 16px; + padding: 16px; + background: var(--surface); } .job-status-header { display: flex; - justify-content: space-between; align-items: center; - gap: 0.75rem; - margin-bottom: 0.75rem; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; } .job-status-header h4 { margin: 0; - color: #2c3e50; -} - -#cancel-job-btn:disabled { - background: #9ca3af; - cursor: default; + font-size: 16px; + font-weight: 700; } .job-status-actions { display: flex; - gap: 0.5rem; align-items: center; -} - -#skip-hung-btn { - background: #f59e0b; - color: #fff; - border: none; - border-radius: 6px; - padding: 0.5rem 0.9rem; - font-size: 0.875rem; - font-weight: 600; - cursor: pointer; - transition: background-color 0.2s ease, opacity 0.2s ease; -} - -#skip-hung-btn:hover { - background: #d97706; -} - -#skip-hung-btn:disabled { - opacity: 0.6; - cursor: not-allowed; + gap: 8px; + flex-wrap: wrap; } .job-status-meta { display: grid; - grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); - gap: 0.5rem 0.75rem; - margin-bottom: 0.75rem; - font-size: 0.9rem; -} - -.job-progress { - height: 22px; - border-radius: 999px; - border: 1px solid #cbd5e1; - background: #e2e8f0; - overflow: hidden; - margin-bottom: 0.8rem; -} - -.job-progress-bar { - height: 100%; - min-width: 2.5rem; - background: linear-gradient(90deg, #2563eb, #0ea5e9); - color: #fff; - font-size: 0.78rem; - font-weight: 700; - display: flex; - align-items: center; - justify-content: center; - transition: width 0.25s ease; -} - -.job-active-modules { - margin-bottom: 0.85rem; -} - -.job-module-chips { - display: flex; - flex-wrap: wrap; - gap: 0.45rem; - margin-top: 0.35rem; -} - -.job-module-chip { - display: inline-flex; - align-items: center; - gap: 0.4rem; - background: #eef6ff; - border: 1px solid #bfdcff; - border-radius: 999px; - padding: 0.32rem 0.68rem; - line-height: 1; -} - -.job-module-chip-name { - font-size: 0.82rem; - color: #1f2937; - font-weight: 600; -} - -.job-module-chip-score { - font-size: 0.72rem; - color: #1d4ed8; - background: #dbeafe; - border: 1px solid #bfdbfe; - border-radius: 999px; - padding: 0.1rem 0.38rem; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; -} - -.job-debug-info { - margin-bottom: 0.85rem; - border: 1px solid #dbe5f0; - background: #f8fbff; - border-radius: 8px; - padding: 0.75rem; -} - -.job-debug-summary { - font-size: 0.82rem; - color: #334155; - margin-top: 0.35rem; -} - -.job-phase-telemetry { - margin-top: 0.55rem; - display: grid; - gap: 0.35rem; -} - -.job-phase-row { - display: grid; - grid-template-columns: minmax(120px, 180px) repeat(4, minmax(60px, auto)); - gap: 0.5rem; - align-items: center; - font-size: 0.8rem; -} - -.job-phase-name { - color: #0f172a; - font-weight: 600; -} - -.job-phase-metric { - color: #475569; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 8px 12px; + margin-bottom: 14px; } .meta-label { - color: #64748b; - font-weight: 600; + color: var(--muted); + font-weight: 700; } .job-status-badge { display: inline-flex; align-items: center; border-radius: 999px; - padding: 0.2rem 0.6rem; - font-size: 0.8rem; - font-weight: 600; + padding: 4px 10px; + font-size: 12px; + font-weight: 700; } .job-status-badge.status-queued, .job-status-badge.status-running { - background: #eff6ff; - color: #1d4ed8; + background: var(--accent-bg); + color: #0f4d7d; } .job-status-badge.status-success { - background: #ecfdf3; - color: #15803d; + background: var(--ok-bg); + color: var(--ok-fg); } .job-status-badge.status-failed { - background: #fef2f2; - color: #b91c1c; + background: var(--crit-bg); + color: var(--crit-fg); } .job-status-badge.status-canceled { - background: #f1f5f9; - color: #334155; + background: var(--surface-3); + color: #4b5563; +} + +.job-progress, +.convert-progress-track { + height: 14px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface-3); + overflow: hidden; +} + +.job-progress { + margin-bottom: 14px; +} + +.job-progress-bar, +.convert-progress-bar { + height: 100%; + background: linear-gradient(90deg, var(--accent), #4cb5ff); + transition: width 0.2s ease; +} + +.job-progress-bar { + display: flex; + align-items: center; + justify-content: center; + min-width: 44px; + color: #ffffff; + font-size: 11px; + font-weight: 700; +} + +.convert-progress { + margin-top: 16px; +} + +.convert-progress-meta { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 6px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.job-active-modules, +.job-debug-info, +.job-status-logs { + margin-top: 14px; +} + +.job-module-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.job-module-chip { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 999px; + border: 1px solid rgba(33, 133, 208, 0.2); + background: var(--accent-bg); + padding: 6px 10px; + line-height: 1; +} + +.job-module-chip-name { + font-size: 12px; + font-weight: 700; + color: #17466b; +} + +.job-module-chip-score { + border: 1px solid rgba(33, 133, 208, 0.2); + border-radius: 999px; + background: #ffffff; + padding: 2px 7px; + font-size: 11px; + font-weight: 700; + color: var(--accent); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.job-debug-info { + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface-2); + padding: 12px; +} + +.job-debug-summary { + margin-top: 8px; + color: #374151; + font-size: 12px; +} + +.job-phase-telemetry { + display: grid; + gap: 6px; + margin-top: 10px; +} + +.job-phase-row { + display: grid; + grid-template-columns: minmax(120px, 180px) repeat(4, minmax(70px, auto)); + gap: 8px; + align-items: center; + font-size: 12px; +} + +.job-phase-name { + font-weight: 700; + color: var(--ink); +} + +.job-phase-metric, +.log-time, +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.job-phase-metric { + color: #4b5563; } .job-status-logs ul { list-style: none; - margin-top: 0.35rem; - border-top: 1px solid #e5e7eb; + margin: 8px 0 0; + padding: 0; + border-top: 1px solid var(--border-lite); } .job-status-logs li { display: grid; - grid-template-columns: 90px 1fr; - gap: 0.5rem; - padding: 0.45rem 0; - border-bottom: 1px solid #eef2f7; - font-size: 0.85rem; + grid-template-columns: 92px 1fr; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--border-lite); + font-size: 13px; } .log-time { - color: #64748b; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + color: var(--muted); } .log-message { - color: #334155; + color: var(--ink); } #upload-status { - margin-top: 1rem; + margin-top: 12px; + min-height: 22px; + color: var(--muted); text-align: center; - padding: 0.5rem; -} - -#upload-status.error { - color: #e74c3c; } #upload-status.success { - color: #27ae60; + color: var(--ok-fg); +} + +#upload-status.error { + color: var(--crit-fg); } -/* Parsers info */ .parsers-info { - margin-top: 1.5rem; + margin-top: 28px; + padding: 18px 0 2px; text-align: center; + border-top: 1px solid var(--border-lite); } .parsers-title { - font-size: 0.85rem; - color: #4b5563; - margin-bottom: 0.6rem; - font-weight: 600; + margin: 0; + color: var(--muted); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; } -.parsers-list { - display: flex; - flex-wrap: wrap; - gap: 0.6rem; - justify-content: center; +.parsers-summary { + margin: 8px 0 0; + color: var(--ink); + font-size: 18px; + font-weight: 700; } -.parser-chip { - display: inline-flex; - align-items: center; - gap: 0.45rem; - background: #eef6ff; - padding: 0.38rem 0.72rem; - border-radius: 999px; - border: 1px solid #bfdcff; - line-height: 1; +.parsers-text { + max-width: 760px; + margin: 10px auto 0; + color: #6b7280; + font-size: 13px; + line-height: 1.7; } -.parser-chip-name { - font-size: 0.85rem; - color: #1f2937; - font-weight: 500; -} - -.parser-chip-version { - font-size: 0.72rem; - color: #1d4ed8; - background: #dbeafe; - padding: 0.12rem 0.42rem; - border-radius: 999px; - border: 1px solid #bfdbfe; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; -} - -/* File Info */ -.parser-badge, .file-name { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.badge-label { - font-size: 0.875rem; - color: #666; - font-weight: 500; -} - -.badge-value { - font-size: 0.875rem; - color: #2c3e50; - font-weight: 600; - background: #e3f2fd; - padding: 0.25rem 0.75rem; - border-radius: 4px; - border: 1px solid #90caf9; -} - -.parser-badge .badge-value { - background: #e8f5e9; - border-color: #81c784; -} - -.result-panel { +.viewer-panel { background: transparent; border: none; - border-radius: 0; - padding: 0; - margin-bottom: 0; + box-shadow: none; + overflow: visible; +} + +.viewer-panel-header { + background: transparent; + margin-bottom: 10px; +} + +.viewer-panel-header h2 { + border-radius: 4px 4px 0 0; +} + +.viewer-panel-header p { + padding-bottom: 0; } .audit-viewer-shell { - min-height: 60vh; - margin: 0; + border: 1px solid var(--border); + border-radius: 4px; + box-shadow: var(--shadow-shell); + overflow: hidden; + background: #ffffff; } .audit-viewer-frame { + display: block; width: 100%; min-height: 60vh; border: none; - border-radius: 0; - background: #fff; - display: block; + background: #ffffff; } -/* Tabs */ -.tabs { - display: flex; - border-bottom: 2px solid #ddd; - margin-bottom: 1rem; - flex-wrap: wrap; -} - -.tab { - padding: 0.75rem 1.5rem; - background: none; - border: none; - cursor: pointer; - font-size: 1rem; - color: #666; - border-bottom: 2px solid transparent; - margin-bottom: -2px; -} - -.tab:hover { - color: #333; -} - -.tab.active { - color: #3498db; - border-bottom-color: #3498db; -} - -.tab-content { - display: none; - background: white; - border-radius: 8px; - padding: 1rem; -} - -.tab-content.active { - display: block; -} - -/* Toolbar */ -.toolbar { - display: flex; - gap: 1rem; - margin-bottom: 1rem; - align-items: center; - flex-wrap: wrap; -} - -.toolbar select, -.toolbar button { - padding: 0.5rem 1rem; - border-radius: 4px; - font-size: 0.875rem; -} - -.toolbar select { - border: 1px solid #ddd; -} - -.toolbar button { - background: #27ae60; - color: white; - border: none; - cursor: pointer; -} - -.toolbar button:hover { - background: #219a52; -} - -/* Tables */ -table { - width: 100%; - border-collapse: collapse; -} - -.table-scroll { - width: 100%; - overflow-x: auto; - -webkit-overflow-scrolling: touch; -} - -th, td { - padding: 0.75rem; - text-align: left; - border-bottom: 1px solid #eee; -} - -th { - background: #f8f9fa; - font-weight: 600; -} - -tr:hover { - background: #f8f9fa; -} - -#parse-errors-table { - min-width: 980px; - table-layout: fixed; -} - -#parse-errors-table th:nth-child(1), -#parse-errors-table td:nth-child(1) { - width: 92px; -} - -#parse-errors-table th:nth-child(2), -#parse-errors-table td:nth-child(2) { - width: 110px; -} - -#parse-errors-table th:nth-child(3), -#parse-errors-table td:nth-child(3) { - width: 95px; -} - -#parse-errors-table th:nth-child(4), -#parse-errors-table td:nth-child(4) { - width: 300px; -} - -#parse-errors-table th:nth-child(5), -#parse-errors-table td:nth-child(5) { - width: auto; -} - -#parse-errors-table td, -#parse-errors-table th { - vertical-align: top; -} - -#parse-errors-table td code { - white-space: normal; - word-break: break-word; - overflow-wrap: anywhere; - display: inline-block; -} - -#parse-errors-table td:last-child { - white-space: normal; - word-break: break-word; - overflow-wrap: anywhere; -} - -code { - font-family: 'Monaco', 'Menlo', monospace; - font-size: 0.85em; - background: #f0f0f0; - padding: 0.1em 0.3em; - border-radius: 3px; -} - -/* Severity badges */ -.severity { - padding: 0.25rem 0.5rem; - border-radius: 4px; - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; -} - -.severity.critical { - background: #e74c3c; - color: white; -} - -.severity.warning { - background: #f39c12; - color: white; -} - -.severity.info { - background: #3498db; - color: white; -} - -/* Category badges */ -.category-badge { - display: inline-block; - padding: 0.2rem 0.5rem; - border-radius: 4px; - font-size: 0.75rem; - font-weight: 500; - background: #95a5a6; - color: white; -} - -.category-badge.board { - background: #2c3e50; -} - -.category-badge.cpu { - background: #e74c3c; -} - -.category-badge.memory { - background: #3498db; -} - -.category-badge.storage { - background: #27ae60; -} - -.category-badge.pcie { - background: #e67e22; -} - -.category-badge.network { - background: #1abc9c; -} - -.category-badge.psu { - background: #f39c12; -} - -.category-badge.fru { - background: #9b59b6; -} - -.category-badge.firmware { - background: #34495e; -} - -/* Toolbar label */ -.toolbar-label { - font-size: 0.875rem; - color: #666; -} - -/* Config sections */ -.config-section { - margin-bottom: 1.5rem; -} - -.config-section h3 { - font-size: 1rem; - color: #2c3e50; - margin-bottom: 0.5rem; - border-bottom: 1px solid #eee; - padding-bottom: 0.25rem; -} - -/* Specification section */ -.spec-section { - background: #f8f9fa; - border-radius: 8px; - padding: 1rem; - border-left: 4px solid #3498db; -} - -.spec-list { - list-style: none; - margin: 0; - padding: 0; -} - -.spec-list li { - padding: 0.4rem 0; - border-bottom: 1px dashed #e0e0e0; -} - -.spec-list li:last-child { - border-bottom: none; -} - -.spec-category { - font-weight: 600; - color: #2c3e50; - min-width: 180px; - display: inline-block; -} - -.spec-qty { - color: #666; - font-weight: 500; -} - -.card { - background: #f8f9fa; - padding: 1rem; - border-radius: 4px; - margin-bottom: 0.5rem; -} - -/* Sensors */ -.sensor-group { - margin-bottom: 1.5rem; -} - -.sensor-group h3 { - font-size: 1rem; - color: #2c3e50; - margin-bottom: 0.75rem; -} - -.sensor-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - gap: 0.5rem; -} - -.sensor-card { - display: flex; - flex-direction: column; - padding: 0.5rem 0.75rem; - border-radius: 4px; - background: #f8f9fa; - border-left: 3px solid #27ae60; -} - -.sensor-card.ok { - border-left-color: #27ae60; -} - -.sensor-card.warn { - border-left-color: #f39c12; -} - -.sensor-card.voltage-out-of-range { - border-left-color: #e74c3c; - background: #fff5f5; -} - -.sensor-card.ns { - border-left-color: #95a5a6; - opacity: 0.6; -} - -.sensor-name { - font-size: 0.75rem; - color: #666; -} - -.sensor-value { - font-size: 1rem; - font-weight: 600; - color: #2c3e50; -} - -/* Footer */ -footer { - text-align: center; - padding: 2rem; -} - -.footer-buttons { - display: flex; - gap: 0.5rem; - justify-content: center; - flex-wrap: wrap; +.page-footer { + width: min(1500px, calc(100vw - 48px)); + margin: -20px auto 36px; + padding-top: 10px; } .footer-info { - margin-top: 1rem; - font-size: 0.8rem; - color: #666; + color: var(--muted); + font-size: 12px; +} + +.footer-info p { + margin: 0; } .footer-info a { - color: #3498db; text-decoration: none; } -.footer-info a:hover { - text-decoration: underline; +code { + border-radius: 3px; + background: #f3f4f6; + padding: 0.1em 0.3em; + font-size: 0.92em; } -#clear-btn { - background: #e74c3c; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; -} - -#clear-btn:hover { - background: #c0392b; -} - -#restart-btn { - background: #3498db; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; -} - -#restart-btn:hover { - background: #2980b9; -} - -#exit-btn { - background: #95a5a6; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; -} - -#exit-btn:hover { - background: #7f8c8d; -} - -/* Utility */ .hidden { display: none !important; } -.no-data { - text-align: center; - color: #888; - padding: 2rem; -} - -/* Server info header */ -.server-info { - background: #2c3e50; - color: white; - padding: 1rem 1.5rem; - border-radius: 8px 8px 0 0; - margin-bottom: 0; - display: flex; - gap: 2rem; - flex-wrap: wrap; -} - -.server-info-item { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.server-info-label { - opacity: 0.8; - font-size: 0.875rem; -} - -.server-info strong { - font-size: 1.1rem; -} - -.server-info code { - background: rgba(255,255,255,0.15); - padding: 0.2rem 0.5rem; - border-radius: 4px; - font-size: 1rem; -} - -/* Configuration tabs */ -.config-tabs { - display: flex; - flex-wrap: wrap; - gap: 0.25rem; - margin-bottom: 1rem; - background: #f0f0f0; - padding: 0.25rem; - border-radius: 6px; -} - -.config-tab { - padding: 0.5rem 1rem; - background: none; - border: none; - cursor: pointer; - font-size: 0.875rem; - color: #666; - border-radius: 4px; - transition: all 0.2s; -} - -.config-tab:hover { - color: #333; - background: #e0e0e0; -} - -.config-tab.active { - color: white; - background: #3498db; -} - -.config-tab-content { - display: none; -} - -.config-tab-content.active { - display: block; -} - -.config-tab-content h3 { - margin-bottom: 1rem; - padding-bottom: 0.5rem; - border-bottom: 2px solid #e0e0e0; - color: #2c3e50; -} - -.pcie-group-title { - margin: 1rem 0 0.5rem; - color: #34495e; - font-size: 0.95rem; -} - -/* Config tables */ -.config-table { - font-size: 0.875rem; -} - -.config-table th { - background: #2c3e50; - color: white; - font-weight: 500; - padding: 0.5rem 0.75rem; - white-space: nowrap; -} - -.config-table td { - padding: 0.5rem 0.75rem; - vertical-align: middle; -} - -.config-table tbody tr:nth-child(even) { - background: #f8f9fa; -} - -.config-table tbody tr:hover { - background: #e8f4fc; -} - -/* Memory table specific */ -.memory-table .row-warning { - background: #fff3cd !important; -} - -.memory-table .row-warning:hover { - background: #ffe8a1 !important; -} - -.memory-table .present-yes { - color: #27ae60; - font-weight: bold; -} - -.memory-table .present-no { - color: #95a5a6; -} - -.memory-table .status-warning { - color: #e74c3c; - font-weight: bold; -} - -/* Section overview stats */ -.memory-overview, -.section-overview { - display: flex; - gap: 1rem; - margin-bottom: 1rem; - flex-wrap: wrap; -} - -.stat-box { - background: #f8f9fa; - padding: 0.75rem 1.25rem; - border-radius: 8px; - text-align: center; - border-left: 4px solid #3498db; - min-width: 80px; -} - -.stat-box.model-box { - flex-grow: 1; - text-align: left; - border-left-color: #27ae60; -} - -.stat-box.model-box .stat-value { - font-size: 1rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.stat-value { - display: block; - font-size: 1.25rem; - font-weight: bold; - color: #2c3e50; -} - -.stat-label { - font-size: 0.7rem; - color: #666; - text-transform: uppercase; -} - -.stat-box.pcie-balance-ok { - border-left-color: #27ae60; -} - -.stat-box.pcie-balance-warning { - border-left-color: #f39c12; -} - -.stat-box.pcie-balance-critical { - border-left-color: #e74c3c; -} - -.pcie-balance-bars { - margin-bottom: 1rem; - display: grid; - gap: 0.5rem; - max-width: 560px; -} - -.pcie-balance-row { - display: grid; - grid-template-columns: 72px 1fr 42px; - gap: 0.5rem; - align-items: center; -} - -.pcie-balance-cpu, -.pcie-balance-value { - font-size: 0.8rem; - color: #2c3e50; - font-weight: 600; -} - -.pcie-balance-track { - height: 10px; - border-radius: 999px; - background: #e5e9ec; - overflow: hidden; -} - -.pcie-balance-fill { - height: 100%; - border-radius: inherit; - min-width: 2px; -} - -.pcie-balance-fill.pcie-balance-ok { - background: #27ae60; -} - -.pcie-balance-fill.pcie-balance-warning { - background: #f39c12; -} - -.pcie-balance-fill.pcie-balance-critical { - background: #e74c3c; -} - -/* Responsive */ -@media (max-width: 768px) { - .sensor-grid { - grid-template-columns: repeat(2, 1fr); +@media (max-width: 900px) { + .page-main, + .page-footer { + width: calc(100vw - 24px); } - table { - font-size: 0.875rem; + .page-header { + padding: 14px 16px; } - th, td { - padding: 0.5rem; + .page-main { + margin-top: 18px; + margin-bottom: 42px; } - .config-tabs { + .job-phase-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .source-switch { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + width: 100%; + } + + .source-switch-btn { + justify-content: center; + } + + .upload-area { + padding: 18px 14px; + } + + .upload-dropzone strong { + font-size: 22px; + } + + .upload-copy { + font-size: 14px; + } + + .job-status, + #api-connect-form, + #convert-source-content, + #upload-status, + .parsers-info, + #convert-folder-summary, + #convert-status, + .convert-progress { + margin-left: 12px; + margin-right: 12px; + } + + .job-status-header, + .header-actions, + .api-form-actions, + .job-status-actions { + align-items: stretch; + } + + .job-status-header, + .header-actions { flex-direction: column; } - .config-tab { - text-align: left; + .header-actions button, + .header-action, + #api-connect-btn, + #api-collect-btn, + #convert-folder-btn, + #convert-run-btn, + #cancel-job-btn, + #skip-hung-btn, + .upload-area button { + width: 100%; } - .memory-overview { - flex-direction: column; - } - - .config-table { - font-size: 0.75rem; - } - - .config-table th, - .config-table td { - padding: 0.25rem 0.5rem; + .job-status li { + grid-template-columns: 1fr; } } - -/* PCIe degraded link highlighting */ -.pcie-degraded { - color: #dc3545; - font-weight: 600; -} - -.pcie-max { - color: #6c757d; - font-size: 0.9em; -} diff --git a/web/static/js/app.js b/web/static/js/app.js index 03cf905..b104780 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -175,7 +175,7 @@ function startApiProbe() { .then(async (response) => { const body = await response.json().catch(() => ({})); if (!response.ok) { - throw new Error(body.error || 'Проверка подключения не удалась'); + throw new Error(body.error || 'Connection check failed'); } apiProbeResult = body; @@ -186,7 +186,7 @@ function startApiProbe() { renderApiConnectStatus(false); const status = document.getElementById('api-connect-status'); if (status) { - status.textContent = err.message || 'Проверка подключения не удалась'; + status.textContent = err.message || 'Connection check failed'; status.className = 'api-connect-status error'; } }) @@ -208,7 +208,7 @@ function startCollectionWithOptions() { if (!apiProbeResult || !apiProbeResult.reachable) { const status = document.getElementById('api-connect-status'); if (status) { - status.textContent = 'Сначала выполните проверку подключения.'; + status.textContent = 'Run the connection check first.'; status.className = 'api-connect-status error'; } return; @@ -228,20 +228,20 @@ function renderApiProbeState() { } if (!apiProbeResult || !apiProbeResult.reachable) { - status.textContent = 'Проверка подключения не пройдена.'; + status.textContent = 'Connection check did not pass.'; status.className = 'api-connect-status error'; probeOptions.classList.add('hidden'); - connectButton.textContent = 'Подключиться'; + connectButton.textContent = 'Connect'; return; } const hostOn = apiProbeResult.host_powered_on; if (hostOn) { - status.textContent = apiProbeResult.message || 'Связь с BMC есть, host включён.'; + status.textContent = apiProbeResult.message || 'Connected to the BMC. The host is powered on.'; status.className = 'api-connect-status success'; } else { - status.textContent = apiProbeResult.message || 'Связь с BMC есть, host выключен.'; + status.textContent = apiProbeResult.message || 'Connected to the BMC. The host is powered off.'; status.className = 'api-connect-status warning'; } @@ -256,7 +256,7 @@ function renderApiProbeState() { } } - connectButton.textContent = 'Переподключиться'; + connectButton.textContent = 'Reconnect'; } function resetApiProbeState() { @@ -265,7 +265,7 @@ function resetApiProbeState() { const connectButton = document.getElementById('api-connect-btn'); const probeOptions = document.getElementById('api-probe-options'); if (connectButton) { - connectButton.textContent = 'Подключиться'; + connectButton.textContent = 'Connect'; } if (probeOptions) { probeOptions.classList.add('hidden'); @@ -289,30 +289,30 @@ function validateCollectForm() { const errors = {}; if (!host) { - errors.host = 'Укажите host.'; + errors.host = 'Enter a host.'; } const port = Number(portRaw); const isPortInteger = Number.isInteger(port); if (!portRaw) { - errors.port = 'Укажите порт.'; + errors.port = 'Enter a port.'; } else if (!isPortInteger || port < 1 || port > 65535) { - errors.port = 'Порт должен быть от 1 до 65535.'; + errors.port = 'Port must be between 1 and 65535.'; } if (!username) { - errors.username = 'Укажите username.'; + errors.username = 'Enter a username.'; } if (!password) { - errors.password = 'Введите пароль.'; + errors.password = 'Enter a password.'; } if (Object.keys(errors).length > 0) { return { isValid: false, errors, payload: null }; } - // TODO: UI для выбора протокола вернем, когда откроем IPMI коннектор. + // TODO: restore the protocol selector when a real IPMI connector exists. const payload = { host, protocol: 'redfish', @@ -356,7 +356,7 @@ function renderFormErrors(errors) { } summary.classList.remove('hidden'); - summary.innerHTML = `Исправьте ошибки в форме:`; + summary.innerHTML = `Fix the form errors:`; } function renderApiConnectStatus(isValid) { @@ -366,12 +366,12 @@ function renderApiConnectStatus(isValid) { } if (!isValid) { - status.textContent = 'Форма не отправлена: есть ошибки.'; + status.textContent = 'The form was not submitted because it contains errors.'; status.className = 'api-connect-status error'; return; } - status.textContent = 'Подключение...'; + status.textContent = 'Connecting...'; status.className = 'api-connect-status info'; } @@ -398,7 +398,7 @@ function startCollectionJob(payload) { .then(async (response) => { const body = await response.json().catch(() => ({})); if (!response.ok) { - throw new Error(body.error || 'Не удалось запустить задачу'); + throw new Error(body.error || 'Failed to start the job'); } collectionJob = { @@ -413,7 +413,7 @@ function startCollectionJob(payload) { debugInfo: null, payload }; - appendJobLog(body.message || 'Задача поставлена в очередь'); + appendJobLog(body.message || 'Job queued'); renderCollectionJob(); collectionJobPollTimer = window.setInterval(() => { @@ -426,7 +426,7 @@ function startCollectionJob(payload) { renderApiConnectStatus(false); const status = document.getElementById('api-connect-status'); if (status) { - status.textContent = err.message || 'Ошибка запуска задачи'; + status.textContent = err.message || 'Failed to start the job'; status.className = 'api-connect-status error'; } }); @@ -442,7 +442,7 @@ function pollCollectionJobStatus() { .then(async (response) => { const body = await response.json().catch(() => ({})); if (!response.ok) { - throw new Error(body.error || 'Не удалось получить статус задачи'); + throw new Error(body.error || 'Failed to fetch job status'); } const prevStatus = collectionJob.status; @@ -462,7 +462,7 @@ function pollCollectionJobStatus() { if (collectionJob.status === 'success') { loadDataFromStatus(); } else if (collectionJob.status === 'failed' && collectionJob.error) { - appendJobLog(`Ошибка: ${collectionJob.error}`); + appendJobLog(`Error: ${collectionJob.error}`); renderCollectionJob(); } } else if (prevStatus !== collectionJob.status && collectionJob.status === 'running') { @@ -470,7 +470,7 @@ function pollCollectionJobStatus() { } }) .catch((err) => { - appendJobLog(`Ошибка статуса: ${err.message}`); + appendJobLog(`Status error: ${err.message}`); renderCollectionJob(); clearCollectionJobPolling(); setApiFormBlocked(false); @@ -484,7 +484,7 @@ function skipHungCollectionJob() { const btn = document.getElementById('skip-hung-btn'); if (btn) { btn.disabled = true; - btn.textContent = 'Пропуск...'; + btn.textContent = 'Skipping...'; } fetch(`/api/collect/${encodeURIComponent(collectionJob.id)}/skip`, { method: 'POST' @@ -492,16 +492,16 @@ function skipHungCollectionJob() { .then(async (response) => { const body = await response.json().catch(() => ({})); if (!response.ok) { - throw new Error(body.error || 'Не удалось пропустить зависшие запросы'); + throw new Error(body.error || 'Failed to skip hung requests'); } syncServerLogs(body.logs); renderCollectionJob(); }) .catch((err) => { - appendJobLog(`Ошибка пропуска: ${err.message}`); + appendJobLog(`Skip error: ${err.message}`); if (btn) { btn.disabled = false; - btn.textContent = 'Пропустить зависшие'; + btn.textContent = 'Skip Hung Requests'; } renderCollectionJob(); }); @@ -517,7 +517,7 @@ function cancelCollectionJob() { .then(async (response) => { const body = await response.json().catch(() => ({})); if (!response.ok) { - throw new Error(body.error || 'Не удалось отменить задачу'); + throw new Error(body.error || 'Failed to cancel the job'); } collectionJob.status = normalizeJobStatus(body.status || 'canceled'); collectionJob.progress = Number.isFinite(body.progress) ? body.progress : collectionJob.progress; @@ -526,7 +526,7 @@ function cancelCollectionJob() { renderCollectionJob(); }) .catch((err) => { - appendJobLog(`Ошибка отмены: ${err.message}`); + appendJobLog(`Cancel error: ${err.message}`); renderCollectionJob(); }); } @@ -542,7 +542,7 @@ function appendJobLog(message) { // but mark as hidden so renderCollectionJob skips it. collectionJob.logs.push({ id: ++collectionJobLogCounter, - time: parsed.time || new Date().toLocaleTimeString('ru-RU', { hour12: false }), + time: parsed.time || new Date().toLocaleTimeString('en-GB', { hour12: false }), message: parsed.message, hidden: true }); @@ -551,7 +551,7 @@ function appendJobLog(message) { collectionJob.logs.push({ id: ++collectionJobLogCounter, - time: parsed.time || new Date().toLocaleTimeString('ru-RU', { hour12: false }), + time: parsed.time || new Date().toLocaleTimeString('en-GB', { hour12: false }), message: humanizeCollectLogMessage(parsed.message) }); } @@ -559,32 +559,31 @@ function appendJobLog(message) { // Transform technical log messages into human-readable form for the UI. // The original messages are preserved in collect.log / raw_export. function humanizeCollectLogMessage(msg) { - // "Redfish snapshot: документов=520, ETA≈16s, корни=Chassis(294), Systems(114), последний=/redfish/v1/..." - // → "Snapshot: /Chassis/Self/PCIeDevices/00_34_04" + // Match the existing server-side snapshot progress format and collapse it to one path. let m = msg.match(/snapshot:\s+документов=\d+[^,]*,.*последний=(\S+)/i); if (m) { const path = m[1].replace(/^\.\.\./, '').replace(/^\/redfish\/v1/, '') || m[1]; return `Snapshot: ${path}`; } - // "Redfish snapshot: собрано N документов" + // Match the existing server-side snapshot completion format. m = msg.match(/snapshot:\s+собрано\s+(\d+)\s+документов/i); if (m) { - return `Snapshot: итого ${m[1]} документов`; + return `Snapshot: ${m[1]} documents collected`; } - // "Redfish: plan-B завершен за 30s (targets=18, recovered=0)" + // Match the existing server-side plan-B completion format. m = msg.match(/plan-B завершен за ([^(]+)\(targets=(\d+),\s*recovered=(\d+)\)/i); if (m) { const recovered = parseInt(m[3], 10); - const suffix = recovered > 0 ? `, восстановлено ${m[3]}` : ''; - return `Plan-B: завершен за ${m[1].trim()}${suffix}`; + const suffix = recovered > 0 ? `, recovered ${m[3]}` : ''; + return `Plan-B: completed in ${m[1].trim()}${suffix}`; } - // "Redfish: prefetch критичных endpoint (адаптивно 9/72)..." + // Match the existing server-side prefetch progress format. m = msg.match(/prefetch критичных endpoint[^(]*\(([^)]+)\)/i); if (m) { - return `Prefetch критичных endpoint (${m[1]})`; + return `Critical endpoint prefetch (${m[1]})`; } // Strip "Redfish: " / "Redfish snapshot: " prefix — redundant in context @@ -621,9 +620,9 @@ function renderCollectionJob() { statusValue.className = `job-status-badge status-${collectionJob.status.toLowerCase()}`; const isTerminal = isCollectionJobTerminal(collectionJob.status); const terminalMessage = { - success: 'Сбор завершен', - failed: 'Сбор завершился ошибкой', - canceled: 'Сбор отменен' + success: 'Collection completed', + failed: 'Collection failed', + canceled: 'Collection canceled' }[collectionJob.status]; const activity = isTerminal ? terminalMessage : latestCollectionActivityMessage(); const eta = isTerminal ? '-' : latestCollectionETA(); @@ -652,7 +651,7 @@ function renderCollectionJob() { } else { skipBtn.classList.add('hidden'); skipBtn.disabled = false; - skipBtn.textContent = 'Пропустить зависшие'; + skipBtn.textContent = 'Skip Hung Requests'; } } @@ -664,18 +663,18 @@ function latestCollectionActivityMessage() { return humanizeCollectionPhase(collectionJob.currentPhase); } if (!collectionJob || !Array.isArray(collectionJob.logs) || collectionJob.logs.length === 0) { - return 'Сбор данных...'; + return 'Collecting data...'; } const last = String(collectionJob.logs[collectionJob.logs.length - 1].message || '').trim(); if (!last) { - return 'Сбор данных...'; + return 'Collecting data...'; } // Job logs already contain server timestamp prefix. Show concise step text in progress label. const cleaned = last.replace(/^\d{4}-\d{2}-\d{2}T[^\s]+\s+/, '').trim(); if (!cleaned) { - return 'Сбор данных...'; + return 'Collecting data...'; } - return cleaned.replace(/\s*[,(]?\s*ETA[^,;)]*/i, '').trim() || 'Сбор данных...'; + return cleaned.replace(/\s*[,(]?\s*ETA[^,;)]*/i, '').trim() || 'Collecting data...'; } function latestCollectionETA() { @@ -771,7 +770,7 @@ function parseServerLogLine(raw) { return { time: null, message: String(raw).trim() }; } const d = new Date(m[1]); - const time = isNaN(d) ? null : d.toLocaleTimeString('ru-RU', { hour12: false }); + const time = isNaN(d) ? null : d.toLocaleTimeString('en-GB', { hour12: false }); return { time, message: m[2].trim() }; } @@ -789,7 +788,7 @@ function humanizeCollectionPhase(phase) { prefetch: 'Prefetch critical endpoints', critical_plan_b: 'Critical plan-B', profile_plan_b: 'Profile plan-B' - }[value] || value || 'Сбор данных...'; + }[value] || value || 'Collecting data...'; } function formatDurationSeconds(totalSeconds) { @@ -933,15 +932,17 @@ async function loadParsersInfo() { const container = document.getElementById('parsers-info'); if (data.parsers && data.parsers.length > 0) { - let html = '

Подключенные парсеры:

'; - data.parsers.forEach(p => { - html += ` - ${escapeHtml(p.name)} - v${escapeHtml(p.version)} - `; - }); - html += '
'; - container.innerHTML = html; + const parserNames = data.parsers.map((p) => { + const name = escapeHtml(p.name || ''); + const version = escapeHtml(p.version || ''); + return version ? `${name} (v${version})` : name; + }).filter(Boolean); + + container.innerHTML = ` +

Parsers

+

${escapeHtml(String(parserNames.length))} loaded

+

${parserNames.join(' · ')}

+ `; } } catch (err) { console.error('Failed to load parsers info:', err); @@ -980,7 +981,7 @@ function initUpload() { async function uploadFile(file) { const status = document.getElementById('upload-status'); - status.textContent = 'Загрузка и анализ...'; + status.textContent = 'Uploading and analyzing...'; status.className = ''; const formData = new FormData(); @@ -996,15 +997,15 @@ async function uploadFile(file) { if (response.ok) { status.innerHTML = `${escapeHtml(result.vendor)}
` + - `${result.stats.sensors} сенсоров, ${result.stats.fru} компонентов, ${result.stats.events} событий`; + `${result.stats.sensors} sensors, ${result.stats.fru} components, ${result.stats.events} events`; status.className = 'success'; loadData(result.vendor, result.filename); } else { - status.textContent = result.error || 'Ошибка загрузки'; + status.textContent = result.error || 'Upload failed'; status.className = 'error'; } } catch (err) { - status.textContent = 'Ошибка соединения'; + status.textContent = 'Connection error'; status.className = 'error'; } } @@ -1020,7 +1021,7 @@ function initConvertMode() { const raw = Array.from(folderInput.files || []).filter(file => file && file.name); const summary = document.getElementById('convert-folder-summary'); if (summary) { - summary.textContent = 'Проверка дубликатов…'; + summary.textContent = 'Checking duplicates...'; summary.className = 'api-connect-status'; } const { unique, duplicates } = await deduplicateConvertFiles(raw); @@ -1042,7 +1043,7 @@ function renderConvertSummary() { } if (convertFiles.length === 0) { - summary.textContent = 'Выберите папку с файлами, включая вложенные каталоги.'; + summary.textContent = 'Choose a folder with files, including nested directories.'; summary.className = 'api-connect-status'; return; } @@ -1053,19 +1054,19 @@ function renderConvertSummary() { const previewCount = 5; const previewFiles = supportedFiles.slice(0, previewCount).map(file => escapeHtml(file.webkitRelativePath || file.name)); const remaining = supportedFiles.length - previewFiles.length; - const previewText = previewFiles.length > 0 ? `Примеры: ${previewFiles.join(', ')}` : ''; - const skippedText = skippedCount > 0 ? ` Пропущено неподдерживаемых: ${skippedCount}.` : ''; + const previewText = previewFiles.length > 0 ? `Examples: ${previewFiles.join(', ')}` : ''; + const skippedText = skippedCount > 0 ? ` Unsupported files skipped: ${skippedCount}.` : ''; const batchCount = Math.ceil(supportedFiles.length / CONVERT_MAX_FILES_PER_BATCH); - const batchesText = batchCount > 1 ? ` Будет ${batchCount} прохода(ов) по ${CONVERT_MAX_FILES_PER_BATCH} файлов.` : ''; + const batchesText = batchCount > 1 ? ` ${batchCount} pass(es) of ${CONVERT_MAX_FILES_PER_BATCH} files will be required.` : ''; let dupText = ''; if (convertDuplicates.length > 0) { const names = convertDuplicates.map(d => escapeHtml(d.name)).join(', '); - const reasons = convertDuplicates.map(d => d.reason === 'hash' ? 'одинаковое содержимое' : 'одинаковое имя'); + const reasons = convertDuplicates.map(d => d.reason === 'hash' ? 'same content' : 'same name'); const uniqueReasons = [...new Set(reasons)].join(', '); - dupText = ` ⚠ Пропущено дубликатов: ${convertDuplicates.length} (${uniqueReasons}): ${names}.`; + dupText = ` ⚠ Duplicates skipped: ${convertDuplicates.length} (${uniqueReasons}): ${names}.`; } - summary.innerHTML = `${supportedFiles.length} файлов готовы к конвертации.${previewText ? ` ${previewText}` : ''}${remaining > 0 ? ` и ещё ${remaining}` : ''}.${skippedText}${batchesText}${dupText}`; + summary.innerHTML = `${supportedFiles.length} files are ready for conversion.${previewText ? ` ${previewText}` : ''}${remaining > 0 ? ` and ${remaining} more` : ''}.${skippedText}${batchesText}${dupText}`; summary.className = 'api-connect-status'; } @@ -1075,22 +1076,22 @@ async function runConvertBatch() { return; } if (convertFiles.length === 0) { - renderConvertStatus('Нет файлов для конвертации', 'error'); + renderConvertStatus('No files selected for conversion', 'error'); return; } const selectedFiles = convertFiles.filter(file => file && file.name); const supportedFiles = selectedFiles.filter(file => isSupportedConvertFileName(file.webkitRelativePath || file.name)); if (supportedFiles.length === 0) { - renderConvertStatus('В выбранной папке нет файлов поддерживаемого типа', 'error'); + renderConvertStatus('The selected folder does not contain any supported files', 'error'); return; } const batches = chunkFiles(supportedFiles, CONVERT_MAX_FILES_PER_BATCH); isConvertRunning = true; runButton.disabled = true; - renderConvertProgress(0, 'Подготовка загрузки...'); - renderConvertStatus(`Выполняю пакетную конвертацию (${batches.length} проходов)...`, 'info'); + renderConvertProgress(0, 'Preparing upload...'); + renderConvertStatus(`Running batch conversion (${batches.length} pass(es))...`, 'info'); try { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); @@ -1099,7 +1100,7 @@ async function runConvertBatch() { for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) { const batchFiles = batches[batchIdx]; const pass = batchIdx + 1; - const passLabel = `Проход ${pass}/${batches.length}`; + const passLabel = `Pass ${pass}/${batches.length}`; const passStart = Math.round((batchIdx / batches.length) * 100); const passEnd = Math.round(((batchIdx + 1) / batches.length) * 100); @@ -1112,19 +1113,19 @@ async function runConvertBatch() { const startResponse = await uploadConvertBatch(formData, (percent) => { const clamped = Math.max(0, Math.min(100, Number(percent) || 0)); const uploadPhase = passStart + Math.round((passEnd - passStart) * 0.3 * (clamped / 100)); - renderConvertProgress(uploadPhase, `${passLabel}: загрузка ${clamped}%`); + renderConvertProgress(uploadPhase, `${passLabel}: upload ${clamped}%`); }); if (!startResponse.ok) { const errorPayload = parseConvertErrorPayload(startResponse.bodyText); hideConvertProgress(); - renderConvertStatus(`${passLabel}: ${errorPayload.error || 'пакетная конвертация завершилась с ошибкой'}`, 'error'); + renderConvertStatus(`${passLabel}: ${errorPayload.error || 'batch conversion failed'}`, 'error'); return; } if (!startResponse.jobId) { hideConvertProgress(); - renderConvertStatus(`${passLabel}: сервер не вернул идентификатор задачи`, 'error'); + renderConvertStatus(`${passLabel}: server did not return a job ID`, 'error'); return; } @@ -1132,27 +1133,27 @@ async function runConvertBatch() { const serverProgress = Math.max(0, Math.min(100, Number(statusPayload.progress || 0))); const phase = 0.3 + 0.7 * (serverProgress / 100); const combined = passStart + Math.round((passEnd - passStart) * phase); - renderConvertProgress(combined, `${passLabel}: конвертация ${serverProgress}%`); + renderConvertProgress(combined, `${passLabel}: conversion ${serverProgress}%`); }); const downloadResponse = await downloadConvertArchive(startResponse.jobId); if (!downloadResponse.ok) { const errorPayload = parseConvertErrorPayload(downloadResponse.bodyText); hideConvertProgress(); - renderConvertStatus(`${passLabel}: ${errorPayload.error || 'не удалось скачать результат'}`, 'error'); + renderConvertStatus(`${passLabel}: ${errorPayload.error || 'failed to download the result'}`, 'error'); return; } const suffix = batches.length > 1 ? `-part${pass}` : ''; downloadBlob(downloadResponse.blob, `logpile-convert-${timestamp}${suffix}.zip`); - passSummaries.push(downloadResponse.summaryHeader || `${passLabel}: завершено`); + passSummaries.push(downloadResponse.summaryHeader || `${passLabel}: completed`); } hideConvertProgress(); renderConvertStatus(passSummaries.join(' | '), 'success'); } catch (err) { hideConvertProgress(); - renderConvertStatus('Ошибка соединения при конвертации', 'error'); + renderConvertStatus('Connection error during conversion', 'error'); } finally { isConvertRunning = false; runButton.disabled = false; @@ -1217,7 +1218,7 @@ async function waitForConvertJob(jobId, onProgress) { const response = await fetch(`/api/convert/${encodeURIComponent(jobId)}`); const payload = await response.json().catch(() => ({})); if (!response.ok) { - throw new Error(payload.error || 'Не удалось получить статус конвертации'); + throw new Error(payload.error || 'Failed to fetch conversion status'); } if (onProgress) { @@ -1229,7 +1230,7 @@ async function waitForConvertJob(jobId, onProgress) { return payload; } if (status === 'failed' || status === 'canceled') { - throw new Error(payload.error || 'Конвертация завершилась ошибкой'); + throw new Error(payload.error || 'Conversion failed'); } await delay(900); @@ -1375,7 +1376,7 @@ function renderConvertProgress(percent, label) { wrap.classList.remove('hidden'); bar.style.width = `${safePercent}%`; value.textContent = `${safePercent}%`; - text.textContent = label || 'Выполняется...'; + text.textContent = label || 'Running...'; } function hideConvertProgress() { @@ -1400,43 +1401,10 @@ function downloadBlob(blob, filename) { }, 3000); } -// Tab navigation -function initTabs() { - const tabs = document.querySelectorAll('.tab'); - tabs.forEach(tab => { - tab.addEventListener('click', () => { - tabs.forEach(t => t.classList.remove('active')); - document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); - tab.classList.add('active'); - document.getElementById(tab.dataset.tab).classList.add('active'); - }); - }); -} - -// Filters -function initFilters() { - document.getElementById('sensor-filter').addEventListener('change', (e) => { - filterSensors(e.target.value); - }); - document.getElementById('severity-filter').addEventListener('change', (e) => { - filterEvents(e.target.value); - }); - document.getElementById('serial-filter').addEventListener('change', (e) => { - filterSerials(e.target.value); - }); -} - -let allSensors = []; -let allEvents = []; -let allSerials = []; -let allParseErrors = []; - -let currentVendor = ''; let auditViewerNonce = 0; // Load data from API async function loadData(vendor, filename) { - currentVendor = vendor || ''; document.getElementById('upload-section').classList.add('hidden'); document.getElementById('data-section').classList.remove('hidden'); document.getElementById('clear-btn').classList.remove('hidden'); @@ -1444,13 +1412,6 @@ async function loadData(vendor, filename) { document.getElementById('header-reanimator-btn').classList.remove('hidden'); document.getElementById('header-log-meta').classList.remove('hidden'); - // Update vendor badge if exists (legacy support) - const vendorBadge = document.getElementById('vendor-badge'); - if (vendorBadge && currentVendor) { - vendorBadge.textContent = currentVendor; - vendorBadge.classList.remove('hidden'); - } - loadAuditViewer(); } @@ -1487,704 +1448,6 @@ function resizeAuditViewerFrame() { } } -async function loadConfig() { - try { - const response = await fetch('/api/config'); - const config = await response.json(); - renderConfig(config); - } catch (err) { - console.error('Failed to load config:', err); - } -} - -function renderConfig(data) { - const container = document.getElementById('config-content'); - - if (!data || Object.keys(data).length === 0) { - container.innerHTML = '

Нет данных о конфигурации

'; - return; - } - - const config = data.hardware || data; - const spec = data.specification; - const redfishFetchErrors = Array.isArray(data.redfish_fetch_errors) ? data.redfish_fetch_errors : []; - const devices = Array.isArray(config.devices) ? config.devices : []; - const volumes = Array.isArray(config.volumes) ? config.volumes : []; - - const cpus = devices.filter(d => d.kind === 'cpu'); - const memory = devices.filter(d => d.kind === 'memory'); - const powerSupplies = devices.filter(d => d.kind === 'psu'); - const storage = devices.filter(d => d.kind === 'storage'); - const gpus = devices.filter(d => d.kind === 'gpu'); - const networkAdapters = devices.filter(d => d.kind === 'network'); - const inventoryRows = devices.filter(d => ['pcie', 'storage', 'gpu', 'network'].includes(d.kind)); - const pcieBalance = calculateCPUToPCIeBalance(inventoryRows, cpus); - const pcieByCPU = new Map(); - pcieBalance.perCPU.forEach(item => { - const idx = extractCPUIndex(item.label); - if (idx !== null) pcieByCPU.set(idx, item.lanes); - }); - const memoryByCPU = calculateMemoryModulesByCPU(memory); - - let html = ''; - - // Server info header - if (config.board) { - html += `
-
Модель сервера: ${escapeHtml(config.board.product_name || '-')}
-
Серийный номер: ${escapeHtml(config.board.serial_number || '-')}
-
`; - } - - // Configuration sub-tabs - html += `
- - - - - - - - -
`; - - // Specification tab - html += '
'; - const partialInventory = detectPartialRedfishInventory({ - cpus, - memory, - redfishFetchErrors - }); - if (partialInventory) { - html += `
-

Частичный инвентарь

-

${escapeHtml(partialInventory)}

-
`; - } - if (spec && spec.length > 0) { - html += '

Спецификация сервера

    '; - spec.forEach(item => { - html += `
  • ${escapeHtml(item.category)} ${escapeHtml(item.name)} - ${item.quantity} шт.
  • `; - }); - html += '
'; - } else { - html += '

Нет данных о спецификации

'; - } - html += '
'; - - // CPU tab - html += '
'; - if (cpus.length > 0) { - const cpuCount = cpus.length; - const cpuModel = cpus[0].model || '-'; - const totalCores = cpus.reduce((sum, c) => sum + (c.cores || 0), 0); - const totalThreads = cpus.reduce((sum, c) => sum + (c.threads || 0), 0); - const balanceClass = pcieBalance.severity === 'critical' - ? 'pcie-balance-critical' - : (pcieBalance.severity === 'warning' ? 'pcie-balance-warning' : 'pcie-balance-ok'); - const balanceLabel = pcieBalance.severity === 'critical' - ? 'Перевес высокий' - : (pcieBalance.severity === 'warning' ? 'Есть перевес' : 'Распределено ровно'); - html += `

Процессоры

-
-
${cpuCount}Процессоров
-
${totalCores}Ядер
-
${totalThreads}Потоков
-
${pcieBalance.totalLanes}Занято PCIe линий
-
${balanceLabel}Баланс PCIe
-
${escapeHtml(cpuModel)}Модель
-
-
`; - pcieBalance.perCPU.forEach(cpu => { - html += `
- ${escapeHtml(cpu.label)} -
- ${cpu.lanes} -
`; - }); - html += `
- `; - cpus.forEach(cpu => { - const socket = cpu.slot || '-'; - const cpuIdx = extractCPUIndex(socket); - const pcieUsed = cpuIdx !== null ? (pcieByCPU.get(cpuIdx) || 0) : '-'; - const memoryModules = cpuIdx !== null ? (memoryByCPU.get(cpuIdx) || 0) : '-'; - const tdp = (cpu.details && cpu.details.tdp_w) || '-'; - const l3 = (cpu.details && cpu.details.l3_cache_kb) ? Math.round(cpu.details.l3_cache_kb / 1024) : '-'; - const ppin = (cpu.details && cpu.details.ppin) || '-'; - html += ` - - - - - - - - - - - - `; - }); - html += '
SocketМодельЯдраПотокиЧастотаMax TurboTDPL3 CachePCIe линии (занято)Модулей памятиPPIN
${escapeHtml(socket)}${escapeHtml(cpu.model || '-')}${cpu.cores || '-'}${cpu.threads || '-'}${cpu.frequency_mhz ? cpu.frequency_mhz + ' MHz' : '-'}${cpu.max_frequency_mhz ? cpu.max_frequency_mhz + ' MHz' : '-'}${tdp !== '-' ? tdp + 'W' : '-'}${l3 !== '-' ? l3 + ' MB' : '-'}${pcieUsed}${memoryModules}${escapeHtml(ppin)}
'; - } else { - html += '

Нет данных о процессорах

'; - } - html += '
'; - - // Memory tab - html += '
'; - if (memory.length > 0) { - const totalGB = memory.reduce((sum, m) => sum + (m.size_mb || 0), 0) / 1024; - const presentCount = memory.filter(m => m.present !== false).length; - const workingCount = memory.filter(m => (m.size_mb || 0) > 0).length; - html += `

Модули памяти

-
-
${totalGB} GBВсего
-
${presentCount}Установлено
-
${workingCount}Активно
-
- - - `; - memory.forEach(mem => { - const present = mem.present !== false ? '✓' : '-'; - const presentClass = mem.present !== false ? 'present-yes' : 'present-no'; - const sizeGB = (mem.size_mb || 0) / 1024; - const statusClass = (mem.status === 'OK' || !mem.status) ? '' : 'status-warning'; - const rowClass = sizeGB === 0 ? 'row-warning' : ''; - html += ` - - - - - - - - - - - `; - }); - html += '
LocationНаличиеРазмерТипMax частотаТекущая частотаПроизводительАртикулСерийный номерСтатус
${escapeHtml(mem.location || mem.slot)}${present}${sizeGB} GB${escapeHtml(mem.type || '-')}${(mem.details && mem.details.max_speed_mhz) || '-'} MHz${(mem.details && mem.details.current_speed_mhz) || mem.speed_mhz || '-'} MHz${escapeHtml(mem.manufacturer || '-')}${escapeHtml(mem.part_number || '-')}${escapeHtml(mem.serial_number || '-')}${escapeHtml(mem.status || 'OK')}
'; - } else { - html += '

Нет данных о памяти

'; - } - html += '
'; - - // Power tab - html += '
'; - if (powerSupplies.length > 0) { - const psuTotal = powerSupplies.length; - const psuPresent = powerSupplies.filter(p => p.present !== false).length; - const psuOK = powerSupplies.filter(p => p.status === 'OK').length; - const psuModel = powerSupplies[0].model || '-'; - const psuWattage = powerSupplies[0].wattage_w || 0; - const psuCurrentPowerW = powerSupplies.reduce((sum, psu) => { - if (Number.isFinite(psu.output_power_w) && psu.output_power_w > 0) { - return sum + psu.output_power_w; - } - if (Number.isFinite(psu.input_power_w) && psu.input_power_w > 0) { - return sum + psu.input_power_w; - } - return sum; - }, 0); - const psuCurrentPowerLabel = psuCurrentPowerW > 0 ? `${psuCurrentPowerW}W` : '-'; - html += `

Блоки питания

-
-
${psuTotal}Всего
-
${psuPresent}Подключено
-
${psuOK}Работает
-
${psuWattage}WМощность
-
${psuCurrentPowerLabel}Текущая суммарная
-
${escapeHtml(psuModel)}Модель
-
- `; - powerSupplies.forEach(psu => { - const statusClass = psu.status === 'OK' ? '' : 'status-warning'; - html += ` - - - - - - - - - - `; - }); - html += '
СлотПроизводительМодельМощностьВходВыходНапряжениеТемператураСтатус
${escapeHtml(psu.slot)}${escapeHtml(psu.manufacturer || psu.vendor || '-')}${escapeHtml(psu.model || '-')}${psu.wattage_w || '-'}W${psu.input_power_w || '-'}W${psu.output_power_w || '-'}W${psu.input_voltage ? psu.input_voltage.toFixed(0) : '-'}V${psu.temperature_c || '-'}°C${escapeHtml(psu.status || '-')}
'; - } else { - html += '

Нет данных о блоках питания

'; - } - html += '
'; - - // Storage tab - html += '
'; - if (storage.length > 0 || volumes.length > 0) { - const storTotal = storage.length; - const storHDD = storage.filter(s => s.type === 'HDD').length; - const storSSD = storage.filter(s => s.type === 'SSD').length; - const storNVMe = storage.filter(s => s.type === 'NVMe').length; - const totalTB = (storage.reduce((sum, s) => sum + (s.size_gb || 0), 0) / 1000).toFixed(1); - let typesSummary = []; - if (storHDD > 0) typesSummary.push(`${storHDD} HDD`); - if (storSSD > 0) typesSummary.push(`${storSSD} SSD`); - if (storNVMe > 0) typesSummary.push(`${storNVMe} NVMe`); - html += `

Накопители

-
-
${storTotal}Всего слотов
-
${storage.filter(s => s.present).length}Установлено
-
${totalTB > 0 ? totalTB + ' TB' : '-'}Объём
-
${volumes.length}Логических томов
-
${typesSummary.join(', ') || '-'}По типам
-
- `; - storage.forEach(s => { - const presentIcon = s.present ? '' : ''; - const presentText = s.present ? 'Present' : 'Empty'; - html += ` - - - - - - - - - `; - }); - html += '
NO.СтатусРасположениеBackplane IDТипМодельРазмерСерийный номер
${escapeHtml(s.slot || '-')}${presentIcon} ${presentText}${escapeHtml(s.location || '-')}${s.details && s.details.backplane_id !== undefined ? s.details.backplane_id : '-'}${escapeHtml(s.type || '-')}${escapeHtml(s.model || '-')}${s.size_gb > 0 ? s.size_gb + ' GB' : '-'}${s.serial_number ? '' + escapeHtml(s.serial_number) + '' : '-'}
'; - if (volumes.length > 0) { - html += `

Логические тома (RAID/VROC)

- `; - volumes.forEach(v => { - html += ` - - - - - - - `; - }); - html += '
IDИмяКонтроллерRAIDРазмерСтатус
${escapeHtml(v.id || '-')}${escapeHtml(v.name || '-')}${escapeHtml(v.controller || '-')}${escapeHtml(v.raid_level || '-')}${v.size_gb > 0 ? `${v.size_gb} GB` : '-'}${escapeHtml(v.status || '-')}
'; - } - } else { - html += '

Нет данных о накопителях

'; - } - html += '
'; - - // GPU tab - html += '
'; - const gpuRows = gpus; - if (gpuRows.length > 0) { - const gpuCount = gpuRows.length; - const gpuModel = gpuRows[0].model || '-'; - const gpuVendor = gpuRows[0].manufacturer || '-'; - html += `

Графические процессоры

-
-
${gpuCount}Всего GPU
-
${escapeHtml(gpuVendor)}Производитель
-
${escapeHtml(gpuModel)}Модель
-
- `; - gpuRows.forEach(gpu => { - const pcieLink = formatPCIeLink( - gpu.current_link_width || gpu.link_width, - gpu.current_link_speed || gpu.link_speed, - gpu.max_link_width, - gpu.max_link_speed - ); - html += ` - - - - - - - `; - }); - html += '
СлотМодельПроизводительBDFPCIeСерийный номер
${escapeHtml(gpu.slot || '-')}${escapeHtml(gpu.model || '-')}${escapeHtml(gpu.manufacturer || '-')}${escapeHtml(gpu.bdf || '-')}${pcieLink}${escapeHtml(gpu.serial_number || '-')}
'; - } else { - html += '

Нет GPU

'; - } - html += '
'; - - // Network tab - html += '
'; - const networkRows = networkAdapters; - const normalizeNetworkPortCount = (value) => { - const num = Number(value); - if (!Number.isFinite(num) || num <= 0 || num > 256) { - return null; - } - return Math.trunc(num); - }; - if (networkRows.length > 0) { - const nicCount = networkRows.length; - const totalPorts = networkRows.reduce((sum, n) => sum + (normalizeNetworkPortCount(n.port_count) || 0), 0); - const nicTypes = [...new Set(networkRows.map(n => n.port_type).filter(t => t))]; - const nicModels = [...new Set(networkRows.map(n => n.model).filter(m => m))]; - html += `

Сетевые адаптеры

-
-
${nicCount}Адаптеров
-
${totalPorts}Портов
-
${nicTypes.join(', ') || '-'}Тип портов
-
${escapeHtml(nicModels.join(', ') || '-')}Модели
-
- `; - networkRows.forEach(nic => { - const macs = nic.mac_addresses ? nic.mac_addresses.join(', ') : '-'; - const statusClass = nic.status === 'OK' ? '' : 'status-warning'; - const displayPortCount = normalizeNetworkPortCount(nic.port_count); - html += ` - - - - - - - - `; - }); - html += '
СлотМодельПроизводительПортыТипMAC адресаСтатус
${escapeHtml(nic.location || nic.slot || '-')}${escapeHtml(nic.model || '-')}${escapeHtml(nic.manufacturer || nic.vendor || '-')}${displayPortCount ?? '-'}${escapeHtml(nic.port_type || '-')}${escapeHtml(macs)}${escapeHtml(nic.status || '-')}
'; - } else { - html += '

Нет данных о сетевых адаптерах

'; - } - html += '
'; - - // PCIe Device Inventory tab - html += '
'; - if (inventoryRows.length > 0) { - html += '

PCIe устройства

'; - const groups = new Map(); - inventoryRows.forEach(p => { - const idx = extractCPUIndex(p.slot); - const key = idx === null ? 'other' : `cpu${idx}`; - if (!groups.has(key)) { - groups.set(key, { - idx, - title: idx === null ? 'Без привязки к CPU' : `CPU${idx}`, - lanes: 0, - rows: [] - }); - } - const lanes = Number(p.link_width) > 0 ? Number(p.link_width) : (Number(p.max_link_width) > 0 ? Number(p.max_link_width) : 0); - const group = groups.get(key); - group.lanes += lanes; - group.rows.push(p); - }); - - const sortedGroups = [...groups.values()].sort((a, b) => { - if (a.idx === null) return 1; - if (b.idx === null) return -1; - return a.idx - b.idx; - }); - - sortedGroups.forEach(group => { - html += `

${escapeHtml(group.title)} · занято линий: ${group.lanes}

`; - html += ''; - group.rows.forEach(p => { - const pcieLink = formatPCIeLink( - p.link_width, - p.link_speed, - p.max_link_width, - p.max_link_speed - ); - const firmware = p.firmware || findPCIeFirmwareVersion(config.firmware, p); - html += ` - - - - - - - - - `; - }); - html += '
СлотBDFМодельПроизводительVendor:Device IDPCIe LinkСерийный номерПрошивка
${escapeHtml(p.slot || '-')}${escapeHtml(p.bdf || '-')}${escapeHtml(p.model || p.part_number || p.device_class || '-')}${escapeHtml(p.manufacturer || '-')}${p.vendor_id ? p.vendor_id.toString(16) : '-'}:${p.device_id ? p.device_id.toString(16) : '-'}${pcieLink}${escapeHtml(p.serial_number || '-')}${escapeHtml(firmware || '-')}
'; - }); - } else { - html += '

Нет данных о PCIe устройствах

'; - } - html += '
'; - - container.innerHTML = html; - - // Initialize config sub-tabs - initConfigTabs(); -} - -function initConfigTabs() { - const tabs = document.querySelectorAll('.config-tab'); - tabs.forEach(tab => { - tab.addEventListener('click', () => { - tabs.forEach(t => t.classList.remove('active')); - document.querySelectorAll('.config-tab-content').forEach(c => c.classList.remove('active')); - tab.classList.add('active'); - document.getElementById('config-' + tab.dataset.configTab).classList.add('active'); - }); - }); -} - -async function loadFirmware() { - try { - const response = await fetch('/api/firmware'); - const firmware = await response.json(); - renderFirmware(firmware); - } catch (err) { - console.error('Failed to load firmware:', err); - } -} - -let allFirmware = []; - -function renderFirmware(firmware) { - allFirmware = firmware || []; - - // Render in Firmware tab - const tbody = document.querySelector('#firmware-table tbody'); - tbody.innerHTML = ''; - - if (!firmware || firmware.length === 0) { - tbody.innerHTML = 'Нет данных о прошивках'; - } else { - firmware.forEach(fw => { - const row = document.createElement('tr'); - row.innerHTML = ` - ${escapeHtml(fw.component)} - ${escapeHtml(fw.model)} - ${escapeHtml(fw.version)} - `; - tbody.appendChild(row); - }); - } -} - -async function loadSensors() { - try { - const response = await fetch('/api/sensors'); - allSensors = await response.json(); - renderSensors(allSensors); - } catch (err) { - console.error('Failed to load sensors:', err); - } -} - -function renderSensors(sensors) { - const container = document.getElementById('sensors-content'); - - if (!sensors || sensors.length === 0) { - container.innerHTML = '

Нет данных о сенсорах

'; - return; - } - - // Group by type - const byType = {}; - sensors.forEach(s => { - if (!byType[s.type]) byType[s.type] = []; - byType[s.type].push(s); - }); - - const typeNames = { - temperature: 'Температура', - voltage: 'Напряжение', - power: 'Мощность', - fan_speed: 'Вентиляторы', - fan_status: 'Статус вентиляторов', - psu_status: 'Статус БП', - cpu_status: 'Статус CPU', - storage_status: 'Статус накопителей', - other: 'Прочее' - }; - - let html = ''; - for (const [type, items] of Object.entries(byType)) { - html += `
-

${typeNames[type] || type}

-
`; - - items.forEach(s => { - let valueStr = ''; - let statusClass = s.status === 'ok' ? 'ok' : (s.status === 'ns' ? 'ns' : 'warn'); - const sensorName = String(s.name || '').toLowerCase(); - const isPSUVoltage = type === 'voltage' && sensorName.includes('psu') && sensorName.includes('voltage'); - - if (Number.isFinite(s.value)) { - valueStr = `${s.value} ${s.unit}`; - } else if (s.raw_value) { - valueStr = s.raw_value; - } else { - valueStr = s.status; - } - - // Server computes PSU voltage range status; UI only reflects it. - let extraClass = ''; - if (isPSUVoltage && s.status === 'warn') { - extraClass = ' voltage-out-of-range'; - } - - html += `
- ${escapeHtml(s.name)} - ${escapeHtml(valueStr)} -
`; - }); - - html += '
'; - } - - container.innerHTML = html; -} - -function filterSensors(type) { - if (!type) { - renderSensors(allSensors); - return; - } - const filtered = allSensors.filter(s => s.type === type); - renderSensors(filtered); -} - -async function loadSerials() { - try { - const response = await fetch('/api/serials'); - allSerials = await response.json(); - renderSerials(allSerials); - } catch (err) { - console.error('Failed to load serials:', err); - } -} - -function renderSerials(serials) { - const tbody = document.querySelector('#serials-table tbody'); - tbody.innerHTML = ''; - - if (!serials || serials.length === 0) { - tbody.innerHTML = 'Нет серийных номеров'; - return; - } - - const categoryNames = { - 'Board': 'Мат. плата', - 'CPU': 'Процессор', - 'Memory': 'Память', - 'Storage': 'Накопитель', - 'GPU': 'Видеокарта', - 'PCIe': 'PCIe', - 'Network': 'Сеть', - 'PSU': 'БП', - 'Firmware': 'Прошивка', - 'FRU': 'FRU' - }; - - serials.forEach(item => { - // Skip items without serial number or with N/A - if (!item.serial_number || item.serial_number === 'N/A') return; - const row = document.createElement('tr'); - row.innerHTML = ` - ${categoryNames[item.category] || item.category} - ${escapeHtml(item.component)} - ${escapeHtml(item.location || '-')} - ${escapeHtml(item.serial_number)} - ${escapeHtml(item.manufacturer || '-')} - `; - tbody.appendChild(row); - }); -} - -function filterSerials(category) { - if (!category) { - renderSerials(allSerials); - return; - } - const filtered = allSerials.filter(s => s.category === category); - renderSerials(filtered); -} - -async function loadEvents() { - try { - const response = await fetch('/api/events'); - allEvents = await response.json(); - renderEvents(allEvents); - } catch (err) { - console.error('Failed to load events:', err); - } -} - -async function loadParseErrors() { - try { - const response = await fetch('/api/parse-errors'); - const payload = await response.json(); - allParseErrors = Array.isArray(payload && payload.items) ? payload.items : []; - renderParseErrors(allParseErrors); - } catch (err) { - console.error('Failed to load parse errors:', err); - allParseErrors = []; - renderParseErrors([]); - } -} - -function renderParseErrors(items) { - const tbody = document.querySelector('#parse-errors-table tbody'); - if (!tbody) return; - tbody.innerHTML = ''; - - if (!items || items.length === 0) { - tbody.innerHTML = 'Ошибок разбора не обнаружено'; - return; - } - - items.forEach(item => { - const row = document.createElement('tr'); - const severity = (item.severity || 'info').toLowerCase(); - const source = item.source || '-'; - const category = item.category || '-'; - const path = item.path || '-'; - const message = item.message || item.detail || '-'; - row.innerHTML = ` - ${escapeHtml(source)} - ${escapeHtml(category)} - ${escapeHtml(severity)} - ${escapeHtml(path)} - ${escapeHtml(message)} - `; - tbody.appendChild(row); - }); -} - -function renderEvents(events) { - const tbody = document.querySelector('#events-table tbody'); - tbody.innerHTML = ''; - - if (!events || events.length === 0) { - tbody.innerHTML = 'Нет событий'; - return; - } - - events.forEach(event => { - const row = document.createElement('tr'); - row.innerHTML = ` - ${formatDate(event.timestamp)} - ${escapeHtml(event.source)} - ${escapeHtml(event.description)} - ${event.severity} - `; - tbody.appendChild(row); - }); -} - -function filterEvents(severity) { - if (!severity) { - renderEvents(allEvents); - return; - } - const filtered = allEvents.filter(e => e.severity === severity); - renderEvents(filtered); -} - // Export functions function exportData(format) { window.location.href = `/api/export/${format}`; @@ -2201,11 +1464,6 @@ async function clearData() { document.getElementById('header-reanimator-btn').classList.add('hidden'); document.getElementById('header-log-meta').classList.add('hidden'); document.getElementById('upload-status').textContent = ''; - allSensors = []; - allEvents = []; - allSerials = []; - allParseErrors = []; - currentVendor = ''; const frame = document.getElementById('audit-viewer-frame'); if (frame) { frame.src = 'about:blank'; @@ -2217,7 +1475,7 @@ async function clearData() { // Restart app (reload page) function restartApp() { - if (confirm('Перезапустить приложение? Все загруженные данные будут потеряны.')) { + if (confirm('Restart the application? All loaded data will be lost.')) { fetch('/api/clear', { method: 'DELETE' }).then(() => { window.location.reload(); }); @@ -2226,203 +1484,21 @@ function restartApp() { // Exit app (shutdown server) async function exitApp() { - if (confirm('Завершить работу приложения?')) { + if (confirm('Shut down the application?')) { try { await fetch('/api/shutdown', { method: 'POST' }); - document.body.innerHTML = '

LOGPile

Приложение завершено. Можете закрыть эту вкладку.

'; + document.body.innerHTML = '

LOGPile

The application has stopped. You can close this tab.

'; } catch (err) { // Server shutdown, connection will fail - document.body.innerHTML = '

LOGPile

Приложение завершено. Можете закрыть эту вкладку.

'; + document.body.innerHTML = '

LOGPile

The application has stopped. You can close this tab.

'; } } } // Utilities -function formatDate(isoString) { - if (!isoString) return '-'; - const date = new Date(isoString); - return date.toLocaleString('ru-RU'); -} - function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } - -function detectPartialRedfishInventory({ cpus, memory, redfishFetchErrors }) { - const errors = Array.isArray(redfishFetchErrors) ? redfishFetchErrors : []; - const paths = errors.map(item => String(item && typeof item === 'object' ? (item.path || '') : '')).filter(Boolean); - const cpuMissing = (!Array.isArray(cpus) || cpus.length === 0) && paths.some(p => /\/Systems\/[^/]+\/Processors(\/)?$/i.test(p)); - const memMissing = (!Array.isArray(memory) || memory.length === 0) && paths.some(p => /\/Systems\/[^/]+\/Memory(\/)?$/i.test(p)); - if (!cpuMissing && !memMissing) return ''; - if (cpuMissing && memMissing) return 'Не удалось восстановить CPU и Memory: Redfish endpoint\'ы /Processors и /Memory были недоступны во время сбора.'; - if (cpuMissing) return 'CPU-инвентарь неполный: Redfish endpoint /Processors был недоступен во время сбора.'; - return 'Memory-инвентарь неполный: Redfish endpoint /Memory был недоступен во время сбора.'; -} - -function calculateCPUToPCIeBalance(inventoryRows, cpus) { - const laneByCPU = new Map(); - const cpuIndexes = new Set(); - - (cpus || []).forEach(cpu => { - const idx = extractCPUIndex(cpu.slot); - if (idx !== null) { - cpuIndexes.add(idx); - laneByCPU.set(idx, 0); - } - }); - - (inventoryRows || []).forEach(dev => { - const idx = extractCPUIndex(dev.slot); - if (idx === null) return; - - const lanes = Number(dev.link_width) > 0 - ? Number(dev.link_width) - : (Number(dev.max_link_width) > 0 - ? Number(dev.max_link_width) - : (dev.bdf ? 1 : 0)); - if (lanes <= 0) return; - - if (!laneByCPU.has(idx)) laneByCPU.set(idx, 0); - laneByCPU.set(idx, laneByCPU.get(idx) + lanes); - cpuIndexes.add(idx); - }); - - const indexes = [...cpuIndexes].sort((a, b) => a - b); - const values = indexes.map(i => laneByCPU.get(i) || 0); - const totalLanes = values.reduce((a, b) => a + b, 0); - const maxLanes = values.length ? Math.max(...values) : 0; - const minLanes = values.length ? Math.min(...values) : 0; - const diffRatio = totalLanes > 0 ? (maxLanes - minLanes) / totalLanes : 0; - let severity = 'ok'; - if (values.length > 1) { - if (diffRatio >= 0.35) severity = 'critical'; - else if (diffRatio >= 0.2) severity = 'warning'; - } - - const denominator = maxLanes > 0 ? maxLanes : 1; - const perCPU = indexes.map(i => { - const lanes = laneByCPU.get(i) || 0; - return { - label: `CPU${i}`, - lanes, - percent: Math.round((lanes / denominator) * 100) - }; - }); - - if (perCPU.length === 0) { - perCPU.push({ label: 'CPU?', lanes: 0, percent: 0 }); - } - - return { totalLanes, severity, perCPU }; -} - -function extractCPUIndex(slot) { - const s = String(slot || '').trim(); - if (!s) return null; - const m = s.match(/cpu\s*([0-9]+)/i); - if (!m) return null; - const idx = Number(m[1]); - return Number.isFinite(idx) ? idx : null; -} - -function calculateMemoryModulesByCPU(memoryRows) { - const out = new Map(); - (memoryRows || []).forEach(mem => { - if (mem.present === false || (mem.size_mb || 0) <= 0) return; - const idx = extractCPUIndex(mem.location || mem.slot); - if (idx === null) return; - out.set(idx, (out.get(idx) || 0) + 1); - }); - return out; -} - -function findPCIeFirmwareVersion(firmwareEntries, pcieDevice) { - if (!Array.isArray(firmwareEntries) || !pcieDevice) return ''; - - const slot = (pcieDevice.slot || '').trim().toLowerCase(); - const model = (pcieDevice.part_number || '').trim().toLowerCase(); - if (!slot && !model) return ''; - - const slotPatterns = slot - ? [ - new RegExp(`^psu\\s*${escapeRegExp(slot)}\\b`, 'i'), - new RegExp(`^nic\\s+${escapeRegExp(slot)}\\b`, 'i'), - new RegExp(`^gpu\\s+${escapeRegExp(slot)}\\b`, 'i'), - new RegExp(`^nvswitch\\s+${escapeRegExp(slot)}\\b`, 'i') - ] - : []; - - for (const fw of firmwareEntries) { - const name = (fw.device_name || '').trim().toLowerCase(); - const version = (fw.version || '').trim(); - if (!name || !version) continue; - if (slot && slotPatterns.some(re => re.test(name))) return version; - if (model && name.includes(model)) return version; - } - - return ''; -} - -function escapeRegExp(value) { - return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function formatPCIeLink(currentWidth, currentSpeed, maxWidth, maxSpeed) { - // Helper to convert speed to generation - function speedToGen(speed) { - if (!speed) return ''; - const gtMatch = speed.match(/(\d+\.?\d*)\s*GT/i); - if (gtMatch) { - const gts = parseFloat(gtMatch[1]); - if (gts >= 32) return 'Gen5'; - if (gts >= 16) return 'Gen4'; - if (gts >= 8) return 'Gen3'; - if (gts >= 5) return 'Gen2'; - if (gts >= 2.5) return 'Gen1'; - } - return ''; - } - - // Helper to extract GT/s value for comparison - function extractGTs(speed) { - if (!speed) return 0; - const gtMatch = speed.match(/(\d+\.?\d*)\s*GT/i); - return gtMatch ? parseFloat(gtMatch[1]) : 0; - } - - // If no data, return dash - if (!currentWidth && !currentSpeed) return '-'; - - const curGen = speedToGen(currentSpeed); - const maxGen = speedToGen(maxSpeed); - - // Check if current is lower than max - const widthDegraded = maxWidth && currentWidth && currentWidth < maxWidth; - const speedDegraded = maxSpeed && currentSpeed && extractGTs(currentSpeed) < extractGTs(maxSpeed); - - // Build current link string - const curWidthStr = currentWidth ? `x${currentWidth}` : ''; - const curLinkStr = curGen ? `${curWidthStr} ${curGen}` : `${curWidthStr} ${currentSpeed || ''}`; - - // Build max link string (if available) - let maxLinkStr = ''; - if (maxWidth || maxSpeed) { - const maxWidthStr = maxWidth ? `x${maxWidth}` : ''; - maxLinkStr = maxGen ? `${maxWidthStr} ${maxGen}` : `${maxWidthStr} ${maxSpeed || ''}`; - } - - // Apply degraded class if needed - const degradedClass = (widthDegraded || speedDegraded) ? ' class="pcie-degraded"' : ''; - - // Format output: show "current" or "current / max" if max differs - if (maxLinkStr && (widthDegraded || speedDegraded)) { - return `${curLinkStr} / ${maxLinkStr}`; - } else if (maxLinkStr && maxLinkStr !== curLinkStr) { - return `${curLinkStr} / ${maxLinkStr}`; - } else { - return curLinkStr; - } -} diff --git a/web/templates/index.html b/web/templates/index.html index 0b28ca6..dea0c6d 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1,5 +1,5 @@ - + @@ -7,57 +7,63 @@ -
-
-
-

LOGPile mchus.pro

-

Анализатор диагностических данных BMC/IPMI

-
-