show if users have webcams and make sorting options better for windows and webcam. Also make webcam settings sticky.

This commit is contained in:
KingKDot
2026-06-12 10:20:02 -05:00
parent b6672ee375
commit cc0e6f737b
15 changed files with 373 additions and 25 deletions
+7 -1
View File
@@ -120,6 +120,7 @@ function clientQueryParams(page = state.page) {
os: state.filterOs || "all", os: state.filterOs || "all",
country: state.filterCountry || "all", country: state.filterCountry || "all",
group: state.filterGroup || "all", group: state.filterGroup || "all",
webcam: state.filterWebcam || "all",
}); });
} }
@@ -222,6 +223,11 @@ function updateOsFilter(items) {
allOption.textContent = "All OS"; allOption.textContent = "All OS";
osSelect.appendChild(allOption); osSelect.appendChild(allOption);
const windowsOption = document.createElement("option");
windowsOption.value = "windows";
windowsOption.textContent = "Windows";
osSelect.appendChild(windowsOption);
osArray.forEach((os) => { osArray.forEach((os) => {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = os; option.value = os;
@@ -229,7 +235,7 @@ function updateOsFilter(items) {
osSelect.appendChild(option); osSelect.appendChild(option);
}); });
if (currentValue !== "all" && osList.has(currentValue)) { if (currentValue === "windows" || (currentValue !== "all" && osList.has(currentValue))) {
osSelect.value = currentValue; osSelect.value = currentValue;
} }
} }
+9
View File
@@ -2277,6 +2277,7 @@ html.nav-pre-sidebar.nav-pre-collapsed #top-nav:empty::before {
#filter-status, #filter-status,
#filter-os, #filter-os,
#filter-group, #filter-group,
#filter-webcam,
#sort { #sort {
transition: transition:
border-color 150ms ease, border-color 150ms ease,
@@ -2287,6 +2288,7 @@ html.nav-pre-sidebar.nav-pre-collapsed #top-nav:empty::before {
#filter-status:focus, #filter-status:focus,
#filter-os:focus, #filter-os:focus,
#filter-group:focus, #filter-group:focus,
#filter-webcam:focus,
#sort:focus { #sort:focus {
border-color: rgba(100, 116, 139, 0.82) !important; border-color: rgba(100, 116, 139, 0.82) !important;
box-shadow: none !important; box-shadow: none !important;
@@ -2715,6 +2717,7 @@ textarea,
#filter-status, #filter-status,
#filter-os, #filter-os,
#filter-group, #filter-group,
#filter-webcam,
#sort, #sort,
#console-input { #console-input {
background-color: var(--ui-surface); background-color: var(--ui-surface);
@@ -2735,6 +2738,7 @@ textarea:focus,
#filter-status:focus, #filter-status:focus,
#filter-os:focus, #filter-os:focus,
#filter-group:focus, #filter-group:focus,
#filter-webcam:focus,
#sort:focus, #sort:focus,
#console-input:focus { #console-input:focus {
border-color: var(--ui-border-strong) !important; border-color: var(--ui-border-strong) !important;
@@ -2951,6 +2955,9 @@ tbody tr:hover {
.cv-pill-admin { background: rgba(234, 179, 8, 0.12); color: #fde68a; border-color: rgba(234, 179, 8, 0.3); } .cv-pill-admin { background: rgba(234, 179, 8, 0.12); color: #fde68a; border-color: rgba(234, 179, 8, 0.3); }
.cv-pill-system { background: rgba(239, 68, 68, 0.12); color: #fca5a5; border-color: rgba(239, 68, 68, 0.3); } .cv-pill-system { background: rgba(239, 68, 68, 0.12); color: #fca5a5; border-color: rgba(239, 68, 68, 0.3); }
.cv-pill-ti { background: rgba(168, 85, 247, 0.12); color: #d8b4fe; border-color: rgba(168, 85, 247, 0.3); } .cv-pill-ti { background: rgba(168, 85, 247, 0.12); color: #d8b4fe; border-color: rgba(168, 85, 247, 0.3); }
.cv-pill-webcam { background: rgba(20, 184, 166, 0.12); color: #99f6e4; border-color: rgba(20, 184, 166, 0.3); }
.cv-mini-pill.cv-webcam-btn { padding: 0; cursor: pointer; }
.cv-mini-pill.cv-webcam-btn:hover { background: rgba(20, 184, 166, 0.20); border-color: rgba(20, 184, 166, 0.5); }
.cv-pill-muted { background: rgba(245, 158, 11, 0.12); color: #fcd34d; border-color: rgba(245, 158, 11, 0.3); } .cv-pill-muted { background: rgba(245, 158, 11, 0.12); color: #fcd34d; border-color: rgba(245, 158, 11, 0.3); }
.cv-pill-macperm.is-ok { background: rgba(16, 185, 129, 0.12); color: #86efac; border-color: rgba(16, 185, 129, 0.3); } .cv-pill-macperm.is-ok { background: rgba(16, 185, 129, 0.12); color: #86efac; border-color: rgba(16, 185, 129, 0.3); }
.cv-pill-macperm.is-warn { background: rgba(245, 158, 11, 0.12); color: #fcd34d; border-color: rgba(245, 158, 11, 0.3); } .cv-pill-macperm.is-warn { background: rgba(245, 158, 11, 0.12); color: #fcd34d; border-color: rgba(245, 158, 11, 0.3); }
@@ -3228,6 +3235,8 @@ tbody tr:hover {
} }
.cv-icon-btn:disabled { opacity: 0.4; cursor: not-allowed; } .cv-icon-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.cv-icon-btn.cv-icon-sm { width: 26px; height: 26px; font-size: 11px; } .cv-icon-btn.cv-icon-sm { width: 26px; height: 26px; font-size: 11px; }
.cv-icon-btn.cv-webcam-btn { color: #99f6e4; border-color: rgba(20, 184, 166, 0.3); }
.cv-icon-btn.cv-webcam-btn:hover { background: rgba(20, 184, 166, 0.10); border-color: rgba(20, 184, 166, 0.45); }
.cv-icon-btn.cv-icon-danger { color: var(--cv-danger); border-color: rgba(239, 68, 68, 0.25); } .cv-icon-btn.cv-icon-danger { color: var(--cv-danger); border-color: rgba(239, 68, 68, 0.25); }
.cv-icon-btn.cv-icon-danger:hover { background: rgba(239, 68, 68, 0.10); border-color: rgba(239, 68, 68, 0.45); } .cv-icon-btn.cv-icon-danger:hover { background: rgba(239, 68, 68, 0.10); border-color: rgba(239, 68, 68, 0.45); }
+18 -1
View File
@@ -28,6 +28,7 @@ const sortSelect = document.getElementById("sort");
const filterStatusSelect = document.getElementById("filter-status"); const filterStatusSelect = document.getElementById("filter-status");
const filterOsSelect = document.getElementById("filter-os"); const filterOsSelect = document.getElementById("filter-os");
const filterGroupSelect = document.getElementById("filter-group"); const filterGroupSelect = document.getElementById("filter-group");
const filterWebcamSelect = document.getElementById("filter-webcam");
const showOfflineToggle = document.getElementById("toggle-offline"); const showOfflineToggle = document.getElementById("toggle-offline");
const displayFieldsMenu = document.getElementById("display-fields-menu"); const displayFieldsMenu = document.getElementById("display-fields-menu");
const selectAllBtn = document.getElementById("select-all"); const selectAllBtn = document.getElementById("select-all");
@@ -56,6 +57,7 @@ const PREF_SORT_KEY = "overlord_sort";
const PREF_FILTER_OS_KEY = "overlord_filter_os"; const PREF_FILTER_OS_KEY = "overlord_filter_os";
const PREF_FILTER_COUNTRY_KEY = "overlord_filter_country"; const PREF_FILTER_COUNTRY_KEY = "overlord_filter_country";
const PREF_FILTER_GROUP_KEY = "overlord_filter_group"; const PREF_FILTER_GROUP_KEY = "overlord_filter_group";
const PREF_FILTER_WEBCAM_KEY = "overlord_filter_webcam";
const PREF_DISPLAY_FIELDS_KEY = "overlord_display_fields"; const PREF_DISPLAY_FIELDS_KEY = "overlord_display_fields";
const DEFAULT_DISPLAY_FIELDS = { const DEFAULT_DISPLAY_FIELDS = {
user: true, user: true,
@@ -944,6 +946,7 @@ function initializeRenderer() {
requestPreview, requestPreview,
requestThumbnail, requestThumbnail,
pingClient: (id) => sendCommand(id, "ping"), pingClient: (id) => sendCommand(id, "ping"),
onOpenWebcam: (id) => window.open(`/webcam?clientId=${encodeURIComponent(id)}`, "_blank", "noopener"),
onMacPermissionRequest: (id, _card, permissionKey) => requestMacPermissions(id, permissionKey), onMacPermissionRequest: (id, _card, permissionKey) => requestMacPermissions(id, permissionKey),
onMacPermissionRefresh: (id) => requestMacPermissions(id, "", { refreshOnly: true }), onMacPermissionRefresh: (id) => requestMacPermissions(id, "", { refreshOnly: true }),
onMacPermissionApply: (id) => applyMacPermissionChanges(id), onMacPermissionApply: (id) => applyMacPermissionChanges(id),
@@ -1057,6 +1060,14 @@ filterGroupSelect?.addEventListener("change", (e) => {
loadWithOptions({ force: true, reorder: true }); loadWithOptions({ force: true, reorder: true });
}); });
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 });
});
import("./country-picker.js").then(({ initCountryPicker }) => { import("./country-picker.js").then(({ initCountryPicker }) => {
initCountryPicker((code) => { initCountryPicker((code) => {
state.filterCountry = code; state.filterCountry = code;
@@ -1078,7 +1089,7 @@ import("./country-picker.js").then(({ initCountryPicker }) => {
} }
const savedSort = localStorage.getItem(PREF_SORT_KEY); const savedSort = localStorage.getItem(PREF_SORT_KEY);
const validSorts = ["stable", "last_seen_desc", "host_asc", "ping_asc", "ping_desc", "country_asc", "country_desc", "group_asc", "group_desc", "admin_first"]; const validSorts = ["stable", "last_seen_desc", "host_asc", "ping_asc", "ping_desc", "country_asc", "country_desc", "group_asc", "group_desc", "admin_first", "elevated_first"];
if (savedSort && validSorts.includes(savedSort)) { if (savedSort && validSorts.includes(savedSort)) {
state.sort = savedSort; state.sort = savedSort;
if (sortSelect) sortSelect.value = savedSort; if (sortSelect) sortSelect.value = savedSort;
@@ -1100,6 +1111,12 @@ import("./country-picker.js").then(({ initCountryPicker }) => {
state.filterGroup = savedGroup; state.filterGroup = savedGroup;
if (filterGroupSelect) filterGroupSelect.value = savedGroup; if (filterGroupSelect) filterGroupSelect.value = savedGroup;
} }
const savedWebcam = localStorage.getItem(PREF_FILTER_WEBCAM_KEY);
if (savedWebcam && ["all", "available", "none"].includes(savedWebcam)) {
state.filterWebcam = savedWebcam;
if (filterWebcamSelect) filterWebcamSelect.value = savedWebcam;
}
})(); })();
showOfflineToggle?.addEventListener("change", (e) => { showOfflineToggle?.addEventListener("change", (e) => {
+25 -1
View File
@@ -321,6 +321,7 @@ export function createRenderer({
requestPreview, requestPreview,
requestThumbnail, requestThumbnail,
pingClient, pingClient,
onOpenWebcam,
onMacPermissionRequest, onMacPermissionRequest,
onMacPermissionRefresh, onMacPermissionRefresh,
onMacPermissionApply, onMacPermissionApply,
@@ -479,6 +480,12 @@ export function createRenderer({
return; return;
} }
if (e.target.closest(".cv-webcam-btn")) {
e.stopPropagation();
if (onOpenWebcam) onOpenWebcam(clientId);
return;
}
if (e.target.closest(".cv-ping-btn")) { if (e.target.closest(".cv-ping-btn")) {
e.stopPropagation(); e.stopPropagation();
if (pingClient) pingClient(clientId); if (pingClient) pingClient(clientId);
@@ -655,7 +662,7 @@ export function createRenderer({
} }
function cardDigest(c) { function cardDigest(c) {
return `${currentDisplayDigest}|${currentLayout}|${c.id}|${!!c.online}|${c.lastSeen}|${c.pingMs}|${c.host}|${c.user}|${c.os}|${c.arch}|${c.version}|${c.monitors}|${c.country}|${c.nickname}|${c.customTag}|${c.customTagNote}|${!!c.bookmarked}|${!!c.isAdmin}|${c.elevation}|${JSON.stringify(c.permissions || {})}|${c.cpu}|${c.gpu}|${c.ram}|${c.batteryPercent}|${c.batteryCharging}|${c.hwid}|${c.disconnectReason}|${c.disconnectDetail}|${c.groupId}|${c.groupName}|${c.groupColor}|${!!c.notificationsMuted}`; return `${currentDisplayDigest}|${currentLayout}|${c.id}|${!!c.online}|${c.lastSeen}|${c.pingMs}|${c.host}|${c.user}|${c.os}|${c.arch}|${c.version}|${c.monitors}|${c.country}|${c.nickname}|${c.customTag}|${c.customTagNote}|${!!c.bookmarked}|${!!c.isAdmin}|${c.elevation}|${JSON.stringify(c.permissions || {})}|${c.cpu}|${c.gpu}|${c.ram}|${c.batteryPercent}|${c.batteryCharging}|${!!c.webcamAvailable}|${JSON.stringify(c.webcamDevices || [])}|${c.hwid}|${c.disconnectReason}|${c.disconnectDetail}|${c.groupId}|${c.groupName}|${c.groupColor}|${!!c.notificationsMuted}`;
} }
function cardThumbDigest(c) { function cardThumbDigest(c) {
@@ -789,6 +796,7 @@ export function createRenderer({
el.dataset.customTag = String(client.customTag || ""); el.dataset.customTag = String(client.customTag || "");
el.dataset.bookmarked = String(!!client.bookmarked); el.dataset.bookmarked = String(!!client.bookmarked);
el.dataset.notificationsMuted = String(!!client.notificationsMuted); el.dataset.notificationsMuted = String(!!client.notificationsMuted);
el.dataset.hasWebcam = String(!!client.webcamAvailable);
el.dataset.admin = String(!!client.isAdmin); el.dataset.admin = String(!!client.isAdmin);
el.dataset.groupId = String(client.groupId || ""); el.dataset.groupId = String(client.groupId || "");
el.dataset.groupName = String(client.groupName || ""); el.dataset.groupName = String(client.groupName || "");
@@ -833,6 +841,16 @@ export function createRenderer({
return node; return node;
} }
function webcamButtonHtml(client, extraClass = "") {
if (!client.webcamAvailable || isViewer) return "";
return `<button type="button" class="cv-icon-btn cv-webcam-btn ${extraClass}" title="Open webcam" aria-label="Open webcam"><i class="fa-solid fa-video"></i></button>`;
}
function webcamBadgeHtml(client) {
if (!client.webcamAvailable || isViewer) return "";
return `<button type="button" class="cv-mini-pill cv-pill-webcam cv-webcam-btn" title="Open webcam" aria-label="Open webcam"><i class="fa-solid fa-video"></i></button>`;
}
function updateCard(card, client) { function updateCard(card, client) {
const wasChecked = card.querySelector(".client-checkbox")?.checked || false; const wasChecked = card.querySelector(".client-checkbox")?.checked || false;
const wasTagNoteExpanded = card.dataset.tagNoteExpanded === "true"; const wasTagNoteExpanded = card.dataset.tagNoteExpanded === "true";
@@ -914,6 +932,7 @@ export function createRenderer({
${client.isAdmin ? `<span class="cv-mini-pill cv-pill-admin" title="Admin"><i class="fa-solid fa-shield-halved"></i></span>` : ""} ${client.isAdmin ? `<span class="cv-mini-pill cv-pill-admin" title="Admin"><i class="fa-solid fa-shield-halved"></i></span>` : ""}
${client.elevation === "system" ? `<span class="cv-mini-pill cv-pill-system" title="SYSTEM"><i class="fa-solid fa-gear"></i></span>` : ""} ${client.elevation === "system" ? `<span class="cv-mini-pill cv-pill-system" title="SYSTEM"><i class="fa-solid fa-gear"></i></span>` : ""}
${client.elevation === "trustedinstaller" ? `<span class="cv-mini-pill cv-pill-ti" title="TrustedInstaller"><i class="fa-solid fa-lock"></i></span>` : ""} ${client.elevation === "trustedinstaller" ? `<span class="cv-mini-pill cv-pill-ti" title="TrustedInstaller"><i class="fa-solid fa-lock"></i></span>` : ""}
${webcamBadgeHtml(client)}
${macPermissionBadgeHtml(client)} ${macPermissionBadgeHtml(client)}
${client.notificationsMuted ? `<span class="cv-mini-pill cv-pill-muted" title="Notifications muted"><i class="fa-solid fa-bell-slash"></i></span>` : ""} ${client.notificationsMuted ? `<span class="cv-mini-pill cv-pill-muted" title="Notifications muted"><i class="fa-solid fa-bell-slash"></i></span>` : ""}
</div> </div>
@@ -930,6 +949,7 @@ export function createRenderer({
<div class="cv-spacer"></div> <div class="cv-spacer"></div>
<div class="cv-actions"> <div class="cv-actions">
${isViewer ? "" : `<button class="command-btn cv-btn-primary" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-terminal"></i><span>Commands</span></button>`} ${isViewer ? "" : `<button class="command-btn cv-btn-primary" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-terminal"></i><span>Commands</span></button>`}
${webcamButtonHtml(client)}
<button class="cv-icon-btn cv-ping-btn" title="Ping" ${client.online ? "" : "disabled"}><i class="fa-solid fa-satellite-dish"></i></button> <button class="cv-icon-btn cv-ping-btn" title="Ping" ${client.online ? "" : "disabled"}><i class="fa-solid fa-satellite-dish"></i></button>
${isViewer ? "" : `<button class="cv-icon-btn cv-icon-danger ban-btn" title="Ban IP" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-ban"></i></button>`} ${isViewer ? "" : `<button class="cv-icon-btn cv-icon-danger ban-btn" title="Ban IP" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-ban"></i></button>`}
<button class="cv-icon-btn cv-expand-btn" title="More info" aria-expanded="false"><i class="fa-solid fa-chevron-down"></i></button> <button class="cv-icon-btn cv-expand-btn" title="More info" aria-expanded="false"><i class="fa-solid fa-chevron-down"></i></button>
@@ -993,6 +1013,7 @@ export function createRenderer({
${client.isAdmin ? `<span class="cv-mini-pill cv-pill-admin" title="Admin"><i class="fa-solid fa-shield-halved"></i></span>` : ""} ${client.isAdmin ? `<span class="cv-mini-pill cv-pill-admin" title="Admin"><i class="fa-solid fa-shield-halved"></i></span>` : ""}
${client.elevation === "system" ? `<span class="cv-mini-pill cv-pill-system" title="SYSTEM"><i class="fa-solid fa-gear"></i></span>` : ""} ${client.elevation === "system" ? `<span class="cv-mini-pill cv-pill-system" title="SYSTEM"><i class="fa-solid fa-gear"></i></span>` : ""}
${client.elevation === "trustedinstaller" ? `<span class="cv-mini-pill cv-pill-ti" title="TI"><i class="fa-solid fa-lock"></i></span>` : ""} ${client.elevation === "trustedinstaller" ? `<span class="cv-mini-pill cv-pill-ti" title="TI"><i class="fa-solid fa-lock"></i></span>` : ""}
${webcamBadgeHtml(client)}
${macPermissionBadgeHtml(client)} ${macPermissionBadgeHtml(client)}
${client.notificationsMuted ? `<span class="cv-mini-pill cv-pill-muted" title="Notifications muted"><i class="fa-solid fa-bell-slash"></i></span>` : ""} ${client.notificationsMuted ? `<span class="cv-mini-pill cv-pill-muted" title="Notifications muted"><i class="fa-solid fa-bell-slash"></i></span>` : ""}
</span> </span>
@@ -1012,6 +1033,7 @@ export function createRenderer({
<td class="cv-td-actions"> <td class="cv-td-actions">
<div class="cv-actions cv-actions-table"> <div class="cv-actions cv-actions-table">
${isViewer ? "" : `<button class="command-btn cv-btn-primary cv-btn-sm" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-terminal"></i><span>Commands</span></button>`} ${isViewer ? "" : `<button class="command-btn cv-btn-primary cv-btn-sm" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-terminal"></i><span>Commands</span></button>`}
${webcamButtonHtml(client, "cv-icon-sm")}
${isViewer ? "" : `<button class="cv-icon-btn cv-icon-sm kebab-btn" title="More" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-ellipsis-vertical"></i></button>`} ${isViewer ? "" : `<button class="cv-icon-btn cv-icon-sm kebab-btn" title="More" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-ellipsis-vertical"></i></button>`}
</div> </div>
</td> </td>
@@ -1054,6 +1076,7 @@ export function createRenderer({
client.isAdmin ? `<span class="cv-mini-pill cv-pill-admin" title="Admin"><i class="fa-solid fa-shield-halved"></i></span>` : "", client.isAdmin ? `<span class="cv-mini-pill cv-pill-admin" title="Admin"><i class="fa-solid fa-shield-halved"></i></span>` : "",
client.elevation === "system" ? `<span class="cv-mini-pill cv-pill-system" title="SYSTEM"><i class="fa-solid fa-gear"></i></span>` : "", client.elevation === "system" ? `<span class="cv-mini-pill cv-pill-system" title="SYSTEM"><i class="fa-solid fa-gear"></i></span>` : "",
client.elevation === "trustedinstaller" ? `<span class="cv-mini-pill cv-pill-ti" title="TrustedInstaller"><i class="fa-solid fa-lock"></i></span>` : "", client.elevation === "trustedinstaller" ? `<span class="cv-mini-pill cv-pill-ti" title="TrustedInstaller"><i class="fa-solid fa-lock"></i></span>` : "",
webcamBadgeHtml(client),
macPermissionBadgeHtml(client), macPermissionBadgeHtml(client),
client.notificationsMuted ? `<span class="cv-mini-pill cv-pill-muted" title="Notifications muted"><i class="fa-solid fa-bell-slash"></i></span>` : "", client.notificationsMuted ? `<span class="cv-mini-pill cv-pill-muted" title="Notifications muted"><i class="fa-solid fa-bell-slash"></i></span>` : "",
].join(""); ].join("");
@@ -1102,6 +1125,7 @@ export function createRenderer({
${macPermDetail ? `<div class="cv-card-perms">${macPermDetail}</div>` : ""} ${macPermDetail ? `<div class="cv-card-perms">${macPermDetail}</div>` : ""}
<div class="cv-card-actions"> <div class="cv-card-actions">
${isViewer ? "" : `<button class="command-btn cv-btn-primary cv-btn-flex" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-terminal"></i><span>Commands</span></button>`} ${isViewer ? "" : `<button class="command-btn cv-btn-primary cv-btn-flex" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-terminal"></i><span>Commands</span></button>`}
${webcamButtonHtml(client)}
<button class="cv-icon-btn cv-ping-btn" title="Ping" ${client.online ? "" : "disabled"}><i class="fa-solid fa-satellite-dish"></i></button> <button class="cv-icon-btn cv-ping-btn" title="Ping" ${client.online ? "" : "disabled"}><i class="fa-solid fa-satellite-dish"></i></button>
${isViewer ? "" : `<button class="cv-icon-btn cv-icon-danger ban-btn" title="Ban IP" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-ban"></i></button>`} ${isViewer ? "" : `<button class="cv-icon-btn cv-icon-danger ban-btn" title="Ban IP" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-ban"></i></button>`}
</div> </div>
+1
View File
@@ -7,6 +7,7 @@ export const state = {
filterOs: "all", filterOs: "all",
filterCountry: "all", filterCountry: "all",
filterGroup: "all", filterGroup: "all",
filterWebcam: "all",
lastDigest: "", lastDigest: "",
isLoading: false, isLoading: false,
pendingForce: false, pendingForce: false,
+67 -8
View File
@@ -2,6 +2,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
import { checkFeatureAccess } from "./feature-gate.js"; import { checkFeatureAccess } from "./feature-gate.js";
import { WhepClient } from "./whep.js"; import { WhepClient } from "./whep.js";
import { P2PClient } from "./webrtc-p2p.js"; import { P2PClient } from "./webrtc-p2p.js";
import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-settings.js";
(async function () { (async function () {
const clientId = new URLSearchParams(location.search).get("clientId"); const clientId = new URLSearchParams(location.search).get("clientId");
@@ -58,16 +59,53 @@ import { P2PClient } from "./webrtc-p2p.js";
let clientIsAdmin = false; let clientIsAdmin = false;
let firewallWarningAcked = false; let firewallWarningAcked = false;
const codecPrefKey = "webcamCodecPreferH264";
let prefersH264 = typeof VideoDecoder === "function"; let prefersH264 = typeof VideoDecoder === "function";
let savedCameraIndex = null;
const storedCodecPref = localStorage.getItem(codecPrefKey); function setSelectValue(select, value) {
if (storedCodecPref === "0") { if (!select || value === undefined || value === null) return false;
prefersH264 = false; const normalized = String(value);
} else if (storedCodecPref === "1") { const exists = Array.from(select.options || []).some((opt) => opt.value === normalized);
prefersH264 = typeof VideoDecoder === "function"; if (!exists) return false;
select.value = normalized;
return true;
} }
function applySavedCamera() {
if (savedCameraIndex === null || savedCameraIndex === undefined) return false;
if (!setSelectValue(cameraSelect, savedCameraIndex)) return false;
selectedDeviceIndex = Number(cameraSelect.value) || 0;
applyFpsInputLimits();
return true;
}
function applySharedSettings(settings) {
if (!settings || typeof settings !== "object") return;
if (Number.isFinite(Number(settings.camera))) {
savedCameraIndex = Number(settings.camera);
}
if (fpsInput && settings.fps !== undefined) fpsInput.value = String(settings.fps);
if (qualitySlider && settings.quality !== undefined) qualitySlider.value = String(settings.quality);
setSelectValue(webrtcMode, settings.webrtcMode);
if (typeof settings.preferH264 === "boolean") {
prefersH264 = settings.preferH264 && typeof VideoDecoder === "function";
}
applySavedCamera();
}
function readSharedSettings() {
return {
camera: Number(cameraSelect?.value ?? savedCameraIndex ?? 0),
fps: Number(fpsInput?.value || 30),
quality: Number(qualitySlider?.value || 90),
preferH264: !!prefersH264,
webrtcMode: getWebrtcMode(),
};
}
applySharedSettings(await loadSharedUiSettings("webcam"));
const sharedSettingsSaver = createSharedUiSettingsSaver("webcam", readSharedSettings);
if (codecH264) { if (codecH264) {
codecH264.checked = prefersH264; codecH264.checked = prefersH264;
codecH264.disabled = typeof VideoDecoder !== "function"; codecH264.disabled = typeof VideoDecoder !== "function";
@@ -176,7 +214,9 @@ import { P2PClient } from "./webrtc-p2p.js";
: (dev.name || `Camera ${idx + 1}`); : (dev.name || `Camera ${idx + 1}`);
cameraSelect.appendChild(opt); cameraSelect.appendChild(opt);
} }
selectedDeviceIndex = Number(selected) || 0; selectedDeviceIndex = savedCameraIndex !== null && savedCameraIndex !== undefined
? Number(savedCameraIndex) || 0
: Number(selected) || 0;
const selectedOpt = Array.from(cameraSelect.options).find((o) => Number(o.value) === selectedDeviceIndex); const selectedOpt = Array.from(cameraSelect.options).find((o) => Number(o.value) === selectedDeviceIndex);
if (selectedOpt) { if (selectedOpt) {
cameraSelect.value = selectedOpt.value; cameraSelect.value = selectedOpt.value;
@@ -185,6 +225,9 @@ import { P2PClient } from "./webrtc-p2p.js";
} }
selectedDeviceIndex = Number(cameraSelect.value) || 0; selectedDeviceIndex = Number(cameraSelect.value) || 0;
applyFpsInputLimits(); applyFpsInputLimits();
if (selectedDeviceIndex !== Number(selected || 0) && ws && ws.readyState === WebSocket.OPEN) {
send("webcam_select", { index: selectedDeviceIndex });
}
} }
function isH264KeyFrame(data) { function isH264KeyFrame(data) {
@@ -466,6 +509,10 @@ import { P2PClient } from "./webrtc-p2p.js";
ws.onopen = () => { ws.onopen = () => {
requestCameraList(); requestCameraList();
if (savedCameraIndex !== null && savedCameraIndex !== undefined) {
selectedDeviceIndex = Number(savedCameraIndex) || 0;
send("webcam_select", { index: selectedDeviceIndex });
}
applyFpsSettings(); applyFpsSettings();
pushQuality(qualitySlider ? qualitySlider.value : 90); pushQuality(qualitySlider ? qualitySlider.value : 90);
if (desiredStreaming) { if (desiredStreaming) {
@@ -543,6 +590,7 @@ import { P2PClient } from "./webrtc-p2p.js";
}); });
window.addEventListener("beforeunload", () => { window.addEventListener("beforeunload", () => {
sharedSettingsSaver.saveNow();
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
send("webcam_stop"); send("webcam_stop");
} }
@@ -555,9 +603,11 @@ import { P2PClient } from "./webrtc-p2p.js";
cameraSelect.addEventListener("change", () => { cameraSelect.addEventListener("change", () => {
const index = Number(cameraSelect.value) || 0; const index = Number(cameraSelect.value) || 0;
savedCameraIndex = index;
selectedDeviceIndex = index; selectedDeviceIndex = index;
applyFpsInputLimits(); applyFpsInputLimits();
send("webcam_select", { index }); send("webcam_select", { index });
sharedSettingsSaver.scheduleSave();
}); });
fpsInput.addEventListener("input", () => { fpsInput.addEventListener("input", () => {
@@ -566,18 +616,19 @@ import { P2PClient } from "./webrtc-p2p.js";
if (Number.isFinite(val) && val > maxFps) { if (Number.isFinite(val) && val > maxFps) {
fpsInput.value = String(maxFps); fpsInput.value = String(maxFps);
} }
sharedSettingsSaver.scheduleSave();
}); });
if (codecH264) { if (codecH264) {
codecH264.addEventListener("change", function () { codecH264.addEventListener("change", function () {
prefersH264 = !!codecH264.checked && typeof VideoDecoder === "function"; prefersH264 = !!codecH264.checked && typeof VideoDecoder === "function";
localStorage.setItem(codecPrefKey, prefersH264 ? "1" : "0");
if (!prefersH264) { if (!prefersH264) {
destroyVideoDecoder(); destroyVideoDecoder();
} }
if (qualitySlider) { if (qualitySlider) {
pushQuality(qualitySlider.value); pushQuality(qualitySlider.value);
} }
sharedSettingsSaver.scheduleSave();
}); });
} }
@@ -586,13 +637,21 @@ import { P2PClient } from "./webrtc-p2p.js";
qualitySlider.addEventListener("input", function () { qualitySlider.addEventListener("input", function () {
updateQualityLabel(qualitySlider.value); updateQualityLabel(qualitySlider.value);
pushQuality(qualitySlider.value); pushQuality(qualitySlider.value);
sharedSettingsSaver.scheduleSave();
}); });
} }
applyFps.addEventListener("click", () => { applyFps.addEventListener("click", () => {
applyFpsSettings(); applyFpsSettings();
sharedSettingsSaver.scheduleSave();
}); });
if (webrtcMode) {
webrtcMode.addEventListener("change", () => {
sharedSettingsSaver.scheduleSave();
});
}
screenshotBtn.addEventListener("click", () => { screenshotBtn.addEventListener("click", () => {
downloadScreenshot(); downloadScreenshot();
}); });
+6 -1
View File
@@ -19,7 +19,7 @@
<script defer src="/vendor/animejs/anime.min.js"></script> <script defer src="/vendor/animejs/anime.min.js"></script>
<script defer src="/vendor/chart.js/chart.umd.js"></script> <script defer src="/vendor/chart.js/chart.umd.js"></script>
<script defer src="/vendor/gridstack/gridstack-all.js"></script> <script defer src="/vendor/gridstack/gridstack-all.js"></script>
<link rel="preload" href="/api/clients?page=1&amp;pageSize=12&amp;q=&amp;sort=stable&amp;status=all&amp;os=all&amp;country=all&amp;group=all" as="fetch" crossorigin="use-credentials" /> <link rel="preload" href="/api/clients?page=1&amp;pageSize=12&amp;q=&amp;sort=stable&amp;status=all&amp;os=all&amp;country=all&amp;group=all&amp;webcam=all" as="fetch" crossorigin="use-credentials" />
<link rel="preload" href="/api/auth/me" as="fetch" crossorigin="use-credentials" /> <link rel="preload" href="/api/auth/me" as="fetch" crossorigin="use-credentials" />
<link rel="preload" href="/api/groups" as="fetch" crossorigin="use-credentials" /> <link rel="preload" href="/api/groups" as="fetch" crossorigin="use-credentials" />
</head> </head>
@@ -184,6 +184,10 @@
<option value="all">All Groups</option> <option value="all">All Groups</option>
<option value="none">No Group</option> <option value="none">No Group</option>
</select> </select>
<select id="filter-webcam">
<option value="all">All Webcams</option>
<option value="available">Has Webcam</option>
</select>
<div id="country-picker-anchor" class="relative"></div> <div id="country-picker-anchor" class="relative"></div>
</div> </div>
</details> </details>
@@ -201,6 +205,7 @@
<option value="group_asc">Group A-Z</option> <option value="group_asc">Group A-Z</option>
<option value="group_desc">Group Z-A</option> <option value="group_desc">Group Z-A</option>
<option value="admin_first">Admin first</option> <option value="admin_first">Admin first</option>
<option value="elevated_first">Elevated first</option>
</select> </select>
</div> </div>
</details> </details>
+155 -3
View File
@@ -1,16 +1,28 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { db } from "./db/connection"; import { db } from "./db/connection";
import { deleteClientRow, getClientMetricsSummary, listClients, setClientBookmark, setClientTag, upsertClientRow } from "./db"; import { deleteClientRow, getClientMetricsSummary, listClients, setClientBookmark, setClientTag, setClientWebcamInfo, upsertClientRow } from "./db";
const createdClientIds: string[] = []; const createdClientIds: string[] = [];
function createTempClient(id: string, options: { online: boolean; lastSeen: number; host: string; pingMs?: number; bookmarked?: boolean }) { function createTempClient(
id: string,
options: {
online: boolean;
lastSeen: number;
host: string;
pingMs?: number;
bookmarked?: boolean;
os?: string;
isAdmin?: boolean;
elevation?: string;
},
) {
upsertClientRow({ upsertClientRow({
id, id,
hwid: id, hwid: id,
role: "client", role: "client",
host: options.host, host: options.host,
os: "windows", os: options.os || "windows",
arch: "amd64", arch: "amd64",
version: "1.0.0", version: "1.0.0",
user: "tester", user: "tester",
@@ -18,6 +30,8 @@ function createTempClient(id: string, options: { online: boolean; lastSeen: numb
lastSeen: options.lastSeen, lastSeen: options.lastSeen,
online: options.online ? 1 : 0, online: options.online ? 1 : 0,
pingMs: options.pingMs, pingMs: options.pingMs,
isAdmin: options.isAdmin,
elevation: options.elevation,
}); });
if (options.bookmarked) { if (options.bookmarked) {
setClientBookmark(id, true); setClientBookmark(id, true);
@@ -190,6 +204,144 @@ describe("client metrics summary", () => {
cleanupCreatedClients(); cleanupCreatedClients();
} }
}); });
test("webcam filter returns only clients with available webcam devices", () => {
try {
const prefix = `webcam-filter-${Date.now().toString(36)}`;
const now = Date.now();
const withWebcam = `${prefix}-with`;
const withoutWebcam = `${prefix}-without`;
const legacyWebcam = `${prefix}-legacy`;
createTempClient(withWebcam, {
online: true,
lastSeen: now,
host: "webcam-host",
});
createTempClient(withoutWebcam, {
online: true,
lastSeen: now - 1,
host: "plain-host",
});
createTempClient(legacyWebcam, {
online: true,
lastSeen: now - 2,
host: "legacy-webcam-host",
});
setClientWebcamInfo(withWebcam, true, [{ index: 0, name: "Integrated Camera" }]);
db.run(
`UPDATE clients SET webcam_available=0, webcam_devices=? WHERE id=?`,
JSON.stringify([{ index: 1, name: "USB Camera" }]),
legacyWebcam,
);
const result = listClients({
page: 1,
pageSize: 12,
search: prefix,
sort: "last_seen_desc",
statusFilter: "all",
osFilter: "all",
countryFilter: "all",
enrollmentFilter: "all",
webcamFilter: "available",
});
expect(result.items.map((item) => item.id).sort()).toEqual([legacyWebcam, withWebcam].sort());
expect(result.items.every((item) => item.webcamAvailable)).toBe(true);
} finally {
cleanupCreatedClients();
}
});
test("general Windows OS filter includes Windows version variants", () => {
try {
const prefix = `windows-filter-${Date.now().toString(36)}`;
const now = Date.now();
const win24h2 = `${prefix}-24h2`;
const win25h2 = `${prefix}-25h2`;
const mac = `${prefix}-mac`;
createTempClient(win24h2, {
online: true,
lastSeen: now,
host: "win-24h2",
os: "Windows 11 Pro 24H2",
});
createTempClient(win25h2, {
online: true,
lastSeen: now - 1,
host: "win-25h2",
os: "Windows 11 Pro 25H2",
});
createTempClient(mac, {
online: true,
lastSeen: now - 2,
host: "mac-host",
os: "macOS 15.5",
});
const result = listClients({
page: 1,
pageSize: 12,
search: prefix,
sort: "last_seen_desc",
statusFilter: "all",
osFilter: "windows",
countryFilter: "all",
enrollmentFilter: "all",
});
expect(result.items.map((item) => item.id).sort()).toEqual([win24h2, win25h2].sort());
} finally {
cleanupCreatedClients();
}
});
test("elevated sort puts elevated clients before regular clients", () => {
try {
const prefix = `elevated-sort-${Date.now().toString(36)}`;
const now = Date.now();
const regular = `${prefix}-regular`;
const system = `${prefix}-system`;
const trustedInstaller = `${prefix}-ti`;
createTempClient(regular, {
online: true,
lastSeen: now,
host: "regular-host",
});
createTempClient(system, {
online: true,
lastSeen: now - 1,
host: "system-host",
isAdmin: true,
elevation: "system",
});
createTempClient(trustedInstaller, {
online: true,
lastSeen: now - 2,
host: "ti-host",
isAdmin: true,
elevation: "trustedinstaller",
});
const result = listClients({
page: 1,
pageSize: 12,
search: prefix,
sort: "elevated_first",
statusFilter: "all",
osFilter: "all",
countryFilter: "all",
enrollmentFilter: "all",
});
expect(result.items.map((item) => item.id)).toEqual([trustedInstaller, system, regular]);
} finally {
cleanupCreatedClients();
}
});
}); });
describe("client search index", () => { describe("client search index", () => {
+44 -5
View File
@@ -221,8 +221,8 @@ export type OfflineStateUpdate = {
disconnectDetail?: string; disconnectDetail?: string;
}; };
const UPSERT_CLIENT_ROW_SQL = `INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag, custom_tag_note, monitors, country, last_seen, online, ping_ms, build_tag, built_by_user_id, enrollment_status, public_key, key_fingerprint, cpu, gpu, ram, battery_percent, battery_charging, is_admin, elevation, permissions) const UPSERT_CLIENT_ROW_SQL = `INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag, custom_tag_note, monitors, country, last_seen, online, ping_ms, build_tag, built_by_user_id, enrollment_status, public_key, key_fingerprint, cpu, gpu, ram, battery_percent, battery_charging, webcam_available, webcam_devices, is_admin, elevation, permissions)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, 0), ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, 0), ?, COALESCE(?, 0), ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
hwid=COALESCE(excluded.hwid, clients.hwid), hwid=COALESCE(excluded.hwid, clients.hwid),
role=COALESCE(excluded.role, clients.role), role=COALESCE(excluded.role, clients.role),
@@ -250,6 +250,8 @@ const UPSERT_CLIENT_ROW_SQL = `INSERT INTO clients (id, hwid, role, ip, host, os
ram=COALESCE(excluded.ram, clients.ram), ram=COALESCE(excluded.ram, clients.ram),
battery_percent=COALESCE(excluded.battery_percent, clients.battery_percent), battery_percent=COALESCE(excluded.battery_percent, clients.battery_percent),
battery_charging=COALESCE(excluded.battery_charging, clients.battery_charging), battery_charging=COALESCE(excluded.battery_charging, clients.battery_charging),
webcam_available=COALESCE(excluded.webcam_available, clients.webcam_available),
webcam_devices=COALESCE(excluded.webcam_devices, clients.webcam_devices),
is_admin=COALESCE(excluded.is_admin, clients.is_admin), is_admin=COALESCE(excluded.is_admin, clients.is_admin),
elevation=COALESCE(excluded.elevation, clients.elevation), elevation=COALESCE(excluded.elevation, clients.elevation),
permissions=COALESCE(excluded.permissions, clients.permissions) permissions=COALESCE(excluded.permissions, clients.permissions)
@@ -287,6 +289,8 @@ function upsertClientRowInternal(partial: ClientDbRow): void {
partial.ram ?? null, partial.ram ?? null,
partial.batteryPercent ?? null, partial.batteryPercent ?? null,
partial.batteryCharging !== undefined && partial.batteryCharging !== null ? (partial.batteryCharging ? 1 : 0) : null, partial.batteryCharging !== undefined && partial.batteryCharging !== null ? (partial.batteryCharging ? 1 : 0) : null,
partial.webcamAvailable !== undefined ? (partial.webcamAvailable ? 1 : 0) : null,
partial.webcamDevices ? JSON.stringify(partial.webcamDevices) : null,
partial.isAdmin !== undefined ? (partial.isAdmin ? 1 : 0) : null, partial.isAdmin !== undefined ? (partial.isAdmin ? 1 : 0) : null,
partial.elevation ?? null, partial.elevation ?? null,
partial.permissions ? JSON.stringify(partial.permissions) : null, partial.permissions ? JSON.stringify(partial.permissions) : null,
@@ -951,6 +955,7 @@ export function listClients(filters: ListFilters): ListResult {
allowedClientIds, allowedClientIds,
deniedClientIds, deniedClientIds,
groupFilter, groupFilter,
webcamFilter,
} = filters; } = filters;
const where: string[] = []; const where: string[] = [];
const params: any[] = []; const params: any[] = [];
@@ -971,8 +976,12 @@ export function listClients(filters: ListFilters): ListResult {
} }
if (osFilter && osFilter !== "all") { if (osFilter && osFilter !== "all") {
where.push("c.os=?"); if (osFilter === "windows") {
params.push(osFilter); where.push("LOWER(COALESCE(c.os, '')) LIKE 'windows%'");
} else {
where.push("c.os=?");
params.push(osFilter);
}
} }
if (countryFilter && countryFilter !== "all") { if (countryFilter && countryFilter !== "all") {
@@ -1015,6 +1024,17 @@ export function listClients(filters: ListFilters): ListResult {
} }
} }
const webcamAvailableSql =
"(COALESCE(c.webcam_available, 0)=1 OR (c.webcam_devices IS NOT NULL AND c.webcam_devices NOT IN ('', '[]', 'null')))";
if (webcamFilter && webcamFilter !== "all") {
if (webcamFilter === "available") {
where.push(webcamAvailableSql);
} else if (webcamFilter === "none") {
where.push(`NOT ${webcamAvailableSql}`);
}
}
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const needsGroupJoinForFilter = const needsGroupJoinForFilter =
sort === "group_asc" || sort === "group_asc" ||
@@ -1041,6 +1061,8 @@ export function listClients(filters: ListFilters): ListResult {
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(c.country, 'zz')) DESC, c.id ASC`; return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(c.country, 'zz')) DESC, c.id ASC`;
case "admin_first": case "admin_first":
return `ORDER BY ${online}, ${bookmark}, c.is_admin DESC, c.id ASC`; return `ORDER BY ${online}, ${bookmark}, c.is_admin DESC, c.id ASC`;
case "elevated_first":
return `ORDER BY ${online}, ${bookmark}, CASE WHEN c.elevation IN ('trustedinstaller', 'system', 'admin', 'elevated') OR c.is_admin=1 THEN 1 ELSE 0 END DESC, CASE c.elevation WHEN 'trustedinstaller' THEN 4 WHEN 'system' THEN 3 WHEN 'admin' THEN 2 WHEN 'elevated' THEN 1 ELSE 0 END DESC, c.id ASC`;
case "group_asc": case "group_asc":
return `ORDER BY ${online}, ${bookmark}, g.name IS NULL, LOWER(g.name) ASC, c.id ASC`; return `ORDER BY ${online}, ${bookmark}, g.name IS NULL, LOWER(g.name) ASC, c.id ASC`;
case "group_desc": case "group_desc":
@@ -1053,7 +1075,7 @@ export function listClients(filters: ListFilters): ListResult {
const offset = (page - 1) * pageSize; const offset = (page - 1) * pageSize;
const clientFields = const clientFields =
"c.id, c.hwid, c.role, c.ip, c.host, c.os, c.arch, c.version, c.user, c.nickname, c.custom_tag as customTag, c.custom_tag_note as customTagNote, c.monitors, c.country, c.last_seen as lastSeen, c.online, c.ping_ms as pingMs, c.bookmarked, c.build_tag as buildTag, c.built_by_user_id as builtByUserId, c.enrollment_status as enrollmentStatus, c.public_key as publicKey, c.key_fingerprint as keyFingerprint, c.cpu, c.gpu, c.ram, c.battery_percent as batteryPercent, c.battery_charging as batteryCharging, c.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, c.notifications_muted as notificationsMuted, c.deny_reason as denyReason"; `c.id, c.hwid, c.role, c.ip, c.host, c.os, c.arch, c.version, c.user, c.nickname, c.custom_tag as customTag, c.custom_tag_note as customTagNote, c.monitors, c.country, c.last_seen as lastSeen, c.online, c.ping_ms as pingMs, c.bookmarked, c.build_tag as buildTag, c.built_by_user_id as builtByUserId, c.enrollment_status as enrollmentStatus, c.public_key as publicKey, c.key_fingerprint as keyFingerprint, c.cpu, c.gpu, c.ram, c.battery_percent as batteryPercent, c.battery_charging as batteryCharging, CASE WHEN ${webcamAvailableSql} THEN 1 ELSE 0 END as webcamAvailable, c.webcam_devices as webcamDevices, c.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, c.notifications_muted as notificationsMuted, c.deny_reason as denyReason`;
let rows: any[] | undefined; let rows: any[] | undefined;
let totalCount = 0; let totalCount = 0;
@@ -1176,6 +1198,8 @@ export function listClients(filters: ListFilters): ListResult {
ram: c.ram || null, ram: c.ram || null,
batteryPercent: typeof c.batteryPercent === "number" ? c.batteryPercent : null, batteryPercent: typeof c.batteryPercent === "number" ? c.batteryPercent : null,
batteryCharging: c.batteryCharging === null || c.batteryCharging === undefined ? null : c.batteryCharging === 1, batteryCharging: c.batteryCharging === null || c.batteryCharging === undefined ? null : c.batteryCharging === 1,
webcamAvailable: c.webcamAvailable === 1,
webcamDevices: c.webcamDevices ? (() => { try { return JSON.parse(c.webcamDevices); } catch { return []; } })() : [],
isAdmin: c.isAdmin === 1, isAdmin: c.isAdmin === 1,
elevation: c.elevation || null, elevation: c.elevation || null,
permissions: c.permissions ? (() => { try { return JSON.parse(c.permissions); } catch { return null; } })() : null, permissions: c.permissions ? (() => { try { return JSON.parse(c.permissions); } catch { return null; } })() : null,
@@ -1591,6 +1615,21 @@ export function setClientNotificationsMuted(id: string, muted: boolean): boolean
return ((result as any)?.changes || 0) > 0; return ((result as any)?.changes || 0) > 0;
} }
export function setClientWebcamInfo(
id: string,
webcamAvailable: boolean,
webcamDevices: { index: number; name: string; maxFps?: number }[],
): boolean {
const result = db.run(
`UPDATE clients SET webcam_available=?, webcam_devices=? WHERE id=?`,
webcamAvailable ? 1 : 0,
JSON.stringify(webcamDevices),
id,
);
if (((result as any)?.changes || 0) > 0) invalidateClientMetricsSummaryCache();
return ((result as any)?.changes || 0) > 0;
}
export function isClientNotificationsMuted(id: string): boolean { export function isClientNotificationsMuted(id: string): boolean {
const row = db.query<{ notifications_muted: number }>( const row = db.query<{ notifications_muted: number }>(
`SELECT notifications_muted FROM clients WHERE id=?`, `SELECT notifications_muted FROM clients WHERE id=?`,
+9
View File
@@ -85,9 +85,18 @@ try {
try { try {
db.run(`ALTER TABLE clients ADD COLUMN battery_charging INTEGER`); db.run(`ALTER TABLE clients ADD COLUMN battery_charging INTEGER`);
} catch {} } catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN webcam_available INTEGER NOT NULL DEFAULT 0`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN webcam_devices TEXT`);
} catch {}
try { try {
db.run(`ALTER TABLE clients ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0`); db.run(`ALTER TABLE clients ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0`);
} catch {} } catch {}
db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_webcam_available ON clients(webcam_available);`,
);
db.run( db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_public_key ON clients(public_key);`, `CREATE INDEX IF NOT EXISTS idx_clients_public_key ON clients(public_key);`,
); );
@@ -84,15 +84,16 @@ export async function handleClientRoutes(
const countryFilter = url.searchParams.get("country") || "all"; const countryFilter = url.searchParams.get("country") || "all";
const enrollmentFilter = url.searchParams.get("enrollmentFilter") || "approved"; const enrollmentFilter = url.searchParams.get("enrollmentFilter") || "approved";
const groupFilter = url.searchParams.get("group") || "all"; const groupFilter = url.searchParams.get("group") || "all";
const webcamFilter = url.searchParams.get("webcam") || "all";
const isEnrollmentRequest = url.searchParams.has("enrollmentFilter"); const isEnrollmentRequest = url.searchParams.has("enrollmentFilter");
if (user.role === "admin") { if (user.role === "admin") {
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter }); const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter, webcamFilter });
return Response.json(result, { headers: deps.CORS_HEADERS }); return Response.json(result, { headers: deps.CORS_HEADERS });
} }
if (user.role === "operator" && isEnrollmentRequest) { if (user.role === "operator" && isEnrollmentRequest) {
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter, builtByUserId: user.userId, requireBuildOwner: true }); const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter, webcamFilter, builtByUserId: user.userId, requireBuildOwner: true });
return Response.json(result, { headers: deps.CORS_HEADERS }); return Response.json(result, { headers: deps.CORS_HEADERS });
} }
@@ -104,7 +105,7 @@ export async function handleClientRoutes(
const allowedClientIds = scope === "allowlist" ? listUserClientRuleIdsByAccess(user.userId, "allow") : undefined; const allowedClientIds = scope === "allowlist" ? listUserClientRuleIdsByAccess(user.userId, "allow") : undefined;
const deniedClientIds = scope === "denylist" ? listUserClientRuleIdsByAccess(user.userId, "deny") : undefined; const deniedClientIds = scope === "denylist" ? listUserClientRuleIdsByAccess(user.userId, "deny") : undefined;
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter, allowedClientIds, deniedClientIds }); const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter, webcamFilter, allowedClientIds, deniedClientIds });
return Response.json(result, { headers: deps.CORS_HEADERS }); return Response.json(result, { headers: deps.CORS_HEADERS });
} }
@@ -237,6 +237,7 @@ function summarizeCpuProfile(profile: any, durationMs: number) {
const SHARED_UI_SETTING_FEATURES: Record<string, FeatureName> = { const SHARED_UI_SETTING_FEATURES: Record<string, FeatureName> = {
remote_desktop: "remote_desktop", remote_desktop: "remote_desktop",
hvnc: "hvnc", hvnc: "hvnc",
webcam: "webcam",
}; };
function pickString(value: unknown, allowed: readonly string[]): string | undefined { function pickString(value: unknown, allowed: readonly string[]): string | undefined {
@@ -299,6 +300,9 @@ function sanitizeSharedUiSettings(scope: string, raw: unknown): Record<string, u
assignIfDefined(out, "cloneProfile", pickBoolean(input.cloneProfile)); assignIfDefined(out, "cloneProfile", pickBoolean(input.cloneProfile));
assignIfDefined(out, "cloneLite", pickBoolean(input.cloneLite)); assignIfDefined(out, "cloneLite", pickBoolean(input.cloneLite));
assignIfDefined(out, "killIfRunning", pickBoolean(input.killIfRunning)); assignIfDefined(out, "killIfRunning", pickBoolean(input.killIfRunning));
} else if (scope === "webcam") {
assignIfDefined(out, "camera", pickSteppedNumber(input.camera, 0, 63));
assignIfDefined(out, "fps", pickSteppedNumber(input.fps, 1, 120));
} }
return out; return out;
@@ -18,7 +18,7 @@ import * as sessionManager from "../../sessions/sessionManager";
import type { SocketData } from "../../sessions/types"; import type { SocketData } from "../../sessions/types";
import type { ClientInfo } from "../../types"; import type { ClientInfo } from "../../types";
import { clearClientSyncState, handleFrame, handleHello, handlePing, handlePong } from "../../wsHandlers"; import { clearClientSyncState, handleFrame, handleHello, handlePing, handlePong } from "../../wsHandlers";
import { queueClientDbUpdate } from "../../client-db-sync"; import { flushQueuedClientDbUpdates, queueClientDbUpdate } from "../../client-db-sync";
import { getMaxPayloadLimit, getMessageByteLength, isAllowedClientMessageType } from "../../wsValidation"; import { getMaxPayloadLimit, getMessageByteLength, isAllowedClientMessageType } from "../../wsValidation";
import { stopAllProxiesForClient } from "../socks5-proxy-manager"; import { stopAllProxiesForClient } from "../socks5-proxy-manager";
import { verifyBuildToken, isBuildBanned } from "../build-signing"; import { verifyBuildToken, isBuildBanned } from "../build-signing";
@@ -668,6 +668,7 @@ export async function handleWebSocketMessage(
} }
await handleHello(infoObj, payload as Hello, ws, ip); await handleHello(infoObj, payload as Hello, ws, ip);
flushQueuedClientDbUpdates();
clientManager.addClient(infoObj.id, infoObj); clientManager.addClient(infoObj.id, infoObj);
clearThumbnail(resolvedId); clearThumbnail(resolvedId);
@@ -678,6 +679,7 @@ export async function handleWebSocketMessage(
deps.dispatchAutoDeploysForConnection(infoObj, ws); deps.dispatchAutoDeploysForConnection(infoObj, ws);
deps.dispatchAutoLoadPlugins(infoObj); deps.dispatchAutoLoadPlugins(infoObj);
deps.dispatchKeylogArchiveSync?.(infoObj.id, ws); deps.dispatchKeylogArchiveSync?.(infoObj.id, ws);
deps.sendDesktopCommand(infoObj, "webcam_list", {});
deps.notifyDashboard(); deps.notifyDashboard();
if (!reconnectedWithinGrace) { if (!reconnectedWithinGrace) {
deps.notifyDashboardClientEvent("client_online", { deps.notifyDashboardClientEvent("client_online", {
@@ -840,6 +842,7 @@ export async function handleWebSocketMessage(
break; break;
case "webcam_devices": case "webcam_devices":
deps.handleWebcamDevices(client.id, payload); deps.handleWebcamDevices(client.id, payload);
deps.notifyDashboard();
break; break;
case "hvnc_clone_progress": case "hvnc_clone_progress":
deps.handleHVNCCloneProgress(client.id, payload); deps.handleHVNCCloneProgress(client.id, payload);
@@ -11,6 +11,7 @@ import * as sessionManager from "../sessions/sessionManager";
import type { ConsoleSession, RemoteDesktopViewer, SocketData } from "../sessions/types"; import type { ConsoleSession, RemoteDesktopViewer, SocketData } from "../sessions/types";
import type { ClientInfo } from "../types"; import type { ClientInfo } from "../types";
import { canUserAccessClient } from "../users"; import { canUserAccessClient } from "../users";
import { setClientWebcamInfo } from "../db";
import { issueWebrtcPublishToken, webrtcStreamPathFor } from "./routes/webrtc-routes"; import { issueWebrtcPublishToken, webrtcStreamPathFor } from "./routes/webrtc-routes";
import { import {
buildViewerFrameBuffer, buildViewerFrameBuffer,
@@ -775,8 +776,23 @@ export function handleWebcamViewerOpen(ws: ServerWebSocket<SocketData>) {
} }
export function handleWebcamDevices(clientId: string, payload: any) { export function handleWebcamDevices(clientId: string, payload: any) {
const devices = Array.isArray(payload?.devices)
? payload.devices.slice(0, 32).map((device: any, index: number) => ({
index: Number.isFinite(Number(device?.index)) ? Number(device.index) : index,
name: String(device?.name || `Camera ${index + 1}`).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").slice(0, 160),
...(Number.isFinite(Number(device?.maxFps)) && Number(device.maxFps) > 0
? { maxFps: Math.min(240, Math.round(Number(device.maxFps))) }
: {}),
}))
: [];
const target = clientManager.getClient(clientId);
if (target) {
target.webcamAvailable = devices.length > 0;
target.webcamDevices = devices;
}
setClientWebcamInfo(clientId, devices.length > 0, devices);
for (const session of sessionManager.getWebcamSessionsForClient(clientId)) { for (const session of sessionManager.getWebcamSessionsForClient(clientId)) {
safeSendViewer(session.viewer, payload); safeSendViewer(session.viewer, { ...payload, devices });
} }
} }
+3
View File
@@ -39,6 +39,8 @@ export type ClientInfo = {
ram?: string; ram?: string;
batteryPercent?: number | null; batteryPercent?: number | null;
batteryCharging?: boolean | null; batteryCharging?: boolean | null;
webcamAvailable?: boolean;
webcamDevices?: { index: number; name: string; maxFps?: number }[];
isAdmin?: boolean; isAdmin?: boolean;
elevation?: string; elevation?: string;
permissions?: Record<string, boolean>; permissions?: Record<string, boolean>;
@@ -69,6 +71,7 @@ export type ListFilters = {
allowedClientIds?: string[]; allowedClientIds?: string[];
deniedClientIds?: string[]; deniedClientIds?: string[];
groupFilter?: string; groupFilter?: string;
webcamFilter?: string;
}; };
export type ListItem = Omit<ClientInfo, 'ws'> & { export type ListItem = Omit<ClientInfo, 'ws'> & {