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 { 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 { 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 { const counts: Record = {}; 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> { 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, }; }