feat: add provider-backed microservices
This commit is contained in:
@@ -35,10 +35,46 @@ export interface UniDeskConfig {
|
||||
};
|
||||
};
|
||||
docker: { composeFile: string; projectName: string };
|
||||
microservices: UniDeskMicroserviceConfig[];
|
||||
paths: { stateDir: string; logsDir: string; docsReferenceDir: string };
|
||||
sshForwarding: { mode: string; keyDir: string; host: string; port: number; user: string };
|
||||
}
|
||||
|
||||
export interface UniDeskMicroserviceConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
providerId: string;
|
||||
description: string;
|
||||
repository: {
|
||||
url: string;
|
||||
commitId: string;
|
||||
dockerfile: string;
|
||||
composeFile: string;
|
||||
composeService: string;
|
||||
containerName: string;
|
||||
};
|
||||
backend: {
|
||||
nodeBaseUrl: string;
|
||||
nodeBindHost: string;
|
||||
nodePort: number;
|
||||
proxyMode: string;
|
||||
frontendOnly: boolean;
|
||||
public: boolean;
|
||||
allowedPathPrefixes: string[];
|
||||
healthPath: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
development: {
|
||||
providerId: string;
|
||||
sshPassthrough: boolean;
|
||||
worktreePath: string;
|
||||
};
|
||||
frontend: {
|
||||
route: string;
|
||||
integrated: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||
export const repoRoot = join(moduleDir, "..", "..");
|
||||
|
||||
@@ -70,6 +106,68 @@ function portPair(obj: Record<string, unknown>, key: string): { port: number; co
|
||||
return { port: numberField(value, "port", `network.${key}`), containerPort: numberField(value, "containerPort", `network.${key}`) };
|
||||
}
|
||||
|
||||
function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
|
||||
const value = obj[key];
|
||||
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalArray(value: unknown, name: string): Record<string, unknown>[] {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value)) throw new Error(`${name} must be an array`);
|
||||
return value.map((item, index) => asRecord(item, `${name}[${index}]`));
|
||||
}
|
||||
|
||||
function stringArrayField(obj: Record<string, unknown>, key: string, path: string): string[] {
|
||||
const value = obj[key];
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) {
|
||||
throw new Error(`${path}.${key} must be an array of non-empty strings`);
|
||||
}
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
function microserviceConfig(item: Record<string, unknown>, index: number): UniDeskMicroserviceConfig {
|
||||
const path = `microservices[${index}]`;
|
||||
const repository = asRecord(item.repository, `${path}.repository`);
|
||||
const backend = asRecord(item.backend, `${path}.backend`);
|
||||
const development = asRecord(item.development, `${path}.development`);
|
||||
const frontend = asRecord(item.frontend, `${path}.frontend`);
|
||||
return {
|
||||
id: stringField(item, "id", path),
|
||||
name: stringField(item, "name", path),
|
||||
providerId: stringField(item, "providerId", path),
|
||||
description: stringField(item, "description", path),
|
||||
repository: {
|
||||
url: stringField(repository, "url", `${path}.repository`),
|
||||
commitId: stringField(repository, "commitId", `${path}.repository`),
|
||||
dockerfile: stringField(repository, "dockerfile", `${path}.repository`),
|
||||
composeFile: stringField(repository, "composeFile", `${path}.repository`),
|
||||
composeService: stringField(repository, "composeService", `${path}.repository`),
|
||||
containerName: stringField(repository, "containerName", `${path}.repository`),
|
||||
},
|
||||
backend: {
|
||||
nodeBaseUrl: stringField(backend, "nodeBaseUrl", `${path}.backend`),
|
||||
nodeBindHost: stringField(backend, "nodeBindHost", `${path}.backend`),
|
||||
nodePort: numberField(backend, "nodePort", `${path}.backend`),
|
||||
proxyMode: stringField(backend, "proxyMode", `${path}.backend`),
|
||||
frontendOnly: booleanField(backend, "frontendOnly", `${path}.backend`),
|
||||
public: booleanField(backend, "public", `${path}.backend`),
|
||||
allowedPathPrefixes: stringArrayField(backend, "allowedPathPrefixes", `${path}.backend`),
|
||||
healthPath: stringField(backend, "healthPath", `${path}.backend`),
|
||||
timeoutMs: numberField(backend, "timeoutMs", `${path}.backend`),
|
||||
},
|
||||
development: {
|
||||
providerId: stringField(development, "providerId", `${path}.development`),
|
||||
sshPassthrough: booleanField(development, "sshPassthrough", `${path}.development`),
|
||||
worktreePath: stringField(development, "worktreePath", `${path}.development`),
|
||||
},
|
||||
frontend: {
|
||||
route: stringField(frontend, "route", `${path}.frontend`),
|
||||
integrated: booleanField(frontend, "integrated", `${path}.frontend`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function readConfig(): UniDeskConfig {
|
||||
const configPath = rootPath("config.json");
|
||||
if (!existsSync(configPath)) throw new Error(`config.json not found at ${configPath}`);
|
||||
@@ -82,6 +180,7 @@ export function readConfig(): UniDeskConfig {
|
||||
const auth = asRecord(parsed.auth, "auth");
|
||||
const providerGateway = asRecord(parsed.providerGateway, "providerGateway");
|
||||
const docker = asRecord(parsed.docker, "docker");
|
||||
const microservices = optionalArray(parsed.microservices, "microservices").map(microserviceConfig);
|
||||
const paths = asRecord(parsed.paths, "paths");
|
||||
const sshForwarding = asRecord(parsed.sshForwarding, "sshForwarding");
|
||||
const labels = asRecord(providerGateway.labels, "providerGateway.labels");
|
||||
@@ -129,6 +228,7 @@ export function readConfig(): UniDeskConfig {
|
||||
},
|
||||
},
|
||||
docker: { composeFile: stringField(docker, "composeFile", "docker"), projectName: stringField(docker, "projectName", "docker") },
|
||||
microservices,
|
||||
paths: {
|
||||
stateDir: stringField(paths, "stateDir", "paths"),
|
||||
logsDir: stringField(paths, "logsDir", "paths"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { runCommand } from "./command";
|
||||
import { type UniDeskConfig, repoRoot } from "./config";
|
||||
|
||||
export const dispatchCommands = ["docker.ps", "provider.upgrade", "host.ssh", "echo"] as const;
|
||||
export const dispatchCommands = ["docker.ps", "provider.upgrade", "host.ssh", "microservice.http", "echo"] as const;
|
||||
export type DebugDispatchCommand = typeof dispatchCommands[number];
|
||||
|
||||
export function isDebugDispatchCommand(value: unknown): value is DebugDispatchCommand {
|
||||
|
||||
@@ -69,6 +69,7 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
chmodSync(logDir, 0o777);
|
||||
const labels = JSON.stringify(config.providerGateway.labels);
|
||||
const microservices = JSON.stringify(config.microservices);
|
||||
const lines = {
|
||||
UNIDESK_PUBLIC_HOST: config.network.publicHost,
|
||||
UNIDESK_CORE_PORT: String(config.network.core.port),
|
||||
@@ -82,6 +83,7 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
|
||||
UNIDESK_PROVIDER_ID: config.providerGateway.id,
|
||||
UNIDESK_PROVIDER_NAME: config.providerGateway.name,
|
||||
UNIDESK_PROVIDER_LABELS_JSON: labels,
|
||||
UNIDESK_MICROSERVICES_JSON: microservices,
|
||||
UNIDESK_AUTH_USERNAME: config.auth.username,
|
||||
UNIDESK_AUTH_PASSWORD: config.auth.password,
|
||||
UNIDESK_SESSION_SECRET: config.auth.sessionSecret,
|
||||
|
||||
+114
-4
@@ -1,9 +1,10 @@
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { connect } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { chromium } from "playwright";
|
||||
import { runCommand } from "./command";
|
||||
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
|
||||
import { boundedJsonDetail } from "./preview";
|
||||
|
||||
type CheckStatus = "passed" | "failed";
|
||||
|
||||
@@ -67,7 +68,16 @@ function tcpProbe(host: string, port: number, timeoutMs = 2500): Promise<unknown
|
||||
}
|
||||
|
||||
function addCheck(checks: E2ECheck[], name: string, passed: boolean, detail: unknown): void {
|
||||
checks.push({ name, status: passed ? "passed" : "failed", detail });
|
||||
checks.push({
|
||||
name,
|
||||
status: passed ? "passed" : "failed",
|
||||
detail: boundedJsonDetail(detail, passed ? 4_000 : 24_000, {
|
||||
maxDepth: passed ? 3 : 5,
|
||||
maxArrayItems: passed ? 3 : 8,
|
||||
maxObjectKeys: passed ? 12 : 40,
|
||||
maxStringLength: passed ? 600 : 1600,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function safeTestId(value: string): string {
|
||||
@@ -211,9 +221,11 @@ async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E
|
||||
const portsText = (portSummary.rows ?? []).map((row) => `${row.name} ${row.ports}`).join("\n");
|
||||
const corePublic = await fetchProbe(`${urls.blockedCoreUrl}/health`, 2500);
|
||||
const databasePublic = await tcpProbe(urls.blockedDatabaseHost, urls.blockedDatabasePort);
|
||||
const findjobPublic = await fetchProbe(`http://${config.network.publicHost}:3254/api/health`, 2500);
|
||||
addCheck(checks, "network:only-frontend-provider-ports", !portsText.includes(`:${config.network.core.port}->`) && !portsText.includes(`:${config.network.database.port}->`), portSummary);
|
||||
addCheck(checks, "network:core-public-blocked", (corePublic as { reachable?: boolean }).reachable === false, corePublic);
|
||||
addCheck(checks, "network:database-public-blocked", (databasePublic as { reachable?: boolean }).reachable === false, databasePublic);
|
||||
addCheck(checks, "network:findjob-public-blocked", (findjobPublic as { reachable?: boolean }).reachable === false, findjobPublic);
|
||||
}
|
||||
|
||||
async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<void> {
|
||||
@@ -221,6 +233,14 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
const coreNodes = dockerCoreJson("/api/nodes");
|
||||
const systemStatus = dockerCoreJson("/api/nodes/system-status?limit=24");
|
||||
const dockerStatus = dockerCoreJson("/api/nodes/docker-status");
|
||||
const microservices = dockerCoreJson("/api/microservices");
|
||||
const findjobStatus = dockerCoreJson("/api/microservices/findjob/status");
|
||||
const findjobHealth = dockerCoreJson("/api/microservices/findjob/health");
|
||||
const findjobSummary = dockerCoreJson("/api/microservices/findjob/proxy/api/summary");
|
||||
const findjobJobsPreview = dockerCoreJson("/api/microservices/findjob/proxy/api/jobs?__unideskArrayLimit=jobs:5");
|
||||
const pipelineStatus = dockerCoreJson("/api/microservices/pipeline/status");
|
||||
const pipelineHealth = dockerCoreJson("/api/microservices/pipeline/health");
|
||||
const pipelineSnapshot = dockerCoreJson("/api/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3");
|
||||
const providerIngress = await fetchProbe(urls.providerIngressHealthUrl);
|
||||
const overviewBody = (coreOverview as { body?: { ok?: boolean; dbReady?: boolean; onlineNodeCount?: number } }).body;
|
||||
const nodeList = (coreNodes as { body?: { nodes?: Array<{ providerId?: string; status?: string; labels?: Record<string, unknown> }> } }).body?.nodes ?? [];
|
||||
@@ -235,6 +255,42 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
addCheck(checks, "provider:gateway-version-label", mainNode?.labels?.providerGatewayVersion === expectedGatewayVersion && mainNode?.labels?.providerGatewayUpgradePolicy === "always-enabled", { providerId: config.providerGateway.id, expectedGatewayVersion, labels: mainNode?.labels ?? null });
|
||||
addCheck(checks, "provider:system-status", (systemStatus as { ok?: boolean }).ok === true && mainSystem?.current !== undefined && Number.isFinite(mainSystem.current.cpu?.percent) && Number.isFinite(mainSystem.current.memory?.percent) && mainSystem.current.memory?.mode === "actual_without_cache" && Number.isFinite(mainSystem.current.memory?.cacheBytes) && Number.isFinite(mainSystem.current.disk?.percent) && (mainSystem.history?.length ?? 0) > 0, systemStatusCheckDetail(systemStatus, config.providerGateway.id));
|
||||
addCheck(checks, "provider:docker-status", (dockerStatus as { ok?: boolean }).ok === true && mainDocker?.dockerStatus !== undefined && ((mainDocker.dockerStatus.counts?.containers ?? 0) > 0 || (mainDocker.dockerStatus.containers?.length ?? 0) > 0), dockerStatusCheckDetail(dockerStatus, config.providerGateway.id));
|
||||
const microserviceList = (microservices as { body?: { microservices?: Array<{ id?: string; providerId?: string; backend?: { public?: boolean }; runtime?: { providerStatus?: string; container?: { name?: string; state?: string } } }> } }).body?.microservices ?? [];
|
||||
const findjob = microserviceList.find((service) => service.id === "findjob");
|
||||
const pipeline = microserviceList.find((service) => service.id === "pipeline");
|
||||
const findjobSummaryBody = (findjobSummary as { body?: { totalJobs?: number; prioritizedJobs?: number } }).body;
|
||||
const findjobJobs = (findjobJobsPreview as { body?: { jobs?: unknown[]; _unidesk?: { arrayLimits?: { jobs?: { returnedLength?: number; originalLength?: number } } } } }).body;
|
||||
const pipelineSnapshotBody = (pipelineSnapshot as { body?: { ok?: boolean; registry?: { ok?: boolean; components?: unknown[] }; pipelines?: unknown[]; runs?: unknown[]; _unidesk?: { arrayLimits?: { "registry.components"?: { returnedLength?: number; originalLength?: number }; runs?: { returnedLength?: number; originalLength?: number } } } } }).body;
|
||||
const firstPipelineRun = Array.isArray(pipelineSnapshotBody?.runs)
|
||||
? pipelineSnapshotBody.runs[0] as { runId?: string; pipelineId?: string; status?: string; updatedAt?: string } | undefined
|
||||
: undefined;
|
||||
const pipelineSnapshotDetail = {
|
||||
ok: (pipelineSnapshot as { ok?: boolean }).ok,
|
||||
status: (pipelineSnapshot as { status?: number }).status,
|
||||
body: {
|
||||
ok: pipelineSnapshotBody?.ok,
|
||||
registryOk: pipelineSnapshotBody?.registry?.ok,
|
||||
componentPreviewCount: pipelineSnapshotBody?.registry?.components?.length ?? 0,
|
||||
pipelinePreviewCount: pipelineSnapshotBody?.pipelines?.length ?? 0,
|
||||
runPreviewCount: pipelineSnapshotBody?.runs?.length ?? 0,
|
||||
firstRun: firstPipelineRun === undefined ? null : {
|
||||
runId: firstPipelineRun.runId,
|
||||
pipelineId: firstPipelineRun.pipelineId,
|
||||
status: firstPipelineRun.status,
|
||||
updatedAt: firstPipelineRun.updatedAt,
|
||||
},
|
||||
arrayLimits: pipelineSnapshotBody?._unidesk?.arrayLimits,
|
||||
},
|
||||
};
|
||||
addCheck(checks, "microservice:catalog-findjob", (microservices as { ok?: boolean }).ok === true && findjob?.providerId === "D601" && findjob.backend?.public === false, { microservices });
|
||||
addCheck(checks, "microservice:catalog-pipeline", (microservices as { ok?: boolean }).ok === true && pipeline?.providerId === "D601" && pipeline.backend?.public === false && pipeline.runtime?.container?.name === "pipeline-v2-webui", { microservices });
|
||||
addCheck(checks, "microservice:findjob-status", (findjobStatus as { ok?: boolean }).ok === true && (findjobStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", findjobStatus);
|
||||
addCheck(checks, "microservice:findjob-health", (findjobHealth as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (findjobHealth as { body?: { ok?: boolean } }).body?.ok === true, findjobHealth);
|
||||
addCheck(checks, "microservice:findjob-summary", (findjobSummary as { ok?: boolean }).ok === true && Number.isFinite(findjobSummaryBody?.totalJobs) && Number.isFinite(findjobSummaryBody?.prioritizedJobs), findjobSummary);
|
||||
addCheck(checks, "microservice:findjob-jobs-preview", (findjobJobsPreview as { ok?: boolean }).ok === true && Array.isArray(findjobJobs?.jobs) && (findjobJobs.jobs.length ?? 0) > 0 && (findjobJobs._unidesk?.arrayLimits?.jobs?.returnedLength ?? 0) <= 5, findjobJobsPreview);
|
||||
addCheck(checks, "microservice:pipeline-status", (pipelineStatus as { ok?: boolean }).ok === true && (pipelineStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", pipelineStatus);
|
||||
addCheck(checks, "microservice:pipeline-health", (pipelineHealth as { ok?: boolean; body?: { ok?: boolean; service?: string } }).ok === true && (pipelineHealth as { body?: { ok?: boolean } }).body?.ok === true, pipelineHealth);
|
||||
addCheck(checks, "microservice:pipeline-snapshot", (pipelineSnapshot as { ok?: boolean }).ok === true && pipelineSnapshotBody?.ok === true && pipelineSnapshotBody.registry?.ok === true && Array.isArray(pipelineSnapshotBody.registry.components) && pipelineSnapshotBody.registry.components.length > 0 && Array.isArray(pipelineSnapshotBody.pipelines) && pipelineSnapshotBody.pipelines.length > 0 && Array.isArray(pipelineSnapshotBody.runs) && pipelineSnapshotBody.runs.length > 0 && (pipelineSnapshotBody._unidesk?.arrayLimits?.["registry.components"]?.returnedLength ?? 999) <= 8 && (pipelineSnapshotBody._unidesk?.arrayLimits?.runs?.returnedLength ?? 999) <= 3, pipelineSnapshotDetail);
|
||||
const upgradeDispatch = dockerCoreJson("/api/dispatch", {
|
||||
method: "POST",
|
||||
body: { providerId: config.providerGateway.id, command: "provider.upgrade", payload: { source: "cli-e2e", mode: "plan" } },
|
||||
@@ -305,7 +361,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
await page.waitForSelector(`text=${config.providerGateway.name}`, { timeout: 10000 });
|
||||
await page.setViewportSize({ width: 390, height: 860 });
|
||||
const mobileRailHeights: number[] = [];
|
||||
for (const moduleLabel of ["运行总览", "资源节点", "任务调度", "系统配置"]) {
|
||||
for (const moduleLabel of ["运行总览", "资源节点", "任务调度", "微服务", "系统配置"]) {
|
||||
await page.getByRole("button", { name: new RegExp(moduleLabel) }).click();
|
||||
await page.waitForTimeout(80);
|
||||
const height = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().height));
|
||||
@@ -381,6 +437,41 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
const gatewayTextLower = gatewayText.toLowerCase();
|
||||
const sshAvailabilityTexts = await page.locator('[data-testid="gateway-version-page"] [data-testid^="ssh-availability-"]').evaluateAll((elements) => elements.map((element) => (element as HTMLElement).innerText));
|
||||
const upgradeAvailabilityTexts = await page.locator('[data-testid="gateway-version-page"] [data-testid^="upgrade-availability-"]').evaluateAll((elements) => elements.map((element) => (element as HTMLElement).innerText));
|
||||
await page.getByRole("button", { name: /微服务/ }).click();
|
||||
await page.waitForSelector('[data-testid="microservice-catalog-page"]', { timeout: 10000 });
|
||||
await page.waitForSelector('[data-testid="microservice-row-findjob"]', { timeout: 10000 });
|
||||
await page.waitForSelector('[data-testid="microservice-row-pipeline"]', { timeout: 10000 });
|
||||
const microserviceCatalogText = await page.locator('[data-testid="microservice-catalog-page"]').innerText({ timeout: 5000 });
|
||||
await page.getByRole("button", { name: /FindJob/ }).click();
|
||||
await page.waitForSelector('[data-testid="findjob-page"]', { timeout: 10000 });
|
||||
await page.waitForFunction(() => {
|
||||
const text = document.body.innerText.toLowerCase();
|
||||
const originalText = document.body.innerText;
|
||||
return text.includes("findjob 工作台".toLowerCase())
|
||||
&& text.includes("岗位总量")
|
||||
&& text.includes("d601")
|
||||
&& text.includes("近期岗位")
|
||||
&& /岗位总量\s+\d+/.test(originalText)
|
||||
&& /health\s+ok/i.test(originalText)
|
||||
&& /[1-9]\d*\/[1-9]\d*\s+preview/i.test(originalText);
|
||||
}, undefined, { timeout: 30000 });
|
||||
const findjobText = await page.locator('[data-testid="findjob-page"]').innerText({ timeout: 5000 });
|
||||
await page.getByRole("button", { name: /Pipeline/ }).click();
|
||||
await page.waitForSelector('[data-testid="pipeline-page"]', { timeout: 10000 });
|
||||
await page.waitForFunction(() => {
|
||||
const text = document.body.innerText;
|
||||
const lower = text.toLowerCase();
|
||||
return lower.includes("pipeline v2 工作台")
|
||||
&& text.includes("控制图")
|
||||
&& text.includes("最近运行")
|
||||
&& /Health\s+OK/i.test(text)
|
||||
&& /组件\s+\d+/.test(text)
|
||||
&& /运行记录\s+[1-9]\d*/.test(text);
|
||||
}, undefined, { timeout: 30000 });
|
||||
const pipelineText = await page.locator('[data-testid="pipeline-page"]').innerText({ timeout: 5000 });
|
||||
const microserviceCatalogTextLower = microserviceCatalogText.toLowerCase();
|
||||
const findjobTextLower = findjobText.toLowerCase();
|
||||
const pipelineTextLower = pipelineText.toLowerCase();
|
||||
addCheck(checks, "frontend:login-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && bodyText.includes("核心在线"), { screenshotPath });
|
||||
addCheck(checks, "frontend:public-provider-info-visible", publicFrontendReached && bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && rawText.includes('"status": "online"') && rawText.includes(`"providerId": "${config.providerGateway.id}"`), { frontendUrl: urls.frontendUrl, landedUrl, providerId: config.providerGateway.id, rawTextPreview: rawText.slice(0, 400) });
|
||||
addCheck(checks, "frontend:mobile-nav-fixed-height", mobileRailMax - mobileRailMin <= 1 && mobileRailMax <= 44, { mobileRailHeights });
|
||||
@@ -394,6 +485,9 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
addCheck(checks, "frontend:docker-status-visible", dockerText.toLowerCase().includes("docker desktop 视图") && dockerText.toLowerCase().includes("containers") && dockerText.includes("unidesk_pgdata_10gb") && (dockerText.includes("unidesk-frontend") || dockerText.includes("unidesk-backend-core")), { dockerTextPreview: dockerText.slice(0, 800) });
|
||||
addCheck(checks, "frontend:gateway-version-records-visible", gatewayTextLower.includes("provider gateway 版本") && gatewayText.includes("自动更新记录") && gatewayText.includes(config.providerGateway.id) && gatewayText.includes(`v${providerGatewayPackageVersion()}`) && gatewayText.includes("provider.upgrade"), { gatewayTextPreview: gatewayText.slice(0, 900) });
|
||||
addCheck(checks, "frontend:provider-operation-availability-visible", sshAvailabilityTexts.length >= 1 && upgradeAvailabilityTexts.length >= 1 && sshAvailabilityTexts.every((text) => text.includes("SSH 透传")) && upgradeAvailabilityTexts.every((text) => text.includes("远程更新")) && upgradeAvailabilityTexts.some((text) => text.includes("always-enabled")), { sshAvailabilityTexts, upgradeAvailabilityTexts });
|
||||
addCheck(checks, "frontend:microservice-catalog-visible", microserviceCatalogTextLower.includes("findjob") && microserviceCatalogTextLower.includes("pipeline") && microserviceCatalogText.includes("D601") && microserviceCatalogTextLower.includes("private") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/findjob") && microserviceCatalogText.includes("https://github.com/pikasTech/pipeline"), { microserviceCatalogPreview: microserviceCatalogText.slice(0, 1200) });
|
||||
addCheck(checks, "frontend:findjob-integrated-visible", findjobTextLower.includes("findjob 工作台".toLowerCase()) && findjobText.includes("岗位总量") && findjobText.includes("D601") && findjobText.includes("近期岗位") && findjobText.includes("仅 UniDesk frontend 代理访问") && /岗位总量\s+\d+/.test(findjobText) && /health\s+ok/i.test(findjobText) && /[1-9]\d*\/[1-9]\d*\s+preview/i.test(findjobText), { findjobTextPreview: findjobText.slice(0, 1200) });
|
||||
addCheck(checks, "frontend:pipeline-integrated-visible", pipelineTextLower.includes("pipeline v2 工作台".toLowerCase()) && pipelineText.includes("D601") && pipelineText.includes("控制图") && pipelineText.includes("最近运行") && pipelineText.includes("仅 UniDesk frontend 代理访问") && /Health\s+OK/i.test(pipelineText) && /组件\s+\d+/.test(pipelineText) && /运行记录\s+[1-9]\d*/.test(pipelineText), { pipelineTextPreview: pipelineText.slice(0, 1200) });
|
||||
addCheck(checks, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors });
|
||||
return { screenshotPath, bodyText, consoleErrors };
|
||||
} finally {
|
||||
@@ -409,11 +503,27 @@ export async function runE2E(config: UniDeskConfig): Promise<unknown> {
|
||||
const markerId = databaseChecks(config, checks);
|
||||
const frontend = await frontendCheck(config, urls, checks);
|
||||
const ok = checks.every((check) => check.status === "passed");
|
||||
return {
|
||||
const fullResult = {
|
||||
ok,
|
||||
urls,
|
||||
markerId,
|
||||
screenshotPath: frontend.screenshotPath,
|
||||
checks,
|
||||
};
|
||||
const resultPath = rootPath(".state", "e2e", `${markerId}_result.json`);
|
||||
writeFileSync(resultPath, `${JSON.stringify(fullResult, null, 2)}\n`, "utf8");
|
||||
return {
|
||||
ok,
|
||||
urls,
|
||||
markerId,
|
||||
screenshotPath: frontend.screenshotPath,
|
||||
resultPath,
|
||||
checkCounts: {
|
||||
total: checks.length,
|
||||
passed: checks.filter((check) => check.status === "passed").length,
|
||||
failed: checks.filter((check) => check.status === "failed").length,
|
||||
},
|
||||
checks: checks.map((check) => ({ name: check.name, status: check.status })),
|
||||
failedChecks: checks.filter((check) => check.status === "failed"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { runCommand } from "./command";
|
||||
import { type UniDeskConfig, repoRoot } from "./config";
|
||||
import { jsonByteLength, previewJson } from "./preview";
|
||||
|
||||
function coreInternalFetch(path: string, init?: { method?: string; body?: unknown }): unknown {
|
||||
if (!path.startsWith("/")) throw new Error("core internal path must start with /");
|
||||
const code = `
|
||||
const res = await fetch(${JSON.stringify(`http://127.0.0.1:8080${path}`)}, ${JSON.stringify({
|
||||
method: init?.method ?? "GET",
|
||||
headers: init?.body === undefined ? undefined : { "content-type": "application/json" },
|
||||
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
})});
|
||||
const text = await res.text();
|
||||
let body = null;
|
||||
try { body = text ? JSON.parse(text) : null; } catch { body = { text }; }
|
||||
console.log(JSON.stringify({ ok: res.ok, status: res.status, body }));
|
||||
`;
|
||||
const result = runCommand(["docker", "exec", "unidesk-backend-core", "bun", "-e", code], repoRoot);
|
||||
if (result.exitCode !== 0) {
|
||||
return { ok: false, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) };
|
||||
}
|
||||
try {
|
||||
return JSON.parse(result.stdout.trim()) as unknown;
|
||||
} catch {
|
||||
return { ok: true, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) };
|
||||
}
|
||||
}
|
||||
|
||||
function requireId(value: string | undefined, command: string): string {
|
||||
if (value === undefined || value.length === 0) throw new Error(`${command} requires microservice id`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireProxyPath(value: string | undefined): string {
|
||||
if (value === undefined || value.length === 0) throw new Error("microservice proxy requires upstream path, for example /api/summary");
|
||||
if (!value.startsWith("/")) throw new Error("microservice proxy upstream path must start with /");
|
||||
return value;
|
||||
}
|
||||
|
||||
function encodeId(value: string): string {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
function numberOption(args: string[], name: string, defaultValue: number): number {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) return defaultValue;
|
||||
const raw = args[index + 1];
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function summarizeMicroserviceProxyResponse(response: unknown, args: string[]): unknown {
|
||||
if (args.includes("--raw")) return response;
|
||||
const maxBodyBytes = numberOption(args, "--max-body-bytes", 60_000);
|
||||
if (typeof response !== "object" || response === null || Array.isArray(response)) return response;
|
||||
const record = response as Record<string, unknown>;
|
||||
if (!("body" in record)) return response;
|
||||
const bodyBytes = jsonByteLength(record.body);
|
||||
if (bodyBytes <= maxBodyBytes) return response;
|
||||
const rest = { ...record };
|
||||
delete rest.body;
|
||||
return {
|
||||
...rest,
|
||||
bodyOmitted: true,
|
||||
bodyBytes,
|
||||
bodyMaxBytes: maxBodyBytes,
|
||||
bodyPreview: previewJson(record.body, { maxDepth: 3, maxArrayItems: 3, maxObjectKeys: 16, maxStringLength: 320 }),
|
||||
rawHint: "Re-run with --raw for the full body, or add/tighten __unideskArrayLimit=<path>:<limit> in the proxied path.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function runMicroserviceCommand(_config: UniDeskConfig, args: string[]): Promise<unknown> {
|
||||
const [action = "list", idArg, pathArg] = args;
|
||||
if (action === "list") return coreInternalFetch("/api/microservices");
|
||||
if (action === "status") {
|
||||
const id = requireId(idArg, "microservice status");
|
||||
return coreInternalFetch(`/api/microservices/${encodeId(id)}/status`);
|
||||
}
|
||||
if (action === "health") {
|
||||
const id = requireId(idArg, "microservice health");
|
||||
return coreInternalFetch(`/api/microservices/${encodeId(id)}/health`);
|
||||
}
|
||||
if (action === "proxy") {
|
||||
const id = requireId(idArg, "microservice proxy");
|
||||
const path = requireProxyPath(pathArg);
|
||||
return summarizeMicroserviceProxyResponse(coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`), args);
|
||||
}
|
||||
throw new Error("microservice command must be one of: list, status, health, proxy");
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
export interface PreviewOptions {
|
||||
maxDepth?: number;
|
||||
maxArrayItems?: number;
|
||||
maxObjectKeys?: number;
|
||||
maxStringLength?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PREVIEW_OPTIONS: Required<PreviewOptions> = {
|
||||
maxDepth: 4,
|
||||
maxArrayItems: 6,
|
||||
maxObjectKeys: 32,
|
||||
maxStringLength: 1000,
|
||||
};
|
||||
|
||||
function optionsWithDefaults(options: PreviewOptions = {}): Required<PreviewOptions> {
|
||||
return {
|
||||
maxDepth: options.maxDepth ?? DEFAULT_PREVIEW_OPTIONS.maxDepth,
|
||||
maxArrayItems: options.maxArrayItems ?? DEFAULT_PREVIEW_OPTIONS.maxArrayItems,
|
||||
maxObjectKeys: options.maxObjectKeys ?? DEFAULT_PREVIEW_OPTIONS.maxObjectKeys,
|
||||
maxStringLength: options.maxStringLength ?? DEFAULT_PREVIEW_OPTIONS.maxStringLength,
|
||||
};
|
||||
}
|
||||
|
||||
export function jsonByteLength(value: unknown): number {
|
||||
try {
|
||||
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
||||
} catch {
|
||||
return Buffer.byteLength(String(value), "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
export function previewJson(value: unknown, options: PreviewOptions = {}, depth = 0): unknown {
|
||||
const limits = optionsWithDefaults(options);
|
||||
if (value === null || typeof value === "number" || typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
if (value.length <= limits.maxStringLength) return value;
|
||||
return {
|
||||
_previewType: "string",
|
||||
length: value.length,
|
||||
text: value.slice(0, limits.maxStringLength),
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
if (typeof value !== "object") return String(value);
|
||||
if (depth >= limits.maxDepth) {
|
||||
return {
|
||||
_previewType: Array.isArray(value) ? "array" : "object",
|
||||
truncated: true,
|
||||
...(Array.isArray(value) ? { length: value.length } : { keys: Object.keys(value as Record<string, unknown>).length }),
|
||||
};
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return {
|
||||
_previewType: "array",
|
||||
length: value.length,
|
||||
items: value.slice(0, limits.maxArrayItems).map((item) => previewJson(item, limits, depth + 1)),
|
||||
truncatedItems: Math.max(0, value.length - limits.maxArrayItems),
|
||||
};
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const entries = Object.entries(record);
|
||||
const preview: Record<string, unknown> = {};
|
||||
for (const [key, item] of entries.slice(0, limits.maxObjectKeys)) {
|
||||
preview[key] = previewJson(item, limits, depth + 1);
|
||||
}
|
||||
if (entries.length > limits.maxObjectKeys) {
|
||||
preview._preview = {
|
||||
truncatedKeys: entries.length - limits.maxObjectKeys,
|
||||
totalKeys: entries.length,
|
||||
};
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
export function boundedJsonDetail(value: unknown, maxBytes: number, options: PreviewOptions = {}): unknown {
|
||||
const originalBytes = jsonByteLength(value);
|
||||
if (originalBytes <= maxBytes) return value;
|
||||
return {
|
||||
_truncated: true,
|
||||
originalBytes,
|
||||
maxBytes,
|
||||
preview: previewJson(value, options),
|
||||
};
|
||||
}
|
||||
+29
-1
@@ -1,6 +1,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { type UniDeskConfig } from "./config";
|
||||
import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug";
|
||||
import { summarizeMicroserviceProxyResponse } from "./microservices";
|
||||
import { parseSshArgs } from "./ssh";
|
||||
|
||||
export interface RemoteCliOptions {
|
||||
@@ -354,6 +355,29 @@ async function remoteDebugTask(session: FrontendSession, args: string[]): Promis
|
||||
return { transport: "frontend", tasksResponse, taskId, task: task ?? null };
|
||||
}
|
||||
|
||||
async function remoteMicroservice(session: FrontendSession, args: string[]): Promise<unknown> {
|
||||
const action = args[1] ?? "list";
|
||||
const id = args[2];
|
||||
const path = args[3];
|
||||
if (action === "list") {
|
||||
return { transport: "frontend", response: await frontendJson(session, "/api/microservices", undefined, 12_000) };
|
||||
}
|
||||
if ((action === "status" || action === "health") && id !== undefined) {
|
||||
return {
|
||||
transport: "frontend",
|
||||
response: await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/${action}`, undefined, 18_000),
|
||||
};
|
||||
}
|
||||
if (action === "proxy" && id !== undefined && path !== undefined && path.startsWith("/")) {
|
||||
const response = await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/proxy${path}`, undefined, 24_000);
|
||||
return {
|
||||
transport: "frontend",
|
||||
response: summarizeMicroserviceProxyResponse(response, args),
|
||||
};
|
||||
}
|
||||
throw new Error("remote microservice command must be: microservice list | status <id> | health <id> | proxy <id> <path>");
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -399,7 +423,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>"],
|
||||
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "microservice list", "microservice status <id>", "microservice health <id>", "microservice proxy <id> <path>"],
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
@@ -415,6 +439,10 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
|
||||
emitRemoteJson(name, await remoteDebugTask(session, args));
|
||||
return 0;
|
||||
}
|
||||
if (top === "microservice") {
|
||||
emitRemoteJson(name, await remoteMicroservice(session, args));
|
||||
return 0;
|
||||
}
|
||||
if (top === "ssh") {
|
||||
return await runRemoteSshOverFrontend(session, sub, args.slice(2));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user