Files
inventory/templates/index.html
Nathan Woodburn 0ce79935d7
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 1m4s
Build Docker / BuildImage (push) Successful in 1m26s
feat: Initial code
2026-03-26 23:07:05 +11:00

316 lines
13 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{app_name}}</title>
<link rel="icon" href="/assets/img/favicon.png" type="image/png">
<link rel="stylesheet" href="/assets/css/index.css">
</head>
<body>
<main class="dashboard">
<header class="hero">
<div>
<h1>{{app_name}}</h1>
<p class="subtitle">Home lab inventory dashboard</p>
<p class="meta">Loaded at {{datetime}}</p>
</div>
<div class="hero-actions">
<button id="refresh-button" type="button">Run Collection</button>
<span id="last-run">Last run: waiting</span>
</div>
</header>
<section class="stats-grid" id="stats-grid"></section>
<section class="panel">
<h2>Topology</h2>
<div id="topology-grid" class="topology-grid"></div>
</section>
<section class="panel">
<h2>Sources</h2>
<div id="source-list" class="source-list"></div>
</section>
<section class="panel">
<div class="panel-header">
<h2>Asset Inventory</h2>
<div class="filters">
<input id="search-input" type="search" placeholder="Search assets, hosts, subnets">
<select id="source-filter">
<option value="">All sources</option>
</select>
<select id="status-filter">
<option value="">All statuses</option>
</select>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Source</th>
<th>Status</th>
<th>Host</th>
<th>Subnet</th>
<th>Public IP</th>
</tr>
</thead>
<tbody id="asset-table-body"></tbody>
</table>
</div>
</section>
<div id="asset-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="asset-modal-title">
<div class="modal-card">
<div class="modal-header">
<h3 id="asset-modal-title">Service Details</h3>
<button id="modal-close" type="button">Close</button>
</div>
<div id="asset-modal-body" class="modal-body"></div>
</div>
</div>
</main>
<script>
const statsGrid = document.getElementById('stats-grid');
const topologyGrid = document.getElementById('topology-grid');
const sourceList = document.getElementById('source-list');
const sourceFilter = document.getElementById('source-filter');
const statusFilter = document.getElementById('status-filter');
const searchInput = document.getElementById('search-input');
const assetTableBody = document.getElementById('asset-table-body');
const lastRunElement = document.getElementById('last-run');
const refreshButton = document.getElementById('refresh-button');
const assetModal = document.getElementById('asset-modal');
const assetModalBody = document.getElementById('asset-modal-body');
const modalClose = document.getElementById('modal-close');
let allAssets = [];
function statusClass(status) {
if (!status) return 'status-unknown';
const value = status.toLowerCase();
if (['running', 'online', 'healthy', 'up', 'configured', 'ok'].includes(value)) return 'status-online';
if (['stopped', 'offline', 'error', 'down', 'unhealthy'].includes(value)) return 'status-offline';
return 'status-unknown';
}
function renderStats(summary) {
const stats = [
{ label: 'Total Assets', value: summary.total_assets || 0 },
{ label: 'Online', value: summary.online_assets || 0 },
{ label: 'Offline', value: summary.offline_assets || 0 },
{ label: 'Sources', value: summary.source_count || 0 },
{ label: 'Subnets', value: summary.subnet_count || 0 },
];
statsGrid.innerHTML = stats
.map(item => `
<article class="stat-card">
<span>${item.label}</span>
<strong>${item.value}</strong>
</article>
`)
.join('');
}
function renderTopology(topology) {
const networks = topology.networks || [];
if (!networks.length) {
topologyGrid.innerHTML = '<p class="empty-state">No subnet mapping discovered yet.</p>';
return;
}
topologyGrid.innerHTML = networks
.map(network => `
<article class="topology-card">
<h3>${network.subnet}</h3>
<p>${network.asset_count} assets</p>
<p>${network.source_count} sources</p>
<p>${(network.public_ips || []).join(', ') || 'No public IP'}</p>
</article>
`)
.join('');
}
function renderSources(sources, selectedSource = '') {
if (!sources.length) {
sourceList.innerHTML = '<p class="empty-state">No source status available.</p>';
return;
}
sourceList.innerHTML = sources
.map(source => `
<article class="source-card">
<div>
<h3>${source.name}</h3>
<p>${source.last_error || 'No errors'}</p>
</div>
<span class="status-badge ${statusClass(source.last_status || 'unknown')}">${source.last_status || 'unknown'}</span>
</article>
`)
.join('');
const availableSources = [...new Set(sources.map(item => item.name))];
sourceFilter.innerHTML = '<option value="">All sources</option>' +
availableSources.map(source => `<option value="${source}">${source}</option>`).join('');
if (selectedSource && availableSources.includes(selectedSource)) {
sourceFilter.value = selectedSource;
}
}
function renderAssets() {
const source = sourceFilter.value;
const status = statusFilter.value;
const term = searchInput.value.trim().toLowerCase();
const filtered = allAssets.filter(asset => {
if (source && asset.source !== source) return false;
if (status && (asset.status || '') !== status) return false;
if (!term) return true;
const text = [asset.name, asset.hostname, asset.asset_type, asset.source, asset.subnet, asset.public_ip]
.filter(Boolean)
.join(' ')
.toLowerCase();
return text.includes(term);
});
if (!filtered.length) {
assetTableBody.innerHTML = '<tr><td colspan="7" class="empty-state">No assets match this filter.</td></tr>';
return;
}
assetTableBody.innerHTML = filtered
.map((asset, index) => `
<tr class="asset-row" data-index="${index}">
<td>${asset.name || '-'}</td>
<td>${asset.asset_type || '-'}</td>
<td>${asset.source || '-'}</td>
<td><span class="status-badge ${statusClass(asset.status)}">${asset.status || 'unknown'}</span></td>
<td>${asset.hostname || asset.node || '-'}</td>
<td>${asset.subnet || '-'}</td>
<td>${asset.public_ip || '-'}</td>
</tr>
`)
.join('');
assetTableBody.querySelectorAll('.asset-row').forEach(row => {
row.addEventListener('click', () => {
const idx = Number(row.getAttribute('data-index'));
const selected = filtered[idx];
if (selected) {
openAssetModal(selected);
}
});
});
}
function openAssetModal(asset) {
const metadata = asset.metadata || {};
const linked = metadata.linked_proxmox_assets || [];
assetModalBody.innerHTML = `
<div class="detail-grid">
<div><strong>Name</strong><span>${asset.name || '-'}</span></div>
<div><strong>Type</strong><span>${asset.asset_type || '-'}</span></div>
<div><strong>Source</strong><span>${asset.source || '-'}</span></div>
<div><strong>Status</strong><span>${asset.status || '-'}</span></div>
<div><strong>Hostname</strong><span>${asset.hostname || '-'}</span></div>
<div><strong>Node</strong><span>${asset.node || '-'}</span></div>
<div><strong>IPs</strong><span>${(asset.ip_addresses || []).join(', ') || '-'}</span></div>
<div><strong>Last Seen</strong><span>${asset.last_seen || '-'}</span></div>
</div>
<h4>Linked Proxmox Assets</h4>
<pre>${linked.length ? JSON.stringify(linked, null, 2) : 'No proxmox links found'}</pre>
<h4>Metadata</h4>
<pre>${JSON.stringify(metadata, null, 2)}</pre>
`;
assetModal.classList.remove('hidden');
}
function closeAssetModal() {
assetModal.classList.add('hidden');
assetModalBody.innerHTML = '';
}
async function loadStatus() {
const selectedSource = sourceFilter.value;
const selectedStatus = statusFilter.value;
const [summaryResponse, topologyResponse, sourcesResponse, assetsResponse] = await Promise.all([
fetch('/api/v1/summary'),
fetch('/api/v1/topology'),
fetch('/api/v1/sources'),
fetch('/api/v1/assets')
]);
const summaryData = await summaryResponse.json();
const topologyData = await topologyResponse.json();
const sourcesData = await sourcesResponse.json();
const assetsData = await assetsResponse.json();
renderStats(summaryData.summary || {});
renderTopology(topologyData);
renderSources(sourcesData.sources || [], selectedSource);
allAssets = assetsData.assets || [];
const statuses = [...new Set(allAssets.map(item => item.status).filter(Boolean))];
statusFilter.innerHTML = '<option value="">All statuses</option>' +
statuses.map(item => `<option value="${item}">${item}</option>`).join('');
if (selectedStatus && statuses.includes(selectedStatus)) {
statusFilter.value = selectedStatus;
}
renderAssets();
const run = summaryData.last_run;
lastRunElement.textContent = run ? `Last run: ${run.status} at ${run.finished_at || run.started_at}` : 'Last run: waiting';
}
async function manualRefresh() {
refreshButton.disabled = true;
refreshButton.textContent = 'Collecting...';
try {
const response = await fetch('/api/v1/collect/trigger', { method: 'POST' });
const payload = await response.json();
if (!response.ok) {
alert(`Collection failed: ${payload.error || payload.status || 'unknown error'}`);
}
} catch (error) {
alert(`Collection request failed: ${error}`);
} finally {
refreshButton.disabled = false;
refreshButton.textContent = 'Run Collection';
await loadStatus();
}
}
sourceFilter.addEventListener('change', renderAssets);
statusFilter.addEventListener('change', renderAssets);
searchInput.addEventListener('input', renderAssets);
refreshButton.addEventListener('click', manualRefresh);
modalClose.addEventListener('click', closeAssetModal);
assetModal.addEventListener('click', (event) => {
if (event.target === assetModal) {
closeAssetModal();
}
});
loadStatus().catch(error => console.error('Initial load failed:', error));
setInterval(() => {
loadStatus().catch(error => console.error('Status refresh failed:', error));
}, 15000);
</script>
</body>
</html>