change around pings, fix main.js so the actual real fucking number shows instead of it freaking out.
This commit is contained in:
@@ -1119,7 +1119,9 @@ function initializeRenderer() {
|
||||
rendererSetLayout = rSetLayout;
|
||||
registerRenderer((data, options) => {
|
||||
renderMerge(data, options);
|
||||
updateDashboardStatsFromClients(data);
|
||||
if (isUnfilteredClientView()) {
|
||||
updateDashboardStatsFromClients(data);
|
||||
}
|
||||
if (!options?.fromPluginDashboard) {
|
||||
refreshDashboardPluginContributions(data?.items || []);
|
||||
}
|
||||
@@ -1151,6 +1153,15 @@ function initializeRenderer() {
|
||||
}
|
||||
}
|
||||
|
||||
function isUnfilteredClientView() {
|
||||
return !state.searchTerm &&
|
||||
(state.filterStatus || "all") === "all" &&
|
||||
(state.filterOs || "all") === "all" &&
|
||||
(state.filterCountry || "all") === "all" &&
|
||||
(state.filterGroup || "all") === "all" &&
|
||||
(state.filterWebcam || "all") === "all";
|
||||
}
|
||||
|
||||
if (logoutBtn && !logoutBtn.dataset.boundLogout) {
|
||||
logoutBtn.dataset.boundLogout = "true";
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { encodeMessage } from "./protocol";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { metrics } from "./metrics";
|
||||
import { logAudit, AuditAction } from "./auditLog";
|
||||
import { sendPingRequest } from "./wsHandlers";
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 12;
|
||||
const CORS_HEADERS = {
|
||||
@@ -61,10 +62,7 @@ export function handleCommand(target: ClientInfo, action: string, req: Request)
|
||||
metrics.recordCommand(action);
|
||||
|
||||
if (action === "ping") {
|
||||
const nonce = Date.now() + Math.floor(Math.random() * 1000);
|
||||
target.lastPingSent = Date.now();
|
||||
target.lastPingNonce = nonce;
|
||||
target.ws.send(encodeMessage({ type: "ping", ts: nonce }));
|
||||
sendPingRequest(target, target.ws, "manual", 0);
|
||||
return OK();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getHeartbeatBatchSize } from "./maintenance-loops";
|
||||
import { getHeartbeatBatchSize, shouldSendHeartbeatPing } from "./maintenance-loops";
|
||||
|
||||
describe("maintenance heartbeat batching", () => {
|
||||
test("spreads large fleets across the heartbeat interval", () => {
|
||||
@@ -13,4 +13,11 @@ describe("maintenance heartbeat batching", () => {
|
||||
test("handles empty fleets", () => {
|
||||
expect(getHeartbeatBatchSize(0, 30_000)).toBe(0);
|
||||
});
|
||||
|
||||
test("only sends heartbeat pings after the per-client interval", () => {
|
||||
const now = Date.now();
|
||||
expect(shouldSendHeartbeatPing({}, now, 30_000)).toBe(true);
|
||||
expect(shouldSendHeartbeatPing({ lastPingSent: now - 29_999 }, now, 30_000)).toBe(false);
|
||||
expect(shouldSendHeartbeatPing({ lastPingSent: now - 30_000 }, now, 30_000)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,16 @@ export function getHeartbeatBatchSize(totalClients: number, heartbeatIntervalMs:
|
||||
return Math.max(1, Math.ceil(totalClients / ticksPerSweep));
|
||||
}
|
||||
|
||||
export function shouldSendHeartbeatPing(
|
||||
info: Pick<ClientInfo, "lastPingSent">,
|
||||
now: number,
|
||||
heartbeatIntervalMs: number,
|
||||
): boolean {
|
||||
if (heartbeatIntervalMs <= 0) return false;
|
||||
if (!info.lastPingSent) return true;
|
||||
return now - info.lastPingSent >= heartbeatIntervalMs;
|
||||
}
|
||||
|
||||
export function startMaintenanceLoops(params: StartMaintenanceParams): void {
|
||||
setInterval(() => {
|
||||
const startedAt = Date.now();
|
||||
@@ -82,8 +92,11 @@ export function startMaintenanceLoops(params: StartMaintenanceParams): void {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!shouldSendHeartbeatPing(info, now, params.heartbeatIntervalMs)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
sendPingRequest(info, info.ws, "heartbeat");
|
||||
sendPingRequest(info, info.ws, "heartbeat", params.heartbeatIntervalMs);
|
||||
} catch (err) {
|
||||
logger.debug(`[ping] heartbeat failed for ${id}`, err);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { decodeMessage } from "../../protocol";
|
||||
import type { ClientInfo } from "../../types";
|
||||
import { dispatchPingBulk } from "./client-command-routes";
|
||||
|
||||
function makeClient(): ClientInfo {
|
||||
const sent: Uint8Array[] = [];
|
||||
return {
|
||||
id: "bulk-ping-client",
|
||||
role: "client",
|
||||
lastSeen: Date.now(),
|
||||
ws: {
|
||||
send(data: Uint8Array) {
|
||||
sent.push(data);
|
||||
},
|
||||
_sent: sent,
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("client command ping diagnostics", () => {
|
||||
test("dispatchPingBulk sends the requested number of pings", () => {
|
||||
const client = makeClient();
|
||||
const sent = (client.ws as any)._sent as Uint8Array[];
|
||||
|
||||
const count = dispatchPingBulk(client, 3);
|
||||
|
||||
expect(count).toBe(3);
|
||||
expect(sent.length).toBe(3);
|
||||
for (const raw of sent) {
|
||||
const payload = decodeMessage(raw) as any;
|
||||
expect(payload.type).toBe("ping");
|
||||
expect(typeof payload.ts).toBe("number");
|
||||
}
|
||||
expect(typeof client.lastPingNonce).toBe("number");
|
||||
});
|
||||
|
||||
test("dispatchPingBulk clamps invalid and large counts", () => {
|
||||
const invalid = makeClient();
|
||||
expect(dispatchPingBulk(invalid, "bad")).toBe(1);
|
||||
expect((invalid.ws as any)._sent.length).toBe(1);
|
||||
|
||||
const large = makeClient();
|
||||
expect(dispatchPingBulk(large, 5000)).toBe(1000);
|
||||
expect((large.ws as any)._sent.length).toBe(1000);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import { metrics } from "../../metrics";
|
||||
import { encodeMessage } from "../../protocol";
|
||||
import { requireClientAccess, requireFeatureAccess, requirePermission } from "../../rbac";
|
||||
import { clearThumbnail } from "../../thumbnails";
|
||||
import type { ClientInfo } from "../../types";
|
||||
import { sendPingRequest } from "../../wsHandlers";
|
||||
|
||||
type RequestIpProvider = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
@@ -32,6 +34,20 @@ type ClientCommandDeps = {
|
||||
pendingCommandReplies: Map<string, PendingCommandReply>;
|
||||
};
|
||||
|
||||
function clampPositiveInt(value: unknown, fallback: number, max: number): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||
return Math.min(max, Math.max(1, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
export function dispatchPingBulk(target: ClientInfo, countValue: unknown): number {
|
||||
const count = clampPositiveInt(countValue, 1, 1000);
|
||||
for (let i = 0; i < count; i++) {
|
||||
sendPingRequest(target, target.ws, "manual-bulk", 0);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export async function handleClientCommandRoute(
|
||||
req: Request,
|
||||
url: URL,
|
||||
@@ -71,12 +87,13 @@ export async function handleClientCommandRoute(
|
||||
let success = true;
|
||||
|
||||
if (action === "ping") {
|
||||
const nonce = Date.now() + Math.floor(Math.random() * 1000);
|
||||
target.lastPingSent = Date.now();
|
||||
target.lastPingNonce = nonce;
|
||||
target.ws.send(encodeMessage({ type: "ping", ts: nonce }));
|
||||
sendPingRequest(target, target.ws, "manual", 0);
|
||||
metrics.recordCommand("ping");
|
||||
} else if (action === "ping_bulk") {
|
||||
Math.max(1, Math.min(1000, Number(body?.count || 1)));
|
||||
const count = dispatchPingBulk(target, body?.count);
|
||||
metrics.recordCommand("ping_bulk");
|
||||
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, targetClientId: targetId, details: `ping_bulk:${count}`, success: true });
|
||||
return Response.json({ ok: true, sent: count }, { headers: deps.CORS_HEADERS });
|
||||
} else if (action === "disconnect") {
|
||||
try {
|
||||
requirePermission(user, "clients:disconnect");
|
||||
|
||||
@@ -30,6 +30,25 @@ describe("wsHandlers ping/pong", () => {
|
||||
expect(ws.sent.length).toBe(1);
|
||||
});
|
||||
|
||||
test("sendPingRequest can bypass the interval for manual pings", () => {
|
||||
const ws: MockWs = {
|
||||
sent: [],
|
||||
send(msg) {
|
||||
this.sent.push(msg);
|
||||
},
|
||||
};
|
||||
const info = {
|
||||
id: "client-manual-ping",
|
||||
role: "client",
|
||||
ws,
|
||||
lastSeen: Date.now(),
|
||||
} as any;
|
||||
|
||||
expect(sendPingRequest(info, ws, "test")).toBe(true);
|
||||
expect(sendPingRequest(info, ws, "manual", 0)).toBe(true);
|
||||
expect(ws.sent.length).toBe(2);
|
||||
});
|
||||
|
||||
test("handlePing responds with pong without starting another server ping", () => {
|
||||
const ws: MockWs = {
|
||||
sent: [],
|
||||
@@ -53,6 +72,50 @@ describe("wsHandlers ping/pong", () => {
|
||||
expect(info.lastPingNonce).toBeUndefined();
|
||||
});
|
||||
|
||||
test("handlePing preserves zero timestamps", () => {
|
||||
const ws: MockWs = {
|
||||
sent: [],
|
||||
send(msg) {
|
||||
this.sent.push(msg);
|
||||
},
|
||||
};
|
||||
const info = {
|
||||
id: "client-zero-ping",
|
||||
role: "client",
|
||||
ws,
|
||||
lastSeen: Date.now(),
|
||||
} as any;
|
||||
|
||||
handlePing(info, { type: "ping", ts: 0 } as any, ws);
|
||||
|
||||
const payload = decodeMessage(ws.sent[0]) as any;
|
||||
expect(payload.type).toBe("pong");
|
||||
expect(payload.ts).toBe(0);
|
||||
});
|
||||
|
||||
test("handlePing falls back for invalid timestamps", () => {
|
||||
const ws: MockWs = {
|
||||
sent: [],
|
||||
send(msg) {
|
||||
this.sent.push(msg);
|
||||
},
|
||||
};
|
||||
const info = {
|
||||
id: "client-invalid-ping",
|
||||
role: "client",
|
||||
ws,
|
||||
lastSeen: Date.now(),
|
||||
} as any;
|
||||
const before = Date.now();
|
||||
|
||||
handlePing(info, { type: "ping", ts: "bad" } as any, ws);
|
||||
|
||||
const payload = decodeMessage(ws.sent[0]) as any;
|
||||
expect(payload.type).toBe("pong");
|
||||
expect(payload.ts).toBeGreaterThanOrEqual(before);
|
||||
expect(payload.ts).toBeLessThanOrEqual(Date.now());
|
||||
});
|
||||
|
||||
test("handlePong clears nonce and records ping", () => {
|
||||
metrics.reset();
|
||||
const ws: MockWs = {
|
||||
|
||||
@@ -171,19 +171,28 @@ export function handlePing(info: ClientInfo, payload: Ping, ws: any) {
|
||||
isAdmin: info.isAdmin,
|
||||
});
|
||||
}
|
||||
ws.send(encodeMessage({ type: "pong", ts: payload.ts || Date.now() }));
|
||||
const ts = typeof payload.ts === "number" && Number.isFinite(payload.ts)
|
||||
? payload.ts
|
||||
: Date.now();
|
||||
ws.send(encodeMessage({ type: "pong", ts }));
|
||||
}
|
||||
|
||||
export function sendPingRequest(info: ClientInfo, ws: any, reason: string) {
|
||||
export function sendPingRequest(
|
||||
info: ClientInfo,
|
||||
ws: any,
|
||||
reason: string,
|
||||
minIntervalMs = 1_000,
|
||||
): boolean {
|
||||
const now = Date.now();
|
||||
if (info.lastPingSent && now - info.lastPingSent < 1_000) {
|
||||
return;
|
||||
if (minIntervalMs > 0 && info.lastPingSent && now - info.lastPingSent < minIntervalMs) {
|
||||
return false;
|
||||
}
|
||||
const nonce = now + Math.floor(Math.random() * 1000);
|
||||
info.lastPingSent = now;
|
||||
info.lastPingNonce = nonce;
|
||||
//console.log(`[ping] send ping to client=${info.id} reason=${reason} nonce=${nonce}`);
|
||||
ws.send(encodeMessage({ type: "ping", ts: nonce }));
|
||||
return true;
|
||||
}
|
||||
|
||||
export function handlePong(info: ClientInfo, payload: WireMessage) {
|
||||
|
||||
Reference in New Issue
Block a user