548 lines
13 KiB
Go
548 lines
13 KiB
Go
package viewer
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"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 hiddenFields = map[string]struct{}{
|
|
"status_at_collection": {},
|
|
}
|
|
|
|
var hiddenTableFields = map[string]struct{}{
|
|
"status_checked_at": {},
|
|
}
|
|
|
|
const vendorDeviceIDField = "ven:dev"
|
|
|
|
var commonPreferredColumns = []string{
|
|
"status",
|
|
"slot",
|
|
"location",
|
|
"vendor",
|
|
"manufacturer",
|
|
"device_class",
|
|
"type",
|
|
"device_name",
|
|
"name",
|
|
"model",
|
|
"product_name",
|
|
"part_number",
|
|
"serial_number",
|
|
vendorDeviceIDField,
|
|
"firmware",
|
|
"version",
|
|
"bdf",
|
|
}
|
|
|
|
var preferredColumns = map[string][]string{
|
|
"firmware": {"device_name", "version"},
|
|
"cpus": {"model", "clock", "cores", "threads", "l1", "l2", "l3", "microcode", "socket"},
|
|
"memory": {"part_number", "serial_number", "slot"},
|
|
"storage": {"type", "model", "serial_number", "firmware", "size_gb", "slot"},
|
|
"pcie_devices": {"device_class", "manufacturer", "model", "serial_number", "slot", "bdf"},
|
|
"power_supplies": {"vendor", "model", "part_number", "serial_number", "slot"},
|
|
"fans": {"name", "rpm"},
|
|
"power": {"name", "voltage_v", "current_a", "power_w"},
|
|
"temperatures": {"name", "celsius", "threshold_warning_celsius", "threshold_critical_celsius"},
|
|
"other": {"name", "value", "unit"},
|
|
}
|
|
|
|
type RenderOptions struct {
|
|
DownloadArchiveURL string
|
|
DownloadArchiveLabel string
|
|
NoticeTitle string
|
|
NoticeBody string
|
|
}
|
|
|
|
func RenderHTML(snapshot []byte, title string) ([]byte, error) {
|
|
return RenderHTMLWithOptions(snapshot, title, RenderOptions{})
|
|
}
|
|
|
|
func RenderHTMLWithOptions(snapshot []byte, title string, opts RenderOptions) ([]byte, error) {
|
|
page, err := buildPageData(snapshot, title, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return web.Render(page)
|
|
}
|
|
|
|
func buildPageData(snapshot []byte, title string, opts RenderOptions) (pageData, error) {
|
|
page := pageData{
|
|
Title: title,
|
|
NoticeTitle: strings.TrimSpace(opts.NoticeTitle),
|
|
NoticeBody: strings.TrimSpace(opts.NoticeBody),
|
|
DownloadArchiveURL: strings.TrimSpace(opts.DownloadArchiveURL),
|
|
DownloadArchiveLabel: strings.TrimSpace(opts.DownloadArchiveLabel),
|
|
}
|
|
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.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 isHiddenField(key) {
|
|
continue
|
|
}
|
|
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 isHiddenField(key) {
|
|
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:
|
|
if key == "pcie_devices" {
|
|
return []sectionView{buildPCIeSection(typed)}
|
|
}
|
|
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] = formatRowValue(column, row)
|
|
}
|
|
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 buildPCIeSection(items []any) sectionView {
|
|
grouped := make(map[string][]map[string]any)
|
|
classNames := make([]string, 0)
|
|
seenClasses := make(map[string]struct{})
|
|
|
|
for _, item := range items {
|
|
row, ok := item.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
className := strings.TrimSpace(formatValue(row["device_class"]))
|
|
if className == "" {
|
|
className = "Unclassified"
|
|
}
|
|
grouped[className] = append(grouped[className], row)
|
|
if _, ok := seenClasses[className]; !ok {
|
|
seenClasses[className] = struct{}{}
|
|
classNames = append(classNames, className)
|
|
}
|
|
}
|
|
|
|
sort.Strings(classNames)
|
|
groups := make([]tableGroupView, 0, len(classNames))
|
|
for _, className := range classNames {
|
|
rows := grouped[className]
|
|
sortPCIeRows(rows)
|
|
columns := collectColumns("pcie_devices", rows)
|
|
items := make([]tableRow, 0, len(rows))
|
|
for _, row := range rows {
|
|
cells := make(map[string]string, len(columns))
|
|
for _, column := range columns {
|
|
cells[column] = formatRowValue(column, row)
|
|
}
|
|
items = append(items, tableRow{
|
|
Status: strings.TrimSpace(cells["status"]),
|
|
Cells: cells,
|
|
RawCells: row,
|
|
})
|
|
}
|
|
groups = append(groups, tableGroupView{
|
|
Title: className,
|
|
Columns: columns,
|
|
Items: items,
|
|
})
|
|
}
|
|
|
|
return sectionView{
|
|
ID: "pcie_devices",
|
|
Title: titleFor("pcie_devices"),
|
|
Kind: "grouped_tables",
|
|
Groups: groups,
|
|
}
|
|
}
|
|
|
|
func collectColumns(section string, rows []map[string]any) []string {
|
|
seen := make(map[string]struct{})
|
|
for _, row := range rows {
|
|
for key := range row {
|
|
if isHiddenTableField(section, key) {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
}
|
|
if hasVendorDeviceID(row) {
|
|
seen[vendorDeviceIDField] = struct{}{}
|
|
}
|
|
}
|
|
|
|
columns := make([]string, 0, len(seen))
|
|
for _, key := range append(commonPreferredColumns, 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 {
|
|
if isHiddenField(key) {
|
|
continue
|
|
}
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
rows := make([]fieldRow, 0, len(keys))
|
|
if combinedVendorDeviceID := formatVendorDeviceID(object); combinedVendorDeviceID != "" {
|
|
rows = append(rows, fieldRow{Key: vendorDeviceIDField, Value: combinedVendorDeviceID})
|
|
}
|
|
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 formatStringValue(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:
|
|
return formatObjectValue(typed)
|
|
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 formatStringValue(text)
|
|
}
|
|
}
|
|
|
|
func formatObjectValue(value map[string]any) string {
|
|
keys := make([]string, 0, len(value))
|
|
for key := range value {
|
|
if isHiddenField(key) {
|
|
continue
|
|
}
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
lines := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
formatted := formatValue(value[key])
|
|
if strings.TrimSpace(formatted) == "" {
|
|
lines = append(lines, key+":")
|
|
continue
|
|
}
|
|
|
|
parts := strings.Split(formatted, "\n")
|
|
lines = append(lines, key+": "+parts[0])
|
|
for _, part := range parts[1:] {
|
|
lines = append(lines, " "+part)
|
|
}
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func formatStringValue(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
|
|
if formatted, ok := formatDate(value); ok {
|
|
return formatted
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
func formatRowValue(column string, row map[string]any) string {
|
|
if column == vendorDeviceIDField {
|
|
return formatVendorDeviceID(row)
|
|
}
|
|
return formatValue(row[column])
|
|
}
|
|
|
|
func formatVendorDeviceID(value map[string]any) string {
|
|
vendorID := strings.TrimSpace(formatValue(value["vendor_id"]))
|
|
deviceID := strings.TrimSpace(formatValue(value["device_id"]))
|
|
if vendorID == "" || deviceID == "" {
|
|
return ""
|
|
}
|
|
return vendorID + ":" + deviceID
|
|
}
|
|
|
|
func formatDate(value string) (string, bool) {
|
|
layouts := []struct {
|
|
layout string
|
|
hasTime bool
|
|
}{
|
|
{time.RFC3339Nano, true},
|
|
{time.RFC3339, true},
|
|
{"2006-01-02 15:04:05", true},
|
|
{"2006-01-02 15:04", true},
|
|
{"2006-01-02", false},
|
|
}
|
|
|
|
for _, candidate := range layouts {
|
|
parsed, err := time.Parse(candidate.layout, value)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if !candidate.hasTime {
|
|
return parsed.Format("02 Jan 2006"), true
|
|
}
|
|
|
|
if parsed.Location() == time.UTC {
|
|
return parsed.Format("02 Jan 2006, 15:04 UTC"), true
|
|
}
|
|
|
|
return parsed.Format("02 Jan 2006, 15:04 -07:00"), true
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
func titleFor(key string) string {
|
|
if value, ok := sectionTitles[key]; ok {
|
|
return value
|
|
}
|
|
return strings.ReplaceAll(strings.Title(strings.ReplaceAll(key, "_", " ")), "Pcie", "PCIe")
|
|
}
|
|
|
|
func isHiddenField(key string) bool {
|
|
if key == "vendor_id" || key == "device_id" {
|
|
return true
|
|
}
|
|
_, ok := hiddenFields[key]
|
|
return ok
|
|
}
|
|
|
|
func hasVendorDeviceID(value map[string]any) bool {
|
|
return formatVendorDeviceID(value) != ""
|
|
}
|
|
|
|
func isHiddenTableField(section string, key string) bool {
|
|
if isHiddenField(key) {
|
|
return true
|
|
}
|
|
if section == "pcie_devices" && key == "device_class" {
|
|
return true
|
|
}
|
|
_, ok := hiddenTableFields[key]
|
|
return ok
|
|
}
|
|
|
|
func sortPCIeRows(rows []map[string]any) {
|
|
sort.SliceStable(rows, func(i, j int) bool {
|
|
left := []string{
|
|
formatRowValue("slot", rows[i]),
|
|
formatRowValue("location", rows[i]),
|
|
formatRowValue("vendor", rows[i]),
|
|
formatRowValue("model", rows[i]),
|
|
formatRowValue("serial_number", rows[i]),
|
|
formatRowValue(vendorDeviceIDField, rows[i]),
|
|
formatRowValue("bdf", rows[i]),
|
|
}
|
|
right := []string{
|
|
formatRowValue("slot", rows[j]),
|
|
formatRowValue("location", rows[j]),
|
|
formatRowValue("vendor", rows[j]),
|
|
formatRowValue("model", rows[j]),
|
|
formatRowValue("serial_number", rows[j]),
|
|
formatRowValue(vendorDeviceIDField, rows[j]),
|
|
formatRowValue("bdf", rows[j]),
|
|
}
|
|
|
|
for idx := range left {
|
|
if left[idx] == right[idx] {
|
|
continue
|
|
}
|
|
return left[idx] < right[idx]
|
|
}
|
|
return false
|
|
})
|
|
}
|