generated from nathanwoodburn/python-webserver-template
feat: Initial code
This commit is contained in:
@@ -1,41 +1,344 @@
|
||||
:root {
|
||||
--bg: #0e1117;
|
||||
--panel: #171b23;
|
||||
--ink: #e6edf3;
|
||||
--muted: #9aa6b2;
|
||||
--line: #2d3748;
|
||||
--accent: #2f81f7;
|
||||
--good: #2ea043;
|
||||
--bad: #f85149;
|
||||
--warn: #d29922;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
h1 {
|
||||
font-size: 50px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(1200px 600px at 5% -10%, #1f2937 0%, transparent 65%),
|
||||
radial-gradient(1000px 500px at 95% 0%, #102a43 0%, transparent 55%),
|
||||
var(--bg);
|
||||
}
|
||||
.centre {
|
||||
margin-top: 10%;
|
||||
text-align: center;
|
||||
|
||||
.dashboard {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem 3rem;
|
||||
}
|
||||
a {
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
|
||||
font-size: clamp(1.9rem, 4.5vw, 2.9rem);
|
||||
}
|
||||
|
||||
.subtitle,
|
||||
.meta {
|
||||
margin: 0.2rem 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.65rem 1rem;
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.stat-card,
|
||||
.panel,
|
||||
.topology-card,
|
||||
.source-card {
|
||||
background: color-mix(in srgb, var(--panel) 95%, #000000);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.stat-card span {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
display: block;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 0.95rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 0.8rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.topology-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.topology-card,
|
||||
.source-card {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.topology-card h3,
|
||||
.source-card h3 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.topology-card p,
|
||||
.source-card p {
|
||||
margin: 0.2rem 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.source-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.source-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.55rem;
|
||||
background: #0f141b;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
input {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
border-bottom: 1px solid #202734;
|
||||
padding: 0.56rem;
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
|
||||
.asset-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.asset-row:hover td {
|
||||
background: #1a2230;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
background: color-mix(in srgb, var(--good) 20%, #ffffff);
|
||||
color: var(--good);
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
background: color-mix(in srgb, var(--bad) 18%, #ffffff);
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
.status-unknown {
|
||||
background: color-mix(in srgb, var(--warn) 22%, #ffffff);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(5, 10, 15, 0.72);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: min(900px, 100%);
|
||||
max-height: 88vh;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
background: #111722;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-body h4 {
|
||||
margin: 1rem 0 0.4rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.detail-grid div {
|
||||
background: #151d2b;
|
||||
border: 1px solid #293547;
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-grid strong {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.modal pre {
|
||||
background: #0b111b;
|
||||
border: 1px solid #243146;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem;
|
||||
overflow: auto;
|
||||
color: #c9d7e8;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
table a {
|
||||
color: #58a6ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
|
||||
table a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Mike section styling */
|
||||
.mike-section {
|
||||
margin-top: 30px;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 20px;
|
||||
background-color: rgba(50, 50, 50, 0.3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mike-section h2 {
|
||||
color: #f0f0f0;
|
||||
margin-top: 0;
|
||||
}
|
||||
.hero-actions {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mike-section p {
|
||||
line-height: 1.6;
|
||||
margin-bottom: 15px;
|
||||
input {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -4,55 +4,311 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nathan.Woodburn/</title>
|
||||
<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>
|
||||
<div class="spacer"></div>
|
||||
<div class="centre">
|
||||
<h1>Nathan.Woodburn/</h1>
|
||||
<span>The current date and time is {{datetime}}</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div class="spacer"></div>
|
||||
<div class="centre">
|
||||
<h2 id="test-content-header">Pulling data</h2>
|
||||
<span class="test-content">This is a test content area that will be updated with data from the server.</span>
|
||||
<br>
|
||||
<br>
|
||||
<span class="test-content-timestamp">Timestamp: Waiting to pull data</span>
|
||||
</div>
|
||||
<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>
|
||||
function fetchData() {
|
||||
// Fetch the data from the server
|
||||
fetch('/api/v1/data')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Get the data header element
|
||||
const dataHeader = document.getElementById('test-content-header');
|
||||
// Update the header with the fetched data
|
||||
dataHeader.textContent = data.header;
|
||||
// Get the test content element
|
||||
const testContent = document.querySelector('.test-content');
|
||||
// Update the content with the fetched data
|
||||
testContent.textContent = data.content;
|
||||
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');
|
||||
|
||||
// Get the timestamp element
|
||||
const timestampElement = document.querySelector('.test-content-timestamp');
|
||||
// Update the timestamp with the fetched data
|
||||
timestampElement.textContent = `Timestamp: ${data.timestamp}`;
|
||||
})
|
||||
.catch(error => console.error('Error fetching data:', error));
|
||||
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';
|
||||
}
|
||||
|
||||
// Initial fetch after 2 seconds
|
||||
setTimeout(fetchData, 2000);
|
||||
|
||||
// Then fetch every 2 seconds
|
||||
setInterval(fetchData, 2000);
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user