Split embedded and standalone chart surfaces
This commit is contained in:
5
Makefile
Normal file
5
Makefile
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
run:
|
||||||
|
go run ./cmd/reanimator-chart
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
10
README.md
10
README.md
@@ -4,6 +4,16 @@
|
|||||||
|
|
||||||
It is intended to be embedded into other Go applications that collect audit data in different ways and want a consistent HTML view of the resulting Reanimator JSON.
|
It is intended to be embedded into other Go applications that collect audit data in different ways and want a consistent HTML view of the resulting Reanimator JSON.
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
For embedding instructions, see [`docs/embedding.md`](/Users/mchusavitin/Documents/git/reanimator/chart/docs/embedding.md).
|
||||||
|
|
||||||
|
Standalone local run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
## Product Scope
|
## Product Scope
|
||||||
|
|
||||||
- render a Reanimator JSON snapshot as HTML
|
- render a Reanimator JSON snapshot as HTML
|
||||||
|
|||||||
@@ -4,30 +4,41 @@
|
|||||||
|
|
||||||
The package is intended to be embedded by other Go applications.
|
The package is intended to be embedded by other Go applications.
|
||||||
|
|
||||||
Expected package shape:
|
Current package shape:
|
||||||
|
|
||||||
- `viewer.RenderHTML(snapshot []byte) ([]byte, error)`
|
- `viewer.RenderHTML(snapshot []byte) ([]byte, error)`
|
||||||
- `viewer.NewHandler(...) http.Handler`
|
- `viewer.NewHandler(viewer.HandlerOptions{...}) http.Handler`
|
||||||
|
- `viewer.NewStandaloneHandler(viewer.HandlerOptions{...}) http.Handler`
|
||||||
|
|
||||||
Exact signatures may change during implementation, but the integration model is fixed:
|
Integration model:
|
||||||
|
|
||||||
- embedding app provides the JSON
|
- embedding app provides the JSON
|
||||||
- chart renders HTML
|
- chart renders HTML
|
||||||
|
- embedded mode does not own snapshot selection UI
|
||||||
|
- standalone mode may provide a local upload screen on `GET /`
|
||||||
|
|
||||||
## Expected Runtime Endpoints
|
## Expected Runtime Endpoints
|
||||||
|
|
||||||
These endpoints are expected for the standalone binary only:
|
These endpoints are expected for the standalone binary only:
|
||||||
|
|
||||||
- `GET /` - viewer page
|
- `GET /` - upload page
|
||||||
- `POST /render` - accept one Reanimator JSON payload and return rendered HTML or JSON render result
|
- `POST /render` - accept one Reanimator JSON payload and return rendered HTML
|
||||||
- `GET /healthz` - basic process health
|
- `GET /healthz` - basic process health
|
||||||
|
|
||||||
|
Embedded handler endpoints:
|
||||||
|
|
||||||
|
- `GET /` - empty viewer shell with no upload controls
|
||||||
|
- `POST /render` - accept one Reanimator JSON payload and return rendered HTML
|
||||||
|
- `GET /healthz` - basic process health
|
||||||
|
- `GET /static/...` - embedded static assets
|
||||||
|
|
||||||
## UI Route Rules
|
## UI Route Rules
|
||||||
|
|
||||||
- No multi-product navigation
|
- No multi-product navigation
|
||||||
- No upload wizard with preview/confirm/execute stages
|
- No upload wizard with preview/confirm/execute stages
|
||||||
- No collector/API-connect workflow
|
- No collector/API-connect workflow
|
||||||
- No background job polling API
|
- No background job polling API
|
||||||
|
- Embedded mode must not force a file picker into host applications
|
||||||
|
|
||||||
## Response Rules
|
## Response Rules
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Decision: Embedded And Standalone Surfaces Are Separate
|
||||||
|
|
||||||
|
**Date:** 2026-03-15
|
||||||
|
**Status:** active
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`chart` has two legitimate use cases:
|
||||||
|
|
||||||
|
- embedded inside another Go application that already knows how to locate and pass a snapshot
|
||||||
|
- run as a standalone local tool for manual inspection of a JSON file
|
||||||
|
|
||||||
|
These two use cases need different first-screen behavior.
|
||||||
|
|
||||||
|
The embedded case must stay clean and must not force upload controls into a host application's UI.
|
||||||
|
|
||||||
|
The standalone case still needs a practical way to open a snapshot from the browser.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The project exposes two handler surfaces:
|
||||||
|
|
||||||
|
- `viewer.NewHandler(...)` for embedded mode
|
||||||
|
- `viewer.NewStandaloneHandler(...)` for standalone mode
|
||||||
|
|
||||||
|
Embedded mode renders the viewer without upload controls on `GET /`.
|
||||||
|
|
||||||
|
Standalone mode renders a first-screen upload form on `GET /` and posts the selected snapshot to `/render`.
|
||||||
|
|
||||||
|
Both modes share the same read-only snapshot renderer and the same `/render` behavior once snapshot bytes are provided.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Host applications can embed `chart` without inheriting standalone upload UI.
|
||||||
|
- The standalone binary remains usable without requiring an external host application.
|
||||||
|
- Future UI work must keep embedded and standalone entry surfaces separate.
|
||||||
@@ -14,7 +14,7 @@ func main() {
|
|||||||
addr = ":8080"
|
addr = ":8080"
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := viewer.NewHandler(viewer.HandlerOptions{
|
handler := viewer.NewStandaloneHandler(viewer.HandlerOptions{
|
||||||
Title: "Reanimator Chart",
|
Title: "Reanimator Chart",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
114
docs/embedding.md
Normal file
114
docs/embedding.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Embedding Chart
|
||||||
|
|
||||||
|
`chart` is intended to be embedded into another Go application that already obtains a Reanimator JSON snapshot.
|
||||||
|
|
||||||
|
The embedding application remains the source of truth for:
|
||||||
|
|
||||||
|
- where the JSON comes from
|
||||||
|
- when the snapshot is collected
|
||||||
|
- how routing, auth, and navigation work
|
||||||
|
|
||||||
|
`chart` only renders one snapshot as a read-only HTML view.
|
||||||
|
|
||||||
|
## Integration Modes
|
||||||
|
|
||||||
|
There are two supported integration styles:
|
||||||
|
|
||||||
|
1. Render HTML directly from bytes:
|
||||||
|
|
||||||
|
```go
|
||||||
|
html, err := viewer.RenderHTML(snapshotBytes, "Hardware Snapshot")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("render chart html: %w", err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Mount the embedded viewer HTTP handler:
|
||||||
|
|
||||||
|
```go
|
||||||
|
mux.Handle("/chart/", http.StripPrefix("/chart", viewer.NewHandler(viewer.HandlerOptions{
|
||||||
|
Title: "Hardware Snapshot",
|
||||||
|
})))
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `viewer.NewHandler(...)` inside another application.
|
||||||
|
|
||||||
|
Use `viewer.NewStandaloneHandler(...)` only for the standalone `chart` binary or for a dedicated upload page where the viewer itself owns the first-screen file picker.
|
||||||
|
|
||||||
|
## Embedded Handler Behavior
|
||||||
|
|
||||||
|
`viewer.NewHandler(...)` is the embedded mode.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
|
||||||
|
- `GET /` renders the viewer shell with no upload controls
|
||||||
|
- `POST /render` accepts one snapshot and returns rendered HTML
|
||||||
|
- `GET /healthz` returns process health
|
||||||
|
- `GET /static/...` serves embedded CSS assets
|
||||||
|
|
||||||
|
The embedded handler does not show a file-upload screen on `GET /`.
|
||||||
|
|
||||||
|
This is intentional: the host application decides how the snapshot is selected and passed in.
|
||||||
|
|
||||||
|
## Snapshot Submission
|
||||||
|
|
||||||
|
`POST /render` accepts:
|
||||||
|
|
||||||
|
- raw `application/json`
|
||||||
|
- `multipart/form-data` with `snapshot_file`
|
||||||
|
|
||||||
|
Example with raw JSON:
|
||||||
|
|
||||||
|
```go
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "/chart/render", bytes.NewReader(snapshotBytes))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
```
|
||||||
|
|
||||||
|
Example with server-side render without an HTTP round-trip:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func chartPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
snapshotBytes := loadSnapshotForRequest(r)
|
||||||
|
|
||||||
|
html, err := viewer.RenderHTML(snapshotBytes, "Hardware Snapshot")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to render snapshot", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write(html)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended Host Responsibilities
|
||||||
|
|
||||||
|
When embedding `chart`, keep these responsibilities in the host application:
|
||||||
|
|
||||||
|
- authentication and authorization
|
||||||
|
- selecting the target host or audit record
|
||||||
|
- loading snapshot bytes from disk, database, object storage, or another service
|
||||||
|
- page layout outside the chart surface
|
||||||
|
- links back to the rest of the host application
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Input must be one Reanimator JSON snapshot.
|
||||||
|
- `chart` is read-only.
|
||||||
|
- `chart` preserves unknown fields where possible and does not compute new health summaries.
|
||||||
|
- Presentation formatting is allowed, but payload semantics must not change.
|
||||||
|
|
||||||
|
## Standalone Binary
|
||||||
|
|
||||||
|
The standalone binary uses `viewer.NewStandaloneHandler(...)`.
|
||||||
|
|
||||||
|
That mode adds a first-screen upload form on `GET /` for local manual use.
|
||||||
|
|
||||||
|
Local run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run
|
||||||
|
```
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
package viewer
|
package viewer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -9,10 +12,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type HandlerOptions struct {
|
type HandlerOptions struct {
|
||||||
Title string
|
Title string
|
||||||
|
Standalone bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(opts HandlerOptions) http.Handler {
|
func NewHandler(opts HandlerOptions) http.Handler {
|
||||||
|
opts.Standalone = false
|
||||||
|
return newHandler(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStandaloneHandler(opts HandlerOptions) http.Handler {
|
||||||
|
opts.Standalone = true
|
||||||
|
return newHandler(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHandler(opts HandlerOptions) http.Handler {
|
||||||
title := strings.TrimSpace(opts.Title)
|
title := strings.TrimSpace(opts.Title)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = "Reanimator Chart"
|
title = "Reanimator Chart"
|
||||||
@@ -25,7 +39,15 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
_, _ = w.Write([]byte("ok"))
|
_, _ = w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||||
html, err := RenderHTML(nil, title)
|
var (
|
||||||
|
html []byte
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if opts.Standalone {
|
||||||
|
html, err = web.RenderUpload(pageData{Title: title})
|
||||||
|
} else {
|
||||||
|
html, err = RenderHTML(nil, title)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -34,31 +56,28 @@ 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) {
|
||||||
var payload string
|
payload, err := readSnapshotPayload(r)
|
||||||
if strings.Contains(r.Header.Get("Content-Type"), "application/json") {
|
if err != nil {
|
||||||
body, err := io.ReadAll(r.Body)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
if err != nil {
|
return
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
payload = string(body)
|
|
||||||
} else {
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
payload = r.FormValue("snapshot")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
page, err := buildPageData([]byte(payload), title)
|
page, err := buildPageData([]byte(payload), title)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
page = pageData{
|
if opts.Standalone {
|
||||||
Title: title,
|
html, renderErr := web.RenderUpload(pageData{
|
||||||
Error: err.Error(),
|
Title: title,
|
||||||
InputJSON: prettyJSON(payload),
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
if renderErr != nil {
|
||||||
|
http.Error(w, renderErr.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write(html)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} else {
|
page = pageData{Title: title, Error: err.Error()}
|
||||||
page.InputJSON = prettyJSON(payload)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html, renderErr := web.Render(page)
|
html, renderErr := web.Render(page)
|
||||||
@@ -71,3 +90,51 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
})
|
})
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readSnapshotPayload(r *http.Request) (string, error) {
|
||||||
|
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse content type: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mediaType {
|
||||||
|
case "application/json":
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read request body: %w", err)
|
||||||
|
}
|
||||||
|
return string(body), nil
|
||||||
|
case "multipart/form-data":
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
return "", fmt.Errorf("parse multipart form: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := readSnapshotFile(r, "snapshot_file")
|
||||||
|
if err == nil && strings.TrimSpace(payload) != "" {
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, http.ErrMissingFile) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return r.FormValue("snapshot"), nil
|
||||||
|
default:
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
return "", fmt.Errorf("parse form: %w", err)
|
||||||
|
}
|
||||||
|
return r.FormValue("snapshot"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSnapshotFile(r *http.Request, field string) (string, error) {
|
||||||
|
file, _, err := r.FormFile(field)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read uploaded file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read uploaded file contents: %w", err)
|
||||||
|
}
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
|||||||
84
viewer/handler_test.go
Normal file
84
viewer/handler_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package viewer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderAcceptsMultipartSnapshotFile(t *testing.T) {
|
||||||
|
handler := NewStandaloneHandler(HandlerOptions{Title: "Reanimator Chart"})
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
part, err := writer.CreateFormFile("snapshot_file", "snapshot.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFormFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot := `{
|
||||||
|
"target_host": "upload-host",
|
||||||
|
"hardware": {
|
||||||
|
"board": {
|
||||||
|
"serial_number": "BOARD-002"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
if _, err := part.Write([]byte(snapshot)); err != nil {
|
||||||
|
t.Fatalf("Write() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("Close() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/render", &body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := rec.Body.String()
|
||||||
|
for _, needle := range []string{"upload-host", "BOARD-002", "Board"} {
|
||||||
|
if !strings.Contains(response, needle) {
|
||||||
|
t.Fatalf("expected response to contain %q", needle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbeddedHandlerRootHasNoUploadForm(t *testing.T) {
|
||||||
|
handler := NewHandler(HandlerOptions{Title: "Reanimator Chart"})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "multipart/form-data") {
|
||||||
|
t.Fatalf("expected embedded handler root to omit upload form")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStandaloneHandlerRootShowsUploadForm(t *testing.T) {
|
||||||
|
handler := NewStandaloneHandler(HandlerOptions{Title: "Reanimator Chart"})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "multipart/form-data") || !strings.Contains(body, "snapshot_file") {
|
||||||
|
t.Fatalf("expected standalone handler root to include upload form")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ type pageData struct {
|
|||||||
Title string
|
Title string
|
||||||
HasSnapshot bool
|
HasSnapshot bool
|
||||||
Error string
|
Error string
|
||||||
InputJSON string
|
|
||||||
Meta []fieldRow
|
Meta []fieldRow
|
||||||
Sections []sectionView
|
Sections []sectionView
|
||||||
}
|
}
|
||||||
@@ -16,6 +15,7 @@ type sectionView struct {
|
|||||||
Rows []fieldRow
|
Rows []fieldRow
|
||||||
Columns []string
|
Columns []string
|
||||||
Items []tableRow
|
Items []tableRow
|
||||||
|
Groups []tableGroupView
|
||||||
}
|
}
|
||||||
|
|
||||||
type fieldRow struct {
|
type fieldRow struct {
|
||||||
@@ -28,3 +28,9 @@ type tableRow struct {
|
|||||||
Cells map[string]string
|
Cells map[string]string
|
||||||
RawCells map[string]any
|
RawCells map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type tableGroupView struct {
|
||||||
|
Title string
|
||||||
|
Columns []string
|
||||||
|
Items []tableRow
|
||||||
|
}
|
||||||
|
|||||||
284
viewer/render.go
284
viewer/render.go
@@ -1,11 +1,11 @@
|
|||||||
package viewer
|
package viewer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"reanimator/chart/web"
|
"reanimator/chart/web"
|
||||||
)
|
)
|
||||||
@@ -38,17 +38,47 @@ 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{
|
||||||
|
"status",
|
||||||
|
"slot",
|
||||||
|
"location",
|
||||||
|
"vendor",
|
||||||
|
"manufacturer",
|
||||||
|
"device_class",
|
||||||
|
"type",
|
||||||
|
"device_name",
|
||||||
|
"name",
|
||||||
|
"model",
|
||||||
|
"product_name",
|
||||||
|
"part_number",
|
||||||
|
"serial_number",
|
||||||
|
vendorDeviceIDField,
|
||||||
|
"firmware",
|
||||||
|
"version",
|
||||||
|
"bdf",
|
||||||
|
}
|
||||||
|
|
||||||
var preferredColumns = map[string][]string{
|
var preferredColumns = map[string][]string{
|
||||||
"firmware": {"device_name", "version"},
|
"firmware": {"device_name", "version"},
|
||||||
"cpus": {"socket", "model", "manufacturer", "status"},
|
"cpus": {"model", "clock", "cores", "threads", "l1", "l2", "l3", "microcode", "socket"},
|
||||||
"memory": {"slot", "location", "serial_number", "part_number", "size_mb", "status"},
|
"memory": {"part_number", "serial_number", "slot"},
|
||||||
"storage": {"slot", "type", "model", "serial_number", "firmware", "size_gb", "status"},
|
"storage": {"type", "model", "serial_number", "firmware", "size_gb", "slot"},
|
||||||
"pcie_devices": {"slot", "bdf", "device_class", "manufacturer", "model", "serial_number", "status"},
|
"pcie_devices": {"device_class", "manufacturer", "model", "serial_number", "slot", "bdf"},
|
||||||
"power_supplies": {"slot", "vendor", "model", "serial_number", "part_number", "status"},
|
"power_supplies": {"vendor", "model", "part_number", "serial_number", "slot"},
|
||||||
"fans": {"name", "location", "rpm", "status"},
|
"fans": {"name", "rpm"},
|
||||||
"power": {"name", "location", "voltage_v", "current_a", "power_w", "status"},
|
"power": {"name", "voltage_v", "current_a", "power_w"},
|
||||||
"temperatures": {"name", "location", "celsius", "threshold_warning_celsius", "threshold_critical_celsius", "status"},
|
"temperatures": {"name", "celsius", "threshold_warning_celsius", "threshold_critical_celsius"},
|
||||||
"other": {"name", "location", "value", "unit", "status"},
|
"other": {"name", "value", "unit"},
|
||||||
}
|
}
|
||||||
|
|
||||||
func RenderHTML(snapshot []byte, title string) ([]byte, error) {
|
func RenderHTML(snapshot []byte, title string) ([]byte, error) {
|
||||||
@@ -71,7 +101,6 @@ func buildPageData(snapshot []byte, title string) (pageData, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
page.HasSnapshot = true
|
page.HasSnapshot = true
|
||||||
page.InputJSON = strings.TrimSpace(string(snapshot))
|
|
||||||
page.Meta = buildMeta(root)
|
page.Meta = buildMeta(root)
|
||||||
page.Sections = buildSections(root)
|
page.Sections = buildSections(root)
|
||||||
return page, nil
|
return page, nil
|
||||||
@@ -81,6 +110,9 @@ 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{}{}
|
||||||
@@ -91,6 +123,9 @@ func buildMeta(root map[string]any) []fieldRow {
|
|||||||
if key == "hardware" {
|
if key == "hardware" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if isHiddenField(key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, ok := used[key]; ok {
|
if _, ok := used[key]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -147,6 +182,9 @@ func buildSection(key string, value any) []sectionView {
|
|||||||
Rows: buildFieldRows(typed),
|
Rows: buildFieldRows(typed),
|
||||||
}}
|
}}
|
||||||
case []any:
|
case []any:
|
||||||
|
if key == "pcie_devices" {
|
||||||
|
return []sectionView{buildPCIeSection(typed)}
|
||||||
|
}
|
||||||
return []sectionView{buildTableSection(key, typed)}
|
return []sectionView{buildTableSection(key, typed)}
|
||||||
default:
|
default:
|
||||||
return []sectionView{{
|
return []sectionView{{
|
||||||
@@ -192,7 +230,7 @@ func buildTableSection(key string, items []any) sectionView {
|
|||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
cells := make(map[string]string, len(columns))
|
cells := make(map[string]string, len(columns))
|
||||||
for _, column := range columns {
|
for _, column := range columns {
|
||||||
cells[column] = formatValue(row[column])
|
cells[column] = formatRowValue(column, row)
|
||||||
}
|
}
|
||||||
status := strings.TrimSpace(cells["status"])
|
status := strings.TrimSpace(cells["status"])
|
||||||
tableRows = append(tableRows, tableRow{
|
tableRows = append(tableRows, tableRow{
|
||||||
@@ -211,16 +249,76 @@ func buildTableSection(key string, items []any) sectionView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildPCIeSection(items []any) sectionView {
|
||||||
|
grouped := make(map[string][]map[string]any)
|
||||||
|
classNames := make([]string, 0)
|
||||||
|
seenClasses := make(map[string]struct{})
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
row, ok := item.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
className := strings.TrimSpace(formatValue(row["device_class"]))
|
||||||
|
if className == "" {
|
||||||
|
className = "Unclassified"
|
||||||
|
}
|
||||||
|
grouped[className] = append(grouped[className], row)
|
||||||
|
if _, ok := seenClasses[className]; !ok {
|
||||||
|
seenClasses[className] = struct{}{}
|
||||||
|
classNames = append(classNames, className)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(classNames)
|
||||||
|
groups := make([]tableGroupView, 0, len(classNames))
|
||||||
|
for _, className := range classNames {
|
||||||
|
rows := grouped[className]
|
||||||
|
sortPCIeRows(rows)
|
||||||
|
columns := collectColumns("pcie_devices", rows)
|
||||||
|
items := make([]tableRow, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
cells := make(map[string]string, len(columns))
|
||||||
|
for _, column := range columns {
|
||||||
|
cells[column] = formatRowValue(column, row)
|
||||||
|
}
|
||||||
|
items = append(items, tableRow{
|
||||||
|
Status: strings.TrimSpace(cells["status"]),
|
||||||
|
Cells: cells,
|
||||||
|
RawCells: row,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
groups = append(groups, tableGroupView{
|
||||||
|
Title: className,
|
||||||
|
Columns: columns,
|
||||||
|
Items: items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return sectionView{
|
||||||
|
ID: "pcie_devices",
|
||||||
|
Title: titleFor("pcie_devices"),
|
||||||
|
Kind: "grouped_tables",
|
||||||
|
Groups: groups,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func collectColumns(section string, rows []map[string]any) []string {
|
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) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
seen[key] = struct{}{}
|
seen[key] = struct{}{}
|
||||||
}
|
}
|
||||||
|
if hasVendorDeviceID(row) {
|
||||||
|
seen[vendorDeviceIDField] = struct{}{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
columns := make([]string, 0, len(seen))
|
columns := make([]string, 0, len(seen))
|
||||||
for _, key := range preferredColumns[section] {
|
for _, key := range append(commonPreferredColumns, preferredColumns[section]...) {
|
||||||
if _, ok := seen[key]; ok {
|
if _, ok := seen[key]; ok {
|
||||||
columns = append(columns, key)
|
columns = append(columns, key)
|
||||||
delete(seen, key)
|
delete(seen, key)
|
||||||
@@ -238,11 +336,17 @@ func collectColumns(section string, rows []map[string]any) []string {
|
|||||||
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])})
|
||||||
}
|
}
|
||||||
@@ -255,7 +359,7 @@ func formatValue(value any) string {
|
|||||||
}
|
}
|
||||||
switch typed := value.(type) {
|
switch typed := value.(type) {
|
||||||
case string:
|
case string:
|
||||||
return typed
|
return formatStringValue(typed)
|
||||||
case []any:
|
case []any:
|
||||||
parts := make([]string, 0, len(typed))
|
parts := make([]string, 0, len(typed))
|
||||||
for _, item := range typed {
|
for _, item := range typed {
|
||||||
@@ -263,8 +367,7 @@ func formatValue(value any) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(parts, "\n")
|
return strings.Join(parts, "\n")
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
data, _ := json.MarshalIndent(typed, "", " ")
|
return formatObjectValue(typed)
|
||||||
return string(data)
|
|
||||||
default:
|
default:
|
||||||
data, err := json.Marshal(typed)
|
data, err := json.Marshal(typed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -273,10 +376,98 @@ func formatValue(value any) string {
|
|||||||
text := string(data)
|
text := string(data)
|
||||||
text = strings.TrimPrefix(text, `"`)
|
text = strings.TrimPrefix(text, `"`)
|
||||||
text = strings.TrimSuffix(text, `"`)
|
text = strings.TrimSuffix(text, `"`)
|
||||||
return text
|
return formatStringValue(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
lines := make([]string, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
formatted := formatValue(value[key])
|
||||||
|
if strings.TrimSpace(formatted) == "" {
|
||||||
|
lines = append(lines, key+":")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(formatted, "\n")
|
||||||
|
lines = append(lines, key+": "+parts[0])
|
||||||
|
for _, part := range parts[1:] {
|
||||||
|
lines = append(lines, " "+part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatStringValue(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if formatted, ok := formatDate(value); ok {
|
||||||
|
return formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatRowValue(column string, row map[string]any) string {
|
||||||
|
if column == vendorDeviceIDField {
|
||||||
|
return formatVendorDeviceID(row)
|
||||||
|
}
|
||||||
|
return formatValue(row[column])
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
hasTime bool
|
||||||
|
}{
|
||||||
|
{time.RFC3339Nano, true},
|
||||||
|
{time.RFC3339, true},
|
||||||
|
{"2006-01-02 15:04:05", true},
|
||||||
|
{"2006-01-02 15:04", true},
|
||||||
|
{"2006-01-02", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, candidate := range layouts {
|
||||||
|
parsed, err := time.Parse(candidate.layout, value)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !candidate.hasTime {
|
||||||
|
return parsed.Format("02 Jan 2006"), true
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Location() == time.UTC {
|
||||||
|
return parsed.Format("02 Jan 2006, 15:04 UTC"), true
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.Format("02 Jan 2006, 15:04 -07:00"), true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func titleFor(key string) string {
|
func titleFor(key string) string {
|
||||||
if value, ok := sectionTitles[key]; ok {
|
if value, ok := sectionTitles[key]; ok {
|
||||||
return value
|
return value
|
||||||
@@ -284,13 +475,56 @@ 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 prettyJSON(input string) string {
|
func isHiddenField(key string) bool {
|
||||||
if strings.TrimSpace(input) == "" {
|
if key == "vendor_id" || key == "device_id" {
|
||||||
return ""
|
return true
|
||||||
}
|
}
|
||||||
var out bytes.Buffer
|
_, ok := hiddenFields[key]
|
||||||
if err := json.Indent(&out, []byte(input), "", " "); err != nil {
|
return ok
|
||||||
return input
|
}
|
||||||
}
|
|
||||||
return out.String()
|
func hasVendorDeviceID(value map[string]any) bool {
|
||||||
|
return formatVendorDeviceID(value) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
formatRowValue("slot", rows[i]),
|
||||||
|
formatRowValue("location", rows[i]),
|
||||||
|
formatRowValue("vendor", rows[i]),
|
||||||
|
formatRowValue("model", rows[i]),
|
||||||
|
formatRowValue("serial_number", rows[i]),
|
||||||
|
formatRowValue(vendorDeviceIDField, rows[i]),
|
||||||
|
formatRowValue("bdf", rows[i]),
|
||||||
|
}
|
||||||
|
right := []string{
|
||||||
|
formatRowValue("slot", rows[j]),
|
||||||
|
formatRowValue("location", rows[j]),
|
||||||
|
formatRowValue("vendor", rows[j]),
|
||||||
|
formatRowValue("model", rows[j]),
|
||||||
|
formatRowValue("serial_number", rows[j]),
|
||||||
|
formatRowValue(vendorDeviceIDField, rows[j]),
|
||||||
|
formatRowValue("bdf", rows[j]),
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx := range left {
|
||||||
|
if left[idx] == right[idx] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return left[idx] < right[idx]
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package viewer
|
package viewer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -34,6 +35,7 @@ func TestRenderHTMLIncludesKnownSectionsAndFields(t *testing.T) {
|
|||||||
for _, needle := range []string{
|
for _, needle := range []string{
|
||||||
"Reanimator Chart",
|
"Reanimator Chart",
|
||||||
"test-host",
|
"test-host",
|
||||||
|
"15 Mar 2026, 12:00 UTC",
|
||||||
"Board",
|
"Board",
|
||||||
"CPUs",
|
"CPUs",
|
||||||
"BOARD-001",
|
"BOARD-001",
|
||||||
@@ -45,4 +47,226 @@ func TestRenderHTMLIncludesKnownSectionsAndFields(t *testing.T) {
|
|||||||
t.Fatalf("expected rendered html to contain %q", needle)
|
t.Fatalf("expected rendered html to contain %q", needle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Contains(text, "2026-03-15T12:00:00Z") {
|
||||||
|
t.Fatalf("expected RFC3339 timestamp to be rendered in human-readable form")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderHTMLFormatsNestedObjectsWithoutRawJSON(t *testing.T) {
|
||||||
|
snapshot := []byte(`{
|
||||||
|
"target_host": "nested-host",
|
||||||
|
"hardware": {
|
||||||
|
"board": {
|
||||||
|
"status_history": {
|
||||||
|
"last_change": "2026-03-15T12:30:00Z",
|
||||||
|
"reason": "manual review"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
html, err := RenderHTML(snapshot, "Reanimator Chart")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderHTML() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := string(html)
|
||||||
|
for _, needle := range []string{
|
||||||
|
"status_history",
|
||||||
|
"last_change: 15 Mar 2026, 12:30 UTC",
|
||||||
|
"reason: manual review",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, needle) {
|
||||||
|
t.Fatalf("expected rendered html to contain %q", needle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(text, "{"") || strings.Contains(text, "\"last_change\"") {
|
||||||
|
t.Fatalf("expected nested object to render without raw JSON blob")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectColumnsOrdersStatusThenLocationThenIdentity(t *testing.T) {
|
||||||
|
rows := []map[string]any{
|
||||||
|
{
|
||||||
|
"serial_number": "SN-1",
|
||||||
|
"model": "Model-X",
|
||||||
|
"vendor": "Vendor-A",
|
||||||
|
"vendor_id": "8086",
|
||||||
|
"device_id": "1234",
|
||||||
|
"firmware": "1.2.3",
|
||||||
|
"location": "Bay 4",
|
||||||
|
"slot": "Slot 9",
|
||||||
|
"status": "OK",
|
||||||
|
"status_at_collection": "OK",
|
||||||
|
"status_checked_at": "2026-03-15T12:40:00Z",
|
||||||
|
"temperature_c": 40,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := collectColumns("storage", rows)
|
||||||
|
want := []string{
|
||||||
|
"status",
|
||||||
|
"slot",
|
||||||
|
"location",
|
||||||
|
"vendor",
|
||||||
|
"model",
|
||||||
|
"serial_number",
|
||||||
|
"ven:dev",
|
||||||
|
"firmware",
|
||||||
|
"temperature_c",
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("collectColumns() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectColumnsOrdersCPUFields(t *testing.T) {
|
||||||
|
rows := []map[string]any{
|
||||||
|
{
|
||||||
|
"model": "Xeon Gold",
|
||||||
|
"clock": "2.90 GHz",
|
||||||
|
"cores": 16,
|
||||||
|
"threads": 32,
|
||||||
|
"l1": "1 MiB",
|
||||||
|
"l2": "16 MiB",
|
||||||
|
"l3": "22 MiB",
|
||||||
|
"microcode": "0xd000375",
|
||||||
|
"socket": "CPU0",
|
||||||
|
"status": "OK",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := collectColumns("cpus", rows)
|
||||||
|
want := []string{
|
||||||
|
"status",
|
||||||
|
"model",
|
||||||
|
"clock",
|
||||||
|
"cores",
|
||||||
|
"threads",
|
||||||
|
"l1",
|
||||||
|
"l2",
|
||||||
|
"l3",
|
||||||
|
"microcode",
|
||||||
|
"socket",
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("collectColumns() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderHTMLHidesStatusAtCollection(t *testing.T) {
|
||||||
|
snapshot := []byte(`{
|
||||||
|
"target_host": "hidden-field-host",
|
||||||
|
"hardware": {
|
||||||
|
"storage": [
|
||||||
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"status_at_collection": "OK",
|
||||||
|
"status_checked_at": "2026-03-15T12:45:00Z",
|
||||||
|
"location": "Bay 1",
|
||||||
|
"vendor": "Vendor-A",
|
||||||
|
"model": "Model-Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"board": {
|
||||||
|
"status": "OK",
|
||||||
|
"status_at_collection": "OK",
|
||||||
|
"status_checked_at": "2026-03-15T12:40:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
html, err := RenderHTML(snapshot, "Reanimator Chart")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderHTML() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "<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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderHTMLCombinesVendorAndDeviceID(t *testing.T) {
|
||||||
|
snapshot := []byte(`{
|
||||||
|
"target_host": "pci-host",
|
||||||
|
"hardware": {
|
||||||
|
"pcie_devices": [
|
||||||
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"location": "Slot 3",
|
||||||
|
"vendor": "Intel",
|
||||||
|
"model": "Ethernet Adapter",
|
||||||
|
"vendor_id": "8086",
|
||||||
|
"device_id": "1234"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
html, err := RenderHTML(snapshot, "Reanimator Chart")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderHTML() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := string(html)
|
||||||
|
if !strings.Contains(text, "ven:dev") {
|
||||||
|
t.Fatalf("expected combined vendor/device id column 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, "<th>vendor_id</th>") || strings.Contains(text, "<th>device_id</th>") {
|
||||||
|
t.Fatalf("expected raw vendor_id and device_id columns to be hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderHTMLGroupsPCIeDevicesByClass(t *testing.T) {
|
||||||
|
snapshot := []byte(`{
|
||||||
|
"target_host": "pcie-group-host",
|
||||||
|
"hardware": {
|
||||||
|
"pcie_devices": [
|
||||||
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"slot": "Slot 2",
|
||||||
|
"device_class": "Network controller",
|
||||||
|
"vendor": "Vendor-B",
|
||||||
|
"model": "NIC-B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"status": "Warning",
|
||||||
|
"slot": "Slot 1",
|
||||||
|
"device_class": "Display controller",
|
||||||
|
"vendor": "Vendor-A",
|
||||||
|
"model": "GPU-A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
html, err := RenderHTML(snapshot, "Reanimator Chart")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderHTML() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := string(html)
|
||||||
|
if !strings.Contains(text, "<h3>Display controller</h3>") || !strings.Contains(text, "<h3>Network controller</h3>") {
|
||||||
|
t.Fatalf("expected PCIe devices to be split into class subheadings")
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "<th>device_class</th>") {
|
||||||
|
t.Fatalf("expected device_class column to be hidden from PCIe tables")
|
||||||
|
}
|
||||||
|
if strings.Index(text, "<h3>Display controller</h3>") > strings.Index(text, "<h3>Network controller</h3>") {
|
||||||
|
t.Fatalf("expected PCIe class groups to be sorted by device_class")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
web/embed.go
10
web/embed.go
@@ -16,6 +16,8 @@ var pageTemplate = template.Must(template.New("view.html").Funcs(template.FuncMa
|
|||||||
"joinLines": joinLines,
|
"joinLines": joinLines,
|
||||||
}).ParseFS(content, "templates/view.html"))
|
}).ParseFS(content, "templates/view.html"))
|
||||||
|
|
||||||
|
var uploadTemplate = template.Must(template.New("upload.html").ParseFS(content, "templates/upload.html"))
|
||||||
|
|
||||||
func Render(data any) ([]byte, error) {
|
func Render(data any) ([]byte, error) {
|
||||||
var out strings.Builder
|
var out strings.Builder
|
||||||
if err := pageTemplate.ExecuteTemplate(&out, "view.html", data); err != nil {
|
if err := pageTemplate.ExecuteTemplate(&out, "view.html", data); err != nil {
|
||||||
@@ -24,6 +26,14 @@ func Render(data any) ([]byte, error) {
|
|||||||
return []byte(out.String()), nil
|
return []byte(out.String()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RenderUpload(data any) ([]byte, error) {
|
||||||
|
var out strings.Builder
|
||||||
|
if err := uploadTemplate.ExecuteTemplate(&out, "upload.html", data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []byte(out.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
func Static() http.Handler {
|
func Static() http.Handler {
|
||||||
sub, err := fs.Sub(content, "static")
|
sub, err := fs.Sub(content, "static")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -52,9 +52,10 @@ body {
|
|||||||
padding: 20px 0 32px;
|
padding: 20px 0 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-panel,
|
.empty-panel,
|
||||||
.meta-panel,
|
.meta-panel,
|
||||||
.section-card {
|
.section-card,
|
||||||
|
.upload-panel {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -63,30 +64,63 @@ body {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-label,
|
.empty-panel h2,
|
||||||
.section-card h2,
|
.section-card h2,
|
||||||
.meta-panel h2 {
|
.meta-panel h2,
|
||||||
|
.upload-panel h2 {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
.empty-panel p,
|
||||||
|
.upload-panel p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-panel {
|
||||||
|
width: min(680px, 100%);
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone {
|
||||||
|
display: block;
|
||||||
|
margin-top: 16px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
background: #faf6ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone input {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 220px;
|
margin-bottom: 12px;
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 12px;
|
|
||||||
background: #fcfaf6;
|
|
||||||
color: var(--ink);
|
|
||||||
font: 13px/1.5 "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-actions {
|
.upload-eyebrow {
|
||||||
margin-top: 12px;
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
.upload-dropzone strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-actions {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-actions button {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
@@ -98,7 +132,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.error-box {
|
.error-box {
|
||||||
margin-top: 12px;
|
margin-bottom: 16px;
|
||||||
border: 1px solid var(--crit-fg);
|
border: 1px solid var(--crit-fg);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -121,6 +155,24 @@ button {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sections-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card-half {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.kv-table,
|
.kv-table,
|
||||||
.data-table {
|
.data-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -154,6 +206,19 @@ button {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-group + .table-group {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-group h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.data-table thead th {
|
.data-table thead th {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -198,6 +263,10 @@ button {
|
|||||||
width: min(100vw - 20px, 1500px);
|
width: min(100vw - 20px, 1500px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sections-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
padding: 18px 14px 14px;
|
padding: 18px 14px 14px;
|
||||||
}
|
}
|
||||||
@@ -206,9 +275,15 @@ button {
|
|||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-panel,
|
.empty-panel,
|
||||||
.meta-panel,
|
.meta-panel,
|
||||||
.section-card {
|
.section-card,
|
||||||
|
.upload-panel {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-card-half,
|
||||||
|
.section-card-full {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
web/templates/upload.html
Normal file
38
web/templates/upload.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<!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">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>{{ .Title }}</h1>
|
||||||
|
<p>Read-only viewer for Reanimator JSON snapshots</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="page-main">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
<strong>Choose a snapshot JSON file</strong>
|
||||||
|
<span>The file is rendered read-only and not modified.</span>
|
||||||
|
</label>
|
||||||
|
<div class="upload-actions">
|
||||||
|
<button type="submit">Render Snapshot</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{ if .Error }}
|
||||||
|
<div class="error-box">{{ .Error }}</div>
|
||||||
|
{{ end }}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -15,19 +15,6 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="page-main">
|
<main class="page-main">
|
||||||
<section class="input-panel">
|
|
||||||
<form method="post" action="/render">
|
|
||||||
<label for="snapshot" class="input-label">Snapshot JSON</label>
|
|
||||||
<textarea id="snapshot" name="snapshot" spellcheck="false" placeholder='{"target_host":"...","hardware":{...}}'>{{ .InputJSON }}</textarea>
|
|
||||||
<div class="input-actions">
|
|
||||||
<button type="submit">Render Snapshot</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{{ if .Error }}
|
|
||||||
<div class="error-box">{{ .Error }}</div>
|
|
||||||
{{ end }}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{{ if .HasSnapshot }}
|
{{ if .HasSnapshot }}
|
||||||
<section class="meta-panel">
|
<section class="meta-panel">
|
||||||
<h2>Snapshot Metadata</h2>
|
<h2>Snapshot Metadata</h2>
|
||||||
@@ -49,8 +36,9 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div class="sections-grid">
|
||||||
{{ range .Sections }}
|
{{ range .Sections }}
|
||||||
<section class="section-card" id="{{ .ID }}">
|
<section class="section-card {{ if or (eq .ID "board") (eq .ID "firmware") }}section-card-half{{ else }}section-card-full{{ end }}" id="{{ .ID }}">
|
||||||
<h2>{{ .Title }}</h2>
|
<h2>{{ .Title }}</h2>
|
||||||
|
|
||||||
{{ if eq .Kind "object" }}
|
{{ if eq .Kind "object" }}
|
||||||
@@ -103,8 +91,59 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if eq .Kind "grouped_tables" }}
|
||||||
|
{{ range .Groups }}
|
||||||
|
<div class="table-group">
|
||||||
|
<h3>{{ .Title }}</h3>
|
||||||
|
{{ $group := . }}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{{ range .Columns }}
|
||||||
|
<th>{{ . }}</th>
|
||||||
|
{{ end }}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .Items }}
|
||||||
|
<tr>
|
||||||
|
{{ $row := . }}
|
||||||
|
{{ range $group.Columns }}
|
||||||
|
<td>
|
||||||
|
{{ $value := index $row.Cells . }}
|
||||||
|
{{ if eq . "status" }}
|
||||||
|
<span class="status-badge {{ statusClass $value }}">{{ $value }}</span>
|
||||||
|
{{ else }}
|
||||||
|
{{ range joinLines $value }}
|
||||||
|
<div>{{ . }}</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
{{ end }}
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
</section>
|
</section>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if .Error }}
|
||||||
|
<section class="error-box">{{ .Error }}</section>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if not .HasSnapshot }}
|
||||||
|
<section class="empty-panel">
|
||||||
|
<h2>Snapshot Viewer</h2>
|
||||||
|
<p>This page renders one Reanimator snapshot provided by the embedding application.</p>
|
||||||
|
</section>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user