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