From 86757918054941c1c7e400522f095159d52ff27d Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Wed, 1 Apr 2026 16:19:25 +0300 Subject: [PATCH] feat(viewer): add severity filtering for event logs --- viewer/model.go | 28 +++++++---- viewer/render.go | 101 ++++++++++++++++++++++++++++++++++--- viewer/render_test.go | 44 ++++++++++++++++ web/static/view.css | 49 ++++++++++++++++++ web/static/view.js | 31 ++++++++++++ web/templates/view.html | 109 ++++++++++++++++++++++++++-------------- 6 files changed, 306 insertions(+), 56 deletions(-) create mode 100644 web/static/view.js diff --git a/viewer/model.go b/viewer/model.go index 7b006c1..0675cf9 100644 --- a/viewer/model.go +++ b/viewer/model.go @@ -13,13 +13,14 @@ type pageData struct { } type sectionView struct { - ID string - Title string - Kind string - Rows []fieldRow - Columns []string - Items []tableRow - Groups []tableGroupView + ID string + Title string + Kind string + Rows []fieldRow + Columns []string + Items []tableRow + Groups []tableGroupView + SeverityOptions []severityOption } type fieldRow struct { @@ -29,12 +30,19 @@ type fieldRow struct { type tableRow struct { Status string + Severity string Cells map[string]string RawCells map[string]any } type tableGroupView struct { - Title string - Columns []string - Items []tableRow + Title string + Columns []string + Items []tableRow + SeverityOptions []severityOption +} + +type severityOption struct { + Value string + Label string } diff --git a/viewer/render.go b/viewer/render.go index 7693f2e..30f857e 100644 --- a/viewer/render.go +++ b/viewer/render.go @@ -252,17 +252,19 @@ func buildTableSection(key string, items []any) sectionView { status := strings.TrimSpace(cells["status"]) tableRows = append(tableRows, tableRow{ Status: status, + Severity: normalizeSeverity(cells["severity"]), Cells: cells, RawCells: row, }) } return sectionView{ - ID: key, - Title: titleFor(key), - Kind: "table", - Columns: columns, - Items: tableRows, + ID: key, + Title: titleFor(key), + Kind: "table", + Columns: columns, + Items: tableRows, + SeverityOptions: collectSeverityOptions(columns, rows), } } @@ -301,14 +303,16 @@ func buildPCIeSection(items []any) sectionView { } items = append(items, tableRow{ Status: strings.TrimSpace(cells["status"]), + Severity: normalizeSeverity(cells["severity"]), Cells: cells, RawCells: row, }) } groups = append(groups, tableGroupView{ - Title: className, - Columns: columns, - Items: items, + Title: className, + Columns: columns, + Items: items, + SeverityOptions: collectSeverityOptions(columns, rows), }) } @@ -350,6 +354,58 @@ func collectColumns(section string, rows []map[string]any) []string { return append(columns, extra...) } +func collectSeverityOptions(columns []string, rows []map[string]any) []severityOption { + if !containsColumn(columns, "severity") { + return nil + } + + seen := make(map[string]string) + for _, row := range rows { + label := strings.TrimSpace(formatRowValue("severity", row)) + value := normalizeSeverity(label) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = canonicalSeverityLabel(label, value) + } + if len(seen) == 0 { + return nil + } + + knownOrder := []string{"critical", "warning", "info"} + options := make([]severityOption, 0, len(seen)) + for _, value := range knownOrder { + label, ok := seen[value] + if !ok { + continue + } + options = append(options, severityOption{Value: value, Label: label}) + delete(seen, value) + } + + extraValues := make([]string, 0, len(seen)) + for value := range seen { + extraValues = append(extraValues, value) + } + sort.Strings(extraValues) + for _, value := range extraValues { + options = append(options, severityOption{Value: value, Label: seen[value]}) + } + return options +} + +func containsColumn(columns []string, target string) bool { + for _, column := range columns { + if column == target { + return true + } + } + return false +} + func buildFieldRows(object map[string]any) []fieldRow { keys := make([]string, 0, len(object)) for key := range object { @@ -444,6 +500,35 @@ func formatRowValue(column string, row map[string]any) string { return formatValue(row[column]) } +func normalizeSeverity(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "critical": + return "critical" + case "warning", "warn": + return "warning" + case "info", "informational": + return "info" + default: + return strings.ToLower(strings.TrimSpace(value)) + } +} + +func canonicalSeverityLabel(raw, normalized string) string { + switch normalized { + case "critical": + return "Critical" + case "warning": + return "Warning" + case "info": + return "Info" + default: + if strings.TrimSpace(raw) == "" { + return "" + } + return strings.TrimSpace(raw) + } +} + func formatVendorDeviceID(value map[string]any) string { vendorID := strings.TrimSpace(formatValue(value["vendor_id"])) deviceID := strings.TrimSpace(formatValue(value["device_id"])) diff --git a/viewer/render_test.go b/viewer/render_test.go index 935d4d4..d89a137 100644 --- a/viewer/render_test.go +++ b/viewer/render_test.go @@ -270,3 +270,47 @@ func TestRenderHTMLGroupsPCIeDevicesByClass(t *testing.T) { t.Fatalf("expected PCIe class groups to be sorted by device_class") } } + +func TestRenderHTMLAddsSeverityFilterForEventLogs(t *testing.T) { + snapshot := []byte(`{ + "target_host": "event-host", + "hardware": { + "event_logs": [ + { + "component_ref": "PSU0", + "event_time": "2026-03-15T12:00:00Z", + "message": "Power restored", + "severity": "Info", + "source": "bmc" + }, + { + "component_ref": "PSU1", + "event_time": "2026-03-15T12:05:00Z", + "message": "Power failure", + "severity": "Critical", + "source": "bmc" + } + ] + } +}`) + + html, err := RenderHTML(snapshot, "Reanimator Chart") + if err != nil { + t.Fatalf("RenderHTML() error = %v", err) + } + + text := string(html) + for _, needle := range []string{ + "Event Logs", + "All severities", + ``, + ``, + `data-severity="critical"`, + `data-severity="info"`, + "/static/view.js", + } { + if !strings.Contains(text, needle) { + t.Fatalf("expected rendered html to contain %q", needle) + } + } +} diff --git a/web/static/view.css b/web/static/view.css index 28700f4..e880a08 100644 --- a/web/static/view.css +++ b/web/static/view.css @@ -326,6 +326,45 @@ body { border-bottom: 1px solid var(--border-lite); } +.table-block { + display: block; +} + +.table-toolbar { + display: flex; + align-items: center; + gap: 10px; + margin: 0 0 10px; +} + +.table-toolbar-label { + color: var(--muted); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.table-severity-filter { + min-width: 180px; + border: 1px solid var(--border); + border-radius: 4px; + padding: 7px 10px; + background: var(--surface); + color: var(--ink); + font: inherit; +} + +.table-severity-filter:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.table-filter-empty { + margin: 10px 0 0; + color: var(--muted); +} + /* ── Status ──────────────────────────────────────── */ .status-badge { @@ -370,4 +409,14 @@ body { .section-card-full { grid-column: auto; } + + .table-toolbar { + align-items: stretch; + flex-direction: column; + } + + .table-severity-filter { + min-width: 0; + width: 100%; + } } diff --git a/web/static/view.js b/web/static/view.js new file mode 100644 index 0000000..e6257c2 --- /dev/null +++ b/web/static/view.js @@ -0,0 +1,31 @@ +document.addEventListener("DOMContentLoaded", () => { + document.querySelectorAll(".table-filterable").forEach((container) => { + const select = container.querySelector(".table-severity-filter"); + if (!select) { + return; + } + + const rows = Array.from(container.querySelectorAll("tbody tr[data-severity-row='true']")); + const emptyNotice = container.querySelector(".table-filter-empty"); + + const applyFilter = () => { + const selected = select.value; + let visibleCount = 0; + + rows.forEach((row) => { + const matches = selected === "" || row.dataset.severity === selected; + row.hidden = !matches; + if (matches) { + visibleCount += 1; + } + }); + + if (emptyNotice) { + emptyNotice.hidden = visibleCount !== 0; + } + }; + + select.addEventListener("change", applyFilter); + applyFilter(); + }); +}); diff --git a/web/templates/view.html b/web/templates/view.html index fd94a34..479cf35 100644 --- a/web/templates/view.html +++ b/web/templates/view.html @@ -5,6 +5,7 @@ {{ .Title }} +