From 34ebaa524d9c230021a4193cfc0027fb628f5fd5 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Wed, 22 Apr 2026 21:19:12 +0300 Subject: [PATCH] feat(viewer): compact status and severity table icons --- bible-local/architecture/data-model.md | 3 +- bible-local/architecture/runtime-flows.md | 11 +- .../ui-information-architecture.md | 4 +- ...atus-columns-use-icon-only-presentation.md | 31 ++ viewer/render.go | 11 + viewer/render_test.go | 25 + web/embed.go | 22 +- web/static/view.css | 440 +++++++++--------- web/templates/view.html | 16 +- 9 files changed, 322 insertions(+), 241 deletions(-) create mode 100644 bible-local/decisions/2026-04-22-status-columns-use-icon-only-presentation.md diff --git a/bible-local/architecture/data-model.md b/bible-local/architecture/data-model.md index e7d915a..e800237 100644 --- a/bible-local/architecture/data-model.md +++ b/bible-local/architecture/data-model.md @@ -43,7 +43,8 @@ For array sections: ## Special Field Handling -- `status` is rendered as a colored badge +- `status` is rendered as a colored badge; in table sections it may collapse to an icon-only presentation column +- `severity` may render as both its source text field and a separate icon-only leading table column - arrays such as `mac_addresses` may be rendered as line-separated values or badges inside one cell - nested values such as `status_history` may be rendered in expandable detail blocks inside one cell diff --git a/bible-local/architecture/runtime-flows.md b/bible-local/architecture/runtime-flows.md index 25ea49f..05fb7aa 100644 --- a/bible-local/architecture/runtime-flows.md +++ b/bible-local/architecture/runtime-flows.md @@ -15,7 +15,16 @@ 1. read the raw `status` value from the payload 2. normalize only for presentation matching (`OK`, `Warning`, `Critical`, `Unknown`, `Empty`) 3. apply status badge class -4. do not change the raw value shown to the user +4. in dense table layouts, render `status` as an icon-only column with an empty header when it improves scanning +5. preserve the raw status value in accessible labeling even when the visible cell shows only a pictogram + +## Severity Presentation Flow + +1. read the raw `severity` value from the payload +2. map the raw value only to a presentation glyph/color class +3. when a table includes `severity`, add a leftmost icon-only column for it +4. keep the original textual `severity` column visible in the table +5. preserve the raw severity value in accessible labeling for the pictogram cell ## Unknown Field Invariant diff --git a/bible-local/architecture/ui-information-architecture.md b/bible-local/architecture/ui-information-architecture.md index a519d56..c0a1b59 100644 --- a/bible-local/architecture/ui-information-architecture.md +++ b/bible-local/architecture/ui-information-architecture.md @@ -60,4 +60,6 @@ Preferred order: - tables must remain readable on desktop and mobile - section navigation must work without JavaScript -- color must not be the only status indicator; always show text +- color must not be the only status indicator; pair it with a shape or glyph +- table `status` columns may use icon-only cells and an empty header when that improves scanability, but the raw status value must remain available via accessible labeling +- table sections with `severity` may add a separate leftmost icon-only column for fast scanning while keeping the textual `severity` field visible diff --git a/bible-local/decisions/2026-04-22-status-columns-use-icon-only-presentation.md b/bible-local/decisions/2026-04-22-status-columns-use-icon-only-presentation.md new file mode 100644 index 0000000..008f3a7 --- /dev/null +++ b/bible-local/decisions/2026-04-22-status-columns-use-icon-only-presentation.md @@ -0,0 +1,31 @@ +# Decision: Status Table Columns Use Icon-Only Presentation + +**Date:** 2026-04-22 +**Status:** active + +## Context + +Dense hardware tables frequently repeat the same `status` values. + +Showing a textual `status` header and textual badges in every row wastes horizontal space and reduces scan speed, especially in sensor subtables. + +The viewer still needs to keep status meaning explicit and avoid relying on color alone. + +## Decision + +Table columns named `status` render as compact icon-only columns. + +This includes: + +- an empty visible header cell for the `status` column +- a minimal-width table column sized for the pictogram +- a glyph plus color to distinguish state +- accessible labeling that preserves the raw status value without showing repeated text in the cell + +Object sections may continue to show status as a regular field value. + +## Consequences + +- Table layouts gain more room for source fields such as `name`, `model`, and `location`. +- Status meaning remains available to assistive technologies even when the visible cell is icon-only. +- Future table UI work should keep `status` compact instead of reintroducing wide text badges by default. diff --git a/viewer/render.go b/viewer/render.go index 30f857e..d2787cf 100644 --- a/viewer/render.go +++ b/viewer/render.go @@ -49,6 +49,7 @@ var hiddenTableFields = map[string]struct{}{ const vendorDeviceIDField = "ven:dev" var commonPreferredColumns = []string{ + "severity_icon", "status", "slot", "location", @@ -333,6 +334,9 @@ func collectColumns(section string, rows []map[string]any) []string { } seen[key] = struct{}{} } + if hasSeverity(row) { + seen["severity_icon"] = struct{}{} + } if hasVendorDeviceID(row) { seen[vendorDeviceIDField] = struct{}{} } @@ -494,6 +498,9 @@ func formatStringValue(value string) string { } func formatRowValue(column string, row map[string]any) string { + if column == "severity_icon" { + return strings.TrimSpace(formatValue(row["severity"])) + } if column == vendorDeviceIDField { return formatVendorDeviceID(row) } @@ -589,6 +596,10 @@ func hasVendorDeviceID(value map[string]any) bool { return formatVendorDeviceID(value) != "" } +func hasSeverity(value map[string]any) bool { + return strings.TrimSpace(formatValue(value["severity"])) != "" +} + func isHiddenTableField(section string, key string) bool { if isHiddenField(key) { return true diff --git a/viewer/render_test.go b/viewer/render_test.go index d89a137..0cbf6e3 100644 --- a/viewer/render_test.go +++ b/viewer/render_test.go @@ -47,6 +47,18 @@ func TestRenderHTMLIncludesKnownSectionsAndFields(t *testing.T) { t.Fatalf("expected rendered html to contain %q", needle) } } + for _, needle := range []string{ + ``, + ``, + ``, + } { + if !strings.Contains(text, needle) { + t.Fatalf("expected rendered html to contain %q", needle) + } + } + if strings.Contains(text, "status") { + t.Fatalf("expected status table headers to be rendered without visible text") + } if strings.Contains(text, "2026-03-15T12:00:00Z") { t.Fatalf("expected RFC3339 timestamp to be rendered in human-readable form") @@ -266,6 +278,12 @@ func TestRenderHTMLGroupsPCIeDevicesByClass(t *testing.T) { if strings.Contains(text, "device_class") { t.Fatalf("expected device_class column to be hidden from PCIe tables") } + if !strings.Contains(text, ``) { + t.Fatalf("expected grouped PCIe tables to render compact status header cells") + } + if !strings.Contains(text, ``) { + t.Fatalf("expected grouped PCIe tables to render icon-only status cells with accessible labels") + } if strings.Index(text, "

