Compare commits

9 Commits

Author SHA1 Message Date
f6517987b3 fix(viewer): restore base stylesheet 2026-04-22 21:34:17 +03:00
20e058c959 chore(chart): set module version - v1.0 2026-04-22 21:22:31 +03:00
34ebaa524d feat(viewer): compact status and severity table icons 2026-04-22 21:19:12 +03:00
Mikhail Chusavitin
2fb01d30a6 chore: update bible submodule 2026-04-01 16:31:03 +03:00
Mikhail Chusavitin
8675791805 feat(viewer): add severity filtering for event logs 2026-04-01 16:28:42 +03:00
Mikhail Chusavitin
ac8120c8ab Prioritize PCIe MAC and NUMA columns 2026-03-25 19:59:55 +03:00
Mikhail Chusavitin
c025ae0477 Redesign chart UI toward clean professional style
- Replace warm serif theme with Semantic UI-inspired design:
  white background, Lato font, subtle transparent borders
- Section cards become transparent wrappers with standalone h2
  headings; tables carry their own border and shadow
- Status indicators replaced with plain colored symbols
  (✓ ✗ ! ? –) — no badge backgrounds
- Remove section nav (table of contents)
- Fix HTML: remove redundant div in header, error section
  tag → div[role=alert], add aria-label to nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 02:03:05 +03:00
Mikhail Chusavitin
e5b2ba652c feat: add support bundle notice to viewer 2026-03-16 18:22:07 +03:00
Mikhail Chusavitin
a71f55a6f9 Remove chart header subtitle 2026-03-16 00:19:19 +03:00
15 changed files with 691 additions and 218 deletions

View File

@@ -2,6 +2,8 @@
`chart` is a small read-only web viewer for Reanimator hardware JSON snapshots.
Version: `1.0`
It is intended to be embedded into other Go applications that collect audit data in different ways and want a consistent HTML view of the resulting Reanimator JSON.
## Integration

2
bible

Submodule bible updated: 5a69e0bba8...688b87e98d

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -62,7 +62,7 @@ func newHandler(opts HandlerOptions) http.Handler {
return
}
page, err := buildPageData([]byte(payload), title)
page, err := buildPageData([]byte(payload), title, RenderOptions{})
if err != nil {
if opts.Standalone {
html, renderErr := web.RenderUpload(pageData{

View File

@@ -1,21 +1,26 @@
package viewer
type pageData struct {
Title string
HasSnapshot bool
Error string
Meta []fieldRow
Sections []sectionView
Title string
HasSnapshot bool
Error string
NoticeTitle string
NoticeBody string
DownloadArchiveURL string
DownloadArchiveLabel string
Meta []fieldRow
Sections []sectionView
}
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 {
@@ -25,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
}

View File

@@ -49,6 +49,7 @@ var hiddenTableFields = map[string]struct{}{
const vendorDeviceIDField = "ven:dev"
var commonPreferredColumns = []string{
"severity_icon",
"status",
"slot",
"location",
@@ -73,7 +74,7 @@ var preferredColumns = map[string][]string{
"cpus": {"model", "clock", "cores", "threads", "l1", "l2", "l3", "microcode", "socket"},
"memory": {"part_number", "serial_number", "slot"},
"storage": {"type", "model", "serial_number", "firmware", "size_gb", "slot"},
"pcie_devices": {"device_class", "manufacturer", "model", "serial_number", "slot", "bdf"},
"pcie_devices": {"device_class", "manufacturer", "model", "serial_number", "mac_addresses", "slot", "numa_node", "link_speed", "link_width", "bdf"},
"power_supplies": {"vendor", "model", "part_number", "serial_number", "slot"},
"fans": {"name", "rpm"},
"power": {"name", "voltage_v", "current_a", "power_w"},
@@ -81,16 +82,33 @@ var preferredColumns = map[string][]string{
"other": {"name", "value", "unit"},
}
type RenderOptions struct {
DownloadArchiveURL string
DownloadArchiveLabel string
NoticeTitle string
NoticeBody string
}
func RenderHTML(snapshot []byte, title string) ([]byte, error) {
page, err := buildPageData(snapshot, title)
return RenderHTMLWithOptions(snapshot, title, RenderOptions{})
}
func RenderHTMLWithOptions(snapshot []byte, title string, opts RenderOptions) ([]byte, error) {
page, err := buildPageData(snapshot, title, opts)
if err != nil {
return nil, err
}
return web.Render(page)
}
func buildPageData(snapshot []byte, title string) (pageData, error) {
page := pageData{Title: title}
func buildPageData(snapshot []byte, title string, opts RenderOptions) (pageData, error) {
page := pageData{
Title: title,
NoticeTitle: strings.TrimSpace(opts.NoticeTitle),
NoticeBody: strings.TrimSpace(opts.NoticeBody),
DownloadArchiveURL: strings.TrimSpace(opts.DownloadArchiveURL),
DownloadArchiveLabel: strings.TrimSpace(opts.DownloadArchiveLabel),
}
if strings.TrimSpace(string(snapshot)) == "" {
return page, nil
}
@@ -235,17 +253,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),
}
}
@@ -284,14 +304,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),
})
}
@@ -312,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{}{}
}
@@ -333,6 +358,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 {
@@ -421,12 +498,44 @@ 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)
}
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"]))
@@ -487,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

