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
+163 -91
View File
@@ -425,6 +425,8 @@ select.filter-sel:focus{border-color:var(--cyan)}
/* ── MISC ── */ /* ── MISC ── */
.empty{color:var(--dim);font-size:0.7rem;letter-spacing:1px;padding:30px;text-align:center} .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} .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}} @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.meter{height:4px;background:var(--border);margin-top:4px;position:relative} .meter{height:4px;background:var(--border);margin-top:4px;position:relative}
@@ -485,16 +487,16 @@ select.filter-sel:focus{border-color:var(--cyan)}
<!-- DASHBOARD --> <!-- DASHBOARD -->
<div class="tab active" id="tab-dashboard"> <div class="tab active" id="tab-dashboard">
<div class="page-title">DASHBOARD</div> <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 style="display:grid;grid-template-columns:1fr 1fr;gap:16px" id="dash-bottom"></div>
</div> </div>
<!-- AGENTS --> <!-- AGENTS -->
<div class="tab" id="tab-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 class="actions"><button class="btn btn-sm" onclick="loadAgents()">REFRESH</button></div>
</div> </div>
<div class="tbl-wrap" id="agents-tbl"><div class="loading">LOADING...</div></div> <div class="tbl-wrap" id="agents-tbl"></div>
</div> </div>
<!-- NETWORK --> <!-- 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> <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> &nbsp;<span class="lbl" id="net-count" style="color:var(--cyan)"></span>
</div> </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> </div>
<!-- ALERTS --> <!-- 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('all',this)">ALL</button>
<button class="filter-btn" onclick="setAlertFilter('resolved',this)">RESOLVED</button> <button class="filter-btn" onclick="setAlertFilter('resolved',this)">RESOLVED</button>
</div> </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> </div>
<!-- KB FACTS --> <!-- KB FACTS -->
@@ -550,7 +552,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
<option value="__all__">ALL</option> <option value="__all__">ALL</option>
</select> </select>
</div> </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> </div>
<!-- KB INTENTS --> <!-- KB INTENTS -->
@@ -561,7 +563,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
<button class="btn btn-sm" onclick="loadIntents()">REFRESH</button> <button class="btn btn-sm" onclick="loadIntents()">REFRESH</button>
</div> </div>
</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> </div>
<!-- HOME ASSISTANT --> <!-- 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()"> &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> <span class="lbl" id="ha-count" style="color:var(--cyan)"></span>
</div> </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> </div>
<!-- NEWS --> <!-- 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 style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div> <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 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> <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 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> </div>
</div> </div>
@@ -605,19 +607,19 @@ select.filter-sel:focus{border-color:var(--cyan)}
<div class="page-title">PROXMOX VMs <div class="page-title">PROXMOX VMs
<div class="actions"><button class="btn btn-sm" onclick="loadVMs()">REFRESH</button></div> <div class="actions"><button class="btn btn-sm" onclick="loadVMs()">REFRESH</button></div>
</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> </div>
<!-- SITES --> <!-- SITES -->
<div class="tab" id="tab-sites"> <div class="tab" id="tab-sites">
<div class="page-title">SITE HEALTH</div> <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> </div>
<!-- USERS --> <!-- USERS -->
<div class="tab" id="tab-users"> <div class="tab" id="tab-users">
<div class="page-title">USERS</div> <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>
</div><!-- /content --> </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 ──────────────────────────────────────────────────────────────────── // ── AGENTS ────────────────────────────────────────────────────────────────────
async function loadAgents() { 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'); const agents = await api('agents_list');
if (!agents.length) { document.getElementById('agents-tbl').innerHTML='<div class="empty">NO AGENTS REGISTERED</div>'; return; } const tbody = document.getElementById('agents-tbody');
let rows = agents.map(a => {
const m = a.metrics; if (!agents.length) {
const meterCell = m tbl.innerHTML = '<div class="empty">NO AGENTS REGISTERED</div>';
? `<span style="font-size:0.65rem">${m.cpu_pct??'—'}% CPU · ${m.mem_pct??'—'}% RAM</span>` title.textContent = 'AGENTS';
: `<span style="color:var(--dim);font-size:0.65rem">—</span>`; return;
return `<tr> }
<td><span class="dot ${a.status==='online'?'dot-green':'dot-red'}"></span>${esc(a.hostname)}</td>
<td>${statusBadge(a.status)}</td> // Reveal each agent row with a staggered delay
<td><span class="badge badge-cyan">${esc(a.agent_type).toUpperCase()}</span></td> agents.forEach((a, i) => {
<td>${esc(a.ip_address||'—')}</td> setTimeout(() => {
<td>${meterCell}</td> const m = a.metrics;
<td class="ts">${ago(a.last_seen)}</td> const online = a.status === 'online';
<td class="ts">${ts(a.created_at)}</td> const lastSeen = a.last_seen ? (Date.now() - new Date(a.last_seen)) / 1000 : null;
<td><div class="actions-col"> const fresh = lastSeen !== null && lastSeen < 30;
<button class="btn btn-xs btn-red" onclick="delAgent('${esc(a.agent_id)}','${esc(a.hostname)}')">DELETE</button>
</div></td> // CPU/RAM mini bars
</tr>`; let meterCell = `<span style="color:var(--dim);font-size:0.65rem">no metrics</span>`;
}).join(''); if (m) {
document.getElementById('agents-tbl').innerHTML = `<table> function miniBar(pct, warn=70, crit=85) {
<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> if (pct == null) return '—';
<tbody>${rows}</tbody></table>`; 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||'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><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) { function delAgent(id, name) {
@@ -832,7 +912,7 @@ function setNetFilter(f, el) {
} }
async function loadNetwork() { 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'); _allDevices = await api('network_list');
renderNetwork(); renderNetwork();
} }
@@ -842,20 +922,18 @@ function renderNetwork() {
if (_netFilter === 'online') devs = devs.filter(d => d.status === 'online'); if (_netFilter === 'online') devs = devs.filter(d => d.status === 'online');
if (_netFilter === 'offline') devs = devs.filter(d => d.status === 'offline'); if (_netFilter === 'offline') devs = devs.filter(d => d.status === 'offline');
if (_netFilter === 'named') devs = devs.filter(d => d.alias); if (_netFilter === 'named') devs = devs.filter(d => d.alias);
const onlineCount = _allDevices.filter(d=>d.status==='online').length; const onlineCount = _allDevices.filter(d=>d.status==='online').length;
document.getElementById('net-count').textContent = `${onlineCount}/${_allDevices.length} ONLINE`; 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; } 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 name = d.alias || d.hostname || d.ip;
const isNamed = !!d.alias;
const vendor = d.device_type || '—'; const vendor = d.device_type || '—';
return `<tr> return `<td><span class="dot ${d.status==='online'?'dot-green':d.status==='offline'?'dot-red':'dot-dim'}"></span>
<td> <strong>${esc(name)}</strong>${d.alias?'':' <span style="color:var(--dim);font-size:0.6rem">(discovered)</span>'}</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>
<td style="color:var(--cyan)">${esc(d.ip)}</td> <td style="color:var(--cyan)">${esc(d.ip)}</td>
<td style="font-size:0.65rem;color:var(--dim)">${esc(d.mac||'—')}</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> <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" 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-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> <button class="btn btn-xs btn-red" onclick="delNet(${d.id},'${esc(name)}')">DEL</button>
</div></td> </div></td>`;
</tr>`; }, null, null);
}).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>`;
} }
async function scanNow() { async function scanNow() {
@@ -931,25 +1005,24 @@ function setAlertFilter(f, el) {
} }
async function loadAlerts() { 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}); const alerts = await api('alerts_list', {filter:_alertFilter});
if (!alerts.length) { document.getElementById('alerts-tbl').innerHTML='<div class="empty">NO ALERTS</div>'; return; } if (!alerts.length) { document.getElementById('alerts-tbl').innerHTML='<div class="empty">NO ALERTS</div>'; return; }
let rows = alerts.map(a => `<tr> document.getElementById('alerts-tbl').innerHTML = `<table>
<td>${sevBadge(a.severity)}</td> <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>${esc(a.alert_type)}</td>
<td class="trunc">${esc(a.title)}</td> <td class="trunc">${esc(a.title)}</td>
<td class="trunc ts">${esc(a.message||'—')}</td> <td class="trunc ts">${esc(a.message||'—')}</td>
<td>${a.resolved ? '<span class="badge badge-dim">RESOLVED</span>' : '<span class="badge badge-red">ACTIVE</span>'}</td> <td>${a.resolved?'<span class="badge badge-dim">RESOLVED</span>':'<span class="badge badge-red">ACTIVE</span>'}</td>
<td class="ts">${ts(a.created_at)}</td> <td class="ts">${ts(a.created_at)}</td>
<td><div class="actions-col"> <td><div class="actions-col">
${!a.resolved?`<button class="btn btn-xs btn-green" onclick="apiPost('alerts_resolve',{id:${a.id}},()=>{toast('Resolved','ok');loadAlerts()})">RESOLVE</button>`:''} ${!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-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> <button class="btn btn-xs btn-red" onclick="apiPost('alerts_delete',{id:${a.id}},()=>{toast('Deleted','ok');loadAlerts()})">DEL</button>
</div></td> </div></td>`, null, null);
</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>`;
} }
function alertModal(id=0, type='manual', title='', message='', severity='info') { function alertModal(id=0, type='manual', title='', message='', severity='info') {
@@ -978,23 +1051,22 @@ async function loadFactCategories() {
} }
async function loadFacts() { 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 cat = document.getElementById('factCat')?.value || '__all__';
const facts = await api('facts_list', {category: cat}); const facts = await api('facts_list', {category: cat});
if (!facts.length) { document.getElementById('facts-tbl').innerHTML='<div class="empty">NO FACTS</div>'; return; } if (!facts.length) { document.getElementById('facts-tbl').innerHTML='<div class="empty">NO FACTS</div>'; return; }
let rows = facts.map(f => `<tr> document.getElementById('facts-tbl').innerHTML = `<table>
<td><span class="badge badge-cyan">${esc(f.category)}</span></td> <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>${esc(f.fact_key)}</td>
<td class="trunc" style="max-width:320px" title="${esc(f.fact_value)}">${esc(f.fact_value)}</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 class="ts">${ago(f.updated_at)}</td>
<td><div class="actions-col"> <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-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> <button class="btn btn-xs btn-red" onclick="apiPost('facts_delete',{id:${f.id}},()=>{toast('Deleted','ok');loadFacts()})">DEL</button>
</div></td> </div></td>`, null, null);
</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>`;
} }
function factModal(id=0, category='', key='', value='') { function factModal(id=0, category='', key='', value='') {
@@ -1011,13 +1083,16 @@ function factModal(id=0, category='', key='', value='') {
// ── KB INTENTS ──────────────────────────────────────────────────────────────── // ── KB INTENTS ────────────────────────────────────────────────────────────────
async function loadIntents() { 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'); const intents = await api('intents_list');
if (!intents.length) { document.getElementById('intents-tbl').innerHTML='<div class="empty">NO INTENTS</div>'; return; } 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'}"> document.getElementById('intents-tbl').innerHTML = `<table>
<td>${esc(i.intent_name)}</td> <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: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><span class="badge badge-dim">${esc(i.action_type)}</span></td>
<td style="text-align:center">${i.priority}</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> <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" 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-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> <button class="btn btn-xs btn-red" onclick="apiPost('intents_delete',{id:${i.id}},()=>{toast('Deleted','ok');loadIntents()})">DEL</button>
</div></td> </div></td>`, null, null);
</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>`;
} }
function intentModal(id=0, name='', pattern='', response='', type='response', priority=5, active=1) { 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 ───────────────────────────────────────────────────────────────────── // ── SITES ─────────────────────────────────────────────────────────────────────
async function loadSites() { 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'); const sites = await api('sites_list');
if (!sites.length) { document.getElementById('sites-content').innerHTML='<div class="empty">NO SITE DATA</div>'; return; } if (!sites.length) { document.getElementById('sites-content').innerHTML='<div class="empty">NO SITE DATA</div>'; return; }
const labels = { const labels = {
@@ -1074,18 +1145,19 @@ async function loadSites() {
// ── USERS ───────────────────────────────────────────────────────────────────── // ── USERS ─────────────────────────────────────────────────────────────────────
async function loadUsers() { 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'); const users = await api('users_list');
let rows = users.map(u => `<tr> if (!users.length) { document.getElementById('users-tbl').innerHTML='<div class="empty">NO USERS</div>'; return; }
<td>${esc(u.username)}</td> 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>${esc(u.display_name||'—')}</td>
<td class="ts">${ago(u.last_seen)}</td> <td class="ts">${ago(u.last_seen)}</td>
<td class="ts">${ts(u.created_at)}</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> <td><button class="btn btn-xs btn-yellow" onclick="userModal(${u.id},'${esc(u.display_name||'')}')">EDIT</button></td>`,
</tr>`).join(''); null, null);
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>`;
} }
function userModal(id, display) { function userModal(id, display) {
@@ -1120,7 +1192,7 @@ document.addEventListener('keydown', e => { if (e.key==='Enter' && e.ctrlKey &&
let _haEntities = []; let _haEntities = [];
async function loadHA() { 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 domain = document.getElementById('ha-domain')?.value || '';
const data = await api('ha_list', {domain}); const data = await api('ha_list', {domain});
_haEntities = data.entities || []; _haEntities = data.entities || [];
@@ -1172,8 +1244,8 @@ function renderHATable(entities) {
// ── NEWS ────────────────────────────────────────────────────────────────────── // ── NEWS ──────────────────────────────────────────────────────────────────────
async function loadNews() { async function loadNews() {
document.getElementById('news-custom').innerHTML='<div class="loading">LOADING...</div>'; document.getElementById('news-custom').innerHTML='<div class="loading">SCANNING...</div>';
document.getElementById('news-live').innerHTML='<div class="loading">LOADING...</div>'; document.getElementById('news-live').innerHTML='<div class="loading">SCANNING...</div>';
const data = await api('news_list'); const data = await api('news_list');
// Custom entries // Custom entries
@@ -1219,7 +1291,7 @@ function newsCustomModal(id=0, title='', url='') {
// ── PROXMOX VMs ─────────────────────────────────────────────────────────────── // ── PROXMOX VMs ───────────────────────────────────────────────────────────────
async function loadVMs() { 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 data = await api('vms_list');
const vms = [...(data.vms||[]), ...(data.containers||[])]; 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; } if (!vms.length) { document.getElementById('vms-tbl').innerHTML='<div class="empty">NO VM DATA — Proxmox cache empty, refreshes every 5 min</div>'; return; }