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
Commit
c080acb0e001b92b3850c13e297152a4481323aa3ae62a05503e1b036eb95b5e
Parent
e8b561659020874…
1 file changed
+78
-22
+78
-22
| --- internal/api/ui/index.html | ||
| +++ internal/api/ui/index.html | ||
| @@ -422,15 +422,26 @@ | ||
| 422 | 422 | </div> |
| 423 | 423 | |
| 424 | 424 | <!-- AGENTS --> |
| 425 | 425 | <div class="tab-pane" id="pane-agents"> |
| 426 | 426 | <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> | |
| 428 | 434 | <div class="spacer"></div> |
| 429 | 435 | <span class="badge" id="agent-count" style="margin-right:4px">0</span> |
| 430 | 436 | <button class="sm" onclick="loadAgents()">↻ refresh</button> |
| 431 | 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> | |
| 432 | 443 | </div> |
| 433 | 444 | <div style="flex:1;overflow-y:auto"> |
| 434 | 445 | <div id="agents-container"></div> |
| 435 | 446 | </div> |
| 436 | 447 | </div> |
| @@ -441,12 +452,13 @@ | ||
| 441 | 452 | <div class="card"> |
| 442 | 453 | <div class="card-header"> |
| 443 | 454 | <h2>channels</h2> |
| 444 | 455 | <span class="badge" id="chan-count">0</span> |
| 445 | 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"> | |
| 446 | 458 | <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"> | |
| 448 | 460 | <button class="sm primary" onclick="quickJoin()">join</button> |
| 449 | 461 | </div> |
| 450 | 462 | </div> |
| 451 | 463 | <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div> |
| 452 | 464 | </div> |
| @@ -1474,31 +1486,63 @@ | ||
| 1474 | 1486 | if (mins < 10) return '<span style="color:#d29922" title="idle">●</span>'; |
| 1475 | 1487 | } |
| 1476 | 1488 | return '<span style="color:#484f58" title="offline">●</span>'; |
| 1477 | 1489 | } |
| 1478 | 1490 | |
| 1491 | +let agentPage = 0; | |
| 1492 | +const AGENTS_PER_PAGE = 25; | |
| 1493 | + | |
| 1479 | 1494 | function renderAgentTable() { |
| 1480 | 1495 | const q = (document.getElementById('agent-search').value||'').toLowerCase(); |
| 1496 | + const statusFilter = document.getElementById('agent-status-filter').value; | |
| 1481 | 1497 | 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 || | |
| 1483 | 1507 | a.nick.toLowerCase().includes(q) || |
| 1484 | 1508 | a.type.toLowerCase().includes(q) || |
| 1485 | 1509 | (a.config?.channels||[]).some(c => c.toLowerCase().includes(q)) || |
| 1486 | 1510 | (a.config?.permissions||[]).some(p => p.toLowerCase().includes(q))); |
| 1511 | + | |
| 1487 | 1512 | // Sort: online first, then by last_seen descending, then by nick. |
| 1488 | 1513 | agents.sort((a, b) => { |
| 1489 | 1514 | if (a.online !== b.online) return a.online ? -1 : 1; |
| 1490 | 1515 | const aT = a.last_seen ? new Date(a.last_seen).getTime() : 0; |
| 1491 | 1516 | const bT = b.last_seen ? new Date(b.last_seen).getTime() : 0; |
| 1492 | 1517 | if (aT !== bT) return bT - aT; |
| 1493 | 1518 | return a.nick.localeCompare(b.nick); |
| 1494 | 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 | + | |
| 1495 | 1528 | 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 => { | |
| 1498 | 1543 | 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 | 1544 | const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : ''; |
| 1501 | 1545 | const seen = a.last_seen ? relTime(a.last_seen) : 'never'; |
| 1502 | 1546 | const seenStyle = a.online ? 'color:#3fb950' : 'color:#8b949e'; |
| 1503 | 1547 | return `<tr${a.revoked?' style="opacity:0.5"':''}> |
| 1504 | 1548 | <td>${presenceDot(a)} <strong>${esc(a.nick)}</strong></td> |
| @@ -1636,34 +1680,46 @@ | ||
| 1636 | 1680 | <div style="margin-top:8px;font-size:11px;color:#7ee787">⚠ Save the passphrase — shown once only.</div></div>` |
| 1637 | 1681 | ); |
| 1638 | 1682 | } |
| 1639 | 1683 | |
| 1640 | 1684 | // --- channels tab --- |
| 1685 | +let allChannels = []; | |
| 1686 | + | |
| 1641 | 1687 | async function loadChanTab() { |
| 1642 | 1688 | if (!getToken()) return; |
| 1643 | 1689 | try { |
| 1644 | 1690 | 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(); | |
| 1661 | 1693 | } catch(e) { |
| 1662 | 1694 | document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>'; |
| 1663 | 1695 | } |
| 1664 | 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 | +} | |
| 1665 | 1721 | async function quickJoin() { |
| 1666 | 1722 | let ch = document.getElementById('quick-join-input').value.trim(); |
| 1667 | 1723 | if (!ch) return; |
| 1668 | 1724 | if (!ch.startsWith('#')) ch = '#' + ch; |
| 1669 | 1725 | const slug = ch.replace(/^#/,''); |
| 1670 | 1726 |
| --- 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 |