Bootstrap reanimator chart viewer
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user