feat: Agents page + secure /api/agent REST (v1.22.0)
- New admin Agents page (nav + /agents route + views/agents.ejs): live list of
connected theta-agent hosts with CPU/RAM/disk/ZFS/GPU telemetry and online
status, updated live over socket.io ('agent.telemetry'/'agent.discovery').
- Auth + admin-gate the /api/agent REST router (it was mounted without
middleware.auth — anyone could list nodes / send commands). The agent
WebSocket (/api/agent/ws) is unaffected (handled by the raw wss upgrade with
its own token auth).
- package.json + lockfile bumped to 1.22.0 to match the tag.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2><i class="fa-solid fa-microchip"></i> Theta Agents <small class="text-muted">(connected hosts)</small></h2>
|
||||
<button class="btn btn-outline-primary" onclick="loadAgents()"><i class="fa-solid fa-rotate"></i> Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light"><h5 class="card-title mb-0">Connected agents</h5></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th>IP</th>
|
||||
<th>Status</th>
|
||||
<th style="width:110px">CPU</th>
|
||||
<th style="width:110px">RAM</th>
|
||||
<th style="width:110px">Disk</th>
|
||||
<th>ZFS</th>
|
||||
<th>GPU</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agents-tbody">
|
||||
<tr><td colspan="9" class="text-center text-muted">Loading agents...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted small mt-3">
|
||||
Live data from the theta-agent telemetry stream. An agent reports hostname/IP discovery and
|
||||
CPU/RAM/disk/ZFS/GPU usage every ~60s over the WebSocket; "Online" means seen in the last 90s.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
app.auth.forceLogin(['app_sso_admin', 'admin']);
|
||||
|
||||
let agentsById = {}; // token -> agent record
|
||||
|
||||
function bar(val) {
|
||||
val = Math.max(0, Math.min(100, val || 0));
|
||||
return `<div class="progress" style="height:8px"><div class="progress-bar" role="progressbar" style="width:${val}%"></div></div>`;
|
||||
}
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return '';
|
||||
const m = moment(iso);
|
||||
return m.isValid() ? m.fromNow() : '';
|
||||
}
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return app.util.escapeHtml(String(s));
|
||||
}
|
||||
|
||||
function renderRow(id, a) {
|
||||
const d = a.discovery || {};
|
||||
const t = a.telemetry || {};
|
||||
const online = !!a.isOnline;
|
||||
const badge = `<span class="badge ${online ? 'bg-success' : 'bg-secondary'}">${online ? 'Online' : 'Offline'}</span>`;
|
||||
const cpu = t.cpu_usage_percent != null ? t.cpu_usage_percent : 0;
|
||||
const ram = t.ram_usage_percent != null ? t.ram_usage_percent : 0;
|
||||
const disk = t.disk_usage_percent != null ? t.disk_usage_percent : 0;
|
||||
const gpu = (t.gpu_usage_percent != null && t.gpu_usage_percent >= 0) ? t.gpu_usage_percent + '%' : 'N/A';
|
||||
return `<tr id="agent-${id}">
|
||||
<td><strong>${esc(a.hostname || 'unknown')}</strong>${d.location ? `<div class="small text-muted">${esc(d.location)}</div>` : ''}</td>
|
||||
<td>${esc(a.ipAddress || '')}</td>
|
||||
<td>${badge}</td>
|
||||
<td>${cpu}% ${bar(cpu)}</td>
|
||||
<td>${ram}% ${bar(ram)}</td>
|
||||
<td>${disk}% ${bar(disk)}</td>
|
||||
<td>${esc(t.zfs_health || 'N/A')}</td>
|
||||
<td>${gpu}</td>
|
||||
<td class="small text-muted">${timeAgo(a.lastSeen)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
async function loadAgents() {
|
||||
const tbody = document.getElementById('agents-tbody');
|
||||
try {
|
||||
const res = await app.api.get('agent/nodes');
|
||||
const agents = (res && res.agents) || [];
|
||||
agentsById = {};
|
||||
agents.forEach(a => { agentsById[a.token] = a; });
|
||||
tbody.innerHTML = agents.length
|
||||
? agents.map(a => renderRow(a.token, a)).join('')
|
||||
: '<tr><td colspan="9" class="text-center text-muted">No agents connected.</td></tr>';
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="9" class="text-center text-danger">Error loading agents: ${esc(err && err.message || err)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Live updates from the server's agent.* socket.io broadcasts. The app's
|
||||
// default socket is scoped to P2PSub, so open a dedicated socket here.
|
||||
const agentSocket = io({ auth: { token: app.auth.getToken() } });
|
||||
agentSocket.on('agent.telemetry', (msg) => {
|
||||
const a = agentsById[msg && msg.token];
|
||||
if (a) { a.telemetry = msg.payload; a.isOnline = true; const row = document.getElementById('agent-' + msg.token); if (row) row.outerHTML = renderRow(msg.token, a); }
|
||||
});
|
||||
agentSocket.on('agent.discovery', (msg) => {
|
||||
const a = agentsById[msg && msg.token];
|
||||
if (a) { a.discovery = msg.payload; a.hostname = (msg.payload && msg.payload.hostname) || a.hostname; const row = document.getElementById('agent-' + msg.token); if (row) row.outerHTML = renderRow(msg.token, a); }
|
||||
});
|
||||
|
||||
loadAgents();
|
||||
// Re-fetch periodically to reflect connect/disconnect + isOnline (90s window).
|
||||
setInterval(loadAgents, 30000);
|
||||
</script>
|
||||
|
||||
<%- include('bottom') %>
|
||||
Reference in New Issue
Block a user