5 Commits

13 changed files with 258 additions and 27 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: 688b87e98d...d2600f1279

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

@@ -10,7 +10,8 @@ It is designed to be embedded as a module in other Go applications that already
- Render one Reanimator JSON snapshot as HTML
- Read-only presentation of top-level metadata and hardware sections
- Tabular rendering for arrays such as `cpus`, `memory`, `storage`, `pcie_devices`, `power_supplies`, and sensor subsections
- Tabular rendering for arrays such as `cpus`, `memory`, `storage`, `pcie_devices`, `power_supplies`, `event_logs`, and sensor subsections
- Object rendering for singleton/config sections such as `board` and `platform_config`
- Status color coding for fast scanning
- Lightweight section navigation
- Standalone HTML rendering or embeddable HTTP handler

View File

@@ -30,13 +30,16 @@ Preferred order:
6. `pcie_devices`
7. `power_supplies`
8. `sensors`
9. unknown sections
9. `event_logs`
10. `platform_config`
11. unknown sections
## Section Presentation
- singleton object sections render as key-value table
- array sections render as compact data tables
- sensors render as separate subtables for `fans`, `power`, `temperatures`, and `other`
- `platform_config` renders as a key-value table without interpreting setting names or values
## Visual Rules
@@ -60,4 +63,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

@@ -82,3 +82,22 @@ func TestStandaloneHandlerRootShowsUploadForm(t *testing.T) {
t.Fatalf("expected standalone handler root to include upload form")
}
}
func TestStaticJSUsesScriptContentType(t *testing.T) {
handler := NewHandler(HandlerOptions{Title: "Reanimator Chart"})
req := httptest.NewRequest(http.MethodGet, "/static/view.js", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
contentType := rec.Header().Get("Content-Type")
if !strings.Contains(contentType, "javascript") {
t.Fatalf("Content-Type = %q, want javascript MIME type", contentType)
}
if !strings.Contains(rec.Body.String(), "DOMContentLoaded") {
t.Fatalf("expected static JS asset body to be served")
}
}

View File

@@ -19,21 +19,25 @@ var sectionOrder = []string{
"pcie_devices",
"power_supplies",
"sensors",
"event_logs",
"platform_config",
}
var sectionTitles = map[string]string{
"board": "Board",
"firmware": "Firmware",
"cpus": "CPUs",
"memory": "Memory",
"storage": "Storage",
"pcie_devices": "PCIe Devices",
"power_supplies": "Power Supplies",
"sensors": "Sensors",
"fans": "Fans",
"power": "Power",
"temperatures": "Temperatures",
"other": "Other",
"board": "Board",
"firmware": "Firmware",
"cpus": "CPUs",
"memory": "Memory",
"storage": "Storage",
"pcie_devices": "PCIe Devices",
"power_supplies": "Power Supplies",
"sensors": "Sensors",
"event_logs": "Event Logs",
"platform_config": "Platform Config",
"fans": "Fans",
"power": "Power",
"temperatures": "Temperatures",
"other": "Other",
}
var preferredMetaKeys = []string{"target_host", "collected_at", "source_type", "protocol", "filename"}
@@ -49,6 +53,7 @@ var hiddenTableFields = map[string]struct{}{
const vendorDeviceIDField = "ven:dev"
var commonPreferredColumns = []string{
"severity_icon",
"status",
"slot",
"location",
@@ -72,9 +77,10 @@ var preferredColumns = map[string][]string{
"firmware": {"device_name", "version"},
"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"},
"storage": {"type", "model", "serial_number", "firmware", "size_gb", "logical_block_size_bytes", "physical_block_size_bytes", "metadata_bytes_per_block", "slot"},
"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"},
"event_logs": {"severity_icon", "source", "event_time", "severity", "message_id", "message", "component_ref", "fingerprint", "is_active", "raw_payload"},
"fans": {"name", "rpm"},
"power": {"name", "voltage_v", "current_a", "power_w"},
"temperatures": {"name", "celsius", "threshold_warning_celsius", "threshold_critical_celsius"},
@@ -333,6 +339,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 +503,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 +601,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,6 +278,12 @@ 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")
}
@@ -307,10 +325,95 @@ func TestRenderHTMLAddsSeverityFilterForEventLogs(t *testing.T) {
`<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")
}
}
func TestRenderHTMLDisplaysHardwareContract210Fields(t *testing.T) {
snapshot := []byte(`{
"filename": "redfish://10.10.10.103",
"source_type": "api",
"protocol": "redfish",
"target_host": "10.10.10.103",
"collected_at": "2026-02-10T15:30:00Z",
"hardware": {
"board": {
"manufacturer": "Supermicro",
"product_name": "X12DPG-QT6",
"serial_number": "21D634101"
},
"storage": [
{
"slot": "OB01",
"type": "NVMe",
"model": "INTEL SSDPF2KX076T1",
"size_gb": 7680,
"logical_block_size_bytes": 512,
"physical_block_size_bytes": 4096,
"metadata_bytes_per_block": 8,
"serial_number": "BTAX41900GF87P6DGN",
"manufacturer": "Intel",
"firmware": "9CV10510",
"interface": "NVMe",
"present": true,
"status": "OK"
}
],
"event_logs": [
{
"source": "redfish",
"event_time": "2026-03-15T14:03:20Z",
"severity": "Info",
"message_id": "OpenBMC.0.1.SystemReboot",
"message": "System reboot requested by administrator",
"component_ref": "Mainboard"
}
],
"platform_config": {
"SecureBoot": "Enabled",
"BiosVersion": "06.08.05",
"TpmEnabled": true,
"NumaEnabled": false,
"HyperThreading": "Enabled"
}
}
}`)
html, err := RenderHTML(snapshot, "Reanimator Chart")
if err != nil {
t.Fatalf("RenderHTML() error = %v", err)
}
text := string(html)
for _, needle := range []string{
"Storage",
"Event Logs",
"Platform Config",
"<th>logical_block_size_bytes</th>",
"<th>physical_block_size_bytes</th>",
"<th>metadata_bytes_per_block</th>",
"512",
"4096",
"8",
"SecureBoot",
"Enabled",
"TpmEnabled",
"true",
"System reboot requested by administrator",
} {
if !strings.Contains(text, needle) {
t.Fatalf("expected rendered html to contain %q", needle)
}
}
}

View File

@@ -4,6 +4,7 @@ import (
"embed"
"html/template"
"io/fs"
"mime"
"net/http"
"strings"
)
@@ -12,12 +13,19 @@ 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"))
func init() {
if err := mime.AddExtensionType(".js", "text/javascript; charset=utf-8"); err != nil {
panic(err)
}
}
func Render(data any) ([]byte, error) {
var out strings.Builder
if err := pageTemplate.ExecuteTemplate(&out, "view.html", data); err != nil {
@@ -59,6 +67,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

@@ -365,6 +365,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 +393,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) {

View File

@@ -81,7 +81,7 @@
<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>
@@ -90,10 +90,12 @@
<tr data-severity-row="true" data-severity="{{ .Severity }}">
{{ $row := . }}
{{ range $section.Columns }}
<td>
<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>
@@ -134,7 +136,7 @@
<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>
@@ -143,10 +145,12 @@
<tr data-severity-row="true" data-severity="{{ .Severity }}">
{{ $row := . }}
{{ range $group.Columns }}
<td>
<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>