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:
2026-08-03 23:10:16 -04:00
parent 5aad6c13bf
commit ccf3122668
7 changed files with 146 additions and 4 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "t42-sso-manager",
"version": "1.21.0",
"version": "1.22.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
"version": "1.21.0",
"version": "1.22.0",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.21.0",
"version": "1.22.0",
"description": "A very simple LDAP management and SSO system",
"author": [
{
+20 -1
View File
@@ -1,8 +1,12 @@
'use strict';
const express = require('express');
const middleware = require('../middleware/auth');
const permission = require('../utils/permission');
const agentManager = require('../utils/agent_manager');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
module.exports = function initAgentWebSockets(app) {
if (!app.wss) {
console.warn("WebSocket server for agents is not initialized.");
@@ -71,8 +75,23 @@ module.exports = function initAgentWebSockets(app) {
} catch (e) {}
});
// REST API routes for Agent Management (mounted under /api/agent)
// REST API routes for Agent Management (mounted under /api/agent). The agent
// WebSocket (/api/agent/ws) is handled by the raw `wss` upgrade server in
// bin/www with its own ?token= auth — unaffected by the express middleware
// here. These REST routes are admin-facing, so they're auth + admin gated.
const router = express.Router();
router.use(middleware.auth);
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ADMIN_GROUPS);
next();
} catch (err) {
if (err && (err.status === 401 || err.name === 'Insufficient Permission')) {
return res.status(403).json({ status: 'error', message: 'admin only' });
}
next(err);
}
});
router.get('/nodes', (req, res) => {
res.json({
+6
View File
@@ -59,6 +59,12 @@ router.get('/overview', function(req, res) {
res.render('overview', {...values});
});
// Connected theta-agent hosts + live telemetry (admin). Data from
// GET /api/agent/nodes; live updates via socket.io 'agent.*' events.
router.get('/agents', function(req, res) {
res.render('agents', {...values});
});
router.get('/admin', (req, res) => res.redirect(301, '/overview'));
router.get('/notifications', (req, res) => res.redirect(301, '/overview'));
router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
+1
View File
@@ -46,6 +46,7 @@ module.exports = {
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
// Vault requires login - per-user secrets at secret/users/<uid>/*.
{href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']},
{href: '/agents', icon: 'fa-solid fa-microchip', label: 'Agents', groups: ['app_sso_admin', 'admin']},
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
],
};
+112
View File
@@ -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') %>