Files
core/internal/api/ui_assets.tmpl

718 lines
39 KiB
Cheetah

{{define "asset"}}
<!DOCTYPE html>
<html lang="en">
{{template "head" .}}
<body>
{{template "topbar" .}}
{{template "breadcrumbs" .}}
<main class="container">
<section class="card">
<div class="button-row" style="justify-content: space-between; margin-bottom: 16px;">
<h2 style="margin: 0;">Server Card</h2>
<div class="button-row">
<button class="button" id="asset-edit-toggle" type="button">Edit</button>
<button class="button button-secondary" id="asset-edit-cancel" type="button" hidden>Cancel</button>
</div>
</div>
<div class="meta" id="asset-edit-message" style="margin-bottom: 12px;"></div>
<div style="display:grid; grid-template-columns:minmax(0,1fr) 260px; gap:16px; align-items:start;">
<div class="meta-grid">
<div>
<span>Name</span>
<div class="field-value">{{.Asset.Name}}</div>
<input class="input field-input" id="asset-name-input" value="{{.Asset.Name}}" hidden />
</div>
<div>
<span>Vendor Serial</span>
<div class="field-value">{{.Asset.VendorSerial}}</div>
<input class="input field-input" id="asset-vendor-serial-input" value="{{.Asset.VendorSerial}}" hidden />
</div>
<div>
<span>Vendor</span>
<div class="field-value">{{if .Asset.Vendor}}<a href="{{.AssetVendorURL}}">{{.Asset.Vendor}}</a>{{else}}—{{end}}</div>
<input class="input field-input" id="asset-vendor-input" value="{{if .Asset.Vendor}}{{.Asset.Vendor}}{{end}}" hidden />
</div>
<div>
<span>Model</span>
<div class="field-value">{{if .Asset.Model}}<a href="{{.AssetModelURL}}">{{.Asset.Model}}</a>{{else}}—{{end}}</div>
<input class="input field-input" id="asset-model-input" value="{{if .Asset.Model}}{{.Asset.Model}}{{end}}" hidden />
</div>
<div>
<span>Asset Tag</span>
<div class="field-value">{{if .Asset.MachineTag}}{{.Asset.MachineTag}}{{else}}—{{end}}</div>
<input class="input field-input" id="asset-tag-input" value="{{if .Asset.MachineTag}}{{.Asset.MachineTag}}{{end}}" hidden />
</div>
<div><span>Status</span><span class="badge {{assetStatusClass .AssetStatus}}">{{assetStatusText .AssetStatus}}</span></div>
</div>
<div style="border:1px solid #d6dde7; border-radius:14px; padding:12px; background:#fbfcfe;">
<div class="meta" style="margin-bottom:8px; font-weight:600;">Component Health</div>
<div style="display:flex; gap:12px; align-items:center;">
<div style="width:92px; height:92px; border-radius:999px; {{safeCSS .ComponentStatusChartStyle}} flex:0 0 auto; border:1px solid #e5e7eb;"></div>
<div style="display:grid; gap:6px; min-width:0;">
<div style="display:flex; align-items:center; gap:8px;"><span style="width:10px; height:10px; border-radius:999px; background:#7ccf8a; display:inline-block;"></span><span class="meta" style="margin:0;">Healthy: {{.ComponentHealthyCount}}</span></div>
<div style="display:flex; align-items:center; gap:8px;"><span style="width:10px; height:10px; border-radius:999px; background:#f28b82; display:inline-block;"></span><span class="meta" style="margin:0;">Failed: {{.ComponentFailedCount}}</span></div>
<div style="display:flex; align-items:center; gap:8px;"><span style="width:10px; height:10px; border-radius:999px; background:#cbd5e1; display:inline-block;"></span><span class="meta" style="margin:0;">Unknown: {{.ComponentUnknownCount}}</span></div>
</div>
</div>
</div>
</div>
</section>
<section class="card">
<div class="button-row" style="justify-content: space-between; margin-bottom: 12px;">
<h2 style="margin:0;">Current Components</h2>
<div class="button-row">
<button class="button" id="current-components-add" type="button">Add</button>
<button class="button" id="current-components-edit" type="button" disabled>Edit</button>
<button class="button" id="current-components-remove" type="button" disabled>Remove</button>
</div>
</div>
{{if .HasFirmwareMismatch}}
<div class="meta"><span class="badge status-yellow">Firmware mismatch</span> Identical devices on this server have different firmware versions.</div>
{{end}}
<div class="meta" id="current-components-message" style="margin-bottom: 10px;"></div>
<div id="current-components-panel" data-asset-id="{{.Asset.ID}}">
<div class="meta">Loading current components…</div>
</div>
</section>
<section class="card">
<h2>Previous Components</h2>
{{if .PreviousComponentGroups}}
{{range .PreviousComponentGroups}}
<h3>{{.TypeTitle}}</h3>
<table class="table">
<thead>
<tr>
<th>Location</th>
<th>Vendor Serial</th>
<th>Vendor</th>
<th>Model</th>
<th>Firmware</th>
</tr>
</thead>
<tbody>
{{range .Rows}}
<tr class="clickable" onclick="navigateToRow('{{componentUIURL .Component}}')">
<td>{{if .Location}}{{.Location}}{{else}}—{{end}}</td>
<td>{{.Component.VendorSerial}}</td>
<td>{{if .Component.Vendor}}{{.Component.Vendor}}{{else}}—{{end}}</td>
<td>{{if .Component.Model}}{{.Component.Model}}{{else}}—{{end}}</td>
<td>{{if index $.PreviousComponentFirmwareByID .Component.ID}}{{index $.PreviousComponentFirmwareByID .Component.ID}}{{else}}—{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{else}}
<div class="meta">No previous components.</div>
{{end}}
</section>
<section class="card">
<h2>Movement & Events</h2>
<div id="asset-timeline-panel-{{.Asset.ID}}" class="timeline-panel"></div>
<div class="meta">Timeline groups similar events per day and shows source (ingest / csv / manual edit).</div>
</section>
</main>
<div class="modal" id="asset-components-add-modal">
<div class="modal-card" style="width:min(960px,100%);">
<div class="button-row" style="justify-content:space-between; align-items:center;">
<h3 class="modal-header" style="margin:0;">Add Component</h3>
<button class="button button-secondary" type="button" data-close-modal="asset-components-add-modal">Close</button>
</div>
<div class="button-row" style="margin-bottom:12px;">
<button class="button" type="button" id="add-component-tab-create">Create new</button>
<button class="button button-secondary" type="button" id="add-component-tab-attach">Attach existing</button>
</div>
<div class="meta" id="add-component-message" style="margin-bottom: 12px;"></div>
<div id="add-component-create-pane">
<div class="form">
<div class="field"><label for="add-component-vendor-serial">Vendor Serial</label><input class="input" id="add-component-vendor-serial" /></div>
<div class="field"><label for="add-component-vendor">Vendor</label><input class="input" id="add-component-vendor" /></div>
<div class="field"><label for="add-component-model">Model</label><input class="input" id="add-component-model" /></div>
<div class="field"><label for="add-component-firmware">Firmware</label><input class="input" id="add-component-firmware" /></div>
<div class="field"><label for="add-component-slot">Slot</label><input class="input" id="add-component-slot" placeholder="AOC#1" /></div>
<div class="button-row">
<button class="button button-secondary" type="button" id="add-component-check-duplicate">Check duplicate</button>
<button class="button" type="button" id="add-component-create-submit">Create & Attach</button>
</div>
<div id="add-component-duplicate-result" class="meta" style="margin-top:8px;"></div>
</div>
</div>
<div id="add-component-attach-pane" hidden>
<div class="field"><label for="attach-component-search">Search existing</label><input class="input" id="attach-component-search" type="search" placeholder="serial / vendor / model" /></div>
<div class="meta" id="attach-component-search-message" style="margin-bottom:8px;"></div>
<div id="attach-component-results" style="max-height: 280px; overflow:auto;"></div>
<div class="field" style="margin-top:12px;"><label for="attach-component-slot">Slot</label><input class="input" id="attach-component-slot" placeholder="AOC#1" /></div>
</div>
</div>
</div>
<div class="modal" id="asset-components-edit-modal">
<div class="modal-card" style="width:min(760px,100%);">
<div class="button-row" style="justify-content:space-between; align-items:center;">
<h3 class="modal-header" style="margin:0;">Edit Components</h3>
<button class="button button-secondary" type="button" data-close-modal="asset-components-edit-modal">Close</button>
</div>
<div class="meta" id="edit-components-message" style="margin-bottom:10px;"></div>
<div class="meta" id="edit-components-selection-summary" style="margin-bottom:10px;"></div>
<div class="form">
<div class="field"><label for="edit-components-status">Status</label>
<select class="input" id="edit-components-status">
<option value="">Do not change</option>
<option value="OK">OK</option>
<option value="FAILED">Failed</option>
<option value="UNKNOWN">Unknown</option>
</select>
</div>
<div class="field"><label for="edit-components-slot">Slot</label><input class="input" id="edit-components-slot" placeholder="variable" /></div>
<div class="field"><label for="edit-components-vendor-serial">Vendor Serial</label><input class="input" id="edit-components-vendor-serial" placeholder="variable" /></div>
<div class="field"><label for="edit-components-vendor">Vendor</label><input class="input" id="edit-components-vendor" placeholder="variable" /><label style="display:inline-flex; gap:6px; margin-top:6px;"><input type="checkbox" id="edit-components-clear-vendor" /> Clear</label></div>
<div class="field"><label for="edit-components-model">Model</label><input class="input" id="edit-components-model" placeholder="variable" /><label style="display:inline-flex; gap:6px; margin-top:6px;"><input type="checkbox" id="edit-components-clear-model" /> Clear</label></div>
<div class="field"><label for="edit-components-firmware">Firmware</label><input class="input" id="edit-components-firmware" placeholder="variable" /><label style="display:inline-flex; gap:6px; margin-top:6px;"><input type="checkbox" id="edit-components-clear-firmware" /> Clear</label></div>
<div class="button-row"><button class="button" type="button" id="edit-components-submit">Apply</button></div>
</div>
</div>
</div>
<div class="modal" id="asset-components-remove-modal">
<div class="modal-card" style="width:min(640px,100%);">
<div class="button-row" style="justify-content:space-between; align-items:center;">
<h3 class="modal-header" style="margin:0;">De-assert Components</h3>
<button class="button button-secondary" type="button" data-close-modal="asset-components-remove-modal">Close</button>
</div>
<div class="meta" id="remove-components-message" style="margin-bottom:10px;"></div>
<div class="meta" id="remove-components-selection-summary" style="margin-bottom:10px;"></div>
<div class="field">
<label for="remove-components-status">Status after removal</label>
<select class="input" id="remove-components-status" required>
<option value="">Select status</option>
<option value="working">Working</option>
<option value="not_working">Not working</option>
<option value="unknown">Unknown</option>
</select>
</div>
<div class="field">
<label for="remove-components-reason">Reason (optional)</label>
<input class="input" id="remove-components-reason" />
</div>
<div class="button-row"><button class="button" type="button" id="remove-components-submit">De-assert</button></div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
initTimelinePanel({
rootId: 'asset-timeline-panel-{{.Asset.ID}}',
apiBase: '/api/history/assets/{{.Asset.ID}}/timeline',
detailBase: '/api/history/assets/{{.Asset.ID}}/events'
});
});
</script>
<script>
const assetEditToggle = document.getElementById('asset-edit-toggle');
const assetEditCancel = document.getElementById('asset-edit-cancel');
const assetEditMessage = document.getElementById('asset-edit-message');
const assetFieldValues = [...document.querySelectorAll('.field-value')];
const assetFieldInputs = [...document.querySelectorAll('.field-input')];
const assetInputs = {
name: document.getElementById('asset-name-input'),
vendorSerial: document.getElementById('asset-vendor-serial-input'),
vendor: document.getElementById('asset-vendor-input'),
model: document.getElementById('asset-model-input'),
assetTag: document.getElementById('asset-tag-input')
};
const initialAssetForm = {
name: assetInputs.name ? assetInputs.name.value : '',
vendorSerial: assetInputs.vendorSerial ? assetInputs.vendorSerial.value : '',
vendor: assetInputs.vendor ? assetInputs.vendor.value : '',
model: assetInputs.model ? assetInputs.model.value : '',
assetTag: assetInputs.assetTag ? assetInputs.assetTag.value : ''
};
let assetEditMode = false;
function setAssetMessage(message) {
if (assetEditMessage) {
assetEditMessage.textContent = message;
}
}
function resetAssetForm() {
if (assetInputs.name) assetInputs.name.value = initialAssetForm.name;
if (assetInputs.vendorSerial) assetInputs.vendorSerial.value = initialAssetForm.vendorSerial;
if (assetInputs.vendor) assetInputs.vendor.value = initialAssetForm.vendor;
if (assetInputs.model) assetInputs.model.value = initialAssetForm.model;
if (assetInputs.assetTag) assetInputs.assetTag.value = initialAssetForm.assetTag;
}
function setAssetEditMode(enabled) {
assetEditMode = enabled;
assetFieldValues.forEach((element) => {
element.hidden = enabled;
});
assetFieldInputs.forEach((element) => {
element.hidden = !enabled;
});
if (assetEditToggle) {
assetEditToggle.textContent = enabled ? 'Save' : 'Edit';
}
if (assetEditCancel) {
assetEditCancel.hidden = !enabled;
}
if (!enabled) {
setAssetMessage('');
}
}
async function saveAsset() {
const payload = {
name: (assetInputs.name ? assetInputs.name.value : '').trim(),
vendor_serial: (assetInputs.vendorSerial ? assetInputs.vendorSerial.value : '').trim(),
vendor: (assetInputs.vendor ? assetInputs.vendor.value : '').trim(),
model: (assetInputs.model ? assetInputs.model.value : '').trim(),
asset_tag: (assetInputs.assetTag ? assetInputs.assetTag.value : '').trim()
};
if (!payload.name || !payload.vendor_serial) {
setAssetMessage('Name and vendor serial are required.');
return;
}
const response = await fetch('/registry/assets/{{.Asset.ID}}', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!response.ok) {
const body = await response.json().catch(() => ({error: 'Request failed'}));
setAssetMessage(body.error || 'Update asset failed');
return;
}
window.location.reload();
}
if (assetEditToggle) {
assetEditToggle.addEventListener('click', async () => {
if (!assetEditMode) {
setAssetEditMode(true);
return;
}
await saveAsset();
});
}
if (assetEditCancel) {
assetEditCancel.addEventListener('click', () => {
resetAssetForm();
setAssetEditMode(false);
});
}
</script>
<script>
(() => {
const assetID = '{{.Asset.ID}}';
const panel = document.getElementById('current-components-panel');
const messageEl = document.getElementById('current-components-message');
const addBtn = document.getElementById('current-components-add');
const editBtn = document.getElementById('current-components-edit');
const removeBtn = document.getElementById('current-components-remove');
const selectedKey = `ui_asset_components_selected_ids_v1:${assetID}`;
const state = { rows: [], filters: {}, selected: new Set(), attachSelected: null, addMode: 'create' };
const statusBadge = (status) => {
const s = (status || '').toLowerCase();
if (s === 'healthy') return ['Healthy', 'status-green'];
if (s === 'failed') return ['Failed', 'status-red'];
return ['Unknown', 'status-yellow'];
};
const text = (v) => (v == null || String(v).trim() === '' ? '—' : String(v));
const esc = (v) => {
const s = String(v ?? '');
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#39;');
};
function setMsg(msg) { if (messageEl) messageEl.textContent = msg || ''; }
function loadSelection() {
try {
const raw = localStorage.getItem(selectedKey);
if (!raw) return;
const arr = JSON.parse(raw);
if (Array.isArray(arr)) arr.forEach((id) => typeof id === 'string' && state.selected.add(id));
} catch {}
}
function saveSelection() {
try { localStorage.setItem(selectedKey, JSON.stringify([...state.selected])); } catch {}
}
function closeModal(id) { const m = document.getElementById(id); if (m) m.classList.remove('open'); }
function openModal(id) { const m = document.getElementById(id); if (m) m.classList.add('open'); }
document.querySelectorAll('[data-close-modal]').forEach((btn) => {
btn.addEventListener('click', () => closeModal(btn.getAttribute('data-close-modal')));
});
document.querySelectorAll('.modal').forEach((m) => {
m.addEventListener('click', (e) => { if (e.target === m) m.classList.remove('open'); });
});
function filteredRows() {
return state.rows.filter((row) => {
const f = state.filters;
if (f.status && row.status !== f.status) return false;
if (f.type && (row.type || '') !== f.type) return false;
if (f.slot && !String(row.slot || '').toLowerCase().includes(f.slot.toLowerCase())) return false;
if (f.vendor_serial && !String(row.vendor_serial || '').toLowerCase().includes(f.vendor_serial.toLowerCase())) return false;
if (f.vendor && !String(row.vendor || '').toLowerCase().includes(f.vendor.toLowerCase())) return false;
if (f.model && !String(row.model || '').toLowerCase().includes(f.model.toLowerCase())) return false;
if (f.firmware && !String(row.firmware || '').toLowerCase().includes(f.firmware.toLowerCase())) return false;
return true;
});
}
function syncActionButtons() {
const count = state.selected.size;
if (editBtn) editBtn.disabled = count === 0;
if (removeBtn) removeBtn.disabled = count === 0;
}
function renderPanel() {
if (!panel) return;
const rows = filteredRows();
const allVisibleSelected = rows.length > 0 && rows.every((r) => state.selected.has(r.component_id));
const someVisibleSelected = rows.some((r) => state.selected.has(r.component_id));
panel.innerHTML = `
<div class="meta" style="margin-bottom:8px;">Selected: ${state.selected.size}</div>
<table class="table" data-disable-auto-filters="true">
<thead>
<tr>
<th><label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;"><input type="checkbox" id="cc-select-all" ${allVisibleSelected ? 'checked' : ''}><span>Select</span></label></th>
<th>Status</th><th>Type</th><th>Slot</th><th>Vendor Serial</th><th>Vendor</th><th>Model</th><th>Firmware</th>
</tr>
<tr class="table-filter-row">
<th></th>
<th><input class="table-filter-input js-cc-filter" data-key="status" value="${esc(state.filters.status || '')}" list="cc-filter-status" placeholder="Filter"><datalist id="cc-filter-status">${(state.available?.statuses||[]).map((v)=>`<option value=\"${esc(v)}\"></option>`).join('')}</datalist></th>
<th><input class="table-filter-input js-cc-filter" data-key="type" value="${esc(state.filters.type || '')}" list="cc-filter-type" placeholder="Filter"><datalist id="cc-filter-type">${(state.available?.types||[]).map((v)=>`<option value=\"${esc(v)}\"></option>`).join('')}</datalist></th>
<th><input class="table-filter-input js-cc-filter" data-key="slot" value="${esc(state.filters.slot || '')}" list="cc-filter-slot" placeholder="Filter"><datalist id="cc-filter-slot">${(state.available?.slots||[]).map((v)=>`<option value=\"${esc(v)}\"></option>`).join('')}</datalist></th>
<th><input class="table-filter-input js-cc-filter" data-key="vendor_serial" value="${esc(state.filters.vendor_serial || '')}" placeholder="Filter"></th>
<th><input class="table-filter-input js-cc-filter" data-key="vendor" value="${esc(state.filters.vendor || '')}" placeholder="Filter"></th>
<th><input class="table-filter-input js-cc-filter" data-key="model" value="${esc(state.filters.model || '')}" placeholder="Filter"></th>
<th><input class="table-filter-input js-cc-filter" data-key="firmware" value="${esc(state.filters.firmware || '')}" placeholder="Filter"></th>
</tr>
</thead>
<tbody>
${rows.length === 0 ? `<tr><td colspan="8" class="meta">No active components.</td></tr>` : rows.map((row) => {
const [badgeText, badgeClass] = statusBadge(row.status);
return `<tr class="clickable" data-component-id="${esc(row.component_id)}">
<td onclick="event.stopPropagation()"><input type="checkbox" class="cc-select" data-id="${esc(row.component_id)}" ${state.selected.has(row.component_id) ? 'checked' : ''}></td>
<td><span class="badge ${badgeClass}">${badgeText}</span></td>
<td>${esc(text(row.type))}</td>
<td>${esc(text(row.slot))}</td>
<td>${esc(text(row.vendor_serial))}</td>
<td>${esc(text(row.vendor))}</td>
<td>${esc(text(row.model))}</td>
<td>${esc(text(row.firmware))}</td>
</tr>`;
}).join('')}
</tbody>
</table>
`;
const selectAll = panel.querySelector('#cc-select-all');
if (selectAll) {
selectAll.indeterminate = !allVisibleSelected && someVisibleSelected;
selectAll.addEventListener('change', () => {
rows.forEach((row) => {
if (selectAll.checked) state.selected.add(row.component_id);
else state.selected.delete(row.component_id);
});
saveSelection();
syncActionButtons();
renderPanel();
});
}
panel.querySelectorAll('.cc-select').forEach((cb) => {
cb.addEventListener('change', () => {
const id = cb.dataset.id;
if (!id) return;
if (cb.checked) state.selected.add(id); else state.selected.delete(id);
saveSelection();
syncActionButtons();
renderPanel();
});
});
panel.querySelectorAll('tbody tr.clickable').forEach((tr) => {
tr.addEventListener('click', () => {
const id = tr.dataset.componentId;
if (!id) return;
const row = state.rows.find((r) => r.component_id === id);
if (!row) return;
const v = encodeURIComponent((row.vendor || '_').trim() || '_');
const m = encodeURIComponent((row.model || '_').trim() || '_');
const sn = encodeURIComponent((row.vendor_serial || '').trim());
if (sn) window.location.assign(`/ui/component/${v}/${m}/${sn}`);
});
});
panel.querySelectorAll('.js-cc-filter').forEach((input) => {
input.addEventListener('input', () => {
state.filters[input.dataset.key] = input.value.trim();
renderPanel();
});
});
syncActionButtons();
}
async function loadCurrentComponents() {
const res = await fetch(`/api/assets/${encodeURIComponent(assetID)}/current-components`);
if (!res.ok) throw new Error('Failed to load current components');
const body = await res.json();
state.rows = Array.isArray(body.items) ? body.items : [];
state.available = (body.filters && body.filters.available) || {};
renderPanel();
}
function selectedRows() {
return state.rows.filter((r) => state.selected.has(r.component_id));
}
function openAddModal() {
state.addMode = 'create';
state.attachSelected = null;
document.getElementById('add-component-message').textContent = '';
document.getElementById('add-component-duplicate-result').textContent = '';
document.getElementById('add-component-create-pane').hidden = false;
document.getElementById('add-component-attach-pane').hidden = true;
document.getElementById('add-component-tab-create').classList.remove('button-secondary');
document.getElementById('add-component-tab-attach').classList.add('button-secondary');
openModal('asset-components-add-modal');
}
function setAddMode(mode) {
state.addMode = mode;
const createPane = document.getElementById('add-component-create-pane');
const attachPane = document.getElementById('add-component-attach-pane');
createPane.hidden = mode !== 'create';
attachPane.hidden = mode !== 'attach';
document.getElementById('add-component-tab-create').classList.toggle('button-secondary', mode !== 'create');
document.getElementById('add-component-tab-attach').classList.toggle('button-secondary', mode !== 'attach');
}
async function runDuplicateCheck() {
const vendorSerial = document.getElementById('add-component-vendor-serial').value.trim();
const model = document.getElementById('add-component-model').value.trim();
if (!vendorSerial) return;
const out = document.getElementById('add-component-duplicate-result');
out.textContent = 'Checking...';
const res = await fetch(`/api/assets/${encodeURIComponent(assetID)}/components/add/check`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({vendor_serial: vendorSerial, model: model || null})
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
out.textContent = body.error || 'Duplicate check failed';
return;
}
if (body.result === 'ok_to_create') {
out.textContent = 'No duplicate found. You can create a new component.';
return;
}
const existing = body.existing_component || {};
out.innerHTML = `Found existing component <strong>${esc(existing.vendor_serial || '')}</strong>${existing.model ? ' (' + esc(existing.model) + ')' : ''}. Use Attach existing.` + (existing.installed_asset_id ? ' It is currently installed on another server.' : '');
}
async function submitCreateAndAttach() {
const msg = document.getElementById('add-component-message');
msg.textContent = '';
const payload = {
mode: 'create_and_attach',
vendor_serial: document.getElementById('add-component-vendor-serial').value.trim(),
vendor: document.getElementById('add-component-vendor').value.trim() || null,
model: document.getElementById('add-component-model').value.trim() || null,
firmware: document.getElementById('add-component-firmware').value.trim() || null,
slot: document.getElementById('add-component-slot').value.trim() || null
};
if (!payload.vendor_serial) {
msg.textContent = 'Vendor serial is required.';
return;
}
const res = await fetch(`/api/assets/${encodeURIComponent(assetID)}/components/actions/add`, {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
msg.textContent = body.error || 'Create & attach failed';
if (body.details) {
document.getElementById('add-component-duplicate-result').textContent = 'Existing component found. Switch to Attach existing.';
}
return;
}
closeModal('asset-components-add-modal');
setMsg('Component added.');
await loadCurrentComponents();
}
async function runAttachSearch() {
const q = document.getElementById('attach-component-search').value.trim();
const wrap = document.getElementById('attach-component-results');
wrap.innerHTML = '<div class=\"meta\">Loading…</div>';
const res = await fetch(`/api/components/search-lite?q=${encodeURIComponent(q)}&limit=20`);
const body = await res.json().catch(() => ({}));
if (!res.ok) { wrap.innerHTML = `<div class=\"error\">${esc(body.error || 'Search failed')}</div>`; return; }
const items = Array.isArray(body.items) ? body.items : [];
if (!items.length) { wrap.innerHTML = '<div class=\"meta\">No components found.</div>'; return; }
wrap.innerHTML = items.map((it) => {
const installed = it.installed_asset ? `${it.installed_asset.name || it.installed_asset.id}${it.slot ? ' · ' + it.slot : ''}` : 'Not installed';
const btnLabel = it.installed_asset && it.installed_asset.id !== assetID ? 'Force attach' : 'Attach';
return `<div class=\"card\" style=\"padding:10px; margin-bottom:8px;\">
<div style=\"font-weight:600;\">${esc(it.vendor_serial || '')}${it.model ? ' · ' + esc(it.model) : ''}</div>
<div class=\"meta\">${esc(installed)}</div>
<div class=\"button-row\" style=\"margin-top:8px;\">
<button class=\"button button-secondary js-attach-select\" data-component-id=\"${esc(it.component_id)}\" data-force=\"${it.installed_asset && it.installed_asset.id !== assetID ? '1' : '0'}\">${btnLabel}</button>
</div>
</div>`;
}).join('');
wrap.querySelectorAll('.js-attach-select').forEach((btn) => {
btn.addEventListener('click', async () => {
const msg = document.getElementById('add-component-message');
msg.textContent = '';
const payload = {
mode: 'attach_existing',
component_id: btn.dataset.componentId,
force: btn.dataset.force === '1',
slot: document.getElementById('attach-component-slot').value.trim() || null
};
const res2 = await fetch(`/api/assets/${encodeURIComponent(assetID)}/components/actions/add`, {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
const body2 = await res2.json().catch(() => ({}));
if (!res2.ok) { msg.textContent = body2.error || 'Attach failed'; return; }
closeModal('asset-components-add-modal');
setMsg('Component attached.');
await loadCurrentComponents();
});
});
}
function setFieldForSelection(inputId, values, disableWhenMulti) {
const input = document.getElementById(inputId);
if (!input) return;
const uniq = [...new Set(values.map((v) => (v == null ? '' : String(v))))];
if (uniq.length === 1) {
input.value = uniq[0];
input.placeholder = '';
} else {
input.value = '';
input.placeholder = 'variable';
}
input.disabled = disableWhenMulti;
}
function openEditModal() {
const rows = selectedRows();
if (!rows.length) return;
document.getElementById('edit-components-message').textContent = '';
document.getElementById('edit-components-selection-summary').textContent = `Selected: ${rows.length} component(s)`;
const multi = rows.length > 1;
setFieldForSelection('edit-components-slot', rows.map((r) => r.slot || ''), multi);
setFieldForSelection('edit-components-vendor-serial', rows.map((r) => r.vendor_serial || ''), multi);
setFieldForSelection('edit-components-vendor', rows.map((r) => r.vendor || ''), false);
setFieldForSelection('edit-components-model', rows.map((r) => r.model || ''), false);
setFieldForSelection('edit-components-firmware', rows.map((r) => r.firmware || ''), false);
document.getElementById('edit-components-status').value = '';
['vendor','model','firmware'].forEach((k) => {
const cb = document.getElementById(`edit-components-clear-${k}`);
if (cb) cb.checked = false;
});
openModal('asset-components-edit-modal');
}
async function submitEdit() {
const rows = selectedRows();
if (!rows.length) return;
const multi = rows.length > 1;
const msg = document.getElementById('edit-components-message');
msg.textContent = '';
const changes = {};
const status = document.getElementById('edit-components-status').value;
if (status) changes.status = status;
const slot = document.getElementById('edit-components-slot').value.trim();
const vendorSerial = document.getElementById('edit-components-vendor-serial').value.trim();
if (!multi && slot !== '') changes.slot = slot;
if (!multi && vendorSerial !== '') changes.vendor_serial = vendorSerial;
const vendor = document.getElementById('edit-components-vendor').value.trim();
const model = document.getElementById('edit-components-model').value.trim();
const firmware = document.getElementById('edit-components-firmware').value.trim();
if (vendor !== '') changes.vendor = vendor;
if (model !== '') changes.model = model;
if (firmware !== '') changes.firmware = firmware;
const payload = {
component_ids: rows.map((r) => r.component_id),
changes,
clear: {
vendor: document.getElementById('edit-components-clear-vendor').checked,
model: document.getElementById('edit-components-clear-model').checked,
firmware: document.getElementById('edit-components-clear-firmware').checked
}
};
const res = await fetch(`/api/assets/${encodeURIComponent(assetID)}/components/actions/edit`, {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
const body = await res.json().catch(() => ({}));
if (!res.ok) { msg.textContent = body.error || 'Bulk edit failed'; return; }
closeModal('asset-components-edit-modal');
setMsg(`Updated ${body.changed_components || 0} component(s).`);
await loadCurrentComponents();
}
function openRemoveModal() {
const rows = selectedRows();
if (!rows.length) return;
document.getElementById('remove-components-message').textContent = '';
document.getElementById('remove-components-selection-summary').textContent = `Will de-assert ${rows.length} component(s) from this server.`;
document.getElementById('remove-components-status').value = '';
document.getElementById('remove-components-reason').value = '';
openModal('asset-components-remove-modal');
}
async function submitRemove() {
const rows = selectedRows();
const status = document.getElementById('remove-components-status').value;
const msg = document.getElementById('remove-components-message');
msg.textContent = '';
if (!status) { msg.textContent = 'Status after removal is required.'; return; }
const payload = {
component_ids: rows.map((r) => r.component_id),
deassert_status: status,
reason: document.getElementById('remove-components-reason').value.trim() || null
};
const res = await fetch(`/api/assets/${encodeURIComponent(assetID)}/components/actions/remove`, {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
const body = await res.json().catch(() => ({}));
if (!res.ok) { msg.textContent = body.error || 'De-assert failed'; return; }
rows.forEach((r) => state.selected.delete(r.component_id));
saveSelection();
closeModal('asset-components-remove-modal');
setMsg(`De-asserted ${body.changed_components || 0} component(s).`);
await loadCurrentComponents();
}
if (addBtn) addBtn.addEventListener('click', openAddModal);
if (editBtn) editBtn.addEventListener('click', openEditModal);
if (removeBtn) removeBtn.addEventListener('click', openRemoveModal);
document.getElementById('add-component-tab-create')?.addEventListener('click', () => setAddMode('create'));
document.getElementById('add-component-tab-attach')?.addEventListener('click', () => setAddMode('attach'));
document.getElementById('add-component-check-duplicate')?.addEventListener('click', () => { runDuplicateCheck().catch((e) => { document.getElementById('add-component-duplicate-result').textContent = e.message || 'Check failed'; }); });
document.getElementById('add-component-create-submit')?.addEventListener('click', () => { submitCreateAndAttach().catch((e) => { document.getElementById('add-component-message').textContent = e.message || 'Create failed'; }); });
document.getElementById('attach-component-search')?.addEventListener('input', (() => {
let t;
return () => {
clearTimeout(t);
t = setTimeout(() => { runAttachSearch().catch((e) => { document.getElementById('attach-component-search-message').textContent = e.message || 'Search failed'; }); }, 250);
};
})());
document.getElementById('edit-components-submit')?.addEventListener('click', () => { submitEdit().catch((e) => { document.getElementById('edit-components-message').textContent = e.message || 'Edit failed'; }); });
document.getElementById('remove-components-submit')?.addEventListener('click', () => { submitRemove().catch((e) => { document.getElementById('remove-components-message').textContent = e.message || 'Remove failed'; }); });
loadSelection();
loadCurrentComponents().catch((err) => {
if (panel) panel.innerHTML = `<div class="error">${esc(err.message || 'Failed to load current components')}</div>`;
});
})();
</script>
</body>
</html>
{{end}}