Files
chart/viewer/render.go
2026-03-15 17:28:19 +03:00

297 lines
7.1 KiB
Go

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