feat(viewer): add severity filtering for event logs

This commit is contained in:
Mikhail Chusavitin
2026-04-01 16:19:25 +03:00
parent ac8120c8ab
commit 8675791805
6 changed files with 306 additions and 56 deletions

View File

@@ -13,13 +13,14 @@ type pageData struct {
} }
type sectionView struct { type sectionView struct {
ID string ID string
Title string Title string
Kind string Kind string
Rows []fieldRow Rows []fieldRow
Columns []string Columns []string
Items []tableRow Items []tableRow
Groups []tableGroupView Groups []tableGroupView
SeverityOptions []severityOption
} }
type fieldRow struct { type fieldRow struct {
@@ -29,12 +30,19 @@ type fieldRow struct {
type tableRow struct { type tableRow struct {
Status string Status string
Severity string
Cells map[string]string Cells map[string]string
RawCells map[string]any RawCells map[string]any
} }
type tableGroupView struct { type tableGroupView struct {
Title string Title string
Columns []string Columns []string
Items []tableRow Items []tableRow
SeverityOptions []severityOption
}
type severityOption struct {
Value string
Label string
} }

View File

@@ -252,17 +252,19 @@ func buildTableSection(key string, items []any) sectionView {
status := strings.TrimSpace(cells["status"]) status := strings.TrimSpace(cells["status"])
tableRows = append(tableRows, tableRow{ tableRows = append(tableRows, tableRow{
Status: status, Status: status,
Severity: normalizeSeverity(cells["severity"]),
Cells: cells, Cells: cells,
RawCells: row, RawCells: row,
}) })
} }
return sectionView{ return sectionView{
ID: key, ID: key,
Title: titleFor(key), Title: titleFor(key),
Kind: "table", Kind: "table",
Columns: columns, Columns: columns,
Items: tableRows, Items: tableRows,
SeverityOptions: collectSeverityOptions(columns, rows),
} }
} }
@@ -301,14 +303,16 @@ func buildPCIeSection(items []any) sectionView {
} }
items = append(items, tableRow{ items = append(items, tableRow{
Status: strings.TrimSpace(cells["status"]), Status: strings.TrimSpace(cells["status"]),
Severity: normalizeSeverity(cells["severity"]),
Cells: cells, Cells: cells,
RawCells: row, RawCells: row,
}) })
} }
groups = append(groups, tableGroupView{ groups = append(groups, tableGroupView{
Title: className, Title: className,
Columns: columns, Columns: columns,
Items: items, Items: items,
SeverityOptions: collectSeverityOptions(columns, rows),
}) })
} }
@@ -350,6 +354,58 @@ func collectColumns(section string, rows []map[string]any) []string {
return append(columns, extra...) 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 { func buildFieldRows(object map[string]any) []fieldRow {
keys := make([]string, 0, len(object)) keys := make([]string, 0, len(object))
for key := range object { for key := range object {
@@ -444,6 +500,35 @@ func formatRowValue(column string, row map[string]any) string {
return formatValue(row[column]) 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 { func formatVendorDeviceID(value map[string]any) string {
vendorID := strings.TrimSpace(formatValue(value["vendor_id"])) vendorID := strings.TrimSpace(formatValue(value["vendor_id"]))
deviceID := strings.TrimSpace(formatValue(value["device_id"])) deviceID := strings.TrimSpace(formatValue(value["device_id"]))

View File

@@ -270,3 +270,47 @@ func TestRenderHTMLGroupsPCIeDevicesByClass(t *testing.T) {
t.Fatalf("expected PCIe class groups to be sorted by device_class") 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",
`<option value="critical">Critical</option>`,
`<option value="info">Info</option>`,
`data-severity="critical"`,
`data-severity="info"`,
"/static/view.js",
} {
if !strings.Contains(text, needle) {
t.Fatalf("expected rendered html to contain %q", needle)
}
}
}

View File

@@ -326,6 +326,45 @@ body {
border-bottom: 1px solid var(--border-lite); 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 ──────────────────────────────────────── */
.status-badge { .status-badge {
@@ -370,4 +409,14 @@ body {
.section-card-full { .section-card-full {
grid-column: auto; grid-column: auto;
} }
.table-toolbar {
align-items: stretch;
flex-direction: column;
}
.table-severity-filter {
min-width: 0;
width: 100%;
}
} }

31
web/static/view.js Normal file
View File

@@ -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();
});
});

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }}</title> <title>{{ .Title }}</title>
<link rel="stylesheet" href="/static/view.css"> <link rel="stylesheet" href="/static/view.css">
<script defer src="/static/view.js"></script>
</head> </head>
<body> <body>
<header class="page-header"> <header class="page-header">
@@ -63,43 +64,18 @@
{{ if eq .Kind "table" }} {{ if eq .Kind "table" }}
{{ $section := . }} {{ $section := . }}
<div class="table-wrap"> <div class="table-block {{ if .SeverityOptions }}table-filterable{{ end }}">
<table class="data-table"> {{ if .SeverityOptions }}
<thead> <div class="table-toolbar">
<tr> <label class="table-toolbar-label" for="{{ .ID }}-severity-filter">Severity</label>
{{ range .Columns }} <select class="table-severity-filter" id="{{ .ID }}-severity-filter">
<th>{{ . }}</th> <option value="">All severities</option>
{{ end }} {{ range .SeverityOptions }}
</tr> <option value="{{ .Value }}">{{ .Label }}</option>
</thead>
<tbody>
{{ range .Items }}
<tr>
{{ $row := . }}
{{ range $section.Columns }}
<td>
{{ $value := index $row.Cells . }}
{{ if eq . "status" }}
<span class="status-badge {{ statusClass $value }}">{{ $value }}</span>
{{ else }}
{{ range joinLines $value }}
<div>{{ . }}</div>
{{ end }}
{{ end }}
</td>
{{ end }}
</tr>
{{ end }} {{ end }}
</tbody> </select>
</table> </div>
</div> {{ end }}
{{ end }}
{{ if eq .Kind "grouped_tables" }}
{{ range .Groups }}
<div class="table-group">
<h3>{{ .Title }}</h3>
{{ $group := . }}
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"> <table class="data-table">
<thead> <thead>
@@ -111,9 +87,9 @@
</thead> </thead>
<tbody> <tbody>
{{ range .Items }} {{ range .Items }}
<tr> <tr data-severity-row="true" data-severity="{{ .Severity }}">
{{ $row := . }} {{ $row := . }}
{{ range $group.Columns }} {{ range $section.Columns }}
<td> <td>
{{ $value := index $row.Cells . }} {{ $value := index $row.Cells . }}
{{ if eq . "status" }} {{ if eq . "status" }}
@@ -130,6 +106,63 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{{ if .SeverityOptions }}
<p class="table-filter-empty" hidden>No rows match the selected severity.</p>
{{ end }}
</div>
{{ end }}
{{ if eq .Kind "grouped_tables" }}
{{ range .Groups }}
<div class="table-group">
<h3>{{ .Title }}</h3>
{{ $group := . }}
<div class="table-block {{ if .SeverityOptions }}table-filterable{{ end }}">
{{ if .SeverityOptions }}
<div class="table-toolbar">
<label class="table-toolbar-label">Severity</label>
<select class="table-severity-filter">
<option value="">All severities</option>
{{ range .SeverityOptions }}
<option value="{{ .Value }}">{{ .Label }}</option>
{{ end }}
</select>
</div>
{{ end }}
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
{{ range .Columns }}
<th>{{ . }}</th>
{{ end }}
</tr>
</thead>
<tbody>
{{ range .Items }}
<tr data-severity-row="true" data-severity="{{ .Severity }}">
{{ $row := . }}
{{ range $group.Columns }}
<td>
{{ $value := index $row.Cells . }}
{{ if eq . "status" }}
<span class="status-badge {{ statusClass $value }}">{{ $value }}</span>
{{ else }}
{{ range joinLines $value }}
<div>{{ . }}</div>
{{ end }}
{{ end }}
</td>
{{ end }}
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ if .SeverityOptions }}
<p class="table-filter-empty" hidden>No rows match the selected severity.</p>
{{ end }}
</div>
</div> </div>
{{ end }} {{ end }}
{{ end }} {{ end }}