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
Commit
ada73432140ce08c6bb167753135ce8ffb873557d6bd55341e540c3cca1d73bb
Parent
81587e63ffd76e6…
1 file changed
+39
-7
+39
-7
| --- internal/api/ui/index.html | ||
| +++ internal/api/ui/index.html | ||
| @@ -1345,11 +1345,12 @@ | ||
| 1345 | 1345 | ]); |
| 1346 | 1346 | |
| 1347 | 1347 | // Status card. |
| 1348 | 1348 | document.getElementById('stat-status').innerHTML = '<span class="dot green"></span>'+s.status; |
| 1349 | 1349 | 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; | |
| 1351 | 1352 | const d = new Date(s.started); |
| 1352 | 1353 | document.getElementById('stat-started').textContent = d.toLocaleTimeString(); |
| 1353 | 1354 | document.getElementById('stat-started-rel').textContent = d.toLocaleDateString(); |
| 1354 | 1355 | document.getElementById('status-error').style.display = 'none'; |
| 1355 | 1356 | document.getElementById('metrics-updated').textContent = 'updated '+new Date().toLocaleTimeString(); |
| @@ -1446,39 +1447,70 @@ | ||
| 1446 | 1447 | document.getElementById('user-count').textContent = countTxt; |
| 1447 | 1448 | renderTable('users-container', null, rows, |
| 1448 | 1449 | all.length ? 'no users match the filter' : 'no users registered yet', |
| 1449 | 1450 | ['nick','type','channels','registered','']); |
| 1450 | 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 | +} | |
| 1451 | 1473 | |
| 1452 | 1474 | function renderAgentTable() { |
| 1453 | 1475 | const q = (document.getElementById('agent-search').value||'').toLowerCase(); |
| 1454 | 1476 | const bots = allAgents.filter(a => a.type !== 'operator'); |
| 1455 | 1477 | const agents = bots.filter(a => !q || |
| 1456 | 1478 | a.nick.toLowerCase().includes(q) || |
| 1457 | 1479 | a.type.toLowerCase().includes(q) || |
| 1458 | 1480 | (a.config?.channels||[]).some(c => c.toLowerCase().includes(q)) || |
| 1459 | 1481 | (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)' : ''); | |
| 1461 | 1492 | const rows = agents.map(a => { |
| 1462 | 1493 | const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join(''); |
| 1463 | 1494 | const perms = (a.config?.permissions||[]).map(p=>`<span class="tag perm">${esc(p)}</span>`).join(''); |
| 1464 | 1495 | 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> | |
| 1467 | 1500 | <td><span class="tag type-${a.type}">${esc(a.type)}</span>${rev}</td> |
| 1468 | 1501 | <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> | |
| 1471 | 1503 | <td><div class="actions">${!a.revoked?` |
| 1472 | 1504 | <button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button> |
| 1473 | 1505 | <button class="sm danger" onclick="revokeAgent('${esc(a.nick)}')">revoke</button>`:``} |
| 1474 | 1506 | <button class="sm danger" onclick="deleteAgent('${esc(a.nick)}')">delete</button></div></td> |
| 1475 | 1507 | </tr>`; |
| 1476 | 1508 | }); |
| 1477 | 1509 | renderTable('agents-container', null, rows, |
| 1478 | 1510 | bots.length ? 'no agents match the filter' : 'no agents registered yet', |
| 1479 | - ['nick','type','channels','permissions','registered','']); | |
| 1511 | + ['nick','type','channels','last seen','']); | |
| 1480 | 1512 | } |
| 1481 | 1513 | |
| 1482 | 1514 | async function revokeAgent(nick) { |
| 1483 | 1515 | if (!confirm(`Revoke "${nick}"? This cannot be undone.`)) return; |
| 1484 | 1516 | try { await api('POST', `/v1/agents/${nick}/revoke`); await loadAgents(); await loadStatus(); } |
| 1485 | 1517 |
| --- 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 |