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 = `Исправьте ошибки в форме:
${messages.map(msg => `- ${escapeHtml(msg)}
`).join('')}
`;
+ summary.innerHTML = `Fix the form errors:${messages.map(msg => `- ${escapeHtml(msg)}
`).join('')}
`;
}
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 += `
-
| Socket | Модель | Ядра | Потоки | Частота | Max Turbo | TDP | L3 Cache | PCIe линии (занято) | Модулей памяти | PPIN |
`;
- 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 += `
- | ${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)} |
-
`;
- });
- html += '
';
- } 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}Активно
-
-
- | Location | Наличие | Размер | Тип | Max частота | Текущая частота | Производитель | Артикул | Серийный номер | Статус |
-
`;
- 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 += `
- | ${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')} |
-
`;
- });
- html += '
';
- } 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 += `
- | ${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 || '-')} |
-
`;
- });
- html += '
';
- } 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(', ') || '-'}По типам
-
-
| NO. | Статус | Расположение | Backplane ID | Тип | Модель | Размер | Серийный номер |
`;
- storage.forEach(s => {
- const presentIcon = s.present ? '●' : '○';
- const presentText = s.present ? 'Present' : 'Empty';
- html += `
- | ${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) + '' : '-'} |
-
`;
- });
- html += '
';
- if (volumes.length > 0) {
- html += `
Логические тома (RAID/VROC)
-
| ID | Имя | Контроллер | RAID | Размер | Статус |
`;
- volumes.forEach(v => {
- html += `
- | ${escapeHtml(v.id || '-')} |
- ${escapeHtml(v.name || '-')} |
- ${escapeHtml(v.controller || '-')} |
- ${escapeHtml(v.raid_level || '-')} |
- ${v.size_gb > 0 ? `${v.size_gb} GB` : '-'} |
- ${escapeHtml(v.status || '-')} |
-
`;
- });
- html += '
';
- }
- } 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)}Модель
-
-
| Слот | Модель | Производитель | BDF | PCIe | Серийный номер |
`;
- 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 += `
- | ${escapeHtml(gpu.slot || '-')} |
- ${escapeHtml(gpu.model || '-')} |
- ${escapeHtml(gpu.manufacturer || '-')} |
- ${escapeHtml(gpu.bdf || '-')} |
- ${pcieLink} |
- ${escapeHtml(gpu.serial_number || '-')} |
-
`;
- });
- html += '
';
- } 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(', ') || '-')}Модели
-
-
| Слот | Модель | Производитель | Порты | Тип | MAC адреса | Статус |
`;
- 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 += `
- | ${escapeHtml(nic.location || nic.slot || '-')} |
- ${escapeHtml(nic.model || '-')} |
- ${escapeHtml(nic.manufacturer || nic.vendor || '-')} |
- ${displayPortCount ?? '-'} |
- ${escapeHtml(nic.port_type || '-')} |
- ${escapeHtml(macs)} |
- ${escapeHtml(nic.status || '-')} |
-
`;
- });
- html += '
';
- } 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 += '
| Слот | BDF | Модель | Производитель | Vendor:Device ID | PCIe Link | Серийный номер | Прошивка |
';
- 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 += `
- | ${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 || '-')} |
-
`;
- });
- html += '
';
- });
- } 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 += ``;
- });
-
- 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 @@
-