Add screenshot page, add polling screenshots, better screenshot memory cleanup, better visualization/no jitter when screenshots are shown now.
This commit is contained in:
@@ -81,6 +81,10 @@ export interface Config {
|
||||
buildSigning: {
|
||||
banlist: string[];
|
||||
};
|
||||
thumbnails: {
|
||||
dashboardEnabled: boolean;
|
||||
wallEnabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: Config = {
|
||||
@@ -160,6 +164,10 @@ const DEFAULT_CONFIG: Config = {
|
||||
buildSigning: {
|
||||
banlist: [],
|
||||
},
|
||||
thumbnails: {
|
||||
dashboardEnabled: true,
|
||||
wallEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
type SaveSecrets = {
|
||||
@@ -558,6 +566,16 @@ export function loadConfig(): Config {
|
||||
? fileConfig.buildSigning.banlist.filter((v: unknown): v is string => typeof v === "string" && v.length > 0)
|
||||
: [...DEFAULT_CONFIG.buildSigning.banlist],
|
||||
},
|
||||
thumbnails: {
|
||||
dashboardEnabled:
|
||||
fileConfig.thumbnails?.dashboardEnabled !== undefined
|
||||
? Boolean(fileConfig.thumbnails.dashboardEnabled)
|
||||
: DEFAULT_CONFIG.thumbnails.dashboardEnabled,
|
||||
wallEnabled:
|
||||
fileConfig.thumbnails?.wallEnabled !== undefined
|
||||
? Boolean(fileConfig.thumbnails.wallEnabled)
|
||||
: DEFAULT_CONFIG.thumbnails.wallEnabled,
|
||||
},
|
||||
};
|
||||
|
||||
if (saveChanged) {
|
||||
@@ -858,6 +876,33 @@ export async function updateBuildRateLimitConfig(
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function updateThumbnailsConfig(
|
||||
updates: Partial<Config["thumbnails"]>,
|
||||
): Promise<Config["thumbnails"]> {
|
||||
const current = getConfig();
|
||||
|
||||
const next: Config["thumbnails"] = {
|
||||
dashboardEnabled:
|
||||
updates.dashboardEnabled !== undefined
|
||||
? Boolean(updates.dashboardEnabled)
|
||||
: current.thumbnails.dashboardEnabled,
|
||||
wallEnabled:
|
||||
updates.wallEnabled !== undefined
|
||||
? Boolean(updates.wallEnabled)
|
||||
: current.thumbnails.wallEnabled,
|
||||
};
|
||||
|
||||
configCache = {
|
||||
...current,
|
||||
thumbnails: next,
|
||||
};
|
||||
|
||||
const fileConfig = readFileConfigForUpdate();
|
||||
fileConfig.thumbnails = next;
|
||||
await writePersistentFileConfig(fileConfig);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getBuildSigningSecrets(): BuildSigningSecrets | null {
|
||||
const dataDir = ensureDataDir();
|
||||
const savePath = resolve(dataDir, "save.json");
|
||||
@@ -926,6 +971,7 @@ export function getExportableConfig(serverVersion: string): Record<string, unkno
|
||||
publicKey: buildSigningSecrets?.publicKey || "",
|
||||
banlist: config.buildSigning.banlist,
|
||||
},
|
||||
thumbnails: config.thumbnails,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -999,6 +1045,11 @@ export async function importFullConfig(data: Record<string, any>): Promise<{ app
|
||||
applied.push("buildRateLimit");
|
||||
}
|
||||
|
||||
if (data.thumbnails && typeof data.thumbnails === "object") {
|
||||
await updateThumbnailsConfig(data.thumbnails);
|
||||
applied.push("thumbnails");
|
||||
}
|
||||
|
||||
if (data.auth && typeof data.auth === "object") {
|
||||
const dataDir = ensureDataDir();
|
||||
const savePath = resolve(dataDir, "save.json");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Database from "bun:sqlite";
|
||||
import type { Statement } from "bun:sqlite";
|
||||
import { ClientInfo, ListFilters, ListResult, ClientRole } from "./types";
|
||||
import { getThumbnail } from "./thumbnails";
|
||||
import { hasThumbnail, getThumbnailVersion } from "./thumbnails";
|
||||
import { resolve } from "path";
|
||||
import { ensureDataDir } from "./paths";
|
||||
|
||||
@@ -1186,7 +1186,8 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
groupName: c.groupName || null,
|
||||
groupColor: c.groupColor || null,
|
||||
notificationsMuted: c.notificationsMuted === 1,
|
||||
thumbnail: getThumbnail(c.id),
|
||||
hasThumbnail: hasThumbnail(c.id),
|
||||
thumbnailVersion: getThumbnailVersion(c.id),
|
||||
}));
|
||||
|
||||
return { page, pageSize, total: totalRow.c, online: onlineRow.c, items };
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
listUserClientRuleIdsByAccess,
|
||||
} from "../../users";
|
||||
import { notifyDashboardViewers } from "../../sessions/sessionManager";
|
||||
import { clearThumbnail } from "../../thumbnails";
|
||||
|
||||
type RequestIpProvider = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
@@ -252,7 +253,7 @@ export async function handleClientRoutes(
|
||||
if (!canUserAccessClient(user.userId, user.role, clientId)) {
|
||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||
}
|
||||
const { generateThumbnail, markThumbnailRequested } = await import("../../thumbnails");
|
||||
const { generateThumbnail, markThumbnailRequested, getThumbnailVersion } = await import("../../thumbnails");
|
||||
markThumbnailRequested(clientId);
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (target?.online) {
|
||||
@@ -268,7 +269,40 @@ export async function handleClientRoutes(
|
||||
metrics.recordCommand("screenshot");
|
||||
}
|
||||
const success = await generateThumbnail(clientId);
|
||||
return Response.json({ ok: true, updated: success }, { headers: deps.CORS_HEADERS });
|
||||
const version = getThumbnailVersion(clientId);
|
||||
return Response.json(
|
||||
{ ok: true, updated: success, version },
|
||||
{ headers: deps.CORS_HEADERS },
|
||||
);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && thumbnailMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
const clientId = thumbnailMatch[1];
|
||||
if (!canUserAccessClient(user.userId, user.role, clientId)) {
|
||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||
}
|
||||
const { getThumbnailRecord } = await import("../../thumbnails");
|
||||
const record = getThumbnailRecord(clientId);
|
||||
if (!record) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
const etag = `"${clientId}-${record.version}"`;
|
||||
if (req.headers.get("if-none-match") === etag) {
|
||||
return new Response(null, { status: 304, headers: { ETag: etag } });
|
||||
}
|
||||
return new Response(record.bytes as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": record.contentType,
|
||||
"Content-Length": String(record.bytes.byteLength),
|
||||
ETag: etag,
|
||||
"Cache-Control": "private, max-age=300",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "DELETE" && url.pathname === "/api/clients/offline") {
|
||||
@@ -338,6 +372,7 @@ export async function handleClientRoutes(
|
||||
}
|
||||
|
||||
deleteClientRow(targetId);
|
||||
clearThumbnail(targetId);
|
||||
notifyDashboardViewers();
|
||||
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
@@ -771,6 +806,7 @@ export async function handleClientRoutes(
|
||||
metrics.recordCommand("uninstall");
|
||||
clientManager.deleteClient(targetId);
|
||||
deleteClientRow(targetId);
|
||||
clearThumbnail(targetId);
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
|
||||
import { logger } from "../../logger";
|
||||
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig } from "../../config";
|
||||
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig } from "../../config";
|
||||
import { getClientMetricsSummary, getClientMetricsSummaryForUser } from "../../db";
|
||||
import { metrics } from "../../metrics";
|
||||
import { requirePermission } from "../../rbac";
|
||||
@@ -693,5 +693,40 @@ export async function handleMiscRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Thumbnail toggles (GET: any user, PUT: admin only) ─────────────
|
||||
if (url.pathname === "/api/settings/thumbnails") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
if (req.method === "GET") {
|
||||
return Response.json({ thumbnails: getConfig().thumbnails }, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
if (req.method === "PUT") {
|
||||
if (user.role !== "admin") return new Response("Forbidden: Admin access required", { status: 403 });
|
||||
|
||||
let body: any = {};
|
||||
try { body = await req.json(); } catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await updateThumbnailsConfig({
|
||||
dashboardEnabled: body?.dashboardEnabled !== undefined ? Boolean(body.dashboardEnabled) : undefined,
|
||||
wallEnabled: body?.wallEnabled !== undefined ? Boolean(body.wallEnabled) : undefined,
|
||||
});
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip: deps.requestIP?.(req)?.address || "unknown",
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Updated thumbnail settings (dashboard=${updated.dashboardEnabled}, wall=${updated.wallEnabled})`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return Response.json({ ok: true, thumbnails: updated }, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { getConfig } from "../../config";
|
||||
import { requirePermission, type Permission } from "../../rbac";
|
||||
import { canUserAccessClient, getUserById, type UserRole } from "../../users";
|
||||
|
||||
@@ -65,6 +66,7 @@ type QueryClientPageDef = {
|
||||
/** Static pages: each entry maps a URL path to its HTML file + access rules. */
|
||||
const STATIC_PAGES: StaticPageDef[] = [
|
||||
{ path: "/metrics", file: "metrics.html", access: "any", checkPasswordChange: true },
|
||||
{ path: "/screenshots", file: "screenshots.html", access: "no-viewer", checkPasswordChange: true },
|
||||
{ path: "/settings", file: "settings.html", access: "any", checkPasswordChange: true },
|
||||
{ path: "/logs", file: "logs.html", access: "any", checkPasswordChange: true, permission: "audit:view" },
|
||||
{ path: "/notifications", file: "notifications.html", access: "admin-or-operator", checkPasswordChange: true },
|
||||
@@ -162,6 +164,10 @@ export async function handlePageRoutes(
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return serveLoginOrUnauthorized(deps);
|
||||
|
||||
if (page.path === "/screenshots" && !getConfig().thumbnails.wallEnabled) {
|
||||
return new Response("Screenshot wall is disabled by the administrator", { status: 403 });
|
||||
}
|
||||
|
||||
if (page.checkPasswordChange) {
|
||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
||||
if (maybeChange) return maybeChange;
|
||||
|
||||
@@ -21,17 +21,50 @@ const MAX_THUMBNAIL_SOURCE_BYTES = Math.max(
|
||||
256 * 1024,
|
||||
Number(process.env.OVERLORD_THUMBNAIL_MAX_SOURCE_BYTES || 16 * 1024 * 1024),
|
||||
);
|
||||
const THUMBNAIL_CACHE_MAX = Math.max(
|
||||
64,
|
||||
Number(process.env.OVERLORD_THUMBNAIL_CACHE_MAX || 2000),
|
||||
);
|
||||
|
||||
const thumbnails = new Map<string, string>();
|
||||
type ThumbnailRecord = {
|
||||
bytes: Uint8Array;
|
||||
contentType: string;
|
||||
version: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
const thumbnails = new Map<string, ThumbnailRecord>();
|
||||
const latestFrames = new Map<string, { bytes: Uint8Array; format: string; capturedAt: number }>();
|
||||
const thumbnailRequests = new Map<string, number>();
|
||||
|
||||
export function setThumbnail(id: string, dataUrl: string) {
|
||||
thumbnails.set(id, dataUrl);
|
||||
function touchThumbnailLRU(id: string) {
|
||||
const existing = thumbnails.get(id);
|
||||
if (!existing) return;
|
||||
thumbnails.delete(id);
|
||||
thumbnails.set(id, existing);
|
||||
}
|
||||
|
||||
export function getThumbnail(id: string) {
|
||||
return thumbnails.get(id) ?? null;
|
||||
function evictThumbnailsIfFull() {
|
||||
while (thumbnails.size > THUMBNAIL_CACHE_MAX) {
|
||||
const oldestKey = thumbnails.keys().next().value;
|
||||
if (oldestKey === undefined) break;
|
||||
thumbnails.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
export function hasThumbnail(id: string): boolean {
|
||||
return thumbnails.has(id);
|
||||
}
|
||||
|
||||
export function getThumbnailRecord(id: string): ThumbnailRecord | null {
|
||||
const rec = thumbnails.get(id);
|
||||
if (!rec) return null;
|
||||
touchThumbnailLRU(id);
|
||||
return rec;
|
||||
}
|
||||
|
||||
export function getThumbnailVersion(id: string): number {
|
||||
return thumbnails.get(id)?.version ?? 0;
|
||||
}
|
||||
|
||||
export function clearThumbnail(id: string) {
|
||||
@@ -48,7 +81,7 @@ export function setLatestFrame(id: string, bytes: Uint8Array, format: string) {
|
||||
latestFrames.set(id, { bytes, format, capturedAt: Date.now() });
|
||||
}
|
||||
|
||||
async function buildThumbnailDataUrl(bytes: Uint8Array, format: string): Promise<string | null> {
|
||||
async function buildThumbnailBytes(bytes: Uint8Array, format: string): Promise<Uint8Array | null> {
|
||||
if (!bytes || bytes.byteLength === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -71,7 +104,7 @@ async function buildThumbnailDataUrl(bytes: Uint8Array, format: string): Promise
|
||||
.webp({ quality: THUMBNAIL_QUALITY })
|
||||
.toBuffer();
|
||||
|
||||
return `data:image/webp;base64,${output.toString("base64")}`;
|
||||
return new Uint8Array(output);
|
||||
}
|
||||
|
||||
export async function generateThumbnail(id: string): Promise<boolean> {
|
||||
@@ -81,11 +114,20 @@ export async function generateThumbnail(id: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await buildThumbnailDataUrl(frameData.bytes, frameData.format);
|
||||
if (!dataUrl) {
|
||||
const out = await buildThumbnailBytes(frameData.bytes, frameData.format);
|
||||
if (!out) {
|
||||
return false;
|
||||
}
|
||||
thumbnails.set(id, dataUrl);
|
||||
const prior = thumbnails.get(id);
|
||||
const now = Date.now();
|
||||
if (prior) thumbnails.delete(id);
|
||||
thumbnails.set(id, {
|
||||
bytes: out,
|
||||
contentType: "image/webp",
|
||||
version: (prior?.version ?? 0) + 1,
|
||||
updatedAt: now,
|
||||
});
|
||||
evictThumbnailsIfFull();
|
||||
latestFrames.delete(id);
|
||||
return true;
|
||||
} catch (err) {
|
||||
|
||||
@@ -71,7 +71,8 @@ export type ListFilters = {
|
||||
|
||||
export type ListItem = Omit<ClientInfo, 'ws'> & {
|
||||
online: boolean;
|
||||
thumbnail: string | null;
|
||||
hasThumbnail: boolean;
|
||||
thumbnailVersion: number;
|
||||
};
|
||||
|
||||
export type ListResult = {
|
||||
|
||||
@@ -10,7 +10,7 @@ async function getGeoip() {
|
||||
import { ClientInfo } from "./types";
|
||||
import {
|
||||
consumeThumbnailRequest,
|
||||
getThumbnail,
|
||||
hasThumbnail,
|
||||
generateThumbnail,
|
||||
setLatestFrame,
|
||||
} from "./thumbnails";
|
||||
@@ -297,8 +297,7 @@ export function handleFrame(info: ClientInfo, payload: any) {
|
||||
if (safeFormat) {
|
||||
const now = Date.now();
|
||||
const thumbnailRequested = consumeThumbnailRequest(info.id);
|
||||
const hasThumbnail = Boolean(getThumbnail(info.id));
|
||||
if (thumbnailRequested || !hasThumbnail) {
|
||||
if (thumbnailRequested || !hasThumbnail(info.id)) {
|
||||
setLatestFrame(info.id, bytes, safeFormat);
|
||||
void generateThumbnail(info.id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user