Split embedded and standalone chart surfaces

This commit is contained in:
Mikhail Chusavitin
2026-03-15 21:41:38 +03:00
parent df91e24fea
commit 5ce37f9997
15 changed files with 1039 additions and 86 deletions

View File

@@ -1,7 +1,10 @@
package viewer
import (
"errors"
"fmt"
"io"
"mime"
"net/http"
"strings"
@@ -9,10 +12,21 @@ import (
)
type HandlerOptions struct {
Title string
Title string
Standalone bool
}
func NewHandler(opts HandlerOptions) http.Handler {
opts.Standalone = false
return newHandler(opts)
}
func NewStandaloneHandler(opts HandlerOptions) http.Handler {
opts.Standalone = true
return newHandler(opts)
}
func newHandler(opts HandlerOptions) http.Handler {
title := strings.TrimSpace(opts.Title)
if title == "" {
title = "Reanimator Chart"
@@ -25,7 +39,15 @@ func NewHandler(opts HandlerOptions) http.Handler {
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
html, err := RenderHTML(nil, title)
var (
html []byte
err error
)
if opts.Standalone {
html, err = web.RenderUpload(pageData{Title: title})
} else {
html, err = RenderHTML(nil, title)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -34,31 +56,28 @@ func NewHandler(opts HandlerOptions) http.Handler {
_, _ = 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")
payload, err := readSnapshotPayload(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
page, err := buildPageData([]byte(payload), title)
if err != nil {
page = pageData{
Title: title,
Error: err.Error(),
InputJSON: prettyJSON(payload),
if opts.Standalone {
html, renderErr := web.RenderUpload(pageData{
Title: title,
Error: err.Error(),
})
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
}
} else {
page.InputJSON = prettyJSON(payload)
page = pageData{Title: title, Error: err.Error()}
}
html, renderErr := web.Render(page)
@@ -71,3 +90,51 @@ func NewHandler(opts HandlerOptions) http.Handler {
})
return mux
}
func readSnapshotPayload(r *http.Request) (string, error) {
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return "", fmt.Errorf("parse content type: %w", err)
}
switch mediaType {
case "application/json":
body, err := io.ReadAll(r.Body)
if err != nil {
return "", fmt.Errorf("read request body: %w", err)
}
return string(body), nil
case "multipart/form-data":
if err := r.ParseMultipartForm(32 << 20); err != nil {
return "", fmt.Errorf("parse multipart form: %w", err)
}
payload, err := readSnapshotFile(r, "snapshot_file")
if err == nil && strings.TrimSpace(payload) != "" {
return payload, nil
}
if err != nil && !errors.Is(err, http.ErrMissingFile) {
return "", err
}
return r.FormValue("snapshot"), nil
default:
if err := r.ParseForm(); err != nil {
return "", fmt.Errorf("parse form: %w", err)
}
return r.FormValue("snapshot"), nil
}
}
func readSnapshotFile(r *http.Request, field string) (string, error) {
file, _, err := r.FormFile(field)
if err != nil {
return "", fmt.Errorf("read uploaded file: %w", err)
}
defer file.Close()
body, err := io.ReadAll(file)
if err != nil {
return "", fmt.Errorf("read uploaded file contents: %w", err)
}
return string(body), nil
}

84
viewer/handler_test.go Normal file
View File

@@ -0,0 +1,84 @@
package viewer
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestRenderAcceptsMultipartSnapshotFile(t *testing.T) {
handler := NewStandaloneHandler(HandlerOptions{Title: "Reanimator Chart"})
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("snapshot_file", "snapshot.json")
if err != nil {
t.Fatalf("CreateFormFile() error = %v", err)
}
snapshot := `{
"target_host": "upload-host",
"hardware": {
"board": {
"serial_number": "BOARD-002"
}
}
}`
if _, err := part.Write([]byte(snapshot)); err != nil {
t.Fatalf("Write() error = %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/render", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
response := rec.Body.String()
for _, needle := range []string{"upload-host", "BOARD-002", "Board"} {
if !strings.Contains(response, needle) {
t.Fatalf("expected response to contain %q", needle)
}
}
}
func TestEmbeddedHandlerRootHasNoUploadForm(t *testing.T) {
handler := NewHandler(HandlerOptions{Title: "Reanimator Chart"})
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if strings.Contains(rec.Body.String(), "multipart/form-data") {
t.Fatalf("expected embedded handler root to omit upload form")
}
}
func TestStandaloneHandlerRootShowsUploadForm(t *testing.T) {
handler := NewStandaloneHandler(HandlerOptions{Title: "Reanimator Chart"})
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
body := rec.Body.String()
if !strings.Contains(body, "multipart/form-data") || !strings.Contains(body, "snapshot_file") {
t.Fatalf("expected standalone handler root to include upload form")
}
}

View File

@@ -4,7 +4,6 @@ type pageData struct {
Title string
HasSnapshot bool
Error string
InputJSON string
Meta []fieldRow
Sections []sectionView
}
@@ -16,6 +15,7 @@ type sectionView struct {
Rows []fieldRow
Columns []string
Items []tableRow
Groups []tableGroupView
}
type fieldRow struct {
@@ -28,3 +28,9 @@ type tableRow struct {
Cells map[string]string
RawCells map[string]any
}
type tableGroupView struct {
Title string
Columns []string
Items []tableRow
}

View File

@@ -1,11 +1,11 @@
package viewer
import (
"bytes"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"reanimator/chart/web"
)
@@ -38,17 +38,47 @@ var sectionTitles = map[string]string{
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": {"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"},
"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"},
}
func RenderHTML(snapshot []byte, title string) ([]byte, error) {
@@ -71,7 +101,6 @@ func buildPageData(snapshot []byte, title string) (pageData, error) {
}
page.HasSnapshot = true
page.InputJSON = strings.TrimSpace(string(snapshot))
page.Meta = buildMeta(root)
page.Sections = buildSections(root)
return page, nil
@@ -81,6 +110,9 @@ 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{}{}
@@ -91,6 +123,9 @@ func buildMeta(root map[string]any) []fieldRow {
if key == "hardware" {
continue
}
if isHiddenField(key) {
continue
}
if _, ok := used[key]; ok {
continue
}
@@ -147,6 +182,9 @@ func buildSection(key string, value any) []sectionView {
Rows: buildFieldRows(typed),
}}
case []any:
if key == "pcie_devices" {
return []sectionView{buildPCIeSection(typed)}
}
return []sectionView{buildTableSection(key, typed)}
default:
return []sectionView{{
@@ -192,7 +230,7 @@ func buildTableSection(key string, items []any) sectionView {
for _, row := range rows {
cells := make(map[string]string, len(columns))
for _, column := range columns {
cells[column] = formatValue(row[column])
cells[column] = formatRowValue(column, row)
}
status := strings.TrimSpace(cells["status"])
tableRows = append(tableRows, tableRow{
@@ -211,16 +249,76 @@ func buildTableSection(key string, items []any) sectionView {
}
}
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 preferredColumns[section] {
for _, key := range append(commonPreferredColumns, preferredColumns[section]...) {
if _, ok := seen[key]; ok {
columns = append(columns, key)
delete(seen, key)
@@ -238,11 +336,17 @@ func collectColumns(section string, rows []map[string]any) []string {
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])})
}
@@ -255,7 +359,7 @@ func formatValue(value any) string {
}
switch typed := value.(type) {
case string:
return typed
return formatStringValue(typed)
case []any:
parts := make([]string, 0, len(typed))
for _, item := range typed {
@@ -263,8 +367,7 @@ func formatValue(value any) string {
}
return strings.Join(parts, "\n")
case map[string]any:
data, _ := json.MarshalIndent(typed, "", " ")
return string(data)
return formatObjectValue(typed)
default:
data, err := json.Marshal(typed)
if err != nil {
@@ -273,10 +376,98 @@ func formatValue(value any) string {
text := string(data)
text = strings.TrimPrefix(text, `"`)
text = strings.TrimSuffix(text, `"`)
return 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
@@ -284,13 +475,56 @@ func titleFor(key string) string {
return strings.ReplaceAll(strings.Title(strings.ReplaceAll(key, "_", " ")), "Pcie", "PCIe")
}
func prettyJSON(input string) string {
if strings.TrimSpace(input) == "" {
return ""
func isHiddenField(key string) bool {
if key == "vendor_id" || key == "device_id" {
return true
}
var out bytes.Buffer
if err := json.Indent(&out, []byte(input), "", " "); err != nil {
return input
}
return out.String()
_, 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
})
}

View File

@@ -1,6 +1,7 @@
package viewer
import (
"reflect"
"strings"
"testing"
)
@@ -34,6 +35,7 @@ func TestRenderHTMLIncludesKnownSectionsAndFields(t *testing.T) {
for _, needle := range []string{
"Reanimator Chart",
"test-host",
"15 Mar 2026, 12:00 UTC",
"Board",
"CPUs",
"BOARD-001",
@@ -45,4 +47,226 @@ func TestRenderHTMLIncludesKnownSectionsAndFields(t *testing.T) {
t.Fatalf("expected rendered html to contain %q", needle)
}
}
if strings.Contains(text, "2026-03-15T12:00:00Z") {
t.Fatalf("expected RFC3339 timestamp to be rendered in human-readable form")
}
}
func TestRenderHTMLFormatsNestedObjectsWithoutRawJSON(t *testing.T) {
snapshot := []byte(`{
"target_host": "nested-host",
"hardware": {
"board": {
"status_history": {
"last_change": "2026-03-15T12:30:00Z",
"reason": "manual review"
}
}
}
}`)
html, err := RenderHTML(snapshot, "Reanimator Chart")
if err != nil {
t.Fatalf("RenderHTML() error = %v", err)
}
text := string(html)
for _, needle := range []string{
"status_history",
"last_change: 15 Mar 2026, 12:30 UTC",
"reason: manual review",
} {
if !strings.Contains(text, needle) {
t.Fatalf("expected rendered html to contain %q", needle)
}
}
if strings.Contains(text, "{&#34;") || strings.Contains(text, "\"last_change\"") {
t.Fatalf("expected nested object to render without raw JSON blob")
}
}
func TestCollectColumnsOrdersStatusThenLocationThenIdentity(t *testing.T) {
rows := []map[string]any{
{
"serial_number": "SN-1",
"model": "Model-X",
"vendor": "Vendor-A",
"vendor_id": "8086",
"device_id": "1234",
"firmware": "1.2.3",
"location": "Bay 4",
"slot": "Slot 9",
"status": "OK",
"status_at_collection": "OK",
"status_checked_at": "2026-03-15T12:40:00Z",
"temperature_c": 40,
},
}
got := collectColumns("storage", rows)
want := []string{
"status",
"slot",
"location",
"vendor",
"model",
"serial_number",
"ven:dev",
"firmware",
"temperature_c",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("collectColumns() = %v, want %v", got, want)
}
}
func TestCollectColumnsOrdersCPUFields(t *testing.T) {
rows := []map[string]any{
{
"model": "Xeon Gold",
"clock": "2.90 GHz",
"cores": 16,
"threads": 32,
"l1": "1 MiB",
"l2": "16 MiB",
"l3": "22 MiB",
"microcode": "0xd000375",
"socket": "CPU0",
"status": "OK",
},
}
got := collectColumns("cpus", rows)
want := []string{
"status",
"model",
"clock",
"cores",
"threads",
"l1",
"l2",
"l3",
"microcode",
"socket",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("collectColumns() = %v, want %v", got, want)
}
}
func TestRenderHTMLHidesStatusAtCollection(t *testing.T) {
snapshot := []byte(`{
"target_host": "hidden-field-host",
"hardware": {
"storage": [
{
"status": "OK",
"status_at_collection": "OK",
"status_checked_at": "2026-03-15T12:45:00Z",
"location": "Bay 1",
"vendor": "Vendor-A",
"model": "Model-Z"
}
],
"board": {
"status": "OK",
"status_at_collection": "OK",
"status_checked_at": "2026-03-15T12:40:00Z"
}
}
}`)
html, err := RenderHTML(snapshot, "Reanimator Chart")
if err != nil {
t.Fatalf("RenderHTML() error = %v", err)
}
text := string(html)
if strings.Contains(text, "status_at_collection") {
t.Fatalf("expected status_at_collection to be hidden from rendered output")
}
if !strings.Contains(text, "<th>status_checked_at</th>") {
t.Fatalf("expected status_checked_at to remain visible in object sections")
}
if strings.Contains(text, "<thead>\n <tr>\n <th>status_checked_at</th>") {
t.Fatalf("expected status_checked_at to be hidden from table headers")
}
}
func TestRenderHTMLCombinesVendorAndDeviceID(t *testing.T) {
snapshot := []byte(`{
"target_host": "pci-host",
"hardware": {
"pcie_devices": [
{
"status": "OK",
"location": "Slot 3",
"vendor": "Intel",
"model": "Ethernet Adapter",
"vendor_id": "8086",
"device_id": "1234"
}
]
}
}`)
html, err := RenderHTML(snapshot, "Reanimator Chart")
if err != nil {
t.Fatalf("RenderHTML() error = %v", err)
}
text := string(html)
if !strings.Contains(text, "ven:dev") {
t.Fatalf("expected combined vendor/device id column to be rendered")
}
if !strings.Contains(text, "8086:1234") {
t.Fatalf("expected vendor/device id value to be rendered as ven:dev")
}
if strings.Contains(text, "<th>vendor_id</th>") || strings.Contains(text, "<th>device_id</th>") {
t.Fatalf("expected raw vendor_id and device_id columns to be hidden")
}
}
func TestRenderHTMLGroupsPCIeDevicesByClass(t *testing.T) {
snapshot := []byte(`{
"target_host": "pcie-group-host",
"hardware": {
"pcie_devices": [
{
"status": "OK",
"slot": "Slot 2",
"device_class": "Network controller",
"vendor": "Vendor-B",
"model": "NIC-B"
},
{
"status": "Warning",
"slot": "Slot 1",
"device_class": "Display controller",
"vendor": "Vendor-A",
"model": "GPU-A"
}
]
}
}`)
html, err := RenderHTML(snapshot, "Reanimator Chart")
if err != nil {
t.Fatalf("RenderHTML() error = %v", err)
}
text := string(html)
if !strings.Contains(text, "<h3>Display controller</h3>") || !strings.Contains(text, "<h3>Network controller</h3>") {
t.Fatalf("expected PCIe devices to be split into class subheadings")
}
if strings.Contains(text, "<th>device_class</th>") {
t.Fatalf("expected device_class column to be hidden from PCIe tables")
}
if strings.Index(text, "<h3>Display controller</h3>") > strings.Index(text, "<h3>Network controller</h3>") {
t.Fatalf("expected PCIe class groups to be sorted by device_class")
}
}