ScuttleBot

ui: agent presence indicators — online/offline/idle dots, last seen, sorted list - Green dot = online, yellow = idle (<10min), gray = offline, red square = revoked - Agents sorted: online first, then by last_seen descending - Header shows "N/M online" count - Status card shows online/total agents - Revoked agents dimmed at 50% opacity - Replaced permissions column with last seen (relative time) Closes #48

lmata 2026-04-03 19:59 trunk
Commit ada73432140ce08c6bb167753135ce8ffb873557d6bd55341e540c3cca1d73bb
1 file changed +39 -7
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -1345,11 +1345,12 @@
13451345
]);
13461346
13471347
// Status card.
13481348
document.getElementById('stat-status').innerHTML = '<span class="dot green"></span>'+s.status;
13491349
document.getElementById('stat-uptime').textContent = s.uptime;
1350
- document.getElementById('stat-agents').textContent = s.agents;
1350
+ const onlineAgents = allAgents.filter(a => a.online).length;
1351
+ document.getElementById('stat-agents').textContent = onlineAgents + '/' + s.agents;
13511352
const d = new Date(s.started);
13521353
document.getElementById('stat-started').textContent = d.toLocaleTimeString();
13531354
document.getElementById('stat-started-rel').textContent = d.toLocaleDateString();
13541355
document.getElementById('status-error').style.display = 'none';
13551356
document.getElementById('metrics-updated').textContent = 'updated '+new Date().toLocaleTimeString();
@@ -1446,39 +1447,70 @@
14461447
document.getElementById('user-count').textContent = countTxt;
14471448
renderTable('users-container', null, rows,
14481449
all.length ? 'no users match the filter' : 'no users registered yet',
14491450
['nick','type','channels','registered','']);
14501451
}
1452
+
1453
+function relTime(ts) {
1454
+ if (!ts) return 'never';
1455
+ const ms = Date.now() - new Date(ts).getTime();
1456
+ if (ms < 0) return 'just now';
1457
+ const s = Math.floor(ms/1000), m = Math.floor(s/60), h = Math.floor(m/60), d = Math.floor(h/24);
1458
+ if (d > 0) return d + 'd ago';
1459
+ if (h > 0) return h + 'h ago';
1460
+ if (m > 0) return m + 'm ago';
1461
+ return s + 's ago';
1462
+}
1463
+
1464
+function presenceDot(a) {
1465
+ if (a.revoked) return '<span style="color:#f85149" title="revoked">◼</span>';
1466
+ if (a.online) return '<span style="color:#3fb950" title="online">●</span>';
1467
+ if (a.last_seen) {
1468
+ const mins = (Date.now() - new Date(a.last_seen).getTime()) / 60000;
1469
+ if (mins < 10) return '<span style="color:#d29922" title="idle">●</span>';
1470
+ }
1471
+ return '<span style="color:#484f58" title="offline">●</span>';
1472
+}
14511473
14521474
function renderAgentTable() {
14531475
const q = (document.getElementById('agent-search').value||'').toLowerCase();
14541476
const bots = allAgents.filter(a => a.type !== 'operator');
14551477
const agents = bots.filter(a => !q ||
14561478
a.nick.toLowerCase().includes(q) ||
14571479
a.type.toLowerCase().includes(q) ||
14581480
(a.config?.channels||[]).some(c => c.toLowerCase().includes(q)) ||
14591481
(a.config?.permissions||[]).some(p => p.toLowerCase().includes(q)));
1460
- document.getElementById('agent-count').textContent = bots.length + (agents.length !== bots.length ? ' / '+agents.length+' shown' : '');
1482
+ // Sort: online first, then by last_seen descending, then by nick.
1483
+ agents.sort((a, b) => {
1484
+ if (a.online !== b.online) return a.online ? -1 : 1;
1485
+ const aT = a.last_seen ? new Date(a.last_seen).getTime() : 0;
1486
+ const bT = b.last_seen ? new Date(b.last_seen).getTime() : 0;
1487
+ if (aT !== bT) return bT - aT;
1488
+ return a.nick.localeCompare(b.nick);
1489
+ });
1490
+ const onlineCount = bots.filter(a => a.online).length;
1491
+ document.getElementById('agent-count').textContent = onlineCount + '/' + bots.length + ' online' + (agents.length !== bots.length ? ' ('+agents.length+' shown)' : '');
14611492
const rows = agents.map(a => {
14621493
const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join('');
14631494
const perms = (a.config?.permissions||[]).map(p=>`<span class="tag perm">${esc(p)}</span>`).join('');
14641495
const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : '';
1465
- return `<tr>
1466
- <td><strong>${esc(a.nick)}</strong></td>
1496
+ const seen = a.last_seen ? relTime(a.last_seen) : 'never';
1497
+ const seenStyle = a.online ? 'color:#3fb950' : 'color:#8b949e';
1498
+ return `<tr${a.revoked?' style="opacity:0.5"':''}>
1499
+ <td>${presenceDot(a)} <strong>${esc(a.nick)}</strong></td>
14671500
<td><span class="tag type-${a.type}">${esc(a.type)}</span>${rev}</td>
14681501
<td>${chs||'<span style="color:#8b949e">—</span>'}</td>
1469
- <td>${perms||'<span style="color:#8b949e">—</span>'}</td>
1470
- <td style="white-space:nowrap">${fmtTime(a.created_at)}</td>
1502
+ <td style="white-space:nowrap;${seenStyle}">${seen}</td>
14711503
<td><div class="actions">${!a.revoked?`
14721504
<button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button>
14731505
<button class="sm danger" onclick="revokeAgent('${esc(a.nick)}')">revoke</button>`:``}
14741506
<button class="sm danger" onclick="deleteAgent('${esc(a.nick)}')">delete</button></div></td>
14751507
</tr>`;
14761508
});
14771509
renderTable('agents-container', null, rows,
14781510
bots.length ? 'no agents match the filter' : 'no agents registered yet',
1479
- ['nick','type','channels','permissions','registered','']);
1511
+ ['nick','type','channels','last seen','']);
14801512
}
14811513
14821514
async function revokeAgent(nick) {
14831515
if (!confirm(`Revoke "${nick}"? This cannot be undone.`)) return;
14841516
try { await api('POST', `/v1/agents/${nick}/revoke`); await loadAgents(); await loadStatus(); }
14851517
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -1345,11 +1345,12 @@
1345 ]);
1346
1347 // Status card.
1348 document.getElementById('stat-status').innerHTML = '<span class="dot green"></span>'+s.status;
1349 document.getElementById('stat-uptime').textContent = s.uptime;
1350 document.getElementById('stat-agents').textContent = s.agents;
 
1351 const d = new Date(s.started);
1352 document.getElementById('stat-started').textContent = d.toLocaleTimeString();
1353 document.getElementById('stat-started-rel').textContent = d.toLocaleDateString();
1354 document.getElementById('status-error').style.display = 'none';
1355 document.getElementById('metrics-updated').textContent = 'updated '+new Date().toLocaleTimeString();
@@ -1446,39 +1447,70 @@
1446 document.getElementById('user-count').textContent = countTxt;
1447 renderTable('users-container', null, rows,
1448 all.length ? 'no users match the filter' : 'no users registered yet',
1449 ['nick','type','channels','registered','']);
1450 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1451
1452 function renderAgentTable() {
1453 const q = (document.getElementById('agent-search').value||'').toLowerCase();
1454 const bots = allAgents.filter(a => a.type !== 'operator');
1455 const agents = bots.filter(a => !q ||
1456 a.nick.toLowerCase().includes(q) ||
1457 a.type.toLowerCase().includes(q) ||
1458 (a.config?.channels||[]).some(c => c.toLowerCase().includes(q)) ||
1459 (a.config?.permissions||[]).some(p => p.toLowerCase().includes(q)));
1460 document.getElementById('agent-count').textContent = bots.length + (agents.length !== bots.length ? ' / '+agents.length+' shown' : '');
 
 
 
 
 
 
 
 
 
1461 const rows = agents.map(a => {
1462 const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join('');
1463 const perms = (a.config?.permissions||[]).map(p=>`<span class="tag perm">${esc(p)}</span>`).join('');
1464 const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : '';
1465 return `<tr>
1466 <td><strong>${esc(a.nick)}</strong></td>
 
 
1467 <td><span class="tag type-${a.type}">${esc(a.type)}</span>${rev}</td>
1468 <td>${chs||'<span style="color:#8b949e">—</span>'}</td>
1469 <td>${perms||'<span style="color:#8b949e">—</span>'}</td>
1470 <td style="white-space:nowrap">${fmtTime(a.created_at)}</td>
1471 <td><div class="actions">${!a.revoked?`
1472 <button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button>
1473 <button class="sm danger" onclick="revokeAgent('${esc(a.nick)}')">revoke</button>`:``}
1474 <button class="sm danger" onclick="deleteAgent('${esc(a.nick)}')">delete</button></div></td>
1475 </tr>`;
1476 });
1477 renderTable('agents-container', null, rows,
1478 bots.length ? 'no agents match the filter' : 'no agents registered yet',
1479 ['nick','type','channels','permissions','registered','']);
1480 }
1481
1482 async function revokeAgent(nick) {
1483 if (!confirm(`Revoke "${nick}"? This cannot be undone.`)) return;
1484 try { await api('POST', `/v1/agents/${nick}/revoke`); await loadAgents(); await loadStatus(); }
1485
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -1345,11 +1345,12 @@
1345 ]);
1346
1347 // Status card.
1348 document.getElementById('stat-status').innerHTML = '<span class="dot green"></span>'+s.status;
1349 document.getElementById('stat-uptime').textContent = s.uptime;
1350 const onlineAgents = allAgents.filter(a => a.online).length;
1351 document.getElementById('stat-agents').textContent = onlineAgents + '/' + s.agents;
1352 const d = new Date(s.started);
1353 document.getElementById('stat-started').textContent = d.toLocaleTimeString();
1354 document.getElementById('stat-started-rel').textContent = d.toLocaleDateString();
1355 document.getElementById('status-error').style.display = 'none';
1356 document.getElementById('metrics-updated').textContent = 'updated '+new Date().toLocaleTimeString();
@@ -1446,39 +1447,70 @@
1447 document.getElementById('user-count').textContent = countTxt;
1448 renderTable('users-container', null, rows,
1449 all.length ? 'no users match the filter' : 'no users registered yet',
1450 ['nick','type','channels','registered','']);
1451 }
1452
1453 function relTime(ts) {
1454 if (!ts) return 'never';
1455 const ms = Date.now() - new Date(ts).getTime();
1456 if (ms < 0) return 'just now';
1457 const s = Math.floor(ms/1000), m = Math.floor(s/60), h = Math.floor(m/60), d = Math.floor(h/24);
1458 if (d > 0) return d + 'd ago';
1459 if (h > 0) return h + 'h ago';
1460 if (m > 0) return m + 'm ago';
1461 return s + 's ago';
1462 }
1463
1464 function presenceDot(a) {
1465 if (a.revoked) return '<span style="color:#f85149" title="revoked">◼</span>';
1466 if (a.online) return '<span style="color:#3fb950" title="online">●</span>';
1467 if (a.last_seen) {
1468 const mins = (Date.now() - new Date(a.last_seen).getTime()) / 60000;
1469 if (mins < 10) return '<span style="color:#d29922" title="idle">●</span>';
1470 }
1471 return '<span style="color:#484f58" title="offline">●</span>';
1472 }
1473
1474 function renderAgentTable() {
1475 const q = (document.getElementById('agent-search').value||'').toLowerCase();
1476 const bots = allAgents.filter(a => a.type !== 'operator');
1477 const agents = bots.filter(a => !q ||
1478 a.nick.toLowerCase().includes(q) ||
1479 a.type.toLowerCase().includes(q) ||
1480 (a.config?.channels||[]).some(c => c.toLowerCase().includes(q)) ||
1481 (a.config?.permissions||[]).some(p => p.toLowerCase().includes(q)));
1482 // Sort: online first, then by last_seen descending, then by nick.
1483 agents.sort((a, b) => {
1484 if (a.online !== b.online) return a.online ? -1 : 1;
1485 const aT = a.last_seen ? new Date(a.last_seen).getTime() : 0;
1486 const bT = b.last_seen ? new Date(b.last_seen).getTime() : 0;
1487 if (aT !== bT) return bT - aT;
1488 return a.nick.localeCompare(b.nick);
1489 });
1490 const onlineCount = bots.filter(a => a.online).length;
1491 document.getElementById('agent-count').textContent = onlineCount + '/' + bots.length + ' online' + (agents.length !== bots.length ? ' ('+agents.length+' shown)' : '');
1492 const rows = agents.map(a => {
1493 const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join('');
1494 const perms = (a.config?.permissions||[]).map(p=>`<span class="tag perm">${esc(p)}</span>`).join('');
1495 const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : '';
1496 const seen = a.last_seen ? relTime(a.last_seen) : 'never';
1497 const seenStyle = a.online ? 'color:#3fb950' : 'color:#8b949e';
1498 return `<tr${a.revoked?' style="opacity:0.5"':''}>
1499 <td>${presenceDot(a)} <strong>${esc(a.nick)}</strong></td>
1500 <td><span class="tag type-${a.type}">${esc(a.type)}</span>${rev}</td>
1501 <td>${chs||'<span style="color:#8b949e">—</span>'}</td>
1502 <td style="white-space:nowrap;${seenStyle}">${seen}</td>
 
1503 <td><div class="actions">${!a.revoked?`
1504 <button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button>
1505 <button class="sm danger" onclick="revokeAgent('${esc(a.nick)}')">revoke</button>`:``}
1506 <button class="sm danger" onclick="deleteAgent('${esc(a.nick)}')">delete</button></div></td>
1507 </tr>`;
1508 });
1509 renderTable('agents-container', null, rows,
1510 bots.length ? 'no agents match the filter' : 'no agents registered yet',
1511 ['nick','type','channels','last seen','']);
1512 }
1513
1514 async function revokeAgent(nick) {
1515 if (!confirm(`Revoke "${nick}"? This cannot be undone.`)) return;
1516 try { await api('POST', `/v1/agents/${nick}/revoke`); await loadAgents(); await loadStatus(); }
1517

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button