Admin: progressive row reveal on all tables — scanShell + staggered render, no stuck LOADING state

This commit is contained in:
2026-05-30 04:55:59 +00:00
parent f4eef862d1
commit d38d66d147
+157 -85
View File
@@ -425,6 +425,8 @@ select.filter-sel:focus{border-color:var(--cyan)}
/* ── MISC ── */
.empty{color:var(--dim);font-size:0.7rem;letter-spacing:1px;padding:30px;text-align:center}
@keyframes agentIn{from{opacity:0;transform:translateX(-12px)}to{opacity:1;transform:translateX(0)}}
.agent-row{animation:agentIn .18s ease forwards;opacity:0}
.loading{color:var(--dim);font-size:0.7rem;letter-spacing:2px;padding:30px;text-align:center;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.meter{height:4px;background:var(--border);margin-top:4px;position:relative}
@@ -485,16 +487,16 @@ select.filter-sel:focus{border-color:var(--cyan)}
<!-- DASHBOARD -->
<div class="tab active" id="tab-dashboard">
<div class="page-title">DASHBOARD</div>
<div class="stat-grid" id="dash-cards"><div class="loading">LOADING...</div></div>
<div class="stat-grid" id="dash-cards"><div class="loading">SCANNING...</div></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px" id="dash-bottom"></div>
</div>
<!-- AGENTS -->
<div class="tab" id="tab-agents">
<div class="page-title">AGENTS
<div class="page-title"><span id="agents-title">AGENTS</span>
<div class="actions"><button class="btn btn-sm" onclick="loadAgents()">REFRESH</button></div>
</div>
<div class="tbl-wrap" id="agents-tbl"><div class="loading">LOADING...</div></div>
<div class="tbl-wrap" id="agents-tbl"></div>
</div>
<!-- NETWORK -->
@@ -514,7 +516,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
<button class="filter-btn" id="nf-named" onclick="setNetFilter('named',this)">NAMED</button>
&nbsp;<span class="lbl" id="net-count" style="color:var(--cyan)"></span>
</div>
<div class="tbl-wrap" id="network-tbl"><div class="loading">LOADING...</div></div>
<div class="tbl-wrap" id="network-tbl"><div class="loading">SCANNING...</div></div>
</div>
<!-- ALERTS -->
@@ -533,7 +535,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
<button class="filter-btn" onclick="setAlertFilter('all',this)">ALL</button>
<button class="filter-btn" onclick="setAlertFilter('resolved',this)">RESOLVED</button>
</div>
<div class="tbl-wrap" id="alerts-tbl"><div class="loading">LOADING...</div></div>
<div class="tbl-wrap" id="alerts-tbl"><div class="loading">SCANNING...</div></div>
</div>
<!-- KB FACTS -->
@@ -550,7 +552,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
<option value="__all__">ALL</option>
</select>
</div>
<div class="tbl-wrap" id="facts-tbl"><div class="loading">LOADING...</div></div>
<div class="tbl-wrap" id="facts-tbl"><div class="loading">SCANNING...</div></div>
</div>
<!-- KB INTENTS -->
@@ -561,7 +563,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
<button class="btn btn-sm" onclick="loadIntents()">REFRESH</button>
</div>
</div>
<div class="tbl-wrap" id="intents-tbl"><div class="loading">LOADING...</div></div>
<div class="tbl-wrap" id="intents-tbl"><div class="loading">SCANNING...</div></div>
</div>
<!-- HOME ASSISTANT -->
@@ -577,7 +579,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
&nbsp;<input id="ha-search" placeholder="search name or entity_id..." style="background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:4px 8px;font-family:var(--font);font-size:0.65rem;width:220px;outline:none" oninput="filterHATable()" onchange="filterHATable()">
<span class="lbl" id="ha-count" style="color:var(--cyan)"></span>
</div>
<div class="tbl-wrap" id="ha-tbl"><div class="loading">LOADING...</div></div>
<div class="tbl-wrap" id="ha-tbl"><div class="loading">SCANNING...</div></div>
</div>
<!-- NEWS -->
@@ -591,11 +593,11 @@ select.filter-sel:focus{border-color:var(--cyan)}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div>
<div style="color:var(--cyan);font-size:0.65rem;letter-spacing:2px;margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px">PINNED / CUSTOM NEWS</div>
<div id="news-custom"><div class="loading">LOADING...</div></div>
<div id="news-custom"><div class="loading">SCANNING...</div></div>
</div>
<div>
<div style="color:var(--dim);font-size:0.65rem;letter-spacing:2px;margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px">LIVE FEED (auto-refreshed)</div>
<div id="news-live"><div class="loading">LOADING...</div></div>
<div id="news-live"><div class="loading">SCANNING...</div></div>
</div>
</div>
</div>
@@ -605,19 +607,19 @@ select.filter-sel:focus{border-color:var(--cyan)}
<div class="page-title">PROXMOX VMs
<div class="actions"><button class="btn btn-sm" onclick="loadVMs()">REFRESH</button></div>
</div>
<div class="tbl-wrap" id="vms-tbl"><div class="loading">LOADING...</div></div>
<div class="tbl-wrap" id="vms-tbl"><div class="loading">SCANNING...</div></div>
</div>
<!-- SITES -->
<div class="tab" id="tab-sites">
<div class="page-title">SITE HEALTH</div>
<div id="sites-content"><div class="loading">LOADING...</div></div>
<div id="sites-content"><div class="loading">SCANNING...</div></div>
</div>
<!-- USERS -->
<div class="tab" id="tab-users">
<div class="page-title">USERS</div>
<div class="tbl-wrap" id="users-tbl"><div class="loading">LOADING...</div></div>
<div class="tbl-wrap" id="users-tbl"><div class="loading">SCANNING...</div></div>
</div>
</div><!-- /content -->
@@ -786,32 +788,110 @@ async function loadDashboard() {
`;
}
// ── PROGRESSIVE RENDER HELPER ─────────────────────────────────────────────────
// Renders rows one-by-one into a tbody, staggered so the table "fills in" live.
// titleEl: element to show scanning progress. headers: th array. rowFn: item→html string.
function progressiveRender(items, tbodyId, rowFn, titleEl, titleDone) {
const tbody = document.getElementById(tbodyId);
if (!tbody) return;
if (!items.length) {
tbody.closest('table')?.parentElement && (tbody.closest('.tbl-wrap').innerHTML = '<div class="empty">NO DATA</div>');
if (titleEl) titleEl.textContent = titleDone || '';
return;
}
const n = items.length;
const stagger = Math.min(100, Math.max(15, Math.floor(1800 / n))); // cap total at ~1.8s
items.forEach((item, i) => {
setTimeout(() => {
const tr = document.createElement('tr');
tr.className = 'agent-row'; // reuse slide-in animation
tr.innerHTML = rowFn(item, i);
tbody.appendChild(tr);
if (titleEl) {
const done = i + 1;
titleEl.innerHTML = done < n
? `${titleDone.split(' ')[0]} <span style="color:var(--dim);font-size:0.6rem;letter-spacing:2px">SCANNING... ${done}/${n}</span>`
: `${titleDone} <span style="color:var(--cyan);font-size:0.6rem;letter-spacing:2px">${n} TOTAL</span>`;
}
}, i * stagger);
});
}
// Sets up the empty table shell immediately while fetch is in flight
function scanShell(tblWrapId, headers, titleEl, scanLabel) {
const wrap = document.getElementById(tblWrapId);
if (!wrap) return;
const ths = headers.map(h=>`<th>${h}</th>`).join('');
wrap.innerHTML = `<table><thead><tr>${ths}</tr></thead><tbody id="${tblWrapId}-tbody"></tbody></table>`;
if (titleEl) titleEl.innerHTML = `${scanLabel} <span style="color:var(--dim);font-size:0.6rem;letter-spacing:2px">SCANNING...</span>`;
}
// ── AGENTS ────────────────────────────────────────────────────────────────────
async function loadAgents() {
document.getElementById('agents-tbl').innerHTML = '<div class="loading">LOADING...</div>';
const tbl = document.getElementById('agents-tbl');
const title = document.getElementById('agents-title');
// Build empty table shell immediately — no waiting
tbl.innerHTML = `<table id="agents-table">
<thead><tr><th>HOSTNAME</th><th>STATUS</th><th>TYPE</th><th>IP</th><th>METRICS</th><th>LAST SEEN</th><th>REGISTERED</th><th></th></tr></thead>
<tbody id="agents-tbody"></tbody></table>`;
title.innerHTML = 'AGENTS <span style="color:var(--dim);font-size:0.6rem;letter-spacing:2px">SCANNING...</span>';
const agents = await api('agents_list');
if (!agents.length) { document.getElementById('agents-tbl').innerHTML='<div class="empty">NO AGENTS REGISTERED</div>'; return; }
let rows = agents.map(a => {
const tbody = document.getElementById('agents-tbody');
if (!agents.length) {
tbl.innerHTML = '<div class="empty">NO AGENTS REGISTERED</div>';
title.textContent = 'AGENTS';
return;
}
// Reveal each agent row with a staggered delay
agents.forEach((a, i) => {
setTimeout(() => {
const m = a.metrics;
const meterCell = m
? `<span style="font-size:0.65rem">${m.cpu_pct??'—'}% CPU · ${m.mem_pct??'—'}% RAM</span>`
: `<span style="color:var(--dim);font-size:0.65rem">—</span>`;
return `<tr>
<td><span class="dot ${a.status==='online'?'dot-green':'dot-red'}"></span>${esc(a.hostname)}</td>
const online = a.status === 'online';
const lastSeen = a.last_seen ? (Date.now() - new Date(a.last_seen)) / 1000 : null;
const fresh = lastSeen !== null && lastSeen < 30;
// CPU/RAM mini bars
let meterCell = `<span style="color:var(--dim);font-size:0.65rem">no metrics</span>`;
if (m) {
function miniBar(pct, warn=70, crit=85) {
if (pct == null) return '—';
const c = pct>=crit?'var(--red)':pct>=warn?'var(--yellow)':'var(--green)';
return `<span style="color:${c}">${Math.round(pct)}%</span>`;
}
meterCell = `<span style="font-size:0.65rem">CPU ${miniBar(m.cpu_pct)} · RAM ${miniBar(m.mem_pct)} · DISK ${miniBar(m.disk_pct,80,90)}</span>`;
}
const row = document.createElement('tr');
row.className = 'agent-row';
row.style.animationDelay = '0s';
row.innerHTML = `
<td>
<span class="dot ${online ? 'dot-green' : 'dot-red'}"></span>
<strong>${esc(a.hostname)}</strong>
${fresh && online ? '<span style="font-size:0.55rem;color:var(--green);margin-left:4px">● LIVE</span>' : ''}
</td>
<td>${statusBadge(a.status)}</td>
<td><span class="badge badge-cyan">${esc(a.agent_type).toUpperCase()}</span></td>
<td>${esc(a.ip_address||'—')}</td>
<td><span class="badge badge-cyan">${esc(a.agent_type||'linux').toUpperCase()}</span></td>
<td style="font-size:0.72rem">${esc(a.ip_address||'—')}</td>
<td>${meterCell}</td>
<td class="ts">${ago(a.last_seen)}</td>
<td class="ts">${ts(a.created_at)}</td>
<td><div class="actions-col">
<button class="btn btn-xs btn-red" onclick="delAgent('${esc(a.agent_id)}','${esc(a.hostname)}')">DELETE</button>
</div></td>
</tr>`;
}).join('');
document.getElementById('agents-tbl').innerHTML = `<table>
<thead><tr><th>HOSTNAME</th><th>STATUS</th><th>TYPE</th><th>IP</th><th>METRICS</th><th>LAST SEEN</th><th>REGISTERED</th><th>ACTIONS</th></tr></thead>
<tbody>${rows}</tbody></table>`;
<td><button class="btn btn-xs btn-red" onclick="delAgent('${esc(a.agent_id)}','${esc(a.hostname)}')">DEL</button></td>`;
tbody?.appendChild(row);
// Update title counter as agents appear
const found = i + 1;
const online_count = agents.slice(0, found).filter(x => x.status === 'online').length;
if (title) title.innerHTML = found < agents.length
? `AGENTS <span style="color:var(--dim);font-size:0.6rem;letter-spacing:2px">SCANNING... ${found}/${agents.length}</span>`
: `AGENTS <span style="color:var(--cyan);font-size:0.6rem;letter-spacing:2px">${online_count} ONLINE / ${agents.length} TOTAL</span>`;
}, i * 120); // 120ms stagger per agent
});
}
function delAgent(id, name) {
@@ -832,7 +912,7 @@ function setNetFilter(f, el) {
}
async function loadNetwork() {
document.getElementById('network-tbl').innerHTML = '<div class="loading">LOADING...</div>';
scanShell('network-tbl', ['NAME','IP','MAC','VENDOR / TYPE','STATUS','LAST SEEN','ACTIONS'], null, null);
_allDevices = await api('network_list');
renderNetwork();
}
@@ -842,20 +922,18 @@ function renderNetwork() {
if (_netFilter === 'online') devs = devs.filter(d => d.status === 'online');
if (_netFilter === 'offline') devs = devs.filter(d => d.status === 'offline');
if (_netFilter === 'named') devs = devs.filter(d => d.alias);
const onlineCount = _allDevices.filter(d=>d.status==='online').length;
document.getElementById('net-count').textContent = `${onlineCount}/${_allDevices.length} ONLINE`;
if (!devs.length) { document.getElementById('network-tbl').innerHTML='<div class="empty">NO DEVICES MATCH FILTER</div>'; return; }
let rows = devs.map(d => {
// Re-build shell (filter changed)
document.getElementById('network-tbl').innerHTML = `<table>
<thead><tr><th>NAME</th><th>IP</th><th>MAC</th><th>VENDOR / TYPE</th><th>STATUS</th><th>LAST SEEN</th><th>ACTIONS</th></tr></thead>
<tbody id="network-tbl-tbody"></tbody></table>`;
progressiveRender(devs, 'network-tbl-tbody', d => {
const name = d.alias || d.hostname || d.ip;
const isNamed = !!d.alias;
const vendor = d.device_type || '—';
return `<tr>
<td>
<span class="dot ${d.status==='online'?'dot-green':d.status==='offline'?'dot-red':'dot-dim'}"></span>
<strong>${esc(name)}</strong>${isNamed?'':' <span style="color:var(--dim);font-size:0.6rem">(discovered)</span>'}
</td>
return `<td><span class="dot ${d.status==='online'?'dot-green':d.status==='offline'?'dot-red':'dot-dim'}"></span>
<strong>${esc(name)}</strong>${d.alias?'':' <span style="color:var(--dim);font-size:0.6rem">(discovered)</span>'}</td>
<td style="color:var(--cyan)">${esc(d.ip)}</td>
<td style="font-size:0.65rem;color:var(--dim)">${esc(d.mac||'—')}</td>
<td class="trunc ts" style="max-width:140px" title="${esc(vendor)}">${esc(vendor)}</td>
@@ -865,12 +943,8 @@ function renderNetwork() {
<button class="btn btn-xs" onclick="pingDev('${esc(d.ip)}',this)">PING</button>
<button class="btn btn-xs btn-yellow" onclick="netModal(${d.id},'${esc(d.ip)}','${esc(d.alias||'')}','${esc(d.device_type||'')}')">NAME</button>
<button class="btn btn-xs btn-red" onclick="delNet(${d.id},'${esc(name)}')">DEL</button>
</div></td>
</tr>`;
}).join('');
document.getElementById('network-tbl').innerHTML = `<table>
<thead><tr><th>NAME</th><th>IP</th><th>MAC</th><th>VENDOR / TYPE</th><th>STATUS</th><th>LAST SEEN</th><th>ACTIONS</th></tr></thead>
<tbody>${rows}</tbody></table>`;
</div></td>`;
}, null, null);
}
async function scanNow() {
@@ -931,11 +1005,14 @@ function setAlertFilter(f, el) {
}
async function loadAlerts() {
document.getElementById('alerts-tbl').innerHTML='<div class="loading">LOADING...</div>';
scanShell('alerts-tbl', ['SEV','TYPE','TITLE','MESSAGE','STATUS','CREATED','ACTIONS'], null, null);
const alerts = await api('alerts_list', {filter:_alertFilter});
if (!alerts.length) { document.getElementById('alerts-tbl').innerHTML='<div class="empty">NO ALERTS</div>'; return; }
let rows = alerts.map(a => `<tr>
<td>${sevBadge(a.severity)}</td>
document.getElementById('alerts-tbl').innerHTML = `<table>
<thead><tr><th>SEV</th><th>TYPE</th><th>TITLE</th><th>MESSAGE</th><th>STATUS</th><th>CREATED</th><th>ACTIONS</th></tr></thead>
<tbody id="alerts-tbl-tbody"></tbody></table>`;
progressiveRender(alerts, 'alerts-tbl-tbody', a =>
`<td>${sevBadge(a.severity)}</td>
<td>${esc(a.alert_type)}</td>
<td class="trunc">${esc(a.title)}</td>
<td class="trunc ts">${esc(a.message||'—')}</td>
@@ -945,11 +1022,7 @@ async function loadAlerts() {
${!a.resolved?`<button class="btn btn-xs btn-green" onclick="apiPost('alerts_resolve',{id:${a.id}},()=>{toast('Resolved','ok');loadAlerts()})">RESOLVE</button>`:''}
<button class="btn btn-xs btn-yellow" onclick="alertModal(${a.id},'${esc(a.alert_type)}','${esc(a.title)}','${esc(a.message||'')}','${esc(a.severity)}')">EDIT</button>
<button class="btn btn-xs btn-red" onclick="apiPost('alerts_delete',{id:${a.id}},()=>{toast('Deleted','ok');loadAlerts()})">DEL</button>
</div></td>
</tr>`).join('');
document.getElementById('alerts-tbl').innerHTML = `<table>
<thead><tr><th>SEV</th><th>TYPE</th><th>TITLE</th><th>MESSAGE</th><th>STATUS</th><th>CREATED</th><th>ACTIONS</th></tr></thead>
<tbody>${rows}</tbody></table>`;
</div></td>`, null, null);
}
function alertModal(id=0, type='manual', title='', message='', severity='info') {
@@ -978,23 +1051,22 @@ async function loadFactCategories() {
}
async function loadFacts() {
document.getElementById('facts-tbl').innerHTML='<div class="loading">LOADING...</div>';
scanShell('facts-tbl', ['CATEGORY','KEY','VALUE','UPDATED','ACTIONS'], null, null);
const cat = document.getElementById('factCat')?.value || '__all__';
const facts = await api('facts_list', {category: cat});
if (!facts.length) { document.getElementById('facts-tbl').innerHTML='<div class="empty">NO FACTS</div>'; return; }
let rows = facts.map(f => `<tr>
<td><span class="badge badge-cyan">${esc(f.category)}</span></td>
document.getElementById('facts-tbl').innerHTML = `<table>
<thead><tr><th>CATEGORY</th><th>KEY</th><th>VALUE</th><th>UPDATED</th><th>ACTIONS</th></tr></thead>
<tbody id="facts-tbl-tbody"></tbody></table>`;
progressiveRender(facts, 'facts-tbl-tbody', f =>
`<td><span class="badge badge-cyan">${esc(f.category)}</span></td>
<td>${esc(f.fact_key)}</td>
<td class="trunc" style="max-width:320px" title="${esc(f.fact_value)}">${esc(f.fact_value)}</td>
<td class="ts">${ago(f.updated_at)}</td>
<td><div class="actions-col">
<button class="btn btn-xs btn-yellow" onclick='factModal(${f.id},"${esc(f.category)}","${esc(f.fact_key)}",${JSON.stringify(f.fact_value)})'>EDIT</button>
<button class="btn btn-xs btn-red" onclick="apiPost('facts_delete',{id:${f.id}},()=>{toast('Deleted','ok');loadFacts()})">DEL</button>
</div></td>
</tr>`).join('');
document.getElementById('facts-tbl').innerHTML = `<table>
<thead><tr><th>CATEGORY</th><th>KEY</th><th>VALUE</th><th>UPDATED</th><th>ACTIONS</th></tr></thead>
<tbody>${rows}</tbody></table>`;
</div></td>`, null, null);
}
function factModal(id=0, category='', key='', value='') {
@@ -1011,13 +1083,16 @@ function factModal(id=0, category='', key='', value='') {
// ── KB INTENTS ────────────────────────────────────────────────────────────────
async function loadIntents() {
document.getElementById('intents-tbl').innerHTML='<div class="loading">LOADING...</div>';
scanShell('intents-tbl', ['NAME','PATTERN','RESPONSE','TYPE','PRI','STATUS','ACTIONS'], null, null);
const intents = await api('intents_list');
if (!intents.length) { document.getElementById('intents-tbl').innerHTML='<div class="empty">NO INTENTS</div>'; return; }
let rows = intents.map(i => `<tr style="${i.active?'':'opacity:0.45'}">
<td>${esc(i.intent_name)}</td>
document.getElementById('intents-tbl').innerHTML = `<table>
<thead><tr><th>NAME</th><th>PATTERN</th><th>RESPONSE</th><th>TYPE</th><th style="text-align:center">PRI</th><th>STATUS</th><th>ACTIONS</th></tr></thead>
<tbody id="intents-tbl-tbody"></tbody></table>`;
progressiveRender(intents, 'intents-tbl-tbody', i =>
`<td>${esc(i.intent_name)}</td>
<td class="trunc" style="max-width:240px" title="${esc(i.pattern)}"><code style="font-size:0.65rem;color:var(--yellow)">${esc(i.pattern)}</code></td>
<td class="trunc" style="max-width:200px" title="${esc(i.response_template||'')}"><span style="font-size:0.7rem">${esc(i.response_template||'—')}</span></td>
<td class="trunc" style="max-width:200px"><span style="font-size:0.7rem">${esc(i.response_template||'—')}</span></td>
<td><span class="badge badge-dim">${esc(i.action_type)}</span></td>
<td style="text-align:center">${i.priority}</td>
<td>${i.active?'<span class="badge badge-green">ON</span>':'<span class="badge badge-dim">OFF</span>'}</td>
@@ -1025,11 +1100,7 @@ async function loadIntents() {
<button class="btn btn-xs" onclick="apiPost('intents_toggle',{id:${i.id}},()=>{toast('Toggled','ok');loadIntents()})">${i.active?'DISABLE':'ENABLE'}</button>
<button class="btn btn-xs btn-yellow" onclick='intentModal(${i.id},"${esc(i.intent_name)}","${esc(i.pattern)}",${JSON.stringify(i.response_template||"")},"${esc(i.action_type)}",${i.priority},${i.active})'>EDIT</button>
<button class="btn btn-xs btn-red" onclick="apiPost('intents_delete',{id:${i.id}},()=>{toast('Deleted','ok');loadIntents()})">DEL</button>
</div></td>
</tr>`).join('');
document.getElementById('intents-tbl').innerHTML = `<table>
<thead><tr><th>NAME</th><th>PATTERN</th><th>RESPONSE</th><th>TYPE</th><th style="text-align:center">PRI</th><th>STATUS</th><th>ACTIONS</th></tr></thead>
<tbody>${rows}</tbody></table>`;
</div></td>`, null, null);
}
function intentModal(id=0, name='', pattern='', response='', type='response', priority=5, active=1) {
@@ -1053,7 +1124,7 @@ function intentModal(id=0, name='', pattern='', response='', type='response', pr
// ── SITES ─────────────────────────────────────────────────────────────────────
async function loadSites() {
document.getElementById('sites-content').innerHTML='<div class="loading">LOADING...</div>';
document.getElementById('sites-content').innerHTML='<div class="loading">SCANNING...</div>';
const sites = await api('sites_list');
if (!sites.length) { document.getElementById('sites-content').innerHTML='<div class="empty">NO SITE DATA</div>'; return; }
const labels = {
@@ -1074,18 +1145,19 @@ async function loadSites() {
// ── USERS ─────────────────────────────────────────────────────────────────────
async function loadUsers() {
document.getElementById('users-tbl').innerHTML='<div class="loading">LOADING...</div>';
scanShell('users-tbl', ['USERNAME','DISPLAY NAME','LAST SEEN','CREATED','ACTIONS'], null, null);
const users = await api('users_list');
let rows = users.map(u => `<tr>
<td>${esc(u.username)}</td>
if (!users.length) { document.getElementById('users-tbl').innerHTML='<div class="empty">NO USERS</div>'; return; }
document.getElementById('users-tbl').innerHTML = `<table>
<thead><tr><th>USERNAME</th><th>DISPLAY NAME</th><th>LAST SEEN</th><th>CREATED</th><th>ACTIONS</th></tr></thead>
<tbody id="users-tbl-tbody"></tbody></table>`;
progressiveRender(users, 'users-tbl-tbody', u =>
`<td>${esc(u.username)}</td>
<td>${esc(u.display_name||'—')}</td>
<td class="ts">${ago(u.last_seen)}</td>
<td class="ts">${ts(u.created_at)}</td>
<td><button class="btn btn-xs btn-yellow" onclick="userModal(${u.id},'${esc(u.display_name||'')}')">EDIT</button></td>
</tr>`).join('');
document.getElementById('users-tbl').innerHTML = `<table>
<thead><tr><th>USERNAME</th><th>DISPLAY NAME</th><th>LAST SEEN</th><th>CREATED</th><th>ACTIONS</th></tr></thead>
<tbody>${rows}</tbody></table>`;
<td><button class="btn btn-xs btn-yellow" onclick="userModal(${u.id},'${esc(u.display_name||'')}')">EDIT</button></td>`,
null, null);
}
function userModal(id, display) {
@@ -1120,7 +1192,7 @@ document.addEventListener('keydown', e => { if (e.key==='Enter' && e.ctrlKey &&
let _haEntities = [];
async function loadHA() {
document.getElementById('ha-tbl').innerHTML = '<div class="loading">LOADING...</div>';
document.getElementById('ha-tbl').innerHTML = '<div class="loading">SCANNING...</div>';
const domain = document.getElementById('ha-domain')?.value || '';
const data = await api('ha_list', {domain});
_haEntities = data.entities || [];
@@ -1172,8 +1244,8 @@ function renderHATable(entities) {
// ── NEWS ──────────────────────────────────────────────────────────────────────
async function loadNews() {
document.getElementById('news-custom').innerHTML='<div class="loading">LOADING...</div>';
document.getElementById('news-live').innerHTML='<div class="loading">LOADING...</div>';
document.getElementById('news-custom').innerHTML='<div class="loading">SCANNING...</div>';
document.getElementById('news-live').innerHTML='<div class="loading">SCANNING...</div>';
const data = await api('news_list');
// Custom entries
@@ -1219,7 +1291,7 @@ function newsCustomModal(id=0, title='', url='') {
// ── PROXMOX VMs ───────────────────────────────────────────────────────────────
async function loadVMs() {
document.getElementById('vms-tbl').innerHTML='<div class="loading">LOADING...</div>';
document.getElementById('vms-tbl').innerHTML='<div class="loading">SCANNING...</div>';
const data = await api('vms_list');
const vms = [...(data.vms||[]), ...(data.containers||[])];
if (!vms.length) { document.getElementById('vms-tbl').innerHTML='<div class="empty">NO VM DATA — Proxmox cache empty, refreshes every 5 min</div>'; return; }