// Agents admin — Windows machines that send heartbeats to the central
// server (instead of being probed). Lives at /agents view; admin-only.
//
// Data flow:
// GET /api/admin/agents list
// POST /api/admin/agents create → returns enrollment_token (visible ONLY here)
// PATCH /api/admin/agents/{id} rename / change group / disable
// POST /api/admin/agents/{id}/pause pause heartbeat alerting
// POST /api/admin/agents/{id}/resume
// POST /api/admin/agents/{id}/regenerate-enrollment
// DELETE /api/admin/agents/{id}
//
// Hooks rules: every useState / useEffect declared above any early
// return — same pattern as users_admin.jsx and admin.jsx.
const AGENT_INPUT = {
width: '100%', padding: '5px 9px', background: 'var(--surface)',
border: '1px solid var(--border-2)', borderRadius: 2,
color: 'var(--text)', fontFamily: 'var(--mono)', fontSize: 12, outline: 'none',
};
function EnrollmentTokenModal({ token, agent, expires, onClose, onCopied }) {
const [copied, setCopied] = React.useState(false);
if (!token) return null;
const expiresIn = expires ? Math.max(0, Math.round((expires * 1000 - Date.now()) / 3600000)) : 24;
async function copy() {
try {
await navigator.clipboard.writeText(token);
setCopied(true);
onCopied && onCopied();
setTimeout(() => setCopied(false), 1800);
} catch (_) {
window.prompt('Copy this enrollment code:', token);
}
}
return (
Enrollment code · {agent ? agent.label : ''}
Този код се показва само сега. Копирай го и го въведи в pulse-agent.exe wizard-а на Windows машината.
Изтича след ~{expiresIn}h.
{token}
);
}
function AgentEditor({ agent, onClose, onSaved }) {
const open = !!agent;
const [label, setLabel] = React.useState('');
const [group, setGroup] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
React.useEffect(() => {
if (!agent) return;
setLabel(agent.label || '');
setGroup(agent.group || 'Windows clients');
setErr(null);
}, [agent?.id]);
if (!open) return ;
async function save() {
setBusy(true); setErr(null);
try {
const updated = await window.apiFetch(`/api/admin/agents/${agent.id}`, {
method: 'PATCH',
body: JSON.stringify({ label: label.trim(), group: group.trim() }),
});
onSaved(updated);
} catch (e) { setErr(e.message || 'save failed'); }
finally { setBusy(false); }
}
return (
setLabel(e.target.value)}
placeholder="Reception PC – CityDent Sofia" />
setGroup(e.target.value)}
placeholder="Windows clients" />
{agent.hostname || '—'}{agent.windowsUser ? ` · user ${agent.windowsUser}` : ''}
machine guid: {agent.machineGuid || '—'}
mac: {(agent.macAddresses || []).join(', ') || '—'}
agent v{agent.agentVersion || '—'}
{err &&
{err}
}
);
}
function AddAgentForm({ onCreated }) {
const [label, setLabel] = React.useState('');
const [group, setGroup] = React.useState('Windows clients');
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
async function submit(e) {
e.preventDefault();
setBusy(true); setErr(null);
try {
const res = await window.apiFetch('/api/admin/agents', {
method: 'POST',
body: JSON.stringify({ label: label.trim(), group: group.trim() || 'Windows clients' }),
});
onCreated(res);
setLabel('');
} catch (e) { setErr(e.message || 'create failed'); }
finally { setBusy(false); }
}
return (
);
}
function AgentsAdmin() {
const [agents, setAgents] = React.useState(null);
const [editing, setEditing] = React.useState(null);
const [confirmDelete, setConfirmDelete] = React.useState(null);
const [tokenModal, setTokenModal] = React.useState(null); // { token, agent, expires }
const [err, setErr] = React.useState(null);
const [now, setNow] = React.useState(Date.now());
async function load() {
setErr(null);
try {
const data = await window.apiFetch('/api/admin/agents');
setAgents(data.agents || []);
} catch (e) { if (e.message !== 'unauthorized') setErr(e.message); }
}
React.useEffect(() => { load(); }, []);
React.useEffect(() => {
// Refresh list every 10s so liveness flips visible without manual reload.
const t = setInterval(load, 10_000);
// Tick state every second so the "last seen" relative timestamps move.
const tick = setInterval(() => setNow(Date.now()), 1000);
return () => { clearInterval(t); clearInterval(tick); };
}, []);
async function onCreated(res) {
setTokenModal({ token: res.enrollmentToken, agent: res.agent, expires: res.enrollmentExpires });
await load();
}
async function onSaved() { setEditing(null); await load(); }
async function onTogglePause(a) {
try {
await window.apiFetch(`/api/admin/agents/${a.id}/${a.paused ? 'resume' : 'pause'}`, { method: 'POST' });
await load();
} catch (e) { setErr(e.message); }
}
async function onRegenerate(a) {
if (!confirm(`Re-issue enrollment code for "${a.label || a.id}"?\n\nThe existing device key will be revoked — the running pulse-agent.exe will start failing with 401.`)) return;
try {
const res = await window.apiFetch(`/api/admin/agents/${a.id}/regenerate-enrollment`, { method: 'POST' });
setTokenModal({ token: res.enrollmentToken, agent: res.agent, expires: res.enrollmentExpires });
await load();
} catch (e) { setErr(e.message); }
}
async function onDelete(a) {
try {
await window.apiFetch(`/api/admin/agents/${a.id}`, { method: 'DELETE' });
await load();
} catch (e) { setErr(e.message); }
}
const counts = React.useMemo(() => {
if (!agents) return { ok: 0, warn: 0, crit: 0, unknown: 0, paused: 0 };
const c = { ok: 0, warn: 0, crit: 0, unknown: 0, paused: 0 };
for (const a of agents) {
if (a.paused) { c.paused++; continue; }
c[a.status] = (c[a.status] || 0) + 1;
}
return c;
}, [agents]);
return (
Windows agents
{agents
? `${agents.length} · ${counts.ok} live · ${counts.warn} late · ${counts.crit} offline · ${counts.paused} paused`
: 'loading…'}
download .exe
{err &&
{err}
}
|
label |
hostname |
group |
last seen |
version |
status |
actions |
{(agents || []).map(a => {
const lastText = a.lastSeen
? window.fmtRel(a.lastSeen)
: (a.enrollmentPending ? 'awaiting enrollment' : 'never');
const statusLabel = a.paused ? 'paused'
: a.status === 'ok' ? 'live'
: a.status === 'warn' ? 'late'
: a.status === 'crit' ? 'offline'
: a.status === 'unknown' ? 'pending'
: a.status;
return (
|
{a.label || a.id} |
{a.hostname || '—'}{a.windowsUser ? ` · ${a.windowsUser}` : ''} |
{a.group} |
{lastText} |
{a.agentVersion || '—'} |
{statusLabel} |
{confirmDelete === a.id ? (
) : (
)}
|
);
})}
{(!agents || agents.length === 0) && (
|
{agents === null ? 'Loading…' : 'No agents yet. Click "new agent" to create your first enrollment code.'}
|
)}
How it works
- Click new agent. Enter a friendly label (e.g. "Reception PC – Sofia").
- Copy the one-time enrollment code shown in the modal — it's visible only once.
- Download pulse-agent.exe on the Windows machine and run it.
- The wizard asks for the server URL (default
https://pulse.sitnov.work) and the enrollment code.
- Once enrolled, the agent runs in the system tray and pings the server every ~30 seconds.
- If a heartbeat is missed for 90 s the row goes
offline and the regular alert pipeline fires.
setEditing(null)} onSaved={onSaved} />
{tokenModal && (
setTokenModal(null)}
/>
)}
);
}
Object.assign(window, { AgentsAdmin });