ScuttleBot

ui: add pagination and status filtering to agents, search to channels Agents tab: - Status filter dropdown: all/online/offline/revoked - Text search filters by nick, type, channel - Pagination at 25 agents per page with prev/next controls - Filters and search reset to page 0 Channels tab: - Search filter on channel names - Count shows filtered/total when filtering

lmata 2026-04-03 21:58 trunk
Commit c080acb0e001b92b3850c13e297152a4481323aa3ae62a05503e1b036eb95b5e
1 file changed +78 -22
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -422,15 +422,26 @@
422422
</div>
423423
424424
<!-- AGENTS -->
425425
<div class="tab-pane" id="pane-agents">
426426
<div class="filter-bar">
427
- <input type="text" id="agent-search" placeholder="search by nick, type, channel…" oninput="renderAgentTable()" style="max-width:320px">
427
+ <input type="text" id="agent-search" placeholder="search by nick, type, channel…" oninput="agentPage=0;renderAgentTable()" style="max-width:280px">
428
+ <select id="agent-status-filter" onchange="agentPage=0;renderAgentTable()" style="padding:5px 8px;font-size:12px">
429
+ <option value="all">all</option>
430
+ <option value="online">online</option>
431
+ <option value="offline">offline</option>
432
+ <option value="revoked">revoked</option>
433
+ </select>
428434
<div class="spacer"></div>
429435
<span class="badge" id="agent-count" style="margin-right:4px">0</span>
430436
<button class="sm" onclick="loadAgents()">↻ refresh</button>
431437
<button class="sm primary" onclick="openDrawer()">+ register agent</button>
438
+ </div>
439
+ <div id="agent-pagination" style="display:none;padding:4px 16px;font-size:12px;color:#8b949e;display:flex;align-items:center;gap:8px">
440
+ <button class="sm" id="agent-prev" onclick="agentPage--;renderAgentTable()">← prev</button>
441
+ <span id="agent-page-info"></span>
442
+ <button class="sm" id="agent-next" onclick="agentPage++;renderAgentTable()">next →</button>
432443
</div>
433444
<div style="flex:1;overflow-y:auto">
434445
<div id="agents-container"></div>
435446
</div>
436447
</div>
@@ -441,12 +452,13 @@
441452
<div class="card">
442453
<div class="card-header">
443454
<h2>channels</h2>
444455
<span class="badge" id="chan-count">0</span>
445456
<div class="spacer"></div>
457
+ <input type="text" id="chan-search" placeholder="filter…" oninput="renderChanList()" style="width:120px;padding:5px 8px;font-size:12px">
446458
<div style="display:flex;gap:6px;align-items:center">
447
- <input type="text" id="quick-join-input" placeholder="#channel" style="width:160px;padding:5px 8px;font-size:12px" autocomplete="off">
459
+ <input type="text" id="quick-join-input" placeholder="#channel" style="width:140px;padding:5px 8px;font-size:12px" autocomplete="off">
448460
<button class="sm primary" onclick="quickJoin()">join</button>
449461
</div>
450462
</div>
451463
<div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
452464
</div>
@@ -1474,31 +1486,63 @@
14741486
if (mins < 10) return '<span style="color:#d29922" title="idle">●</span>';
14751487
}
14761488
return '<span style="color:#484f58" title="offline">●</span>';
14771489
}
14781490
1491
+let agentPage = 0;
1492
+const AGENTS_PER_PAGE = 25;
1493
+
14791494
function renderAgentTable() {
14801495
const q = (document.getElementById('agent-search').value||'').toLowerCase();
1496
+ const statusFilter = document.getElementById('agent-status-filter').value;
14811497
const bots = allAgents.filter(a => a.type !== 'operator');
1482
- const agents = bots.filter(a => !q ||
1498
+
1499
+ // Status filter.
1500
+ let filtered = bots;
1501
+ if (statusFilter === 'online') filtered = bots.filter(a => a.online);
1502
+ else if (statusFilter === 'offline') filtered = bots.filter(a => !a.online && !a.revoked);
1503
+ else if (statusFilter === 'revoked') filtered = bots.filter(a => a.revoked);
1504
+
1505
+ // Text search.
1506
+ const agents = filtered.filter(a => !q ||
14831507
a.nick.toLowerCase().includes(q) ||
14841508
a.type.toLowerCase().includes(q) ||
14851509
(a.config?.channels||[]).some(c => c.toLowerCase().includes(q)) ||
14861510
(a.config?.permissions||[]).some(p => p.toLowerCase().includes(q)));
1511
+
14871512
// Sort: online first, then by last_seen descending, then by nick.
14881513
agents.sort((a, b) => {
14891514
if (a.online !== b.online) return a.online ? -1 : 1;
14901515
const aT = a.last_seen ? new Date(a.last_seen).getTime() : 0;
14911516
const bT = b.last_seen ? new Date(b.last_seen).getTime() : 0;
14921517
if (aT !== bT) return bT - aT;
14931518
return a.nick.localeCompare(b.nick);
14941519
});
1520
+
1521
+ // Pagination.
1522
+ const totalPages = Math.max(1, Math.ceil(agents.length / AGENTS_PER_PAGE));
1523
+ if (agentPage >= totalPages) agentPage = totalPages - 1;
1524
+ if (agentPage < 0) agentPage = 0;
1525
+ const start = agentPage * AGENTS_PER_PAGE;
1526
+ const pageAgents = agents.slice(start, start + AGENTS_PER_PAGE);
1527
+
14951528
const onlineCount = bots.filter(a => a.online).length;
1496
- document.getElementById('agent-count').textContent = onlineCount + '/' + bots.length + ' online' + (agents.length !== bots.length ? ' ('+agents.length+' shown)' : '');
1497
- const rows = agents.map(a => {
1529
+ document.getElementById('agent-count').textContent = onlineCount + '/' + bots.length + ' online' + (agents.length !== bots.length ? ' (' + agents.length + ' shown)' : '');
1530
+
1531
+ // Pagination controls.
1532
+ const pagEl = document.getElementById('agent-pagination');
1533
+ if (agents.length > AGENTS_PER_PAGE) {
1534
+ pagEl.style.display = 'flex';
1535
+ document.getElementById('agent-prev').disabled = agentPage === 0;
1536
+ document.getElementById('agent-next').disabled = agentPage >= totalPages - 1;
1537
+ document.getElementById('agent-page-info').textContent = `page ${agentPage+1}/${totalPages}`;
1538
+ } else {
1539
+ pagEl.style.display = 'none';
1540
+ }
1541
+
1542
+ const rows = pageAgents.map(a => {
14981543
const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join('');
1499
- const perms = (a.config?.permissions||[]).map(p=>`<span class="tag perm">${esc(p)}</span>`).join('');
15001544
const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : '';
15011545
const seen = a.last_seen ? relTime(a.last_seen) : 'never';
15021546
const seenStyle = a.online ? 'color:#3fb950' : 'color:#8b949e';
15031547
return `<tr${a.revoked?' style="opacity:0.5"':''}>
15041548
<td>${presenceDot(a)} <strong>${esc(a.nick)}</strong></td>
@@ -1636,34 +1680,46 @@
16361680
<div style="margin-top:8px;font-size:11px;color:#7ee787">⚠ Save the passphrase — shown once only.</div></div>`
16371681
);
16381682
}
16391683
16401684
// --- channels tab ---
1685
+let allChannels = [];
1686
+
16411687
async function loadChanTab() {
16421688
if (!getToken()) return;
16431689
try {
16441690
const data = await api('GET', '/v1/channels');
1645
- const channels = data.channels || [];
1646
- document.getElementById('chan-count').textContent = channels.length;
1647
- if (channels.length === 0) {
1648
- document.getElementById('channels-list').innerHTML = '<div class="empty">no channels joined yet — type a channel name above and click join</div>';
1649
- return;
1650
- }
1651
- document.getElementById('channels-list').innerHTML = channels.sort().map(ch =>
1652
- `<div class="chan-card">
1653
- <div>
1654
- <div class="chan-name">${esc(ch)}</div>
1655
- <div class="chan-meta">joined</div>
1656
- </div>
1657
- <div class="spacer"></div>
1658
- <button class="sm" onclick="switchTab('chat');setTimeout(()=>selectChannel('${esc(ch)}'),50)">open chat →</button>
1659
- </div>`
1660
- ).join('');
1691
+ allChannels = (data.channels || []).sort();
1692
+ renderChanList();
16611693
} catch(e) {
16621694
document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
16631695
}
16641696
}
1697
+
1698
+function renderChanList() {
1699
+ const q = (document.getElementById('chan-search').value||'').toLowerCase();
1700
+ const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q));
1701
+ document.getElementById('chan-count').textContent = filtered.length + (filtered.length !== allChannels.length ? '/' + allChannels.length : '');
1702
+ if (allChannels.length === 0) {
1703
+ document.getElementById('channels-list').innerHTML = '<div class="empty">no channels joined yet — type a channel name above and click join</div>';
1704
+ return;
1705
+ }
1706
+ if (filtered.length === 0) {
1707
+ document.getElementById('channels-list').innerHTML = '<div class="empty">no channels match the filter</div>';
1708
+ return;
1709
+ }
1710
+ document.getElementById('channels-list').innerHTML = filtered.map(ch =>
1711
+ `<div class="chan-card">
1712
+ <div>
1713
+ <div class="chan-name">${esc(ch)}</div>
1714
+ <div class="chan-meta">joined</div>
1715
+ </div>
1716
+ <div class="spacer"></div>
1717
+ <button class="sm" onclick="switchTab('chat');setTimeout(()=>selectChannel('${esc(ch)}'),50)">open chat →</button>
1718
+ </div>`
1719
+ ).join('');
1720
+}
16651721
async function quickJoin() {
16661722
let ch = document.getElementById('quick-join-input').value.trim();
16671723
if (!ch) return;
16681724
if (!ch.startsWith('#')) ch = '#' + ch;
16691725
const slug = ch.replace(/^#/,'');
16701726
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -422,15 +422,26 @@
422 </div>
423
424 <!-- AGENTS -->
425 <div class="tab-pane" id="pane-agents">
426 <div class="filter-bar">
427 <input type="text" id="agent-search" placeholder="search by nick, type, channel…" oninput="renderAgentTable()" style="max-width:320px">
 
 
 
 
 
 
428 <div class="spacer"></div>
429 <span class="badge" id="agent-count" style="margin-right:4px">0</span>
430 <button class="sm" onclick="loadAgents()">↻ refresh</button>
431 <button class="sm primary" onclick="openDrawer()">+ register agent</button>
 
 
 
 
 
432 </div>
433 <div style="flex:1;overflow-y:auto">
434 <div id="agents-container"></div>
435 </div>
436 </div>
@@ -441,12 +452,13 @@
441 <div class="card">
442 <div class="card-header">
443 <h2>channels</h2>
444 <span class="badge" id="chan-count">0</span>
445 <div class="spacer"></div>
 
446 <div style="display:flex;gap:6px;align-items:center">
447 <input type="text" id="quick-join-input" placeholder="#channel" style="width:160px;padding:5px 8px;font-size:12px" autocomplete="off">
448 <button class="sm primary" onclick="quickJoin()">join</button>
449 </div>
450 </div>
451 <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
452 </div>
@@ -1474,31 +1486,63 @@
1474 if (mins < 10) return '<span style="color:#d29922" title="idle">●</span>';
1475 }
1476 return '<span style="color:#484f58" title="offline">●</span>';
1477 }
1478
 
 
 
1479 function renderAgentTable() {
1480 const q = (document.getElementById('agent-search').value||'').toLowerCase();
 
1481 const bots = allAgents.filter(a => a.type !== 'operator');
1482 const agents = bots.filter(a => !q ||
 
 
 
 
 
 
 
 
1483 a.nick.toLowerCase().includes(q) ||
1484 a.type.toLowerCase().includes(q) ||
1485 (a.config?.channels||[]).some(c => c.toLowerCase().includes(q)) ||
1486 (a.config?.permissions||[]).some(p => p.toLowerCase().includes(q)));
 
1487 // Sort: online first, then by last_seen descending, then by nick.
1488 agents.sort((a, b) => {
1489 if (a.online !== b.online) return a.online ? -1 : 1;
1490 const aT = a.last_seen ? new Date(a.last_seen).getTime() : 0;
1491 const bT = b.last_seen ? new Date(b.last_seen).getTime() : 0;
1492 if (aT !== bT) return bT - aT;
1493 return a.nick.localeCompare(b.nick);
1494 });
 
 
 
 
 
 
 
 
1495 const onlineCount = bots.filter(a => a.online).length;
1496 document.getElementById('agent-count').textContent = onlineCount + '/' + bots.length + ' online' + (agents.length !== bots.length ? ' ('+agents.length+' shown)' : '');
1497 const rows = agents.map(a => {
 
 
 
 
 
 
 
 
 
 
 
 
1498 const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join('');
1499 const perms = (a.config?.permissions||[]).map(p=>`<span class="tag perm">${esc(p)}</span>`).join('');
1500 const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : '';
1501 const seen = a.last_seen ? relTime(a.last_seen) : 'never';
1502 const seenStyle = a.online ? 'color:#3fb950' : 'color:#8b949e';
1503 return `<tr${a.revoked?' style="opacity:0.5"':''}>
1504 <td>${presenceDot(a)} <strong>${esc(a.nick)}</strong></td>
@@ -1636,34 +1680,46 @@
1636 <div style="margin-top:8px;font-size:11px;color:#7ee787">⚠ Save the passphrase — shown once only.</div></div>`
1637 );
1638 }
1639
1640 // --- channels tab ---
 
 
1641 async function loadChanTab() {
1642 if (!getToken()) return;
1643 try {
1644 const data = await api('GET', '/v1/channels');
1645 const channels = data.channels || [];
1646 document.getElementById('chan-count').textContent = channels.length;
1647 if (channels.length === 0) {
1648 document.getElementById('channels-list').innerHTML = '<div class="empty">no channels joined yet — type a channel name above and click join</div>';
1649 return;
1650 }
1651 document.getElementById('channels-list').innerHTML = channels.sort().map(ch =>
1652 `<div class="chan-card">
1653 <div>
1654 <div class="chan-name">${esc(ch)}</div>
1655 <div class="chan-meta">joined</div>
1656 </div>
1657 <div class="spacer"></div>
1658 <button class="sm" onclick="switchTab('chat');setTimeout(()=>selectChannel('${esc(ch)}'),50)">open chat →</button>
1659 </div>`
1660 ).join('');
1661 } catch(e) {
1662 document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
1663 }
1664 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1665 async function quickJoin() {
1666 let ch = document.getElementById('quick-join-input').value.trim();
1667 if (!ch) return;
1668 if (!ch.startsWith('#')) ch = '#' + ch;
1669 const slug = ch.replace(/^#/,'');
1670
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -422,15 +422,26 @@
422 </div>
423
424 <!-- AGENTS -->
425 <div class="tab-pane" id="pane-agents">
426 <div class="filter-bar">
427 <input type="text" id="agent-search" placeholder="search by nick, type, channel…" oninput="agentPage=0;renderAgentTable()" style="max-width:280px">
428 <select id="agent-status-filter" onchange="agentPage=0;renderAgentTable()" style="padding:5px 8px;font-size:12px">
429 <option value="all">all</option>
430 <option value="online">online</option>
431 <option value="offline">offline</option>
432 <option value="revoked">revoked</option>
433 </select>
434 <div class="spacer"></div>
435 <span class="badge" id="agent-count" style="margin-right:4px">0</span>
436 <button class="sm" onclick="loadAgents()">↻ refresh</button>
437 <button class="sm primary" onclick="openDrawer()">+ register agent</button>
438 </div>
439 <div id="agent-pagination" style="display:none;padding:4px 16px;font-size:12px;color:#8b949e;display:flex;align-items:center;gap:8px">
440 <button class="sm" id="agent-prev" onclick="agentPage--;renderAgentTable()">← prev</button>
441 <span id="agent-page-info"></span>
442 <button class="sm" id="agent-next" onclick="agentPage++;renderAgentTable()">next →</button>
443 </div>
444 <div style="flex:1;overflow-y:auto">
445 <div id="agents-container"></div>
446 </div>
447 </div>
@@ -441,12 +452,13 @@
452 <div class="card">
453 <div class="card-header">
454 <h2>channels</h2>
455 <span class="badge" id="chan-count">0</span>
456 <div class="spacer"></div>
457 <input type="text" id="chan-search" placeholder="filter…" oninput="renderChanList()" style="width:120px;padding:5px 8px;font-size:12px">
458 <div style="display:flex;gap:6px;align-items:center">
459 <input type="text" id="quick-join-input" placeholder="#channel" style="width:140px;padding:5px 8px;font-size:12px" autocomplete="off">
460 <button class="sm primary" onclick="quickJoin()">join</button>
461 </div>
462 </div>
463 <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
464 </div>
@@ -1474,31 +1486,63 @@
1486 if (mins < 10) return '<span style="color:#d29922" title="idle">●</span>';
1487 }
1488 return '<span style="color:#484f58" title="offline">●</span>';
1489 }
1490
1491 let agentPage = 0;
1492 const AGENTS_PER_PAGE = 25;
1493
1494 function renderAgentTable() {
1495 const q = (document.getElementById('agent-search').value||'').toLowerCase();
1496 const statusFilter = document.getElementById('agent-status-filter').value;
1497 const bots = allAgents.filter(a => a.type !== 'operator');
1498
1499 // Status filter.
1500 let filtered = bots;
1501 if (statusFilter === 'online') filtered = bots.filter(a => a.online);
1502 else if (statusFilter === 'offline') filtered = bots.filter(a => !a.online && !a.revoked);
1503 else if (statusFilter === 'revoked') filtered = bots.filter(a => a.revoked);
1504
1505 // Text search.
1506 const agents = filtered.filter(a => !q ||
1507 a.nick.toLowerCase().includes(q) ||
1508 a.type.toLowerCase().includes(q) ||
1509 (a.config?.channels||[]).some(c => c.toLowerCase().includes(q)) ||
1510 (a.config?.permissions||[]).some(p => p.toLowerCase().includes(q)));
1511
1512 // Sort: online first, then by last_seen descending, then by nick.
1513 agents.sort((a, b) => {
1514 if (a.online !== b.online) return a.online ? -1 : 1;
1515 const aT = a.last_seen ? new Date(a.last_seen).getTime() : 0;
1516 const bT = b.last_seen ? new Date(b.last_seen).getTime() : 0;
1517 if (aT !== bT) return bT - aT;
1518 return a.nick.localeCompare(b.nick);
1519 });
1520
1521 // Pagination.
1522 const totalPages = Math.max(1, Math.ceil(agents.length / AGENTS_PER_PAGE));
1523 if (agentPage >= totalPages) agentPage = totalPages - 1;
1524 if (agentPage < 0) agentPage = 0;
1525 const start = agentPage * AGENTS_PER_PAGE;
1526 const pageAgents = agents.slice(start, start + AGENTS_PER_PAGE);
1527
1528 const onlineCount = bots.filter(a => a.online).length;
1529 document.getElementById('agent-count').textContent = onlineCount + '/' + bots.length + ' online' + (agents.length !== bots.length ? ' (' + agents.length + ' shown)' : '');
1530
1531 // Pagination controls.
1532 const pagEl = document.getElementById('agent-pagination');
1533 if (agents.length > AGENTS_PER_PAGE) {
1534 pagEl.style.display = 'flex';
1535 document.getElementById('agent-prev').disabled = agentPage === 0;
1536 document.getElementById('agent-next').disabled = agentPage >= totalPages - 1;
1537 document.getElementById('agent-page-info').textContent = `page ${agentPage+1}/${totalPages}`;
1538 } else {
1539 pagEl.style.display = 'none';
1540 }
1541
1542 const rows = pageAgents.map(a => {
1543 const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join('');
 
1544 const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : '';
1545 const seen = a.last_seen ? relTime(a.last_seen) : 'never';
1546 const seenStyle = a.online ? 'color:#3fb950' : 'color:#8b949e';
1547 return `<tr${a.revoked?' style="opacity:0.5"':''}>
1548 <td>${presenceDot(a)} <strong>${esc(a.nick)}</strong></td>
@@ -1636,34 +1680,46 @@
1680 <div style="margin-top:8px;font-size:11px;color:#7ee787">⚠ Save the passphrase — shown once only.</div></div>`
1681 );
1682 }
1683
1684 // --- channels tab ---
1685 let allChannels = [];
1686
1687 async function loadChanTab() {
1688 if (!getToken()) return;
1689 try {
1690 const data = await api('GET', '/v1/channels');
1691 allChannels = (data.channels || []).sort();
1692 renderChanList();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1693 } catch(e) {
1694 document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
1695 }
1696 }
1697
1698 function renderChanList() {
1699 const q = (document.getElementById('chan-search').value||'').toLowerCase();
1700 const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q));
1701 document.getElementById('chan-count').textContent = filtered.length + (filtered.length !== allChannels.length ? '/' + allChannels.length : '');
1702 if (allChannels.length === 0) {
1703 document.getElementById('channels-list').innerHTML = '<div class="empty">no channels joined yet — type a channel name above and click join</div>';
1704 return;
1705 }
1706 if (filtered.length === 0) {
1707 document.getElementById('channels-list').innerHTML = '<div class="empty">no channels match the filter</div>';
1708 return;
1709 }
1710 document.getElementById('channels-list').innerHTML = filtered.map(ch =>
1711 `<div class="chan-card">
1712 <div>
1713 <div class="chan-name">${esc(ch)}</div>
1714 <div class="chan-meta">joined</div>
1715 </div>
1716 <div class="spacer"></div>
1717 <button class="sm" onclick="switchTab('chat');setTimeout(()=>selectChannel('${esc(ch)}'),50)">open chat →</button>
1718 </div>`
1719 ).join('');
1720 }
1721 async function quickJoin() {
1722 let ch = document.getElementById('quick-join-input').value.trim();
1723 if (!ch) return;
1724 if (!ch.startsWith('#')) ch = '#' + ch;
1725 const slug = ch.replace(/^#/,'');
1726

Keyboard Shortcuts

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