feat: add provider-backed microservices
This commit is contained in:
@@ -32,9 +32,45 @@ interface RuntimeConfig {
|
||||
providerToken: string;
|
||||
heartbeatTimeoutMs: number;
|
||||
taskPendingTimeoutMs: number;
|
||||
microservices: MicroserviceConfig[];
|
||||
logFile: string;
|
||||
}
|
||||
|
||||
interface MicroserviceConfig {
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
interface WsData {
|
||||
providerId?: string;
|
||||
channel?: "provider" | "ssh";
|
||||
@@ -86,6 +122,88 @@ function readOptionalNumberEnv(name: string, fallback: number): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown, name: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${name} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringFromRecord(value: Record<string, unknown>, key: string, path: string): string {
|
||||
const field = value[key];
|
||||
if (typeof field !== "string" || field.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function numberFromRecord(value: Record<string, unknown>, key: string, path: string): number {
|
||||
const field = value[key];
|
||||
if (typeof field !== "number" || !Number.isFinite(field) || field <= 0) throw new Error(`${path}.${key} must be a positive number`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function booleanFromRecord(value: Record<string, unknown>, key: string, path: string): boolean {
|
||||
const field = value[key];
|
||||
if (typeof field !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function stringArrayFromRecord(value: Record<string, unknown>, key: string, path: string): string[] {
|
||||
const field = value[key];
|
||||
if (!Array.isArray(field) || field.some((item) => typeof item !== "string" || item.length === 0)) {
|
||||
throw new Error(`${path}.${key} must be an array of non-empty strings`);
|
||||
}
|
||||
return field as string[];
|
||||
}
|
||||
|
||||
function parseMicroserviceConfig(value: unknown, index: number): MicroserviceConfig {
|
||||
const path = `MICROSERVICES_JSON[${index}]`;
|
||||
const item = asRecord(value, path);
|
||||
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: stringFromRecord(item, "id", path),
|
||||
name: stringFromRecord(item, "name", path),
|
||||
providerId: stringFromRecord(item, "providerId", path),
|
||||
description: stringFromRecord(item, "description", path),
|
||||
repository: {
|
||||
url: stringFromRecord(repository, "url", `${path}.repository`),
|
||||
commitId: stringFromRecord(repository, "commitId", `${path}.repository`),
|
||||
dockerfile: stringFromRecord(repository, "dockerfile", `${path}.repository`),
|
||||
composeFile: stringFromRecord(repository, "composeFile", `${path}.repository`),
|
||||
composeService: stringFromRecord(repository, "composeService", `${path}.repository`),
|
||||
containerName: stringFromRecord(repository, "containerName", `${path}.repository`),
|
||||
},
|
||||
backend: {
|
||||
nodeBaseUrl: stringFromRecord(backend, "nodeBaseUrl", `${path}.backend`),
|
||||
nodeBindHost: stringFromRecord(backend, "nodeBindHost", `${path}.backend`),
|
||||
nodePort: numberFromRecord(backend, "nodePort", `${path}.backend`),
|
||||
proxyMode: stringFromRecord(backend, "proxyMode", `${path}.backend`),
|
||||
frontendOnly: booleanFromRecord(backend, "frontendOnly", `${path}.backend`),
|
||||
public: booleanFromRecord(backend, "public", `${path}.backend`),
|
||||
allowedPathPrefixes: stringArrayFromRecord(backend, "allowedPathPrefixes", `${path}.backend`),
|
||||
healthPath: stringFromRecord(backend, "healthPath", `${path}.backend`),
|
||||
timeoutMs: numberFromRecord(backend, "timeoutMs", `${path}.backend`),
|
||||
},
|
||||
development: {
|
||||
providerId: stringFromRecord(development, "providerId", `${path}.development`),
|
||||
sshPassthrough: booleanFromRecord(development, "sshPassthrough", `${path}.development`),
|
||||
worktreePath: stringFromRecord(development, "worktreePath", `${path}.development`),
|
||||
},
|
||||
frontend: {
|
||||
route: stringFromRecord(frontend, "route", `${path}.frontend`),
|
||||
integrated: booleanFromRecord(frontend, "integrated", `${path}.frontend`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readMicroservicesEnv(): MicroserviceConfig[] {
|
||||
const raw = process.env.MICROSERVICES_JSON;
|
||||
if (raw === undefined || raw.length === 0) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) throw new Error("MICROSERVICES_JSON must be an array");
|
||||
return parsed.map(parseMicroserviceConfig);
|
||||
}
|
||||
|
||||
function readConfig(): RuntimeConfig {
|
||||
return {
|
||||
port: readNumberEnv("PORT"),
|
||||
@@ -94,6 +212,7 @@ function readConfig(): RuntimeConfig {
|
||||
providerToken: requiredEnv("PROVIDER_TOKEN"),
|
||||
heartbeatTimeoutMs: readNumberEnv("HEARTBEAT_TIMEOUT_MS"),
|
||||
taskPendingTimeoutMs: readOptionalNumberEnv("TASK_PENDING_TIMEOUT_MS", 10 * 60 * 1000),
|
||||
microservices: readMicroservicesEnv(),
|
||||
logFile: requiredEnv("LOG_FILE"),
|
||||
};
|
||||
}
|
||||
@@ -735,20 +854,54 @@ async function getOverview(): Promise<JsonValue> {
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchTask(req: Request): Promise<Response> {
|
||||
const body = (await req.json()) as { providerId?: unknown; command?: unknown; payload?: unknown };
|
||||
const providerId = typeof body.providerId === "string" ? body.providerId : "";
|
||||
const command = isProviderDispatchCommand(body.command) ? body.command : null;
|
||||
const payload = typeof body.payload === "object" && body.payload !== null ? (body.payload as Record<string, JsonValue>) : {};
|
||||
if (!providerId) {
|
||||
return jsonResponse({ ok: false, error: "providerId is required" }, 400);
|
||||
}
|
||||
if (command === null) {
|
||||
return jsonResponse({ ok: false, error: "command must be one of docker.ps, provider.upgrade, host.ssh, echo" }, 400);
|
||||
}
|
||||
if (command === "host.ssh" && !(await providerSupports(providerId, "host.ssh"))) {
|
||||
return jsonResponse({ ok: false, error: `provider does not declare host.ssh capability: ${providerId}` }, 409);
|
||||
}
|
||||
function microserviceById(id: string): MicroserviceConfig | null {
|
||||
return config.microservices.find((service) => service.id === id) ?? null;
|
||||
}
|
||||
|
||||
function dockerStatusRecord(status: JsonValue | null): Record<string, unknown> {
|
||||
return typeof status === "object" && status !== null && !Array.isArray(status) ? status as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function findContainer(status: JsonValue | null, containerName: string): JsonValue | null {
|
||||
const containers = dockerStatusRecord(status).containers;
|
||||
if (!Array.isArray(containers)) return null;
|
||||
const container = containers.find((item) => {
|
||||
if (typeof item !== "object" || item === null || Array.isArray(item)) return false;
|
||||
return (item as Record<string, unknown>).name === containerName;
|
||||
});
|
||||
return container === undefined ? null : container as JsonValue;
|
||||
}
|
||||
|
||||
async function getMicroservices(): Promise<JsonValue[]> {
|
||||
const nodes = await getNodes();
|
||||
const dockerStatuses = await getNodeDockerStatuses();
|
||||
return config.microservices.map((service) => {
|
||||
const node = nodes.find((item) => item.providerId === service.providerId) ?? null;
|
||||
const docker = dockerStatuses.find((item) => item.providerId === service.providerId) ?? null;
|
||||
const container = findContainer(docker?.dockerStatus ?? null, service.repository.containerName);
|
||||
return {
|
||||
...service,
|
||||
runtime: {
|
||||
providerStatus: node?.status ?? "missing",
|
||||
providerName: node?.name ?? service.providerId,
|
||||
providerLastHeartbeat: node?.lastHeartbeat ?? null,
|
||||
container,
|
||||
backendPortMapping: {
|
||||
providerId: service.providerId,
|
||||
node: `${service.backend.nodeBindHost}:${service.backend.nodePort}`,
|
||||
mainServerAccess: "frontend-only backend proxy",
|
||||
public: service.backend.public,
|
||||
},
|
||||
},
|
||||
} satisfies JsonValue;
|
||||
});
|
||||
}
|
||||
|
||||
async function createAndSendTask(
|
||||
providerId: string,
|
||||
command: CoreDispatchMessage["command"],
|
||||
payload: Record<string, JsonValue>,
|
||||
): Promise<{ taskId: string; providerOnline: boolean }> {
|
||||
const taskId = `task_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
await sql`
|
||||
INSERT INTO unidesk_tasks (id, provider_id, command, status, payload, result)
|
||||
@@ -757,7 +910,7 @@ async function dispatchTask(req: Request): Promise<Response> {
|
||||
const socket = activeProviders.get(providerId);
|
||||
if (!socket) {
|
||||
await recordEvent("task_queued_provider_offline", providerId, { taskId, providerId, command });
|
||||
return jsonResponse({ ok: true, taskId, status: "queued", providerOnline: false });
|
||||
return { taskId, providerOnline: false };
|
||||
}
|
||||
const dispatch: CoreDispatchMessage = { type: "dispatch", taskId, command, payload };
|
||||
socket.send(JSON.stringify(dispatch));
|
||||
@@ -767,7 +920,127 @@ async function dispatchTask(req: Request): Promise<Response> {
|
||||
WHERE id = ${taskId} AND status = 'queued'
|
||||
`;
|
||||
await recordEvent("task_dispatched", providerId, { taskId, providerId, command });
|
||||
return jsonResponse({ ok: true, taskId, status: "dispatched", providerOnline: true });
|
||||
return { taskId, providerOnline: true };
|
||||
}
|
||||
|
||||
async function rawTask(taskId: string): Promise<{ id: string; provider_id: string; command: string; status: string; payload: JsonValue; result: JsonValue | null; updated_at: Date | string } | null> {
|
||||
const rows = await sql<Array<{ id: string; provider_id: string; command: string; status: string; payload: JsonValue; result: JsonValue | null; updated_at: Date | string }>>`
|
||||
SELECT id, provider_id, command, status, payload, result, updated_at
|
||||
FROM unidesk_tasks
|
||||
WHERE id = ${taskId}
|
||||
LIMIT 1
|
||||
`;
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function waitForTaskTerminal(taskId: string, timeoutMs: number): Promise<ReturnType<typeof rawTask> extends Promise<infer T> ? T : never> {
|
||||
const started = Date.now();
|
||||
let latest = await rawTask(taskId);
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
latest = await rawTask(taskId);
|
||||
if (latest !== null && (latest.status === "succeeded" || latest.status === "failed")) return latest;
|
||||
await Bun.sleep(200);
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function isMicroservicePathAllowed(service: MicroserviceConfig, path: string): boolean {
|
||||
return service.backend.allowedPathPrefixes.some((prefix) => path === prefix || path.startsWith(prefix));
|
||||
}
|
||||
|
||||
function readMicroserviceArrayLimits(url: URL): { query: string; jsonArrayLimits: Record<string, JsonValue> } {
|
||||
const params = new URLSearchParams(url.searchParams);
|
||||
const jsonArrayLimits: Record<string, JsonValue> = {};
|
||||
const rawLimits = params.getAll("__unideskArrayLimit");
|
||||
params.delete("__unideskArrayLimit");
|
||||
for (const raw of rawLimits) {
|
||||
for (const entry of raw.split(",")) {
|
||||
const [path = "", limitText = ""] = entry.split(":");
|
||||
if (!/^[A-Za-z0-9_.-]+$/.test(path)) continue;
|
||||
const limit = Number(limitText);
|
||||
if (Number.isInteger(limit) && limit > 0 && limit <= 500) jsonArrayLimits[path] = limit;
|
||||
}
|
||||
}
|
||||
const search = params.toString();
|
||||
return { query: search.length > 0 ? `?${search}` : "", jsonArrayLimits };
|
||||
}
|
||||
|
||||
function responseFromMicroserviceResult(task: Awaited<ReturnType<typeof rawTask>>): Response {
|
||||
if (task === null) return jsonResponse({ ok: false, error: "microservice proxy task missing" }, 502);
|
||||
if (task.status !== "succeeded") return jsonResponse({ ok: false, error: "microservice proxy task failed", task }, 502);
|
||||
const result = dockerStatusRecord(task.result);
|
||||
const status = Number(result.status);
|
||||
const contentType = typeof result.contentType === "string" ? result.contentType : "application/json; charset=utf-8";
|
||||
const bodyText = typeof result.bodyText === "string" ? result.bodyText : "";
|
||||
if (!Number.isInteger(status) || status < 100 || status > 599) {
|
||||
return jsonResponse({ ok: false, error: "microservice proxy returned invalid upstream status", task }, 502);
|
||||
}
|
||||
return new Response(bodyText, {
|
||||
status,
|
||||
headers: { "content-type": contentType },
|
||||
});
|
||||
}
|
||||
|
||||
async function microserviceRoute(req: Request, url: URL): Promise<Response> {
|
||||
const rest = url.pathname.slice("/api/microservices/".length);
|
||||
const slashIndex = rest.indexOf("/");
|
||||
const serviceId = decodeURIComponent(slashIndex === -1 ? rest : rest.slice(0, slashIndex));
|
||||
const suffix = slashIndex === -1 ? "" : rest.slice(slashIndex + 1);
|
||||
const service = microserviceById(serviceId);
|
||||
if (service === null) return jsonResponse({ ok: false, error: `microservice not found: ${serviceId}` }, 404);
|
||||
if (suffix === "" || suffix === "status") return jsonResponse({ ok: true, microservice: (await getMicroservices()).find((item) => recordValue(item, "id") === serviceId) ?? service });
|
||||
if (req.method !== "GET" && req.method !== "HEAD") return jsonResponse({ ok: false, error: "microservice frontend proxy only supports GET/HEAD" }, 405);
|
||||
|
||||
const proxyPrefix = "proxy";
|
||||
const targetPath = suffix === "health"
|
||||
? service.backend.healthPath
|
||||
: suffix === proxyPrefix
|
||||
? "/"
|
||||
: suffix.startsWith(`${proxyPrefix}/`)
|
||||
? `/${suffix.slice(proxyPrefix.length + 1)}`
|
||||
: "";
|
||||
if (targetPath.length === 0) return jsonResponse({ ok: false, error: "microservice route must be /status, /health, or /proxy/<path>" }, 404);
|
||||
if (!isMicroservicePathAllowed(service, targetPath)) {
|
||||
return jsonResponse({ ok: false, error: "microservice path is not allowed", serviceId, targetPath }, 403);
|
||||
}
|
||||
if (!(await providerSupports(service.providerId, "microservice.http"))) {
|
||||
return jsonResponse({ ok: false, error: `provider does not declare microservice.http capability: ${service.providerId}` }, 409);
|
||||
}
|
||||
const proxyOptions = readMicroserviceArrayLimits(url);
|
||||
const { taskId, providerOnline } = await createAndSendTask(service.providerId, "microservice.http", {
|
||||
source: "microservice-frontend-proxy",
|
||||
serviceId: service.id,
|
||||
method: req.method,
|
||||
targetBaseUrl: service.backend.nodeBaseUrl,
|
||||
path: targetPath,
|
||||
query: proxyOptions.query,
|
||||
jsonArrayLimits: proxyOptions.jsonArrayLimits,
|
||||
timeoutMs: service.backend.timeoutMs,
|
||||
});
|
||||
if (!providerOnline) return jsonResponse({ ok: false, error: `provider is offline: ${service.providerId}`, taskId }, 503);
|
||||
const task = await waitForTaskTerminal(taskId, service.backend.timeoutMs + 3000);
|
||||
return responseFromMicroserviceResult(task);
|
||||
}
|
||||
|
||||
async function dispatchTask(req: Request): Promise<Response> {
|
||||
const body = (await req.json()) as { providerId?: unknown; command?: unknown; payload?: unknown };
|
||||
const providerId = typeof body.providerId === "string" ? body.providerId : "";
|
||||
const command = isProviderDispatchCommand(body.command) ? body.command : null;
|
||||
const payload = typeof body.payload === "object" && body.payload !== null ? (body.payload as Record<string, JsonValue>) : {};
|
||||
if (!providerId) {
|
||||
return jsonResponse({ ok: false, error: "providerId is required" }, 400);
|
||||
}
|
||||
if (command === null) {
|
||||
return jsonResponse({ ok: false, error: "command must be one of docker.ps, provider.upgrade, host.ssh, microservice.http, echo" }, 400);
|
||||
}
|
||||
if (command === "host.ssh" && !(await providerSupports(providerId, "host.ssh"))) {
|
||||
return jsonResponse({ ok: false, error: `provider does not declare host.ssh capability: ${providerId}` }, 409);
|
||||
}
|
||||
if (command === "microservice.http" && !(await providerSupports(providerId, "microservice.http"))) {
|
||||
return jsonResponse({ ok: false, error: `provider does not declare microservice.http capability: ${providerId}` }, 409);
|
||||
}
|
||||
const { taskId, providerOnline } = await createAndSendTask(providerId, command, payload);
|
||||
return jsonResponse({ ok: true, taskId, status: providerOnline ? "dispatched" : "queued", providerOnline });
|
||||
}
|
||||
|
||||
function numberFromUnknown(value: unknown, fallback: number, min: number, max: number): number {
|
||||
@@ -889,6 +1162,8 @@ async function route(req: Request, server: Server<WsData>): Promise<Response | u
|
||||
if (url.pathname === "/api/nodes/docker-status") return jsonResponse({ ok: true, dockerStatuses: await getNodeDockerStatuses() });
|
||||
if (url.pathname === "/api/events") return jsonResponse({ ok: true, events: await getEvents(readLimit(url, 100)) });
|
||||
if (url.pathname === "/api/tasks") return jsonResponse({ ok: true, tasks: await getTasks(readLimit(url, 100), url.searchParams.get("status") ?? "all") });
|
||||
if (url.pathname === "/api/microservices") return jsonResponse({ ok: true, microservices: await getMicroservices() });
|
||||
if (url.pathname.startsWith("/api/microservices/")) return microserviceRoute(req, url);
|
||||
if (url.pathname === "/api/dispatch" && req.method === "POST") return dispatchTask(req);
|
||||
if (url.pathname === "/logs") return jsonResponse({ ok: true, logs: recentLogs.slice(-readLimit(url, 100)) });
|
||||
if (url.pathname === "/favicon.ico") return textResponse("", 204);
|
||||
|
||||
@@ -401,6 +401,8 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
.status-badge.offline, .status-badge.failed { color: var(--danger); border-color: rgba(207, 106, 84, 0.45); }
|
||||
.status-badge.running, .status-badge.dispatched, .status-badge.accepted, .status-badge.internal { color: var(--accent-2); border-color: rgba(78, 183, 168, 0.45); }
|
||||
.status-badge.queued, .status-badge.warn { color: var(--warn); border-color: rgba(215, 161, 58, 0.45); }
|
||||
.status-badge.private, .status-badge.p1, .status-badge.prioritized, .status-badge.verified { color: var(--accent-2); border-color: rgba(78, 183, 168, 0.45); }
|
||||
.status-badge.stale, .status-badge.invalid, .status-badge.abandoned { color: var(--warn); border-color: rgba(215, 161, 58, 0.45); }
|
||||
|
||||
.docker-page {
|
||||
display: grid;
|
||||
@@ -837,6 +839,8 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 760px; }
|
||||
.node-list-table { min-width: 1180px; }
|
||||
.task-history-table { min-width: 1080px; }
|
||||
.microservice-table { min-width: 1320px; }
|
||||
.findjob-job-table table { min-width: 1180px; }
|
||||
th, td {
|
||||
padding: 7px 9px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
@@ -961,6 +965,117 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
.result-card dd { margin: 0; }
|
||||
.result-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||
|
||||
.microservice-page, .findjob-page, .pipeline-page {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.microservice-actions, .inline-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.findjob-hero, .pipeline-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1.4fr) minmax(260px, 0.9fr) minmax(220px, 0.7fr);
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.microservice-ref-card {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: var(--panel-3);
|
||||
}
|
||||
.microservice-ref-card span {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.microservice-ref-card strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.findjob-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 0.9fr) minmax(560px, 1.35fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.findjob-grid .panel:nth-child(3) { grid-column: 1 / -1; }
|
||||
.pipeline-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 0.9fr) minmax(520px, 1.25fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5) { grid-column: 1 / -1; }
|
||||
.pipeline-node-table table { min-width: 1080px; }
|
||||
.component-strata {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
|
||||
gap: 7px;
|
||||
}
|
||||
.component-stratum, .pipeline-run-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 9px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: var(--panel-3);
|
||||
}
|
||||
.component-stratum span {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.13em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.component-stratum strong {
|
||||
color: var(--accent-2);
|
||||
font-size: 18px;
|
||||
}
|
||||
.pipeline-component-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.pipeline-run-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.pipeline-log-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.pipeline-log-list code {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 5px 7px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: rgba(0,0,0,0.18);
|
||||
color: #b9d5d8;
|
||||
}
|
||||
.draft-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.draft-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 9px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: var(--panel-3);
|
||||
}
|
||||
|
||||
.endpoint-list article { grid-template-columns: 150px minmax(220px, 1fr) auto; }
|
||||
.policy-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.policy-grid article { grid-template-columns: 1fr; align-items: start; }
|
||||
@@ -1051,7 +1166,8 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
.metric-grid, .policy-grid, .security-board, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.dispatch-form { grid-template-columns: 1fr 1fr; }
|
||||
.dispatch-actions { align-items: center; }
|
||||
.page-grid, .docker-layout, .monitor-layout { grid-template-columns: 1fr; }
|
||||
.page-grid, .docker-layout, .monitor-layout, .findjob-grid, .findjob-hero, .pipeline-grid, .pipeline-hero { grid-template-columns: 1fr; }
|
||||
.findjob-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5) { grid-column: 1; }
|
||||
.gateway-record-grid { grid-template-columns: 1fr; }
|
||||
.overview-grid .panel:nth-child(n+3), .dispatch-grid .panel:first-child, .topology-grid .panel:nth-child(3) { grid-column: 1; }
|
||||
}
|
||||
@@ -1149,6 +1265,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
white-space: nowrap;
|
||||
}
|
||||
.metric-grid, .policy-grid, .security-board, .dispatch-form, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid, .gateway-record-grid { grid-template-columns: 1fr; }
|
||||
.compact-row, .heartbeat-row, .log-row, .endpoint-list article, .volume-route { grid-template-columns: 1fr; align-items: start; }
|
||||
.compact-row, .heartbeat-row, .log-row, .endpoint-list article, .volume-route, .findjob-hero, .pipeline-hero { grid-template-columns: 1fr; align-items: start; }
|
||||
.docker-hero, .monitor-hero { flex-direction: column; }
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ const MODULES = [
|
||||
{ id: "history", label: "任务历史" },
|
||||
{ id: "results", label: "执行结果" },
|
||||
] },
|
||||
{ id: "apps", label: "微服务", code: "APP", tabs: [
|
||||
{ id: "catalog", label: "服务目录" },
|
||||
{ id: "findjob", label: "FindJob" },
|
||||
{ id: "pipeline", label: "Pipeline" },
|
||||
] },
|
||||
{ id: "config", label: "系统配置", code: "CFG", tabs: [
|
||||
{ id: "topology", label: "连接拓扑" },
|
||||
{ id: "auth", label: "认证策略" },
|
||||
@@ -147,6 +152,14 @@ function summarizeValue(value: any): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function summarizeFieldValue(key: string, value: any): string {
|
||||
if (key === "bodyText" && typeof value === "string") {
|
||||
const kind = /^\s*[{[]/.test(value) ? "JSON" : "HTTP";
|
||||
return `${kind} body ${value.length} chars`;
|
||||
}
|
||||
return summarizeValue(value);
|
||||
}
|
||||
|
||||
function objectEntries(value: any): Array<[string, any]> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
||||
return Object.entries(value);
|
||||
@@ -260,6 +273,75 @@ function latestScheduledUpgradeTask(records: any[]): any | null {
|
||||
return records.find((task) => taskUpgradeMode(task) === "schedule") || records[0] || null;
|
||||
}
|
||||
|
||||
function microserviceRuntime(service: any): AnyRecord {
|
||||
return service?.runtime && typeof service.runtime === "object" && !Array.isArray(service.runtime) ? service.runtime : {};
|
||||
}
|
||||
|
||||
function microserviceBackend(service: any): AnyRecord {
|
||||
return service?.backend && typeof service.backend === "object" && !Array.isArray(service.backend) ? service.backend : {};
|
||||
}
|
||||
|
||||
function microserviceRepository(service: any): AnyRecord {
|
||||
return service?.repository && typeof service.repository === "object" && !Array.isArray(service.repository) ? service.repository : {};
|
||||
}
|
||||
|
||||
function findjobSummaryMetric(summary: any, key: string): string {
|
||||
const value = summary && typeof summary === "object" ? summary[key] : undefined;
|
||||
return Number.isFinite(Number(value)) ? String(value) : "--";
|
||||
}
|
||||
|
||||
function findjobJobRows(data: any): any[] {
|
||||
const rows = Array.isArray(data?.jobs) ? data.jobs : [];
|
||||
return rows.slice(0, 40);
|
||||
}
|
||||
|
||||
function findjobDraftRows(data: any): any[] {
|
||||
const rows = Array.isArray(data?.drafts) ? data.drafts : [];
|
||||
return rows.slice(0, 12);
|
||||
}
|
||||
|
||||
function pipelineSnapshotArrays(snapshot: any): { components: any[]; pipelines: any[]; runs: any[] } {
|
||||
return {
|
||||
components: Array.isArray(snapshot?.registry?.components) ? snapshot.registry.components : [],
|
||||
pipelines: Array.isArray(snapshot?.pipelines) ? snapshot.pipelines : [],
|
||||
runs: Array.isArray(snapshot?.runs) ? snapshot.runs : [],
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineArrayCount(snapshot: any, path: string, fallback: number): number {
|
||||
const meta = snapshot?._unidesk?.arrayLimits?.[path];
|
||||
const original = Number(meta?.originalLength);
|
||||
return Number.isFinite(original) ? original : fallback;
|
||||
}
|
||||
|
||||
function pipelineComponentRef(value: any): string {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return "--";
|
||||
return `${value.componentClass || "--"}/${value.id || "--"}`;
|
||||
}
|
||||
|
||||
function pipelineRunNodeStatus(run: any, nodeId: string): string {
|
||||
const nodes = Array.isArray(run?.nodes) ? run.nodes : [];
|
||||
const node = nodes.find((item: any) => item?.nodeId === nodeId || item?.id === nodeId);
|
||||
return String(node?.status || "pending");
|
||||
}
|
||||
|
||||
function pipelineStatusCounts(runs: any[]): AnyRecord {
|
||||
return runs.reduce((counts: AnyRecord, run: any) => {
|
||||
const status = String(run?.status || "unknown").toLowerCase();
|
||||
counts[status] = (counts[status] || 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function pipelineComponentClassCounts(components: any[]): Array<{ name: string; count: number }> {
|
||||
const counts = components.reduce((memo: AnyRecord, component: any) => {
|
||||
const name = String(component?.componentClass || "unknown");
|
||||
memo[name] = (memo[name] || 0) + 1;
|
||||
return memo;
|
||||
}, {});
|
||||
return Object.entries(counts).map(([name, count]) => ({ name, count: Number(count) })).sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
async function requestJson(path: string, options: AnyRecord = {}): Promise<any> {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (options.body && !headers.has("content-type")) headers.set("content-type", "application/json");
|
||||
@@ -387,7 +469,7 @@ function DataSummary({ data, empty = "无数据" }: AnyRecord) {
|
||||
const entries = Object.entries(data).slice(0, 5);
|
||||
if (entries.length === 0) return h("span", { className: "muted" }, empty);
|
||||
return h("div", { className: "summary-grid" }, entries.map(([key, value]) =>
|
||||
h("span", { key, className: "summary-item" }, h("b", null, key), h("span", null, summarizeValue(value))),
|
||||
h("span", { key, className: "summary-item" }, h("b", null, key), h("span", null, summarizeFieldValue(key, value))),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1031,6 +1113,291 @@ function DockerSidePanel({ title, items, render, limit }: AnyRecord) {
|
||||
);
|
||||
}
|
||||
|
||||
function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord) {
|
||||
const privateServices = microservices.filter((service: any) => microserviceBackend(service).public === false);
|
||||
return h("div", { className: "microservice-page", "data-testid": "microservice-catalog-page" },
|
||||
h(Panel, { title: "Microservice 目录", eyebrow: "Provider Mounted Backends" },
|
||||
h("div", { className: "metric-grid" },
|
||||
h(MetricCard, { label: "服务总数", value: microservices.length, hint: "config.json microservices" }),
|
||||
h(MetricCard, { label: "私有后端", value: privateServices.length, hint: "不直接暴露公网", tone: "ok" }),
|
||||
h(MetricCard, { label: "D601 服务", value: microservices.filter((service: any) => service.providerId === "D601").length, hint: "compute-node docker" }),
|
||||
h(MetricCard, { label: "集成前端", value: microservices.filter((service: any) => service.frontend?.integrated).length, hint: "UniDesk React 页面" }),
|
||||
),
|
||||
),
|
||||
h(Panel, { title: "服务映射", eyebrow: "Repo Reference + Runtime" },
|
||||
microservices.length === 0 ? h(EmptyState, { title: "暂无 Microservice", text: "在 config.json 的 microservices 中登记 provider、仓库引用和后端映射" }) :
|
||||
h("div", { className: "table-wrap" }, h("table", { className: "microservice-table" },
|
||||
h("thead", null, h("tr", null, h("th", null, "服务"), h("th", null, "Provider"), h("th", null, "代码引用"), h("th", null, "Docker 引用"), h("th", null, "后端映射"), h("th", null, "开发入口"), h("th", null, "运行态"), h("th", null, "操作"))),
|
||||
h("tbody", null, microservices.map((service: any) => {
|
||||
const runtime = microserviceRuntime(service);
|
||||
const repository = microserviceRepository(service);
|
||||
const backend = microserviceBackend(service);
|
||||
return h("tr", { key: service.id, "data-testid": `microservice-row-${safeId(service.id)}` },
|
||||
h("td", null, h("strong", null, service.name), h("code", null, service.id)),
|
||||
h("td", null, h("strong", null, runtime.providerName || service.providerId), h("code", null, service.providerId)),
|
||||
h("td", null, h("span", null, repository.url || "--"), h("code", null, repository.commitId || "--")),
|
||||
h("td", null, h("span", null, repository.composeFile || "--"), h("code", null, `${repository.composeService || "--"} / ${repository.containerName || "--"}`)),
|
||||
h("td", null,
|
||||
h(StatusBadge, { status: backend.public ? "warn" : "online" }, backend.public ? "public" : "private"),
|
||||
h("code", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"} -> ${backend.proxyMode || "--"}`),
|
||||
),
|
||||
h("td", null, h("span", null, service.development?.sshPassthrough ? "SSH 透传" : "未配置"), h("code", null, service.development?.worktreePath || "--")),
|
||||
h("td", null, h(StatusBadge, { status: runtime.providerStatus === "online" ? "online" : "warn" }, runtime.providerStatus || "unknown"), h(DataSummary, { data: runtime.container, empty: "容器快照未上报" })),
|
||||
h("td", null,
|
||||
h("div", { className: "microservice-actions" },
|
||||
service.id === "findjob" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "findjob"), "data-testid": "open-findjob-button" }, "打开") : null,
|
||||
service.id === "pipeline" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "pipeline"), "data-testid": "open-pipeline-button" }, "打开") : null,
|
||||
h(RawButton, { title: `Microservice ${service.id}`, data: service, onOpen: onRaw }),
|
||||
),
|
||||
),
|
||||
);
|
||||
})),
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function FindJobPage({ microservices, onRaw }: AnyRecord) {
|
||||
const service = microservices.find((item: any) => item.id === "findjob") || null;
|
||||
const [state, setState] = useState({ loading: false, error: "", health: null, summary: null, jobs: null, drafts: null, refreshedAt: null });
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (!service) return;
|
||||
setState((prev: any) => ({ ...prev, loading: true, error: "" }));
|
||||
try {
|
||||
const [health, summary, jobs, drafts] = await Promise.all([
|
||||
requestJson(`${cfg.apiBaseUrl}/microservices/findjob/health`),
|
||||
requestJson(`${cfg.apiBaseUrl}/microservices/findjob/proxy/api/summary`),
|
||||
requestJson(`${cfg.apiBaseUrl}/microservices/findjob/proxy/api/jobs?__unideskArrayLimit=jobs:40`),
|
||||
requestJson(`${cfg.apiBaseUrl}/microservices/findjob/proxy/api/drafts`),
|
||||
]);
|
||||
setState({ loading: false, error: "", health, summary, jobs, drafts, refreshedAt: new Date() });
|
||||
} catch (err) {
|
||||
setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "FindJob 加载失败") }));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [service?.id, service?.runtime?.providerStatus]);
|
||||
|
||||
if (!service) return h(EmptyState, { title: "FindJob 未登记", text: "请在 config.json 的 microservices 中登记 id=findjob" });
|
||||
|
||||
const runtime = microserviceRuntime(service);
|
||||
const repository = microserviceRepository(service);
|
||||
const backend = microserviceBackend(service);
|
||||
const summary = state.summary || {};
|
||||
const jobs = findjobJobRows(state.jobs);
|
||||
const drafts = findjobDraftRows(state.drafts);
|
||||
const transform = state.jobs?._unidesk?.arrayLimits?.jobs;
|
||||
return h("div", { className: "findjob-page", "data-testid": "findjob-page" },
|
||||
h(Panel, {
|
||||
title: "FindJob 工作台",
|
||||
eyebrow: "D601 Backend Microservice",
|
||||
actions: h("div", { className: "panel-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "findjob-refresh-button" }, state.loading ? "刷新中" : "刷新"),
|
||||
h(RawButton, { title: "FindJob Microservice", data: service, onOpen: onRaw, testId: "raw-findjob-service" }),
|
||||
),
|
||||
},
|
||||
h("div", { className: "findjob-hero" },
|
||||
h("div", null,
|
||||
h("div", { className: "node-version-line" },
|
||||
h(StatusBadge, { status: runtime.providerStatus === "online" ? "online" : "warn" }, runtime.providerStatus || "unknown"),
|
||||
h("span", null, service.providerId),
|
||||
h("span", null, backend.public ? "公网暴露" : "仅 UniDesk frontend 代理访问"),
|
||||
),
|
||||
h("p", { className: "muted paragraph" }, service.description),
|
||||
),
|
||||
h("div", { className: "microservice-ref-card" },
|
||||
h("span", null, "Repo"),
|
||||
h("strong", null, repository.url || "--"),
|
||||
h("code", null, repository.commitId || "--"),
|
||||
),
|
||||
h("div", { className: "microservice-ref-card" },
|
||||
h("span", null, "D601 Docker"),
|
||||
h("strong", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`),
|
||||
h("code", null, `${repository.composeFile || "--"} / ${repository.composeService || "--"}`),
|
||||
),
|
||||
),
|
||||
state.error ? h("div", { className: "form-error wide" }, state.error) : null,
|
||||
),
|
||||
h("div", { className: "findjob-grid" },
|
||||
h(Panel, { title: "岗位指标", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Summary" },
|
||||
h("div", { className: "metric-grid" },
|
||||
h(MetricCard, { label: "岗位总量", value: findjobSummaryMetric(summary, "totalJobs"), hint: "tracked jobs", tone: "ok" }),
|
||||
h(MetricCard, { label: "原始岗位", value: findjobSummaryMetric(summary, "rawJobs"), hint: "raw queue" }),
|
||||
h(MetricCard, { label: "已验证", value: findjobSummaryMetric(summary, "verifiedJobs"), hint: "verified set" }),
|
||||
h(MetricCard, { label: "优先处理", value: findjobSummaryMetric(summary, "prioritizedJobs"), hint: "prioritized" }),
|
||||
h(MetricCard, { label: "过期", value: findjobSummaryMetric(summary, "staleJobs"), hint: "stale jobs", tone: "warn" }),
|
||||
h(MetricCard, { label: "无效", value: findjobSummaryMetric(summary, "invalidJobs"), hint: "invalid jobs", tone: "warn" }),
|
||||
h(MetricCard, { label: "上海", value: findjobSummaryMetric(summary, "shanghaiJobs"), hint: "city filter" }),
|
||||
h(MetricCard, { label: "Health", value: state.health?.ok ? "OK" : "--", hint: "D601 /api/health" }),
|
||||
),
|
||||
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "FindJob Summary", data: summary, onOpen: onRaw, testId: "raw-findjob-summary" })),
|
||||
),
|
||||
h(Panel, { title: "近期岗位", eyebrow: transform ? `${transform.returnedLength}/${transform.originalLength} Preview` : `${jobs.length} Preview` },
|
||||
jobs.length === 0 ? h(EmptyState, { title: "暂无岗位预览", text: "等待 D601 findjob backend 返回 /api/jobs" }) :
|
||||
h("div", { className: "table-wrap findjob-job-table" }, h("table", null,
|
||||
h("thead", null, h("tr", null, h("th", null, "优先级"), h("th", null, "状态"), h("th", null, "单位"), h("th", null, "职位"), h("th", null, "城市"), h("th", null, "阶段"), h("th", null, "截止"), h("th", null, "证据"))),
|
||||
h("tbody", null, jobs.map((job: any) => h("tr", { key: job.id },
|
||||
h("td", null, h(StatusBadge, { status: String(job.priority || "").toLowerCase() || "unknown" }, job.priority || "--")),
|
||||
h("td", null, h(StatusBadge, { status: String(job.status || "").toLowerCase() || "unknown" }, job.status || "--")),
|
||||
h("td", null, job.organization_name || "--", h("code", null, job.id || "--")),
|
||||
h("td", null, job.display_title || job.title || "--"),
|
||||
h("td", null, job.display_city || job.city || "--"),
|
||||
h("td", null, job.workflow_stage || "--"),
|
||||
h("td", null, job.deadline || "--"),
|
||||
h("td", null, job.evidence_url ? h("a", { href: job.evidence_url, target: "_blank", rel: "noreferrer" }, "打开") : h("span", { className: "muted" }, "无")),
|
||||
))),
|
||||
)),
|
||||
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "FindJob Jobs Preview", data: state.jobs, onOpen: onRaw, testId: "raw-findjob-jobs" })),
|
||||
),
|
||||
h(Panel, { title: "草稿与报告", eyebrow: `${drafts.length} Drafts` },
|
||||
drafts.length === 0 ? h(EmptyState, { title: "暂无草稿", text: "D601 findjob backend 未返回 drafts" }) :
|
||||
h("div", { className: "draft-list" }, drafts.map((draft: any) => h("article", { key: draft.id, className: "draft-card" },
|
||||
h("div", { className: "node-card-head" }, h("strong", null, draft.id), h(StatusBadge, { status: draft.status }, draft.status || "--")),
|
||||
h("div", { className: "docker-meta compact" },
|
||||
h("span", null, draft.workflow_stage || "--"),
|
||||
h("span", null, `jobs ${draft.counts?.jobs ?? 0}`),
|
||||
h("span", null, `reports ${draft.counts?.reports ?? 0}`),
|
||||
),
|
||||
h("span", null, draft.latestReportPath || "暂无报告"),
|
||||
h("code", null, fmtDate(draft.updated_at || draft.updatedAt)),
|
||||
))),
|
||||
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "FindJob Drafts", data: state.drafts, onOpen: onRaw, testId: "raw-findjob-drafts" })),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function PipelinePage({ microservices, onRaw }: AnyRecord) {
|
||||
const service = microservices.find((item: any) => item.id === "pipeline") || null;
|
||||
const [state, setState] = useState({ loading: false, error: "", health: null, snapshot: null, refreshedAt: null });
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (!service) return;
|
||||
setState((prev: any) => ({ ...prev, loading: true, error: "" }));
|
||||
try {
|
||||
const [health, snapshot] = await Promise.all([
|
||||
requestJson(`${cfg.apiBaseUrl}/microservices/pipeline/health`),
|
||||
requestJson(`${cfg.apiBaseUrl}/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3`),
|
||||
]);
|
||||
setState({ loading: false, error: "", health, snapshot, refreshedAt: new Date() });
|
||||
} catch (err) {
|
||||
setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Pipeline 加载失败") }));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [service?.id, service?.runtime?.providerStatus]);
|
||||
|
||||
if (!service) return h(EmptyState, { title: "Pipeline 未登记", text: "请在 config.json 的 microservices 中登记 id=pipeline" });
|
||||
|
||||
const runtime = microserviceRuntime(service);
|
||||
const repository = microserviceRepository(service);
|
||||
const backend = microserviceBackend(service);
|
||||
const snapshot = state.snapshot || {};
|
||||
const { components, pipelines, runs } = pipelineSnapshotArrays(snapshot);
|
||||
const activePipeline = pipelines[0] || {};
|
||||
const pipelineNodes = Array.isArray(activePipeline.nodes) ? activePipeline.nodes : [];
|
||||
const pipelineEdges = Array.isArray(activePipeline.edges) ? activePipeline.edges : [];
|
||||
const latestRun = runs[0] || null;
|
||||
const statusCounts = pipelineStatusCounts(runs);
|
||||
const componentClasses = pipelineComponentClassCounts(components);
|
||||
const componentCount = Number(state.health?.components) || pipelineArrayCount(snapshot, "registry.components", components.length);
|
||||
const runCount = pipelineArrayCount(snapshot, "runs", runs.length);
|
||||
return h("div", { className: "pipeline-page", "data-testid": "pipeline-page" },
|
||||
h(Panel, {
|
||||
title: "Pipeline v2 工作台",
|
||||
eyebrow: "D601 Snapshot Microservice",
|
||||
actions: h("div", { className: "panel-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "pipeline-refresh-button" }, state.loading ? "刷新中" : "刷新"),
|
||||
h(RawButton, { title: "Pipeline Microservice", data: service, onOpen: onRaw, testId: "raw-pipeline-service" }),
|
||||
),
|
||||
},
|
||||
h("div", { className: "pipeline-hero" },
|
||||
h("div", null,
|
||||
h("div", { className: "node-version-line" },
|
||||
h(StatusBadge, { status: runtime.providerStatus === "online" ? "online" : "warn" }, runtime.providerStatus || "unknown"),
|
||||
h("span", null, service.providerId),
|
||||
h("span", null, backend.public ? "公网暴露" : "仅 UniDesk frontend 代理访问"),
|
||||
),
|
||||
h("p", { className: "muted paragraph" }, service.description),
|
||||
),
|
||||
h("div", { className: "microservice-ref-card" },
|
||||
h("span", null, "Repo"),
|
||||
h("strong", null, repository.url || "--"),
|
||||
h("code", null, repository.commitId || "--"),
|
||||
),
|
||||
h("div", { className: "microservice-ref-card" },
|
||||
h("span", null, "D601 Docker"),
|
||||
h("strong", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`),
|
||||
h("code", null, `${repository.composeFile || "--"} / ${repository.composeService || "--"}`),
|
||||
),
|
||||
),
|
||||
state.error ? h("div", { className: "form-error wide" }, state.error) : null,
|
||||
),
|
||||
h("div", { className: "pipeline-grid" },
|
||||
h(Panel, { title: "观测指标", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Snapshot" },
|
||||
h("div", { className: "metric-grid" },
|
||||
h(MetricCard, { label: "Health", value: state.health?.ok ? "OK" : "--", hint: state.health?.service || "D601 /health", tone: state.health?.ok ? "ok" : "warn" }),
|
||||
h(MetricCard, { label: "组件", value: componentCount, hint: "components registry", tone: snapshot?.registry?.ok === false ? "warn" : "ok" }),
|
||||
h(MetricCard, { label: "Pipeline", value: pipelines.length, hint: `${pipelineNodes.length} nodes / ${pipelineEdges.length} edges` }),
|
||||
h(MetricCard, { label: "运行记录", value: runCount, hint: `${statusCounts.succeeded || 0} succeeded / ${statusCounts.running || 0} running` }),
|
||||
h(MetricCard, { label: "OA 记录", value: Array.isArray(latestRun?.submissions) ? latestRun.submissions.length : 0, hint: latestRun?.runId || "latest run" }),
|
||||
h(MetricCard, { label: "Procedure", value: Array.isArray(latestRun?.procedureRuns) ? latestRun.procedureRuns.length : 0, hint: latestRun?.status || "no run" }),
|
||||
),
|
||||
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "Pipeline Snapshot", data: snapshot, onOpen: onRaw, testId: "raw-pipeline-snapshot" })),
|
||||
),
|
||||
h(Panel, { title: "组件矩阵", eyebrow: `${componentClasses.length} classes` },
|
||||
componentClasses.length === 0 ? h(EmptyState, { title: "暂无组件", text: "等待 D601 pipeline backend 返回 registry.components" }) :
|
||||
h("div", { className: "component-strata" }, componentClasses.map((item) => h("article", { key: item.name, className: "component-stratum" },
|
||||
h("span", null, item.name),
|
||||
h("strong", null, item.count),
|
||||
))),
|
||||
h("div", { className: "pipeline-component-list" },
|
||||
components.slice(0, 12).map((component: any) => h("span", { key: component.key, className: "data-chip" }, h("b", null, component.componentClass || "--"), h("span", null, component.id || component.key || "--"))),
|
||||
),
|
||||
),
|
||||
h(Panel, { title: "控制图", eyebrow: `${activePipeline.id || "pipeline"} / latest run ${latestRun?.status || "--"}` },
|
||||
pipelineNodes.length === 0 ? h(EmptyState, { title: "暂无控制图", text: "等待 D601 pipeline backend 返回 pipeline.nodes" }) :
|
||||
h("div", { className: "table-wrap pipeline-node-table" }, h("table", null,
|
||||
h("thead", null, h("tr", null, h("th", null, "节点"), h("th", null, "状态"), h("th", null, "类型"), h("th", null, "组件引用"), h("th", null, "输入摘要"))),
|
||||
h("tbody", null, pipelineNodes.slice(0, 32).map((node: any) => h("tr", { key: node.id },
|
||||
h("td", null, h("strong", null, node.id || "--")),
|
||||
h("td", null, h(StatusBadge, { status: pipelineRunNodeStatus(latestRun, node.id) }, pipelineRunNodeStatus(latestRun, node.id))),
|
||||
h("td", null, node.kind || "--"),
|
||||
h("td", null, h("code", null, pipelineComponentRef(node.componentRef))),
|
||||
h("td", null, h(DataSummary, { data: node.instanceInputs, empty: "无实例输入" })),
|
||||
))),
|
||||
)),
|
||||
),
|
||||
h(Panel, { title: "最近运行", eyebrow: `${runs.length}/${runCount} preview` },
|
||||
runs.length === 0 ? h(EmptyState, { title: "暂无运行记录", text: "Pipeline .state/pipeline-runs 还没有可展示状态" }) :
|
||||
h("div", { className: "pipeline-run-list" }, runs.map((run: any) => h("article", { key: run.runId, className: "pipeline-run-card" },
|
||||
h("div", { className: "node-card-head" }, h("strong", null, run.runId || "--"), h(StatusBadge, { status: run.status }, run.status || "--")),
|
||||
h("div", { className: "docker-meta compact" },
|
||||
h("span", null, run.pipelineId || "--"),
|
||||
h("span", null, `nodes ${Array.isArray(run.nodes) ? run.nodes.length : 0}`),
|
||||
h("span", null, `oa ${Array.isArray(run.submissions) ? run.submissions.length : 0}`),
|
||||
h("span", null, `procedures ${Array.isArray(run.procedureRuns) ? run.procedureRuns.length : 0}`),
|
||||
),
|
||||
h("p", { className: "muted paragraph" }, summarizeValue(run.task)),
|
||||
h("code", null, fmtDate(run.updatedAt)),
|
||||
))),
|
||||
),
|
||||
h(Panel, { title: "证据日志", eyebrow: latestRun?.runId || "latest worker tail" },
|
||||
!latestRun ? h(EmptyState, { title: "暂无证据", text: "没有 Pipeline run 时不会展示 worker log tail" }) :
|
||||
h("div", { className: "pipeline-log-list" },
|
||||
(Array.isArray(latestRun.workerLogTail) && latestRun.workerLogTail.length > 0 ? latestRun.workerLogTail.slice(-12) : [`${latestRun.runId} ${latestRun.status || "--"} / ${fmtDate(latestRun.updatedAt)}`]).map((line: string, index: number) => h("code", { key: `${index}-${line.slice(0, 24)}` }, line)),
|
||||
),
|
||||
h("div", { className: "panel-actions inline-actions" }, latestRun ? h(RawButton, { title: `Pipeline Run ${latestRun.runId}`, data: latestRun, onOpen: onRaw, testId: "raw-pipeline-run" }) : null),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function DispatchPage({ nodes, onDispatched, onRaw }: AnyRecord) {
|
||||
const onlineNodes = nodes.filter((node: any) => node.status === "online");
|
||||
const [providerId, setProviderId] = useState(onlineNodes[0]?.providerId || nodes[0]?.providerId || "");
|
||||
@@ -1085,6 +1452,7 @@ function DispatchPage({ nodes, onDispatched, onRaw }: AnyRecord) {
|
||||
h("label", null, "Command", h("select", { value: command, onChange: (event: any) => setCommand(event.target.value) },
|
||||
h("option", { value: "docker.ps" }, "docker.ps"),
|
||||
h("option", { value: "host.ssh" }, "host.ssh"),
|
||||
h("option", { value: "microservice.http" }, "microservice.http"),
|
||||
h("option", { value: "echo" }, "echo"),
|
||||
)),
|
||||
h("label", null, "来源", h("input", { value: source, onChange: (event: any) => setSource(event.target.value) })),
|
||||
@@ -1275,6 +1643,9 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
|
||||
if (activeModule === "tasks" && activeTab === "pending") return h(TaskPendingPage, { tasks: data.pendingTasks, onRaw });
|
||||
if (activeModule === "tasks" && activeTab === "history") return h(TaskHistoryPage, { tasks: data.tasks, onRaw });
|
||||
if (activeModule === "tasks" && activeTab === "results") return h(TaskResultsPage, { tasks: data.tasks, onRaw });
|
||||
if (activeModule === "apps" && activeTab === "catalog") return h(MicroserviceCatalogPage, { microservices: data.microservices, onRaw, onNavigate });
|
||||
if (activeModule === "apps" && activeTab === "findjob") return h(FindJobPage, { microservices: data.microservices, onRaw });
|
||||
if (activeModule === "apps" && activeTab === "pipeline") return h(PipelinePage, { microservices: data.microservices, onRaw });
|
||||
if (activeModule === "config" && activeTab === "topology") return h(TopologyPage, { data });
|
||||
if (activeModule === "config" && activeTab === "auth") return h(AuthPage, { session });
|
||||
if (activeModule === "config" && activeTab === "security") return h(SecurityPage);
|
||||
@@ -1283,8 +1654,8 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
|
||||
|
||||
function Shell({ session, onLogout }: AnyRecord) {
|
||||
const [activeModule, setActiveModule] = useState("ops");
|
||||
const [activeTabs, setActiveTabs] = useState({ ops: "status", nodes: "list", tasks: "dispatch", config: "topology" });
|
||||
const [data, setData] = useState({ overview: null, nodes: [], systemStatuses: [], dockerStatuses: [], events: [], tasks: [], pendingTasks: [], logs: [] });
|
||||
const [activeTabs, setActiveTabs] = useState({ ops: "status", nodes: "list", tasks: "dispatch", apps: "catalog", config: "topology" });
|
||||
const [data, setData] = useState({ overview: null, nodes: [], systemStatuses: [], dockerStatuses: [], microservices: [], events: [], tasks: [], pendingTasks: [], logs: [] });
|
||||
const [connection, setConnection] = useState({ ok: false, text: "连接中" });
|
||||
const [lastRefresh, setLastRefresh] = useState(null);
|
||||
const [clock, setClock] = useState(new Date());
|
||||
@@ -1295,11 +1666,12 @@ function Shell({ session, onLogout }: AnyRecord) {
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
try {
|
||||
const [overview, nodes, systemStatuses, dockerStatuses, events, tasks, pendingTasks, logs] = await Promise.all([
|
||||
const [overview, nodes, systemStatuses, dockerStatuses, microservices, events, tasks, pendingTasks, logs] = await Promise.all([
|
||||
requestJson(`${cfg.apiBaseUrl}/overview`),
|
||||
requestJson(`${cfg.apiBaseUrl}/nodes`),
|
||||
requestJson(`${cfg.apiBaseUrl}/nodes/system-status?limit=120`),
|
||||
requestJson(`${cfg.apiBaseUrl}/nodes/docker-status`),
|
||||
requestJson(`${cfg.apiBaseUrl}/microservices`),
|
||||
requestJson(`${cfg.apiBaseUrl}/events?limit=100`),
|
||||
requestJson(`${cfg.apiBaseUrl}/tasks?limit=300`),
|
||||
requestJson(`${cfg.apiBaseUrl}/tasks?status=pending&limit=100`),
|
||||
@@ -1310,6 +1682,7 @@ function Shell({ session, onLogout }: AnyRecord) {
|
||||
nodes: nodes.nodes || [],
|
||||
systemStatuses: systemStatuses.systemStatuses || [],
|
||||
dockerStatuses: dockerStatuses.dockerStatuses || [],
|
||||
microservices: microservices.microservices || [],
|
||||
events: events.events || [],
|
||||
tasks: tasks.tasks || [],
|
||||
pendingTasks: pendingTasks.tasks || [],
|
||||
|
||||
@@ -193,7 +193,7 @@ function sendJson(value: unknown): void {
|
||||
}
|
||||
|
||||
function sendRegister(): void {
|
||||
const capabilities = ["heartbeat", "system.status", "docker.status", "docker.ps", "provider.upgrade", "echo"];
|
||||
const capabilities = ["heartbeat", "system.status", "docker.status", "docker.ps", "provider.upgrade", "microservice.http", "echo"];
|
||||
if (isHostSshConfigured()) capabilities.push("host.ssh");
|
||||
sendJson({
|
||||
type: "register",
|
||||
@@ -609,6 +609,45 @@ function defaultHostSshProbeCommand(): string {
|
||||
return "printf 'UNIDESK_SSH_TEST user=%s host=%s bridge=%s cwd=%s\\n' \"$(whoami)\" \"$(hostname)\" \"${UNIDESK_BRIDGE:-}\" \"$(pwd)\"";
|
||||
}
|
||||
|
||||
function normalizeShellCommand(command: string): string {
|
||||
return command.replace(/\\\r?\n/g, " ").replace(/\s+/g, " ").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function hostSshSelfMutationReason(command: string, cwd: string | null): string | null {
|
||||
const normalized = normalizeShellCommand(command);
|
||||
const currentContainerName = `unidesk-provider-gateway-${safeDockerName(config.providerId)}`.toLowerCase();
|
||||
const composeProject = config.upgradeComposeProject.toLowerCase();
|
||||
const composeService = config.upgradeService.toLowerCase();
|
||||
const composeFile = config.upgradeComposeFile.toLowerCase();
|
||||
const composeEnvFile = config.upgradeEnvFile.toLowerCase();
|
||||
const hostProjectRoot = config.upgradeHostProjectRoot.toLowerCase();
|
||||
const currentCwd = (cwd ?? "").toLowerCase();
|
||||
const mutatesCompose = /\bdocker\s+compose\b|\bdocker-compose\b/.test(normalized)
|
||||
&& /\b(up|build|restart|stop|rm|down|kill)\b/.test(normalized);
|
||||
const mentionsCurrentProvider = normalized.includes(currentContainerName)
|
||||
|| normalized.includes(composeProject)
|
||||
|| normalized.includes(composeService)
|
||||
|| normalized.includes(composeFile)
|
||||
|| normalized.includes(composeEnvFile)
|
||||
|| normalized.includes(hostProjectRoot)
|
||||
|| (currentCwd.length > 0 && currentCwd.startsWith(hostProjectRoot));
|
||||
if (mutatesCompose && mentionsCurrentProvider) {
|
||||
return "docker compose mutation targets the current provider-gateway deployment";
|
||||
}
|
||||
const mutatesContainer = /\bdocker\s+(container\s+)?(rm|stop|restart|kill)\b/.test(normalized);
|
||||
if (mutatesContainer && normalized.includes(currentContainerName)) {
|
||||
return "docker container mutation targets the current provider-gateway container";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function assertHostSshCommandAllowed(command: string, cwd: string | null): void {
|
||||
const reason = hostSshSelfMutationReason(command, cwd);
|
||||
if (reason !== null) {
|
||||
throw new Error(`blocked unsafe host.ssh self-mutation: ${reason}; use provider.upgrade mode=schedule or a detached local node shell instead`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runHostSsh(payload: Record<string, JsonValue>): Promise<JsonValue> {
|
||||
if (!isHostSshConfigured()) {
|
||||
throw new Error(`host SSH bridge is not configured; missing ${missingHostSshFields().join(", ")}`);
|
||||
@@ -629,6 +668,7 @@ async function runHostSsh(payload: Record<string, JsonValue>): Promise<JsonValue
|
||||
throw new Error(`host SSH command is too long: ${command.length} bytes`);
|
||||
}
|
||||
const cwd = payloadString(payload, "cwd") ?? config.hostRemoteCwd;
|
||||
if (mode === "exec") assertHostSshCommandAllowed(command, cwd);
|
||||
const scriptParts = [
|
||||
"set -e",
|
||||
cwd === null ? null : `cd ${shellQuote(cwd)}`,
|
||||
@@ -682,6 +722,7 @@ async function runHostSsh(payload: Record<string, JsonValue>): Promise<JsonValue
|
||||
function hostSshRemoteScript(command: string | null, cwd: string | null, cols?: number, rows?: number): string {
|
||||
const fallbackCwd = config.hostRemoteCwd ?? `/home/${config.hostSshUser ?? "root"}`;
|
||||
const requestedCwd = cwd ?? fallbackCwd;
|
||||
if (command !== null && command.length > 0) assertHostSshCommandAllowed(command, requestedCwd);
|
||||
const loginShell = config.hostLoginShell ?? "/bin/bash";
|
||||
const resize = Number.isFinite(cols) && Number.isFinite(rows)
|
||||
? `stty rows ${Math.max(8, Math.min(120, Math.floor(rows ?? 30)))} cols ${Math.max(20, Math.min(300, Math.floor(cols ?? 100)))} 2>/dev/null || true`
|
||||
@@ -846,7 +887,7 @@ function safeDockerName(value: string): string {
|
||||
|
||||
function upgradePlan(taskId: string): Record<string, JsonValue> {
|
||||
const workspace = config.upgradeWorkspacePath;
|
||||
const composeCommand = [
|
||||
const composeBaseCommand = [
|
||||
"docker",
|
||||
"compose",
|
||||
"--env-file",
|
||||
@@ -855,14 +896,39 @@ function upgradePlan(taskId: string): Record<string, JsonValue> {
|
||||
`${workspace}/${config.upgradeComposeFile}`,
|
||||
"-p",
|
||||
config.upgradeComposeProject,
|
||||
];
|
||||
const composeBuildCommand = [
|
||||
...composeBaseCommand,
|
||||
"build",
|
||||
config.upgradeService,
|
||||
];
|
||||
const listServiceContainersCommand = [
|
||||
"docker",
|
||||
"ps",
|
||||
"-aq",
|
||||
"--filter",
|
||||
`label=com.docker.compose.project=${config.upgradeComposeProject}`,
|
||||
"--filter",
|
||||
`label=com.docker.compose.service=${config.upgradeService}`,
|
||||
];
|
||||
const composeUpCommand = [
|
||||
...composeBaseCommand,
|
||||
"up",
|
||||
"-d",
|
||||
"--no-deps",
|
||||
"--build",
|
||||
"--force-recreate",
|
||||
config.upgradeService,
|
||||
];
|
||||
const updaterName = `unidesk-provider-upgrader-${safeDockerName(taskId)}`;
|
||||
const script = `set -eu; sleep 2; cd ${shellQuote(workspace)}; ${composeCommand.map(shellQuote).join(" ")}`;
|
||||
const script = [
|
||||
"set -eu",
|
||||
"sleep 2",
|
||||
`cd ${shellQuote(workspace)}`,
|
||||
composeBuildCommand.map(shellQuote).join(" "),
|
||||
`ids=$(${listServiceContainersCommand.map(shellQuote).join(" ")})`,
|
||||
`if [ -n "$ids" ]; then docker rm -f $ids; fi`,
|
||||
composeUpCommand.map(shellQuote).join(" "),
|
||||
].join("; ");
|
||||
const dockerRunCommand = [
|
||||
"docker",
|
||||
"run",
|
||||
@@ -894,7 +960,19 @@ function upgradePlan(taskId: string): Record<string, JsonValue> {
|
||||
composeFile: config.upgradeComposeFile,
|
||||
envFile: config.upgradeEnvFile,
|
||||
},
|
||||
composeCommand,
|
||||
composeCommand: composeUpCommand,
|
||||
composeBuildCommand,
|
||||
listServiceContainersCommand,
|
||||
composeUpCommand,
|
||||
replacementStrategy: {
|
||||
buildBeforeRemove: true,
|
||||
removeScope: {
|
||||
projectLabel: config.upgradeComposeProject,
|
||||
serviceLabel: config.upgradeService,
|
||||
},
|
||||
noDeps: true,
|
||||
namedVolumesPreserved: true,
|
||||
},
|
||||
dockerRunCommand,
|
||||
};
|
||||
}
|
||||
@@ -917,6 +995,118 @@ async function runProviderUpgrade(taskId: string, payload: Record<string, JsonVa
|
||||
};
|
||||
}
|
||||
|
||||
function payloadNumber(payload: Record<string, JsonValue>, key: string, fallback: number): number {
|
||||
const raw = payload[key];
|
||||
const value = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : fallback;
|
||||
if (!Number.isFinite(value) || value <= 0) return fallback;
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function payloadJsonArrayLimits(payload: Record<string, JsonValue>): Record<string, number> {
|
||||
const raw = payload.jsonArrayLimits;
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
|
||||
const limits: Record<string, number> = {};
|
||||
for (const [path, value] of Object.entries(raw)) {
|
||||
if (!/^[A-Za-z0-9_.-]+$/.test(path)) continue;
|
||||
const limit = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
|
||||
if (Number.isInteger(limit) && limit > 0 && limit <= 500) limits[path] = limit;
|
||||
}
|
||||
return limits;
|
||||
}
|
||||
|
||||
function assertAllowedMicroserviceBase(rawBaseUrl: string): URL {
|
||||
const baseUrl = new URL(rawBaseUrl);
|
||||
if (baseUrl.protocol !== "http:") throw new Error(`microservice backend only supports http URLs, got ${baseUrl.protocol}`);
|
||||
const host = baseUrl.hostname.toLowerCase();
|
||||
const allowedHosts = new Set(["127.0.0.1", "localhost", "host.docker.internal"]);
|
||||
if (!allowedHosts.has(host)) throw new Error(`microservice backend host is not allowed: ${baseUrl.hostname}`);
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
function arrayAtPath(value: unknown, path: string): JsonValue[] | null {
|
||||
let current: unknown = value;
|
||||
for (const part of path.split(".")) {
|
||||
if (typeof current !== "object" || current === null || Array.isArray(current)) return null;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return Array.isArray(current) ? current as JsonValue[] : null;
|
||||
}
|
||||
|
||||
function applyJsonArrayLimits(bodyText: string, contentType: string, limits: Record<string, number>): { bodyText: string; transform: JsonValue } {
|
||||
const entries = Object.entries(limits);
|
||||
if (entries.length === 0) return { bodyText, transform: { applied: false } };
|
||||
if (!contentType.toLowerCase().includes("json")) {
|
||||
return { bodyText, transform: { applied: false, reason: "content-type is not json" } };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(bodyText) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return { bodyText, transform: { applied: false, reason: "json root is not an object" } };
|
||||
}
|
||||
const root = parsed as Record<string, unknown>;
|
||||
const applied: Record<string, JsonValue> = {};
|
||||
for (const [path, limit] of entries) {
|
||||
const array = arrayAtPath(root, path);
|
||||
if (array === null) continue;
|
||||
const originalLength = array.length;
|
||||
if (array.length > limit) array.splice(limit);
|
||||
applied[path] = { limit, originalLength, returnedLength: array.length };
|
||||
}
|
||||
root._unidesk = { arrayLimits: applied };
|
||||
return { bodyText: JSON.stringify(parsed), transform: { applied: Object.keys(applied).length > 0, arrayLimits: applied } };
|
||||
} catch (error) {
|
||||
return { bodyText, transform: { applied: false, error: error instanceof Error ? error.message : String(error) } };
|
||||
}
|
||||
}
|
||||
|
||||
async function runMicroserviceHttp(payload: Record<string, JsonValue>): Promise<JsonValue> {
|
||||
const method = payload.method === "HEAD" ? "HEAD" : "GET";
|
||||
const targetBaseUrl = payloadString(payload, "targetBaseUrl");
|
||||
if (targetBaseUrl === null) throw new Error("microservice.http requires targetBaseUrl");
|
||||
const path = payloadString(payload, "path") ?? "/";
|
||||
const query = payloadString(payload, "query") ?? "";
|
||||
if (!path.startsWith("/")) throw new Error("microservice.http path must start with /");
|
||||
if (query.length > 0 && !query.startsWith("?")) throw new Error("microservice.http query must start with ?");
|
||||
const baseUrl = assertAllowedMicroserviceBase(targetBaseUrl);
|
||||
const url = new URL(path, baseUrl);
|
||||
url.search = query;
|
||||
const timeoutMs = Math.max(1000, Math.min(30_000, payloadNumber(payload, "timeoutMs", 10_000)));
|
||||
const jsonArrayLimits = payloadJsonArrayLimits(payload);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { method, signal: controller.signal });
|
||||
const rawBodyText = await response.text();
|
||||
const contentType = response.headers.get("content-type") ?? "text/plain; charset=utf-8";
|
||||
const transformed = applyJsonArrayLimits(rawBodyText, contentType, jsonArrayLimits);
|
||||
return {
|
||||
ok: true,
|
||||
serviceId: payloadString(payload, "serviceId") ?? "unknown",
|
||||
method,
|
||||
url: url.toString(),
|
||||
status: response.status,
|
||||
upstreamOk: response.ok,
|
||||
contentType,
|
||||
bodyText: truncateText(transformed.bodyText, 1024 * 1024),
|
||||
upstreamBodyBytes: rawBodyText.length,
|
||||
returnedBodyBytes: Math.min(transformed.bodyText.length, 1024 * 1024),
|
||||
truncated: transformed.bodyText.length > 1024 * 1024,
|
||||
transform: transformed.transform,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
serviceId: payloadString(payload, "serviceId") ?? "unknown",
|
||||
method,
|
||||
url: url.toString(),
|
||||
timeoutMs,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDispatch(message: CoreDispatchMessage): Promise<void> {
|
||||
logger("info", "dispatch_received", { taskId: message.taskId, command: message.command, payload: message.payload });
|
||||
await sendTaskStatus(message.taskId, "accepted", "provider accepted task");
|
||||
@@ -941,6 +1131,15 @@ async function handleDispatch(message: CoreDispatchMessage): Promise<void> {
|
||||
await sendTaskStatus(message.taskId, "succeeded", "host SSH command completed", result);
|
||||
return;
|
||||
}
|
||||
if (message.command === "microservice.http") {
|
||||
const result = await runMicroserviceHttp(message.payload);
|
||||
if ((result as { ok?: unknown }).ok !== true) {
|
||||
await sendTaskStatus(message.taskId, "failed", "microservice HTTP proxy failed", result);
|
||||
return;
|
||||
}
|
||||
await sendTaskStatus(message.taskId, "succeeded", "microservice HTTP proxy completed", result);
|
||||
return;
|
||||
}
|
||||
await sendTaskStatus(message.taskId, "succeeded", "echo completed", { echo: message.payload });
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
|
||||
@@ -106,7 +106,7 @@ export interface ProviderTaskStatusMessage {
|
||||
result?: JsonValue;
|
||||
}
|
||||
|
||||
export type ProviderDispatchCommand = "docker.ps" | "provider.upgrade" | "host.ssh" | "echo";
|
||||
export type ProviderDispatchCommand = "docker.ps" | "provider.upgrade" | "host.ssh" | "microservice.http" | "echo";
|
||||
|
||||
export interface CoreDispatchMessage {
|
||||
type: "dispatch";
|
||||
@@ -271,7 +271,7 @@ export function parseJsonObject(value: string, name: string): Record<string, Jso
|
||||
}
|
||||
|
||||
export function isProviderDispatchCommand(value: unknown): value is ProviderDispatchCommand {
|
||||
return value === "docker.ps" || value === "provider.upgrade" || value === "host.ssh" || value === "echo";
|
||||
return value === "docker.ps" || value === "provider.upgrade" || value === "host.ssh" || value === "microservice.http" || value === "echo";
|
||||
}
|
||||
|
||||
export function isProviderToCoreMessage(value: unknown): value is ProviderToCoreMessage {
|
||||
|
||||
Reference in New Issue
Block a user