diff --git a/public_html/admin/index.php b/public_html/admin/index.php
index cc0c3f5..bd1a80f 100644
--- a/public_html/admin/index.php
+++ b/public_html/admin/index.php
@@ -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)}
-
@@ -514,7 +516,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
-
+
@@ -533,7 +535,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
-
+
@@ -550,7 +552,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
-
+
@@ -561,7 +563,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
-
+
@@ -577,7 +579,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
-
+
@@ -591,11 +593,11 @@ select.filter-sel:focus{border-color:var(--cyan)}
LIVE FEED (auto-refreshed)
-
+
@@ -605,19 +607,19 @@ select.filter-sel:focus{border-color:var(--cyan)}
-
+
@@ -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 = 'NO DATA
');
+ 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]} SCANNING... ${done}/${n}`
+ : `${titleDone} ${n} TOTAL`;
+ }
+ }, 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=>`${h} | `).join('');
+ wrap.innerHTML = ``;
+ if (titleEl) titleEl.innerHTML = `${scanLabel} SCANNING...`;
+}
+
// ── AGENTS ────────────────────────────────────────────────────────────────────
async function loadAgents() {
- document.getElementById('agents-tbl').innerHTML = 'LOADING...
';
+ const tbl = document.getElementById('agents-tbl');
+ const title = document.getElementById('agents-title');
+
+ // Build empty table shell immediately — no waiting
+ tbl.innerHTML = `
+ | HOSTNAME | STATUS | TYPE | IP | METRICS | LAST SEEN | REGISTERED | |
+
`;
+
+ title.innerHTML = 'AGENTS SCANNING...';
+
const agents = await api('agents_list');
- if (!agents.length) { document.getElementById('agents-tbl').innerHTML='NO AGENTS REGISTERED
'; return; }
- let rows = agents.map(a => {
- const m = a.metrics;
- const meterCell = m
- ? `${m.cpu_pct??'—'}% CPU · ${m.mem_pct??'—'}% RAM`
- : `—`;
- return `
- | ${esc(a.hostname)} |
- ${statusBadge(a.status)} |
- ${esc(a.agent_type).toUpperCase()} |
- ${esc(a.ip_address||'—')} |
- ${meterCell} |
- ${ago(a.last_seen)} |
- ${ts(a.created_at)} |
-
-
- |
-
`;
- }).join('');
- document.getElementById('agents-tbl').innerHTML = `
- | HOSTNAME | STATUS | TYPE | IP | METRICS | LAST SEEN | REGISTERED | ACTIONS |
- ${rows}
`;
+ const tbody = document.getElementById('agents-tbody');
+
+ if (!agents.length) {
+ tbl.innerHTML = 'NO AGENTS REGISTERED
';
+ title.textContent = 'AGENTS';
+ return;
+ }
+
+ // Reveal each agent row with a staggered delay
+ agents.forEach((a, i) => {
+ setTimeout(() => {
+ const m = a.metrics;
+ 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 = `no metrics`;
+ 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 `${Math.round(pct)}%`;
+ }
+ meterCell = `CPU ${miniBar(m.cpu_pct)} · RAM ${miniBar(m.mem_pct)} · DISK ${miniBar(m.disk_pct,80,90)}`;
+ }
+
+ const row = document.createElement('tr');
+ row.className = 'agent-row';
+ row.style.animationDelay = '0s';
+ row.innerHTML = `
+
+
+ ${esc(a.hostname)}
+ ${fresh && online ? '● LIVE' : ''}
+ |
+ ${statusBadge(a.status)} |
+ ${esc(a.agent_type||'linux').toUpperCase()} |
+ ${esc(a.ip_address||'—')} |
+ ${meterCell} |
+ ${ago(a.last_seen)} |
+ ${ts(a.created_at)} |
+ | `;
+ 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 SCANNING... ${found}/${agents.length}`
+ : `AGENTS ${online_count} ONLINE / ${agents.length} TOTAL`;
+ }, 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 = 'LOADING...
';
+ 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='NO DEVICES MATCH FILTER
'; return; }
- let rows = devs.map(d => {
+ // Re-build shell (filter changed)
+ document.getElementById('network-tbl').innerHTML = `
+ | NAME | IP | MAC | VENDOR / TYPE | STATUS | LAST SEEN | ACTIONS |
+
`;
+ progressiveRender(devs, 'network-tbl-tbody', d => {
const name = d.alias || d.hostname || d.ip;
- const isNamed = !!d.alias;
const vendor = d.device_type || '—';
- return `
- |
-
- ${esc(name)}${isNamed?'':' (discovered)'}
- |
+ return `
+ ${esc(name)}${d.alias?'':' (discovered)'} |
${esc(d.ip)} |
${esc(d.mac||'—')} |
${esc(vendor)} |
@@ -865,12 +943,8 @@ function renderNetwork() {
-
-
`;
- }).join('');
- document.getElementById('network-tbl').innerHTML = `
- | NAME | IP | MAC | VENDOR / TYPE | STATUS | LAST SEEN | ACTIONS |
- ${rows}
`;
+ `;
+ }, null, null);
}
async function scanNow() {
@@ -931,25 +1005,24 @@ function setAlertFilter(f, el) {
}
async function loadAlerts() {
- document.getElementById('alerts-tbl').innerHTML='LOADING...
';
+ 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='NO ALERTS
'; return; }
- let rows = alerts.map(a => `
- | ${sevBadge(a.severity)} |
+ document.getElementById('alerts-tbl').innerHTML = `
+ | SEV | TYPE | TITLE | MESSAGE | STATUS | CREATED | ACTIONS |
+
`;
+ progressiveRender(alerts, 'alerts-tbl-tbody', a =>
+ `${sevBadge(a.severity)} |
${esc(a.alert_type)} |
${esc(a.title)} |
${esc(a.message||'—')} |
- ${a.resolved ? 'RESOLVED' : 'ACTIVE'} |
+ ${a.resolved?'RESOLVED':'ACTIVE'} |
${ts(a.created_at)} |
${!a.resolved?``:''}
- |
-
`).join('');
- document.getElementById('alerts-tbl').innerHTML = `
- | SEV | TYPE | TITLE | MESSAGE | STATUS | CREATED | ACTIONS |
- ${rows}
`;
+ `, 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='LOADING...
';
+ 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='NO FACTS
'; return; }
- let rows = facts.map(f => `
- | ${esc(f.category)} |
+ document.getElementById('facts-tbl').innerHTML = `
+ | CATEGORY | KEY | VALUE | UPDATED | ACTIONS |
+
`;
+ progressiveRender(facts, 'facts-tbl-tbody', f =>
+ `${esc(f.category)} |
${esc(f.fact_key)} |
${esc(f.fact_value)} |
${ago(f.updated_at)} |
- |
-
`).join('');
- document.getElementById('facts-tbl').innerHTML = `
- | CATEGORY | KEY | VALUE | UPDATED | ACTIONS |
- ${rows}
`;
+ `, 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='LOADING...
';
+ 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='NO INTENTS
'; return; }
- let rows = intents.map(i => `
- | ${esc(i.intent_name)} |
+ document.getElementById('intents-tbl').innerHTML = `
+ | NAME | PATTERN | RESPONSE | TYPE | PRI | STATUS | ACTIONS |
+
`;
+ progressiveRender(intents, 'intents-tbl-tbody', i =>
+ `${esc(i.intent_name)} |
${esc(i.pattern)} |
- ${esc(i.response_template||'—')} |
+ ${esc(i.response_template||'—')} |
${esc(i.action_type)} |
${i.priority} |
${i.active?'ON':'OFF'} |
@@ -1025,11 +1100,7 @@ async function loadIntents() {
-
-
`).join('');
- document.getElementById('intents-tbl').innerHTML = `
- | NAME | PATTERN | RESPONSE | TYPE | PRI | STATUS | ACTIONS |
- ${rows}
`;
+ `, 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='LOADING...
';
+ document.getElementById('sites-content').innerHTML='SCANNING...
';
const sites = await api('sites_list');
if (!sites.length) { document.getElementById('sites-content').innerHTML='NO SITE DATA
'; return; }
const labels = {
@@ -1074,18 +1145,19 @@ async function loadSites() {
// ── USERS ─────────────────────────────────────────────────────────────────────
async function loadUsers() {
- document.getElementById('users-tbl').innerHTML='LOADING...
';
+ scanShell('users-tbl', ['USERNAME','DISPLAY NAME','LAST SEEN','CREATED','ACTIONS'], null, null);
const users = await api('users_list');
- let rows = users.map(u => `
- | ${esc(u.username)} |
+ if (!users.length) { document.getElementById('users-tbl').innerHTML='NO USERS
'; return; }
+ document.getElementById('users-tbl').innerHTML = `
+ | USERNAME | DISPLAY NAME | LAST SEEN | CREATED | ACTIONS |
+
`;
+ progressiveRender(users, 'users-tbl-tbody', u =>
+ `${esc(u.username)} |
${esc(u.display_name||'—')} |
${ago(u.last_seen)} |
${ts(u.created_at)} |
- |
-
`).join('');
- document.getElementById('users-tbl').innerHTML = `
- | USERNAME | DISPLAY NAME | LAST SEEN | CREATED | ACTIONS |
- ${rows}
`;
+ | `,
+ 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 = 'LOADING...
';
+ document.getElementById('ha-tbl').innerHTML = 'SCANNING...
';
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='LOADING...
';
- document.getElementById('news-live').innerHTML='LOADING...
';
+ document.getElementById('news-custom').innerHTML='SCANNING...
';
+ document.getElementById('news-live').innerHTML='SCANNING...
';
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='LOADING...
';
+ document.getElementById('vms-tbl').innerHTML='SCANNING...
';
const data = await api('vms_list');
const vms = [...(data.vms||[]), ...(data.containers||[])];
if (!vms.length) { document.getElementById('vms-tbl').innerHTML='NO VM DATA — Proxmox cache empty, refreshes every 5 min
'; return; }