feat: integrate todo note microservice and modularize frontend
This commit is contained in:
@@ -19,12 +19,18 @@
|
||||
"components/frontend": {
|
||||
"name": "@unidesk/frontend",
|
||||
"dependencies": {
|
||||
"@xyflow/react": "12.10.2",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.3.28",
|
||||
"@types/react-dom": "18.3.7",
|
||||
},
|
||||
},
|
||||
"components/provider-gateway": {
|
||||
"name": "@unidesk/provider-gateway",
|
||||
"version": "0.2.1",
|
||||
},
|
||||
"components/shared": {
|
||||
"name": "@unidesk/shared",
|
||||
@@ -33,8 +39,26 @@
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
|
||||
|
||||
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||
|
||||
"@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="],
|
||||
|
||||
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
||||
|
||||
"@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="],
|
||||
|
||||
"@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="],
|
||||
|
||||
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
|
||||
|
||||
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
|
||||
|
||||
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
||||
|
||||
"@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
|
||||
|
||||
"@unidesk/backend-core": ["@unidesk/backend-core@workspace:components/backend-core"],
|
||||
|
||||
"@unidesk/frontend": ["@unidesk/frontend@workspace:components/frontend"],
|
||||
@@ -43,8 +67,34 @@
|
||||
|
||||
"@unidesk/shared": ["@unidesk/shared@workspace:components/shared"],
|
||||
|
||||
"@xyflow/react": ["@xyflow/react@12.10.2", "", { "dependencies": { "@xyflow/system": "0.0.76", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ=="],
|
||||
|
||||
"@xyflow/system": ["@xyflow/system@0.0.76", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||
|
||||
"classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
||||
|
||||
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
|
||||
|
||||
"d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
|
||||
|
||||
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
||||
|
||||
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||
|
||||
"d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
"d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
|
||||
|
||||
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
@@ -60,5 +110,9 @@
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ interface RuntimeConfig {
|
||||
providerToken: string;
|
||||
heartbeatTimeoutMs: number;
|
||||
taskPendingTimeoutMs: number;
|
||||
databaseVolumeName: string;
|
||||
databaseVolumeSize: string;
|
||||
microservices: MicroserviceConfig[];
|
||||
logFile: string;
|
||||
}
|
||||
@@ -56,6 +58,7 @@ interface MicroserviceConfig {
|
||||
proxyMode: string;
|
||||
frontendOnly: boolean;
|
||||
public: boolean;
|
||||
allowedMethods: string[];
|
||||
allowedPathPrefixes: string[];
|
||||
healthPath: string;
|
||||
timeoutMs: number;
|
||||
@@ -180,6 +183,7 @@ function parseMicroserviceConfig(value: unknown, index: number): MicroserviceCon
|
||||
proxyMode: stringFromRecord(backend, "proxyMode", `${path}.backend`),
|
||||
frontendOnly: booleanFromRecord(backend, "frontendOnly", `${path}.backend`),
|
||||
public: booleanFromRecord(backend, "public", `${path}.backend`),
|
||||
allowedMethods: stringArrayFromRecord(backend, "allowedMethods", `${path}.backend`).map((method) => method.toUpperCase()),
|
||||
allowedPathPrefixes: stringArrayFromRecord(backend, "allowedPathPrefixes", `${path}.backend`),
|
||||
healthPath: stringFromRecord(backend, "healthPath", `${path}.backend`),
|
||||
timeoutMs: numberFromRecord(backend, "timeoutMs", `${path}.backend`),
|
||||
@@ -212,6 +216,8 @@ function readConfig(): RuntimeConfig {
|
||||
providerToken: requiredEnv("PROVIDER_TOKEN"),
|
||||
heartbeatTimeoutMs: readNumberEnv("HEARTBEAT_TIMEOUT_MS"),
|
||||
taskPendingTimeoutMs: readOptionalNumberEnv("TASK_PENDING_TIMEOUT_MS", 10 * 60 * 1000),
|
||||
databaseVolumeName: requiredEnv("DATABASE_VOLUME_NAME"),
|
||||
databaseVolumeSize: requiredEnv("DATABASE_VOLUME_SIZE"),
|
||||
microservices: readMicroservicesEnv(),
|
||||
logFile: requiredEnv("LOG_FILE"),
|
||||
};
|
||||
@@ -242,7 +248,7 @@ function jsonResponse(body: unknown, status = 200): Response {
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-methods": "GET,POST,OPTIONS",
|
||||
"access-control-allow-methods": "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS",
|
||||
"access-control-allow-headers": "content-type,x-provider-token",
|
||||
},
|
||||
});
|
||||
@@ -832,16 +838,35 @@ async function countPendingTasks(): Promise<number> {
|
||||
return Number(rows[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
async function getPgdataUsage(): Promise<JsonValue> {
|
||||
const rows = await sql<Array<{ database_name: string; database_bytes: string | number; database_pretty: string }>>`
|
||||
SELECT
|
||||
current_database()::text AS database_name,
|
||||
pg_database_size(current_database())::bigint AS database_bytes,
|
||||
pg_size_pretty(pg_database_size(current_database()))::text AS database_pretty
|
||||
`;
|
||||
const row = rows[0];
|
||||
return {
|
||||
volumeName: config.databaseVolumeName,
|
||||
volumeSize: config.databaseVolumeSize,
|
||||
databaseName: row?.database_name ?? "unknown",
|
||||
databaseBytes: Number(row?.database_bytes ?? 0),
|
||||
databasePretty: row?.database_pretty ?? "--",
|
||||
};
|
||||
}
|
||||
|
||||
async function getOverview(): Promise<JsonValue> {
|
||||
const nodes = await getNodes();
|
||||
const pendingTasks = await countPendingTasks();
|
||||
const dockerStatuses = await getNodeDockerStatuses();
|
||||
const systemStatuses = await getNodeSystemStatuses(1);
|
||||
const pgdata = await getPgdataUsage();
|
||||
const online = nodes.filter((node) => node.status === "online").length;
|
||||
return {
|
||||
service: "unidesk-core",
|
||||
ok: true,
|
||||
dbReady,
|
||||
pgdata,
|
||||
uptimeSeconds: Math.floor((Date.now() - serviceStartedAt.getTime()) / 1000),
|
||||
nodeCount: nodes.length,
|
||||
onlineNodeCount: online,
|
||||
@@ -948,6 +973,10 @@ function isMicroservicePathAllowed(service: MicroserviceConfig, path: string): b
|
||||
return service.backend.allowedPathPrefixes.some((prefix) => path === prefix || path.startsWith(prefix));
|
||||
}
|
||||
|
||||
function isMicroserviceMethodAllowed(service: MicroserviceConfig, method: string): boolean {
|
||||
return service.backend.allowedMethods.includes(method.toUpperCase());
|
||||
}
|
||||
|
||||
function readMicroserviceArrayLimits(url: URL): { query: string; jsonArrayLimits: Record<string, JsonValue> } {
|
||||
const params = new URLSearchParams(url.searchParams);
|
||||
const jsonArrayLimits: Record<string, JsonValue> = {};
|
||||
@@ -988,8 +1017,10 @@ async function microserviceRoute(req: Request, url: URL): Promise<Response> {
|
||||
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 method = req.method.toUpperCase();
|
||||
if ((suffix === "" || suffix === "status") && (method === "GET" || method === "HEAD")) {
|
||||
return jsonResponse({ ok: true, microservice: (await getMicroservices()).find((item) => recordValue(item, "id") === serviceId) ?? service });
|
||||
}
|
||||
|
||||
const proxyPrefix = "proxy";
|
||||
const targetPath = suffix === "health"
|
||||
@@ -1000,6 +1031,12 @@ async function microserviceRoute(req: Request, url: URL): Promise<Response> {
|
||||
? `/${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 (suffix === "health" && method !== "GET" && method !== "HEAD") {
|
||||
return jsonResponse({ ok: false, error: "microservice health only supports GET/HEAD" }, 405);
|
||||
}
|
||||
if (!isMicroserviceMethodAllowed(service, method)) {
|
||||
return jsonResponse({ ok: false, error: "microservice method is not allowed", serviceId, method, allowedMethods: service.backend.allowedMethods }, 405);
|
||||
}
|
||||
if (!isMicroservicePathAllowed(service, targetPath)) {
|
||||
return jsonResponse({ ok: false, error: "microservice path is not allowed", serviceId, targetPath }, 403);
|
||||
}
|
||||
@@ -1007,13 +1044,22 @@ async function microserviceRoute(req: Request, url: URL): Promise<Response> {
|
||||
return jsonResponse({ ok: false, error: `provider does not declare microservice.http capability: ${service.providerId}` }, 409);
|
||||
}
|
||||
const proxyOptions = readMicroserviceArrayLimits(url);
|
||||
const bodyText = method === "GET" || method === "HEAD" ? "" : await req.text();
|
||||
if (bodyText.length > 1024 * 1024) {
|
||||
return jsonResponse({ ok: false, error: "microservice request body is too large", maxBytes: 1024 * 1024 }, 413);
|
||||
}
|
||||
const requestHeaders: Record<string, JsonValue> = {};
|
||||
const contentType = req.headers.get("content-type");
|
||||
if (contentType !== null) requestHeaders["content-type"] = contentType.slice(0, 200);
|
||||
const { taskId, providerOnline } = await createAndSendTask(service.providerId, "microservice.http", {
|
||||
source: "microservice-frontend-proxy",
|
||||
serviceId: service.id,
|
||||
method: req.method,
|
||||
method,
|
||||
targetBaseUrl: service.backend.nodeBaseUrl,
|
||||
path: targetPath,
|
||||
query: proxyOptions.query,
|
||||
requestHeaders,
|
||||
bodyText,
|
||||
jsonArrayLimits: proxyOptions.jsonArrayLimits,
|
||||
timeoutMs: service.backend.timeoutMs,
|
||||
});
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
"check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xyflow/react": "12.10.2",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.3.28",
|
||||
"@types/react-dom": "18.3.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>UniDesk Control Plane</title>
|
||||
<link rel="stylesheet" href="/vendor/xyflow.css" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1015,7 +1015,130 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
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; }
|
||||
.pipeline-flow-frame {
|
||||
height: min(68vh, 720px);
|
||||
min-height: 520px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background:
|
||||
radial-gradient(circle at 18% 22%, rgba(78, 183, 168, 0.11), transparent 30%),
|
||||
linear-gradient(135deg, rgba(215, 161, 58, 0.09), transparent 44%),
|
||||
#0b1319;
|
||||
}
|
||||
.pipeline-flow-frame .react-flow__pane { cursor: grab; }
|
||||
.pipeline-flow-frame .react-flow__controls {
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
.pipeline-flow-frame .react-flow__controls-button {
|
||||
border-bottom-color: var(--line-soft);
|
||||
background: #111a22;
|
||||
color: var(--text);
|
||||
}
|
||||
.pipeline-flow-frame .react-flow__controls-button svg { fill: currentColor; }
|
||||
.pipeline-flow-frame .react-flow__minimap {
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(8, 13, 18, 0.88);
|
||||
}
|
||||
.pipeline-flow-frame .react-flow__edges {
|
||||
z-index: 5;
|
||||
}
|
||||
.pipeline-flow-frame .react-flow__nodes {
|
||||
z-index: 4;
|
||||
}
|
||||
.pipeline-flow-frame .react-flow__edge-path {
|
||||
stroke: rgba(129, 147, 159, 0.72);
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 0 5px rgba(78, 183, 168, 0.12));
|
||||
}
|
||||
.pipeline-flow-frame .react-flow__edge {
|
||||
opacity: 0.82;
|
||||
z-index: 12;
|
||||
}
|
||||
.pipeline-flow-frame .react-flow__edge:hover,
|
||||
.pipeline-flow-frame .react-flow__edge.selected {
|
||||
opacity: 1;
|
||||
}
|
||||
.pipeline-flow-frame .react-flow__edge:hover .react-flow__edge-path,
|
||||
.pipeline-flow-frame .react-flow__edge.selected .react-flow__edge-path {
|
||||
stroke-width: 2.8;
|
||||
}
|
||||
.pipeline-flow-frame .react-flow__edge.succeeded .react-flow__edge-path { stroke: var(--accent-2); }
|
||||
.pipeline-flow-frame .react-flow__edge.running .react-flow__edge-path { stroke: var(--accent); }
|
||||
.pipeline-flow-frame .react-flow__edge.failed .react-flow__edge-path { stroke: var(--danger); }
|
||||
.pipeline-flow-frame .pipeline-flow-handle {
|
||||
width: 9px;
|
||||
height: 18px;
|
||||
border: 1px solid rgba(78, 183, 168, 0.8);
|
||||
border-radius: 2px;
|
||||
background: #071016;
|
||||
}
|
||||
.pipeline-flow-frame .pipeline-flow-handle.input.top,
|
||||
.pipeline-flow-frame .pipeline-flow-handle.input.bottom {
|
||||
width: 18px;
|
||||
height: 9px;
|
||||
}
|
||||
.pipeline-flow-frame .pipeline-flow-handle.input.left { left: -6px; }
|
||||
.pipeline-flow-frame .pipeline-flow-handle.input.top { top: -6px; }
|
||||
.pipeline-flow-frame .pipeline-flow-handle.input.bottom { bottom: -6px; }
|
||||
.pipeline-flow-frame .pipeline-flow-handle.output.right {
|
||||
right: -6px;
|
||||
border-color: rgba(215, 161, 58, 0.86);
|
||||
}
|
||||
.pipeline-flow-node {
|
||||
width: 236px;
|
||||
border: 1px solid rgba(129, 147, 159, 0.4);
|
||||
background: linear-gradient(180deg, rgba(19, 29, 38, 0.98), rgba(10, 18, 24, 0.98));
|
||||
color: var(--text);
|
||||
box-shadow: 0 14px 34px rgba(0,0,0,0.32);
|
||||
}
|
||||
.pipeline-flow-node-body {
|
||||
position: relative;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.pipeline-flow-node.succeeded { border-color: rgba(78, 183, 168, 0.72); }
|
||||
.pipeline-flow-node.running { border-color: rgba(215, 161, 58, 0.82); }
|
||||
.pipeline-flow-node.failed { border-color: rgba(207, 106, 84, 0.78); }
|
||||
.pipeline-flow-node.quality-gate { background: linear-gradient(180deg, rgba(22, 38, 34, 0.98), rgba(10, 19, 17, 0.98)); }
|
||||
.pipeline-flow-node.control-block { background: linear-gradient(180deg, rgba(36, 31, 20, 0.98), rgba(17, 15, 11, 0.98)); }
|
||||
.flow-node-label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.flow-node-label strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
.flow-node-label span {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.flow-node-label code {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--accent);
|
||||
}
|
||||
.pipeline-flow-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.pipeline-flow-summary span {
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
.component-strata {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
|
||||
@@ -1268,3 +1391,300 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
.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; }
|
||||
}
|
||||
|
||||
.shell.rail-collapsed {
|
||||
grid-template-columns: 58px minmax(0, 1fr);
|
||||
}
|
||||
.rail-toggle {
|
||||
margin-left: auto;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--line-soft);
|
||||
color: var(--muted);
|
||||
background: rgba(0,0,0,0.18);
|
||||
}
|
||||
.rail-toggle:hover { color: var(--text); border-color: var(--accent); }
|
||||
.rail.collapsed {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
.rail.collapsed .brand {
|
||||
justify-content: center;
|
||||
padding-inline: 0;
|
||||
}
|
||||
.rail.collapsed .brand-text,
|
||||
.rail.collapsed .module > span:not(.module-code) {
|
||||
display: none;
|
||||
}
|
||||
.rail.collapsed .rail-toggle {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
left: 8px;
|
||||
width: 40px;
|
||||
}
|
||||
.rail.collapsed .module {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: center;
|
||||
min-height: 38px;
|
||||
padding: 7px 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.rail.collapsed .module-code {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.todo-note-page {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.todo-note-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1.35fr) minmax(260px, 0.9fr) minmax(220px, 0.7fr);
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.todo-note-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.52fr) minmax(680px, 1.8fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.todo-list-panel .panel-body,
|
||||
.todo-main-stack .panel-body {
|
||||
min-width: 0;
|
||||
}
|
||||
.todo-create-list,
|
||||
.todo-add-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.todo-instance-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
max-height: calc(100vh - 310px);
|
||||
overflow: auto;
|
||||
}
|
||||
.todo-instance-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 2px 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line-soft);
|
||||
color: var(--muted);
|
||||
background: var(--panel-3);
|
||||
text-align: left;
|
||||
}
|
||||
.todo-instance-row.active,
|
||||
.todo-instance-row:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent-2);
|
||||
background: rgba(78, 183, 168, 0.09);
|
||||
}
|
||||
.todo-instance-row strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.todo-instance-row code {
|
||||
grid-column: 1 / -1;
|
||||
color: #bcd2d7;
|
||||
}
|
||||
.todo-workbench {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: var(--todo-font-size, 13px);
|
||||
}
|
||||
.todo-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.todo-filter-strip,
|
||||
.todo-toolbar-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
.todo-filter {
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
background: rgba(12, 18, 24, 0.62);
|
||||
}
|
||||
.todo-filter.active,
|
||||
.todo-filter:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent-2);
|
||||
background: rgba(78, 183, 168, 0.11);
|
||||
}
|
||||
.todo-font-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.todo-font-control input { width: 86px; padding: 0; }
|
||||
.todo-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.todo-root-drop {
|
||||
padding: 6px 8px;
|
||||
border: 1px dashed var(--line);
|
||||
color: var(--muted);
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
.todo-tree {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
max-height: calc(100vh - 398px);
|
||||
min-height: 280px;
|
||||
overflow: auto;
|
||||
align-content: start;
|
||||
}
|
||||
.todo-row-wrap {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
.todo-row {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 24px minmax(240px, 1fr) 178px minmax(360px, auto);
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-left: calc(var(--todo-depth, 0) * 18px);
|
||||
padding: 6px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: var(--panel-3);
|
||||
}
|
||||
.todo-row.completed {
|
||||
opacity: 0.76;
|
||||
}
|
||||
.todo-row.completed .todo-title-cell strong {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted);
|
||||
}
|
||||
.todo-row.dragging {
|
||||
border-color: var(--accent);
|
||||
background: rgba(215, 161, 58, 0.08);
|
||||
}
|
||||
.todo-expand {
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--line-soft);
|
||||
color: var(--muted);
|
||||
background: rgba(0,0,0,0.14);
|
||||
}
|
||||
.todo-expand:disabled { opacity: 0.45; cursor: default; }
|
||||
.todo-title-cell {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
.todo-title-cell strong {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
font-size: var(--todo-font-size, 13px);
|
||||
}
|
||||
.todo-meta-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.todo-reminder { color: var(--accent); }
|
||||
.todo-reminder-input {
|
||||
min-width: 176px;
|
||||
padding: 5px 6px;
|
||||
}
|
||||
.todo-row-actions,
|
||||
.todo-edit-inline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.todo-row-actions .ghost-btn {
|
||||
min-height: 24px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.todo-children {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.todo-note-layout,
|
||||
.todo-note-hero,
|
||||
.todo-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.todo-row {
|
||||
grid-template-columns: 28px 24px minmax(220px, 1fr);
|
||||
}
|
||||
.todo-reminder-input,
|
||||
.todo-row-actions {
|
||||
grid-column: 3;
|
||||
}
|
||||
.todo-stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.shell.rail-collapsed {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
.rail.collapsed {
|
||||
height: 42px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.rail.collapsed .brand {
|
||||
justify-content: flex-start;
|
||||
padding: 0 8px 0 0;
|
||||
}
|
||||
.rail.collapsed .rail-toggle {
|
||||
position: static;
|
||||
width: 24px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.rail.collapsed .module {
|
||||
width: auto;
|
||||
min-width: 88px;
|
||||
min-height: 30px;
|
||||
grid-template-columns: auto 1fr;
|
||||
justify-items: start;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
margin: 0;
|
||||
}
|
||||
.rail.collapsed .module > span:not(.module-code) {
|
||||
display: inline;
|
||||
}
|
||||
.todo-stats-grid,
|
||||
.todo-create-list,
|
||||
.todo-add-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.todo-tree {
|
||||
max-height: none;
|
||||
min-height: 220px;
|
||||
}
|
||||
.todo-row {
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
margin-left: 0;
|
||||
}
|
||||
.todo-row > input[type="checkbox"] { grid-column: 1; }
|
||||
.todo-title-cell,
|
||||
.todo-reminder-input,
|
||||
.todo-row-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
export {};
|
||||
|
||||
declare const React: {
|
||||
createElement: (...args: any[]) => any;
|
||||
useEffect: (...args: any[]) => any;
|
||||
useMemo: (...args: any[]) => any;
|
||||
useState: (...args: any[]) => any;
|
||||
};
|
||||
declare const ReactDOM: { createRoot: (element: Element | null) => { render: (node: any) => void } };
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { FindJobPage } from "./findjob";
|
||||
import { PipelinePage } from "./pipeline";
|
||||
import { TodoNotePage } from "./todo-note";
|
||||
|
||||
type AnyRecord = Record<string, any>;
|
||||
type ReactNode = any;
|
||||
|
||||
function readClientConfig(): AnyRecord {
|
||||
const raw = document.getElementById("root")?.getAttribute("data-config");
|
||||
@@ -24,7 +19,8 @@ function readClientConfig(): AnyRecord {
|
||||
|
||||
const cfg: AnyRecord = readClientConfig();
|
||||
const h = React.createElement;
|
||||
const { useEffect, useMemo, useState } = React;
|
||||
const { useEffect, useMemo } = React;
|
||||
const useState: any = React.useState;
|
||||
|
||||
function errorMessage(error: unknown, fallback = "操作失败"): string {
|
||||
return error instanceof Error ? error.message : String(error || fallback);
|
||||
@@ -52,6 +48,7 @@ const MODULES = [
|
||||
] },
|
||||
{ id: "apps", label: "微服务", code: "APP", tabs: [
|
||||
{ id: "catalog", label: "服务目录" },
|
||||
{ id: "todo-note", label: "Todo Note" },
|
||||
{ id: "findjob", label: "FindJob" },
|
||||
{ id: "pipeline", label: "Pipeline" },
|
||||
] },
|
||||
@@ -285,63 +282,6 @@ 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");
|
||||
@@ -526,14 +466,19 @@ function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock }
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({ activeModule, onChange }: AnyRecord) {
|
||||
return h("aside", { className: "rail", "aria-label": "主模块" },
|
||||
h("div", { className: "brand" }, h("span", { className: "brand-mark" }, "UD"), h("span", { className: "brand-text" }, "UniDesk")),
|
||||
function Sidebar({ activeModule, onChange, collapsed, onToggle }: AnyRecord) {
|
||||
return h("aside", { className: `rail ${collapsed ? "collapsed" : ""}`, "aria-label": "主模块" },
|
||||
h("div", { className: "brand" },
|
||||
h("span", { className: "brand-mark" }, "UD"),
|
||||
h("span", { className: "brand-text" }, "UniDesk"),
|
||||
h("button", { type: "button", className: "rail-toggle", onClick: onToggle, "aria-label": collapsed ? "展开左侧边栏" : "收起左侧边栏", "data-testid": "rail-toggle" }, collapsed ? "»" : "«"),
|
||||
),
|
||||
MODULES.map((module: any) => h("button", {
|
||||
key: module.id,
|
||||
type: "button",
|
||||
className: `module ${activeModule === module.id ? "active" : ""}`,
|
||||
onClick: () => onChange(module.id),
|
||||
title: module.label,
|
||||
}, h("span", { className: "module-code" }, module.code), h("span", null, module.label))),
|
||||
);
|
||||
}
|
||||
@@ -555,10 +500,12 @@ function OverviewPage({ data, onRaw, onNavigate }: AnyRecord) {
|
||||
const pendingTasks = data.pendingTasks || data.tasks.filter(isPendingTask);
|
||||
const pendingCount = overview.pendingTaskCount ?? pendingTasks.length;
|
||||
const recentTasks = data.tasks.slice(0, 5);
|
||||
const pgdata = overview.pgdata || {};
|
||||
return h("div", { className: "page-grid overview-grid" },
|
||||
h(Panel, { title: "核心指标", eyebrow: "Control" },
|
||||
h("div", { className: "metric-grid" },
|
||||
h(MetricCard, { label: "数据库", value: overview.dbReady ? "READY" : "WAIT", hint: "PostgreSQL internal network", tone: overview.dbReady ? "ok" : "warn" }),
|
||||
h(MetricCard, { label: "PGDATA", value: fmtBytes(pgdata.databaseBytes), hint: `${pgdata.volumeName || "unidesk_pgdata_10gb"} / ${pgdata.databasePretty || "--"}`, tone: "ok", testId: "pgdata-usage-card" }),
|
||||
h(MetricCard, { label: "在线节点", value: overview.onlineNodeCount ?? 0, hint: `${overview.nodeCount ?? 0} registered`, tone: "ok" }),
|
||||
h(MetricCard, { label: "WebSocket", value: overview.activeSocketCount ?? 0, hint: "Provider ingress sockets" }),
|
||||
h(MetricCard, { label: "待处理任务", value: pendingCount, hint: pendingCount > 0 ? "点击查看具体任务" : `timeout ${fmtDuration(Math.floor((overview.taskPendingTimeoutMs ?? 0) / 1000))}`, tone: pendingCount > 0 ? "warn" : "ok", onClick: () => onNavigate("tasks", "pending"), testId: "pending-task-card" }),
|
||||
@@ -992,12 +939,13 @@ function DockerStatusPage({ nodes, dockerStatuses, onRaw }: AnyRecord) {
|
||||
}
|
||||
|
||||
const status = active.dockerStatus;
|
||||
const isMainServer = active.providerId === "main-server";
|
||||
const counts = status?.counts || {};
|
||||
const daemon = status?.daemon || {};
|
||||
const containers = status?.containers || [];
|
||||
const images = status?.images || [];
|
||||
const volumes = sortVolumes(status?.volumes || []);
|
||||
const databaseVolume = volumes.find(isDatabaseVolume);
|
||||
const databaseVolume = isMainServer ? volumes.find(isDatabaseVolume) : null;
|
||||
const networks = status?.networks || [];
|
||||
const runningContainers = containers.filter((item: any) => item.state === "running");
|
||||
const stoppedContainers = containers.filter((item: any) => item.state !== "running");
|
||||
@@ -1041,10 +989,10 @@ function DockerStatusPage({ nodes, dockerStatuses, onRaw }: AnyRecord) {
|
||||
h("div", { className: "docker-metrics" },
|
||||
h(MetricCard, { label: "Containers", value: counts.containers ?? containers.length, hint: `${counts.running ?? runningContainers.length} running / ${counts.stopped ?? stoppedContainers.length} stopped`, tone: "ok" }),
|
||||
h(MetricCard, { label: "Images", value: counts.images ?? images.length, hint: `${counts.daemonImages ?? counts.images ?? images.length} daemon images` }),
|
||||
h(MetricCard, { label: "Volumes", value: counts.volumes ?? volumes.length, hint: databaseVolume ? "database volume visible" : "local volumes", tone: databaseVolume ? "ok" : "" }),
|
||||
h(MetricCard, { label: "Volumes", value: counts.volumes ?? volumes.length, hint: isMainServer ? (databaseVolume ? "database volume visible" : "database volume missing") : "node local volumes", tone: databaseVolume ? "ok" : "" }),
|
||||
h(MetricCard, { label: "Networks", value: counts.networks ?? networks.length, hint: daemon.driver ? `driver ${daemon.driver}` : "docker networks" }),
|
||||
),
|
||||
h(DatabaseVolumeCard, { volume: databaseVolume, volumeCount: volumes.length }),
|
||||
isMainServer ? h(DatabaseVolumeCard, { volume: databaseVolume, volumeCount: volumes.length }) : null,
|
||||
h("div", { className: "docker-section-head" },
|
||||
h("h3", null, "Containers"),
|
||||
h("span", null, `updated ${fmtDate(active.dockerUpdatedAt || status.collectedAt)}`),
|
||||
@@ -1064,9 +1012,9 @@ function DockerStatusPage({ nodes, dockerStatuses, onRaw }: AnyRecord) {
|
||||
),
|
||||
h("div", { className: "docker-side-stack" },
|
||||
h(DockerSidePanel, { title: "Images", items: images, render: (image: any) => h("article", { key: `${image.id}-${image.repository}`, className: "docker-side-row" }, h("strong", null, `${image.repository}:${image.tag}`), h("span", null, image.size || "--"), h("code", null, image.id || "--")) }),
|
||||
h(DockerSidePanel, { title: "Volumes", items: volumes, limit: volumes.length, render: (volume: any) => h("article", { key: volume.name, className: `docker-side-row volume-row ${isDatabaseVolume(volume) ? "database-volume" : ""}`, "data-testid": isDatabaseVolume(volume) ? "database-volume-row" : undefined },
|
||||
h(DockerSidePanel, { title: "Volumes", items: volumes, limit: volumes.length, render: (volume: any) => h("article", { key: volume.name, className: `docker-side-row volume-row ${isMainServer && isDatabaseVolume(volume) ? "database-volume" : ""}`, "data-testid": isMainServer && isDatabaseVolume(volume) ? "database-volume-row" : undefined },
|
||||
h("strong", null, volume.name),
|
||||
h("span", null, isDatabaseVolume(volume) ? "PostgreSQL" : isHashVolumeName(String(volume.name || "")) ? "anonymous" : "named"),
|
||||
h("span", null, isMainServer && isDatabaseVolume(volume) ? "PostgreSQL" : isHashVolumeName(String(volume.name || "")) ? "anonymous" : "named"),
|
||||
h("code", null, volume.mountpoint || volume.driver || volume.scope || "--"),
|
||||
) }),
|
||||
h(DockerSidePanel, { title: "Networks", items: networks, render: (network: any) => h("article", { key: network.id || network.name, className: "docker-side-row" }, h("strong", null, network.name), h("span", null, network.driver || "--"), h("code", null, network.id || "--")) }),
|
||||
@@ -1147,6 +1095,7 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord
|
||||
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,
|
||||
service.id === "todo-note" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "todo-note"), "data-testid": "open-todo-note-button" }, "打开") : null,
|
||||
h(RawButton, { title: `Microservice ${service.id}`, data: service, onOpen: onRaw }),
|
||||
),
|
||||
),
|
||||
@@ -1157,247 +1106,6 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord
|
||||
);
|
||||
}
|
||||
|
||||
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 || "");
|
||||
@@ -1644,8 +1352,9 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
|
||||
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 === "apps" && activeTab === "todo-note") return h(TodoNotePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
|
||||
if (activeModule === "apps" && activeTab === "findjob") return h(FindJobPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
|
||||
if (activeModule === "apps" && activeTab === "pipeline") return h(PipelinePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
|
||||
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);
|
||||
@@ -1660,6 +1369,7 @@ function Shell({ session, onLogout }: AnyRecord) {
|
||||
const [lastRefresh, setLastRefresh] = useState(null);
|
||||
const [clock, setClock] = useState(new Date());
|
||||
const [raw, setRaw] = useState(null);
|
||||
const [railCollapsed, setRailCollapsed] = useState(false);
|
||||
|
||||
const module = MODULES.find((item: any) => item.id === activeModule) || MODULES[0];
|
||||
const activeTab = activeTabs[activeModule] || module.tabs[0].id;
|
||||
@@ -1724,8 +1434,8 @@ function Shell({ session, onLogout }: AnyRecord) {
|
||||
setRaw({ title, data: rawData });
|
||||
}
|
||||
|
||||
return h("div", { className: "shell", "data-testid": "app-shell" },
|
||||
h(Sidebar, { activeModule, onChange: setActiveModule }),
|
||||
return h("div", { className: `shell ${railCollapsed ? "rail-collapsed" : ""}`, "data-testid": "app-shell" },
|
||||
h(Sidebar, { activeModule, onChange: setActiveModule, collapsed: railCollapsed, onToggle: () => setRailCollapsed((value: boolean) => !value) }),
|
||||
h("main", { className: "workspace" },
|
||||
h(TopBar, { connection, lastRefresh, onRefresh: refresh, onLogout: () => onLogout(true), session, clock }),
|
||||
h(TabBar, { module, activeTab, onChange: setTab }),
|
||||
@@ -1765,4 +1475,6 @@ function App() {
|
||||
return h(Shell, { session, onLogout: logout });
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(h(App));
|
||||
const rootElement = document.getElementById("root");
|
||||
if (rootElement === null) throw new Error("root element not found");
|
||||
createRoot(rootElement).render(h(App));
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import React from "react";
|
||||
|
||||
type AnyRecord = Record<string, any>;
|
||||
|
||||
const h = React.createElement;
|
||||
const { useEffect } = React;
|
||||
const useState: any = React.useState;
|
||||
|
||||
function errorMessage(error: unknown, fallback = "操作失败"): string {
|
||||
return error instanceof Error ? error.message : String(error || fallback);
|
||||
}
|
||||
|
||||
function fmtDate(value: any): string {
|
||||
if (!value) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function fmtClock(value: Date): string {
|
||||
return value.toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
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");
|
||||
const response = await fetch(path, { credentials: "same-origin", ...options, headers });
|
||||
const text = await response.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = { text };
|
||||
}
|
||||
if (!response.ok || body?.ok === false) {
|
||||
const message = body?.error?.message || body?.error || `HTTP ${response.status}`;
|
||||
const error = new Error(message);
|
||||
(error as Error & { status?: number }).status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function StatusBadge({ status, children }: AnyRecord) {
|
||||
const normalized = String(status || "unknown").toLowerCase();
|
||||
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, hint, tone }: AnyRecord) {
|
||||
return h("article", { className: `metric-card ${tone || ""}` },
|
||||
h("div", { className: "metric-label" }, label),
|
||||
h("div", { className: "metric-value" }, value),
|
||||
h("div", { className: "metric-hint" }, hint),
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, eyebrow, actions, children, className }: AnyRecord) {
|
||||
return h("section", { className: `panel ${className || ""}` },
|
||||
h("div", { className: "panel-head" },
|
||||
h("div", null,
|
||||
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
|
||||
h("h2", null, title),
|
||||
),
|
||||
actions ? h("div", { className: "panel-actions" }, actions) : null,
|
||||
),
|
||||
h("div", { className: "panel-body" }, children),
|
||||
);
|
||||
}
|
||||
|
||||
function RawButton({ title, data, onOpen, testId }: AnyRecord) {
|
||||
return h("button", {
|
||||
type: "button",
|
||||
className: "ghost-btn",
|
||||
"data-testid": testId,
|
||||
onClick: () => onOpen(title, data),
|
||||
}, "查看原始JSON");
|
||||
}
|
||||
|
||||
function EmptyState({ title, text }: AnyRecord) {
|
||||
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export function FindJobPage({ microservices, onRaw, apiBaseUrl = "/api" }: 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(`${apiBaseUrl}/microservices/findjob/health`),
|
||||
requestJson(`${apiBaseUrl}/microservices/findjob/proxy/api/summary`),
|
||||
requestJson(`${apiBaseUrl}/microservices/findjob/proxy/api/jobs?__unideskArrayLimit=jobs:40`),
|
||||
requestJson(`${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" })),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,11 @@ async function buildFrontendApp(): Promise<string> {
|
||||
entrypoints: [join(import.meta.dir, "app.tsx")],
|
||||
target: "browser",
|
||||
format: "iife",
|
||||
define: {
|
||||
"process.env.NODE_ENV": "\"production\"",
|
||||
"import.meta.env": "{\"MODE\":\"production\"}",
|
||||
"import.meta.env.MODE": "\"production\"",
|
||||
},
|
||||
minify: false,
|
||||
sourcemap: "none",
|
||||
});
|
||||
@@ -259,6 +264,7 @@ async function proxyApi(req: Request, url: URL): Promise<Response> {
|
||||
function vendorPath(pathname: string): string | null {
|
||||
if (pathname === "/vendor/react.production.min.js") return join(vendorDir, "react", "umd", "react.production.min.js");
|
||||
if (pathname === "/vendor/react-dom.production.min.js") return join(vendorDir, "react-dom", "umd", "react-dom.production.min.js");
|
||||
if (pathname === "/vendor/xyflow.css") return join(vendorDir, "@xyflow", "react", "dist", "style.css");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
import React from "react";
|
||||
import { Background, BaseEdge, Controls, Handle, MarkerType, MiniMap, Position, ReactFlow, type Edge, type Node } from "@xyflow/react";
|
||||
|
||||
type AnyRecord = Record<string, any>;
|
||||
|
||||
const h = React.createElement;
|
||||
const { useEffect } = React;
|
||||
const useState: any = React.useState;
|
||||
|
||||
const pipelineInputPorts: AnyRecord[] = [
|
||||
{ id: "in-left-top", side: "left", position: Position.Left, style: { top: "24%" } },
|
||||
{ id: "in-left-mid", side: "left", position: Position.Left, style: { top: "50%" } },
|
||||
{ id: "in-left-bottom", side: "left", position: Position.Left, style: { top: "76%" } },
|
||||
{ id: "in-top-left", side: "top", position: Position.Top, style: { left: "28%" } },
|
||||
{ id: "in-top-mid", side: "top", position: Position.Top, style: { left: "50%" } },
|
||||
{ id: "in-top-right", side: "top", position: Position.Top, style: { left: "72%" } },
|
||||
{ id: "in-bottom-left", side: "bottom", position: Position.Bottom, style: { left: "28%" } },
|
||||
{ id: "in-bottom-mid", side: "bottom", position: Position.Bottom, style: { left: "50%" } },
|
||||
{ id: "in-bottom-right", side: "bottom", position: Position.Bottom, style: { left: "72%" } },
|
||||
];
|
||||
|
||||
const pipelineOutputPorts: AnyRecord[] = [
|
||||
{ id: "out-right-top", position: Position.Right, style: { top: "24%" } },
|
||||
{ id: "out-right-mid", position: Position.Right, style: { top: "50%" } },
|
||||
{ id: "out-right-bottom", position: Position.Right, style: { top: "76%" } },
|
||||
];
|
||||
|
||||
function PipelineFlowNode({ data }: AnyRecord) {
|
||||
return h("div", { className: "pipeline-flow-node-body" },
|
||||
pipelineInputPorts.map((port) => h(Handle, {
|
||||
key: port.id,
|
||||
id: port.id,
|
||||
type: "target",
|
||||
position: port.position,
|
||||
isConnectable: false,
|
||||
className: `pipeline-flow-handle input ${port.side}`,
|
||||
style: port.style,
|
||||
})),
|
||||
pipelineOutputPorts.map((port) => h(Handle, {
|
||||
key: port.id,
|
||||
id: port.id,
|
||||
type: "source",
|
||||
position: port.position,
|
||||
isConnectable: false,
|
||||
className: "pipeline-flow-handle output right",
|
||||
style: port.style,
|
||||
})),
|
||||
data?.label,
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineCurveEdge({ id, sourceX, sourceY, targetX, targetY, targetPosition, markerEnd, markerStart, style, data }: AnyRecord) {
|
||||
const laneOffset = Number(data?.laneOffset || 0);
|
||||
const forward = targetX >= sourceX;
|
||||
const distance = Math.max(1, Math.abs(targetX - sourceX));
|
||||
const handleOffset = Math.max(8, Math.min(92, distance * 0.24));
|
||||
let path = "";
|
||||
if (targetPosition === Position.Top || targetPosition === Position.Bottom) {
|
||||
const sourceRailX = sourceX + handleOffset;
|
||||
const verticalSign = targetPosition === Position.Top ? -1 : 1;
|
||||
const railY = targetY + verticalSign * (46 + Math.min(86, Math.abs(laneOffset) * 0.35));
|
||||
path = [
|
||||
`M ${sourceX},${sourceY}`,
|
||||
`C ${sourceRailX},${sourceY} ${sourceRailX},${sourceY} ${sourceRailX},${sourceY}`,
|
||||
`L ${sourceRailX},${railY}`,
|
||||
`L ${targetX},${railY}`,
|
||||
`C ${targetX},${railY} ${targetX},${targetY - verticalSign * 18} ${targetX},${targetY}`,
|
||||
].join(" ");
|
||||
} else if (forward && Math.abs(laneOffset) < 4) {
|
||||
path = [
|
||||
`M ${sourceX},${sourceY}`,
|
||||
`C ${sourceX + handleOffset},${sourceY} ${targetX - handleOffset},${targetY} ${targetX},${targetY}`,
|
||||
].join(" ");
|
||||
} else if (forward) {
|
||||
const sourceRailX = sourceX + handleOffset;
|
||||
const targetRailX = targetX - handleOffset;
|
||||
const railY = (sourceY + targetY) / 2 + laneOffset;
|
||||
path = [
|
||||
`M ${sourceX},${sourceY}`,
|
||||
`C ${sourceRailX},${sourceY} ${sourceRailX},${sourceY} ${sourceRailX},${sourceY}`,
|
||||
`L ${sourceRailX},${railY}`,
|
||||
`L ${targetRailX},${railY}`,
|
||||
`C ${targetRailX},${railY} ${targetRailX},${targetY} ${targetRailX},${targetY}`,
|
||||
`C ${targetRailX},${targetY} ${targetRailX},${targetY} ${targetX},${targetY}`,
|
||||
].join(" ");
|
||||
} else {
|
||||
const detourX = Math.max(sourceX, targetX) + 92 + Math.min(130, Math.abs(laneOffset));
|
||||
const railY = (sourceY + targetY) / 2 + laneOffset;
|
||||
path = [
|
||||
`M ${sourceX},${sourceY}`,
|
||||
`C ${detourX},${sourceY} ${detourX},${railY} ${detourX},${railY}`,
|
||||
`C ${detourX},${targetY} ${targetX - handleOffset},${targetY} ${targetX},${targetY}`,
|
||||
].join(" ");
|
||||
}
|
||||
return h(BaseEdge, { id, path, markerEnd, markerStart, style, interactionWidth: 28 });
|
||||
}
|
||||
|
||||
const pipelineEdgeTypes: any = { pipelineCurve: PipelineCurveEdge };
|
||||
const pipelineNodeTypes: any = { pipelineNode: PipelineFlowNode };
|
||||
|
||||
function errorMessage(error: unknown, fallback = "操作失败"): string {
|
||||
return error instanceof Error ? error.message : String(error || fallback);
|
||||
}
|
||||
|
||||
function fmtDate(value: any): string {
|
||||
if (!value) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function fmtClock(value: Date): string {
|
||||
return value.toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function summarizeValue(value: any): string {
|
||||
if (value === null || value === undefined) return "--";
|
||||
if (typeof value === "boolean") return value ? "是" : "否";
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}...` : value;
|
||||
if (Array.isArray(value)) return `${value.length} 项`;
|
||||
if (typeof value === "object") return `${Object.keys(value).length} 字段`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
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");
|
||||
const response = await fetch(path, { credentials: "same-origin", ...options, headers });
|
||||
const text = await response.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = { text };
|
||||
}
|
||||
if (!response.ok || body?.ok === false) {
|
||||
const message = body?.error?.message || body?.error || `HTTP ${response.status}`;
|
||||
const error = new Error(message);
|
||||
(error as Error & { status?: number }).status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function StatusBadge({ status, children }: AnyRecord) {
|
||||
const normalized = String(status || "unknown").toLowerCase();
|
||||
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, hint, tone }: AnyRecord) {
|
||||
return h("article", { className: `metric-card ${tone || ""}` },
|
||||
h("div", { className: "metric-label" }, label),
|
||||
h("div", { className: "metric-value" }, value),
|
||||
h("div", { className: "metric-hint" }, hint),
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, eyebrow, actions, children, className }: AnyRecord) {
|
||||
return h("section", { className: `panel ${className || ""}` },
|
||||
h("div", { className: "panel-head" },
|
||||
h("div", null,
|
||||
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
|
||||
h("h2", null, title),
|
||||
),
|
||||
actions ? h("div", { className: "panel-actions" }, actions) : null,
|
||||
),
|
||||
h("div", { className: "panel-body" }, children),
|
||||
);
|
||||
}
|
||||
|
||||
function RawButton({ title, data, onOpen, testId }: AnyRecord) {
|
||||
return h("button", {
|
||||
type: "button",
|
||||
className: "ghost-btn",
|
||||
"data-testid": testId,
|
||||
onClick: () => onOpen(title, data),
|
||||
}, "查看原始JSON");
|
||||
}
|
||||
|
||||
function EmptyState({ title, text }: AnyRecord) {
|
||||
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
|
||||
}
|
||||
|
||||
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 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));
|
||||
}
|
||||
|
||||
function pipelineRawNodeIds(value: any): string[] {
|
||||
if (Array.isArray(value)) return value.map((item) => typeof item === "string" ? item : String(item?.id || item?.nodeId || "")).filter(Boolean);
|
||||
if (Array.isArray(value?.nodes)) return pipelineRawNodeIds(value.nodes);
|
||||
if (Array.isArray(value?.nodeIds)) return pipelineRawNodeIds(value.nodeIds);
|
||||
return [];
|
||||
}
|
||||
|
||||
function pipelineGraphColumns(pipeline: any): string[][] {
|
||||
const rawBatches = Array.isArray(pipeline?.topologicalBatches) ? pipeline.topologicalBatches : [];
|
||||
const explicit = rawBatches.map(pipelineRawNodeIds).filter((batch: string[]) => batch.length > 0);
|
||||
if (explicit.length > 0) return explicit;
|
||||
|
||||
const nodes = Array.isArray(pipeline?.nodes) ? pipeline.nodes : [];
|
||||
const ids: string[] = nodes.map((node: any) => String(node?.id || "")).filter(Boolean);
|
||||
const idSet = new Set(ids);
|
||||
const rawEdges = Array.isArray(pipeline?.edges) ? pipeline.edges : [];
|
||||
const incoming: Map<string, number> = new Map(ids.map((id) => [id, 0]));
|
||||
const outgoing: Map<string, string[]> = new Map(ids.map((id) => [id, []]));
|
||||
for (const edge of rawEdges) {
|
||||
const from = String(edge?.from || edge?.source || "");
|
||||
const to = String(edge?.to || edge?.target || "");
|
||||
if (!idSet.has(from) || !idSet.has(to)) continue;
|
||||
outgoing.get(from)?.push(to);
|
||||
incoming.set(to, (incoming.get(to) || 0) + 1);
|
||||
}
|
||||
const levels = new Map<string, number>();
|
||||
const queue = ids.filter((id) => (incoming.get(id) || 0) === 0);
|
||||
for (const id of queue) levels.set(id, 0);
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const nextLevel = (levels.get(current) || 0) + 1;
|
||||
for (const next of outgoing.get(current) || []) {
|
||||
incoming.set(next, Math.max(0, (incoming.get(next) || 0) - 1));
|
||||
levels.set(next, Math.max(levels.get(next) || 0, nextLevel));
|
||||
if ((incoming.get(next) || 0) === 0) queue.push(next);
|
||||
}
|
||||
}
|
||||
ids.forEach((id) => { if (!levels.has(id)) levels.set(id, 0); });
|
||||
const maxLevel = Math.max(0, ...Array.from(levels.values()));
|
||||
return Array.from({ length: maxLevel + 1 }, (_, level) => ids.filter((id) => levels.get(id) === level)).filter((batch) => batch.length > 0);
|
||||
}
|
||||
|
||||
function pipelineFlowEdgeKey(edge: AnyRecord): string {
|
||||
return `${edge.source}->${edge.target}-${edge.index}`;
|
||||
}
|
||||
|
||||
function pipelineFlowElements(pipeline: any, latestRun: any): { nodes: Node[]; edges: Edge[] } {
|
||||
const pipelineNodes = Array.isArray(pipeline?.nodes) ? pipeline.nodes : [];
|
||||
const nodeById: Map<string, AnyRecord> = new Map(pipelineNodes.map((node: any) => [String(node?.id || ""), node]));
|
||||
const columns = pipelineGraphColumns(pipeline);
|
||||
const flowNodes: Node[] = [];
|
||||
const nodeLayout: Map<string, { column: number; row: number; y: number }> = new Map();
|
||||
const columnGap = 330;
|
||||
const rowGap = 122;
|
||||
columns.forEach((batch, columnIndex) => {
|
||||
const columnHeight = batch.length * rowGap;
|
||||
batch.forEach((nodeId, rowIndex) => {
|
||||
const node = nodeById.get(nodeId) || { id: nodeId };
|
||||
const status = pipelineRunNodeStatus(latestRun, nodeId).toLowerCase();
|
||||
const kind = String(node.kind || "node").toLowerCase();
|
||||
const y = rowIndex * rowGap - Math.floor(columnHeight / 2);
|
||||
nodeLayout.set(nodeId, { column: columnIndex, row: rowIndex, y });
|
||||
flowNodes.push({
|
||||
id: nodeId,
|
||||
type: "pipelineNode",
|
||||
position: {
|
||||
x: columnIndex * columnGap,
|
||||
y,
|
||||
},
|
||||
data: {
|
||||
label: h("div", { className: "flow-node-label" },
|
||||
h("strong", null, nodeId),
|
||||
h("span", null, kind),
|
||||
h("code", null, pipelineComponentRef(node.componentRef)),
|
||||
h(StatusBadge, { status }, status),
|
||||
),
|
||||
},
|
||||
className: `pipeline-flow-node ${kind} ${status}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
const graphEdges = (Array.isArray(pipeline?.edges) ? pipeline.edges : []).flatMap((edge: any, index: number) => {
|
||||
const source = String(edge?.from || edge?.source || "");
|
||||
const target = String(edge?.to || edge?.target || "");
|
||||
if (!nodeById.has(source) || !nodeById.has(target)) return [];
|
||||
return [{ source, target, index }];
|
||||
});
|
||||
const sourceTotals = graphEdges.reduce((memo: Map<string, number>, edge: AnyRecord) => memo.set(edge.source, (memo.get(edge.source) || 0) + 1), new Map<string, number>());
|
||||
const targetTotals = graphEdges.reduce((memo: Map<string, number>, edge: AnyRecord) => memo.set(edge.target, (memo.get(edge.target) || 0) + 1), new Map<string, number>());
|
||||
const pairTotals = graphEdges.reduce((memo: Map<string, number>, edge: AnyRecord) => {
|
||||
const key = `${edge.source}->${edge.target}`;
|
||||
return memo.set(key, (memo.get(key) || 0) + 1);
|
||||
}, new Map<string, number>());
|
||||
const sourceSeen = new Map<string, number>();
|
||||
const targetSeen = new Map<string, number>();
|
||||
const pairSeen = new Map<string, number>();
|
||||
const targetHandleByEdge = new Map<string, AnyRecord>();
|
||||
const targetPortLoads: Map<string, Map<string, number>> = new Map();
|
||||
const sortedIncomingEdges = [...graphEdges].sort((left: AnyRecord, right: AnyRecord) => {
|
||||
const leftSource = nodeLayout.get(left.source);
|
||||
const leftTarget = nodeLayout.get(left.target);
|
||||
const rightSource = nodeLayout.get(right.source);
|
||||
const rightTarget = nodeLayout.get(right.target);
|
||||
const leftScore = Math.abs((leftTarget?.column || 0) - (leftSource?.column || 0)) * columnGap + Math.abs((leftTarget?.y || 0) - (leftSource?.y || 0));
|
||||
const rightScore = Math.abs((rightTarget?.column || 0) - (rightSource?.column || 0)) * columnGap + Math.abs((rightTarget?.y || 0) - (rightSource?.y || 0));
|
||||
return leftScore - rightScore || left.index - right.index;
|
||||
});
|
||||
sortedIncomingEdges.forEach((edge: AnyRecord) => {
|
||||
const sourceLayout = nodeLayout.get(edge.source) || { column: 0, row: 0, y: 0 };
|
||||
const targetLayout = nodeLayout.get(edge.target) || { column: 0, row: 0, y: 0 };
|
||||
const span = Math.max(0, targetLayout.column - sourceLayout.column);
|
||||
const verticalDelta = sourceLayout.y - targetLayout.y;
|
||||
const loads = targetPortLoads.get(edge.target) || new Map<string, number>();
|
||||
targetPortLoads.set(edge.target, loads);
|
||||
const port = pipelineInputPorts.slice().sort((left, right) => {
|
||||
const loadDiff = (loads.get(left.id) || 0) - (loads.get(right.id) || 0);
|
||||
if (loadDiff !== 0) return loadDiff;
|
||||
const score = (item: AnyRecord): number => {
|
||||
const side = String(item.side);
|
||||
let value = 0;
|
||||
if (side === "left") value += span <= 1 ? 0 : 24;
|
||||
if (side === "top") value += verticalDelta < -36 ? -18 : 42;
|
||||
if (side === "bottom") value += verticalDelta > 36 ? -18 : 42;
|
||||
if (span <= 1 && Math.abs(verticalDelta) <= 82 && side !== "left") value += 38;
|
||||
if (span > 1 && side !== "left") value -= 10;
|
||||
return value;
|
||||
};
|
||||
return score(left) - score(right);
|
||||
})[0];
|
||||
loads.set(port.id, (loads.get(port.id) || 0) + 1);
|
||||
targetHandleByEdge.set(pipelineFlowEdgeKey(edge), port);
|
||||
});
|
||||
const edges: Edge[] = graphEdges.map((edge: AnyRecord) => {
|
||||
const targetStatus = pipelineRunNodeStatus(latestRun, edge.target).toLowerCase();
|
||||
const pairKey = `${edge.source}->${edge.target}`;
|
||||
const sourceSlot = sourceSeen.get(edge.source) || 0;
|
||||
const targetSlot = targetSeen.get(edge.target) || 0;
|
||||
const pairSlot = pairSeen.get(pairKey) || 0;
|
||||
sourceSeen.set(edge.source, sourceSlot + 1);
|
||||
targetSeen.set(edge.target, targetSlot + 1);
|
||||
pairSeen.set(pairKey, pairSlot + 1);
|
||||
const sourceLane = sourceSlot - ((sourceTotals.get(edge.source) || 1) - 1) / 2;
|
||||
const targetLane = targetSlot - ((targetTotals.get(edge.target) || 1) - 1) / 2;
|
||||
const pairLane = pairSlot - ((pairTotals.get(pairKey) || 1) - 1) / 2;
|
||||
const sourceLayout = nodeLayout.get(edge.source);
|
||||
const targetLayout = nodeLayout.get(edge.target);
|
||||
const span = Math.max(1, (targetLayout?.column || 0) - (sourceLayout?.column || 0));
|
||||
const verticalDelta = Math.abs((targetLayout?.y || 0) - (sourceLayout?.y || 0));
|
||||
const lane = pairLane * 2 + sourceLane + targetLane * 0.45;
|
||||
const railDirection = lane === 0 ? (edge.index % 2 === 0 ? -1 : 1) : Math.sign(lane);
|
||||
const targetPort = targetHandleByEdge.get(pipelineFlowEdgeKey(edge)) || pipelineInputPorts[1];
|
||||
const shouldScatter = span > 1 || verticalDelta > 96 || Math.abs(lane) > 0.2 || targetPort.side !== "left";
|
||||
const laneOffset = shouldScatter ? Math.max(-220, Math.min(220, lane * 34 + railDirection * Math.min(92, 22 + span * 16))) : 0;
|
||||
const sourceHandleIndex = Math.max(0, Math.min(pipelineOutputPorts.length - 1, Math.round(sourceLane + (pipelineOutputPorts.length - 1) / 2)));
|
||||
const sourcePort = pipelineOutputPorts[sourceHandleIndex] || pipelineOutputPorts[1];
|
||||
const edgeColor = targetStatus === "succeeded" ? "var(--accent-2)" : targetStatus === "running" ? "var(--accent)" : targetStatus === "failed" ? "var(--danger)" : "rgba(129, 147, 159, 0.78)";
|
||||
return {
|
||||
id: `${edge.source}->${edge.target}-${edge.index}`,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: sourcePort.id,
|
||||
targetHandle: targetPort.id,
|
||||
type: "pipelineCurve",
|
||||
zIndex: 12,
|
||||
animated: targetStatus === "running",
|
||||
data: { laneOffset, targetSide: targetPort.side },
|
||||
style: { stroke: edgeColor },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: edgeColor },
|
||||
className: `pipeline-flow-edge ${targetStatus}`,
|
||||
};
|
||||
});
|
||||
return { nodes: flowNodes, edges };
|
||||
}
|
||||
|
||||
export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: 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(`${apiBaseUrl}/microservices/pipeline/health`),
|
||||
requestJson(`${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);
|
||||
const flow = pipelineFlowElements(activePipeline, latestRun);
|
||||
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: "pipeline-flow-frame", "data-testid": "pipeline-react-flow" },
|
||||
h(ReactFlow, {
|
||||
nodes: flow.nodes,
|
||||
edges: flow.edges,
|
||||
nodeTypes: pipelineNodeTypes,
|
||||
edgeTypes: pipelineEdgeTypes,
|
||||
fitView: true,
|
||||
fitViewOptions: { padding: 0.18 },
|
||||
nodesDraggable: false,
|
||||
nodesConnectable: false,
|
||||
elementsSelectable: true,
|
||||
minZoom: 0.25,
|
||||
maxZoom: 1.4,
|
||||
proOptions: { hideAttribution: true },
|
||||
},
|
||||
h(Background, { gap: 22, size: 1, color: "rgba(215, 161, 58, 0.24)" }),
|
||||
h(MiniMap, { pannable: true, zoomable: true, className: "pipeline-minimap" }),
|
||||
h(Controls, { showInteractive: false }),
|
||||
),
|
||||
),
|
||||
h("div", { className: "pipeline-flow-summary" },
|
||||
h("span", null, `${flow.nodes.length} nodes`),
|
||||
h("span", null, `${flow.edges.length} edges`),
|
||||
h("span", null, `latest ${latestRun?.runId || "--"}`),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
import React from "react";
|
||||
|
||||
type AnyRecord = Record<string, any>;
|
||||
type ReactNode = any;
|
||||
|
||||
const h = React.createElement;
|
||||
const { useEffect } = React;
|
||||
const useState: any = React.useState;
|
||||
|
||||
function errorMessage(error: unknown, fallback = "操作失败"): string {
|
||||
return error instanceof Error ? error.message : String(error || fallback);
|
||||
}
|
||||
|
||||
function fmtDate(value: any): string {
|
||||
if (!value) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function fmtClock(value: Date): string {
|
||||
return value.toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
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");
|
||||
const response = await fetch(path, { credentials: "same-origin", ...options, headers });
|
||||
const text = await response.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = { text };
|
||||
}
|
||||
if (!response.ok || body?.ok === false) {
|
||||
const message = body?.error?.message || body?.error || `HTTP ${response.status}`;
|
||||
const error = new Error(message);
|
||||
(error as Error & { status?: number }).status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function StatusBadge({ status, children }: AnyRecord) {
|
||||
const normalized = String(status || "unknown").toLowerCase();
|
||||
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, hint, tone }: AnyRecord) {
|
||||
return h("article", { className: `metric-card ${tone || ""}` },
|
||||
h("div", { className: "metric-label" }, label),
|
||||
h("div", { className: "metric-value" }, value),
|
||||
h("div", { className: "metric-hint" }, hint),
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, eyebrow, actions, children, className }: AnyRecord) {
|
||||
return h("section", { className: `panel ${className || ""}` },
|
||||
h("div", { className: "panel-head" },
|
||||
h("div", null,
|
||||
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
|
||||
h("h2", null, title),
|
||||
),
|
||||
actions ? h("div", { className: "panel-actions" }, actions) : null,
|
||||
),
|
||||
h("div", { className: "panel-body" }, children),
|
||||
);
|
||||
}
|
||||
|
||||
function RawButton({ title, data, onOpen, testId }: AnyRecord) {
|
||||
return h("button", {
|
||||
type: "button",
|
||||
className: "ghost-btn",
|
||||
"data-testid": testId,
|
||||
onClick: () => onOpen(title, data),
|
||||
}, "查看原始JSON");
|
||||
}
|
||||
|
||||
function EmptyState({ title, text }: AnyRecord) {
|
||||
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
|
||||
}
|
||||
|
||||
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 safeId(value: any): string {
|
||||
return String(value).replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
}
|
||||
|
||||
function fmtPercent(value: number): string {
|
||||
if (!Number.isFinite(value)) return "--";
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function todoApi(apiBaseUrl: string, path: string): string {
|
||||
return `${apiBaseUrl}/microservices/todo-note/proxy${path}`;
|
||||
}
|
||||
|
||||
function todoCount(nodes: any[]): { total: number; completed: number; active: number } {
|
||||
return nodes.reduce((memo, node: any) => {
|
||||
const nested = todoCount(Array.isArray(node.children) ? node.children : []);
|
||||
const done = Boolean(node.completed);
|
||||
return {
|
||||
total: memo.total + 1 + nested.total,
|
||||
completed: memo.completed + (done ? 1 : 0) + nested.completed,
|
||||
active: memo.active + (done ? 0 : 1) + nested.active,
|
||||
};
|
||||
}, { total: 0, completed: 0, active: 0 });
|
||||
}
|
||||
|
||||
function todoVisible(todo: any, filter: string): boolean {
|
||||
const selfVisible = filter === "all" || (filter === "completed" ? Boolean(todo.completed) : !todo.completed);
|
||||
const children = Array.isArray(todo.children) ? todo.children : [];
|
||||
return selfVisible || children.some((child: any) => todoVisible(child, filter));
|
||||
}
|
||||
|
||||
function todoReminderLocal(value: any): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function localReminderIso(value: string): string | null {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
function todoRegistryRows(registry: any): any[] {
|
||||
return Array.isArray(registry?.instances) ? registry.instances : [];
|
||||
}
|
||||
|
||||
export function TodoNotePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
|
||||
const service = microservices.find((item: any) => item.id === "todo-note") || null;
|
||||
const [health, setHealth] = useState(null);
|
||||
const [registry, setRegistry] = useState(null);
|
||||
const [activeId, setActiveId] = useState("");
|
||||
const [instance, setInstance] = useState(null);
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [fontSize, setFontSize] = useState(13);
|
||||
const [newListName, setNewListName] = useState("");
|
||||
const [newTodoTitle, setNewTodoTitle] = useState("");
|
||||
const [editingId, setEditingId] = useState("");
|
||||
const [editingTitle, setEditingTitle] = useState("");
|
||||
const [dragTodoId, setDragTodoId] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [refreshedAt, setRefreshedAt] = useState(null);
|
||||
|
||||
const rows = todoRegistryRows(registry);
|
||||
const counts = todoCount(Array.isArray(instance?.todos) ? instance.todos : []);
|
||||
const runtime = service ? microserviceRuntime(service) : {};
|
||||
const repository = service ? microserviceRepository(service) : {};
|
||||
const backend = service ? microserviceBackend(service) : {};
|
||||
|
||||
async function loadRegistry(preferId = activeId): Promise<string> {
|
||||
const [healthResult, registryResult] = await Promise.all([
|
||||
requestJson(`${apiBaseUrl}/microservices/todo-note/health`),
|
||||
requestJson(todoApi(apiBaseUrl, "/api/instances")),
|
||||
]);
|
||||
setHealth(healthResult);
|
||||
setRegistry(registryResult);
|
||||
const nextRows = todoRegistryRows(registryResult);
|
||||
const nextId = nextRows.some((row: any) => row.id === preferId) ? preferId : nextRows[0]?.id || "";
|
||||
setActiveId(nextId);
|
||||
return nextId;
|
||||
}
|
||||
|
||||
async function loadInstance(instanceId = activeId): Promise<void> {
|
||||
if (!instanceId) {
|
||||
setInstance(null);
|
||||
return;
|
||||
}
|
||||
const result = await requestJson(todoApi(apiBaseUrl, `/api/instances/${encodeURIComponent(instanceId)}`));
|
||||
setInstance(result);
|
||||
}
|
||||
|
||||
async function refresh(preferId = activeId): Promise<void> {
|
||||
if (!service) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const nextId = await loadRegistry(preferId);
|
||||
await loadInstance(nextId);
|
||||
setRefreshedAt(new Date());
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "Todo Note 加载失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyTodoAction(action: AnyRecord): Promise<void> {
|
||||
if (!activeId) return;
|
||||
setError("");
|
||||
try {
|
||||
const updated = await requestJson(todoApi(apiBaseUrl, `/api/instances/${encodeURIComponent(activeId)}/actions`), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
setInstance(updated);
|
||||
await loadRegistry(activeId);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "Todo 操作失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function createList(event: any): Promise<void> {
|
||||
event.preventDefault();
|
||||
const name = newListName.trim();
|
||||
if (!name) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await requestJson(todoApi(apiBaseUrl, "/api/instances"), { method: "POST", body: JSON.stringify({ name }) });
|
||||
setNewListName("");
|
||||
await refresh(created.id);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "创建清单失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteList(instanceId: string): Promise<void> {
|
||||
if (!window.confirm("确认删除这个 Todo Note 清单?")) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await requestJson(todoApi(apiBaseUrl, `/api/instances/${encodeURIComponent(instanceId)}`), { method: "DELETE" });
|
||||
await refresh(activeId === instanceId ? "" : activeId);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "删除清单失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function addRootTodo(event: any): Promise<void> {
|
||||
event.preventDefault();
|
||||
const title = newTodoTitle.trim();
|
||||
if (!title) return;
|
||||
setNewTodoTitle("");
|
||||
await applyTodoAction({ type: "addTodo", title });
|
||||
}
|
||||
|
||||
async function undoRedo(kind: "undo" | "redo"): Promise<void> {
|
||||
if (!activeId) return;
|
||||
setError("");
|
||||
try {
|
||||
const updated = await requestJson(todoApi(apiBaseUrl, `/api/instances/${encodeURIComponent(activeId)}/${kind}`), { method: "POST", body: JSON.stringify({}) });
|
||||
setInstance(updated);
|
||||
await loadRegistry(activeId);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, `${kind} 失败`));
|
||||
}
|
||||
}
|
||||
|
||||
function beginEdit(todo: any): void {
|
||||
setEditingId(todo.id);
|
||||
setEditingTitle(String(todo.title || ""));
|
||||
}
|
||||
|
||||
async function saveEdit(todoId: string): Promise<void> {
|
||||
const title = editingTitle.trim();
|
||||
setEditingId("");
|
||||
setEditingTitle("");
|
||||
if (title) await applyTodoAction({ type: "updateTodoTitle", todoId, title });
|
||||
}
|
||||
|
||||
async function addChild(todoId: string): Promise<void> {
|
||||
const title = window.prompt("新增子任务标题");
|
||||
if (title && title.trim()) await applyTodoAction({ type: "addTodo", title: title.trim(), parentId: todoId });
|
||||
}
|
||||
|
||||
async function dropTodo(targetParentId: string | null, targetIndex: number): Promise<void> {
|
||||
if (!dragTodoId) return;
|
||||
const payload: AnyRecord = { type: "moveTodo", todoId: dragTodoId, targetIndex };
|
||||
if (targetParentId) payload.targetParentId = targetParentId;
|
||||
setDragTodoId("");
|
||||
await applyTodoAction(payload);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [service?.id, service?.runtime?.providerStatus]);
|
||||
|
||||
if (!service) return h(EmptyState, { title: "Todo Note 未登记", text: "请在 config.json 的 microservices 中登记 id=todo-note" });
|
||||
|
||||
const activeSummary = rows.find((row: any) => row.id === activeId) || null;
|
||||
return h("div", { className: "todo-note-page", "data-testid": "todo-note-page" },
|
||||
h(Panel, {
|
||||
title: "Todo Note 工作台",
|
||||
eyebrow: "Main Server Backend Microservice",
|
||||
actions: h("div", { className: "panel-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", disabled: loading, onClick: () => refresh(activeId), "data-testid": "todo-note-refresh-button" }, loading ? "刷新中" : "刷新"),
|
||||
h(RawButton, { title: "Todo Note Microservice", data: service, onOpen: onRaw, testId: "raw-todo-note-service" }),
|
||||
),
|
||||
},
|
||||
h("div", { className: "todo-note-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("span", null, health?.ok ? "Health OK" : "Health --"),
|
||||
),
|
||||
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, "Main Server Docker"), h("strong", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`), h("code", null, `${repository.composeService || "--"} / ${repository.containerName || "--"}`)),
|
||||
),
|
||||
error ? h("div", { className: "form-error wide" }, error) : null,
|
||||
),
|
||||
h("div", { className: "todo-note-layout" },
|
||||
h(Panel, { title: "清单", eyebrow: `${rows.length} Instances`, className: "todo-list-panel" },
|
||||
h("form", { className: "todo-create-list", onSubmit: createList },
|
||||
h("input", { placeholder: "新清单名称", value: newListName, onChange: (event: any) => setNewListName(event.target.value), "aria-label": "新清单名称" }),
|
||||
h("button", { type: "submit", className: "ghost-btn", disabled: loading || !newListName.trim() }, "创建"),
|
||||
),
|
||||
rows.length === 0 ? h(EmptyState, { title: "暂无清单", text: "迁移或创建清单后会出现在这里" }) :
|
||||
h("div", { className: "todo-instance-list" }, rows.map((row: any) => h("button", {
|
||||
key: row.id,
|
||||
type: "button",
|
||||
className: `todo-instance-row ${activeId === row.id ? "active" : ""}`,
|
||||
onClick: () => { setActiveId(row.id); loadInstance(row.id); },
|
||||
"data-testid": `todo-instance-${safeId(row.id)}`,
|
||||
},
|
||||
h("strong", null, row.name),
|
||||
h("span", null, `${row.completedCount ?? 0}/${row.todoCount ?? 0} 完成`),
|
||||
h("code", null, row.id),
|
||||
))),
|
||||
),
|
||||
h("div", { className: "todo-main-stack" },
|
||||
h(Panel, {
|
||||
title: activeSummary?.name || "待选择清单",
|
||||
eyebrow: refreshedAt ? `Updated ${fmtClock(refreshedAt)}` : "Todo Tree",
|
||||
actions: instance ? h("div", { className: "panel-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => applyTodoAction({ type: "renameInstance", name: window.prompt("清单新名称", instance.name) || instance.name }) }, "重命名"),
|
||||
h("button", { type: "button", className: "ghost-btn danger", onClick: () => deleteList(activeId) }, "删除清单"),
|
||||
h(RawButton, { title: `Todo Instance ${activeId}`, data: instance, onOpen: onRaw, testId: "raw-todo-instance" }),
|
||||
) : null,
|
||||
},
|
||||
!instance ? h(EmptyState, { title: "未选择清单", text: "左侧选择一个 Todo Note 清单" }) :
|
||||
h("div", { className: "todo-workbench", style: { "--todo-font-size": `${fontSize}px` } },
|
||||
h("div", { className: "todo-toolbar" },
|
||||
h("form", { className: "todo-add-form", onSubmit: addRootTodo },
|
||||
h("input", { placeholder: "新增根任务", value: newTodoTitle, onChange: (event: any) => setNewTodoTitle(event.target.value), "aria-label": "新增根任务" }),
|
||||
h("button", { type: "submit", className: "ghost-btn", disabled: !newTodoTitle.trim() }, "新增"),
|
||||
),
|
||||
h("div", { className: "todo-filter-strip" }, ["all", "active", "completed"].map((item) => h("button", {
|
||||
key: item,
|
||||
type: "button",
|
||||
className: `todo-filter ${filter === item ? "active" : ""}`,
|
||||
onClick: () => setFilter(item),
|
||||
}, item === "all" ? "全部" : item === "active" ? "未完成" : "已完成"))),
|
||||
h("div", { className: "todo-toolbar-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => applyTodoAction({ type: "setAllTodosExpanded", expanded: true }) }, "全部展开"),
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => applyTodoAction({ type: "setAllTodosExpanded", expanded: false }) }, "全部收起"),
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => undoRedo("undo") }, "撤销"),
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => undoRedo("redo") }, "重做"),
|
||||
h("label", { className: "todo-font-control" }, "字号", h("input", { type: "range", min: 11, max: 18, value: fontSize, onChange: (event: any) => setFontSize(Number(event.target.value)) })),
|
||||
),
|
||||
),
|
||||
h("div", { className: "todo-stats-grid" },
|
||||
h(MetricCard, { label: "总任务", value: counts.total, hint: `${rows.length} lists` }),
|
||||
h(MetricCard, { label: "已完成", value: counts.completed, hint: `${fmtPercent(counts.total ? counts.completed / counts.total * 100 : 0)}`, tone: "ok" }),
|
||||
h(MetricCard, { label: "未完成", value: counts.active, hint: filter === "active" ? "当前筛选" : "active tasks", tone: counts.active > 0 ? "warn" : "ok" }),
|
||||
h(MetricCard, { label: "历史指针", value: instance.historyPointer ?? 0, hint: "undo / redo" }),
|
||||
),
|
||||
h("div", {
|
||||
className: "todo-root-drop",
|
||||
onDragOver: (event: any) => event.preventDefault(),
|
||||
onDrop: (event: any) => { event.preventDefault(); dropTodo(null, (instance.todos || []).length); },
|
||||
}, "拖到这里可移为根任务末尾"),
|
||||
h("div", { className: "todo-tree", "data-testid": "todo-note-tree" },
|
||||
(instance.todos || []).filter((todo: any) => todoVisible(todo, filter)).length === 0
|
||||
? h(EmptyState, { title: "没有匹配任务", text: "调整筛选或新增任务" })
|
||||
: (instance.todos || []).filter((todo: any) => todoVisible(todo, filter)).map((todo: any, index: number) => h(TodoRow, {
|
||||
key: todo.id,
|
||||
todo,
|
||||
depth: 0,
|
||||
parentId: null,
|
||||
index,
|
||||
siblingCount: instance.todos.length,
|
||||
filter,
|
||||
editingId,
|
||||
editingTitle,
|
||||
setEditingTitle,
|
||||
beginEdit,
|
||||
saveEdit,
|
||||
applyTodoAction,
|
||||
addChild,
|
||||
dragTodoId,
|
||||
setDragTodoId,
|
||||
dropTodo,
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function TodoRow(props: AnyRecord): ReactNode {
|
||||
const { todo, depth, parentId, index, siblingCount, filter, editingId, editingTitle, setEditingTitle, beginEdit, saveEdit, applyTodoAction, addChild, dragTodoId, setDragTodoId, dropTodo } = props;
|
||||
const children = Array.isArray(todo.children) ? todo.children : [];
|
||||
const visibleChildren = children.filter((child: any) => todoVisible(child, filter));
|
||||
const isEditing = editingId === todo.id;
|
||||
const targetParentId = parentId || null;
|
||||
return h("div", { className: "todo-row-wrap" },
|
||||
h("article", {
|
||||
className: `todo-row ${todo.completed ? "completed" : ""} ${dragTodoId === todo.id ? "dragging" : ""}`,
|
||||
style: { "--todo-depth": depth },
|
||||
draggable: true,
|
||||
onDragStart: (event: any) => { setDragTodoId(todo.id); event.dataTransfer.effectAllowed = "move"; },
|
||||
onDragOver: (event: any) => event.preventDefault(),
|
||||
onDrop: (event: any) => { event.preventDefault(); dropTodo(todo.id, children.length); },
|
||||
"data-testid": `todo-row-${safeId(todo.id)}`,
|
||||
},
|
||||
h("button", { type: "button", className: "todo-expand", disabled: children.length === 0, onClick: () => applyTodoAction({ type: "toggleTodoExpanded", todoId: todo.id }) }, children.length === 0 ? "·" : todo.expanded ? "▾" : "▸"),
|
||||
h("input", { type: "checkbox", checked: Boolean(todo.completed), onChange: () => applyTodoAction({ type: "toggleTodoCompleted", todoId: todo.id }), "aria-label": `完成 ${todo.title}` }),
|
||||
h("div", { className: "todo-title-cell", onDoubleClick: () => beginEdit(todo) },
|
||||
isEditing ? h("div", { className: "todo-edit-inline" },
|
||||
h("input", {
|
||||
value: editingTitle,
|
||||
autoFocus: true,
|
||||
onChange: (event: any) => setEditingTitle(event.target.value),
|
||||
onKeyDown: (event: any) => {
|
||||
if (event.key === "Enter") saveEdit(todo.id);
|
||||
if (event.key === "Escape") beginEdit({ id: "", title: "" });
|
||||
},
|
||||
}),
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => saveEdit(todo.id) }, "保存"),
|
||||
) : h("strong", null, todo.title || "Untitled"),
|
||||
h("div", { className: "todo-meta-line" },
|
||||
h("span", null, `子项 ${children.length}`),
|
||||
h("span", null, `更新 ${fmtDate(todo.updatedAt)}`),
|
||||
todo.reminderAt ? h("span", { className: "todo-reminder" }, `提醒 ${fmtDate(todo.reminderAt)}`) : h("span", null, "无提醒"),
|
||||
),
|
||||
),
|
||||
h("input", { className: "todo-reminder-input", type: "datetime-local", value: todoReminderLocal(todo.reminderAt), onChange: (event: any) => applyTodoAction({ type: "setTodoReminder", todoId: todo.id, reminderAt: localReminderIso(event.target.value) }) }),
|
||||
h("div", { className: "todo-row-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => beginEdit(todo) }, "编辑"),
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => addChild(todo.id) }, "子项"),
|
||||
h("button", { type: "button", className: "ghost-btn", disabled: index <= 0, onClick: () => applyTodoAction({ type: "moveTodo", todoId: todo.id, ...(targetParentId ? { targetParentId } : {}), targetIndex: index - 1 }) }, "上移"),
|
||||
h("button", { type: "button", className: "ghost-btn", disabled: index >= siblingCount - 1, onClick: () => applyTodoAction({ type: "moveTodo", todoId: todo.id, ...(targetParentId ? { targetParentId } : {}), targetIndex: index + 1 }) }, "下移"),
|
||||
h("button", { type: "button", className: "ghost-btn", disabled: !parentId, onClick: () => applyTodoAction({ type: "moveTodo", todoId: todo.id, targetIndex: 9999 }) }, "提升"),
|
||||
h("button", { type: "button", className: "ghost-btn danger", onClick: () => applyTodoAction({ type: "deleteTodo", todoId: todo.id }) }, "删除"),
|
||||
),
|
||||
),
|
||||
todo.expanded && visibleChildren.length > 0 ? h("div", { className: "todo-children" }, visibleChildren.map((child: any, childIndex: number) => h(TodoRow, {
|
||||
key: child.id,
|
||||
todo: child,
|
||||
depth: depth + 1,
|
||||
parentId: todo.id,
|
||||
index: childIndex,
|
||||
siblingCount: children.length,
|
||||
filter,
|
||||
editingId,
|
||||
editingTitle,
|
||||
setEditingTitle,
|
||||
beginEdit,
|
||||
saveEdit,
|
||||
applyTodoAction,
|
||||
addChild,
|
||||
dragTodoId,
|
||||
setDragTodoId,
|
||||
dropTodo,
|
||||
}))) : null,
|
||||
);
|
||||
}
|
||||
@@ -1018,7 +1018,7 @@ 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"]);
|
||||
const allowedHosts = new Set(["127.0.0.1", "localhost", "host.docker.internal", "todo-note"]);
|
||||
if (!allowedHosts.has(host)) throw new Error(`microservice backend host is not allowed: ${baseUrl.hostname}`);
|
||||
return baseUrl;
|
||||
}
|
||||
@@ -1060,7 +1060,9 @@ function applyJsonArrayLimits(bodyText: string, contentType: string, limits: Rec
|
||||
}
|
||||
|
||||
async function runMicroserviceHttp(payload: Record<string, JsonValue>): Promise<JsonValue> {
|
||||
const method = payload.method === "HEAD" ? "HEAD" : "GET";
|
||||
const rawMethod = String(payload.method || "GET").toUpperCase();
|
||||
const allowedMethods = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
|
||||
const method = allowedMethods.has(rawMethod) ? rawMethod : "GET";
|
||||
const targetBaseUrl = payloadString(payload, "targetBaseUrl");
|
||||
if (targetBaseUrl === null) throw new Error("microservice.http requires targetBaseUrl");
|
||||
const path = payloadString(payload, "path") ?? "/";
|
||||
@@ -1072,10 +1074,22 @@ async function runMicroserviceHttp(payload: Record<string, JsonValue>): Promise<
|
||||
url.search = query;
|
||||
const timeoutMs = Math.max(1000, Math.min(30_000, payloadNumber(payload, "timeoutMs", 10_000)));
|
||||
const jsonArrayLimits = payloadJsonArrayLimits(payload);
|
||||
const bodyText = payloadString(payload, "bodyText") ?? "";
|
||||
const requestHeaders = typeof payload.requestHeaders === "object" && payload.requestHeaders !== null && !Array.isArray(payload.requestHeaders)
|
||||
? payload.requestHeaders as Record<string, JsonValue>
|
||||
: {};
|
||||
const headers = new Headers();
|
||||
const contentType = typeof requestHeaders["content-type"] === "string" ? requestHeaders["content-type"] : "";
|
||||
if (contentType.length > 0) headers.set("content-type", contentType);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { method, signal: controller.signal });
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: method === "GET" || method === "HEAD" ? undefined : bodyText,
|
||||
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);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react",
|
||||
"types": ["bun", "node"],
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
|
||||
Reference in New Issue
Block a user