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
+
+
+
+ | Slot |
+ ${assetIDs.map((id) => `${escapeHTML(assetLabelByID.get(id) || id)} | `).join('')}
+
+
+
+ ${slotRows.map((row) => `
+
+ | ${escapeHTML(row.slot)} |
+ ${assetIDs.map((id) => `${escapeHTML(row.byAsset.get(id) || '—')} | `).join('')}
+
+ `).join('')}
+
+
+ `
+ : `No slot-level differences found.
`;
+
+ const modelTable = modelRows.length > 0
+ ? `
+ Differences By Component Count
+
+
+
+ | Component family |
+ ${assetIDs.map((id) => `${escapeHTML(assetLabelByID.get(id) || id)} | `).join('')}
+
+
+
+ ${modelRows.map((row) => `
+
+ | ${escapeHTML(row.label)} |
+ ${assetIDs.map((id) => `${row.byAsset.get(id) || 0} | `).join('')}
+
+ `).join('')}
+
+
+ `
+ : `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)}
+
+
+
+ `);
+ 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.
+
+
+ `);
+ }
+ });
+ }