refactor(viewer): consolidate rendering and harden uploads
This commit is contained in:
@@ -8,7 +8,7 @@ It is intended to be embedded into other Go applications that collect audit data
|
||||
|
||||
## 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
|
||||
|
||||
@@ -66,4 +66,4 @@ make run
|
||||
|
||||
## 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,7 +6,8 @@ The package is intended to be embedded by other Go applications.
|
||||
|
||||
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.NewStandaloneHandler(viewer.HandlerOptions{...}) http.Handler`
|
||||
|
||||
@@ -32,6 +33,10 @@ Embedded handler endpoints:
|
||||
- `GET /healthz` - basic process health
|
||||
- `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
|
||||
|
||||
- No multi-product navigation
|
||||
|
||||
+14
-10
@@ -13,20 +13,19 @@ import (
|
||||
|
||||
type HandlerOptions struct {
|
||||
Title string
|
||||
Standalone bool
|
||||
}
|
||||
|
||||
const maxSnapshotRequestBytes int64 = 64 << 20
|
||||
|
||||
func NewHandler(opts HandlerOptions) http.Handler {
|
||||
opts.Standalone = false
|
||||
return newHandler(opts)
|
||||
return newHandler(opts, false)
|
||||
}
|
||||
|
||||
func NewStandaloneHandler(opts HandlerOptions) http.Handler {
|
||||
opts.Standalone = true
|
||||
return newHandler(opts)
|
||||
return newHandler(opts, true)
|
||||
}
|
||||
|
||||
func newHandler(opts HandlerOptions) http.Handler {
|
||||
func newHandler(opts HandlerOptions, standalone bool) http.Handler {
|
||||
title := strings.TrimSpace(opts.Title)
|
||||
if title == "" {
|
||||
title = "Reanimator Chart"
|
||||
@@ -43,7 +42,7 @@ func newHandler(opts HandlerOptions) http.Handler {
|
||||
html []byte
|
||||
err error
|
||||
)
|
||||
if opts.Standalone {
|
||||
if standalone {
|
||||
html, err = web.RenderUpload(pageData{Title: title})
|
||||
} else {
|
||||
html, err = RenderHTML(nil, title)
|
||||
@@ -56,6 +55,7 @@ func newHandler(opts HandlerOptions) http.Handler {
|
||||
_, _ = w.Write(html)
|
||||
})
|
||||
mux.HandleFunc("POST /render", func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxSnapshotRequestBytes)
|
||||
payload, err := readSnapshotPayload(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
@@ -64,7 +64,7 @@ func newHandler(opts HandlerOptions) http.Handler {
|
||||
|
||||
page, err := buildPageData([]byte(payload), title, RenderOptions{})
|
||||
if err != nil {
|
||||
if opts.Standalone {
|
||||
if standalone {
|
||||
html, renderErr := web.RenderUpload(pageData{
|
||||
Title: title,
|
||||
Error: err.Error(),
|
||||
@@ -105,8 +105,12 @@ func readSnapshotPayload(r *http.Request) (string, error) {
|
||||
}
|
||||
return string(body), nil
|
||||
case "multipart/form-data":
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
return "", fmt.Errorf("parse multipart form: %w", err)
|
||||
parseErr := r.ParseMultipartForm(32 << 20)
|
||||
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")
|
||||
|
||||
@@ -101,3 +101,24 @@ func TestStaticJSUsesScriptContentType(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ type sectionView struct {
|
||||
Columns []string
|
||||
Items []tableRow
|
||||
Groups []tableGroupView
|
||||
SeverityOptions []severityOption
|
||||
}
|
||||
|
||||
type fieldRow struct {
|
||||
@@ -29,20 +28,12 @@ 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
|
||||
SeverityOptions []severityOption
|
||||
}
|
||||
|
||||
type severityOption struct {
|
||||
Value string
|
||||
Label string
|
||||
}
|
||||
|
||||
+61
-147
@@ -44,16 +44,6 @@ var sectionTitles = map[string]string{
|
||||
|
||||
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{
|
||||
"severity_icon",
|
||||
"status",
|
||||
@@ -69,7 +59,8 @@ var commonPreferredColumns = []string{
|
||||
"product_name",
|
||||
"part_number",
|
||||
"serial_number",
|
||||
vendorDeviceIDField,
|
||||
"vendor_id",
|
||||
"device_id",
|
||||
"firmware",
|
||||
"version",
|
||||
"bdf",
|
||||
@@ -136,9 +127,6 @@ func buildMeta(root map[string]any) []fieldRow {
|
||||
rows := make([]fieldRow, 0)
|
||||
used := make(map[string]struct{})
|
||||
for _, key := range preferredMetaKeys {
|
||||
if isHiddenField(key) {
|
||||
continue
|
||||
}
|
||||
if value, ok := root[key]; ok {
|
||||
rows = append(rows, fieldRow{Key: key, Value: formatValue(value)})
|
||||
used[key] = struct{}{}
|
||||
@@ -147,10 +135,9 @@ func buildMeta(root map[string]any) []fieldRow {
|
||||
extraKeys := make([]string, 0)
|
||||
for key := range root {
|
||||
if key == "hardware" {
|
||||
if _, handledAsSections := root[key].(map[string]any); handledAsSections {
|
||||
continue
|
||||
}
|
||||
if isHiddenField(key) {
|
||||
continue
|
||||
}
|
||||
if _, ok := used[key]; ok {
|
||||
continue
|
||||
@@ -208,6 +195,14 @@ func buildSection(key string, value any) []sectionView {
|
||||
Rows: buildFieldRows(typed),
|
||||
}}
|
||||
case []any:
|
||||
if !allObjectItems(typed) {
|
||||
return []sectionView{{
|
||||
ID: key,
|
||||
Title: titleFor(key),
|
||||
Kind: "object",
|
||||
Rows: buildArrayRows(typed),
|
||||
}}
|
||||
}
|
||||
if key == "pcie_devices" {
|
||||
return []sectionView{buildPCIeSection(typed)}
|
||||
}
|
||||
@@ -225,24 +220,57 @@ func buildSection(key string, value 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"} {
|
||||
value, ok := sensors[key]
|
||||
if !ok {
|
||||
continue
|
||||
if _, ok := sensors[key]; ok {
|
||||
orderedKeys = append(orderedKeys, key)
|
||||
used[key] = struct{}{}
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
section := buildTableSection(key, items)
|
||||
section.ID = "sensors-" + key
|
||||
section.Title = "Sensors / " + titleFor(key)
|
||||
var extraKeys []string
|
||||
for key := range sensors {
|
||||
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)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
@@ -258,23 +286,13 @@ func buildTableSection(key string, items []any) sectionView {
|
||||
for _, column := range columns {
|
||||
cells[column] = formatRowValue(column, row)
|
||||
}
|
||||
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,
|
||||
SeverityOptions: collectSeverityOptions(columns, rows),
|
||||
}
|
||||
return sectionView{ID: key, Title: titleFor(key), Kind: "table", Columns: columns, Items: tableRows}
|
||||
}
|
||||
|
||||
func buildPCIeSection(items []any) sectionView {
|
||||
@@ -311,18 +329,11 @@ func buildPCIeSection(items []any) sectionView {
|
||||
cells[column] = formatRowValue(column, row)
|
||||
}
|
||||
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,
|
||||
SeverityOptions: collectSeverityOptions(columns, rows),
|
||||
})
|
||||
groups = append(groups, tableGroupView{Title: className, Columns: columns, Items: items})
|
||||
}
|
||||
|
||||
return sectionView{
|
||||
@@ -337,7 +348,7 @@ func collectColumns(section string, rows []map[string]any) []string {
|
||||
seen := make(map[string]struct{})
|
||||
for _, row := range rows {
|
||||
for key := range row {
|
||||
if isHiddenTableField(section, key) {
|
||||
if section == "pcie_devices" && key == "device_class" {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
@@ -345,9 +356,6 @@ func collectColumns(section string, rows []map[string]any) []string {
|
||||
if hasSeverity(row) {
|
||||
seen["severity_icon"] = struct{}{}
|
||||
}
|
||||
if hasVendorDeviceID(row) {
|
||||
seen[vendorDeviceIDField] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
columns := make([]string, 0, len(seen))
|
||||
@@ -366,72 +374,14 @@ 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 {
|
||||
if isHiddenField(key) {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
rows := make([]fieldRow, 0, len(keys))
|
||||
if combinedVendorDeviceID := formatVendorDeviceID(object); combinedVendorDeviceID != "" {
|
||||
rows = append(rows, fieldRow{Key: vendorDeviceIDField, Value: combinedVendorDeviceID})
|
||||
}
|
||||
for _, key := range keys {
|
||||
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 {
|
||||
keys := make([]string, 0, len(value))
|
||||
for key := range value {
|
||||
if isHiddenField(key) {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
@@ -509,9 +456,6 @@ 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])
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
layouts := []struct {
|
||||
layout string
|
||||
@@ -592,33 +527,10 @@ func titleFor(key string) string {
|
||||
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 {
|
||||
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) {
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
left := []string{
|
||||
@@ -627,7 +539,8 @@ func sortPCIeRows(rows []map[string]any) {
|
||||
formatRowValue("vendor", rows[i]),
|
||||
formatRowValue("model", 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]),
|
||||
}
|
||||
right := []string{
|
||||
@@ -636,7 +549,8 @@ func sortPCIeRows(rows []map[string]any) {
|
||||
formatRowValue("vendor", rows[j]),
|
||||
formatRowValue("model", 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]),
|
||||
}
|
||||
|
||||
|
||||
+52
-17
@@ -125,8 +125,11 @@ func TestCollectColumnsOrdersStatusThenLocationThenIdentity(t *testing.T) {
|
||||
"vendor",
|
||||
"model",
|
||||
"serial_number",
|
||||
"ven:dev",
|
||||
"vendor_id",
|
||||
"device_id",
|
||||
"firmware",
|
||||
"status_at_collection",
|
||||
"status_checked_at",
|
||||
"temperature_c",
|
||||
}
|
||||
|
||||
@@ -170,7 +173,7 @@ func TestCollectColumnsOrdersCPUFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHTMLHidesStatusAtCollection(t *testing.T) {
|
||||
func TestRenderHTMLPreservesStatusMetadata(t *testing.T) {
|
||||
snapshot := []byte(`{
|
||||
"target_host": "hidden-field-host",
|
||||
"hardware": {
|
||||
@@ -198,18 +201,16 @@ func TestRenderHTMLHidesStatusAtCollection(t *testing.T) {
|
||||
}
|
||||
|
||||
text := string(html)
|
||||
if strings.Contains(text, "status_at_collection") {
|
||||
t.Fatalf("expected status_at_collection to be hidden from rendered output")
|
||||
if !strings.Contains(text, "status_at_collection") {
|
||||
t.Fatalf("expected status_at_collection to remain visible")
|
||||
}
|
||||
if !strings.Contains(text, "<th>status_checked_at</th>") {
|
||||
t.Fatalf("expected status_checked_at to remain visible in object sections")
|
||||
}
|
||||
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")
|
||||
if !strings.Contains(text, "<th>status_checked_at</th>") ||
|
||||
!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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHTMLCombinesVendorAndDeviceID(t *testing.T) {
|
||||
func TestRenderHTMLPreservesVendorAndDeviceID(t *testing.T) {
|
||||
snapshot := []byte(`{
|
||||
"target_host": "pci-host",
|
||||
"hardware": {
|
||||
@@ -232,14 +233,14 @@ func TestRenderHTMLCombinesVendorAndDeviceID(t *testing.T) {
|
||||
}
|
||||
|
||||
text := string(html)
|
||||
if !strings.Contains(text, "ven:dev") {
|
||||
t.Fatalf("expected combined vendor/device id column to be rendered")
|
||||
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 source vendor_id and device_id columns to be rendered")
|
||||
}
|
||||
if !strings.Contains(text, "8086:1234") {
|
||||
t.Fatalf("expected vendor/device id value to be rendered as ven:dev")
|
||||
if !strings.Contains(text, "8086") || !strings.Contains(text, "1234") {
|
||||
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>") {
|
||||
t.Fatalf("expected raw vendor_id and device_id columns to be hidden")
|
||||
if strings.Contains(text, "ven:dev") || strings.Contains(text, "8086:1234") {
|
||||
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-critical" role="img" aria-label="Critical" title="Critical"></span>`,
|
||||
`<th data-col="severity">severity</th>`,
|
||||
"/static/view.js",
|
||||
"static/view.js",
|
||||
} {
|
||||
if !strings.Contains(text, 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ .Title }}</title>
|
||||
<link rel="stylesheet" href="/static/view.css">
|
||||
<link rel="stylesheet" href="static/view.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="page-header">
|
||||
@@ -15,7 +15,7 @@
|
||||
<section class="upload-panel">
|
||||
<h2>Open Snapshot</h2>
|
||||
<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">
|
||||
<input id="snapshot_file" name="snapshot_file" type="file" accept=".json,application/json" required>
|
||||
<span class="upload-eyebrow">Standalone Mode</span>
|
||||
|
||||
+42
-74
@@ -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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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>
|
||||
<link rel="stylesheet" href="static/view.css">
|
||||
<script defer src="static/view.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="page-header">
|
||||
@@ -63,84 +101,14 @@
|
||||
{{ end }}
|
||||
|
||||
{{ if eq .Kind "table" }}
|
||||
{{ $section := . }}
|
||||
<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>
|
||||
{{ template "data-table" . }}
|
||||
{{ end }}
|
||||
|
||||
{{ if eq .Kind "grouped_tables" }}
|
||||
{{ range .Groups }}
|
||||
<div class="table-group">
|
||||
<h3>{{ .Title }}</h3>
|
||||
{{ $group := . }}
|
||||
<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>
|
||||
{{ template "data-table" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
Reference in New Issue
Block a user