From b265274750be9d85b9d9e30e8a55febf8de1817d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 18 May 2026 06:59:51 +0000 Subject: [PATCH] feat: add devops-controlled dev ci flow --- AGENTS.md | 10 +- TEST.md | 4 +- config.json | 54 ++ deploy.json | 161 ++-- docs/plan/d601-k3s-dev-environment.md | 2 + docs/reference/ci.md | 25 +- docs/reference/cli.md | 9 +- docs/reference/codex-deploy.md | 21 +- docs/reference/deploy.md | 85 +- docs/reference/deployment.md | 6 +- docs/reference/microservices.md | 10 +- docs/reference/repo-tree.md | 4 +- scripts/bootstrap/devops-install.sh | 180 +++++ scripts/cli.ts | 9 +- scripts/src/ci.ts | 609 ++++++++++++--- scripts/src/deploy.ts | 190 +++-- src/components/frontend/public/app.js | 2 +- src/components/frontend/src/app.tsx | 2 +- .../microservices/devops/Dockerfile | 20 + src/components/microservices/devops/go.mod | 3 + src/components/microservices/devops/main.go | 739 ++++++++++++++++++ .../k3sctl-adapter/docker-compose.d601.yml | 3 +- .../k3s/ci/unidesk-ci.pipeline.yaml | 448 ++++++++++- .../k3s/dev/unidesk-dev-code-queue.k8s.yaml | 44 +- .../k3s/dev/unidesk-dev-core.k8s.yaml | 16 +- .../k3s/dev/unidesk-dev-foundation.k8s.yaml | 4 +- .../k3sctl-adapter/k3s/devops.k3s.json | 37 + .../k3sctl-adapter/k3s/devops.k8s.yaml | 171 ++++ .../microservices/k3sctl-adapter/src/index.ts | 2 +- 29 files changed, 2504 insertions(+), 366 deletions(-) create mode 100755 scripts/bootstrap/devops-install.sh create mode 100644 src/components/microservices/devops/Dockerfile create mode 100644 src/components/microservices/devops/go.mod create mode 100644 src/components/microservices/devops/main.go create mode 100644 src/components/microservices/k3sctl-adapter/k3s/devops.k3s.json create mode 100644 src/components/microservices/k3sctl-adapter/k3s/devops.k8s.yaml diff --git a/AGENTS.md b/AGENTS.md index b2f2b5ab..9fe9535d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,10 +37,10 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts microservice list/status/health/diagnostics/tunnel-self-test/proxy`:管理和验证挂载在主 server、计算节点 Docker 或 k3s 控制面上的用户服务,`proxy` 支持受控 JSON body,OA Event Flow/Todo Note/Baidu Netdisk/Code Queue Manager on main-server、k3s Control/Code Queue 执行面/MDTODO/Decision Center/FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。 - `bun scripts/cli.ts decision upload/list/show/health`:通过 backend-core 用户服务代理上传会议记录/决议 Markdown、列出记录和查看详情;Decision Center 运行在 D601 k3s,规则见 `docs/reference/microservices.md`。 - `bun scripts/cli.ts decision diary import/list/months/show`:把带日期标题的工作日志 Markdown 拆成 `YYYY-MM/YYYY-MM-DD.md` 日记条目并写入 PostgreSQL,规则见 `docs/reference/microservices.md`。 -- `bun scripts/cli.ts deploy check/plan/apply [--file deploy.json|--env dev|prod] [--service ]`:按根目录或固定环境 ref 的服务 repo 和 commit 期望状态校验或更新用户服务;`--env dev` 当前开放 backend-core/frontend/code-queue 开发环境部署,目标侧自行 fetch、构建、部署和 live commit 验证;规则见 `docs/reference/deploy.md`。 +- `bun scripts/cli.ts deploy check/plan/apply [--file deploy.json|--env dev|prod] [--service ]`:按根目录 `deploy.json` 或 `origin/master:deploy.json#environments.` 的服务 repo 和 commit 期望状态校验或更新用户服务;维护通道直连 D601 只允许 `--env dev --service devops` 做 DevOps 自举/修复,backend-core/frontend/code-queue 等直管或代管微服务必须经 DevOps 控制面部署;规则见 `docs/reference/deploy.md`。 - `bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]` / `dev-env prewarm-images`:离线校验 D601 `unidesk-dev` 生产隔离护栏,或把开发底座基础镜像预热到 D601 原生 k3s containerd,规则见 `docs/reference/deploy.md` 与 `docs/reference/microservices.md`。 -- `bun scripts/cli.ts ci install/status/run/logs`:在 D601 原生 k3s 上安装和运行 Tekton CI,只做每 commit 检查和 Code Queue 只读性能门禁,不部署 CD;规则见 `docs/reference/ci.md`。 -- `bun scripts/cli.ts codex deploy `:Code Queue 兼容部署入口,会生成临时 desired manifest 并调用 `deploy apply --service code-queue` 的同一条 target-side build 与 live commit 验证路径;规则见 `docs/reference/codex-deploy.md`。 +- `bun scripts/cli.ts ci install/status/run/run-dev-e2e/logs`:在 D601 原生 k3s 上安装和运行 Tekton CI,支持每 commit 检查、Code Queue 只读性能门禁和手动触发的 `master:deploy.json#environments.dev` 临时 namespace e2e,不部署 CD;规则见 `docs/reference/ci.md`。 +- `bun scripts/cli.ts codex deploy `:旧 Code Queue 兼容部署入口已禁用,原因是它会绕过 DevOps 控制面直连 D601 部署 Code Queue;后续 Code Queue 部署必须经 DevOps 控制面,规则见 `docs/reference/codex-deploy.md`。 - `bun scripts/cli.ts codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue ]`:通过 backend-core 私有代理提交 Code Queue 任务;控制面默认走主 server `code-queue-mgr` 写入 PostgreSQL,`--dry-run` 可只检查请求体不入队,规则见 `docs/reference/cli.md`。 - `bun scripts/cli.ts codex task `:按 Code Queue 任务 ID 查询初始 prompt、最后 assistant message、工具调用摘要、attempt/judge/error 和耗时,便于新任务引用历史 session。 - `bun scripts/cli.ts codex judge --attempt [--dry-run]`:按指定 task/attempt 用与队列 worker 相同的上下文构建和 MiniMax judge 调用路径单步复现完成判定;`--dry-run` 只输出 prompt/payload 诊断。 @@ -73,6 +73,6 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `docs/reference/pipeline-oa-event-flow.md`:Pipeline/OA 事件流、审核/无审核流转、单步调试、甘特图渲染和最终去残留规则。 - `docs/reference/pipeline-model-proxy.md`:Pipeline v2 model proxy 链路架构、D601 宿主 proxy 服务部署、harness token 注入规则和 smoke test 验证流程。 - `docs/reference/deploy.md`:`deploy.json` desired-state、target-side build、一次性构建 proxy、直管/代管服务部署 executor 和 live commit 验证规则。 -- `docs/reference/ci.md`:D601 k3s Tekton CI、只读主数据库性能门禁和 CLI 入口规则。 -- `docs/reference/codex-deploy.md`:D601 Code Queue `codex deploy ` 异步部署管线、路径约定和验证入口。 +- `docs/reference/ci.md`:D601 k3s Tekton CI、只读主数据库性能门禁、DevOps 控制面和 CLI 入口规则。 +- `docs/reference/codex-deploy.md`:D601 Code Queue 旧 `codex deploy ` 入口禁用原因、DevOps 控制面迁移边界和后续 CD 目标行为。 - `reference`:兼容旧路径的符号链接,指向 `docs/reference/`。 diff --git a/TEST.md b/TEST.md index 3a6dd04f..1d62d38d 100644 --- a/TEST.md +++ b/TEST.md @@ -103,7 +103,7 @@ ## T23 D601 Code Queue User Service -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `code-queue-mgr` 显示为 `providerId=main-server`、`deployment.mode=internal-sidecar`、Compose 后端 `http://code-queue-mgr:4278`、`frontend.integrated=false`,并确认稳定 `code-queue` 条目说明队列管理/提交/历史/轻量 Trace 默认由主 server `code-queue-mgr` 负责,D601 k3s Code Queue 只负责 scheduler/runner/active run control 和执行态写回;使用 `bun scripts/cli.ts server rebuild code-queue-mgr` 重建主 server 控制面,再运行 `bun scripts/cli.ts microservice health code-queue-mgr`、`bun scripts/cli.ts microservice health code-queue`、`bun scripts/cli.ts microservice proxy code-queue '/api/tasks/overview?limit=5&transcriptLimit=1&compact=1&afterSeq=0&preferId='`、`bun scripts/cli.ts codex submit --dry-run --queue ` 和 `bun scripts/cli.ts codex task <已有taskId>`,确认普通控制/读取路径经 backend-core 分流到 master `code-queue-mgr`,返回 `role=master-control-plane`、`schemaReady=true`、PostgreSQL pool 上限、`noRunnerDependencies=true`、任务初始 prompt、最后 assistant message、工具调用摘要、attempt/judge/error 和耗时,不依赖 D601 `code-queue-write` ready endpoint。随后使用 `bun scripts/cli.ts codex deploy <已push的commitId>` 重建/启动 D601 Code Queue 执行面,确认命令立即返回异步 job id,`bun scripts/cli.ts job status --tail-bytes 30000` 能看到 fetch/export、rsync、Docker build、native k3s provider egress proxy、有效 `rancher/mirrored-pause:3.6` sandbox 镜像导入、k3s image import、kubectl apply、部署 commit 戳记、rollout、legacy direct cleanup 和 health commit 验证进度,并确认 job 最终校验真实 D601 scheduler `/health` 返回的 `deploy.commit` 精确匹配本次 remote commit,不能由旧服务或旧 Pod 充数;同时确认主 server 根目录 `docker-compose.yml` 中只存在 `code-queue-mgr` 而不存在执行面 `code-queue` service,并通过 `bun scripts/cli.ts ssh D601 argv bash -lc 'systemctl is-active k3s && KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get nodes -o wide && sudo ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F docker.io/rancher/mirrored-pause:3.6 && ! docker ps --format "{{.Names}} {{.Image}}" | grep -E "[[:space:]]rancher/k3s:" && ! docker ps --format "{{.Names}}" | grep -Fx code-queue-backend'` 或等价检查证明 D601 k3s 是 WSL 原生 systemd 服务、native containerd 已有正确 pause sandbox 镜像、没有 active `rancher/k3s` 控制面容器且旧 direct Docker `code-queue-backend` 没有并行运行。运行 `bun scripts/cli.ts microservice proxy k3sctl-adapter /api/control-plane --raw` 和执行面专属 `bun scripts/cli.ts microservice proxy code-queue /api/dev-ready --raw`,确认 D601 scheduler/read/write ready endpoint、`queue.storage.primary=postgres`、`queue.storage.postgresReady=true`、`queue.devReady.missingTools=[]`、`queue.devReady.docker.versionOk=true`、`queue.devReady.docker.composeOk=true`;`queue.devReady.ssh.ready` 只在需要跨 Provider SSH/Windows-native 任务时作为强制项。在 D601 active Code Queue Pod 内验证主 PostgreSQL 端口映射可执行 `select 1`,主 OA Event Flow 端口映射 `/health` 可访问,集群内 ClaudeQQ Service `http://claudeqq.unidesk.svc.cluster.local:3290/health` 可访问;这些映射不得成为任意公网入口。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `code-queue-mgr` 显示为 `providerId=main-server`、`deployment.mode=internal-sidecar`、Compose 后端 `http://code-queue-mgr:4278`、`frontend.integrated=false`,并确认稳定 `code-queue` 条目说明队列管理/提交/历史/轻量 Trace 默认由主 server `code-queue-mgr` 负责,D601 k3s Code Queue 只负责 scheduler/runner/active run control 和执行态写回;使用 `bun scripts/cli.ts server rebuild code-queue-mgr` 重建主 server 控制面,再运行 `bun scripts/cli.ts microservice health code-queue-mgr`、`bun scripts/cli.ts microservice health code-queue`、`bun scripts/cli.ts microservice proxy code-queue '/api/tasks/overview?limit=5&transcriptLimit=1&compact=1&afterSeq=0&preferId='`、`bun scripts/cli.ts codex submit --dry-run --queue ` 和 `bun scripts/cli.ts codex task <已有taskId>`,确认普通控制/读取路径经 backend-core 分流到 master `code-queue-mgr`,返回 `role=master-control-plane`、`schemaReady=true`、PostgreSQL pool 上限、`noRunnerDependencies=true`、任务初始 prompt、最后 assistant message、工具调用摘要、attempt/judge/error 和耗时,不依赖 D601 `code-queue-write` ready endpoint。随后运行 `bun scripts/cli.ts codex deploy <已push的commitId>`,确认命令返回结构化错误并明确说明维护通道直连 D601 部署已禁用、Code Queue 部署必须经 DevOps 控制面,且不会返回异步部署 job id;再运行 `bun scripts/cli.ts deploy apply --service code-queue --dry-run --run-now` 可只做 would-deploy 预览,去掉 `--dry-run` 时必须在运行时变更前拒绝 D601 非 DevOps 直连部署。确认主 server 根目录 `docker-compose.yml` 中只存在 `code-queue-mgr` 而不存在执行面 `code-queue` service,并通过 `bun scripts/cli.ts ssh D601 argv bash -lc 'systemctl is-active k3s && KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get nodes -o wide && sudo ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F docker.io/rancher/mirrored-pause:3.6 && ! docker ps --format "{{.Names}} {{.Image}}" | grep -E "[[:space:]]rancher/k3s:" && ! docker ps --format "{{.Names}}" | grep -Fx code-queue-backend'` 或等价检查证明 D601 k3s 是 WSL 原生 systemd 服务、native containerd 已有正确 pause sandbox 镜像、没有 active `rancher/k3s` 控制面容器且旧 direct Docker `code-queue-backend` 没有并行运行。运行 `bun scripts/cli.ts microservice proxy k3sctl-adapter /api/control-plane --raw` 和执行面专属 `bun scripts/cli.ts microservice proxy code-queue /api/dev-ready --raw`,确认 D601 scheduler/read/write ready endpoint、`queue.storage.primary=postgres`、`queue.storage.postgresReady=true`、`queue.devReady.missingTools=[]`、`queue.devReady.docker.versionOk=true`、`queue.devReady.docker.composeOk=true`;`queue.devReady.ssh.ready` 只在需要跨 Provider SSH/Windows-native 任务时作为强制项。在 D601 active Code Queue Pod 内验证主 PostgreSQL 端口映射可执行 `select 1`,主 OA Event Flow 端口映射 `/health` 可访问,集群内 ClaudeQQ Service `http://claudeqq.unidesk.svc.cluster.local:3290/health` 可访问;这些映射不得成为任意公网入口。 随后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / Code Queue`,确认页面显示默认模型 `gpt-5.5`、默认执行 Provider `D601`、默认工作目录 `/workspace`、模型下拉菜单包含 `gpt-5.4-mini`/`gpt-5.4`/`gpt-5.5`、入队份数、队列指标、任务 ID、复制任务 ID、引用按钮、任务耗时、引用任务 ID、清空输入、创建成功提示、任务提交表单、Trace 输出、attempt 表、MiniMax/fallback judge 状态、追加 prompt、打断和重试控件;通过页面提交一个小任务,确认任务进入 queued/running/succeeded 或可解释的 failed 状态,并且输出区能看到运行中的 Codex 消息。批量验收时设置 `入队份数=5` 或用 `---` 分隔 5 段 prompt,一次性入队 5 条任务,确认 5 条任务按顺序运行并全部进入 succeeded 或可解释的非成功终态,不能只运行第一条后停止;其中任一任务被 judge 判定 `fail` 时只能把当前任务标为 failed,后续 queued 任务仍必须继续推进。测试异常中断时可以提交长任务后点击 `打断`,确认任务变为 canceled 或被 judge 标记为非成功终态;自动重试只应在服务端/传输异常、任务正常结束但 execution record 显示未完成、或 judge 判定 retry 时发生;retry 必须复用已有 Codex thread 并 append 继续执行 prompt,只有当前任务 complete 后才推进队列中的下一个任务。MiniMax judge 必须能处理 Markdown fence/夹杂文本等 JSON 去噪;若去噪后仍失败,必须把解析错误和上一轮去噪前原始回答反馈给 MiniMax 修复后重试,日志中应出现 `judge_json_parse_retry`,且 repair 成功时仍以 `source=minimax` 返回。Codex provider key 只能通过 `OPENAI_API_KEY`、`CRS_OAI_KEY` 这类运行时环境透传,MiniMax API key 只能通过 D601 env-file 运行时环境传入,禁止写入 `config.json`、Dockerfile、源码或测试文档。 @@ -113,7 +113,7 @@ ## T23B D601 Decision Center User Service -阅读 `AGENTS.md` 和 `docs/reference/microservices.md`,运行 `bun scripts/cli.ts microservice list`,确认 `decision-center` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL `https://github.com/pikasTech/unidesk`、k3s/k8s `k3s://unidesk/decision-center:4277` 逻辑服务映射、`deployment.mode=k3sctl-managed`、`runtime.orchestrator=k3sctl` 且无业务直连容器摘要;使用 `bun scripts/cli.ts deploy apply --service decision-center` 按 `deploy.json` 期望状态部署,确认 job 在 D601 target-side build、导入原生 k3s/containerd、apply `src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml`、stamp deployment commit、rollout 并通过 UniDesk microservice proxy 验证 live commit。运行 `bun scripts/cli.ts microservice health decision-center`,确认 `service=decision-center`、`storage=postgres`、`schemaReady=true` 且 health 中包含 `diaryEntryCount`;准备一份临时 Markdown 会议记录,运行 `bun scripts/cli.ts decision upload --title --type meeting --level G1 --status active --evidence <url>`,再运行 `bun scripts/cli.ts decision list` 和 `bun scripts/cli.ts decision show <id>`,确认 CLI 只通过 backend-core 用户服务代理访问,返回结构化 JSON 且能看到刚上传的记录。再准备一份包含 `# 2026年5月1日` 和 `# 2026年5月2日` 的临时工作日志 Markdown,运行 `bun scripts/cli.ts decision diary import <markdown-file> --source-file test-work-log.md --tag e2e`、`bun scripts/cli.ts decision diary months`、`bun scripts/cli.ts decision diary list --month 2026-05` 和 `bun scripts/cli.ts decision diary show 2026-05-01`,确认日记按 `YYYY-MM/YYYY-MM-DD.md` 虚拟路径拆分、写入 PostgreSQL 且重复导入幂等。最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / Decision Center`,确认页面显示 G0/G1 目标、P0/P1 Blocker、停放事项、最近会议/决议、筛选、全部记录表和工作日记标签;日记标签可按月筛选并查看单日 Markdown 正文;页面不得提供聊天/LLM 会话窗口,默认不得裸 JSON,完整 JSON 只能通过 `查看原始JSON` 打开。 +阅读 `AGENTS.md` 和 `docs/reference/microservices.md`,运行 `bun scripts/cli.ts microservice list`,确认 `decision-center` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL `https://github.com/pikasTech/unidesk`、k3s/k8s `k3s://unidesk/decision-center:4277` 逻辑服务映射、`deployment.mode=k3sctl-managed`、`runtime.orchestrator=k3sctl` 且无业务直连容器摘要;运行 `bun scripts/cli.ts deploy apply --service decision-center --run-now`,确认命令在运行时变更前返回结构化错误,说明维护通道直连 D601 只允许部署 DevOps;Decision Center 后续版本部署必须经 DevOps 控制面。随后运行 `bun scripts/cli.ts microservice health decision-center`,确认 `service=decision-center`、`storage=postgres`、`schemaReady=true` 且 health 中包含 `diaryEntryCount`;准备一份临时 Markdown 会议记录,运行 `bun scripts/cli.ts decision upload <markdown-file> --title <title> --type meeting --level G1 --status active --evidence <url>`,再运行 `bun scripts/cli.ts decision list` 和 `bun scripts/cli.ts decision show <id>`,确认 CLI 只通过 backend-core 用户服务代理访问,返回结构化 JSON 且能看到刚上传的记录。再准备一份包含 `# 2026年5月1日` 和 `# 2026年5月2日` 的临时工作日志 Markdown,运行 `bun scripts/cli.ts decision diary import <markdown-file> --source-file test-work-log.md --tag e2e`、`bun scripts/cli.ts decision diary months`、`bun scripts/cli.ts decision diary list --month 2026-05` 和 `bun scripts/cli.ts decision diary show 2026-05-01`,确认日记按 `YYYY-MM/YYYY-MM-DD.md` 虚拟路径拆分、写入 PostgreSQL 且重复导入幂等。最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / Decision Center`,确认页面显示 G0/G1 目标、P0/P1 Blocker、停放事项、最近会议/决议、筛选、全部记录表和工作日记标签;日记标签可按月筛选并查看单日 Markdown 正文;页面不得提供聊天/LLM 会话窗口,默认不得裸 JSON,完整 JSON 只能通过 `查看原始JSON` 打开。 ## T24 MET Nonlinear D601 GPU User Service diff --git a/config.json b/config.json index 64587ef2..8061f195 100644 --- a/config.json +++ b/config.json @@ -807,6 +807,60 @@ ], "activeNodeId": "D601" } + }, + { + "id": "devops", + "name": "DevOps Control", + "providerId": "D601", + "description": "DevOps Control 是 D601 k3s 代管的轻量 CI/CD 控制面,负责常态触发 master deploy.json dev namespace e2e、查询 PipelineRun 状态和日志;provider-gateway SSH 只保留 bootstrap 和故障维护。", + "repository": { + "url": "https://github.com/pikasTech/unidesk", + "commitId": "local", + "dockerfile": "src/components/microservices/devops/Dockerfile", + "composeFile": "src/components/microservices/k3sctl-adapter/k3s/devops.k3s.json", + "composeService": "devops", + "containerName": "k3s:devops" + }, + "backend": { + "nodeBaseUrl": "k3s://devops", + "nodeBindHost": "k3s://unidesk-ci/devops", + "nodePort": 4286, + "proxyMode": "k3sctl-adapter-http", + "frontendOnly": true, + "public": false, + "allowedMethods": [ + "GET", + "HEAD", + "POST" + ], + "allowedPathPrefixes": [ + "/health", + "/live", + "/logs", + "/api/" + ], + "healthPath": "/health", + "timeoutMs": 120000 + }, + "development": { + "providerId": "D601", + "sshPassthrough": true, + "worktreePath": "/home/ubuntu/.unidesk/devops-deploy" + }, + "frontend": { + "route": "/apps/devops", + "integrated": false + }, + "deployment": { + "mode": "k3sctl-managed", + "adapterServiceId": "k3sctl-adapter", + "k3sServiceId": "devops", + "namespace": "unidesk-ci", + "expectedNodeIds": [ + "D601" + ], + "activeNodeId": "D601" + } } ], "paths": { diff --git a/deploy.json b/deploy.json index 4e966b8d..d04707ee 100644 --- a/deploy.json +++ b/deploy.json @@ -1,71 +1,98 @@ { - "schemaVersion": 1, - "environment": "prod", - "services": [ - { - "id": "findjob", - "repo": "https://gitee.com/Lyon1998/findjob", - "commitId": "2d43212c5f474df5d87820985a6c75a8c2e7ac42" + "schemaVersion": 2, + "environments": { + "prod": { + "services": [ + { + "id": "findjob", + "repo": "https://gitee.com/Lyon1998/findjob", + "commitId": "2d43212c5f474df5d87820985a6c75a8c2e7ac42" + }, + { + "id": "pipeline", + "repo": "https://github.com/pikasTech/pipeline", + "commitId": "87811a8d43edf216a4f4d8efa55bbb96bad8df14" + }, + { + "id": "met-nonlinear", + "repo": "https://github.com/pikasTech/met_nonlinear", + "commitId": "9fcdfc0b505e52cc88cf51b196543dc055da2334" + }, + { + "id": "claudeqq", + "repo": "https://gitee.com/lyon1998/agent_skills", + "commitId": "203b1f46684c91340ecbbd8a74502bd55e4f2011" + }, + { + "id": "todo-note", + "repo": "https://gitee.com/Lyon1998/todo_note", + "commitId": "a14ce0eb855a685fa17b47adacd54623e72cd2ff" + }, + { + "id": "project-manager", + "repo": "https://github.com/pikasTech/unidesk", + "commitId": "0c3cdb4ee06a23361ed511a2da033d67b53d16f4" + }, + { + "id": "baidu-netdisk", + "repo": "https://github.com/pikasTech/unidesk", + "commitId": "0c3cdb4ee06a23361ed511a2da033d67b53d16f4" + }, + { + "id": "oa-event-flow", + "repo": "https://github.com/pikasTech/unidesk", + "commitId": "0c3cdb4ee06a23361ed511a2da033d67b53d16f4" + }, + { + "id": "k3sctl-adapter", + "repo": "https://github.com/pikasTech/unidesk", + "commitId": "4d9eed65132f158b2323b128aabed5613cb15627" + }, + { + "id": "code-queue", + "repo": "https://github.com/pikasTech/unidesk", + "commitId": "aae14e2b1ccf8f91e84b6ac89d72681028794472" + }, + { + "id": "code-queue-mgr", + "repo": "https://github.com/pikasTech/unidesk", + "commitId": "22b02e7ce98a32647f8c3962dbf90aafabd53ff0" + }, + { + "id": "mdtodo", + "repo": "https://github.com/pikasTech/unidesk", + "commitId": "75fb6757b2504ba86d61f2587fb34a9c9ed4019a" + }, + { + "id": "decision-center", + "repo": "https://github.com/pikasTech/unidesk", + "commitId": "54c1f8e165f90fa8509fda1f0c01f8c3fa82cbee" + } + ] }, - { - "id": "pipeline", - "repo": "https://github.com/pikasTech/pipeline", - "commitId": "87811a8d43edf216a4f4d8efa55bbb96bad8df14" - }, - { - "id": "met-nonlinear", - "repo": "https://github.com/pikasTech/met_nonlinear", - "commitId": "9fcdfc0b505e52cc88cf51b196543dc055da2334" - }, - { - "id": "claudeqq", - "repo": "https://gitee.com/lyon1998/agent_skills", - "commitId": "203b1f46684c91340ecbbd8a74502bd55e4f2011" - }, - { - "id": "todo-note", - "repo": "https://gitee.com/Lyon1998/todo_note", - "commitId": "a14ce0eb855a685fa17b47adacd54623e72cd2ff" - }, - { - "id": "project-manager", - "repo": "https://github.com/pikasTech/unidesk", - "commitId": "0c3cdb4ee06a23361ed511a2da033d67b53d16f4" - }, - { - "id": "baidu-netdisk", - "repo": "https://github.com/pikasTech/unidesk", - "commitId": "0c3cdb4ee06a23361ed511a2da033d67b53d16f4" - }, - { - "id": "oa-event-flow", - "repo": "https://github.com/pikasTech/unidesk", - "commitId": "0c3cdb4ee06a23361ed511a2da033d67b53d16f4" - }, - { - "id": "k3sctl-adapter", - "repo": "https://github.com/pikasTech/unidesk", - "commitId": "4d9eed65132f158b2323b128aabed5613cb15627" - }, - { - "id": "code-queue", - "repo": "https://github.com/pikasTech/unidesk", - "commitId": "aae14e2b1ccf8f91e84b6ac89d72681028794472" - }, - { - "id": "code-queue-mgr", - "repo": "https://github.com/pikasTech/unidesk", - "commitId": "22b02e7ce98a32647f8c3962dbf90aafabd53ff0" - }, - { - "id": "mdtodo", - "repo": "https://github.com/pikasTech/unidesk", - "commitId": "75fb6757b2504ba86d61f2587fb34a9c9ed4019a" - }, - { - "id": "decision-center", - "repo": "https://github.com/pikasTech/unidesk", - "commitId": "54c1f8e165f90fa8509fda1f0c01f8c3fa82cbee" + "dev": { + "services": [ + { + "id": "backend-core", + "repo": "https://github.com/pikasTech/unidesk", + "commitId": "348c644" + }, + { + "id": "frontend", + "repo": "https://github.com/pikasTech/unidesk", + "commitId": "348c644" + }, + { + "id": "code-queue", + "repo": "https://github.com/pikasTech/unidesk", + "commitId": "348c644" + }, + { + "id": "devops", + "repo": "https://github.com/pikasTech/unidesk", + "commitId": "348c644" + } + ] } - ] + } } diff --git a/docs/plan/d601-k3s-dev-environment.md b/docs/plan/d601-k3s-dev-environment.md index 20abec6a..b30fdf8e 100644 --- a/docs/plan/d601-k3s-dev-environment.md +++ b/docs/plan/d601-k3s-dev-environment.md @@ -1,5 +1,7 @@ # D601 k3s 开发环境建设计划 +> 状态:本过程计划的环境分支方案已废弃。长期权威规则以 `docs/reference/deploy.md` 和 `docs/reference/ci.md` 为准:dev/prod 期望状态统一写在 `master` 分支的根目录 `deploy.json`,通过 `environments.dev` 和 `environments.prod` 区分,不再以 `deploy/dev` 或 `deploy/prod` 分支作为环境事实源。本文件保留为阶段性历史计划,不作为新实现依据。 + ## 目标 在现有 D601 原生 k3s 集群内建设一套与生产隔离的 UniDesk 开发环境,让以 LLM 为主力的开发流程可以部署、破坏、重建和验证 backend-core、frontend、Code Queue 及其数据库依赖,而不打断生产主 server。 diff --git a/docs/reference/ci.md b/docs/reference/ci.md index c8f4d4f7..a1ca0956 100644 --- a/docs/reference/ci.md +++ b/docs/reference/ci.md @@ -1,6 +1,6 @@ # UniDesk CI On D601 k3s -UniDesk CI is hosted on the D601 native k3s cluster with Tekton Pipelines and Tekton Triggers. It is CI only. CD remains the existing `deploy.json` / `deploy apply` / `codex deploy <commit>` path, and no Tekton task may roll out production services. +UniDesk CI is hosted on the D601 native k3s cluster with Tekton Pipelines and Tekton Triggers. It is CI only. CD remains separate from Tekton; D601 service deployment must go through the DevOps control plane, while maintenance-channel direct D601 apply is reserved for DevOps bootstrap/repair. No Tekton task may roll out production services. ## Components @@ -8,9 +8,10 @@ UniDesk CI is hosted on the D601 native k3s cluster with Tekton Pipelines and Te - Tekton Triggers: `v0.34.0`. - UniDesk CI namespace: `unidesk-ci`. - Manifests: `src/components/microservices/k3sctl-adapter/k3s/ci/`. -- CLI entry: `bun scripts/cli.ts ci install|status|run|logs`. +- CLI entry: `bun scripts/cli.ts ci install|status|run|run-dev-e2e|logs`. +- DevOps control service: `src/components/microservices/devops`, normally installed in `unidesk-ci` and reached through the k3s service-proxy path. -The CLI reaches D601 through the existing `k3sctl-adapter` Host SSH maintenance bridge and then runs native `KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl ...`. It does not require backend-core to be running and does not expose a new public port. +Bootstrap and recovery may reach D601 through backend-core `/api/dispatch` with the existing `host.ssh` provider capability, then run native `KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl ...` on D601. That maintenance path is limited to DevOps bootstrap/repair and CI bootstrap checks; it must not deploy backend-core, frontend, Code Queue, Decision Center, k3sctl-adapter or other direct/managed microservices. Normal CI/CD control should move to `CLI -> backend-core -> k3sctl-adapter -> DevOps -> Kubernetes API/Tekton` after DevOps is healthy. No new public port is exposed. ## Pipeline Scope @@ -22,7 +23,7 @@ Each commit CI run performs: - Temporary `code-queue-ci-read` Deployment and ClusterIP Service in `unidesk-ci`. - Code Queue read performance checks against the production PostgreSQL through `d601-tcp-egress-gateway`. -`ci install` also prewarms the D601 k3s containerd runtime with the Tekton entrypoint/workingdir helper images, `oven/bun:1-debian`, `alpine/git:2.45.2` and `unidesk-code-queue:d601`. Missing images are pulled through the node-local provider-gateway WS egress proxy and then imported into native k3s containerd with digests preserved, so PipelineRun pods do not hang on external registry pulls. +`ci install` also prewarms the D601 k3s containerd runtime with the Tekton entrypoint/workingdir helper images, `oven/bun:1-debian`, `alpine/git:2.45.2` and `unidesk-code-queue:dev`. Missing images are pulled through the node-local provider-gateway WS egress proxy and then imported into native k3s containerd with digests preserved, so PipelineRun pods do not hang on external registry pulls. Git clone and dependency downloads inside the repo check task use `d601-provider-egress-proxy.unidesk.svc.cluster.local:18789`; the NO_PROXY list keeps the in-cluster read service, D601 TCP egress gateway and any in-cluster CI Git mirror on the cluster network. @@ -42,6 +43,16 @@ The temporary Code Queue service uses: This means the CI service can read existing tasks, Trace summaries, Trace steps and Trace step details from the main database, but it must not schedule, mutate, notify, backfill or become deployment truth. +## Dev Namespace E2E + +`ci run-dev-e2e` is the manual CI entry for the dev desired-state smoke flow. The CLI fetches `origin/master:deploy.json`, reads `environments.dev`, records the `origin/master` commit that supplied the manifest, then normally calls DevOps through the existing microservice proxy to create a Tekton `PipelineRun`. The Pipeline verifies that the in-cluster Git fetch sees the same master commit before it reads `deploy.json`. + +`ci run-dev-e2e --direct` is reserved for CI bootstrap and recovery when DevOps is not healthy yet. It creates only the CI PipelineRun through the maintenance Host SSH path, does not deploy any direct/managed microservice, and must not become the normal CI control path. + +The first CI stage creates a temporary namespace named `unidesk-ci-e2e-<run-id>`, stores the selected desired manifest in a ConfigMap, starts an in-namespace smoke target, calls its `/health` endpoint through the Kubernetes Service DNS name, verifies the dev service commit IDs carried into the target, and deletes the namespace unless `--keep-namespace` is set. This stage proves the manual trigger, master desired-state pinning, namespace lifecycle, in-cluster Service DNS and e2e result path without mutating `unidesk`, `unidesk-dev`, production PostgreSQL, or any production workload. + +The current dev namespace e2e is a harness and smoke gate, not a full frontend/backend/code-queue stack rollout. Full-stack temporary namespace deployment can be added behind the same `run-dev-e2e` command after image build/import and per-run database bootstrap are promoted into CI. + ## Performance Gate The initial budgets live in `unidesk-ci/unidesk-ci-budgets`: @@ -74,6 +85,12 @@ Run CI manually for a commit: bun scripts/cli.ts ci run --revision <commit> ``` +Run the dev namespace e2e harness manually: + +```bash +bun scripts/cli.ts ci run-dev-e2e --wait-ms 600000 +``` + Inspect a run: ```bash diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 36c45d9b..4a46f6d3 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -22,10 +22,11 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 - `microservice list/status/health/diagnostics/tunnel-self-test/proxy` 通过 backend-core 内网 API 管理挂载在计算节点 Docker 或 k3s 控制面中的用户服务(底层命令名仍为 microservice);`health`、`diagnostics`、`tunnel-self-test` 和 `proxy` 会走真实 backend-core -> provider-gateway 或 k3sctl-adapter -> 节点服务链路,`proxy` 支持受控 JSON 请求体并对超大响应 body 默认输出有界预览,规则见 `docs/reference/microservices.md`。 - `decision upload/list/show/health` 通过 backend-core 用户服务代理访问 D601 k3s Decision Center,用于上传会议记录/决议 Markdown、列出权威记录、查看详情和健康检查;它不得直连 D601 Service、NodePort 或 provider-gateway 业务 HTTP。 - `decision diary import <markdown-file>` 将带 `# YYYY年M月D日`、`# YYYY-MM-DD` 或 `# YYYY/M/D` 标题的工作日志拆成每天一篇 Markdown 日记,按 `YYYY-MM/YYYY-MM-DD.md` 虚拟路径写入 Decision Center PostgreSQL;`decision diary list/months/show` 分别用于按月/日期查询、列出月份和查看单日正文。 -- `deploy check/plan/apply` 默认从根目录 `deploy.json` 读取服务 repo 与 commit 期望状态,join `config.json` 和现有 manifest 后使用 target-side build 单一路径校验或更新直管服务与 k3s 代管服务;`deploy plan --env dev|prod` 只从固定 Git ref 读取 manifest 并输出 dry-run 环境计划,不使用本地 dirty worktree;`deploy apply --env dev --service backend-core|frontend|code-queue` 可按 `origin/deploy/dev:deploy.json` 部署当前 D601 dev slice,`--env prod` apply 仍禁用;规则见 `docs/reference/deploy.md`。 +- `deploy check/plan/apply` 默认从根目录 `deploy.json` 读取服务 repo 与 commit 期望状态,join `config.json` 和现有 manifest 后使用 target-side build 单一路径校验或更新直管服务与 k3s 代管服务;`deploy plan --env dev|prod` 只从 `origin/master:deploy.json#environments.<env>` 读取 manifest 并输出 dry-run 环境计划,不使用本地 dirty worktree;维护通道直连 D601 apply 只允许 `--env dev --service devops` 做 DevOps 自举/修复,backend-core/frontend/code-queue 等 dev 服务必须经 DevOps 控制面部署,`--env prod` apply 仍禁用;规则见 `docs/reference/deploy.md`。 - `dev-env validate [--manifest path] [--kubectl-dry-run]` 离线校验 D601 `unidesk-dev` namespace、dev PostgreSQL 底座和 dev workload manifest。默认检查 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml`;也可显式校验 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml` 或 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml`。所有 namespaced 对象必须只落到 `unidesk-dev`,foundation manifest 必须包含 `postgres-dev` StatefulSet/Service、dev secret/config、迁移 Job 和 DB URL guard,core manifest 必须包含 `backend-core-dev`/`frontend-dev` Deployment/Service,Code Queue dev manifest 必须包含 `code-queue-scheduler-dev`、`code-queue-read-dev`、`code-queue-write-dev` 和 dev provider egress proxy。加 `--kubectl-dry-run` 时额外执行 `kubectl apply --dry-run=client --validate=false -f <manifest>`,仍不 apply 资源。 - `dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]` 创建异步 job,通过 UniDesk SSH 维护桥在 D601 上把开发底座依赖镜像从 Docker 缓存导入原生 k3s containerd。默认镜像是 `postgres:16-alpine` 和 `rancher/mirrored-library-busybox:1.36.1`,用于避免 `postgres-dev` 与 local-path helper pod 卡在外部 registry 拉取。该命令固定验证 `/etc/rancher/k3s/k3s.yaml` 指向的 native k3s 上下文,并输出 `dev_env_containerd_image_ready=...` 作为成功判据;它不 apply manifest、不修改生产 `unidesk` namespace。 -- `codex deploy <commitId>` 是 Code Queue 兼容部署入口,会生成临时 desired manifest 并调用 `deploy apply --service code-queue` 的同一条 target-side build、k3s import、rollout 和 live commit 验证路径;详细规则见 `docs/reference/codex-deploy.md`。 +- `ci install|status|run|run-dev-e2e|logs` 管理 D601 原生 k3s 上的 Tekton CI。`run` 手动创建每 commit 检查和 Code Queue 只读性能门禁;`run-dev-e2e` 手动读取 `origin/master:deploy.json#environments.dev`,创建临时 `unidesk-ci-e2e-*` namespace,验证 dev desired manifest、临时 Service DNS 和 smoke e2e 结果,默认清理 namespace,不修改 `unidesk`、`unidesk-dev` 或生产数据库;规则见 `docs/reference/ci.md`。 +- `codex deploy <commitId>` 是旧 Code Queue 兼容部署入口,已禁用以防止维护通道直连 D601 部署 Code Queue;后续 Code Queue 部署必须经 DevOps 控制面,详细规则见 `docs/reference/codex-deploy.md`。 - `codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue queueId] [--provider-id id] [--cwd path] [--model model] [--reasoning-effort effort] [--execution-mode mode] [--max-attempts N] [--reference-task-id id] [--dry-run]` 通过 backend-core 私有代理向稳定 `code-queue` 用户服务路径提交任务;prompt 必须且只能来自位置参数、文件或 stdin 之一,`--dry-run` 只返回结构化请求且不实际入队。提交确认和 dry-run 必须返回完整 prompt、字符数和 `truncated=false`,不能套用任务详情的预览截断策略,否则长任务 prompt 无法被人工验收。backend-core 默认把提交、队列 CRUD、已读状态、历史摘要和轻量 Trace 读取分流到主 server `code-queue-mgr`,由它写入主 PostgreSQL;D601 scheduler 只轮询并执行已入库任务。 - `codex task <taskId>` 通过 Code Queue 私有代理按任务 ID 查询结构化执行摘要;默认只返回有界 prompt/response 预览、执行 Provider、工作目录、最后 assistant message、最近工具调用摘要、attempt、judge、错误、耗时和 trace 翻页提示,适合在新队列任务中引用历史 session 且避免噪声爆炸。该摘要读取默认由主 server `code-queue-mgr` 从 PostgreSQL 返回,不依赖 D601 `code-queue-read` Service 可用。 - `codex task <taskId> --trace --tail|--from-start|--after-seq N|--before-seq N --limit N` 按页拉取 Code Queue 的逻辑 trace;响应会返回 `nextAfterSeq`、`previousBeforeSeq`、`hasMore`、`hasBefore` 和下一页/上一页命令,默认 `--trace` 取最新一页,需要完整 prompt/最后 response 时加 `--full`。 @@ -42,9 +43,9 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 长时操作采用 Fire-and-Forget 模式:CLI 创建 `.state/jobs/{jobId}.json`,后台进程执行真实命令,并将 stdout、stderr 分别写入 `.state/jobs/{jobId}.stdout.log` 与 `.state/jobs/{jobId}.stderr.log`。调用者通过 `bun scripts/cli.ts job status <jobId>` 查询进度和尾部输出。 -`server rebuild` 与 `server start`、`server stop` 一样必须通过返回的 job id 确认结果;不要把连续 `server rebuild` 命令理解成“前一个重建已完成”,因为两个命令只是在快速创建异步 job。重建 frontend 的标准流程是运行 `bun scripts/cli.ts server rebuild frontend`,随后轮询 `bun scripts/cli.ts job status <jobId>` 到 `succeeded`,再用 `server status` 或 `e2e run` 验证公网 frontend;重建 Todo Note 后端使用 `bun scripts/cli.ts server rebuild todo-note`,随后用 `microservice health todo-note` 和 `microservice proxy todo-note /api/instances` 验证;重建 Code Queue Manager 使用 `bun scripts/cli.ts server rebuild code-queue-mgr`,随后用 `microservice health code-queue-mgr`、`microservice health code-queue` 和 `codex submit --dry-run` 验证主 server 控制面路径;重建 Project Manager 后端使用 `bun scripts/cli.ts server rebuild project-manager`,随后用 `microservice health project-manager` 和 `microservice proxy project-manager /api/projects` 验证;重建 Baidu Netdisk 后端使用 `bun scripts/cli.ts server rebuild baidu-netdisk`,随后用 `microservice health baidu-netdisk` 和 `microservice proxy baidu-netdisk /api/transfers` 验证;重建 OA Event Flow 后端使用 `bun scripts/cli.ts server rebuild oa-event-flow`,随后用 `microservice health oa-event-flow` 和 `microservice proxy oa-event-flow /api/diagnostics` 验证。D601 Code Queue 执行面和 Decision Center 后端由 D601 k3s/k8s 控制面代管,必须使用 `bun scripts/cli.ts deploy apply --service code-queue`、`bun scripts/cli.ts deploy apply --service decision-center` 或 Code Queue 兼容入口 `bun scripts/cli.ts codex deploy <commitId>` 部署已 push 的 remote commit;部署 job 自身必须通过真实 `/health` 和 k3s Deployment annotation 证明不是旧服务在充数,之后再用 `microservice health <service>` 和对应私有代理 API 做人工复核。不得把 `docker rm` 手工兜底当成正式交付步骤。 +`server rebuild` 与 `server start`、`server stop` 一样必须通过返回的 job id 确认结果;不要把连续 `server rebuild` 命令理解成“前一个重建已完成”,因为两个命令只是在快速创建异步 job。重建 frontend 的标准流程是运行 `bun scripts/cli.ts server rebuild frontend`,随后轮询 `bun scripts/cli.ts job status <jobId>` 到 `succeeded`,再用 `server status` 或 `e2e run` 验证公网 frontend;重建 Todo Note 后端使用 `bun scripts/cli.ts server rebuild todo-note`,随后用 `microservice health todo-note` 和 `microservice proxy todo-note /api/instances` 验证;重建 Code Queue Manager 使用 `bun scripts/cli.ts server rebuild code-queue-mgr`,随后用 `microservice health code-queue-mgr`、`microservice health code-queue` 和 `codex submit --dry-run` 验证主 server 控制面路径;重建 Project Manager 后端使用 `bun scripts/cli.ts server rebuild project-manager`,随后用 `microservice health project-manager` 和 `microservice proxy project-manager /api/projects` 验证;重建 Baidu Netdisk 后端使用 `bun scripts/cli.ts server rebuild baidu-netdisk`,随后用 `microservice health baidu-netdisk` 和 `microservice proxy baidu-netdisk /api/transfers` 验证;重建 OA Event Flow 后端使用 `bun scripts/cli.ts server rebuild oa-event-flow`,随后用 `microservice health oa-event-flow` 和 `microservice proxy oa-event-flow /api/diagnostics` 验证。D601 Code Queue 执行面和 Decision Center 后端由 D601 k3s/k8s 控制面代管,但不得再通过维护通道直连 D601 做部署;除 DevOps 自举/修复外,D601 直管或代管微服务必须由 DevOps 控制面执行部署、rollout 和 live commit 验证。不得把 `docker rm` 手工兜底当成正式交付步骤。 -新部署入口优先使用 `deploy apply`。旧的 `server rebuild` 和 `codex deploy` 只保留为兼容入口,后续实现应收敛到同一个 reconciler:从 remote commit 导出源码,在目标节点一次性代理构建镜像,部署后用 live commit 校验证明不是旧服务。 +新部署入口优先使用 `deploy apply`,但 D601 维护直连 apply 只服务 DevOps 自举/修复。旧的 `codex deploy` 已禁用;后续 Code Queue、Decision Center、backend-core dev、frontend dev 等 D601 服务部署应收敛到 DevOps 控制面:从 remote commit 导出源码,在目标节点一次性代理构建镜像,部署后用 live commit 校验证明不是旧服务。 ## Output Contract diff --git a/docs/reference/codex-deploy.md b/docs/reference/codex-deploy.md index e32a03c9..3857bdfb 100644 --- a/docs/reference/codex-deploy.md +++ b/docs/reference/codex-deploy.md @@ -1,25 +1,22 @@ # Code Queue Deploy -`bun scripts/cli.ts codex deploy <commitId>` 是兼容入口。新的正式部署入口是 `bun scripts/cli.ts deploy apply --service code-queue`;兼容入口会生成一个只包含 Code Queue repo 与 commit 的临时 desired manifest,再调用同一个 deploy reconciler。命令只在主 server 工作区执行;它会立即返回异步 job id,后台 job 通过 backend-core 的 `host.ssh` dispatch 在 D601 完成实际部署。 +`bun scripts/cli.ts codex deploy <commitId>` 是旧兼容入口,现已禁用。原因是它会通过 backend-core `host.ssh` 维护通道直连 D601 部署 Code Queue,绕过 DevOps 控制面;维护通道直连 D601 现在只允许部署或修复 DevOps 本身。 + +Code Queue 后续正式部署必须由 DevOps 控制面执行:CLI 读取 `origin/master:deploy.json#environments.dev` 或生产 desired-state 后,经 backend-core、k3sctl-adapter 和 DevOps 触发 target-side build、k3s image import、rollout、stamp 和 live commit 验证。 ## Command ```bash -bun scripts/cli.ts deploy apply --service code-queue bun scripts/cli.ts codex deploy <commitId> -bun scripts/cli.ts job status <jobId> --tail-bytes 30000 ``` -- `commitId` 必须是已经 push 到 remote 的 7-40 位 hex commit SHA。 -- `--provider-id D601` 是默认值;当前部署路径只支持 D601 active instance。 -- `--timeout-ms N` 控制后台部署总超时,默认 `900000`。 -- `--skip-build` 不再支持;target-side Docker build 是强制步骤。 +该命令必须返回结构化错误,提示改用 DevOps 控制面;不得再创建后台部署 job。`--skip-build` 不再支持。 ## Pipeline -部署 job 的步骤固定为: +历史部署 job 曾固定为以下步骤;它们现在只能作为 DevOps 控制面实现 Code Queue CD 时的目标行为,不能由 `codex deploy` 或维护通道直连触发: -1. 对 Code Queue 部署先确保 PostgreSQL 中存在 `unidesk_deploy_ssh_identities(id='github.com')`,该记录保存 GitHub deploy SSH identity 的 private key、public key fingerprint 和 github.com `known_hosts` 行。`codex deploy` 会用主 server 当前 `/root/.ssh/id_ed25519` 种子化这条记录,然后通过 backend-core `/ws/ssh` 交互通道把 identity 流式分发到 D601 WSL `/home/ubuntu/.ssh/id_ed25519`、`id_ed25519.pub` 和 `known_hosts`,并在 D601 侧执行 `ssh -T git@github.com` 验证;secret 不得写入 `host.ssh` task payload、deploy 日志、Docker image 或 Kubernetes Secret。 +1. 对 Code Queue 部署先确保 PostgreSQL 中存在 `unidesk_deploy_ssh_identities(id='github.com')`,该记录保存 GitHub deploy SSH identity 的 private key、public key fingerprint 和 github.com `known_hosts` 行。DevOps 控制面不得把 secret 写入 task payload、deploy 日志、Docker image 或 Kubernetes Secret。 2. 在 D601 的 deploy cache 中通过本机 provider-gateway WS egress proxy 执行 `git fetch` remote,并用 `git archive <commitId>` 导出 tracked files 到一次性 export 目录;不得让 D601 直连 GitHub,也不得临时创建 SSH SOCKS、公网 master proxy 或 backend-core/provider-ingress fallback。 3. 用 `rsync --delete` 同步导出的 repo 到 `/home/ubuntu/cq-deploy`,保留 `.state/`、`logs/`、`.git/`、`node_modules/` 和 `dist/`。 4. 在 D601 用目标 Docker daemon 的本地 BuildKit builder 构建 `unidesk-code-queue:d601`,复用 D601 上已有基础镜像、inline cache 和 Code Queue build-base;provider-gateway WS egress 是唯一允许的构建代理通道,只作为本次 build 的环境变量与 build-arg 注入,并配合本次 build 的 `--network host` 让 RUN 阶段访问 D601 宿主 loopback proxy,不能污染 D601 宿主 Docker/HTTP proxy 配置,不能新建 SSH SOCKS、公网 master proxy 或直连 fallback。 @@ -31,9 +28,9 @@ bun scripts/cli.ts job status <jobId> --tail-bytes 30000 ## Observability -`codex deploy` 本身不阻塞等待部署结束。返回 JSON 中的 `statusCommand` 和 `tailCommand` 是唯一状态入口。后台 job 的 stderr 是 JSONL progress,每个长步骤会记录远端 `/tmp/unidesk-deploy-*.log` 和 sentinel 文件;失败时 `job status` 会显示最后日志尾部。 +DevOps 控制面实现 Code Queue CD 后,部署触发本身不应阻塞等待完成。返回 JSON 中必须包含 run id、status command 或等价查询入口;后台日志必须有界可查,失败时能显示最后日志尾部。 -`job status` 到 `succeeded` 时,部署 job 已经完成 live commit 验证。需要人工复核时可用以下命令确认 `deploy.commit`: +部署 run 到 `succeeded` 时,必须已经完成 live commit 验证。需要人工复核时可用以下命令确认 `deploy.commit`: ```bash bun scripts/cli.ts microservice health code-queue @@ -44,7 +41,7 @@ D601 原生 k3s 的人工诊断必须显式使用 host kubeconfig:`KUBECONFIG= ## Boundaries -Code Queue 由 D601 k3s/k8s 控制面代管,不再通过 `server rebuild` 或手工 `docker compose up` 作为正式部署路径。`codex deploy` 可以在 Code Queue 自身正在执行任务时运行;服务重启后由 restart-recovery 恢复任务状态,不能等待当前 Code Queue task 退出后再部署。 +Code Queue 由 D601 k3s/k8s 控制面代管,不再通过 `server rebuild`、`codex deploy`、维护通道直连 D601 或手工 `docker compose up` 作为正式部署路径。Code Queue 部署必须在自身正在执行任务时仍可运行;服务重启后由 restart-recovery 恢复任务状态,不能等待当前 Code Queue task 退出后再部署。 ## TCP Egress Gateway diff --git a/docs/reference/deploy.md b/docs/reference/deploy.md index 7933e2f2..93b45250 100644 --- a/docs/reference/deploy.md +++ b/docs/reference/deploy.md @@ -4,37 +4,66 @@ UniDesk deployment is driven by a desired-state manifest. The manifest answers o ## Manifest -The root `deploy.json` is intentionally minimal: +The root `deploy.json` is the single desired-state source for both prod and dev. Environment branches such as `deploy/dev` and `deploy/prod` are deprecated because they create a second control plane for version intent. ```json { - "schemaVersion": 1, - "environment": "prod", - "services": [ - { - "id": "code-queue", - "repo": "https://github.com/pikasTech/unidesk", - "commitId": "0c3cdb4ee06a23361ed511a2da033d67b53d16f4" + "schemaVersion": 2, + "environments": { + "prod": { + "services": [ + { + "id": "code-queue", + "repo": "https://github.com/pikasTech/unidesk", + "commitId": "0c3cdb4ee06a23361ed511a2da033d67b53d16f4" + } + ] + }, + "dev": { + "services": [ + { + "id": "backend-core", + "repo": "https://github.com/pikasTech/unidesk", + "commitId": "348c644" + } + ] } - ] + } } ``` -`environment` is optional only for the legacy local-file compatibility path. When present it must be exactly `dev` or `prod`. Any `--env <name>` command requires the manifest to declare the same `environment`; `--env dev` must reject `environment=prod`, and `--env prod` must reject `environment=dev`. +`schemaVersion=1` remains accepted only as a local compatibility format. Standard environment commands use `schemaVersion=2` and select `environments.dev.services` or `environments.prod.services`. `deploy.json` must not contain provider IDs, ports, compose service names, Kubernetes namespace, health paths, environment variables, Dockerfile paths or build commands. The deploy reconciler joins each `id` with `config.json.microservices[]` and existing k3s manifests to resolve those details. A service listed in `deploy.json` but missing from `config.json` is an error. A service with no Dockerfile source artifact is reported as unsupported rather than silently skipped. `commitId` may be a unique pushed short SHA or a full SHA; every deploy command resolves it through the remote repository to a full 40-character commit before target-side build or rollout, and fails immediately if the SHA is missing or ambiguous. -Environment mode never reads the local working tree manifest. The mapping is fixed: - -- `dev -> origin/deploy/dev` -- `prod -> origin/deploy/prod` - -`deploy check --env ...` and `deploy plan --env ...` fetch the fixed ref, read `deploy.json` from that ref, validate the declared environment, and report the manifest commit/blob, service commit IDs, target namespace, database fingerprint and Provider identity without mutating runtime resources. `deploy apply --env dev` is enabled for the current isolated D601 dev slice: `backend-core`, `frontend` and `code-queue`. If no `--service` is given and the dev manifest still includes unsupported later-stage services such as `code-queue-mgr`, the command fails before changing runtime resources. `deploy apply --env prod` remains disabled until the production environment executor and authorization policy are explicitly added. - -The `deploy/dev` and `deploy/prod` branches are environment desired-state branches, not source branches. They should contain only `deploy.json`; Kubernetes manifests, Dockerfiles and executor code continue to live on `master` and are selected through the commit IDs declared in the environment manifest. +Environment mode never reads the local dirty working tree manifest. `deploy check --env ...`, `deploy plan --env ...` and `deploy apply --env ...` fetch `origin/master`, read `origin/master:deploy.json`, select `environments.<env>`, and report the manifest commit/blob, service commit IDs, target namespace, database fingerprint and Provider identity. Maintenance-channel direct D601 apply is intentionally narrow: only `deploy apply --env dev --service devops` may use that path, and only for DevOps bootstrap, repair or break-glass recovery. `deploy apply --env dev --service backend-core|frontend|code-queue` and local-manifest D601 service apply are rejected before runtime mutation; those services must be deployed by the DevOps control plane after it is healthy. `deploy apply --env prod` remains disabled until the production environment executor and authorization policy are explicitly added. `config.json.microservices[].repository.commitId` is retained for catalog compatibility, but `deploy.json` is the deployment version authority for the reconciler. +## DevOps Bootstrap + +DevOps has an intentional first-install bootstrap path to avoid a circular dependency where the service that should deploy CI/CD must already exist before it can deploy itself. + +The only supported first-install shape is a one-shot D601-side script: + +```bash +tmp=$(mktemp) && curl -fsSL https://raw.githubusercontent.com/pikasTech/unidesk/master/scripts/bootstrap/devops-install.sh -o "$tmp" && sudo bash "$tmp" --commit <unidesk-commit-id> --env dev +``` + +The bootstrapper may use D601 local shell, native SSH or provider-gateway Host SSH as a maintenance bridge, but only for DevOps bootstrap, repair and break-glass recovery. This maintenance bridge must not deploy backend-core, frontend, Code Queue, Decision Center, k3sctl-adapter or any other direct/managed microservice. It must run source fetch, Go build, Docker build, k3s image import and Kubernetes apply on D601. The main server must not compile Go/Rust or build DevOps images for D601. + +The bootstrapper is deliberately narrow and idempotent: + +- Verify D601 native k3s and `/etc/rancher/k3s/k3s.yaml`. +- Clone or fetch the UniDesk repo on D601 and checkout the requested commit. +- Build `src/components/microservices/devops/Dockerfile` on D601. +- Import `unidesk-devops:dev` into native k3s containerd. +- Apply `src/components/microservices/k3sctl-adapter/k3s/devops.k8s.yaml` into `unidesk-ci`. +- Wait for `deployment/devops` rollout and `/health`. +- Write a local bootstrap receipt with repo, requested commit, resolved commit, namespace, image and health result. + +After DevOps is healthy, normal CI/CD control should move to `CLI -> backend-core -> k3sctl-adapter -> DevOps -> Kubernetes API/Tekton`. Host SSH remains a DevOps repair path, not a general CI/CD control plane and not a service deployment path. + ## D601 Dev Foundation Phase 2 of the D601 dev environment creates only the isolated namespace and database foundation. The authoritative manifest is `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml`. @@ -43,7 +72,7 @@ It may create resources only in `unidesk-dev`: - `Namespace unidesk-dev`, plus quota and default limits. - `Secret unidesk-dev-runtime-secrets` as a dev-only template for DB credentials, provider token, auth/session secret, and Code Queue model secret placeholders. -- `ConfigMap unidesk-dev-runtime-config` for dev identity, fixed deploy ref `origin/deploy/dev`, provider id `D601-dev`, Code Queue dev paths, and non-secret runtime defaults. +- `ConfigMap unidesk-dev-runtime-config` for dev identity, desired-state source `origin/master:deploy.json#environments.dev`, provider id `D601-dev`, Code Queue dev paths, and non-secret runtime defaults. - `ConfigMap unidesk-dev-db-guard` with an executable guard script that rejects production-looking `DATABASE_URL` values. - `StatefulSet/Service postgres-dev` with a 5Gi persistent volume claim and bounded CPU/memory requests/limits. - `Job unidesk-dev-db-migrate`, which waits for `postgres-dev`, runs the guard, then prepares backend-core and Code Queue tables in the independent `unidesk_dev` database. @@ -62,7 +91,7 @@ Phase 3 introduces the dev backend/frontend manifest at `src/components/microser `backend-core-dev` must use `unidesk-dev-runtime-config` and `unidesk-dev-runtime-secrets`, connect to `postgres-dev.../unidesk_dev`, expose HTTP on 8080 and provider ingress on 8081, and write logs under `/var/log/unidesk-dev`. `frontend-dev` must set `CORE_INTERNAL_URL=http://backend-core-dev.unidesk-dev.svc.cluster.local:8080` and must not proxy to production backend-core. -The manifest keeps placeholder image tags and deploy commit values in source control. `deploy apply --env dev --service backend-core|frontend` fetches `origin/deploy/dev:deploy.json`, materializes the requested source commit on D601, copies the dev core control manifest, narrows it to the selected Service/Deployment pair, replaces placeholders with the requested commit and dev image tag, builds on D601, imports the image into native k3s containerd, applies only the `unidesk-dev` objects and stamps the Deployment. Client dry-run and static validation are the required checks before any controlled apply: +The manifest keeps placeholder image tags and deploy commit values in source control. Maintenance-channel direct D601 apply must not deploy `backend-core-dev` or `frontend-dev`; the CLI rejects `deploy apply --env dev --service backend-core|frontend` before runtime mutation. Dev core deployment must be implemented as a DevOps-controlled CD action that fetches `origin/master:deploy.json`, selects `environments.dev`, materializes the requested source commit on D601, narrows the dev core control manifest to the selected Service/Deployment pair, replaces placeholders with the requested commit and dev image tag, builds on D601, imports the image into native k3s containerd, applies only the `unidesk-dev` objects and stamps the Deployment. Client dry-run and static validation are the required checks before any controlled apply: - `bun scripts/cli.ts dev-env validate --manifest src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml` - `KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl apply --dry-run=client --validate=false -f src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml` @@ -75,7 +104,7 @@ Phase 5 introduces the dev Code Queue execution manifest at `src/components/micr All dev Code Queue components must use `unidesk-dev-runtime-config` and `unidesk-dev-runtime-secrets`, connect to `postgres-dev.../unidesk_dev`, write logs and state under `/home/ubuntu/unidesk-dev-code-queue-deploy/state`, and expose HTTP on 4222 only as ClusterIP services. The scheduler uses `CODE_QUEUE_MAIN_PROVIDER_ID=D601-dev`, `CODE_QUEUE_WORKDIR=/workspace-dev`, `CODE_QUEUE_REMOTE_WORKDIR=/home/ubuntu/unidesk-dev-workspace`, disables ClaudeQQ notifications by default, and does not use the production `d601-tcp-egress-gateway` or production PostgreSQL route. -`deploy apply --env dev --service code-queue` fetches `origin/deploy/dev:deploy.json`, materializes the requested source commit on D601, uses the dev Code Queue control manifest from that D601 materialized commit, narrows it to Code Queue dev objects, replaces placeholders with the requested commit and `unidesk-code-queue:dev`, builds on D601, imports the image into native k3s containerd, applies only `unidesk-dev` objects and stamps the dev Deployments. Because Code Queue carries the agent toolchain and browser/runtime dependencies, dev builds may reuse an existing D601 `unidesk-code-queue:d601-build-base` or `unidesk-code-queue:d601` image when the dev build-base tag is absent, and the deploy executor allows a longer Code Queue build window than lightweight services. The scheduler has an explicit 5Gi memory limit and must use `Recreate` rollout strategy so an update does not temporarily require two scheduler replicas under the namespace quota. All dev Code Queue containers must set CPU limits so the namespace `LimitRange` does not inject a quota-breaking default CPU limit. Live health verification uses the Kubernetes API service proxy for the dev ClusterIP Service, not `kubectl exec` or debug binaries inside the application image. This first dev execution slice proves deployability, health and dev database isolation; wiring the dev frontend stable `code-queue` route through a dev `code-queue-mgr` is a separate later phase. +Maintenance-channel direct D601 apply must not deploy dev Code Queue; the CLI rejects `deploy apply --env dev --service code-queue` and the old `codex deploy` compatibility entry is disabled. Dev Code Queue deployment must be a DevOps-controlled CD action that fetches `origin/master:deploy.json`, selects `environments.dev`, materializes the requested source commit on D601, uses the dev Code Queue control manifest from that D601 materialized commit, narrows it to Code Queue dev objects, replaces placeholders with the requested commit and `unidesk-code-queue:dev`, builds on D601, imports the image into native k3s containerd, applies only `unidesk-dev` objects and stamps the dev Deployments. Because Code Queue carries the agent toolchain and browser/runtime dependencies, dev builds may reuse an existing D601 `unidesk-code-queue:d601-build-base` or `unidesk-code-queue:d601` image when the dev build-base tag is absent, and the deploy executor allows a longer Code Queue build window than lightweight services. The scheduler has an explicit 5Gi memory limit and must use `Recreate` rollout strategy so an update does not temporarily require two scheduler replicas under the namespace quota. All dev Code Queue containers must set CPU limits so the namespace `LimitRange` does not inject a quota-breaking default CPU limit. Live health verification uses the Kubernetes API service proxy for the dev ClusterIP Service, not `kubectl exec` or debug binaries inside the application image. This first dev execution slice proves deployability, health and dev database isolation; wiring the dev frontend stable `code-queue` route through a dev `code-queue-mgr` is a separate later phase. ## CLI @@ -83,9 +112,9 @@ All dev Code Queue components must use `unidesk-dev-runtime-config` and `unidesk `bun scripts/cli.ts deploy plan [--file deploy.json] [--service <id>]` prints the same live state plus the intended action: `noop`, `deploy` or `unsupported`. -`bun scripts/cli.ts deploy plan --env dev [--service <id>]` reads `origin/deploy/dev:deploy.json` and prints a dry-run environment plan without checking or mutating live runtime resources. `deploy check --env dev` uses the same dry-run environment plan. `--env prod` is available for parity as a dry-run planning path; it reads `origin/deploy/prod:deploy.json` and must not use a dirty local `deploy.json`. +`bun scripts/cli.ts deploy plan --env dev [--service <id>]` reads `origin/master:deploy.json#environments.dev` and prints a dry-run environment plan without checking or mutating live runtime resources. `deploy check --env dev` uses the same dry-run environment plan. `--env prod` is available for parity as a dry-run planning path; it reads `origin/master:deploy.json#environments.prod` and must not use a dirty local `deploy.json`. -`bun scripts/cli.ts deploy apply [--file deploy.json | --env dev] [--service <id>] [--dry-run] [--force]` starts an asynchronous job. Use `bun scripts/cli.ts job status <jobId> --tail-bytes 30000` to observe progress. `--dry-run` resolves the same plan but does not build or replace runtime objects. `--force` rebuilds even when the live commit matches. Environment apply currently supports `--env dev --service backend-core`, `--env dev --service frontend` and `--env dev --service code-queue`; `--env prod` apply is rejected. +`bun scripts/cli.ts deploy apply [--file deploy.json | --env dev] [--service <id>] [--dry-run] [--force]` starts an asynchronous job only for supported targets. Use `bun scripts/cli.ts job status <jobId> --tail-bytes 30000` to observe progress. `--dry-run` resolves the same plan but does not build or replace runtime objects. `--force` rebuilds even when the live commit matches. Environment apply currently supports only `--env dev --service devops` on the D601 maintenance direct path; `--env prod` apply is rejected, and D601 non-DevOps service apply is rejected before any runtime mutation. All deploy commands output JSON. Long operations must use `.state/jobs/` and bounded log tails; no deploy path may succeed with missing progress output. @@ -124,17 +153,17 @@ The reconciler selects the executor from `config.json`: - `deployment.mode=unidesk-direct` on `main-server`: build the image on the main server, then use the fixed UniDesk Compose project and `up -d --no-build --no-deps --force-recreate <service>`. - `deployment.mode=internal-sidecar` on `main-server`: use the same main-server target-side source export, Docker build, image label stamping, fixed Compose project replacement and live commit verification as direct Compose services. This class is for private sidecars such as `code-queue-mgr`; it is still versioned by `deploy.json.commitId`, not by the operator's current worktree. -- `deployment.mode=unidesk-direct` on a provider: dispatch `host.ssh` to that provider, build on the provider, then use the service's provider-local compose file and project. The executor resolves the actual Compose project, image name, build context, Dockerfile and target from the running container labels and `docker compose config`; it must not guess an image tag that the service will not actually run. +- `deployment.mode=unidesk-direct` on a provider: this executor is disabled for D601 service deployment except for the explicit DevOps bootstrap/repair path. The historical behavior dispatched `host.ssh` to the provider, built on the provider, then used the service's provider-local compose file and project; that shape must move behind DevOps for D601 services so the maintenance bridge cannot become a second deployment control plane. - Control bridges that UniDesk needs in order to inspect or repair an orchestrator must stay in this direct class. In particular, `k3sctl-adapter` is a UniDesk-managed bridge to native k3s and must remain outside k3s; Docker packaging on Docker Desktop/WSL must create an explicit host-local bridge, currently an adapter-container SSH local tunnel, to reach `/etc/rancher/k3s/k3s.yaml` and WSL `127.0.0.1:6443`. -- `deployment.mode=k3sctl-managed`: dispatch to the active control target, build on that target, verify or install native k3s on the host OS/WSL distro, import the image into native k3s/containerd, apply the existing Kubernetes manifest, stamp the Deployment and wait for rollout. The executor must use the native kubeconfig and containerd socket, for example `/etc/rancher/k3s/k3s.yaml` and `/run/k3s/containerd/containerd.sock`; running k3s itself in Docker is forbidden for both control-plane and worker nodes. A `rancher/k3s` image or legacy container may only be used as a temporary artifact source during migration, and any active containerized k3s control plane must be stopped before verification succeeds. The executor must preload a valid `rancher/mirrored-pause:3.6` sandbox image into native k3s containerd through the provider-gateway one-shot egress path, verify its entrypoint is `/pause`, and reject fake or sleep-based replacement images. Code Queue's k3s migration executor must also stop/remove the legacy direct Docker `code-queue-backend` after k3s rollout, so there is never a second scheduler running beside the native k3s scheduler. +- `deployment.mode=k3sctl-managed`: the target behavior is to build on the active control target, verify native k3s on the host OS/WSL distro, import the image into native k3s/containerd, apply the existing Kubernetes manifest, stamp the Deployment and wait for rollout. On D601, maintenance-channel direct execution of this behavior is reserved for DevOps itself; other k3s managed services must be reconciled by DevOps after bootstrap. The executor must use the native kubeconfig and containerd socket, for example `/etc/rancher/k3s/k3s.yaml` and `/run/k3s/containerd/containerd.sock`; running k3s itself in Docker is forbidden for both control-plane and worker nodes. A `rancher/k3s` image or legacy container may only be used as a temporary artifact source during migration, and any active containerized k3s control plane must be stopped before verification succeeds. The executor must preload a valid `rancher/mirrored-pause:3.6` sandbox image into native k3s containerd through the provider-gateway one-shot egress path, verify its entrypoint is `/pause`, and reject fake or sleep-based replacement images. Code Queue's k3s migration executor must also stop/remove the legacy direct Docker `code-queue-backend` after k3s rollout, so there is never a second scheduler running beside the native k3s scheduler. -Existing service-specific commands such as Code Queue deploy should converge onto this reconciler path instead of keeping a parallel implementation. +Existing service-specific commands such as Code Queue deploy are disabled as direct D601 deploy paths. Their build/import/rollout semantics should converge into DevOps-controlled CD instead of keeping a parallel implementation. -Decision Center is a standard `k3sctl-managed` service in this model. `deploy apply --service decision-center` must build `src/components/microservices/decision-center/Dockerfile` on D601, import `unidesk-decision-center:d601` into native k3s containerd, apply `src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml`, stamp the Deployment, and verify health through `/api/microservices/decision-center/health`. It must not add a main-server Compose service, NodePort, hostPort, or provider-gateway direct HTTP backend for Decision Center. +Decision Center is a standard `k3sctl-managed` service in this model, but D601 maintenance-channel direct apply must not deploy it. DevOps-controlled CD for Decision Center should build `src/components/microservices/decision-center/Dockerfile` on D601, import `unidesk-decision-center:d601` into native k3s containerd, apply `src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml`, stamp the Deployment, and verify health through `/api/microservices/decision-center/health`. It must not add a main-server Compose service, NodePort, hostPort, or provider-gateway direct HTTP backend for Decision Center. ## CI Separation -Continuous integration is intentionally separate from this deploy reconciler. D601 k3s hosts Tekton CI resources described in `docs/reference/ci.md`, but those PipelineRuns only clone, check and run read-only performance gates. They must not call `deploy apply`, `codex deploy`, `kubectl rollout restart` for production services, or mutate `deploy.json`. +Continuous integration is intentionally separate from this deploy reconciler. D601 k3s hosts Tekton CI resources described in `docs/reference/ci.md`, but those PipelineRuns only clone, check, run read-only performance gates, or create temporary CI-owned namespaces for dev manifest smoke e2e. They must not call `deploy apply`, `codex deploy`, `kubectl rollout restart` for production services, mutate `deploy.json`, or write production namespaces. The Code Queue performance gate may create a temporary `code-queue-ci-read` service and read the main PostgreSQL through the existing `d601-tcp-egress-gateway`. Because it runs with `CODE_QUEUE_SERVICE_ROLE=read`, scheduler/backfill/notification disabled and EmptyDir state, it is not deployment truth and does not need a temporary database for the current read-only checks. diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index 41b06124..758283ca 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -27,7 +27,7 @@ CLI 会优先使用 `docker compose` v2 plugin;当 v2 plugin 不存在时才 Compose v2 安装后仍然必须遵守 UniDesk 的服务控制入口:全栈生命周期用 `server start` / `server stop`,单服务重建用 `server rebuild <service>`。不要因为 v2 可用就直接在生产栈上手工执行未纳入 CLI 的 `up --build`、`down -v` 或跨项目清理命令;所有会影响容器的动作都应保持 job 可观测、Compose project 固定、database named volume 保留。主 server Compose 命令必须从 `providerGateway.upgrade.hostProjectRoot` 指定的 canonical UniDesk 根目录运行,临时 worktree、Code Queue 导出目录或实验分支不得复用生产 `-p unidesk` 和固定 `container_name` 去替换生产容器。 -版本化用户服务部署优先使用 `bun scripts/cli.ts deploy apply`。`deploy.json` 只声明服务 `id`、`repo` 和 `commitId`;目标节点、Dockerfile、Compose、Kubernetes manifest、健康检查和代理路径继续来自 `config.json` 与现有 manifest。主 server 直管微服务和内部 sidecar,例如 `code-queue-mgr`,也必须支持这一路径:`deploy apply --service code-queue-mgr` 从 `deploy.json` 指定 commit 导出源码、构建镜像、替换固定 Compose service 并验证运行中镜像/健康信息的 commit。部署必须遵循 target-side build:服务部署到哪台 target,就在哪台 target 从 remote commit 导出源码、一次性代理构建镜像并部署;不得把中心构建镜像作为默认分发路径,也不得用 `docker commit` 或脏 worktree 作为部署输入。完整规则见 `docs/reference/deploy.md`。 +版本化用户服务部署优先使用 `bun scripts/cli.ts deploy apply` 或 DevOps 控制面,但 D601 维护通道直连 apply 只允许部署或修复 DevOps 本身。`deploy.json` 只声明服务 `id`、`repo` 和 `commitId`;目标节点、Dockerfile、Compose、Kubernetes manifest、健康检查和代理路径继续来自 `config.json` 与现有 manifest。主 server 直管微服务和内部 sidecar,例如 `code-queue-mgr`,也必须支持这一路径:`deploy apply --service code-queue-mgr` 从 `deploy.json` 指定 commit 导出源码、构建镜像、替换固定 Compose service 并验证运行中镜像/健康信息的 commit。部署必须遵循 target-side build:服务部署到哪台 target,就在哪台 target 从 remote commit 导出源码、一次性代理构建镜像并部署;不得把中心构建镜像作为默认分发路径,也不得用 `docker commit` 或脏 worktree 作为部署输入。完整规则见 `docs/reference/deploy.md`。 ## Main Server Swap @@ -43,7 +43,7 @@ swap 管理不能被强塞进所有热路径。`server start/status` 可以暴 ## Single Service Rebuild -前端、backend-core、本机 provider-gateway 或主 server 承载的 Todo Note/Code Queue Manager/Project Manager/Baidu Netdisk/OA Event Flow 用户服务需要非版本化本地重建时,统一使用 `bun scripts/cli.ts server rebuild <service>`,其中 `<service>` 只能是 `backend-core`、`frontend`、`provider-gateway`、`todo-note`、`code-queue-mgr`、`project-manager`、`baidu-netdisk` 或 `oa-event-flow`。需要按 commit 上线或恢复到 desired-state 时必须改用 `bun scripts/cli.ts deploy apply --service <id>`;直管微服务也不能把脏工作树或手工重建作为部署真相。D601 Code Queue 执行面、File Browser、FindJob、Pipeline、MET Nonlinear 和 ClaudeQQ 部署在计算节点,不属于主 server Compose 可重建服务;其中 D601 Code Queue 执行面的正式入口是 `bun scripts/cli.ts codex deploy <commitId>`。该命令先执行目标服务镜像构建,构建成功后才通过 `up -d --no-deps --force-recreate <service>` 替换目标容器,避免构建失败导致运行中的服务被提前停掉。 +前端、backend-core、本机 provider-gateway 或主 server 承载的 Todo Note/Code Queue Manager/Project Manager/Baidu Netdisk/OA Event Flow 用户服务需要非版本化本地重建时,统一使用 `bun scripts/cli.ts server rebuild <service>`,其中 `<service>` 只能是 `backend-core`、`frontend`、`provider-gateway`、`todo-note`、`code-queue-mgr`、`project-manager`、`baidu-netdisk` 或 `oa-event-flow`。需要按 commit 上线或恢复到 desired-state 时必须改用 `bun scripts/cli.ts deploy apply --service <id>`;直管微服务也不能把脏工作树或手工重建作为部署真相。D601 Code Queue 执行面、File Browser、FindJob、Pipeline、MET Nonlinear 和 ClaudeQQ 部署在计算节点,不属于主 server Compose 可重建服务;其中 D601 Code Queue 执行面不得再通过 `codex deploy` 或维护通道直连 D601 部署,必须经 DevOps 控制面执行 build-first、rollout 和 live commit 验证。 frontend 改动必须明确上线到公网:修改 `src/components/frontend/src/`、`src/components/frontend/public/style.css`、frontend 使用的共享 TSX/TS 模块或 WebUI 导航后,必须在同一变更集中执行 `bun scripts/cli.ts server rebuild frontend`,并等待 job 成功。公网 WebUI 的 `/app.js` 是 `unidesk-frontend` 容器启动时从镜像内源码转译生成的运行时 bundle;只改工作区文件、只跑 `bun run check`、只跑 `Bun.build` 或只刷新浏览器都不会替换已经运行的容器。 @@ -53,7 +53,7 @@ frontend 的 Docker 上线顺序为:先运行必要的本地校验,例如 `b 紧急灾备或数据迁移期间如需手工启动单个 Compose service,也必须保持与 CLI 相同的隔离语义:使用固定 `--env-file .state/docker-compose.env` 和 `up -d --no-deps <service>`,只启动目标容器;如果需要刷新 backend-core 的服务目录或环境变量,应把 `backend-core` 作为显式目标单独重建/替换,不能依赖 `up` 的依赖解析顺手重建 database、backend-core 或其他服务。 -正式流程不得依赖人工 `docker rm` 兜底;手工删除旧容器后若 job、Docker client 或 daemon 在 `up` 前中断,会直接造成用户服务代理失败。`server rebuild <service>` 和 `codex deploy <commitId>` 都必须是可观测 job:build-first、受控替换、post-up validation、保留命名卷或 `.state` 运行态目录。Code Queue 等计算节点长任务服务即使被重建也必须依赖服务自身 restart-recovery 恢复任务,不能用“避免重建”掩盖恢复缺陷。 +正式流程不得依赖人工 `docker rm` 兜底;手工删除旧容器后若 job、Docker client 或 daemon 在 `up` 前中断,会直接造成用户服务代理失败。`server rebuild <service>`、`deploy apply` 和 DevOps 部署 run 都必须是可观测流程:build-first、受控替换、post-up validation、保留命名卷或 `.state` 运行态目录。Code Queue 等计算节点长任务服务即使被重建也必须依赖服务自身 restart-recovery 恢复任务,不能用“避免重建”掩盖恢复缺陷。 ## User Service Restart Recovery diff --git a/docs/reference/microservices.md b/docs/reference/microservices.md index 5527b8ba..9e4a3e72 100644 --- a/docs/reference/microservices.md +++ b/docs/reference/microservices.md @@ -165,9 +165,9 @@ D601 上必须显式使用原生 k3s kubeconfig:`KUBECONFIG=/etc/rancher/k3s/k `backend-core-dev` 与 `frontend-dev` 的第一版 manifest 固定为 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml`。该 manifest 只允许创建 `unidesk-dev` 内的 `backend-core-dev`、`frontend-dev` Deployment/Service;不得修改生产主 server Compose、生产 `unidesk` namespace 或生产 backend/frontend。 -`backend-core-dev` 必须从 `unidesk-dev-runtime-config` 和 `unidesk-dev-runtime-secrets` 注入 dev-only 配置,使用 `postgres-dev.../unidesk_dev`、dev Provider token、dev log path 和 `UNIDESK_DEPLOY_REF=origin/deploy/dev`。`frontend-dev` 必须把 `CORE_INTERNAL_URL` 指向 `backend-core-dev.unidesk-dev.svc.cluster.local:8080`,页面在 dev identity 下显示 DEV 标记,`/health` 返回 dev namespace、database、service id、deploy ref 和 commit metadata。生产环境未设置 dev identity 时,backend-core 和 frontend health payload 保持生产兼容形状。 +`backend-core-dev` 必须从 `unidesk-dev-runtime-config` 和 `unidesk-dev-runtime-secrets` 注入 dev-only 配置,使用 `postgres-dev.../unidesk_dev`、dev Provider token、dev log path 和 `UNIDESK_DEPLOY_REF=origin/master:deploy.json#environments.dev`。`frontend-dev` 必须把 `CORE_INTERNAL_URL` 指向 `backend-core-dev.unidesk-dev.svc.cluster.local:8080`,页面在 dev identity 下显示 DEV 标记,`/health` 返回 dev namespace、database、service id、deploy ref 和 commit metadata。生产环境未设置 dev identity 时,backend-core 和 frontend health payload 保持生产兼容形状。 -`unidesk-dev-core.k8s.yaml` 当前使用 placeholder image/commit;正式 rollout 需要后续 `deploy apply --env dev` executor 从 `origin/deploy/dev:deploy.json` 替换 commit 并构建镜像。当前验收只做静态校验和 Kubernetes client dry-run,不能把 placeholder manifest 当成已上线。 +`unidesk-dev-core.k8s.yaml` 当前使用 placeholder image/commit;正式 rollout 需要 `deploy apply --env dev` executor 从 `origin/master:deploy.json#environments.dev` 替换 commit 并构建镜像。当前验收只做静态校验和 Kubernetes client dry-run,不能把 placeholder manifest 当成已上线。 ### Code Queue k3s-Managed @@ -188,8 +188,8 @@ D601 上必须显式使用原生 k3s kubeconfig:`KUBECONFIG=/etc/rancher/k3s/k - MiniMax/OpenCode 并发:`minimax-m2.7` 通过 OpenCode JSON 事件端口运行;每个 Code Queue task 必须使用独立的 OpenCode XDG data/config/cache/state 目录,禁止多队列并发任务共享同一个 OpenCode SQLite/WAL 状态目录,否则并发 smoke 会触发 `PRAGMA journal_mode = WAL` 之类的数据库锁或初始化错误。用于验证 k3s/k8s 链路的 MiniMax smoke 以“至少 4 个任务、分布到 2 个 queue、至少 2 个终态成功”为链路验收线;剩余失败如果是 OpenCode 最终回复捕获、业务任务判定或模型限流,应作为 Code Queue 执行可靠性问题单独排查,不能反推 k3s 代理链路失败。 - 默认出网代理:D601 active Code Queue Pod 必须默认把 `HTTP_PROXY`、`HTTPS_PROXY` 和 `ALL_PROXY` 注入给 Codex/OpenCode、`git`、`curl`、`npm` 等任务子进程;当前唯一上游是 D601 provider-gateway egress HTTP CONNECT 代理,并通过 Kubernetes `Service d601-provider-egress-proxy` 暴露给 `unidesk` namespace 内的 Pod。该 Service 通过 selector 指向 D601 上的 hostNetwork 桥接 Pod,桥接 Pod 在集群端监听 service port `18789`、在宿主侧只连接 `127.0.0.1:18789` 的 provider-gateway egress endpoint;不得再用手工 EndpointSlice、provider-gateway Docker bridge IP 或固定 `172.*` 地址作为长期拓扑。Pod 内代理 URL 使用 `http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789`,provider-gateway 宿主端口仍只允许绑定 `127.0.0.1`,不得开放公网;桥接 Pod 或 provider-gateway 重建后必须用 Code Queue `/health.egressProxy.connected=true` 验证。这里的 provider-gateway 只承担出网代理,不承担 Code Queue 业务 HTTP 代理;业务访问仍只能走 Kubernetes API service proxy。k3s/k8s 原生 egress gateway、service mesh 或 CNI egress policy 只作为后续网络层增强方向,当前交付态不引入第二套出网控制面。远程开发/执行容器不得只依赖这些环境变量,必须在容器网络层用 TUN 默认路由和 OUTPUT 防火墙强制外网流量只能经 master TUN 出口。 - 出网代理无 fallback 纪律:Code Queue 的运行时配置只允许一个默认出网路径,即 provider-gateway egress proxy;不得在代码中同时保留 Code Queue 自建 WebSocket proxy、临时 shell proxy、D601 本地直连公网、主 server direct HTTP proxy 等隐式分支。任何新增网络 fallback 都必须先进入本参考文档并配套 `/health` 可见状态,否则视为残留旧路径。 -- 上线纪律:Code Queue 相关的前端或后端改进必须在同一任务内正式上线并验证公网 frontend 或 live API,不能只停留在源码、构建产物或“后续再上线”。修改 Code Queue 自身时不得等待当前 Code Queue task 结束、等待 queue idle 或等待 `0 running` 后才重启;D601 active 实例的正式后端部署入口是 `bun scripts/cli.ts codex deploy <commitId>`,它按已 push 的 remote commit 做 build-first 镜像替换、k3s image import、manifest apply、rollout 和健康验证,并用 k3s adapter、Code Queue live API 或公网 frontend 证明任务和队列仍可读可继续。 -- 期望状态部署:新的通用入口是 `bun scripts/cli.ts deploy apply --service code-queue`,它从 `deploy.json` 读取 repo 与 commit,再按 `docs/reference/deploy.md` 的 target-side build 规范在 D601 构建、导入 k3s、rollout 并验证 live commit。`codex deploy <commitId>` 是兼容入口,后续实现应复用同一个 reconciler,不得维护第二套部署语义。 +- 上线纪律:Code Queue 相关的前端或后端改进必须在同一任务内正式上线并验证公网 frontend 或 live API,不能只停留在源码、构建产物或“后续再上线”。修改 Code Queue 自身时不得等待当前 Code Queue task 结束、等待 queue idle 或等待 `0 running` 后才重启;D601 active 实例的后端部署必须经 DevOps 控制面执行 build-first 镜像替换、k3s image import、manifest apply、rollout 和健康验证,并用 k3s adapter、Code Queue live API 或公网 frontend 证明任务和队列仍可读可继续。 +- 期望状态部署:Code Queue 仍由 `deploy.json` 的 repo 与 commit 声明版本,但维护通道直连 D601 只允许部署 DevOps 本身,不能再用 `deploy apply --service code-queue` 或 `codex deploy <commitId>` 部署 Code Queue。DevOps 控制面实现 Code Queue CD 时,应复用 `docs/reference/deploy.md` 的 target-side build 规范在 D601 构建、导入 k3s、rollout 并验证 live commit,不得维护第二套部署语义。 - 更名与灾备恢复:旧版 Codex 队列服务名只允许作为兼容诊断和一次性迁移来源;`code-queue-backend` 容器自身 `/health` 正常但 `microservice health code-queue` 返回 provider 直连错误时,优先判定为 backend-core 仍加载旧 `MICROSERVICES_JSON` 或 adapter manifest 未刷新,必须刷新 `.state/docker-compose.env`、重建/替换 `backend-core` 与 `k3sctl-adapter`,随后用 `microservice list` 验证 `code-queue` 的 `runtime.orchestrator=k3sctl`、`backend.proxyMode=k3sctl-adapter-http` 和无业务容器直连摘要。正式 k3s 部署成功后,旧 direct Docker `code-queue-backend` 必须停止并移除,不能与 `code-queue-scheduler` 同时运行;否则会形成双 scheduler、双健康来源和错误的恢复判断。 - Codex 认证:容器必须从 D601 的 `/home/ubuntu/.codex/config.toml` 同步 Codex provider 配置到 D601 `.state/code-queue/codex-home/config.toml`,并只读挂载 `/home/ubuntu/.codex/auth.json` 到容器 `/root/.codex/auth.json` 后同步到 `.state/code-queue/codex-home/auth.json`,让 `codex app-server` 使用与 host 一致的 provider 登录态;同时通过 D601 `.state/code-queue-d601.env` 或 k8s `code-queue-env` secret 透传 `OPENAI_API_KEY`、`CRS_OAI_KEY` 等 provider 所需变量。这些 provider 环境变量和 auth 文件不得写入仓库,必须由 D601 运行时文件或 k8s secret 注入,确保容器重建和重启后不会丢失认证。新增 provider 的 `env_key` 时必须增加同类运行时透传和 Compose/k8s 持久化,禁止把 Codex 或 MiniMax 密钥写入仓库文件。Code Queue 容器必须只读挂载 D601 WSL host 的 SSH 目录到 `/root/.ssh`(默认 `/home/ubuntu/.ssh`),让容器内 `git push`、`ssh -T git@github.com` 与 WSL host 使用同一套 GitHub SSH key/known_hosts;不得把私钥复制进镜像或仓库。 - Develop-ready 镜像:Code Queue 镜像必须在启动前预装 UniDesk/Pipeline 调试所需工具,至少包含 `codex`、`bun`、`node`、`npm`/`npx`、`git`、`rg`、`curl`、`python3`/`pip3`、`docker`、`docker compose`、`docker-compose`、`jq`、`ssh`、`rsync`、`make`、`gcc`/`g++`、`iptables`、`tar`、`gzip` 和 `unzip`;不得依赖 Codex 任务运行时再 `apt-get install` 这些基础环境。 @@ -200,7 +200,7 @@ D601 上必须显式使用原生 k3s kubeconfig:`KUBECONFIG=/etc/rancher/k3s/k - Codex 控制:服务内部启动 `codex app-server --listen stdio://`,用 JSON-RPC 调用 `thread/start`、`turn/start`、`turn/steer` 和 `turn/interrupt`,并监听 `turn/completed`、assistant delta、reasoning delta、command output delta、file diff delta 等通知生成前端可轮询的 transcript。 - 用户输入持久化:任务初始 prompt 以 `basePrompt/displayPrompt` 作为结构化来源,运行中追加的 `turn/steer` prompt 必须写入 `promptHistory`;transcript 构建时从这些结构化字段合成 `Submitted prompt` 和 `Steer prompt`,不能只依赖有 600 条上限的 raw output,否则长任务输出增长后会丢失关键人工指令。 - 队列语义:`POST /api/tasks` 或 `/api/tasks/batch` 入队,服务始终只运行一个 Codex turn;当前任务真正终止后才推进下一个任务。`GET /api/tasks` 与 `GET /api/tasks/{id}` 返回队列、attempt、judge 和输出;`GET /api/tasks/{id}/summary` 返回按任务 ID 查询的结构化摘要,包括初始 prompt、最后 assistant message、工具调用摘要、attempt、judge、错误和耗时;CLI 入口是 `bun scripts/cli.ts codex task <taskId>`。`GET|POST /api/tasks/{id}/judge?attempt=N` 与 CLI `bun scripts/cli.ts codex judge <taskId> --attempt N` 用于单步复现指定 attempt 的 judge,必须复用真实队列 worker 的上下文构建、prompt 压缩、MiniMax 调用、JSON 去噪/repair 和 fallback 路径;`dryRun=1`/`--dry-run` 只输出 prompt/payload 和重建诊断,不调用 MiniMax。`POST /api/tasks/{id}/steer` 向运行中 turn 推入 prompt;`POST /api/tasks/{id}/interrupt` 或 `DELETE /api/tasks/{id}` 打断/取消;`POST /api/tasks/{id}/retry` 手动重试。队列 worker 必须隔离单个 task 的异常,不能因为某个 app-server、数据库 claim、judge 异常、judge 超时或 judge 判定 `fail` 让后续 queued 任务停止;`fail` 只把当前任务标为 failed,随后必须继续扫描并推进下一个 queued/retry_wait 任务。数据库 claim 必须有硬超时且失败时释放 active run slot;judge 必须有独立 watchdog,超时后走 fallback judge 并继续推进。当存在 queued/retry_wait 且 worker 空闲时,watchdog 必须自动重新调度。 -- 稳定性与重启恢复:Code Queue 的第一目标是长期稳定可用;部署修复或运维排障时不得因为担心容器重启会打断任务而拒绝重启、重建或替换 active Pod。容器重启、服务进程重启和镜像替换后,队列、`promptHistory`、running/judging/retry_wait 任务和 active session 元数据必须从 PostgreSQL 恢复,并在已有 `codexThreadId` 可用时用 `thread/resume` 和 continuation prompt 无缝继续当前任务;如果原 app-server turn 已丢失,也必须把当前任务恢复到可 retry/continue 的状态,不能错误推进下一个任务或永久卡住。D601 侧重建必须走 `bun scripts/cli.ts codex deploy <commitId>`;禁止先手工 `docker rm` 或只手工 `docker compose up` 再依赖后续命令补救,因为中断窗口会让 Pod/容器消失并触发 frontend/core 用户服务代理失败。重启后出现 active task 丢失、手动 steer/interrupt 记录丢失、running 任务卡死、误判完成、跳过当前任务、容器消失或阻塞队列,均属于 Code Queue 的 P0 核心缺陷,必须先修复并补充 restart-recovery 验收,不能把“避免重启”作为交付策略。 +- 稳定性与重启恢复:Code Queue 的第一目标是长期稳定可用;部署修复或运维排障时不得因为担心容器重启会打断任务而拒绝重启、重建或替换 active Pod。容器重启、服务进程重启和镜像替换后,队列、`promptHistory`、running/judging/retry_wait 任务和 active session 元数据必须从 PostgreSQL 恢复,并在已有 `codexThreadId` 可用时用 `thread/resume` 和 continuation prompt 无缝继续当前任务;如果原 app-server turn 已丢失,也必须把当前任务恢复到可 retry/continue 的状态,不能错误推进下一个任务或永久卡住。D601 侧重建必须走 DevOps 控制面;禁止先手工 `docker rm`、只手工 `docker compose up` 或用维护通道直连 D601 部署 Code Queue 再依赖后续命令补救,因为中断窗口会让 Pod/容器消失并触发 frontend/core 用户服务代理失败。重启后出现 active task 丢失、手动 steer/interrupt 记录丢失、running 任务卡死、误判完成、跳过当前任务、容器消失或阻塞队列,均属于 Code Queue 的 P0 核心缺陷,必须先修复并补充 restart-recovery 验收,不能把“避免重启”作为交付策略。 - 调度与 active run slot:Code Queue 必须把“queue processor 正在等待/退避/轮询”和“实际占用 Codex/OpenCode 子进程运行槽”分开建模;`CODE_QUEUE_MAX_ACTIVE_QUEUES` 只限制真实 active run slot,不能把 retry backoff、等待内存下降或等待前序任务的 `processingQueues` 计入 active slot,否则设置全局 active slot 上限时,一个空等队列会把其他 runnable queue 永久饿死。多个 queue 同时等待 active slot 时必须显式维护 FIFO waiter 队列,避免某个长 retry/backoff 队列刚释放 slot 就立刻重抢,导致更早进入等待的 `retry_wait` 任务长期饥饿;`/health` 必须同时暴露真实 `activeQueueIds`、`activeRunSlotCount`、等待中的 `processingQueueIds` 和 active slot waiters,排障时以 active run slot 与 waiter 顺序判断是否真的有任务在跑、谁应下一个启动。restart-recovery 后的 `retry_wait` 任务若缺失 `codexThreadId`/OpenCode session id,不得无限拒绝 retry;必须用紧凑 recovery prompt 和原始任务摘要重新开一个 agent thread/session,让任务继续推进并在 Trace 中留下 recovery 证据。任何修改 scheduler、retry backoff、queue move、manual retry、shutdown recovery 或内存等待逻辑时,都必须保留“空等 processor 不占 active run slot”、“等待者 FIFO 不饥饿”和“缺失 thread/session 可恢复”的自测或 live 验证。 - 内存优化过程与防回归:Code Queue 已迁移到 D601,但内存治理仍必须按“PostgreSQL 权威源优先、进程热状态最小化、容器硬上限兜底”的顺序设计。长期可复用的优化路径是:先确认任务、queue、readAt、promptHistory、active session 和通知 outbox 均可从 PostgreSQL 恢复;再把历史任务列表、详情、统计、Trace/output 和 `/health` 的只读查询改为 PostgreSQL 直读或聚合查询;随后只把 `queued`、`running`、`judging`、`retry_wait` 等调度必需任务载入 Bun 堆,并在 PostgreSQL 查询侧裁剪 hot `output`/`events`;最后用 dirty-only flush、append-only 输出归档、Codex SQLite 小批量导出、`bun --smol`、`mem_limit=600m`、`memswap_limit=1536m`、`NODE_OPTIONS=--max-old-space-size=768` 和 cgroup memory watchdog 作为运行时防线。PostgreSQL 到进程的单次读取足够快,不能为了减少 SQL 查询把全部历史 `task_json`、Trace、output 或统计摘要常驻内存;任何新增缓存都必须有默认较小的环境变量上限、明确淘汰策略、可从 PostgreSQL 或 append-only 归档重建,且不得影响重启恢复。新增或修改 `/api/tasks`、overview、stats、summary、transcript、output、trace、health、flush、scheduler 和通知路径时,禁止在常规请求中调用会物化全量历史任务 JSON 的代码,禁止启动后无条件重写全量历史 task JSON,禁止用未设上限的 `Map`/数组保存历史 output/event/Trace,`CODE_QUEUE_MAX_ACTIVE_QUEUES=0` 表示不按 queue 数量设置全局排队上限;如显式设置为正数,必须同时说明内存预算并补充内存压测验收。memory watchdog 必须以 cgroup working set 为主要判断,且在 swap 仍有余量时不得提前杀掉唯一 active run;否则 TypeScript/Playwright 这类短时高内存验证会被错误中断并让 retry 队列反复震荡。 - 列表/详情延迟优化原则:Code Queue 控制面交互的长期目标是常规历史规模下首屏、`GET /api/tasks/overview`、`POST /api/tasks/<id>/read` 和分页加载均在 1s 内完成;性能面板出现十几秒级 `core_proxy` 或 Code Queue 用户服务代理慢操作时,必须优先按后端查询形态和前后端通信策略定位,不能把问题归因于 React 渲染后只改 UI。后端优化顺序是:先为 queue、status、updated/created 时间、readAt/terminal unread 和常用筛选条件补齐 PostgreSQL 索引;再用 SQL `COUNT`、`GROUP BY`、条件聚合和分页 ID 查询生成 queue/status/stats/unread 摘要;随后按 ID 轻量加载当前页、selected、active 和 unread priority task,禁止为了列表或已读操作解析完整 Trace、output archive、Codex transcript 或物化全量历史 `task_json`。`read`/`read-all` 这类 mutation 必须是 SQL-only 更新并返回最小 patch/queue 计数,不能触发 overview 全量重算或重载所有任务;启动 warm 只能预热小体积聚合和索引路径,不得把历史任务作为常驻缓存。允许 frontend/backend 代理使用秒级、严格有界、mutation 自动失效的 overview micro-cache 来吸收重复刷新,但 cache 只能作为抖动保护,不能替代数据库索引、聚合查询和分页披露,也不能让 stale readAt/queue/status 状态跨设备可见。 diff --git a/docs/reference/repo-tree.md b/docs/reference/repo-tree.md index 8961ccb4..f7e3169c 100644 --- a/docs/reference/repo-tree.md +++ b/docs/reference/repo-tree.md @@ -18,7 +18,7 @@ - command.ts (Bounded command execution helpers) - output.ts (JSON output helpers) - e2e.ts (Public frontend/provider ingress, internal core/database, and Playwright frontend E2E checks) - - ci.ts (D601 k3s Tekton CI install/status/manual-run/logs helpers; CI only, no CD) + - ci.ts (D601 k3s Tekton CI install/status/manual-run/dev-namespace-e2e/logs helpers; CI only, no CD) - logs/ (Generated service logs; ignored by git) - .state/ (Generated job state and compose env; ignored by git) - docs/ @@ -33,7 +33,7 @@ - provider-gateway.md (Provider connection and host SSH maintenance bridge) - observability.md (Logs and status visibility) - e2e.md (Delivery gate, Playwright frontend E2E, and database persistence checks) - - ci.md (D601 k3s Tekton CI, read-only production database performance gate, and trigger boundary) + - ci.md (D601 k3s Tekton CI, read-only production database performance gate, master deploy.json dev namespace e2e harness, and trigger boundary) - src/ (TypeScript component monorepo) - package.json (Component workspace metadata) - bun.lock (Component dependency lockfile) diff --git a/scripts/bootstrap/devops-install.sh b/scripts/bootstrap/devops-install.sh new file mode 100755 index 00000000..c2db9814 --- /dev/null +++ b/scripts/bootstrap/devops-install.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_url="https://github.com/pikasTech/unidesk" +commit_id="" +environment="dev" +namespace="unidesk-ci" +work_dir="/home/ubuntu/.unidesk/bootstrap/devops" +image="unidesk-devops:dev" +dry_run="0" + +usage() { + cat <<'EOF' +Usage: + devops-install.sh --commit <commit> [--env dev] [--repo-url URL] [--namespace unidesk-ci] [--dry-run] + +This script is a one-shot D601 bootstrapper. It installs or repairs the UniDesk +DevOps control service in native k3s, then normal CI/CD should use DevOps APIs. +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --commit|--commit-id) + commit_id="${2:-}" + shift 2 + ;; + --env|--environment) + environment="${2:-}" + shift 2 + ;; + --repo-url) + repo_url="${2:-}" + shift 2 + ;; + --namespace) + namespace="${2:-}" + shift 2 + ;; + --work-dir) + work_dir="${2:-}" + shift 2 + ;; + --dry-run) + dry_run="1" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! [[ "$commit_id" =~ ^[0-9a-fA-F]{7,40}$ ]]; then + echo "--commit must be a 7-40 character git SHA" >&2 + exit 2 +fi + +if [ "$environment" != "dev" ]; then + echo "only --env dev is supported by the first bootstrapper" >&2 + exit 2 +fi + +log() { + printf '{"at":"%s","event":"%s"}\n' "$(date -Iseconds)" "$*" +} + +need_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + exit 1 + fi +} + +root_exec() { + if [ "$(id -u)" = "0" ]; then + "$@" + elif sudo -n true >/dev/null 2>&1; then + sudo -n "$@" + else + echo "root access is required for k3s containerd import" >&2 + exit 1 + fi +} + +need_cmd git +need_cmd docker +need_cmd kubectl + +if [ ! -f /etc/rancher/k3s/k3s.yaml ]; then + echo "native k3s kubeconfig not found: /etc/rancher/k3s/k3s.yaml" >&2 + exit 1 +fi + +export KUBECONFIG=/etc/rancher/k3s/k3s.yaml +kubectl get nodes >/dev/null + +log "bootstrap_preflight_ok" +if [ "$dry_run" = "1" ]; then + exit 0 +fi + +repo_dir="$work_dir/repo" +mkdir -p "$work_dir" +if [ ! -d "$repo_dir/.git" ]; then + rm -rf "$repo_dir" + git clone --no-checkout "$repo_url" "$repo_dir" +fi + +git -C "$repo_dir" remote set-url origin "$repo_url" +git -C "$repo_dir" fetch --no-tags origin "$commit_id" || git -C "$repo_dir" fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*' +resolved="$(git -C "$repo_dir" rev-parse --verify "$commit_id^{commit}")" +git -C "$repo_dir" checkout --detach "$resolved" + +log "source_ready commit=$resolved" + +docker buildx build --load \ + --progress=plain \ + --label "unidesk.ai/service-id=devops" \ + --label "unidesk.ai/source-repo=$repo_url" \ + --label "unidesk.ai/source-commit=$resolved" \ + --label "unidesk.ai/dockerfile=src/components/microservices/devops/Dockerfile" \ + -t "$image" \ + -f "$repo_dir/src/components/microservices/devops/Dockerfile" \ + "$repo_dir" + +archive="$work_dir/devops-image.tar" +rm -f "$archive" +docker save "$image" -o "$archive" +root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images import "$archive" + +manifest="$work_dir/devops.k8s.yaml" +cp "$repo_dir/src/components/microservices/k3sctl-adapter/k3s/devops.k8s.yaml" "$manifest" +python3 - "$manifest" "$image" "$repo_url" "$resolved" "$commit_id" <<'PY' +import re +import sys + +path, image, repo, commit, requested = sys.argv[1:] +text = open(path, encoding="utf-8").read() +text = re.sub(r"image: unidesk-devops:[^\n]+", f"image: {image}", text) +text = text.replace("value: https://github.com/pikasTech/unidesk", f"value: {repo}") +text = text.replace("unidesk.ai/deploy-commit: replace-with-deploy-env-commit", f"unidesk.ai/deploy-commit: {commit}") +text = text.replace("unidesk.ai/deploy-requested-commit: replace-with-deploy-env-commit", f"unidesk.ai/deploy-requested-commit: {requested}") +text = text.replace("value: replace-with-deploy-env-commit", f"value: {commit}") +open(path, "w", encoding="utf-8").write(text) +PY + +kubectl create namespace "$namespace" --dry-run=client -o yaml | kubectl apply -f - +kubectl apply -f "$manifest" +kubectl -n "$namespace" rollout status deployment/devops --timeout=180s +kubectl -n "$namespace" get --raw "/api/v1/namespaces/$namespace/services/http:devops:4286/proxy/health" >/tmp/unidesk-devops-health.json + +receipt="$work_dir/receipt.json" +python3 - "$receipt" "$repo_url" "$resolved" "$commit_id" "$namespace" "$image" </tmp/unidesk-devops-health.json <<'PY' +import json +import sys +from datetime import datetime, timezone + +path, repo, commit, requested, namespace, image = sys.argv[1:] +health = json.load(sys.stdin) +receipt = { + "installedAt": datetime.now(timezone.utc).isoformat(), + "repo": repo, + "commit": commit, + "requestedCommit": requested, + "namespace": namespace, + "image": image, + "health": health, +} +with open(path, "w", encoding="utf-8") as handle: + json.dump(receipt, handle, ensure_ascii=False, indent=2) + handle.write("\n") +print(json.dumps({"ok": True, "receipt": path, "health": health}, ensure_ascii=False)) +PY diff --git a/scripts/cli.ts b/scripts/cli.ts index 7266bcd5..560da9ce 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -58,7 +58,7 @@ function help(): unknown { { command: "decision diary show <YYYY-MM-DD|id>", description: "Show one daily diary Markdown entry." }, { command: "decision list [--type ...] [--status ...] [--level ...] [--linked-goal-id id] [--limit N]", description: "List Decision Center records through the user-service proxy." }, { command: "decision show <id>", description: "Show one Decision Center record." }, - { command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--dry-run] [--force]", description: "Reconcile services from a repo+commit manifest; --env reads fixed environment refs and can apply supported dev services." }, + { command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--dry-run] [--force]", description: "Reconcile services from a repo+commit manifest; --env reads origin/master:deploy.json environments and can apply supported dev services." }, { command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." }, { command: "schedule list|get|runs|run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N." }, { command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." }, @@ -75,7 +75,7 @@ function help(): unknown { { command: "debug dispatch [providerId] [docker.ps|provider.upgrade|host.ssh|microservice.http|echo] [--wait-ms N]", description: "Submit a real internal-core dispatch request for CLI debugging." }, { command: "debug task <taskId|latest>", description: "Read a dispatched task record from internal core for CLI debugging." }, { command: "network perf [--service code-queue --path /api/tasks/overview?limit=30 --count N --concurrency N --label before|after]", description: "Benchmark frontend -> backend-core -> provider/adapter user-service networking and report latency/proxy-mode distributions." }, - { command: "ci install|status|run|logs", description: "Manage D601 k3s Tekton CI only; does not deploy CD. CI reads the production PostgreSQL through a temporary read-only Code Queue service." }, + { command: "ci install|status|run|run-dev-e2e|logs", description: "Manage D601 k3s Tekton CI only; run-dev-e2e manually validates master deploy.json dev state in an isolated temporary namespace." }, { command: "e2e run [--only pattern[,pattern...]] [--skip pattern[,pattern...]]", description: "Run selected public/internal/Playwright E2E checks; use --only for focused iteration and rerun without filters for final regression." }, ], }; @@ -290,7 +290,10 @@ async function main(): Promise<void> { } if (top === "ci") { - emitJson(commandName, runCiCommand(config, args.slice(1))); + const result = await runCiCommand(config, args.slice(1)); + const ok = (result as { ok?: unknown }).ok !== false; + emitJson(commandName, result, ok); + if (!ok) process.exitCode = 1; return; } diff --git a/scripts/src/ci.ts b/scripts/src/ci.ts index 31800cd5..6a94adf1 100644 --- a/scripts/src/ci.ts +++ b/scripts/src/ci.ts @@ -1,12 +1,11 @@ -import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { runCommand } from "./command"; import { type UniDeskConfig, repoRoot, rootPath } from "./config"; import { startJob } from "./jobs"; +import { coreInternalFetch } from "./microservices"; -const k3sctlContainerName = "k3sctl-adapter"; -const k3sctlSshKey = "/run/host-ssh/id_ed25519"; -const d601SshTarget = "ubuntu@host.docker.internal"; +const d601ProviderId = "D601"; const d601Kubeconfig = "/etc/rancher/k3s/k3s.yaml"; const tektonPipelineVersion = "v1.12.0"; const tektonTriggersVersion = "v0.34.0"; @@ -14,6 +13,7 @@ const tektonPipelineReleaseUrl = `https://infra.tekton.dev/tekton-releases/pipel const tektonTriggersReleaseUrl = `https://infra.tekton.dev/tekton-releases/triggers/previous/${tektonTriggersVersion}/release.yaml`; const tektonTriggersInterceptorsUrl = `https://infra.tekton.dev/tekton-releases/triggers/previous/${tektonTriggersVersion}/interceptors.yaml`; const providerGatewayWsEgressProxyUrl = "http://127.0.0.1:18789"; +const ciCodeQueueImage = "unidesk-code-queue:dev"; const ciRuntimeImages = [ "rancher/mirrored-pause:3.6", "rancher/mirrored-library-busybox:1.36.1", @@ -25,7 +25,7 @@ const ciRuntimeImages = [ "ghcr.io/tektoncd/triggers/eventlistenersink-7ad1faa98cddbcb0c24990303b220bb8:v0.34.0", "oven/bun:1-debian", "alpine/git:2.45.2", - "unidesk-code-queue:d601", + ciCodeQueueImage, ]; interface CiOptions { @@ -34,6 +34,35 @@ interface CiOptions { waitMs: number; } +interface CiDevE2EOptions { + repoUrl: string; + desiredRef: string; + deployCommit: string; + environment: "dev"; + services: Array<{ id: string; commitId: string; repo: string }>; + runId: string; + keepNamespace: boolean; + waitMs: number; + direct: boolean; +} + +interface DispatchResult { + ok: boolean; + taskId: string | null; + status: string | null; + stdout: string; + stderr: string; + exitCode: number | null; + raw: unknown; +} + +interface DeployDevManifestSummary { + deployCommit: string; + desiredRef: string; + environment: "dev"; + services: Array<{ id: string; commitId: string; repo: string }>; +} + function stringOption(args: string[], name: string): string | null { const index = args.indexOf(name); if (index === -1) return null; @@ -56,88 +85,243 @@ function requireRevision(value: string | null): string { return value; } +function requireDesiredRef(value: string | null): string { + const ref = value ?? "master"; + if (!/^[A-Za-z0-9._/-]{1,160}$/u.test(ref) || ref.startsWith("-") || ref.includes("..")) { + throw new Error("ci run-dev-e2e --desired-ref contains unsupported characters"); + } + return ref; +} + +function boolFlag(args: string[], name: string): boolean { + return args.includes(name); +} + function shellQuote(value: string): string { return `'${value.replace(/'/gu, "'\\''")}'`; } -function dockerExecK3sctl(args: string[]) { - return runCommand(["docker", "exec", k3sctlContainerName, ...args], repoRoot); -} - -function dockerExecK3sctlWithInput(args: string[], input: string) { - const command = ["docker", "exec", "-i", k3sctlContainerName, ...args]; - const result = spawnSync(command[0], command.slice(1), { - cwd: repoRoot, - encoding: "utf8", - input, - maxBuffer: 1024 * 1024 * 8, - }); - return { - command, - cwd: repoRoot, - exitCode: result.status, - stdout: result.stdout ?? "", - stderr: result.stderr ?? result.error?.message ?? "", - }; -} - -function remoteKubectlCommand(script: string): string[] { - return [ - "sh", - "-lc", - [ - "ssh", - "-i", - shellQuote(k3sctlSshKey), - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/tmp/unidesk-ci-known-hosts", - "-o", - "ConnectTimeout=10", - shellQuote(d601SshTarget), - shellQuote(`KUBECONFIG=${d601Kubeconfig} bash -lc ${shellQuote(script)}`), - ].join(" "), - ]; -} - -function runRemoteKubectl(script: string) { - const result = dockerExecK3sctl(remoteKubectlCommand(script)); - if (result.exitCode !== 0) { - throw new Error(`D601 kubectl command failed: ${result.stderr || result.stdout}`); +function chunks(value: string, size: number): string[] { + const result: string[] = []; + for (let index = 0; index < value.length; index += size) { + result.push(value.slice(index, index + size)); } return result; } -function remoteApplyManifest(path: string): void { - const absolute = rootPath(path); - if (!existsSync(absolute)) throw new Error(`manifest not found: ${path}`); - const result = dockerExecK3sctlWithInput([ - "sh", - "-lc", - [ - "ssh", - "-i", - shellQuote(k3sctlSshKey), - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/tmp/unidesk-ci-known-hosts", - "-o", - "ConnectTimeout=10", - shellQuote(d601SshTarget), - shellQuote(`KUBECONFIG=${d601Kubeconfig} kubectl apply -f -`), - ].join(" "), - ], readFileSync(absolute, "utf8")); - if (result.exitCode !== 0) { - throw new Error(`kubectl apply failed for ${path}: ${result.stderr || result.stdout}`); - } +function asRecord(value: unknown): Record<string, unknown> | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null; } -function prewarmCiRuntimeImages(): void { - const images = ciRuntimeImages.map(shellQuote).join(" "); - runRemoteKubectl([ +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function coreBody(response: unknown): Record<string, unknown> | null { + return asRecord(asRecord(response)?.body); +} + +function proxyBody(response: unknown): Record<string, unknown> | null { + const body = coreBody(response); + const nested = asRecord(body?.body); + return nested ?? body; +} + +async function dispatchSsh(command: string, waitMs: number, remoteTimeoutMs: number): Promise<DispatchResult> { + const dispatchResponse = coreInternalFetch("/api/dispatch", { + method: "POST", + body: { + providerId: d601ProviderId, + command: "host.ssh", + payload: { + source: "ci-cli", + mode: "exec", + command, + timeoutMs: remoteTimeoutMs, + cwd: "/home/ubuntu", + }, + }, + }); + const dispatchBody = coreBody(dispatchResponse); + const taskId = asString(dispatchBody?.taskId); + if (dispatchBody?.ok !== true || taskId.length === 0) { + return { + ok: false, + taskId: taskId || null, + status: null, + stdout: "", + stderr: asString(dispatchBody?.error) || "dispatch did not return a task id", + exitCode: null, + raw: dispatchResponse, + }; + } + const deadline = Date.now() + Math.max(waitMs, remoteTimeoutMs + 10_000); + let latest: unknown = null; + while (Date.now() < deadline) { + latest = coreInternalFetch(`/api/tasks/${encodeURIComponent(taskId)}`, { maxResponseBytes: 3_000_000 }); + const task = asRecord(coreBody(latest)?.task); + const status = asString(task?.status); + if (status === "succeeded" || status === "failed") { + const result = asRecord(task?.result); + const exitCode = typeof result?.exitCode === "number" ? result.exitCode : null; + const stdout = asString(result?.stdout); + const stderr = asString(result?.stderr); + return { + ok: status === "succeeded" && (exitCode === null || exitCode === 0), + taskId, + status, + stdout, + stderr, + exitCode, + raw: task, + }; + } + await Bun.sleep(500); + } + return { + ok: false, + taskId, + status: "timeout", + stdout: "", + stderr: `host.ssh task ${taskId} did not finish within ${Math.max(waitMs, remoteTimeoutMs + 10_000)}ms`, + exitCode: null, + raw: latest, + }; +} + +async function runRemoteKubectl(script: string, waitMs = 60_000, remoteTimeoutMs = 45_000): Promise<DispatchResult> { + const result = await runRemoteKubectlRaw(script, waitMs, remoteTimeoutMs); + if (!result.ok) { + throw new Error(`D601 kubectl command failed: ${result.stderr || result.stdout || JSON.stringify(result.raw)}`); + } + return result; +} + +async function runRemoteKubectlRaw(script: string, waitMs = 60_000, remoteTimeoutMs = 45_000): Promise<DispatchResult> { + const command = [ "set -euo pipefail", + `export KUBECONFIG=${shellQuote(d601Kubeconfig)}`, + script, + ].join("\n"); + return dispatchSsh(command, waitMs, remoteTimeoutMs); +} + +async function uploadRemoteBase64(path: string, encoded: string): Promise<DispatchResult> { + const init = await dispatchSsh([ + "set -euo pipefail", + `target=${shellQuote(path)}`, + "rm -f \"$target\"", + ": > \"$target\"", + "chmod 600 \"$target\"", + ].join("\n"), 20_000, 10_000); + if (!init.ok) return init; + for (const chunk of chunks(encoded, 950)) { + const append = await dispatchSsh([ + "set -euo pipefail", + `target=${shellQuote(path)}`, + `printf %s ${shellQuote(chunk)} >> "$target"`, + ].join("\n"), 20_000, 10_000); + if (!append.ok) return append; + } + return dispatchSsh([ + "set -euo pipefail", + `target=${shellQuote(path)}`, + "wc -c \"$target\"", + ].join("\n"), 20_000, 10_000); +} + +async function runRemoteBackground(label: string, script: string, timeoutMs: number): Promise<DispatchResult> { + const token = randomUUID().replace(/-/gu, "").slice(0, 12); + const safeLabel = label.replace(/[^a-z0-9-]/giu, "-").toLowerCase().slice(0, 48); + const base = `/tmp/unidesk-ci-${safeLabel}-${token}`; + const scriptPath = `${base}.sh`; + const logPath = `${base}.log`; + const donePath = `${base}.done`; + const encoded = Buffer.from(script, "utf8").toString("base64"); + const upload = await uploadRemoteBase64(`${scriptPath}.b64`, encoded); + if (!upload.ok) return upload; + const start = await dispatchSsh([ + "set -euo pipefail", + `script_path=${shellQuote(scriptPath)}`, + `log_path=${shellQuote(logPath)}`, + `done_path=${shellQuote(donePath)}`, + "rm -f \"$script_path\" \"$log_path\" \"$done_path\"", + "base64 -d \"$script_path.b64\" > \"$script_path\"", + "rm -f \"$script_path.b64\"", + "chmod 700 \"$script_path\"", + "nohup bash -lc \"bash '$script_path' >'$log_path' 2>&1; code=\\$?; printf '%s\\n' \\\"\\$code\\\" >'$done_path'\" >/tmp/unidesk-ci-nohup.log 2>&1 &", + "printf 'remote_job_pid=%s\\nlog=%s\\ndone=%s\\n' \"$!\" \"$log_path\" \"$done_path\"", + ].join("\n"), 20_000, 10_000); + if (!start.ok) return start; + + const deadline = Date.now() + timeoutMs; + let latest: DispatchResult = start; + while (Date.now() < deadline) { + await Bun.sleep(8_000); + latest = await dispatchSsh([ + "set -euo pipefail", + `log_path=${shellQuote(logPath)}`, + `done_path=${shellQuote(donePath)}`, + "if [ -f \"$done_path\" ]; then", + " code=\"$(cat \"$done_path\" 2>/dev/null || printf 1)\"", + " printf 'REMOTE_DONE:%s\\n' \"$code\"", + "else", + " printf 'REMOTE_RUNNING\\n'", + "fi", + "tail -n 160 \"$log_path\" 2>/dev/null || true", + ].join("\n"), 75_000, 12_000); + if (!latest.ok) { + if (latest.status === "timeout" || latest.stderr.includes("did not finish within")) { + continue; + } + return latest; + } + const firstLine = latest.stdout.split(/\r?\n/u)[0] ?? ""; + if (firstLine.startsWith("REMOTE_DONE:")) { + const code = Number(firstLine.slice("REMOTE_DONE:".length).trim()); + return { + ...latest, + ok: code === 0, + exitCode: Number.isInteger(code) ? code : 1, + status: code === 0 ? "succeeded" : "failed", + }; + } + } + return { + ...latest, + ok: false, + status: "timeout", + exitCode: 124, + stderr: `remote background job ${label} did not finish within ${timeoutMs}ms`, + }; +} + +async function remoteApplyManifest(path: string): Promise<void> { + const absolute = rootPath(path); + if (!existsSync(absolute)) throw new Error(`manifest not found: ${path}`); + const encoded = Buffer.from(readFileSync(absolute, "utf8"), "utf8").toString("base64"); + const token = randomUUID().replace(/-/gu, "").slice(0, 12); + const b64Path = `/tmp/unidesk-ci-apply-${token}.b64`; + const upload = await uploadRemoteBase64(b64Path, encoded); + if (!upload.ok) throw new Error(`failed to upload manifest ${path}: ${upload.stderr || upload.stdout}`); + const script = [ + "set -euo pipefail", + `export KUBECONFIG=${shellQuote(d601Kubeconfig)}`, + "tmp=$(mktemp /tmp/unidesk-ci-apply.XXXXXX.yaml)", + `b64_path=${shellQuote(b64Path)}`, + "trap 'rm -f \"$tmp\" \"$b64_path\"' EXIT", + "base64 -d \"$b64_path\" > \"$tmp\"", + "kubectl apply -f \"$tmp\"", + ].join("\n"); + const result = await runRemoteBackground(`apply-${path.split("/").pop() ?? "manifest"}`, script, 180_000); + if (!result.ok) throw new Error(`kubectl apply failed for ${path}: ${result.stderr || result.stdout}`); +} + +async function prewarmCiRuntimeImages(): Promise<void> { + const images = ciRuntimeImages.map(shellQuote).join(" "); + const script = [ + "set -euo pipefail", + `export KUBECONFIG=${shellQuote(d601Kubeconfig)}`, "export DOCKER_CONFIG=/tmp/unidesk-ci-docker-config", "mkdir -p \"$DOCKER_CONFIG\"", "printf '{}\\n' > \"$DOCKER_CONFIG/config.json\"", @@ -152,18 +336,37 @@ function prewarmCiRuntimeImages(): void { "done", "pause_entrypoint=$(docker image inspect rancher/mirrored-pause:3.6 --format '{{json .Config.Entrypoint}}' 2>/dev/null || true)", "if ! printf '%s' \"$pause_entrypoint\" | grep -q '\"/pause\"'; then echo native_k3s_pause_image_invalid_entrypoint=$pause_entrypoint >&2; exit 1; fi", + "containerd_images=$(/mnt/c/Windows/System32/wsl.exe -u root -- ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls 2>/tmp/unidesk-ci-containerd-images.err || true)", + "containerd_ready=1", + "for image in \"${images[@]}\"; do", + " case \"$image\" in", + " rancher/*|oven/*|alpine/*) ref=\"docker.io/$image\" ;;", + " unidesk-*) ref=\"docker.io/library/$image\" ;;", + " *) ref=\"$image\" ;;", + " esac", + " if ! printf '%s\\n' \"$containerd_images\" | grep -F \"$ref\" >/dev/null; then", + " containerd_ready=0", + " echo ci_runtime_image_containerd_missing=$ref", + " fi", + "done", + "if [ \"$containerd_ready\" = \"1\" ]; then", + " echo ci_runtime_images_containerd_cached=all", + " exit 0", + "fi", "rm -f /tmp/unidesk-ci-runtime-images.tar", "docker save \"${images[@]}\" -o /tmp/unidesk-ci-runtime-images.tar", "/mnt/c/Windows/System32/wsl.exe -u root -- ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images import --digests --all-platforms /tmp/unidesk-ci-runtime-images.tar >/tmp/unidesk-ci-runtime-images-import.log", "/mnt/c/Windows/System32/wsl.exe -u root -- ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F 'docker.io/rancher/mirrored-pause:3.6' >/dev/null", "/mnt/c/Windows/System32/wsl.exe -u root -- ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F 'docker.io/oven/bun:1-debian' >/dev/null", "/mnt/c/Windows/System32/wsl.exe -u root -- ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F 'docker.io/alpine/git:2.45.2' >/dev/null", - "/mnt/c/Windows/System32/wsl.exe -u root -- ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F 'docker.io/library/unidesk-code-queue:d601' >/dev/null", - ].join("\n")); + `/mnt/c/Windows/System32/wsl.exe -u root -- ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F ${shellQuote(`docker.io/library/${ciCodeQueueImage}`)} >/dev/null`, + ].join("\n"); + const result = await runRemoteBackground("prewarm-runtime-images", script, 900_000); + if (!result.ok) throw new Error(`CI runtime image prewarm failed: ${result.stderr || result.stdout}`); } -function status(): Record<string, unknown> { - const summary = runRemoteKubectl([ +async function status(): Promise<Record<string, unknown>> { + const summary = await runRemoteKubectl([ "set -euo pipefail", "printf 'tekton_pipelines='", "kubectl get deploy -n tekton-pipelines -o name 2>/dev/null | tr '\\n' ' ' || true", @@ -175,7 +378,7 @@ function status(): Record<string, unknown> { ].join("\n")); return { ok: true, - providerId: "D601", + providerId: d601ProviderId, orchestrator: "native-k3s", tekton: { pipelineVersion: tektonPipelineVersion, @@ -185,23 +388,26 @@ function status(): Record<string, unknown> { }; } -function install(): Record<string, unknown> { +async function install(): Promise<Record<string, unknown>> { if (!existsSync(rootPath("src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml"))) { throw new Error("CI manifests are missing"); } - prewarmCiRuntimeImages(); - runRemoteKubectl([ + await prewarmCiRuntimeImages(); + const installTektonScript = [ "set -euo pipefail", + `export KUBECONFIG=${shellQuote(d601Kubeconfig)}`, `kubectl apply -f ${shellQuote(tektonPipelineReleaseUrl)}`, "kubectl wait --for=condition=Available deployment --all -n tekton-pipelines --timeout=900s", `kubectl apply -f ${shellQuote(tektonTriggersReleaseUrl)}`, `kubectl apply -f ${shellQuote(tektonTriggersInterceptorsUrl)}`, "kubectl wait --for=condition=Available deployment --all -n tekton-pipelines --timeout=900s", "kubectl wait --for=condition=Available deployment --all -n tekton-pipelines-resolvers --timeout=900s", - ].join("\n")); - remoteApplyManifest("src/components/microservices/k3sctl-adapter/k3s/ci/tekton-install.yaml"); - remoteApplyManifest("src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml"); - remoteApplyManifest("src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.triggers.yaml"); + ].join("\n"); + const installTekton = await runRemoteBackground("install-tekton", installTektonScript, 1_200_000); + if (!installTekton.ok) throw new Error(`Tekton install failed: ${installTekton.stderr || installTekton.stdout}`); + await remoteApplyManifest("src/components/microservices/k3sctl-adapter/k3s/ci/tekton-install.yaml"); + await remoteApplyManifest("src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml"); + await remoteApplyManifest("src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.triggers.yaml"); return status(); } @@ -233,31 +439,66 @@ spec: `; } -function remoteCreatePipelineRun(manifest: string): string { - const result = dockerExecK3sctlWithInput([ - "sh", - "-lc", - [ - "ssh", - "-i", - shellQuote(k3sctlSshKey), - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/tmp/unidesk-ci-known-hosts", - shellQuote(d601SshTarget), - shellQuote(`KUBECONFIG=${d601Kubeconfig} kubectl create -f - -o jsonpath='{.metadata.name}'`), - ].join(" "), - ], manifest); - if (result.exitCode !== 0) throw new Error(result.stderr || result.stdout); +function devE2EPipelineRunManifest(options: CiDevE2EOptions): string { + const deployRevisionLabel = options.deployCommit.slice(0, 40); + return `apiVersion: tekton.dev/v1 +kind: PipelineRun +metadata: + generateName: unidesk-dev-e2e-${options.runId}- + namespace: unidesk-ci + labels: + app.kubernetes.io/name: unidesk-dev-namespace-e2e + app.kubernetes.io/part-of: unidesk + unidesk.ai/ci-kind: dev-namespace-e2e + unidesk.ai/deploy-ref: master-deploy-json-dev + unidesk.ai/deploy-commit: ${JSON.stringify(deployRevisionLabel)} +spec: + pipelineRef: + name: unidesk-dev-namespace-e2e + taskRunTemplate: + serviceAccountName: unidesk-ci-runner + params: + - name: repo-url + value: ${JSON.stringify(options.repoUrl)} + - name: desired-ref + value: ${JSON.stringify(options.desiredRef)} + - name: deploy-commit + value: ${JSON.stringify(options.deployCommit)} + - name: environment + value: ${JSON.stringify(options.environment)} + - name: run-id + value: ${JSON.stringify(options.runId)} + - name: keep-namespace + value: ${JSON.stringify(options.keepNamespace ? "true" : "false")} + workspaces: + - name: shared-workspace + persistentVolumeClaim: + claimName: unidesk-ci-cache +`; +} + +async function remoteCreatePipelineRun(manifest: string): Promise<string> { + const encoded = Buffer.from(manifest, "utf8").toString("base64"); + const token = randomUUID().replace(/-/gu, "").slice(0, 12); + const b64Path = `/tmp/unidesk-ci-pipelinerun-${token}.b64`; + const upload = await uploadRemoteBase64(b64Path, encoded); + if (!upload.ok) throw new Error(`failed to upload PipelineRun manifest: ${upload.stderr || upload.stdout}`); + const result = await runRemoteKubectl([ + "tmp=$(mktemp /tmp/unidesk-ci-run.XXXXXX.yaml)", + `b64_path=${shellQuote(b64Path)}`, + "trap 'rm -f \"$tmp\" \"$b64_path\"' EXIT", + "base64 -d \"$b64_path\" > \"$tmp\"", + "kubectl create -f \"$tmp\" -o jsonpath='{.metadata.name}'", + ].join("\n"), 60_000, 45_000); return result.stdout.trim(); } -function run(options: CiOptions): Record<string, unknown> { - const name = remoteCreatePipelineRun(pipelineRunManifest(options)); - const wait = options.waitMs > 0 ? dockerExecK3sctl(remoteKubectlCommand([ +async function waitForPipelineRun(name: string, waitMs: number): Promise<DispatchResult | null> { + if (waitMs <= 0) return null; + const command = [ "set -euo pipefail", - `deadline=$((SECONDS + ${Math.ceil(options.waitMs / 1000)}))`, + `export KUBECONFIG=${shellQuote(d601Kubeconfig)}`, + `deadline=$((SECONDS + ${Math.ceil(waitMs / 1000)}))`, "while [ \"$SECONDS\" -lt \"$deadline\" ]; do", ` condition="$(kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o jsonpath='{range .status.conditions[?(@.type==\"Succeeded\")]}{.status}{\"\\t\"}{.reason}{\"\\t\"}{.message}{end}' 2>/dev/null || true)"`, " case \"$condition\" in", @@ -277,7 +518,13 @@ function run(options: CiOptions): Record<string, unknown> { `echo "Timed out waiting for pipelinerun/${name}" >&2`, `kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o json`, "exit 124", - ].join("\n"))) : null; + ].join("\n"); + return dispatchSsh(command, waitMs + 30_000, waitMs + 20_000); +} + +async function run(options: CiOptions): Promise<Record<string, unknown>> { + const name = await remoteCreatePipelineRun(pipelineRunManifest(options)); + const wait = await waitForPipelineRun(name, options.waitMs); const waitSucceeded = wait === null || wait.exitCode === 0 || wait.stdout.trimStart().startsWith("True\tSucceeded\t"); return { ok: waitSucceeded, @@ -296,14 +543,111 @@ function run(options: CiOptions): Record<string, unknown> { }; } -function logs(name: string): Record<string, unknown> { +function resolveDeployDevManifest(desiredRef: string): DeployDevManifestSummary { + const remoteRef = `refs/remotes/origin/${desiredRef}`; + const fetch = runCommand(["git", "fetch", "--quiet", "origin", `+refs/heads/${desiredRef}:${remoteRef}`], repoRoot); + if (fetch.exitCode !== 0) throw new Error(`failed to fetch origin/${desiredRef}: ${fetch.stderr || fetch.stdout}`); + const deployCommitResult = runCommand(["git", "rev-parse", `origin/${desiredRef}`], repoRoot); + if (deployCommitResult.exitCode !== 0) throw new Error(`failed to resolve origin/${desiredRef}: ${deployCommitResult.stderr || deployCommitResult.stdout}`); + const show = runCommand(["git", "show", `origin/${desiredRef}:deploy.json`], repoRoot); + if (show.exitCode !== 0) throw new Error(`failed to read deploy.json from origin/${desiredRef}: ${show.stderr || show.stdout}`); + const parsed = JSON.parse(show.stdout) as unknown; + const record = asRecord(parsed); + if (record?.schemaVersion !== 2) throw new Error(`origin/${desiredRef}:deploy.json must use schemaVersion=2`); + const environments = asRecord(record.environments); + const dev = asRecord(environments?.dev); + const rawServices = Array.isArray(dev?.services) ? dev.services : []; + const services = rawServices.map((item) => { + const service = asRecord(item); + return { + id: asString(service?.id), + commitId: asString(service?.commitId).toLowerCase(), + repo: asString(service?.repo), + }; + }).filter((service) => service.id.length > 0 && service.commitId.length > 0); + if (services.length === 0) throw new Error(`origin/${desiredRef}:deploy.json has no environments.dev services with commitId`); + return { + deployCommit: deployCommitResult.stdout.trim(), + desiredRef, + environment: "dev", + services, + }; +} + +function makeRunId(deployCommit: string): string { + const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14).toLowerCase(); + return `${stamp}-${deployCommit.slice(0, 8).toLowerCase()}`.replace(/[^a-z0-9-]/gu, "-").slice(0, 48); +} + +async function runDevE2E(options: CiDevE2EOptions): Promise<Record<string, unknown>> { + if (!options.direct) { + const devopsResponse = coreInternalFetch("/api/microservices/devops/proxy/api/ci/dev-e2e/run", { + method: "POST", + body: { + repoUrl: options.repoUrl, + desiredRef: options.desiredRef, + environment: options.environment, + runId: options.runId, + keepNamespace: options.keepNamespace, + }, + maxResponseBytes: 2_000_000, + }); + const devopsBody = proxyBody(devopsResponse); + if (devopsBody?.ok === true) { + const pipelineRun = asString(devopsBody.pipelineRun); + const wait = pipelineRun.length > 0 ? await waitForPipelineRun(pipelineRun, options.waitMs) : null; + const waitSucceeded = wait === null || wait.exitCode === 0 || wait.stdout.trimStart().startsWith("True\tSucceeded\t"); + return { + ...devopsBody, + ok: waitSucceeded, + triggerMode: "devops-service", + wait: wait === null ? null : { + stdoutTail: wait.stdout.slice(-6000), + stderrTail: wait.stderr.slice(-6000), + }, + }; + } + return { + ok: false, + triggerMode: "devops-service", + error: "DevOps service trigger failed or did not return ok=true; use --direct only for CI bootstrap/recovery, never as a service deployment path.", + devopsResponse, + }; + } + const name = await remoteCreatePipelineRun(devE2EPipelineRunManifest(options)); + const wait = await waitForPipelineRun(name, options.waitMs); + const waitSucceeded = wait === null || wait.exitCode === 0 || wait.stdout.trimStart().startsWith("True\tSucceeded\t"); + return { + ok: waitSucceeded, + pipelineRun: name, + namespace: "unidesk-ci", + temporaryNamespace: `unidesk-ci-e2e-${options.runId}`, + repoUrl: options.repoUrl, + desiredRef: options.desiredRef, + deployCommit: options.deployCommit, + environment: options.environment, + services: options.services, + keepNamespace: options.keepNamespace, + triggerMode: "direct-maintenance", + wait: wait === null ? null : { + stdoutTail: wait.stdout.slice(-6000), + stderrTail: wait.stderr.slice(-6000), + }, + next: [ + `bun scripts/cli.ts ci logs ${name}`, + "bun scripts/cli.ts ci status", + ], + }; +} + +async function logs(name: string): Promise<Record<string, unknown>> { if (name.length === 0) throw new Error("ci logs requires PipelineRun name"); - const result = runRemoteKubectl([ + const result = await runRemoteKubectl([ "set -euo pipefail", `kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o wide`, `kubectl get taskrun -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} -o wide`, - `for pod in $(kubectl get pods -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} -o name); do echo "===== $pod"; kubectl logs -n unidesk-ci "$pod" --all-containers=true --tail=160; done`, - ].join("\n")); + `for pod in $(kubectl get pods -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} -o name); do echo "===== $pod"; kubectl logs -n unidesk-ci "$pod" --all-containers=true --tail=160 || true; done`, + ].join("\n"), 60_000, 45_000); return { ok: true, pipelineRun: name, @@ -314,11 +658,12 @@ function logs(name: string): Record<string, unknown> { function help(): Record<string, unknown> { return { - command: "ci install|status|run|logs", + command: "ci install|status|run|run-dev-e2e|logs", description: "Manage the D601 k3s Tekton CI gate. This intentionally does not deploy CD.", examples: [ "bun scripts/cli.ts ci install", "bun scripts/cli.ts ci run --revision <commit>", + "bun scripts/cli.ts ci run-dev-e2e --wait-ms 600000", "bun scripts/cli.ts ci logs <pipelineRun>", ], tekton: { @@ -330,10 +675,22 @@ function help(): Record<string, unknown> { interceptors: tektonTriggersInterceptorsUrl, }, }, + runDevE2E: { + defaultTriggerMode: "devops-service", + directMaintenanceFlag: "--direct", + desiredState: "origin/master:deploy.json#environments.dev", + }, }; } -export function runCiCommand(_config: UniDeskConfig, args: string[]): Record<string, unknown> { +function requireRunId(value: string): string { + if (!/^[a-z0-9]([-a-z0-9]{0,46}[a-z0-9])?$/u.test(value)) { + throw new Error("ci run-dev-e2e run id must be DNS-safe lowercase alnum/dash, max 48 chars"); + } + return value; +} + +export async function runCiCommand(_config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> { const [action = "status", nameArg] = args; if (action === "help" || action === "--help" || action === "-h") return help(); if (action === "install") return install(); @@ -344,8 +701,26 @@ export function runCiCommand(_config: UniDeskConfig, args: string[]): Record<str const waitMs = numberOption(args, "--wait-ms", 0); return run({ repoUrl, revision, waitMs }); } + if (action === "run-dev-e2e") { + const repoUrl = stringOption(args, "--repo") ?? stringOption(args, "--repo-url") ?? "https://github.com/pikasTech/unidesk"; + const desiredRef = requireDesiredRef(stringOption(args, "--desired-ref") ?? stringOption(args, "--deploy-branch")); + const manifest = resolveDeployDevManifest(desiredRef); + const waitMs = numberOption(args, "--wait-ms", 0); + const runId = requireRunId(stringOption(args, "--run-id") ?? makeRunId(manifest.deployCommit)); + return runDevE2E({ + repoUrl, + desiredRef, + deployCommit: manifest.deployCommit, + environment: manifest.environment, + services: manifest.services, + runId, + keepNamespace: boolFlag(args, "--keep-namespace"), + waitMs, + direct: boolFlag(args, "--direct"), + }); + } if (action === "logs") return logs(nameArg ?? ""); - throw new Error("ci command must be one of: install, status, run, logs"); + throw new Error("ci command must be one of: install, status, run, run-dev-e2e, logs"); } export function startCiInstallJob(): Record<string, unknown> { diff --git a/scripts/src/deploy.ts b/scripts/src/deploy.ts index 277d9613..933a4223 100644 --- a/scripts/src/deploy.ts +++ b/scripts/src/deploy.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { runCommand } from "./command"; @@ -18,7 +18,7 @@ interface DeployManifestService { } interface DeployManifest { - schemaVersion: 1; + schemaVersion: 1 | 2; environment: DeployEnvironment | null; services: DeployManifestService[]; } @@ -131,13 +131,14 @@ const nativeK3sInstallVersion = "v1.34.1+k3s1"; const nativeK3sImage = "rancher/k3s:v1.34.1-k3s1"; const nativeK3sCtrAddress = "/run/k3s/containerd/containerd.sock"; const unideskRepoUrl = "https://github.com/pikasTech/unidesk"; -const devApplySupportedServiceIds = new Set(["backend-core", "frontend", "code-queue"]); +const d601MaintenanceDeployAllowedServiceIds = new Set(["devops"]); +const devApplySupportedServiceIds = d601MaintenanceDeployAllowedServiceIds; const deployEnvironmentTargets: Record<DeployEnvironment, DeployEnvironmentTarget> = { dev: { environment: "dev", remote: "origin", - branch: "deploy/dev", - gitRef: "origin/deploy/dev", + branch: "master", + gitRef: "origin/master", namespace: "unidesk-dev", runtimeScope: "d601-k3s-dev", database: { @@ -155,8 +156,8 @@ const deployEnvironmentTargets: Record<DeployEnvironment, DeployEnvironmentTarge prod: { environment: "prod", remote: "origin", - branch: "deploy/prod", - gitRef: "origin/deploy/prod", + branch: "master", + gitRef: "origin/master", namespace: "unidesk", runtimeScope: "prod-main-server-compose-and-d601-k3s", database: { @@ -194,7 +195,7 @@ function deployHelp(action: string | undefined = undefined): Record<string, unkn }, options: [ { name: "--file <path>", default: defaultDeployFile, description: "Desired-state manifest path relative to the repo root. JSON and ESM JS manifests are supported, for example deploy.json or develop.js." }, - { name: "--env <dev|prod>", description: "Read deploy.json from the fixed environment ref: dev=origin/deploy/dev, prod=origin/deploy/prod. Apply is currently enabled for supported dev services only." }, + { name: "--env <dev|prod>", description: "Read the named environment from origin/master:deploy.json. Direct D601 apply is enabled only for DevOps bootstrap/repair." }, { name: "--service <id>", description: "Limit reconcile to one service from the manifest." }, { name: "--dry-run", description: "Prepare and validate without mutating the target service." }, { name: "--force", description: "Redeploy even when the live commit appears up to date." }, @@ -474,23 +475,42 @@ function parseOptions(args: string[]): DeployOptions { }; } -function positionalArgs(args: string[]): string[] { - const result: string[] = []; - for (let index = 0; index < args.length; index += 1) { - const value = args[index] ?? ""; - if (value.startsWith("--")) { - if (!["--run-now", "--force"].includes(value)) index += 1; - continue; - } - result.push(value); - } - return result; +function parseDeployManifestService(item: unknown, index: number, seen: Set<string>, path: string): DeployManifestService { + const service = asRecord(item); + if (service === null) throw new Error(`deploy manifest ${path}[${index}] must be an object`); + const id = asString(service.id); + const repo = asString(service.repo); + const commitId = asString(service.commitId).toLowerCase(); + if (id.length === 0) throw new Error(`deploy manifest ${path}[${index}].id must be a non-empty string`); + if (repo.length === 0) throw new Error(`deploy manifest ${path}[${index}].repo must be a non-empty string`); + if (!/^[0-9a-f]{7,40}$/iu.test(commitId)) throw new Error(`deploy manifest ${path}[${index}].commitId must be a 7-40 char git SHA`); + if (seen.has(id)) throw new Error(`duplicate deploy manifest service id: ${id}`); + seen.add(id); + return { id, repo, commitId }; +} + +function parseDeployManifestServices(value: unknown, path: string): DeployManifestService[] { + if (!Array.isArray(value)) throw new Error(`deploy manifest ${path} must be an array`); + const seen = new Set<string>(); + return value.map((item, index) => parseDeployManifestService(item, index, seen, path)); } function parseDeployManifest(parsed: unknown, source: string, expectedEnvironment: DeployEnvironment | null): DeployManifest { const record = asRecord(parsed); if (record === null) throw new Error(`deploy manifest ${source} must be an object`); - if (record.schemaVersion !== 1) throw new Error("deploy manifest schemaVersion must be 1"); + if (record.schemaVersion !== 1 && record.schemaVersion !== 2) throw new Error("deploy manifest schemaVersion must be 1 or 2"); + if (record.schemaVersion === 2) { + const environments = asRecord(record.environments); + if (environments === null) throw new Error("deploy manifest schemaVersion=2 requires environments"); + if (expectedEnvironment === null) { + const prod = asRecord(environments.prod); + if (prod === null) throw new Error("deploy manifest schemaVersion=2 requires environments.prod for local compatibility mode"); + return { schemaVersion: 2, environment: "prod", services: parseDeployManifestServices(prod.services, "environments.prod.services") }; + } + const envRecord = asRecord(environments[expectedEnvironment]); + if (envRecord === null) throw new Error(`deploy manifest ${source} does not contain environments.${expectedEnvironment}`); + return { schemaVersion: 2, environment: expectedEnvironment, services: parseDeployManifestServices(envRecord.services, `environments.${expectedEnvironment}.services`) }; + } const rawEnvironment = record.environment; let environment: DeployEnvironment | null = null; if (rawEnvironment !== undefined) { @@ -505,22 +525,7 @@ function parseDeployManifest(parsed: unknown, source: string, expectedEnvironmen throw new Error(`deploy manifest ${source} declares environment=${environment}, refusing --env ${expectedEnvironment}`); } } - if (!Array.isArray(record.services)) throw new Error("deploy manifest services must be an array"); - const seen = new Set<string>(); - const services = record.services.map((item, index) => { - const service = asRecord(item); - if (service === null) throw new Error(`deploy manifest services[${index}] must be an object`); - const id = asString(service.id); - const repo = asString(service.repo); - const commitId = asString(service.commitId).toLowerCase(); - if (id.length === 0) throw new Error(`deploy manifest services[${index}].id must be a non-empty string`); - if (repo.length === 0) throw new Error(`deploy manifest services[${index}].repo must be a non-empty string`); - if (!/^[0-9a-f]{7,40}$/iu.test(commitId)) throw new Error(`deploy manifest services[${index}].commitId must be a 7-40 char git SHA`); - if (seen.has(id)) throw new Error(`duplicate deploy manifest service id: ${id}`); - seen.add(id); - return { id, repo, commitId }; - }); - return { schemaVersion: 1, environment, services }; + return { schemaVersion: 1, environment, services: parseDeployManifestServices(record.services, "services") }; } function readEnvironmentDeployManifest(environment: DeployEnvironment): { manifest: DeployManifest; source: DeployEnvironmentManifestSource } { @@ -535,8 +540,9 @@ function readEnvironmentDeployManifest(environment: DeployEnvironment): { manife const blob = runGitOrThrow(["rev-parse", `${target.gitRef}:deploy.json`], repoRoot, `failed to resolve ${target.gitRef}:deploy.json`).stdout.trim().toLowerCase(); if (!/^[0-9a-f]{40}$/iu.test(blob)) throw new Error(`failed to resolve a deploy.json blob for ${target.gitRef}`); const raw = runGitOrThrow(["show", `${target.gitRef}:deploy.json`], repoRoot, `failed to read ${target.gitRef}:deploy.json`).stdout; + const manifest = parseDeployManifest(JSON.parse(raw) as unknown, `${target.gitRef}:deploy.json#environments.${environment}`, environment); return { - manifest: parseDeployManifest(JSON.parse(raw) as unknown, `${target.gitRef}:deploy.json`, environment), + manifest, source: { source: "git-ref", path: "deploy.json", @@ -659,6 +665,20 @@ function devK3sDeployService(id: string): UniDeskMicroserviceConfig | undefined allowedMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"], allowedPathPrefixes: ["/", "/api/", "/logs"], }, + devops: { + name: "UniDesk DevOps Control", + description: "D601 k3s-managed DevOps control plane for normal CI trigger/status/log paths.", + dockerfile: "src/components/microservices/devops/Dockerfile", + composeFile: "src/components/microservices/k3sctl-adapter/k3s/devops.k3s.json", + composeService: "devops", + containerName: "k3s:devops", + nodeBaseUrl: "k3s://devops", + nodePort: 4286, + healthPath: "/health", + route: "/devops", + allowedMethods: ["GET", "HEAD", "POST"], + allowedPathPrefixes: ["/health", "/live", "/logs", "/api/"], + }, }; const spec = specs[id]; if (spec === undefined) return undefined; @@ -669,7 +689,7 @@ function devK3sDeployService(id: string): UniDeskMicroserviceConfig | undefined description: spec.description, repository: { url: unideskRepoUrl, - commitId: "deploy-dev", + commitId: "deploy-env", dockerfile: spec.dockerfile, composeFile: spec.composeFile, composeService: spec.composeService, @@ -677,9 +697,9 @@ function devK3sDeployService(id: string): UniDeskMicroserviceConfig | undefined }, backend: { nodeBaseUrl: spec.nodeBaseUrl, - nodeBindHost: `k3s://unidesk-dev/${spec.composeService}`, + nodeBindHost: `k3s://${id === "devops" ? "unidesk-ci" : "unidesk-dev"}/${spec.composeService}`, nodePort: spec.nodePort, - proxyMode: "dev-k3s-direct", + proxyMode: id === "devops" ? "k3sctl-adapter-http" : "dev-k3s-direct", frontendOnly: true, public: false, allowedMethods: spec.allowedMethods, @@ -691,14 +711,16 @@ function devK3sDeployService(id: string): UniDeskMicroserviceConfig | undefined mode: "k3sctl-managed", adapterServiceId: "k3sctl-adapter", k3sServiceId: spec.composeService, - namespace: "unidesk-dev", + namespace: id === "devops" ? "unidesk-ci" : "unidesk-dev", expectedNodeIds: ["D601"], activeNodeId: "D601", }, development: { providerId: "D601", sshPassthrough: true, - worktreePath: id === "code-queue" + worktreePath: id === "devops" + ? "/home/ubuntu/.unidesk/devops-deploy" + : id === "code-queue" ? "/home/ubuntu/unidesk-dev-code-queue-deploy/code-queue" : `/home/ubuntu/unidesk-dev-core-deploy/${id}`, }, @@ -721,10 +743,15 @@ function isCoreDeployService(service: UniDeskMicroserviceConfig): boolean { function isDevK3sDeployService(service: UniDeskMicroserviceConfig): boolean { return service.deployment.mode === "k3sctl-managed" - && service.deployment.namespace === deployEnvironmentTargets.dev.namespace && devApplySupportedServiceIds.has(service.id); } +function isD601MaintenanceDeployBlocked(service: UniDeskMicroserviceConfig): boolean { + if (targetIsMain(service)) return false; + if (service.providerId !== "D601") return false; + return !d601MaintenanceDeployAllowedServiceIds.has(service.id); +} + function isDirectComposeDeployMode(service: UniDeskMicroserviceConfig): boolean { return service.deployment.mode === "unidesk-direct" || service.deployment.mode === "internal-sidecar"; } @@ -992,6 +1019,12 @@ function dockerBuildTimeoutMs(service: UniDeskMicroserviceConfig, options: Deplo return Math.min(options.timeoutMs, maxBuildMs); } +function devK3sPrepullImages(service: UniDeskMicroserviceConfig): string[] { + if (!isDevK3sDeployService(service)) return []; + if (service.id === "devops") return ["golang:1.23-bookworm", "debian:bookworm-slim"]; + return ["oven/bun:1-alpine"]; +} + function prepareSourceScript(service: UniDeskMicroserviceConfig, desired: DeployManifestService, exportDir: string): string { if (targetIsMain(service) && isUnideskRepo(desired.repo)) { return [ @@ -1172,11 +1205,11 @@ function buildImageScript(service: UniDeskMicroserviceConfig, desired: DeployMan "if ! docker buildx version >/dev/null 2>&1; then", " if ! docker buildx inspect default >/dev/null 2>&1; then echo target_build_builder=missing >&2; exit 1; fi", "fi", - ...(isDevK3sDeployService(service) ? [ - "if ! docker image inspect oven/bun:1-alpine >/dev/null 2>&1; then", - " docker pull oven/bun:1-alpine", + ...devK3sPrepullImages(service).flatMap((imageName) => [ + `if ! docker image inspect ${shellQuote(imageName)} >/dev/null 2>&1; then`, + ` docker pull ${shellQuote(imageName)}`, "fi", - ] : []), + ]), "builder_args=()", "if docker buildx inspect --builder default >/dev/null 2>&1; then builder_args=(--builder default); echo target_build_builder=default; else echo target_build_builder=implicit; fi", "docker buildx inspect \"${builder_args[@]}\" --bootstrap || true", @@ -1218,9 +1251,9 @@ function patchDevK3sManifestScript(service: UniDeskMicroserviceConfig, desired: " raise SystemExit(f'dev service {service_id}/{deployment_name} not found in {path}')", "patched = []", "for segment in kept:", - " segment = segment.replace('unidesk.ai/image-source: deploy-dev-commit', 'unidesk.ai/image-source: deploy-env-commit')", + " segment = segment.replace('unidesk.ai/image-source: deploy-env-commit', 'unidesk.ai/image-source: deploy-env-commit')", " segment = re.sub(r'image: unidesk-[^\\n]+:dev-placeholder', f'image: {image}', segment)", - " segment = re.sub(r'value: replace-with-deploy-dev-commit', f'value: {commit}', segment)", + " segment = re.sub(r'value: replace-with-deploy-env-commit', f'value: {commit}', segment)", " segment = segment.replace('value: https://github.com/pikasTech/unidesk', f'value: {repo}')", " patched.append(segment.strip() + '\\n')", "out = '---\\n'.join(patched)", @@ -2148,6 +2181,15 @@ async function ensureGithubSshIdentityStep(config: UniDeskConfig, service: UniDe async function applyOneService(config: UniDeskConfig, service: UniDeskMicroserviceConfig, desired: DeployManifestService, options: DeployOptions): Promise<Record<string, unknown>> { const steps: StepResult[] = []; const startedAt = nowIso(); + if (!options.dryRun && isD601MaintenanceDeployBlocked(service)) { + return { + ok: false, + serviceId: service.id, + skipped: true, + reason: `D601 maintenance-channel direct deployment is allowed only for ${[...d601MaintenanceDeployAllowedServiceIds].join(", ")} bootstrap/repair. Deploy ${service.id} through the DevOps control plane instead.`, + steps, + }; + } const reason = unsupportedReason(service); if (reason !== null) return { ok: false, serviceId: service.id, skipped: true, reason, steps }; const before = await readRuntimeState(config, service, desired); @@ -2274,6 +2316,7 @@ function environmentDryRunPlan( mutatesRuntime: false, environment, gitRef: source.ref, + environmentPath: `environments.${environment}`, manifest: { source: source.source, path: source.path, @@ -2283,6 +2326,7 @@ function environmentDryRunPlan( commit: source.commit, blob: source.blob, environment: manifest.environment, + environmentPath: `environments.${environment}`, fetchedAt: source.fetchedAt, }, target: environmentTargetSummary(target), @@ -2309,6 +2353,17 @@ function unsupportedDevApplyServices(manifest: DeployManifest, serviceId: string return services.map((service) => service.id).filter((id) => !devApplySupportedServiceIds.has(id)); } +function blockedD601MaintenanceDeployServices(config: UniDeskConfig, manifest: DeployManifest, serviceId: string | null): string[] { + return selectServices(config, manifest, serviceId) + .map((item) => item.config) + .filter(isD601MaintenanceDeployBlocked) + .map((service) => service.id); +} + +function d601MaintenanceDeployBlockMessage(blocked: string[]): string { + return `D601 maintenance-channel direct deployment is allowed only for ${[...d601MaintenanceDeployAllowedServiceIds].join(", ")} bootstrap/repair; blocked services: ${blocked.join(", ")}. Use the DevOps control plane for other direct/managed microservices.`; +} + async function runApplyNow(config: UniDeskConfig, manifest: DeployManifest, options: DeployOptions): Promise<Record<string, unknown>> { const selected = selectServices(config, manifest, options.serviceId); const startedAt = nowIso(); @@ -2333,7 +2388,7 @@ async function runApplyNow(config: UniDeskConfig, manifest: DeployManifest, opti function applyJob(config: UniDeskConfig, args: string[], options: DeployOptions): Record<string, unknown> { const runArgs = args.includes("--run-now") ? args : [...args, "--run-now"]; const command = [process.execPath, rootPath("scripts", "cli.ts"), "deploy", ...runArgs]; - const source = options.environment === null ? options.file : `${deployEnvironmentTargets[options.environment].gitRef}:deploy.json`; + const source = options.environment === null ? options.file : `${deployEnvironmentTargets[options.environment].gitRef}:deploy.json#environments.${options.environment}`; const job = startJob("deploy_apply", command, `Reconcile services from ${source}${options.serviceId === null ? "" : ` service=${options.serviceId}`}`); return { ok: true, @@ -2361,40 +2416,27 @@ export async function runDeployCommand(config: UniDeskConfig | null, args: strin throw new Error(`deploy apply --env dev currently supports only ${[...devApplySupportedServiceIds].join(", ")}; unsupported selected services: ${unsupported.join(", ")}`); } if (config === null) throw new Error("deploy apply --env dev requires config.json"); + if (!options.dryRun) { + const blocked = blockedD601MaintenanceDeployServices(config, manifest, options.serviceId); + if (blocked.length > 0) throw new Error(d601MaintenanceDeployBlockMessage(blocked)); + } if (!options.runNow) return applyJob(config, args, options); return await runApplyNow(config, manifest, options); } if (config === null) throw new Error("deploy local manifest mode requires config.json"); const manifest = resolveManifestCommits(await readDeployManifest(options.file), options.serviceId); if (action === "check" || action === "plan") return await checkOrPlan(config, manifest, options, action); + if (!options.dryRun) { + const blocked = blockedD601MaintenanceDeployServices(config, manifest, options.serviceId); + if (blocked.length > 0) throw new Error(d601MaintenanceDeployBlockMessage(blocked)); + } if (!options.runNow) return applyJob(config, args, options); return await runApplyNow(config, manifest, options); } -export async function runCodeQueueDeployCompatCommand(config: UniDeskConfig, args: string[]): Promise<unknown> { - if (args.includes("--skip-build")) throw new Error("codex deploy no longer supports --skip-build; target-side Docker build is mandatory"); +export async function runCodeQueueDeployCompatCommand(_config: UniDeskConfig, args: string[]): Promise<unknown> { + if (args.includes("--skip-build")) throw new Error("codex deploy is disabled; --skip-build is not supported"); const providerId = optionValue(args, ["--provider-id", "--provider"]) ?? "D601"; if (providerId !== "D601") throw new Error(`codex deploy compatibility path only supports D601; got ${providerId}`); - const commitId = optionValue(args, ["--commit", "--commit-id"]) ?? positionalArgs(args)[0] ?? ""; - if (!/^[0-9a-f]{7,40}$/iu.test(commitId)) throw new Error("codex deploy requires a 7-40 char commit id"); - const service = config.microservices.find((item) => item.id === "code-queue"); - if (service === undefined) throw new Error("config.json does not contain microservice id=code-queue"); - const manifestRelDir = join(".state", "deploy", "manifests"); - mkdirSync(rootPath(manifestRelDir), { recursive: true }); - const manifestRelPath = join(manifestRelDir, `code-queue-${commitId.toLowerCase()}.json`); - writeFileSync(rootPath(manifestRelPath), `${JSON.stringify({ - schemaVersion: 1, - services: [{ id: "code-queue", repo: service.repository.url, commitId: commitId.toLowerCase() }], - }, null, 2)}\n`, "utf8"); - const deployArgs = [ - "apply", - "--file", - manifestRelPath, - "--service", - "code-queue", - ...(args.includes("--run-now") ? ["--run-now"] : []), - ...(args.includes("--force") ? ["--force"] : []), - ...(optionValue(args, ["--timeout-ms"]) === undefined ? [] : ["--timeout-ms", optionValue(args, ["--timeout-ms"]) as string]), - ]; - return await runDeployCommand(config, deployArgs); + throw new Error("codex deploy is disabled because D601 maintenance-channel direct deployment is now reserved for DevOps bootstrap/repair. Use the DevOps control plane for Code Queue deployment."); } diff --git a/src/components/frontend/public/app.js b/src/components/frontend/public/app.js index ab2aa4d4..a73267c0 100644 --- a/src/components/frontend/public/app.js +++ b/src/components/frontend/public/app.js @@ -290,4 +290,4 @@ html,body{width:${f}px!important;min-height:${n}px!important;overflow:hidden!imp <g transform="translate(152 114)">${X}</g> ${m} ${S} - </svg>`,width:W,height:L}}function kA(l,u){let r=URL.createObjectURL(l),f=document.createElement("a");f.href=r,f.download=u,f.click(),setTimeout(()=>URL.revokeObjectURL(r),1000)}async function zL(l,u){let r=KL(u,"pipeline"),{svg:f,width:n,height:i}=NX(l,u),t=new Blob([f],{type:"image/svg+xml;charset=utf-8"}),y=URL.createObjectURL(t);try{let c=new Image;await new Promise((F,U)=>{c.onload=()=>F(),c.onerror=()=>U(Error("svg image load failed")),c.src=y});let $=document.createElement("canvas");$.width=n,$.height=i;let A=$.getContext("2d");if(!A)throw Error("canvas unavailable");A.drawImage(c,0,0);let j=await new Promise((F)=>$.toBlob(F,"image/png"));if(!j)throw Error("png export failed");kA(j,`${r}.png`)}catch{kA(t,`${r}.svg`)}finally{URL.revokeObjectURL(y)}}async function LX(l){let u=KL(String(l?.title||"pipeline-gantt"),"pipeline-gantt"),{svg:r,width:f,height:n}=WX(l),i=new Blob([r],{type:"image/svg+xml;charset=utf-8"}),t=URL.createObjectURL(i);try{let y=new Image;await new Promise((j,F)=>{y.onload=()=>j(),y.onerror=()=>F(Error("gantt svg image load failed")),y.src=t});let c=document.createElement("canvas");c.width=f,c.height=n;let $=c.getContext("2d");if(!$)throw Error("canvas unavailable");$.drawImage(y,0,0);let A=await new Promise((j)=>c.toBlob(j,"image/png"));if(!A)throw Error("gantt png export failed");kA(A,`${u}.png`)}catch{kA(i,`${u}.svg`)}finally{URL.revokeObjectURL(t)}}async function GX(l){for(let u of l){if(u.flow.nodes.length===0)continue;await zL(u.flow,u.title),await new Promise((r)=>setTimeout(r,750))}}function _L(l,u){return l.find((r)=>String(r?.pipelineId||"")===u)||null}function $L(l){return al(l?.startedAt)??al(l?.artifact?.startedAt)??al(l?.request?.createdAt)??al(l?.updatedAt)??0}function TX(l,u){return l.filter((r)=>String(r?.pipelineId||"")===u).slice().sort((r,f)=>$L(r)-$L(f)||String(r?.runId||"").localeCompare(String(f?.runId||"")))}function Jj(l,u){let r=String(u?.runId||""),f=l.findIndex((t)=>String(t?.runId||"")===r),n=f>=0?f+1:l.length,i=String(u?.status||"--");return`Epoch ${n} / ${r||"--"} / ${i}`}function Sf(l){return String(l?.procedureRunId||l?.runId||"")}function oA(l,u){let r=String(l?.nodeId||l?.request?.nodeId||"");if(r)return r;let f=Sf(l),n=`${u}__`;if(f.startsWith(n))return f.slice(n.length).replace(/__\d+$/u,"");return""}function PA(l,u){let r=Yl(l?.artifact)?l.artifact:{},f=Yl(l?.request)?l.request:{};return t_(l?.startedAt,r.startedAt,f.createdAt,f.startedAt,l?.createdAt,l?.updatedAt,u?.startedAt,u?.request?.createdAt)}function CA(l,u){let r=String(l?.status?.status||l?.artifact?.status||l?.status||"").toLowerCase(),f=Yl(l?.artifact)?l.artifact:{},n=wj(r);return t_(l?.finishedAt,f.finishedAt,l?.completedAt,n?l?.updatedAt:void 0,n?f.updatedAt:void 0,n?u?.updatedAt:void 0)}function EL(l,u,r=Date.now()){let f=String(l?.runId||""),n=new Set(u.map((i)=>String(i?.id||"")).filter(Boolean));return Sl(l?.procedureRuns).flatMap((i)=>{let t=oA(i,f);if(!t)return[];let y=String(i?.status?.status||i?.artifact?.status||i?.status||"unknown").toLowerCase(),c=PA(i,l),$=al(c);if($===null)return[];let A=CA(i,l),j=al(A)??(wj(y)?al(i?.updatedAt)??$+1000:r),F=Math.max($+1000,j);return[{nodeId:t,knownNode:n.has(t),procedureRunId:Sf(i),status:y,startMs:$,endMs:F,startedAt:f_($),finishedAt:f_(F),durationMs:F-$,runId:f,raw:i}]}).sort((i,t)=>i.startMs-t.startMs||i.endMs-t.endMs||i.nodeId.localeCompare(t.nodeId))}function mX(l,u,r=[]){let f=u.map((A)=>Number(A.startMs)).filter(Number.isFinite),n=u.map((A)=>Number(A.endMs)).filter(Number.isFinite);for(let A of r){let j=Xu(A?.eventMs??A?.ms);if(j!==null)f.push(j),n.push(j)}let i=al(l?.startedAt)??al(l?.artifact?.startedAt)??al(l?.request?.createdAt),t=al(l?.finishedAt)??al(l?.artifact?.finishedAt)??al(l?.updatedAt);if(i!==null)f.push(i);if(t!==null)n.push(t);let y=Date.now(),c=f.length>0?Math.min(...f):y-60000,$=Math.max(c+60000,n.length>0?Math.max(...n):y);return{startMs:c,endMs:$,durationMs:$-c}}var MA=12,OL=20,Uj=100,KX=!1;function r0(l){let u=Number(l);if(!Number.isFinite(u))return 0;return Math.max(0,Math.min(100,Math.round(u*100)/100))}function zX(l){let u=Math.max(MA,Number(l||MA)),r=Math.log(u/MA)/Math.log(OL);return r0(r*100)}var i_=zX(Uj);function mj(l){let u=r0(l)/100,r=MA*Math.pow(OL,u),f=u<0.24?"全局":u<0.64?"均衡":"细节";return{value:r0(u*100),pxPerMinute:r,label:f}}function yj(l){let u=Math.round(Number(l));return Math.abs(u-Uj)<=1?Uj:u}function EX(l,u=i_){let r=Math.max(1,Number(l.durationMs||0)/60000),f=mj(u);return Math.round(Math.max(360,Math.min(7200,r*Number(f.pxPerMinute||48))))}function OX(l,u=7){let r=Math.max(1,Number(l.endMs||0)-Number(l.startMs||0));return Array.from({length:u},(f,n)=>{let i=u===1?0:n/(u-1);return{ms:Number(l.startMs)+r*i,percent:i*100}})}function ZX(l,u){let r=Math.max(1,Number(u.endMs)-Number(u.startMs));return Math.max(0,Math.min(100,(l-Number(u.startMs))/r*100))}function Xu(l){let u=Number(l);return Number.isFinite(u)?u:null}function Kj(l){return JL(l?.status)&&!wj(l?.status)}function ZL(l,u,r,f){let n=Math.max(1,r-u),i=Math.max(0,Math.min(1,(l-u)/n));return Number((i*f).toFixed(3))}function AL(l,u){if(!u)return null;let r=Xu(u?.startMs),f=Xu(u?.endMs),n=Xu(u?.chartHeight);if(r===null||f===null||n===null)return null;return ZL(l,r,f,n)}function pL(l,u){let r=Xu(l?.rawStartMs??l?.startMs)??Xu(l?.startMs)??u,f=Xu(l?.endMs)??r+1000;if(!Kj(l))return Math.max(r+1000,f);return Math.max(r+1000,f,u)}function pX(l,u,r,f){let n=Xu(l?.startMs)??f-60000,i=Xu(l?.endMs)??f,t=r.reduce((N,W)=>Math.max(N,pL(W,f)),i),y=Math.max(n+60000,i,t),c=Math.max(1,y-n),$={startMs:n,endMs:y,durationMs:c},A=EX($,u),j=mj(u),F=Math.max(5,Math.min(18,Math.round(A/150))),U=OX($,F).map((N)=>{let W=Number(N.ms),L=ZL(W,n,y,A);return{...N,y:L,timestamp:f_(W),offsetMs:W-n}});return{source:"frontend-y",startMs:n,endMs:y,durationMs:c,chartHeight:A,scale:r0(u),normalizedScale:Number((r0(u)/100).toFixed(3)),pxPerMinute:Number(Number(j.pxPerMinute||0).toFixed(3)),ticks:U}}function HX(l,u,r){if(!Kj(l))return l;let f=Xu(l?.rawStartMs??l?.startMs)??Xu(l?.startMs)??r,n=pL(l,r),i=AL(f,u),t=AL(n,u),y=Xu(i??l?.y1??l?.startY)??0,c=Xu(t??l?.y2??l?.endY)??y+10,$=Math.max(24,c-y);return{...l,live:!0,startMs:f,endMs:n,durationMs:Math.max(1000,n-f),finishedAt:f_(n),y1:y,y2:c,startY:y,endY:c,height:$}}function zj(l,u,r){return ZX(l,u)/100*r}function Xy(l){return Boolean(l&&String(l?.source||"")!=="frontend-y")}function HL(l,u,r,f,n){if(Xy(f))for(let t of n){let y=Xu(l?.[t]);if(y!==null)return y}let i=Xu(l?.ms??l?.eventMs??l?.startMs);return zj(i??Number(u.startMs),u,r)}function gA(l,u,r,f){return HL(l,u,r,f,["y1","startY"])}function Nj(l,u,r,f){if(Xy(f)){let i=Xu(l?.y2??l?.endY);if(i!==null)return i}let n=Xu(l?.endMs)??Number(u.endMs);return zj(n,u,r)}function BL(l,u,r,f){if(Xy(f)){let i=Xu(l?.height);if(i!==null)return Math.max(1,i)}let n=l?.live?24:10;return Math.max(n,Nj(l,u,r,f)-gA(l,u,r,f))}function wf(l,u,r,f){return HL(l,u,r,f,["y","timeAxisY"])}function DL(l,u,r,f){if(Xy(f)||String(f?.source||"")==="frontend-y"){let t=Xu(l?.y);if(t!==null)return t}let n=Xu(l?.percent);if(n!==null)return n/100*r;let i=Xu(l?.ms)??Number(u.startMs);return zj(i,u,r)}function BX(l){let u=String(l?.promptEvent||l?.raw?.promptEvent||l?.event||"").toLowerCase();if(!["node-long-running-observation","node-finished"].includes(u))return"";let r=String(l?.sourceNodeId||l?.raw?.sourceNodeId||l?.raw?.detail?.nodeId||""),f=String(l?.nodeId||l?.targetNodeId||"");return r&&r!==f?r:""}function DX(l,u){let r=new Set(u.map((n)=>[String(n.sourceNodeId||""),String(n.targetNodeId||""),String(n.targetMarkerId||""),String(n.action||"")].join(":"))),f=[...u];for(let n of l){let i=BX(n),t=String(n?.nodeId||""),y=String(n?.id||"");if(!i||!t||!y)continue;let c=[i,t,y,"observe"].join(":");if(r.has(c))continue;r.add(c),f.push({id:`observation-arrow:${y}:${i}:${t}`,commandId:String(n?.commandId||n?.eventId||y),sourceNodeId:i,targetNodeId:t,sourceMarkerId:"",targetMarkerId:y,sourceKind:"monitor",action:"observe",status:"observation"})}return{markers:l,arrows:f}}function VX(l){let u=un(l),r=String(l?.promptEvent||"");if(u==="initial-prompt-delivered")return"initial";if(r==="node-finished"||r==="node-long-running-observation"||r.startsWith("monitor-"))return"monitor";if(u==="monitor-prompt-delivered"||String(l?.sourceKind||"").toLowerCase()==="monitor")return"monitor";return"append"}function SX(l){return Sl(l?.tags||l?.raw?.tags).map((u)=>String(u||"")).filter(Boolean)}function XX(l){let u=un(l),r=String(l?.promptEvent||"");if(u==="initial-prompt-delivered")return"初始 prompt";if(r==="node-long-running-observation")return"长任务观察";if(r==="node-finished")return SX(l).includes("monitor.audit")?"节点完成 / OA 审核":"节点完成";if(r==="monitor-interval")return"Monitor observation";if(r==="monitor-start")return"Monitor start";if(r==="monitor-stop")return"Monitor stop";if(u==="monitor-prompt-delivered")return"Monitor prompt";if(u==="append-prompt-queued")return"追加 prompt 已排队";return"追加 prompt"}function jL(l){let u=un(l);if(u==="control-command-applied")return 3;if(u==="control-command-ignored")return 2;if(u==="control-command-queued")return 1;return 0}function YX(l,u){let r=String(l?.commandId||"");if(r)return`command:${r}`;return["control-event",Dy(l)||t_(l?.createdAt,l?.timestamp)||`index-${u}`,String(l?.sourceKind||""),String(l?.sourceNodeId||""),String(l?.targetNodeId||""),rt(l)].join(":")}function PX(l){return jj([l?.targetNodeId,...Sl(l?.resetNodeIds)])}function CX(l,u){let r=u_(l),f=un(l),n=String(l?.targetNodeId||""),i=Boolean(n)&&u!==n;if(f==="control-command-applied")return i?`${r} 波及`:`${r} 生效`;if(f==="control-command-ignored")return`${r} 忽略`;if(f==="control-command-queued")return`${r} 已发起`;return i?`${r} 波及`:r}function MX(l){if(un(l)==="control-command-ignored")return"ignored";let r=rt(l);if(r==="restart"||r==="redo")return"restart";if(r==="modify")return"modify";if(r==="approve")return"approve";if(r==="guide")return"guide";return"pending"}function hX(l){let u=String(l?.sourceKind||"").toLowerCase();if(u==="monitor")return"monitor";if(u==="webui")return"webui";if(u==="cli")return"cli";return"system"}function RX(l,u,r,f){let n=l.filter(($)=>String($.nodeId||"")===u).sort(($,A)=>Number($.startMs)-Number(A.startMs)),i=n.find(($)=>r>=Number($.startMs)-1000&&r<=Number($.endMs)+1000);if(i)return{ms:r,onInterval:!0,snapReason:"inside-interval",procedureRunId:String(i.procedureRunId||"")};let t=rt(f),y=n.slice().reverse().find(($)=>Number($.endMs)<=r+1000);if(y&&t==="approve")return{ms:Number(y.endMs),onInterval:!0,snapReason:"previous-interval-end",procedureRunId:String(y.procedureRunId||"")};let c=n.find(($)=>Number($.startMs)>=r-1000);if(c&&["guide","modify","restart","redo"].includes(t))return{ms:Number(c.startMs),onInterval:!0,snapReason:"next-interval-start",procedureRunId:String(c.procedureRunId||"")};return{ms:r,onInterval:!1,snapReason:"event-time",procedureRunId:String(f?.procedureRunId||"")}}function VL(l,u,r,f){let n=Math.hypot(r-l,f-u),i=n>gW?gW:0,t=i>0?r-(r-l)/n*i:r,y=i>0?f-(f-u)/n*i:f,c=t-l,$=Math.max(16,Math.min(42,Math.abs(c)*0.45+12)),A=c===0?1:Math.sign(c);return`M ${l},${u} C ${l+A*$},${u} ${t-A*$},${y} ${t},${y}`}function xX(l,u){let r=String(l?.runId||u?.runId||""),f=EL({...Yl(u)?u:{},...Yl(l)?l:{},runId:r,procedureRuns:Sl(l?.procedureRuns).length>0?l.procedureRuns:u?.procedureRuns},[]),n=[],i=[],t=[],y=new Set,c=new Map,$=(F,U)=>{if(!F.nodeId||!Number.isFinite(Number(F.ms)))return;if(y.has(F.id))return;y.add(F.id),U.push(F)};for(let F of Sl(l?.procedureRuns)){let U=oA(F,r),N=Sf(F);if(!U)continue;for(let W of Sl(F?.attempts)){let L=aA(W);for(let J of Aj(W?.controlEventRecords)){let w=un(J);if(!["initial-prompt-delivered","append-prompt-delivered","monitor-prompt-delivered"].includes(w))continue;let Q=Dy(J),q=al(Q);if(q===null)continue;let T=String(J?.eventId||"");$({id:`prompt:${T||`${N}:${L}:${w}:${q}`}`,runId:r,nodeId:U,procedureRunId:N,attempt:L,kind:"prompt",tone:VX(J),status:"delivered",label:XX(J),ms:q,timestampIso:Q,sourceKind:String(J?.sourceKind||""),sourceNodeId:String(J?.sourceNodeId||""),targetNodeId:U,action:"",eventId:T,commandId:String(J?.commandId||""),raw:J},n)}}}let A=new Map;Aj(l?.controlEvents).forEach((F,U)=>{let N=YX(F,U),W=A.get(N)||{key:N,events:[]};W.events.push(F),A.set(N,W)});for(let F of A.values()){let U=Sl(F.events).slice().sort((p,V)=>jL(V)-jL(p)),N=Sl(F.events).find((p)=>un(p)==="control-command-queued")||null,W=U[0]||N;if(!N&&!W)continue;let L=String(N?.sourceNodeId||W?.sourceNodeId||""),J=String(N?.sourceKind||W?.sourceKind||""),w=Dy(N)||Dy(W)||t_(N?.createdAt,W?.createdAt),Q=al(w),q=String(W?.commandId||N?.commandId||F.key),T=(un(W)||"control-command-queued").replace(/^control-command-/u,""),O="";if(L&&Q!==null)O=`control-source:${q}:${L}`,c.set(q,O),$({id:O,runId:r,nodeId:L,procedureRunId:String(N?.procedureRunId||W?.procedureRunId||""),attempt:"",kind:"control-source",tone:hX(N||W),status:T,label:`${u_(N||W)} 发起`,ms:Q,timestampIso:w,action:rt(N||W),sourceKind:J,sourceNodeId:L,targetNodeId:String(W?.targetNodeId||N?.targetNodeId||""),commandId:q,raw:N||W},i);let Z=W||N,E=Dy(Z)||w,D=al(E);if(D===null)continue;let Y=PX(Z);for(let p of Y){let V=RX(f,p,D,Z),B=`control-target:${q}:${p}`;if($({id:B,runId:r,nodeId:p,procedureRunId:V.procedureRunId,attempt:"",kind:"control-target",tone:MX(Z),status:T,label:CX(Z,p),ms:V.ms,eventMs:D,onInterval:V.onInterval,snapReason:V.snapReason,snapped:Number(V.ms)!==D,timestampIso:E,renderedTimestampIso:f_(Number(V.ms)),action:rt(Z),sourceKind:J,sourceNodeId:L,targetNodeId:p,commandId:q,raw:Z},i),O&&L&&L!==p)t.push({id:`control-arrow:${q}:${L}:${p}`,commandId:q,sourceNodeId:L,targetNodeId:p,sourceMarkerId:O,targetMarkerId:B,sourceKind:J,action:rt(Z),status:T})}}let j=[...n,...i].sort((F,U)=>Number(F.ms)-Number(U.ms)||String(F.nodeId).localeCompare(String(U.nodeId))||String(F.id).localeCompare(String(U.id)));return{...DX(j,t),sourceMarkerByCommand:c}}function bX({details:l,selectedNodeId:u,selectedNodeRuntime:r,control:f,onRaw:n}){if(!l)return K("span",{className:"muted"},"点击“抓取过程”读取 node 运行材料;主界面只显示结构化摘要,完整内容需点开原始 JSON。");let i=Sl(l.procedureRuns),t=i.at(-1)||{},y=Sl(t.attempts),c=y.at(-1)||{},$=Sl(t.workerLogTail),A=Sl(c.controlEventsTail),j=Sl(c.controlPromptsTail),F=Sl(c.monitorPromptsTail),U=fj(A),N=fj(j),W=fj(F),L=c.opencodeMessages||{};return K("div",{className:"pipeline-evidence-list compact"},K(Df,{title:"Node runtime",subtitle:u||"--",facts:[`status ${r?.status||"pending"}`,`attempts ${r?.attempts??y.length}`,`procedure ${r?.currentProcedureRunId||Sf(t)||"--"}`,f.fetchedAt?`fetched ${iu(f.fetchedAt)}`:"not fetched"],data:l.node||l,onRaw:n,testId:"raw-pipeline-node-runtime"}),K(Df,{title:"Procedure runs",subtitle:`${i.length} groups`,facts:[`latest ${t.status?.status||t.status||"--"}`,`steps ${Sl(t.recentSteps).length}`,`duration ${Vf(al(t.finishedAt)&&al(t.startedAt)?Number(al(t.finishedAt))-Number(al(t.startedAt)):t.durationMs)}`],data:i,onRaw:n,testId:"raw-pipeline-node-procedures"}),K(Df,{title:"OpenCode messages",subtitle:String(L.exists?"available":"not indexed"),facts:[`messages ${RA(L.messageCount)}`,`size ${RA(L.size)}`,`updated ${Wl(L.updatedAt)}`],data:L,onRaw:n,testId:"raw-pipeline-node-messages"}),K(Df,{title:"Control prompts",subtitle:"manual / monitor append queues",facts:[`manual tail ${N.total}`,`monitor tail ${W.total}`,`last ${Wl(Qj(N.lastAt,W.lastAt))}`],data:{controlPromptsTail:j,monitorPromptsTail:F},onRaw:n,testId:"raw-pipeline-node-prompts"}),K(Df,{title:"Control events",subtitle:U.eventKinds.length>0?U.eventKinds.join(", "):"event tail",facts:[`tail ${U.total}`,`parsed ${U.parsed}`,`last ${Wl(U.lastAt)}`],data:A,onRaw:n,testId:"raw-pipeline-node-events"}),K(Df,{title:"Worker log",subtitle:"tail is hidden on main canvas",facts:[`tail ${$.length} lines`,"raw only via button",`procedure ${Sf(t)||"--"}`],data:$,onRaw:n,testId:"raw-pipeline-node-worker-log"}))}function vX({activeRun:l,onRaw:u}){if(!l)return K(qf,{title:"暂无运行材料",text:"没有 Pipeline epoch 时不会展示运行材料索引。"});let r=Sl(l.nodes),f=Sl(l.procedureRuns),n=Sl(l.submissions),i=Sl(l.workerLogTail),t=oW(r),y=oW(f),c=f.filter((A)=>String(A?.status||"").toLowerCase()==="failed"),$=Qj(...f.flatMap((A)=>[A.updatedAt,A.finishedAt,A.startedAt]));return K("div",{className:"pipeline-evidence-list"},K(Df,{title:"Epoch overview",subtitle:l.runId||"--",facts:[`pipeline ${l.pipelineId||"--"}`,`status ${l.status||"--"}`,`started ${Wl(l.startedAt)}`,`updated ${Wl(l.updatedAt)}`],data:l,onRaw:u,testId:"raw-pipeline-run"}),K(Df,{title:"Node states",subtitle:`${r.length} nodes`,facts:[`running ${t.running||0}`,`succeeded ${t.succeeded||0}`,`failed ${t.failed||0}`,`pending ${t.pending||0}`],data:r,onRaw:u,testId:"raw-pipeline-run-nodes"}),K(Df,{title:"Procedure run index",subtitle:`${f.length} procedure records`,facts:[`succeeded ${y.succeeded||0}`,`failed ${y.failed||0}`,`latest ${Wl($)}`,`errors ${c.length}`],data:f,onRaw:u,testId:"raw-pipeline-run-procedures"}),K(Df,{title:"OA submissions",subtitle:`${n.length} submission files`,facts:[`records ${n.length}`,`task ${RA(l.task)}`,"raw grouped by run"],data:n,onRaw:u,testId:"raw-pipeline-run-submissions"}),K(Df,{title:"Worker log tail",subtitle:"hidden from main interface",facts:[`tail ${i.length} lines`,"display raw only after click",`updated ${Wl(l.updatedAt)}`],data:i,onRaw:u,testId:"raw-pipeline-run-worker-log"}))}function sX({diagnostics:l,onRaw:u}){let r=Sl(l?.runs).filter(Yl),f=Sl(l?.forbiddenResiduals),n=Yl(l?.guarantees)?l.guarantees:{},i=l?.hasNeutralNodeFinishedEvidence===!0&&l?.hasNoAuditPolicyEvidence===!0&&l?.hasAuditPolicyEvidence===!0,t=l?.ok===!0&&i&&f.length===0,y=r[0]||null,c=[{label:"中性完成事实",ok:n.neutralNodeFinished===!0,hint:"node-finished 不携带流程策略"},{label:"Config 策略判定",ok:n.auditPolicyFromConfig===!0,hint:"OA backend 读取当前 epoch 配置"},{label:"控制命令来自 OA",ok:n.runnerConsumesControlCommandsFromOaEvents===!0,hint:"runner 只消费 OA control.command"},{label:"无独立审核事件",ok:n.noIndependentAuditRequestEvent===!0,hint:"审核由 node-finished + policy 派生"},{label:"无批次门禁",ok:n.noBatchFinishedControlGate===!0,hint:"下游启动由每个 node 完成驱动"}];return K("div",{className:"pipeline-oa-panel","data-testid":"pipeline-oa-event-flow-panel"},K("div",{className:"metric-grid compact"},K(Mr,{label:"OA Flow",value:t?"100%":"--",hint:String(l?.mode||"waiting diagnostics"),tone:t?"ok":"warn"}),K(Mr,{label:"禁止残留",value:f.length,hint:f.length===0?"source scan clean":"needs cleanup",tone:f.length===0?"ok":"warn"}),K(Mr,{label:"No-audit",value:l?.hasNoAuditPolicyEvidence?"OK":"--",hint:"OA 下游策略证据",tone:l?.hasNoAuditPolicyEvidence?"ok":"warn"}),K(Mr,{label:"Monitor 审核",value:l?.hasAuditPolicyEvidence?"OK":"--",hint:"OA 控制事件闭环",tone:l?.hasAuditPolicyEvidence?"ok":"warn"})),K("div",{className:"pipeline-oa-guarantees"},c.map(($)=>K("article",{key:$.label,className:`pipeline-oa-guarantee ${$.ok?"ok":"warn"}`},K(u0,{status:$.ok?"online":"warn"},$.ok?"OK":"MISS"),K("div",null,K("strong",null,$.label),K("span",null,$.hint))))),K("div",{className:"pipeline-evidence-list compact"},r.slice(0,6).map(($)=>K(Df,{key:$.runId,title:String($.runId||"--"),subtitle:[Number($.monitorAuditNodeFinishedCount||0)>0?"monitor audit":"",Number($.noAuditPolicyCount||0)>0?"no-audit policy":""].filter(Boolean).join(" / ")||"event evidence",facts:[`events ${$.eventCount||0}`,`node-finished ${$.nodeFinishedCount||0}`,`policy-in-detail ${$.nodeFinishedWithPolicyCount||0}`,`queued ${$.controlQueuedCount||0}`,`applied ${$.controlAppliedCount||0}`],data:$,onRaw:u,testId:`raw-pipeline-oa-run-${String($.runId||"run").replace(/[^a-zA-Z0-9_.-]+/g,"-")}`}))),y?K("p",{className:"muted paragraph"},`最新证据 ${y.runId}: ${y.nodeFinishedCount||0} 个 node-finished,${y.controlAppliedCount||0} 个控制结果。`):K(qf,{title:"暂无 OA 事件流证据",text:"等待 Pipeline backend 暴露 diagnostics。"}),l?K("div",{className:"panel-actions inline-actions"},K(rn,{title:"Pipeline OA Event Flow Diagnostics",data:l,onOpen:u,testId:"raw-pipeline-oa-event-flow"})):null)}function kX({quota:l,onRaw:u}){let r=Yl(l?.summary)?l.summary:{},f=Yl(l?.target)?l.target:{},n=Yl(l?.cache)?l.cache:{},i=l?.ok===!0,t=String(l?.modelId||r.modelName||f.modelName||"MiniMax-M2.7"),y=r.totalCount??f.currentIntervalTotalCount,c=r.usageCount??f.currentIntervalUsageCount,$=r.remainingCount??f.currentIntervalRemainingCount,A=r.remainingRatio??(Number.isFinite(Number(y))&&Number(y)>0&&Number.isFinite(Number($))?Number($)/Number(y):void 0),j=r.usageRatio??(Number.isFinite(Number(y))&&Number(y)>0&&Number.isFinite(Number(c))?Number(c)/Number(y):void 0),F=r.resetAt||f.endAt,U=r.remainsMs??f.remainsMs,N=Number($),W=!i||Number.isFinite(N)&&N<=0?"warn":"ok",L=[i?`endpoint ${l?.endpoint||"--"}`:"quota unavailable",`fetched ${hA(l?.fetchedAt)}`,n.hit?`cache ${Vf(n.ageMs)}`:"live quota"];return K("div",{className:"pipeline-minimax-quota-panel","data-testid":"pipeline-minimax-quota-panel"},K("div",{className:"metric-grid compact"},K(Mr,{label:"MiniMax",value:i?t:"--",hint:l?.modelComponent||l?.error||"model/minimax-m27",tone:W}),K(Mr,{label:"当前窗口",value:`${rj(c)}/${rj(y)}`,hint:`已用 ${aW(j)}`,tone:W}),K(Mr,{label:"剩余额度",value:rj($),hint:`剩余 ${aW(A)}`,tone:W}),K(Mr,{label:"重置时间",value:hA(F),hint:U!==void 0?`约 ${Vf(U)}`:Wl(F),tone:W})),K(qj,{items:L}),i?K("p",{className:"muted paragraph"},`MiniMax 限额来自 D601 Pipeline 后端实时查询;当前模型匹配 ${r.modelName||f.modelName||t}。`):K(lu,{error:l?.error||"MiniMax 限额查询失败"}),l?K("div",{className:"panel-actions inline-actions"},K(rn,{title:"Pipeline MiniMax Quota",data:l,onOpen:u,testId:"raw-pipeline-minimax-quota"})):null)}function gX({epochs:l,activeRun:u,activePipeline:r,pipelineNodes:f,pipelineEdges:n,runDetails:i,nodeDetails:t,nodeDetailsState:y,ganttScale:c=i_,onGanttScaleChange:$,onRunChange:A,onIntervalSelect:j,onMarkerSelect:F,selection:U,detailOpen:N,onDetailOpenChange:W,onRaw:L}){let[J,w]=dr(KX),[Q,q]=dr({startY:0,endY:0,startMs:0,endMs:0}),[T,O]=dr(Date.now()),Z=ei(null),E=String(u?.runId||""),D=Boolean(N),Y=(jl)=>{if(typeof W==="function")W(jl)},p=r0(c??i_),V=String(i?.runId||"")===E?i?.details:null,B=V?{...Yl(u)?u:{},...Yl(V)?V:{},runId:E,procedureRuns:Sl(V?.procedureRuns).length>0?V.procedureRuns:u?.procedureRuns}:u,m=EL(B,f,T),X=V?xX(V,B):{markers:[],arrows:[]},S=Sl(X.markers),b=mX(B,m,S),z=pX(b,p,m,T),P=String(z.source||"frontend-y"),s=m.map((jl)=>HX(jl,z,T)),k={startMs:Number(z.startMs),endMs:Number(z.endMs),durationMs:Math.max(1,Number(z.durationMs??Number(z.endMs)-Number(z.startMs)))},v=mj(p),tl={...v,pxPerMinute:Number(z.pxPerMinute??v.pxPerMinute)},I=Math.round(Number(z.chartHeight||360)),M=m.some(Kj);zn(()=>{if(!E||!M)return;let jl=window.setInterval(()=>O(Date.now()),1000);return()=>window.clearInterval(jl)},[E,M]);let rl=JX(r,f,Array.isArray(n)?n:[]),cl=f.map((jl)=>String(jl?.id||"")).filter(Boolean),$l=s.map((jl)=>String(jl.nodeId||"")).filter(Boolean),Tl=S.map((jl)=>String(jl.nodeId||"")).filter(Boolean),Ql=Array.from(new Set([...rl,...cl,...$l,...Tl])),Ol={startY:0,endY:I,startMs:Number(k.startMs),endMs:Number(k.endMs)},h=Number(Q?.endY||0)>0?Q:Ol,a=(jl)=>{return gA(jl,k,I,z)<=Number(h.endY)&&Nj(jl,k,I,z)>=Number(h.startY)},ul=(jl)=>{let ol=wf(jl,k,I,z);return ol>=Number(h.startY)&&ol<=Number(h.endY)},zl=new Set(Ql.filter((jl)=>s.some((ol)=>ol.nodeId===jl&&a(ol))||S.some((ol)=>ol.nodeId===jl&&ul(ol)))),o=J?Ql.filter((jl)=>zl.has(jl)):Ql,ql=`${lj}px ${o.length>0?o.map(()=>`${mn}px`).join(" "):"minmax(160px, 1fr)"}`,pl=Sl(z.ticks).filter(Yl),Bl=String(U?.mode==="interval"?U?.interval?.procedureRunId||"":""),Il=String(U?.mode==="event"?U?.marker?.id||"":""),nu=()=>{let jl=Z.current;if(!jl){q(Ol);return}let ol=Math.max(0,jl.scrollTop-uj),wr=Math.max(120,jl.clientHeight-uj),hl=Math.min(I,ol+wr),Yu={startY:ol,endY:hl,startMs:Number(k.startMs),endMs:Number(k.endMs)},eu=Math.max(0,Math.min(1,ol/I)),rf=Math.max(eu,Math.min(1,hl/I)),Ar=Math.max(1,Number(k.endMs)-Number(k.startMs));Yu.startMs=Number(k.startMs)+Ar*eu,Yu.endMs=Number(k.startMs)+Ar*rf,q(Yu)};zn(()=>{let jl=Z.current,ol=window.setTimeout(nu,0);return jl?.addEventListener("scroll",nu),window.addEventListener("resize",nu),()=>{window.clearTimeout(ol),jl?.removeEventListener("scroll",nu),window.removeEventListener("resize",nu)}},[E,k.startMs,k.endMs,I]);let Ml=Math.max(0,Ql.length-o.length),wu=new Set(S.filter((jl)=>o.includes(String(jl.nodeId||""))&&ul(jl)).map((jl)=>String(jl.id))),Qr=new Map(S.map((jl)=>[String(jl.id),jl])),Or=Sl(X.arrows).filter((jl)=>{if(!wu.has(String(jl.targetMarkerId||"")))return!1;if(String(jl.action||"")==="observe")return o.includes(String(jl.sourceNodeId||""));return wu.has(String(jl.sourceMarkerId||""))}),$r=lj+Math.max(1,o.length)*mn,ir=(jl)=>{let ol=r0(jl.target.value);if(typeof $==="function")$(ol);window.setTimeout(nu,0)},tr=()=>LX({title:`${r?.id||"pipeline"}-${E||"epoch"}-gantt`,meta:[`run ${E||"--"}`,`${Wl(k.startMs)} -> ${Wl(k.endMs)}`,`duration ${Vf(k.durationMs)}`,`${tl.label} / ${yj(tl.pxPerMinute)} px/min`,`${o.length}/${Ql.length} nodes`,`${S.length} markers`],visibleNodeIds:o,intervals:s,markers:S.filter((jl)=>o.includes(String(jl.nodeId||""))),arrows:Or,ticks:pl,bounds:k,chartHeight:I,backendLayout:z}),hr=Yl(V?.gantt?.diagnostics)?V.gantt.diagnostics:null;return K(Kn,{title:"Epoch 甘特图",eyebrow:`${r?.id||"pipeline"} / ${l.length} epochs`,className:"pipeline-wide-panel",loading:i?.loading,actions:K("div",{className:"pipeline-gantt-actions"},K("select",{value:E,disabled:l.length===0,onChange:(jl)=>A(jl.target.value),"data-testid":"pipeline-epoch-select"},l.map((jl)=>K("option",{key:jl.runId,value:jl.runId},Jj(l,jl)))),K("label",{className:"pipeline-gantt-toggle"},K("input",{type:"checkbox","data-testid":"pipeline-gantt-auto-hide-idle",checked:J,onChange:(jl)=>{w(Boolean(jl.target.checked)),window.setTimeout(nu,0)}}),K("span",null,"自动隐藏空闲列")),K("label",{className:"pipeline-gantt-scale"},K("span",null,K("b",null,"时间尺度"),K("em",{"data-testid":"pipeline-gantt-scale-label"},`${tl.label} · ${yj(tl.pxPerMinute)} px/min`)),K("input",{type:"range",min:0,max:100,step:0.01,value:p,onChange:ir,"aria-label":"调整甘特图时间尺度","data-testid":"pipeline-gantt-time-scale"}),K("small",null,K("span",null,"全局"),K("span",null,"细节"))),u?K("button",{type:"button",className:"ghost-btn",onClick:tr,disabled:o.length===0,"data-testid":"pipeline-export-gantt"},"导出甘特图"):null,u?K(rn,{title:`Pipeline Epoch ${u.runId}`,data:u,onOpen:L,testId:"raw-pipeline-epoch-gantt"}):null)},!u?K(qf,{title:"暂无 Epoch",text:"当前 pipeline 还没有完整运行记录。"}):s.length===0?K(qf,{title:"暂无时间区间",text:"等待 D601 Pipeline backend 在 procedure summary 中返回 startedAt / finishedAt。"}):K("div",{className:"pipeline-gantt-wrap"},K("div",{className:`pipeline-gantt-detail-layout ${D?"detail-open":"detail-collapsed"}`,"data-testid":"pipeline-gantt-detail-layout","data-sidebar-open":D?"true":"false"},K("div",{className:"pipeline-gantt-main"},K("div",{className:"pipeline-gantt-main-head"},K("div",{className:"pipeline-gantt-meta"},K("span",null,`time ${Wl(k.startMs)} -> ${Wl(k.endMs)}`),K("span",null,`duration ${Vf(k.durationMs)}`),K("span",null,`scale ${tl.label} / ${yj(tl.pxPerMinute)} px/min`),K("span",null,`layout ${P}`),hr?K("span",null,`align ${hr.timeAxisAlignmentOk===!1?"check":"ok"}`):null,K("span",null,`visible ${o.length}/${Ql.length} nodes`),V?K("span",null,`markers ${S.length}`):null,J&&Ml>0?K("span",null,`hidden idle ${Ml}`):null),!D?K("button",{type:"button",className:"pipeline-sidecar-tab right",disabled:!U?.mode,onClick:()=>Y(!0),"data-testid":"pipeline-gantt-sidebar-toggle"},U?.mode?"展开详情":"点击甘特图元素展开详情"):null),K("div",{className:"pipeline-gantt-viewport",ref:Z,"data-testid":"pipeline-epoch-gantt","data-pipeline-id":r?.id||"","data-run-id":E,"data-layout-source":P,"data-start-ms":String(k.startMs),"data-end-ms":String(k.endMs),"data-chart-height":String(I)},K("div",{className:"pipeline-gantt-board",style:{gridTemplateColumns:ql,minWidth:`${$r}px`}},K("div",{className:"pipeline-gantt-head time"},"Time"),o.length===0?K("div",{className:"pipeline-gantt-head empty"},"当前时间窗无工作节点"):o.map((jl)=>K("div",{key:`head-${jl}`,className:"pipeline-gantt-head node",title:jl,"data-testid":"pipeline-gantt-head-node","data-node-id":jl},K(IS,{value:jl}))),K("div",{className:"pipeline-gantt-time-axis",style:{height:`${I}px`}},pl.map((jl)=>{let ol=DL(jl,k,I,z);return K("div",{key:`tick-${jl.ms}-${ol}`,className:"pipeline-gantt-tick",style:{top:`${ol}px`},"data-testid":"pipeline-gantt-tick","data-ms":String(jl.ms),"data-y":String(ol)},K("b",null,Wl(jl.ms)),K("span",null,`+${Vf(Number(jl.offsetMs??Number(jl.ms)-Number(k.startMs)))}`))})),o.length>0?K("svg",{className:"pipeline-gantt-arrow-layer",width:o.length*mn,height:I,viewBox:`0 0 ${o.length*mn} ${I}`,style:{left:`${lj}px`,top:`${uj}px`,width:`${o.length*mn}px`,height:`${I}px`},"aria-hidden":"true"},K("defs",null,K("marker",{id:"pipeline-gantt-arrowhead",viewBox:"0 0 10 10",refX:9,refY:5,markerWidth:6,markerHeight:6,orient:"auto-start-reverse"},K("path",{d:"M 0 0 L 10 5 L 0 10 z",fill:"context-stroke"}))),Or.map((jl)=>{let ol=Qr.get(String(jl.targetMarkerId||""));if(!ol)return null;let wr=Qr.get(String(jl.sourceMarkerId||"")),hl=String(wr?.nodeId||jl.sourceNodeId||""),Yu=o.indexOf(hl),eu=o.indexOf(String(ol.nodeId||""));if(Yu<0||eu<0)return null;let rf=Yu*mn+mn/2,Ar=eu*mn+mn/2,Zr=wr?wf(wr,k,I,z):wf(ol,k,I,z),Zn=wf(ol,k,I,z);return K("path",{key:jl.id,className:`pipeline-gantt-arrow ${String(jl.sourceKind||"").toLowerCase()} ${String(jl.status||"").toLowerCase()} ${String(jl.action||"").toLowerCase()}`,d:VL(rf,Zr,Ar,Zn),markerEnd:"url(#pipeline-gantt-arrowhead)","data-testid":String(jl.action||"")==="observe"?"pipeline-gantt-observation-arrow":"pipeline-gantt-arrow","data-source-node-id":String(jl.sourceNodeId||""),"data-target-node-id":String(jl.targetNodeId||""),"data-target-marker-id":String(jl.targetMarkerId||""),"data-action":String(jl.action||""),"data-source-y":String(Zr),"data-target-y":String(Zn)})})):null,o.length===0?K("div",{className:"pipeline-gantt-empty-col",style:{height:`${I}px`}},"滚动到有活动的时间段后,相关 node 列会自动出现。"):o.map((jl)=>{let ol=s.filter((hl)=>hl.nodeId===jl),wr=S.filter((hl)=>String(hl.nodeId||"")===jl);return K("div",{key:`col-${jl}`,className:"pipeline-gantt-node-col",style:{height:`${I}px`}},ol.map((hl)=>{let Yu=gA(hl,k,I,z),eu=Nj(hl,k,I,z),rf=BL(hl,k,I,z),Ar=String(hl.procedureRunId||`${jl}-${hl.startMs}`);return K("button",{key:Ar,type:"button",className:`pipeline-gantt-bar ${hl.status} ${hl.live?"live":""} ${Bl===Ar?"selected":""}`,style:{top:`${Yu}px`,height:`${rf}px`},title:`${jl} ${hl.status} ${Wl(hl.startedAt||hl.startMs)} -> ${Wl(hl.finishedAt||hl.endMs)}`,onClick:()=>j(hl),"data-testid":"pipeline-gantt-line","data-node-id":jl,"data-procedure-run-id":String(hl.procedureRunId||""),"data-status":String(hl.status||""),"data-live":hl.live?"true":"false","data-start-ms":String(hl.startMs||""),"data-end-ms":String(hl.endMs||""),"data-y1":String(Yu),"data-y2":String(eu),"data-natural-height":String(Math.max(0,eu-Yu))},K("strong",null,hl.status||"working"),K("span",null,Vf(hl.durationMs)))}),wr.map((hl)=>K("button",{key:hl.id,type:"button",className:`pipeline-gantt-marker ${hl.kind} ${hl.tone||""} ${hl.status||""} ${Il===String(hl.id)?"selected":""}`,style:{top:`${wf(hl,k,I,z)}px`},title:`${hl.label||"event"} / ${Wl(hl.timestampIso||hl.timestamp||hl.ms)}`,onClick:()=>F(hl),"data-testid":hl.kind==="prompt"?"pipeline-gantt-prompt-marker":"pipeline-gantt-control-marker","data-marker-id":String(hl.id||""),"data-ms":String(hl.ms??hl.eventMs??""),"data-y":String(wf(hl,k,I,z))})))})))),D?K(gS,{selection:U,runDetails:i,nodeDetails:t,nodeDetailsState:y,onRaw:L,onCollapse:()=>Y(!1)}):null)))}function ri(){return{loading:!1,actionLoading:"",error:"",message:"",details:null,fetchedAt:null,appendPrompt:"",guidePrompt:"",modifyPrompt:"",approveReason:"",redoReason:""}}function di(){return{mode:"",runId:"",interval:null,marker:null}}function cj(){return{runId:"",loading:!1,error:"",details:null,fetchedAt:null}}function dc(l,u){return`${l}/microservices/pipeline/proxy${u}`}function IX({activeRun:l,pipelineRuns:u,selectedRunId:r,onRunChange:f,selectedNodeId:n,selectedNodeConfig:i,selectedNodeRuntime:t,control:y,onControlChange:c,onFetch:$,onAction:A,onRaw:j,onCollapse:F}){let U=String(l?.runId||""),N=String(t?.status||"pending"),W=!U||!n||y.loading||Boolean(y.actionLoading),L=(w)=>(Q)=>c({[w]:Q.target.value,error:"",message:""}),J=u.length>0?u:l?[l]:[];return K("aside",{className:"pipeline-node-control","data-testid":"pipeline-node-control"},K("div",{className:"pipeline-node-control-head"},K("div",null,K("p",{className:"panel-eyebrow"},"Manual Node Control"),K(fu,{title:n||"点击控制图中的 node",level:3,loading:y.loading||Boolean(y.actionLoading)})),K("div",{className:"pipeline-node-control-head-actions"},n?K(u0,{status:N},N):K(u0,{status:"pending"},"idle"),K("button",{type:"button",className:"ghost-btn mini",onClick:F,"data-testid":"pipeline-node-sidebar-collapse"},"收起"))),K("div",{className:"pipeline-control-runbar"},K("label",null,K("span",null,"目标 run"),K("select",{value:U||r,disabled:J.length===0,onChange:(w)=>f(w.target.value),"data-testid":"pipeline-node-run-select"},J.map((w)=>K("option",{key:w.runId,value:w.runId},`${w.runId||"--"} / ${w.status||"--"}`)))),K("button",{type:"button",className:"ghost-btn",disabled:W,onClick:$,"data-testid":"pipeline-node-fetch"},y.loading?"抓取中":"抓取过程"),y.details?K(rn,{title:`Pipeline Node ${n}`,data:y.details,onOpen:j,testId:"raw-pipeline-node-control"}):null),K("div",{className:"pipeline-control-meta"},K("span",null,K("b",null,"kind"),String(i?.kind||"--")),K("span",null,K("b",null,"procedure"),String(t?.currentProcedureRunId||"--")),K("span",null,K("b",null,"attempts"),String(t?.attempts??"--")),K("span",null,K("b",null,"updated"),Wl(l?.updatedAt))),!n?K(qf,{title:"未选择 node",text:"点击 React Flow 控制图中的任意 node 后,可抓取执行过程、追加 prompt、下发引导、增量修改、审核通过或重做。"}):null,K(lu,{error:y.error,wide:!0}),K("div",{className:"pipeline-control-actions"},K("label",null,K("span",null,"实时追加 prompt(仅 running node)"),K("textarea",{value:y.appendPrompt,onChange:L("appendPrompt"),placeholder:"让当前执行中的 agent 继续、补充检查或调整当前步骤...",rows:4,disabled:!n,"data-testid":"pipeline-node-append-input"}),K("button",{type:"button",className:"primary-btn compact",disabled:W||!String(y.appendPrompt||"").trim(),onClick:()=>A("append"),"data-testid":"pipeline-node-append-button"},y.actionLoading==="append"?"追加中":"追加到运行中 node")),K("label",null,K("span",null,"下次尝试引导 prompt"),K("textarea",{value:y.guidePrompt,onChange:L("guidePrompt"),placeholder:"给该 node 下一次 attempt 的执行提示;不会立即打断当前 session。",rows:4,disabled:!n,"data-testid":"pipeline-node-guide-input"}),K("button",{type:"button",className:"ghost-btn compact",disabled:W||!String(y.guidePrompt||"").trim(),onClick:()=>A("guide"),"data-testid":"pipeline-node-guide-button"},y.actionLoading==="guide"?"下发中":"下发 guide")),K("label",null,K("span",null,"完成后增量修改 prompt"),K("textarea",{value:y.modifyPrompt,onChange:L("modifyPrompt"),placeholder:"在该 node 已完成结果基础上追加修改要求;runner 会重跑目标 node,并保留同 node 既有 OA 输出作为上下文。",rows:4,disabled:!n,"data-testid":"pipeline-node-modify-input"}),K("button",{type:"button",className:"ghost-btn compact",disabled:W||!String(y.modifyPrompt||"").trim(),onClick:()=>A("modify"),"data-testid":"pipeline-node-modify-button"},y.actionLoading==="modify"?"排队中":"增量修改 node")),K("label",null,K("span",null,"Monitor 审核通过原因"),K("textarea",{value:y.approveReason,onChange:L("approveReason"),placeholder:"当流程配置开启 monitor 审核时,记录审核通过原因并释放后续 node。",rows:3,disabled:!n,"data-testid":"pipeline-node-approve-input"}),K("button",{type:"button",className:"primary-btn compact",disabled:W||!String(y.approveReason||"").trim(),onClick:()=>A("approve"),"data-testid":"pipeline-node-approve-button"},y.actionLoading==="approve"?"提交中":"审核通过")),K("label",null,K("span",null,"重做 / restart 原因"),K("textarea",{value:y.redoReason,onChange:L("redoReason"),placeholder:"说明为什么需要重做;runner 会重置目标 node 以及非 rework 下游 node。",rows:4,disabled:!n,"data-testid":"pipeline-node-redo-input"}),K("button",{type:"button",className:"danger-btn compact",disabled:W||!String(y.redoReason||"").trim(),onClick:()=>A("redo"),"data-testid":"pipeline-node-redo-button"},y.actionLoading==="redo"?"排队中":"重做 node"))),K("div",{className:"pipeline-control-evidence"},K("strong",null,"Node 过程索引"),K(bX,{details:y.details,selectedNodeId:n,selectedNodeRuntime:t,control:y,onRaw:j})))}function SL({microservices:l,onRaw:u,apiBaseUrl:r="/api"}){let f=l.find((fl)=>fl.id==="pipeline")||null,[n,i]=dr({loading:!1,error:"",health:null,snapshot:null,oaDiagnostics:null,minimaxQuota:null,refreshedAt:null}),[t,y]=dr(""),[c,$]=dr(""),[A,j]=dr(""),[F,U]=dr(ri()),[N,W]=dr({}),[L,J]=dr(di()),[w,Q]=dr(cj()),[q,T]=dr(i_),[O,Z]=dr(!1),[E,D]=dr(!1),Y=ei(0),{addNotification:p}=Xr(),V=ei(!1),B=ei(0),m=ei(""),X=ei({}),S=ei(""),b=ei("");async function z(fl={}){let Dl=fl.silent===!0;if(!f)return;if(V.current)return;V.current=!0;let Cl=Y.current+1;if(Y.current=Cl,!Dl)i((dl)=>({...dl,loading:!0,error:""}));try{let dl=`__unideskArrayLimit=registry.components:80,runs:${HS}`,[ju,ku,$u]=await Promise.all([oi(`${r}/microservices/pipeline/proxy/api/snapshot?${dl}`,{cache:"no-store"}),oi(`${r}/microservices/pipeline/proxy/api/oa-event-flow/diagnostics`,{cache:"no-store"}).catch((Yf)=>({ok:!1,error:El(Yf,"OA event flow diagnostics failed")})),oi(`${r}/microservices/pipeline/proxy/api/model-quota/minimax`,{cache:"no-store"}).catch((Yf)=>({ok:!1,error:El(Yf,"MiniMax quota failed")}))]);if(Cl!==Y.current)return;let qr={ok:ju?.ok!==!1,service:"pipeline-v2-control snapshot"};i({loading:!1,error:"",health:qr,snapshot:ju,oaDiagnostics:ku,minimaxQuota:$u,refreshedAt:new Date})}catch(dl){if(Cl!==Y.current)return;i((ju)=>({...ju,loading:!1,error:El(dl,"Pipeline 加载失败")}))}finally{V.current=!1}}zn(()=>{if(z(),!f)return;let fl=()=>{if(XA())z({silent:!0})},Dl=window.setInterval(()=>{fl()},kW),Cl=()=>{if(XA())fl()};return document.addEventListener("visibilitychange",Cl),()=>{window.clearInterval(Dl),document.removeEventListener("visibilitychange",Cl)}},[f?.id,f?.runtime?.providerStatus,r]);let P=aS(f),s=dS(f),k=oS(f),v=n.snapshot||{},tl=n.oaDiagnostics||null,I=n.minimaxQuota||null,{components:M,pipelines:rl,runs:cl}=eS(v),$l=String(cl[0]?.pipelineId||""),Tl=($l?rl.find((fl)=>String(fl.id||"")===$l):null)||rl[0]||{},Ql=rl.find((fl)=>String(fl.id||"")===t)||Tl,Ol=String(Ql.id||""),h=GL(Ql),a=Lj(Ql),ul=_L(cl,Ol),zl=TX(cl,Ol),o=zl.find((fl)=>String(fl?.runId||"")===c)||ul,ql=String(w.runId||"")===String(o?.runId||"")?fX(w.details):null,pl=nX(o,ql),Bl=String(pl?.runId||""),Il=h.find((fl)=>String(fl?.id||"")===A)||null,nu=A?TL(pl,A):null,Ml=uX(cl),wu=cX(M),Qr=Number(n.health?.components)||rL(v,"registry.components",M.length),Or=rL(v,"runs",cl.length),$r=iL(Ql,pl,M),ir={nodes:$r.nodes.map((fl)=>fl.id===A?{...fl,selected:!0,className:`${fl.className||""} selected-control-node`}:fl),edges:$r.edges},tr=rl.map((fl)=>{let Dl=String(fl.id||"pipeline"),Cl=_L(cl,Dl);return{title:`${Dl}-${Cl?.runId||"snapshot"}`,flow:iL(fl,Cl,M)}}),hr=String(L?.runId||Bl||""),jl=String(L?.interval?.nodeId||L?.marker?.nodeId||""),ol=hr&&jl?N[tj(hr,jl)]||null:null,wr=xA(F.details,hr,jl),hl=xA(ol?.details,hr,jl)||wr,Yu=hr&&jl?{...Yl(ol)?ol:{},runId:hr,nodeId:jl,details:hl,loading:Boolean(ol?.loading)||!hl&&Boolean(F.loading)&&A===jl,error:String(ol?.error||""),fetchedAt:ol?.fetchedAt||(wr?F.fetchedAt:null)}:null,eu=zl.map((fl)=>String(fl?.runId||"")).filter(Boolean).join("|"),rf=h.map((fl)=>String(fl?.id||"")).filter(Boolean).join("|");zn(()=>{S.current=A},[A]),zn(()=>{b.current=Bl},[Bl]),zn(()=>{if(!c||eu.split("|").includes(c))return;$("")},[c,eu]),zn(()=>{if(!A||rf.split("|").includes(A))return;j(""),U(ri()),J(di()),Z(!1),D(!1)},[A,rf]),zn(()=>{if(!A)Z(!1)},[A]),zn(()=>{if(!L.mode)D(!1)},[L.mode]);async function Ar(fl=Bl,Dl={}){if(!fl){Q(cj());return}let Cl=r0(Dl.scale??q??i_),dl=`${fl}:timeline`;if(m.current===dl)return;m.current=dl;let ju=Dl.silent===!0,ku=B.current+1;B.current=ku,Q(($u)=>({runId:fl,scale:Cl,loading:!ju||String($u.runId||"")!==fl||!$u.details,error:"",details:ju&&$u.runId===fl?$u.details:$u.runId===fl?$u.details:null,fetchedAt:$u.runId===fl?$u.fetchedAt:null}));try{let[$u,qr]=await Promise.all([oi(dc(r,`/api/node-control/runs/${encodeURIComponent(fl)}?tail=160&view=timeline`),{cache:"no-store",strictJson:!0}),oi(dc(r,`/api/runs/${encodeURIComponent(fl)}`),{cache:"no-store"}).catch((Yf)=>({ok:!1,runSummaryError:El(Yf,"抓取评分失败")}))]);if(ku!==B.current)return;Q({runId:fl,scale:Cl,loading:!1,error:"",details:{...$u,run:Yl(qr?.run)?qr.run:void 0,runSummaryError:qr?.runSummaryError},fetchedAt:new Date})}catch($u){if(ku!==B.current)return;Q((qr)=>({runId:fl,scale:Cl,loading:!1,error:El($u,"抓取 epoch 执行过程失败"),details:qr.runId===fl?qr.details:null,fetchedAt:qr.runId===fl?qr.fetchedAt:null}))}finally{if(m.current===dl)m.current=""}}function Zr(fl,Dl,Cl){let dl=tj(fl,Dl);W((ju)=>{let ku={...ju,[dl]:{...Yl(ju?.[dl])?ju[dl]:{},runId:fl,nodeId:Dl,...Cl}},$u=Object.keys(ku);if($u.length>32)for(let qr of $u.slice(0,$u.length-32))delete ku[qr];return ku})}async function Zn(fl,Dl){if(!fl||!Dl)return;let Cl=tj(fl,Dl),dl=Number(X.current?.[Cl]||0)+1;X.current={...X.current,[Cl]:dl},Zr(fl,Dl,{loading:!0,error:""});try{let ju=await oi(dc(r,`/api/node-control/runs/${encodeURIComponent(fl)}/nodes/${encodeURIComponent(Dl)}?tail=160`),{cache:"no-store",strictJson:!0});if(Number(X.current?.[Cl]||0)!==dl)return;let ku=new Date;if(Zr(fl,Dl,{loading:!1,details:ju,fetchedAt:ku,error:""}),S.current===Dl&&b.current===fl)U(($u)=>({...$u,loading:!1,details:ju,fetchedAt:ku,error:""}))}catch(ju){if(Number(X.current?.[Cl]||0)!==dl)return;Zr(fl,Dl,{loading:!1,error:El(ju,"抓取 Gantt node 详情失败")})}}zn(()=>{if(!Bl){Q(cj());return}Ar(Bl);let fl=()=>{if(XA())Ar(Bl,{silent:!0})},Dl=window.setInterval(()=>{fl()},kW),Cl=()=>{if(XA())fl()};return document.addEventListener("visibilitychange",Cl),()=>{window.clearInterval(Dl),document.removeEventListener("visibilitychange",Cl)}},[Bl,r]);async function ff(fl=Bl,Dl=A){if(!fl||!Dl){U((Cl)=>({...Cl,error:"请先选择 run 和 node",message:""}));return}U((Cl)=>({...Cl,loading:!0,error:"",message:""}));try{let Cl=await oi(dc(r,`/api/node-control/runs/${encodeURIComponent(fl)}/nodes/${encodeURIComponent(Dl)}?tail=160`),{cache:"no-store",strictJson:!0}),dl=new Date;U((ju)=>({...ju,loading:!1,details:Cl,fetchedAt:dl,error:""})),Zr(fl,Dl,{loading:!1,details:Cl,fetchedAt:dl,error:""})}catch(Cl){U((dl)=>({...dl,loading:!1,error:El(Cl,"抓取 node 执行过程失败")}))}}async function ii(fl){let Dl=String(fl?.runId||Bl||""),Cl=String(fl?.nodeId||"");if(J({mode:"interval",runId:Dl,interval:fl,marker:null}),D(!0),!Dl||!Cl)return;if(Dl!==Bl)$(Dl);j(Cl),U(ri()),Ar(Dl,{silent:!0}),Zn(Dl,Cl)}async function Au(fl){let Dl=String(fl?.runId||Bl||""),Cl=String(fl?.nodeId||"");if(J({mode:"event",runId:Dl,interval:null,marker:fl}),D(!0),!Dl)return;if(Dl!==Bl)$(Dl);if(Ar(Dl,{silent:!0}),!Cl)return;j(Cl),U(ri()),Zn(Dl,Cl)}async function nt(fl){if(!Bl||!A){U((dl)=>({...dl,error:"请先选择 run 和 node",message:""}));return}let Dl=fl==="append"?"prompts":fl,Cl=fl==="append"?F.appendPrompt:fl==="guide"?F.guidePrompt:fl==="modify"?F.modifyPrompt:fl==="approve"?F.approveReason:F.redoReason;if(!String(Cl||"").trim()){U((dl)=>({...dl,error:"操作内容不能为空",message:""}));return}U((dl)=>({...dl,actionLoading:fl,error:"",message:""}));try{let dl=fl==="redo"||fl==="approve"?{reason:Cl,source:"unidesk-frontend",sourceKind:"webui"}:{prompt:Cl,source:"unidesk-frontend",sourceKind:"webui"},ju=await oi(dc(r,`/api/node-control/runs/${encodeURIComponent(Bl)}/nodes/${encodeURIComponent(A)}/${Dl}`),{method:"POST",body:JSON.stringify(dl)});if(U(($u)=>({...$u,actionLoading:"",details:ju,fetchedAt:new Date,appendPrompt:fl==="append"?"":$u.appendPrompt,guidePrompt:fl==="guide"?"":$u.guidePrompt,modifyPrompt:fl==="modify"?"":$u.modifyPrompt,approveReason:fl==="approve"?"":$u.approveReason,redoReason:fl==="redo"?"":$u.redoReason,message:fl==="append"?"已追加到运行中 node":fl==="guide"?"已下发 guide,等待 runner 处理":fl==="modify"?"已排队增量修改命令":fl==="approve"?"已提交审核通过决策":"已排队重做命令"})),p("success",fl==="append"?"已追加到运行中 node":fl==="guide"?"已下发 guide,等待 runner 处理":fl==="modify"?"已排队增量修改命令":fl==="approve"?"已提交审核通过决策":"已排队重做命令"),await ff(Bl,A),await Ar(Bl,{silent:!0}),fl!=="append")await z()}catch(dl){U((ju)=>({...ju,actionLoading:"",error:El(dl,"node 控制操作失败")}))}}if(!f)return K(qf,{title:"Pipeline 未登记",text:"请在 config.json 的 microservices 中登记用户服务 id=pipeline"});return K("div",{className:"pipeline-page","data-testid":"pipeline-page"},K(Kn,{title:"Pipeline v2 工作台",eyebrow:"D601 Snapshot 用户服务",loading:n.loading,actions:K("div",{className:"panel-actions"},K("button",{type:"button",className:"ghost-btn",onClick:z,disabled:n.loading,"data-testid":"pipeline-refresh-button"},n.loading?"刷新中":"刷新"),K(rn,{title:"Pipeline 用户服务",data:f,onOpen:u,testId:"raw-pipeline-service"}))},K("div",{className:"pipeline-hero"},K("div",null,K("div",{className:"node-version-line"},K(u0,{status:P.providerStatus==="online"?"online":"warn"},P.providerStatus||"unknown"),K("span",null,f.providerId),K("span",null,k.public?"公网暴露":"仅 UniDesk frontend 代理访问")),K("p",{className:"muted paragraph"},f.description)),K("div",{className:"microservice-ref-card"},K("span",null,"Repo"),K("strong",null,s.url||"--"),K("code",null,s.commitId||"--")),K("div",{className:"microservice-ref-card"},K("span",null,"D601 Docker"),K("strong",null,`${k.nodeBindHost||"--"}:${k.nodePort||"--"}`),K("code",null,`${s.composeFile||"--"} / ${s.composeService||"--"}`))),K(lu,{error:n.error,wide:!0})),K("div",{className:"pipeline-grid"},K(Kn,{title:"控制图",eyebrow:`${Ql.id||"pipeline"} / run ${pl?.status||"--"}`,className:"pipeline-wide-panel",loading:n.loading,actions:K("div",{className:"pipeline-toolbar"},K("select",{value:Ol,disabled:rl.length===0,onChange:(fl)=>{y(fl.target.value),$(""),j(""),U(ri()),J(di()),Z(!1),D(!1)},"data-testid":"pipeline-select"},rl.map((fl)=>K("option",{key:fl.id,value:fl.id},fl.id||fl.key))),K("select",{value:Bl,disabled:zl.length===0,onChange:(fl)=>{if($(fl.target.value),U(ri()),J(di()),Z(!1),D(!1),A)ff(fl.target.value,A)},"data-testid":"pipeline-run-select"},zl.map((fl)=>K("option",{key:fl.runId,value:fl.runId},Jj(zl,fl)))),K("button",{type:"button",className:"ghost-btn",disabled:ir.nodes.length===0,onClick:()=>zL(ir,`${Ql.id||"pipeline"}-${pl?.runId||"snapshot"}`),"data-testid":"pipeline-export-graph"},"导出渲染图"),K("button",{type:"button",className:"ghost-btn",disabled:tr.every((fl)=>fl.flow.nodes.length===0),onClick:()=>GX(tr),"data-testid":"pipeline-export-all-graphs"},"批量导出"))},h.length===0?K(qf,{title:"暂无控制图",text:"等待 D601 pipeline backend 返回 config.nodes / config.edges"}):K("div",{className:`pipeline-control-shell ${O?"detail-open":"detail-collapsed"}`,"data-testid":"pipeline-control-shell","data-sidebar-open":O?"true":"false"},K("div",{className:"pipeline-flow-frame","data-testid":"pipeline-react-flow"},K(MW,{nodes:ir.nodes,edges:ir.edges,nodeTypes:XS,edgeTypes:SS,fitView:!0,fitViewOptions:{padding:0.18},nodesDraggable:!1,nodesConnectable:!1,elementsSelectable:!0,minZoom:0.25,maxZoom:1.4,proOptions:{hideAttribution:!0},onNodeClick:(fl,Dl)=>{let Cl=String(Dl.id);if(j(Cl),U(ri()),Z(!0),Bl)ff(Bl,Cl)}},K(RW,{gap:22,size:1,color:"rgba(215, 161, 58, 0.24)"}),K(bW,{showInteractive:!1})),!O?K("button",{type:"button",className:"pipeline-sidecar-tab right",disabled:!A,onClick:()=>Z(!0),"data-testid":"pipeline-node-sidebar-toggle"},A?"展开 node 控制":"点击 node 展开控制"):null),O?K(IX,{activeRun:pl,pipelineRuns:zl,selectedRunId:c,onRunChange:(fl)=>{if($(fl),U(ri()),J(di()),A)ff(fl,A)},selectedNodeId:A,selectedNodeConfig:Il,selectedNodeRuntime:nu,control:F,onControlChange:(fl)=>U((Dl)=>({...Dl,...fl})),onFetch:()=>ff(),onAction:nt,onRaw:u,onCollapse:()=>Z(!1)}):null),K("div",{className:"pipeline-flow-summary"},K("span",null,`${ir.nodes.length} nodes`),K("span",null,`${ir.edges.length} edges`),K("span",null,`${rl.length} pipelines`),K("span",null,`source config+components(${M.length})`),K("span",null,`run ${pl?.runId||"--"}`),K("span",null,`score ${Fj(pl)}`),K("span",null,A?`selected ${A}`:"click node to control"))),K(gX,{epochs:zl,activeRun:pl,activePipeline:Ql,pipelineNodes:h,pipelineEdges:a,selection:L,detailOpen:E,onDetailOpenChange:D,runDetails:w,nodeDetails:hl,nodeDetailsState:Yu,ganttScale:q,onGanttScaleChange:T,onIntervalSelect:ii,onMarkerSelect:Au,onRunChange:(fl)=>{if($(fl),U(ri()),J(di()),D(!1),A)ff(fl,A)},onRaw:u}),K(Kn,{title:"观测指标",eyebrow:n.refreshedAt?`Updated ${iu(n.refreshedAt)}`:"Snapshot",loading:n.loading},K("div",{className:"metric-grid"},K(Mr,{label:"Health",value:n.health?.ok?"OK":"--",hint:n.health?.service||"D601 /health",tone:n.health?.ok?"ok":"warn"}),K(Mr,{label:"组件",value:Qr,hint:"components registry",tone:v?.registry?.ok===!1?"warn":"ok"}),K(Mr,{label:"Pipeline",value:rl.length,hint:`${h.length} nodes / ${a.length} edges`}),K(Mr,{label:"运行记录",value:Or,hint:`${Ml.succeeded||0} succeeded / ${Ml.running||0} running`}),K(Mr,{label:"OA 记录",value:Array.isArray(ul?.submissions)?ul.submissions.length:0,hint:ul?.runId||"latest run"}),K(Mr,{label:"Procedure",value:Array.isArray(ul?.procedureRuns)?ul.procedureRuns.length:0,hint:ul?.status||"no run"}),K(Mr,{label:"Score",value:Fj(pl),hint:pl?.runId||"selected epoch",tone:Tj(pl)})),K("div",{className:"panel-actions inline-actions"},K(rn,{title:"Pipeline Snapshot",data:v,onOpen:u,testId:"raw-pipeline-snapshot"}))),K(Kn,{title:"评分器",eyebrow:pl?.runId||"selected epoch",loading:n.loading},K(yX,{run:pl,onRaw:u})),K(Kn,{title:"MiniMax 限额",eyebrow:"model/minimax-m27 quota",loading:n.loading},K(kX,{quota:I,onRaw:u})),K(Kn,{title:"OA 事件流",eyebrow:"100% event-driven diagnostics",className:"pipeline-wide-panel",loading:n.loading},K(sX,{diagnostics:tl,onRaw:u})),K(Kn,{title:"组件矩阵",eyebrow:`${wu.length} classes`,loading:n.loading},wu.length===0?K(qf,{title:"暂无组件",text:"等待 D601 pipeline backend 返回 registry.components"}):K("div",{className:"component-strata"},wu.map((fl)=>K("article",{key:fl.name,className:"component-stratum"},K("span",null,fl.name),K("strong",null,fl.count)))),K("div",{className:"pipeline-component-list"},M.slice(0,12).map((fl)=>K("span",{key:fl.key,className:"data-chip"},K("b",null,fl.componentClass||"--"),K("span",null,fl.id||fl.key||"--"))))),K(Kn,{title:"Epoch 列表",eyebrow:`${zl.length}/${Or} preview`,loading:n.loading},zl.length===0?K(qf,{title:"暂无运行记录",text:"当前 pipeline 在 .state/pipeline-runs 中还没有 epoch。"}):K("div",{className:"pipeline-run-list"},zl.map((fl)=>{let Dl=String(fl?.runId||"")===Bl?pl:fl;return K("article",{key:fl.runId,className:`pipeline-run-card ${String(fl.runId||"")===Bl?"active":""}`,role:"button",tabIndex:0,onClick:()=>{$(String(fl.runId||"")),J(di())},onKeyDown:(Cl)=>{if(Cl.key==="Enter"||Cl.key===" ")$(String(fl.runId||"")),J(di())}},K("div",{className:"node-card-head"},K("strong",null,Jj(zl,fl)),K(u0,{status:fl.status},fl.status||"--")),K("div",{className:"docker-meta compact"},K("span",null,Dl?.pipelineId||"--"),K("span",null,`nodes ${Array.isArray(Dl?.nodes)?Dl.nodes.length:0}`),K("span",null,`oa ${Array.isArray(Dl?.submissions)?Dl.submissions.length:0}`),K("span",null,`procedures ${Array.isArray(Dl?.procedureRuns)?Dl.procedureRuns.length:0}`),K(tX,{run:Dl})),K("p",{className:"muted paragraph"},RA(Dl?.task)),K("span",{className:"pipeline-run-time"},Wl(Dl?.updatedAt)))}))),K(Kn,{title:"运行材料索引",eyebrow:pl?.runId||"selected epoch",className:"pipeline-wide-panel",loading:n.loading},K(vX,{activeRun:pl,onRaw:u}))))}var l6=Rl(Ju(),1);var _l=l6.default.createElement,{useEffect:aX}=l6.default,dA=l6.default.useState,Ej={id:"",sequenceNo:"",contractNo:"",name:"",currentStatus:"",pending:"",paymentStatus:"",notes:""};function oX({status:l,children:u}){let r=String(l||"unknown").toLowerCase();return _l("span",{className:`status-badge ${r}`},u||l||"unknown")}function eA({label:l,value:u,hint:r,tone:f}){return _l("article",{className:`metric-card ${f||""}`},_l("div",{className:"metric-label"},l),_l("div",{className:"metric-value"},u),_l("div",{className:"metric-hint"},r))}function Oj({title:l,eyebrow:u,actions:r,children:f,className:n,loading:i}){return _l("section",{className:`panel ${n||""}`},_l("div",{className:"panel-head"},_l("div",null,u?_l("p",{className:"panel-eyebrow"},u):null,_l(fu,{title:l,loading:i})),r?_l("div",{className:"panel-actions"},r):null),_l("div",{className:"panel-body"},f))}function XL({title:l,data:u,onOpen:r,testId:f}){return _l("button",{type:"button",className:"ghost-btn","data-testid":f,onClick:()=>r(l,u)},"查看原始JSON")}function YL({title:l,text:u}){return _l("div",{className:"empty-state"},_l("strong",null,l),_l("span",null,u))}function dX(l){return l?.runtime&&typeof l.runtime==="object"&&!Array.isArray(l.runtime)?l.runtime:{}}function eX(l){return l?.backend&&typeof l.backend==="object"&&!Array.isArray(l.backend)?l.backend:{}}function lY(l){return l?.repository&&typeof l.repository==="object"&&!Array.isArray(l.repository)?l.repository:{}}function Yy(l,u){return`${l}/microservices/project-manager/proxy${u}`}function uY(l){return{id:String(l.id||""),sequenceNo:l.sequenceNo===null||l.sequenceNo===void 0?"":String(l.sequenceNo),contractNo:String(l.contractNo||""),name:String(l.name||""),currentStatus:String(l.currentStatus||""),pending:String(l.pending||""),paymentStatus:String(l.paymentStatus||""),notes:String(l.notes||"")}}function rY(l){return{sequenceNo:l.sequenceNo===""?null:Number(l.sequenceNo),contractNo:String(l.contractNo||"").trim(),name:String(l.name||"").trim(),currentStatus:String(l.currentStatus||"").trim(),pending:String(l.pending||"").trim(),paymentStatus:String(l.paymentStatus||"").trim(),paymentRatio:String(l.paymentStatus||"").trim(),notes:String(l.notes||"").trim()}}function Zj(l){return String(l||"item").replace(/[^A-Za-z0-9_-]+/g,"-")}function fY(l){let u=new Uint8Array(l),r="",f=32768;for(let n=0;n<u.length;n+=f)r+=String.fromCharCode(...u.subarray(n,n+f));return btoa(r)}function nY({projects:l,activeId:u,onSelect:r,onRaw:f}){if(!l.length)return _l(YL,{title:"暂无项目",text:"可以从 Excel 导入,或用右侧表单新建项目。"});return _l("div",{className:"table-wrap project-manager-table","data-testid":"project-manager-table"},_l("table",null,_l("thead",null,_l("tr",null,_l("th",null,"序号"),_l("th",null,"合同号"),_l("th",null,"项目名称"),_l("th",null,"当前状况"),_l("th",null,"待完成"),_l("th",null,"付款"),_l("th",null,"其它"),_l("th",null,"操作"))),_l("tbody",null,l.map((n)=>_l("tr",{key:n.id,className:u===n.id?"active-row":"","data-testid":`project-manager-row-${Zj(n.id)}`},_l("td",null,n.sequenceNo??"--"),_l("td",null,_l("strong",null,n.contractNo||"--"),_l("code",null,n.id||"--")),_l("td",null,_l("strong",null,n.name||"--"),_l("span",{className:"muted block"},n.sourceFile||"--")),_l("td",null,n.currentStatus||"--"),_l("td",null,_l("span",{className:"preline"},n.pending||"--")),_l("td",null,_l(oX,{status:Number(n.paymentRatio||0)>=1?"online":"warn"},n.paymentStatus||"--")),_l("td",null,n.notes||"--"),_l("td",null,_l("div",{className:"inline-actions"},_l("button",{type:"button",className:"ghost-btn",onClick:()=>r(n),"data-testid":`project-manager-edit-${Zj(n.id)}`},"编辑"),_l(XL,{title:`Project ${n.contractNo||n.id}`,data:n,onOpen:f,testId:`raw-project-${Zj(n.id)}`}))))))))}function PL({microservices:l,onRaw:u,apiBaseUrl:r="/api"}){let f=l.find((E)=>E.id==="project-manager")||null,[n,i]=dA({loading:!1,saving:!1,importing:!1,exporting:!1,error:"",notice:"",health:null,list:null,refreshedAt:null}),[t,y]=dA({...Ej}),[c,$]=dA(""),[A,j]=dA("all"),{addNotification:F}=Xr();async function U(E=c,D=A){if(!f)return;i((Y)=>({...Y,loading:!0,error:""}));try{let Y=new URLSearchParams({pageSize:"200",status:D});if(E.trim())Y.set("q",E.trim());let[p,V]=await Promise.all([ml(`${r}/microservices/project-manager/health`),ml(Yy(r,`/api/projects?${Y.toString()}`))]);i((B)=>({...B,loading:!1,health:p,list:V,refreshedAt:new Date,error:""}))}catch(Y){i((p)=>({...p,loading:!1,error:El(Y,"Project Manager 加载失败")}))}}aX(()=>{U()},[f?.id,f?.runtime?.providerStatus]);async function N(E){E.preventDefault(),i((D)=>({...D,saving:!0,error:"",notice:""}));try{let D=rY(t);if(t.id)await ml(Yy(r,`/api/projects/${encodeURIComponent(t.id)}`),{method:"PUT",body:JSON.stringify(D)});else await ml(Yy(r,"/api/projects"),{method:"POST",body:JSON.stringify(D)});let Y=t.id?"项目已更新":"项目已创建";i((p)=>({...p,saving:!1,notice:Y})),F("success",Y),await U()}catch(D){i((Y)=>({...Y,saving:!1,error:El(D,"保存项目失败")}))}}async function W(){if(!t.id)return;if(!window.confirm(`删除项目 ${t.contractNo||t.name||t.id} ?`))return;i((E)=>({...E,saving:!0,error:"",notice:""}));try{await ml(Yy(r,`/api/projects/${encodeURIComponent(t.id)}`),{method:"DELETE"}),y({...Ej});let E="项目已删除";i((D)=>({...D,saving:!1,notice:E})),F("success",E),await U()}catch(E){i((D)=>({...D,saving:!1,error:El(E,"删除项目失败")}))}}async function L(E){let D=E.target.files?.[0];if(!D)return;i((Y)=>({...Y,importing:!0,error:"",notice:""}));try{let Y=fY(await D.arrayBuffer()),V=`Excel 已导入 ${(await ml(Yy(r,"/api/import/excel"),{method:"POST",body:JSON.stringify({fileName:D.name,contentBase64:Y,replace:!1})})).imported||0} 条项目`;i((B)=>({...B,importing:!1,notice:V})),F("success",V),E.target.value="",await U()}catch(Y){i((p)=>({...p,importing:!1,error:El(Y,"Excel 导入失败")}))}}async function J(){i((E)=>({...E,exporting:!0,error:""}));try{let E=await IU(Yy(r,"/api/projects/export.xlsx")),D=URL.createObjectURL(E),Y=document.createElement("a");Y.href=D,Y.download=`project-manager-${E7()}.xlsx`,document.body.appendChild(Y),Y.click(),Y.remove(),URL.revokeObjectURL(D),i((p)=>({...p,exporting:!1,notice:"Excel 已导出"}))}catch(E){i((D)=>({...D,exporting:!1,error:El(E,"Excel 导出失败")}))}}if(!f)return _l(YL,{title:"Project Manager 未登记",text:"请在 config.json 的 microservices 中登记用户服务 id=project-manager"});let w=dX(f),Q=lY(f),q=eX(f),T=Array.isArray(n.list?.projects)?n.list.projects:[],O=n.list?.summary||{},Z=n.health||{};return _l("div",{className:"project-manager-page","data-testid":"project-manager-page"},_l(Oj,{title:"项目管理工作台",eyebrow:"Main Server PostgreSQL 用户服务",loading:n.loading||n.exporting,actions:_l("div",{className:"panel-actions"},_l("button",{type:"button",className:"ghost-btn",disabled:n.loading,onClick:()=>U(),"data-testid":"project-manager-refresh-button"},n.loading?"刷新中":"刷新"),_l("button",{type:"button",className:"ghost-btn",disabled:n.exporting,onClick:J,"data-testid":"project-manager-export-button"},n.exporting?"导出中":"导出 Excel"),_l(XL,{title:"Project Manager 用户服务",data:f,onOpen:u,testId:"raw-project-manager-service"}))},_l("div",{className:"project-manager-hero"},_l(eA,{label:"项目总数",value:O.total??T.length,hint:`PG 表 ${Z.storage?.table||"project_manager_projects"}`,tone:"ok"}),_l(eA,{label:"进行中",value:O.active??"--",hint:"当前状态未完全完成"}),_l(eA,{label:"已完成",value:O.completed??"--",hint:"按 完成 关键字统计",tone:"ok"}),_l(eA,{label:"未全款",value:O.unpaid??"--",hint:"付款比例 < 1",tone:Number(O.unpaid||0)>0?"warn":"ok"})),_l(lu,{error:n.error}),n.notice?_l("div",{className:"form-success"},n.notice):null),_l("div",{className:"project-manager-hero"},_l("div",{className:"microservice-ref-card"},_l("span",null,"Repo"),_l("strong",null,Q.url||"--"),_l("code",null,Q.commitId||"--")),_l("div",{className:"microservice-ref-card"},_l("span",null,"Main Server Docker"),_l("strong",null,`${q.nodeBindHost||"--"}:${q.nodePort||"--"}`),_l("code",null,`${Q.composeService||"--"} / ${Q.containerName||"--"}`)),_l("div",{className:"microservice-ref-card"},_l("span",null,"Runtime"),_l("strong",null,w.providerName||f.providerId),_l("code",null,`Health ${Z.ok?"OK":"--"} / ${n.refreshedAt?iu(n.refreshedAt):"--"}`)),_l("div",{className:"microservice-ref-card"},_l("span",null,"Import Source"),_l("strong",null,"D601 WeChat Excel"),_l("code",null,"合作项目列表_I_20260309.xlsx"))),_l("div",{className:"project-manager-layout"},_l(Oj,{title:"项目清单",eyebrow:"CRUD + Excel Export",loading:n.loading||n.importing||n.exporting,actions:_l("div",{className:"inline-actions project-manager-filters"},_l("input",{value:c,onChange:(E)=>$(E.target.value),placeholder:"搜索合同号 / 项目名称 / 状态","data-testid":"project-manager-search"}),_l("select",{value:A,onChange:(E)=>{j(E.target.value),U(c,E.target.value)},"data-testid":"project-manager-status-filter"},_l("option",{value:"all"},"全部"),_l("option",{value:"active"},"进行中"),_l("option",{value:"completed"},"已完成"),_l("option",{value:"unpaid"},"未全款")),_l("button",{type:"button",className:"ghost-btn",onClick:()=>U(c,A)},"筛选"))},_l(nY,{projects:T,activeId:t.id,onSelect:(E)=>y(uY(E)),onRaw:u})),_l(Oj,{title:t.id?"编辑项目":"新建项目",eyebrow:"PostgreSQL Write Path",loading:n.saving||n.importing},_l("form",{className:"stack-form project-manager-form",onSubmit:N,"data-testid":"project-manager-form"},t.id?_l("label",null,"项目 ID",_l("input",{value:t.id,disabled:!0})):null,_l("label",null,"序号",_l("input",{type:"number",value:t.sequenceNo,onChange:(E)=>y((D)=>({...D,sequenceNo:E.target.value}))})),_l("label",null,"合同号",_l("input",{value:t.contractNo,onChange:(E)=>y((D)=>({...D,contractNo:E.target.value})),required:!0})),_l("label",null,"项目名称",_l("input",{value:t.name,onChange:(E)=>y((D)=>({...D,name:E.target.value})),required:!0})),_l("label",null,"当前状况",_l("textarea",{value:t.currentStatus,onChange:(E)=>y((D)=>({...D,currentStatus:E.target.value}))})),_l("label",null,"待完成",_l("textarea",{value:t.pending,onChange:(E)=>y((D)=>({...D,pending:E.target.value}))})),_l("label",null,"付款情况",_l("input",{value:t.paymentStatus,onChange:(E)=>y((D)=>({...D,paymentStatus:E.target.value})),placeholder:"例如 1 / 0.5 / 50%"})),_l("label",null,"其它",_l("input",{value:t.notes,onChange:(E)=>y((D)=>({...D,notes:E.target.value}))})),_l("div",{className:"inline-actions"},_l("button",{type:"submit",className:"primary-btn",disabled:n.saving,"data-testid":"project-manager-save-button"},n.saving?"保存中":t.id?"保存修改":"创建项目"),_l("button",{type:"button",className:"ghost-btn",onClick:()=>y({...Ej})},"清空"),t.id?_l("button",{type:"button",className:"danger-btn",disabled:n.saving,onClick:W,"data-testid":"project-manager-delete-button"},"删除"):null)),_l("div",{className:"project-manager-import"},_l("p",{className:"muted paragraph"},"浏览器只访问 UniDesk frontend;后端通过同源用户服务代理写入主 PostgreSQL,不暴露 4233 公网端口。"),_l("label",{className:"file-import"},n.importing?"导入中...":"导入 Excel",_l("input",{type:"file",accept:".xlsx",onChange:L,disabled:n.importing,"data-testid":"project-manager-import-input"}))))))}var f6=Rl(Ju(),1);var Fl=f6.default.createElement,{useEffect:iY}=f6.default,lf=f6.default.useState;function tY({status:l,children:u}){let r=String(l||"unknown").toLowerCase();return Fl("span",{className:`status-badge ${r}`},u||l||"unknown")}function u6({label:l,value:u,hint:r,tone:f}){return Fl("article",{className:`metric-card ${f||""}`},Fl("div",{className:"metric-label"},l),Fl("div",{className:"metric-value"},u),Fl("div",{className:"metric-hint"},r))}function pj({title:l,eyebrow:u,actions:r,children:f,className:n,loading:i}){return Fl("section",{className:`panel ${n||""}`},Fl("div",{className:"panel-head"},Fl("div",null,u?Fl("p",{className:"panel-eyebrow"},u):null,Fl(fu,{title:l,loading:i})),r?Fl("div",{className:"panel-actions"},r):null),Fl("div",{className:"panel-body"},f))}function CL({title:l,data:u,onOpen:r,testId:f}){return Fl("button",{type:"button",className:"ghost-btn","data-testid":f,onClick:()=>r(l,u)},"查看原始JSON")}function r6({title:l,text:u}){return Fl("div",{className:"empty-state"},Fl("strong",null,l),Fl("span",null,u))}function yY(l){return l?.runtime&&typeof l.runtime==="object"&&!Array.isArray(l.runtime)?l.runtime:{}}function cY(l){return l?.backend&&typeof l.backend==="object"&&!Array.isArray(l.backend)?l.backend:{}}function _Y(l){return l?.repository&&typeof l.repository==="object"&&!Array.isArray(l.repository)?l.repository:{}}function hL(l){return String(l).replace(/[^a-zA-Z0-9_-]/g,"_")}function $Y(l){if(!Number.isFinite(l))return"--";return`${l.toFixed(1)}%`}function Py(l,u){return`${l}/microservices/todo-note/proxy${u}`}function RL(l){return l.reduce((u,r)=>{let f=RL(Array.isArray(r.children)?r.children:[]),n=Boolean(r.completed);return{total:u.total+1+f.total,completed:u.completed+(n?1:0)+f.completed,active:u.active+(n?0:1)+f.active}},{total:0,completed:0,active:0})}function Bj(l,u){let r=u==="all"||(u==="completed"?Boolean(l.completed):!l.completed),f=Array.isArray(l.children)?l.children:[];return r||f.some((n)=>Bj(n,u))}function ML(l){return Array.isArray(l?.instances)?l.instances:[]}function Hj(l,u){for(let r of l){if(r?.id===u)return Array.isArray(r.children)?r.children:[];let f=Hj(Array.isArray(r?.children)?r.children:[],u);if(f.length>0)return f}return[]}function xL({microservices:l,onRaw:u,apiBaseUrl:r="/api"}){let f=l.find((o)=>o.id==="todo-note")||null,[n,i]=lf(null),[t,y]=lf(null),[c,$]=lf(""),[A,j]=lf(null),[F,U]=lf("all"),[N,W]=lf(13),[L,J]=lf(""),[w,Q]=lf(""),[q,T]=lf(""),[O,Z]=lf(""),[E,D]=lf(""),[Y,p]=lf(!1),[V,B]=lf(""),[m,X]=lf(null),S=ML(t),b=RL(Array.isArray(A?.todos)?A.todos:[]),z=f?yY(f):{},P=f?_Y(f):{},s=f?cY(f):{};async function k(o=c){let[ql,pl]=await Promise.all([ml(`${r}/microservices/todo-note/health`),ml(Py(r,"/api/instances"))]);i(ql),y(pl);let Bl=ML(pl),Il=Bl.some((nu)=>nu.id===o)?o:Bl[0]?.id||"";return $(Il),Il}async function v(o=c){if(!o){j(null);return}let ql=await ml(Py(r,`/api/instances/${encodeURIComponent(o)}`));j(ql)}async function tl(o=c){if(!f)return;p(!0),B("");try{let ql=await k(o);await v(ql),X(new Date)}catch(ql){B(El(ql,"Todo Note 加载失败"))}finally{p(!1)}}async function I(o){if(!c)return null;B("");try{let ql=await ml(Py(r,`/api/instances/${encodeURIComponent(c)}/actions`),{method:"POST",body:JSON.stringify({action:o})});return j(ql),await k(c),ql}catch(ql){return B(El(ql,"Todo 操作失败")),null}}async function M(o){o.preventDefault();let ql=L.trim();if(!ql)return;p(!0),B("");try{let pl=await ml(Py(r,"/api/instances"),{method:"POST",body:JSON.stringify({name:ql})});J(""),await tl(pl.id)}catch(pl){B(El(pl,"创建清单失败"))}finally{p(!1)}}async function rl(o){if(!window.confirm("确认删除这个 Todo Note 清单?"))return;p(!0),B("");try{await ml(Py(r,`/api/instances/${encodeURIComponent(o)}`),{method:"DELETE"}),await tl(c===o?"":c)}catch(ql){B(El(ql,"删除清单失败"))}finally{p(!1)}}async function cl(o){o.preventDefault();let ql=w.trim();if(!ql)return;Q(""),await I({type:"addTodo",title:ql})}async function $l(o){if(!c)return;B("");try{let ql=await ml(Py(r,`/api/instances/${encodeURIComponent(c)}/${o}`),{method:"POST",body:JSON.stringify({})});j(ql),await k(c)}catch(ql){B(El(ql,`${o} 失败`))}}function Tl(o){T(o.id),Z(String(o.title||""))}async function Ql(o){let ql=O.trim();if(T(""),Z(""),ql)await I({type:"updateTodoTitle",todoId:o,title:ql})}async function Ol(o){let pl=window.prompt("新增子任务标题")?.trim();if(!pl)return;let Bl=Hj(Array.isArray(A?.todos)?A.todos:[],o),Il=new Set(Bl.map((Qr)=>Qr.id)),nu=await I({type:"addTodo",title:pl,parentId:o,targetIndex:0});if(!nu)return;let Ml=Hj(Array.isArray(nu?.todos)?nu.todos:[],o),wu=Ml.find((Qr)=>!Il.has(Qr.id));if(wu&&Ml[0]?.id!==wu.id)await I({type:"moveTodo",todoId:wu.id,targetParentId:o,targetIndex:0})}async function h(o,ql){if(!E)return;let pl={type:"moveTodo",todoId:E,targetIndex:ql};if(o)pl.targetParentId=o;D(""),await I(pl)}if(iY(()=>{tl()},[f?.id,f?.runtime?.providerStatus]),!f)return Fl(r6,{title:"Todo Note 未登记",text:"请在 config.json 的 microservices 中登记用户服务 id=todo-note"});let a=S.find((o)=>o.id===c)||null,ul=Array.isArray(A?.todos)?A.todos:[],zl=ul.map((o,ql)=>({todo:o,index:ql})).filter((o)=>Bj(o.todo,F));return Fl("div",{className:"todo-note-page","data-testid":"todo-note-page"},Fl(pj,{title:"Todo Note 工作台",eyebrow:"Main Server 用户服务",loading:Y,actions:Fl("div",{className:"panel-actions"},Fl("button",{type:"button",className:"ghost-btn",disabled:Y,onClick:()=>tl(c),"data-testid":"todo-note-refresh-button"},Y?"刷新中":"刷新"),Fl(CL,{title:"Todo Note 用户服务",data:f,onOpen:u,testId:"raw-todo-note-service"}))},Fl("div",{className:"todo-note-hero"},Fl("div",null,Fl("div",{className:"node-version-line"},Fl(tY,{status:z.providerStatus==="online"?"online":"warn"},z.providerStatus||"unknown"),Fl("span",null,f.providerId),Fl("span",null,s.public?"公网暴露":"仅 UniDesk frontend 代理访问"),Fl("span",null,n?.ok?"Health OK":"Health --")),Fl("p",{className:"muted paragraph"},f.description)),Fl("div",{className:"microservice-ref-card"},Fl("span",null,"Repo"),Fl("strong",null,P.url||"--"),Fl("code",null,P.commitId||"--")),Fl("div",{className:"microservice-ref-card"},Fl("span",null,"Main Server Docker"),Fl("strong",null,`${s.nodeBindHost||"--"}:${s.nodePort||"--"}`),Fl("code",null,`${P.composeService||"--"} / ${P.containerName||"--"}`))),Fl(lu,{error:V,wide:!0})),Fl("div",{className:"todo-note-layout"},Fl(pj,{title:"清单",eyebrow:`${S.length} Instances`,className:"todo-list-panel",loading:Y},Fl("form",{className:"todo-create-list",onSubmit:M},Fl("input",{placeholder:"新清单名称",value:L,onChange:(o)=>J(o.target.value),"aria-label":"新清单名称"}),Fl("button",{type:"submit",className:"ghost-btn",disabled:Y||!L.trim()},"创建")),S.length===0?Fl(r6,{title:"暂无清单",text:"迁移或创建清单后会出现在这里"}):Fl("div",{className:"todo-instance-list"},S.map((o)=>Fl("button",{key:o.id,type:"button",className:`todo-instance-row ${c===o.id?"active":""}`,onClick:()=>{$(o.id),v(o.id)},"data-testid":`todo-instance-${hL(o.id)}`},Fl("strong",null,o.name),Fl("span",null,`${o.completedCount??0}/${o.todoCount??0} 完成`),Fl("code",null,o.id))))),Fl("div",{className:"todo-main-stack"},Fl(pj,{title:a?.name||"待选择清单",eyebrow:m?`Updated ${iu(m)}`:"Todo Tree",loading:Y,actions:A?Fl("div",{className:"panel-actions"},Fl("button",{type:"button",className:"ghost-btn",onClick:()=>I({type:"renameInstance",name:window.prompt("清单新名称",A.name)||A.name})},"重命名"),Fl("button",{type:"button",className:"ghost-btn danger",onClick:()=>rl(c)},"删除清单"),Fl(CL,{title:`Todo Instance ${c}`,data:A,onOpen:u,testId:"raw-todo-instance"})):null},!A?Fl(r6,{title:"未选择清单",text:"左侧选择一个 Todo Note 清单"}):Fl("div",{className:"todo-workbench",style:{"--todo-font-size":`${N}px`}},Fl("div",{className:"todo-toolbar"},Fl("form",{className:"todo-add-form",onSubmit:cl},Fl("input",{placeholder:"新增根任务",value:w,onChange:(o)=>Q(o.target.value),"aria-label":"新增根任务"}),Fl("button",{type:"submit",className:"ghost-btn",disabled:!w.trim()},"新增")),Fl("div",{className:"todo-filter-strip"},["all","active","completed"].map((o)=>Fl("button",{key:o,type:"button",className:`todo-filter ${F===o?"active":""}`,onClick:()=>U(o)},o==="all"?"全部":o==="active"?"未完成":"已完成"))),Fl("div",{className:"todo-toolbar-actions"},Fl("button",{type:"button",className:"ghost-btn",onClick:()=>I({type:"setAllTodosExpanded",expanded:!0})},"全部展开"),Fl("button",{type:"button",className:"ghost-btn",onClick:()=>I({type:"setAllTodosExpanded",expanded:!1})},"全部收起"),Fl("button",{type:"button",className:"ghost-btn",onClick:()=>$l("undo")},"撤销"),Fl("button",{type:"button",className:"ghost-btn",onClick:()=>$l("redo")},"重做"),Fl("label",{className:"todo-font-control"},"字号",Fl("input",{type:"range",min:11,max:18,value:N,onChange:(o)=>W(Number(o.target.value))})))),Fl("div",{className:"todo-stats-grid"},Fl(u6,{label:"总任务",value:b.total,hint:`${S.length} lists`}),Fl(u6,{label:"已完成",value:b.completed,hint:`${$Y(b.total?b.completed/b.total*100:0)}`,tone:"ok"}),Fl(u6,{label:"未完成",value:b.active,hint:F==="active"?"当前筛选":"active tasks",tone:b.active>0?"warn":"ok"}),Fl(u6,{label:"历史指针",value:A.historyPointer??0,hint:"undo / redo"})),Fl("div",{className:"todo-root-drop",onDragOver:(o)=>o.preventDefault(),onDrop:(o)=>{o.preventDefault(),h(null,ul.length)}},"拖到这里可移为根任务末尾"),Fl("div",{className:"todo-tree","data-testid":"todo-note-tree"},zl.length===0?Fl(r6,{title:"没有匹配任务",text:"调整筛选或新增任务"}):zl.map(({todo:o,index:ql})=>Fl(bL,{key:o.id,todo:o,depth:0,parentId:null,index:ql,siblingCount:ul.length,filter:F,editingId:q,editingTitle:O,setEditingTitle:Z,beginEdit:Tl,saveEdit:Ql,applyTodoAction:I,addChild:Ol,dragTodoId:E,setDragTodoId:D,dropTodo:h}))))))))}function bL(l){let{todo:u,depth:r,parentId:f,index:n,siblingCount:i,filter:t,editingId:y,editingTitle:c,setEditingTitle:$,beginEdit:A,saveEdit:j,applyTodoAction:F,addChild:U,dragTodoId:N,setDragTodoId:W,dropTodo:L}=l,J=Array.isArray(u.children)?u.children:[],w=J.map((T,O)=>({child:T,childIndex:O})).filter((T)=>Bj(T.child,t)),Q=y===u.id,q=f||null;return Fl("div",{className:"todo-row-wrap"},Fl("article",{className:`todo-row ${u.completed?"completed":""} ${N===u.id?"dragging":""}`,style:{"--todo-depth":r},draggable:!0,onDragStart:(T)=>{W(u.id),T.dataTransfer.effectAllowed="move"},onDragOver:(T)=>T.preventDefault(),onDrop:(T)=>{T.preventDefault(),L(u.id,J.length)},"data-testid":`todo-row-${hL(u.id)}`},Fl("button",{type:"button",className:"todo-expand",disabled:J.length===0,onClick:()=>F({type:"toggleTodoExpanded",todoId:u.id})},J.length===0?"·":u.expanded?"▾":"▸"),Fl("input",{type:"checkbox",checked:Boolean(u.completed),onChange:()=>F({type:"toggleTodoCompleted",todoId:u.id}),"aria-label":`完成 ${u.title}`}),Fl("div",{className:"todo-title-cell",onDoubleClick:()=>A(u)},Q?Fl("div",{className:"todo-edit-inline"},Fl("input",{value:c,autoFocus:!0,onChange:(T)=>$(T.target.value),onKeyDown:(T)=>{if(T.key==="Enter")j(u.id);if(T.key==="Escape")A({id:"",title:""})}}),Fl("button",{type:"button",className:"ghost-btn",onClick:()=>j(u.id)},"保存")):Fl("strong",null,u.title||"Untitled"),Fl("div",{className:"todo-meta-line"},Fl("span",null,`子项 ${J.length}`),Fl("span",null,`更新 ${Wl(u.updatedAt)}`),u.reminderAt?Fl("span",{className:"todo-reminder"},`提醒 ${Wl(u.reminderAt)}`):Fl("span",null,"无提醒"))),Fl("input",{className:"todo-reminder-input",type:"datetime-local",value:D6(u.reminderAt),onChange:(T)=>F({type:"setTodoReminder",todoId:u.id,reminderAt:O7(T.target.value)})}),Fl("div",{className:"todo-row-actions"},Fl("button",{type:"button",className:"ghost-btn",onClick:()=>A(u)},"编辑"),Fl("button",{type:"button",className:"ghost-btn",onClick:()=>U(u.id)},"子项"),Fl("button",{type:"button",className:"ghost-btn",disabled:n<=0,onClick:()=>F({type:"moveTodo",todoId:u.id,...q?{targetParentId:q}:{},targetIndex:n-1})},"上移"),Fl("button",{type:"button",className:"ghost-btn",disabled:n<=0,onClick:()=>F({type:"moveTodo",todoId:u.id,...q?{targetParentId:q}:{},targetIndex:0})},"置顶"),Fl("button",{type:"button",className:"ghost-btn",disabled:n>=i-1,onClick:()=>F({type:"moveTodo",todoId:u.id,...q?{targetParentId:q}:{},targetIndex:n+1})},"下移"),Fl("button",{type:"button",className:"ghost-btn",disabled:!f,onClick:()=>F({type:"moveTodo",todoId:u.id,targetIndex:9999})},"提升"),Fl("button",{type:"button",className:"ghost-btn danger",onClick:()=>F({type:"deleteTodo",todoId:u.id})},"删除"))),u.expanded&&w.length>0?Fl("div",{className:"todo-children"},w.map(({child:T,childIndex:O})=>Fl(bL,{key:T.id,todo:T,depth:r+1,parentId:u.id,index:O,siblingCount:J.length,filter:t,editingId:y,editingTitle:c,setEditingTitle:$,beginEdit:A,saveEdit:j,applyTodoAction:F,addChild:U,dragTodoId:N,setDragTodoId:W,dropTodo:L}))):null)}var vL=Rl(Ju(),1),f0=vL.default.createElement;function sL({title:l,items:u,actions:r,className:f,testId:n}){let i=Array.isArray(u)?u:[];return f0("section",{className:`top-status-bar ${f||""}`,"data-testid":n},f0("div",{className:"top-status-main"},l?f0("strong",{className:"top-status-title"},l):null,f0("div",{className:"top-status-chips"},i.map((t,y)=>f0("span",{key:t?.key||`${t?.label||"status"}-${y}`,className:`top-status-chip ${t?.tone||""}`,"data-testid":t?.testId},t?.label?f0("b",null,t.label):null,f0("span",null,t?.value??"--"))))),r?f0("div",{className:"top-status-actions"},r):null)}var y_=Rl(Ju(),1);var wl=y_.default.createElement,{useEffect:AY,useMemo:jY}=y_.default,FY=y_.default.useState;function kL({status:l,children:u,title:r}){let f=String(l||"unknown").toLowerCase();return wl("span",{className:`status-badge ${f}`,title:r},u||l||"unknown")}function n6({label:l,value:u,hint:r,tone:f}){return wl("article",{className:`metric-card ${f||""}`},wl("div",{className:"metric-label"},l),wl("div",{className:"metric-value"},u),wl("div",{className:"metric-hint"},r))}function Dj({title:l,eyebrow:u,actions:r,children:f,className:n,loading:i}){return wl("section",{className:`panel ${n||""}`},wl("div",{className:"panel-head"},wl("div",null,u?wl("p",{className:"panel-eyebrow"},u):null,wl(fu,{title:l,loading:i})),r?wl("div",{className:"panel-actions"},r):null),wl("div",{className:"panel-body"},f))}function gL({title:l,data:u,onOpen:r,testId:f}){return wl("button",{type:"button",className:"ghost-btn","data-testid":f,onClick:()=>r?.(l,u)},"查看原始JSON")}function Vj({title:l,text:u}){return wl("div",{className:"empty-state"},wl("strong",null,l),wl("span",null,u))}function Cy(l){return Array.isArray(l)?l:[]}function Sj(l){return l&&typeof l==="object"&&!Array.isArray(l)?l:{}}function JY(l){return l?.runtime&&typeof l.runtime==="object"&&!Array.isArray(l.runtime)?l.runtime:{}}function UY(l,u){return`${l}/microservices/k3sctl-adapter/proxy${u}`}function NY(l){return l.find((u)=>String(u?.id||"")==="k3sctl-adapter")||null}function QY(l){if(l?.healthy===!0)return"online";if(String(l?.role||"")==="standby")return"warn";return"failed"}function wY(l){return l?.healthy===!0?"online":"failed"}function qY(l){if(l===!0)return"YES";if(l===!1)return"NO";return"--"}function WY(l){return Array.from(new Set(l.flatMap((u)=>Cy(u?.expectedNodeIds).map((r)=>String(r))))).filter(Boolean).sort()}function LY(l){let u=l.find((r)=>r?.id==="code-queue")||l[0];return String(u?.activeInstanceId||"--")}function GY(l){return wl("article",{key:l?.id||l?.nodeId,className:"k3s-instance-card"},wl("div",{className:"node-card-head"},wl("strong",null,l?.nodeId||l?.id||"--"),wl(kL,{status:QY(l)},l?.healthy?"HEALTHY":"DEGRADED")),wl("div",{className:"k3s-instance-role"},wl("span",null,String(l?.role||"worker").toUpperCase()),wl("code",null,l?.id||"--")),wl("dl",{className:"k3s-kv"},wl("dt",null,"Base URL"),wl("dd",null,wl("code",null,l?.baseUrl||"--")),wl("dt",null,"Proxy"),wl("dd",null,l?.proxyMode||"--"),wl("dt",null,"Health"),wl("dd",null,`${l?.upstreamStatus??"--"} / ${l?.status||"unknown"}`),wl("dt",null,"Checked"),wl("dd",null,Wl(l?.checkedAt))))}function TY(l,u){let r=Cy(l?.instances),f=Sj(l?.active);return wl(Dj,{key:l?.id||"service",title:l?.id||"managed-service",eyebrow:`${l?.namespace||"unidesk"} / k3s managed service`,className:"k3s-service-panel",actions:wl(gL,{title:`k3s service ${l?.id||""}`,data:l,onOpen:u,testId:`raw-k3s-service-${l?.id||"unknown"}`})},wl("div",{className:"k3s-service-summary"},wl("div",null,wl("span",null,"状态"),wl(kL,{status:wY(l)},l?.status||"unknown")),wl("div",null,wl("span",null,"Active"),wl("strong",null,l?.activeInstanceId||"--")),wl("div",null,wl("span",null,"Single Writer"),wl("strong",null,qY(l?.singleWriter))),wl("div",null,wl("span",null,"Active Health"),wl("strong",null,f?.upstreamStatus??"--"))),r.length===0?wl(Vj,{title:"暂无 k3s 实例",text:"adapter 没有返回该服务的 endpoint 列表"}):wl("div",{className:"k3s-instance-grid"},r.map(GY)))}function IL({microservices:l,onRaw:u,apiBaseUrl:r,onNavigate:f}){let n=NY(Array.isArray(l)?l:[]),i=JY(n),[t,y]=FY({loading:!1,error:"",data:null,refreshedAt:null});async function c(){y((w)=>({...w,loading:!0,error:""}));try{let w=await ml(UY(r,"/api/control-plane"));y({loading:!1,error:"",data:w,refreshedAt:new Date})}catch(w){y((Q)=>({...Q,loading:!1,error:El(w,"加载 k3s 控制平面失败")}))}}AY(()=>{c()},[r]);let $=jY(()=>Cy(t.data?.services),[t.data]),A=WY($),j=$.filter((w)=>w?.healthy===!0).length,F=$.reduce((w,Q)=>w+Cy(Q?.instances).length,0),U=$.reduce((w,Q)=>w+Cy(Q?.instances).filter((q)=>q?.healthy===!0).length,0),N=LY($),W=Sj(t.data?.kubectl),L=Sj(t.data?.kubeApiProxy),J=Cy(t.data?.manifestPaths).map((w)=>String(w));if(!n)return wl(Vj,{title:"k3sctl-adapter 未登记",text:"请在 config.json 的 microservices 中登记 id=k3sctl-adapter,并通过该微服务连接 k3s 控制平面。"});return wl("div",{className:"k3s-page","data-testid":"k3sctl-page"},wl(Dj,{title:"k3s Control Plane",eyebrow:"Managed by k3sctl-adapter",className:"k3s-hero-panel",loading:t.loading,actions:wl(y_.default.Fragment,null,wl("button",{type:"button",className:"ghost-btn",onClick:c,disabled:t.loading,"data-testid":"k3s-refresh-button"},t.loading?"刷新中":"刷新"),f?wl("button",{type:"button",className:"ghost-btn",onClick:()=>f("apps","code-queue"),"data-testid":"k3s-open-code-queue"},"打开 Code Queue"):null,wl(gL,{title:"k3sctl-adapter microservice",data:n,onOpen:u,testId:"raw-k3s-adapter"}))},wl("div",{className:"k3s-hero"},wl("div",{className:"k3s-orb","aria-hidden":"true"},wl("span",null,"k3s")),wl("div",{className:"k3s-hero-copy"},wl("p",{className:"eyebrow"},"D601 native control plane"),wl("h2",null,"UniDesk 只管理 adapter;业务微服务交给 k3s 标准服务路由"),wl("p",{className:"muted paragraph"},"Code Queue 的前端/API 请求进入 k3sctl-adapter,再由 adapter 转发到 k3s active service。provider-gateway 只用于维护 adapter 和节点诊断,不再直接管理 Code Queue 容器。"),wl("div",{className:"k3s-route-strip"},wl("span",null,"NO FALLBACK"),wl("code",null,t.data?.runtimePath||"frontend -> backend-core -> k3sctl-adapter")))),wl("div",{className:"metric-grid"},wl(n6,{label:"控制面",value:t.data?.clusterId||"D601",hint:`adapter ${i.providerStatus||"unknown"}`,tone:i.providerStatus==="online"?"ok":"warn"}),wl(n6,{label:"代管服务",value:$.length,hint:`${j}/${$.length||0} healthy`,tone:j===$.length&&$.length>0?"ok":"warn"}),wl(n6,{label:"节点",value:A.join(" / ")||"--",hint:"expected k3s nodes"}),wl(n6,{label:"实例",value:`${U}/${F}`,hint:`active ${N}`,tone:U===F&&F>0?"ok":"warn"})),wl("div",{className:"k3s-control-plane-grid"},wl("article",{className:"k3s-control-plane-card"},wl("span",null,"service proxy"),wl("strong",null,L.configured===!0?"K8S API PROXY":"PROXY DEGRADED"),wl("p",null,L.configured===!0?`${L.mode||"kubernetes-api-service-proxy"} via ${L.connectHost||"--"}`:"adapter 必须通过 k8s API service proxy 访问业务服务,不回退到业务容器直连。")),wl("article",{className:"k3s-control-plane-card"},wl("span",null,"manifests"),wl("strong",null,J.length||"--"),wl("p",null,J.join(" / ")||"未配置 manifest")),wl("article",{className:"k3s-control-plane-card"},wl("span",null,"cluster snapshot"),wl("strong",null,W.enabled===!0?W.ok===!0?"KUBECTL OK":"KUBECTL DEGRADED":"API ONLY"),wl("p",null,W.enabled===!0?`nodes ${W.nodeCount??"--"}`:"控制面页面以 adapter 返回的 k8s service proxy 状态为准;kubectl 只作为可选快照。"))),t.error?wl(lu,{error:t.error}):null,t.refreshedAt?wl("p",{className:"muted paragraph"},`最近刷新 ${iu(t.refreshedAt)}`):null),$.length===0?wl(Dj,{title:"代管服务",eyebrow:"k3s services",loading:t.loading},wl(Vj,{title:"暂无 k3s 服务",text:"等待 k3sctl-adapter 返回 /api/services;Code Queue 应显示 D601 scheduler/read/write 服务实例。"})):$.map((w)=>TY(w,u)))}var c_=Rl(Ju(),1);var ou=c_.default.createElement;function aL({onClose:l}){let{notifications:u,removeNotification:r,clearNotifications:f}=Xr(),n=c_.default.useRef(null);if(c_.default.useEffect(()=>{let i=(t)=>{if(n.current&&!n.current.contains(t.target))l()};return document.addEventListener("mousedown",i),()=>document.removeEventListener("mousedown",i)},[l]),u.length===0)return ou("div",{className:"notification-popup",ref:n},ou("div",{className:"notification-popup-header"},ou("span",null,"通知"),ou("button",{className:"notification-popup-close",onClick:l},"×")),ou("div",{className:"notification-popup-empty"},"暂无通知"));return ou("div",{className:"notification-popup",ref:n},ou("div",{className:"notification-popup-header"},ou("span",null,`通知 (${u.length})`),ou("div",{className:"notification-popup-actions"},ou("button",{className:"notification-popup-clear",onClick:f},"清空"),ou("button",{className:"notification-popup-close",onClick:l},"×"))),ou("div",{className:"notification-popup-list"},u.slice().reverse().map((i)=>ou("div",{key:i.id,className:`notification-item ${i.type}`},ou("span",{className:"notification-item-icon"},i.type==="success"?"✓":"×"),ou("span",{className:"notification-item-message"},i.message),ou("button",{className:"notification-item-dismiss",onClick:()=>r(i.id)},"×")))))}function oL({notification:l}){let{removeNotification:u}=Xr();return c_.default.useEffect(()=>{let r=setTimeout(()=>{u(l.id)},3000);return()=>clearTimeout(r)},[l.id,u]),ou("div",{className:`notification-banner ${l.type}`,role:"alert"},ou("span",{className:"notification-banner-icon"},l.type==="success"?"✓":"×"),ou("span",{className:"notification-banner-message"},l.message),ou("button",{className:"notification-banner-dismiss",onClick:()=>u(l.id)},"×"))}function $G(l,u){let r=document.getElementById("root")?.getAttribute(l);if(!r)return u;try{let f=JSON.parse(r);return typeof f==="object"&&f!==null&&!Array.isArray(f)?f:u}catch{return u}}var sl=$G("data-config",{apiBaseUrl:"/api",authUsername:"admin"}),dL=sl.environment&&typeof sl.environment==="object"?sl.environment:{},mY=$G("data-codex-overview",null),_=i0.default.createElement,{useEffect:En,useMemo:A_}=i0.default,kl=i0.default.useState,Cj=i0.default.createContext(!1),Xf=bQ(p3),KY={id:"code-queue",name:"Code Queue",providerId:"D601",description:"Code Queue",repository:{containerName:"k3s:code-queue"},backend:{nodeBaseUrl:"k3s://code-queue",nodeBindHost:"k3s://unidesk/code-queue",nodePort:4222,proxyMode:"k3sctl-adapter-http",public:!1},deployment:{mode:"k3sctl-managed",adapterServiceId:"k3sctl-adapter",k3sServiceId:"code-queue"},runtime:{orchestrator:"k3sctl",providerStatus:"loading",providerName:"D601"}};function eL(){return typeof document>"u"||document.visibilityState!=="hidden"}function AG(l){return l?.environment==="dev"||l?.namespace==="unidesk-dev"}function zY(l){let u=typeof l==="string"?l:"";return u.length>=7?u.slice(0,7):u||"unknown"}function EY(l,u){if(l==="ops"&&u==="status")return 5000;if(l==="nodes"&&u==="monitor")return 5000;if(l==="tasks"&&(u==="dispatch"||u==="scheduled"||u==="pending"))return 5000;if(l==="nodes"||l==="ops")return 1e4;if(l==="apps")return 15000;if(l==="tasks")return 15000;return 30000}async function OY(l){if(!l?._summaryOnly||!l?.id)return l;return(await ml(`${sl.apiBaseUrl}/tasks/${encodeURIComponent(String(l.id))}`))?.task||l}function j_(l){return l?._summaryOnly?{...l,_loadRaw:()=>OY(l)}:l}function fi(l){if(!Number.isFinite(l))return"--";let u=Math.max(0,l);if(u===0)return"0s";if(u<0.01)return"<0.01s";if(u<0.1)return`${u.toFixed(2)}s`;if(u<1)return`${u.toFixed(1)}s`;if(u<10&&!Number.isInteger(u))return`${u.toFixed(1)}s`;if(u<60)return`${Math.round(u)}s`;let r=Math.floor(u);if(r<3600)return`${Math.floor(r/60)}m ${r%60}s`;return`${Math.floor(r/3600)}h ${Math.floor(r%3600/60)}m`}function Wf(l){let u=Number(l);if(!Number.isFinite(u))return"--";if(u<1)return`${Math.max(0,u).toFixed(1)}ms`;if(u<10)return`${u.toFixed(1)}ms`;if(u<1000)return`${Math.round(u)}ms`;return fi(u/1000)}function Nr(l){let u=Number(l);if(!Number.isFinite(u)||u<=0)return"--";let r=["B","KB","MB","GB","TB"],f=u,n=0;while(f>=1024&&n<r.length-1)f/=1024,n+=1;return`${f.toFixed(n===0?0:1)} ${r[n]}`}function n0(l){let u=Number(l);return Number.isFinite(u)?`${Math.max(0,Math.min(100,u)).toFixed(1)}%`:"--"}function ZY(l){let u=Number(l);return Number.isFinite(u)?`${Math.max(0,u).toFixed(1)}%`:"--"}function Xj(l){let u=Number(l);if(!Number.isFinite(u)||u<=0)return"0 B/s";return`${Nr(u)}/s`}function vl(l,u=0){let r=Number(l);return Number.isFinite(r)?r:u}function hy(l){return["queued","dispatched","running"].includes(String(l?.status||"").toLowerCase())}function Mj(l){if(!l)return"--";let u=new Date(l);if(Number.isNaN(u.getTime()))return"--";return fi(Math.max(0,Math.floor((Date.now()-u.getTime())/1000)))}function On(l){if(!l)return null;let u=new Date(l);return Number.isNaN(u.getTime())?null:u.getTime()}function jG(l){let u=On(l?.createdAt);if(u===null)return null;let f=["succeeded","failed"].includes(String(l?.status||"").toLowerCase())?On(l?.updatedAt):Date.now();if(f===null)return null;return Math.max(0,(f-u)/1000)}function FG(l){if(String(l?.status||"").toLowerCase()!=="failed")return"";let u=l?.result;if(typeof u==="string")return u;if(u&&typeof u==="object"&&!Array.isArray(u)){let r=u;for(let f of["error","reason","message","stderr","detail"])if(typeof r[f]==="string"&&r[f].length>0)return r[f]}return"任务失败但 provider 未返回明确原因"}function ft(l){if(l===null||l===void 0)return"--";if(typeof l==="boolean")return l?"是":"否";if(typeof l==="number")return String(l);if(typeof l==="string")return l.length>80?`${l.slice(0,77)}...`:l;if(Array.isArray(l))return`${l.length} 项`;if(typeof l==="object")return`${Object.keys(l).length} 字段`;return String(l)}function pY(l,u){let r=l.replace(/[-_\s]/g,"").toLowerCase(),f=r==="ts"||r.endsWith("at")||r.endsWith("timestamp")||r.endsWith("heartbeat");if((typeof u==="string"||typeof u==="number")&&f){let n=Wl(u);if(n!=="--")return n}if(l==="bodyText"&&typeof u==="string")return`${/^\s*[{[]/.test(u)?"JSON":"HTTP"} body ${u.length} chars`;return ft(u)}function JG(l){if(!l||typeof l!=="object"||Array.isArray(l))return[];return Object.entries(l)}function uf(l){return String(l).replace(/[^a-zA-Z0-9_-]/g,"_")}function hj(l,u){return l&&typeof l==="object"&&!Array.isArray(l)?l[u]:void 0}function t6(l,u,r="未知"){let f=hj(l?.labels,u);return typeof f==="string"&&f.length>0?f:r}function UG(l){return t6(l,"providerGatewayVersion")}function $_(l){return t6(l,"providerGatewayUpgradePolicy")}function lG(l){return t6(l,"providerGatewayStartedAt","")}function NG(l){let u=hj(l?.labels,"unideskCapabilities");if(typeof u==="string")return u.split(",").map((r)=>r.trim()).filter(Boolean);return Array.isArray(u)?u.filter((r)=>typeof r==="string"):[]}function QG(l,u){return NG(l).includes(u)}function uG(l,u){let r=hj(l?.labels,u);return r===!0||r==="true"||r==="1"}function HY(l){if(!QG(l,"host.ssh"))return{tone:"fail",label:"不可用",detail:"未声明 host.ssh"};if(!uG(l,"hostSshConfigured"))return{tone:"warn",label:"未配置",detail:"缺少 SSH 环境变量"};if(!uG(l,"hostSshKeyPresent"))return{tone:"warn",label:"缺 key",detail:"私钥未挂载"};return{tone:"ok",label:"可用",detail:t6(l,"hostSshTarget","host.ssh ready")}}function BY(l){if(!QG(l,"provider.upgrade"))return{tone:"fail",label:"不可用",detail:"未声明 provider.upgrade"};let u=$_(l);if(u!=="always-enabled")return{tone:"warn",label:"待确认",detail:`策略 ${u}`};return{tone:"ok",label:"可用",detail:"always-enabled"}}function Rj(l){let u=typeof l==="string"&&l.length>0?l:"未知";if(u==="未知")return"版本未知";return u.startsWith("v")?u:`v${u}`}function wG(l){return l?.payload&&typeof l.payload==="object"&&!Array.isArray(l.payload)?l.payload:{}}function y6(l){return l?.result&&typeof l.result==="object"&&!Array.isArray(l.result)?l.result:{}}function i6(l){let u=wG(l),r=y6(l);return(u.mode??r.mode)==="schedule"?"schedule":"plan"}function DY(l){let u=wG(l).source;return typeof u==="string"&&u.length>0?u:"unknown"}function VY(l){let u=y6(l),r=u.plan&&typeof u.plan==="object"&&!Array.isArray(u.plan)?u.plan:{},f=u.policy??r.policy;return typeof f==="string"&&f.length>0?f:"--"}function qG(l){let u=y6(l),r=u.plan&&typeof u.plan==="object"&&!Array.isArray(u.plan)?u.plan:{},f=u.targetProviderGatewayVersion??u.providerGatewayVersion??r.targetProviderGatewayVersion??r.providerGatewayVersion;return typeof f==="string"&&f.length>0?Rj(f):"版本未知"}function WG(l){if(String(l?.status||"").toLowerCase()==="failed")return FG(l);if(hy(l))return"等待 provider 回传升级终态";let r=y6(l);if(typeof r.updaterContainerId==="string"&&r.updaterContainerId.length>0)return`updater ${r.updaterContainerId.slice(0,18)}`;if(typeof r.message==="string"&&r.message.length>0)return r.message;if(r.plan)return"升级计划已生成";return"无升级结果摘要"}function LG(l,u){return l.filter((r)=>r?.providerId===u&&r?.command==="provider.upgrade").sort((r,f)=>(On(f.updatedAt)??0)-(On(r.updatedAt)??0))}function SY(l){return l.find((u)=>i6(u)==="schedule")||l[0]||null}function GG(l){return l?.runtime&&typeof l.runtime==="object"&&!Array.isArray(l.runtime)?l.runtime:{}}function rG(l){return l?.backend&&typeof l.backend==="object"&&!Array.isArray(l.backend)?l.backend:{}}function XY(l){return l?.repository&&typeof l.repository==="object"&&!Array.isArray(l.repository)?l.repository:{}}function Tu({status:l,children:u}){let r=String(l||"unknown").toLowerCase();return _("span",{className:`status-badge ${r}`},u||l||"unknown")}function Nu({label:l,value:u,hint:r,tone:f,onClick:n,testId:i}){let t=typeof n==="function";return _("article",{className:`metric-card ${f||""} ${t?"clickable":""}`,role:t?"button":void 0,tabIndex:t?0:void 0,"data-testid":i,onClick:n,onKeyDown:t?(y)=>{if(y.key==="Enter"||y.key===" ")y.preventDefault(),n()}:void 0},_("div",{className:"metric-label"},l),_("div",{className:"metric-value"},u),_("div",{className:"metric-hint"},r))}function uu({title:l,eyebrow:u,actions:r,children:f,className:n,loading:i}){let t=i0.default.useContext(Cj),y=Boolean(i)||t;return _("section",{className:`panel ${n||""}`},_("div",{className:"panel-head"},_("div",null,u?_("p",{className:"panel-eyebrow"},u):null,_(fu,{title:l,loading:y})),r?_("div",{className:"panel-actions"},r):null),_("div",{className:"panel-body"},f))}function du({title:l,data:u,onOpen:r,testId:f}){let[n,i]=kl(!1),t=u&&typeof u==="object"&&typeof u._loadRaw==="function"?u._loadRaw:null;async function y(){if(!t){r(l,u);return}i(!0);try{r(l,await t())}catch(c){r(l,{ok:!1,error:El(c,"读取原始 JSON 失败"),fallback:u})}finally{i(!1)}}return _("button",{type:"button",className:"ghost-btn","data-testid":f,disabled:n,onClick:()=>void y()},n?"读取中":"查看原始JSON")}function YY({raw:l,onClose:u}){if(!l)return null;return _("div",{className:"modal-backdrop",role:"presentation"},_("section",{className:"raw-dialog",role:"dialog","aria-modal":"true","aria-label":l.title},_("div",{className:"raw-dialog-head"},_("h2",null,l.title),_("button",{type:"button",className:"ghost-btn",onClick:u},"关闭")),_("pre",{className:"raw-json","data-testid":"raw-json"},JSON.stringify(l.data,null,2))))}function TG({labels:l,limit:u=8}){let r=JG(l).slice(0,u);if(r.length===0)return _("span",{className:"muted"},"无标签");return _("div",{className:"chip-row"},r.map(([f,n])=>_("span",{key:f,className:"data-chip"},_("b",null,f),_("span",null,ft(n)))))}function My({node:l}){let u=UG(l);return _("span",{className:`version-chip ${u==="未知"?"unknown":""}`,"data-testid":`gateway-version-${uf(l?.providerId||"unknown")}`},Rj(u))}function fG({title:l,state:u,testId:r}){return _("span",{className:`capability-badge ${u.tone}`,title:u.detail,"data-testid":r},_("b",null,l),_("strong",null,u.label),_("small",null,u.detail))}function xj({node:l}){let u=uf(l?.providerId||"unknown");return _("div",{className:"node-availability-strip"},_(fG,{title:"SSH 透传",state:HY(l),testId:`ssh-availability-${u}`}),_(fG,{title:"远程更新",state:BY(l),testId:`upgrade-availability-${u}`}))}function t0({data:l,empty:u="无数据"}){if(l===null||l===void 0)return _("span",{className:"muted"},u);if(typeof l!=="object")return _("span",{className:"summary-value"},ft(l));if(Array.isArray(l))return _("span",{className:"summary-value"},`${l.length} 项列表`);let r=Object.entries(l).slice(0,5);if(r.length===0)return _("span",{className:"muted"},u);return _("div",{className:"summary-grid"},r.map(([f,n])=>_("span",{key:f,className:"summary-item"},_("b",null,f),_("span",null,pY(f,n)))))}function Qu({title:l,text:u}){return _("div",{className:"empty-state"},_("strong",null,l),_("span",null,u))}function PY({onLogin:l}){let[u,r]=kl(sl.authUsername||"admin"),[f,n]=kl(""),[i,t]=kl(""),[y,c]=kl(!1);async function $(A){A.preventDefault(),c(!0),t("");try{let j=await ml("/login",{method:"POST",body:JSON.stringify({username:u,password:f})});l(j)}catch(j){t(El(j,"登录失败"))}finally{c(!1)}}return _("main",{className:"login-screen","data-testid":"login-screen"},_("section",{className:"login-card"},_("div",{className:"login-brand"},_("span",{className:"brand-mark"},"UD"),_("div",null,_("h1",null,"UniDesk"),_("p",null,"Control Plane Login"))),_("form",{className:"login-form",onSubmit:$},_("label",null,"账号",_("input",{name:"username",autoComplete:"username",value:u,onChange:(A)=>r(A.target.value)})),_("label",null,"密码",_("input",{name:"password",type:"password",autoComplete:"current-password",value:f,onChange:(A)=>n(A.target.value)})),_(lu,{error:i}),_("button",{type:"submit",disabled:y},y?"登录中":"登录")),_("div",{className:"login-note"},"默认账号由 config.json 注入;公网入口只暴露前端登录面。")))}function CY({connection:l,lastRefresh:u,onRefresh:r,onLogout:f,session:n,clock:i,activeStatusItems:t=[],onNotificationToggle:y,unreadCount:c=0,environment:$={}}){let A=AG($),j=[...A?[{key:"environment",label:"环境",value:`${$.namespace||"unidesk-dev"}`,tone:"warn"}]:[],{key:"core",label:"核心",value:l.text,tone:l.ok?"ok":"fail",testId:"conn-text"},...Array.isArray(t)?t:[],{key:"refresh",label:"刷新",value:u?iu(u):"未刷新"},{key:"clock",label:Z_,value:iu(i)},{key:"user",label:"用户",value:n?.user?.username||"--",tone:"user"}];return _("header",{className:"topbar"},_("div",null,_("p",{className:"eyebrow"},"Distributed Work Platform"),_("h1",null,"UniDesk 控制平面"),A?_("div",{className:"dev-env-ribbon","data-testid":"dev-environment-ribbon"},_("b",null,"DEV"),_("span",null,$.namespace||"unidesk-dev"),_("span",null,$.deployRef||"origin/deploy/dev"),_("span",null,zY($.commit||$.requestedCommit))):null),_(sL,{className:"global-top-status",title:"状态",items:j,actions:[_("button",{key:"notification",type:"button",className:`notification-icon-btn ${c>0?"has-unread":""}`,onClick:y,"aria-label":"通知"},"\uD83D\uDD14",c>0?_("span",{key:"badge",className:"notification-badge"},c>99?"99+":c):null),_("button",{key:"refresh",type:"button",className:"ghost-btn",onClick:r},"刷新"),_("button",{key:"logout",type:"button",className:"ghost-btn danger",onClick:f},"退出")]}))}function MY(l){return!l.defaultPrevented&&l.button===0&&!l.metaKey&&!l.altKey&&!l.ctrlKey&&!l.shiftKey&&l.currentTarget.target!=="_blank"}function mG({moduleId:l,tabId:u,className:r,active:f=!1,title:n,testId:i,onNavigate:t,children:y}){let c=H3(Xf,l,u);return _("a",{href:c,role:"button",className:r,title:n,"aria-current":f?"page":void 0,"data-testid":i,"data-route":c,onClick:($)=>{if(!MY($))return;$.preventDefault(),t(l,u)}},y)}function hY({activeModule:l,activeTabs:u,onNavigate:r,collapsed:f,onToggle:n}){return _("aside",{className:`rail ${f?"collapsed":""}`,"aria-label":"主模块"},_("div",{className:"brand"},_("span",{className:"brand-mark"},"UD"),_("span",{className:"brand-text"},"UniDesk"),_("button",{type:"button",className:"rail-toggle",onClick:n,"aria-label":f?"展开左侧边栏":"收起左侧边栏","data-testid":"rail-toggle"},f?"»":"«")),p3.map((i)=>{let t=u[i.id]||Nc[i.id]||i.tabs[0]?.id||"";return _(mG,{key:i.id,moduleId:i.id,tabId:t,className:`module ${l===i.id?"active":""}`,active:l===i.id,title:i.label,onNavigate:r},_("span",{className:"module-code"},i.code),_("span",null,i.label))}))}function RY({module:l,activeTab:u,onNavigate:r}){return _("nav",{className:"tabs","aria-label":`${l.label} 子功能`},l.tabs.map((f)=>_(mG,{key:f.id,moduleId:l.id,tabId:f.id,className:`tab ${u===f.id?"active":""}`,active:u===f.id,onNavigate:r},f.label)))}function xY({data:l,onRaw:u,onNavigate:r}){let f=l.overview||{},n=l.nodes.filter((U)=>U.status==="online"),i=l.pendingTasks||l.tasks.filter(hy),t=f.pendingTaskCount??i.length,y=l.tasks.slice(0,5),c=f.pgdata||{},$=f.microserviceAvailability||{},A=vl($.totalCount),j=vl($.healthyCount),F=vl($.unhealthyCount);return _("div",{className:"page-grid overview-grid","data-testid":"overview-page"},_(uu,{title:"核心指标",eyebrow:"Control"},_("div",{className:"metric-grid"},_(Nu,{label:"数据库",value:f.dbReady?"READY":"WAIT",hint:"PostgreSQL internal network",tone:f.dbReady?"ok":"warn"}),_(Nu,{label:"PGDATA",value:Nr(c.databaseBytes),hint:`${c.volumeName||"unidesk_pgdata_10gb"} / ${c.databasePretty||"--"} / budget ${c.volumeSize||"--"}`,tone:"ok",testId:"pgdata-usage-card"}),_(Nu,{label:"在线节点",value:f.onlineNodeCount??0,hint:`${f.nodeCount??0} registered`,tone:"ok"}),_(Nu,{label:"WebSocket",value:f.activeSocketCount??0,hint:"Provider ingress sockets"}),_(Nu,{label:"用户服务可用",value:A>0?`${j}/${A}`:"--",hint:A>0?`healthyCount ${j} · unhealthyCount ${F}`:"strict /health probes",tone:A>0&&F===0?"ok":"warn",testId:"microservice-availability-card"}),_(Nu,{label:"待处理任务",value:t,hint:t>0?"点击查看具体任务":`timeout ${fi(Math.floor((f.taskPendingTimeoutMs??0)/1000))}`,tone:t>0?"warn":"ok",onClick:()=>r("tasks","pending"),testId:"pending-task-card"}))),_(uu,{title:"本机 Provider",eyebrow:"Self Connected"},n.length===0?_(Qu,{title:"暂无在线节点",text:"provider-gateway 未完成自接入"}):_("div",{className:"node-card-list"},n.slice(0,4).map((U)=>_(bY,{key:U.providerId,node:U,onRaw:u})))),_(uu,{title:"待处理任务明细",eyebrow:`${t} Pending`,actions:_("button",{type:"button",className:"ghost-btn",onClick:()=>r("tasks","pending"),"data-testid":"pending-task-detail-link"},"进入任务调度")},i.length===0?_(Qu,{title:"当前无待处理",text:"queued / dispatched / running 超时后会自动转为 failed,避免总览长期卡住"}):_("div",{className:"compact-list"},i.slice(0,5).map((U)=>_(yG,{key:U.id,task:U,onRaw:u})))),_(uu,{title:"最近任务",eyebrow:"Dispatch"},y.length===0?_(Qu,{title:"暂无任务",text:"可以在任务调度模块发起 docker.ps 或 echo"}):_("div",{className:"compact-list"},y.map((U)=>_(yG,{key:U.id,task:U,onRaw:u})))))}function bY({node:l,onRaw:u}){return _("article",{className:"node-card"},_("div",{className:"node-card-head"},_("div",null,_("strong",null,l.name),_("code",null,l.providerId)),_(Tu,{status:l.status})),_("div",{className:"node-version-line"},_(My,{node:l}),_("span",null,`升级策略 ${$_(l)}`)),_(xj,{node:l}),_(TG,{labels:l.labels,limit:6}),_("div",{className:"node-card-foot"},_("span",null,`心跳 ${Wl(l.lastHeartbeat)}`),_(du,{title:`Provider ${l.providerId}`,data:l,onOpen:u,testId:`raw-node-${uf(l.providerId)}`})))}function vY({events:l,onRaw:u}){return _(uu,{title:"事件摘要",eyebrow:"Latest 100"},l.length===0?_(Qu,{title:"暂无事件",text:"Provider 注册、心跳超时和任务状态会写入事件流"}):_("div",{className:"table-wrap"},_("table",null,_("thead",null,_("tr",null,_("th",null,"ID"),_("th",null,"类型"),_("th",null,"来源"),_("th",null,"摘要"),_("th",null,"时间"),_("th",null,"操作"))),_("tbody",null,l.map((r)=>_("tr",{key:r.id},_("td",null,_("code",null,r.id)),_("td",null,_(Tu,{status:r.type},r.type)),_("td",null,_("code",null,r.source)),_("td",null,_(t0,{data:r.payload})),_("td",null,Wl(r.createdAt)),_("td",null,_(du,{title:`Event ${r.id}`,data:r,onOpen:u}))))))))}function sY({logs:l,onRaw:u}){return _(uu,{title:"服务日志",eyebrow:"Core Recent"},l.length===0?_(Qu,{title:"暂无日志",text:"backend-core 内存日志会在请求和 provider 事件后出现"}):_("div",{className:"log-list"},l.slice(-80).reverse().map((r,f)=>_("article",{key:f,className:`log-row ${r.level||"info"}`},_("span",null,Wl(r.ts)),_("b",null,r.level||"info"),_("strong",null,r.message||"log"),_(t0,{data:r.data,empty:"无附加字段"}),_(du,{title:`Log ${r.message||f}`,data:r,onOpen:u})))))}function kY({nodes:l,onRaw:u}){return _(uu,{title:"节点清单",eyebrow:`${l.length} Providers`},l.length===0?_(Qu,{title:"暂无 Provider 节点",text:"确认 provider-gateway 已连接 provider ingress"}):_("div",{className:"table-wrap"},_("table",{className:"node-list-table"},_("thead",null,_("tr",null,_("th",null,"状态"),_("th",null,"Provider"),_("th",null,"网关版本"),_("th",null,"运维可用性"),_("th",null,"资源标签"),_("th",null,"连接时间"),_("th",null,"最后心跳"),_("th",null,"操作"))),_("tbody",null,l.map((r)=>_("tr",{key:r.providerId},_("td",null,_(Tu,{status:r.status})),_("td",null,_("strong",null,r.name),_("code",null,r.providerId)),_("td",null,_("div",{className:"gateway-cell"},_(My,{node:r}),_("span",null,$_(r)))),_("td",null,_(xj,{node:r})),_("td",null,_(TG,{labels:r.labels,limit:5})),_("td",null,Wl(r.connectedAt)),_("td",null,Wl(r.lastHeartbeat)),_("td",null,_(du,{title:`Provider ${r.providerId}`,data:r,onOpen:u,testId:`raw-node-table-${uf(r.providerId)}`}))))))))}function gY({nodes:l}){let u=A_(()=>{let r=[];for(let f of l)for(let[n,i]of JG(f.labels))r.push({providerId:f.providerId,name:f.name,key:n,value:i});return r},[l]);return _(uu,{title:"资源标签",eyebrow:"Structured Labels"},u.length===0?_(Qu,{title:"暂无标签",text:"provider-gateway 注册消息会同步资源标签"}):_("div",{className:"label-matrix"},u.map((r)=>_("article",{key:`${r.providerId}-${r.key}`,className:"label-card"},_("span",null,r.key),_("strong",null,ft(r.value)),_("code",null,r.providerId)))))}function IY({nodes:l}){return _(uu,{title:"心跳状态",eyebrow:"Provider Liveness"},l.length===0?_(Qu,{title:"无心跳",text:"等待 provider 注册和 heartbeat"}):_("div",{className:"heartbeat-list"},l.map((u)=>_("article",{key:u.providerId,className:"heartbeat-row"},_("span",{className:`pulse ${u.status}`}),_("div",null,_("strong",null,u.name),_("code",null,u.providerId)),_("div",null,_("span",null,"connected"),_("b",null,Wl(u.connectedAt))),_("div",null,_("span",null,"last heartbeat"),_("b",null,Wl(u.lastHeartbeat)))))))}function aY({nodes:l,systemStatuses:u,tasks:r,onRaw:f,refresh:n}){let[i,t]=kl(""),y=A_(()=>l.map((W)=>{let L=u.find((J)=>J.providerId===W.providerId);return{...W,systemCurrent:L?.current||null,systemHistory:L?.history||[],systemUpdatedAt:L?.updatedAt||null}}),[l,u]),c=y.find((W)=>W.providerId===i)||y[0]||null;if(En(()=>{if(!i&&y[0])t(y[0].providerId)},[y.length,i]),!c)return _(Qu,{title:"暂无资源监控",text:"等待 provider 上报 CPU、内存和硬盘指标"});let $=c.systemCurrent,A=c.systemHistory||[],j=$?.cpu||{},F=$?.memory||{},U=$?.disk||{},N=A.length>0?A:$?[{at:$.collectedAt,cpuPercent:vl(j.percent),memoryPercent:vl(F.percent),diskPercent:vl(U.percent)}]:[];return _("div",{className:"monitor-page","data-testid":"node-monitor-page"},_("div",{className:"docker-node-strip"},y.map((W)=>_("button",{key:W.providerId,type:"button",className:`docker-node-tile ${c.providerId===W.providerId?"active":""}`,onClick:()=>t(W.providerId)},_("span",{className:`pulse ${W.status}`}),_("strong",null,W.name),_("code",null,W.providerId),_("span",null,W.systemCurrent?`CPU ${n0(W.systemCurrent.cpu?.percent)} / MEM ${n0(W.systemCurrent.memory?.percent)}`:"等待指标")))),_("div",{className:"monitor-layout"},_(uu,{title:"任务管理器视图",eyebrow:c.name,className:"monitor-main-panel",actions:$?_(du,{title:`System ${c.providerId}`,data:{current:$,history:A},onOpen:f}):null},!$?_(Qu,{title:"系统指标未上报",text:"provider-gateway 会周期性采集 /proc 与 df,并保存历史曲线"}):_("div",null,_("div",{className:"monitor-hero"},_("div",null,_("p",{className:"panel-eyebrow"},"Node Performance"),_("h3",null,c.name),_("div",{className:"docker-meta"},_("span",null,`${j.cores||0} CPU cores`),_("span",null,`load ${vl(j.load1).toFixed(2)} / ${vl(j.load5).toFixed(2)} / ${vl(j.load15).toFixed(2)}`),_("span",null,`memory actual ${Nr(F.usedBytes)} / ${Nr(F.totalBytes)}`),_("span",null,`disk ${Nr(U.usedBytes)} / ${Nr(U.totalBytes)}`))),_(Tu,{status:$.ok?"online":"warn"},$.ok?"METRICS READY":"METRICS DEGRADED")),_("div",{className:"monitor-chart-grid"},_(Yj,{title:"CPU",metricKey:"cpuPercent",current:j.percent,points:N,detail:`${j.cores||0} cores / load ${vl(j.load1).toFixed(2)}`,tone:"cpu",testId:"metric-chart-cpu"}),_(Yj,{title:"Memory",metricKey:"memoryPercent",current:F.percent,points:N,detail:`${Nr(F.usedBytes)} actual / ${Nr(F.cacheBytes)} cache excluded`,tone:"memory",testId:"metric-chart-memory"}),_(Yj,{title:"Disk",metricKey:"diskPercent",current:U.percent,points:N,detail:`${U.path||"/"} mounted ${U.mount||"--"}`,tone:"disk",testId:"metric-chart-disk"})),_("div",{className:"monitor-summary-grid"},_(Nu,{label:"CPU 当前",value:n0(j.percent),hint:`history ${N.length} samples`,tone:"ok"}),_(Nu,{label:"实际内存",value:Nr(F.usedBytes),hint:`${n0(F.percent)} 不含缓存`}),_(Nu,{label:"硬盘已用",value:Nr(U.usedBytes),hint:n0(U.percent)}),_(Nu,{label:"更新时间",value:Wl(c.systemUpdatedAt||$.collectedAt),hint:c.providerId})),_(oY,{current:$,onRaw:f}))),_("div",{className:"monitor-side-stack"},_(iP,{provider:c,refresh:n,onRaw:f}),_(tP,{provider:c,tasks:r,onRaw:f,limit:5}),_(uu,{title:"采样说明",eyebrow:"Retention"},_("div",{className:"monitor-note-list"},_("article",null,_("b",null,"CPU"),_("span",null,"从 /proc/stat 计算相邻采样差值,首个采样用 load/cores 近似")),_("article",null,_("b",null,"Memory"),_("span",null,"实际内存 = MemTotal - MemFree - Buffers - Cached - SReclaimable + Shmem,不把 page cache / buffer 计入占用")),_("article",null,_("b",null,"Disk"),_("span",null,"使用 df -PB1 对配置路径采样,默认监控根文件系统")),_("article",null,_("b",null,"Process"),_("span",null,"从 /proc/[pid] 采集进程 CPU、实际内存 PSS、RSS、线程数和磁盘 I/O 速率;PSS 不重复计算共享内存,表格默认按内存占用降序")))))))}function KG(l){return vl(l.memoryBytes,vl(l.pssBytes,vl(l.rssBytes)))}function nG(l,u){if(u==="memory")return KG(l);if(u==="cpu")return vl(l.cpuPercent);if(u==="disk")return vl(l.readBytesPerSecond)+vl(l.writeBytesPerSecond);if(u==="pid")return vl(l.pid);if(u==="threads")return vl(l.threads);if(u==="runtime")return vl(l.elapsedSeconds);if(u==="user")return String(l.user||"");return String(l.name||l.command||"")}function iG({value:l,label:u,tone:r}){let f=Math.max(1,Math.min(100,vl(l)));return _("div",{className:`process-meter ${r||""}`},_("span",{style:{width:`${f}%`}}),_("b",null,u))}function oY({current:l,onRaw:u}){let[r,f]=kl({key:"memory",direction:"desc"}),n=i0.default.useContext(Cj),i=l?.processSummary&&typeof l.processSummary==="object"?l.processSummary:{},t=Array.isArray(l?.processes)?l.processes:[],y=String(i.memoryMode||""),c=y.includes("pss_smaps_rollup")?"PSS":y==="rss_minus_shared_fallback"?"RSS-shared":"RSS fallback",$=A_(()=>{let j=r.direction==="asc"?1:-1;return[...t].sort((F,U)=>{let N=nG(F,r.key),W=nG(U,r.key);if(typeof N==="string"||typeof W==="string")return String(N).localeCompare(String(W),"zh-CN")*j;return(N-W)*j||vl(F.pid)-vl(U.pid)})},[t,r.key,r.direction]),A=(j,F)=>{let U=r.key===F,N=U?r.direction==="asc"?"ascending":"descending":"none";return _("th",{"aria-sort":N},_("button",{type:"button",className:`process-sort-button ${U?"active":""}`,"data-testid":`process-sort-${F}`,onClick:()=>f((W)=>({key:F,direction:W.key===F&&W.direction==="desc"?"asc":"desc"}))},j,_("span",null,U?r.direction==="desc"?"↓":"↑":"↕")))};return _("section",{className:"process-resource-panel","data-testid":"process-resource-panel"},_("div",{className:"process-resource-head"},_("div",null,_("p",{className:"panel-eyebrow"},"Windows Resource Monitor Style"),_(fu,{title:"进程资源占用",level:3,loading:n})),_("div",{className:"process-resource-actions"},_("span",{className:"data-chip"},"默认按内存排序"),_("span",{className:"data-chip"},`内存口径 ${c}`),_("span",{className:"data-chip"},`${vl(i.visible,$.length)} / ${vl(i.total,$.length)} 进程`),_(du,{title:"Process Resource Snapshot",data:{processSummary:i,processes:t},onOpen:u,testId:"raw-process-resources"}))),$.length===0?_(Qu,{title:"暂无进程资源数据",text:"等待 provider-gateway 上报 /proc/[pid] 采样;旧版 provider 需要先升级到支持进程资源表的版本"}):_("div",{className:"process-table-wrap"},_("table",{className:"process-resource-table","data-testid":"process-resource-table"},_("thead",null,_("tr",null,A("进程","name"),A("PID","pid"),A("用户","user"),_("th",null,"状态"),A("CPU","cpu"),A("内存","memory"),_("th",null,"PSS / RSS"),A("磁盘 I/O","disk"),A("线程","threads"),A("运行时长","runtime"))),_("tbody",null,$.map((j)=>{let F=vl(j.readBytesPerSecond)+vl(j.writeBytesPerSecond),U=KG(j);return _("tr",{key:`${j.pid}-${j.startedAt}`,"data-testid":`process-row-${uf(j.pid)}`,"data-memory-bytes":String(U),"data-cpu-percent":String(vl(j.cpuPercent)),"data-disk-bps":String(F),"data-pid":String(vl(j.pid))},_("td",null,_("div",{className:"process-name-cell"},_("strong",null,j.name||"--"),_("span",{className:"process-command"},j.command||"--"))),_("td",null,_("code",null,j.pid||"--")),_("td",null,j.user||`uid:${j.uid??"--"}`),_("td",null,_("span",{className:`process-state state-${uf(j.state||"unknown")}`},j.state||"?")),_("td",null,_(iG,{value:j.cpuPercent,label:ZY(j.cpuPercent),tone:"cpu"})),_("td",null,_(iG,{value:j.memoryPercent,label:n0(j.memoryPercent),tone:"memory"})),_("td",null,_("div",{className:"process-io-cell"},_("strong",null,Nr(U)),_("span",null,`RSS ${Nr(j.rssBytes)}`))),_("td",null,_("div",{className:"process-io-cell"},_("strong",null,Xj(F)),_("span",null,`R ${Xj(j.readBytesPerSecond)} / W ${Xj(j.writeBytesPerSecond)}`))),_("td",null,j.threads||0),_("td",null,fi(vl(j.elapsedSeconds))))})))))}function Yj({title:l,metricKey:u,current:r,points:f,detail:n,tone:i,testId:t}){let y=f.map((F)=>Math.max(0,Math.min(100,vl(F[u])))),c=y.length>1?y:[y[0]||0,y[0]||0],$=c.length<=1?100:100/(c.length-1),A=c.map((F,U)=>`${(U*$).toFixed(2)},${(46-F*0.42).toFixed(2)}`).join(" "),j=`0,48 ${A} 100,48`;return _("article",{className:`metric-chart ${i}`,"data-testid":t},_("div",{className:"metric-chart-head"},_("div",null,_("span",null,l),_("strong",null,n0(r))),_("code",null,`${f.length} pts`)),_("svg",{viewBox:"0 0 100 48",preserveAspectRatio:"none",role:"img","aria-label":`${l} usage curve`},_("polygon",{points:j}),_("polyline",{points:A}),_("line",{x1:"0",x2:"100",y1:"24",y2:"24"})),_("div",{className:"metric-chart-foot"},_("span",null,"0%"),_("span",null,n),_("span",null,"100%")))}function ni(l){return Array.isArray(l)?l:[]}function dY(l){let u=ni(l?.core?.requests?.componentSummary);return[...ni(l?.frontend?.requests?.componentSummary),...u].sort((f,n)=>vl(n.requestCount)-vl(f.requestCount))}function eY(l){let u=ni(l?.core?.operations?.summary);return[...ni(l?.frontend?.operations?.summary),...u].sort((f,n)=>vl(n.count)-vl(f.count))}function lP(l){let u=ni(l?.core?.requests?.recentFailures).map((f)=>({source:"backend",...f}));return[...ni(l?.frontend?.requests?.recentFailures).map((f)=>({source:"frontend",...f})),...u].sort((f,n)=>(On(n.at)??0)-(On(f.at)??0)).slice(0,20)}function uP(l){let u=ni(l?.core?.operations?.recentSlowOperations);return[...ni(l?.frontend?.operations?.recentSlowOperations),...u].sort((f,n)=>vl(n.durationMs)-vl(f.durationMs)).slice(0,20)}function rP(l){let u=performance.memory,r=Number(u?.usedJSHeapSize);if(Number.isFinite(r)&&r>0)return r;let f=Number(l?.appBundleBytes);if(Number.isFinite(f)&&f>0)return f;return vl(l?.process?.heapUsedBytes)}function fP({points:l}){let u=ni(l),r=u.map((F)=>vl(F.mb)),f=Math.max(1,...r),n=Math.max(0,Math.min(...r,0)),i=Math.max(1,f-n),t=u.length>1?u:[...u,...u],y=t.length<=1?100:100/(t.length-1),c=t.map((F,U)=>{let N=vl(F.mb);return`${(U*y).toFixed(2)},${(48-(N-n)/i*42).toFixed(2)}`}).join(" "),$=`0,50 ${c} 100,50`,A=u.at(-1),j=u[0];return _("article",{className:"performance-memory-card","data-testid":"performance-memory-chart"},_("div",{className:"performance-memory-head"},_("strong",null,`Bwebui: ${A?`${vl(A.mb).toFixed(1)}MB`:"--"}`),_("span",null,u.length>0?`${u.length} samples`:"等待采样")),_("svg",{viewBox:"0 0 100 50",preserveAspectRatio:"none",role:"img","aria-label":"Bwebui memory trend"},_("polygon",{points:$}),_("polyline",{points:c}),_("line",{x1:"0",x2:"100",y1:"25",y2:"25"})),_("div",{className:"performance-axis-row"},_("span",null,j?iu(new Date(j.at)):"--"),_("span",null,"时间"),_("span",null,A?iu(new Date(A.at)):"--")),_("div",{className:"performance-axis-row"},_("span",null,`${n.toFixed(1)}`),_("span",null,"(MB)"),_("span",null,`${f.toFixed(1)}`)))}function nP({onRaw:l}){let[u,r]=kl({core:null,frontend:null}),[f,n]=kl([]),[i,t]=kl(""),[y,c]=kl(!1),[$,A]=kl(null),[j,F]=kl(!1);async function U(){c(!0),t("");try{let[V,B]=await Promise.all([ml(`${sl.apiBaseUrl}/performance`,{cache:"no-store"}),ml(`${sl.apiBaseUrl}/frontend-performance`,{cache:"no-store"})]);r({core:V,frontend:B});let m=rP(B);n((X)=>[...X,{at:new Date().toISOString(),mb:m/1048576}].slice(-80))}catch(V){t(El(V,"性能指标加载失败"))}finally{c(!1)}}En(()=>{U();let V=setInterval(()=>void U(),5000);return()=>clearInterval(V)},[]);async function N(){F(!0),t(""),A(null);try{let V=await ml(`${sl.apiBaseUrl}/code-queue-load-test`,{method:"POST",body:JSON.stringify({targetMs:1000,timeoutMs:90000,url:sl.frontendPublicUrl||window.location.origin})});A(V),U()}catch(V){t(El(V,"Code Queue Playwright 测量失败"))}finally{F(!1)}}let W=dY(u),L=lP(u),J=eY(u),w=uP(u),Q=u.core?.process||{},q=u.frontend?.process||{},T=u.core?.database?.codeQueueStorage||{},O=vl(T.total),Z=$?.result||{},E=vl(Z.wallMs,NaN),D=vl(Z.networkIdleMs,NaN),Y=Z.withinTarget===!0,p=j?"running":$===null?"idle":$.measurementOk===!0?Y?"passed":"slow":"failed";return _("div",{className:"performance-page","data-testid":"performance-page"},_("div",{className:"performance-hero"},_("div",null,_("p",{className:"panel-eyebrow"},"Unified Performance"),_(fu,{title:"性能面板",loading:y||j}),_("p",null,"按组件统计 HTTP 请求、失败率、P95 延迟,并汇总 backend/frontend 内部操作耗时。")),_("div",{className:"inline-actions"},_("button",{type:"button",className:"ghost-btn",onClick:()=>void N(),disabled:j,"data-testid":"code-queue-load-test-button"},j?"测试中...":"测试 Code Queue 加载"),_("button",{type:"button",className:"ghost-btn",onClick:()=>void U(),disabled:y,"data-testid":"performance-refresh-button"},y?"刷新中":"刷新"),_(du,{title:"Performance Snapshot",data:u,onOpen:l,testId:"raw-performance"}))),_(lu,{error:i}),_("div",{className:"performance-top-grid"},_(fP,{points:f}),_("div",{className:"performance-metric-stack"},_(Nu,{label:"backend RSS",value:Nr(Q.rssBytes),hint:`heap ${Nr(Q.heapUsedBytes)}`}),_(Nu,{label:"frontend RSS",value:Nr(q.rssBytes),hint:`bundle ${Nr(u.frontend?.appBundleBytes)}`}),_(Nu,{label:"Codex PG 任务",value:O||"--",hint:T.ok?"unidesk_code_queue_tasks":"等待表初始化",tone:T.ok?"ok":"warn"}),_(Nu,{label:"请求样本",value:vl(u.core?.requests?.sampleCount)+vl(u.frontend?.requests?.sampleCount),hint:"rolling window 3000"}))),_(uu,{title:"Code Queue 加载基准",eyebrow:"Playwright / target <1s",className:"codex-load-test-panel",loading:j,actions:_("div",{className:"panel-actions"},_("button",{type:"button",className:"primary-btn",onClick:()=>void N(),disabled:j,"data-testid":"code-queue-load-test-panel-button"},j?"正在运行 Playwright...":"手动触发测试"),$?_(du,{title:"Code Queue Load Test",data:$,onOpen:l,testId:"raw-code-queue-load-test"}):null)},_("div",{className:"codex-load-test-grid","data-testid":"code-queue-load-test-result"},_(Nu,{label:"总耗时",value:j?"运行中":Number.isFinite(E)?Wf(E):"--",hint:$===null?"点击按钮启动远端 Playwright":`目标 ${Wf(Z.targetMs||1000)} / ${Z.url||"Code Queue"}`,tone:p==="passed"?"ok":p==="failed"||p==="slow"?"warn":""}),_(Nu,{label:"判定",value:j?"RUNNING":p==="passed"?"PASS <1s":p==="slow"?"SLOW":p==="failed"?"FAILED":"--",hint:$?.measurementOk===!1?String($.error||Z.error||"measurement failed").slice(0,120):"导航开始 -> DOMContentLoaded -> data-load-state=complete",tone:p==="passed"?"ok":p==="idle"||p==="running"?"":"fail"}),_(Nu,{label:"Network idle",value:Number.isFinite(D)?Wf(D):"--",hint:`DOMContentLoaded ${Wf(Z.domContentLoadedMs)} / ${Z.networkIdleReached===!1?"未在 5s 内空闲":"已空闲"}`,tone:Number.isFinite(D)&&D<=1000?"ok":"warn"}),_(Nu,{label:"组件耗时",value:Number.isFinite(vl(Z.componentLoadMs,NaN))?Wf(Z.componentLoadMs):"--",hint:`queue ${Wf(Z.queueMs)} / detail ${Wf(Z.detailMs)}`,tone:vl(Z.componentLoadMs)>1000?"warn":"ok"}),_(Nu,{label:"Trace 规模",value:Number.isFinite(vl(Z.transcriptRows,NaN))?String(Z.transcriptRows):"--",hint:`${Z.visibleTaskCount??0} visible tasks / ${Z.partial?"preview":"complete"}`})),j?_("div",{className:"performance-empty-line"},"正在通过 main-server Host SSH 启动 Playwright,完成后会显示 wall time、组件耗时和最慢 API。"):null,$&&Array.isArray(Z.slowestApi)&&Z.slowestApi.length>0?_("div",{className:"table-wrap performance-table-wrap compact codex-load-api-table"},_("table",{className:"performance-table"},_("thead",null,_("tr",null,["API","状态","耗时"].map((V)=>_("th",{key:V},V)))),_("tbody",null,Z.slowestApi.slice(0,5).map((V,B)=>_("tr",{key:`${V.url}-${B}`},_("td",null,_("code",null,V.url)),_("td",null,V.status),_("td",null,Wf(V.durationMs))))))):null),_("div",{className:"performance-grid"},_(uu,{title:"组件汇总",eyebrow:"Requests",loading:y},W.length===0?_(Qu,{title:"暂无请求样本",text:"刷新几次或打开页面后会自动形成组件统计"}):_("div",{className:"table-wrap performance-table-wrap"},_("table",{className:"performance-table"},_("thead",null,_("tr",null,["组件","请求数","失败数","失败率","平均延迟","P95"].map((V)=>_("th",{key:V},V)))),_("tbody",null,W.map((V)=>_("tr",{key:V.component},_("td",null,_("code",null,V.component)),_("td",null,V.requestCount),_("td",null,V.failureCount),_("td",null,n0(vl(V.failureRate)*100)),_("td",null,Wf(V.averageLatencyMs)),_("td",null,Wf(V.p95LatencyMs)))))))),_(uu,{title:"最近失败请求",eyebrow:"Failures",loading:y},L.length===0?_("div",{className:"performance-empty-line"},"最近没有失败请求"):_("div",{className:"table-wrap performance-table-wrap compact"},_("table",{className:"performance-table"},_("thead",null,_("tr",null,["时间","来源","组件","状态","路径"].map((V)=>_("th",{key:V},V)))),_("tbody",null,L.map((V,B)=>_("tr",{key:`${V.at}-${B}`},_("td",null,Wl(V.at)),_("td",null,V.source),_("td",null,_("code",null,V.component)),_("td",null,_(Tu,{status:"failed"},V.status)),_("td",null,_("code",null,V.path)))))))),_(uu,{title:"内部操作汇总",eyebrow:"Operations",loading:y},J.length===0?_(Qu,{title:"暂无内部操作样本",text:"API 查询和代理请求会自动记录内部操作耗时"}):_("div",{className:"table-wrap performance-table-wrap"},_("table",{className:"performance-table"},_("thead",null,_("tr",null,["服务","操作","次数","平均延迟","P95"].map((V)=>_("th",{key:V},V)))),_("tbody",null,J.map((V)=>_("tr",{key:`${V.service}-${V.operation}`},_("td",null,V.service),_("td",null,_("code",null,V.operation)),_("td",null,V.count),_("td",null,Wf(V.averageLatencyMs)),_("td",null,Wf(V.p95LatencyMs)))))))),_(uu,{title:"最近慢操作",eyebrow:"Slowest",loading:y},w.length===0?_(Qu,{title:"暂无慢操作",text:"后端会记录最近窗口内耗时最高的内部操作"}):_("div",{className:"table-wrap performance-table-wrap"},_("table",{className:"performance-table"},_("thead",null,_("tr",null,["时间","操作","耗时","结果","细节"].map((V)=>_("th",{key:V},V)))),_("tbody",null,w.map((V,B)=>_("tr",{key:`${V.at}-${V.operation}-${B}`},_("td",null,Wl(V.at)),_("td",null,_("code",null,V.operation)),_("td",null,Wf(V.durationMs)),_("td",null,V.ok?"成功":"失败"),_("td",null,V.detail||"-")))))))))}function iP({provider:l,refresh:u,onRaw:r}){let[f,n]=kl(""),[i,t]=kl(null),[y,c]=kl("");async function $(A){n(A),c("");try{let j=await ml(`${sl.apiBaseUrl}/dispatch`,{method:"POST",body:JSON.stringify({providerId:l.providerId,command:"provider.upgrade",payload:{mode:A,source:"frontend-resource-monitor",requestedAt:new Date().toISOString()}})});t({mode:A,...j}),await u()}catch(j){c(El(j,"升级命令下发失败"))}finally{n("")}}return _(uu,{title:"Provider Gateway 升级",eyebrow:"Remote Control",loading:Boolean(f)},_("div",{className:"upgrade-control","data-testid":"provider-upgrade-control"},_("p",null,"通过 UniDesk WebSocket 向当前计算节点下发 provider.upgrade;预检只生成升级计划,执行升级会调度节点本地 updater 容器。"),_("div",{className:"upgrade-target-line"},_("span",null,"指定 Provider"),_("code",null,l.providerId),_(My,{node:l})),_("div",{className:"upgrade-actions"},_("button",{type:"button",className:"ghost-btn",disabled:Boolean(f),onClick:()=>$("plan"),"data-testid":"upgrade-plan-button"},f==="plan"?"预检中":"预检升级"),_("button",{type:"button",className:"ghost-btn danger",disabled:Boolean(f),onClick:()=>$("schedule"),"data-testid":"upgrade-schedule-button"},f==="schedule"?"调度中":"执行升级")),_(lu,{error:y}),i?_("div",{className:"upgrade-result"},_(Tu,{status:i.status||"queued"},i.status||"queued"),_("span",null,`${i.mode==="schedule"?"执行升级":"预检升级"} 已下发`),_("span",null,`指定版本 ${Rj(UG(l))}`),_("code",null,i.taskId||"--"),_(du,{title:"Provider Upgrade Dispatch",data:i,onOpen:r})):_("span",{className:"muted"},"升级任务结果会进入任务历史;执行升级可能导致 provider 短暂重连。")))}function zG({records:l,onRaw:u,compact:r=!1}){if(l.length===0)return _(Qu,{title:"暂无远程更新记录",text:"该节点还没有 provider.upgrade 任务;执行预检或升级后会在这里形成结构化记录"});return _("div",{className:`upgrade-record-table-wrap table-wrap ${r?"compact":""}`},_("table",{className:"upgrade-record-table"},_("thead",null,_("tr",null,_("th",null,"状态"),_("th",null,"模式"),_("th",null,"任务"),_("th",null,"来源"),_("th",null,"耗时"),_("th",null,"策略"),_("th",null,"Gateway 版本"),_("th",null,"结果记录"),_("th",null,"更新时间"),_("th",null,"操作"))),_("tbody",null,l.map((f)=>_("tr",{key:f.id,"data-testid":`gateway-upgrade-record-${uf(f.id)}`},_("td",null,_(Tu,{status:f.status})),_("td",null,_("span",{className:`mode-chip ${i6(f)}`},i6(f)==="schedule"?"执行升级":"预检")),_("td",null,_("strong",null,"provider.upgrade"),_("code",null,f.id)),_("td",null,DY(f)),_("td",null,_(OG,{task:f})),_("td",null,VY(f)),_("td",null,_("span",{className:"version-chip"},qG(f))),_("td",null,_("span",{className:`upgrade-outcome ${String(f.status||"").toLowerCase()}`},WG(f))),_("td",null,Wl(f.updatedAt)),_("td",null,_(du,{title:`Provider Upgrade Task ${f.id}`,data:j_(f),onOpen:u})))))))}function tP({provider:l,tasks:u,onRaw:r,limit:f=5}){let n=LG(u,l.providerId).slice(0,f);return _(uu,{title:"远程更新记录",eyebrow:l.providerId,actions:_(My,{node:l}),className:"provider-upgrade-records-panel"},_("div",{"data-testid":`provider-upgrade-records-${uf(l.providerId)}`},_(zG,{records:n,onRaw:r,compact:!0})))}function yP({nodes:l,tasks:u,onRaw:r}){let f=A_(()=>l.map((i)=>{let t=LG(u,i.providerId);return{node:i,records:t,latest:SY(t),capabilities:NG(i)}}),[l,u]),n=f.reduce((i,t)=>i+t.records.length,0);return _("div",{className:"gateway-page","data-testid":"gateway-version-page"},_(uu,{title:"Provider Gateway 版本",eyebrow:`${l.length} Providers / ${n} 更新记录`},l.length===0?_(Qu,{title:"暂无 Provider 节点",text:"等待 provider-gateway 注册后显示版本号和升级记录"}):_("div",{className:"table-wrap gateway-version-table-wrap"},_("table",{className:"gateway-version-table"},_("thead",null,_("tr",null,_("th",null,"状态"),_("th",null,"Provider"),_("th",null,"Gateway 版本"),_("th",null,"升级策略"),_("th",null,"运维可用性"),_("th",null,"运行时间"),_("th",null,"能力"),_("th",null,"最近远程更新"),_("th",null,"操作"))),_("tbody",null,f.map((i)=>_("tr",{key:i.node.providerId},_("td",null,_(Tu,{status:i.node.status})),_("td",null,_("strong",null,i.node.name),_("code",null,i.node.providerId)),_("td",null,_(My,{node:i.node})),_("td",null,$_(i.node)),_("td",null,_(xj,{node:i.node})),_("td",null,lG(i.node)?Wl(lG(i.node)):"待新版上报"),_("td",null,_("div",{className:"capability-row"},i.capabilities.length===0?_("span",{className:"muted"},"未声明"):i.capabilities.slice(0,5).map((t)=>_("span",{key:t,className:"data-chip"},t)))),_("td",null,i.latest?_("div",{className:"latest-upgrade-cell"},_(Tu,{status:i.latest.status}),_("span",null,`${i6(i.latest)==="schedule"?"执行升级":"预检"} / ${Wl(i.latest.updatedAt)}`),_("small",null,`Gateway ${qG(i.latest)}`),_("small",null,WG(i.latest))):_("span",{className:"muted"},"暂无记录")),_("td",null,_(du,{title:`Provider ${i.node.providerId}`,data:i.node,onOpen:r})))))))),_(uu,{title:"远程更新记录",eyebrow:"Structured provider.upgrade records"},l.length===0?_(Qu,{title:"暂无记录",text:"没有 provider 节点时不会生成远程更新记录"}):_("div",{className:"gateway-record-grid"},f.map((i)=>_("article",{key:i.node.providerId,className:"gateway-record-card","data-testid":`gateway-records-${uf(i.node.providerId)}`},_("div",{className:"gateway-record-head"},_("div",null,_("strong",null,i.node.name),_("code",null,i.node.providerId)),_(My,{node:i.node})),_("div",{className:"gateway-record-meta"},_("span",null,`心跳 ${Wl(i.node.lastHeartbeat)}`),_("span",null,`策略 ${$_(i.node)}`),_("span",null,`${i.records.length} 条记录`)),_(zG,{records:i.records.slice(0,8),onRaw:r,compact:!0}))))))}function cP(l){if(l==="running")return"online";if(l==="paused"||l==="restarting")return"warn";if(l==="exited"||l==="dead")return"offline";return"internal"}function EG(l){return/^[a-f0-9]{48,64}$/i.test(l)}function __(l){let u=String(l?.name||""),r=String(l?.labels||"");return u==="unidesk_pgdata_10gb"||r.includes("com.docker.compose.volume=unidesk_pgdata_10gb")||u.toLowerCase().includes("pgdata")}function tG(l){let u=String(l?.name||""),r=String(l?.labels||"");if(__(l))return 0;if(r.includes("com.docker.compose.project=unidesk"))return 1;if(!EG(u))return 2;return 3}function _P(l){return[...l].sort((u,r)=>{let f=tG(u)-tG(r);if(f!==0)return f;return String(u.name||"").localeCompare(String(r.name||""))})}function $P({nodes:l,dockerStatuses:u,onRaw:r}){let[f,n]=kl(""),i=A_(()=>l.map((w)=>{let Q=u.find((q)=>q.providerId===w.providerId);return{...w,dockerStatus:Q?.dockerStatus||null,dockerUpdatedAt:Q?.updatedAt||null}}),[l,u]),t=i.find((w)=>w.providerId===f)||i[0]||null;if(En(()=>{if(!f&&i[0])n(i[0].providerId)},[i.length,f]),!t)return _(Qu,{title:"暂无 Docker 节点",text:"等待 provider 上报 Docker daemon 状态"});let y=t.dockerStatus,c=t.providerId==="main-server",$=y?.counts||{},A=y?.daemon||{},j=y?.containers||[],F=y?.images||[],U=_P(y?.volumes||[]),N=c?U.find(__):null,W=y?.networks||[],L=j.filter((w)=>w.state==="running"),J=j.filter((w)=>w.state!=="running");return _("div",{className:"docker-page","data-testid":"docker-status-page"},_("div",{className:"docker-node-strip"},i.map((w)=>_("button",{key:w.providerId,type:"button",className:`docker-node-tile ${t.providerId===w.providerId?"active":""}`,onClick:()=>n(w.providerId)},_("span",{className:`pulse ${w.status}`}),_("strong",null,w.name),_("code",null,w.providerId),_("span",null,w.dockerStatus?`Docker ${w.dockerStatus.ok?"ready":"degraded"}`:"等待上报")))),_("div",{className:"docker-layout"},_(uu,{title:"Docker Desktop 视图",eyebrow:t.name,className:"docker-main-panel",actions:y?_(du,{title:`Docker ${t.providerId}`,data:y,onOpen:r}):null},!y?_(Qu,{title:"Docker 状态未上报",text:"provider-gateway 会在连接后周期性采集 docker info / ps / images / volume / network"}):_("div",null,_("div",{className:"docker-hero"},_("div",null,_("p",{className:"panel-eyebrow"},"Daemon"),_("h3",null,A.name||t.providerId),_("div",{className:"docker-meta"},_("span",null,A.serverVersion?`Engine ${A.serverVersion}`:"Engine --"),_("span",null,A.operatingSystem||"OS --"),_("span",null,A.architecture||"arch --"),_("span",null,`${A.cpus||0} CPU / ${Nr(A.memoryBytes)}`))),_(Tu,{status:y.ok?"online":"warn"},y.ok?"Docker Ready":"Docker Degraded")),_("div",{className:"docker-metrics"},_(Nu,{label:"Containers",value:$.containers??j.length,hint:`${$.running??L.length} running / ${$.stopped??J.length} stopped`,tone:"ok"}),_(Nu,{label:"Images",value:$.images??F.length,hint:`${$.daemonImages??$.images??F.length} daemon images`}),_(Nu,{label:"Volumes",value:$.volumes??U.length,hint:c?N?"database volume visible":"database volume missing":"node local volumes",tone:N?"ok":""}),_(Nu,{label:"Networks",value:$.networks??W.length,hint:A.driver?`driver ${A.driver}`:"docker networks"})),c?_(AP,{volume:N,volumeCount:U.length}):null,_("div",{className:"docker-section-head"},_("h3",null,"Containers"),_("span",null,`updated ${Wl(t.dockerUpdatedAt||y.collectedAt)}`)),_("div",{className:"docker-container-table table-wrap","data-testid":"docker-container-table"},_("table",null,_("thead",null,_("tr",null,_("th",null,"状态"),_("th",null,"容器"),_("th",null,"镜像"),_("th",null,"端口"),_("th",null,"运行时间"),_("th",null,"重启策略"),_("th",null,"PID"),_("th",null,"大小"))),_("tbody",null,j.length===0?_("tr",null,_("td",{colSpan:8},"暂无容器")):j.map((w)=>_("tr",{key:`${w.id}-${w.name}`},_("td",null,_(Tu,{status:cP(w.state)},w.state||"unknown")),_("td",null,_("strong",null,w.name||"--"),_("code",null,w.id||"--")),_("td",null,w.image||"--"),_("td",null,w.ports||_("span",{className:"muted"},"未发布")),_("td",null,w.runningFor||w.status||"--"),_("td",null,w.restartPolicy?_(Tu,{status:w.restartPolicy==="always"?"online":"warn"},w.restartPolicy):"--"),_("td",null,w.pidMode?_("code",null,w.pidMode):"--"),_("td",null,w.size||"--")))))))),_("div",{className:"docker-side-stack"},_(Pj,{title:"Images",items:F,render:(w)=>_("article",{key:`${w.id}-${w.repository}`,className:"docker-side-row"},_("strong",null,`${w.repository}:${w.tag}`),_("span",null,w.size||"--"),_("code",null,w.id||"--"))}),_(Pj,{title:"Volumes",items:U,limit:U.length,render:(w)=>_("article",{key:w.name,className:`docker-side-row volume-row ${c&&__(w)?"database-volume":""}`,"data-testid":c&&__(w)?"database-volume-row":void 0},_("strong",null,w.name),_("span",null,c&&__(w)?"PostgreSQL":EG(String(w.name||""))?"anonymous":"named"),_("code",null,w.mountpoint||w.driver||w.scope||"--"))}),_(Pj,{title:"Networks",items:W,render:(w)=>_("article",{key:w.id||w.name,className:"docker-side-row"},_("strong",null,w.name),_("span",null,w.driver||"--"),_("code",null,w.id||"--"))}))))}function AP({volume:l,volumeCount:u}){return _("section",{className:`docker-volume-focus ${l?"ready":"missing"}`,"data-testid":"database-volume-card"},_("div",{className:"volume-focus-head"},_("span",{className:"panel-eyebrow"},"Database Named Volume"),_(Tu,{status:l?"online":"warn"},l?"FOUND":"MISSING")),l?_("div",{className:"volume-focus-body"},_("strong",null,l.name),_("span",null,"PostgreSQL data volume for unidesk-database"),_("div",{className:"volume-route"},_("code",null,l.mountpoint||"/var/lib/docker/volumes/unidesk_pgdata_10gb/_data"),_("span",null,"->"),_("code",null,"unidesk-database:/var/lib/postgresql/data")),_("div",{className:"docker-meta compact"},_("span",null,`driver ${l.driver||"--"}`),_("span",null,`scope ${l.scope||"--"}`),_("span",null,`${u} volumes reported`))):_("div",{className:"volume-focus-body"},_("strong",null,"unidesk_pgdata_10gb"),_("span",null,"当前 Docker 快照没有发现数据库命名卷;请检查 provider-gateway 的 Docker volume 上报。")))}function Pj({title:l,items:u,render:r,limit:f}){let n=u.slice(0,f??12),i=Math.max(0,u.length-n.length);return _(uu,{title:l,eyebrow:`${u.length} items`,className:"docker-side-panel"},u.length===0?_(Qu,{title:`暂无 ${l}`,text:"等待 Docker 状态采集"}):_("div",{className:"docker-side-list"},n.map(r),i>0?_("div",{className:"docker-side-more"},`+ ${i} more`):null))}function jP({microservices:l,onRaw:u,onNavigate:r}){let f=l.filter((n)=>rG(n).public===!1);return _("div",{className:"microservice-page","data-testid":"microservice-catalog-page"},_(uu,{title:"用户服务目录",eyebrow:"Provider Mounted User Services"},_("div",{className:"metric-grid"},_(Nu,{label:"服务总数",value:l.length,hint:"config.json 用户服务登记"}),_(Nu,{label:"私有后端",value:f.length,hint:"不直接暴露公网",tone:"ok"}),_(Nu,{label:"D601 服务",value:l.filter((n)=>n.providerId==="D601").length,hint:"compute-node docker"}),_(Nu,{label:"集成前端",value:l.filter((n)=>n.frontend?.integrated).length,hint:"UniDesk React 页面"}))),_(uu,{title:"服务映射",eyebrow:"Repo Reference + Runtime"},l.length===0?_(Qu,{title:"暂无用户服务",text:"在 config.json 的 microservices 中登记用户服务的 provider、仓库引用和后端映射"}):_("div",{className:"table-wrap"},_("table",{className:"microservice-table"},_("thead",null,_("tr",null,_("th",null,"服务"),_("th",null,"Provider"),_("th",null,"代码引用"),_("th",null,"Docker 引用"),_("th",null,"后端映射"),_("th",null,"开发入口"),_("th",null,"运行态"),_("th",null,"操作"))),_("tbody",null,l.map((n)=>{let i=GG(n),t=XY(n),y=rG(n),c=i.availability||{},$=c.status||(i.providerStatus==="online"?"unknown":"unhealthy");return _("tr",{key:n.id,"data-testid":`microservice-row-${uf(n.id)}`},_("td",null,_("strong",null,n.name),_("code",null,n.id)),_("td",null,_("strong",null,i.providerName||n.providerId),_("code",null,n.providerId)),_("td",null,_("span",null,t.url||"--"),_("code",null,t.commitId||"--")),_("td",null,_("span",null,t.composeFile||"--"),_("code",null,`${t.composeService||"--"} / ${t.containerName||"--"}`)),_("td",null,_(Tu,{status:y.public?"warn":"online"},y.public?"public":"private"),_("code",null,`${y.nodeBindHost||"--"}:${y.nodePort||"--"} -> ${y.proxyMode||"--"}`)),_("td",null,_("span",null,n.development?.sshPassthrough?"SSH 透传":"未配置"),_("code",null,n.development?.worktreePath||"--")),_("td",null,_(Tu,{status:$==="healthy"?"online":$==="unknown"?"warn":"failed"},$),_("span",null,c.reason||i.providerStatus||"unknown"),_(t0,{data:i.container,empty:"容器快照未上报"})),_("td",null,_("div",{className:"microservice-actions"},n.id==="findjob"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","findjob"),"data-testid":"open-findjob-button"},"打开"):null,n.id==="pipeline"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","pipeline"),"data-testid":"open-pipeline-button"},"打开"):null,n.id==="todo-note"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","todo-note"),"data-testid":"open-todo-note-button"},"打开"):null,n.id==="met-nonlinear"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","met-nonlinear"),"data-testid":"open-met-nonlinear-button"},"打开"):null,n.id==="claudeqq"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","claudeqq"),"data-testid":"open-claudeqq-button"},"打开"):null,n.id==="baidu-netdisk"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","baidu-netdisk"),"data-testid":"open-baidu-netdisk-button"},"打开"):null,n.id==="oa-event-flow"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","oa-event-flow"),"data-testid":"open-oa-event-flow-button"},"打开"):null,n.id==="k3sctl-adapter"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","k3sctl"),"data-testid":"open-k3sctl-button"},"打开"):null,n.id==="code-queue"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","code-queue"),"data-testid":"open-code-queue-button"},"打开"):null,n.id==="mdtodo"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","mdtodo"),"data-testid":"open-mdtodo-button"},"打开"):null,n.id==="decision-center"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","decision-center"),"data-testid":"open-decision-center-button"},"打开"):null,n.id==="project-manager"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","project-manager"),"data-testid":"open-project-manager-button"},"打开"):null,_(du,{title:`用户服务 ${n.id}`,data:n,onOpen:u}))))}))))))}function FP({nodes:l,onDispatched:u,onRaw:r}){let f=l.filter((p)=>p.status==="online"),[n,i]=kl(f[0]?.providerId||l[0]?.providerId||""),[t,y]=kl("docker.ps"),[c,$]=kl("frontend"),[A,j]=kl("operator-check"),[F,U]=kl("normal"),[N,W]=kl(!1),[L,J]=kl(""),[w,Q]=kl(!1),[q,T]=kl(null),[O,Z]=kl("");En(()=>{if(!n&&(f[0]?.providerId||l[0]?.providerId))i(f[0]?.providerId||l[0].providerId)},[l.length,f.length,n]);function E(){return{source:c,note:A,priority:F}}function D(){J(JSON.stringify(E(),null,2)),W(!0)}async function Y(p){p.preventDefault(),Q(!0),Z("");try{let V=N?JSON.parse(L||"{}"):E(),B=await ml(`${sl.apiBaseUrl}/dispatch`,{method:"POST",body:JSON.stringify({providerId:n,command:t,payload:V})});T(B),await u()}catch(V){Z(El(V,"下发失败"))}finally{Q(!1)}}return _("div",{className:"page-grid dispatch-grid"},_(uu,{title:"下发任务",eyebrow:"Real WebSocket Dispatch"},_("form",{className:"dispatch-form",onSubmit:Y},_("label",null,"Provider",_("select",{value:n,onChange:(p)=>i(p.target.value)},l.map((p)=>_("option",{key:p.providerId,value:p.providerId},`${p.name} / ${p.providerId}`)))),_("label",null,"Command",_("select",{value:t,onChange:(p)=>y(p.target.value)},_("option",{value:"docker.ps"},"docker.ps"),_("option",{value:"host.ssh"},"host.ssh"),_("option",{value:"microservice.http"},"microservice.http"),_("option",{value:"echo"},"echo"))),_("label",null,"来源",_("input",{value:c,onChange:(p)=>$(p.target.value)})),_("label",null,"备注",_("input",{value:A,onChange:(p)=>j(p.target.value)})),_("label",null,"优先级",_("select",{value:F,onChange:(p)=>U(p.target.value)},_("option",{value:"normal"},"normal"),_("option",{value:"low"},"low"),_("option",{value:"urgent"},"urgent"))),_("div",{className:"dispatch-actions"},_("button",{type:"button",className:"ghost-btn",onClick:D},"查看原始JSON"),_("button",{type:"submit",disabled:w||!n},w?"下发中":"下发任务")),N?_("label",{className:"raw-editor-label"},"高级 Payload",_("textarea",{className:"raw-editor",value:L,onChange:(p)=>J(p.target.value)})):null,_(lu,{error:O,wide:!0}))),_(uu,{title:"下发结果",eyebrow:"Response"},q?_("div",{className:"result-card"},_(Tu,{status:q.status||"queued"},q.status||"queued"),_("dl",null,_("dt",null,"Task ID"),_("dd",null,_("code",null,q.taskId||"--")),_("dt",null,"Provider 在线"),_("dd",null,ft(q.providerOnline))),_(du,{title:"Dispatch Response",data:q,onOpen:r})):_(Qu,{title:"等待操作",text:"任务响应会以结构化结果卡展示"})))}function yG({task:l,onRaw:u}){return _("article",{className:"compact-row"},_(Tu,{status:l.status}),_("div",null,_("strong",null,l.command),_("code",null,l.id)),_("span",null,hy(l)?`已等待 ${Mj(l.updatedAt)}`:`耗时 ${fi(jG(l)??0)}`),_(du,{title:`Task ${l.id}`,data:j_(l),onOpen:u}))}function OG({task:l}){let u=jG(l),r=hy(l);return _("div",{className:"task-duration"},_("strong",null,u===null?"--":fi(u)),_("span",null,r?`已运行 / 创建 ${Wl(l.createdAt)}`:`创建 ${Wl(l.createdAt)}`))}function JP({task:l}){let u=String(l?.status||"").toLowerCase(),r=l?.result,f=r&&typeof r==="object"&&!Array.isArray(r)?r:{},i=["exitCode","code","signal","timeoutMs","previousStatus","mode"].filter((t)=>f[t]!==void 0&&f[t]!==null);if(u==="failed"){let t=FG(l);return _("div",{className:"task-diagnostic failed"},_("b",null,"失败原因"),_("span",{className:"diagnostic-reason"},ft(t)),i.length>0?_("div",{className:"diagnostic-meta"},i.map((y)=>_("span",{key:y,className:"data-chip"},_("b",null,y),_("span",null,ft(f[y]))))):null)}if(hy(l))return _("div",{className:"task-diagnostic warn"},_("b",null,"等待终态"),_("span",null,`最后更新 ${Mj(l.updatedAt)} 前`));return _("div",{className:"task-diagnostic ok"},_("b",null,"完成摘要"),_(t0,{data:r,empty:"无执行输出"}))}function UP({tasks:l,onRaw:u}){let r=l.filter(hy);return _("div",{"data-testid":"pending-task-page"},_(uu,{title:"待处理任务",eyebrow:`${r.length} Pending`},r.length===0?_(Qu,{title:"当前无待处理任务",text:"queued / dispatched / running 会在超时后自动转为 failed;历史记录仍可在任务历史中查看"}):_("div",{className:"table-wrap","data-testid":"pending-task-table"},_("table",null,_("thead",null,_("tr",null,_("th",null,"状态"),_("th",null,"任务"),_("th",null,"Provider"),_("th",null,"已等待"),_("th",null,"载荷摘要"),_("th",null,"操作"))),_("tbody",null,r.map((f)=>_("tr",{key:f.id},_("td",null,_(Tu,{status:f.status})),_("td",null,_("strong",null,f.command),_("code",null,f.id)),_("td",null,_("code",null,f.providerId)),_("td",null,Mj(f.updatedAt)),_("td",null,_(t0,{data:f.payload})),_("td",null,_(du,{title:`Pending Task ${f.id}`,data:j_(f),onOpen:u})))))))))}function NP({tasks:l,onRaw:u}){return _("div",{"data-testid":"task-history-page"},_(uu,{title:"任务历史",eyebrow:`${l.length} Tasks`},l.length===0?_(Qu,{title:"暂无任务",text:"下发任务后会在这里看到生命周期"}):_("div",{className:"table-wrap"},_("table",{className:"task-history-table"},_("thead",null,_("tr",null,_("th",null,"状态"),_("th",null,"任务"),_("th",null,"Provider"),_("th",null,"任务耗时"),_("th",null,"载荷摘要"),_("th",null,"诊断信息"),_("th",null,"更新时间"),_("th",null,"操作"))),_("tbody",null,l.map((r)=>_("tr",{key:r.id,"data-testid":`task-row-${uf(r.id)}`},_("td",null,_(Tu,{status:r.status})),_("td",null,_("strong",null,r.command),_("code",null,r.id)),_("td",null,_("code",null,r.providerId)),_("td",null,_(OG,{task:r})),_("td",null,_(t0,{data:r.payload})),_("td",null,_(JP,{task:r})),_("td",null,Wl(r.updatedAt)),_("td",null,_(du,{title:`Task ${r.id}`,data:j_(r),onOpen:u})))))))))}function QP({tasks:l,onRaw:u}){let r=l.filter((f)=>["succeeded","failed"].includes(f.status));return _(uu,{title:"执行结果",eyebrow:"Finished Tasks"},r.length===0?_(Qu,{title:"暂无结果",text:"任务完成后展示 provider 返回的结构化摘要"}):_("div",{className:"result-grid"},r.map((f)=>_("article",{key:f.id,className:"result-card"},_("div",{className:"node-card-head"},_("strong",null,f.command),_(Tu,{status:f.status})),_("code",null,f.id),_(t0,{data:f.result,empty:"无执行输出"}),_(du,{title:`Task Result ${f.id}`,data:j_(f),onOpen:u})))))}function wP(l){if(!l||typeof l!=="object")return"--";if(l.type==="interval")return`每 ${fi(Number(l.everySeconds||0))}`;return`每天 ${l.timeOfDay||"03:00"} UTC`}function qP(l){if(!l||typeof l!=="object")return"--";if(l.type==="pgdata_backup")return`PGDATA -> ${l.remoteBaseDir||"/SERVER_DATA/UNIDESK_PG_DATA"}`;if(l.type==="dispatch")return`${l.providerId||"--"} / ${l.command||"--"}`;return String(l.type||"--")}function WP(l){let u=String(l||"").toLowerCase();if(u==="succeeded")return"online";if(u==="failed")return"failed";if(u==="running"||u==="queued")return"warn";return u}function LP(l){let u=Number(l?.durationMs);if(Number.isFinite(u)&&u>=0)return fi(u/1000);let r=On(l?.startedAt||l?.createdAt);if(r===null)return"--";let n=On(l?.finishedAt)??Date.now();return fi(Math.max(0,(n-r)/1000))}function cG(l){return{id:"unidesk-pgdata-baidu-daily",name:"PGDATA daily Baidu Netdisk backup",description:"Daily PostgreSQL physical base backup uploaded to Baidu Netdisk /SERVER_DATA with monthly rotation.",enabled:!0,timeOfDay:"03:30",actionType:"pgdata_backup",providerId:l[0]?.providerId||"main-server",command:"echo",payloadJson:JSON.stringify({source:"scheduled-task",message:"hello from scheduler"},null,2),remoteBaseDir:"/SERVER_DATA/UNIDESK_PG_DATA",stagingSubdir:"server-data/unidesk-pg-data",timeoutMs:"3600000"}}function GP({schedules:l,scheduleRuns:u,nodes:r,refresh:f,onRaw:n}){let[i,t]=kl(cG(r||[])),[y,c]=kl(!1),[$,A]=kl(""),[j,F]=kl(""),U=[...u||[]].sort((q,T)=>(On(T.updatedAt)??0)-(On(q.updatedAt)??0));function N(q,T){t((O)=>({...O,[q]:T}))}function W(q){let T=q?.action||{};t({id:q?.id||"",name:q?.name||"",description:q?.description||"",enabled:q?.enabled!==!1,timeOfDay:q?.schedule?.timeOfDay||"03:30",actionType:T.type||"dispatch",providerId:T.providerId||r[0]?.providerId||"main-server",command:T.command||"echo",payloadJson:JSON.stringify(T.payload||{source:"scheduled-task"},null,2),remoteBaseDir:T.remoteBaseDir||"/SERVER_DATA/UNIDESK_PG_DATA",stagingSubdir:T.stagingSubdir||"server-data/unidesk-pg-data",timeoutMs:String(T.timeoutMs||3600000)}),F(`正在编辑 ${q?.id||""}`)}function L(){let q={id:i.id,name:i.name,description:i.description,enabled:i.enabled,concurrencyPolicy:"skip",schedule:{type:"daily",timeOfDay:i.timeOfDay,timezone:"Etc/UTC"}};if(i.actionType==="pgdata_backup")return{...q,action:{type:"pgdata_backup",volumeName:"unidesk_pgdata_10gb",remoteBaseDir:i.remoteBaseDir,stagingSubdir:i.stagingSubdir,timeoutMs:Number(i.timeoutMs)||3600000,cleanupLocal:!0}};return{...q,action:{type:"dispatch",providerId:i.providerId,command:i.command,payload:JSON.parse(i.payloadJson||"{}"),timeoutMs:Number(i.timeoutMs)||600000}}}async function J(q){q.preventDefault(),c(!0),A(""),F("");try{let T=L(),O=encodeURIComponent(String(T.id));await ml(`${sl.apiBaseUrl}/schedules/${O}`,{method:"PUT",body:JSON.stringify(T)}),F("定时任务已保存"),await f()}catch(T){A(El(T,"保存定时任务失败"))}finally{c(!1)}}async function w(q){if(!q?.id)return;c(!0),A(""),F("");try{await ml(`${sl.apiBaseUrl}/schedules/${encodeURIComponent(q.id)}`,{method:"DELETE"}),F(`已删除 ${q.id}`),await f()}catch(T){A(El(T,"删除定时任务失败"))}finally{c(!1)}}async function Q(q){if(!q?.id)return;c(!0),A(""),F("");try{let T=await ml(`${sl.apiBaseUrl}/schedules/${encodeURIComponent(q.id)}/run`,{method:"POST",body:"{}"});F(`已触发 ${q.id} / ${T?.run?.id||"run"}`),await f()}catch(T){A(El(T,"触发定时任务失败"))}finally{c(!1)}}return _("div",{className:"page-grid scheduled-task-page","data-testid":"scheduled-task-page"},_(uu,{title:"定时任务",eyebrow:`${(l||[]).length} Schedules`},(l||[]).length===0?_(Qu,{title:"暂无定时任务",text:"创建 daily / dispatch / PGDATA backup 任务后会在这里展示下一次执行时间和最近结果"}):_("div",{className:"schedule-card-grid"},(l||[]).map((q)=>_("article",{key:q.id,className:"schedule-card","data-testid":`schedule-row-${uf(q.id)}`},_("div",{className:"node-card-head"},_("strong",null,q.name||q.id),_(Tu,{status:q.enabled?"online":"warn"},q.enabled?"enabled":"disabled")),_("code",null,q.id),_("dl",null,_("dt",null,"计划"),_("dd",null,wP(q.schedule)),_("dt",null,"动作"),_("dd",null,qP(q.action)),_("dt",null,"下次执行"),_("dd",null,Wl(q.nextRunAt)),_("dt",null,"最近执行"),_("dd",null,q.lastRunAt?`${Wl(q.lastRunAt)} / ${q.lastRunId||"--"}`:"--")),_("div",{className:"dispatch-actions"},_("button",{type:"button",className:"ghost-btn",disabled:y,onClick:()=>W(q)},"编辑"),_("button",{type:"button",className:"ghost-btn",disabled:y,onClick:()=>Q(q),"data-testid":`schedule-run-${uf(q.id)}`},"手动触发"),_("button",{type:"button",className:"ghost-btn danger",disabled:y,onClick:()=>w(q)},"删除"),_(du,{title:`Schedule ${q.id}`,data:q,onOpen:n})))))),_(uu,{title:i.id?"配置定时任务":"新建定时任务",eyebrow:"CRUD"},_("form",{className:"dispatch-form schedule-form",onSubmit:J},_("label",null,"ID",_("input",{value:i.id,onChange:(q)=>N("id",q.target.value)})),_("label",null,"名称",_("input",{value:i.name,onChange:(q)=>N("name",q.target.value)})),_("label",null,"每日执行时间 UTC",_("input",{value:i.timeOfDay,placeholder:"03:30",onChange:(q)=>N("timeOfDay",q.target.value)})),_("label",null,"启用",_("select",{value:i.enabled?"true":"false",onChange:(q)=>N("enabled",q.target.value==="true")},_("option",{value:"true"},"enabled"),_("option",{value:"false"},"disabled"))),_("label",null,"动作类型",_("select",{value:i.actionType,onChange:(q)=>N("actionType",q.target.value)},_("option",{value:"pgdata_backup"},"PGDATA 备份到百度网盘"),_("option",{value:"dispatch"},"Provider Dispatch"))),i.actionType==="pgdata_backup"?[_("label",{key:"remote"},"网盘根目录",_("input",{value:i.remoteBaseDir,onChange:(q)=>N("remoteBaseDir",q.target.value)})),_("label",{key:"staging"},"本地 staging 子目录",_("input",{value:i.stagingSubdir,onChange:(q)=>N("stagingSubdir",q.target.value)}))]:[_("label",{key:"provider"},"Provider",_("select",{value:i.providerId,onChange:(q)=>N("providerId",q.target.value)},(r||[]).map((q)=>_("option",{key:q.providerId,value:q.providerId},`${q.name} / ${q.providerId}`)))),_("label",{key:"command"},"Command",_("select",{value:i.command,onChange:(q)=>N("command",q.target.value)},_("option",{value:"echo"},"echo"),_("option",{value:"docker.ps"},"docker.ps"),_("option",{value:"host.ssh"},"host.ssh"),_("option",{value:"microservice.http"},"microservice.http"))),_("label",{key:"payload",className:"raw-editor-label"},"Payload JSON",_("textarea",{className:"raw-editor",value:i.payloadJson,onChange:(q)=>N("payloadJson",q.target.value)}))],_("label",null,"超时 ms",_("input",{value:i.timeoutMs,onChange:(q)=>N("timeoutMs",q.target.value)})),_("label",{className:"raw-editor-label"},"描述",_("textarea",{className:"raw-editor compact",value:i.description,onChange:(q)=>N("description",q.target.value)})),_("div",{className:"dispatch-actions"},_("button",{type:"button",className:"ghost-btn",disabled:y,onClick:()=>t(cG(r||[]))},"重置"),_("button",{type:"submit",disabled:y||!i.id},y?"保存中":"保存任务")),j?_("p",{className:"muted paragraph"},j):null,_(lu,{error:$,wide:!0}))),_(uu,{title:"历史执行记录",eyebrow:`${U.length} Runs`},U.length===0?_(Qu,{title:"暂无执行记录",text:"定时触发或手动触发后会生成 run history"}):_("div",{className:"table-wrap"},_("table",{className:"task-history-table schedule-run-table"},_("thead",null,_("tr",null,_("th",null,"状态"),_("th",null,"任务"),_("th",null,"触发"),_("th",null,"耗时"),_("th",null,"结果摘要"),_("th",null,"更新时间"),_("th",null,"操作"))),_("tbody",null,U.map((q)=>_("tr",{key:q.id,"data-testid":`schedule-run-row-${uf(q.id)}`},_("td",null,_(Tu,{status:WP(q.status)},q.status)),_("td",null,_("strong",null,q.scheduleId),_("code",null,q.id),q.taskId?_("code",null,q.taskId):null),_("td",null,q.trigger||"--"),_("td",null,LP(q)),_("td",null,_(t0,{data:q.result||q.error,empty:"无结果"})),_("td",null,Wl(q.updatedAt)),_("td",null,_(du,{title:`Schedule Run ${q.id}`,data:q,onOpen:n})))))))))}function TP({data:l}){let u=l.overview||{};return _("div",{className:"page-grid topology-grid"},_(uu,{title:"公开入口",eyebrow:"Public"},_("div",{className:"endpoint-list"},_("article",null,_("b",null,"Frontend"),_("span",null,sl.frontendPublicUrl||window.location.origin),_(Tu,{status:"online"},"public")),_("article",null,_("b",null,"Provider Ingress"),_("span",null,sl.providerIngressPublicUrl||"ws://public/ws/provider"),_(Tu,{status:"online"},"public")))),_(uu,{title:"内部服务",eyebrow:"Docker Network Only"},_("div",{className:"endpoint-list"},_("article",null,_("b",null,"backend-core API"),_("span",null,"http://backend-core:8080"),_(Tu,{status:"internal"},"internal")),_("article",null,_("b",null,"database"),_("span",null,"postgres://database:5432/unidesk"),_(Tu,{status:"internal"},"internal")))),_(uu,{title:"运行态",eyebrow:"Runtime"},_("div",{className:"metric-grid"},_(Nu,{label:"DB Ready",value:u.dbReady?"YES":"NO",hint:"internal health"}),_(Nu,{label:"Online Nodes",value:u.onlineNodeCount??0,hint:"provider-gateway self-link"}))))}function mP({session:l}){return _(uu,{title:"认证策略",eyebrow:"Frontend Login"},_("div",{className:"policy-grid"},_("article",null,_("span",null,"默认账号"),_("strong",null,sl.authUsername||"admin")),_("article",null,_("span",null,"当前会话"),_("strong",null,l?.user?.username||"--")),_("article",null,_("span",null,"Session TTL"),_("strong",null,`${sl.sessionTtlSeconds||0}s`)),_("article",null,_("span",null,"API 访问"),_("strong",null,"同源 Cookie 保护"))),_("p",{className:"muted paragraph"},"浏览器只访问 frontend 同源接口;frontend 容器使用 Docker 内网代理 backend-core API。"))}function KP(){return _(uu,{title:"安全边界",eyebrow:"Exposure Rule"},_("div",{className:"security-board"},_("article",{className:"allow"},_("b",null,"允许公网"),_("span",null,"frontend 登录入口"),_("span",null,"provider ingress WebSocket/health")),_("article",{className:"deny"},_("b",null,"禁止公网"),_("span",null,"backend-core REST API"),_("span",null,"PostgreSQL database")),_("article",null,_("b",null,"数据库卷"),_("span",null,"named volume unidesk_pgdata_10gb"),_("span",null,"CLI stop/start 不删除数据卷"))))}function zP({activeModule:l,activeTab:u,data:r,session:f,refresh:n,onRaw:i,onNavigate:t}){if(l==="ops"&&u==="status")return _(xY,{data:r,onRaw:i,onNavigate:t});if(l==="ops"&&u==="performance")return _(nP,{onRaw:i});if(l==="ops"&&u==="events")return _(vY,{events:r.events,onRaw:i});if(l==="ops"&&u==="logs")return _(sY,{logs:r.logs,onRaw:i});if(l==="nodes"&&u==="list")return _(kY,{nodes:r.nodes,onRaw:i});if(l==="nodes"&&u==="monitor")return _(aY,{nodes:r.nodes,systemStatuses:r.systemStatuses,tasks:r.tasks,onRaw:i,refresh:n});if(l==="nodes"&&u==="docker")return _($P,{nodes:r.nodes,dockerStatuses:r.dockerStatuses,onRaw:i});if(l==="nodes"&&u==="gateway")return _(yP,{nodes:r.nodes,tasks:r.tasks,onRaw:i});if(l==="nodes"&&u==="labels")return _(gY,{nodes:r.nodes});if(l==="nodes"&&u==="heartbeats")return _(IY,{nodes:r.nodes});if(l==="tasks"&&u==="dispatch")return _(FP,{nodes:r.nodes,onDispatched:n,onRaw:i});if(l==="tasks"&&u==="scheduled")return _(GP,{schedules:r.schedules,scheduleRuns:r.scheduleRuns,nodes:r.nodes,refresh:n,onRaw:i});if(l==="tasks"&&u==="pending")return _(UP,{tasks:r.pendingTasks,onRaw:i});if(l==="tasks"&&u==="history")return _(NP,{tasks:r.tasks,onRaw:i});if(l==="tasks"&&u==="results")return _(QP,{tasks:r.tasks,onRaw:i});if(l==="apps"&&u==="catalog")return _(jP,{microservices:r.microservices,onRaw:i,onNavigate:t});if(l==="apps"&&u==="todo-note")return _(xL,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="findjob")return _(BQ,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="pipeline")return _(SL,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="met-nonlinear")return _(YQ,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="claudeqq")return _(fN,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="baidu-netdisk")return _(lN,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="filebrowser")return _(HQ,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="oa-event-flow")return _(gQ,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="k3sctl")return _(IL,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl,onNavigate:t});if(l==="apps"&&u==="code-queue")return _(GQ,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl,initialTasksData:mY});if(l==="apps"&&u==="mdtodo")return _(hQ,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="decision-center")return _(KQ,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="project-manager")return _(PL,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="config"&&u==="topology")return _(TP,{data:r});if(l==="config"&&u==="auth")return _(mP,{session:f});if(l==="config"&&u==="security")return _(KP);return _(Qu,{title:"未找到页面",text:"请选择左侧主模块和顶部子功能标签"})}function EP({session:l,onLogout:u}){let r=k2(Xf,window.location.pathname),[f,n]=kl(r.moduleId),[i,t]=kl({...Nc,[r.moduleId]:r.tabId}),[y,c]=kl({overview:null,nodes:[],systemStatuses:[],dockerStatuses:[],microservices:[],events:[],tasks:[],pendingTasks:[],schedules:[],scheduleRuns:[],logs:[]}),[$,A]=kl({ok:!1,text:"连接中"}),[j,F]=kl(null),[U,N]=kl(new Date),[W,L]=kl(null),[J,w]=kl(!1),[Q,q]=kl(!1),T=i0.default.useRef(!1),O=Xf.moduleById[f]||Xf.modules[0],Z=i[f]||Nc[f]||O.tabs[0].id,E=Array.isArray(y.microservices)?y.microservices:[],D=E.length===0&&f==="apps"&&Z==="code-queue"?[KY]:E,Y=D===E?y:{...y,microservices:D},p=f==="apps"?D.find((I)=>String(I?.id||"")===(Z==="k3sctl"?"k3sctl-adapter":Z)):null,V=p?GG(p):{},B=O.tabs.find((I)=>I.id===Z)?.label||Z,m=p?[{key:"microservice",label:"用户服务",value:`${B} ${V.providerStatus==="online"?"在线":V.providerStatus||"未知"}`,tone:V.providerStatus==="online"?"ok":"warn",testId:"active-microservice-status"}]:[];async function X(){if(T.current)return;T.current=!0,q(!0);try{let I=[],M=(Ql,Ol)=>{I.push([Ql,ml(Ol)])},rl=f==="ops"&&Z==="status",cl=rl||f==="config"&&Z==="topology",$l=rl||f==="nodes"||f==="tasks"&&(Z==="dispatch"||Z==="scheduled"),Tl=f==="apps"&&Z!=="code-queue";if(cl)M("overview",`${sl.apiBaseUrl}/overview`);if($l)M("nodes",`${sl.apiBaseUrl}/nodes`);if(f==="nodes"&&Z==="monitor")M("systemStatuses",`${sl.apiBaseUrl}/nodes/system-status?limit=60`),M("tasks",`${sl.apiBaseUrl}/tasks?limit=120&summary=1`);else if(f==="nodes"&&Z==="docker")M("dockerStatuses",`${sl.apiBaseUrl}/nodes/docker-status`);else if(f==="nodes"&&Z==="gateway")M("tasks",`${sl.apiBaseUrl}/tasks?limit=300&summary=1`);else if(f==="tasks"&&Z==="scheduled")M("schedules",`${sl.apiBaseUrl}/schedules?limit=100`),M("scheduleRuns",`${sl.apiBaseUrl}/schedules/runs?limit=100`);else if(f==="tasks"&&Z==="pending")M("pendingTasks",`${sl.apiBaseUrl}/tasks?status=pending&limit=100&summary=1`);else if(f==="tasks"&&(Z==="history"||Z==="results"))M("tasks",`${sl.apiBaseUrl}/tasks?limit=300&summary=1`);else if(rl)M("tasks",`${sl.apiBaseUrl}/tasks?limit=8&lite=1`),M("pendingTasks",`${sl.apiBaseUrl}/tasks?status=pending&limit=20&lite=1`);if(Tl)M("microservices",`${sl.apiBaseUrl}/microservices`);if(f==="ops"&&Z==="events")M("events",`${sl.apiBaseUrl}/events?limit=100`);if(f==="ops"&&Z==="logs")M("logs","/logs?limit=100");await Promise.all(I.map(async([Ql,Ol])=>{let h=await Ol,a={};if(Ql==="overview")a.overview=h;if(Ql==="nodes")a.nodes=h.nodes||[];if(Ql==="systemStatuses")a.systemStatuses=h.systemStatuses||[];if(Ql==="dockerStatuses")a.dockerStatuses=h.dockerStatuses||[];if(Ql==="microservices")a.microservices=h.microservices||[];if(Ql==="events")a.events=h.events||[];if(Ql==="tasks")a.tasks=h.tasks||[];if(Ql==="pendingTasks")a.pendingTasks=h.tasks||[];if(Ql==="schedules")a.schedules=h.schedules||[];if(Ql==="scheduleRuns")a.scheduleRuns=h.runs||[];if(Ql==="logs")a.logs=h.logs||[];c((ul)=>({...ul,...a}))})),A({ok:!0,text:"核心在线"}),F(new Date)}catch(I){if(A({ok:!1,text:El(I,"连接失败")}),I.status===401)u(!1)}finally{T.current=!1,q(!1)}}En(()=>{let I=()=>{if(!eL())return;X()};I();let M=setInterval(I,EY(f,Z)),rl=()=>{if(eL())I()};return document.addEventListener("visibilitychange",rl),()=>{clearInterval(M),document.removeEventListener("visibilitychange",rl)}},[f,Z]),En(()=>{let I=setInterval(()=>N(new Date),1000);return()=>clearInterval(I)},[]),En(()=>{let I=vQ(Xf,window.location.pathname);if(I&&window.location.pathname!==I)window.history.replaceState(null,"",I)},[]),En(()=>{let I=()=>{let M=k2(Xf,window.location.pathname);n(M.moduleId),t((rl)=>({...rl,[M.moduleId]:M.tabId})),L(null)};return window.addEventListener("popstate",I),()=>window.removeEventListener("popstate",I)},[]),En(()=>{window.scrollTo({top:0,left:0,behavior:"auto"})},[f,Z]);function S(I,M,rl="push"){let cl=Xf.moduleById[I]?I:Xf.fallbackTarget.moduleId,$l=Xf.moduleById[cl]?.tabs.some((Ql)=>Ql.id===M)?M:Nc[cl]||Xf.moduleById[cl]?.tabs[0]?.id||Xf.fallbackTarget.tabId;n(cl),t((Ql)=>({...Ql,[cl]:$l}));let Tl=H3(Xf,cl,$l);if(window.location.pathname!==Tl){let Ql=rl==="replace"?"replaceState":"pushState";window.history[Ql](null,"",Tl)}}function b(I,M){L({title:I,data:M})}let[z,P]=kl(!1),{unreadCount:s,notifications:k}=Xr(),v=k.length>0?k[k.length-1]:null,tl=AG(dL);return _("div",{className:`shell ${J?"rail-collapsed":""} ${tl?"dev-shell":""}`,"data-testid":"app-shell"},_(hY,{activeModule:f,activeTabs:i,onNavigate:S,collapsed:J,onToggle:()=>w((I)=>!I)}),_("main",{className:"workspace"},_(CY,{connection:$,lastRefresh:j,onRefresh:X,onLogout:()=>u(!0),session:l,clock:U,activeStatusItems:m,onNotificationToggle:()=>P((I)=>!I),unreadCount:s,environment:dL}),_(RY,{module:O,activeTab:Z,onNavigate:S}),_(Cj.Provider,{value:Q},_(zP,{activeModule:f,activeTab:Z,data:Y,session:l,refresh:X,onRaw:b,onNavigate:S}))),_(YY,{raw:W,onClose:()=>L(null)}),v&&_(oL,{key:v.id,notification:v}),z&&_(aL,{onClose:()=>P(!1)}))}function OP(){let[l,u]=kl(!0),[r,f]=kl(null);async function n(){u(!0);try{let t=await ml("/api/session");f(t.authenticated?t:null)}catch{f(null)}finally{u(!1)}}async function i(t){if(t)try{await ml("/logout",{method:"POST"})}catch{}f(null)}if(En(()=>{n()},[]),l)return _("main",{className:"loading-screen"},_("div",{className:"brand-mark"},"UD"),_("span",null,"加载会话"));if(!r)return _(PY,{onLogin:f});return _(eU,null,_(EP,{session:r,onLogout:i}))}var ZG=document.getElementById("root");if(ZG===null)throw Error("root element not found");_G.createRoot(ZG).render(_(OP));})(); + </svg>`,width:W,height:L}}function kA(l,u){let r=URL.createObjectURL(l),f=document.createElement("a");f.href=r,f.download=u,f.click(),setTimeout(()=>URL.revokeObjectURL(r),1000)}async function zL(l,u){let r=KL(u,"pipeline"),{svg:f,width:n,height:i}=NX(l,u),t=new Blob([f],{type:"image/svg+xml;charset=utf-8"}),y=URL.createObjectURL(t);try{let c=new Image;await new Promise((F,U)=>{c.onload=()=>F(),c.onerror=()=>U(Error("svg image load failed")),c.src=y});let $=document.createElement("canvas");$.width=n,$.height=i;let A=$.getContext("2d");if(!A)throw Error("canvas unavailable");A.drawImage(c,0,0);let j=await new Promise((F)=>$.toBlob(F,"image/png"));if(!j)throw Error("png export failed");kA(j,`${r}.png`)}catch{kA(t,`${r}.svg`)}finally{URL.revokeObjectURL(y)}}async function LX(l){let u=KL(String(l?.title||"pipeline-gantt"),"pipeline-gantt"),{svg:r,width:f,height:n}=WX(l),i=new Blob([r],{type:"image/svg+xml;charset=utf-8"}),t=URL.createObjectURL(i);try{let y=new Image;await new Promise((j,F)=>{y.onload=()=>j(),y.onerror=()=>F(Error("gantt svg image load failed")),y.src=t});let c=document.createElement("canvas");c.width=f,c.height=n;let $=c.getContext("2d");if(!$)throw Error("canvas unavailable");$.drawImage(y,0,0);let A=await new Promise((j)=>c.toBlob(j,"image/png"));if(!A)throw Error("gantt png export failed");kA(A,`${u}.png`)}catch{kA(i,`${u}.svg`)}finally{URL.revokeObjectURL(t)}}async function GX(l){for(let u of l){if(u.flow.nodes.length===0)continue;await zL(u.flow,u.title),await new Promise((r)=>setTimeout(r,750))}}function _L(l,u){return l.find((r)=>String(r?.pipelineId||"")===u)||null}function $L(l){return al(l?.startedAt)??al(l?.artifact?.startedAt)??al(l?.request?.createdAt)??al(l?.updatedAt)??0}function TX(l,u){return l.filter((r)=>String(r?.pipelineId||"")===u).slice().sort((r,f)=>$L(r)-$L(f)||String(r?.runId||"").localeCompare(String(f?.runId||"")))}function Jj(l,u){let r=String(u?.runId||""),f=l.findIndex((t)=>String(t?.runId||"")===r),n=f>=0?f+1:l.length,i=String(u?.status||"--");return`Epoch ${n} / ${r||"--"} / ${i}`}function Sf(l){return String(l?.procedureRunId||l?.runId||"")}function oA(l,u){let r=String(l?.nodeId||l?.request?.nodeId||"");if(r)return r;let f=Sf(l),n=`${u}__`;if(f.startsWith(n))return f.slice(n.length).replace(/__\d+$/u,"");return""}function PA(l,u){let r=Yl(l?.artifact)?l.artifact:{},f=Yl(l?.request)?l.request:{};return t_(l?.startedAt,r.startedAt,f.createdAt,f.startedAt,l?.createdAt,l?.updatedAt,u?.startedAt,u?.request?.createdAt)}function CA(l,u){let r=String(l?.status?.status||l?.artifact?.status||l?.status||"").toLowerCase(),f=Yl(l?.artifact)?l.artifact:{},n=wj(r);return t_(l?.finishedAt,f.finishedAt,l?.completedAt,n?l?.updatedAt:void 0,n?f.updatedAt:void 0,n?u?.updatedAt:void 0)}function EL(l,u,r=Date.now()){let f=String(l?.runId||""),n=new Set(u.map((i)=>String(i?.id||"")).filter(Boolean));return Sl(l?.procedureRuns).flatMap((i)=>{let t=oA(i,f);if(!t)return[];let y=String(i?.status?.status||i?.artifact?.status||i?.status||"unknown").toLowerCase(),c=PA(i,l),$=al(c);if($===null)return[];let A=CA(i,l),j=al(A)??(wj(y)?al(i?.updatedAt)??$+1000:r),F=Math.max($+1000,j);return[{nodeId:t,knownNode:n.has(t),procedureRunId:Sf(i),status:y,startMs:$,endMs:F,startedAt:f_($),finishedAt:f_(F),durationMs:F-$,runId:f,raw:i}]}).sort((i,t)=>i.startMs-t.startMs||i.endMs-t.endMs||i.nodeId.localeCompare(t.nodeId))}function mX(l,u,r=[]){let f=u.map((A)=>Number(A.startMs)).filter(Number.isFinite),n=u.map((A)=>Number(A.endMs)).filter(Number.isFinite);for(let A of r){let j=Xu(A?.eventMs??A?.ms);if(j!==null)f.push(j),n.push(j)}let i=al(l?.startedAt)??al(l?.artifact?.startedAt)??al(l?.request?.createdAt),t=al(l?.finishedAt)??al(l?.artifact?.finishedAt)??al(l?.updatedAt);if(i!==null)f.push(i);if(t!==null)n.push(t);let y=Date.now(),c=f.length>0?Math.min(...f):y-60000,$=Math.max(c+60000,n.length>0?Math.max(...n):y);return{startMs:c,endMs:$,durationMs:$-c}}var MA=12,OL=20,Uj=100,KX=!1;function r0(l){let u=Number(l);if(!Number.isFinite(u))return 0;return Math.max(0,Math.min(100,Math.round(u*100)/100))}function zX(l){let u=Math.max(MA,Number(l||MA)),r=Math.log(u/MA)/Math.log(OL);return r0(r*100)}var i_=zX(Uj);function mj(l){let u=r0(l)/100,r=MA*Math.pow(OL,u),f=u<0.24?"全局":u<0.64?"均衡":"细节";return{value:r0(u*100),pxPerMinute:r,label:f}}function yj(l){let u=Math.round(Number(l));return Math.abs(u-Uj)<=1?Uj:u}function EX(l,u=i_){let r=Math.max(1,Number(l.durationMs||0)/60000),f=mj(u);return Math.round(Math.max(360,Math.min(7200,r*Number(f.pxPerMinute||48))))}function OX(l,u=7){let r=Math.max(1,Number(l.endMs||0)-Number(l.startMs||0));return Array.from({length:u},(f,n)=>{let i=u===1?0:n/(u-1);return{ms:Number(l.startMs)+r*i,percent:i*100}})}function ZX(l,u){let r=Math.max(1,Number(u.endMs)-Number(u.startMs));return Math.max(0,Math.min(100,(l-Number(u.startMs))/r*100))}function Xu(l){let u=Number(l);return Number.isFinite(u)?u:null}function Kj(l){return JL(l?.status)&&!wj(l?.status)}function ZL(l,u,r,f){let n=Math.max(1,r-u),i=Math.max(0,Math.min(1,(l-u)/n));return Number((i*f).toFixed(3))}function AL(l,u){if(!u)return null;let r=Xu(u?.startMs),f=Xu(u?.endMs),n=Xu(u?.chartHeight);if(r===null||f===null||n===null)return null;return ZL(l,r,f,n)}function pL(l,u){let r=Xu(l?.rawStartMs??l?.startMs)??Xu(l?.startMs)??u,f=Xu(l?.endMs)??r+1000;if(!Kj(l))return Math.max(r+1000,f);return Math.max(r+1000,f,u)}function pX(l,u,r,f){let n=Xu(l?.startMs)??f-60000,i=Xu(l?.endMs)??f,t=r.reduce((N,W)=>Math.max(N,pL(W,f)),i),y=Math.max(n+60000,i,t),c=Math.max(1,y-n),$={startMs:n,endMs:y,durationMs:c},A=EX($,u),j=mj(u),F=Math.max(5,Math.min(18,Math.round(A/150))),U=OX($,F).map((N)=>{let W=Number(N.ms),L=ZL(W,n,y,A);return{...N,y:L,timestamp:f_(W),offsetMs:W-n}});return{source:"frontend-y",startMs:n,endMs:y,durationMs:c,chartHeight:A,scale:r0(u),normalizedScale:Number((r0(u)/100).toFixed(3)),pxPerMinute:Number(Number(j.pxPerMinute||0).toFixed(3)),ticks:U}}function HX(l,u,r){if(!Kj(l))return l;let f=Xu(l?.rawStartMs??l?.startMs)??Xu(l?.startMs)??r,n=pL(l,r),i=AL(f,u),t=AL(n,u),y=Xu(i??l?.y1??l?.startY)??0,c=Xu(t??l?.y2??l?.endY)??y+10,$=Math.max(24,c-y);return{...l,live:!0,startMs:f,endMs:n,durationMs:Math.max(1000,n-f),finishedAt:f_(n),y1:y,y2:c,startY:y,endY:c,height:$}}function zj(l,u,r){return ZX(l,u)/100*r}function Xy(l){return Boolean(l&&String(l?.source||"")!=="frontend-y")}function HL(l,u,r,f,n){if(Xy(f))for(let t of n){let y=Xu(l?.[t]);if(y!==null)return y}let i=Xu(l?.ms??l?.eventMs??l?.startMs);return zj(i??Number(u.startMs),u,r)}function gA(l,u,r,f){return HL(l,u,r,f,["y1","startY"])}function Nj(l,u,r,f){if(Xy(f)){let i=Xu(l?.y2??l?.endY);if(i!==null)return i}let n=Xu(l?.endMs)??Number(u.endMs);return zj(n,u,r)}function BL(l,u,r,f){if(Xy(f)){let i=Xu(l?.height);if(i!==null)return Math.max(1,i)}let n=l?.live?24:10;return Math.max(n,Nj(l,u,r,f)-gA(l,u,r,f))}function wf(l,u,r,f){return HL(l,u,r,f,["y","timeAxisY"])}function DL(l,u,r,f){if(Xy(f)||String(f?.source||"")==="frontend-y"){let t=Xu(l?.y);if(t!==null)return t}let n=Xu(l?.percent);if(n!==null)return n/100*r;let i=Xu(l?.ms)??Number(u.startMs);return zj(i,u,r)}function BX(l){let u=String(l?.promptEvent||l?.raw?.promptEvent||l?.event||"").toLowerCase();if(!["node-long-running-observation","node-finished"].includes(u))return"";let r=String(l?.sourceNodeId||l?.raw?.sourceNodeId||l?.raw?.detail?.nodeId||""),f=String(l?.nodeId||l?.targetNodeId||"");return r&&r!==f?r:""}function DX(l,u){let r=new Set(u.map((n)=>[String(n.sourceNodeId||""),String(n.targetNodeId||""),String(n.targetMarkerId||""),String(n.action||"")].join(":"))),f=[...u];for(let n of l){let i=BX(n),t=String(n?.nodeId||""),y=String(n?.id||"");if(!i||!t||!y)continue;let c=[i,t,y,"observe"].join(":");if(r.has(c))continue;r.add(c),f.push({id:`observation-arrow:${y}:${i}:${t}`,commandId:String(n?.commandId||n?.eventId||y),sourceNodeId:i,targetNodeId:t,sourceMarkerId:"",targetMarkerId:y,sourceKind:"monitor",action:"observe",status:"observation"})}return{markers:l,arrows:f}}function VX(l){let u=un(l),r=String(l?.promptEvent||"");if(u==="initial-prompt-delivered")return"initial";if(r==="node-finished"||r==="node-long-running-observation"||r.startsWith("monitor-"))return"monitor";if(u==="monitor-prompt-delivered"||String(l?.sourceKind||"").toLowerCase()==="monitor")return"monitor";return"append"}function SX(l){return Sl(l?.tags||l?.raw?.tags).map((u)=>String(u||"")).filter(Boolean)}function XX(l){let u=un(l),r=String(l?.promptEvent||"");if(u==="initial-prompt-delivered")return"初始 prompt";if(r==="node-long-running-observation")return"长任务观察";if(r==="node-finished")return SX(l).includes("monitor.audit")?"节点完成 / OA 审核":"节点完成";if(r==="monitor-interval")return"Monitor observation";if(r==="monitor-start")return"Monitor start";if(r==="monitor-stop")return"Monitor stop";if(u==="monitor-prompt-delivered")return"Monitor prompt";if(u==="append-prompt-queued")return"追加 prompt 已排队";return"追加 prompt"}function jL(l){let u=un(l);if(u==="control-command-applied")return 3;if(u==="control-command-ignored")return 2;if(u==="control-command-queued")return 1;return 0}function YX(l,u){let r=String(l?.commandId||"");if(r)return`command:${r}`;return["control-event",Dy(l)||t_(l?.createdAt,l?.timestamp)||`index-${u}`,String(l?.sourceKind||""),String(l?.sourceNodeId||""),String(l?.targetNodeId||""),rt(l)].join(":")}function PX(l){return jj([l?.targetNodeId,...Sl(l?.resetNodeIds)])}function CX(l,u){let r=u_(l),f=un(l),n=String(l?.targetNodeId||""),i=Boolean(n)&&u!==n;if(f==="control-command-applied")return i?`${r} 波及`:`${r} 生效`;if(f==="control-command-ignored")return`${r} 忽略`;if(f==="control-command-queued")return`${r} 已发起`;return i?`${r} 波及`:r}function MX(l){if(un(l)==="control-command-ignored")return"ignored";let r=rt(l);if(r==="restart"||r==="redo")return"restart";if(r==="modify")return"modify";if(r==="approve")return"approve";if(r==="guide")return"guide";return"pending"}function hX(l){let u=String(l?.sourceKind||"").toLowerCase();if(u==="monitor")return"monitor";if(u==="webui")return"webui";if(u==="cli")return"cli";return"system"}function RX(l,u,r,f){let n=l.filter(($)=>String($.nodeId||"")===u).sort(($,A)=>Number($.startMs)-Number(A.startMs)),i=n.find(($)=>r>=Number($.startMs)-1000&&r<=Number($.endMs)+1000);if(i)return{ms:r,onInterval:!0,snapReason:"inside-interval",procedureRunId:String(i.procedureRunId||"")};let t=rt(f),y=n.slice().reverse().find(($)=>Number($.endMs)<=r+1000);if(y&&t==="approve")return{ms:Number(y.endMs),onInterval:!0,snapReason:"previous-interval-end",procedureRunId:String(y.procedureRunId||"")};let c=n.find(($)=>Number($.startMs)>=r-1000);if(c&&["guide","modify","restart","redo"].includes(t))return{ms:Number(c.startMs),onInterval:!0,snapReason:"next-interval-start",procedureRunId:String(c.procedureRunId||"")};return{ms:r,onInterval:!1,snapReason:"event-time",procedureRunId:String(f?.procedureRunId||"")}}function VL(l,u,r,f){let n=Math.hypot(r-l,f-u),i=n>gW?gW:0,t=i>0?r-(r-l)/n*i:r,y=i>0?f-(f-u)/n*i:f,c=t-l,$=Math.max(16,Math.min(42,Math.abs(c)*0.45+12)),A=c===0?1:Math.sign(c);return`M ${l},${u} C ${l+A*$},${u} ${t-A*$},${y} ${t},${y}`}function xX(l,u){let r=String(l?.runId||u?.runId||""),f=EL({...Yl(u)?u:{},...Yl(l)?l:{},runId:r,procedureRuns:Sl(l?.procedureRuns).length>0?l.procedureRuns:u?.procedureRuns},[]),n=[],i=[],t=[],y=new Set,c=new Map,$=(F,U)=>{if(!F.nodeId||!Number.isFinite(Number(F.ms)))return;if(y.has(F.id))return;y.add(F.id),U.push(F)};for(let F of Sl(l?.procedureRuns)){let U=oA(F,r),N=Sf(F);if(!U)continue;for(let W of Sl(F?.attempts)){let L=aA(W);for(let J of Aj(W?.controlEventRecords)){let w=un(J);if(!["initial-prompt-delivered","append-prompt-delivered","monitor-prompt-delivered"].includes(w))continue;let Q=Dy(J),q=al(Q);if(q===null)continue;let T=String(J?.eventId||"");$({id:`prompt:${T||`${N}:${L}:${w}:${q}`}`,runId:r,nodeId:U,procedureRunId:N,attempt:L,kind:"prompt",tone:VX(J),status:"delivered",label:XX(J),ms:q,timestampIso:Q,sourceKind:String(J?.sourceKind||""),sourceNodeId:String(J?.sourceNodeId||""),targetNodeId:U,action:"",eventId:T,commandId:String(J?.commandId||""),raw:J},n)}}}let A=new Map;Aj(l?.controlEvents).forEach((F,U)=>{let N=YX(F,U),W=A.get(N)||{key:N,events:[]};W.events.push(F),A.set(N,W)});for(let F of A.values()){let U=Sl(F.events).slice().sort((p,V)=>jL(V)-jL(p)),N=Sl(F.events).find((p)=>un(p)==="control-command-queued")||null,W=U[0]||N;if(!N&&!W)continue;let L=String(N?.sourceNodeId||W?.sourceNodeId||""),J=String(N?.sourceKind||W?.sourceKind||""),w=Dy(N)||Dy(W)||t_(N?.createdAt,W?.createdAt),Q=al(w),q=String(W?.commandId||N?.commandId||F.key),T=(un(W)||"control-command-queued").replace(/^control-command-/u,""),O="";if(L&&Q!==null)O=`control-source:${q}:${L}`,c.set(q,O),$({id:O,runId:r,nodeId:L,procedureRunId:String(N?.procedureRunId||W?.procedureRunId||""),attempt:"",kind:"control-source",tone:hX(N||W),status:T,label:`${u_(N||W)} 发起`,ms:Q,timestampIso:w,action:rt(N||W),sourceKind:J,sourceNodeId:L,targetNodeId:String(W?.targetNodeId||N?.targetNodeId||""),commandId:q,raw:N||W},i);let Z=W||N,E=Dy(Z)||w,D=al(E);if(D===null)continue;let Y=PX(Z);for(let p of Y){let V=RX(f,p,D,Z),B=`control-target:${q}:${p}`;if($({id:B,runId:r,nodeId:p,procedureRunId:V.procedureRunId,attempt:"",kind:"control-target",tone:MX(Z),status:T,label:CX(Z,p),ms:V.ms,eventMs:D,onInterval:V.onInterval,snapReason:V.snapReason,snapped:Number(V.ms)!==D,timestampIso:E,renderedTimestampIso:f_(Number(V.ms)),action:rt(Z),sourceKind:J,sourceNodeId:L,targetNodeId:p,commandId:q,raw:Z},i),O&&L&&L!==p)t.push({id:`control-arrow:${q}:${L}:${p}`,commandId:q,sourceNodeId:L,targetNodeId:p,sourceMarkerId:O,targetMarkerId:B,sourceKind:J,action:rt(Z),status:T})}}let j=[...n,...i].sort((F,U)=>Number(F.ms)-Number(U.ms)||String(F.nodeId).localeCompare(String(U.nodeId))||String(F.id).localeCompare(String(U.id)));return{...DX(j,t),sourceMarkerByCommand:c}}function bX({details:l,selectedNodeId:u,selectedNodeRuntime:r,control:f,onRaw:n}){if(!l)return K("span",{className:"muted"},"点击“抓取过程”读取 node 运行材料;主界面只显示结构化摘要,完整内容需点开原始 JSON。");let i=Sl(l.procedureRuns),t=i.at(-1)||{},y=Sl(t.attempts),c=y.at(-1)||{},$=Sl(t.workerLogTail),A=Sl(c.controlEventsTail),j=Sl(c.controlPromptsTail),F=Sl(c.monitorPromptsTail),U=fj(A),N=fj(j),W=fj(F),L=c.opencodeMessages||{};return K("div",{className:"pipeline-evidence-list compact"},K(Df,{title:"Node runtime",subtitle:u||"--",facts:[`status ${r?.status||"pending"}`,`attempts ${r?.attempts??y.length}`,`procedure ${r?.currentProcedureRunId||Sf(t)||"--"}`,f.fetchedAt?`fetched ${iu(f.fetchedAt)}`:"not fetched"],data:l.node||l,onRaw:n,testId:"raw-pipeline-node-runtime"}),K(Df,{title:"Procedure runs",subtitle:`${i.length} groups`,facts:[`latest ${t.status?.status||t.status||"--"}`,`steps ${Sl(t.recentSteps).length}`,`duration ${Vf(al(t.finishedAt)&&al(t.startedAt)?Number(al(t.finishedAt))-Number(al(t.startedAt)):t.durationMs)}`],data:i,onRaw:n,testId:"raw-pipeline-node-procedures"}),K(Df,{title:"OpenCode messages",subtitle:String(L.exists?"available":"not indexed"),facts:[`messages ${RA(L.messageCount)}`,`size ${RA(L.size)}`,`updated ${Wl(L.updatedAt)}`],data:L,onRaw:n,testId:"raw-pipeline-node-messages"}),K(Df,{title:"Control prompts",subtitle:"manual / monitor append queues",facts:[`manual tail ${N.total}`,`monitor tail ${W.total}`,`last ${Wl(Qj(N.lastAt,W.lastAt))}`],data:{controlPromptsTail:j,monitorPromptsTail:F},onRaw:n,testId:"raw-pipeline-node-prompts"}),K(Df,{title:"Control events",subtitle:U.eventKinds.length>0?U.eventKinds.join(", "):"event tail",facts:[`tail ${U.total}`,`parsed ${U.parsed}`,`last ${Wl(U.lastAt)}`],data:A,onRaw:n,testId:"raw-pipeline-node-events"}),K(Df,{title:"Worker log",subtitle:"tail is hidden on main canvas",facts:[`tail ${$.length} lines`,"raw only via button",`procedure ${Sf(t)||"--"}`],data:$,onRaw:n,testId:"raw-pipeline-node-worker-log"}))}function vX({activeRun:l,onRaw:u}){if(!l)return K(qf,{title:"暂无运行材料",text:"没有 Pipeline epoch 时不会展示运行材料索引。"});let r=Sl(l.nodes),f=Sl(l.procedureRuns),n=Sl(l.submissions),i=Sl(l.workerLogTail),t=oW(r),y=oW(f),c=f.filter((A)=>String(A?.status||"").toLowerCase()==="failed"),$=Qj(...f.flatMap((A)=>[A.updatedAt,A.finishedAt,A.startedAt]));return K("div",{className:"pipeline-evidence-list"},K(Df,{title:"Epoch overview",subtitle:l.runId||"--",facts:[`pipeline ${l.pipelineId||"--"}`,`status ${l.status||"--"}`,`started ${Wl(l.startedAt)}`,`updated ${Wl(l.updatedAt)}`],data:l,onRaw:u,testId:"raw-pipeline-run"}),K(Df,{title:"Node states",subtitle:`${r.length} nodes`,facts:[`running ${t.running||0}`,`succeeded ${t.succeeded||0}`,`failed ${t.failed||0}`,`pending ${t.pending||0}`],data:r,onRaw:u,testId:"raw-pipeline-run-nodes"}),K(Df,{title:"Procedure run index",subtitle:`${f.length} procedure records`,facts:[`succeeded ${y.succeeded||0}`,`failed ${y.failed||0}`,`latest ${Wl($)}`,`errors ${c.length}`],data:f,onRaw:u,testId:"raw-pipeline-run-procedures"}),K(Df,{title:"OA submissions",subtitle:`${n.length} submission files`,facts:[`records ${n.length}`,`task ${RA(l.task)}`,"raw grouped by run"],data:n,onRaw:u,testId:"raw-pipeline-run-submissions"}),K(Df,{title:"Worker log tail",subtitle:"hidden from main interface",facts:[`tail ${i.length} lines`,"display raw only after click",`updated ${Wl(l.updatedAt)}`],data:i,onRaw:u,testId:"raw-pipeline-run-worker-log"}))}function sX({diagnostics:l,onRaw:u}){let r=Sl(l?.runs).filter(Yl),f=Sl(l?.forbiddenResiduals),n=Yl(l?.guarantees)?l.guarantees:{},i=l?.hasNeutralNodeFinishedEvidence===!0&&l?.hasNoAuditPolicyEvidence===!0&&l?.hasAuditPolicyEvidence===!0,t=l?.ok===!0&&i&&f.length===0,y=r[0]||null,c=[{label:"中性完成事实",ok:n.neutralNodeFinished===!0,hint:"node-finished 不携带流程策略"},{label:"Config 策略判定",ok:n.auditPolicyFromConfig===!0,hint:"OA backend 读取当前 epoch 配置"},{label:"控制命令来自 OA",ok:n.runnerConsumesControlCommandsFromOaEvents===!0,hint:"runner 只消费 OA control.command"},{label:"无独立审核事件",ok:n.noIndependentAuditRequestEvent===!0,hint:"审核由 node-finished + policy 派生"},{label:"无批次门禁",ok:n.noBatchFinishedControlGate===!0,hint:"下游启动由每个 node 完成驱动"}];return K("div",{className:"pipeline-oa-panel","data-testid":"pipeline-oa-event-flow-panel"},K("div",{className:"metric-grid compact"},K(Mr,{label:"OA Flow",value:t?"100%":"--",hint:String(l?.mode||"waiting diagnostics"),tone:t?"ok":"warn"}),K(Mr,{label:"禁止残留",value:f.length,hint:f.length===0?"source scan clean":"needs cleanup",tone:f.length===0?"ok":"warn"}),K(Mr,{label:"No-audit",value:l?.hasNoAuditPolicyEvidence?"OK":"--",hint:"OA 下游策略证据",tone:l?.hasNoAuditPolicyEvidence?"ok":"warn"}),K(Mr,{label:"Monitor 审核",value:l?.hasAuditPolicyEvidence?"OK":"--",hint:"OA 控制事件闭环",tone:l?.hasAuditPolicyEvidence?"ok":"warn"})),K("div",{className:"pipeline-oa-guarantees"},c.map(($)=>K("article",{key:$.label,className:`pipeline-oa-guarantee ${$.ok?"ok":"warn"}`},K(u0,{status:$.ok?"online":"warn"},$.ok?"OK":"MISS"),K("div",null,K("strong",null,$.label),K("span",null,$.hint))))),K("div",{className:"pipeline-evidence-list compact"},r.slice(0,6).map(($)=>K(Df,{key:$.runId,title:String($.runId||"--"),subtitle:[Number($.monitorAuditNodeFinishedCount||0)>0?"monitor audit":"",Number($.noAuditPolicyCount||0)>0?"no-audit policy":""].filter(Boolean).join(" / ")||"event evidence",facts:[`events ${$.eventCount||0}`,`node-finished ${$.nodeFinishedCount||0}`,`policy-in-detail ${$.nodeFinishedWithPolicyCount||0}`,`queued ${$.controlQueuedCount||0}`,`applied ${$.controlAppliedCount||0}`],data:$,onRaw:u,testId:`raw-pipeline-oa-run-${String($.runId||"run").replace(/[^a-zA-Z0-9_.-]+/g,"-")}`}))),y?K("p",{className:"muted paragraph"},`最新证据 ${y.runId}: ${y.nodeFinishedCount||0} 个 node-finished,${y.controlAppliedCount||0} 个控制结果。`):K(qf,{title:"暂无 OA 事件流证据",text:"等待 Pipeline backend 暴露 diagnostics。"}),l?K("div",{className:"panel-actions inline-actions"},K(rn,{title:"Pipeline OA Event Flow Diagnostics",data:l,onOpen:u,testId:"raw-pipeline-oa-event-flow"})):null)}function kX({quota:l,onRaw:u}){let r=Yl(l?.summary)?l.summary:{},f=Yl(l?.target)?l.target:{},n=Yl(l?.cache)?l.cache:{},i=l?.ok===!0,t=String(l?.modelId||r.modelName||f.modelName||"MiniMax-M2.7"),y=r.totalCount??f.currentIntervalTotalCount,c=r.usageCount??f.currentIntervalUsageCount,$=r.remainingCount??f.currentIntervalRemainingCount,A=r.remainingRatio??(Number.isFinite(Number(y))&&Number(y)>0&&Number.isFinite(Number($))?Number($)/Number(y):void 0),j=r.usageRatio??(Number.isFinite(Number(y))&&Number(y)>0&&Number.isFinite(Number(c))?Number(c)/Number(y):void 0),F=r.resetAt||f.endAt,U=r.remainsMs??f.remainsMs,N=Number($),W=!i||Number.isFinite(N)&&N<=0?"warn":"ok",L=[i?`endpoint ${l?.endpoint||"--"}`:"quota unavailable",`fetched ${hA(l?.fetchedAt)}`,n.hit?`cache ${Vf(n.ageMs)}`:"live quota"];return K("div",{className:"pipeline-minimax-quota-panel","data-testid":"pipeline-minimax-quota-panel"},K("div",{className:"metric-grid compact"},K(Mr,{label:"MiniMax",value:i?t:"--",hint:l?.modelComponent||l?.error||"model/minimax-m27",tone:W}),K(Mr,{label:"当前窗口",value:`${rj(c)}/${rj(y)}`,hint:`已用 ${aW(j)}`,tone:W}),K(Mr,{label:"剩余额度",value:rj($),hint:`剩余 ${aW(A)}`,tone:W}),K(Mr,{label:"重置时间",value:hA(F),hint:U!==void 0?`约 ${Vf(U)}`:Wl(F),tone:W})),K(qj,{items:L}),i?K("p",{className:"muted paragraph"},`MiniMax 限额来自 D601 Pipeline 后端实时查询;当前模型匹配 ${r.modelName||f.modelName||t}。`):K(lu,{error:l?.error||"MiniMax 限额查询失败"}),l?K("div",{className:"panel-actions inline-actions"},K(rn,{title:"Pipeline MiniMax Quota",data:l,onOpen:u,testId:"raw-pipeline-minimax-quota"})):null)}function gX({epochs:l,activeRun:u,activePipeline:r,pipelineNodes:f,pipelineEdges:n,runDetails:i,nodeDetails:t,nodeDetailsState:y,ganttScale:c=i_,onGanttScaleChange:$,onRunChange:A,onIntervalSelect:j,onMarkerSelect:F,selection:U,detailOpen:N,onDetailOpenChange:W,onRaw:L}){let[J,w]=dr(KX),[Q,q]=dr({startY:0,endY:0,startMs:0,endMs:0}),[T,O]=dr(Date.now()),Z=ei(null),E=String(u?.runId||""),D=Boolean(N),Y=(jl)=>{if(typeof W==="function")W(jl)},p=r0(c??i_),V=String(i?.runId||"")===E?i?.details:null,B=V?{...Yl(u)?u:{},...Yl(V)?V:{},runId:E,procedureRuns:Sl(V?.procedureRuns).length>0?V.procedureRuns:u?.procedureRuns}:u,m=EL(B,f,T),X=V?xX(V,B):{markers:[],arrows:[]},S=Sl(X.markers),b=mX(B,m,S),z=pX(b,p,m,T),P=String(z.source||"frontend-y"),s=m.map((jl)=>HX(jl,z,T)),k={startMs:Number(z.startMs),endMs:Number(z.endMs),durationMs:Math.max(1,Number(z.durationMs??Number(z.endMs)-Number(z.startMs)))},v=mj(p),tl={...v,pxPerMinute:Number(z.pxPerMinute??v.pxPerMinute)},I=Math.round(Number(z.chartHeight||360)),M=m.some(Kj);zn(()=>{if(!E||!M)return;let jl=window.setInterval(()=>O(Date.now()),1000);return()=>window.clearInterval(jl)},[E,M]);let rl=JX(r,f,Array.isArray(n)?n:[]),cl=f.map((jl)=>String(jl?.id||"")).filter(Boolean),$l=s.map((jl)=>String(jl.nodeId||"")).filter(Boolean),Tl=S.map((jl)=>String(jl.nodeId||"")).filter(Boolean),Ql=Array.from(new Set([...rl,...cl,...$l,...Tl])),Ol={startY:0,endY:I,startMs:Number(k.startMs),endMs:Number(k.endMs)},h=Number(Q?.endY||0)>0?Q:Ol,a=(jl)=>{return gA(jl,k,I,z)<=Number(h.endY)&&Nj(jl,k,I,z)>=Number(h.startY)},ul=(jl)=>{let ol=wf(jl,k,I,z);return ol>=Number(h.startY)&&ol<=Number(h.endY)},zl=new Set(Ql.filter((jl)=>s.some((ol)=>ol.nodeId===jl&&a(ol))||S.some((ol)=>ol.nodeId===jl&&ul(ol)))),o=J?Ql.filter((jl)=>zl.has(jl)):Ql,ql=`${lj}px ${o.length>0?o.map(()=>`${mn}px`).join(" "):"minmax(160px, 1fr)"}`,pl=Sl(z.ticks).filter(Yl),Bl=String(U?.mode==="interval"?U?.interval?.procedureRunId||"":""),Il=String(U?.mode==="event"?U?.marker?.id||"":""),nu=()=>{let jl=Z.current;if(!jl){q(Ol);return}let ol=Math.max(0,jl.scrollTop-uj),wr=Math.max(120,jl.clientHeight-uj),hl=Math.min(I,ol+wr),Yu={startY:ol,endY:hl,startMs:Number(k.startMs),endMs:Number(k.endMs)},eu=Math.max(0,Math.min(1,ol/I)),rf=Math.max(eu,Math.min(1,hl/I)),Ar=Math.max(1,Number(k.endMs)-Number(k.startMs));Yu.startMs=Number(k.startMs)+Ar*eu,Yu.endMs=Number(k.startMs)+Ar*rf,q(Yu)};zn(()=>{let jl=Z.current,ol=window.setTimeout(nu,0);return jl?.addEventListener("scroll",nu),window.addEventListener("resize",nu),()=>{window.clearTimeout(ol),jl?.removeEventListener("scroll",nu),window.removeEventListener("resize",nu)}},[E,k.startMs,k.endMs,I]);let Ml=Math.max(0,Ql.length-o.length),wu=new Set(S.filter((jl)=>o.includes(String(jl.nodeId||""))&&ul(jl)).map((jl)=>String(jl.id))),Qr=new Map(S.map((jl)=>[String(jl.id),jl])),Or=Sl(X.arrows).filter((jl)=>{if(!wu.has(String(jl.targetMarkerId||"")))return!1;if(String(jl.action||"")==="observe")return o.includes(String(jl.sourceNodeId||""));return wu.has(String(jl.sourceMarkerId||""))}),$r=lj+Math.max(1,o.length)*mn,ir=(jl)=>{let ol=r0(jl.target.value);if(typeof $==="function")$(ol);window.setTimeout(nu,0)},tr=()=>LX({title:`${r?.id||"pipeline"}-${E||"epoch"}-gantt`,meta:[`run ${E||"--"}`,`${Wl(k.startMs)} -> ${Wl(k.endMs)}`,`duration ${Vf(k.durationMs)}`,`${tl.label} / ${yj(tl.pxPerMinute)} px/min`,`${o.length}/${Ql.length} nodes`,`${S.length} markers`],visibleNodeIds:o,intervals:s,markers:S.filter((jl)=>o.includes(String(jl.nodeId||""))),arrows:Or,ticks:pl,bounds:k,chartHeight:I,backendLayout:z}),hr=Yl(V?.gantt?.diagnostics)?V.gantt.diagnostics:null;return K(Kn,{title:"Epoch 甘特图",eyebrow:`${r?.id||"pipeline"} / ${l.length} epochs`,className:"pipeline-wide-panel",loading:i?.loading,actions:K("div",{className:"pipeline-gantt-actions"},K("select",{value:E,disabled:l.length===0,onChange:(jl)=>A(jl.target.value),"data-testid":"pipeline-epoch-select"},l.map((jl)=>K("option",{key:jl.runId,value:jl.runId},Jj(l,jl)))),K("label",{className:"pipeline-gantt-toggle"},K("input",{type:"checkbox","data-testid":"pipeline-gantt-auto-hide-idle",checked:J,onChange:(jl)=>{w(Boolean(jl.target.checked)),window.setTimeout(nu,0)}}),K("span",null,"自动隐藏空闲列")),K("label",{className:"pipeline-gantt-scale"},K("span",null,K("b",null,"时间尺度"),K("em",{"data-testid":"pipeline-gantt-scale-label"},`${tl.label} · ${yj(tl.pxPerMinute)} px/min`)),K("input",{type:"range",min:0,max:100,step:0.01,value:p,onChange:ir,"aria-label":"调整甘特图时间尺度","data-testid":"pipeline-gantt-time-scale"}),K("small",null,K("span",null,"全局"),K("span",null,"细节"))),u?K("button",{type:"button",className:"ghost-btn",onClick:tr,disabled:o.length===0,"data-testid":"pipeline-export-gantt"},"导出甘特图"):null,u?K(rn,{title:`Pipeline Epoch ${u.runId}`,data:u,onOpen:L,testId:"raw-pipeline-epoch-gantt"}):null)},!u?K(qf,{title:"暂无 Epoch",text:"当前 pipeline 还没有完整运行记录。"}):s.length===0?K(qf,{title:"暂无时间区间",text:"等待 D601 Pipeline backend 在 procedure summary 中返回 startedAt / finishedAt。"}):K("div",{className:"pipeline-gantt-wrap"},K("div",{className:`pipeline-gantt-detail-layout ${D?"detail-open":"detail-collapsed"}`,"data-testid":"pipeline-gantt-detail-layout","data-sidebar-open":D?"true":"false"},K("div",{className:"pipeline-gantt-main"},K("div",{className:"pipeline-gantt-main-head"},K("div",{className:"pipeline-gantt-meta"},K("span",null,`time ${Wl(k.startMs)} -> ${Wl(k.endMs)}`),K("span",null,`duration ${Vf(k.durationMs)}`),K("span",null,`scale ${tl.label} / ${yj(tl.pxPerMinute)} px/min`),K("span",null,`layout ${P}`),hr?K("span",null,`align ${hr.timeAxisAlignmentOk===!1?"check":"ok"}`):null,K("span",null,`visible ${o.length}/${Ql.length} nodes`),V?K("span",null,`markers ${S.length}`):null,J&&Ml>0?K("span",null,`hidden idle ${Ml}`):null),!D?K("button",{type:"button",className:"pipeline-sidecar-tab right",disabled:!U?.mode,onClick:()=>Y(!0),"data-testid":"pipeline-gantt-sidebar-toggle"},U?.mode?"展开详情":"点击甘特图元素展开详情"):null),K("div",{className:"pipeline-gantt-viewport",ref:Z,"data-testid":"pipeline-epoch-gantt","data-pipeline-id":r?.id||"","data-run-id":E,"data-layout-source":P,"data-start-ms":String(k.startMs),"data-end-ms":String(k.endMs),"data-chart-height":String(I)},K("div",{className:"pipeline-gantt-board",style:{gridTemplateColumns:ql,minWidth:`${$r}px`}},K("div",{className:"pipeline-gantt-head time"},"Time"),o.length===0?K("div",{className:"pipeline-gantt-head empty"},"当前时间窗无工作节点"):o.map((jl)=>K("div",{key:`head-${jl}`,className:"pipeline-gantt-head node",title:jl,"data-testid":"pipeline-gantt-head-node","data-node-id":jl},K(IS,{value:jl}))),K("div",{className:"pipeline-gantt-time-axis",style:{height:`${I}px`}},pl.map((jl)=>{let ol=DL(jl,k,I,z);return K("div",{key:`tick-${jl.ms}-${ol}`,className:"pipeline-gantt-tick",style:{top:`${ol}px`},"data-testid":"pipeline-gantt-tick","data-ms":String(jl.ms),"data-y":String(ol)},K("b",null,Wl(jl.ms)),K("span",null,`+${Vf(Number(jl.offsetMs??Number(jl.ms)-Number(k.startMs)))}`))})),o.length>0?K("svg",{className:"pipeline-gantt-arrow-layer",width:o.length*mn,height:I,viewBox:`0 0 ${o.length*mn} ${I}`,style:{left:`${lj}px`,top:`${uj}px`,width:`${o.length*mn}px`,height:`${I}px`},"aria-hidden":"true"},K("defs",null,K("marker",{id:"pipeline-gantt-arrowhead",viewBox:"0 0 10 10",refX:9,refY:5,markerWidth:6,markerHeight:6,orient:"auto-start-reverse"},K("path",{d:"M 0 0 L 10 5 L 0 10 z",fill:"context-stroke"}))),Or.map((jl)=>{let ol=Qr.get(String(jl.targetMarkerId||""));if(!ol)return null;let wr=Qr.get(String(jl.sourceMarkerId||"")),hl=String(wr?.nodeId||jl.sourceNodeId||""),Yu=o.indexOf(hl),eu=o.indexOf(String(ol.nodeId||""));if(Yu<0||eu<0)return null;let rf=Yu*mn+mn/2,Ar=eu*mn+mn/2,Zr=wr?wf(wr,k,I,z):wf(ol,k,I,z),Zn=wf(ol,k,I,z);return K("path",{key:jl.id,className:`pipeline-gantt-arrow ${String(jl.sourceKind||"").toLowerCase()} ${String(jl.status||"").toLowerCase()} ${String(jl.action||"").toLowerCase()}`,d:VL(rf,Zr,Ar,Zn),markerEnd:"url(#pipeline-gantt-arrowhead)","data-testid":String(jl.action||"")==="observe"?"pipeline-gantt-observation-arrow":"pipeline-gantt-arrow","data-source-node-id":String(jl.sourceNodeId||""),"data-target-node-id":String(jl.targetNodeId||""),"data-target-marker-id":String(jl.targetMarkerId||""),"data-action":String(jl.action||""),"data-source-y":String(Zr),"data-target-y":String(Zn)})})):null,o.length===0?K("div",{className:"pipeline-gantt-empty-col",style:{height:`${I}px`}},"滚动到有活动的时间段后,相关 node 列会自动出现。"):o.map((jl)=>{let ol=s.filter((hl)=>hl.nodeId===jl),wr=S.filter((hl)=>String(hl.nodeId||"")===jl);return K("div",{key:`col-${jl}`,className:"pipeline-gantt-node-col",style:{height:`${I}px`}},ol.map((hl)=>{let Yu=gA(hl,k,I,z),eu=Nj(hl,k,I,z),rf=BL(hl,k,I,z),Ar=String(hl.procedureRunId||`${jl}-${hl.startMs}`);return K("button",{key:Ar,type:"button",className:`pipeline-gantt-bar ${hl.status} ${hl.live?"live":""} ${Bl===Ar?"selected":""}`,style:{top:`${Yu}px`,height:`${rf}px`},title:`${jl} ${hl.status} ${Wl(hl.startedAt||hl.startMs)} -> ${Wl(hl.finishedAt||hl.endMs)}`,onClick:()=>j(hl),"data-testid":"pipeline-gantt-line","data-node-id":jl,"data-procedure-run-id":String(hl.procedureRunId||""),"data-status":String(hl.status||""),"data-live":hl.live?"true":"false","data-start-ms":String(hl.startMs||""),"data-end-ms":String(hl.endMs||""),"data-y1":String(Yu),"data-y2":String(eu),"data-natural-height":String(Math.max(0,eu-Yu))},K("strong",null,hl.status||"working"),K("span",null,Vf(hl.durationMs)))}),wr.map((hl)=>K("button",{key:hl.id,type:"button",className:`pipeline-gantt-marker ${hl.kind} ${hl.tone||""} ${hl.status||""} ${Il===String(hl.id)?"selected":""}`,style:{top:`${wf(hl,k,I,z)}px`},title:`${hl.label||"event"} / ${Wl(hl.timestampIso||hl.timestamp||hl.ms)}`,onClick:()=>F(hl),"data-testid":hl.kind==="prompt"?"pipeline-gantt-prompt-marker":"pipeline-gantt-control-marker","data-marker-id":String(hl.id||""),"data-ms":String(hl.ms??hl.eventMs??""),"data-y":String(wf(hl,k,I,z))})))})))),D?K(gS,{selection:U,runDetails:i,nodeDetails:t,nodeDetailsState:y,onRaw:L,onCollapse:()=>Y(!1)}):null)))}function ri(){return{loading:!1,actionLoading:"",error:"",message:"",details:null,fetchedAt:null,appendPrompt:"",guidePrompt:"",modifyPrompt:"",approveReason:"",redoReason:""}}function di(){return{mode:"",runId:"",interval:null,marker:null}}function cj(){return{runId:"",loading:!1,error:"",details:null,fetchedAt:null}}function dc(l,u){return`${l}/microservices/pipeline/proxy${u}`}function IX({activeRun:l,pipelineRuns:u,selectedRunId:r,onRunChange:f,selectedNodeId:n,selectedNodeConfig:i,selectedNodeRuntime:t,control:y,onControlChange:c,onFetch:$,onAction:A,onRaw:j,onCollapse:F}){let U=String(l?.runId||""),N=String(t?.status||"pending"),W=!U||!n||y.loading||Boolean(y.actionLoading),L=(w)=>(Q)=>c({[w]:Q.target.value,error:"",message:""}),J=u.length>0?u:l?[l]:[];return K("aside",{className:"pipeline-node-control","data-testid":"pipeline-node-control"},K("div",{className:"pipeline-node-control-head"},K("div",null,K("p",{className:"panel-eyebrow"},"Manual Node Control"),K(fu,{title:n||"点击控制图中的 node",level:3,loading:y.loading||Boolean(y.actionLoading)})),K("div",{className:"pipeline-node-control-head-actions"},n?K(u0,{status:N},N):K(u0,{status:"pending"},"idle"),K("button",{type:"button",className:"ghost-btn mini",onClick:F,"data-testid":"pipeline-node-sidebar-collapse"},"收起"))),K("div",{className:"pipeline-control-runbar"},K("label",null,K("span",null,"目标 run"),K("select",{value:U||r,disabled:J.length===0,onChange:(w)=>f(w.target.value),"data-testid":"pipeline-node-run-select"},J.map((w)=>K("option",{key:w.runId,value:w.runId},`${w.runId||"--"} / ${w.status||"--"}`)))),K("button",{type:"button",className:"ghost-btn",disabled:W,onClick:$,"data-testid":"pipeline-node-fetch"},y.loading?"抓取中":"抓取过程"),y.details?K(rn,{title:`Pipeline Node ${n}`,data:y.details,onOpen:j,testId:"raw-pipeline-node-control"}):null),K("div",{className:"pipeline-control-meta"},K("span",null,K("b",null,"kind"),String(i?.kind||"--")),K("span",null,K("b",null,"procedure"),String(t?.currentProcedureRunId||"--")),K("span",null,K("b",null,"attempts"),String(t?.attempts??"--")),K("span",null,K("b",null,"updated"),Wl(l?.updatedAt))),!n?K(qf,{title:"未选择 node",text:"点击 React Flow 控制图中的任意 node 后,可抓取执行过程、追加 prompt、下发引导、增量修改、审核通过或重做。"}):null,K(lu,{error:y.error,wide:!0}),K("div",{className:"pipeline-control-actions"},K("label",null,K("span",null,"实时追加 prompt(仅 running node)"),K("textarea",{value:y.appendPrompt,onChange:L("appendPrompt"),placeholder:"让当前执行中的 agent 继续、补充检查或调整当前步骤...",rows:4,disabled:!n,"data-testid":"pipeline-node-append-input"}),K("button",{type:"button",className:"primary-btn compact",disabled:W||!String(y.appendPrompt||"").trim(),onClick:()=>A("append"),"data-testid":"pipeline-node-append-button"},y.actionLoading==="append"?"追加中":"追加到运行中 node")),K("label",null,K("span",null,"下次尝试引导 prompt"),K("textarea",{value:y.guidePrompt,onChange:L("guidePrompt"),placeholder:"给该 node 下一次 attempt 的执行提示;不会立即打断当前 session。",rows:4,disabled:!n,"data-testid":"pipeline-node-guide-input"}),K("button",{type:"button",className:"ghost-btn compact",disabled:W||!String(y.guidePrompt||"").trim(),onClick:()=>A("guide"),"data-testid":"pipeline-node-guide-button"},y.actionLoading==="guide"?"下发中":"下发 guide")),K("label",null,K("span",null,"完成后增量修改 prompt"),K("textarea",{value:y.modifyPrompt,onChange:L("modifyPrompt"),placeholder:"在该 node 已完成结果基础上追加修改要求;runner 会重跑目标 node,并保留同 node 既有 OA 输出作为上下文。",rows:4,disabled:!n,"data-testid":"pipeline-node-modify-input"}),K("button",{type:"button",className:"ghost-btn compact",disabled:W||!String(y.modifyPrompt||"").trim(),onClick:()=>A("modify"),"data-testid":"pipeline-node-modify-button"},y.actionLoading==="modify"?"排队中":"增量修改 node")),K("label",null,K("span",null,"Monitor 审核通过原因"),K("textarea",{value:y.approveReason,onChange:L("approveReason"),placeholder:"当流程配置开启 monitor 审核时,记录审核通过原因并释放后续 node。",rows:3,disabled:!n,"data-testid":"pipeline-node-approve-input"}),K("button",{type:"button",className:"primary-btn compact",disabled:W||!String(y.approveReason||"").trim(),onClick:()=>A("approve"),"data-testid":"pipeline-node-approve-button"},y.actionLoading==="approve"?"提交中":"审核通过")),K("label",null,K("span",null,"重做 / restart 原因"),K("textarea",{value:y.redoReason,onChange:L("redoReason"),placeholder:"说明为什么需要重做;runner 会重置目标 node 以及非 rework 下游 node。",rows:4,disabled:!n,"data-testid":"pipeline-node-redo-input"}),K("button",{type:"button",className:"danger-btn compact",disabled:W||!String(y.redoReason||"").trim(),onClick:()=>A("redo"),"data-testid":"pipeline-node-redo-button"},y.actionLoading==="redo"?"排队中":"重做 node"))),K("div",{className:"pipeline-control-evidence"},K("strong",null,"Node 过程索引"),K(bX,{details:y.details,selectedNodeId:n,selectedNodeRuntime:t,control:y,onRaw:j})))}function SL({microservices:l,onRaw:u,apiBaseUrl:r="/api"}){let f=l.find((fl)=>fl.id==="pipeline")||null,[n,i]=dr({loading:!1,error:"",health:null,snapshot:null,oaDiagnostics:null,minimaxQuota:null,refreshedAt:null}),[t,y]=dr(""),[c,$]=dr(""),[A,j]=dr(""),[F,U]=dr(ri()),[N,W]=dr({}),[L,J]=dr(di()),[w,Q]=dr(cj()),[q,T]=dr(i_),[O,Z]=dr(!1),[E,D]=dr(!1),Y=ei(0),{addNotification:p}=Xr(),V=ei(!1),B=ei(0),m=ei(""),X=ei({}),S=ei(""),b=ei("");async function z(fl={}){let Dl=fl.silent===!0;if(!f)return;if(V.current)return;V.current=!0;let Cl=Y.current+1;if(Y.current=Cl,!Dl)i((dl)=>({...dl,loading:!0,error:""}));try{let dl=`__unideskArrayLimit=registry.components:80,runs:${HS}`,[ju,ku,$u]=await Promise.all([oi(`${r}/microservices/pipeline/proxy/api/snapshot?${dl}`,{cache:"no-store"}),oi(`${r}/microservices/pipeline/proxy/api/oa-event-flow/diagnostics`,{cache:"no-store"}).catch((Yf)=>({ok:!1,error:El(Yf,"OA event flow diagnostics failed")})),oi(`${r}/microservices/pipeline/proxy/api/model-quota/minimax`,{cache:"no-store"}).catch((Yf)=>({ok:!1,error:El(Yf,"MiniMax quota failed")}))]);if(Cl!==Y.current)return;let qr={ok:ju?.ok!==!1,service:"pipeline-v2-control snapshot"};i({loading:!1,error:"",health:qr,snapshot:ju,oaDiagnostics:ku,minimaxQuota:$u,refreshedAt:new Date})}catch(dl){if(Cl!==Y.current)return;i((ju)=>({...ju,loading:!1,error:El(dl,"Pipeline 加载失败")}))}finally{V.current=!1}}zn(()=>{if(z(),!f)return;let fl=()=>{if(XA())z({silent:!0})},Dl=window.setInterval(()=>{fl()},kW),Cl=()=>{if(XA())fl()};return document.addEventListener("visibilitychange",Cl),()=>{window.clearInterval(Dl),document.removeEventListener("visibilitychange",Cl)}},[f?.id,f?.runtime?.providerStatus,r]);let P=aS(f),s=dS(f),k=oS(f),v=n.snapshot||{},tl=n.oaDiagnostics||null,I=n.minimaxQuota||null,{components:M,pipelines:rl,runs:cl}=eS(v),$l=String(cl[0]?.pipelineId||""),Tl=($l?rl.find((fl)=>String(fl.id||"")===$l):null)||rl[0]||{},Ql=rl.find((fl)=>String(fl.id||"")===t)||Tl,Ol=String(Ql.id||""),h=GL(Ql),a=Lj(Ql),ul=_L(cl,Ol),zl=TX(cl,Ol),o=zl.find((fl)=>String(fl?.runId||"")===c)||ul,ql=String(w.runId||"")===String(o?.runId||"")?fX(w.details):null,pl=nX(o,ql),Bl=String(pl?.runId||""),Il=h.find((fl)=>String(fl?.id||"")===A)||null,nu=A?TL(pl,A):null,Ml=uX(cl),wu=cX(M),Qr=Number(n.health?.components)||rL(v,"registry.components",M.length),Or=rL(v,"runs",cl.length),$r=iL(Ql,pl,M),ir={nodes:$r.nodes.map((fl)=>fl.id===A?{...fl,selected:!0,className:`${fl.className||""} selected-control-node`}:fl),edges:$r.edges},tr=rl.map((fl)=>{let Dl=String(fl.id||"pipeline"),Cl=_L(cl,Dl);return{title:`${Dl}-${Cl?.runId||"snapshot"}`,flow:iL(fl,Cl,M)}}),hr=String(L?.runId||Bl||""),jl=String(L?.interval?.nodeId||L?.marker?.nodeId||""),ol=hr&&jl?N[tj(hr,jl)]||null:null,wr=xA(F.details,hr,jl),hl=xA(ol?.details,hr,jl)||wr,Yu=hr&&jl?{...Yl(ol)?ol:{},runId:hr,nodeId:jl,details:hl,loading:Boolean(ol?.loading)||!hl&&Boolean(F.loading)&&A===jl,error:String(ol?.error||""),fetchedAt:ol?.fetchedAt||(wr?F.fetchedAt:null)}:null,eu=zl.map((fl)=>String(fl?.runId||"")).filter(Boolean).join("|"),rf=h.map((fl)=>String(fl?.id||"")).filter(Boolean).join("|");zn(()=>{S.current=A},[A]),zn(()=>{b.current=Bl},[Bl]),zn(()=>{if(!c||eu.split("|").includes(c))return;$("")},[c,eu]),zn(()=>{if(!A||rf.split("|").includes(A))return;j(""),U(ri()),J(di()),Z(!1),D(!1)},[A,rf]),zn(()=>{if(!A)Z(!1)},[A]),zn(()=>{if(!L.mode)D(!1)},[L.mode]);async function Ar(fl=Bl,Dl={}){if(!fl){Q(cj());return}let Cl=r0(Dl.scale??q??i_),dl=`${fl}:timeline`;if(m.current===dl)return;m.current=dl;let ju=Dl.silent===!0,ku=B.current+1;B.current=ku,Q(($u)=>({runId:fl,scale:Cl,loading:!ju||String($u.runId||"")!==fl||!$u.details,error:"",details:ju&&$u.runId===fl?$u.details:$u.runId===fl?$u.details:null,fetchedAt:$u.runId===fl?$u.fetchedAt:null}));try{let[$u,qr]=await Promise.all([oi(dc(r,`/api/node-control/runs/${encodeURIComponent(fl)}?tail=160&view=timeline`),{cache:"no-store",strictJson:!0}),oi(dc(r,`/api/runs/${encodeURIComponent(fl)}`),{cache:"no-store"}).catch((Yf)=>({ok:!1,runSummaryError:El(Yf,"抓取评分失败")}))]);if(ku!==B.current)return;Q({runId:fl,scale:Cl,loading:!1,error:"",details:{...$u,run:Yl(qr?.run)?qr.run:void 0,runSummaryError:qr?.runSummaryError},fetchedAt:new Date})}catch($u){if(ku!==B.current)return;Q((qr)=>({runId:fl,scale:Cl,loading:!1,error:El($u,"抓取 epoch 执行过程失败"),details:qr.runId===fl?qr.details:null,fetchedAt:qr.runId===fl?qr.fetchedAt:null}))}finally{if(m.current===dl)m.current=""}}function Zr(fl,Dl,Cl){let dl=tj(fl,Dl);W((ju)=>{let ku={...ju,[dl]:{...Yl(ju?.[dl])?ju[dl]:{},runId:fl,nodeId:Dl,...Cl}},$u=Object.keys(ku);if($u.length>32)for(let qr of $u.slice(0,$u.length-32))delete ku[qr];return ku})}async function Zn(fl,Dl){if(!fl||!Dl)return;let Cl=tj(fl,Dl),dl=Number(X.current?.[Cl]||0)+1;X.current={...X.current,[Cl]:dl},Zr(fl,Dl,{loading:!0,error:""});try{let ju=await oi(dc(r,`/api/node-control/runs/${encodeURIComponent(fl)}/nodes/${encodeURIComponent(Dl)}?tail=160`),{cache:"no-store",strictJson:!0});if(Number(X.current?.[Cl]||0)!==dl)return;let ku=new Date;if(Zr(fl,Dl,{loading:!1,details:ju,fetchedAt:ku,error:""}),S.current===Dl&&b.current===fl)U(($u)=>({...$u,loading:!1,details:ju,fetchedAt:ku,error:""}))}catch(ju){if(Number(X.current?.[Cl]||0)!==dl)return;Zr(fl,Dl,{loading:!1,error:El(ju,"抓取 Gantt node 详情失败")})}}zn(()=>{if(!Bl){Q(cj());return}Ar(Bl);let fl=()=>{if(XA())Ar(Bl,{silent:!0})},Dl=window.setInterval(()=>{fl()},kW),Cl=()=>{if(XA())fl()};return document.addEventListener("visibilitychange",Cl),()=>{window.clearInterval(Dl),document.removeEventListener("visibilitychange",Cl)}},[Bl,r]);async function ff(fl=Bl,Dl=A){if(!fl||!Dl){U((Cl)=>({...Cl,error:"请先选择 run 和 node",message:""}));return}U((Cl)=>({...Cl,loading:!0,error:"",message:""}));try{let Cl=await oi(dc(r,`/api/node-control/runs/${encodeURIComponent(fl)}/nodes/${encodeURIComponent(Dl)}?tail=160`),{cache:"no-store",strictJson:!0}),dl=new Date;U((ju)=>({...ju,loading:!1,details:Cl,fetchedAt:dl,error:""})),Zr(fl,Dl,{loading:!1,details:Cl,fetchedAt:dl,error:""})}catch(Cl){U((dl)=>({...dl,loading:!1,error:El(Cl,"抓取 node 执行过程失败")}))}}async function ii(fl){let Dl=String(fl?.runId||Bl||""),Cl=String(fl?.nodeId||"");if(J({mode:"interval",runId:Dl,interval:fl,marker:null}),D(!0),!Dl||!Cl)return;if(Dl!==Bl)$(Dl);j(Cl),U(ri()),Ar(Dl,{silent:!0}),Zn(Dl,Cl)}async function Au(fl){let Dl=String(fl?.runId||Bl||""),Cl=String(fl?.nodeId||"");if(J({mode:"event",runId:Dl,interval:null,marker:fl}),D(!0),!Dl)return;if(Dl!==Bl)$(Dl);if(Ar(Dl,{silent:!0}),!Cl)return;j(Cl),U(ri()),Zn(Dl,Cl)}async function nt(fl){if(!Bl||!A){U((dl)=>({...dl,error:"请先选择 run 和 node",message:""}));return}let Dl=fl==="append"?"prompts":fl,Cl=fl==="append"?F.appendPrompt:fl==="guide"?F.guidePrompt:fl==="modify"?F.modifyPrompt:fl==="approve"?F.approveReason:F.redoReason;if(!String(Cl||"").trim()){U((dl)=>({...dl,error:"操作内容不能为空",message:""}));return}U((dl)=>({...dl,actionLoading:fl,error:"",message:""}));try{let dl=fl==="redo"||fl==="approve"?{reason:Cl,source:"unidesk-frontend",sourceKind:"webui"}:{prompt:Cl,source:"unidesk-frontend",sourceKind:"webui"},ju=await oi(dc(r,`/api/node-control/runs/${encodeURIComponent(Bl)}/nodes/${encodeURIComponent(A)}/${Dl}`),{method:"POST",body:JSON.stringify(dl)});if(U(($u)=>({...$u,actionLoading:"",details:ju,fetchedAt:new Date,appendPrompt:fl==="append"?"":$u.appendPrompt,guidePrompt:fl==="guide"?"":$u.guidePrompt,modifyPrompt:fl==="modify"?"":$u.modifyPrompt,approveReason:fl==="approve"?"":$u.approveReason,redoReason:fl==="redo"?"":$u.redoReason,message:fl==="append"?"已追加到运行中 node":fl==="guide"?"已下发 guide,等待 runner 处理":fl==="modify"?"已排队增量修改命令":fl==="approve"?"已提交审核通过决策":"已排队重做命令"})),p("success",fl==="append"?"已追加到运行中 node":fl==="guide"?"已下发 guide,等待 runner 处理":fl==="modify"?"已排队增量修改命令":fl==="approve"?"已提交审核通过决策":"已排队重做命令"),await ff(Bl,A),await Ar(Bl,{silent:!0}),fl!=="append")await z()}catch(dl){U((ju)=>({...ju,actionLoading:"",error:El(dl,"node 控制操作失败")}))}}if(!f)return K(qf,{title:"Pipeline 未登记",text:"请在 config.json 的 microservices 中登记用户服务 id=pipeline"});return K("div",{className:"pipeline-page","data-testid":"pipeline-page"},K(Kn,{title:"Pipeline v2 工作台",eyebrow:"D601 Snapshot 用户服务",loading:n.loading,actions:K("div",{className:"panel-actions"},K("button",{type:"button",className:"ghost-btn",onClick:z,disabled:n.loading,"data-testid":"pipeline-refresh-button"},n.loading?"刷新中":"刷新"),K(rn,{title:"Pipeline 用户服务",data:f,onOpen:u,testId:"raw-pipeline-service"}))},K("div",{className:"pipeline-hero"},K("div",null,K("div",{className:"node-version-line"},K(u0,{status:P.providerStatus==="online"?"online":"warn"},P.providerStatus||"unknown"),K("span",null,f.providerId),K("span",null,k.public?"公网暴露":"仅 UniDesk frontend 代理访问")),K("p",{className:"muted paragraph"},f.description)),K("div",{className:"microservice-ref-card"},K("span",null,"Repo"),K("strong",null,s.url||"--"),K("code",null,s.commitId||"--")),K("div",{className:"microservice-ref-card"},K("span",null,"D601 Docker"),K("strong",null,`${k.nodeBindHost||"--"}:${k.nodePort||"--"}`),K("code",null,`${s.composeFile||"--"} / ${s.composeService||"--"}`))),K(lu,{error:n.error,wide:!0})),K("div",{className:"pipeline-grid"},K(Kn,{title:"控制图",eyebrow:`${Ql.id||"pipeline"} / run ${pl?.status||"--"}`,className:"pipeline-wide-panel",loading:n.loading,actions:K("div",{className:"pipeline-toolbar"},K("select",{value:Ol,disabled:rl.length===0,onChange:(fl)=>{y(fl.target.value),$(""),j(""),U(ri()),J(di()),Z(!1),D(!1)},"data-testid":"pipeline-select"},rl.map((fl)=>K("option",{key:fl.id,value:fl.id},fl.id||fl.key))),K("select",{value:Bl,disabled:zl.length===0,onChange:(fl)=>{if($(fl.target.value),U(ri()),J(di()),Z(!1),D(!1),A)ff(fl.target.value,A)},"data-testid":"pipeline-run-select"},zl.map((fl)=>K("option",{key:fl.runId,value:fl.runId},Jj(zl,fl)))),K("button",{type:"button",className:"ghost-btn",disabled:ir.nodes.length===0,onClick:()=>zL(ir,`${Ql.id||"pipeline"}-${pl?.runId||"snapshot"}`),"data-testid":"pipeline-export-graph"},"导出渲染图"),K("button",{type:"button",className:"ghost-btn",disabled:tr.every((fl)=>fl.flow.nodes.length===0),onClick:()=>GX(tr),"data-testid":"pipeline-export-all-graphs"},"批量导出"))},h.length===0?K(qf,{title:"暂无控制图",text:"等待 D601 pipeline backend 返回 config.nodes / config.edges"}):K("div",{className:`pipeline-control-shell ${O?"detail-open":"detail-collapsed"}`,"data-testid":"pipeline-control-shell","data-sidebar-open":O?"true":"false"},K("div",{className:"pipeline-flow-frame","data-testid":"pipeline-react-flow"},K(MW,{nodes:ir.nodes,edges:ir.edges,nodeTypes:XS,edgeTypes:SS,fitView:!0,fitViewOptions:{padding:0.18},nodesDraggable:!1,nodesConnectable:!1,elementsSelectable:!0,minZoom:0.25,maxZoom:1.4,proOptions:{hideAttribution:!0},onNodeClick:(fl,Dl)=>{let Cl=String(Dl.id);if(j(Cl),U(ri()),Z(!0),Bl)ff(Bl,Cl)}},K(RW,{gap:22,size:1,color:"rgba(215, 161, 58, 0.24)"}),K(bW,{showInteractive:!1})),!O?K("button",{type:"button",className:"pipeline-sidecar-tab right",disabled:!A,onClick:()=>Z(!0),"data-testid":"pipeline-node-sidebar-toggle"},A?"展开 node 控制":"点击 node 展开控制"):null),O?K(IX,{activeRun:pl,pipelineRuns:zl,selectedRunId:c,onRunChange:(fl)=>{if($(fl),U(ri()),J(di()),A)ff(fl,A)},selectedNodeId:A,selectedNodeConfig:Il,selectedNodeRuntime:nu,control:F,onControlChange:(fl)=>U((Dl)=>({...Dl,...fl})),onFetch:()=>ff(),onAction:nt,onRaw:u,onCollapse:()=>Z(!1)}):null),K("div",{className:"pipeline-flow-summary"},K("span",null,`${ir.nodes.length} nodes`),K("span",null,`${ir.edges.length} edges`),K("span",null,`${rl.length} pipelines`),K("span",null,`source config+components(${M.length})`),K("span",null,`run ${pl?.runId||"--"}`),K("span",null,`score ${Fj(pl)}`),K("span",null,A?`selected ${A}`:"click node to control"))),K(gX,{epochs:zl,activeRun:pl,activePipeline:Ql,pipelineNodes:h,pipelineEdges:a,selection:L,detailOpen:E,onDetailOpenChange:D,runDetails:w,nodeDetails:hl,nodeDetailsState:Yu,ganttScale:q,onGanttScaleChange:T,onIntervalSelect:ii,onMarkerSelect:Au,onRunChange:(fl)=>{if($(fl),U(ri()),J(di()),D(!1),A)ff(fl,A)},onRaw:u}),K(Kn,{title:"观测指标",eyebrow:n.refreshedAt?`Updated ${iu(n.refreshedAt)}`:"Snapshot",loading:n.loading},K("div",{className:"metric-grid"},K(Mr,{label:"Health",value:n.health?.ok?"OK":"--",hint:n.health?.service||"D601 /health",tone:n.health?.ok?"ok":"warn"}),K(Mr,{label:"组件",value:Qr,hint:"components registry",tone:v?.registry?.ok===!1?"warn":"ok"}),K(Mr,{label:"Pipeline",value:rl.length,hint:`${h.length} nodes / ${a.length} edges`}),K(Mr,{label:"运行记录",value:Or,hint:`${Ml.succeeded||0} succeeded / ${Ml.running||0} running`}),K(Mr,{label:"OA 记录",value:Array.isArray(ul?.submissions)?ul.submissions.length:0,hint:ul?.runId||"latest run"}),K(Mr,{label:"Procedure",value:Array.isArray(ul?.procedureRuns)?ul.procedureRuns.length:0,hint:ul?.status||"no run"}),K(Mr,{label:"Score",value:Fj(pl),hint:pl?.runId||"selected epoch",tone:Tj(pl)})),K("div",{className:"panel-actions inline-actions"},K(rn,{title:"Pipeline Snapshot",data:v,onOpen:u,testId:"raw-pipeline-snapshot"}))),K(Kn,{title:"评分器",eyebrow:pl?.runId||"selected epoch",loading:n.loading},K(yX,{run:pl,onRaw:u})),K(Kn,{title:"MiniMax 限额",eyebrow:"model/minimax-m27 quota",loading:n.loading},K(kX,{quota:I,onRaw:u})),K(Kn,{title:"OA 事件流",eyebrow:"100% event-driven diagnostics",className:"pipeline-wide-panel",loading:n.loading},K(sX,{diagnostics:tl,onRaw:u})),K(Kn,{title:"组件矩阵",eyebrow:`${wu.length} classes`,loading:n.loading},wu.length===0?K(qf,{title:"暂无组件",text:"等待 D601 pipeline backend 返回 registry.components"}):K("div",{className:"component-strata"},wu.map((fl)=>K("article",{key:fl.name,className:"component-stratum"},K("span",null,fl.name),K("strong",null,fl.count)))),K("div",{className:"pipeline-component-list"},M.slice(0,12).map((fl)=>K("span",{key:fl.key,className:"data-chip"},K("b",null,fl.componentClass||"--"),K("span",null,fl.id||fl.key||"--"))))),K(Kn,{title:"Epoch 列表",eyebrow:`${zl.length}/${Or} preview`,loading:n.loading},zl.length===0?K(qf,{title:"暂无运行记录",text:"当前 pipeline 在 .state/pipeline-runs 中还没有 epoch。"}):K("div",{className:"pipeline-run-list"},zl.map((fl)=>{let Dl=String(fl?.runId||"")===Bl?pl:fl;return K("article",{key:fl.runId,className:`pipeline-run-card ${String(fl.runId||"")===Bl?"active":""}`,role:"button",tabIndex:0,onClick:()=>{$(String(fl.runId||"")),J(di())},onKeyDown:(Cl)=>{if(Cl.key==="Enter"||Cl.key===" ")$(String(fl.runId||"")),J(di())}},K("div",{className:"node-card-head"},K("strong",null,Jj(zl,fl)),K(u0,{status:fl.status},fl.status||"--")),K("div",{className:"docker-meta compact"},K("span",null,Dl?.pipelineId||"--"),K("span",null,`nodes ${Array.isArray(Dl?.nodes)?Dl.nodes.length:0}`),K("span",null,`oa ${Array.isArray(Dl?.submissions)?Dl.submissions.length:0}`),K("span",null,`procedures ${Array.isArray(Dl?.procedureRuns)?Dl.procedureRuns.length:0}`),K(tX,{run:Dl})),K("p",{className:"muted paragraph"},RA(Dl?.task)),K("span",{className:"pipeline-run-time"},Wl(Dl?.updatedAt)))}))),K(Kn,{title:"运行材料索引",eyebrow:pl?.runId||"selected epoch",className:"pipeline-wide-panel",loading:n.loading},K(vX,{activeRun:pl,onRaw:u}))))}var l6=Rl(Ju(),1);var _l=l6.default.createElement,{useEffect:aX}=l6.default,dA=l6.default.useState,Ej={id:"",sequenceNo:"",contractNo:"",name:"",currentStatus:"",pending:"",paymentStatus:"",notes:""};function oX({status:l,children:u}){let r=String(l||"unknown").toLowerCase();return _l("span",{className:`status-badge ${r}`},u||l||"unknown")}function eA({label:l,value:u,hint:r,tone:f}){return _l("article",{className:`metric-card ${f||""}`},_l("div",{className:"metric-label"},l),_l("div",{className:"metric-value"},u),_l("div",{className:"metric-hint"},r))}function Oj({title:l,eyebrow:u,actions:r,children:f,className:n,loading:i}){return _l("section",{className:`panel ${n||""}`},_l("div",{className:"panel-head"},_l("div",null,u?_l("p",{className:"panel-eyebrow"},u):null,_l(fu,{title:l,loading:i})),r?_l("div",{className:"panel-actions"},r):null),_l("div",{className:"panel-body"},f))}function XL({title:l,data:u,onOpen:r,testId:f}){return _l("button",{type:"button",className:"ghost-btn","data-testid":f,onClick:()=>r(l,u)},"查看原始JSON")}function YL({title:l,text:u}){return _l("div",{className:"empty-state"},_l("strong",null,l),_l("span",null,u))}function dX(l){return l?.runtime&&typeof l.runtime==="object"&&!Array.isArray(l.runtime)?l.runtime:{}}function eX(l){return l?.backend&&typeof l.backend==="object"&&!Array.isArray(l.backend)?l.backend:{}}function lY(l){return l?.repository&&typeof l.repository==="object"&&!Array.isArray(l.repository)?l.repository:{}}function Yy(l,u){return`${l}/microservices/project-manager/proxy${u}`}function uY(l){return{id:String(l.id||""),sequenceNo:l.sequenceNo===null||l.sequenceNo===void 0?"":String(l.sequenceNo),contractNo:String(l.contractNo||""),name:String(l.name||""),currentStatus:String(l.currentStatus||""),pending:String(l.pending||""),paymentStatus:String(l.paymentStatus||""),notes:String(l.notes||"")}}function rY(l){return{sequenceNo:l.sequenceNo===""?null:Number(l.sequenceNo),contractNo:String(l.contractNo||"").trim(),name:String(l.name||"").trim(),currentStatus:String(l.currentStatus||"").trim(),pending:String(l.pending||"").trim(),paymentStatus:String(l.paymentStatus||"").trim(),paymentRatio:String(l.paymentStatus||"").trim(),notes:String(l.notes||"").trim()}}function Zj(l){return String(l||"item").replace(/[^A-Za-z0-9_-]+/g,"-")}function fY(l){let u=new Uint8Array(l),r="",f=32768;for(let n=0;n<u.length;n+=f)r+=String.fromCharCode(...u.subarray(n,n+f));return btoa(r)}function nY({projects:l,activeId:u,onSelect:r,onRaw:f}){if(!l.length)return _l(YL,{title:"暂无项目",text:"可以从 Excel 导入,或用右侧表单新建项目。"});return _l("div",{className:"table-wrap project-manager-table","data-testid":"project-manager-table"},_l("table",null,_l("thead",null,_l("tr",null,_l("th",null,"序号"),_l("th",null,"合同号"),_l("th",null,"项目名称"),_l("th",null,"当前状况"),_l("th",null,"待完成"),_l("th",null,"付款"),_l("th",null,"其它"),_l("th",null,"操作"))),_l("tbody",null,l.map((n)=>_l("tr",{key:n.id,className:u===n.id?"active-row":"","data-testid":`project-manager-row-${Zj(n.id)}`},_l("td",null,n.sequenceNo??"--"),_l("td",null,_l("strong",null,n.contractNo||"--"),_l("code",null,n.id||"--")),_l("td",null,_l("strong",null,n.name||"--"),_l("span",{className:"muted block"},n.sourceFile||"--")),_l("td",null,n.currentStatus||"--"),_l("td",null,_l("span",{className:"preline"},n.pending||"--")),_l("td",null,_l(oX,{status:Number(n.paymentRatio||0)>=1?"online":"warn"},n.paymentStatus||"--")),_l("td",null,n.notes||"--"),_l("td",null,_l("div",{className:"inline-actions"},_l("button",{type:"button",className:"ghost-btn",onClick:()=>r(n),"data-testid":`project-manager-edit-${Zj(n.id)}`},"编辑"),_l(XL,{title:`Project ${n.contractNo||n.id}`,data:n,onOpen:f,testId:`raw-project-${Zj(n.id)}`}))))))))}function PL({microservices:l,onRaw:u,apiBaseUrl:r="/api"}){let f=l.find((E)=>E.id==="project-manager")||null,[n,i]=dA({loading:!1,saving:!1,importing:!1,exporting:!1,error:"",notice:"",health:null,list:null,refreshedAt:null}),[t,y]=dA({...Ej}),[c,$]=dA(""),[A,j]=dA("all"),{addNotification:F}=Xr();async function U(E=c,D=A){if(!f)return;i((Y)=>({...Y,loading:!0,error:""}));try{let Y=new URLSearchParams({pageSize:"200",status:D});if(E.trim())Y.set("q",E.trim());let[p,V]=await Promise.all([ml(`${r}/microservices/project-manager/health`),ml(Yy(r,`/api/projects?${Y.toString()}`))]);i((B)=>({...B,loading:!1,health:p,list:V,refreshedAt:new Date,error:""}))}catch(Y){i((p)=>({...p,loading:!1,error:El(Y,"Project Manager 加载失败")}))}}aX(()=>{U()},[f?.id,f?.runtime?.providerStatus]);async function N(E){E.preventDefault(),i((D)=>({...D,saving:!0,error:"",notice:""}));try{let D=rY(t);if(t.id)await ml(Yy(r,`/api/projects/${encodeURIComponent(t.id)}`),{method:"PUT",body:JSON.stringify(D)});else await ml(Yy(r,"/api/projects"),{method:"POST",body:JSON.stringify(D)});let Y=t.id?"项目已更新":"项目已创建";i((p)=>({...p,saving:!1,notice:Y})),F("success",Y),await U()}catch(D){i((Y)=>({...Y,saving:!1,error:El(D,"保存项目失败")}))}}async function W(){if(!t.id)return;if(!window.confirm(`删除项目 ${t.contractNo||t.name||t.id} ?`))return;i((E)=>({...E,saving:!0,error:"",notice:""}));try{await ml(Yy(r,`/api/projects/${encodeURIComponent(t.id)}`),{method:"DELETE"}),y({...Ej});let E="项目已删除";i((D)=>({...D,saving:!1,notice:E})),F("success",E),await U()}catch(E){i((D)=>({...D,saving:!1,error:El(E,"删除项目失败")}))}}async function L(E){let D=E.target.files?.[0];if(!D)return;i((Y)=>({...Y,importing:!0,error:"",notice:""}));try{let Y=fY(await D.arrayBuffer()),V=`Excel 已导入 ${(await ml(Yy(r,"/api/import/excel"),{method:"POST",body:JSON.stringify({fileName:D.name,contentBase64:Y,replace:!1})})).imported||0} 条项目`;i((B)=>({...B,importing:!1,notice:V})),F("success",V),E.target.value="",await U()}catch(Y){i((p)=>({...p,importing:!1,error:El(Y,"Excel 导入失败")}))}}async function J(){i((E)=>({...E,exporting:!0,error:""}));try{let E=await IU(Yy(r,"/api/projects/export.xlsx")),D=URL.createObjectURL(E),Y=document.createElement("a");Y.href=D,Y.download=`project-manager-${E7()}.xlsx`,document.body.appendChild(Y),Y.click(),Y.remove(),URL.revokeObjectURL(D),i((p)=>({...p,exporting:!1,notice:"Excel 已导出"}))}catch(E){i((D)=>({...D,exporting:!1,error:El(E,"Excel 导出失败")}))}}if(!f)return _l(YL,{title:"Project Manager 未登记",text:"请在 config.json 的 microservices 中登记用户服务 id=project-manager"});let w=dX(f),Q=lY(f),q=eX(f),T=Array.isArray(n.list?.projects)?n.list.projects:[],O=n.list?.summary||{},Z=n.health||{};return _l("div",{className:"project-manager-page","data-testid":"project-manager-page"},_l(Oj,{title:"项目管理工作台",eyebrow:"Main Server PostgreSQL 用户服务",loading:n.loading||n.exporting,actions:_l("div",{className:"panel-actions"},_l("button",{type:"button",className:"ghost-btn",disabled:n.loading,onClick:()=>U(),"data-testid":"project-manager-refresh-button"},n.loading?"刷新中":"刷新"),_l("button",{type:"button",className:"ghost-btn",disabled:n.exporting,onClick:J,"data-testid":"project-manager-export-button"},n.exporting?"导出中":"导出 Excel"),_l(XL,{title:"Project Manager 用户服务",data:f,onOpen:u,testId:"raw-project-manager-service"}))},_l("div",{className:"project-manager-hero"},_l(eA,{label:"项目总数",value:O.total??T.length,hint:`PG 表 ${Z.storage?.table||"project_manager_projects"}`,tone:"ok"}),_l(eA,{label:"进行中",value:O.active??"--",hint:"当前状态未完全完成"}),_l(eA,{label:"已完成",value:O.completed??"--",hint:"按 完成 关键字统计",tone:"ok"}),_l(eA,{label:"未全款",value:O.unpaid??"--",hint:"付款比例 < 1",tone:Number(O.unpaid||0)>0?"warn":"ok"})),_l(lu,{error:n.error}),n.notice?_l("div",{className:"form-success"},n.notice):null),_l("div",{className:"project-manager-hero"},_l("div",{className:"microservice-ref-card"},_l("span",null,"Repo"),_l("strong",null,Q.url||"--"),_l("code",null,Q.commitId||"--")),_l("div",{className:"microservice-ref-card"},_l("span",null,"Main Server Docker"),_l("strong",null,`${q.nodeBindHost||"--"}:${q.nodePort||"--"}`),_l("code",null,`${Q.composeService||"--"} / ${Q.containerName||"--"}`)),_l("div",{className:"microservice-ref-card"},_l("span",null,"Runtime"),_l("strong",null,w.providerName||f.providerId),_l("code",null,`Health ${Z.ok?"OK":"--"} / ${n.refreshedAt?iu(n.refreshedAt):"--"}`)),_l("div",{className:"microservice-ref-card"},_l("span",null,"Import Source"),_l("strong",null,"D601 WeChat Excel"),_l("code",null,"合作项目列表_I_20260309.xlsx"))),_l("div",{className:"project-manager-layout"},_l(Oj,{title:"项目清单",eyebrow:"CRUD + Excel Export",loading:n.loading||n.importing||n.exporting,actions:_l("div",{className:"inline-actions project-manager-filters"},_l("input",{value:c,onChange:(E)=>$(E.target.value),placeholder:"搜索合同号 / 项目名称 / 状态","data-testid":"project-manager-search"}),_l("select",{value:A,onChange:(E)=>{j(E.target.value),U(c,E.target.value)},"data-testid":"project-manager-status-filter"},_l("option",{value:"all"},"全部"),_l("option",{value:"active"},"进行中"),_l("option",{value:"completed"},"已完成"),_l("option",{value:"unpaid"},"未全款")),_l("button",{type:"button",className:"ghost-btn",onClick:()=>U(c,A)},"筛选"))},_l(nY,{projects:T,activeId:t.id,onSelect:(E)=>y(uY(E)),onRaw:u})),_l(Oj,{title:t.id?"编辑项目":"新建项目",eyebrow:"PostgreSQL Write Path",loading:n.saving||n.importing},_l("form",{className:"stack-form project-manager-form",onSubmit:N,"data-testid":"project-manager-form"},t.id?_l("label",null,"项目 ID",_l("input",{value:t.id,disabled:!0})):null,_l("label",null,"序号",_l("input",{type:"number",value:t.sequenceNo,onChange:(E)=>y((D)=>({...D,sequenceNo:E.target.value}))})),_l("label",null,"合同号",_l("input",{value:t.contractNo,onChange:(E)=>y((D)=>({...D,contractNo:E.target.value})),required:!0})),_l("label",null,"项目名称",_l("input",{value:t.name,onChange:(E)=>y((D)=>({...D,name:E.target.value})),required:!0})),_l("label",null,"当前状况",_l("textarea",{value:t.currentStatus,onChange:(E)=>y((D)=>({...D,currentStatus:E.target.value}))})),_l("label",null,"待完成",_l("textarea",{value:t.pending,onChange:(E)=>y((D)=>({...D,pending:E.target.value}))})),_l("label",null,"付款情况",_l("input",{value:t.paymentStatus,onChange:(E)=>y((D)=>({...D,paymentStatus:E.target.value})),placeholder:"例如 1 / 0.5 / 50%"})),_l("label",null,"其它",_l("input",{value:t.notes,onChange:(E)=>y((D)=>({...D,notes:E.target.value}))})),_l("div",{className:"inline-actions"},_l("button",{type:"submit",className:"primary-btn",disabled:n.saving,"data-testid":"project-manager-save-button"},n.saving?"保存中":t.id?"保存修改":"创建项目"),_l("button",{type:"button",className:"ghost-btn",onClick:()=>y({...Ej})},"清空"),t.id?_l("button",{type:"button",className:"danger-btn",disabled:n.saving,onClick:W,"data-testid":"project-manager-delete-button"},"删除"):null)),_l("div",{className:"project-manager-import"},_l("p",{className:"muted paragraph"},"浏览器只访问 UniDesk frontend;后端通过同源用户服务代理写入主 PostgreSQL,不暴露 4233 公网端口。"),_l("label",{className:"file-import"},n.importing?"导入中...":"导入 Excel",_l("input",{type:"file",accept:".xlsx",onChange:L,disabled:n.importing,"data-testid":"project-manager-import-input"}))))))}var f6=Rl(Ju(),1);var Fl=f6.default.createElement,{useEffect:iY}=f6.default,lf=f6.default.useState;function tY({status:l,children:u}){let r=String(l||"unknown").toLowerCase();return Fl("span",{className:`status-badge ${r}`},u||l||"unknown")}function u6({label:l,value:u,hint:r,tone:f}){return Fl("article",{className:`metric-card ${f||""}`},Fl("div",{className:"metric-label"},l),Fl("div",{className:"metric-value"},u),Fl("div",{className:"metric-hint"},r))}function pj({title:l,eyebrow:u,actions:r,children:f,className:n,loading:i}){return Fl("section",{className:`panel ${n||""}`},Fl("div",{className:"panel-head"},Fl("div",null,u?Fl("p",{className:"panel-eyebrow"},u):null,Fl(fu,{title:l,loading:i})),r?Fl("div",{className:"panel-actions"},r):null),Fl("div",{className:"panel-body"},f))}function CL({title:l,data:u,onOpen:r,testId:f}){return Fl("button",{type:"button",className:"ghost-btn","data-testid":f,onClick:()=>r(l,u)},"查看原始JSON")}function r6({title:l,text:u}){return Fl("div",{className:"empty-state"},Fl("strong",null,l),Fl("span",null,u))}function yY(l){return l?.runtime&&typeof l.runtime==="object"&&!Array.isArray(l.runtime)?l.runtime:{}}function cY(l){return l?.backend&&typeof l.backend==="object"&&!Array.isArray(l.backend)?l.backend:{}}function _Y(l){return l?.repository&&typeof l.repository==="object"&&!Array.isArray(l.repository)?l.repository:{}}function hL(l){return String(l).replace(/[^a-zA-Z0-9_-]/g,"_")}function $Y(l){if(!Number.isFinite(l))return"--";return`${l.toFixed(1)}%`}function Py(l,u){return`${l}/microservices/todo-note/proxy${u}`}function RL(l){return l.reduce((u,r)=>{let f=RL(Array.isArray(r.children)?r.children:[]),n=Boolean(r.completed);return{total:u.total+1+f.total,completed:u.completed+(n?1:0)+f.completed,active:u.active+(n?0:1)+f.active}},{total:0,completed:0,active:0})}function Bj(l,u){let r=u==="all"||(u==="completed"?Boolean(l.completed):!l.completed),f=Array.isArray(l.children)?l.children:[];return r||f.some((n)=>Bj(n,u))}function ML(l){return Array.isArray(l?.instances)?l.instances:[]}function Hj(l,u){for(let r of l){if(r?.id===u)return Array.isArray(r.children)?r.children:[];let f=Hj(Array.isArray(r?.children)?r.children:[],u);if(f.length>0)return f}return[]}function xL({microservices:l,onRaw:u,apiBaseUrl:r="/api"}){let f=l.find((o)=>o.id==="todo-note")||null,[n,i]=lf(null),[t,y]=lf(null),[c,$]=lf(""),[A,j]=lf(null),[F,U]=lf("all"),[N,W]=lf(13),[L,J]=lf(""),[w,Q]=lf(""),[q,T]=lf(""),[O,Z]=lf(""),[E,D]=lf(""),[Y,p]=lf(!1),[V,B]=lf(""),[m,X]=lf(null),S=ML(t),b=RL(Array.isArray(A?.todos)?A.todos:[]),z=f?yY(f):{},P=f?_Y(f):{},s=f?cY(f):{};async function k(o=c){let[ql,pl]=await Promise.all([ml(`${r}/microservices/todo-note/health`),ml(Py(r,"/api/instances"))]);i(ql),y(pl);let Bl=ML(pl),Il=Bl.some((nu)=>nu.id===o)?o:Bl[0]?.id||"";return $(Il),Il}async function v(o=c){if(!o){j(null);return}let ql=await ml(Py(r,`/api/instances/${encodeURIComponent(o)}`));j(ql)}async function tl(o=c){if(!f)return;p(!0),B("");try{let ql=await k(o);await v(ql),X(new Date)}catch(ql){B(El(ql,"Todo Note 加载失败"))}finally{p(!1)}}async function I(o){if(!c)return null;B("");try{let ql=await ml(Py(r,`/api/instances/${encodeURIComponent(c)}/actions`),{method:"POST",body:JSON.stringify({action:o})});return j(ql),await k(c),ql}catch(ql){return B(El(ql,"Todo 操作失败")),null}}async function M(o){o.preventDefault();let ql=L.trim();if(!ql)return;p(!0),B("");try{let pl=await ml(Py(r,"/api/instances"),{method:"POST",body:JSON.stringify({name:ql})});J(""),await tl(pl.id)}catch(pl){B(El(pl,"创建清单失败"))}finally{p(!1)}}async function rl(o){if(!window.confirm("确认删除这个 Todo Note 清单?"))return;p(!0),B("");try{await ml(Py(r,`/api/instances/${encodeURIComponent(o)}`),{method:"DELETE"}),await tl(c===o?"":c)}catch(ql){B(El(ql,"删除清单失败"))}finally{p(!1)}}async function cl(o){o.preventDefault();let ql=w.trim();if(!ql)return;Q(""),await I({type:"addTodo",title:ql})}async function $l(o){if(!c)return;B("");try{let ql=await ml(Py(r,`/api/instances/${encodeURIComponent(c)}/${o}`),{method:"POST",body:JSON.stringify({})});j(ql),await k(c)}catch(ql){B(El(ql,`${o} 失败`))}}function Tl(o){T(o.id),Z(String(o.title||""))}async function Ql(o){let ql=O.trim();if(T(""),Z(""),ql)await I({type:"updateTodoTitle",todoId:o,title:ql})}async function Ol(o){let pl=window.prompt("新增子任务标题")?.trim();if(!pl)return;let Bl=Hj(Array.isArray(A?.todos)?A.todos:[],o),Il=new Set(Bl.map((Qr)=>Qr.id)),nu=await I({type:"addTodo",title:pl,parentId:o,targetIndex:0});if(!nu)return;let Ml=Hj(Array.isArray(nu?.todos)?nu.todos:[],o),wu=Ml.find((Qr)=>!Il.has(Qr.id));if(wu&&Ml[0]?.id!==wu.id)await I({type:"moveTodo",todoId:wu.id,targetParentId:o,targetIndex:0})}async function h(o,ql){if(!E)return;let pl={type:"moveTodo",todoId:E,targetIndex:ql};if(o)pl.targetParentId=o;D(""),await I(pl)}if(iY(()=>{tl()},[f?.id,f?.runtime?.providerStatus]),!f)return Fl(r6,{title:"Todo Note 未登记",text:"请在 config.json 的 microservices 中登记用户服务 id=todo-note"});let a=S.find((o)=>o.id===c)||null,ul=Array.isArray(A?.todos)?A.todos:[],zl=ul.map((o,ql)=>({todo:o,index:ql})).filter((o)=>Bj(o.todo,F));return Fl("div",{className:"todo-note-page","data-testid":"todo-note-page"},Fl(pj,{title:"Todo Note 工作台",eyebrow:"Main Server 用户服务",loading:Y,actions:Fl("div",{className:"panel-actions"},Fl("button",{type:"button",className:"ghost-btn",disabled:Y,onClick:()=>tl(c),"data-testid":"todo-note-refresh-button"},Y?"刷新中":"刷新"),Fl(CL,{title:"Todo Note 用户服务",data:f,onOpen:u,testId:"raw-todo-note-service"}))},Fl("div",{className:"todo-note-hero"},Fl("div",null,Fl("div",{className:"node-version-line"},Fl(tY,{status:z.providerStatus==="online"?"online":"warn"},z.providerStatus||"unknown"),Fl("span",null,f.providerId),Fl("span",null,s.public?"公网暴露":"仅 UniDesk frontend 代理访问"),Fl("span",null,n?.ok?"Health OK":"Health --")),Fl("p",{className:"muted paragraph"},f.description)),Fl("div",{className:"microservice-ref-card"},Fl("span",null,"Repo"),Fl("strong",null,P.url||"--"),Fl("code",null,P.commitId||"--")),Fl("div",{className:"microservice-ref-card"},Fl("span",null,"Main Server Docker"),Fl("strong",null,`${s.nodeBindHost||"--"}:${s.nodePort||"--"}`),Fl("code",null,`${P.composeService||"--"} / ${P.containerName||"--"}`))),Fl(lu,{error:V,wide:!0})),Fl("div",{className:"todo-note-layout"},Fl(pj,{title:"清单",eyebrow:`${S.length} Instances`,className:"todo-list-panel",loading:Y},Fl("form",{className:"todo-create-list",onSubmit:M},Fl("input",{placeholder:"新清单名称",value:L,onChange:(o)=>J(o.target.value),"aria-label":"新清单名称"}),Fl("button",{type:"submit",className:"ghost-btn",disabled:Y||!L.trim()},"创建")),S.length===0?Fl(r6,{title:"暂无清单",text:"迁移或创建清单后会出现在这里"}):Fl("div",{className:"todo-instance-list"},S.map((o)=>Fl("button",{key:o.id,type:"button",className:`todo-instance-row ${c===o.id?"active":""}`,onClick:()=>{$(o.id),v(o.id)},"data-testid":`todo-instance-${hL(o.id)}`},Fl("strong",null,o.name),Fl("span",null,`${o.completedCount??0}/${o.todoCount??0} 完成`),Fl("code",null,o.id))))),Fl("div",{className:"todo-main-stack"},Fl(pj,{title:a?.name||"待选择清单",eyebrow:m?`Updated ${iu(m)}`:"Todo Tree",loading:Y,actions:A?Fl("div",{className:"panel-actions"},Fl("button",{type:"button",className:"ghost-btn",onClick:()=>I({type:"renameInstance",name:window.prompt("清单新名称",A.name)||A.name})},"重命名"),Fl("button",{type:"button",className:"ghost-btn danger",onClick:()=>rl(c)},"删除清单"),Fl(CL,{title:`Todo Instance ${c}`,data:A,onOpen:u,testId:"raw-todo-instance"})):null},!A?Fl(r6,{title:"未选择清单",text:"左侧选择一个 Todo Note 清单"}):Fl("div",{className:"todo-workbench",style:{"--todo-font-size":`${N}px`}},Fl("div",{className:"todo-toolbar"},Fl("form",{className:"todo-add-form",onSubmit:cl},Fl("input",{placeholder:"新增根任务",value:w,onChange:(o)=>Q(o.target.value),"aria-label":"新增根任务"}),Fl("button",{type:"submit",className:"ghost-btn",disabled:!w.trim()},"新增")),Fl("div",{className:"todo-filter-strip"},["all","active","completed"].map((o)=>Fl("button",{key:o,type:"button",className:`todo-filter ${F===o?"active":""}`,onClick:()=>U(o)},o==="all"?"全部":o==="active"?"未完成":"已完成"))),Fl("div",{className:"todo-toolbar-actions"},Fl("button",{type:"button",className:"ghost-btn",onClick:()=>I({type:"setAllTodosExpanded",expanded:!0})},"全部展开"),Fl("button",{type:"button",className:"ghost-btn",onClick:()=>I({type:"setAllTodosExpanded",expanded:!1})},"全部收起"),Fl("button",{type:"button",className:"ghost-btn",onClick:()=>$l("undo")},"撤销"),Fl("button",{type:"button",className:"ghost-btn",onClick:()=>$l("redo")},"重做"),Fl("label",{className:"todo-font-control"},"字号",Fl("input",{type:"range",min:11,max:18,value:N,onChange:(o)=>W(Number(o.target.value))})))),Fl("div",{className:"todo-stats-grid"},Fl(u6,{label:"总任务",value:b.total,hint:`${S.length} lists`}),Fl(u6,{label:"已完成",value:b.completed,hint:`${$Y(b.total?b.completed/b.total*100:0)}`,tone:"ok"}),Fl(u6,{label:"未完成",value:b.active,hint:F==="active"?"当前筛选":"active tasks",tone:b.active>0?"warn":"ok"}),Fl(u6,{label:"历史指针",value:A.historyPointer??0,hint:"undo / redo"})),Fl("div",{className:"todo-root-drop",onDragOver:(o)=>o.preventDefault(),onDrop:(o)=>{o.preventDefault(),h(null,ul.length)}},"拖到这里可移为根任务末尾"),Fl("div",{className:"todo-tree","data-testid":"todo-note-tree"},zl.length===0?Fl(r6,{title:"没有匹配任务",text:"调整筛选或新增任务"}):zl.map(({todo:o,index:ql})=>Fl(bL,{key:o.id,todo:o,depth:0,parentId:null,index:ql,siblingCount:ul.length,filter:F,editingId:q,editingTitle:O,setEditingTitle:Z,beginEdit:Tl,saveEdit:Ql,applyTodoAction:I,addChild:Ol,dragTodoId:E,setDragTodoId:D,dropTodo:h}))))))))}function bL(l){let{todo:u,depth:r,parentId:f,index:n,siblingCount:i,filter:t,editingId:y,editingTitle:c,setEditingTitle:$,beginEdit:A,saveEdit:j,applyTodoAction:F,addChild:U,dragTodoId:N,setDragTodoId:W,dropTodo:L}=l,J=Array.isArray(u.children)?u.children:[],w=J.map((T,O)=>({child:T,childIndex:O})).filter((T)=>Bj(T.child,t)),Q=y===u.id,q=f||null;return Fl("div",{className:"todo-row-wrap"},Fl("article",{className:`todo-row ${u.completed?"completed":""} ${N===u.id?"dragging":""}`,style:{"--todo-depth":r},draggable:!0,onDragStart:(T)=>{W(u.id),T.dataTransfer.effectAllowed="move"},onDragOver:(T)=>T.preventDefault(),onDrop:(T)=>{T.preventDefault(),L(u.id,J.length)},"data-testid":`todo-row-${hL(u.id)}`},Fl("button",{type:"button",className:"todo-expand",disabled:J.length===0,onClick:()=>F({type:"toggleTodoExpanded",todoId:u.id})},J.length===0?"·":u.expanded?"▾":"▸"),Fl("input",{type:"checkbox",checked:Boolean(u.completed),onChange:()=>F({type:"toggleTodoCompleted",todoId:u.id}),"aria-label":`完成 ${u.title}`}),Fl("div",{className:"todo-title-cell",onDoubleClick:()=>A(u)},Q?Fl("div",{className:"todo-edit-inline"},Fl("input",{value:c,autoFocus:!0,onChange:(T)=>$(T.target.value),onKeyDown:(T)=>{if(T.key==="Enter")j(u.id);if(T.key==="Escape")A({id:"",title:""})}}),Fl("button",{type:"button",className:"ghost-btn",onClick:()=>j(u.id)},"保存")):Fl("strong",null,u.title||"Untitled"),Fl("div",{className:"todo-meta-line"},Fl("span",null,`子项 ${J.length}`),Fl("span",null,`更新 ${Wl(u.updatedAt)}`),u.reminderAt?Fl("span",{className:"todo-reminder"},`提醒 ${Wl(u.reminderAt)}`):Fl("span",null,"无提醒"))),Fl("input",{className:"todo-reminder-input",type:"datetime-local",value:D6(u.reminderAt),onChange:(T)=>F({type:"setTodoReminder",todoId:u.id,reminderAt:O7(T.target.value)})}),Fl("div",{className:"todo-row-actions"},Fl("button",{type:"button",className:"ghost-btn",onClick:()=>A(u)},"编辑"),Fl("button",{type:"button",className:"ghost-btn",onClick:()=>U(u.id)},"子项"),Fl("button",{type:"button",className:"ghost-btn",disabled:n<=0,onClick:()=>F({type:"moveTodo",todoId:u.id,...q?{targetParentId:q}:{},targetIndex:n-1})},"上移"),Fl("button",{type:"button",className:"ghost-btn",disabled:n<=0,onClick:()=>F({type:"moveTodo",todoId:u.id,...q?{targetParentId:q}:{},targetIndex:0})},"置顶"),Fl("button",{type:"button",className:"ghost-btn",disabled:n>=i-1,onClick:()=>F({type:"moveTodo",todoId:u.id,...q?{targetParentId:q}:{},targetIndex:n+1})},"下移"),Fl("button",{type:"button",className:"ghost-btn",disabled:!f,onClick:()=>F({type:"moveTodo",todoId:u.id,targetIndex:9999})},"提升"),Fl("button",{type:"button",className:"ghost-btn danger",onClick:()=>F({type:"deleteTodo",todoId:u.id})},"删除"))),u.expanded&&w.length>0?Fl("div",{className:"todo-children"},w.map(({child:T,childIndex:O})=>Fl(bL,{key:T.id,todo:T,depth:r+1,parentId:u.id,index:O,siblingCount:J.length,filter:t,editingId:y,editingTitle:c,setEditingTitle:$,beginEdit:A,saveEdit:j,applyTodoAction:F,addChild:U,dragTodoId:N,setDragTodoId:W,dropTodo:L}))):null)}var vL=Rl(Ju(),1),f0=vL.default.createElement;function sL({title:l,items:u,actions:r,className:f,testId:n}){let i=Array.isArray(u)?u:[];return f0("section",{className:`top-status-bar ${f||""}`,"data-testid":n},f0("div",{className:"top-status-main"},l?f0("strong",{className:"top-status-title"},l):null,f0("div",{className:"top-status-chips"},i.map((t,y)=>f0("span",{key:t?.key||`${t?.label||"status"}-${y}`,className:`top-status-chip ${t?.tone||""}`,"data-testid":t?.testId},t?.label?f0("b",null,t.label):null,f0("span",null,t?.value??"--"))))),r?f0("div",{className:"top-status-actions"},r):null)}var y_=Rl(Ju(),1);var wl=y_.default.createElement,{useEffect:AY,useMemo:jY}=y_.default,FY=y_.default.useState;function kL({status:l,children:u,title:r}){let f=String(l||"unknown").toLowerCase();return wl("span",{className:`status-badge ${f}`,title:r},u||l||"unknown")}function n6({label:l,value:u,hint:r,tone:f}){return wl("article",{className:`metric-card ${f||""}`},wl("div",{className:"metric-label"},l),wl("div",{className:"metric-value"},u),wl("div",{className:"metric-hint"},r))}function Dj({title:l,eyebrow:u,actions:r,children:f,className:n,loading:i}){return wl("section",{className:`panel ${n||""}`},wl("div",{className:"panel-head"},wl("div",null,u?wl("p",{className:"panel-eyebrow"},u):null,wl(fu,{title:l,loading:i})),r?wl("div",{className:"panel-actions"},r):null),wl("div",{className:"panel-body"},f))}function gL({title:l,data:u,onOpen:r,testId:f}){return wl("button",{type:"button",className:"ghost-btn","data-testid":f,onClick:()=>r?.(l,u)},"查看原始JSON")}function Vj({title:l,text:u}){return wl("div",{className:"empty-state"},wl("strong",null,l),wl("span",null,u))}function Cy(l){return Array.isArray(l)?l:[]}function Sj(l){return l&&typeof l==="object"&&!Array.isArray(l)?l:{}}function JY(l){return l?.runtime&&typeof l.runtime==="object"&&!Array.isArray(l.runtime)?l.runtime:{}}function UY(l,u){return`${l}/microservices/k3sctl-adapter/proxy${u}`}function NY(l){return l.find((u)=>String(u?.id||"")==="k3sctl-adapter")||null}function QY(l){if(l?.healthy===!0)return"online";if(String(l?.role||"")==="standby")return"warn";return"failed"}function wY(l){return l?.healthy===!0?"online":"failed"}function qY(l){if(l===!0)return"YES";if(l===!1)return"NO";return"--"}function WY(l){return Array.from(new Set(l.flatMap((u)=>Cy(u?.expectedNodeIds).map((r)=>String(r))))).filter(Boolean).sort()}function LY(l){let u=l.find((r)=>r?.id==="code-queue")||l[0];return String(u?.activeInstanceId||"--")}function GY(l){return wl("article",{key:l?.id||l?.nodeId,className:"k3s-instance-card"},wl("div",{className:"node-card-head"},wl("strong",null,l?.nodeId||l?.id||"--"),wl(kL,{status:QY(l)},l?.healthy?"HEALTHY":"DEGRADED")),wl("div",{className:"k3s-instance-role"},wl("span",null,String(l?.role||"worker").toUpperCase()),wl("code",null,l?.id||"--")),wl("dl",{className:"k3s-kv"},wl("dt",null,"Base URL"),wl("dd",null,wl("code",null,l?.baseUrl||"--")),wl("dt",null,"Proxy"),wl("dd",null,l?.proxyMode||"--"),wl("dt",null,"Health"),wl("dd",null,`${l?.upstreamStatus??"--"} / ${l?.status||"unknown"}`),wl("dt",null,"Checked"),wl("dd",null,Wl(l?.checkedAt))))}function TY(l,u){let r=Cy(l?.instances),f=Sj(l?.active);return wl(Dj,{key:l?.id||"service",title:l?.id||"managed-service",eyebrow:`${l?.namespace||"unidesk"} / k3s managed service`,className:"k3s-service-panel",actions:wl(gL,{title:`k3s service ${l?.id||""}`,data:l,onOpen:u,testId:`raw-k3s-service-${l?.id||"unknown"}`})},wl("div",{className:"k3s-service-summary"},wl("div",null,wl("span",null,"状态"),wl(kL,{status:wY(l)},l?.status||"unknown")),wl("div",null,wl("span",null,"Active"),wl("strong",null,l?.activeInstanceId||"--")),wl("div",null,wl("span",null,"Single Writer"),wl("strong",null,qY(l?.singleWriter))),wl("div",null,wl("span",null,"Active Health"),wl("strong",null,f?.upstreamStatus??"--"))),r.length===0?wl(Vj,{title:"暂无 k3s 实例",text:"adapter 没有返回该服务的 endpoint 列表"}):wl("div",{className:"k3s-instance-grid"},r.map(GY)))}function IL({microservices:l,onRaw:u,apiBaseUrl:r,onNavigate:f}){let n=NY(Array.isArray(l)?l:[]),i=JY(n),[t,y]=FY({loading:!1,error:"",data:null,refreshedAt:null});async function c(){y((w)=>({...w,loading:!0,error:""}));try{let w=await ml(UY(r,"/api/control-plane"));y({loading:!1,error:"",data:w,refreshedAt:new Date})}catch(w){y((Q)=>({...Q,loading:!1,error:El(w,"加载 k3s 控制平面失败")}))}}AY(()=>{c()},[r]);let $=jY(()=>Cy(t.data?.services),[t.data]),A=WY($),j=$.filter((w)=>w?.healthy===!0).length,F=$.reduce((w,Q)=>w+Cy(Q?.instances).length,0),U=$.reduce((w,Q)=>w+Cy(Q?.instances).filter((q)=>q?.healthy===!0).length,0),N=LY($),W=Sj(t.data?.kubectl),L=Sj(t.data?.kubeApiProxy),J=Cy(t.data?.manifestPaths).map((w)=>String(w));if(!n)return wl(Vj,{title:"k3sctl-adapter 未登记",text:"请在 config.json 的 microservices 中登记 id=k3sctl-adapter,并通过该微服务连接 k3s 控制平面。"});return wl("div",{className:"k3s-page","data-testid":"k3sctl-page"},wl(Dj,{title:"k3s Control Plane",eyebrow:"Managed by k3sctl-adapter",className:"k3s-hero-panel",loading:t.loading,actions:wl(y_.default.Fragment,null,wl("button",{type:"button",className:"ghost-btn",onClick:c,disabled:t.loading,"data-testid":"k3s-refresh-button"},t.loading?"刷新中":"刷新"),f?wl("button",{type:"button",className:"ghost-btn",onClick:()=>f("apps","code-queue"),"data-testid":"k3s-open-code-queue"},"打开 Code Queue"):null,wl(gL,{title:"k3sctl-adapter microservice",data:n,onOpen:u,testId:"raw-k3s-adapter"}))},wl("div",{className:"k3s-hero"},wl("div",{className:"k3s-orb","aria-hidden":"true"},wl("span",null,"k3s")),wl("div",{className:"k3s-hero-copy"},wl("p",{className:"eyebrow"},"D601 native control plane"),wl("h2",null,"UniDesk 只管理 adapter;业务微服务交给 k3s 标准服务路由"),wl("p",{className:"muted paragraph"},"Code Queue 的前端/API 请求进入 k3sctl-adapter,再由 adapter 转发到 k3s active service。provider-gateway 只用于维护 adapter 和节点诊断,不再直接管理 Code Queue 容器。"),wl("div",{className:"k3s-route-strip"},wl("span",null,"NO FALLBACK"),wl("code",null,t.data?.runtimePath||"frontend -> backend-core -> k3sctl-adapter")))),wl("div",{className:"metric-grid"},wl(n6,{label:"控制面",value:t.data?.clusterId||"D601",hint:`adapter ${i.providerStatus||"unknown"}`,tone:i.providerStatus==="online"?"ok":"warn"}),wl(n6,{label:"代管服务",value:$.length,hint:`${j}/${$.length||0} healthy`,tone:j===$.length&&$.length>0?"ok":"warn"}),wl(n6,{label:"节点",value:A.join(" / ")||"--",hint:"expected k3s nodes"}),wl(n6,{label:"实例",value:`${U}/${F}`,hint:`active ${N}`,tone:U===F&&F>0?"ok":"warn"})),wl("div",{className:"k3s-control-plane-grid"},wl("article",{className:"k3s-control-plane-card"},wl("span",null,"service proxy"),wl("strong",null,L.configured===!0?"K8S API PROXY":"PROXY DEGRADED"),wl("p",null,L.configured===!0?`${L.mode||"kubernetes-api-service-proxy"} via ${L.connectHost||"--"}`:"adapter 必须通过 k8s API service proxy 访问业务服务,不回退到业务容器直连。")),wl("article",{className:"k3s-control-plane-card"},wl("span",null,"manifests"),wl("strong",null,J.length||"--"),wl("p",null,J.join(" / ")||"未配置 manifest")),wl("article",{className:"k3s-control-plane-card"},wl("span",null,"cluster snapshot"),wl("strong",null,W.enabled===!0?W.ok===!0?"KUBECTL OK":"KUBECTL DEGRADED":"API ONLY"),wl("p",null,W.enabled===!0?`nodes ${W.nodeCount??"--"}`:"控制面页面以 adapter 返回的 k8s service proxy 状态为准;kubectl 只作为可选快照。"))),t.error?wl(lu,{error:t.error}):null,t.refreshedAt?wl("p",{className:"muted paragraph"},`最近刷新 ${iu(t.refreshedAt)}`):null),$.length===0?wl(Dj,{title:"代管服务",eyebrow:"k3s services",loading:t.loading},wl(Vj,{title:"暂无 k3s 服务",text:"等待 k3sctl-adapter 返回 /api/services;Code Queue 应显示 D601 scheduler/read/write 服务实例。"})):$.map((w)=>TY(w,u)))}var c_=Rl(Ju(),1);var ou=c_.default.createElement;function aL({onClose:l}){let{notifications:u,removeNotification:r,clearNotifications:f}=Xr(),n=c_.default.useRef(null);if(c_.default.useEffect(()=>{let i=(t)=>{if(n.current&&!n.current.contains(t.target))l()};return document.addEventListener("mousedown",i),()=>document.removeEventListener("mousedown",i)},[l]),u.length===0)return ou("div",{className:"notification-popup",ref:n},ou("div",{className:"notification-popup-header"},ou("span",null,"通知"),ou("button",{className:"notification-popup-close",onClick:l},"×")),ou("div",{className:"notification-popup-empty"},"暂无通知"));return ou("div",{className:"notification-popup",ref:n},ou("div",{className:"notification-popup-header"},ou("span",null,`通知 (${u.length})`),ou("div",{className:"notification-popup-actions"},ou("button",{className:"notification-popup-clear",onClick:f},"清空"),ou("button",{className:"notification-popup-close",onClick:l},"×"))),ou("div",{className:"notification-popup-list"},u.slice().reverse().map((i)=>ou("div",{key:i.id,className:`notification-item ${i.type}`},ou("span",{className:"notification-item-icon"},i.type==="success"?"✓":"×"),ou("span",{className:"notification-item-message"},i.message),ou("button",{className:"notification-item-dismiss",onClick:()=>r(i.id)},"×")))))}function oL({notification:l}){let{removeNotification:u}=Xr();return c_.default.useEffect(()=>{let r=setTimeout(()=>{u(l.id)},3000);return()=>clearTimeout(r)},[l.id,u]),ou("div",{className:`notification-banner ${l.type}`,role:"alert"},ou("span",{className:"notification-banner-icon"},l.type==="success"?"✓":"×"),ou("span",{className:"notification-banner-message"},l.message),ou("button",{className:"notification-banner-dismiss",onClick:()=>u(l.id)},"×"))}function $G(l,u){let r=document.getElementById("root")?.getAttribute(l);if(!r)return u;try{let f=JSON.parse(r);return typeof f==="object"&&f!==null&&!Array.isArray(f)?f:u}catch{return u}}var sl=$G("data-config",{apiBaseUrl:"/api",authUsername:"admin"}),dL=sl.environment&&typeof sl.environment==="object"?sl.environment:{},mY=$G("data-codex-overview",null),_=i0.default.createElement,{useEffect:En,useMemo:A_}=i0.default,kl=i0.default.useState,Cj=i0.default.createContext(!1),Xf=bQ(p3),KY={id:"code-queue",name:"Code Queue",providerId:"D601",description:"Code Queue",repository:{containerName:"k3s:code-queue"},backend:{nodeBaseUrl:"k3s://code-queue",nodeBindHost:"k3s://unidesk/code-queue",nodePort:4222,proxyMode:"k3sctl-adapter-http",public:!1},deployment:{mode:"k3sctl-managed",adapterServiceId:"k3sctl-adapter",k3sServiceId:"code-queue"},runtime:{orchestrator:"k3sctl",providerStatus:"loading",providerName:"D601"}};function eL(){return typeof document>"u"||document.visibilityState!=="hidden"}function AG(l){return l?.environment==="dev"||l?.namespace==="unidesk-dev"}function zY(l){let u=typeof l==="string"?l:"";return u.length>=7?u.slice(0,7):u||"unknown"}function EY(l,u){if(l==="ops"&&u==="status")return 5000;if(l==="nodes"&&u==="monitor")return 5000;if(l==="tasks"&&(u==="dispatch"||u==="scheduled"||u==="pending"))return 5000;if(l==="nodes"||l==="ops")return 1e4;if(l==="apps")return 15000;if(l==="tasks")return 15000;return 30000}async function OY(l){if(!l?._summaryOnly||!l?.id)return l;return(await ml(`${sl.apiBaseUrl}/tasks/${encodeURIComponent(String(l.id))}`))?.task||l}function j_(l){return l?._summaryOnly?{...l,_loadRaw:()=>OY(l)}:l}function fi(l){if(!Number.isFinite(l))return"--";let u=Math.max(0,l);if(u===0)return"0s";if(u<0.01)return"<0.01s";if(u<0.1)return`${u.toFixed(2)}s`;if(u<1)return`${u.toFixed(1)}s`;if(u<10&&!Number.isInteger(u))return`${u.toFixed(1)}s`;if(u<60)return`${Math.round(u)}s`;let r=Math.floor(u);if(r<3600)return`${Math.floor(r/60)}m ${r%60}s`;return`${Math.floor(r/3600)}h ${Math.floor(r%3600/60)}m`}function Wf(l){let u=Number(l);if(!Number.isFinite(u))return"--";if(u<1)return`${Math.max(0,u).toFixed(1)}ms`;if(u<10)return`${u.toFixed(1)}ms`;if(u<1000)return`${Math.round(u)}ms`;return fi(u/1000)}function Nr(l){let u=Number(l);if(!Number.isFinite(u)||u<=0)return"--";let r=["B","KB","MB","GB","TB"],f=u,n=0;while(f>=1024&&n<r.length-1)f/=1024,n+=1;return`${f.toFixed(n===0?0:1)} ${r[n]}`}function n0(l){let u=Number(l);return Number.isFinite(u)?`${Math.max(0,Math.min(100,u)).toFixed(1)}%`:"--"}function ZY(l){let u=Number(l);return Number.isFinite(u)?`${Math.max(0,u).toFixed(1)}%`:"--"}function Xj(l){let u=Number(l);if(!Number.isFinite(u)||u<=0)return"0 B/s";return`${Nr(u)}/s`}function vl(l,u=0){let r=Number(l);return Number.isFinite(r)?r:u}function hy(l){return["queued","dispatched","running"].includes(String(l?.status||"").toLowerCase())}function Mj(l){if(!l)return"--";let u=new Date(l);if(Number.isNaN(u.getTime()))return"--";return fi(Math.max(0,Math.floor((Date.now()-u.getTime())/1000)))}function On(l){if(!l)return null;let u=new Date(l);return Number.isNaN(u.getTime())?null:u.getTime()}function jG(l){let u=On(l?.createdAt);if(u===null)return null;let f=["succeeded","failed"].includes(String(l?.status||"").toLowerCase())?On(l?.updatedAt):Date.now();if(f===null)return null;return Math.max(0,(f-u)/1000)}function FG(l){if(String(l?.status||"").toLowerCase()!=="failed")return"";let u=l?.result;if(typeof u==="string")return u;if(u&&typeof u==="object"&&!Array.isArray(u)){let r=u;for(let f of["error","reason","message","stderr","detail"])if(typeof r[f]==="string"&&r[f].length>0)return r[f]}return"任务失败但 provider 未返回明确原因"}function ft(l){if(l===null||l===void 0)return"--";if(typeof l==="boolean")return l?"是":"否";if(typeof l==="number")return String(l);if(typeof l==="string")return l.length>80?`${l.slice(0,77)}...`:l;if(Array.isArray(l))return`${l.length} 项`;if(typeof l==="object")return`${Object.keys(l).length} 字段`;return String(l)}function pY(l,u){let r=l.replace(/[-_\s]/g,"").toLowerCase(),f=r==="ts"||r.endsWith("at")||r.endsWith("timestamp")||r.endsWith("heartbeat");if((typeof u==="string"||typeof u==="number")&&f){let n=Wl(u);if(n!=="--")return n}if(l==="bodyText"&&typeof u==="string")return`${/^\s*[{[]/.test(u)?"JSON":"HTTP"} body ${u.length} chars`;return ft(u)}function JG(l){if(!l||typeof l!=="object"||Array.isArray(l))return[];return Object.entries(l)}function uf(l){return String(l).replace(/[^a-zA-Z0-9_-]/g,"_")}function hj(l,u){return l&&typeof l==="object"&&!Array.isArray(l)?l[u]:void 0}function t6(l,u,r="未知"){let f=hj(l?.labels,u);return typeof f==="string"&&f.length>0?f:r}function UG(l){return t6(l,"providerGatewayVersion")}function $_(l){return t6(l,"providerGatewayUpgradePolicy")}function lG(l){return t6(l,"providerGatewayStartedAt","")}function NG(l){let u=hj(l?.labels,"unideskCapabilities");if(typeof u==="string")return u.split(",").map((r)=>r.trim()).filter(Boolean);return Array.isArray(u)?u.filter((r)=>typeof r==="string"):[]}function QG(l,u){return NG(l).includes(u)}function uG(l,u){let r=hj(l?.labels,u);return r===!0||r==="true"||r==="1"}function HY(l){if(!QG(l,"host.ssh"))return{tone:"fail",label:"不可用",detail:"未声明 host.ssh"};if(!uG(l,"hostSshConfigured"))return{tone:"warn",label:"未配置",detail:"缺少 SSH 环境变量"};if(!uG(l,"hostSshKeyPresent"))return{tone:"warn",label:"缺 key",detail:"私钥未挂载"};return{tone:"ok",label:"可用",detail:t6(l,"hostSshTarget","host.ssh ready")}}function BY(l){if(!QG(l,"provider.upgrade"))return{tone:"fail",label:"不可用",detail:"未声明 provider.upgrade"};let u=$_(l);if(u!=="always-enabled")return{tone:"warn",label:"待确认",detail:`策略 ${u}`};return{tone:"ok",label:"可用",detail:"always-enabled"}}function Rj(l){let u=typeof l==="string"&&l.length>0?l:"未知";if(u==="未知")return"版本未知";return u.startsWith("v")?u:`v${u}`}function wG(l){return l?.payload&&typeof l.payload==="object"&&!Array.isArray(l.payload)?l.payload:{}}function y6(l){return l?.result&&typeof l.result==="object"&&!Array.isArray(l.result)?l.result:{}}function i6(l){let u=wG(l),r=y6(l);return(u.mode??r.mode)==="schedule"?"schedule":"plan"}function DY(l){let u=wG(l).source;return typeof u==="string"&&u.length>0?u:"unknown"}function VY(l){let u=y6(l),r=u.plan&&typeof u.plan==="object"&&!Array.isArray(u.plan)?u.plan:{},f=u.policy??r.policy;return typeof f==="string"&&f.length>0?f:"--"}function qG(l){let u=y6(l),r=u.plan&&typeof u.plan==="object"&&!Array.isArray(u.plan)?u.plan:{},f=u.targetProviderGatewayVersion??u.providerGatewayVersion??r.targetProviderGatewayVersion??r.providerGatewayVersion;return typeof f==="string"&&f.length>0?Rj(f):"版本未知"}function WG(l){if(String(l?.status||"").toLowerCase()==="failed")return FG(l);if(hy(l))return"等待 provider 回传升级终态";let r=y6(l);if(typeof r.updaterContainerId==="string"&&r.updaterContainerId.length>0)return`updater ${r.updaterContainerId.slice(0,18)}`;if(typeof r.message==="string"&&r.message.length>0)return r.message;if(r.plan)return"升级计划已生成";return"无升级结果摘要"}function LG(l,u){return l.filter((r)=>r?.providerId===u&&r?.command==="provider.upgrade").sort((r,f)=>(On(f.updatedAt)??0)-(On(r.updatedAt)??0))}function SY(l){return l.find((u)=>i6(u)==="schedule")||l[0]||null}function GG(l){return l?.runtime&&typeof l.runtime==="object"&&!Array.isArray(l.runtime)?l.runtime:{}}function rG(l){return l?.backend&&typeof l.backend==="object"&&!Array.isArray(l.backend)?l.backend:{}}function XY(l){return l?.repository&&typeof l.repository==="object"&&!Array.isArray(l.repository)?l.repository:{}}function Tu({status:l,children:u}){let r=String(l||"unknown").toLowerCase();return _("span",{className:`status-badge ${r}`},u||l||"unknown")}function Nu({label:l,value:u,hint:r,tone:f,onClick:n,testId:i}){let t=typeof n==="function";return _("article",{className:`metric-card ${f||""} ${t?"clickable":""}`,role:t?"button":void 0,tabIndex:t?0:void 0,"data-testid":i,onClick:n,onKeyDown:t?(y)=>{if(y.key==="Enter"||y.key===" ")y.preventDefault(),n()}:void 0},_("div",{className:"metric-label"},l),_("div",{className:"metric-value"},u),_("div",{className:"metric-hint"},r))}function uu({title:l,eyebrow:u,actions:r,children:f,className:n,loading:i}){let t=i0.default.useContext(Cj),y=Boolean(i)||t;return _("section",{className:`panel ${n||""}`},_("div",{className:"panel-head"},_("div",null,u?_("p",{className:"panel-eyebrow"},u):null,_(fu,{title:l,loading:y})),r?_("div",{className:"panel-actions"},r):null),_("div",{className:"panel-body"},f))}function du({title:l,data:u,onOpen:r,testId:f}){let[n,i]=kl(!1),t=u&&typeof u==="object"&&typeof u._loadRaw==="function"?u._loadRaw:null;async function y(){if(!t){r(l,u);return}i(!0);try{r(l,await t())}catch(c){r(l,{ok:!1,error:El(c,"读取原始 JSON 失败"),fallback:u})}finally{i(!1)}}return _("button",{type:"button",className:"ghost-btn","data-testid":f,disabled:n,onClick:()=>void y()},n?"读取中":"查看原始JSON")}function YY({raw:l,onClose:u}){if(!l)return null;return _("div",{className:"modal-backdrop",role:"presentation"},_("section",{className:"raw-dialog",role:"dialog","aria-modal":"true","aria-label":l.title},_("div",{className:"raw-dialog-head"},_("h2",null,l.title),_("button",{type:"button",className:"ghost-btn",onClick:u},"关闭")),_("pre",{className:"raw-json","data-testid":"raw-json"},JSON.stringify(l.data,null,2))))}function TG({labels:l,limit:u=8}){let r=JG(l).slice(0,u);if(r.length===0)return _("span",{className:"muted"},"无标签");return _("div",{className:"chip-row"},r.map(([f,n])=>_("span",{key:f,className:"data-chip"},_("b",null,f),_("span",null,ft(n)))))}function My({node:l}){let u=UG(l);return _("span",{className:`version-chip ${u==="未知"?"unknown":""}`,"data-testid":`gateway-version-${uf(l?.providerId||"unknown")}`},Rj(u))}function fG({title:l,state:u,testId:r}){return _("span",{className:`capability-badge ${u.tone}`,title:u.detail,"data-testid":r},_("b",null,l),_("strong",null,u.label),_("small",null,u.detail))}function xj({node:l}){let u=uf(l?.providerId||"unknown");return _("div",{className:"node-availability-strip"},_(fG,{title:"SSH 透传",state:HY(l),testId:`ssh-availability-${u}`}),_(fG,{title:"远程更新",state:BY(l),testId:`upgrade-availability-${u}`}))}function t0({data:l,empty:u="无数据"}){if(l===null||l===void 0)return _("span",{className:"muted"},u);if(typeof l!=="object")return _("span",{className:"summary-value"},ft(l));if(Array.isArray(l))return _("span",{className:"summary-value"},`${l.length} 项列表`);let r=Object.entries(l).slice(0,5);if(r.length===0)return _("span",{className:"muted"},u);return _("div",{className:"summary-grid"},r.map(([f,n])=>_("span",{key:f,className:"summary-item"},_("b",null,f),_("span",null,pY(f,n)))))}function Qu({title:l,text:u}){return _("div",{className:"empty-state"},_("strong",null,l),_("span",null,u))}function PY({onLogin:l}){let[u,r]=kl(sl.authUsername||"admin"),[f,n]=kl(""),[i,t]=kl(""),[y,c]=kl(!1);async function $(A){A.preventDefault(),c(!0),t("");try{let j=await ml("/login",{method:"POST",body:JSON.stringify({username:u,password:f})});l(j)}catch(j){t(El(j,"登录失败"))}finally{c(!1)}}return _("main",{className:"login-screen","data-testid":"login-screen"},_("section",{className:"login-card"},_("div",{className:"login-brand"},_("span",{className:"brand-mark"},"UD"),_("div",null,_("h1",null,"UniDesk"),_("p",null,"Control Plane Login"))),_("form",{className:"login-form",onSubmit:$},_("label",null,"账号",_("input",{name:"username",autoComplete:"username",value:u,onChange:(A)=>r(A.target.value)})),_("label",null,"密码",_("input",{name:"password",type:"password",autoComplete:"current-password",value:f,onChange:(A)=>n(A.target.value)})),_(lu,{error:i}),_("button",{type:"submit",disabled:y},y?"登录中":"登录")),_("div",{className:"login-note"},"默认账号由 config.json 注入;公网入口只暴露前端登录面。")))}function CY({connection:l,lastRefresh:u,onRefresh:r,onLogout:f,session:n,clock:i,activeStatusItems:t=[],onNotificationToggle:y,unreadCount:c=0,environment:$={}}){let A=AG($),j=[...A?[{key:"environment",label:"环境",value:`${$.namespace||"unidesk-dev"}`,tone:"warn"}]:[],{key:"core",label:"核心",value:l.text,tone:l.ok?"ok":"fail",testId:"conn-text"},...Array.isArray(t)?t:[],{key:"refresh",label:"刷新",value:u?iu(u):"未刷新"},{key:"clock",label:Z_,value:iu(i)},{key:"user",label:"用户",value:n?.user?.username||"--",tone:"user"}];return _("header",{className:"topbar"},_("div",null,_("p",{className:"eyebrow"},"Distributed Work Platform"),_("h1",null,"UniDesk 控制平面"),A?_("div",{className:"dev-env-ribbon","data-testid":"dev-environment-ribbon"},_("b",null,"DEV"),_("span",null,$.namespace||"unidesk-dev"),_("span",null,$.deployRef||"origin/master:deploy.json#environments.dev"),_("span",null,zY($.commit||$.requestedCommit))):null),_(sL,{className:"global-top-status",title:"状态",items:j,actions:[_("button",{key:"notification",type:"button",className:`notification-icon-btn ${c>0?"has-unread":""}`,onClick:y,"aria-label":"通知"},"\uD83D\uDD14",c>0?_("span",{key:"badge",className:"notification-badge"},c>99?"99+":c):null),_("button",{key:"refresh",type:"button",className:"ghost-btn",onClick:r},"刷新"),_("button",{key:"logout",type:"button",className:"ghost-btn danger",onClick:f},"退出")]}))}function MY(l){return!l.defaultPrevented&&l.button===0&&!l.metaKey&&!l.altKey&&!l.ctrlKey&&!l.shiftKey&&l.currentTarget.target!=="_blank"}function mG({moduleId:l,tabId:u,className:r,active:f=!1,title:n,testId:i,onNavigate:t,children:y}){let c=H3(Xf,l,u);return _("a",{href:c,role:"button",className:r,title:n,"aria-current":f?"page":void 0,"data-testid":i,"data-route":c,onClick:($)=>{if(!MY($))return;$.preventDefault(),t(l,u)}},y)}function hY({activeModule:l,activeTabs:u,onNavigate:r,collapsed:f,onToggle:n}){return _("aside",{className:`rail ${f?"collapsed":""}`,"aria-label":"主模块"},_("div",{className:"brand"},_("span",{className:"brand-mark"},"UD"),_("span",{className:"brand-text"},"UniDesk"),_("button",{type:"button",className:"rail-toggle",onClick:n,"aria-label":f?"展开左侧边栏":"收起左侧边栏","data-testid":"rail-toggle"},f?"»":"«")),p3.map((i)=>{let t=u[i.id]||Nc[i.id]||i.tabs[0]?.id||"";return _(mG,{key:i.id,moduleId:i.id,tabId:t,className:`module ${l===i.id?"active":""}`,active:l===i.id,title:i.label,onNavigate:r},_("span",{className:"module-code"},i.code),_("span",null,i.label))}))}function RY({module:l,activeTab:u,onNavigate:r}){return _("nav",{className:"tabs","aria-label":`${l.label} 子功能`},l.tabs.map((f)=>_(mG,{key:f.id,moduleId:l.id,tabId:f.id,className:`tab ${u===f.id?"active":""}`,active:u===f.id,onNavigate:r},f.label)))}function xY({data:l,onRaw:u,onNavigate:r}){let f=l.overview||{},n=l.nodes.filter((U)=>U.status==="online"),i=l.pendingTasks||l.tasks.filter(hy),t=f.pendingTaskCount??i.length,y=l.tasks.slice(0,5),c=f.pgdata||{},$=f.microserviceAvailability||{},A=vl($.totalCount),j=vl($.healthyCount),F=vl($.unhealthyCount);return _("div",{className:"page-grid overview-grid","data-testid":"overview-page"},_(uu,{title:"核心指标",eyebrow:"Control"},_("div",{className:"metric-grid"},_(Nu,{label:"数据库",value:f.dbReady?"READY":"WAIT",hint:"PostgreSQL internal network",tone:f.dbReady?"ok":"warn"}),_(Nu,{label:"PGDATA",value:Nr(c.databaseBytes),hint:`${c.volumeName||"unidesk_pgdata_10gb"} / ${c.databasePretty||"--"} / budget ${c.volumeSize||"--"}`,tone:"ok",testId:"pgdata-usage-card"}),_(Nu,{label:"在线节点",value:f.onlineNodeCount??0,hint:`${f.nodeCount??0} registered`,tone:"ok"}),_(Nu,{label:"WebSocket",value:f.activeSocketCount??0,hint:"Provider ingress sockets"}),_(Nu,{label:"用户服务可用",value:A>0?`${j}/${A}`:"--",hint:A>0?`healthyCount ${j} · unhealthyCount ${F}`:"strict /health probes",tone:A>0&&F===0?"ok":"warn",testId:"microservice-availability-card"}),_(Nu,{label:"待处理任务",value:t,hint:t>0?"点击查看具体任务":`timeout ${fi(Math.floor((f.taskPendingTimeoutMs??0)/1000))}`,tone:t>0?"warn":"ok",onClick:()=>r("tasks","pending"),testId:"pending-task-card"}))),_(uu,{title:"本机 Provider",eyebrow:"Self Connected"},n.length===0?_(Qu,{title:"暂无在线节点",text:"provider-gateway 未完成自接入"}):_("div",{className:"node-card-list"},n.slice(0,4).map((U)=>_(bY,{key:U.providerId,node:U,onRaw:u})))),_(uu,{title:"待处理任务明细",eyebrow:`${t} Pending`,actions:_("button",{type:"button",className:"ghost-btn",onClick:()=>r("tasks","pending"),"data-testid":"pending-task-detail-link"},"进入任务调度")},i.length===0?_(Qu,{title:"当前无待处理",text:"queued / dispatched / running 超时后会自动转为 failed,避免总览长期卡住"}):_("div",{className:"compact-list"},i.slice(0,5).map((U)=>_(yG,{key:U.id,task:U,onRaw:u})))),_(uu,{title:"最近任务",eyebrow:"Dispatch"},y.length===0?_(Qu,{title:"暂无任务",text:"可以在任务调度模块发起 docker.ps 或 echo"}):_("div",{className:"compact-list"},y.map((U)=>_(yG,{key:U.id,task:U,onRaw:u})))))}function bY({node:l,onRaw:u}){return _("article",{className:"node-card"},_("div",{className:"node-card-head"},_("div",null,_("strong",null,l.name),_("code",null,l.providerId)),_(Tu,{status:l.status})),_("div",{className:"node-version-line"},_(My,{node:l}),_("span",null,`升级策略 ${$_(l)}`)),_(xj,{node:l}),_(TG,{labels:l.labels,limit:6}),_("div",{className:"node-card-foot"},_("span",null,`心跳 ${Wl(l.lastHeartbeat)}`),_(du,{title:`Provider ${l.providerId}`,data:l,onOpen:u,testId:`raw-node-${uf(l.providerId)}`})))}function vY({events:l,onRaw:u}){return _(uu,{title:"事件摘要",eyebrow:"Latest 100"},l.length===0?_(Qu,{title:"暂无事件",text:"Provider 注册、心跳超时和任务状态会写入事件流"}):_("div",{className:"table-wrap"},_("table",null,_("thead",null,_("tr",null,_("th",null,"ID"),_("th",null,"类型"),_("th",null,"来源"),_("th",null,"摘要"),_("th",null,"时间"),_("th",null,"操作"))),_("tbody",null,l.map((r)=>_("tr",{key:r.id},_("td",null,_("code",null,r.id)),_("td",null,_(Tu,{status:r.type},r.type)),_("td",null,_("code",null,r.source)),_("td",null,_(t0,{data:r.payload})),_("td",null,Wl(r.createdAt)),_("td",null,_(du,{title:`Event ${r.id}`,data:r,onOpen:u}))))))))}function sY({logs:l,onRaw:u}){return _(uu,{title:"服务日志",eyebrow:"Core Recent"},l.length===0?_(Qu,{title:"暂无日志",text:"backend-core 内存日志会在请求和 provider 事件后出现"}):_("div",{className:"log-list"},l.slice(-80).reverse().map((r,f)=>_("article",{key:f,className:`log-row ${r.level||"info"}`},_("span",null,Wl(r.ts)),_("b",null,r.level||"info"),_("strong",null,r.message||"log"),_(t0,{data:r.data,empty:"无附加字段"}),_(du,{title:`Log ${r.message||f}`,data:r,onOpen:u})))))}function kY({nodes:l,onRaw:u}){return _(uu,{title:"节点清单",eyebrow:`${l.length} Providers`},l.length===0?_(Qu,{title:"暂无 Provider 节点",text:"确认 provider-gateway 已连接 provider ingress"}):_("div",{className:"table-wrap"},_("table",{className:"node-list-table"},_("thead",null,_("tr",null,_("th",null,"状态"),_("th",null,"Provider"),_("th",null,"网关版本"),_("th",null,"运维可用性"),_("th",null,"资源标签"),_("th",null,"连接时间"),_("th",null,"最后心跳"),_("th",null,"操作"))),_("tbody",null,l.map((r)=>_("tr",{key:r.providerId},_("td",null,_(Tu,{status:r.status})),_("td",null,_("strong",null,r.name),_("code",null,r.providerId)),_("td",null,_("div",{className:"gateway-cell"},_(My,{node:r}),_("span",null,$_(r)))),_("td",null,_(xj,{node:r})),_("td",null,_(TG,{labels:r.labels,limit:5})),_("td",null,Wl(r.connectedAt)),_("td",null,Wl(r.lastHeartbeat)),_("td",null,_(du,{title:`Provider ${r.providerId}`,data:r,onOpen:u,testId:`raw-node-table-${uf(r.providerId)}`}))))))))}function gY({nodes:l}){let u=A_(()=>{let r=[];for(let f of l)for(let[n,i]of JG(f.labels))r.push({providerId:f.providerId,name:f.name,key:n,value:i});return r},[l]);return _(uu,{title:"资源标签",eyebrow:"Structured Labels"},u.length===0?_(Qu,{title:"暂无标签",text:"provider-gateway 注册消息会同步资源标签"}):_("div",{className:"label-matrix"},u.map((r)=>_("article",{key:`${r.providerId}-${r.key}`,className:"label-card"},_("span",null,r.key),_("strong",null,ft(r.value)),_("code",null,r.providerId)))))}function IY({nodes:l}){return _(uu,{title:"心跳状态",eyebrow:"Provider Liveness"},l.length===0?_(Qu,{title:"无心跳",text:"等待 provider 注册和 heartbeat"}):_("div",{className:"heartbeat-list"},l.map((u)=>_("article",{key:u.providerId,className:"heartbeat-row"},_("span",{className:`pulse ${u.status}`}),_("div",null,_("strong",null,u.name),_("code",null,u.providerId)),_("div",null,_("span",null,"connected"),_("b",null,Wl(u.connectedAt))),_("div",null,_("span",null,"last heartbeat"),_("b",null,Wl(u.lastHeartbeat)))))))}function aY({nodes:l,systemStatuses:u,tasks:r,onRaw:f,refresh:n}){let[i,t]=kl(""),y=A_(()=>l.map((W)=>{let L=u.find((J)=>J.providerId===W.providerId);return{...W,systemCurrent:L?.current||null,systemHistory:L?.history||[],systemUpdatedAt:L?.updatedAt||null}}),[l,u]),c=y.find((W)=>W.providerId===i)||y[0]||null;if(En(()=>{if(!i&&y[0])t(y[0].providerId)},[y.length,i]),!c)return _(Qu,{title:"暂无资源监控",text:"等待 provider 上报 CPU、内存和硬盘指标"});let $=c.systemCurrent,A=c.systemHistory||[],j=$?.cpu||{},F=$?.memory||{},U=$?.disk||{},N=A.length>0?A:$?[{at:$.collectedAt,cpuPercent:vl(j.percent),memoryPercent:vl(F.percent),diskPercent:vl(U.percent)}]:[];return _("div",{className:"monitor-page","data-testid":"node-monitor-page"},_("div",{className:"docker-node-strip"},y.map((W)=>_("button",{key:W.providerId,type:"button",className:`docker-node-tile ${c.providerId===W.providerId?"active":""}`,onClick:()=>t(W.providerId)},_("span",{className:`pulse ${W.status}`}),_("strong",null,W.name),_("code",null,W.providerId),_("span",null,W.systemCurrent?`CPU ${n0(W.systemCurrent.cpu?.percent)} / MEM ${n0(W.systemCurrent.memory?.percent)}`:"等待指标")))),_("div",{className:"monitor-layout"},_(uu,{title:"任务管理器视图",eyebrow:c.name,className:"monitor-main-panel",actions:$?_(du,{title:`System ${c.providerId}`,data:{current:$,history:A},onOpen:f}):null},!$?_(Qu,{title:"系统指标未上报",text:"provider-gateway 会周期性采集 /proc 与 df,并保存历史曲线"}):_("div",null,_("div",{className:"monitor-hero"},_("div",null,_("p",{className:"panel-eyebrow"},"Node Performance"),_("h3",null,c.name),_("div",{className:"docker-meta"},_("span",null,`${j.cores||0} CPU cores`),_("span",null,`load ${vl(j.load1).toFixed(2)} / ${vl(j.load5).toFixed(2)} / ${vl(j.load15).toFixed(2)}`),_("span",null,`memory actual ${Nr(F.usedBytes)} / ${Nr(F.totalBytes)}`),_("span",null,`disk ${Nr(U.usedBytes)} / ${Nr(U.totalBytes)}`))),_(Tu,{status:$.ok?"online":"warn"},$.ok?"METRICS READY":"METRICS DEGRADED")),_("div",{className:"monitor-chart-grid"},_(Yj,{title:"CPU",metricKey:"cpuPercent",current:j.percent,points:N,detail:`${j.cores||0} cores / load ${vl(j.load1).toFixed(2)}`,tone:"cpu",testId:"metric-chart-cpu"}),_(Yj,{title:"Memory",metricKey:"memoryPercent",current:F.percent,points:N,detail:`${Nr(F.usedBytes)} actual / ${Nr(F.cacheBytes)} cache excluded`,tone:"memory",testId:"metric-chart-memory"}),_(Yj,{title:"Disk",metricKey:"diskPercent",current:U.percent,points:N,detail:`${U.path||"/"} mounted ${U.mount||"--"}`,tone:"disk",testId:"metric-chart-disk"})),_("div",{className:"monitor-summary-grid"},_(Nu,{label:"CPU 当前",value:n0(j.percent),hint:`history ${N.length} samples`,tone:"ok"}),_(Nu,{label:"实际内存",value:Nr(F.usedBytes),hint:`${n0(F.percent)} 不含缓存`}),_(Nu,{label:"硬盘已用",value:Nr(U.usedBytes),hint:n0(U.percent)}),_(Nu,{label:"更新时间",value:Wl(c.systemUpdatedAt||$.collectedAt),hint:c.providerId})),_(oY,{current:$,onRaw:f}))),_("div",{className:"monitor-side-stack"},_(iP,{provider:c,refresh:n,onRaw:f}),_(tP,{provider:c,tasks:r,onRaw:f,limit:5}),_(uu,{title:"采样说明",eyebrow:"Retention"},_("div",{className:"monitor-note-list"},_("article",null,_("b",null,"CPU"),_("span",null,"从 /proc/stat 计算相邻采样差值,首个采样用 load/cores 近似")),_("article",null,_("b",null,"Memory"),_("span",null,"实际内存 = MemTotal - MemFree - Buffers - Cached - SReclaimable + Shmem,不把 page cache / buffer 计入占用")),_("article",null,_("b",null,"Disk"),_("span",null,"使用 df -PB1 对配置路径采样,默认监控根文件系统")),_("article",null,_("b",null,"Process"),_("span",null,"从 /proc/[pid] 采集进程 CPU、实际内存 PSS、RSS、线程数和磁盘 I/O 速率;PSS 不重复计算共享内存,表格默认按内存占用降序")))))))}function KG(l){return vl(l.memoryBytes,vl(l.pssBytes,vl(l.rssBytes)))}function nG(l,u){if(u==="memory")return KG(l);if(u==="cpu")return vl(l.cpuPercent);if(u==="disk")return vl(l.readBytesPerSecond)+vl(l.writeBytesPerSecond);if(u==="pid")return vl(l.pid);if(u==="threads")return vl(l.threads);if(u==="runtime")return vl(l.elapsedSeconds);if(u==="user")return String(l.user||"");return String(l.name||l.command||"")}function iG({value:l,label:u,tone:r}){let f=Math.max(1,Math.min(100,vl(l)));return _("div",{className:`process-meter ${r||""}`},_("span",{style:{width:`${f}%`}}),_("b",null,u))}function oY({current:l,onRaw:u}){let[r,f]=kl({key:"memory",direction:"desc"}),n=i0.default.useContext(Cj),i=l?.processSummary&&typeof l.processSummary==="object"?l.processSummary:{},t=Array.isArray(l?.processes)?l.processes:[],y=String(i.memoryMode||""),c=y.includes("pss_smaps_rollup")?"PSS":y==="rss_minus_shared_fallback"?"RSS-shared":"RSS fallback",$=A_(()=>{let j=r.direction==="asc"?1:-1;return[...t].sort((F,U)=>{let N=nG(F,r.key),W=nG(U,r.key);if(typeof N==="string"||typeof W==="string")return String(N).localeCompare(String(W),"zh-CN")*j;return(N-W)*j||vl(F.pid)-vl(U.pid)})},[t,r.key,r.direction]),A=(j,F)=>{let U=r.key===F,N=U?r.direction==="asc"?"ascending":"descending":"none";return _("th",{"aria-sort":N},_("button",{type:"button",className:`process-sort-button ${U?"active":""}`,"data-testid":`process-sort-${F}`,onClick:()=>f((W)=>({key:F,direction:W.key===F&&W.direction==="desc"?"asc":"desc"}))},j,_("span",null,U?r.direction==="desc"?"↓":"↑":"↕")))};return _("section",{className:"process-resource-panel","data-testid":"process-resource-panel"},_("div",{className:"process-resource-head"},_("div",null,_("p",{className:"panel-eyebrow"},"Windows Resource Monitor Style"),_(fu,{title:"进程资源占用",level:3,loading:n})),_("div",{className:"process-resource-actions"},_("span",{className:"data-chip"},"默认按内存排序"),_("span",{className:"data-chip"},`内存口径 ${c}`),_("span",{className:"data-chip"},`${vl(i.visible,$.length)} / ${vl(i.total,$.length)} 进程`),_(du,{title:"Process Resource Snapshot",data:{processSummary:i,processes:t},onOpen:u,testId:"raw-process-resources"}))),$.length===0?_(Qu,{title:"暂无进程资源数据",text:"等待 provider-gateway 上报 /proc/[pid] 采样;旧版 provider 需要先升级到支持进程资源表的版本"}):_("div",{className:"process-table-wrap"},_("table",{className:"process-resource-table","data-testid":"process-resource-table"},_("thead",null,_("tr",null,A("进程","name"),A("PID","pid"),A("用户","user"),_("th",null,"状态"),A("CPU","cpu"),A("内存","memory"),_("th",null,"PSS / RSS"),A("磁盘 I/O","disk"),A("线程","threads"),A("运行时长","runtime"))),_("tbody",null,$.map((j)=>{let F=vl(j.readBytesPerSecond)+vl(j.writeBytesPerSecond),U=KG(j);return _("tr",{key:`${j.pid}-${j.startedAt}`,"data-testid":`process-row-${uf(j.pid)}`,"data-memory-bytes":String(U),"data-cpu-percent":String(vl(j.cpuPercent)),"data-disk-bps":String(F),"data-pid":String(vl(j.pid))},_("td",null,_("div",{className:"process-name-cell"},_("strong",null,j.name||"--"),_("span",{className:"process-command"},j.command||"--"))),_("td",null,_("code",null,j.pid||"--")),_("td",null,j.user||`uid:${j.uid??"--"}`),_("td",null,_("span",{className:`process-state state-${uf(j.state||"unknown")}`},j.state||"?")),_("td",null,_(iG,{value:j.cpuPercent,label:ZY(j.cpuPercent),tone:"cpu"})),_("td",null,_(iG,{value:j.memoryPercent,label:n0(j.memoryPercent),tone:"memory"})),_("td",null,_("div",{className:"process-io-cell"},_("strong",null,Nr(U)),_("span",null,`RSS ${Nr(j.rssBytes)}`))),_("td",null,_("div",{className:"process-io-cell"},_("strong",null,Xj(F)),_("span",null,`R ${Xj(j.readBytesPerSecond)} / W ${Xj(j.writeBytesPerSecond)}`))),_("td",null,j.threads||0),_("td",null,fi(vl(j.elapsedSeconds))))})))))}function Yj({title:l,metricKey:u,current:r,points:f,detail:n,tone:i,testId:t}){let y=f.map((F)=>Math.max(0,Math.min(100,vl(F[u])))),c=y.length>1?y:[y[0]||0,y[0]||0],$=c.length<=1?100:100/(c.length-1),A=c.map((F,U)=>`${(U*$).toFixed(2)},${(46-F*0.42).toFixed(2)}`).join(" "),j=`0,48 ${A} 100,48`;return _("article",{className:`metric-chart ${i}`,"data-testid":t},_("div",{className:"metric-chart-head"},_("div",null,_("span",null,l),_("strong",null,n0(r))),_("code",null,`${f.length} pts`)),_("svg",{viewBox:"0 0 100 48",preserveAspectRatio:"none",role:"img","aria-label":`${l} usage curve`},_("polygon",{points:j}),_("polyline",{points:A}),_("line",{x1:"0",x2:"100",y1:"24",y2:"24"})),_("div",{className:"metric-chart-foot"},_("span",null,"0%"),_("span",null,n),_("span",null,"100%")))}function ni(l){return Array.isArray(l)?l:[]}function dY(l){let u=ni(l?.core?.requests?.componentSummary);return[...ni(l?.frontend?.requests?.componentSummary),...u].sort((f,n)=>vl(n.requestCount)-vl(f.requestCount))}function eY(l){let u=ni(l?.core?.operations?.summary);return[...ni(l?.frontend?.operations?.summary),...u].sort((f,n)=>vl(n.count)-vl(f.count))}function lP(l){let u=ni(l?.core?.requests?.recentFailures).map((f)=>({source:"backend",...f}));return[...ni(l?.frontend?.requests?.recentFailures).map((f)=>({source:"frontend",...f})),...u].sort((f,n)=>(On(n.at)??0)-(On(f.at)??0)).slice(0,20)}function uP(l){let u=ni(l?.core?.operations?.recentSlowOperations);return[...ni(l?.frontend?.operations?.recentSlowOperations),...u].sort((f,n)=>vl(n.durationMs)-vl(f.durationMs)).slice(0,20)}function rP(l){let u=performance.memory,r=Number(u?.usedJSHeapSize);if(Number.isFinite(r)&&r>0)return r;let f=Number(l?.appBundleBytes);if(Number.isFinite(f)&&f>0)return f;return vl(l?.process?.heapUsedBytes)}function fP({points:l}){let u=ni(l),r=u.map((F)=>vl(F.mb)),f=Math.max(1,...r),n=Math.max(0,Math.min(...r,0)),i=Math.max(1,f-n),t=u.length>1?u:[...u,...u],y=t.length<=1?100:100/(t.length-1),c=t.map((F,U)=>{let N=vl(F.mb);return`${(U*y).toFixed(2)},${(48-(N-n)/i*42).toFixed(2)}`}).join(" "),$=`0,50 ${c} 100,50`,A=u.at(-1),j=u[0];return _("article",{className:"performance-memory-card","data-testid":"performance-memory-chart"},_("div",{className:"performance-memory-head"},_("strong",null,`Bwebui: ${A?`${vl(A.mb).toFixed(1)}MB`:"--"}`),_("span",null,u.length>0?`${u.length} samples`:"等待采样")),_("svg",{viewBox:"0 0 100 50",preserveAspectRatio:"none",role:"img","aria-label":"Bwebui memory trend"},_("polygon",{points:$}),_("polyline",{points:c}),_("line",{x1:"0",x2:"100",y1:"25",y2:"25"})),_("div",{className:"performance-axis-row"},_("span",null,j?iu(new Date(j.at)):"--"),_("span",null,"时间"),_("span",null,A?iu(new Date(A.at)):"--")),_("div",{className:"performance-axis-row"},_("span",null,`${n.toFixed(1)}`),_("span",null,"(MB)"),_("span",null,`${f.toFixed(1)}`)))}function nP({onRaw:l}){let[u,r]=kl({core:null,frontend:null}),[f,n]=kl([]),[i,t]=kl(""),[y,c]=kl(!1),[$,A]=kl(null),[j,F]=kl(!1);async function U(){c(!0),t("");try{let[V,B]=await Promise.all([ml(`${sl.apiBaseUrl}/performance`,{cache:"no-store"}),ml(`${sl.apiBaseUrl}/frontend-performance`,{cache:"no-store"})]);r({core:V,frontend:B});let m=rP(B);n((X)=>[...X,{at:new Date().toISOString(),mb:m/1048576}].slice(-80))}catch(V){t(El(V,"性能指标加载失败"))}finally{c(!1)}}En(()=>{U();let V=setInterval(()=>void U(),5000);return()=>clearInterval(V)},[]);async function N(){F(!0),t(""),A(null);try{let V=await ml(`${sl.apiBaseUrl}/code-queue-load-test`,{method:"POST",body:JSON.stringify({targetMs:1000,timeoutMs:90000,url:sl.frontendPublicUrl||window.location.origin})});A(V),U()}catch(V){t(El(V,"Code Queue Playwright 测量失败"))}finally{F(!1)}}let W=dY(u),L=lP(u),J=eY(u),w=uP(u),Q=u.core?.process||{},q=u.frontend?.process||{},T=u.core?.database?.codeQueueStorage||{},O=vl(T.total),Z=$?.result||{},E=vl(Z.wallMs,NaN),D=vl(Z.networkIdleMs,NaN),Y=Z.withinTarget===!0,p=j?"running":$===null?"idle":$.measurementOk===!0?Y?"passed":"slow":"failed";return _("div",{className:"performance-page","data-testid":"performance-page"},_("div",{className:"performance-hero"},_("div",null,_("p",{className:"panel-eyebrow"},"Unified Performance"),_(fu,{title:"性能面板",loading:y||j}),_("p",null,"按组件统计 HTTP 请求、失败率、P95 延迟,并汇总 backend/frontend 内部操作耗时。")),_("div",{className:"inline-actions"},_("button",{type:"button",className:"ghost-btn",onClick:()=>void N(),disabled:j,"data-testid":"code-queue-load-test-button"},j?"测试中...":"测试 Code Queue 加载"),_("button",{type:"button",className:"ghost-btn",onClick:()=>void U(),disabled:y,"data-testid":"performance-refresh-button"},y?"刷新中":"刷新"),_(du,{title:"Performance Snapshot",data:u,onOpen:l,testId:"raw-performance"}))),_(lu,{error:i}),_("div",{className:"performance-top-grid"},_(fP,{points:f}),_("div",{className:"performance-metric-stack"},_(Nu,{label:"backend RSS",value:Nr(Q.rssBytes),hint:`heap ${Nr(Q.heapUsedBytes)}`}),_(Nu,{label:"frontend RSS",value:Nr(q.rssBytes),hint:`bundle ${Nr(u.frontend?.appBundleBytes)}`}),_(Nu,{label:"Codex PG 任务",value:O||"--",hint:T.ok?"unidesk_code_queue_tasks":"等待表初始化",tone:T.ok?"ok":"warn"}),_(Nu,{label:"请求样本",value:vl(u.core?.requests?.sampleCount)+vl(u.frontend?.requests?.sampleCount),hint:"rolling window 3000"}))),_(uu,{title:"Code Queue 加载基准",eyebrow:"Playwright / target <1s",className:"codex-load-test-panel",loading:j,actions:_("div",{className:"panel-actions"},_("button",{type:"button",className:"primary-btn",onClick:()=>void N(),disabled:j,"data-testid":"code-queue-load-test-panel-button"},j?"正在运行 Playwright...":"手动触发测试"),$?_(du,{title:"Code Queue Load Test",data:$,onOpen:l,testId:"raw-code-queue-load-test"}):null)},_("div",{className:"codex-load-test-grid","data-testid":"code-queue-load-test-result"},_(Nu,{label:"总耗时",value:j?"运行中":Number.isFinite(E)?Wf(E):"--",hint:$===null?"点击按钮启动远端 Playwright":`目标 ${Wf(Z.targetMs||1000)} / ${Z.url||"Code Queue"}`,tone:p==="passed"?"ok":p==="failed"||p==="slow"?"warn":""}),_(Nu,{label:"判定",value:j?"RUNNING":p==="passed"?"PASS <1s":p==="slow"?"SLOW":p==="failed"?"FAILED":"--",hint:$?.measurementOk===!1?String($.error||Z.error||"measurement failed").slice(0,120):"导航开始 -> DOMContentLoaded -> data-load-state=complete",tone:p==="passed"?"ok":p==="idle"||p==="running"?"":"fail"}),_(Nu,{label:"Network idle",value:Number.isFinite(D)?Wf(D):"--",hint:`DOMContentLoaded ${Wf(Z.domContentLoadedMs)} / ${Z.networkIdleReached===!1?"未在 5s 内空闲":"已空闲"}`,tone:Number.isFinite(D)&&D<=1000?"ok":"warn"}),_(Nu,{label:"组件耗时",value:Number.isFinite(vl(Z.componentLoadMs,NaN))?Wf(Z.componentLoadMs):"--",hint:`queue ${Wf(Z.queueMs)} / detail ${Wf(Z.detailMs)}`,tone:vl(Z.componentLoadMs)>1000?"warn":"ok"}),_(Nu,{label:"Trace 规模",value:Number.isFinite(vl(Z.transcriptRows,NaN))?String(Z.transcriptRows):"--",hint:`${Z.visibleTaskCount??0} visible tasks / ${Z.partial?"preview":"complete"}`})),j?_("div",{className:"performance-empty-line"},"正在通过 main-server Host SSH 启动 Playwright,完成后会显示 wall time、组件耗时和最慢 API。"):null,$&&Array.isArray(Z.slowestApi)&&Z.slowestApi.length>0?_("div",{className:"table-wrap performance-table-wrap compact codex-load-api-table"},_("table",{className:"performance-table"},_("thead",null,_("tr",null,["API","状态","耗时"].map((V)=>_("th",{key:V},V)))),_("tbody",null,Z.slowestApi.slice(0,5).map((V,B)=>_("tr",{key:`${V.url}-${B}`},_("td",null,_("code",null,V.url)),_("td",null,V.status),_("td",null,Wf(V.durationMs))))))):null),_("div",{className:"performance-grid"},_(uu,{title:"组件汇总",eyebrow:"Requests",loading:y},W.length===0?_(Qu,{title:"暂无请求样本",text:"刷新几次或打开页面后会自动形成组件统计"}):_("div",{className:"table-wrap performance-table-wrap"},_("table",{className:"performance-table"},_("thead",null,_("tr",null,["组件","请求数","失败数","失败率","平均延迟","P95"].map((V)=>_("th",{key:V},V)))),_("tbody",null,W.map((V)=>_("tr",{key:V.component},_("td",null,_("code",null,V.component)),_("td",null,V.requestCount),_("td",null,V.failureCount),_("td",null,n0(vl(V.failureRate)*100)),_("td",null,Wf(V.averageLatencyMs)),_("td",null,Wf(V.p95LatencyMs)))))))),_(uu,{title:"最近失败请求",eyebrow:"Failures",loading:y},L.length===0?_("div",{className:"performance-empty-line"},"最近没有失败请求"):_("div",{className:"table-wrap performance-table-wrap compact"},_("table",{className:"performance-table"},_("thead",null,_("tr",null,["时间","来源","组件","状态","路径"].map((V)=>_("th",{key:V},V)))),_("tbody",null,L.map((V,B)=>_("tr",{key:`${V.at}-${B}`},_("td",null,Wl(V.at)),_("td",null,V.source),_("td",null,_("code",null,V.component)),_("td",null,_(Tu,{status:"failed"},V.status)),_("td",null,_("code",null,V.path)))))))),_(uu,{title:"内部操作汇总",eyebrow:"Operations",loading:y},J.length===0?_(Qu,{title:"暂无内部操作样本",text:"API 查询和代理请求会自动记录内部操作耗时"}):_("div",{className:"table-wrap performance-table-wrap"},_("table",{className:"performance-table"},_("thead",null,_("tr",null,["服务","操作","次数","平均延迟","P95"].map((V)=>_("th",{key:V},V)))),_("tbody",null,J.map((V)=>_("tr",{key:`${V.service}-${V.operation}`},_("td",null,V.service),_("td",null,_("code",null,V.operation)),_("td",null,V.count),_("td",null,Wf(V.averageLatencyMs)),_("td",null,Wf(V.p95LatencyMs)))))))),_(uu,{title:"最近慢操作",eyebrow:"Slowest",loading:y},w.length===0?_(Qu,{title:"暂无慢操作",text:"后端会记录最近窗口内耗时最高的内部操作"}):_("div",{className:"table-wrap performance-table-wrap"},_("table",{className:"performance-table"},_("thead",null,_("tr",null,["时间","操作","耗时","结果","细节"].map((V)=>_("th",{key:V},V)))),_("tbody",null,w.map((V,B)=>_("tr",{key:`${V.at}-${V.operation}-${B}`},_("td",null,Wl(V.at)),_("td",null,_("code",null,V.operation)),_("td",null,Wf(V.durationMs)),_("td",null,V.ok?"成功":"失败"),_("td",null,V.detail||"-")))))))))}function iP({provider:l,refresh:u,onRaw:r}){let[f,n]=kl(""),[i,t]=kl(null),[y,c]=kl("");async function $(A){n(A),c("");try{let j=await ml(`${sl.apiBaseUrl}/dispatch`,{method:"POST",body:JSON.stringify({providerId:l.providerId,command:"provider.upgrade",payload:{mode:A,source:"frontend-resource-monitor",requestedAt:new Date().toISOString()}})});t({mode:A,...j}),await u()}catch(j){c(El(j,"升级命令下发失败"))}finally{n("")}}return _(uu,{title:"Provider Gateway 升级",eyebrow:"Remote Control",loading:Boolean(f)},_("div",{className:"upgrade-control","data-testid":"provider-upgrade-control"},_("p",null,"通过 UniDesk WebSocket 向当前计算节点下发 provider.upgrade;预检只生成升级计划,执行升级会调度节点本地 updater 容器。"),_("div",{className:"upgrade-target-line"},_("span",null,"指定 Provider"),_("code",null,l.providerId),_(My,{node:l})),_("div",{className:"upgrade-actions"},_("button",{type:"button",className:"ghost-btn",disabled:Boolean(f),onClick:()=>$("plan"),"data-testid":"upgrade-plan-button"},f==="plan"?"预检中":"预检升级"),_("button",{type:"button",className:"ghost-btn danger",disabled:Boolean(f),onClick:()=>$("schedule"),"data-testid":"upgrade-schedule-button"},f==="schedule"?"调度中":"执行升级")),_(lu,{error:y}),i?_("div",{className:"upgrade-result"},_(Tu,{status:i.status||"queued"},i.status||"queued"),_("span",null,`${i.mode==="schedule"?"执行升级":"预检升级"} 已下发`),_("span",null,`指定版本 ${Rj(UG(l))}`),_("code",null,i.taskId||"--"),_(du,{title:"Provider Upgrade Dispatch",data:i,onOpen:r})):_("span",{className:"muted"},"升级任务结果会进入任务历史;执行升级可能导致 provider 短暂重连。")))}function zG({records:l,onRaw:u,compact:r=!1}){if(l.length===0)return _(Qu,{title:"暂无远程更新记录",text:"该节点还没有 provider.upgrade 任务;执行预检或升级后会在这里形成结构化记录"});return _("div",{className:`upgrade-record-table-wrap table-wrap ${r?"compact":""}`},_("table",{className:"upgrade-record-table"},_("thead",null,_("tr",null,_("th",null,"状态"),_("th",null,"模式"),_("th",null,"任务"),_("th",null,"来源"),_("th",null,"耗时"),_("th",null,"策略"),_("th",null,"Gateway 版本"),_("th",null,"结果记录"),_("th",null,"更新时间"),_("th",null,"操作"))),_("tbody",null,l.map((f)=>_("tr",{key:f.id,"data-testid":`gateway-upgrade-record-${uf(f.id)}`},_("td",null,_(Tu,{status:f.status})),_("td",null,_("span",{className:`mode-chip ${i6(f)}`},i6(f)==="schedule"?"执行升级":"预检")),_("td",null,_("strong",null,"provider.upgrade"),_("code",null,f.id)),_("td",null,DY(f)),_("td",null,_(OG,{task:f})),_("td",null,VY(f)),_("td",null,_("span",{className:"version-chip"},qG(f))),_("td",null,_("span",{className:`upgrade-outcome ${String(f.status||"").toLowerCase()}`},WG(f))),_("td",null,Wl(f.updatedAt)),_("td",null,_(du,{title:`Provider Upgrade Task ${f.id}`,data:j_(f),onOpen:u})))))))}function tP({provider:l,tasks:u,onRaw:r,limit:f=5}){let n=LG(u,l.providerId).slice(0,f);return _(uu,{title:"远程更新记录",eyebrow:l.providerId,actions:_(My,{node:l}),className:"provider-upgrade-records-panel"},_("div",{"data-testid":`provider-upgrade-records-${uf(l.providerId)}`},_(zG,{records:n,onRaw:r,compact:!0})))}function yP({nodes:l,tasks:u,onRaw:r}){let f=A_(()=>l.map((i)=>{let t=LG(u,i.providerId);return{node:i,records:t,latest:SY(t),capabilities:NG(i)}}),[l,u]),n=f.reduce((i,t)=>i+t.records.length,0);return _("div",{className:"gateway-page","data-testid":"gateway-version-page"},_(uu,{title:"Provider Gateway 版本",eyebrow:`${l.length} Providers / ${n} 更新记录`},l.length===0?_(Qu,{title:"暂无 Provider 节点",text:"等待 provider-gateway 注册后显示版本号和升级记录"}):_("div",{className:"table-wrap gateway-version-table-wrap"},_("table",{className:"gateway-version-table"},_("thead",null,_("tr",null,_("th",null,"状态"),_("th",null,"Provider"),_("th",null,"Gateway 版本"),_("th",null,"升级策略"),_("th",null,"运维可用性"),_("th",null,"运行时间"),_("th",null,"能力"),_("th",null,"最近远程更新"),_("th",null,"操作"))),_("tbody",null,f.map((i)=>_("tr",{key:i.node.providerId},_("td",null,_(Tu,{status:i.node.status})),_("td",null,_("strong",null,i.node.name),_("code",null,i.node.providerId)),_("td",null,_(My,{node:i.node})),_("td",null,$_(i.node)),_("td",null,_(xj,{node:i.node})),_("td",null,lG(i.node)?Wl(lG(i.node)):"待新版上报"),_("td",null,_("div",{className:"capability-row"},i.capabilities.length===0?_("span",{className:"muted"},"未声明"):i.capabilities.slice(0,5).map((t)=>_("span",{key:t,className:"data-chip"},t)))),_("td",null,i.latest?_("div",{className:"latest-upgrade-cell"},_(Tu,{status:i.latest.status}),_("span",null,`${i6(i.latest)==="schedule"?"执行升级":"预检"} / ${Wl(i.latest.updatedAt)}`),_("small",null,`Gateway ${qG(i.latest)}`),_("small",null,WG(i.latest))):_("span",{className:"muted"},"暂无记录")),_("td",null,_(du,{title:`Provider ${i.node.providerId}`,data:i.node,onOpen:r})))))))),_(uu,{title:"远程更新记录",eyebrow:"Structured provider.upgrade records"},l.length===0?_(Qu,{title:"暂无记录",text:"没有 provider 节点时不会生成远程更新记录"}):_("div",{className:"gateway-record-grid"},f.map((i)=>_("article",{key:i.node.providerId,className:"gateway-record-card","data-testid":`gateway-records-${uf(i.node.providerId)}`},_("div",{className:"gateway-record-head"},_("div",null,_("strong",null,i.node.name),_("code",null,i.node.providerId)),_(My,{node:i.node})),_("div",{className:"gateway-record-meta"},_("span",null,`心跳 ${Wl(i.node.lastHeartbeat)}`),_("span",null,`策略 ${$_(i.node)}`),_("span",null,`${i.records.length} 条记录`)),_(zG,{records:i.records.slice(0,8),onRaw:r,compact:!0}))))))}function cP(l){if(l==="running")return"online";if(l==="paused"||l==="restarting")return"warn";if(l==="exited"||l==="dead")return"offline";return"internal"}function EG(l){return/^[a-f0-9]{48,64}$/i.test(l)}function __(l){let u=String(l?.name||""),r=String(l?.labels||"");return u==="unidesk_pgdata_10gb"||r.includes("com.docker.compose.volume=unidesk_pgdata_10gb")||u.toLowerCase().includes("pgdata")}function tG(l){let u=String(l?.name||""),r=String(l?.labels||"");if(__(l))return 0;if(r.includes("com.docker.compose.project=unidesk"))return 1;if(!EG(u))return 2;return 3}function _P(l){return[...l].sort((u,r)=>{let f=tG(u)-tG(r);if(f!==0)return f;return String(u.name||"").localeCompare(String(r.name||""))})}function $P({nodes:l,dockerStatuses:u,onRaw:r}){let[f,n]=kl(""),i=A_(()=>l.map((w)=>{let Q=u.find((q)=>q.providerId===w.providerId);return{...w,dockerStatus:Q?.dockerStatus||null,dockerUpdatedAt:Q?.updatedAt||null}}),[l,u]),t=i.find((w)=>w.providerId===f)||i[0]||null;if(En(()=>{if(!f&&i[0])n(i[0].providerId)},[i.length,f]),!t)return _(Qu,{title:"暂无 Docker 节点",text:"等待 provider 上报 Docker daemon 状态"});let y=t.dockerStatus,c=t.providerId==="main-server",$=y?.counts||{},A=y?.daemon||{},j=y?.containers||[],F=y?.images||[],U=_P(y?.volumes||[]),N=c?U.find(__):null,W=y?.networks||[],L=j.filter((w)=>w.state==="running"),J=j.filter((w)=>w.state!=="running");return _("div",{className:"docker-page","data-testid":"docker-status-page"},_("div",{className:"docker-node-strip"},i.map((w)=>_("button",{key:w.providerId,type:"button",className:`docker-node-tile ${t.providerId===w.providerId?"active":""}`,onClick:()=>n(w.providerId)},_("span",{className:`pulse ${w.status}`}),_("strong",null,w.name),_("code",null,w.providerId),_("span",null,w.dockerStatus?`Docker ${w.dockerStatus.ok?"ready":"degraded"}`:"等待上报")))),_("div",{className:"docker-layout"},_(uu,{title:"Docker Desktop 视图",eyebrow:t.name,className:"docker-main-panel",actions:y?_(du,{title:`Docker ${t.providerId}`,data:y,onOpen:r}):null},!y?_(Qu,{title:"Docker 状态未上报",text:"provider-gateway 会在连接后周期性采集 docker info / ps / images / volume / network"}):_("div",null,_("div",{className:"docker-hero"},_("div",null,_("p",{className:"panel-eyebrow"},"Daemon"),_("h3",null,A.name||t.providerId),_("div",{className:"docker-meta"},_("span",null,A.serverVersion?`Engine ${A.serverVersion}`:"Engine --"),_("span",null,A.operatingSystem||"OS --"),_("span",null,A.architecture||"arch --"),_("span",null,`${A.cpus||0} CPU / ${Nr(A.memoryBytes)}`))),_(Tu,{status:y.ok?"online":"warn"},y.ok?"Docker Ready":"Docker Degraded")),_("div",{className:"docker-metrics"},_(Nu,{label:"Containers",value:$.containers??j.length,hint:`${$.running??L.length} running / ${$.stopped??J.length} stopped`,tone:"ok"}),_(Nu,{label:"Images",value:$.images??F.length,hint:`${$.daemonImages??$.images??F.length} daemon images`}),_(Nu,{label:"Volumes",value:$.volumes??U.length,hint:c?N?"database volume visible":"database volume missing":"node local volumes",tone:N?"ok":""}),_(Nu,{label:"Networks",value:$.networks??W.length,hint:A.driver?`driver ${A.driver}`:"docker networks"})),c?_(AP,{volume:N,volumeCount:U.length}):null,_("div",{className:"docker-section-head"},_("h3",null,"Containers"),_("span",null,`updated ${Wl(t.dockerUpdatedAt||y.collectedAt)}`)),_("div",{className:"docker-container-table table-wrap","data-testid":"docker-container-table"},_("table",null,_("thead",null,_("tr",null,_("th",null,"状态"),_("th",null,"容器"),_("th",null,"镜像"),_("th",null,"端口"),_("th",null,"运行时间"),_("th",null,"重启策略"),_("th",null,"PID"),_("th",null,"大小"))),_("tbody",null,j.length===0?_("tr",null,_("td",{colSpan:8},"暂无容器")):j.map((w)=>_("tr",{key:`${w.id}-${w.name}`},_("td",null,_(Tu,{status:cP(w.state)},w.state||"unknown")),_("td",null,_("strong",null,w.name||"--"),_("code",null,w.id||"--")),_("td",null,w.image||"--"),_("td",null,w.ports||_("span",{className:"muted"},"未发布")),_("td",null,w.runningFor||w.status||"--"),_("td",null,w.restartPolicy?_(Tu,{status:w.restartPolicy==="always"?"online":"warn"},w.restartPolicy):"--"),_("td",null,w.pidMode?_("code",null,w.pidMode):"--"),_("td",null,w.size||"--")))))))),_("div",{className:"docker-side-stack"},_(Pj,{title:"Images",items:F,render:(w)=>_("article",{key:`${w.id}-${w.repository}`,className:"docker-side-row"},_("strong",null,`${w.repository}:${w.tag}`),_("span",null,w.size||"--"),_("code",null,w.id||"--"))}),_(Pj,{title:"Volumes",items:U,limit:U.length,render:(w)=>_("article",{key:w.name,className:`docker-side-row volume-row ${c&&__(w)?"database-volume":""}`,"data-testid":c&&__(w)?"database-volume-row":void 0},_("strong",null,w.name),_("span",null,c&&__(w)?"PostgreSQL":EG(String(w.name||""))?"anonymous":"named"),_("code",null,w.mountpoint||w.driver||w.scope||"--"))}),_(Pj,{title:"Networks",items:W,render:(w)=>_("article",{key:w.id||w.name,className:"docker-side-row"},_("strong",null,w.name),_("span",null,w.driver||"--"),_("code",null,w.id||"--"))}))))}function AP({volume:l,volumeCount:u}){return _("section",{className:`docker-volume-focus ${l?"ready":"missing"}`,"data-testid":"database-volume-card"},_("div",{className:"volume-focus-head"},_("span",{className:"panel-eyebrow"},"Database Named Volume"),_(Tu,{status:l?"online":"warn"},l?"FOUND":"MISSING")),l?_("div",{className:"volume-focus-body"},_("strong",null,l.name),_("span",null,"PostgreSQL data volume for unidesk-database"),_("div",{className:"volume-route"},_("code",null,l.mountpoint||"/var/lib/docker/volumes/unidesk_pgdata_10gb/_data"),_("span",null,"->"),_("code",null,"unidesk-database:/var/lib/postgresql/data")),_("div",{className:"docker-meta compact"},_("span",null,`driver ${l.driver||"--"}`),_("span",null,`scope ${l.scope||"--"}`),_("span",null,`${u} volumes reported`))):_("div",{className:"volume-focus-body"},_("strong",null,"unidesk_pgdata_10gb"),_("span",null,"当前 Docker 快照没有发现数据库命名卷;请检查 provider-gateway 的 Docker volume 上报。")))}function Pj({title:l,items:u,render:r,limit:f}){let n=u.slice(0,f??12),i=Math.max(0,u.length-n.length);return _(uu,{title:l,eyebrow:`${u.length} items`,className:"docker-side-panel"},u.length===0?_(Qu,{title:`暂无 ${l}`,text:"等待 Docker 状态采集"}):_("div",{className:"docker-side-list"},n.map(r),i>0?_("div",{className:"docker-side-more"},`+ ${i} more`):null))}function jP({microservices:l,onRaw:u,onNavigate:r}){let f=l.filter((n)=>rG(n).public===!1);return _("div",{className:"microservice-page","data-testid":"microservice-catalog-page"},_(uu,{title:"用户服务目录",eyebrow:"Provider Mounted User Services"},_("div",{className:"metric-grid"},_(Nu,{label:"服务总数",value:l.length,hint:"config.json 用户服务登记"}),_(Nu,{label:"私有后端",value:f.length,hint:"不直接暴露公网",tone:"ok"}),_(Nu,{label:"D601 服务",value:l.filter((n)=>n.providerId==="D601").length,hint:"compute-node docker"}),_(Nu,{label:"集成前端",value:l.filter((n)=>n.frontend?.integrated).length,hint:"UniDesk React 页面"}))),_(uu,{title:"服务映射",eyebrow:"Repo Reference + Runtime"},l.length===0?_(Qu,{title:"暂无用户服务",text:"在 config.json 的 microservices 中登记用户服务的 provider、仓库引用和后端映射"}):_("div",{className:"table-wrap"},_("table",{className:"microservice-table"},_("thead",null,_("tr",null,_("th",null,"服务"),_("th",null,"Provider"),_("th",null,"代码引用"),_("th",null,"Docker 引用"),_("th",null,"后端映射"),_("th",null,"开发入口"),_("th",null,"运行态"),_("th",null,"操作"))),_("tbody",null,l.map((n)=>{let i=GG(n),t=XY(n),y=rG(n),c=i.availability||{},$=c.status||(i.providerStatus==="online"?"unknown":"unhealthy");return _("tr",{key:n.id,"data-testid":`microservice-row-${uf(n.id)}`},_("td",null,_("strong",null,n.name),_("code",null,n.id)),_("td",null,_("strong",null,i.providerName||n.providerId),_("code",null,n.providerId)),_("td",null,_("span",null,t.url||"--"),_("code",null,t.commitId||"--")),_("td",null,_("span",null,t.composeFile||"--"),_("code",null,`${t.composeService||"--"} / ${t.containerName||"--"}`)),_("td",null,_(Tu,{status:y.public?"warn":"online"},y.public?"public":"private"),_("code",null,`${y.nodeBindHost||"--"}:${y.nodePort||"--"} -> ${y.proxyMode||"--"}`)),_("td",null,_("span",null,n.development?.sshPassthrough?"SSH 透传":"未配置"),_("code",null,n.development?.worktreePath||"--")),_("td",null,_(Tu,{status:$==="healthy"?"online":$==="unknown"?"warn":"failed"},$),_("span",null,c.reason||i.providerStatus||"unknown"),_(t0,{data:i.container,empty:"容器快照未上报"})),_("td",null,_("div",{className:"microservice-actions"},n.id==="findjob"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","findjob"),"data-testid":"open-findjob-button"},"打开"):null,n.id==="pipeline"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","pipeline"),"data-testid":"open-pipeline-button"},"打开"):null,n.id==="todo-note"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","todo-note"),"data-testid":"open-todo-note-button"},"打开"):null,n.id==="met-nonlinear"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","met-nonlinear"),"data-testid":"open-met-nonlinear-button"},"打开"):null,n.id==="claudeqq"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","claudeqq"),"data-testid":"open-claudeqq-button"},"打开"):null,n.id==="baidu-netdisk"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","baidu-netdisk"),"data-testid":"open-baidu-netdisk-button"},"打开"):null,n.id==="oa-event-flow"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","oa-event-flow"),"data-testid":"open-oa-event-flow-button"},"打开"):null,n.id==="k3sctl-adapter"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","k3sctl"),"data-testid":"open-k3sctl-button"},"打开"):null,n.id==="code-queue"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","code-queue"),"data-testid":"open-code-queue-button"},"打开"):null,n.id==="mdtodo"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","mdtodo"),"data-testid":"open-mdtodo-button"},"打开"):null,n.id==="decision-center"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","decision-center"),"data-testid":"open-decision-center-button"},"打开"):null,n.id==="project-manager"?_("button",{type:"button",className:"ghost-btn",onClick:()=>r("apps","project-manager"),"data-testid":"open-project-manager-button"},"打开"):null,_(du,{title:`用户服务 ${n.id}`,data:n,onOpen:u}))))}))))))}function FP({nodes:l,onDispatched:u,onRaw:r}){let f=l.filter((p)=>p.status==="online"),[n,i]=kl(f[0]?.providerId||l[0]?.providerId||""),[t,y]=kl("docker.ps"),[c,$]=kl("frontend"),[A,j]=kl("operator-check"),[F,U]=kl("normal"),[N,W]=kl(!1),[L,J]=kl(""),[w,Q]=kl(!1),[q,T]=kl(null),[O,Z]=kl("");En(()=>{if(!n&&(f[0]?.providerId||l[0]?.providerId))i(f[0]?.providerId||l[0].providerId)},[l.length,f.length,n]);function E(){return{source:c,note:A,priority:F}}function D(){J(JSON.stringify(E(),null,2)),W(!0)}async function Y(p){p.preventDefault(),Q(!0),Z("");try{let V=N?JSON.parse(L||"{}"):E(),B=await ml(`${sl.apiBaseUrl}/dispatch`,{method:"POST",body:JSON.stringify({providerId:n,command:t,payload:V})});T(B),await u()}catch(V){Z(El(V,"下发失败"))}finally{Q(!1)}}return _("div",{className:"page-grid dispatch-grid"},_(uu,{title:"下发任务",eyebrow:"Real WebSocket Dispatch"},_("form",{className:"dispatch-form",onSubmit:Y},_("label",null,"Provider",_("select",{value:n,onChange:(p)=>i(p.target.value)},l.map((p)=>_("option",{key:p.providerId,value:p.providerId},`${p.name} / ${p.providerId}`)))),_("label",null,"Command",_("select",{value:t,onChange:(p)=>y(p.target.value)},_("option",{value:"docker.ps"},"docker.ps"),_("option",{value:"host.ssh"},"host.ssh"),_("option",{value:"microservice.http"},"microservice.http"),_("option",{value:"echo"},"echo"))),_("label",null,"来源",_("input",{value:c,onChange:(p)=>$(p.target.value)})),_("label",null,"备注",_("input",{value:A,onChange:(p)=>j(p.target.value)})),_("label",null,"优先级",_("select",{value:F,onChange:(p)=>U(p.target.value)},_("option",{value:"normal"},"normal"),_("option",{value:"low"},"low"),_("option",{value:"urgent"},"urgent"))),_("div",{className:"dispatch-actions"},_("button",{type:"button",className:"ghost-btn",onClick:D},"查看原始JSON"),_("button",{type:"submit",disabled:w||!n},w?"下发中":"下发任务")),N?_("label",{className:"raw-editor-label"},"高级 Payload",_("textarea",{className:"raw-editor",value:L,onChange:(p)=>J(p.target.value)})):null,_(lu,{error:O,wide:!0}))),_(uu,{title:"下发结果",eyebrow:"Response"},q?_("div",{className:"result-card"},_(Tu,{status:q.status||"queued"},q.status||"queued"),_("dl",null,_("dt",null,"Task ID"),_("dd",null,_("code",null,q.taskId||"--")),_("dt",null,"Provider 在线"),_("dd",null,ft(q.providerOnline))),_(du,{title:"Dispatch Response",data:q,onOpen:r})):_(Qu,{title:"等待操作",text:"任务响应会以结构化结果卡展示"})))}function yG({task:l,onRaw:u}){return _("article",{className:"compact-row"},_(Tu,{status:l.status}),_("div",null,_("strong",null,l.command),_("code",null,l.id)),_("span",null,hy(l)?`已等待 ${Mj(l.updatedAt)}`:`耗时 ${fi(jG(l)??0)}`),_(du,{title:`Task ${l.id}`,data:j_(l),onOpen:u}))}function OG({task:l}){let u=jG(l),r=hy(l);return _("div",{className:"task-duration"},_("strong",null,u===null?"--":fi(u)),_("span",null,r?`已运行 / 创建 ${Wl(l.createdAt)}`:`创建 ${Wl(l.createdAt)}`))}function JP({task:l}){let u=String(l?.status||"").toLowerCase(),r=l?.result,f=r&&typeof r==="object"&&!Array.isArray(r)?r:{},i=["exitCode","code","signal","timeoutMs","previousStatus","mode"].filter((t)=>f[t]!==void 0&&f[t]!==null);if(u==="failed"){let t=FG(l);return _("div",{className:"task-diagnostic failed"},_("b",null,"失败原因"),_("span",{className:"diagnostic-reason"},ft(t)),i.length>0?_("div",{className:"diagnostic-meta"},i.map((y)=>_("span",{key:y,className:"data-chip"},_("b",null,y),_("span",null,ft(f[y]))))):null)}if(hy(l))return _("div",{className:"task-diagnostic warn"},_("b",null,"等待终态"),_("span",null,`最后更新 ${Mj(l.updatedAt)} 前`));return _("div",{className:"task-diagnostic ok"},_("b",null,"完成摘要"),_(t0,{data:r,empty:"无执行输出"}))}function UP({tasks:l,onRaw:u}){let r=l.filter(hy);return _("div",{"data-testid":"pending-task-page"},_(uu,{title:"待处理任务",eyebrow:`${r.length} Pending`},r.length===0?_(Qu,{title:"当前无待处理任务",text:"queued / dispatched / running 会在超时后自动转为 failed;历史记录仍可在任务历史中查看"}):_("div",{className:"table-wrap","data-testid":"pending-task-table"},_("table",null,_("thead",null,_("tr",null,_("th",null,"状态"),_("th",null,"任务"),_("th",null,"Provider"),_("th",null,"已等待"),_("th",null,"载荷摘要"),_("th",null,"操作"))),_("tbody",null,r.map((f)=>_("tr",{key:f.id},_("td",null,_(Tu,{status:f.status})),_("td",null,_("strong",null,f.command),_("code",null,f.id)),_("td",null,_("code",null,f.providerId)),_("td",null,Mj(f.updatedAt)),_("td",null,_(t0,{data:f.payload})),_("td",null,_(du,{title:`Pending Task ${f.id}`,data:j_(f),onOpen:u})))))))))}function NP({tasks:l,onRaw:u}){return _("div",{"data-testid":"task-history-page"},_(uu,{title:"任务历史",eyebrow:`${l.length} Tasks`},l.length===0?_(Qu,{title:"暂无任务",text:"下发任务后会在这里看到生命周期"}):_("div",{className:"table-wrap"},_("table",{className:"task-history-table"},_("thead",null,_("tr",null,_("th",null,"状态"),_("th",null,"任务"),_("th",null,"Provider"),_("th",null,"任务耗时"),_("th",null,"载荷摘要"),_("th",null,"诊断信息"),_("th",null,"更新时间"),_("th",null,"操作"))),_("tbody",null,l.map((r)=>_("tr",{key:r.id,"data-testid":`task-row-${uf(r.id)}`},_("td",null,_(Tu,{status:r.status})),_("td",null,_("strong",null,r.command),_("code",null,r.id)),_("td",null,_("code",null,r.providerId)),_("td",null,_(OG,{task:r})),_("td",null,_(t0,{data:r.payload})),_("td",null,_(JP,{task:r})),_("td",null,Wl(r.updatedAt)),_("td",null,_(du,{title:`Task ${r.id}`,data:j_(r),onOpen:u})))))))))}function QP({tasks:l,onRaw:u}){let r=l.filter((f)=>["succeeded","failed"].includes(f.status));return _(uu,{title:"执行结果",eyebrow:"Finished Tasks"},r.length===0?_(Qu,{title:"暂无结果",text:"任务完成后展示 provider 返回的结构化摘要"}):_("div",{className:"result-grid"},r.map((f)=>_("article",{key:f.id,className:"result-card"},_("div",{className:"node-card-head"},_("strong",null,f.command),_(Tu,{status:f.status})),_("code",null,f.id),_(t0,{data:f.result,empty:"无执行输出"}),_(du,{title:`Task Result ${f.id}`,data:j_(f),onOpen:u})))))}function wP(l){if(!l||typeof l!=="object")return"--";if(l.type==="interval")return`每 ${fi(Number(l.everySeconds||0))}`;return`每天 ${l.timeOfDay||"03:00"} UTC`}function qP(l){if(!l||typeof l!=="object")return"--";if(l.type==="pgdata_backup")return`PGDATA -> ${l.remoteBaseDir||"/SERVER_DATA/UNIDESK_PG_DATA"}`;if(l.type==="dispatch")return`${l.providerId||"--"} / ${l.command||"--"}`;return String(l.type||"--")}function WP(l){let u=String(l||"").toLowerCase();if(u==="succeeded")return"online";if(u==="failed")return"failed";if(u==="running"||u==="queued")return"warn";return u}function LP(l){let u=Number(l?.durationMs);if(Number.isFinite(u)&&u>=0)return fi(u/1000);let r=On(l?.startedAt||l?.createdAt);if(r===null)return"--";let n=On(l?.finishedAt)??Date.now();return fi(Math.max(0,(n-r)/1000))}function cG(l){return{id:"unidesk-pgdata-baidu-daily",name:"PGDATA daily Baidu Netdisk backup",description:"Daily PostgreSQL physical base backup uploaded to Baidu Netdisk /SERVER_DATA with monthly rotation.",enabled:!0,timeOfDay:"03:30",actionType:"pgdata_backup",providerId:l[0]?.providerId||"main-server",command:"echo",payloadJson:JSON.stringify({source:"scheduled-task",message:"hello from scheduler"},null,2),remoteBaseDir:"/SERVER_DATA/UNIDESK_PG_DATA",stagingSubdir:"server-data/unidesk-pg-data",timeoutMs:"3600000"}}function GP({schedules:l,scheduleRuns:u,nodes:r,refresh:f,onRaw:n}){let[i,t]=kl(cG(r||[])),[y,c]=kl(!1),[$,A]=kl(""),[j,F]=kl(""),U=[...u||[]].sort((q,T)=>(On(T.updatedAt)??0)-(On(q.updatedAt)??0));function N(q,T){t((O)=>({...O,[q]:T}))}function W(q){let T=q?.action||{};t({id:q?.id||"",name:q?.name||"",description:q?.description||"",enabled:q?.enabled!==!1,timeOfDay:q?.schedule?.timeOfDay||"03:30",actionType:T.type||"dispatch",providerId:T.providerId||r[0]?.providerId||"main-server",command:T.command||"echo",payloadJson:JSON.stringify(T.payload||{source:"scheduled-task"},null,2),remoteBaseDir:T.remoteBaseDir||"/SERVER_DATA/UNIDESK_PG_DATA",stagingSubdir:T.stagingSubdir||"server-data/unidesk-pg-data",timeoutMs:String(T.timeoutMs||3600000)}),F(`正在编辑 ${q?.id||""}`)}function L(){let q={id:i.id,name:i.name,description:i.description,enabled:i.enabled,concurrencyPolicy:"skip",schedule:{type:"daily",timeOfDay:i.timeOfDay,timezone:"Etc/UTC"}};if(i.actionType==="pgdata_backup")return{...q,action:{type:"pgdata_backup",volumeName:"unidesk_pgdata_10gb",remoteBaseDir:i.remoteBaseDir,stagingSubdir:i.stagingSubdir,timeoutMs:Number(i.timeoutMs)||3600000,cleanupLocal:!0}};return{...q,action:{type:"dispatch",providerId:i.providerId,command:i.command,payload:JSON.parse(i.payloadJson||"{}"),timeoutMs:Number(i.timeoutMs)||600000}}}async function J(q){q.preventDefault(),c(!0),A(""),F("");try{let T=L(),O=encodeURIComponent(String(T.id));await ml(`${sl.apiBaseUrl}/schedules/${O}`,{method:"PUT",body:JSON.stringify(T)}),F("定时任务已保存"),await f()}catch(T){A(El(T,"保存定时任务失败"))}finally{c(!1)}}async function w(q){if(!q?.id)return;c(!0),A(""),F("");try{await ml(`${sl.apiBaseUrl}/schedules/${encodeURIComponent(q.id)}`,{method:"DELETE"}),F(`已删除 ${q.id}`),await f()}catch(T){A(El(T,"删除定时任务失败"))}finally{c(!1)}}async function Q(q){if(!q?.id)return;c(!0),A(""),F("");try{let T=await ml(`${sl.apiBaseUrl}/schedules/${encodeURIComponent(q.id)}/run`,{method:"POST",body:"{}"});F(`已触发 ${q.id} / ${T?.run?.id||"run"}`),await f()}catch(T){A(El(T,"触发定时任务失败"))}finally{c(!1)}}return _("div",{className:"page-grid scheduled-task-page","data-testid":"scheduled-task-page"},_(uu,{title:"定时任务",eyebrow:`${(l||[]).length} Schedules`},(l||[]).length===0?_(Qu,{title:"暂无定时任务",text:"创建 daily / dispatch / PGDATA backup 任务后会在这里展示下一次执行时间和最近结果"}):_("div",{className:"schedule-card-grid"},(l||[]).map((q)=>_("article",{key:q.id,className:"schedule-card","data-testid":`schedule-row-${uf(q.id)}`},_("div",{className:"node-card-head"},_("strong",null,q.name||q.id),_(Tu,{status:q.enabled?"online":"warn"},q.enabled?"enabled":"disabled")),_("code",null,q.id),_("dl",null,_("dt",null,"计划"),_("dd",null,wP(q.schedule)),_("dt",null,"动作"),_("dd",null,qP(q.action)),_("dt",null,"下次执行"),_("dd",null,Wl(q.nextRunAt)),_("dt",null,"最近执行"),_("dd",null,q.lastRunAt?`${Wl(q.lastRunAt)} / ${q.lastRunId||"--"}`:"--")),_("div",{className:"dispatch-actions"},_("button",{type:"button",className:"ghost-btn",disabled:y,onClick:()=>W(q)},"编辑"),_("button",{type:"button",className:"ghost-btn",disabled:y,onClick:()=>Q(q),"data-testid":`schedule-run-${uf(q.id)}`},"手动触发"),_("button",{type:"button",className:"ghost-btn danger",disabled:y,onClick:()=>w(q)},"删除"),_(du,{title:`Schedule ${q.id}`,data:q,onOpen:n})))))),_(uu,{title:i.id?"配置定时任务":"新建定时任务",eyebrow:"CRUD"},_("form",{className:"dispatch-form schedule-form",onSubmit:J},_("label",null,"ID",_("input",{value:i.id,onChange:(q)=>N("id",q.target.value)})),_("label",null,"名称",_("input",{value:i.name,onChange:(q)=>N("name",q.target.value)})),_("label",null,"每日执行时间 UTC",_("input",{value:i.timeOfDay,placeholder:"03:30",onChange:(q)=>N("timeOfDay",q.target.value)})),_("label",null,"启用",_("select",{value:i.enabled?"true":"false",onChange:(q)=>N("enabled",q.target.value==="true")},_("option",{value:"true"},"enabled"),_("option",{value:"false"},"disabled"))),_("label",null,"动作类型",_("select",{value:i.actionType,onChange:(q)=>N("actionType",q.target.value)},_("option",{value:"pgdata_backup"},"PGDATA 备份到百度网盘"),_("option",{value:"dispatch"},"Provider Dispatch"))),i.actionType==="pgdata_backup"?[_("label",{key:"remote"},"网盘根目录",_("input",{value:i.remoteBaseDir,onChange:(q)=>N("remoteBaseDir",q.target.value)})),_("label",{key:"staging"},"本地 staging 子目录",_("input",{value:i.stagingSubdir,onChange:(q)=>N("stagingSubdir",q.target.value)}))]:[_("label",{key:"provider"},"Provider",_("select",{value:i.providerId,onChange:(q)=>N("providerId",q.target.value)},(r||[]).map((q)=>_("option",{key:q.providerId,value:q.providerId},`${q.name} / ${q.providerId}`)))),_("label",{key:"command"},"Command",_("select",{value:i.command,onChange:(q)=>N("command",q.target.value)},_("option",{value:"echo"},"echo"),_("option",{value:"docker.ps"},"docker.ps"),_("option",{value:"host.ssh"},"host.ssh"),_("option",{value:"microservice.http"},"microservice.http"))),_("label",{key:"payload",className:"raw-editor-label"},"Payload JSON",_("textarea",{className:"raw-editor",value:i.payloadJson,onChange:(q)=>N("payloadJson",q.target.value)}))],_("label",null,"超时 ms",_("input",{value:i.timeoutMs,onChange:(q)=>N("timeoutMs",q.target.value)})),_("label",{className:"raw-editor-label"},"描述",_("textarea",{className:"raw-editor compact",value:i.description,onChange:(q)=>N("description",q.target.value)})),_("div",{className:"dispatch-actions"},_("button",{type:"button",className:"ghost-btn",disabled:y,onClick:()=>t(cG(r||[]))},"重置"),_("button",{type:"submit",disabled:y||!i.id},y?"保存中":"保存任务")),j?_("p",{className:"muted paragraph"},j):null,_(lu,{error:$,wide:!0}))),_(uu,{title:"历史执行记录",eyebrow:`${U.length} Runs`},U.length===0?_(Qu,{title:"暂无执行记录",text:"定时触发或手动触发后会生成 run history"}):_("div",{className:"table-wrap"},_("table",{className:"task-history-table schedule-run-table"},_("thead",null,_("tr",null,_("th",null,"状态"),_("th",null,"任务"),_("th",null,"触发"),_("th",null,"耗时"),_("th",null,"结果摘要"),_("th",null,"更新时间"),_("th",null,"操作"))),_("tbody",null,U.map((q)=>_("tr",{key:q.id,"data-testid":`schedule-run-row-${uf(q.id)}`},_("td",null,_(Tu,{status:WP(q.status)},q.status)),_("td",null,_("strong",null,q.scheduleId),_("code",null,q.id),q.taskId?_("code",null,q.taskId):null),_("td",null,q.trigger||"--"),_("td",null,LP(q)),_("td",null,_(t0,{data:q.result||q.error,empty:"无结果"})),_("td",null,Wl(q.updatedAt)),_("td",null,_(du,{title:`Schedule Run ${q.id}`,data:q,onOpen:n})))))))))}function TP({data:l}){let u=l.overview||{};return _("div",{className:"page-grid topology-grid"},_(uu,{title:"公开入口",eyebrow:"Public"},_("div",{className:"endpoint-list"},_("article",null,_("b",null,"Frontend"),_("span",null,sl.frontendPublicUrl||window.location.origin),_(Tu,{status:"online"},"public")),_("article",null,_("b",null,"Provider Ingress"),_("span",null,sl.providerIngressPublicUrl||"ws://public/ws/provider"),_(Tu,{status:"online"},"public")))),_(uu,{title:"内部服务",eyebrow:"Docker Network Only"},_("div",{className:"endpoint-list"},_("article",null,_("b",null,"backend-core API"),_("span",null,"http://backend-core:8080"),_(Tu,{status:"internal"},"internal")),_("article",null,_("b",null,"database"),_("span",null,"postgres://database:5432/unidesk"),_(Tu,{status:"internal"},"internal")))),_(uu,{title:"运行态",eyebrow:"Runtime"},_("div",{className:"metric-grid"},_(Nu,{label:"DB Ready",value:u.dbReady?"YES":"NO",hint:"internal health"}),_(Nu,{label:"Online Nodes",value:u.onlineNodeCount??0,hint:"provider-gateway self-link"}))))}function mP({session:l}){return _(uu,{title:"认证策略",eyebrow:"Frontend Login"},_("div",{className:"policy-grid"},_("article",null,_("span",null,"默认账号"),_("strong",null,sl.authUsername||"admin")),_("article",null,_("span",null,"当前会话"),_("strong",null,l?.user?.username||"--")),_("article",null,_("span",null,"Session TTL"),_("strong",null,`${sl.sessionTtlSeconds||0}s`)),_("article",null,_("span",null,"API 访问"),_("strong",null,"同源 Cookie 保护"))),_("p",{className:"muted paragraph"},"浏览器只访问 frontend 同源接口;frontend 容器使用 Docker 内网代理 backend-core API。"))}function KP(){return _(uu,{title:"安全边界",eyebrow:"Exposure Rule"},_("div",{className:"security-board"},_("article",{className:"allow"},_("b",null,"允许公网"),_("span",null,"frontend 登录入口"),_("span",null,"provider ingress WebSocket/health")),_("article",{className:"deny"},_("b",null,"禁止公网"),_("span",null,"backend-core REST API"),_("span",null,"PostgreSQL database")),_("article",null,_("b",null,"数据库卷"),_("span",null,"named volume unidesk_pgdata_10gb"),_("span",null,"CLI stop/start 不删除数据卷"))))}function zP({activeModule:l,activeTab:u,data:r,session:f,refresh:n,onRaw:i,onNavigate:t}){if(l==="ops"&&u==="status")return _(xY,{data:r,onRaw:i,onNavigate:t});if(l==="ops"&&u==="performance")return _(nP,{onRaw:i});if(l==="ops"&&u==="events")return _(vY,{events:r.events,onRaw:i});if(l==="ops"&&u==="logs")return _(sY,{logs:r.logs,onRaw:i});if(l==="nodes"&&u==="list")return _(kY,{nodes:r.nodes,onRaw:i});if(l==="nodes"&&u==="monitor")return _(aY,{nodes:r.nodes,systemStatuses:r.systemStatuses,tasks:r.tasks,onRaw:i,refresh:n});if(l==="nodes"&&u==="docker")return _($P,{nodes:r.nodes,dockerStatuses:r.dockerStatuses,onRaw:i});if(l==="nodes"&&u==="gateway")return _(yP,{nodes:r.nodes,tasks:r.tasks,onRaw:i});if(l==="nodes"&&u==="labels")return _(gY,{nodes:r.nodes});if(l==="nodes"&&u==="heartbeats")return _(IY,{nodes:r.nodes});if(l==="tasks"&&u==="dispatch")return _(FP,{nodes:r.nodes,onDispatched:n,onRaw:i});if(l==="tasks"&&u==="scheduled")return _(GP,{schedules:r.schedules,scheduleRuns:r.scheduleRuns,nodes:r.nodes,refresh:n,onRaw:i});if(l==="tasks"&&u==="pending")return _(UP,{tasks:r.pendingTasks,onRaw:i});if(l==="tasks"&&u==="history")return _(NP,{tasks:r.tasks,onRaw:i});if(l==="tasks"&&u==="results")return _(QP,{tasks:r.tasks,onRaw:i});if(l==="apps"&&u==="catalog")return _(jP,{microservices:r.microservices,onRaw:i,onNavigate:t});if(l==="apps"&&u==="todo-note")return _(xL,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="findjob")return _(BQ,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="pipeline")return _(SL,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="met-nonlinear")return _(YQ,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="claudeqq")return _(fN,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="baidu-netdisk")return _(lN,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="filebrowser")return _(HQ,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="oa-event-flow")return _(gQ,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="k3sctl")return _(IL,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl,onNavigate:t});if(l==="apps"&&u==="code-queue")return _(GQ,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl,initialTasksData:mY});if(l==="apps"&&u==="mdtodo")return _(hQ,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="decision-center")return _(KQ,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="apps"&&u==="project-manager")return _(PL,{microservices:r.microservices,onRaw:i,apiBaseUrl:sl.apiBaseUrl});if(l==="config"&&u==="topology")return _(TP,{data:r});if(l==="config"&&u==="auth")return _(mP,{session:f});if(l==="config"&&u==="security")return _(KP);return _(Qu,{title:"未找到页面",text:"请选择左侧主模块和顶部子功能标签"})}function EP({session:l,onLogout:u}){let r=k2(Xf,window.location.pathname),[f,n]=kl(r.moduleId),[i,t]=kl({...Nc,[r.moduleId]:r.tabId}),[y,c]=kl({overview:null,nodes:[],systemStatuses:[],dockerStatuses:[],microservices:[],events:[],tasks:[],pendingTasks:[],schedules:[],scheduleRuns:[],logs:[]}),[$,A]=kl({ok:!1,text:"连接中"}),[j,F]=kl(null),[U,N]=kl(new Date),[W,L]=kl(null),[J,w]=kl(!1),[Q,q]=kl(!1),T=i0.default.useRef(!1),O=Xf.moduleById[f]||Xf.modules[0],Z=i[f]||Nc[f]||O.tabs[0].id,E=Array.isArray(y.microservices)?y.microservices:[],D=E.length===0&&f==="apps"&&Z==="code-queue"?[KY]:E,Y=D===E?y:{...y,microservices:D},p=f==="apps"?D.find((I)=>String(I?.id||"")===(Z==="k3sctl"?"k3sctl-adapter":Z)):null,V=p?GG(p):{},B=O.tabs.find((I)=>I.id===Z)?.label||Z,m=p?[{key:"microservice",label:"用户服务",value:`${B} ${V.providerStatus==="online"?"在线":V.providerStatus||"未知"}`,tone:V.providerStatus==="online"?"ok":"warn",testId:"active-microservice-status"}]:[];async function X(){if(T.current)return;T.current=!0,q(!0);try{let I=[],M=(Ql,Ol)=>{I.push([Ql,ml(Ol)])},rl=f==="ops"&&Z==="status",cl=rl||f==="config"&&Z==="topology",$l=rl||f==="nodes"||f==="tasks"&&(Z==="dispatch"||Z==="scheduled"),Tl=f==="apps"&&Z!=="code-queue";if(cl)M("overview",`${sl.apiBaseUrl}/overview`);if($l)M("nodes",`${sl.apiBaseUrl}/nodes`);if(f==="nodes"&&Z==="monitor")M("systemStatuses",`${sl.apiBaseUrl}/nodes/system-status?limit=60`),M("tasks",`${sl.apiBaseUrl}/tasks?limit=120&summary=1`);else if(f==="nodes"&&Z==="docker")M("dockerStatuses",`${sl.apiBaseUrl}/nodes/docker-status`);else if(f==="nodes"&&Z==="gateway")M("tasks",`${sl.apiBaseUrl}/tasks?limit=300&summary=1`);else if(f==="tasks"&&Z==="scheduled")M("schedules",`${sl.apiBaseUrl}/schedules?limit=100`),M("scheduleRuns",`${sl.apiBaseUrl}/schedules/runs?limit=100`);else if(f==="tasks"&&Z==="pending")M("pendingTasks",`${sl.apiBaseUrl}/tasks?status=pending&limit=100&summary=1`);else if(f==="tasks"&&(Z==="history"||Z==="results"))M("tasks",`${sl.apiBaseUrl}/tasks?limit=300&summary=1`);else if(rl)M("tasks",`${sl.apiBaseUrl}/tasks?limit=8&lite=1`),M("pendingTasks",`${sl.apiBaseUrl}/tasks?status=pending&limit=20&lite=1`);if(Tl)M("microservices",`${sl.apiBaseUrl}/microservices`);if(f==="ops"&&Z==="events")M("events",`${sl.apiBaseUrl}/events?limit=100`);if(f==="ops"&&Z==="logs")M("logs","/logs?limit=100");await Promise.all(I.map(async([Ql,Ol])=>{let h=await Ol,a={};if(Ql==="overview")a.overview=h;if(Ql==="nodes")a.nodes=h.nodes||[];if(Ql==="systemStatuses")a.systemStatuses=h.systemStatuses||[];if(Ql==="dockerStatuses")a.dockerStatuses=h.dockerStatuses||[];if(Ql==="microservices")a.microservices=h.microservices||[];if(Ql==="events")a.events=h.events||[];if(Ql==="tasks")a.tasks=h.tasks||[];if(Ql==="pendingTasks")a.pendingTasks=h.tasks||[];if(Ql==="schedules")a.schedules=h.schedules||[];if(Ql==="scheduleRuns")a.scheduleRuns=h.runs||[];if(Ql==="logs")a.logs=h.logs||[];c((ul)=>({...ul,...a}))})),A({ok:!0,text:"核心在线"}),F(new Date)}catch(I){if(A({ok:!1,text:El(I,"连接失败")}),I.status===401)u(!1)}finally{T.current=!1,q(!1)}}En(()=>{let I=()=>{if(!eL())return;X()};I();let M=setInterval(I,EY(f,Z)),rl=()=>{if(eL())I()};return document.addEventListener("visibilitychange",rl),()=>{clearInterval(M),document.removeEventListener("visibilitychange",rl)}},[f,Z]),En(()=>{let I=setInterval(()=>N(new Date),1000);return()=>clearInterval(I)},[]),En(()=>{let I=vQ(Xf,window.location.pathname);if(I&&window.location.pathname!==I)window.history.replaceState(null,"",I)},[]),En(()=>{let I=()=>{let M=k2(Xf,window.location.pathname);n(M.moduleId),t((rl)=>({...rl,[M.moduleId]:M.tabId})),L(null)};return window.addEventListener("popstate",I),()=>window.removeEventListener("popstate",I)},[]),En(()=>{window.scrollTo({top:0,left:0,behavior:"auto"})},[f,Z]);function S(I,M,rl="push"){let cl=Xf.moduleById[I]?I:Xf.fallbackTarget.moduleId,$l=Xf.moduleById[cl]?.tabs.some((Ql)=>Ql.id===M)?M:Nc[cl]||Xf.moduleById[cl]?.tabs[0]?.id||Xf.fallbackTarget.tabId;n(cl),t((Ql)=>({...Ql,[cl]:$l}));let Tl=H3(Xf,cl,$l);if(window.location.pathname!==Tl){let Ql=rl==="replace"?"replaceState":"pushState";window.history[Ql](null,"",Tl)}}function b(I,M){L({title:I,data:M})}let[z,P]=kl(!1),{unreadCount:s,notifications:k}=Xr(),v=k.length>0?k[k.length-1]:null,tl=AG(dL);return _("div",{className:`shell ${J?"rail-collapsed":""} ${tl?"dev-shell":""}`,"data-testid":"app-shell"},_(hY,{activeModule:f,activeTabs:i,onNavigate:S,collapsed:J,onToggle:()=>w((I)=>!I)}),_("main",{className:"workspace"},_(CY,{connection:$,lastRefresh:j,onRefresh:X,onLogout:()=>u(!0),session:l,clock:U,activeStatusItems:m,onNotificationToggle:()=>P((I)=>!I),unreadCount:s,environment:dL}),_(RY,{module:O,activeTab:Z,onNavigate:S}),_(Cj.Provider,{value:Q},_(zP,{activeModule:f,activeTab:Z,data:Y,session:l,refresh:X,onRaw:b,onNavigate:S}))),_(YY,{raw:W,onClose:()=>L(null)}),v&&_(oL,{key:v.id,notification:v}),z&&_(aL,{onClose:()=>P(!1)}))}function OP(){let[l,u]=kl(!0),[r,f]=kl(null);async function n(){u(!0);try{let t=await ml("/api/session");f(t.authenticated?t:null)}catch{f(null)}finally{u(!1)}}async function i(t){if(t)try{await ml("/logout",{method:"POST"})}catch{}f(null)}if(En(()=>{n()},[]),l)return _("main",{className:"loading-screen"},_("div",{className:"brand-mark"},"UD"),_("span",null,"加载会话"));if(!r)return _(PY,{onLogin:f});return _(eU,null,_(EP,{session:r,onLogout:i}))}var ZG=document.getElementById("root");if(ZG===null)throw Error("root element not found");_G.createRoot(ZG).render(_(OP));})(); diff --git a/src/components/frontend/src/app.tsx b/src/components/frontend/src/app.tsx index 60b4100f..e88c63c9 100644 --- a/src/components/frontend/src/app.tsx +++ b/src/components/frontend/src/app.tsx @@ -542,7 +542,7 @@ function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock, devMode ? h("div", { className: "dev-env-ribbon", "data-testid": "dev-environment-ribbon" }, h("b", null, "DEV"), h("span", null, environment.namespace || "unidesk-dev"), - h("span", null, environment.deployRef || "origin/deploy/dev"), + h("span", null, environment.deployRef || "origin/master:deploy.json#environments.dev"), h("span", null, shortCommit(environment.commit || environment.requestedCommit)), ) : null, ), diff --git a/src/components/microservices/devops/Dockerfile b/src/components/microservices/devops/Dockerfile new file mode 100644 index 00000000..c2f23022 --- /dev/null +++ b/src/components/microservices/devops/Dockerfile @@ -0,0 +1,20 @@ +ARG DEVOPS_GO_IMAGE=golang:1.23-bookworm +ARG DEVOPS_RUNTIME_IMAGE=debian:bookworm-slim + +FROM ${DEVOPS_GO_IMAGE} AS builder +WORKDIR /src +COPY src/components/microservices/devops/go.mod ./go.mod +COPY src/components/microservices/devops/main.go ./main.go +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/unidesk-devops ./main.go + +FROM ${DEVOPS_RUNTIME_IMAGE} + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl git \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /out/unidesk-devops /usr/local/bin/unidesk-devops + +EXPOSE 4286 +CMD ["/usr/local/bin/unidesk-devops"] diff --git a/src/components/microservices/devops/go.mod b/src/components/microservices/devops/go.mod new file mode 100644 index 00000000..7d48dfc6 --- /dev/null +++ b/src/components/microservices/devops/go.mod @@ -0,0 +1,3 @@ +module github.com/pikasTech/unidesk/src/components/microservices/devops + +go 1.23 diff --git a/src/components/microservices/devops/main.go b/src/components/microservices/devops/main.go new file mode 100644 index 00000000..3f62728e --- /dev/null +++ b/src/components/microservices/devops/main.go @@ -0,0 +1,739 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +type jsonMap map[string]any + +type config struct { + Host string + Port int + Namespace string + RepoURL string + DesiredRef string + Environment string + GitProxyURL string + PipelineName string + PipelineServiceAccount string + WorkspaceClaim string + AppImage string + LogFile string +} + +type deployService struct { + ID string `json:"id"` + Repo string `json:"repo"` + CommitID string `json:"commitId"` +} + +type deploySummary struct { + DeployCommit string `json:"deployCommit"` + DesiredRef string `json:"desiredRef"` + Environment string `json:"environment"` + RepoURL string `json:"repoUrl"` + Services []deployService `json:"services"` +} + +type httpError struct { + Status int + Msg string + Detail jsonMap +} + +func (e *httpError) Error() string { + return e.Msg +} + +var ( + startedAt = time.Now().UTC().Format(time.RFC3339) + recentLogs []jsonMap + serviceConfig = readConfig() + refPattern = regexp.MustCompile(`^[A-Za-z0-9._/-]{1,160}$`) + runIDPattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]{0,46}[a-z0-9])?$`) + commitIDPattern = regexp.MustCompile(`^[0-9a-f]{7,40}$`) +) + +func envString(name, fallback string) string { + value := os.Getenv(name) + if value == "" { + return fallback + } + return value +} + +func envInt(name string, fallback int) int { + raw := os.Getenv(name) + if raw == "" { + return fallback + } + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + return fallback + } + return value +} + +func currentNamespace() string { + raw, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace") + if err != nil { + return "unidesk-ci" + } + value := strings.TrimSpace(string(raw)) + if value == "" { + return "unidesk-ci" + } + return value +} + +func readConfig() config { + return config{ + Host: envString("HOST", "0.0.0.0"), + Port: envInt("PORT", 4286), + Namespace: envString("DEVOPS_NAMESPACE", currentNamespace()), + RepoURL: envString("DEVOPS_REPO_URL", "https://github.com/pikasTech/unidesk"), + DesiredRef: envString("DEVOPS_DESIRED_REF", "master"), + Environment: envString("DEVOPS_ENVIRONMENT", "dev"), + GitProxyURL: envString("DEVOPS_GIT_PROXY_URL", "http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789"), + PipelineName: envString("DEVOPS_DEV_E2E_PIPELINE", "unidesk-dev-namespace-e2e"), + PipelineServiceAccount: envString("DEVOPS_PIPELINE_SERVICE_ACCOUNT", "unidesk-ci-runner"), + WorkspaceClaim: envString("DEVOPS_PIPELINE_WORKSPACE_CLAIM", "unidesk-ci-cache"), + AppImage: envString("DEVOPS_DEV_E2E_APP_IMAGE", "unidesk-code-queue:dev"), + LogFile: envString("LOG_FILE", "/var/log/unidesk/devops.jsonl"), + } +} + +func appendLog(level, event string, detail jsonMap) { + record := jsonMap{"at": time.Now().UTC().Format(time.RFC3339), "service": "devops", "level": level, "event": event} + for key, value := range detail { + record[key] = value + } + recentLogs = append(recentLogs, record) + if len(recentLogs) > 300 { + recentLogs = recentLogs[len(recentLogs)-300:] + } + line, _ := json.Marshal(record) + log.Println(string(line)) + if serviceConfig.LogFile != "" { + _ = os.MkdirAll(filepath.Dir(serviceConfig.LogFile), 0o755) + if file, err := os.OpenFile(serviceConfig.LogFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644); err == nil { + _, _ = file.Write(append(line, '\n')) + _ = file.Close() + } + } +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("content-type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func errorBody(err error) jsonMap { + body := jsonMap{"ok": false, "error": err.Error()} + var he *httpError + if errors.As(err, &he) { + for key, value := range he.Detail { + body[key] = value + } + } + return body +} + +func handleError(w http.ResponseWriter, err error) { + status := http.StatusInternalServerError + var he *httpError + if errors.As(err, &he) { + status = he.Status + } + appendLog(map[bool]string{true: "error", false: "warn"}[status >= 500], "request_failed", jsonMap{"status": status, "error": err.Error()}) + writeJSON(w, status, errorBody(err)) +} + +func readJSON(r *http.Request) (jsonMap, error) { + if r.Body == nil { + return jsonMap{}, nil + } + defer r.Body.Close() + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + return nil, err + } + if len(strings.TrimSpace(string(body))) == 0 { + return jsonMap{}, nil + } + var record jsonMap + if err := json.Unmarshal(body, &record); err != nil { + return nil, &httpError{Status: http.StatusBadRequest, Msg: "request body must be JSON"} + } + return record, nil +} + +func stringValue(value any) string { + text, _ := value.(string) + return text +} + +func boolValue(value any) bool { + switch item := value.(type) { + case bool: + return item + case string: + return item == "true" || item == "1" + case float64: + return item == 1 + default: + return false + } +} + +func requireRepoURL(value any) (string, error) { + repo := stringValue(value) + if repo == "" { + repo = serviceConfig.RepoURL + } + parsed, err := url.Parse(repo) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return "", &httpError{Status: http.StatusBadRequest, Msg: "repoUrl must be an https URL"} + } + return repo, nil +} + +func requireDesiredRef(value any) (string, error) { + ref := stringValue(value) + if ref == "" { + ref = serviceConfig.DesiredRef + } + if !refPattern.MatchString(ref) || strings.HasPrefix(ref, "-") || strings.Contains(ref, "..") { + return "", &httpError{Status: http.StatusBadRequest, Msg: "desired ref contains unsupported characters"} + } + return ref, nil +} + +func optionalRunID(value any) (string, error) { + runID := stringValue(value) + if runID == "" { + return "", nil + } + if !runIDPattern.MatchString(runID) { + return "", &httpError{Status: http.StatusBadRequest, Msg: "runId must be DNS-safe lowercase alnum/dash, max 48 chars"} + } + return runID, nil +} + +func gitEnv() []string { + env := os.Environ() + noProxy := "localhost,127.0.0.1,::1,d601-provider-egress-proxy,d601-provider-egress-proxy.unidesk,d601-provider-egress-proxy.unidesk.svc,d601-provider-egress-proxy.unidesk.svc.cluster.local" + add := map[string]string{ + "HTTP_PROXY": serviceConfig.GitProxyURL, + "HTTPS_PROXY": serviceConfig.GitProxyURL, + "ALL_PROXY": serviceConfig.GitProxyURL, + "NO_PROXY": noProxy, + "http_proxy": serviceConfig.GitProxyURL, + "https_proxy": serviceConfig.GitProxyURL, + "all_proxy": serviceConfig.GitProxyURL, + "no_proxy": noProxy, + } + for key, value := range add { + env = append(env, key+"="+value) + } + return env +} + +func runCommand(ctx context.Context, cwd string, args ...string) (string, string, error) { + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Dir = cwd + cmd.Env = gitEnv() + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return stdout.String(), stderr.String(), err +} + +func resolveDeployManifest(repoURL, desiredRef, environment string) (deploySummary, error) { + dir, err := os.MkdirTemp("", "unidesk-devops-deploy-") + if err != nil { + return deploySummary{}, err + } + defer os.RemoveAll(dir) + + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Second) + defer cancel() + if _, stderr, err := runCommand(ctx, dir, "git", "init", "-q"); err != nil { + return deploySummary{}, &httpError{Status: http.StatusBadGateway, Msg: "git init failed", Detail: jsonMap{"stderr": tail(stderr, 2000)}} + } + if _, stderr, err := runCommand(ctx, dir, "git", "remote", "add", "origin", repoURL); err != nil { + return deploySummary{}, &httpError{Status: http.StatusBadGateway, Msg: "git remote add failed", Detail: jsonMap{"stderr": tail(stderr, 2000)}} + } + if _, stderr, err := runCommand(ctx, dir, "git", "fetch", "--depth=1", "origin", desiredRef); err != nil { + return deploySummary{}, &httpError{Status: http.StatusBadGateway, Msg: "failed to fetch desired ref", Detail: jsonMap{"stderr": tail(stderr, 4000)}} + } + stdout, stderr, err := runCommand(ctx, dir, "git", "rev-parse", "FETCH_HEAD") + if err != nil { + return deploySummary{}, &httpError{Status: http.StatusBadGateway, Msg: "failed to resolve desired ref commit", Detail: jsonMap{"stderr": tail(stderr, 2000)}} + } + deployCommit := strings.TrimSpace(stdout) + stdout, stderr, err = runCommand(ctx, dir, "git", "show", "FETCH_HEAD:deploy.json") + if err != nil { + return deploySummary{}, &httpError{Status: http.StatusBadGateway, Msg: "failed to read deploy.json from desired ref", Detail: jsonMap{"stderr": tail(stderr, 4000)}} + } + return parseDeployManifest(stdout, repoURL, desiredRef, environment, deployCommit) +} + +func parseDeployManifest(raw, repoURL, desiredRef, environment, deployCommit string) (deploySummary, error) { + var parsed struct { + SchemaVersion int `json:"schemaVersion"` + Environments map[string]struct { + Services []deployService `json:"services"` + } `json:"environments"` + } + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return deploySummary{}, &httpError{Status: http.StatusBadRequest, Msg: "deploy.json must be valid JSON"} + } + if parsed.SchemaVersion != 2 { + return deploySummary{}, &httpError{Status: http.StatusBadRequest, Msg: "deploy.json must use schemaVersion=2"} + } + env, ok := parsed.Environments[environment] + if !ok { + return deploySummary{}, &httpError{Status: http.StatusBadRequest, Msg: "deploy.json must contain requested environment", Detail: jsonMap{"environment": environment}} + } + if len(env.Services) == 0 { + return deploySummary{}, &httpError{Status: http.StatusBadRequest, Msg: "deploy.json environment must contain services", Detail: jsonMap{"environment": environment}} + } + for index, service := range env.Services { + service.CommitID = strings.ToLower(service.CommitID) + env.Services[index].CommitID = service.CommitID + if service.ID == "" || service.Repo == "" || !commitIDPattern.MatchString(service.CommitID) { + return deploySummary{}, &httpError{Status: http.StatusBadRequest, Msg: fmt.Sprintf("deploy.json environments.%s.services[%d] must contain id, repo and 7-40 char commitId", environment, index)} + } + } + return deploySummary{DeployCommit: deployCommit, DesiredRef: desiredRef, Environment: environment, RepoURL: repoURL, Services: env.Services}, nil +} + +func tail(value string, max int) string { + if len(value) <= max { + return value + } + return value[len(value)-max:] +} + +func randomSuffix() string { + var bytes [4]byte + if _, err := rand.Read(bytes[:]); err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 36) + } + return hex.EncodeToString(bytes[:]) +} + +func makeRunID(deployCommit string) string { + stamp := time.Now().UTC().Format("20060102150405") + runID := fmt.Sprintf("%s-%s", stamp, strings.ToLower(deployCommit[:min(len(deployCommit), 8)])) + runID = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(runID, "-") + if len(runID) > 48 { + return runID[:48] + } + return runID +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func pipelineRunBody(summary deploySummary, runID string, keepNamespace bool) jsonMap { + return jsonMap{ + "apiVersion": "tekton.dev/v1", + "kind": "PipelineRun", + "metadata": jsonMap{ + "generateName": fmt.Sprintf("unidesk-dev-e2e-%s-", runID), + "namespace": serviceConfig.Namespace, + "labels": jsonMap{ + "app.kubernetes.io/name": "unidesk-dev-namespace-e2e", + "app.kubernetes.io/part-of": "unidesk", + "unidesk.ai/ci-kind": "dev-namespace-e2e", + "unidesk.ai/deploy-ref": "master-deploy-json-dev", + "unidesk.ai/deploy-commit": summary.DeployCommit[:min(len(summary.DeployCommit), 40)], + }, + }, + "spec": jsonMap{ + "pipelineRef": jsonMap{"name": serviceConfig.PipelineName}, + "taskRunTemplate": jsonMap{"serviceAccountName": serviceConfig.PipelineServiceAccount}, + "params": []jsonMap{ + {"name": "repo-url", "value": summary.RepoURL}, + {"name": "desired-ref", "value": summary.DesiredRef}, + {"name": "deploy-commit", "value": summary.DeployCommit}, + {"name": "environment", "value": summary.Environment}, + {"name": "run-id", "value": runID}, + {"name": "keep-namespace", "value": map[bool]string{true: "true", false: "false"}[keepNamespace]}, + {"name": "app-image", "value": serviceConfig.AppImage}, + }, + "workspaces": []jsonMap{ + {"name": "shared-workspace", "persistentVolumeClaim": jsonMap{"claimName": serviceConfig.WorkspaceClaim}}, + }, + }, + } +} + +func serviceAccountFile(name string) string { + return filepath.Join("/var/run/secrets/kubernetes.io/serviceaccount", name) +} + +func kubeClient() (*http.Client, error) { + caPEM, err := os.ReadFile(serviceAccountFile("ca.crt")) + if err != nil { + return nil, err + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caPEM) { + return nil, errors.New("failed to load service-account CA") + } + return &http.Client{Timeout: 120 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}}}, nil +} + +func kubeURL(path string) string { + host := envString("KUBERNETES_SERVICE_HOST", "kubernetes.default.svc") + port := envString("KUBERNETES_SERVICE_PORT_HTTPS", envString("KUBERNETES_SERVICE_PORT", "443")) + return fmt.Sprintf("https://%s:%s%s", host, port, path) +} + +func kubeRequest(method, path string, body any) (int, []byte, error) { + client, err := kubeClient() + if err != nil { + return 0, nil, err + } + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return 0, nil, err + } + reader = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, kubeURL(path), reader) + if err != nil { + return 0, nil, err + } + token, err := os.ReadFile(serviceAccountFile("token")) + if err != nil { + return 0, nil, err + } + req.Header.Set("authorization", "Bearer "+strings.TrimSpace(string(token))) + req.Header.Set("accept", "application/json") + if body != nil { + req.Header.Set("content-type", "application/json") + } + res, err := client.Do(req) + if err != nil { + return 0, nil, err + } + defer res.Body.Close() + raw, err := io.ReadAll(io.LimitReader(res.Body, 8*1024*1024)) + if err != nil { + return res.StatusCode, nil, err + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return res.StatusCode, raw, &httpError{Status: res.StatusCode, Msg: "kubernetes api request failed", Detail: jsonMap{"path": path, "status": res.StatusCode, "body": tail(string(raw), 4000)}} + } + return res.StatusCode, raw, nil +} + +func metadataName(value any) string { + record, ok := value.(map[string]any) + if !ok { + return "" + } + metadata, ok := record["metadata"].(map[string]any) + if !ok { + return "" + } + return stringValue(metadata["name"]) +} + +func conditionSummary(value any) jsonMap { + record, ok := value.(map[string]any) + if !ok { + return jsonMap{"terminal": false, "status": "Unknown", "reason": "", "message": ""} + } + status, _ := record["status"].(map[string]any) + conditions, _ := status["conditions"].([]any) + for _, item := range conditions { + condition, ok := item.(map[string]any) + if !ok || condition["type"] != "Succeeded" { + continue + } + statusText := stringValue(condition["status"]) + return jsonMap{ + "terminal": statusText == "True" || statusText == "False", + "succeeded": statusText == "True", + "status": statusText, + "reason": stringValue(condition["reason"]), + "message": tail(stringValue(condition["message"]), 2000), + } + } + return jsonMap{"terminal": false, "status": "Unknown", "reason": "", "message": ""} +} + +func decodeJSON(raw []byte) any { + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return jsonMap{"text": tail(string(raw), 4000)} + } + return value +} + +func listItems(value any) []any { + record, ok := value.(map[string]any) + if !ok { + return nil + } + items, _ := record["items"].([]any) + return items +} + +func compactTaskRun(value any) jsonMap { + record, _ := value.(map[string]any) + status, _ := record["status"].(map[string]any) + return jsonMap{ + "name": metadataName(value), + "condition": conditionSummary(value), + "podName": stringValue(status["podName"]), + "startTime": stringValue(status["startTime"]), + "completionTime": stringValue(status["completionTime"]), + } +} + +func compactPod(value any) jsonMap { + record, _ := value.(map[string]any) + status, _ := record["status"].(map[string]any) + spec, _ := record["spec"].(map[string]any) + return jsonMap{ + "name": metadataName(value), + "phase": stringValue(status["phase"]), + "nodeName": stringValue(spec["nodeName"]), + "podIP": stringValue(status["podIP"]), + "reason": stringValue(status["reason"]), + } +} + +func runStatus(name string) (jsonMap, error) { + namespace := url.PathEscape(serviceConfig.Namespace) + selector := url.QueryEscape("tekton.dev/pipelineRun=" + name) + _, pipelineRaw, err := kubeRequest("GET", fmt.Sprintf("/apis/tekton.dev/v1/namespaces/%s/pipelineruns/%s", namespace, url.PathEscape(name)), nil) + if err != nil { + return nil, err + } + _, taskRaw, _ := kubeRequest("GET", fmt.Sprintf("/apis/tekton.dev/v1/namespaces/%s/taskruns?labelSelector=%s", namespace, selector), nil) + _, podRaw, _ := kubeRequest("GET", fmt.Sprintf("/api/v1/namespaces/%s/pods?labelSelector=%s", namespace, selector), nil) + taskRuns := []jsonMap{} + for _, item := range listItems(decodeJSON(taskRaw)) { + taskRuns = append(taskRuns, compactTaskRun(item)) + } + pods := []jsonMap{} + for _, item := range listItems(decodeJSON(podRaw)) { + pods = append(pods, compactPod(item)) + } + pipeline := decodeJSON(pipelineRaw) + return jsonMap{"ok": true, "pipelineRun": name, "namespace": serviceConfig.Namespace, "condition": conditionSummary(pipeline), "taskRuns": taskRuns, "pods": pods}, nil +} + +func handleRunDevE2E(w http.ResponseWriter, r *http.Request) { + body, err := readJSON(r) + if err != nil { + handleError(w, err) + return + } + repoURL, err := requireRepoURL(body["repoUrl"]) + if err != nil { + handleError(w, err) + return + } + desiredRef, err := requireDesiredRef(body["desiredRef"]) + if err != nil { + handleError(w, err) + return + } + environment := stringValue(body["environment"]) + if environment == "" { + environment = serviceConfig.Environment + } + if environment != "dev" { + handleError(w, &httpError{Status: http.StatusBadRequest, Msg: "only environment=dev is enabled for dev e2e"}) + return + } + runID, err := optionalRunID(body["runId"]) + if err != nil { + handleError(w, err) + return + } + keepNamespace := boolValue(body["keepNamespace"]) + summary, err := resolveDeployManifest(repoURL, desiredRef, environment) + if err != nil { + handleError(w, err) + return + } + if runID == "" { + runID = makeRunID(summary.DeployCommit) + } + namespace := url.PathEscape(serviceConfig.Namespace) + _, raw, err := kubeRequest("POST", fmt.Sprintf("/apis/tekton.dev/v1/namespaces/%s/pipelineruns", namespace), pipelineRunBody(summary, runID, keepNamespace)) + if err != nil { + handleError(w, err) + return + } + name := metadataName(decodeJSON(raw)) + if name == "" { + name = "unknown-" + randomSuffix() + } + appendLog("info", "dev_e2e_started", jsonMap{"pipelineRun": name, "runId": runID, "deployCommit": summary.DeployCommit, "desiredRef": desiredRef, "environment": environment, "keepNamespace": keepNamespace}) + writeJSON(w, http.StatusOK, jsonMap{ + "ok": true, + "mode": "k3s-devops-managed", + "pipelineRun": name, + "namespace": serviceConfig.Namespace, + "temporaryNamespace": "unidesk-ci-e2e-" + runID, + "runId": runID, + "keepNamespace": keepNamespace, + "desiredRef": desiredRef, + "environment": environment, + "deployCommit": summary.DeployCommit, + "services": summary.Services, + "next": []string{"bun scripts/cli.ts ci logs " + name, "bun scripts/cli.ts ci status"}, + }) +} + +func handleLogs(w http.ResponseWriter, name string) { + namespace := url.PathEscape(serviceConfig.Namespace) + selector := url.QueryEscape("tekton.dev/pipelineRun=" + name) + _, podRaw, err := kubeRequest("GET", fmt.Sprintf("/api/v1/namespaces/%s/pods?labelSelector=%s", namespace, selector), nil) + if err != nil { + handleError(w, err) + return + } + logs := []jsonMap{} + for index, item := range listItems(decodeJSON(podRaw)) { + if index >= 12 { + break + } + podName := metadataName(item) + if podName == "" { + continue + } + _, raw, err := kubeRequest("GET", fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/log?allContainers=true&tailLines=180", namespace, url.PathEscape(podName)), nil) + if err != nil { + logs = append(logs, jsonMap{"pod": podName, "ok": false, "error": err.Error()}) + } else { + logs = append(logs, jsonMap{"pod": podName, "ok": true, "text": tail(string(raw), 40000)}) + } + } + writeJSON(w, http.StatusOK, jsonMap{"ok": true, "pipelineRun": name, "namespace": serviceConfig.Namespace, "logs": logs}) +} + +func handleCIStatus(w http.ResponseWriter) { + namespace := url.PathEscape(serviceConfig.Namespace) + _, pipelinesRaw, _ := kubeRequest("GET", fmt.Sprintf("/apis/tekton.dev/v1/namespaces/%s/pipelines", namespace), nil) + _, tasksRaw, _ := kubeRequest("GET", fmt.Sprintf("/apis/tekton.dev/v1/namespaces/%s/tasks", namespace), nil) + _, runsRaw, _ := kubeRequest("GET", fmt.Sprintf("/apis/tekton.dev/v1/namespaces/%s/pipelineruns", namespace), nil) + writeJSON(w, http.StatusOK, jsonMap{ + "ok": true, + "service": "devops", + "namespace": serviceConfig.Namespace, + "mode": "k3s-devops-managed", + "pipelines": namesFromItems(decodeJSON(pipelinesRaw)), + "tasks": namesFromItems(decodeJSON(tasksRaw)), + "recentPipelineRuns": namesFromItems(decodeJSON(runsRaw)), + }) +} + +func namesFromItems(value any) []string { + names := []string{} + for _, item := range listItems(value) { + if name := metadataName(item); name != "" { + names = append(names, name) + } + } + if len(names) > 20 { + return names[len(names)-20:] + } + return names +} + +func router(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + writeJSON(w, http.StatusOK, jsonMap{"ok": true}) + return + } + switch { + case (r.URL.Path == "/" || r.URL.Path == "/health") && (r.Method == http.MethodGet || r.Method == http.MethodHead): + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + writeJSON(w, http.StatusOK, jsonMap{ + "ok": true, + "service": "devops", + "startedAt": startedAt, + "namespace": serviceConfig.Namespace, + "mode": "k3s-devops-managed", + "normalControlPlane": "CLI -> backend-core -> k3sctl-adapter -> devops -> Kubernetes API/Tekton", + "breakGlass": "provider-gateway host.ssh remains bootstrap/recovery only", + }) + case r.URL.Path == "/live" && (r.Method == http.MethodGet || r.Method == http.MethodHead): + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + writeJSON(w, http.StatusOK, jsonMap{"ok": true, "service": "devops", "startedAt": startedAt}) + case r.URL.Path == "/logs" && r.Method == http.MethodGet: + writeJSON(w, http.StatusOK, jsonMap{"ok": true, "logs": recentLogs}) + case r.URL.Path == "/api/ci/status" && r.Method == http.MethodGet: + handleCIStatus(w) + case r.URL.Path == "/api/ci/dev-e2e/run" && r.Method == http.MethodPost: + handleRunDevE2E(w, r) + case strings.HasPrefix(r.URL.Path, "/api/ci/runs/") && strings.HasSuffix(r.URL.Path, "/logs") && r.Method == http.MethodGet: + name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/ci/runs/"), "/logs") + handleLogs(w, name) + case strings.HasPrefix(r.URL.Path, "/api/ci/runs/") && r.Method == http.MethodGet: + name := strings.TrimPrefix(r.URL.Path, "/api/ci/runs/") + status, err := runStatus(name) + if err != nil { + handleError(w, err) + return + } + writeJSON(w, http.StatusOK, status) + default: + writeJSON(w, http.StatusNotFound, jsonMap{"ok": false, "error": "not found"}) + } +} + +func main() { + appendLog("info", "service_started", jsonMap{"port": serviceConfig.Port, "namespace": serviceConfig.Namespace, "pipelineName": serviceConfig.PipelineName, "appImage": serviceConfig.AppImage}) + server := &http.Server{ + Addr: fmt.Sprintf("%s:%d", serviceConfig.Host, serviceConfig.Port), + Handler: http.HandlerFunc(router), + ReadHeaderTimeout: 5 * time.Second, + } + log.Fatal(server.ListenAndServe()) +} diff --git a/src/components/microservices/k3sctl-adapter/docker-compose.d601.yml b/src/components/microservices/k3sctl-adapter/docker-compose.d601.yml index dad1708e..b89a9b7e 100644 --- a/src/components/microservices/k3sctl-adapter/docker-compose.d601.yml +++ b/src/components/microservices/k3sctl-adapter/docker-compose.d601.yml @@ -40,7 +40,8 @@ services: K3SCTL_NATIVE_SERVICE_URL_CODE_QUEUE: "${K3SCTL_NATIVE_SERVICE_URL_CODE_QUEUE:-}" K3SCTL_NATIVE_SERVICE_URL_MDTODO: "${K3SCTL_NATIVE_SERVICE_URL_MDTODO:-}" K3SCTL_NATIVE_SERVICE_URL_DECISION_CENTER: "${K3SCTL_NATIVE_SERVICE_URL_DECISION_CENTER:-}" - K3SCTL_MANIFEST_PATHS: "${K3SCTL_MANIFEST_PATHS:-k3s/code-queue.k3s.json,k3s/mdtodo.k3s.json,k3s/claudeqq.k3s.json,k3s/decision-center.k3s.json}" + K3SCTL_NATIVE_SERVICE_URL_DEVOPS: "${K3SCTL_NATIVE_SERVICE_URL_DEVOPS:-}" + K3SCTL_MANIFEST_PATHS: "${K3SCTL_MANIFEST_PATHS:-k3s/code-queue.k3s.json,k3s/mdtodo.k3s.json,k3s/claudeqq.k3s.json,k3s/decision-center.k3s.json,k3s/devops.k3s.json}" K3SCTL_SERVICES_JSON: "${K3SCTL_SERVICES_JSON:-[]}" UNIDESK_LOG_RETENTION_BYTES: "${UNIDESK_LOG_RETENTION_BYTES:-512MiB}" volumes: diff --git a/src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml b/src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml index 7f37047d..1b94114e 100644 --- a/src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml +++ b/src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml @@ -77,6 +77,40 @@ roleRef: name: unidesk-ci-trigger-reader --- apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: unidesk-ci-dev-e2e-manager + labels: + app.kubernetes.io/name: unidesk-ci + app.kubernetes.io/part-of: unidesk +rules: + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list", "watch", "create", "delete", "patch"] + - apiGroups: [""] + resources: ["configmaps", "services", "pods", "pods/log"] + verbs: ["get", "list", "watch", "create", "delete", "patch"] + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "create", "delete", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: unidesk-ci-dev-e2e-manager + labels: + app.kubernetes.io/name: unidesk-ci + app.kubernetes.io/part-of: unidesk +subjects: + - kind: ServiceAccount + name: unidesk-ci-runner + namespace: unidesk-ci +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: unidesk-ci-dev-e2e-manager +--- +apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: unidesk-ci-cross-namespace @@ -146,7 +180,7 @@ spec: type: string - name: image type: string - default: unidesk-code-queue:d601 + default: unidesk-code-queue:dev workspaces: - name: source volumes: @@ -185,6 +219,7 @@ spec: git rev-parse HEAD | tee "$(workspaces.source.path)/commit.txt" - name: install-and-check image: "$(params.image)" + imagePullPolicy: Never env: - name: DOCKER_HOST value: unix:///var/run/docker.sock @@ -235,12 +270,13 @@ spec: type: string - name: app-image type: string - default: unidesk-code-queue:d601 + default: unidesk-code-queue:dev workspaces: - name: source steps: - name: start-read-service image: "$(params.app-image)" + imagePullPolicy: Never env: - name: HTTP_PROXY value: "" @@ -499,6 +535,7 @@ spec: exit 1 - name: measure image: "$(params.app-image)" + imagePullPolicy: Never workingDir: "$(workspaces.source.path)/repo" env: - name: CI_CODE_QUEUE_URL @@ -550,6 +587,7 @@ spec: bun scripts/ci-code-queue-read-perf.ts - name: cleanup image: "$(params.app-image)" + imagePullPolicy: Never env: - name: HTTP_PROXY value: "" @@ -588,6 +626,408 @@ spec: delete_resource "api/v1/namespaces/$kube_namespace/services/code-queue-ci-read" --- apiVersion: tekton.dev/v1 +kind: Task +metadata: + name: unidesk-dev-namespace-e2e + namespace: unidesk-ci + labels: + app.kubernetes.io/name: unidesk-ci + app.kubernetes.io/component: dev-namespace-e2e +spec: + params: + - name: repo-url + type: string + default: https://github.com/pikasTech/unidesk + - name: desired-ref + type: string + default: master + - name: deploy-commit + type: string + - name: environment + type: string + default: dev + - name: run-id + type: string + - name: keep-namespace + type: string + default: "false" + - name: app-image + type: string + default: unidesk-code-queue:dev + workspaces: + - name: source + steps: + - name: clone-deploy-manifest + image: alpine/git:2.45.2 + env: + - name: HTTP_PROXY + value: "http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789" + - name: HTTPS_PROXY + value: "http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789" + - name: ALL_PROXY + value: "http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789" + - name: NO_PROXY + value: "localhost,127.0.0.1,::1,d601-provider-egress-proxy,d601-provider-egress-proxy.unidesk,d601-provider-egress-proxy.unidesk.svc,d601-provider-egress-proxy.unidesk.svc.cluster.local" + - name: http_proxy + value: "http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789" + - name: https_proxy + value: "http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789" + - name: all_proxy + value: "http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789" + - name: no_proxy + value: "localhost,127.0.0.1,::1,d601-provider-egress-proxy,d601-provider-egress-proxy.unidesk,d601-provider-egress-proxy.unidesk.svc,d601-provider-egress-proxy.unidesk.svc.cluster.local" + script: | + #!/bin/sh + set -eu + run_dir="$(workspaces.source.path)/dev-e2e-$(params.run-id)" + rm -rf "$run_dir" + mkdir -p "$run_dir" + git clone --filter=blob:none --no-checkout "$(params.repo-url)" "$run_dir/repo" + cd "$run_dir/repo" + git fetch --depth=1 origin "$(params.desired-ref)" + actual="$(git rev-parse FETCH_HEAD)" + expected="$(params.deploy-commit)" + if [ "$actual" != "$expected" ]; then + echo "desired ref commit mismatch actual=$actual expected=$expected" >&2 + exit 1 + fi + git show FETCH_HEAD:deploy.json >"$run_dir/deploy.json" + printf '%s\n' "$actual" >"$run_dir/deploy-commit.txt" + - name: namespace-smoke-e2e + image: "$(params.app-image)" + imagePullPolicy: Never + env: + - name: HTTP_PROXY + value: "" + - name: HTTPS_PROXY + value: "" + - name: ALL_PROXY + value: "" + - name: NO_PROXY + value: "localhost,127.0.0.1,::1,kubernetes,kubernetes.default,kubernetes.default.svc,kubernetes.default.svc.cluster.local" + - name: http_proxy + value: "" + - name: https_proxy + value: "" + - name: all_proxy + value: "" + - name: no_proxy + value: "localhost,127.0.0.1,::1,kubernetes,kubernetes.default,kubernetes.default.svc,kubernetes.default.svc.cluster.local" + script: | + #!/usr/bin/env bash + set -euo pipefail + ns="unidesk-ci-e2e-$(params.run-id)" + keep="$(params.keep-namespace)" + run_dir="$(workspaces.source.path)/dev-e2e-$(params.run-id)" + deploy_json="$run_dir/deploy.json" + result_json="$run_dir/dev-e2e-result.json" + kube_api="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT_HTTPS}" + kube_token="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" + kube_ca="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + kube() { + local method="$1" + shift + curl -fsS --cacert "$kube_ca" -H "Authorization: Bearer $kube_token" -X "$method" "$@" + } + delete_path() { + local path="$1" + local code + code="$(curl -sS -o /tmp/unidesk-dev-e2e-delete-response -w "%{http_code}" --cacert "$kube_ca" -H "Authorization: Bearer $kube_token" -X DELETE "$kube_api/$path")" + if [ "$code" = "200" ] || [ "$code" = "202" ] || [ "$code" = "404" ]; then + return 0 + fi + cat /tmp/unidesk-dev-e2e-delete-response >&2 + return 1 + } + cleanup() { + if [ "$keep" = "true" ]; then + echo "dev_e2e_namespace_retained=$ns" + return 0 + fi + delete_path "api/v1/namespaces/$ns" || true + echo "dev_e2e_namespace_deleted=$ns" + } + trap cleanup EXIT + + bun - "$deploy_json" "$run_dir/dev-e2e-service-commits.env" "$(params.environment)" <<'BUN' + const [deployPath, outPath, environment] = process.argv.slice(2); + const manifest = await Bun.file(deployPath).json(); + if (!manifest || manifest.schemaVersion !== 2 || !manifest.environments?.[environment]) { + throw new Error(`deploy.json must contain schemaVersion=2 environments.${environment}`); + } + const services = manifest.environments[environment].services; + if (!Array.isArray(services) || services.length === 0) { + throw new Error(`deploy.json environments.${environment}.services must contain services`); + } + const lines = []; + for (const service of services) { + if (!service?.id || !service?.commitId || !service?.repo) { + throw new Error(`each deploy.json environments.${environment} service must contain id, repo and commitId`); + } + const key = String(service.id).toUpperCase().replace(/[^A-Z0-9]/g, "_"); + lines.push(`${key}_COMMIT=${service.commitId}`); + } + await Bun.write(outPath, lines.join("\n") + "\n"); + console.log(JSON.stringify({ ok: true, environment, services: services.map((service) => ({ id: service.id, commitId: service.commitId })) })); + BUN + # shellcheck disable=SC1091 + source "$run_dir/dev-e2e-service-commits.env" + backend_commit="${BACKEND_CORE_COMMIT:-unknown}" + frontend_commit="${FRONTEND_COMMIT:-unknown}" + code_queue_commit="${CODE_QUEUE_COMMIT:-unknown}" + + delete_path "api/v1/namespaces/$ns" + cat >/tmp/dev-e2e-namespace.yaml <<YAML + apiVersion: v1 + kind: Namespace + metadata: + name: $ns + labels: + app.kubernetes.io/part-of: unidesk + unidesk.ai/purpose: ci-dev-e2e + unidesk.ai/deploy-ref: master-deploy-json-dev + YAML + kube PATCH \ + -H "Content-Type: application/apply-patch+yaml" \ + --data-binary @/tmp/dev-e2e-namespace.yaml \ + "$kube_api/api/v1/namespaces/$ns?fieldManager=unidesk-ci&force=true" >/dev/null + + deploy_json_b64="$(base64 -w0 "$deploy_json")" + cat >/tmp/dev-e2e-configmap.yaml <<YAML + apiVersion: v1 + kind: ConfigMap + metadata: + name: desired-manifest + namespace: $ns + labels: + app.kubernetes.io/name: unidesk-dev-namespace-e2e + app.kubernetes.io/part-of: unidesk + data: + deployCommit: "$(params.deploy-commit)" + environment: "$(params.environment)" + backendCoreCommit: "$backend_commit" + frontendCommit: "$frontend_commit" + codeQueueCommit: "$code_queue_commit" + deploy.json.b64: "$deploy_json_b64" + YAML + kube PATCH \ + -H "Content-Type: application/apply-patch+yaml" \ + --data-binary @/tmp/dev-e2e-configmap.yaml \ + "$kube_api/api/v1/namespaces/$ns/configmaps/desired-manifest?fieldManager=unidesk-ci&force=true" >/dev/null + + cat >/tmp/dev-e2e-target.yaml <<YAML + apiVersion: apps/v1 + kind: Deployment + metadata: + name: dev-e2e-target + namespace: $ns + labels: + app.kubernetes.io/name: unidesk-dev-namespace-e2e + app.kubernetes.io/component: smoke-target + app.kubernetes.io/part-of: unidesk + spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: unidesk-dev-namespace-e2e + app.kubernetes.io/component: smoke-target + template: + metadata: + labels: + app.kubernetes.io/name: unidesk-dev-namespace-e2e + app.kubernetes.io/component: smoke-target + app.kubernetes.io/part-of: unidesk + spec: + nodeSelector: + unidesk.ai/node-id: D601 + terminationGracePeriodSeconds: 5 + containers: + - name: smoke-target + image: "$(params.app-image)" + imagePullPolicy: Never + command: + - bun + - -e + - | + const port = Number(process.env.PORT || 8080); + const payload = { + ok: true, + environment: "dev", + namespace: process.env.CI_E2E_NAMESPACE, + deployCommit: process.env.CI_E2E_DEPLOY_COMMIT, + backendCoreCommit: process.env.BACKEND_CORE_COMMIT, + frontendCommit: process.env.FRONTEND_COMMIT, + codeQueueCommit: process.env.CODE_QUEUE_COMMIT + }; + Bun.serve({ + hostname: "0.0.0.0", + port, + fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/health" || url.pathname === "/") { + return Response.json(payload); + } + return new Response("not found", { status: 404 }); + } + }); + console.log(JSON.stringify({ listening: port, ...payload })); + await new Promise(() => {}); + ports: + - name: http + containerPort: 8080 + env: + - name: PORT + value: "8080" + - name: CI_E2E_NAMESPACE + value: "$ns" + - name: CI_E2E_DEPLOY_COMMIT + value: "$(params.deploy-commit)" + - name: BACKEND_CORE_COMMIT + value: "$backend_commit" + - name: FRONTEND_COMMIT + value: "$frontend_commit" + - name: CODE_QUEUE_COMMIT + value: "$code_queue_commit" + readinessProbe: + httpGet: + path: /health + port: http + periodSeconds: 3 + timeoutSeconds: 2 + failureThreshold: 20 + resources: + requests: + cpu: 20m + memory: 64Mi + limits: + memory: 256Mi + --- + apiVersion: v1 + kind: Service + metadata: + name: dev-e2e-target + namespace: $ns + labels: + app.kubernetes.io/name: unidesk-dev-namespace-e2e + app.kubernetes.io/component: smoke-target + app.kubernetes.io/part-of: unidesk + spec: + type: ClusterIP + selector: + app.kubernetes.io/name: unidesk-dev-namespace-e2e + app.kubernetes.io/component: smoke-target + ports: + - name: http + port: 8080 + targetPort: http + YAML + csplit -s -f /tmp/dev-e2e-target- /tmp/dev-e2e-target.yaml '/^---$/' '{*}' + kube PATCH \ + -H "Content-Type: application/apply-patch+yaml" \ + --data-binary @/tmp/dev-e2e-target-00 \ + "$kube_api/apis/apps/v1/namespaces/$ns/deployments/dev-e2e-target?fieldManager=unidesk-ci&force=true" >/dev/null + kube PATCH \ + -H "Content-Type: application/apply-patch+yaml" \ + --data-binary @/tmp/dev-e2e-target-01 \ + "$kube_api/api/v1/namespaces/$ns/services/dev-e2e-target?fieldManager=unidesk-ci&force=true" >/dev/null + + deadline=$((SECONDS + 180)) + while [ "$SECONDS" -lt "$deadline" ]; do + status="$(kube GET "$kube_api/apis/apps/v1/namespaces/$ns/deployments/dev-e2e-target")" + available="$(printf '%s' "$status" | jq -r '.status.availableReplicas // 0')" + observed="$(printf '%s' "$status" | jq -r '.status.observedGeneration // 0')" + generation="$(printf '%s' "$status" | jq -r '.metadata.generation // 0')" + if [ "$available" -ge 1 ] && [ "$observed" -ge "$generation" ]; then + echo "dev_e2e_target_rollout=available namespace=$ns" + break + fi + sleep 2 + done + if [ "$SECONDS" -ge "$deadline" ]; then + echo "dev_e2e_target_rollout=timeout namespace=$ns" >&2 + kube GET "$kube_api/apis/apps/v1/namespaces/$ns/deployments/dev-e2e-target" >&2 + exit 1 + fi + + bun - "$ns" "$(params.deploy-commit)" "$backend_commit" "$frontend_commit" "$code_queue_commit" "$result_json" <<'BUN' + const [ns, deployCommit, backendCommit, frontendCommit, codeQueueCommit, resultPath] = process.argv.slice(2); + const url = `http://dev-e2e-target.${ns}.svc.cluster.local:8080/health`; + const started = performance.now(); + const response = await fetch(url); + const elapsedMs = Math.round(performance.now() - started); + const body = await response.json(); + const checks = [ + response.ok, + body.ok === true, + body.environment === "dev", + body.namespace === ns, + body.deployCommit === deployCommit, + body.backendCoreCommit === backendCommit, + body.frontendCommit === frontendCommit, + body.codeQueueCommit === codeQueueCommit + ]; + const result = { ok: checks.every(Boolean), elapsedMs, url, body }; + await Bun.write(resultPath, JSON.stringify(result, null, 2) + "\n"); + console.log(JSON.stringify(result)); + if (!result.ok) process.exit(1); + BUN +--- +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: unidesk-dev-namespace-e2e + namespace: unidesk-ci + labels: + app.kubernetes.io/name: unidesk-ci + app.kubernetes.io/component: dev-namespace-e2e + app.kubernetes.io/part-of: unidesk +spec: + params: + - name: repo-url + type: string + default: https://github.com/pikasTech/unidesk + - name: desired-ref + type: string + default: master + - name: deploy-commit + type: string + - name: environment + type: string + default: dev + - name: run-id + type: string + - name: keep-namespace + type: string + default: "false" + - name: app-image + type: string + default: unidesk-code-queue:dev + workspaces: + - name: shared-workspace + tasks: + - name: dev-namespace-e2e + taskRef: + name: unidesk-dev-namespace-e2e + params: + - name: repo-url + value: "$(params.repo-url)" + - name: desired-ref + value: "$(params.desired-ref)" + - name: deploy-commit + value: "$(params.deploy-commit)" + - name: environment + value: "$(params.environment)" + - name: run-id + value: "$(params.run-id)" + - name: keep-namespace + value: "$(params.keep-namespace)" + - name: app-image + value: "$(params.app-image)" + workspaces: + - name: source + workspace: shared-workspace +--- +apiVersion: tekton.dev/v1 kind: Pipeline metadata: name: unidesk-ci @@ -604,10 +1044,10 @@ spec: type: string - name: check-image type: string - default: unidesk-code-queue:d601 + default: unidesk-code-queue:dev - name: code-queue-image type: string - default: unidesk-code-queue:d601 + default: unidesk-code-queue:dev workspaces: - name: shared-workspace tasks: diff --git a/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml b/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml index b5467837..eef072bb 100644 --- a/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml +++ b/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml @@ -155,8 +155,8 @@ metadata: unidesk.ai/deployment-mode: k3sctl-managed unidesk.ai/deploy-service-id: code-queue annotations: - unidesk.ai/deploy-ref: origin/deploy/dev - unidesk.ai/image-source: deploy-dev-commit + unidesk.ai/deploy-ref: origin/master:deploy.json#environments.dev + unidesk.ai/image-source: deploy-env-commit spec: replicas: 1 strategy: @@ -205,9 +205,9 @@ spec: - name: UNIDESK_DEPLOY_REPO value: https://github.com/pikasTech/unidesk - name: UNIDESK_DEPLOY_COMMIT - value: replace-with-deploy-dev-commit + value: replace-with-deploy-env-commit - name: UNIDESK_DEPLOY_REQUESTED_COMMIT - value: replace-with-deploy-dev-commit + value: replace-with-deploy-env-commit volumeMounts: - name: script mountPath: /etc/unidesk-provider-egress @@ -250,8 +250,8 @@ metadata: unidesk.ai/deployment-mode: k3sctl-managed unidesk.ai/deploy-service-id: code-queue annotations: - unidesk.ai/deploy-ref: origin/deploy/dev - unidesk.ai/image-source: deploy-dev-commit + unidesk.ai/deploy-ref: origin/master:deploy.json#environments.dev + unidesk.ai/image-source: deploy-env-commit spec: replicas: 1 strategy: @@ -306,13 +306,13 @@ spec: - name: UNIDESK_DEPLOY_REPO value: https://github.com/pikasTech/unidesk - name: UNIDESK_DEPLOY_COMMIT - value: replace-with-deploy-dev-commit + value: replace-with-deploy-env-commit - name: UNIDESK_DEPLOY_REQUESTED_COMMIT - value: replace-with-deploy-dev-commit + value: replace-with-deploy-env-commit - name: CODE_QUEUE_DEPLOY_COMMIT - value: replace-with-deploy-dev-commit + value: replace-with-deploy-env-commit - name: CODE_QUEUE_DEPLOY_REQUESTED_COMMIT - value: replace-with-deploy-dev-commit + value: replace-with-deploy-env-commit - name: CODE_QUEUE_INSTANCE_ID value: D601-dev - name: CODE_QUEUE_SERVICE_ROLE @@ -460,8 +460,8 @@ metadata: unidesk.ai/deployment-mode: k3sctl-managed unidesk.ai/deploy-service-id: code-queue annotations: - unidesk.ai/deploy-ref: origin/deploy/dev - unidesk.ai/image-source: deploy-dev-commit + unidesk.ai/deploy-ref: origin/master:deploy.json#environments.dev + unidesk.ai/image-source: deploy-env-commit spec: replicas: 1 selector: @@ -514,13 +514,13 @@ spec: - name: UNIDESK_DEPLOY_REPO value: https://github.com/pikasTech/unidesk - name: UNIDESK_DEPLOY_COMMIT - value: replace-with-deploy-dev-commit + value: replace-with-deploy-env-commit - name: UNIDESK_DEPLOY_REQUESTED_COMMIT - value: replace-with-deploy-dev-commit + value: replace-with-deploy-env-commit - name: CODE_QUEUE_DEPLOY_COMMIT - value: replace-with-deploy-dev-commit + value: replace-with-deploy-env-commit - name: CODE_QUEUE_DEPLOY_REQUESTED_COMMIT - value: replace-with-deploy-dev-commit + value: replace-with-deploy-env-commit - name: CODE_QUEUE_INSTANCE_ID value: D601-dev-read - name: CODE_QUEUE_SERVICE_ROLE @@ -603,8 +603,8 @@ metadata: unidesk.ai/deployment-mode: k3sctl-managed unidesk.ai/deploy-service-id: code-queue annotations: - unidesk.ai/deploy-ref: origin/deploy/dev - unidesk.ai/image-source: deploy-dev-commit + unidesk.ai/deploy-ref: origin/master:deploy.json#environments.dev + unidesk.ai/image-source: deploy-env-commit spec: replicas: 1 selector: @@ -657,13 +657,13 @@ spec: - name: UNIDESK_DEPLOY_REPO value: https://github.com/pikasTech/unidesk - name: UNIDESK_DEPLOY_COMMIT - value: replace-with-deploy-dev-commit + value: replace-with-deploy-env-commit - name: UNIDESK_DEPLOY_REQUESTED_COMMIT - value: replace-with-deploy-dev-commit + value: replace-with-deploy-env-commit - name: CODE_QUEUE_DEPLOY_COMMIT - value: replace-with-deploy-dev-commit + value: replace-with-deploy-env-commit - name: CODE_QUEUE_DEPLOY_REQUESTED_COMMIT - value: replace-with-deploy-dev-commit + value: replace-with-deploy-env-commit - name: CODE_QUEUE_INSTANCE_ID value: D601-dev-write - name: CODE_QUEUE_SERVICE_ROLE diff --git a/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml b/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml index 35432365..e21eda67 100644 --- a/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml +++ b/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml @@ -32,8 +32,8 @@ metadata: app.kubernetes.io/part-of: unidesk unidesk.ai/environment: dev annotations: - unidesk.ai/deploy-ref: origin/deploy/dev - unidesk.ai/image-source: deploy-dev-commit + unidesk.ai/deploy-ref: origin/master:deploy.json#environments.dev + unidesk.ai/image-source: deploy-env-commit unidesk.ai/deploy-service-id: backend-core spec: replicas: 1 @@ -105,9 +105,9 @@ spec: - name: UNIDESK_DEPLOY_REPO value: https://github.com/pikasTech/unidesk - name: UNIDESK_DEPLOY_COMMIT - value: replace-with-deploy-dev-commit + value: replace-with-deploy-env-commit - name: UNIDESK_DEPLOY_REQUESTED_COMMIT - value: replace-with-deploy-dev-commit + value: replace-with-deploy-env-commit - name: LOG_FILE value: /var/log/unidesk-dev/backend-core-dev.jsonl volumeMounts: @@ -175,8 +175,8 @@ metadata: app.kubernetes.io/part-of: unidesk unidesk.ai/environment: dev annotations: - unidesk.ai/deploy-ref: origin/deploy/dev - unidesk.ai/image-source: deploy-dev-commit + unidesk.ai/deploy-ref: origin/master:deploy.json#environments.dev + unidesk.ai/image-source: deploy-env-commit unidesk.ai/deploy-service-id: frontend spec: replicas: 1 @@ -250,9 +250,9 @@ spec: - name: UNIDESK_DEPLOY_REPO value: https://github.com/pikasTech/unidesk - name: UNIDESK_DEPLOY_COMMIT - value: replace-with-deploy-dev-commit + value: replace-with-deploy-env-commit - name: UNIDESK_DEPLOY_REQUESTED_COMMIT - value: replace-with-deploy-dev-commit + value: replace-with-deploy-env-commit - name: LOG_FILE value: /var/log/unidesk-dev/frontend-dev.jsonl volumeMounts: diff --git a/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml b/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml index ec9b2609..eae87c3c 100644 --- a/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml +++ b/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml @@ -82,7 +82,7 @@ metadata: data: UNIDESK_ENV: dev UNIDESK_NAMESPACE: unidesk-dev - UNIDESK_DEPLOY_REF: origin/deploy/dev + UNIDESK_DEPLOY_REF: origin/master:deploy.json#environments.dev UNIDESK_PROVIDER_ID: D601-dev UNIDESK_NODE_ID: D601 UNIDESK_DEV_DATABASE_NAME: unidesk_dev @@ -177,7 +177,7 @@ data: ('namespace', 'unidesk-dev', now()), ('database', 'unidesk_dev', now()), ('provider_id', 'D601-dev', now()), - ('deploy_ref', 'origin/deploy/dev', now()) + ('deploy_ref', 'origin/master:deploy.json#environments.dev', now()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now(); CREATE TABLE IF NOT EXISTS unidesk_nodes ( diff --git a/src/components/microservices/k3sctl-adapter/k3s/devops.k3s.json b/src/components/microservices/k3sctl-adapter/k3s/devops.k3s.json new file mode 100644 index 00000000..2103acab --- /dev/null +++ b/src/components/microservices/k3sctl-adapter/k3s/devops.k3s.json @@ -0,0 +1,37 @@ +{ + "apiVersion": "unidesk.ai/k3s/v1", + "kind": "ManagedKubernetesService", + "metadata": { + "name": "devops", + "namespace": "unidesk-ci" + }, + "spec": { + "adapterServiceId": "k3sctl-adapter", + "controlPlane": { + "type": "kubernetes", + "cluster": "unidesk-k3s", + "context": "unidesk-k3s" + }, + "route": { + "kind": "kubernetes-service", + "serviceName": "devops", + "servicePort": 4286 + }, + "activeInstanceId": "D601", + "singleWriter": true, + "expectedNodeIds": [ + "D601" + ], + "instances": [ + { + "id": "D601", + "nodeId": "D601", + "role": "primary", + "baseUrl": "kubernetes://unidesk-ci/services/devops:4286", + "healthPath": "/health", + "healthMode": "service-proxy" + } + ], + "requireAllInstancesHealthy": true + } +} diff --git a/src/components/microservices/k3sctl-adapter/k3s/devops.k8s.yaml b/src/components/microservices/k3sctl-adapter/k3s/devops.k8s.yaml new file mode 100644 index 00000000..1e420d44 --- /dev/null +++ b/src/components/microservices/k3sctl-adapter/k3s/devops.k8s.yaml @@ -0,0 +1,171 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: devops + namespace: unidesk-ci + labels: + app.kubernetes.io/name: devops + app.kubernetes.io/part-of: unidesk +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: devops + namespace: unidesk-ci + labels: + app.kubernetes.io/name: devops + app.kubernetes.io/part-of: unidesk +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["pods/log"] + verbs: ["get", "list"] + - apiGroups: ["tekton.dev"] + resources: ["pipelines", "tasks", "taskruns"] + verbs: ["get", "list", "watch"] + - apiGroups: ["tekton.dev"] + resources: ["pipelineruns"] + verbs: ["get", "list", "watch", "create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: devops + namespace: unidesk-ci + labels: + app.kubernetes.io/name: devops + app.kubernetes.io/part-of: unidesk +subjects: + - kind: ServiceAccount + name: devops + namespace: unidesk-ci +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: devops +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: devops + namespace: unidesk-ci + labels: + app.kubernetes.io/name: devops + app.kubernetes.io/part-of: unidesk + unidesk.ai/deployment-mode: k3sctl-managed + unidesk.ai/instance-id: D601 + unidesk.ai/deploy-service-id: devops +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: devops + unidesk.ai/instance-id: D601 + template: + metadata: + labels: + app.kubernetes.io/name: devops + app.kubernetes.io/part-of: unidesk + unidesk.ai/deployment-mode: k3sctl-managed + unidesk.ai/instance-id: D601 + unidesk.ai/node-id: D601 + unidesk.ai/deploy-service-id: devops + annotations: + unidesk.ai/deploy-service-id: devops + unidesk.ai/deploy-repo: https://github.com/pikasTech/unidesk + unidesk.ai/deploy-commit: replace-with-deploy-env-commit + unidesk.ai/deploy-requested-commit: replace-with-deploy-env-commit + spec: + serviceAccountName: devops + nodeSelector: + unidesk.ai/node-id: D601 + terminationGracePeriodSeconds: 10 + containers: + - name: devops + image: unidesk-devops:dev-placeholder + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 4286 + env: + - name: HOST + value: "0.0.0.0" + - name: PORT + value: "4286" + - name: DEVOPS_NAMESPACE + value: "unidesk-ci" + - name: DEVOPS_REPO_URL + value: "https://github.com/pikasTech/unidesk" + - name: DEVOPS_DESIRED_REF + value: "master" + - name: DEVOPS_ENVIRONMENT + value: "dev" + - name: UNIDESK_DEPLOY_SERVICE_ID + value: "devops" + - name: UNIDESK_DEPLOY_REPO + value: "https://github.com/pikasTech/unidesk" + - name: UNIDESK_DEPLOY_COMMIT + value: replace-with-deploy-env-commit + - name: UNIDESK_DEPLOY_REQUESTED_COMMIT + value: replace-with-deploy-env-commit + - name: DEVOPS_GIT_PROXY_URL + value: "http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789" + - name: DEVOPS_DEV_E2E_PIPELINE + value: "unidesk-dev-namespace-e2e" + - name: DEVOPS_PIPELINE_SERVICE_ACCOUNT + value: "unidesk-ci-runner" + - name: DEVOPS_PIPELINE_WORKSPACE_CLAIM + value: "unidesk-ci-cache" + - name: DEVOPS_DEV_E2E_APP_IMAGE + value: "unidesk-code-queue:dev" + - name: LOG_FILE + value: "/var/log/unidesk/devops.jsonl" + volumeMounts: + - name: logs + mountPath: /var/log/unidesk + readinessProbe: + httpGet: + path: /health + port: http + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 12 + livenessProbe: + httpGet: + path: /live + port: http + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 6 + resources: + requests: + cpu: 20m + memory: 48Mi + limits: + memory: 160Mi + volumes: + - name: logs + hostPath: + path: /home/ubuntu/.unidesk/devops-deploy/logs + type: DirectoryOrCreate +--- +apiVersion: v1 +kind: Service +metadata: + name: devops + namespace: unidesk-ci + labels: + app.kubernetes.io/name: devops + app.kubernetes.io/part-of: unidesk + unidesk.ai/deployment-mode: k3sctl-managed +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: devops + unidesk.ai/instance-id: D601 + ports: + - name: http + port: 4286 + targetPort: http diff --git a/src/components/microservices/k3sctl-adapter/src/index.ts b/src/components/microservices/k3sctl-adapter/src/index.ts index 91dd0839..29abe0fc 100644 --- a/src/components/microservices/k3sctl-adapter/src/index.ts +++ b/src/components/microservices/k3sctl-adapter/src/index.ts @@ -274,7 +274,7 @@ function mergeServices(services: ManagedService[]): ManagedService[] { } function readConfig(): RuntimeConfig { - const paths = manifestPaths(envString("K3SCTL_MANIFEST_PATHS", "k3s/code-queue.k3s.json,k3s/mdtodo.k3s.json,k3s/claudeqq.k3s.json,k3s/decision-center.k3s.json")); + const paths = manifestPaths(envString("K3SCTL_MANIFEST_PATHS", "k3s/code-queue.k3s.json,k3s/mdtodo.k3s.json,k3s/claudeqq.k3s.json,k3s/decision-center.k3s.json,k3s/devops.k3s.json")); const inlineServices = parseServices(envString("K3SCTL_SERVICES_JSON", "[]")); const manifestServices = readManifestServices(paths); return {