Files
pikasTech-unidesk/scripts/src/network-perf.ts
T
2026-05-16 16:03:53 +00:00

197 lines
7.2 KiB
TypeScript

import { type UniDeskConfig } from "./config";
export interface NetworkPerfOptions {
baseUrl: string;
username: string;
password: string;
serviceId: string;
path: string;
count: number;
concurrency: number;
timeoutMs: number;
cacheBust: boolean;
label: string;
}
interface NetworkPerfSample {
index: number;
ok: boolean;
status: number;
durationMs: number;
cache: string;
proxyMode: string;
upstreamProxyMode: string;
responseTruncated: string;
bytes: number;
error: string | null;
}
function argValue(args: string[], name: string): string | null {
const index = args.indexOf(name);
if (index === -1) return null;
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`);
return value;
}
function numberArg(args: string[], name: string, fallback: number): number {
const raw = argValue(args, name);
if (raw === null) return fallback;
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
return value;
}
function normalizeBaseUrl(value: string): string {
return value.replace(/\/+$/u, "");
}
function frontendBaseUrl(host: string, config: UniDeskConfig): string {
if (host.startsWith("http://") || host.startsWith("https://")) return normalizeBaseUrl(host);
if (/:\d+$/u.test(host)) return `http://${host}`;
return `http://${host}:${config.network.frontend.port}`;
}
export function parseNetworkPerfOptions(config: UniDeskConfig, args: string[], fallbackHost?: string): NetworkPerfOptions {
const baseUrl = argValue(args, "--url") ?? (fallbackHost === undefined
? `http://${config.network.publicHost}:${config.network.frontend.port}`
: frontendBaseUrl(fallbackHost, config));
return {
baseUrl: normalizeBaseUrl(baseUrl),
username: argValue(args, "--username") ?? config.auth.username,
password: argValue(args, "--password") ?? config.auth.password,
serviceId: argValue(args, "--service") ?? argValue(args, "--service-id") ?? "code-queue",
path: argValue(args, "--path") ?? "/api/tasks/overview?limit=30",
count: numberArg(args, "--count", 20),
concurrency: numberArg(args, "--concurrency", 1),
timeoutMs: numberArg(args, "--timeout-ms", 45_000),
cacheBust: !args.includes("--no-cache-bust"),
label: argValue(args, "--label") ?? "network-perf",
};
}
async function login(options: NetworkPerfOptions): Promise<string> {
const response = await fetch(`${options.baseUrl}/login`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ username: options.username, password: options.password }),
signal: AbortSignal.timeout(options.timeoutMs),
});
const text = await response.text();
if (!response.ok) throw new Error(`login failed: HTTP ${response.status} ${text.slice(0, 300)}`);
const cookie = response.headers.get("set-cookie")?.split(";", 1)[0] ?? "";
if (cookie.length === 0) throw new Error("login response did not set a session cookie");
return cookie;
}
function sampleUrl(options: NetworkPerfOptions, index: number): string {
const [pathOnly = "/", query = ""] = options.path.split("?", 2);
const params = new URLSearchParams(query);
if (options.cacheBust) params.set("__networkPerf", `${Date.now()}-${index}`);
const search = params.toString();
return `${options.baseUrl}/api/microservices/${encodeURIComponent(options.serviceId)}/proxy${pathOnly}${search.length > 0 ? `?${search}` : ""}`;
}
async function runOne(options: NetworkPerfOptions, cookie: string, index: number): Promise<NetworkPerfSample> {
const started = performance.now();
try {
const response = await fetch(sampleUrl(options, index), {
headers: {
accept: "application/json",
cookie,
},
signal: AbortSignal.timeout(options.timeoutMs),
});
const bodyText = await response.text();
return {
index,
ok: response.ok,
status: response.status,
durationMs: Math.round((performance.now() - started) * 10) / 10,
cache: response.headers.get("x-unidesk-cache") ?? "",
proxyMode: response.headers.get("x-unidesk-proxy-mode") ?? "",
upstreamProxyMode: response.headers.get("x-unidesk-upstream-proxy-mode") ?? "",
responseTruncated: response.headers.get("x-unidesk-response-truncated") ?? "",
bytes: bodyText.length,
error: null,
};
} catch (error) {
return {
index,
ok: false,
status: 0,
durationMs: Math.round((performance.now() - started) * 10) / 10,
cache: "",
proxyMode: "",
upstreamProxyMode: "",
responseTruncated: "",
bytes: 0,
error: error instanceof Error ? error.message : String(error),
};
}
}
function percentile(values: number[], percentileValue: number): number {
if (values.length === 0) return 0;
const sorted = values.slice().sort((left, right) => left - right);
if (percentileValue <= 0) return sorted[0] ?? 0;
if (percentileValue >= 100) return sorted[sorted.length - 1] ?? 0;
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((percentileValue / 100) * sorted.length) - 1));
return sorted[index] ?? 0;
}
function countBy(samples: NetworkPerfSample[], key: keyof NetworkPerfSample): Record<string, number> {
const counts: Record<string, number> = {};
for (const sample of samples) {
const value = String(sample[key] ?? "");
counts[value] = (counts[value] ?? 0) + 1;
}
return counts;
}
export async function runNetworkPerf(options: NetworkPerfOptions): Promise<Record<string, unknown>> {
const cookie = await login(options);
const samples: NetworkPerfSample[] = [];
let nextIndex = 0;
const workers = Array.from({ length: Math.min(options.concurrency, options.count) }, async () => {
while (nextIndex < options.count) {
const index = nextIndex;
nextIndex += 1;
samples.push(await runOne(options, cookie, index));
}
});
const startedAt = Date.now();
await Promise.all(workers);
samples.sort((left, right) => left.index - right.index);
const durations = samples.map((sample) => sample.durationMs);
const successfulDurations = samples.filter((sample) => sample.ok).map((sample) => sample.durationMs);
return {
ok: samples.every((sample) => sample.ok),
label: options.label,
measuredAt: new Date(startedAt).toISOString(),
baseUrl: options.baseUrl,
serviceId: options.serviceId,
path: options.path,
count: options.count,
concurrency: options.concurrency,
timeoutMs: options.timeoutMs,
cacheBust: options.cacheBust,
successCount: samples.filter((sample) => sample.ok).length,
failureCount: samples.filter((sample) => !sample.ok).length,
statusCounts: countBy(samples, "status"),
cacheCounts: countBy(samples, "cache"),
proxyModeCounts: countBy(samples, "proxyMode"),
upstreamProxyModeCounts: countBy(samples, "upstreamProxyMode"),
durationMs: {
min: percentile(durations, 0),
p50: percentile(durations, 50),
p90: percentile(durations, 90),
p95: percentile(durations, 95),
max: percentile(durations, 100),
successfulP50: percentile(successfulDurations, 50),
successfulP95: percentile(successfulDurations, 95),
},
samples,
};
}