diff --git a/README.md b/README.md index 05889fe..d642aea 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/bible-local/architecture/api-surface.md b/bible-local/architecture/api-surface.md index 4b52ac7..7389a43 100644 --- a/bible-local/architecture/api-surface.md +++ b/bible-local/architecture/api-surface.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 diff --git a/viewer/handler.go b/viewer/handler.go index 75d85f6..9edff2a 100644 --- a/viewer/handler.go +++ b/viewer/handler.go @@ -12,21 +12,20 @@ import ( ) type HandlerOptions struct { - Title string - Standalone bool + Title string } +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") diff --git a/viewer/handler_test.go b/viewer/handler_test.go index e7d8e01..eb7c9e8 100644 --- a/viewer/handler_test.go +++ b/viewer/handler_test.go @@ -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) + } +} diff --git a/viewer/model.go b/viewer/model.go index 0675cf9..8dac9be 100644 --- a/viewer/model.go +++ b/viewer/model.go @@ -13,14 +13,13 @@ type pageData struct { } type sectionView struct { - ID string - Title string - Kind string - Rows []fieldRow - Columns []string - Items []tableRow - Groups []tableGroupView - SeverityOptions []severityOption + ID string + Title string + Kind string + Rows []fieldRow + Columns []string + Items []tableRow + Groups []tableGroupView } 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 + Title string + Columns []string + Items []tableRow } diff --git a/viewer/render.go b/viewer/render.go index 2601a13..2ce26a3 100644 --- a/viewer/render.go +++ b/viewer/render.go @@ -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" { - continue - } - if isHiddenField(key) { - continue + if _, handledAsSections := root[key].(map[string]any); handledAsSections { + 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 + } + 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) } - section := buildTableSection(key, items) - section.ID = "sensors-" + key - section.Title = "Sensors / " + titleFor(key) - 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]), } diff --git a/viewer/render_test.go b/viewer/render_test.go index 1871517..b274f5b 100644 --- a/viewer/render_test.go +++ b/viewer/render_test.go @@ -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, "status_checked_at") { - t.Fatalf("expected status_checked_at to remain visible in object sections") - } - if strings.Contains(text, "\n \n status_checked_at") { - t.Fatalf("expected status_checked_at to be hidden from table headers") + if !strings.Contains(text, "status_checked_at") || + !strings.Contains(text, `status_checked_at`) { + 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, `vendor_id`) || !strings.Contains(text, `device_id`) { + 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, "vendor_id") || strings.Contains(text, "device_id") { - 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) { ``, ``, `severity`, - "/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, "hardware") || !strings.Contains(text, "unparsed-hardware-value") { + t.Fatalf("expected non-object hardware value to remain visible: %s", text) + } +} diff --git a/web/templates/upload.html b/web/templates/upload.html index 3c91724..aaa6faf 100644 --- a/web/templates/upload.html +++ b/web/templates/upload.html @@ -4,7 +4,7 @@ {{ .Title }} - +