Bootstrap reanimator chart viewer

This commit is contained in:
Mikhail Chusavitin
2026-03-15 17:28:19 +03:00
commit df91e24fea
22 changed files with 1231 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.DS_Store
bin/

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "bible"]
path = bible
url = https://git.mchus.pro/mchus/bible.git

23
AGENTS.md Normal file
View 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
View 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

Submodule bible added at 5a69e0bba8

22
bible-local/README.md Normal file
View 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

View 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

View 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.

View 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

View 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`

View 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

View File

@@ -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.

View 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.
```

View 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)
}
}

4
go.mod Normal file
View File

@@ -0,0 +1,4 @@
module reanimator/chart
go 1.24.0

73
viewer/handler.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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>