diff --git a/bible-local/architecture/ui-information-architecture.md b/bible-local/architecture/ui-information-architecture.md index e6b196d..6247edf 100644 --- a/bible-local/architecture/ui-information-architecture.md +++ b/bible-local/architecture/ui-information-architecture.md @@ -238,6 +238,10 @@ List behavior checklist (page-by-page): Assets list specifics: - `All Assets` table filtering is server-side and applies to the full dataset before pagination. +- `Actions` section includes: + - `New Asset` + - `Compare config` (opens in a separate window/tab, compares hardware composition for selected servers, shows differences) + - `Delete Selected (With Details)` - `All Assets` supports global bulk selection: - Header `Select` checkbox can select all assets across all pagination pages. - Selected IDs persist across page navigation and are stored in browser local storage. diff --git a/internal/api/ui.go b/internal/api/ui.go index f5f1b70..62e6dc9 100644 --- a/internal/api/ui.go +++ b/internal/api/ui.go @@ -1371,10 +1371,23 @@ func detectFirmwareMismatchByComponent(components []domain.Component, firmwareBy func normalizeComponentType(value string) string { normalized := strings.ToLower(strings.TrimSpace(value)) - if normalized == "" { + if normalized == "" || normalized == "null" || normalized == "-" { return "unknown" } - return normalized + switch normalized { + case "cpu", "processor": + return "cpu" + case "memory", "ram", "dimm", "memory_module": + return "memory" + case "storage", "disk", "drive", "nvme": + return "storage" + case "pcie", "pci", "pcie_device", "network_adapter": + return "pcie_device" + case "psu", "power_supply", "power supply": + return "power_supply" + default: + return normalized + } } func componentTypeTitle(typeKey string) string { diff --git a/internal/api/ui_assets_list.tmpl b/internal/api/ui_assets_list.tmpl index c4b42c7..57648eb 100644 --- a/internal/api/ui_assets_list.tmpl +++ b/internal/api/ui_assets_list.tmpl @@ -11,6 +11,7 @@

Actions

+
@@ -135,6 +136,7 @@ const newAssetButton = document.getElementById('new-asset'); const createAssetCancelButton = document.getElementById('asset-create-cancel'); const createAssetForm = document.getElementById('asset-create-form'); + const compareAssetsButton = document.getElementById('compare-assets-config'); const selectAllAssetsCheckbox = document.getElementById('select-all-assets'); const pageAssetCheckboxes = [...document.querySelectorAll('.asset-select')]; const selectedAssetIDsSet = new Set(); @@ -165,6 +167,255 @@ } } + function escapeHTML(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + function normalizeText(value) { + return String(value ?? '').trim(); + } + + function normalizeCompareKey(value) { + return normalizeText(value).toLowerCase(); + } + + function componentDescriptor(item) { + const vendor = normalizeText(item.vendor || ''); + const model = normalizeText(item.model || ''); + const serial = normalizeText(item.vendor_serial || ''); + const firmware = normalizeText(item.firmware || ''); + const base = [vendor, model].filter(Boolean).join(' '); + const withSerial = serial ? `${base || 'Component'} [${serial}]` : (base || 'Component'); + return firmware ? `${withSerial} · FW ${firmware}` : withSerial; + } + + function componentFamilyKey(item) { + return [ + normalizeCompareKey(item.type || ''), + normalizeCompareKey(item.vendor || ''), + normalizeCompareKey(item.model || '') + ].join('|'); + } + + function componentFamilyLabel(item) { + const type = normalizeText(item.type || ''); + const vendor = normalizeText(item.vendor || ''); + const model = normalizeText(item.model || ''); + const identity = [vendor, model].filter(Boolean).join(' '); + if (type && identity) { + return `${type}: ${identity}`; + } + return identity || type || 'Unknown component'; + } + + function stableAssetLabel(asset) { + const name = normalizeText(asset.name || ''); + const serial = normalizeText(asset.vendor_serial || ''); + if (name && serial) { + return `${name} (${serial})`; + } + return name || serial || asset.id; + } + + async function loadAssetCompareSnapshot(assetID) { + const [assetResp, configResp] = await Promise.all([ + fetch('/assets/' + encodeURIComponent(assetID)), + fetch('/api/assets/' + encodeURIComponent(assetID) + '/current-components') + ]); + if (!assetResp.ok) { + throw new Error('Failed to load asset ' + assetID); + } + const asset = await assetResp.json(); + let items = []; + if (configResp.ok) { + const body = await configResp.json().catch(() => ({})); + if (Array.isArray(body.items)) { + items = body.items.map((item) => ({ + type: normalizeText(item.type || ''), + slot: normalizeText(item.slot || ''), + vendor: normalizeText(item.vendor || ''), + model: normalizeText(item.model || ''), + vendor_serial: normalizeText(item.vendor_serial || ''), + firmware: normalizeText(item.firmware || '') + })); + } + } else { + const fallback = await fetch('/assets/' + encodeURIComponent(assetID) + '/components'); + if (fallback.ok) { + const body = await fallback.json().catch(() => ({})); + const rawItems = Array.isArray(body.items) ? body.items : []; + items = rawItems.map((item) => ({ + type: '', + slot: '', + vendor: normalizeText(item.vendor || ''), + model: normalizeText(item.model || ''), + vendor_serial: normalizeText(item.vendor_serial || ''), + firmware: '' + })); + } + } + return {asset, items}; + } + + function renderCompareResultHTML(snapshots) { + const assetIDs = snapshots.map((s) => s.asset.id); + const assetLabelByID = new Map(snapshots.map((s) => [s.asset.id, stableAssetLabel(s.asset)])); + + const slotRowsMap = new Map(); + snapshots.forEach((snapshot) => { + snapshot.items.forEach((item) => { + const slot = normalizeText(item.slot || ''); + if (!slot) { + return; + } + if (!slotRowsMap.has(slot)) { + slotRowsMap.set(slot, new Map()); + } + slotRowsMap.get(slot).set(snapshot.asset.id, componentDescriptor(item)); + }); + }); + + const slotRows = []; + [...slotRowsMap.keys()].sort((a, b) => a.localeCompare(b)).forEach((slot) => { + const byAsset = slotRowsMap.get(slot); + const signatures = assetIDs.map((id) => normalizeCompareKey(byAsset.get(id) || '')); + const unique = new Set(signatures); + if (unique.size <= 1) { + return; + } + slotRows.push({slot, byAsset}); + }); + + const familyMap = new Map(); + snapshots.forEach((snapshot) => { + snapshot.items.forEach((item) => { + const key = componentFamilyKey(item); + if (!familyMap.has(key)) { + familyMap.set(key, {label: componentFamilyLabel(item), byAsset: new Map()}); + } + const row = familyMap.get(key); + const current = row.byAsset.get(snapshot.asset.id) || 0; + row.byAsset.set(snapshot.asset.id, current + 1); + }); + }); + + const modelRows = []; + [...familyMap.entries()] + .sort((a, b) => a[1].label.localeCompare(b[1].label)) + .forEach(([, row]) => { + const counts = assetIDs.map((id) => row.byAsset.get(id) || 0); + if (new Set(counts).size <= 1) { + return; + } + modelRows.push(row); + }); + + const slotTable = slotRows.length > 0 + ? ` +

Differences By Slot

+ + + + + ${assetIDs.map((id) => ``).join('')} + + + + ${slotRows.map((row) => ` + + + ${assetIDs.map((id) => ``).join('')} + + `).join('')} + +
Slot${escapeHTML(assetLabelByID.get(id) || id)}
${escapeHTML(row.slot)}${escapeHTML(row.byAsset.get(id) || '—')}
+ ` + : `
No slot-level differences found.
`; + + const modelTable = modelRows.length > 0 + ? ` +

Differences By Component Count

+ + + + + ${assetIDs.map((id) => ``).join('')} + + + + ${modelRows.map((row) => ` + + + ${assetIDs.map((id) => ``).join('')} + + `).join('')} + +
Component family${escapeHTML(assetLabelByID.get(id) || id)}
${escapeHTML(row.label)}${row.byAsset.get(id) || 0}
+ ` + : `
No composition count differences found.
`; + + const hasDiff = slotRows.length > 0 || modelRows.length > 0; + const summary = hasDiff + ? `Compared ${snapshots.length} selected server(s). Differences are shown below.` + : `Compared ${snapshots.length} selected server(s). Configurations are identical by available fields.`; + return { + title: `Compare Config (${snapshots.length})`, + summary, + contentHTML: `${slotTable}${modelTable}` + }; + } + + function sharedHeadMarkup() { + return Array.from(document.querySelectorAll('link[rel="stylesheet"], style')) + .map((node) => node.outerHTML) + .join('\n'); + } + + function writeCompareWindow(compareWindow, title, bodyHTML) { + compareWindow.document.open(); + compareWindow.document.write(` + + + + + + ${escapeHTML(title)} + ${sharedHeadMarkup()} + + + ${bodyHTML} + + + `); + compareWindow.document.close(); + } + + function renderCompareWindow(compareWindow, data) { + writeCompareWindow(compareWindow, data.title, ` +
+
+
+

Compare Config

+ +
+
${escapeHTML(data.summary)}
+
+
+ ${data.contentHTML} +
+
+ `); + const closeBtn = compareWindow.document.getElementById('compare-window-close'); + if (closeBtn) { + closeBtn.addEventListener('click', () => compareWindow.close()); + } + } + if (newAssetButton) { newAssetButton.addEventListener('click', () => { setOwnershipMessage(''); @@ -385,6 +636,43 @@ setOwnershipMessage('Delete failed for: ' + failed.join('; ')); }); } + + if (compareAssetsButton) { + compareAssetsButton.addEventListener('click', async () => { + const assetIDs = selectedAssetIDs(); + if (assetIDs.length < 2) { + setOwnershipMessage('Select at least two servers to compare config.'); + return; + } + setOwnershipMessage(''); + const compareWindow = window.open('about:blank', '_blank'); + if (!compareWindow) { + setOwnershipMessage('Popup blocked. Allow popups for this site to open compare window.'); + return; + } + writeCompareWindow(compareWindow, 'Compare Config', ` +
+
+

Compare Config

+
Loading ${assetIDs.length} selected server(s)...
+
+
+ `); + try { + const snapshots = await Promise.all(assetIDs.map((id) => loadAssetCompareSnapshot(id))); + renderCompareWindow(compareWindow, renderCompareResultHTML(snapshots)); + } catch (error) { + writeCompareWindow(compareWindow, 'Compare Config', ` +
+
+

Compare Config

+
Unable to compare selected servers.
+
+
+ `); + } + }); + } diff --git a/internal/api/ui_component_type_test.go b/internal/api/ui_component_type_test.go new file mode 100644 index 0000000..4d67371 --- /dev/null +++ b/internal/api/ui_component_type_test.go @@ -0,0 +1,41 @@ +package api + +import "testing" + +func TestNormalizeComponentTypeAliases(t *testing.T) { + tests := []struct { + in string + want string + }{ + {in: "pcie", want: "pcie_device"}, + {in: "PCI", want: "pcie_device"}, + {in: "psu", want: "power_supply"}, + {in: "power supply", want: "power_supply"}, + {in: "DIMM", want: "memory"}, + {in: "ram", want: "memory"}, + {in: "processor", want: "cpu"}, + {in: " ", want: "unknown"}, + } + for _, tt := range tests { + if got := normalizeComponentType(tt.in); got != tt.want { + t.Fatalf("normalizeComponentType(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestComponentTypeTitleForAliases(t *testing.T) { + tests := []struct { + in string + want string + }{ + {in: "pcie", want: "PCIe Devices"}, + {in: "psu", want: "Power Supplies"}, + {in: "dimm", want: "Memory"}, + {in: "unknown_raw", want: "Other Components"}, + } + for _, tt := range tests { + if got := componentTypeTitle(tt.in); got != tt.want { + t.Fatalf("componentTypeTitle(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/internal/ingest/parser_hardware.go b/internal/ingest/parser_hardware.go index d352924..c92b263 100644 --- a/internal/ingest/parser_hardware.go +++ b/internal/ingest/parser_hardware.go @@ -313,7 +313,7 @@ func flattenPCIe(boardSerial string, items []HardwarePCIeDevice) []HardwareCompo continue } comp := HardwareComponent{ - ComponentType: "pcie", + ComponentType: "pcie_device", VendorSerial: serial, Vendor: normalize.VendorDisplayPtr(normalizeString(item.Manufacturer)), Model: normalizeString(item.Model), @@ -346,7 +346,7 @@ func flattenPSUs(boardSerial string, items []HardwarePowerSupply) []HardwareComp continue } comp := HardwareComponent{ - ComponentType: "psu", + ComponentType: "power_supply", VendorSerial: serial, Vendor: normalize.VendorDisplayPtr(normalizeString(item.Vendor)), Model: normalizeString(item.Model), diff --git a/internal/ingest/parser_hardware_test.go b/internal/ingest/parser_hardware_test.go new file mode 100644 index 0000000..f52c2c4 --- /dev/null +++ b/internal/ingest/parser_hardware_test.go @@ -0,0 +1,49 @@ +package ingest + +import "testing" + +func TestFlattenHardwareComponentsUsesCanonicalTypeKeys(t *testing.T) { + boardSerial := "BOARD-001" + input := HardwareSnapshot{ + Board: HardwareBoard{SerialNumber: boardSerial}, + PCIeDevices: []HardwarePCIeDevice{ + { + Slot: strPtr("PCIe.Slot.1"), + Status: strPtr("OK"), + }, + }, + PowerSupplies: []HardwarePowerSupply{ + { + Slot: strPtr("PSU1"), + SerialNumber: strPtr("PSU-SN-001"), + Status: strPtr("OK"), + }, + }, + } + + components, _ := FlattenHardwareComponents(input) + if len(components) != 2 { + t.Fatalf("expected 2 components, got %d", len(components)) + } + + foundPCIe := false + foundPSU := false + for _, component := range components { + switch component.ComponentType { + case "pcie_device": + foundPCIe = true + case "power_supply": + foundPSU = true + } + } + if !foundPCIe { + t.Fatalf("expected canonical pcie_device component type") + } + if !foundPSU { + t.Fatalf("expected canonical power_supply component type") + } +} + +func strPtr(value string) *string { + return &value +}