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
|
## 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,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
|
||||||
|
|||||||
+15
-11
@@ -12,21 +12,20 @@ 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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+10
-19
@@ -13,14 +13,13 @@ type pageData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type sectionView struct {
|
type sectionView struct {
|
||||||
ID string
|
ID string
|
||||||
Title string
|
Title string
|
||||||
Kind string
|
Kind string
|
||||||
Rows []fieldRow
|
Rows []fieldRow
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-150
@@ -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" {
|
||||||
continue
|
if _, handledAsSections := root[key].(map[string]any); handledAsSections {
|
||||||
}
|
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 {
|
var extraKeys []string
|
||||||
continue
|
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
|
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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 }}
|
||||||
|
|||||||
Reference in New Issue
Block a user