View File

@@ -47,6 +47,18 @@ func TestRenderHTMLIncludesKnownSectionsAndFields(t *testing.T) {
t.Fatalf("expected rendered html to contain %q", needle)
}
}
for _, needle := range []string{
`<th class="status-column" aria-label="status"></th>`,
`<td class="status-column">`,
`<span class="status-badge status-ok" role="img" aria-label="OK" title="OK"></span>`,
} {
if !strings.Contains(text, needle) {
t.Fatalf("expected rendered html to contain %q", needle)
}
}
if strings.Contains(text, "<th>status</th>") {
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,7 +278,64 @@ func TestRenderHTMLGroupsPCIeDevicesByClass(t *testing.T) {
if strings.Contains(text, "<th>device_class</th>") {
t.Fatalf("expected device_class column to be hidden from PCIe tables")
}
if !strings.Contains(text, `<th class="status-column" aria-label="status"></th>`) {
t.Fatalf("expected grouped PCIe tables to render compact status header cells")
}
if !strings.Contains(text, `<span class="status-badge status-warning" role="img" aria-label="Warning" title="Warning"></span>`) {
t.Fatalf("expected grouped PCIe tables to render icon-only status cells with accessible labels")
}
if strings.Index(text, "<h3>Display controller</h3>") > strings.Index(text, "<h3>Network controller</h3>") {
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"`,
`<th class="status-column" aria-label="severity"></th>`,
`<span class="status-badge severity-info" role="img" aria-label="Info" title="Info"></span>`,
`<span class="status-badge severity-critical" role="img" aria-label="Critical" title="Critical"></span>`,
`<th>severity</th>`,
"/static/view.js",
} {
if !strings.Contains(text, needle) {
t.Fatalf("expected rendered html to contain %q", needle)
}
}
if strings.Contains(text, "<th>severity_icon</th>") {
t.Fatalf("expected synthetic severity icon column header to remain visually empty")
}
}

View File

@@ -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

View File

@@ -1,20 +1,20 @@
:root {
--bg: #f4f1ea;
--panel: #fffdf9;
--border: #d8d0c3;
--ink: #26231e;
--muted: #6a6258;
--accent: #8f3b2e;
--ok-bg: #d8f1df;
--ok-fg: #1f6a32;
--warn-bg: #fff0bf;
--warn-fg: #8a6200;
--crit-bg: #ffd7d2;
--crit-fg: #9a2419;
--unknown-bg: #e3e5e8;
--unknown-fg: #4f5965;
--empty-bg: #ece7de;
--empty-fg: #72675b;
--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);
}
* {
@@ -23,160 +23,243 @@
body {
margin: 0;
background:
radial-gradient(circle at top left, #efe1cf 0, transparent 30%),
linear-gradient(180deg, #f8f4ed 0%, var(--bg) 100%);
background: var(--bg);
color: var(--ink);
font: 14px/1.45 "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
font: 14px/1.5 Lato, "Helvetica Neue", Arial, Helvetica, sans-serif;
}
/* ── Header ──────────────────────────────────────── */
.page-header {
padding: 24px 28px 18px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
background: #1b1c1d;
padding: 14px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.page-header h1 {
margin: 0;
font-size: 32px;
line-height: 1.1;
font-size: 18px;
font-weight: 700;
color: rgba(255, 255, 255, 0.9);
}
.page-header p {
margin: 6px 0 0;
color: var(--muted);
/* ── Main layout ─────────────────────────────────── */
.header-actions {
display: flex;
align-items: center;
}
.header-action {
display: inline-block;
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;
}
.header-action:hover {
background: rgba(255, 255, 255, 0.2);
}
.page-main {
width: min(1500px, calc(100vw - 32px));
margin: 0 auto;
padding: 20px 0 32px;
width: min(1500px, calc(100vw - 48px));
margin: 28px auto 56px;
}
/* ── Meta-panel and upload — классические карточки ── */
.empty-panel,
.meta-panel,
.section-card,
.notice-panel,
.upload-panel {
background: var(--panel);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 10px 30px rgba(78, 54, 26, 0.05);
padding: 18px;
margin-bottom: 16px;
border-radius: 4px;
box-shadow: 0 1px 2px 0 rgba(34, 36, 38, 0.15);
overflow: hidden;
margin-bottom: 28px;
}
.empty-panel h2,
.section-card h2,
.meta-panel h2,
.notice-panel h2,
.upload-panel h2 {
display: block;
margin: 0 0 12px;
font-size: 18px;
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, без обёртки ─── */
.section-card {
background: transparent;
border: none;
box-shadow: none;
overflow: visible;
margin-bottom: 32px;
}
.section-card h2 {
display: block;
margin: 0 0 10px;
padding: 0;
background: transparent;
border: none;
font-size: 18px;
font-weight: 700;
color: rgba(0, 0, 0, 0.87);
}
/* таблица внутри 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(680px, 100%);
width: min(520px, 100%);
margin-left: auto;
margin-right: auto;
}
.upload-dropzone {
display: block;
margin-top: 16px;
margin: 12px 16px 0;
border: 1px dashed var(--border);
border-radius: 12px;
padding: 18px;
background: #faf6ef;
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: 6px;
margin-bottom: 4px;
color: var(--accent);
font-size: 12px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.upload-dropzone strong {
display: block;
margin-bottom: 4px;
font-size: 18px;
margin-bottom: 3px;
font-size: 14px;
font-weight: 700;
color: var(--ink);
}
.upload-dropzone span:last-child {
color: var(--muted);
font-size: 13px;
}
.upload-actions {
margin-top: 14px;
padding: 12px 16px 16px;
}
.upload-actions button {
border: 0;
border-radius: 999px;
background: var(--accent);
color: #fff;
padding: 10px 16px;
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-bottom: 16px;
border: 1px solid var(--crit-fg);
border-radius: 10px;
padding: 12px;
margin: 12px 16px;
border: 1px solid var(--crit-border);
border-radius: 4px;
padding: 10px 14px;
background: var(--crit-bg);
color: var(--crit-fg);
}
.section-nav {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 16px;
}
.section-nav a {
text-decoration: none;
color: var(--accent);
background: rgba(143, 59, 46, 0.08);
padding: 7px 11px;
border-radius: 999px;
}
/* ── Sections grid ───────────────────────────────── */
.sections-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
gap: 0 32px;
}
.section-card {
margin-bottom: 0;
}
.section-card-half { grid-column: span 1; }
.section-card-full { grid-column: 1 / -1; }
.section-card-half {
grid-column: span 1;
}
.section-card-full {
grid-column: 1 / -1;
}
/* ── Tables ──────────────────────────────────────── */
.kv-table,
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
background: var(--surface);
}
.kv-table th,
@@ -185,8 +268,8 @@ body {
.data-table td {
vertical-align: top;
text-align: left;
border-top: 1px solid var(--border);
padding: 10px 8px;
border-top: 1px solid var(--border-lite);
padding: 11px 14px;
}
.kv-table tr:first-child th,
@@ -198,69 +281,135 @@ body {
.kv-table th,
.data-table th {
color: var(--muted);
background: var(--surface-2);
color: var(--ink);
font-weight: 700;
white-space: nowrap;
border-bottom: 1px solid var(--border-lite);
border-top: 0;
}
.kv-table th {
width: 1%;
}
.data-table tbody tr:hover {
background: rgba(0, 0, 0, 0.04);
transition: background 0.1s ease;
}
/* table-wrap уже получил border в .section-card .table-wrap */
.table-wrap {
overflow-x: auto;
}
/* для meta-panel table-wrap без дублирования бордера */
.meta-panel .table-wrap {
border: none;
box-shadow: none;
border-radius: 0;
}
.table-group + .table-group {
margin-top: 18px;
border-top: 1px solid var(--border);
}
.table-group h3 {
margin: 0 0 8px;
margin: 0;
padding: 9px 14px;
color: var(--muted);
font-size: 13px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
background: var(--surface-2);
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;
}
.data-table thead th {
position: sticky;
top: 0;
background: #f7f1e7;
.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);
}
.data-table .status-column {
width: 1%;
white-space: nowrap;
text-align: center;
padding-left: 12px;
padding-right: 12px;
}
/* ── Status ──────────────────────────────────────── */
.status-badge {
display: inline-block;
border-radius: 999px;
padding: 3px 9px;
font-weight: 700;
font-size: 0;
white-space: nowrap;
}
.status-ok {
background: var(--ok-bg);
color: var(--ok-fg);
.status-badge::before {
font-size: 15px;
font-weight: 700;
line-height: 1;
}
.status-warning {
background: var(--warn-bg);
color: var(--warn-fg);
}
.status-ok::before { content: '✓'; color: #16ab39; }
.status-warning::before { content: '!'; color: #f2711c; }
.status-critical::before { content: '✗'; color: #db2828; }
.status-unknown::before { content: '?'; color: rgba(0, 0, 0, 0.4); }
.status-empty::before { content: ''; color: rgba(0, 0, 0, 0.3); }
.status-critical {
background: var(--crit-bg);
color: var(--crit-fg);
}
.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); }
.status-unknown {
background: var(--unknown-bg);
color: var(--unknown-fg);
}
.status-empty {
background: var(--empty-bg);
color: var(--empty-fg);
}
/* ── Responsive ──────────────────────────────────── */
@media (max-width: 720px) {
.page-header {
flex-direction: column;
}
.page-main {
width: min(100vw - 20px, 1500px);
width: calc(100vw - 24px);
margin-top: 20px;
}
.sections-grid {
@@ -268,22 +417,21 @@ body {
}
.page-header {
padding: 18px 14px 14px;
}
.page-header h1 {
font-size: 26px;
}
.empty-panel,
.meta-panel,
.section-card,
.upload-panel {
padding: 14px;
padding: 12px 16px;
}
.section-card-half,
.section-card-full {
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

@@ -8,10 +8,7 @@
</head>
<body>
<header class="page-header">
<div>
<h1>{{ .Title }}</h1>
<p>Read-only viewer for Reanimator JSON snapshots</p>
</div>
<h1>{{ .Title }}</h1>
</header>
<main class="page-main">

View File

@@ -5,16 +5,26 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }}</title>
<link rel="stylesheet" href="/static/view.css">
<script defer src="/static/view.js"></script>
</head>
<body>
<header class="page-header">
<div>
<h1>{{ .Title }}</h1>
<p>Read-only viewer for Reanimator JSON snapshots</p>
<h1>{{ .Title }}</h1>
{{ if .DownloadArchiveURL }}
<div class="header-actions">
<a class="header-action" href="{{ .DownloadArchiveURL }}">{{ if .DownloadArchiveLabel }}{{ .DownloadArchiveLabel }}{{ else }}Download archive{{ end }}</a>
</div>
{{ end }}
</header>
<main class="page-main">
{{ if .NoticeTitle }}
<section class="notice-panel">
<h2>{{ .NoticeTitle }}</h2>
<p>{{ .NoticeBody }}</p>
</section>
{{ end }}
{{ if .HasSnapshot }}
<section class="meta-panel">
<h2>Snapshot Metadata</h2>
@@ -30,13 +40,7 @@
</table>
</section>
<nav class="section-nav">
{{ range .Sections }}
<a href="#{{ .ID }}">{{ .Title }}</a>
{{ end }}
</nav>
<div class="sections-grid">
<div class="sections-grid">
{{ range .Sections }}
<section class="section-card {{ if or (eq .ID "board") (eq .ID "firmware") }}section-card-half{{ else }}section-card-full{{ end }}" id="{{ .ID }}">
<h2>{{ .Title }}</h2>
@@ -60,61 +64,38 @@
{{ if eq .Kind "table" }}
{{ $section := . }}
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
{{ range .Columns }}
<th>{{ . }}</th>
{{ end }}
</tr>
</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>
<div class="table-block {{ if .SeverityOptions }}table-filterable{{ end }}">
{{ if .SeverityOptions }}
<div class="table-toolbar">
<label class="table-toolbar-label" for="{{ .ID }}-severity-filter">Severity</label>
<select class="table-severity-filter" id="{{ .ID }}-severity-filter">
<option value="">All severities</option>
{{ range .SeverityOptions }}
<option value="{{ .Value }}">{{ .Label }}</option>
{{ end }}
</tbody>
</table>
</div>
{{ end }}
{{ if eq .Kind "grouped_tables" }}
{{ range .Groups }}
<div class="table-group">
<h3>{{ .Title }}</h3>
{{ $group := . }}
</select>
</div>
{{ end }}
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
{{ range .Columns }}
<th>{{ . }}</th>
<th{{ if or (eq . "status") (eq . "severity_icon") }} class="status-column"{{ end }}{{ if eq . "status" }} aria-label="status"{{ end }}{{ if eq . "severity_icon" }} aria-label="severity"{{ end }}>{{ if and (ne . "status") (ne . "severity_icon") }}{{ . }}{{ end }}</th>
{{ end }}
</tr>
</thead>
<tbody>
{{ range .Items }}
<tr>
<tr data-severity-row="true" data-severity="{{ .Severity }}">
{{ $row := . }}
{{ range $group.Columns }}
<td>
{{ range $section.Columns }}
<td{{ if or (eq . "status") (eq . "severity_icon") }} class="status-column"{{ end }}>
{{ $value := index $row.Cells . }}
{{ if eq . "status" }}
<span class="status-badge {{ statusClass $value }}">{{ $value }}</span>
<span class="status-badge {{ statusClass $value }}" role="img" aria-label="{{ $value }}" title="{{ $value }}"></span>
{{ else if eq . "severity_icon" }}
<span class="status-badge {{ severityClass $value }}" role="img" aria-label="{{ $value }}" title="{{ $value }}"></span>
{{ else }}
{{ range joinLines $value }}
<div>{{ . }}</div>
@@ -127,6 +108,65 @@
</tbody>
</table>
</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{{ if or (eq . "status") (eq . "severity_icon") }} class="status-column"{{ end }}{{ if eq . "status" }} aria-label="status"{{ end }}{{ if eq . "severity_icon" }} aria-label="severity"{{ end }}>{{ if and (ne . "status") (ne . "severity_icon") }}{{ . }}{{ end }}</th>
{{ end }}
</tr>
</thead>
<tbody>
{{ range .Items }}
<tr data-severity-row="true" data-severity="{{ .Severity }}">
{{ $row := . }}
{{ range $group.Columns }}
<td{{ if or (eq . "status") (eq . "severity_icon") }} class="status-column"{{ end }}>
{{ $value := index $row.Cells . }}
{{ if eq . "status" }}
<span class="status-badge {{ statusClass $value }}" role="img" aria-label="{{ $value }}" title="{{ $value }}"></span>
{{ else if eq . "severity_icon" }}
<span class="status-badge {{ severityClass $value }}" role="img" aria-label="{{ $value }}" title="{{ $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>
{{ end }}
{{ end }}
@@ -136,7 +176,7 @@
{{ end }}
{{ if .Error }}
<section class="error-box">{{ .Error }}</section>
<div role="alert" class="error-box">{{ .Error }}</div>
{{ end }}
{{ if not .HasSnapshot }}