Bootstrap reanimator chart viewer
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
bin/
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "bible"]
|
||||
path = bible
|
||||
url = https://git.mchus.pro/mchus/bible.git
|
||||
23
AGENTS.md
Normal file
23
AGENTS.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Instructions For Codex
|
||||
|
||||
Read `bible/` for shared engineering rules.
|
||||
Read `bible-local/` for project-specific architecture.
|
||||
|
||||
Start order:
|
||||
|
||||
1. `bible-local/README.md`
|
||||
2. `bible-local/architecture/system-overview.md`
|
||||
3. relevant architecture files for the current task
|
||||
|
||||
This project is intentionally small and simple.
|
||||
Do not turn it into an analyzer, ingest service, or monitoring system.
|
||||
|
||||
Core product rule:
|
||||
|
||||
- `chart` is a read-only viewer for Reanimator JSON snapshots.
|
||||
- It must display the JSON as-is.
|
||||
- It must not invent, derive, normalize, aggregate, or enrich data beyond presentation-only formatting.
|
||||
|
||||
If a new architectural decision is made, record it in `bible-local/decisions/` in the same commit.
|
||||
26
README.md
Normal file
26
README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Reanimator Chart
|
||||
|
||||
`chart` is a small read-only web viewer for Reanimator hardware JSON snapshots.
|
||||
|
||||
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.
|
||||
|
||||
## Product Scope
|
||||
|
||||
- render a Reanimator JSON snapshot as HTML
|
||||
- preserve the source payload shape
|
||||
- show all fields from JSON
|
||||
- use compact tables and section navigation
|
||||
- color statuses for quick scanning
|
||||
|
||||
## Explicit Non-Goals
|
||||
|
||||
- no ingest pipeline
|
||||
- no audit collection
|
||||
- no background jobs
|
||||
- no summary generation beyond what already exists in JSON
|
||||
- no mutation of the input data
|
||||
- no vendor-specific parsing logic
|
||||
|
||||
## Architecture Docs
|
||||
|
||||
Project-specific architecture lives in [`bible-local/README.md`](/Users/mchusavitin/Documents/git/reanimator/chart/bible-local/README.md).
|
||||
1
bible
Submodule
1
bible
Submodule
Submodule bible added at 5a69e0bba8
22
bible-local/README.md
Normal file
22
bible-local/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Project Bible
|
||||
|
||||
`bible-local/` is the single source of truth for everything specific to Reanimator Chart.
|
||||
|
||||
## Read Order
|
||||
|
||||
1. `architecture/system-overview.md`
|
||||
2. `architecture/data-model.md`
|
||||
3. `architecture/api-surface.md`
|
||||
4. `architecture/runtime-flows.md`
|
||||
5. `architecture/ui-information-architecture.md`
|
||||
6. `decisions/README.md`
|
||||
|
||||
## Structure
|
||||
|
||||
- `architecture/system-overview.md` - product scope, non-goals, stack direction
|
||||
- `architecture/data-model.md` - viewer domain model and rendering data rules
|
||||
- `architecture/api-surface.md` - embeddable handler/API surface
|
||||
- `architecture/runtime-flows.md` - render flow and invariants
|
||||
- `architecture/ui-information-architecture.md` - page structure and UI rules
|
||||
- `decisions/README.md` - architectural decision log format
|
||||
- `decisions/2026-03-15-read-only-schema-preserving-viewer.md` - initial product decision
|
||||
35
bible-local/architecture/api-surface.md
Normal file
35
bible-local/architecture/api-surface.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# API Surface
|
||||
|
||||
## Primary Integration Style
|
||||
|
||||
The package is intended to be embedded by other Go applications.
|
||||
|
||||
Expected package shape:
|
||||
|
||||
- `viewer.RenderHTML(snapshot []byte) ([]byte, error)`
|
||||
- `viewer.NewHandler(...) http.Handler`
|
||||
|
||||
Exact signatures may change during implementation, but the integration model is fixed:
|
||||
|
||||
- embedding app provides the JSON
|
||||
- chart renders HTML
|
||||
|
||||
## Expected Runtime Endpoints
|
||||
|
||||
These endpoints are expected for the standalone binary only:
|
||||
|
||||
- `GET /` - viewer page
|
||||
- `POST /render` - accept one Reanimator JSON payload and return rendered HTML or JSON render result
|
||||
- `GET /healthz` - basic process health
|
||||
|
||||
## UI Route Rules
|
||||
|
||||
- No multi-product navigation
|
||||
- No upload wizard with preview/confirm/execute stages
|
||||
- No collector/API-connect workflow
|
||||
- No background job polling API
|
||||
|
||||
## Response Rules
|
||||
|
||||
- HTML pages are read-only views of one snapshot
|
||||
- API responses must not modify or augment the input payload semantics
|
||||
54
bible-local/architecture/data-model.md
Normal file
54
bible-local/architecture/data-model.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Data Model
|
||||
|
||||
## Canonical Input
|
||||
|
||||
The canonical input is one Reanimator hardware JSON snapshot.
|
||||
|
||||
The viewer does not own the schema. The Reanimator JSON contract remains external to this repository.
|
||||
|
||||
## Viewer Data Model
|
||||
|
||||
The viewer uses a presentation model derived directly from the input JSON:
|
||||
|
||||
- top-level snapshot metadata
|
||||
- ordered top-level sections
|
||||
- table definitions per section
|
||||
- raw field values
|
||||
|
||||
## Rendering Rule
|
||||
|
||||
The viewer is schema-preserving, not schema-owning.
|
||||
|
||||
- Known sections have a preferred order.
|
||||
- Known important fields may have a preferred column order.
|
||||
- Every field present in the input must remain visible.
|
||||
- Unknown fields must not be dropped.
|
||||
|
||||
## Section Model
|
||||
|
||||
Section types expected initially:
|
||||
|
||||
- singleton object section, for example `board`
|
||||
- array-of-object section, for example `cpus`
|
||||
- nested grouped section, for example `sensors.{fans,power,temperatures,other}`
|
||||
|
||||
## Column Rules
|
||||
|
||||
For array sections:
|
||||
|
||||
1. collect the union of keys present across all rows
|
||||
2. place known fields first
|
||||
3. append unknown fields after known fields
|
||||
4. render absent values as empty cells
|
||||
|
||||
## Special Field Handling
|
||||
|
||||
- `status` is rendered as a colored badge
|
||||
- arrays such as `mac_addresses` may be rendered as line-separated values or badges inside one cell
|
||||
- nested values such as `status_history` may be rendered in expandable detail blocks inside one cell
|
||||
|
||||
## Persistence
|
||||
|
||||
There is no database and no canonical stored state in v1.
|
||||
|
||||
The viewer operates on the in-memory snapshot provided by the embedding application or local file input.
|
||||
38
bible-local/architecture/runtime-flows.md
Normal file
38
bible-local/architecture/runtime-flows.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Runtime Flows
|
||||
|
||||
## Render Snapshot Flow
|
||||
|
||||
1. receive one Reanimator JSON snapshot from the caller
|
||||
2. decode the payload into generic/object structures without dropping unknown fields
|
||||
3. identify known top-level sections
|
||||
4. build presentation sections in preferred order
|
||||
5. for each array section, build the union of keys across rows
|
||||
6. order columns with known fields first and unknown fields after
|
||||
7. render the page
|
||||
|
||||
## Status Presentation Flow
|
||||
|
||||
1. read the raw `status` value from the payload
|
||||
2. normalize only for presentation matching (`OK`, `Warning`, `Critical`, `Unknown`, `Empty`)
|
||||
3. apply status badge class
|
||||
4. do not change the raw value shown to the user
|
||||
|
||||
## Unknown Field Invariant
|
||||
|
||||
1. if a field exists in the input, it must remain visible somewhere in the output
|
||||
2. unknown fields may appear after known fields in a section table
|
||||
3. unknown top-level sections may be rendered after known sections
|
||||
4. the viewer must never silently discard fields because they are new or unexpected
|
||||
|
||||
## Non-Augmentation Invariant
|
||||
|
||||
1. do not compute aggregate health summaries unless they already exist in the JSON
|
||||
2. do not derive warnings/failures counters
|
||||
3. do not infer statuses for missing fields
|
||||
4. do not merge or rewrite source values
|
||||
|
||||
## Simplicity Rule
|
||||
|
||||
1. prefer server-rendered HTML
|
||||
2. keep JavaScript optional and presentation-only
|
||||
3. do not introduce a frontend framework without a concrete present need
|
||||
47
bible-local/architecture/system-overview.md
Normal file
47
bible-local/architecture/system-overview.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# System Overview
|
||||
|
||||
## Product
|
||||
|
||||
Reanimator Chart is a small Go web viewer for Reanimator hardware JSON snapshots.
|
||||
|
||||
It is designed to be embedded as a module in other Go applications that already collect audit data and emit Reanimator-compatible JSON.
|
||||
|
||||
## Active Scope
|
||||
|
||||
- Render one Reanimator JSON snapshot as HTML
|
||||
- Read-only presentation of top-level metadata and hardware sections
|
||||
- Tabular rendering for arrays such as `cpus`, `memory`, `storage`, `pcie_devices`, `power_supplies`, and sensor subsections
|
||||
- Status color coding for fast scanning
|
||||
- Lightweight section navigation
|
||||
- Standalone HTML rendering or embeddable HTTP handler
|
||||
|
||||
## Explicitly Out Of Scope
|
||||
|
||||
- Parsing vendor logs or archives
|
||||
- Collecting hardware data
|
||||
- Converting non-Reanimator formats
|
||||
- Editing or mutating snapshot data
|
||||
- Derived health analytics not present in the input JSON
|
||||
- Timeline/history reconstruction
|
||||
- Database storage
|
||||
- Background jobs, upload pipelines, or batch processing UI
|
||||
|
||||
## Product Rules
|
||||
|
||||
- The viewer must display the input snapshot as-is.
|
||||
- The viewer may format values for presentation, but may not invent new data.
|
||||
- Unknown fields must still be visible in the UI.
|
||||
- Known sections may have a preferred visual order, but payload content remains authoritative.
|
||||
|
||||
## Tech Direction
|
||||
|
||||
- Go HTTP server / embeddable handler
|
||||
- Server-rendered HTML
|
||||
- Minimal client-side JavaScript only for presentation helpers such as expand/collapse and sticky section navigation
|
||||
- No SPA framework unless a concrete need appears later
|
||||
|
||||
## Local Run
|
||||
|
||||
Expected future command:
|
||||
|
||||
- `go run ./cmd/reanimator-chart`
|
||||
63
bible-local/architecture/ui-information-architecture.md
Normal file
63
bible-local/architecture/ui-information-architecture.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# UI Information Architecture
|
||||
|
||||
## Page Structure
|
||||
|
||||
The page should contain:
|
||||
|
||||
1. header
|
||||
2. snapshot metadata strip
|
||||
3. compact section navigation
|
||||
4. ordered content sections
|
||||
|
||||
## Header
|
||||
|
||||
Show only essential context:
|
||||
|
||||
- product name
|
||||
- target host if present
|
||||
- collected time if present
|
||||
- source type and protocol if present
|
||||
|
||||
## Section Order
|
||||
|
||||
Preferred order:
|
||||
|
||||
1. `board`
|
||||
2. `firmware`
|
||||
3. `cpus`
|
||||
4. `memory`
|
||||
5. `storage`
|
||||
6. `pcie_devices`
|
||||
7. `power_supplies`
|
||||
8. `sensors`
|
||||
9. unknown sections
|
||||
|
||||
## Section Presentation
|
||||
|
||||
- singleton object sections render as key-value table
|
||||
- array sections render as compact data tables
|
||||
- sensors render as separate subtables for `fans`, `power`, `temperatures`, and `other`
|
||||
|
||||
## Visual Rules
|
||||
|
||||
- status badges:
|
||||
- green for `OK`
|
||||
- yellow/amber for `Warning`
|
||||
- red for `Critical`
|
||||
- gray for `Unknown`
|
||||
- light gray for `Empty`
|
||||
- do not color arbitrary non-status fields
|
||||
- keep tables dense and scan-friendly
|
||||
|
||||
## Complexity Limits
|
||||
|
||||
- no multi-tab application shell
|
||||
- no dashboard analytics
|
||||
- no wizard-like workflow
|
||||
- no collector controls
|
||||
|
||||
## Accessibility
|
||||
|
||||
- tables must remain readable on desktop and mobile
|
||||
- section navigation must work without JavaScript
|
||||
- color must not be the only status indicator; always show text
|
||||
@@ -0,0 +1,37 @@
|
||||
# Decision: Read-Only Schema-Preserving Viewer
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Status:** active
|
||||
|
||||
## Context
|
||||
|
||||
A separate repository is being created to display Reanimator JSON snapshots in a simpler and more reusable way than the existing `logpile` UI.
|
||||
|
||||
The goal is to let other Go applications embed the viewer module and present Reanimator snapshot data without duplicating UI work.
|
||||
|
||||
There was an explicit requirement that the viewer must show all JSON fields and must not add new computed data.
|
||||
|
||||
## Decision
|
||||
|
||||
Reanimator Chart is defined as a small read-only web viewer for one Reanimator JSON snapshot.
|
||||
|
||||
It will:
|
||||
|
||||
- render source data as-is
|
||||
- preserve unknown fields
|
||||
- use simple section navigation and tables
|
||||
- color-code device statuses for presentation only
|
||||
|
||||
It will not:
|
||||
|
||||
- perform collection or parsing of raw vendor logs
|
||||
- run upload/API-connect/convert workflows
|
||||
- compute aggregate health summaries unless they are already present in the input JSON
|
||||
- become a generic analyzer product
|
||||
|
||||
## Consequences
|
||||
|
||||
- The viewer architecture must stay minimal and server-rendered by default.
|
||||
- The rendering layer must be tolerant of schema evolution.
|
||||
- UI code must not silently drop unexpected fields.
|
||||
- `logpile` patterns for upload, convert, collectors, and job management are out of scope here.
|
||||
21
bible-local/decisions/README.md
Normal file
21
bible-local/decisions/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Architectural Decision Log
|
||||
|
||||
Each file in this directory records one architectural decision.
|
||||
|
||||
Format:
|
||||
|
||||
```markdown
|
||||
# Decision: <short title>
|
||||
|
||||
**Date:** YYYY-MM-DD
|
||||
**Status:** active | superseded by YYYY-MM-DD-topic.md
|
||||
|
||||
## Context
|
||||
Why the decision was needed.
|
||||
|
||||
## Decision
|
||||
What was decided.
|
||||
|
||||
## Consequences
|
||||
What this requires or forbids going forward.
|
||||
```
|
||||
25
cmd/reanimator-chart/main.go
Normal file
25
cmd/reanimator-chart/main.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"reanimator/chart/viewer"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := os.Getenv("CHART_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8080"
|
||||
}
|
||||
|
||||
handler := viewer.NewHandler(viewer.HandlerOptions{
|
||||
Title: "Reanimator Chart",
|
||||
})
|
||||
|
||||
log.Printf("reanimator-chart listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, handler); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
73
viewer/handler.go
Normal file
73
viewer/handler.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package viewer
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"reanimator/chart/web"
|
||||
)
|
||||
|
||||
type HandlerOptions struct {
|
||||
Title string
|
||||
}
|
||||
|
||||
func NewHandler(opts HandlerOptions) http.Handler {
|
||||
title := strings.TrimSpace(opts.Title)
|
||||
if title == "" {
|
||||
title = "Reanimator Chart"
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", web.Static()))
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||
html, err := RenderHTML(nil, title)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(html)
|
||||
})
|
||||
mux.HandleFunc("POST /render", func(w http.ResponseWriter, r *http.Request) {
|
||||
var payload string
|
||||
if strings.Contains(r.Header.Get("Content-Type"), "application/json") {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
page = pageData{
|
||||
Title: title,
|
||||
Error: err.Error(),
|
||||
InputJSON: prettyJSON(payload),
|
||||
}
|
||||
} else {
|
||||
page.InputJSON = prettyJSON(payload)
|
||||
}
|
||||
|
||||
html, renderErr := web.Render(page)
|
||||
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 mux
|
||||
}
|
||||
30
viewer/model.go
Normal file
30
viewer/model.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package viewer
|
||||
|
||||
type pageData struct {
|
||||
Title string
|
||||
HasSnapshot bool
|
||||
Error string
|
||||
InputJSON string
|
||||
Meta []fieldRow
|
||||
Sections []sectionView
|
||||
}
|
||||
|
||||
type sectionView struct {
|
||||
ID string
|
||||
Title string
|
||||
Kind string
|
||||
Rows []fieldRow
|
||||
Columns []string
|
||||
Items []tableRow
|
||||
}
|
||||
|
||||
type fieldRow struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
type tableRow struct {
|
||||
Status string
|
||||
Cells map[string]string
|
||||
RawCells map[string]any
|
||||
}
|
||||
296
viewer/render.go
Normal file
296
viewer/render.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package viewer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"reanimator/chart/web"
|
||||
)
|
||||
|
||||
var sectionOrder = []string{
|
||||
"board",
|
||||
"firmware",
|
||||
"cpus",
|
||||
"memory",
|
||||
"storage",
|
||||
"pcie_devices",
|
||||
"power_supplies",
|
||||
"sensors",
|
||||
}
|
||||
|
||||
var sectionTitles = map[string]string{
|
||||
"board": "Board",
|
||||
"firmware": "Firmware",
|
||||
"cpus": "CPUs",
|
||||
"memory": "Memory",
|
||||
"storage": "Storage",
|
||||
"pcie_devices": "PCIe Devices",
|
||||
"power_supplies": "Power Supplies",
|
||||
"sensors": "Sensors",
|
||||
"fans": "Fans",
|
||||
"power": "Power",
|
||||
"temperatures": "Temperatures",
|
||||
"other": "Other",
|
||||
}
|
||||
|
||||
var preferredMetaKeys = []string{"target_host", "collected_at", "source_type", "protocol", "filename"}
|
||||
|
||||
var preferredColumns = map[string][]string{
|
||||
"firmware": {"device_name", "version"},
|
||||
"cpus": {"socket", "model", "manufacturer", "status"},
|
||||
"memory": {"slot", "location", "serial_number", "part_number", "size_mb", "status"},
|
||||
"storage": {"slot", "type", "model", "serial_number", "firmware", "size_gb", "status"},
|
||||
"pcie_devices": {"slot", "bdf", "device_class", "manufacturer", "model", "serial_number", "status"},
|
||||
"power_supplies": {"slot", "vendor", "model", "serial_number", "part_number", "status"},
|
||||
"fans": {"name", "location", "rpm", "status"},
|
||||
"power": {"name", "location", "voltage_v", "current_a", "power_w", "status"},
|
||||
"temperatures": {"name", "location", "celsius", "threshold_warning_celsius", "threshold_critical_celsius", "status"},
|
||||
"other": {"name", "location", "value", "unit", "status"},
|
||||
}
|
||||
|
||||
func RenderHTML(snapshot []byte, title string) ([]byte, error) {
|
||||
page, err := buildPageData(snapshot, title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return web.Render(page)
|
||||
}
|
||||
|
||||
func buildPageData(snapshot []byte, title string) (pageData, error) {
|
||||
page := pageData{Title: title}
|
||||
if strings.TrimSpace(string(snapshot)) == "" {
|
||||
return page, nil
|
||||
}
|
||||
|
||||
var root map[string]any
|
||||
if err := json.Unmarshal(snapshot, &root); err != nil {
|
||||
return pageData{}, fmt.Errorf("decode snapshot: %w", err)
|
||||
}
|
||||
|
||||
page.HasSnapshot = true
|
||||
page.InputJSON = strings.TrimSpace(string(snapshot))
|
||||
page.Meta = buildMeta(root)
|
||||
page.Sections = buildSections(root)
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func buildMeta(root map[string]any) []fieldRow {
|
||||
rows := make([]fieldRow, 0)
|
||||
used := make(map[string]struct{})
|
||||
for _, key := range preferredMetaKeys {
|
||||
if value, ok := root[key]; ok {
|
||||
rows = append(rows, fieldRow{Key: key, Value: formatValue(value)})
|
||||
used[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
extraKeys := make([]string, 0)
|
||||
for key := range root {
|
||||
if key == "hardware" {
|
||||
continue
|
||||
}
|
||||
if _, ok := used[key]; ok {
|
||||
continue
|
||||
}
|
||||
extraKeys = append(extraKeys, key)
|
||||
}
|
||||
sort.Strings(extraKeys)
|
||||
for _, key := range extraKeys {
|
||||
rows = append(rows, fieldRow{Key: key, Value: formatValue(root[key])})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func buildSections(root map[string]any) []sectionView {
|
||||
hardware, _ := root["hardware"].(map[string]any)
|
||||
if len(hardware) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sections := make([]sectionView, 0)
|
||||
used := make(map[string]struct{})
|
||||
for _, key := range sectionOrder {
|
||||
value, ok := hardware[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
used[key] = struct{}{}
|
||||
sections = append(sections, buildSection(key, value)...)
|
||||
}
|
||||
|
||||
extraKeys := make([]string, 0)
|
||||
for key := range hardware {
|
||||
if _, ok := used[key]; ok {
|
||||
continue
|
||||
}
|
||||
extraKeys = append(extraKeys, key)
|
||||
}
|
||||
sort.Strings(extraKeys)
|
||||
for _, key := range extraKeys {
|
||||
sections = append(sections, buildSection(key, hardware[key])...)
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
func buildSection(key string, value any) []sectionView {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
if key == "sensors" {
|
||||
return buildSensorSections(typed)
|
||||
}
|
||||
return []sectionView{{
|
||||
ID: key,
|
||||
Title: titleFor(key),
|
||||
Kind: "object",
|
||||
Rows: buildFieldRows(typed),
|
||||
}}
|
||||
case []any:
|
||||
return []sectionView{buildTableSection(key, typed)}
|
||||
default:
|
||||
return []sectionView{{
|
||||
ID: key,
|
||||
Title: titleFor(key),
|
||||
Kind: "object",
|
||||
Rows: []fieldRow{
|
||||
{Key: key, Value: formatValue(value)},
|
||||
},
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
func buildSensorSections(sensors map[string]any) []sectionView {
|
||||
out := make([]sectionView, 0)
|
||||
for _, key := range []string{"fans", "power", "temperatures", "other"} {
|
||||
value, ok := sensors[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
section := buildTableSection(key, items)
|
||||
section.ID = "sensors-" + key
|
||||
section.Title = "Sensors / " + titleFor(key)
|
||||
out = append(out, section)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildTableSection(key string, items []any) sectionView {
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if row, ok := item.(map[string]any); ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
|
||||
columns := collectColumns(key, rows)
|
||||
tableRows := make([]tableRow, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
cells := make(map[string]string, len(columns))
|
||||
for _, column := range columns {
|
||||
cells[column] = formatValue(row[column])
|
||||
}
|
||||
status := strings.TrimSpace(cells["status"])
|
||||
tableRows = append(tableRows, tableRow{
|
||||
Status: status,
|
||||
Cells: cells,
|
||||
RawCells: row,
|
||||
})
|
||||
}
|
||||
|
||||
return sectionView{
|
||||
ID: key,
|
||||
Title: titleFor(key),
|
||||
Kind: "table",
|
||||
Columns: columns,
|
||||
Items: tableRows,
|
||||
}
|
||||
}
|
||||
|
||||
func collectColumns(section string, rows []map[string]any) []string {
|
||||
seen := make(map[string]struct{})
|
||||
for _, row := range rows {
|
||||
for key := range row {
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
columns := make([]string, 0, len(seen))
|
||||
for _, key := range preferredColumns[section] {
|
||||
if _, ok := seen[key]; ok {
|
||||
columns = append(columns, key)
|
||||
delete(seen, key)
|
||||
}
|
||||
}
|
||||
|
||||
extra := make([]string, 0, len(seen))
|
||||
for key := range seen {
|
||||
extra = append(extra, key)
|
||||
}
|
||||
sort.Strings(extra)
|
||||
return append(columns, extra...)
|
||||
}
|
||||
|
||||
func buildFieldRows(object map[string]any) []fieldRow {
|
||||
keys := make([]string, 0, len(object))
|
||||
for key := range object {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
rows := make([]fieldRow, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
rows = append(rows, fieldRow{Key: key, Value: formatValue(object[key])})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func formatValue(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case []any:
|
||||
parts := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
parts = append(parts, formatValue(item))
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
case map[string]any:
|
||||
data, _ := json.MarshalIndent(typed, "", " ")
|
||||
return string(data)
|
||||
default:
|
||||
data, err := json.Marshal(typed)
|
||||
if err != nil {
|
||||
return fmt.Sprint(typed)
|
||||
}
|
||||
text := string(data)
|
||||
text = strings.TrimPrefix(text, `"`)
|
||||
text = strings.TrimSuffix(text, `"`)
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
func titleFor(key string) string {
|
||||
if value, ok := sectionTitles[key]; ok {
|
||||
return value
|
||||
}
|
||||
return strings.ReplaceAll(strings.Title(strings.ReplaceAll(key, "_", " ")), "Pcie", "PCIe")
|
||||
}
|
||||
|
||||
func prettyJSON(input string) string {
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return ""
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := json.Indent(&out, []byte(input), "", " "); err != nil {
|
||||
return input
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
48
viewer/render_test.go
Normal file
48
viewer/render_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package viewer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderHTMLIncludesKnownSectionsAndFields(t *testing.T) {
|
||||
snapshot := []byte(`{
|
||||
"target_host": "test-host",
|
||||
"collected_at": "2026-03-15T12:00:00Z",
|
||||
"hardware": {
|
||||
"board": {
|
||||
"serial_number": "BOARD-001",
|
||||
"product_name": "Test Server"
|
||||
},
|
||||
"cpus": [
|
||||
{
|
||||
"socket": 0,
|
||||
"model": "Xeon",
|
||||
"status": "OK",
|
||||
"temperature_c": 61.5
|
||||
}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
|
||||
html, err := RenderHTML(snapshot, "Reanimator Chart")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderHTML() error = %v", err)
|
||||
}
|
||||
|
||||
text := string(html)
|
||||
for _, needle := range []string{
|
||||
"Reanimator Chart",
|
||||
"test-host",
|
||||
"Board",
|
||||
"CPUs",
|
||||
"BOARD-001",
|
||||
"Xeon",
|
||||
"temperature_c",
|
||||
"status-ok",
|
||||
} {
|
||||
if !strings.Contains(text, needle) {
|
||||
t.Fatalf("expected rendered html to contain %q", needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
57
web/embed.go
Normal file
57
web/embed.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed templates/* static/*
|
||||
var content embed.FS
|
||||
|
||||
var pageTemplate = template.Must(template.New("view.html").Funcs(template.FuncMap{
|
||||
"statusClass": statusClass,
|
||||
"joinLines": joinLines,
|
||||
}).ParseFS(content, "templates/view.html"))
|
||||
|
||||
func Render(data any) ([]byte, error) {
|
||||
var out strings.Builder
|
||||
if err := pageTemplate.ExecuteTemplate(&out, "view.html", data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(out.String()), nil
|
||||
}
|
||||
|
||||
func Static() http.Handler {
|
||||
sub, err := fs.Sub(content, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return http.FileServer(http.FS(sub))
|
||||
}
|
||||
|
||||
func statusClass(value string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||
case "OK":
|
||||
return "status-ok"
|
||||
case "WARNING":
|
||||
return "status-warning"
|
||||
case "CRITICAL":
|
||||
return "status-critical"
|
||||
case "UNKNOWN":
|
||||
return "status-unknown"
|
||||
case "EMPTY":
|
||||
return "status-empty"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func joinLines(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(value, "\n")
|
||||
}
|
||||
214
web/static/view.css
Normal file
214
web/static/view.css
Normal file
@@ -0,0 +1,214 @@
|
||||
:root {
|
||||
--bg: #f4f1ea;
|
||||
--panel: #fffdf9;
|
||||
--border: #d8d0c3;
|
||||
--ink: #26231e;
|
||||
--muted: #6a6258;
|
||||
--accent: #8f3b2e;
|
||||
--ok-bg: #d8f1df;
|
||||
--ok-fg: #1f6a32;
|
||||
--warn-bg: #fff0bf;
|
||||
--warn-fg: #8a6200;
|
||||
--crit-bg: #ffd7d2;
|
||||
--crit-fg: #9a2419;
|
||||
--unknown-bg: #e3e5e8;
|
||||
--unknown-fg: #4f5965;
|
||||
--empty-bg: #ece7de;
|
||||
--empty-fg: #72675b;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, #efe1cf 0, transparent 30%),
|
||||
linear-gradient(180deg, #f8f4ed 0%, var(--bg) 100%);
|
||||
color: var(--ink);
|
||||
font: 14px/1.45 "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 24px 28px 18px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.page-main {
|
||||
width: min(1500px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 20px 0 32px;
|
||||
}
|
||||
|
||||
.input-panel,
|
||||
.meta-panel,
|
||||
.section-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 30px rgba(78, 54, 26, 0.05);
|
||||
padding: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.input-label,
|
||||
.section-card h2,
|
||||
.meta-panel h2 {
|
||||
display: block;
|
||||
margin: 0 0 12px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 220px;
|
||||
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 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
padding: 10px 16px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--crit-fg);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: var(--crit-bg);
|
||||
color: var(--crit-fg);
|
||||
}
|
||||
|
||||
.section-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.section-nav a {
|
||||
text-decoration: none;
|
||||
color: var(--accent);
|
||||
background: rgba(143, 59, 46, 0.08);
|
||||
padding: 7px 11px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.kv-table,
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.kv-table th,
|
||||
.kv-table td,
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
vertical-align: top;
|
||||
text-align: left;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.kv-table tr:first-child th,
|
||||
.kv-table tr:first-child td,
|
||||
.data-table tr:first-child th,
|
||||
.data-table tr:first-child td {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.kv-table th,
|
||||
.data-table th {
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f7f1e7;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
padding: 3px 9px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-ok {
|
||||
background: var(--ok-bg);
|
||||
color: var(--ok-fg);
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn-fg);
|
||||
}
|
||||
|
||||
.status-critical {
|
||||
background: var(--crit-bg);
|
||||
color: var(--crit-fg);
|
||||
}
|
||||
|
||||
.status-unknown {
|
||||
background: var(--unknown-bg);
|
||||
color: var(--unknown-fg);
|
||||
}
|
||||
|
||||
.status-empty {
|
||||
background: var(--empty-bg);
|
||||
color: var(--empty-fg);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page-main {
|
||||
width: min(100vw - 20px, 1500px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 18px 14px 14px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.input-panel,
|
||||
.meta-panel,
|
||||
.section-card {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
111
web/templates/view.html
Normal file
111
web/templates/view.html
Normal file
@@ -0,0 +1,111 @@
|
||||
<!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="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 }}
|
||||
<section class="meta-panel">
|
||||
<h2>Snapshot Metadata</h2>
|
||||
<table class="kv-table">
|
||||
<tbody>
|
||||
{{ range .Meta }}
|
||||
<tr>
|
||||
<th>{{ .Key }}</th>
|
||||
<td>{{ .Value }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<nav class="section-nav">
|
||||
{{ range .Sections }}
|
||||
<a href="#{{ .ID }}">{{ .Title }}</a>
|
||||
{{ end }}
|
||||
</nav>
|
||||
|
||||
{{ range .Sections }}
|
||||
<section class="section-card" id="{{ .ID }}">
|
||||
<h2>{{ .Title }}</h2>
|
||||
|
||||
{{ if eq .Kind "object" }}
|
||||
<table class="kv-table">
|
||||
<tbody>
|
||||
{{ range .Rows }}
|
||||
<tr>
|
||||
<th>{{ .Key }}</th>
|
||||
<td>
|
||||
{{ range joinLines .Value }}
|
||||
<div>{{ . }}</div>
|
||||
{{ end }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ end }}
|
||||
|
||||
{{ if eq .Kind "table" }}
|
||||
{{ $section := . }}
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{{ range .Columns }}
|
||||
<th>{{ . }}</th>
|
||||
{{ end }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Items }}
|
||||
<tr>
|
||||
{{ $row := . }}
|
||||
{{ range $section.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>
|
||||
{{ end }}
|
||||
</section>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user