718 lines
39 KiB
Cheetah
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/\"/g, '"')
|
|
.replace(/'/g, ''');
|
|
};
|
|
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}}
|