refactor(viewer): consolidate rendering and harden uploads

This commit is contained in:
Mikhail Chusavitin
2026-09-01 12:59:18 +03:00
parent ba751c861d
commit be68069a51
9 changed files with 214 additions and 276 deletions
+2 -2
View File
@@ -8,7 +8,7 @@ It is intended to be embedded into other Go applications that collect audit data
## Integration ## Integration
For embedding instructions, see [`docs/embedding.md`](/Users/mchusavitin/Documents/git/reanimator/chart/docs/embedding.md). For embedding instructions, see [`docs/embedding.md`](docs/embedding.md).
### As Git Submodule ### As Git Submodule
@@ -66,4 +66,4 @@ make run
## Architecture Docs ## Architecture Docs
Project-specific architecture lives in [`bible-local/README.md`](/Users/mchusavitin/Documents/git/reanimator/chart/bible-local/README.md). Project-specific architecture lives in [`bible-local/README.md`](bible-local/README.md).
+6 -1
View File
@@ -6,7 +6,8 @@ The package is intended to be embedded by other Go applications.
Current package shape: Current package shape:
- `viewer.RenderHTML(snapshot []byte) ([]byte, error)` - `viewer.RenderHTML(snapshot []byte, title string) ([]byte, error)`
- `viewer.RenderHTMLWithOptions(snapshot []byte, title string, opts viewer.RenderOptions) ([]byte, error)`
- `viewer.NewHandler(viewer.HandlerOptions{...}) http.Handler` - `viewer.NewHandler(viewer.HandlerOptions{...}) http.Handler`
- `viewer.NewStandaloneHandler(viewer.HandlerOptions{...}) http.Handler` - `viewer.NewStandaloneHandler(viewer.HandlerOptions{...}) http.Handler`
@@ -32,6 +33,10 @@ Embedded handler endpoints:
- `GET /healthz` - basic process health - `GET /healthz` - basic process health
- `GET /static/...` - embedded static assets - `GET /static/...` - embedded static assets
Internal asset and form URLs are relative so the documented
`http.StripPrefix("/chart", ...)` integration works below `/chart/` as well as
at the server root. Snapshot request bodies are limited to 64 MiB.
## UI Route Rules ## UI Route Rules
- No multi-product navigation - No multi-product navigation
+14 -10
View File
@@ -13,20 +13,19 @@ import (
type HandlerOptions struct { type HandlerOptions struct {
Title string Title string
Standalone bool
} }
const maxSnapshotRequestBytes int64 = 64 << 20
func NewHandler(opts HandlerOptions) http.Handler { func NewHandler(opts HandlerOptions) http.Handler {
opts.Standalone = false return newHandler(opts, false)
return newHandler(opts)
} }
func NewStandaloneHandler(opts HandlerOptions) http.Handler { func NewStandaloneHandler(opts HandlerOptions) http.Handler {
opts.Standalone = true return newHandler(opts, true)
return newHandler(opts)
} }
func newHandler(opts HandlerOptions) http.Handler { func newHandler(opts HandlerOptions, standalone bool) http.Handler {
title := strings.TrimSpace(opts.Title) title := strings.TrimSpace(opts.Title)
if title == "" { if title == "" {
title = "Reanimator Chart" title = "Reanimator Chart"
@@ -43,7 +42,7 @@ func newHandler(opts HandlerOptions) http.Handler {
html []byte html []byte
err error err error
) )
if opts.Standalone { if standalone {
html, err = web.RenderUpload(pageData{Title: title}) html, err = web.RenderUpload(pageData{Title: title})
} else { } else {
html, err = RenderHTML(nil, title) html, err = RenderHTML(nil, title)
@@ -56,6 +55,7 @@ func newHandler(opts HandlerOptions) http.Handler {
_, _ = w.Write(html) _, _ = w.Write(html)
}) })
mux.HandleFunc("POST /render", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("POST /render", func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxSnapshotRequestBytes)
payload, err := readSnapshotPayload(r) payload, err := readSnapshotPayload(r)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
@@ -64,7 +64,7 @@ func newHandler(opts HandlerOptions) http.Handler {
page, err := buildPageData([]byte(payload), title, RenderOptions{}) page, err := buildPageData([]byte(payload), title, RenderOptions{})
if err != nil { if err != nil {
if opts.Standalone { if standalone {
html, renderErr := web.RenderUpload(pageData{ html, renderErr := web.RenderUpload(pageData{
Title: title, Title: title,
Error: err.Error(), Error: err.Error(),
@@ -105,8 +105,12 @@ func readSnapshotPayload(r *http.Request) (string, error) {
} }
return string(body), nil return string(body), nil
case "multipart/form-data": case "multipart/form-data":
if err := r.ParseMultipartForm(32 << 20); err != nil { parseErr := r.ParseMultipartForm(32 << 20)
return "", fmt.Errorf("parse multipart form: %w", err) if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
if parseErr != nil {
return "", fmt.Errorf("parse multipart form: %w", parseErr)
} }
payload, err := readSnapshotFile(r, "snapshot_file") payload, err := readSnapshotFile(r, "snapshot_file")
+21
View File
@@ -101,3 +101,24 @@ func TestStaticJSUsesScriptContentType(t *testing.T) {
t.Fatalf("expected static JS asset body to be served") t.Fatalf("expected static JS asset body to be served")
} }
} }
func TestStandaloneHandlerAssetsAndFormWorkUnderPathPrefix(t *testing.T) {
outer := http.NewServeMux()
outer.Handle("/chart/", http.StripPrefix("/chart", NewStandaloneHandler(HandlerOptions{Title: "Reanimator Chart"})))
pageRec := httptest.NewRecorder()
outer.ServeHTTP(pageRec, httptest.NewRequest(http.MethodGet, "/chart/", nil))
if pageRec.Code != http.StatusOK {
t.Fatalf("page status = %d, want %d", pageRec.Code, http.StatusOK)
}
body := pageRec.Body.String()
if !strings.Contains(body, `href="static/view.css"`) || !strings.Contains(body, `action="render"`) {
t.Fatalf("prefixed page contains root-absolute internal URLs: %s", body)
}
staticRec := httptest.NewRecorder()
outer.ServeHTTP(staticRec, httptest.NewRequest(http.MethodGet, "/chart/static/view.js", nil))
if staticRec.Code != http.StatusOK {
t.Fatalf("static asset status = %d, want %d", staticRec.Code, http.StatusOK)
}
}
-9
View File
@@ -20,7 +20,6 @@ type sectionView struct {
Columns []string Columns []string
Items []tableRow Items []tableRow
Groups []tableGroupView Groups []tableGroupView
SeverityOptions []severityOption
} }
type fieldRow struct { type fieldRow struct {
@@ -29,20 +28,12 @@ type fieldRow struct {
} }
type tableRow struct { type tableRow struct {
Status string
Severity string Severity string
Cells map[string]string Cells map[string]string
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
} }
+61 -147
View File
@@ -44,16 +44,6 @@ var sectionTitles = map[string]string{
var preferredMetaKeys = []string{"target_host", "collected_at", "source_type", "protocol", "filename"} var preferredMetaKeys = []string{"target_host", "collected_at", "source_type", "protocol", "filename"}
var hiddenFields = map[string]struct{}{
"status_at_collection": {},
}
var hiddenTableFields = map[string]struct{}{
"status_checked_at": {},
}
const vendorDeviceIDField = "ven:dev"
var commonPreferredColumns = []string{ var commonPreferredColumns = []string{
"severity_icon", "severity_icon",
"status", "status",
@@ -69,7 +59,8 @@ var commonPreferredColumns = []string{
"product_name", "product_name",
"part_number", "part_number",
"serial_number", "serial_number",
vendorDeviceIDField, "vendor_id",
"device_id",
"firmware", "firmware",
"version", "version",
"bdf", "bdf",
@@ -136,9 +127,6 @@ func buildMeta(root map[string]any) []fieldRow {
rows := make([]fieldRow, 0) rows := make([]fieldRow, 0)
used := make(map[string]struct{}) used := make(map[string]struct{})
for _, key := range preferredMetaKeys { for _, key := range preferredMetaKeys {
if isHiddenField(key) {
continue
}
if value, ok := root[key]; ok { if value, ok := root[key]; ok {
rows = append(rows, fieldRow{Key: key, Value: formatValue(value)}) rows = append(rows, fieldRow{Key: key, Value: formatValue(value)})
used[key] = struct{}{} used[key] = struct{}{}
@@ -147,10 +135,9 @@ func buildMeta(root map[string]any) []fieldRow {
extraKeys := make([]string, 0) extraKeys := make([]string, 0)
for key := range root { for key := range root {
if key == "hardware" { if key == "hardware" {
if _, handledAsSections := root[key].(map[string]any); handledAsSections {
continue continue
} }
if isHiddenField(key) {
continue
} }
if _, ok := used[key]; ok { if _, ok := used[key]; ok {
continue continue
@@ -208,6 +195,14 @@ func buildSection(key string, value any) []sectionView {
Rows: buildFieldRows(typed), Rows: buildFieldRows(typed),
}} }}
case []any: case []any:
if !allObjectItems(typed) {
return []sectionView{{
ID: key,
Title: titleFor(key),
Kind: "object",
Rows: buildArrayRows(typed),
}}
}
if key == "pcie_devices" { if key == "pcie_devices" {
return []sectionView{buildPCIeSection(typed)} return []sectionView{buildPCIeSection(typed)}
} }
@@ -225,24 +220,57 @@ func buildSection(key string, value any) []sectionView {
} }
func buildSensorSections(sensors map[string]any) []sectionView { func buildSensorSections(sensors map[string]any) []sectionView {
out := make([]sectionView, 0) orderedKeys := make([]string, 0, len(sensors))
used := make(map[string]struct{}, len(sensors))
for _, key := range []string{"fans", "power", "temperatures", "other"} { for _, key := range []string{"fans", "power", "temperatures", "other"} {
value, ok := sensors[key] if _, ok := sensors[key]; ok {
if !ok { orderedKeys = append(orderedKeys, key)
continue used[key] = struct{}{}
} }
items, ok := value.([]any)
if !ok {
continue
} }
section := buildTableSection(key, items) var extraKeys []string
section.ID = "sensors-" + key for key := range sensors {
section.Title = "Sensors / " + titleFor(key) if _, ok := used[key]; !ok {
extraKeys = append(extraKeys, key)
}
}
sort.Strings(extraKeys)
orderedKeys = append(orderedKeys, extraKeys...)
out := make([]sectionView, 0, len(orderedKeys))
for _, key := range orderedKeys {
var sections []sectionView
if key == "sensors" {
sections = []sectionView{{ID: key, Title: titleFor(key), Kind: "object", Rows: []fieldRow{{Key: key, Value: formatValue(sensors[key])}}}}
} else {
sections = buildSection(key, sensors[key])
}
for _, section := range sections {
section.ID = "sensors-" + section.ID
section.Title = "Sensors / " + section.Title
out = append(out, section) out = append(out, section)
} }
}
return out return out
} }
func allObjectItems(items []any) bool {
for _, item := range items {
if _, ok := item.(map[string]any); !ok {
return false
}
}
return true
}
func buildArrayRows(items []any) []fieldRow {
rows := make([]fieldRow, 0, len(items))
for index, item := range items {
rows = append(rows, fieldRow{Key: fmt.Sprintf("[%d]", index), Value: formatValue(item)})
}
return rows
}
func buildTableSection(key string, items []any) sectionView { func buildTableSection(key string, items []any) sectionView {
rows := make([]map[string]any, 0, len(items)) rows := make([]map[string]any, 0, len(items))
for _, item := range items { for _, item := range items {
@@ -258,23 +286,13 @@ func buildTableSection(key string, items []any) sectionView {
for _, column := range columns { for _, column := range columns {
cells[column] = formatRowValue(column, row) cells[column] = formatRowValue(column, row)
} }
status := strings.TrimSpace(cells["status"])
tableRows = append(tableRows, tableRow{ tableRows = append(tableRows, tableRow{
Status: status,
Severity: normalizeSeverity(cells["severity"]), Severity: normalizeSeverity(cells["severity"]),
Cells: cells, Cells: cells,
RawCells: row,
}) })
} }
return sectionView{ 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),
}
} }
func buildPCIeSection(items []any) sectionView { func buildPCIeSection(items []any) sectionView {
@@ -311,18 +329,11 @@ func buildPCIeSection(items []any) sectionView {
cells[column] = formatRowValue(column, row) cells[column] = formatRowValue(column, row)
} }
items = append(items, tableRow{ items = append(items, tableRow{
Status: strings.TrimSpace(cells["status"]),
Severity: normalizeSeverity(cells["severity"]), Severity: normalizeSeverity(cells["severity"]),
Cells: cells, Cells: cells,
RawCells: row,
}) })
} }
groups = append(groups, tableGroupView{ groups = append(groups, tableGroupView{Title: className, Columns: columns, Items: items})
Title: className,
Columns: columns,
Items: items,
SeverityOptions: collectSeverityOptions(columns, rows),
})
} }
return sectionView{ return sectionView{
@@ -337,7 +348,7 @@ func collectColumns(section string, rows []map[string]any) []string {
seen := make(map[string]struct{}) seen := make(map[string]struct{})
for _, row := range rows { for _, row := range rows {
for key := range row { for key := range row {
if isHiddenTableField(section, key) { if section == "pcie_devices" && key == "device_class" {
continue continue
} }
seen[key] = struct{}{} seen[key] = struct{}{}
@@ -345,9 +356,6 @@ func collectColumns(section string, rows []map[string]any) []string {
if hasSeverity(row) { if hasSeverity(row) {
seen["severity_icon"] = struct{}{} seen["severity_icon"] = struct{}{}
} }
if hasVendorDeviceID(row) {
seen[vendorDeviceIDField] = struct{}{}
}
} }
columns := make([]string, 0, len(seen)) columns := make([]string, 0, len(seen))
@@ -366,72 +374,14 @@ 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 {
if isHiddenField(key) {
continue
}
keys = append(keys, key) keys = append(keys, key)
} }
sort.Strings(keys) sort.Strings(keys)
rows := make([]fieldRow, 0, len(keys)) rows := make([]fieldRow, 0, len(keys))
if combinedVendorDeviceID := formatVendorDeviceID(object); combinedVendorDeviceID != "" {
rows = append(rows, fieldRow{Key: vendorDeviceIDField, Value: combinedVendorDeviceID})
}
for _, key := range keys { for _, key := range keys {
rows = append(rows, fieldRow{Key: key, Value: formatValue(object[key])}) rows = append(rows, fieldRow{Key: key, Value: formatValue(object[key])})
} }
@@ -468,9 +418,6 @@ func formatValue(value any) string {
func formatObjectValue(value map[string]any) string { func formatObjectValue(value map[string]any) string {
keys := make([]string, 0, len(value)) keys := make([]string, 0, len(value))
for key := range value { for key := range value {
if isHiddenField(key) {
continue
}
keys = append(keys, key) keys = append(keys, key)
} }
sort.Strings(keys) sort.Strings(keys)
@@ -509,9 +456,6 @@ func formatRowValue(column string, row map[string]any) string {
if column == "severity_icon" { if column == "severity_icon" {
return strings.TrimSpace(formatValue(row["severity"])) return strings.TrimSpace(formatValue(row["severity"]))
} }
if column == vendorDeviceIDField {
return formatVendorDeviceID(row)
}
return formatValue(row[column]) return formatValue(row[column])
} }
@@ -544,15 +488,6 @@ func canonicalSeverityLabel(raw, normalized string) string {
} }
} }
func formatVendorDeviceID(value map[string]any) string {
vendorID := strings.TrimSpace(formatValue(value["vendor_id"]))
deviceID := strings.TrimSpace(formatValue(value["device_id"]))
if vendorID == "" || deviceID == "" {
return ""
}
return vendorID + ":" + deviceID
}
func formatDate(value string) (string, bool) { func formatDate(value string) (string, bool) {
layouts := []struct { layouts := []struct {
layout string layout string
@@ -592,33 +527,10 @@ func titleFor(key string) string {
return strings.ReplaceAll(strings.Title(strings.ReplaceAll(key, "_", " ")), "Pcie", "PCIe") return strings.ReplaceAll(strings.Title(strings.ReplaceAll(key, "_", " ")), "Pcie", "PCIe")
} }
func isHiddenField(key string) bool {
if key == "vendor_id" || key == "device_id" {
return true
}
_, ok := hiddenFields[key]
return ok
}
func hasVendorDeviceID(value map[string]any) bool {
return formatVendorDeviceID(value) != ""
}
func hasSeverity(value map[string]any) bool { func hasSeverity(value map[string]any) bool {
return strings.TrimSpace(formatValue(value["severity"])) != "" return strings.TrimSpace(formatValue(value["severity"])) != ""
} }
func isHiddenTableField(section string, key string) bool {
if isHiddenField(key) {
return true
}
if section == "pcie_devices" && key == "device_class" {
return true
}
_, ok := hiddenTableFields[key]
return ok
}
func sortPCIeRows(rows []map[string]any) { func sortPCIeRows(rows []map[string]any) {
sort.SliceStable(rows, func(i, j int) bool { sort.SliceStable(rows, func(i, j int) bool {
left := []string{ left := []string{
@@ -627,7 +539,8 @@ func sortPCIeRows(rows []map[string]any) {
formatRowValue("vendor", rows[i]), formatRowValue("vendor", rows[i]),
formatRowValue("model", rows[i]), formatRowValue("model", rows[i]),
formatRowValue("serial_number", rows[i]), formatRowValue("serial_number", rows[i]),
formatRowValue(vendorDeviceIDField, rows[i]), formatRowValue("vendor_id", rows[i]),
formatRowValue("device_id", rows[i]),
formatRowValue("bdf", rows[i]), formatRowValue("bdf", rows[i]),
} }
right := []string{ right := []string{
@@ -636,7 +549,8 @@ func sortPCIeRows(rows []map[string]any) {
formatRowValue("vendor", rows[j]), formatRowValue("vendor", rows[j]),
formatRowValue("model", rows[j]), formatRowValue("model", rows[j]),
formatRowValue("serial_number", rows[j]), formatRowValue("serial_number", rows[j]),
formatRowValue(vendorDeviceIDField, rows[j]), formatRowValue("vendor_id", rows[j]),
formatRowValue("device_id", rows[j]),
formatRowValue("bdf", rows[j]), formatRowValue("bdf", rows[j]),
} }
+52 -17
View File
@@ -125,8 +125,11 @@ func TestCollectColumnsOrdersStatusThenLocationThenIdentity(t *testing.T) {
"vendor", "vendor",
"model", "model",
"serial_number", "serial_number",
"ven:dev", "vendor_id",
"device_id",
"firmware", "firmware",
"status_at_collection",
"status_checked_at",
"temperature_c", "temperature_c",
} }
@@ -170,7 +173,7 @@ func TestCollectColumnsOrdersCPUFields(t *testing.T) {
} }
} }
func TestRenderHTMLHidesStatusAtCollection(t *testing.T) { func TestRenderHTMLPreservesStatusMetadata(t *testing.T) {
snapshot := []byte(`{ snapshot := []byte(`{
"target_host": "hidden-field-host", "target_host": "hidden-field-host",
"hardware": { "hardware": {
@@ -198,18 +201,16 @@ func TestRenderHTMLHidesStatusAtCollection(t *testing.T) {
} }
text := string(html) text := string(html)
if strings.Contains(text, "status_at_collection") { if !strings.Contains(text, "status_at_collection") {
t.Fatalf("expected status_at_collection to be hidden from rendered output") t.Fatalf("expected status_at_collection to remain visible")
} }
if !strings.Contains(text, "<th>status_checked_at</th>") { if !strings.Contains(text, "<th>status_checked_at</th>") ||
t.Fatalf("expected status_checked_at to remain visible in object sections") !strings.Contains(text, `<th data-col="status_checked_at">status_checked_at</th>`) {
} t.Fatal("expected status_checked_at in both object and filterable table")
if strings.Contains(text, "<thead>\n <tr>\n <th>status_checked_at</th>") {
t.Fatalf("expected status_checked_at to be hidden from table headers")
} }
} }
func TestRenderHTMLCombinesVendorAndDeviceID(t *testing.T) { func TestRenderHTMLPreservesVendorAndDeviceID(t *testing.T) {
snapshot := []byte(`{ snapshot := []byte(`{
"target_host": "pci-host", "target_host": "pci-host",
"hardware": { "hardware": {
@@ -232,14 +233,14 @@ func TestRenderHTMLCombinesVendorAndDeviceID(t *testing.T) {
} }
text := string(html) text := string(html)
if !strings.Contains(text, "ven:dev") { if !strings.Contains(text, `<th data-col="vendor_id">vendor_id</th>`) || !strings.Contains(text, `<th data-col="device_id">device_id</th>`) {
t.Fatalf("expected combined vendor/device id column to be rendered") t.Fatalf("expected source vendor_id and device_id columns to be rendered")
} }
if !strings.Contains(text, "8086:1234") { if !strings.Contains(text, "8086") || !strings.Contains(text, "1234") {
t.Fatalf("expected vendor/device id value to be rendered as ven:dev") t.Fatalf("expected source vendor_id and device_id values to be rendered")
} }
if strings.Contains(text, "<th>vendor_id</th>") || strings.Contains(text, "<th>device_id</th>") { if strings.Contains(text, "ven:dev") || strings.Contains(text, "8086:1234") {
t.Fatalf("expected raw vendor_id and device_id columns to be hidden") t.Fatalf("expected no synthetic vendor/device field")
} }
} }
@@ -326,7 +327,7 @@ func TestRenderHTMLAddsSeverityFilterForEventLogs(t *testing.T) {
`<span class="status-badge severity-info" role="img" aria-label="Info" title="Info"></span>`, `<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>`, `<span class="status-badge severity-critical" role="img" aria-label="Critical" title="Critical"></span>`,
`<th data-col="severity">severity</th>`, `<th data-col="severity">severity</th>`,
"/static/view.js", "static/view.js",
} { } {
if !strings.Contains(text, needle) { if !strings.Contains(text, needle) {
t.Fatalf("expected rendered html to contain %q", needle) t.Fatalf("expected rendered html to contain %q", needle)
@@ -414,3 +415,37 @@ func TestRenderHTMLDisplaysHardwareContract210Fields(t *testing.T) {
} }
} }
} }
func TestRenderHTMLPreservesUnknownSensorSectionsAndMixedArrays(t *testing.T) {
snapshot := []byte(`{
"hardware": {
"sensors": {
"humidity": [{"percent": 41}],
"vendor_extension": "raw-sensor-value"
},
"future_array": [{"known": "object-value"}, "scalar-value", 17]
}
}`)
html, err := RenderHTML(snapshot, "Reanimator Chart")
if err != nil {
t.Fatalf("RenderHTML() error = %v", err)
}
text := string(html)
for _, want := range []string{"Sensors / Humidity", "percent", "41", "Sensors / Vendor Extension", "raw-sensor-value", "Future Array", "known: object-value", "scalar-value", "17"} {
if !strings.Contains(text, want) {
t.Fatalf("expected rendered HTML to preserve %q", want)
}
}
}
func TestRenderHTMLPreservesNonObjectHardwareValue(t *testing.T) {
html, err := RenderHTML([]byte(`{"hardware":"unparsed-hardware-value"}`), "Reanimator Chart")
if err != nil {
t.Fatalf("RenderHTML() error = %v", err)
}
text := string(html)
if !strings.Contains(text, "<th>hardware</th>") || !strings.Contains(text, "unparsed-hardware-value") {
t.Fatalf("expected non-object hardware value to remain visible: %s", text)
}
}
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<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">
</head> </head>
<body> <body>
<header class="page-header"> <header class="page-header">
@@ -15,7 +15,7 @@
<section class="upload-panel"> <section class="upload-panel">
<h2>Open Snapshot</h2> <h2>Open Snapshot</h2>
<p>Select a Reanimator JSON snapshot to render.</p> <p>Select a Reanimator JSON snapshot to render.</p>
<form method="post" action="/render" enctype="multipart/form-data"> <form method="post" action="render" enctype="multipart/form-data">
<label class="upload-dropzone" for="snapshot_file"> <label class="upload-dropzone" for="snapshot_file">
<input id="snapshot_file" name="snapshot_file" type="file" accept=".json,application/json" required> <input id="snapshot_file" name="snapshot_file" type="file" accept=".json,application/json" required>
<span class="upload-eyebrow">Standalone Mode</span> <span class="upload-eyebrow">Standalone Mode</span>
+42 -74
View File
@@ -1,11 +1,49 @@
{{ define "data-table" }}
{{ $table := . }}
<div class="table-block table-filterable">
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
{{ range .Columns }}
<th data-col="{{ . }}"{{ 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 $table.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>
<p class="table-filter-empty" hidden>No rows match the active filters.</p>
</div>
{{ end }}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<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> <script defer src="static/view.js"></script>
</head> </head>
<body> <body>
<header class="page-header"> <header class="page-header">
@@ -63,84 +101,14 @@
{{ end }} {{ end }}
{{ if eq .Kind "table" }} {{ if eq .Kind "table" }}
{{ $section := . }} {{ template "data-table" . }}
<div class="table-block table-filterable">
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
{{ range .Columns }}
<th data-col="{{ . }}"{{ 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 $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 }}" 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>
<p class="table-filter-empty" hidden>No rows match the active filters.</p>
</div>
{{ end }} {{ end }}
{{ if eq .Kind "grouped_tables" }} {{ if eq .Kind "grouped_tables" }}
{{ range .Groups }} {{ range .Groups }}
<div class="table-group"> <div class="table-group">
<h3>{{ .Title }}</h3> <h3>{{ .Title }}</h3>
{{ $group := . }} {{ template "data-table" . }}
<div class="table-block table-filterable">
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
{{ range .Columns }}
<th data-col="{{ . }}"{{ 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>
<p class="table-filter-empty" hidden>No rows match the active filters.</p>
</div>
</div> </div>
{{ end }} {{ end }}
{{ end }} {{ end }}