feat: add provider websocket http data plane
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
+14
-1
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
||||
import { type UniDeskConfig } from "./config";
|
||||
import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug";
|
||||
import { summarizeMicroserviceProxyResponse } from "./microservices";
|
||||
import { parseNetworkPerfOptions, runNetworkPerf } from "./network-perf";
|
||||
import { isSshSkillDiscoveryArgs, parseSshArgs } from "./ssh";
|
||||
import { codexJudgeQueryAsync, codexOutputQueryAsync, codexTaskQueryAsync } from "./code-queue";
|
||||
|
||||
@@ -495,6 +496,14 @@ async function remoteCodeQueue(session: FrontendSession, args: string[]): Promis
|
||||
};
|
||||
}
|
||||
|
||||
async function remoteNetworkPerf(options: RemoteCliOptions, config: UniDeskConfig, args: string[]): Promise<unknown> {
|
||||
if (options.host === null) throw new Error("network perf requires --main-server-ip when using remote frontend transport");
|
||||
return {
|
||||
transport: "frontend",
|
||||
result: await runNetworkPerf(parseNetworkPerfOptions(config, args.slice(2), options.host)),
|
||||
};
|
||||
}
|
||||
|
||||
async function runRemoteSshOverFrontend(session: FrontendSession, providerId: string | undefined, args: string[]): Promise<number> {
|
||||
if (!providerId) throw new Error("remote ssh requires provider id, for example: bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh D601 hostname");
|
||||
const parsed = parseSshArgs(args);
|
||||
@@ -549,7 +558,7 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
|
||||
emitRemoteJson(name, {
|
||||
transport: "frontend",
|
||||
baseUrl: session.baseUrl,
|
||||
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "ssh <providerId> skills", "microservice list", "microservice status <id>", "microservice health <id>", "microservice proxy <id> <path>", "codex task <taskId>", "codex judge <taskId> --attempt N"],
|
||||
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "ssh <providerId> skills", "microservice list", "microservice status <id>", "microservice health <id>", "microservice proxy <id> <path>", "codex task <taskId>", "codex judge <taskId> --attempt N", "network perf"],
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
@@ -573,6 +582,10 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
|
||||
emitRemoteJson(name, await remoteCodeQueue(session, args));
|
||||
return 0;
|
||||
}
|
||||
if (top === "network" && sub === "perf") {
|
||||
emitRemoteJson(name, await remoteNetworkPerf(options, config, args));
|
||||
return 0;
|
||||
}
|
||||
if (top === "ssh") {
|
||||
return await runRemoteSshOverFrontend(session, sub, args.slice(2));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user