mirror of
https://github.com/cita-777/metapi.git
synced 2026-10-31 03:02:19 +08:00
[codex] add K3s update center (#314)
* add k3s update center * fix update center status timeouts * Polish update center settings UI * Document k3s update center * Refocus update center docs for users * Clarify k3s migration path in docs
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: metapi-deploy-helper
|
||||
namespace: ai
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: metapi-deploy-helper-admin
|
||||
namespace: ai
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: metapi-deploy-helper
|
||||
namespace: ai
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: admin
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: metapi-deploy-helper-auth
|
||||
namespace: ai
|
||||
type: Opaque
|
||||
stringData:
|
||||
DEPLOY_HELPER_TOKEN: replace-with-a-shared-long-random-token
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: metapi-deploy-helper
|
||||
namespace: ai
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: metapi-deploy-helper
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: metapi-deploy-helper
|
||||
spec:
|
||||
serviceAccountName: metapi-deploy-helper
|
||||
containers:
|
||||
- name: helper
|
||||
image: 1467078763/metapi:latest
|
||||
imagePullPolicy: Always
|
||||
command: ["npm", "run", "start:deploy-helper"]
|
||||
ports:
|
||||
- containerPort: 9850
|
||||
name: http
|
||||
env:
|
||||
- name: DEPLOY_HELPER_HOST
|
||||
value: 0.0.0.0
|
||||
- name: DEPLOY_HELPER_PORT
|
||||
value: "9850"
|
||||
- name: DEPLOY_HELPER_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: metapi-deploy-helper-auth
|
||||
key: DEPLOY_HELPER_TOKEN
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 20
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: metapi-deploy-helper
|
||||
namespace: ai
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: metapi-deploy-helper
|
||||
ports:
|
||||
- name: http
|
||||
port: 9850
|
||||
targetPort: http
|
||||
@@ -21,6 +21,21 @@ FROM node:22-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG KUBECTL_VERSION=v1.31.8
|
||||
ARG HELM_VERSION=v3.18.6
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl tar gzip \
|
||||
&& case "$TARGETARCH" in amd64|arm64) export ARCH="$TARGETARCH" ;; *) echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; esac \
|
||||
&& curl -fsSL -o /usr/local/bin/kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl" \
|
||||
&& chmod +x /usr/local/bin/kubectl \
|
||||
&& curl -fsSL "https://get.helm.sh/helm-${HELM_VERSION}-linux-${ARCH}.tar.gz" -o /tmp/helm.tgz \
|
||||
&& tar -xzf /tmp/helm.tgz -C /tmp \
|
||||
&& mv "/tmp/linux-${ARCH}/helm" /usr/local/bin/helm \
|
||||
&& chmod +x /usr/local/bin/helm \
|
||||
&& rm -rf /tmp/helm.tgz "/tmp/linux-${ARCH}" /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
|
||||
@@ -76,6 +76,7 @@ export default withMermaid(
|
||||
items: [
|
||||
{ text: '上游接入', link: '/upstream-integration' },
|
||||
{ text: '配置说明', link: '/configuration' },
|
||||
{ text: 'K3s 更新中心(高级)', link: '/k3s-update-center' },
|
||||
{ text: '客户端接入', link: '/client-integration' },
|
||||
{ text: '运维手册', link: '/operations' },
|
||||
{ text: '常见问题 FAQ', link: '/faq' },
|
||||
|
||||
@@ -30,6 +30,7 @@ npm run docs:build
|
||||
| 新用户部署与首条请求 | [快速上手](./getting-started.md) | 新安装流程、默认端口、首次调用步骤变化时 |
|
||||
| 上游平台选择与接法 | [上游接入](./upstream-integration.md) | 平台支持范围、默认连接分段、自动识别规则变化时 |
|
||||
| 生产部署与回滚 | [部署指南](./deployment.md) | Docker Compose、反向代理、升级回滚策略变更时 |
|
||||
| K3s / Helm 高级升级面板 | [K3s 更新中心(高级)](./k3s-update-center.md) | 需要说明谁适合使用更新中心、helper 怎么配、K3s/Helm 发布链路怎么接入时 |
|
||||
| 环境变量、参数和配置项 | [配置说明](./configuration.md) | 新增配置、默认值变化、兼容行为变化时 |
|
||||
| 客户端与工具接入 | [客户端接入](./client-integration.md) | Open WebUI、Cherry Studio、Cursor 等接入方式变化时 |
|
||||
| 运维排障与日常维护 | [运维手册](./operations.md) / [常见问题](./faq.md) | 新排障案例、备份恢复、健康检查、典型报错变化时 |
|
||||
|
||||
@@ -32,6 +32,49 @@
|
||||
| `SYSTEM_PROXY_URL` | 系统代理 URL(留空表示不使用) | 空 |
|
||||
| `ACCOUNT_CREDENTIAL_SECRET` | 账号凭证加密密钥(用于加密存储的上游账号密码) | 默认使用 `AUTH_TOKEN` |
|
||||
|
||||
## 更新中心与 Deploy Helper
|
||||
|
||||
如果你现在只是一个普通 Docker / Docker Compose 部署,没有 K3s / Kubernetes、没有 Helm release、也没有 Deploy Helper,那么这一节可以先跳过。
|
||||
|
||||
这一节只写给已经准备使用“K3s 更新中心”的用户。除了设置页里的运行时字段,还需要补齐主服务和 helper 的环境变量。
|
||||
|
||||
如果你是老用户,正在从 Docker Compose 迁到 K3s / Helm,这一节可以提前看,先把迁移后的环境变量和组件关系理解清楚。
|
||||
|
||||
### 主 Metapi 服务
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `DEPLOY_HELPER_TOKEN` | 主服务访问 Deploy Helper 的 Bearer Token | 空 |
|
||||
| `UPDATE_CENTER_HELPER_TOKEN` | `DEPLOY_HELPER_TOKEN` 的兼容别名,二选一即可 | 空 |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 主 Metapi 和 Deploy Helper 必须使用同一个 token,否则更新中心可以显示页面但无法完成 helper 状态查询或部署。
|
||||
|
||||
### Deploy Helper 服务
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `DEPLOY_HELPER_HOST` | helper 监听地址 | `0.0.0.0` |
|
||||
| `DEPLOY_HELPER_PORT` | helper 监听端口 | `9850` |
|
||||
| `DEPLOY_HELPER_TOKEN` | helper Bearer Token,必须和主服务一致 | 空 |
|
||||
|
||||
### 设置页里的持久化字段
|
||||
|
||||
更新中心页面本身还会把下面这些字段持久化到运行数据库:
|
||||
|
||||
- `helperBaseUrl`
|
||||
- `namespace`
|
||||
- `releaseName`
|
||||
- `chartRef`
|
||||
- `imageRepository`
|
||||
- `githubReleasesEnabled`
|
||||
- `dockerHubTagsEnabled`
|
||||
- `defaultDeploySource`
|
||||
|
||||
完整接入步骤见:
|
||||
|
||||
- [K3s 更新中心](./k3s-update-center.md)
|
||||
|
||||
## 数据库配置
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|
||||
@@ -17,6 +17,34 @@
|
||||
> - 当前不再提供 `Release` 压缩包 + Node.js 运行时的独立部署路径。
|
||||
> - 生产/长期运行请用 Docker 系列方案;桌面版面向单机本地使用;源码运行请走本地开发流程。
|
||||
|
||||
## K3s / Helm 更新中心
|
||||
|
||||
如果你现在只是一个最普通的 Docker / Docker Compose 部署,请先跳过这节。
|
||||
|
||||
这套能力只适用于:
|
||||
|
||||
- 你已经在 K3s / Kubernetes 中部署了 Metapi
|
||||
- 而且当前 Metapi 是通过 Helm release 管理的
|
||||
|
||||
它不适用于:
|
||||
|
||||
- 只有一个裸 Docker Compose 容器
|
||||
- 想直接从管理后台更新外部 Docker 主机上的容器
|
||||
|
||||
但如果你是老用户,**正在计划从 Docker Compose 迁到 K3s / Helm,以获得滚动更新能力**,那么这一节和对应专题页是值得提前看的。它写的不是“怎么原地升级 Compose”,而是“迁移完成后你会如何使用更新中心”。
|
||||
|
||||
如果你已经通过 Helm 在 K3s / Kubernetes 中部署 Metapi,并希望在管理后台中:
|
||||
|
||||
- 查看当前运行版本
|
||||
- 检查 GitHub Releases / Docker Hub 的稳定版
|
||||
- 通过集群内 helper 手动触发一次升级
|
||||
|
||||
请直接阅读:
|
||||
|
||||
- [K3s 更新中心](./k3s-update-center.md)
|
||||
|
||||
这页会单独说明 helper 部署、主服务 token 对齐、设置页字段含义、实际升级顺序和已知限制。
|
||||
|
||||
## Zeabur 一键部署
|
||||
|
||||
<a href="https://zeabur.com/templates/DOX5PR">
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
# ☸️ K3s 更新中心(高级)
|
||||
|
||||
[返回部署指南](./deployment.md) · [返回配置说明](./configuration.md) · [返回文档中心](./README.md)
|
||||
|
||||
---
|
||||
|
||||
这页写给已经把 Metapi 部署到 **K3s / Kubernetes + Helm** 的用户。
|
||||
|
||||
如果你现在的环境只是一个最普通的 **Docker Compose 安装**,只有一个 Metapi 容器,其他什么都没有,那么先记住一句话:
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **如果你只是想原地给现有 Compose 容器加一个“后台点按钮更新”的能力,那你现在大概率用不上这页。**
|
||||
>
|
||||
> 当前“更新中心”不会直接帮你更新一台普通 Docker 主机上的 Compose 容器。它依赖集群里的 Deploy Helper 去执行 `helm upgrade` 和 `kubectl rollout status`,所以只适用于已经有 K3s / Kubernetes / Helm 的环境。
|
||||
>
|
||||
> 但如果你是老用户,正打算 **从 Docker Compose 迁到 K3s / Helm,以获得滚动更新能力**,那这页仍然值得看,因为它描述的就是迁移完成后的目标形态。
|
||||
|
||||
## 先判断:你需不需要看这页
|
||||
|
||||
| 你的现状 | 要不要看这页 | 你现在该怎么做 |
|
||||
|------|------------|--------------|
|
||||
| 只有一个 `docker compose up -d` 跑起来的 Metapi 容器 | 不需要 | 继续用普通 Docker 升级方式 |
|
||||
| 目前是 Docker Compose,准备迁到 K3s / Helm 来获得滚动更新 | 需要 | 把这页当成迁移后的目标架构说明,先完成迁移,再启用更新中心 |
|
||||
| 有 K3s / Kubernetes 集群,但 Metapi 不是用 Helm release 部署的 | 暂时不建议 | 这套更新中心无法直接接管现有部署 |
|
||||
| Metapi 已经是 Helm release,想在后台里看版本并手动点一次升级 | 需要 | 继续看下文 |
|
||||
|
||||
## 如果你现在只有一个 Docker Compose 容器
|
||||
|
||||
这就是大多数用户的真实情况。
|
||||
|
||||
对这类用户来说,当前版本最实用的升级路径仍然是普通 Docker 流程:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
如果你还想更稳一点,可以先看镜像版本或仓库 release,再决定要不要拉新镜像。
|
||||
|
||||
你现在**不需要额外准备**这些东西:
|
||||
|
||||
- K3s / Kubernetes 集群
|
||||
- Helm
|
||||
- Deploy Helper
|
||||
- 额外的 Bearer Token
|
||||
- 集群内 Service 地址
|
||||
|
||||
也就是说,除非你本来就在用 K3s / Helm,否则这页可以直接跳过。
|
||||
|
||||
## 如果你正准备从 Docker Compose 迁到 K3s
|
||||
|
||||
这其实是一个很合理的升级方向。
|
||||
|
||||
很多老用户想迁到 K3s / Helm,核心诉求通常不是“为了多一个页面”,而是为了获得这些能力:
|
||||
|
||||
- 发布时尽量减少停机时间
|
||||
- 用 Helm 管理版本和回滚
|
||||
- 后续在后台里看版本来源,并人工触发一次升级
|
||||
|
||||
对这类用户来说,这页不是“现在立刻就能用”的操作手册,而是:
|
||||
|
||||
- “迁移完成后,你会得到什么能力”
|
||||
- “为了用上这个能力,集群侧要提前满足什么前提”
|
||||
|
||||
### 推荐的迁移顺序
|
||||
|
||||
如果你现在还是 Docker Compose 用户,建议按下面的顺序规划:
|
||||
|
||||
1. 先备份当前实例数据。
|
||||
- 如果当前运行库还是 SQLite,先备份 `data/` 目录。
|
||||
- 具体备份方式见 [运维手册](./operations.md)。
|
||||
2. 评估迁移后的运行数据库。
|
||||
- 如果你迁到 K3s 只是想“单副本 + 更规范部署”,SQLite 仍然可以先用。
|
||||
- 如果你更看重后续可维护性、滚动发布体验和外部持久化,通常更建议切到 MySQL / PostgreSQL。
|
||||
3. 在新环境里先把 Metapi 作为 Helm release 跑起来。
|
||||
4. 确认新实例可正常登录、数据正常、代理请求正常。
|
||||
5. 再部署 Deploy Helper,并启用更新中心。
|
||||
|
||||
### 一个很重要的认知
|
||||
|
||||
“迁到 K3s” 和 “启用更新中心” 是两件相关但不同的事:
|
||||
|
||||
- 迁到 K3s / Helm
|
||||
- 是部署形态升级
|
||||
- 启用更新中心
|
||||
- 是迁移完成后,额外获得的一个后台升级入口
|
||||
|
||||
所以更准确的理解应该是:
|
||||
|
||||
- **先把 Docker Compose 用户迁成 Helm 用户,再谈更新中心。**
|
||||
|
||||
## 那这个功能到底是干什么的
|
||||
|
||||
对已经在 K3s / Kubernetes 里跑 Metapi 的用户,这个“更新中心”主要解决的是:
|
||||
|
||||
- 在后台里看当前运行版本
|
||||
- 看官方 GitHub Releases / Docker Hub 有没有新的稳定版
|
||||
- 确认 Deploy Helper 是否健康
|
||||
- 点一次按钮,触发一轮 Helm 升级
|
||||
- 在页面里回看部署日志
|
||||
|
||||
它的定位更接近:
|
||||
|
||||
- “集群内人工触发升级面板”
|
||||
|
||||
而不是:
|
||||
|
||||
- “单容器自动升级器”
|
||||
- “Docker Compose 一键更新”
|
||||
- “无人值守自动发布系统”
|
||||
|
||||
## 使用它之前,你至少要已经有这些东西
|
||||
|
||||
- 一个可用的 K3s / Kubernetes 集群
|
||||
- 用 Helm 部署的 Metapi release
|
||||
- 你的 chart 支持下面两个 values:
|
||||
- `image.repository`
|
||||
- `image.tag`
|
||||
- 目标 Deployment 带有 `app.kubernetes.io/instance=<releaseName>` 标签
|
||||
- 主 Metapi 服务可以访问:
|
||||
- GitHub API
|
||||
- Docker Hub API
|
||||
- Deploy Helper 的集群内地址
|
||||
|
||||
如果上面有任何一项不成立,就先不要配更新中心。
|
||||
|
||||
## helper 是什么
|
||||
|
||||
Deploy Helper 是一个跑在集群里的小服务。它不负责对外提供 Metapi 功能,只负责接受主 Metapi 发来的部署请求,然后在集群里执行:
|
||||
|
||||
- `helm history`
|
||||
- `helm upgrade`
|
||||
- `kubectl rollout status`
|
||||
- 必要时 `helm rollback`
|
||||
|
||||
所以你可以把它理解成:
|
||||
|
||||
- “一个专门替管理后台执行 Helm/Kubectl 的集群内代理”
|
||||
|
||||
## 什么时候你才应该去配 helper
|
||||
|
||||
只有当你已经满足下面这个目标时,才值得去配:
|
||||
|
||||
- “我已经在 K3s / Helm 里跑 Metapi 了,现在想在后台里看版本,并且人工点一下完成升级”
|
||||
|
||||
如果你现在只是:
|
||||
|
||||
- “我有一个 Compose 容器,想在后台里点按钮更新它”
|
||||
|
||||
那答案是:
|
||||
|
||||
- 当前版本还不支持这个场景。
|
||||
|
||||
如果你现在是:
|
||||
|
||||
- “我正在把 Compose 老实例迁到 K3s,希望迁完以后用后台来做后续版本升级”
|
||||
|
||||
那答案是:
|
||||
|
||||
- 这是一个合理场景,这页正是给你看迁移后该怎么接入的。
|
||||
|
||||
## K3s / Helm 用户的接入步骤
|
||||
|
||||
如果你确实已经是 K3s / Helm 用户,接入顺序建议如下。
|
||||
|
||||
### 1. 先部署 Deploy Helper
|
||||
|
||||
仓库里带了一个最小示例清单:
|
||||
|
||||
- `deploy/k3s/metapi-deploy-helper.yaml`
|
||||
|
||||
使用前至少要改这些值:
|
||||
|
||||
- `namespace`
|
||||
- `DEPLOY_HELPER_TOKEN`
|
||||
- `image`
|
||||
|
||||
最小部署命令示例:
|
||||
|
||||
```bash
|
||||
kubectl create namespace ai
|
||||
kubectl apply -f deploy/k3s/metapi-deploy-helper.yaml
|
||||
```
|
||||
|
||||
### 2. 让主 Metapi 和 helper 用同一个 token
|
||||
|
||||
主 Metapi 端支持这两个环境变量,设置一个即可:
|
||||
|
||||
- `DEPLOY_HELPER_TOKEN`
|
||||
- `UPDATE_CENTER_HELPER_TOKEN`
|
||||
|
||||
helper 端使用:
|
||||
|
||||
- `DEPLOY_HELPER_TOKEN`
|
||||
|
||||
它们的值必须完全一致。
|
||||
|
||||
### 3. 在后台“设置 → 更新中心”里填配置
|
||||
|
||||
需要填写的核心字段有:
|
||||
|
||||
| 字段 | 怎么理解 | 典型示例 |
|
||||
|------|--------|----------|
|
||||
| `Deploy Helper URL` | 主 Metapi 访问 helper 的地址 | `http://metapi-deploy-helper.ai.svc.cluster.local:9850` |
|
||||
| `Namespace` | 目标 release 所在命名空间 | `ai` |
|
||||
| `Release Name` | Helm release 名 | `metapi` |
|
||||
| `Chart Ref` | 你的 Helm chart 引用 | `oci://ghcr.io/cita-777/charts/metapi` |
|
||||
| `Image Repository` | 升级时写入 chart 的镜像仓库 | `1467078763/metapi` |
|
||||
| `默认部署来源` | 默认从 GitHub 还是 Docker Hub 取版本 | `GitHub Releases` |
|
||||
|
||||
另外还有 3 个开关:
|
||||
|
||||
- `启用更新中心`
|
||||
- `GitHub Releases`
|
||||
- `Docker Hub`
|
||||
|
||||
### 4. 实际操作顺序
|
||||
|
||||
推荐这样用:
|
||||
|
||||
1. 先保存配置
|
||||
2. 再点“检查更新”
|
||||
3. 确认页面状态都正常:
|
||||
- 当前运行版本正常显示
|
||||
- 版本来源发现了可部署版本
|
||||
- Deploy Helper 显示健康
|
||||
4. 再点部署按钮
|
||||
5. 在页面下方看部署日志
|
||||
|
||||
如果实时日志流断开,页面会自动回退到最近任务快照。
|
||||
|
||||
## 这套能力当前不适合什么场景
|
||||
|
||||
- 只有一台 Docker 主机、一个 Compose 文件、一个 Metapi 容器
|
||||
- 想让后台直接远程更新 Docker Compose 服务
|
||||
- 没有 Helm release,只是手工 apply 的 Deployment
|
||||
- 需要全自动无人值守升级
|
||||
- 需要灰度、分批、审批、回滚编排等更完整的发布系统
|
||||
|
||||
## About 页和更新中心的关系
|
||||
|
||||
About 页里的“更新提醒”更像一个轻量入口:
|
||||
|
||||
- 让你知道大概有没有新版本
|
||||
|
||||
真正的配置和部署入口仍然在:
|
||||
|
||||
- “设置 → 更新中心”
|
||||
|
||||
所以即使你是普通 Docker Compose 用户,也可以把 About 页里的版本提醒当成“看看最近有没有新版本”的地方,但不要把它理解成“马上就能自动更新我这台机器”。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 为什么我在设置页里能看到更新中心,但还是没法用
|
||||
|
||||
因为这个页面会跟着功能一起出现,但真正能否部署,取决于你有没有:
|
||||
|
||||
- K3s / Kubernetes
|
||||
- Helm release
|
||||
- Deploy Helper
|
||||
- 对齐的 token
|
||||
|
||||
缺其中任何一项,都只能看,不能真正部署。
|
||||
|
||||
### 为什么我只有 Docker Compose,按钮没有意义
|
||||
|
||||
因为当前部署链路不是去操作外部 Docker 主机,而是去调用集群里的 helper,然后由 helper 执行 `helm` / `kubectl`。
|
||||
|
||||
### 那普通 Docker Compose 用户现在该怎么升级
|
||||
|
||||
继续使用正常 Docker 流程即可:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
如果你担心直接升级,可以先看仓库 Releases 或镜像 tag,再决定是否升级。
|
||||
|
||||
## 相关入口
|
||||
|
||||
- [部署指南](./deployment.md)
|
||||
- [配置说明](./configuration.md)
|
||||
- [运维手册](./operations.md)
|
||||
@@ -47,6 +47,7 @@
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
|
||||
"start": "node dist/server/index.js",
|
||||
"start:deploy-helper": "node dist/server/update-helper/index.js",
|
||||
"start:desktop": "electron dist/desktop/main.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/server/db/migrate.ts",
|
||||
|
||||
+5
-3
@@ -20,6 +20,7 @@ import { monitorRoutes } from './routes/api/monitor.js';
|
||||
import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js';
|
||||
import { oauthRoutes } from './routes/api/oauth.js';
|
||||
import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
|
||||
import { updateCenterRoutes } from './routes/api/updateCenter.js';
|
||||
import { proxyRoutes } from './routes/proxy/router.js';
|
||||
import { startScheduler } from './services/checkinScheduler.js';
|
||||
import * as routeRefreshWorkflow from './services/routeRefreshWorkflow.js';
|
||||
@@ -439,9 +440,10 @@ await app.register(authRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(accountTokensRoutes);
|
||||
await app.register(searchRoutes);
|
||||
await app.register(eventsRoutes);
|
||||
await app.register(siteAnnouncementsRoutes);
|
||||
await app.register(taskRoutes);
|
||||
await app.register(eventsRoutes);
|
||||
await app.register(siteAnnouncementsRoutes);
|
||||
await app.register(updateCenterRoutes);
|
||||
await app.register(taskRoutes);
|
||||
await app.register(testRoutes);
|
||||
await app.register(monitorRoutes);
|
||||
await app.register(downstreamApiKeysRoutes);
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const {
|
||||
fetchLatestStableGitHubReleaseMock,
|
||||
fetchLatestDockerHubTagMock,
|
||||
getUpdateCenterHelperStatusMock,
|
||||
streamUpdateCenterDeployMock,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchLatestStableGitHubReleaseMock: vi.fn(),
|
||||
fetchLatestDockerHubTagMock: vi.fn(),
|
||||
getUpdateCenterHelperStatusMock: vi.fn(),
|
||||
streamUpdateCenterDeployMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/updateCenterVersionService.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../services/updateCenterVersionService.js')>('../../services/updateCenterVersionService.js');
|
||||
return {
|
||||
...actual,
|
||||
fetchLatestStableGitHubRelease: (...args: unknown[]) => fetchLatestStableGitHubReleaseMock(...args),
|
||||
fetchLatestDockerHubTag: (...args: unknown[]) => fetchLatestDockerHubTagMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/updateCenterHelperClient.js', () => ({
|
||||
getUpdateCenterHelperStatus: (...args: unknown[]) => getUpdateCenterHelperStatusMock(...args),
|
||||
streamUpdateCenterDeploy: (...args: unknown[]) => streamUpdateCenterDeployMock(...args),
|
||||
}));
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
describe('update center routes', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let dataDir = '';
|
||||
let resetBackgroundTasks: (() => void) | null = null;
|
||||
let getBackgroundTask: ((taskId: string) => { status: string; logs?: Array<{ message: string }> } | null) | null = null;
|
||||
|
||||
async function saveValidConfig() {
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/update-center/config',
|
||||
payload: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-update-center-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.DEPLOY_HELPER_TOKEN = 'helper-token';
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
const routesModule = await import('./updateCenter.js');
|
||||
const backgroundTaskModule = await import('../../services/backgroundTaskService.js');
|
||||
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests;
|
||||
getBackgroundTask = backgroundTaskModule.getBackgroundTask;
|
||||
|
||||
app = Fastify();
|
||||
await app.register(routesModule.updateCenterRoutes);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchLatestStableGitHubReleaseMock.mockReset();
|
||||
fetchLatestDockerHubTagMock.mockReset();
|
||||
getUpdateCenterHelperStatusMock.mockReset();
|
||||
streamUpdateCenterDeployMock.mockReset();
|
||||
resetBackgroundTasks?.();
|
||||
|
||||
await db.delete(schema.events).run();
|
||||
await db.delete(schema.settings).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) {
|
||||
await app.close();
|
||||
}
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.DEPLOY_HELPER_TOKEN;
|
||||
});
|
||||
|
||||
it('persists config and returns status with both version channels and helper summary', async () => {
|
||||
fetchLatestStableGitHubReleaseMock.mockResolvedValue({
|
||||
source: 'github-release',
|
||||
rawVersion: 'v1.3.0',
|
||||
normalizedVersion: '1.3.0',
|
||||
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0',
|
||||
});
|
||||
fetchLatestDockerHubTagMock.mockResolvedValue({
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: '1.3.1',
|
||||
normalizedVersion: '1.3.1',
|
||||
url: null,
|
||||
});
|
||||
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
||||
ok: true,
|
||||
releaseName: 'metapi',
|
||||
namespace: 'ai',
|
||||
revision: '12',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageTag: '1.2.3',
|
||||
healthy: true,
|
||||
});
|
||||
|
||||
const saveResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/update-center/config',
|
||||
payload: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
},
|
||||
});
|
||||
|
||||
expect(saveResponse.statusCode).toBe(200);
|
||||
const savedRow = await db.select().from(schema.settings).where(eq(schema.settings.key, 'update_center_k3s_config_v1')).get();
|
||||
expect(savedRow?.value).toContain('metapi-deploy-helper.ai.svc.cluster.local');
|
||||
|
||||
const statusResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/update-center/status',
|
||||
});
|
||||
|
||||
expect(statusResponse.statusCode).toBe(200);
|
||||
expect(statusResponse.json()).toMatchObject({
|
||||
currentVersion: '1.2.3',
|
||||
config: {
|
||||
enabled: true,
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
defaultDeploySource: 'github-release',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: '1.3.1',
|
||||
},
|
||||
helper: {
|
||||
ok: true,
|
||||
healthy: true,
|
||||
releaseName: 'metapi',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns partial status when a single version source lookup fails', async () => {
|
||||
fetchLatestStableGitHubReleaseMock.mockRejectedValue(new Error('GitHub releases lookup timed out'));
|
||||
fetchLatestDockerHubTagMock.mockResolvedValue({
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: '1.3.1',
|
||||
normalizedVersion: '1.3.1',
|
||||
url: null,
|
||||
});
|
||||
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
||||
ok: true,
|
||||
releaseName: 'metapi',
|
||||
namespace: 'ai',
|
||||
revision: '12',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageTag: '1.2.3',
|
||||
healthy: true,
|
||||
});
|
||||
|
||||
await saveValidConfig();
|
||||
|
||||
const statusResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/update-center/status',
|
||||
});
|
||||
|
||||
expect(statusResponse.statusCode).toBe(200);
|
||||
expect(statusResponse.json()).toMatchObject({
|
||||
githubRelease: null,
|
||||
dockerHubTag: {
|
||||
normalizedVersion: '1.3.1',
|
||||
},
|
||||
helper: {
|
||||
ok: true,
|
||||
healthy: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('dedupes deploy requests while a task is already running', async () => {
|
||||
await saveValidConfig();
|
||||
|
||||
let releaseDeploy: (() => void) | null = null;
|
||||
const deployGate = new Promise<void>((resolve) => {
|
||||
releaseDeploy = resolve;
|
||||
});
|
||||
|
||||
streamUpdateCenterDeployMock.mockImplementation(async (_input: unknown, onLog?: (message: string) => void) => {
|
||||
onLog?.('Running helm upgrade');
|
||||
await deployGate;
|
||||
onLog?.('Deployment complete');
|
||||
return {
|
||||
success: true,
|
||||
targetSource: 'github-release',
|
||||
targetTag: '1.3.0',
|
||||
previousRevision: '12',
|
||||
finalRevision: '13',
|
||||
rolledBack: false,
|
||||
logLines: ['Running helm upgrade', 'Deployment complete'],
|
||||
};
|
||||
});
|
||||
|
||||
const firstResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/update-center/deploy',
|
||||
payload: {
|
||||
source: 'github-release',
|
||||
targetVersion: '1.3.0',
|
||||
},
|
||||
});
|
||||
|
||||
const secondResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/update-center/deploy',
|
||||
payload: {
|
||||
source: 'github-release',
|
||||
targetVersion: '1.3.0',
|
||||
},
|
||||
});
|
||||
|
||||
expect(firstResponse.statusCode).toBe(202);
|
||||
expect(secondResponse.statusCode).toBe(202);
|
||||
|
||||
const firstBody = firstResponse.json() as { task?: { id: string }; reused?: boolean };
|
||||
const secondBody = secondResponse.json() as { task?: { id: string }; reused?: boolean };
|
||||
expect(firstBody.task?.id).toBeTruthy();
|
||||
expect(secondBody.task?.id).toBe(firstBody.task?.id);
|
||||
expect(secondBody.reused).toBe(true);
|
||||
|
||||
releaseDeploy?.();
|
||||
});
|
||||
|
||||
it('rejects deploy requests when the update center is disabled', async () => {
|
||||
const saveResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/update-center/config',
|
||||
payload: {
|
||||
enabled: false,
|
||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
},
|
||||
});
|
||||
expect(saveResponse.statusCode).toBe(200);
|
||||
|
||||
const deployResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/update-center/deploy',
|
||||
payload: {
|
||||
source: 'github-release',
|
||||
targetVersion: '1.3.0',
|
||||
},
|
||||
});
|
||||
|
||||
expect(deployResponse.statusCode).toBe(400);
|
||||
expect(deployResponse.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'update center is disabled',
|
||||
});
|
||||
});
|
||||
|
||||
it('streams deployment logs for known tasks and rejects unknown task ids', async () => {
|
||||
await saveValidConfig();
|
||||
|
||||
const missingResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/update-center/tasks/missing-task/stream',
|
||||
});
|
||||
|
||||
expect(missingResponse.statusCode).toBe(404);
|
||||
|
||||
streamUpdateCenterDeployMock.mockImplementation(async (_input: unknown, onLog?: (message: string) => void) => {
|
||||
onLog?.('Resolving target version');
|
||||
onLog?.('Waiting for rollout');
|
||||
return {
|
||||
success: true,
|
||||
targetSource: 'docker-hub-tag',
|
||||
targetTag: '1.3.1',
|
||||
previousRevision: '13',
|
||||
finalRevision: '14',
|
||||
rolledBack: false,
|
||||
logLines: ['Resolving target version', 'Waiting for rollout'],
|
||||
};
|
||||
});
|
||||
|
||||
const deployResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/update-center/deploy',
|
||||
payload: {
|
||||
source: 'docker-hub-tag',
|
||||
targetVersion: '1.3.1',
|
||||
},
|
||||
});
|
||||
|
||||
const deployBody = deployResponse.json() as { task: { id: string } };
|
||||
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const task = getBackgroundTask?.(deployBody.task.id);
|
||||
if (task?.status === 'succeeded') break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
expect(getBackgroundTask?.(deployBody.task.id)?.logs).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ message: 'Resolving target version' }),
|
||||
expect.objectContaining({ message: 'Waiting for rollout' }),
|
||||
]));
|
||||
|
||||
const streamResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/update-center/tasks/${deployBody.task.id}/stream`,
|
||||
});
|
||||
|
||||
expect(streamResponse.statusCode).toBe(200);
|
||||
expect(streamResponse.headers['content-type']).toContain('text/event-stream');
|
||||
expect(streamResponse.body).toContain('event: log');
|
||||
expect(streamResponse.body).toContain('Resolving target version');
|
||||
expect(streamResponse.body).toContain('Waiting for rollout');
|
||||
expect(streamResponse.body).toContain('event: done');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
|
||||
import {
|
||||
appendBackgroundTaskLog,
|
||||
getBackgroundTask,
|
||||
listBackgroundTasks,
|
||||
startBackgroundTask,
|
||||
subscribeToBackgroundTaskLogs,
|
||||
} from '../../services/backgroundTaskService.js';
|
||||
import {
|
||||
fetchLatestDockerHubTag,
|
||||
fetchLatestStableGitHubRelease,
|
||||
getCurrentRuntimeVersion,
|
||||
type UpdateCenterVersionCandidate,
|
||||
type UpdateCenterVersionSource,
|
||||
} from '../../services/updateCenterVersionService.js';
|
||||
import {
|
||||
getDefaultUpdateCenterConfig,
|
||||
loadUpdateCenterConfig,
|
||||
normalizeUpdateCenterConfig,
|
||||
saveUpdateCenterConfig,
|
||||
type UpdateCenterConfig,
|
||||
} from '../../services/updateCenterConfigService.js';
|
||||
import {
|
||||
getUpdateCenterHelperStatus,
|
||||
streamUpdateCenterDeploy,
|
||||
} from '../../services/updateCenterHelperClient.js';
|
||||
|
||||
type DeployBody = {
|
||||
source?: UpdateCenterVersionSource;
|
||||
targetVersion?: string;
|
||||
};
|
||||
|
||||
const UPDATE_CENTER_DEPLOY_TASK_TYPE = 'update-center.deploy';
|
||||
const UPDATE_CENTER_DEPLOY_DEDUPE_KEY = 'update-center.deploy';
|
||||
|
||||
function getUpdateCenterHelperToken(): string {
|
||||
return String(process.env.DEPLOY_HELPER_TOKEN || process.env.UPDATE_CENTER_HELPER_TOKEN || '').trim();
|
||||
}
|
||||
|
||||
function assertDeployableConfig(config: UpdateCenterConfig) {
|
||||
if (!config.enabled) throw new Error('update center is disabled');
|
||||
if (!config.helperBaseUrl) throw new Error('helperBaseUrl is required');
|
||||
if (!config.namespace) throw new Error('namespace is required');
|
||||
if (!config.releaseName) throw new Error('releaseName is required');
|
||||
if (!config.chartRef) throw new Error('chartRef is required');
|
||||
if (!config.imageRepository) throw new Error('imageRepository is required');
|
||||
if (!getUpdateCenterHelperToken()) throw new Error('DEPLOY_HELPER_TOKEN is required');
|
||||
}
|
||||
|
||||
function getDeployTasks() {
|
||||
return listBackgroundTasks(50).filter((task) => task.type === UPDATE_CENTER_DEPLOY_TASK_TYPE);
|
||||
}
|
||||
|
||||
function summarizeHelperError(error: unknown) {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return String(error || 'unknown helper error');
|
||||
}
|
||||
|
||||
async function settleOptional<T>(enabled: boolean, loader: () => Promise<T>): Promise<{
|
||||
value: T | null;
|
||||
error: string | null;
|
||||
}> {
|
||||
if (!enabled) {
|
||||
return {
|
||||
value: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
value: await loader(),
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
value: null,
|
||||
error: summarizeHelperError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function buildUpdateCenterStatus() {
|
||||
const config = await loadUpdateCenterConfig();
|
||||
const helperToken = getUpdateCenterHelperToken();
|
||||
|
||||
const [githubLookup, dockerLookup, helperLookup] = await Promise.all([
|
||||
settleOptional(config.githubReleasesEnabled, async () => await fetchLatestStableGitHubRelease()),
|
||||
settleOptional(config.dockerHubTagsEnabled, async () => await fetchLatestDockerHubTag()),
|
||||
settleOptional(!!config.helperBaseUrl, async () => {
|
||||
if (!helperToken) {
|
||||
throw new Error('DEPLOY_HELPER_TOKEN is required');
|
||||
}
|
||||
return await getUpdateCenterHelperStatus(config, helperToken);
|
||||
}),
|
||||
]);
|
||||
|
||||
const githubRelease = githubLookup.value as UpdateCenterVersionCandidate | null;
|
||||
const dockerHubTag = dockerLookup.value as UpdateCenterVersionCandidate | null;
|
||||
const helper: Record<string, unknown> = helperLookup.value || {
|
||||
ok: false,
|
||||
healthy: false,
|
||||
error: helperLookup.error,
|
||||
};
|
||||
|
||||
const tasks = getDeployTasks();
|
||||
const runningTask = tasks.find((task) => task.status === 'pending' || task.status === 'running') || null;
|
||||
const lastFinishedTask = tasks.find((task) => task.status === 'succeeded' || task.status === 'failed') || null;
|
||||
|
||||
return {
|
||||
currentVersion: getCurrentRuntimeVersion(),
|
||||
config,
|
||||
githubRelease,
|
||||
dockerHubTag,
|
||||
helper,
|
||||
runningTask,
|
||||
lastFinishedTask,
|
||||
};
|
||||
}
|
||||
|
||||
function writeSseEvent(reply: { raw: NodeJS.WritableStream & { write: (chunk: string) => void } }, event: string, data: unknown) {
|
||||
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
export async function updateCenterRoutes(app: FastifyInstance) {
|
||||
app.get('/api/update-center/status', async () => {
|
||||
return await buildUpdateCenterStatus();
|
||||
});
|
||||
|
||||
app.post('/api/update-center/check', async () => {
|
||||
return await buildUpdateCenterStatus();
|
||||
});
|
||||
|
||||
app.put<{ Body: UpdateCenterConfig }>('/api/update-center/config', async (request) => {
|
||||
const next = normalizeUpdateCenterConfig(request.body || getDefaultUpdateCenterConfig());
|
||||
const saved = await saveUpdateCenterConfig(next);
|
||||
return {
|
||||
success: true,
|
||||
config: saved,
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{ Body: DeployBody }>('/api/update-center/deploy', async (request, reply) => {
|
||||
const config = await loadUpdateCenterConfig();
|
||||
try {
|
||||
assertDeployableConfig(config);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: summarizeHelperError(error),
|
||||
});
|
||||
}
|
||||
|
||||
const source = request.body?.source === 'docker-hub-tag'
|
||||
? 'docker-hub-tag'
|
||||
: request.body?.source === 'github-release'
|
||||
? 'github-release'
|
||||
: config.defaultDeploySource;
|
||||
const targetVersion = String(request.body?.targetVersion || '').trim();
|
||||
if (!targetVersion) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: 'targetVersion is required',
|
||||
});
|
||||
}
|
||||
|
||||
let taskId = '';
|
||||
const { task, reused } = startBackgroundTask(
|
||||
{
|
||||
type: UPDATE_CENTER_DEPLOY_TASK_TYPE,
|
||||
title: '更新中心部署',
|
||||
dedupeKey: UPDATE_CENTER_DEPLOY_DEDUPE_KEY,
|
||||
successTitle: '更新中心部署已完成',
|
||||
failureTitle: '更新中心部署失败',
|
||||
},
|
||||
async () => {
|
||||
await Promise.resolve();
|
||||
appendBackgroundTaskLog(taskId, `Resolving target version: ${targetVersion}`);
|
||||
appendBackgroundTaskLog(taskId, `Contacting deploy helper: ${config.helperBaseUrl}`);
|
||||
|
||||
const result = await streamUpdateCenterDeploy(
|
||||
{
|
||||
config,
|
||||
helperToken: getUpdateCenterHelperToken(),
|
||||
source,
|
||||
targetVersion,
|
||||
},
|
||||
(message) => {
|
||||
appendBackgroundTaskLog(taskId, message);
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('deploy helper reported a failed deployment');
|
||||
}
|
||||
appendBackgroundTaskLog(taskId, result.rolledBack ? 'Deployment rolled back' : 'Deployment finished successfully');
|
||||
return result;
|
||||
},
|
||||
);
|
||||
taskId = task.id;
|
||||
|
||||
return reply.code(202).send({
|
||||
success: true,
|
||||
reused,
|
||||
task,
|
||||
});
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/update-center/tasks/:id/stream', async (request, reply) => {
|
||||
const taskId = String(request.params.id || '').trim();
|
||||
const task = getBackgroundTask(taskId);
|
||||
if (!task) {
|
||||
return reply.code(404).send({
|
||||
success: false,
|
||||
message: 'task not found',
|
||||
});
|
||||
}
|
||||
|
||||
reply.hijack();
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
|
||||
const writeDone = () => {
|
||||
const latest = getBackgroundTask(taskId);
|
||||
writeSseEvent(reply, 'done', {
|
||||
status: latest?.status || 'unknown',
|
||||
});
|
||||
reply.raw.end();
|
||||
};
|
||||
|
||||
for (const entry of task.logs) {
|
||||
writeSseEvent(reply, 'log', entry);
|
||||
}
|
||||
|
||||
if (task.status !== 'pending' && task.status !== 'running') {
|
||||
writeDone();
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeToBackgroundTaskLogs(taskId, (entry) => {
|
||||
writeSseEvent(reply, 'log', entry);
|
||||
});
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const latest = getBackgroundTask(taskId);
|
||||
if (!latest) {
|
||||
clearInterval(interval);
|
||||
unsubscribe();
|
||||
writeDone();
|
||||
return;
|
||||
}
|
||||
if (latest.status !== 'pending' && latest.status !== 'running') {
|
||||
clearInterval(interval);
|
||||
unsubscribe();
|
||||
writeDone();
|
||||
}
|
||||
}, 25);
|
||||
interval.unref?.();
|
||||
|
||||
request.raw.on('close', () => {
|
||||
clearInterval(interval);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
__resetBackgroundTasksForTests,
|
||||
appendBackgroundTaskLog,
|
||||
getBackgroundTask,
|
||||
startBackgroundTask,
|
||||
subscribeToBackgroundTaskLogs,
|
||||
} from './backgroundTaskService.js';
|
||||
|
||||
describe('background task log streaming', () => {
|
||||
afterEach(() => {
|
||||
__resetBackgroundTasksForTests();
|
||||
});
|
||||
|
||||
it('appends log entries in order and exposes them through task lookups', async () => {
|
||||
let releaseRunner: (() => void) | null = null;
|
||||
const runnerGate = new Promise<void>((resolve) => {
|
||||
releaseRunner = resolve;
|
||||
});
|
||||
|
||||
const { task } = startBackgroundTask(
|
||||
{
|
||||
type: 'update-center.deploy',
|
||||
title: '更新部署',
|
||||
},
|
||||
async () => {
|
||||
await runnerGate;
|
||||
return { success: true };
|
||||
},
|
||||
);
|
||||
|
||||
appendBackgroundTaskLog(task.id, 'Resolving target version');
|
||||
appendBackgroundTaskLog(task.id, 'Running helm upgrade');
|
||||
|
||||
const currentTask = getBackgroundTask(task.id);
|
||||
expect(currentTask?.logs).toEqual([
|
||||
expect.objectContaining({ seq: 1, message: 'Resolving target version' }),
|
||||
expect.objectContaining({ seq: 2, message: 'Running helm upgrade' }),
|
||||
]);
|
||||
|
||||
releaseRunner?.();
|
||||
});
|
||||
|
||||
it('notifies subscribers when new log entries arrive', async () => {
|
||||
let releaseRunner: (() => void) | null = null;
|
||||
const runnerGate = new Promise<void>((resolve) => {
|
||||
releaseRunner = resolve;
|
||||
});
|
||||
|
||||
const { task } = startBackgroundTask(
|
||||
{
|
||||
type: 'update-center.deploy',
|
||||
title: '更新部署',
|
||||
},
|
||||
async () => {
|
||||
await runnerGate;
|
||||
return { success: true };
|
||||
},
|
||||
);
|
||||
|
||||
const received: string[] = [];
|
||||
const unsubscribe = subscribeToBackgroundTaskLogs(task.id, (entry) => {
|
||||
received.push(entry.message);
|
||||
});
|
||||
|
||||
appendBackgroundTaskLog(task.id, 'Waiting for rollout');
|
||||
appendBackgroundTaskLog(task.id, 'Deployment complete');
|
||||
|
||||
expect(received).toEqual([
|
||||
'Waiting for rollout',
|
||||
'Deployment complete',
|
||||
]);
|
||||
|
||||
unsubscribe();
|
||||
releaseRunner?.();
|
||||
});
|
||||
|
||||
it('trims old log entries to a bounded buffer', async () => {
|
||||
let releaseRunner: (() => void) | null = null;
|
||||
const runnerGate = new Promise<void>((resolve) => {
|
||||
releaseRunner = resolve;
|
||||
});
|
||||
|
||||
const { task } = startBackgroundTask(
|
||||
{
|
||||
type: 'update-center.deploy',
|
||||
title: '更新部署',
|
||||
},
|
||||
async () => {
|
||||
await runnerGate;
|
||||
return { success: true };
|
||||
},
|
||||
);
|
||||
|
||||
for (let index = 1; index <= 250; index += 1) {
|
||||
appendBackgroundTaskLog(task.id, `line-${index}`);
|
||||
}
|
||||
|
||||
const currentTask = getBackgroundTask(task.id);
|
||||
expect(currentTask?.logs).toHaveLength(200);
|
||||
expect(currentTask?.logs[0]).toMatchObject({
|
||||
seq: 51,
|
||||
message: 'line-51',
|
||||
});
|
||||
expect(currentTask?.logs.at(-1)).toMatchObject({
|
||||
seq: 250,
|
||||
message: 'line-250',
|
||||
});
|
||||
|
||||
releaseRunner?.();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,12 @@ import { sendNotification } from './notifyService.js';
|
||||
|
||||
export type BackgroundTaskStatus = 'pending' | 'running' | 'succeeded' | 'failed';
|
||||
|
||||
export type BackgroundTaskLogEntry = {
|
||||
seq: number;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type BackgroundTask = {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -18,6 +24,7 @@ export type BackgroundTask = {
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
expiresAtMs: number;
|
||||
logs: BackgroundTaskLogEntry[];
|
||||
};
|
||||
|
||||
type TaskMessageTemplate = string | ((task: BackgroundTask) => string);
|
||||
@@ -37,9 +44,12 @@ type BackgroundTaskStartOptions = {
|
||||
|
||||
const TASK_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
const TASK_CLEANUP_INTERVAL_MS = 60 * 1000;
|
||||
const TASK_LOG_LIMIT = 200;
|
||||
|
||||
const tasks = new Map<string, BackgroundTask>();
|
||||
const dedupeTaskIds = new Map<string, string>();
|
||||
const taskLogSeq = new Map<string, number>();
|
||||
const taskLogSubscribers = new Map<string, Set<(entry: BackgroundTaskLogEntry) => void>>();
|
||||
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function nowIso() {
|
||||
@@ -72,8 +82,9 @@ function resolveTaskMessage(template: TaskMessageTemplate | undefined, task: Bac
|
||||
}
|
||||
|
||||
function setTaskStatus(task: BackgroundTask, patch: Partial<BackgroundTask>) {
|
||||
const currentTask = tasks.get(task.id) || task;
|
||||
const next: BackgroundTask = {
|
||||
...task,
|
||||
...currentTask,
|
||||
...patch,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
@@ -81,6 +92,67 @@ function setTaskStatus(task: BackgroundTask, patch: Partial<BackgroundTask>) {
|
||||
return next;
|
||||
}
|
||||
|
||||
function cleanupTaskInternals(taskId: string) {
|
||||
taskLogSeq.delete(taskId);
|
||||
taskLogSubscribers.delete(taskId);
|
||||
}
|
||||
|
||||
export function appendBackgroundTaskLog(taskId: string, message: string): BackgroundTaskLogEntry | null {
|
||||
const task = tasks.get(taskId);
|
||||
const normalizedMessage = String(message || '').trim();
|
||||
if (!task || !normalizedMessage) return null;
|
||||
|
||||
const nextSeq = (taskLogSeq.get(taskId) || 0) + 1;
|
||||
taskLogSeq.set(taskId, nextSeq);
|
||||
|
||||
const entry: BackgroundTaskLogEntry = {
|
||||
seq: nextSeq,
|
||||
message: normalizedMessage,
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
|
||||
const nextLogs = [...task.logs, entry];
|
||||
const trimmedLogs = nextLogs.length > TASK_LOG_LIMIT
|
||||
? nextLogs.slice(nextLogs.length - TASK_LOG_LIMIT)
|
||||
: nextLogs;
|
||||
|
||||
tasks.set(taskId, {
|
||||
...task,
|
||||
logs: trimmedLogs,
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
|
||||
const subscribers = taskLogSubscribers.get(taskId);
|
||||
if (subscribers) {
|
||||
for (const subscriber of subscribers) {
|
||||
subscriber(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function subscribeToBackgroundTaskLogs(
|
||||
taskId: string,
|
||||
listener: (entry: BackgroundTaskLogEntry) => void,
|
||||
): () => void {
|
||||
let subscribers = taskLogSubscribers.get(taskId);
|
||||
if (!subscribers) {
|
||||
subscribers = new Set();
|
||||
taskLogSubscribers.set(taskId, subscribers);
|
||||
}
|
||||
subscribers.add(listener);
|
||||
|
||||
return () => {
|
||||
const current = taskLogSubscribers.get(taskId);
|
||||
if (!current) return;
|
||||
current.delete(listener);
|
||||
if (current.size <= 0) {
|
||||
taskLogSubscribers.delete(taskId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function appendTaskEvent(level: 'info' | 'warning' | 'error', title: string, message: string, taskId: string) {
|
||||
try {
|
||||
await db.insert(schema.events).values({
|
||||
@@ -154,6 +226,7 @@ function cleanupExpiredTasks() {
|
||||
if (task.dedupeKey && dedupeTaskIds.get(task.dedupeKey) === taskId) {
|
||||
dedupeTaskIds.delete(task.dedupeKey);
|
||||
}
|
||||
cleanupTaskInternals(taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,9 +269,11 @@ export function startBackgroundTask(
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
expiresAtMs: Date.now() + Math.max(60_000, options.keepMs ?? TASK_TTL_MS),
|
||||
logs: [],
|
||||
};
|
||||
|
||||
tasks.set(task.id, task);
|
||||
taskLogSeq.set(task.id, 0);
|
||||
if (dedupeKey) dedupeTaskIds.set(dedupeKey, task.id);
|
||||
|
||||
appendTaskEvent('info', `${task.title}已开始`, `${task.title} 已开始执行`, task.id);
|
||||
@@ -246,6 +321,8 @@ export function summarizeCheckinResults(results: Array<{ result?: any }>): { tot
|
||||
export function __resetBackgroundTasksForTests() {
|
||||
tasks.clear();
|
||||
dedupeTaskIds.clear();
|
||||
taskLogSeq.clear();
|
||||
taskLogSubscribers.clear();
|
||||
if (cleanupTimer) {
|
||||
clearInterval(cleanupTimer);
|
||||
cleanupTimer = null;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { upsertSetting } from '../db/upsertSetting.js';
|
||||
import type { UpdateCenterVersionSource } from './updateCenterVersionService.js';
|
||||
|
||||
export type UpdateCenterConfig = {
|
||||
enabled: boolean;
|
||||
helperBaseUrl: string;
|
||||
namespace: string;
|
||||
releaseName: string;
|
||||
chartRef: string;
|
||||
imageRepository: string;
|
||||
githubReleasesEnabled: boolean;
|
||||
dockerHubTagsEnabled: boolean;
|
||||
defaultDeploySource: UpdateCenterVersionSource;
|
||||
};
|
||||
|
||||
export const UPDATE_CENTER_CONFIG_SETTING_KEY = 'update_center_k3s_config_v1';
|
||||
|
||||
export function getDefaultUpdateCenterConfig(): UpdateCenterConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
helperBaseUrl: '',
|
||||
namespace: 'default',
|
||||
releaseName: '',
|
||||
chartRef: '',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown, fallback = ''): string {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
export function normalizeUpdateCenterConfig(input: unknown): UpdateCenterConfig {
|
||||
const defaults = getDefaultUpdateCenterConfig();
|
||||
const record = input && typeof input === 'object' ? input as Record<string, unknown> : {};
|
||||
const defaultDeploySource = record.defaultDeploySource === 'docker-hub-tag'
|
||||
? 'docker-hub-tag'
|
||||
: 'github-release';
|
||||
|
||||
return {
|
||||
enabled: normalizeBoolean(record.enabled, defaults.enabled),
|
||||
helperBaseUrl: normalizeString(record.helperBaseUrl, defaults.helperBaseUrl),
|
||||
namespace: normalizeString(record.namespace, defaults.namespace) || defaults.namespace,
|
||||
releaseName: normalizeString(record.releaseName, defaults.releaseName),
|
||||
chartRef: normalizeString(record.chartRef, defaults.chartRef),
|
||||
imageRepository: normalizeString(record.imageRepository, defaults.imageRepository) || defaults.imageRepository,
|
||||
githubReleasesEnabled: normalizeBoolean(record.githubReleasesEnabled, defaults.githubReleasesEnabled),
|
||||
dockerHubTagsEnabled: normalizeBoolean(record.dockerHubTagsEnabled, defaults.dockerHubTagsEnabled),
|
||||
defaultDeploySource,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadUpdateCenterConfig(): Promise<UpdateCenterConfig> {
|
||||
const row = await db.select().from(schema.settings).where(eq(schema.settings.key, UPDATE_CENTER_CONFIG_SETTING_KEY)).get();
|
||||
if (!row?.value) {
|
||||
return getDefaultUpdateCenterConfig();
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeUpdateCenterConfig(JSON.parse(row.value));
|
||||
} catch {
|
||||
return getDefaultUpdateCenterConfig();
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveUpdateCenterConfig(input: unknown): Promise<UpdateCenterConfig> {
|
||||
const next = normalizeUpdateCenterConfig(input);
|
||||
await upsertSetting(UPDATE_CENTER_CONFIG_SETTING_KEY, next);
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { fetchMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('undici', async () => {
|
||||
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||
return {
|
||||
...actual,
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import { getUpdateCenterHelperStatus } from './updateCenterHelperClient.js';
|
||||
|
||||
describe('update center helper client', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('maps helper status aborts to a timeout error', async () => {
|
||||
const abortError = new Error('aborted');
|
||||
(abortError as Error & { name: string }).name = 'AbortError';
|
||||
fetchMock.mockRejectedValue(abortError);
|
||||
|
||||
await expect(getUpdateCenterHelperStatus({
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
}, 'helper-token')).rejects.toThrow('deploy helper status timeout');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { fetch, type RequestInit as UndiciRequestInit } from 'undici';
|
||||
|
||||
import type { UpdateCenterConfig } from './updateCenterConfigService.js';
|
||||
import type { UpdateCenterVersionSource } from './updateCenterVersionService.js';
|
||||
|
||||
const UPDATE_CENTER_HELPER_STATUS_TIMEOUT_MS = 5_000;
|
||||
|
||||
export type UpdateCenterHelperStatus = {
|
||||
ok: boolean;
|
||||
releaseName: string | null;
|
||||
namespace: string | null;
|
||||
revision: string | null;
|
||||
imageRepository: string | null;
|
||||
imageTag: string | null;
|
||||
healthy: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type UpdateCenterDeploySummary = {
|
||||
success: boolean;
|
||||
targetSource: UpdateCenterVersionSource;
|
||||
targetTag: string;
|
||||
previousRevision: string | null;
|
||||
finalRevision: string | null;
|
||||
rolledBack: boolean;
|
||||
logLines: string[];
|
||||
};
|
||||
|
||||
type HelperDeployLogEvent = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
async function fetchHelperJsonWithTimeout(url: string, init: UndiciRequestInit): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, UPDATE_CENTER_HELPER_STATUS_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`helper status failed with HTTP ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`deploy helper status timeout (${Math.round(UPDATE_CENTER_HELPER_STATUS_TIMEOUT_MS / 1000)}s)`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildHelperHeaders(token: string, accept = 'application/json') {
|
||||
return {
|
||||
authorization: `Bearer ${token}`,
|
||||
accept,
|
||||
};
|
||||
}
|
||||
|
||||
function encodeSseEvent(event: string, data: unknown) {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
type SseChunkHandler = (event: string, data: unknown) => void;
|
||||
|
||||
function parseSseBuffer(buffer: string, onChunk: SseChunkHandler) {
|
||||
const events = buffer.split('\n\n');
|
||||
const remainder = events.pop() || '';
|
||||
|
||||
for (const block of events) {
|
||||
const lines = block.split('\n');
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice('event:'.length).trim() || 'message';
|
||||
} else if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice('data:'.length).trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length <= 0) continue;
|
||||
const rawData = dataLines.join('\n');
|
||||
try {
|
||||
onChunk(eventName, JSON.parse(rawData));
|
||||
} catch {
|
||||
onChunk(eventName, rawData);
|
||||
}
|
||||
}
|
||||
|
||||
return remainder;
|
||||
}
|
||||
|
||||
export async function getUpdateCenterHelperStatus(
|
||||
config: UpdateCenterConfig,
|
||||
helperToken: string,
|
||||
): Promise<UpdateCenterHelperStatus> {
|
||||
const query = new URLSearchParams({
|
||||
namespace: config.namespace,
|
||||
releaseName: config.releaseName,
|
||||
});
|
||||
return await fetchHelperJsonWithTimeout(`${config.helperBaseUrl.replace(/\/$/, '')}/status?${query.toString()}`, {
|
||||
headers: buildHelperHeaders(helperToken),
|
||||
}) as UpdateCenterHelperStatus;
|
||||
}
|
||||
|
||||
export async function streamUpdateCenterDeploy(
|
||||
input: {
|
||||
config: UpdateCenterConfig;
|
||||
helperToken: string;
|
||||
source: UpdateCenterVersionSource;
|
||||
targetVersion: string;
|
||||
},
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<UpdateCenterDeploySummary> {
|
||||
const response = await fetch(`${input.config.helperBaseUrl.replace(/\/$/, '')}/deploy`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...buildHelperHeaders(input.helperToken, 'text/event-stream'),
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
namespace: input.config.namespace,
|
||||
releaseName: input.config.releaseName,
|
||||
chartRef: input.config.chartRef,
|
||||
imageRepository: input.config.imageRepository,
|
||||
source: input.source,
|
||||
targetVersion: input.targetVersion,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`helper deploy failed with HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('helper deploy response did not include a stream body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const reader = response.body.getReader();
|
||||
let buffer = '';
|
||||
let finalResult: UpdateCenterDeploySummary | null = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
buffer = parseSseBuffer(buffer, (event, data) => {
|
||||
if (event === 'log' && data && typeof data === 'object') {
|
||||
const message = String((data as HelperDeployLogEvent).message || '').trim();
|
||||
if (message) onLog?.(message);
|
||||
return;
|
||||
}
|
||||
if (event === 'result') {
|
||||
finalResult = data as UpdateCenterDeploySummary;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
parseSseBuffer(`${buffer}\n\n`, (event, data) => {
|
||||
if (event === 'log' && data && typeof data === 'object') {
|
||||
const message = String((data as HelperDeployLogEvent).message || '').trim();
|
||||
if (message) onLog?.(message);
|
||||
return;
|
||||
}
|
||||
if (event === 'result') {
|
||||
finalResult = data as UpdateCenterDeploySummary;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!finalResult) {
|
||||
throw new Error('helper deploy stream ended without a result event');
|
||||
}
|
||||
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
export const __updateCenterHelperClientTestUtils = {
|
||||
encodeSseEvent,
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { fetchMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('undici', async () => {
|
||||
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||
return {
|
||||
...actual,
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
compareStableSemVer,
|
||||
fetchLatestDockerHubTag,
|
||||
fetchLatestStableGitHubRelease,
|
||||
parseStableSemVer,
|
||||
resolvePreferredDeploySource,
|
||||
selectLatestDockerHubTag,
|
||||
selectLatestStableGitHubRelease,
|
||||
} from './updateCenterVersionService.js';
|
||||
|
||||
describe('update center version service', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
describe('parseStableSemVer', () => {
|
||||
it('accepts stable semver strings with optional leading v', () => {
|
||||
expect(parseStableSemVer('1.2.3')).toMatchObject({
|
||||
raw: '1.2.3',
|
||||
normalized: '1.2.3',
|
||||
major: 1,
|
||||
minor: 2,
|
||||
patch: 3,
|
||||
});
|
||||
expect(parseStableSemVer('v2.4.6')).toMatchObject({
|
||||
raw: 'v2.4.6',
|
||||
normalized: '2.4.6',
|
||||
major: 2,
|
||||
minor: 4,
|
||||
patch: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects prerelease and invalid versions', () => {
|
||||
expect(parseStableSemVer('v1.2.3-alpha.1')).toBeNull();
|
||||
expect(parseStableSemVer('latest')).toBeNull();
|
||||
expect(parseStableSemVer('2026-03-29')).toBeNull();
|
||||
expect(parseStableSemVer('1.2')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareStableSemVer', () => {
|
||||
it('compares semver numerically instead of lexicographically', () => {
|
||||
const v120 = parseStableSemVer('1.2.0');
|
||||
const v1100 = parseStableSemVer('1.10.0');
|
||||
const v200 = parseStableSemVer('2.0.0');
|
||||
|
||||
expect(v120).not.toBeNull();
|
||||
expect(v1100).not.toBeNull();
|
||||
expect(v200).not.toBeNull();
|
||||
expect(compareStableSemVer(v120!, v1100!)).toBeLessThan(0);
|
||||
expect(compareStableSemVer(v200!, v1100!)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectLatestStableGitHubRelease', () => {
|
||||
it('ignores drafts and prereleases and returns the highest stable semver release', () => {
|
||||
const latest = selectLatestStableGitHubRelease([
|
||||
{
|
||||
tag_name: 'v1.2.3-alpha.1',
|
||||
html_url: 'https://example.com/pre',
|
||||
draft: false,
|
||||
prerelease: true,
|
||||
published_at: '2026-03-29T00:00:00Z',
|
||||
name: 'v1.2.3-alpha.1',
|
||||
},
|
||||
{
|
||||
tag_name: 'v1.9.9',
|
||||
html_url: 'https://example.com/older',
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
published_at: '2026-03-28T00:00:00Z',
|
||||
name: 'v1.9.9',
|
||||
},
|
||||
{
|
||||
tag_name: 'v1.10.0',
|
||||
html_url: 'https://example.com/latest',
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
published_at: '2026-03-27T00:00:00Z',
|
||||
name: 'v1.10.0',
|
||||
},
|
||||
{
|
||||
tag_name: 'v9.9.9',
|
||||
html_url: 'https://example.com/draft',
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
published_at: '2026-03-30T00:00:00Z',
|
||||
name: 'v9.9.9',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(latest).toMatchObject({
|
||||
rawVersion: 'v1.10.0',
|
||||
normalizedVersion: '1.10.0',
|
||||
source: 'github-release',
|
||||
url: 'https://example.com/latest',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectLatestDockerHubTag', () => {
|
||||
it('ignores non-semver tags and prereleases', () => {
|
||||
const latest = selectLatestDockerHubTag([
|
||||
'latest',
|
||||
'main',
|
||||
'v1.2.3-beta.1',
|
||||
'v1.2.3',
|
||||
'1.10.0',
|
||||
'20260329',
|
||||
]);
|
||||
|
||||
expect(latest).toMatchObject({
|
||||
rawVersion: '1.10.0',
|
||||
normalizedVersion: '1.10.0',
|
||||
source: 'docker-hub-tag',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePreferredDeploySource', () => {
|
||||
it('prefers the configured default source when both channels have valid candidates', () => {
|
||||
const preferred = resolvePreferredDeploySource({
|
||||
defaultSource: 'github-release',
|
||||
githubRelease: {
|
||||
source: 'github-release',
|
||||
rawVersion: 'v1.10.0',
|
||||
normalizedVersion: '1.10.0',
|
||||
url: 'https://example.com/release',
|
||||
},
|
||||
dockerHubTag: {
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: '1.10.1',
|
||||
normalizedVersion: '1.10.1',
|
||||
url: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(preferred?.source).toBe('github-release');
|
||||
expect(preferred?.normalizedVersion).toBe('1.10.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchLatestStableGitHubRelease', () => {
|
||||
it('fetches releases and returns the latest stable semver record', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify([
|
||||
{
|
||||
tag_name: 'v1.2.3-beta.1',
|
||||
html_url: 'https://example.com/beta',
|
||||
draft: false,
|
||||
prerelease: true,
|
||||
},
|
||||
{
|
||||
tag_name: 'v1.2.9',
|
||||
html_url: 'https://example.com/older',
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
},
|
||||
{
|
||||
tag_name: 'v1.10.0',
|
||||
html_url: 'https://example.com/latest',
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
},
|
||||
]), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const latest = await fetchLatestStableGitHubRelease();
|
||||
expect(latest).toMatchObject({
|
||||
source: 'github-release',
|
||||
normalizedVersion: '1.10.0',
|
||||
url: 'https://example.com/latest',
|
||||
});
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/repos/cita-777/metapi/releases');
|
||||
});
|
||||
|
||||
it('maps aborted GitHub lookups to a timeout error', async () => {
|
||||
const abortError = new Error('aborted');
|
||||
(abortError as Error & { name: string }).name = 'AbortError';
|
||||
fetchMock.mockRejectedValue(abortError);
|
||||
|
||||
await expect(fetchLatestStableGitHubRelease()).rejects.toThrow('GitHub releases lookup timeout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchLatestDockerHubTag', () => {
|
||||
it('fetches tags and returns the latest semver tag only', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
results: [
|
||||
{ name: 'latest' },
|
||||
{ name: 'v1.2.3-rc.1' },
|
||||
{ name: 'v1.2.3' },
|
||||
{ name: '1.10.0' },
|
||||
],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const latest = await fetchLatestDockerHubTag();
|
||||
expect(latest).toMatchObject({
|
||||
source: 'docker-hub-tag',
|
||||
normalizedVersion: '1.10.0',
|
||||
});
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/v2/repositories/1467078763/metapi/tags');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { fetch, type RequestInit as UndiciRequestInit } from 'undici';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export type StableSemVer = {
|
||||
raw: string;
|
||||
normalized: string;
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
};
|
||||
|
||||
export type UpdateCenterVersionSource = 'github-release' | 'docker-hub-tag';
|
||||
|
||||
export type UpdateCenterVersionCandidate = {
|
||||
source: UpdateCenterVersionSource;
|
||||
rawVersion: string;
|
||||
normalizedVersion: string;
|
||||
url: string | null;
|
||||
};
|
||||
|
||||
export type GitHubReleaseRecord = {
|
||||
tag_name?: string | null;
|
||||
html_url?: string | null;
|
||||
draft?: boolean;
|
||||
prerelease?: boolean;
|
||||
published_at?: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
||||
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/cita-777/metapi/releases';
|
||||
const DOCKER_HUB_TAGS_URL = 'https://hub.docker.com/v2/repositories/1467078763/metapi/tags?page_size=100';
|
||||
const UPDATE_CENTER_VERSION_FETCH_TIMEOUT_MS = 5_000;
|
||||
|
||||
async function fetchJsonWithTimeout(url: string, init: UndiciRequestInit, timeoutLabel: string): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, UPDATE_CENTER_VERSION_FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${timeoutLabel} failed with HTTP ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`${timeoutLabel} timeout (${Math.round(UPDATE_CENTER_VERSION_FETCH_TIMEOUT_MS / 1000)}s)`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStableSemVer(input: string | null | undefined): StableSemVer | null {
|
||||
const raw = String(input || '').trim();
|
||||
if (!raw) return null;
|
||||
const match = raw.match(STABLE_SEMVER_PATTERN);
|
||||
if (!match) return null;
|
||||
const major = Number.parseInt(match[1], 10);
|
||||
const minor = Number.parseInt(match[2], 10);
|
||||
const patch = Number.parseInt(match[3], 10);
|
||||
if (![major, minor, patch].every(Number.isFinite)) return null;
|
||||
return {
|
||||
raw,
|
||||
normalized: `${major}.${minor}.${patch}`,
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
};
|
||||
}
|
||||
|
||||
export function compareStableSemVer(a: StableSemVer, b: StableSemVer): number {
|
||||
if (a.major !== b.major) return a.major - b.major;
|
||||
if (a.minor !== b.minor) return a.minor - b.minor;
|
||||
return a.patch - b.patch;
|
||||
}
|
||||
|
||||
export function selectLatestStableGitHubRelease(
|
||||
releases: GitHubReleaseRecord[],
|
||||
): UpdateCenterVersionCandidate | null {
|
||||
let selected: { semver: StableSemVer; release: GitHubReleaseRecord } | null = null;
|
||||
|
||||
for (const release of releases) {
|
||||
if (release?.draft || release?.prerelease) continue;
|
||||
const semver = parseStableSemVer(release?.tag_name);
|
||||
if (!semver) continue;
|
||||
if (!selected || compareStableSemVer(semver, selected.semver) > 0) {
|
||||
selected = { semver, release };
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) return null;
|
||||
|
||||
return {
|
||||
source: 'github-release',
|
||||
rawVersion: selected.release.tag_name || selected.semver.raw,
|
||||
normalizedVersion: selected.semver.normalized,
|
||||
url: selected.release.html_url || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectLatestDockerHubTag(tags: string[]): UpdateCenterVersionCandidate | null {
|
||||
let selected: StableSemVer | null = null;
|
||||
|
||||
for (const tag of tags) {
|
||||
const semver = parseStableSemVer(tag);
|
||||
if (!semver) continue;
|
||||
if (!selected || compareStableSemVer(semver, selected) > 0) {
|
||||
selected = semver;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) return null;
|
||||
|
||||
return {
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: selected.raw,
|
||||
normalizedVersion: selected.normalized,
|
||||
url: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePreferredDeploySource(input: {
|
||||
defaultSource: UpdateCenterVersionSource;
|
||||
githubRelease: UpdateCenterVersionCandidate | null;
|
||||
dockerHubTag: UpdateCenterVersionCandidate | null;
|
||||
}): UpdateCenterVersionCandidate | null {
|
||||
if (input.defaultSource === 'github-release') {
|
||||
return input.githubRelease || input.dockerHubTag;
|
||||
}
|
||||
return input.dockerHubTag || input.githubRelease;
|
||||
}
|
||||
|
||||
export async function fetchLatestStableGitHubRelease(): Promise<UpdateCenterVersionCandidate | null> {
|
||||
const releases = await fetchJsonWithTimeout(GITHUB_RELEASES_URL, {
|
||||
headers: {
|
||||
accept: 'application/vnd.github+json',
|
||||
'user-agent': 'metapi-update-center/1.0',
|
||||
},
|
||||
}, 'GitHub releases lookup') as GitHubReleaseRecord[];
|
||||
return selectLatestStableGitHubRelease(Array.isArray(releases) ? releases : []);
|
||||
}
|
||||
|
||||
export async function fetchLatestDockerHubTag(): Promise<UpdateCenterVersionCandidate | null> {
|
||||
const payload = await fetchJsonWithTimeout(DOCKER_HUB_TAGS_URL, {
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'user-agent': 'metapi-update-center/1.0',
|
||||
},
|
||||
}, 'Docker Hub tag lookup') as { results?: Array<{ name?: string | null }> };
|
||||
const tags = Array.isArray(payload?.results)
|
||||
? payload.results.map((item) => String(item?.name || '')).filter(Boolean)
|
||||
: [];
|
||||
return selectLatestDockerHubTag(tags);
|
||||
}
|
||||
|
||||
export function getCurrentRuntimeVersion(): string {
|
||||
try {
|
||||
const packageJsonPath = resolve(process.cwd(), 'package.json');
|
||||
const payload = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { version?: string };
|
||||
const version = String(payload?.version || '').trim();
|
||||
return version || '0.0.0';
|
||||
} catch {
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildUpdateHelperApp } from './app.js';
|
||||
|
||||
describe('update helper app', () => {
|
||||
const apps: Array<Awaited<ReturnType<typeof buildUpdateHelperApp>>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
it('requires bearer auth for protected helper routes', async () => {
|
||||
const app = await buildUpdateHelperApp({
|
||||
token: 'helper-token',
|
||||
getStatus: vi.fn(),
|
||||
deploy: vi.fn(),
|
||||
});
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/status?namespace=ai&releaseName=metapi',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('allows unauthenticated health checks', async () => {
|
||||
const app = await buildUpdateHelperApp({
|
||||
token: 'helper-token',
|
||||
getStatus: vi.fn(),
|
||||
deploy: vi.fn(),
|
||||
});
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/health',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('returns helper status for authorized requests', async () => {
|
||||
const getStatus = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
releaseName: 'metapi',
|
||||
namespace: 'ai',
|
||||
revision: '17',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageTag: '1.3.1',
|
||||
healthy: true,
|
||||
});
|
||||
|
||||
const app = await buildUpdateHelperApp({
|
||||
token: 'helper-token',
|
||||
getStatus,
|
||||
deploy: vi.fn(),
|
||||
});
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/status?namespace=ai&releaseName=metapi',
|
||||
headers: {
|
||||
authorization: 'Bearer helper-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
healthy: true,
|
||||
releaseName: 'metapi',
|
||||
namespace: 'ai',
|
||||
});
|
||||
expect(getStatus).toHaveBeenCalledWith({
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
});
|
||||
});
|
||||
|
||||
it('streams deploy logs and final result as SSE when requested', async () => {
|
||||
const deploy = vi.fn().mockImplementation(async (input, onLog) => {
|
||||
onLog?.('Running helm upgrade');
|
||||
onLog?.('Waiting for rollout');
|
||||
return {
|
||||
success: true,
|
||||
targetSource: input.targetSource,
|
||||
targetTag: input.targetTag,
|
||||
previousRevision: '17',
|
||||
finalRevision: '18',
|
||||
rolledBack: false,
|
||||
logLines: ['Running helm upgrade', 'Waiting for rollout'],
|
||||
};
|
||||
});
|
||||
|
||||
const app = await buildUpdateHelperApp({
|
||||
token: 'helper-token',
|
||||
getStatus: vi.fn(),
|
||||
deploy,
|
||||
});
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/deploy',
|
||||
headers: {
|
||||
authorization: 'Bearer helper-token',
|
||||
accept: 'text/event-stream',
|
||||
},
|
||||
payload: {
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
source: 'github-release',
|
||||
targetVersion: '1.3.0',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.body).toContain('event: log');
|
||||
expect(response.body).toContain('Running helm upgrade');
|
||||
expect(response.body).toContain('event: result');
|
||||
expect(response.body).toContain('"targetTag":"1.3.0"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
|
||||
import {
|
||||
executeUpdateHelperDeploy,
|
||||
getUpdateHelperStatus,
|
||||
type UpdateHelperDeployInput,
|
||||
type UpdateHelperDeploySummary,
|
||||
type UpdateHelperStatus,
|
||||
} from './service.js';
|
||||
|
||||
type BuildUpdateHelperAppOptions = {
|
||||
token: string;
|
||||
getStatus?: (input: { namespace: string; releaseName: string }) => Promise<UpdateHelperStatus>;
|
||||
deploy?: (
|
||||
input: UpdateHelperDeployInput,
|
||||
onLog?: (message: string) => void,
|
||||
) => Promise<UpdateHelperDeploySummary>;
|
||||
};
|
||||
|
||||
function requireBearerToken(requestAuthHeader: string | undefined, expectedToken: string) {
|
||||
const raw = String(requestAuthHeader || '').trim();
|
||||
if (!raw.startsWith('Bearer ')) return false;
|
||||
return raw.slice('Bearer '.length).trim() === expectedToken;
|
||||
}
|
||||
|
||||
function writeSseEvent(reply: { raw: NodeJS.WritableStream & { write: (chunk: string) => void } }, event: string, data: unknown) {
|
||||
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
function toDeployInput(body: Record<string, unknown>): UpdateHelperDeployInput {
|
||||
return {
|
||||
namespace: String(body.namespace || '').trim(),
|
||||
releaseName: String(body.releaseName || '').trim(),
|
||||
chartRef: String(body.chartRef || '').trim(),
|
||||
imageRepository: String(body.imageRepository || '').trim(),
|
||||
targetSource: body.source === 'docker-hub-tag' ? 'docker-hub-tag' : 'github-release',
|
||||
targetTag: String(body.targetVersion || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildUpdateHelperApp(options: BuildUpdateHelperAppOptions): Promise<FastifyInstance> {
|
||||
const app = Fastify();
|
||||
const getStatus = options.getStatus || (async (input) => await getUpdateHelperStatus(input));
|
||||
const deploy = options.deploy || (async (input, onLog) => await executeUpdateHelperDeploy(input, undefined, onLog));
|
||||
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
const path = String(request.raw.url || '').split('?')[0];
|
||||
if (path === '/health') {
|
||||
return;
|
||||
}
|
||||
if (!requireBearerToken(String(request.headers.authorization || ''), options.token)) {
|
||||
return reply.code(401).send({
|
||||
success: false,
|
||||
message: 'unauthorized',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/health', async () => ({ ok: true }));
|
||||
|
||||
app.get<{ Querystring: { namespace?: string; releaseName?: string } }>('/status', async (request, reply) => {
|
||||
const namespace = String(request.query.namespace || '').trim();
|
||||
const releaseName = String(request.query.releaseName || '').trim();
|
||||
if (!namespace || !releaseName) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: 'namespace and releaseName are required',
|
||||
});
|
||||
}
|
||||
return await getStatus({ namespace, releaseName });
|
||||
});
|
||||
|
||||
app.post<{ Body: Record<string, unknown> }>('/deploy', async (request, reply) => {
|
||||
const input = toDeployInput(request.body || {});
|
||||
if (!input.namespace || !input.releaseName || !input.chartRef || !input.imageRepository || !input.targetTag) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: 'namespace, releaseName, chartRef, imageRepository, and targetVersion are required',
|
||||
});
|
||||
}
|
||||
|
||||
const wantsSse = String(request.headers.accept || '').includes('text/event-stream');
|
||||
if (!wantsSse) {
|
||||
return await deploy(input);
|
||||
}
|
||||
|
||||
reply.hijack();
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
|
||||
const result = await deploy(input, (message) => {
|
||||
writeSseEvent(reply, 'log', { message });
|
||||
});
|
||||
writeSseEvent(reply, 'result', result);
|
||||
reply.raw.end();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { buildUpdateHelperApp } from './app.js';
|
||||
|
||||
const port = Number.parseInt(process.env.DEPLOY_HELPER_PORT || '9850', 10) || 9850;
|
||||
const host = (process.env.DEPLOY_HELPER_HOST || '0.0.0.0').trim() || '0.0.0.0';
|
||||
const token = String(process.env.DEPLOY_HELPER_TOKEN || '').trim();
|
||||
|
||||
if (!token) {
|
||||
throw new Error('DEPLOY_HELPER_TOKEN is required');
|
||||
}
|
||||
|
||||
const app = await buildUpdateHelperApp({ token });
|
||||
|
||||
try {
|
||||
await app.listen({ port, host });
|
||||
app.log.info(`Deploy helper listening on http://${host}:${port}`);
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
executeUpdateHelperDeploy,
|
||||
getUpdateHelperStatus,
|
||||
} from './service.js';
|
||||
|
||||
describe('update helper service', () => {
|
||||
it('builds helm upgrade and rollout verification commands with the configured release metadata', async () => {
|
||||
const calls: Array<{ command: string; args: string[] }> = [];
|
||||
const summary = await executeUpdateHelperDeploy(
|
||||
{
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
targetSource: 'github-release',
|
||||
targetTag: '1.3.0',
|
||||
},
|
||||
{
|
||||
runCommand: async ({ command, args }) => {
|
||||
calls.push({ command, args });
|
||||
if (command === 'helm' && args[0] === 'history') {
|
||||
return {
|
||||
stdout: JSON.stringify([{ revision: '12' }]),
|
||||
};
|
||||
}
|
||||
if (command === 'helm' && args[0] === 'status') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
version: 13,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { stdout: '' };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
command: 'helm',
|
||||
args: ['history', 'metapi', '-n', 'ai', '-o', 'json'],
|
||||
},
|
||||
{
|
||||
command: 'helm',
|
||||
args: [
|
||||
'upgrade',
|
||||
'metapi',
|
||||
'oci://ghcr.io/cita-777/charts/metapi',
|
||||
'--namespace',
|
||||
'ai',
|
||||
'--reuse-values',
|
||||
'--set',
|
||||
'image.repository=1467078763/metapi',
|
||||
'--set',
|
||||
'image.tag=1.3.0',
|
||||
],
|
||||
},
|
||||
{
|
||||
command: 'kubectl',
|
||||
args: [
|
||||
'rollout',
|
||||
'status',
|
||||
'deployment',
|
||||
'-n',
|
||||
'ai',
|
||||
'-l',
|
||||
'app.kubernetes.io/instance=metapi',
|
||||
'--timeout=300s',
|
||||
],
|
||||
},
|
||||
{
|
||||
command: 'helm',
|
||||
args: ['status', 'metapi', '-n', 'ai', '-o', 'json'],
|
||||
},
|
||||
]);
|
||||
expect(summary).toMatchObject({
|
||||
success: true,
|
||||
targetSource: 'github-release',
|
||||
targetTag: '1.3.0',
|
||||
previousRevision: '12',
|
||||
finalRevision: '13',
|
||||
rolledBack: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back to the previous revision when rollout verification fails', async () => {
|
||||
const calls: Array<{ command: string; args: string[] }> = [];
|
||||
|
||||
await expect(() => executeUpdateHelperDeploy(
|
||||
{
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
targetSource: 'docker-hub-tag',
|
||||
targetTag: '1.3.1',
|
||||
},
|
||||
{
|
||||
runCommand: async ({ command, args }) => {
|
||||
calls.push({ command, args });
|
||||
if (command === 'helm' && args[0] === 'history') {
|
||||
return {
|
||||
stdout: JSON.stringify([{ revision: '22' }]),
|
||||
};
|
||||
}
|
||||
if (command === 'kubectl' && args[0] === 'rollout') {
|
||||
throw new Error('rollout status timed out');
|
||||
}
|
||||
return { stdout: '' };
|
||||
},
|
||||
},
|
||||
)).rejects.toThrow('rollout status timed out');
|
||||
|
||||
expect(calls).toEqual(expect.arrayContaining([
|
||||
{
|
||||
command: 'helm',
|
||||
args: ['rollback', 'metapi', '22', '-n', 'ai'],
|
||||
},
|
||||
]));
|
||||
});
|
||||
|
||||
it('parses helper status from helm status and values output', async () => {
|
||||
const status = await getUpdateHelperStatus(
|
||||
{
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
},
|
||||
{
|
||||
runCommand: async ({ command, args }) => {
|
||||
if (command === 'helm' && args[0] === 'status') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
version: 17,
|
||||
info: {
|
||||
status: 'deployed',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command === 'helm' && args[0] === 'get') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
image: {
|
||||
repository: '1467078763/metapi',
|
||||
tag: '1.3.1',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error('unexpected command');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(status).toEqual({
|
||||
ok: true,
|
||||
releaseName: 'metapi',
|
||||
namespace: 'ai',
|
||||
revision: '17',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageTag: '1.3.1',
|
||||
healthy: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createInterface } from 'node:readline';
|
||||
|
||||
import type { UpdateCenterVersionSource } from '../services/updateCenterVersionService.js';
|
||||
|
||||
type RunCommandInput = {
|
||||
command: string;
|
||||
args: string[];
|
||||
onLine?: (line: string) => void;
|
||||
};
|
||||
|
||||
type RunCommandResult = {
|
||||
stdout: string;
|
||||
};
|
||||
|
||||
type RunCommand = (input: RunCommandInput) => Promise<RunCommandResult>;
|
||||
|
||||
export type UpdateHelperDeployInput = {
|
||||
namespace: string;
|
||||
releaseName: string;
|
||||
chartRef: string;
|
||||
imageRepository: string;
|
||||
targetSource: UpdateCenterVersionSource;
|
||||
targetTag: string;
|
||||
};
|
||||
|
||||
export type UpdateHelperStatusInput = {
|
||||
namespace: string;
|
||||
releaseName: string;
|
||||
};
|
||||
|
||||
export type UpdateHelperDeploySummary = {
|
||||
success: boolean;
|
||||
targetSource: UpdateCenterVersionSource;
|
||||
targetTag: string;
|
||||
previousRevision: string | null;
|
||||
finalRevision: string | null;
|
||||
rolledBack: boolean;
|
||||
logLines: string[];
|
||||
};
|
||||
|
||||
export type UpdateHelperStatus = {
|
||||
ok: boolean;
|
||||
releaseName: string;
|
||||
namespace: string;
|
||||
revision: string | null;
|
||||
imageRepository: string | null;
|
||||
imageTag: string | null;
|
||||
healthy: boolean;
|
||||
};
|
||||
|
||||
async function runCommand(input: RunCommandInput): Promise<RunCommandResult> {
|
||||
return await new Promise<RunCommandResult>((resolve, reject) => {
|
||||
const child = spawn(input.command, input.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
const handleLine = (line: string) => {
|
||||
const normalized = line.trim();
|
||||
if (!normalized) return;
|
||||
input.onLine?.(normalized);
|
||||
};
|
||||
|
||||
if (child.stdout) {
|
||||
const stdoutReader = createInterface({ input: child.stdout });
|
||||
stdoutReader.on('line', (line) => {
|
||||
stdout += `${line}\n`;
|
||||
handleLine(line);
|
||||
});
|
||||
}
|
||||
|
||||
if (child.stderr) {
|
||||
const stderrReader = createInterface({ input: child.stderr });
|
||||
stderrReader.on('line', (line) => {
|
||||
stderr += `${line}\n`;
|
||||
handleLine(line);
|
||||
});
|
||||
}
|
||||
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout: stdout.trim() });
|
||||
return;
|
||||
}
|
||||
reject(new Error(stderr.trim() || stdout.trim() || `${input.command} exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseLatestRevision(stdout: string): string | null {
|
||||
try {
|
||||
const rows = JSON.parse(stdout) as Array<{ revision?: string | number }>;
|
||||
const last = Array.isArray(rows) && rows.length > 0 ? rows[rows.length - 1] : null;
|
||||
if (!last?.revision) return null;
|
||||
return String(last.revision);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseStatusRevision(stdout: string): string | null {
|
||||
try {
|
||||
const payload = JSON.parse(stdout) as { version?: string | number };
|
||||
if (payload?.version === undefined || payload?.version === null) return null;
|
||||
return String(payload.version);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeUpdateHelperDeploy(
|
||||
input: UpdateHelperDeployInput,
|
||||
deps: {
|
||||
runCommand?: RunCommand;
|
||||
} = {},
|
||||
onLog?: (line: string) => void,
|
||||
): Promise<UpdateHelperDeploySummary> {
|
||||
const commandRunner = deps.runCommand || runCommand;
|
||||
const logLines: string[] = [];
|
||||
const captureLine = (line: string) => {
|
||||
logLines.push(line);
|
||||
onLog?.(line);
|
||||
};
|
||||
|
||||
const history = await commandRunner({
|
||||
command: 'helm',
|
||||
args: ['history', input.releaseName, '-n', input.namespace, '-o', 'json'],
|
||||
});
|
||||
const previousRevision = parseLatestRevision(history.stdout);
|
||||
|
||||
await commandRunner({
|
||||
command: 'helm',
|
||||
args: [
|
||||
'upgrade',
|
||||
input.releaseName,
|
||||
input.chartRef,
|
||||
'--namespace',
|
||||
input.namespace,
|
||||
'--reuse-values',
|
||||
'--set',
|
||||
`image.repository=${input.imageRepository}`,
|
||||
'--set',
|
||||
`image.tag=${input.targetTag}`,
|
||||
],
|
||||
onLine: captureLine,
|
||||
});
|
||||
|
||||
try {
|
||||
await commandRunner({
|
||||
command: 'kubectl',
|
||||
args: [
|
||||
'rollout',
|
||||
'status',
|
||||
'deployment',
|
||||
'-n',
|
||||
input.namespace,
|
||||
'-l',
|
||||
`app.kubernetes.io/instance=${input.releaseName}`,
|
||||
'--timeout=300s',
|
||||
],
|
||||
onLine: captureLine,
|
||||
});
|
||||
} catch (error) {
|
||||
if (previousRevision) {
|
||||
await commandRunner({
|
||||
command: 'helm',
|
||||
args: ['rollback', input.releaseName, previousRevision, '-n', input.namespace],
|
||||
onLine: captureLine,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const status = await commandRunner({
|
||||
command: 'helm',
|
||||
args: ['status', input.releaseName, '-n', input.namespace, '-o', 'json'],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
targetSource: input.targetSource,
|
||||
targetTag: input.targetTag,
|
||||
previousRevision,
|
||||
finalRevision: parseStatusRevision(status.stdout),
|
||||
rolledBack: false,
|
||||
logLines,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUpdateHelperStatus(
|
||||
input: UpdateHelperStatusInput,
|
||||
deps: {
|
||||
runCommand?: RunCommand;
|
||||
} = {},
|
||||
): Promise<UpdateHelperStatus> {
|
||||
const commandRunner = deps.runCommand || runCommand;
|
||||
const status = await commandRunner({
|
||||
command: 'helm',
|
||||
args: ['status', input.releaseName, '-n', input.namespace, '-o', 'json'],
|
||||
});
|
||||
const values = await commandRunner({
|
||||
command: 'helm',
|
||||
args: ['get', 'values', input.releaseName, '-n', input.namespace, '--all', '-o', 'json'],
|
||||
});
|
||||
|
||||
const statusPayload = JSON.parse(status.stdout || '{}') as {
|
||||
version?: string | number;
|
||||
info?: {
|
||||
status?: string;
|
||||
};
|
||||
};
|
||||
const valuesPayload = JSON.parse(values.stdout || '{}') as {
|
||||
image?: {
|
||||
repository?: string;
|
||||
tag?: string;
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
releaseName: input.releaseName,
|
||||
namespace: input.namespace,
|
||||
revision: statusPayload.version === undefined || statusPayload.version === null ? null : String(statusPayload.version),
|
||||
imageRepository: valuesPayload.image?.repository || null,
|
||||
imageTag: valuesPayload.image?.tag || null,
|
||||
healthy: String(statusPayload.info?.status || '').toLowerCase() === 'deployed',
|
||||
};
|
||||
}
|
||||
@@ -136,6 +136,80 @@ async function request(url: string, options: RequestOptions = {}) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function streamSse(
|
||||
url: string,
|
||||
handlers: {
|
||||
onLog?: (entry: any) => void;
|
||||
onDone?: (payload: any) => void;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
) {
|
||||
const response = await fetchAuthenticatedResponse(url, {
|
||||
method: 'GET',
|
||||
signal: handlers.signal,
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await extractResponseErrorMessage(response));
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error('响应未返回流式内容');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const reader = response.body.getReader();
|
||||
let buffer = '';
|
||||
|
||||
const flushBuffer = (final = false) => {
|
||||
const chunks = final ? [...buffer.split('\n\n'), ''] : buffer.split('\n\n');
|
||||
if (!final) buffer = chunks.pop() || '';
|
||||
else buffer = '';
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const lines = chunk.split('\n');
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice('event:'.length).trim() || 'message';
|
||||
} else if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice('data:'.length).trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length <= 0) continue;
|
||||
let payload: any = dataLines.join('\n');
|
||||
try {
|
||||
payload = JSON.parse(payload);
|
||||
} catch {
|
||||
// keep string payload
|
||||
}
|
||||
|
||||
if (eventName === 'log') {
|
||||
handlers.onLog?.(payload);
|
||||
} else if (eventName === 'done') {
|
||||
handlers.onDone?.(payload);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
flushBuffer(false);
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
flushBuffer(true);
|
||||
}
|
||||
}
|
||||
|
||||
function buildQueryString(params?: Record<string, string | number | boolean | null | undefined>) {
|
||||
if (!params) return '';
|
||||
const searchParams = new URLSearchParams();
|
||||
@@ -677,6 +751,27 @@ export const api = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getUpdateCenterStatus: () => request('/api/update-center/status'),
|
||||
saveUpdateCenterConfig: (data: any) => request('/api/update-center/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
checkUpdateCenter: () => request('/api/update-center/check', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
deployUpdateCenter: (data: { source: 'github-release' | 'docker-hub-tag'; targetVersion: string }) => request('/api/update-center/deploy', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
streamUpdateCenterTaskLogs: (
|
||||
taskId: string,
|
||||
handlers: {
|
||||
onLog?: (entry: any) => void;
|
||||
onDone?: (payload: any) => void;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
) => streamSse(`/api/update-center/tasks/${encodeURIComponent(taskId)}/stream`, handlers),
|
||||
testSystemProxy: (data: SystemProxyTestRequest) => request('/api/settings/system-proxy/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
|
||||
+49
-1
@@ -1,3 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { api } from '../api.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { SITE_DOCS_URL } from '../docsLink.js';
|
||||
|
||||
@@ -30,6 +34,37 @@ const LINKS = [
|
||||
];
|
||||
|
||||
export default function About() {
|
||||
const [currentVersion, setCurrentVersion] = useState(`v${VERSION}`);
|
||||
const [latestGitHubVersion, setLatestGitHubVersion] = useState('');
|
||||
const [latestDockerHubVersion, setLatestDockerHubVersion] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadStatus = async () => {
|
||||
try {
|
||||
const status = await api.getUpdateCenterStatus() as {
|
||||
currentVersion?: string;
|
||||
githubRelease?: { normalizedVersion?: string } | null;
|
||||
dockerHubTag?: { normalizedVersion?: string } | null;
|
||||
};
|
||||
if (cancelled) return;
|
||||
if (status.currentVersion) {
|
||||
setCurrentVersion(`v${status.currentVersion}`);
|
||||
}
|
||||
setLatestGitHubVersion(String(status.githubRelease?.normalizedVersion || ''));
|
||||
setLatestDockerHubVersion(String(status.dockerHubTag?.normalizedVersion || ''));
|
||||
} catch {
|
||||
// ignore update-center lookup failures on about page
|
||||
}
|
||||
};
|
||||
|
||||
void loadStatus();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in" style={{ maxWidth: 860 }}>
|
||||
{/* Header */}
|
||||
@@ -47,7 +82,7 @@ export default function About() {
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700 }}>Metapi</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>v{VERSION}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>{currentVersion}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
|
||||
@@ -55,6 +90,19 @@ export default function About() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card animate-slide-up stagger-1" style={{ padding: 22, marginBottom: 14 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>更新提醒</h3>
|
||||
<div style={{ display: 'grid', gap: 8, fontSize: 13 }}>
|
||||
<div>GitHub 稳定版:{latestGitHubVersion || '暂无数据'}</div>
|
||||
<div>Docker Hub:{latestDockerHubVersion || '暂无数据'}</div>
|
||||
<div>
|
||||
<Link to="/settings" style={{ color: 'var(--color-primary)', textDecoration: 'none', fontWeight: 600 }}>
|
||||
前往更新中心
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="card animate-slide-up stagger-2" style={{ padding: 22, marginBottom: 14 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('核心特色')}</h3>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import About from './About.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getUpdateCenterStatus: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('About update center', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getUpdateCenterStatus.mockResolvedValue({
|
||||
currentVersion: '1.2.3',
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: '1.3.1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows current version and newer release summaries', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<About />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('v1.2.3');
|
||||
expect(text).toContain('GitHub 稳定版');
|
||||
expect(text).toContain('1.3.0');
|
||||
expect(text).toContain('Docker Hub');
|
||||
expect(text).toContain('1.3.1');
|
||||
expect(text).toContain('前往更新中心');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import ModernSelect from '../components/ModernSelect.js';
|
||||
import DownstreamApiKeyModal from './settings/DownstreamApiKeyModal.js';
|
||||
import FactoryResetModal from './settings/FactoryResetModal.js';
|
||||
import RouteSelectorModal from './settings/RouteSelectorModal.js';
|
||||
import UpdateCenterSection from './settings/UpdateCenterSection.js';
|
||||
import {
|
||||
applyRoutingProfilePreset,
|
||||
resolveRoutingProfilePreset,
|
||||
@@ -1759,6 +1760,8 @@ export default function Settings() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UpdateCenterSection />
|
||||
|
||||
<div className="card animate-slide-up stagger-6" style={{ padding: 20 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>维护工具</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -168,9 +168,14 @@ describe('Settings log cleanup schedule', () => {
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '测试一次签到'
|
||||
));
|
||||
const scheduleCard = root.root.find((node) => (
|
||||
node.type === 'div'
|
||||
&& String(node.props.className || '').includes('card')
|
||||
&& collectText(node).includes('定时任务')
|
||||
));
|
||||
|
||||
expect(root.root.findAllByType('select')).toHaveLength(0);
|
||||
expect(root.root.findAllByType(ModernSelect).length).toBeGreaterThanOrEqual(3);
|
||||
expect(scheduleCard.findAllByType('select')).toHaveLength(0);
|
||||
expect(scheduleCard.findAllByType(ModernSelect).length).toBeGreaterThanOrEqual(2);
|
||||
expect(String(triggerButton.props.className || '')).toContain('btn-ghost');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../../components/Toast.js';
|
||||
|
||||
import UpdateCenterSection from './UpdateCenterSection.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getUpdateCenterStatus: vi.fn(),
|
||||
saveUpdateCenterConfig: vi.fn(),
|
||||
checkUpdateCenter: vi.fn(),
|
||||
deployUpdateCenter: vi.fn(),
|
||||
streamUpdateCenterTaskLogs: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('UpdateCenterSection', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.document = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as unknown as Document;
|
||||
apiMock.getUpdateCenterStatus.mockResolvedValue({
|
||||
currentVersion: '1.2.3',
|
||||
config: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: '1.3.1',
|
||||
},
|
||||
helper: {
|
||||
ok: true,
|
||||
healthy: true,
|
||||
},
|
||||
runningTask: null,
|
||||
lastFinishedTask: null,
|
||||
});
|
||||
apiMock.saveUpdateCenterConfig.mockResolvedValue({
|
||||
success: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://updated-helper.ai.svc.cluster.local:9850',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
},
|
||||
});
|
||||
apiMock.checkUpdateCenter.mockResolvedValue({
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: '1.3.1',
|
||||
},
|
||||
});
|
||||
apiMock.deployUpdateCenter.mockResolvedValue({
|
||||
success: true,
|
||||
reused: false,
|
||||
task: {
|
||||
id: 'task-1',
|
||||
},
|
||||
});
|
||||
apiMock.streamUpdateCenterTaskLogs.mockImplementation(async (_taskId: string, handlers: { onLog?: (entry: { message: string }) => void; onDone?: (payload: { status: string }) => void }) => {
|
||||
handlers.onLog?.({ message: 'Running helm upgrade' });
|
||||
handlers.onLog?.({ message: 'Waiting for rollout' });
|
||||
handlers.onDone?.({ status: 'succeeded' });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.document = originalDocument;
|
||||
});
|
||||
|
||||
it('loads status, saves config updates, and renders streamed deploy logs', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const helperInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.value === 'http://metapi-deploy-helper.ai.svc.cluster.local:9850'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
helperInput.props.onChange({ target: { value: 'http://updated-helper.ai.svc.cluster.local:9850' } });
|
||||
});
|
||||
const checkboxInputs = root.root.findAll((node) => node.type === 'input' && node.props.type === 'checkbox');
|
||||
|
||||
await act(async () => {
|
||||
checkboxInputs[1].props.onChange({ target: { checked: false } });
|
||||
checkboxInputs[2].props.onChange({ target: { checked: false } });
|
||||
});
|
||||
|
||||
const defaultSourceTrigger = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-trigger')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
defaultSourceTrigger.props.onClick();
|
||||
});
|
||||
|
||||
const dockerHubOption = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-option')
|
||||
&& collectText(node).includes('Docker Hub Tags')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
dockerHubOption.props.onClick();
|
||||
});
|
||||
|
||||
const saveButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('保存更新中心配置')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await saveButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.saveUpdateCenterConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||
helperBaseUrl: 'http://updated-helper.ai.svc.cluster.local:9850',
|
||||
githubReleasesEnabled: false,
|
||||
dockerHubTagsEnabled: false,
|
||||
defaultDeploySource: 'docker-hub-tag',
|
||||
}));
|
||||
|
||||
const deployButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('部署 GitHub 稳定版')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await deployButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
||||
source: 'github-release',
|
||||
targetVersion: '1.3.0',
|
||||
});
|
||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('Running helm upgrade');
|
||||
expect(text).toContain('Waiting for rollout');
|
||||
expect(text).toContain('任务状态 · 已完成');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('disables deploy actions when the helper is unhealthy', async () => {
|
||||
apiMock.getUpdateCenterStatus.mockResolvedValueOnce({
|
||||
currentVersion: '1.2.3',
|
||||
config: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: '1.3.1',
|
||||
},
|
||||
helper: {
|
||||
ok: false,
|
||||
healthy: false,
|
||||
error: 'helper unavailable',
|
||||
},
|
||||
runningTask: null,
|
||||
lastFinishedTask: null,
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const githubDeployButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('btn')
|
||||
&& collectText(node).includes('部署 GitHub 稳定版')
|
||||
));
|
||||
const dockerDeployButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('btn')
|
||||
&& collectText(node).includes('部署 Docker Hub 标签')
|
||||
));
|
||||
|
||||
expect(githubDeployButton.props.disabled).toBe(true);
|
||||
expect(dockerDeployButton.props.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
githubDeployButton.props.onClick?.();
|
||||
dockerDeployButton.props.onClick?.();
|
||||
});
|
||||
|
||||
expect(apiMock.deployUpdateCenter).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,679 @@
|
||||
import React, { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||
|
||||
import { api } from '../../api.js';
|
||||
import ModernSelect from '../../components/ModernSelect.js';
|
||||
import { useToast } from '../../components/Toast.js';
|
||||
import { useIsMobile } from '../../components/useIsMobile.js';
|
||||
|
||||
type UpdateCenterStatus = {
|
||||
currentVersion?: string;
|
||||
config?: {
|
||||
enabled: boolean;
|
||||
helperBaseUrl: string;
|
||||
namespace: string;
|
||||
releaseName: string;
|
||||
chartRef: string;
|
||||
imageRepository: string;
|
||||
githubReleasesEnabled: boolean;
|
||||
dockerHubTagsEnabled: boolean;
|
||||
defaultDeploySource: 'github-release' | 'docker-hub-tag';
|
||||
};
|
||||
githubRelease?: {
|
||||
normalizedVersion?: string;
|
||||
} | null;
|
||||
dockerHubTag?: {
|
||||
normalizedVersion?: string;
|
||||
} | null;
|
||||
helper?: {
|
||||
ok?: boolean;
|
||||
healthy?: boolean;
|
||||
error?: string | null;
|
||||
} | null;
|
||||
runningTask?: {
|
||||
id?: string;
|
||||
status?: string;
|
||||
} | null;
|
||||
lastFinishedTask?: {
|
||||
id?: string;
|
||||
status?: string;
|
||||
finishedAt?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: NonNullable<UpdateCenterStatus['config']> = {
|
||||
enabled: false,
|
||||
helperBaseUrl: '',
|
||||
namespace: 'default',
|
||||
releaseName: '',
|
||||
chartRef: '',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
};
|
||||
|
||||
const DEPLOY_SOURCE_OPTIONS = [
|
||||
{
|
||||
value: 'github-release',
|
||||
label: 'GitHub Releases',
|
||||
description: '优先跟踪仓库稳定版 release。',
|
||||
},
|
||||
{
|
||||
value: 'docker-hub-tag',
|
||||
label: 'Docker Hub Tags',
|
||||
description: '适合直接跟随镜像标签推进部署。',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
const sectionPanelStyle: CSSProperties = {
|
||||
border: '1px solid var(--color-border-light)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: 14,
|
||||
background: 'var(--color-bg)',
|
||||
};
|
||||
|
||||
const summaryLabelStyle: CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-muted)',
|
||||
marginBottom: 6,
|
||||
};
|
||||
|
||||
const summaryValueStyle: CSSProperties = {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
lineHeight: 1.4,
|
||||
};
|
||||
|
||||
const fieldLabelStyle: CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-muted)',
|
||||
marginBottom: 6,
|
||||
};
|
||||
|
||||
const fieldHintStyle: CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-muted)',
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
function formatTaskTime(value?: string | null) {
|
||||
if (!value) return '暂无完成记录';
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)) return value;
|
||||
return new Date(timestamp).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function getTaskBadge(status?: string | null) {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return { className: 'badge badge-info', label: '进行中' };
|
||||
case 'pending':
|
||||
return { className: 'badge badge-warning', label: '排队中' };
|
||||
case 'succeeded':
|
||||
return { className: 'badge badge-success', label: '已完成' };
|
||||
case 'failed':
|
||||
return { className: 'badge badge-error', label: '失败' };
|
||||
default:
|
||||
return { className: 'badge badge-muted', label: '空闲' };
|
||||
}
|
||||
}
|
||||
|
||||
function getHelperBadge(helper?: UpdateCenterStatus['helper'] | null, helperBaseUrl?: string) {
|
||||
if (!helperBaseUrl) {
|
||||
return { className: 'badge badge-muted', label: '未配置' };
|
||||
}
|
||||
if (helper?.healthy) {
|
||||
return { className: 'badge badge-success', label: 'Healthy' };
|
||||
}
|
||||
if (helper?.ok) {
|
||||
return { className: 'badge badge-warning', label: '可达但未就绪' };
|
||||
}
|
||||
return { className: 'badge badge-error', label: '不可用' };
|
||||
}
|
||||
|
||||
function getSourceBadge(enabled: boolean, version?: string) {
|
||||
if (!enabled) {
|
||||
return { className: 'badge badge-muted', label: '已停用' };
|
||||
}
|
||||
if (version) {
|
||||
return { className: 'badge badge-success', label: '可部署' };
|
||||
}
|
||||
return { className: 'badge badge-warning', label: '未发现版本' };
|
||||
}
|
||||
|
||||
export default function UpdateCenterSection() {
|
||||
const toast = useToast();
|
||||
const isMobile = useIsMobile();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
const [status, setStatus] = useState<UpdateCenterStatus | null>(null);
|
||||
const [config, setConfig] = useState(DEFAULT_CONFIG);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [taskStatus, setTaskStatus] = useState('');
|
||||
const streamAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const loadStatus = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const next = await api.getUpdateCenterStatus() as UpdateCenterStatus;
|
||||
setStatus(next);
|
||||
setConfig(next.config || DEFAULT_CONFIG);
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '加载更新中心失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
return () => {
|
||||
streamAbortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const saveConfig = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await api.saveUpdateCenterConfig(config) as { config?: UpdateCenterStatus['config'] };
|
||||
const nextConfig = result.config || config;
|
||||
setConfig(nextConfig);
|
||||
setStatus((prev) => ({
|
||||
...(prev || {}),
|
||||
config: nextConfig,
|
||||
}));
|
||||
toast.success('更新中心配置已保存');
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '保存更新中心配置失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkNow = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const next = await api.checkUpdateCenter() as UpdateCenterStatus;
|
||||
setStatus(next);
|
||||
setConfig(next.config || DEFAULT_CONFIG);
|
||||
toast.success('已刷新更新信息');
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '检查更新失败');
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runDeploy = async (source: 'github-release' | 'docker-hub-tag', targetVersion: string) => {
|
||||
if (!targetVersion) return;
|
||||
setDeploying(true);
|
||||
setLogs([]);
|
||||
setTaskStatus('running');
|
||||
streamAbortRef.current?.abort();
|
||||
streamAbortRef.current = new AbortController();
|
||||
let taskId = '';
|
||||
|
||||
try {
|
||||
const response = await api.deployUpdateCenter({ source, targetVersion }) as { task?: { id: string } };
|
||||
taskId = response.task?.id || '';
|
||||
if (!taskId) {
|
||||
throw new Error('部署任务未返回 taskId');
|
||||
}
|
||||
|
||||
await api.streamUpdateCenterTaskLogs(taskId, {
|
||||
signal: streamAbortRef.current.signal,
|
||||
onLog: (entry) => {
|
||||
const message = String(entry?.message || '').trim();
|
||||
if (!message) return;
|
||||
setLogs((prev) => [...prev, message].slice(-200));
|
||||
},
|
||||
onDone: (payload) => {
|
||||
setTaskStatus(String(payload?.status || 'unknown'));
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (taskId) {
|
||||
try {
|
||||
const taskResponse = await api.getTask(taskId) as { task?: { status?: string; logs?: Array<{ message?: string }> } };
|
||||
const task = taskResponse.task;
|
||||
if (task) {
|
||||
setTaskStatus(String(task.status || 'unknown'));
|
||||
setLogs(Array.isArray(task.logs) ? task.logs.map((entry) => String(entry?.message || '')).filter(Boolean) : []);
|
||||
toast.info('实时日志流已断开,已回退到任务详情快照');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the generic error state
|
||||
}
|
||||
}
|
||||
setTaskStatus('failed');
|
||||
toast.error(error?.message || '部署失败');
|
||||
} finally {
|
||||
setDeploying(false);
|
||||
void loadStatus();
|
||||
}
|
||||
};
|
||||
|
||||
const helperHealthy = !!status?.helper?.healthy;
|
||||
const helperBadge = getHelperBadge(status?.helper, config.helperBaseUrl);
|
||||
const runningTaskBadge = getTaskBadge(status?.runningTask?.status || taskStatus || undefined);
|
||||
const lastFinishedTaskBadge = getTaskBadge(status?.lastFinishedTask?.status || undefined);
|
||||
const visibleTaskStatus = taskStatus || status?.runningTask?.status || status?.lastFinishedTask?.status || 'idle';
|
||||
|
||||
const canDeployGithub = Boolean(
|
||||
!deploying
|
||||
&& config.enabled
|
||||
&& helperHealthy
|
||||
&& config.githubReleasesEnabled
|
||||
&& status?.githubRelease?.normalizedVersion,
|
||||
);
|
||||
const canDeployDocker = Boolean(
|
||||
!deploying
|
||||
&& config.enabled
|
||||
&& helperHealthy
|
||||
&& config.dockerHubTagsEnabled
|
||||
&& status?.dockerHubTag?.normalizedVersion,
|
||||
);
|
||||
|
||||
const renderDeployReason = (enabled: boolean, version?: string, helperError?: string | null) => {
|
||||
if (!config.enabled) return '更新中心已关闭,保存启用后才能部署。';
|
||||
if (!enabled) return '当前来源已停用,开启后才会参与检查和部署。';
|
||||
if (!helperHealthy) return helperError || 'Deploy Helper 未健康,先修复 helper 再部署。';
|
||||
if (!version) return '当前来源还没有可部署版本。';
|
||||
return '版本可用,点击按钮即可通过 helper 发起滚动更新。';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 6 }}>更新中心</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
正在加载部署来源、版本状态和 helper 健康检查...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 6 }}>更新中心</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.55 }}>
|
||||
在设置页里统一查看 GitHub Releases、Docker Hub 版本和 K3s helper 状态,避免部署信息散落在多个入口。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={summaryLabelStyle}>当前运行版本</div>
|
||||
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)' }}>
|
||||
{status?.currentVersion || '-'}
|
||||
</div>
|
||||
<div style={fieldHintStyle}>以当前容器内运行版本为准。</div>
|
||||
</div>
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={summaryLabelStyle}>Deploy Helper</div>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<span className={helperBadge.className}>{helperBadge.label}</span>
|
||||
</div>
|
||||
<div style={{ ...fieldHintStyle, fontFamily: config.helperBaseUrl ? 'var(--font-mono)' : 'inherit' }}>
|
||||
{config.helperBaseUrl || '尚未配置 Helper URL'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={summaryLabelStyle}>默认部署来源</div>
|
||||
<div style={summaryValueStyle}>
|
||||
{DEPLOY_SOURCE_OPTIONS.find((item) => item.value === config.defaultDeploySource)?.label || 'GitHub Releases'}
|
||||
</div>
|
||||
<div style={fieldHintStyle}>保存配置后,手动部署默认优先使用这里的来源。</div>
|
||||
</div>
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={summaryLabelStyle}>最近任务</div>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<span className={status?.runningTask ? runningTaskBadge.className : lastFinishedTaskBadge.className}>
|
||||
{status?.runningTask ? `运行中 · ${runningTaskBadge.label}` : lastFinishedTaskBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={fieldHintStyle}>
|
||||
{status?.runningTask?.id
|
||||
? `任务 ID: ${status.runningTask.id}`
|
||||
: formatTaskTime(status?.lastFinishedTask?.finishedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))',
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<label style={{ ...sectionPanelStyle, display: 'flex', gap: 10, alignItems: 'flex-start', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, enabled: e.target.checked }))}
|
||||
style={{ width: 16, height: 16, marginTop: 2, accentColor: 'var(--color-primary)' }}
|
||||
/>
|
||||
<span style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>启用更新中心</span>
|
||||
<span style={fieldHintStyle}>允许系统检查版本,并在配置齐全时触发 K3s 部署。</span>
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ ...sectionPanelStyle, display: 'flex', gap: 10, alignItems: 'flex-start', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.githubReleasesEnabled}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, githubReleasesEnabled: e.target.checked }))}
|
||||
style={{ width: 16, height: 16, marginTop: 2, accentColor: 'var(--color-primary)' }}
|
||||
/>
|
||||
<span style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>GitHub Releases</span>
|
||||
<span style={fieldHintStyle}>从仓库稳定版 release 提取 SemVer 版本号并提供部署入口。</span>
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ ...sectionPanelStyle, display: 'flex', gap: 10, alignItems: 'flex-start', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.dockerHubTagsEnabled}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, dockerHubTagsEnabled: e.target.checked }))}
|
||||
style={{ width: 16, height: 16, marginTop: 2, accentColor: 'var(--color-primary)' }}
|
||||
/>
|
||||
<span style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>Docker Hub</span>
|
||||
<span style={fieldHintStyle}>从镜像标签中找出最新稳定版本,适合镜像优先的发布链路。</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ ...sectionPanelStyle, marginBottom: 12 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 6 }}>部署配置</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
和现有 Settings 区块保持同一套表单密度。这里保存的是 helper 和目标 release 的持久化配置。
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<div style={fieldLabelStyle}>Deploy Helper URL</div>
|
||||
<input
|
||||
value={config.helperBaseUrl}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, helperBaseUrl: e.target.value }))}
|
||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||
placeholder="http://metapi-deploy-helper.namespace.svc.cluster.local:9850"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<div style={fieldLabelStyle}>默认部署来源</div>
|
||||
<ModernSelect
|
||||
value={config.defaultDeploySource}
|
||||
onChange={(value) => setConfig((prev) => ({
|
||||
...prev,
|
||||
defaultDeploySource: value === 'docker-hub-tag' ? 'docker-hub-tag' : 'github-release',
|
||||
}))}
|
||||
options={DEPLOY_SOURCE_OPTIONS.map((item) => ({ ...item }))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<div style={fieldLabelStyle}>Namespace</div>
|
||||
<input
|
||||
value={config.namespace}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, namespace: e.target.value }))}
|
||||
style={inputStyle}
|
||||
placeholder="default"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<div style={fieldLabelStyle}>Release Name</div>
|
||||
<input
|
||||
value={config.releaseName}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, releaseName: e.target.value }))}
|
||||
style={inputStyle}
|
||||
placeholder="metapi"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<div style={fieldLabelStyle}>Chart Ref</div>
|
||||
<input
|
||||
value={config.chartRef}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, chartRef: e.target.value }))}
|
||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||
placeholder="oci://ghcr.io/cita-777/charts/metapi"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<div style={fieldLabelStyle}>Image Repository</div>
|
||||
<input
|
||||
value={config.imageRepository}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, imageRepository: e.target.value }))}
|
||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||
placeholder="1467078763/metapi"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button type="button" onClick={saveConfig} disabled={saving} className="btn btn-primary">
|
||||
{saving ? '保存中...' : '保存更新中心配置'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkNow}
|
||||
disabled={checking}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{checking ? '检查中...' : '检查更新'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ ...sectionPanelStyle, marginBottom: 12 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 6 }}>可部署版本</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
默认来源会用主按钮强调。Helper 未就绪时,部署入口会自动禁用,避免触发无效任务。
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>GitHub Releases</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span className={getSourceBadge(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion).className}>
|
||||
{getSourceBadge(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion).label}
|
||||
</span>
|
||||
{config.defaultDeploySource === 'github-release' ? (
|
||||
<span className="badge badge-info">默认来源</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>优先使用仓库稳定版 release,适合保留语义化版本节奏。</div>
|
||||
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
|
||||
{status?.githubRelease?.normalizedVersion || '未发现'}
|
||||
</div>
|
||||
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
||||
{renderDeployReason(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion, status?.helper?.error)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!helperHealthy) return;
|
||||
void runDeploy('github-release', status?.githubRelease?.normalizedVersion || '');
|
||||
}}
|
||||
disabled={!canDeployGithub}
|
||||
className={config.defaultDeploySource === 'github-release' ? 'btn btn-primary' : 'btn btn-ghost'}
|
||||
style={config.defaultDeploySource === 'github-release' ? undefined : { border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
部署 GitHub 稳定版
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>Docker Hub</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span className={getSourceBadge(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion).className}>
|
||||
{getSourceBadge(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion).label}
|
||||
</span>
|
||||
{config.defaultDeploySource === 'docker-hub-tag' ? (
|
||||
<span className="badge badge-info">默认来源</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>适合镜像标签领先于 release 的场景,直接跟随容器分发节奏。</div>
|
||||
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
|
||||
{status?.dockerHubTag?.normalizedVersion || '未发现'}
|
||||
</div>
|
||||
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
||||
{renderDeployReason(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion, status?.helper?.error)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!helperHealthy) return;
|
||||
void runDeploy('docker-hub-tag', status?.dockerHubTag?.normalizedVersion || '');
|
||||
}}
|
||||
disabled={!canDeployDocker}
|
||||
className={config.defaultDeploySource === 'docker-hub-tag' ? 'btn btn-primary' : 'btn btn-ghost'}
|
||||
style={config.defaultDeploySource === 'docker-hub-tag' ? undefined : { border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
部署 Docker Hub 标签
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={fieldLabelStyle}>Helper 健康摘要</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 6 }}>
|
||||
<span className={helperBadge.className}>{helperBadge.label}</span>
|
||||
<span className={runningTaskBadge.className}>当前任务 · {runningTaskBadge.label}</span>
|
||||
</div>
|
||||
<div style={fieldHintStyle}>
|
||||
{status?.helper?.error || 'Helper 正常时会先执行 helm upgrade,再等待 kubectl rollout status。'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={fieldLabelStyle}>任务快照</div>
|
||||
<div style={{ display: 'grid', gap: 6, fontSize: 13, color: 'var(--color-text-primary)' }}>
|
||||
<div>
|
||||
运行中任务:
|
||||
<span style={{ marginLeft: 6, color: 'var(--color-text-secondary)' }}>
|
||||
{status?.runningTask?.id ? `${status.runningTask.id} · ${status.runningTask.status || '-'}` : '无'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
最近完成:
|
||||
<span style={{ marginLeft: 6, color: 'var(--color-text-secondary)' }}>
|
||||
{status?.lastFinishedTask?.id
|
||||
? `${status.lastFinishedTask.id} · ${status.lastFinishedTask.status || '-'}`
|
||||
: '无'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={fieldHintStyle}>
|
||||
完成时间:{formatTaskTime(status?.lastFinishedTask?.finishedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>部署日志</div>
|
||||
<span className={getTaskBadge(visibleTaskStatus).className}>
|
||||
任务状态 · {getTaskBadge(visibleTaskStatus).label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
||||
日志流断开时会自动回退到任务快照。这里只保留最近 200 条输出,方便直接判断 Helm 与 rollout 卡在哪一步。
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--color-border-light)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--color-bg-card)',
|
||||
padding: 12,
|
||||
minHeight: 120,
|
||||
}}
|
||||
>
|
||||
{logs.length > 0 ? (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.65,
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
{logs.join('\n')}
|
||||
</pre>
|
||||
) : (
|
||||
<div style={{ ...fieldHintStyle, minHeight: 94, display: 'flex', alignItems: 'center' }}>
|
||||
部署开始后,这里会实时显示 helm upgrade、kubectl rollout status 和回滚日志。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{status?.helper?.error ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-danger)', marginTop: 10 }}>
|
||||
Helper 错误:{status.helper.error}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 10 }}>
|
||||
最近状态:{visibleTaskStatus}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user