feat: integrate todo note microservice and modularize frontend

This commit is contained in:
Codex
2026-05-05 10:33:26 +00:00
parent abd40fa252
commit 1d0046dc50
28 changed files with 2121 additions and 381 deletions
+54
View File
@@ -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=="],
}
}
+50 -4
View File
@@ -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,
});
+5
View File
@@ -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>
+421 -1
View File
@@ -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;
}
}
+34 -322
View File
@@ -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));
+221
View File
@@ -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" })),
),
),
);
}
+6
View File
@@ -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;
}
+555
View File
@@ -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),
),
),
);
}
+485
View File
@@ -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,
);
}
+17 -3
View File
@@ -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);
+1
View File
@@ -3,6 +3,7 @@
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react",
"types": ["bun", "node"],
"strict": true,
"noImplicitReturns": true,