(() => {
const STORAGE_KEYS = {
theme: 'altin.theme',
token: 'altin.token',
serverUrl: 'altin.serverUrl',
};
const state = {
serverUrl: '',
token: '',
agents: [],
filteredAgents: [],
// One selection, owned by the table checkboxes. The bulk-task dialog is a
// read-only view of it - a second selection set inside the dialog let the
// dialog and the table disagree about who was about to be commanded.
selectedIds: new Set(),
filters: {
search: '',
implant: 'all',
os: 'all',
},
bulkCommand: '',
interactCommand: '',
};
const els = {};
const normalizeServer = (value) => value.trim().replace(/\/+$/, '');
const escapeHTML = (value) => String(value ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
const labelFor = (agent) => `${agent.implant_id}@${agent.ip}`;
// The selected agents, in the order the table shows them, resolved against the
// current agent list so a stale id cannot survive a refresh.
const selectedAgents = () => state.agents.filter((agent) => state.selectedIds.has(agent.id));
const setTheme = (theme) => {
const nextTheme = theme === 'light' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', nextTheme);
localStorage.setItem(STORAGE_KEYS.theme, nextTheme);
if (els.themeToggle) {
els.themeToggle.textContent = nextTheme === 'dark' ? 'Light' : 'Dark';
els.themeToggle.setAttribute('aria-label', `Switch to ${nextTheme === 'dark' ? 'light' : 'dark'} theme`);
}
};
const getTheme = () => localStorage.getItem(STORAGE_KEYS.theme) || 'dark';
const redirectToLogin = () => {
window.location.href = '/login.html';
};
const requireAuth = () => {
const token = localStorage.getItem(STORAGE_KEYS.token);
const serverUrl = localStorage.getItem(STORAGE_KEYS.serverUrl);
if (!token || !serverUrl) {
redirectToLogin();
return false;
}
state.token = token;
state.serverUrl = normalizeServer(serverUrl);
return true;
};
const apiFetch = async (path, options = {}) => {
const headers = new Headers(options.headers || {});
headers.set('Authorization', state.token);
if (options.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const response = await fetch(`${state.serverUrl}${path}`, {
...options,
headers,
});
if (response.status === 401) {
redirectToLogin();
throw new Error('Unauthorized');
}
return response;
};
const loadJSON = async (path) => {
const response = await apiFetch(path);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.message || `Request failed for ${path}`);
}
return payload;
};
const setMessage = (message, tone = '') => {
if (!els.bulkTaskMessage) return;
els.bulkTaskMessage.textContent = message;
els.bulkTaskMessage.dataset.tone = tone;
};
// Page-level failures, shown under the page title. This is deliberately not
// setMessage: that writes into the bulk task modal's message area, which is
// hidden unless the dialog is open, so a failed load used to report its
// reason into a dialog nobody could see. Matches the #pageError used on
// index.html.
const setError = (message) => {
if (!els.pageError) return;
els.pageError.textContent = message;
};
// See the identical helper in app.js: the server labels naive local timestamps as
// GMT, so a bare GMT/UTC label is re-read as local time instead of as UTC.
const parseServerTime = (value) => {
if (!value) return NaN;
if (typeof value === 'number') return value;
if (/^\d{10,}$/.test(String(value).trim())) return Number(value);
const text = String(value).trim();
if (/[+-]\d{2}:?\d{2}$/.test(text) || /Z$/i.test(text)) {
return new Date(text).getTime();
}
const iso = text.match(/^(?:[A-Za-z]{3},?\s*)?(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})/);
if (iso) {
return new Date(+iso[1], +iso[2] - 1, +iso[3], +iso[4], +iso[5], +iso[6]).getTime();
}
const rfc = text.match(/^[A-Za-z]{3},?\s*(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})/);
if (rfc) {
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
const month = months.indexOf(rfc[2].toLowerCase());
if (month >= 0) {
return new Date(+rfc[3], month, +rfc[1], +rfc[4], +rfc[5], +rfc[6]).getTime();
}
}
return new Date(text).getTime();
};
const formatTimeAgo = (value) => {
const time = parseServerTime(value);
if (!Number.isFinite(time)) return 'unknown';
const delta = Math.max(0, Date.now() - time);
const minutes = Math.floor(delta / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'just now';
};
const getCommands = (agent) => Array.isArray(agent.commands) ? agent.commands : [];
// --- Agent interaction console -----------------------------------------
// The management API creates tasks without returning their id, so a task we just
// queued has to be identified by diffing the agent's task list around the POST.
// Once it reports a result the task flips to completed, and the output is read back
// from /api/task-results.
const POLL_INTERVAL_MS = 2000;
const TASK_TIMEOUT_MS = 90000;
const console_ = {
agentId: null,
agent: null,
entries: [],
knownTaskIds: new Set(),
seq: 0,
open: false,
sending: false,
};
// Command history is a read-only view of what has already been run against an
// agent. Entries start collapsed and are remembered by task id, so re-rendering
// after a filter change or a refresh does not snap everything shut again.
const history_ = {
agentId: null,
agent: null,
entries: [],
expanded: new Set(),
filter: '',
open: false,
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const interactMessage = (message, tone = '') => {
if (!els.interactMessage) return;
els.interactMessage.textContent = message;
els.interactMessage.dataset.tone = tone;
};
// Show what was actually sent, preserving JSON types so the transcript reads like
// the request body (setsleep 30 5, not "30" "5").
const formatArgs = (args) => (args || [])
.map((value) => (typeof value === 'string' ? value : JSON.stringify(value)))
.join(' ');
const renderInteractLog = () => {
if (!els.interactLog) return;
if (!console_.entries.length) {
els.interactLog.innerHTML = '
';
};
// The implant reads each argument with a typed accessor on the decoded JSON node
// (args[i].getInt / getStr), and the server does NOT type-check - it only compares
// the argument count. So a value sent as a JSON string reaches the agent as a
// JString and the accessor yields garbage.
//
// Verified against Nim 2.2.4, which is what the implant builds with:
// getInt on JInt -> the value (correct)
// getInt on JFloat -> 0, silently (no exception!)
// getInt on JStr -> 0, silently (no exception!)
// getFloat on JInt or JFloat -> the value (both fine)
// getStr on JStr -> the value; on JInt/JFloat -> "" (silently)
//
// getInt is therefore the strict accessor, so whole numbers are always sent as JSON
// integers even when the command declares "float" - a JSON 30.0 would decode to 0.
const coerceArg = (raw, argType) => {
const type = String(argType || 'str').toLowerCase();
if (type === 'int' || type === 'integer' || type === 'long') {
if (!/^[+-]?\d+$/.test(raw)) {
return { error: `"${raw}" is not a valid integer.` };
}
return { value: Number.parseInt(raw, 10) };
}
if (type === 'float' || type === 'double' || type === 'number') {
if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(raw)) {
return { error: `"${raw}" is not a valid number.` };
}
const parsed = Number.parseFloat(raw);
// Send a JSON integer when the value is whole: the implant's getInt accessor
// silently yields 0 for a JSON float, so 30 must go over the wire as 30, not
// 30.0. Genuinely fractional values (30.5) stay floats, which getFloat and
// getInt-free code paths read correctly.
return { value: Number.isInteger(parsed) ? Math.trunc(parsed) : parsed };
}
if (type === 'bool' || type === 'boolean') {
const lowered = raw.toLowerCase();
if (['true', '1', 'yes'].includes(lowered)) return { value: true };
if (['false', '0', 'no'].includes(lowered)) return { value: false };
return { error: `"${raw}" is not a valid boolean (use true or false).` };
}
return { value: raw };
};
// Reads the arg inputs for a command and coerces each to its declared type.
// Shared by the interact console and the bulk-task modal so both send the same
// JSON shapes. `fail` is called with a message and returns false on bad input.
const collectTypedArgs = (container, schema, fail) => {
const inputs = [...container.querySelectorAll('[data-interact-arg], [data-bulk-arg]')];
const args = [];
for (const input of inputs) {
const raw = input.value.trim();
// Blank optional args are skipped; a blank cannot be passed past this point
// without shifting every later positional argument.
if (raw === '') continue;
const index = Number(input.dataset.interactArg ?? input.dataset.bulkArg);
const arg = schema[index] || {};
const { value, error } = coerceArg(raw, arg.arg_type);
if (error) {
fail(`${arg.name || 'Argument'}: ${error}`);
input.focus();
return null;
}
args.push(value);
}
return args;
};
const collectInteractArgs = () => {
const command = getCommands(console_.agent).find((entry) => entry.command === state.interactCommand);
if (!command) return null;
const schema = command.args || [];
const args = collectTypedArgs(els.interactArgsContainer, schema, interactMessage);
if (!args) return null;
// The API requires the argument count to equal the number of required arguments
// exactly, so anything else is rejected with a 404.
const required = schema.filter((arg) => arg.required).length;
if (args.length !== required) {
interactMessage(`This command needs exactly ${required} argument${required === 1 ? '' : 's'}.`, 'error');
return null;
}
return { command, args };
};
const fetchAgentTasks = async () => {
const payload = await loadJSON(`/api/tasks?agent=${encodeURIComponent(console_.agentId)}`);
return payload.tasks || [];
};
// task_id filters on the *agent* parameter server-side, so the task id has to be
// supplied as both. Omitting either one makes the endpoint return a 500.
const fetchTaskOutput = async (taskId) => {
const query = `task_id=${encodeURIComponent(taskId)}&agent=${encodeURIComponent(taskId)}`;
const payload = await loadJSON(`/api/task-results?${query}`);
return payload.results || [];
};
// Waits for one transcript entry to reach a terminal state. Entries fall into two
// kinds: a task we just queued, whose id the API never told us and which therefore has
// to be spotted by diffing the task list, and a task that was already on the server
// when the console opened, whose id we already know.
const pollForResult = async (entry, before) => {
const deadline = Date.now() + TASK_TIMEOUT_MS;
while (console_.open && Date.now() < deadline) {
await sleep(POLL_INTERVAL_MS);
if (!console_.open || entry.state === 'error' || entry.state === 'timeout') return;
let tasks;
try {
tasks = await fetchAgentTasks();
} catch (error) {
// A 401 already triggered a redirect to the login page.
if (error.message === 'Unauthorized') return;
continue;
}
let task = null;
if (entry.taskId) {
task = tasks.find((candidate) => candidate.id === entry.taskId) || null;
if (!task) {
entry.state = 'error';
entry.note = 'Task is no longer on the server.';
renderInteractLog();
return;
}
} else {
const fresh = tasks.filter((candidate) => !before.has(candidate.id));
const exact = fresh.find((candidate) =>
candidate.task === entry.command &&
JSON.stringify(candidate.args || []) === JSON.stringify(entry.args)
);
task = exact || fresh[0] || null;
if (task) entry.taskId = task.id;
}
if (!task) continue;
if (task.sent && entry.state === 'pending') {
entry.state = 'sent';
renderInteractLog();
}
if (!task.completed) continue;
try {
entry.outputs = await fetchTaskOutput(task.id);
entry.state = 'done';
if (!entry.outputs.length) {
entry.note = 'Task completed but the agent reported no result.';
}
} catch (error) {
entry.state = 'error';
entry.note = `Could not read output: ${error.message}`;
}
renderInteractLog();
return;
}
if (console_.open && (entry.state === 'pending' || entry.state === 'sent')) {
entry.state = 'timeout';
entry.note = `No response after ${Math.round(TASK_TIMEOUT_MS / 1000)}s. The agent may be offline, ` +
'asleep, or the command may not have completed.';
}
renderInteractLog();
};
const sendInteractTask = async () => {
if (console_.sending) return;
const collected = collectInteractArgs();
if (!collected) return;
const { command, args } = collected;
console_.sending = true;
els.sendInteractButton.disabled = true;
interactMessage('Sending…');
const entry = {
key: ++console_.seq,
command: command.command,
args,
argsText: formatArgs(args),
state: 'pending',
outputs: [],
note: '',
};
let before;
try {
before = new Set((await fetchAgentTasks()).map((task) => task.id));
} catch (error) {
interactMessage(error.message || 'Could not read the agent task list.', 'error');
console_.sending = false;
els.sendInteractButton.disabled = false;
return;
}
console_.entries.push(entry);
renderInteractLog();
try {
const response = await apiFetch('/api/tasks', {
method: 'POST',
body: JSON.stringify({ agent_id: console_.agentId, command: command.command, args }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
entry.state = 'error';
entry.note = payload.message || `Server rejected the task (${response.status}).`;
interactMessage(entry.note, 'error');
renderInteractLog();
return;
}
interactMessage('Task queued. Waiting for the agent to check in.', 'success');
pollForResult(entry, before);
} catch (error) {
entry.state = 'error';
entry.note = error.message || 'Failed to send the task.';
interactMessage(entry.note, 'error');
renderInteractLog();
} finally {
console_.sending = false;
els.sendInteractButton.disabled = false;
}
};
const openInteract = async (agentId) => {
const summary = state.agents.find((agent) => agent.id === agentId);
if (!summary) return;
console_.agentId = agentId;
console_.agent = summary;
console_.entries = [];
console_.seq = 0;
console_.open = true;
state.interactCommand = '';
els.interactModal.classList.add('is-open');
els.interactModal.classList.remove('hidden');
els.interactModal.setAttribute('aria-hidden', 'false');
els.interactTitle.textContent = 'Interact';
els.interactSubtitle.textContent = `${summary.ip} · ${summary.os} · ${getCommands(summary).length} commands · last check-in ${formatTimeAgo(summary.last_checkin)}`;
els.interactAgentChip.textContent = `${summary.implant_id}@${summary.ip}`;
els.interactHint.textContent = 'Responses arrive when the agent next checks in, so this can take a few seconds.';
interactMessage('');
renderInteractLog();
// Hold off sending until the command list and the existing task list are both
// known, otherwise a command can be queued against a stale command list and then
// show up twice once the seed below catches up.
els.sendInteractButton.disabled = true;
const commands = getCommands(summary);
els.interactCommand.innerHTML = commands.length
? commands.map((command) => ``).join('')
: '';
state.interactCommand = commands[0]?.command || '';
els.interactCommand.value = state.interactCommand;
renderInteractCommand();
// Refresh from the API so the command list reflects the agent's latest
// registration rather than whatever the table happened to be holding.
try {
const full = await loadJSON(`/api/agents/${encodeURIComponent(agentId)}`);
console_.agent = full;
const fresh = getCommands(full);
els.interactCommand.innerHTML = fresh.length
? fresh.map((command) => ``).join('')
: '';
state.interactCommand = fresh[0]?.command || '';
els.interactCommand.value = state.interactCommand;
renderInteractCommand();
} catch (error) {
interactMessage(error.message || 'Could not refresh agent details.', 'error');
}
// Surface tasks that were already queued before this session opened, since the
// server re-delivers every incomplete task on the agent's next check-in. Each one
// gets its own poller so it resolves instead of hanging at "pending" forever.
try {
const tasks = await fetchAgentTasks();
console_.knownTaskIds = new Set(tasks.map((task) => task.id));
tasks.filter((task) => !task.completed).forEach((task) => {
console_.seq += 1;
const entry = {
key: console_.seq,
command: task.task,
args: task.args || [],
argsText: formatArgs(task.args),
state: task.sent ? 'sent' : 'pending',
outputs: [],
taskId: task.id,
note: 'Queued before this console was opened.',
};
console_.entries.push(entry);
pollForResult(entry, new Set());
});
renderInteractLog();
} catch (error) {
if (error.message !== 'Unauthorized') {
interactMessage(error.message || 'Could not load existing tasks.', 'error');
}
} finally {
if (console_.open) els.sendInteractButton.disabled = false;
}
};
const closeInteract = () => {
console_.open = false;
console_.agent = null;
console_.agentId = null;
els.interactModal.classList.remove('is-open');
els.interactModal.classList.add('hidden');
els.interactModal.setAttribute('aria-hidden', 'true');
};
// ------------------------------------------------------------------ *
// Command history
// ------------------------------------------------------------------ */
const historyMessage = (message, tone = '') => {
if (!els.historyMessage) return;
els.historyMessage.textContent = message;
els.historyMessage.className = `modal-message${tone ? ` is-${tone}` : ''}`;
};
// The server exposes no way to scope results to one agent: get_taskresults
// reads request.args['agent'] but compares it against TaskResult.task, so the
// only filters available are "all results" (no params) or "one task" (task_id
// passed as BOTH params, or the endpoint 500s). Fetching once and bucketing
// locally keeps this at two requests instead of one per task.
const fetchHistoryResults = async () => {
const payload = await loadJSON('/api/task-results');
return payload.results || [];
};
const historyState = (entry) => {
if (entry.results.length) return 'done';
return entry.sent ? 'sent' : 'pending';
};
// Newest first, with anything still awaiting a response pinned to the top
// because that is what an operator opening this view is usually after.
const sortHistory = (entries) => [...entries].sort((a, b) => {
const aDone = a.state === 'done';
const bDone = b.state === 'done';
if (aDone !== bDone) return aDone ? 1 : -1;
const at = parseServerTime(a.time);
const bt = parseServerTime(b.time);
const aValid = Number.isFinite(at);
const bValid = Number.isFinite(bt);
if (aValid !== bValid) return aValid ? -1 : 1;
return (bValid ? bt : 0) - (aValid ? at : 0);
});
const buildHistoryEntries = (tasks, results) => {
const byTask = new Map();
for (const result of results) {
const key = result.task;
if (!byTask.has(key)) byTask.set(key, []);
byTask.get(key).push(result);
}
return tasks.map((task) => {
const matched = byTask.get(task.id) || [];
// Oldest first inside an entry, so a command that reported more than
// once reads in the order the agent produced it.
const ordered = [...matched].sort((a, b) => parseServerTime(a.timestamp) - parseServerTime(b.timestamp));
const latest = ordered.length ? ordered[ordered.length - 1].timestamp : null;
return {
taskId: task.id,
command: task.task || 'unknown',
args: task.args || [],
argsText: formatArgs(task.args),
sent: Boolean(task.sent),
completed: Boolean(task.completed),
results: ordered,
time: latest,
state: 'pending',
};
});
};
// Matches the command name or any argument text, so "shell" finds every shell
// run and "passwd" finds the cat that read it.
const historyMatches = (entry, needle) => {
if (!needle) return true;
return entry.command.toLowerCase().includes(needle)
|| entry.argsText.toLowerCase().includes(needle);
};
const renderHistory = () => {
if (!els.historyList) return;
const needle = history_.filter.trim().toLowerCase();
const visible = history_.entries.filter((entry) => historyMatches(entry, needle));
const total = history_.entries.length;
els.historySummary.textContent = needle
? `${visible.length} of ${total} ${total === 1 ? 'command' : 'commands'}`
: `${total} ${total === 1 ? 'command' : 'commands'}`;
if (!total) {
els.historyList.innerHTML = '
No commands have been sent to this agent yet.
';
return;
}
if (!visible.length) {
els.historyList.innerHTML = `
`;
};
const renderTable = () => {
const agents = buildSelectableAgents();
state.filteredAgents = agents;
if (!agents.length) {
// Distinguishes "you deleted the last one" from "your filters exclude
// everything", which otherwise read as the same thing.
const empty = state.agents.length
? 'No agents match the current filters.'
: 'No agents are registered.';
els.agentsTableBody.innerHTML = `
`;
}).join('');
renderSelection();
};
const refreshSelectionFromTable = () => {
const checked = [...els.agentsTableBody.querySelectorAll('input.row-checkbox:checked')].map((input) => input.dataset.agentId);
state.selectedIds = new Set(checked);
renderSelection();
};
const openModal = () => {
els.bulkTaskModal.classList.add('is-open');
els.bulkTaskModal.classList.remove('hidden');
els.bulkTaskModal.setAttribute('aria-hidden', 'false');
renderBulkTargets();
setMessage('');
};
const closeModal = () => {
els.bulkTaskModal.classList.remove('is-open');
els.bulkTaskModal.classList.add('hidden');
els.bulkTaskModal.setAttribute('aria-hidden', 'true');
};
const renderServer = () => {
els.serverChip.textContent = state.serverUrl.replace(/^https?:\/\//, '');
};
const loadAgents = async () => {
const payload = await loadJSON('/api/agents');
state.agents = (payload.agents || []).sort((a, b) => {
const ipCompare = (a.ip || '').localeCompare(b.ip || '', undefined, { numeric: true, sensitivity: 'base' });
if (ipCompare !== 0) return ipCompare;
return (a.implant_id || '').localeCompare(b.implant_id || '', undefined, { sensitivity: 'base' });
});
// Forget selected ids the server no longer knows about. The chips would
// disappear on their own, since selectedAgents intersects with
// state.agents - but leaving the ids behind means any agent that comes
// back under the same id reappears pre-ticked, silently swept into the
// next bulk task.
const live = new Set(state.agents.map((agent) => agent.id));
state.selectedIds.forEach((id) => {
if (!live.has(id)) state.selectedIds.delete(id);
});
updateFilterOptions();
renderTable();
};
// Kept apart from loadAgents because the two failure modes differ. A failed
// first load has nothing to show, but a failed refresh still has a table full
// of agents the user can act on - blanking it to display an error would throw
// away good data over a transient network blip. So a refresh reports through
// the page error line and leaves the table alone.
let refreshing = false;
const refreshAgents = async () => {
if (refreshing) return;
refreshing = true;
els.refreshAgentsButton.disabled = true;
setError('');
try {
// Filters, search text and the open history/interact modals are all
// held outside loadAgents, so a refresh leaves them exactly as they
// were.
await loadAgents();
} catch (error) {
setError(error.message || 'Failed to refresh agents.');
} finally {
refreshing = false;
els.refreshAgentsButton.disabled = false;
}
};
const sendBulkTasks = async () => {
const agents = selectedAgents();
const command = commonCommands().find((cmd) => cmd.command === state.bulkCommand);
if (!agents.length) {
setMessage('No agents selected.', 'error');
return;
}
if (!command) {
setMessage('No common command available for the selected agents.', 'error');
return;
}
const schema = command.args || [];
const args = collectTypedArgs(els.bulkArgsContainer, schema, setMessage);
if (!args) return;
const requiredCount = schema.filter((arg) => arg.required).length;
if (args.length !== requiredCount) {
setMessage('Required arguments are missing.', 'error');
return;
}
setMessage('Sending tasks…');
try {
const results = await Promise.all(agents.map((agent) => apiFetch('/api/tasks', {
method: 'POST',
body: JSON.stringify({
agent_id: agent.id,
command: command.command,
args,
}),
})));
const failed = results.filter((response) => !response.ok).length;
if (failed) {
setMessage(`Completed with ${failed} failed ${failed === 1 ? 'task' : 'tasks'}.`, 'error');
} else {
setMessage(`Task sent to ${agents.length} ${agents.length === 1 ? 'agent' : 'agents'}.`, 'success');
}
} catch (error) {
setMessage(error.message || 'Failed to send tasks.', 'error');
}
};
// --- Delete agent -------------------------------------------------------
// DELETE /api/agents/ removes only the agent row: management_routes.py
// delete_agent does not cascade to tasks. The survivors then make the unscoped
// Task.serialize() dereference a missing agent, so GET /api/tasks returns 500
// and the Overview page loses its task counts. The server is out of scope for
// this work, so the dialog states the consequence rather than implying the
// delete is clean.
const delete_ = {
agentId: null,
agent: null,
busy: false,
};
const deleteMessage = (message, tone = '') => {
if (!els.deleteAgentMessage) return;
els.deleteAgentMessage.textContent = message;
els.deleteAgentMessage.dataset.tone = tone;
};
const openDelete = (agentId) => {
const agent = state.agents.find((candidate) => candidate.id === agentId);
if (!agent) return;
delete_.agentId = agentId;
delete_.agent = agent;
delete_.busy = false;
// Spelled out rather than shown as a bare UUID, so there is no way to
// confirm the wrong row.
els.deleteTargetDetails.innerHTML = [
['Agent', labelFor(agent)],
['ID', agent.id],
['OS', agent.os],
['Last check-in', formatTimeAgo(agent.last_checkin)],
['Tags', (agent.tags || []).length ? agent.tags.join(', ') : 'none'],
['Commands', String(getCommands(agent).length)],
].map(([term, detail]) => `
${escapeHTML(term)}
${escapeHTML(detail)}
`).join('');
els.confirmDeleteAgentButton.disabled = false;
els.confirmDeleteAgentButton.textContent = 'Delete agent';
deleteMessage('');
els.deleteAgentModal.classList.add('is-open');
els.deleteAgentModal.classList.remove('hidden');
els.deleteAgentModal.setAttribute('aria-hidden', 'false');
// Focus the safe option, so a stray Enter cannot destroy the agent.
els.cancelDeleteAgentButton.focus();
};
const closeDelete = () => {
delete_.agentId = null;
delete_.agent = null;
delete_.busy = false;
els.deleteAgentModal.classList.remove('is-open');
els.deleteAgentModal.classList.add('hidden');
els.deleteAgentModal.setAttribute('aria-hidden', 'true');
};
const deleteAgent = async () => {
if (delete_.busy || !delete_.agentId) return;
const agentId = delete_.agentId;
delete_.busy = true;
els.confirmDeleteAgentButton.disabled = true;
els.confirmDeleteAgentButton.textContent = 'Deleting…';
deleteMessage('');
try {
const response = await apiFetch(`/api/agents/${encodeURIComponent(agentId)}`, { method: 'DELETE' });
if (!response.ok) {
// Success is a 204 with no body, so the body is only parsed on
// failure, where the server does send a message.
const payload = await response.json().catch(() => ({}));
throw new Error(payload.message || `Delete failed (HTTP ${response.status}).`);
}
// Dropped locally instead of reloading: the agent is gone from the
// server, and a reload would also pull the unscoped /api/tasks that
// this very delete has just broken.
state.agents = state.agents.filter((agent) => agent.id !== agentId);
state.selectedIds.delete(agentId);
closeDelete();
renderTable();
} catch (error) {
deleteMessage(error.message || 'Failed to delete the agent.', 'error');
} finally {
delete_.busy = false;
els.confirmDeleteAgentButton.disabled = false;
els.confirmDeleteAgentButton.textContent = 'Delete agent';
}
};
const initializeTheme = () => {
setTheme(getTheme());
els.themeToggle.addEventListener('click', () => {
setTheme(getTheme() === 'dark' ? 'light' : 'dark');
});
};
const initializeLogout = () => {
els.logoutButton.addEventListener('click', () => {
localStorage.removeItem(STORAGE_KEYS.token);
redirectToLogin();
});
};
const initializeHandlers = () => {
els.agentSearch.addEventListener('input', (event) => {
state.filters.search = event.target.value;
renderTable();
});
els.implantFilter.addEventListener('change', (event) => {
state.filters.implant = event.target.value;
renderTable();
});
els.osFilter.addEventListener('change', (event) => {
state.filters.os = event.target.value;
renderTable();
});
els.agentsTableBody.addEventListener('change', (event) => {
if (event.target.matches('.row-checkbox')) {
refreshSelectionFromTable();
}
});
// Clicking anywhere on a row opens the interaction console, except the checkbox
// which is reserved for bulk selection. The History button is checked first so
// that opening the history does not also fire the row's own action.
els.agentsTableBody.addEventListener('click', (event) => {
if (event.target.closest('.row-checkbox')) return;
const historyButton = event.target.closest('[data-history-id]');
if (historyButton) {
event.stopPropagation();
openHistory(historyButton.dataset.historyId);
return;
}
// Checked before the row fallback, or deleting a row would also open
// the interact console behind the confirmation dialog.
const deleteButton = event.target.closest('[data-delete-id]');
if (deleteButton) {
event.stopPropagation();
openDelete(deleteButton.dataset.deleteId);
return;
}
const row = event.target.closest('tr[data-agent-id]');
if (!row) return;
openInteract(row.dataset.agentId);
});
els.agentsTableBody.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
// A focused button already turns Enter/Space into its own click event, so
// handling it here too would open the console behind the history modal.
if (event.target.closest('button')) return;
const row = event.target.closest('tr[data-agent-id]');
if (!row) return;
event.preventDefault();
openInteract(row.dataset.agentId);
});
els.selectionChips.addEventListener('click', (event) => {
const button = event.target.closest('[data-remove-selection]');
if (!button) return;
state.selectedIds.delete(button.dataset.removeSelection);
renderTable();
});
els.clearSelectionButton.addEventListener('click', () => {
state.selectedIds.clear();
renderTable();
});
els.selectFilteredButton.addEventListener('click', () => {
buildSelectableAgents().forEach((agent) => state.selectedIds.add(agent.id));
renderTable();
});
els.openBulkTaskButton.addEventListener('click', () => {
if (!state.selectedIds.size) {
setMessage('Select at least one agent first.', 'error');
return;
}
openModal();
});
els.closeBulkTaskButton.addEventListener('click', closeModal);
els.cancelBulkTaskButton.addEventListener('click', closeModal);
els.bulkTaskModal.addEventListener('click', (event) => {
if (event.target === els.bulkTaskModal) {
closeModal();
}
});
els.bulkCommand.addEventListener('change', (event) => {
state.bulkCommand = event.target.value;
renderBulkArgs();
});
els.refreshAgentsButton.addEventListener('click', refreshAgents);
els.submitBulkTaskButton.addEventListener('click', sendBulkTasks);
els.closeDeleteAgentButton.addEventListener('click', closeDelete);
els.cancelDeleteAgentButton.addEventListener('click', closeDelete);
els.confirmDeleteAgentButton.addEventListener('click', deleteAgent);
els.deleteAgentModal.addEventListener('click', (event) => {
if (event.target === els.deleteAgentModal) closeDelete();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && delete_.agentId) closeDelete();
});
els.closeInteractButton.addEventListener('click', closeInteract);
els.cancelInteractButton.addEventListener('click', closeInteract);
els.interactModal.addEventListener('click', (event) => {
if (event.target === els.interactModal) closeInteract();
});
els.closeHistoryButton.addEventListener('click', closeHistory);
els.doneHistoryButton.addEventListener('click', closeHistory);
els.historyModal.addEventListener('click', (event) => {
if (event.target === els.historyModal) closeHistory();
});
els.refreshHistoryButton.addEventListener('click', loadHistory);
els.historySearch.addEventListener('input', (event) => {
history_.filter = event.target.value || '';
renderHistory();
});
// One delegated listener on the list, so toggling never needs a rebind and
// the collapsed/expanded state is re-read from history_ on every render.
els.historyList.addEventListener('click', (event) => {
const toggle = event.target.closest('[data-history-toggle]');
if (!toggle) return;
const id = toggle.dataset.historyToggle;
if (history_.expanded.has(id)) history_.expanded.delete(id);
else history_.expanded.add(id);
renderHistory();
});
els.clearInteractButton.addEventListener('click', () => {
console_.entries = [];
interactMessage('');
renderInteractLog();
});
els.interactCommand.addEventListener('change', (event) => {
state.interactCommand = event.target.value;
renderInteractCommand();
});
els.sendInteractButton.addEventListener('click', sendInteractTask);
};
const cacheElements = () => {
els.themeToggle = document.getElementById('themeToggle');
els.logoutButton = document.getElementById('logoutButton');
els.serverChip = document.getElementById('serverChip');
els.agentSearch = document.getElementById('agentSearch');
els.implantFilter = document.getElementById('implantFilter');
els.osFilter = document.getElementById('osFilter');
els.selectionCount = document.getElementById('selectionCount');
els.selectionChips = document.getElementById('selectionChips');
els.clearSelectionButton = document.getElementById('clearSelectionButton');
els.selectFilteredButton = document.getElementById('selectFilteredButton');
els.openBulkTaskButton = document.getElementById('openBulkTaskButton');
els.agentsTableBody = document.getElementById('agentsTableBody');
els.bulkTaskModal = document.getElementById('bulkTaskModal');
els.closeBulkTaskButton = document.getElementById('closeBulkTaskButton');
els.cancelBulkTaskButton = document.getElementById('cancelBulkTaskButton');
els.bulkTargetCount = document.getElementById('bulkTargetCount');
els.bulkTargetChips = document.getElementById('bulkTargetChips');
els.bulkCommand = document.getElementById('bulkCommand');
els.pageError = document.getElementById('pageError');
els.refreshAgentsButton = document.getElementById('refreshAgentsButton');
els.bulkArgsContainer = document.getElementById('bulkArgsContainer');
els.bulkTaskMessage = document.getElementById('bulkTaskMessage');
els.submitBulkTaskButton = document.getElementById('submitBulkTaskButton');
els.deleteAgentModal = document.getElementById('deleteAgentModal');
els.closeDeleteAgentButton = document.getElementById('closeDeleteAgentButton');
els.cancelDeleteAgentButton = document.getElementById('cancelDeleteAgentButton');
els.confirmDeleteAgentButton = document.getElementById('confirmDeleteAgentButton');
els.deleteTargetDetails = document.getElementById('deleteTargetDetails');
els.deleteAgentMessage = document.getElementById('deleteAgentMessage');
els.interactModal = document.getElementById('interactModal');
els.interactTitle = document.getElementById('interactTitle');
els.interactSubtitle = document.getElementById('interactSubtitle');
els.interactAgentChip = document.getElementById('interactAgentChip');
els.interactHint = document.getElementById('interactHint');
els.interactLog = document.getElementById('interactLog');
els.interactCommand = document.getElementById('interactCommand');
els.interactCommandHelp = document.getElementById('interactCommandHelp');
els.interactArgsContainer = document.getElementById('interactArgsContainer');
els.interactMessage = document.getElementById('interactMessage');
els.sendInteractButton = document.getElementById('sendInteractButton');
els.clearInteractButton = document.getElementById('clearInteractButton');
els.closeInteractButton = document.getElementById('closeInteractButton');
els.cancelInteractButton = document.getElementById('cancelInteractButton');
els.historyModal = document.getElementById('historyModal');
els.historyTitle = document.getElementById('historyTitle');
els.historySubtitle = document.getElementById('historySubtitle');
els.historySearch = document.getElementById('historySearch');
els.historySummary = document.getElementById('historySummary');
els.historyList = document.getElementById('historyList');
els.historyMessage = document.getElementById('historyMessage');
els.refreshHistoryButton = document.getElementById('refreshHistoryButton');
els.closeHistoryButton = document.getElementById('closeHistoryButton');
els.doneHistoryButton = document.getElementById('doneHistoryButton');
};
const main = async () => {
if (!requireAuth()) return;
cacheElements();
initializeTheme();
initializeLogout();
initializeHandlers();
renderServer();
try {
await loadAgents();
} catch (error) {
// setError, not setMessage: on a first load there is genuinely nothing
// to show, so the table is replaced, but the reason has to land
// somewhere the user can actually read it.
setError(error.message || 'Failed to load agents.');
els.agentsTableBody.innerHTML = '