Display controller

") > strings.Index(text, "

Network controller

") { t.Fatalf("expected PCIe class groups to be sorted by device_class") } @@ -307,10 +325,17 @@ func TestRenderHTMLAddsSeverityFilterForEventLogs(t *testing.T) { ``, `data-severity="critical"`, `data-severity="info"`, + ``, + ``, + ``, + `severity`, "/static/view.js", } { if !strings.Contains(text, needle) { t.Fatalf("expected rendered html to contain %q", needle) } } + if strings.Contains(text, "severity_icon") { + t.Fatalf("expected synthetic severity icon column header to remain visually empty") + } } diff --git a/web/embed.go b/web/embed.go index 3c4c8f1..7c8ed20 100644 --- a/web/embed.go +++ b/web/embed.go @@ -12,8 +12,9 @@ import ( var content embed.FS var pageTemplate = template.Must(template.New("view.html").Funcs(template.FuncMap{ - "statusClass": statusClass, - "joinLines": joinLines, + "statusClass": statusClass, + "severityClass": severityClass, + "joinLines": joinLines, }).ParseFS(content, "templates/view.html")) var uploadTemplate = template.Must(template.New("upload.html").ParseFS(content, "templates/upload.html")) @@ -59,6 +60,23 @@ func statusClass(value string) string { } } +func severityClass(value string) string { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "INFO", "INFORMATIONAL": + return "severity-info" + case "WARNING", "WARN": + return "severity-warning" + case "ERROR": + return "severity-error" + case "CRITICAL", "FATAL": + return "severity-critical" + case "DEBUG", "TRACE": + return "severity-debug" + default: + return "severity-unknown" + } +} + func joinLines(value string) []string { if strings.TrimSpace(value) == "" { return nil diff --git a/web/static/view.css b/web/static/view.css index e880a08..081eb80 100644 --- a/web/static/view.css +++ b/web/static/view.css @@ -1,20 +1,18 @@ +@import url('https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;600;700&display=swap'); + :root { - --bg: #ffffff; - --surface: #ffffff; - --surface-2: #f9fafb; - --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; - --crit-border: #e0b4b4; - --ok-bg: #fcfff5; --ok-fg: #2c662d; - --warn-bg: #fffaf3; --warn-fg: #573a08; - --crit-bg: #fff6f6; --crit-fg: #9f3a38; - --unknown-bg: #f9fafb; --unknown-fg: rgba(0, 0, 0, 0.5); - --empty-bg: #f9fafb; --empty-fg: rgba(0, 0, 0, 0.4); + --bg: #f6f7fb; + --surface: #ffffff; + --surface-2: #f2f4f8; + --border: #d9dee7; + --border-lite: #e8ecf2; + --ink: #1d2433; + --muted: #677287; + --accent: #2f6fed; + --danger-bg: #fff4f3; + --danger-border: #f2c3bc; + --danger-text: #8b2f23; + --shadow: 0 10px 30px rgba(18, 30, 55, 0.06); } * { @@ -23,287 +21,264 @@ body { margin: 0; - background: var(--bg); + background: + radial-gradient(circle at top left, rgba(47, 111, 237, 0.09), transparent 32%), + linear-gradient(180deg, #f7f9fc 0%, #eef2f7 100%); color: var(--ink); - font: 14px/1.5 Lato, "Helvetica Neue", Arial, Helvetica, sans-serif; + font-family: "Public Sans", "Segoe UI", sans-serif; } -/* ── Header ──────────────────────────────────────── */ - .page-header { - background: #1b1c1d; - padding: 14px 24px; display: flex; align-items: center; justify-content: space-between; gap: 16px; + padding: 18px 24px; + border-bottom: 1px solid rgba(217, 222, 231, 0.9); + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(14px); + position: sticky; + top: 0; + z-index: 20; } .page-header h1 { margin: 0; - font-size: 18px; + font-size: clamp(22px, 3vw, 30px); font-weight: 700; - color: rgba(255, 255, 255, 0.9); + letter-spacing: -0.03em; } -/* ── Main layout ─────────────────────────────────── */ - .header-actions { display: flex; align-items: center; + gap: 10px; } .header-action { - display: inline-block; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 38px; + padding: 0 16px; + border-radius: 999px; + border: 1px solid rgba(47, 111, 237, 0.18); + background: rgba(47, 111, 237, 0.08); + color: var(--accent); text-decoration: none; - border-radius: 4px; - background: rgba(255, 255, 255, 0.12); - color: rgba(255, 255, 255, 0.85); - padding: 6px 14px; - font-size: 13px; - font-weight: 700; - white-space: nowrap; - transition: background 0.1s ease; + font-weight: 600; } .header-action:hover { - background: rgba(255, 255, 255, 0.2); + background: rgba(47, 111, 237, 0.14); } .page-main { - width: min(1500px, calc(100vw - 48px)); - margin: 28px auto 56px; + width: min(1480px, calc(100vw - 40px)); + margin: 26px auto 40px; } -/* ── Meta-panel and upload — классические карточки ── */ - -.empty-panel, -.meta-panel, .notice-panel, -.upload-panel { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 4px; - box-shadow: 0 1px 2px 0 rgba(34, 36, 38, 0.15); - overflow: hidden; - margin-bottom: 28px; -} - -.empty-panel h2, -.meta-panel h2, -.notice-panel h2, -.upload-panel h2 { - display: block; - margin: 0; - padding: 13px 16px; - background: var(--surface-2); - border-bottom: 1px solid var(--border); - font-size: 13px; - font-weight: 700; - color: var(--ink); -} - -.empty-panel p, -.notice-panel p, -.upload-panel p { - margin: 0; - padding: 12px 16px 0; - color: var(--muted); -} - -.empty-panel p:last-child { - padding-bottom: 16px; -} - -/* ── Section cards — heading + table, без обёртки ─── */ - +.meta-panel, +.empty-panel, .section-card { - background: transparent; - border: none; - box-shadow: none; - overflow: visible; - margin-bottom: 32px; + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(217, 222, 231, 0.92); + box-shadow: var(--shadow); + border-radius: 18px; } +.notice-panel, +.meta-panel, +.empty-panel { + padding: 20px 22px; + margin-bottom: 18px; +} + +.notice-panel h2, +.meta-panel h2, +.empty-panel h2, .section-card h2 { - display: block; - margin: 0 0 10px; - padding: 0; - background: transparent; - border: none; + margin: 0 0 14px; font-size: 18px; font-weight: 700; - color: rgba(0, 0, 0, 0.87); + letter-spacing: -0.02em; } -/* таблица внутри section-card получает свой бордер */ -.section-card .table-wrap { - border: 1px solid var(--border); - border-radius: 4px; - box-shadow: 0 1px 2px 0 rgba(34, 36, 38, 0.15); - overflow-x: auto; -} - -.section-card .kv-table { - border: 1px solid var(--border); - border-radius: 4px; - box-shadow: 0 1px 2px 0 rgba(34, 36, 38, 0.15); -} - -/* ── Upload ──────────────────────────────────────── */ - -.upload-panel { - width: min(520px, 100%); - margin-left: auto; - margin-right: auto; -} - -.upload-dropzone { - display: block; - margin: 12px 16px 0; - border: 1px dashed var(--border); - border-radius: 4px; - padding: 16px; - background: var(--surface-2); - cursor: pointer; - transition: border-color 0.1s ease, background 0.1s ease; -} - -.upload-dropzone:hover { - border-color: var(--accent); - background: var(--accent-bg); -} - -.upload-dropzone input { - display: block; - width: 100%; - margin-bottom: 12px; - font: inherit; - color: var(--ink); -} - -.upload-eyebrow { - display: block; - margin-bottom: 4px; - color: var(--accent); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.upload-dropzone strong { - display: block; - margin-bottom: 3px; - font-size: 14px; - font-weight: 700; - color: var(--ink); -} - -.upload-dropzone span:last-child { +.notice-panel p, +.empty-panel p { + margin: 0; color: var(--muted); - font-size: 13px; + line-height: 1.55; } -.upload-actions { - padding: 12px 16px 16px; -} - -.upload-actions button { - background: var(--accent); - color: #fff; - border: none; - border-radius: 4px; - padding: 8px 18px; - font: inherit; - font-weight: 700; - cursor: pointer; - transition: background 0.1s ease; -} - -.upload-actions button:hover { - background: var(--accent-dark); -} - -.upload-actions button:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -/* ── Error ───────────────────────────────────────── */ - -.error-box { - margin: 12px 16px; - border: 1px solid var(--crit-border); - border-radius: 4px; - padding: 10px 14px; - background: var(--crit-bg); - color: var(--crit-fg); -} - -/* ── Sections grid ───────────────────────────────── */ - .sections-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0 32px; + gap: 18px; } -.section-card-half { grid-column: span 1; } -.section-card-full { grid-column: 1 / -1; } +.section-card { + overflow: hidden; +} -/* ── Tables ──────────────────────────────────────── */ +.section-card h2 { + padding: 18px 20px 14px; +} -.kv-table, -.data-table { +.section-card-half { + grid-column: span 1; +} + +.section-card-full { + grid-column: 1 / -1; +} + +.section-card .kv-table, +.section-card .table-wrap, +.meta-panel .kv-table { width: 100%; +} + +.section-card .table-wrap, +.meta-panel .kv-table { + border-top: 1px solid var(--border-lite); +} + +table { border-collapse: collapse; - font-size: 14px; - background: var(--surface); } .kv-table th, .kv-table td, .data-table th, .data-table td { - vertical-align: top; - text-align: left; - border-top: 1px solid var(--border-lite); + border-bottom: 1px solid var(--border-lite); padding: 11px 14px; + vertical-align: top; } -.kv-table tr:first-child th, -.kv-table tr:first-child td, -.data-table tr:first-child th, -.data-table tr:first-child td { - border-top: 0; +.kv-table tr:last-child th, +.kv-table tr:last-child td, +.data-table tbody tr:last-child td { + border-bottom: none; } .kv-table th, .data-table th { - background: var(--surface-2); - color: var(--ink); + text-align: left; font-weight: 700; - white-space: nowrap; - border-bottom: 1px solid var(--border-lite); - border-top: 0; + color: var(--ink); + background: rgba(242, 244, 248, 0.72); } .kv-table th { - width: 1%; + width: 240px; +} + +.kv-table td, +.data-table td { + color: var(--ink); + line-height: 1.45; +} + +.data-table { + width: 100%; + min-width: 680px; +} + +.data-table tbody tr:nth-child(even) { + background: rgba(246, 247, 251, 0.55); } .data-table tbody tr:hover { - background: rgba(0, 0, 0, 0.04); - transition: background 0.1s ease; + background: rgba(47, 111, 237, 0.06); } -/* table-wrap уже получил border в .section-card .table-wrap */ +.error-box { + margin-top: 18px; + padding: 14px 16px; + border-radius: 12px; + border: 1px solid var(--danger-border); + background: var(--danger-bg); + color: var(--danger-text); +} + +.upload-shell { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 28px; +} + +.upload-panel { + width: min(620px, 100%); + background: rgba(255, 255, 255, 0.94); + border-radius: 22px; + border: 1px solid rgba(217, 222, 231, 0.92); + box-shadow: 0 24px 60px rgba(16, 27, 49, 0.12); + padding: 28px 28px 24px; +} + +.upload-panel h1 { + margin: 0 0 10px; + font-size: clamp(28px, 4vw, 36px); + letter-spacing: -0.04em; +} + +.upload-panel p { + margin: 0 0 20px; + color: var(--muted); + line-height: 1.55; +} + +.upload-form { + display: grid; + gap: 14px; +} + +.upload-form label { + display: grid; + gap: 7px; + font-weight: 600; +} + +.upload-form input[type="file"] { + border: 1px dashed var(--border); + border-radius: 14px; + padding: 16px; + background: var(--surface-2); + color: var(--muted); +} + +.upload-form button { + justify-self: start; + min-height: 42px; + padding: 0 18px; + border: none; + border-radius: 999px; + background: linear-gradient(135deg, #2f6fed 0%, #1746b3 100%); + color: #fff; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.upload-form button:hover { + filter: brightness(1.05); +} + +.upload-error { + margin: 0 0 18px; +} + +/* table-wrap already gets border from .section-card .table-wrap */ .table-wrap { overflow-x: auto; } -/* для meta-panel table-wrap без дублирования бордера */ +/* meta-panel table-wrap without duplicate border */ .meta-panel .table-wrap { border: none; box-shadow: none; @@ -365,6 +340,14 @@ body { color: var(--muted); } +.data-table .status-column { + width: 1%; + white-space: nowrap; + text-align: center; + padding-left: 12px; + padding-right: 12px; +} + /* ── Status ──────────────────────────────────────── */ .status-badge { @@ -385,6 +368,13 @@ body { .status-unknown::before { content: '?'; color: rgba(0, 0, 0, 0.4); } .status-empty::before { content: '–'; color: rgba(0, 0, 0, 0.3); } +.severity-info::before { content: 'i'; color: #2185d0; } +.severity-warning::before { content: '!'; color: #f2711c; } +.severity-error::before { content: '×'; color: #db2828; } +.severity-critical::before { content: '✗'; color: #a33333; } +.severity-debug::before { content: '•'; color: rgba(0, 0, 0, 0.55); } +.severity-unknown::before { content: '?'; color: rgba(0, 0, 0, 0.4); } + /* ── Responsive ──────────────────────────────────── */ @media (max-width: 720px) { @@ -409,14 +399,4 @@ 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/templates/view.html b/web/templates/view.html index 479cf35..cd8814a 100644 --- a/web/templates/view.html +++ b/web/templates/view.html @@ -81,7 +81,7 @@ {{ range .Columns }} - {{ . }} + {{ if and (ne . "status") (ne . "severity_icon") }}{{ . }}{{ end }} {{ end }} @@ -90,10 +90,12 @@ {{ $row := . }} {{ range $section.Columns }} - + {{ $value := index $row.Cells . }} {{ if eq . "status" }} - {{ $value }} + + {{ else if eq . "severity_icon" }} + {{ else }} {{ range joinLines $value }}
{{ . }}
@@ -134,7 +136,7 @@ {{ range .Columns }} - {{ . }} + {{ if and (ne . "status") (ne . "severity_icon") }}{{ . }}{{ end }} {{ end }} @@ -143,10 +145,12 @@ {{ $row := . }} {{ range $group.Columns }} - + {{ $value := index $row.Cells . }} {{ if eq . "status" }} - {{ $value }} + + {{ else if eq . "severity_icon" }} + {{ else }} {{ range joinLines $value }}
{{ . }}