-
@@ -128,11 +160,35 @@ async function handleToggle(instance: { :placeholder="$t('createModal.placeholder')" @keyup.enter="handleCreate" /> + + + + + @@ -364,7 +420,8 @@ input:checked + .slider:before { font-size: 16px; } -.modal-content input { +.modal-content input, +.modal-content select { padding: 8px 10px; background: var(--input-bg); border: 1px solid var(--input-border); @@ -373,10 +430,22 @@ input:checked + .slider:before { outline: none; } -.modal-content input:focus { +.modal-content input:focus, +.modal-content select:focus { border-color: var(--create-brass-primary); } +.field-label { + font-size: 12px; + color: var(--text-muted); +} + +.modal-hint { + margin: 0; + font-size: 12px; + color: var(--text-muted); +} + .modal-actions { display: flex; justify-content: flex-end; @@ -410,6 +479,11 @@ input:checked + .slider:before { filter: brightness(1.1); } +.btn-confirm:disabled { + opacity: 0.55; + cursor: not-allowed; +} + /* ========== 底部输出 ========== */ .output { margin-top: auto; From daa05d484199933c8f675f40bb62304849092df0 Mon Sep 17 00:00:00 2001 From: mf1bzz-desktop Date: Tue, 31 Mar 2026 22:07:53 +0800 Subject: [PATCH 21/28] =?UTF-8?q?chore:=20=E6=B7=BB=E5=8A=A0markdownlint?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .markdownlint.json | 3 +++ docs/exec-plans/.markdownlint.json | 4 ++++ 2 files changed, 7 insertions(+) create mode 100644 .markdownlint.json create mode 100644 docs/exec-plans/.markdownlint.json diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..900d8e0 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,3 @@ +{ + "default": true +} \ No newline at end of file diff --git a/docs/exec-plans/.markdownlint.json b/docs/exec-plans/.markdownlint.json new file mode 100644 index 0000000..66621ea --- /dev/null +++ b/docs/exec-plans/.markdownlint.json @@ -0,0 +1,4 @@ +{ + "MD013": false, + "MD028": false +} \ No newline at end of file From 6217c96b5f30a9dd54b7f36beca1da1492ad542d Mon Sep 17 00:00:00 2001 From: mf1bzz-desktop Date: Tue, 31 Mar 2026 22:19:14 +0800 Subject: [PATCH 22/28] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0plan=E4=B8=8ERe?= =?UTF-8?q?adme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Readme.md | 2 +- .../20260331-static-image-registry.md | 46 +++++++++---------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Readme.md b/Readme.md index e2a0aa7..bc0d782 100644 --- a/Readme.md +++ b/Readme.md @@ -12,7 +12,7 @@ task dev # 一键启动前后端开发服务 ### 镜像与模板 -- [ ] 静态镜像注册表(后端 JSON 配置) +- [x] 静态镜像注册表(后端 JSON 配置) - [ ] 镜像市场前端页面(浏览、筛选、选用镜像) - [ ] 基于 YAML 的游戏模板规范设计 - [ ] 模板解析引擎(根据模板自动生成 Docker 启动参数) diff --git a/docs/exec-plans/completed/20260331-static-image-registry.md b/docs/exec-plans/completed/20260331-static-image-registry.md index e34b61c..480fde2 100644 --- a/docs/exec-plans/completed/20260331-static-image-registry.md +++ b/docs/exec-plans/completed/20260331-static-image-registry.md @@ -274,29 +274,29 @@ func (h *RegistryHandler) GetImages(w http.ResponseWriter, r *http.Request) { .. ## 执行步骤 -- [ ] 后端 Model 层 - - [ ] 新建 `backend/internal/model/registry.go`,定义 `RegistryImage` 结构体 - - [ ] 修改 `backend/internal/model/errors.go`,新增 `ErrImageNotFound` -- [ ] 后端注册表数据 - - [ ] 新建 `backend/registry.json`,写入初始镜像条目 -- [ ] 后端 Service 层 - - [ ] 新建 `backend/internal/service/registry_service.go`,实现 `RegistryService` - - [ ] 编写 `registry_service_test.go` 单元测试(加载合法/非法 JSON、按 ID 查询) - - [ ] 修改 `backend/internal/service/docker_service.go`:移除 `image` 字段和 `defaultImage` 常量、新增 `ImageRegistry` 接口依赖、更新 `CreateInstance` 签名 -- [ ] 后端 API 层 - - [ ] 新建 `backend/internal/api/registry_handlers.go`,实现 `RegistryHandler` - - [ ] 修改 `backend/internal/api/handlers.go`:`createRequest` 增加 `ImageID`、`InstanceService` 接口同步更新 - - [ ] 修改 `backend/internal/api/router.go`:注册 `GET /api/registry/images` - - [ ] 更新 `handlers_test.go`:适配新的 `CreateInstance` 签名 -- [ ] 后端入口 - - [ ] 修改 `backend/main.go`:移除 `MINEDOCK_IMAGE`、初始化 `RegistryService`、注入 `DockerService`、创建 `RegistryHandler` -- [ ] 前端适配 - - [ ] 修改 `frontend/src/api/index.ts`:新增 `RegistryImage` 类型和 `listRegistryImages()` 函数、更新 `createInstance` 签名 - - [ ] 新建 `frontend/src/stores/registry.ts`:镜像注册表 Pinia store - - [ ] 修改 `frontend/src/stores/containers.ts`:`create` action 增加 `imageId` 参数 -- [ ] 文档更新 - - [ ] 修改 `docs/api/contracts.md`:新增 `GET /api/registry/images`、更新 `POST /api/instances` - - [ ] 修改 `docs/design-docs/instance_lifecycle.md`:补充 `ImageID` 字段 +- [x] 后端 Model 层 + - [x] 新建 `backend/internal/model/registry.go`,定义 `RegistryImage` 结构体 + - [x] 修改 `backend/internal/model/errors.go`,新增 `ErrImageNotFound` +- [x] 后端注册表数据 + - [x] 新建 `backend/registry.json`,写入初始镜像条目 +- [x] 后端 Service 层 + - [x] 新建 `backend/internal/service/registry_service.go`,实现 `RegistryService` + - [x] 编写 `registry_service_test.go` 单元测试(加载合法/非法 JSON、按 ID 查询) + - [x] 修改 `backend/internal/service/docker_service.go`:移除 `image` 字段和 `defaultImage` 常量、新增 `ImageRegistry` 接口依赖、更新 `CreateInstance` 签名 +- [x] 后端 API 层 + - [x] 新建 `backend/internal/api/registry_handlers.go`,实现 `RegistryHandler` + - [x] 修改 `backend/internal/api/handlers.go`:`createRequest` 增加 `ImageID`、`InstanceService` 接口同步更新 + - [x] 修改 `backend/internal/api/router.go`:注册 `GET /api/registry/images` + - [x] 更新 `handlers_test.go`:适配新的 `CreateInstance` 签名 +- [x] 后端入口 + - [x] 修改 `backend/main.go`:移除 `MINEDOCK_IMAGE`、初始化 `RegistryService`、注入 `DockerService`、创建 `RegistryHandler` +- [x] 前端适配 + - [x] 修改 `frontend/src/api/index.ts`:新增 `RegistryImage` 类型和 `listRegistryImages()` 函数、更新 `createInstance` 签名 + - [x] 新建 `frontend/src/stores/registry.ts`:镜像注册表 Pinia store + - [x] 修改 `frontend/src/stores/containers.ts`:`create` action 增加 `imageId` 参数 +- [x] 文档更新 + - [x] 修改 `docs/api/contracts.md`:新增 `GET /api/registry/images`、更新 `POST /api/instances` + - [x] 修改 `docs/design-docs/instance_lifecycle.md`:补充 `ImageID` 字段 ## 已确认的决策 From 5ea7fb5b2466a7548f34ef395976e2a52c742b10 Mon Sep 17 00:00:00 2001 From: mf1bzz-desktop Date: Wed, 1 Apr 2026 14:45:46 +0800 Subject: [PATCH 23/28] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=95=9C?= =?UTF-8?q?=E5=83=8F=E5=B8=82=E5=9C=BA=E5=89=8D=E7=AB=AF=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Readme.md | 3 +- .../20260331-image-marketplace-page.md | 193 ++++++++++++ docs/standards/frontend.md | 4 +- frontend/src/components/Sidebar.vue | 16 + frontend/src/locales/en-US.json | 10 +- frontend/src/locales/zh-CN.json | 10 +- frontend/src/router/index.ts | 5 + frontend/src/stores/registry.ts | 63 +++- frontend/src/views/ContainerList.vue | 50 ++- frontend/src/views/ImageRegistry.vue | 287 ++++++++++++++++++ 10 files changed, 627 insertions(+), 14 deletions(-) create mode 100644 docs/exec-plans/completed/20260331-image-marketplace-page.md create mode 100644 frontend/src/views/ImageRegistry.vue diff --git a/Readme.md b/Readme.md index bc0d782..0397bc2 100644 --- a/Readme.md +++ b/Readme.md @@ -13,13 +13,14 @@ task dev # 一键启动前后端开发服务 ### 镜像与模板 - [x] 静态镜像注册表(后端 JSON 配置) -- [ ] 镜像市场前端页面(浏览、筛选、选用镜像) +- [x] 镜像市场前端页面(浏览、筛选、选用镜像) - [ ] 基于 YAML 的游戏模板规范设计 - [ ] 模板解析引擎(根据模板自动生成 Docker 启动参数) ### 实例生命周期 - [x] 容器基础生命周期:创建、删除、开启、停止 +- [ ] 容器运行状态实时同步 - [ ] 创建容器时支持镜像选择 - [ ] 创建容器时支持环境变量注入 - [ ] 创建容器时支持 Volume 持久化目录挂载 diff --git a/docs/exec-plans/completed/20260331-image-marketplace-page.md b/docs/exec-plans/completed/20260331-image-marketplace-page.md new file mode 100644 index 0000000..11514a6 --- /dev/null +++ b/docs/exec-plans/completed/20260331-image-marketplace-page.md @@ -0,0 +1,193 @@ +# 镜像市场前端页面 + +## 背景 + +静态镜像注册表后端已实现(`GET /api/registry/images`、`POST /api/instances` 需要 `image_id`)。前端 API 层(`listRegistryImages()`)和 Pinia store(`useRegistryStore`)也已就绪。当前创建容器时通过 `ContainerList.vue` 弹窗内的下拉框选择镜像,但缺少一个独立的**镜像市场页面**让用户直观浏览、筛选、选用镜像。 + +本计划实现 `/registry` 路由下的镜像市场页面,作为发现和选用镜像的主入口。 + +## 需要评审的内容 + +> [!IMPORTANT] +> **镜像市场 → 创建容器的交互流程** +> +> 用户在镜像市场**直接点击整张游戏卡片**,使用 Vue Router 跳转到容器列表页 `/`,并通过 query 参数 `?imageId=minecraft-java` 传递预选镜像。`ContainerList.vue` 检测到该参数后自动弹出创建弹窗并预填镜像。 +> +> 卡片上不设独立的「选用」按钮,卡片本身即为点击入口,交互更简洁直接。 + +> [!IMPORTANT] +> **按 category 筛选** +> +> 镜像卡片支持按 `category` 筛选。筛选栏提供「全部」及各 `category`(从后端数据动态提取)选项卡。当前 registry.json 中有 `minecraft` 和 `sandbox` 两个分类。 + +## 拟定更改 + +### 路由 + +#### [MODIFY] index.ts (`frontend/src/router/index.ts`) + +- 新增路由 `/registry`,路由名 `ImageRegistry`,懒加载 `views/ImageRegistry.vue` + +--- + +### 视图层 + +#### [NEW] ImageRegistry.vue (`frontend/src/views/ImageRegistry.vue`) + +**镜像市场页面**,页面结构: + +```text +┌─────────────────────────────────────────┐ +│ page-header: "镜像市场" 标题 │ +├─────────────────────────────────────────┤ +│ 筛选栏: [全部] [minecraft] [sandbox] │ +├─────────────────────────────────────────┤ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ ⛏️ │ │ ⛏️ │ │ 🌳 │ │ +│ │ MC Java │ │ MC 基岩 │ │ 泰拉瑞亚│ │ +│ │ 描述... │ │ 描述... │ │ 描述... │ │ +│ │ minecraft│ │ minecraft│ │ sandbox │ │ +│ └─────────┘ └─────────┘ └─────────┘ │ +│ (整张卡片可点击 → 跳转创建容器) │ +│ │ +│ 空状态 / 加载中 提示 │ +└─────────────────────────────────────────┘ +``` + +关键技术细节: + +- **数据来源**:使用 `useRegistryStore`,`onMounted` 时调用 `fetchImages()` +- **分类筛选**:`computed` 动态提取所有 category,`selectedCategory` ref 控制当前筛选项,`filteredImages` computed 过滤结果 +- **图标方案**:前端维护 `icon → emoji` 映射表(如 `minecraft-java → ⛏️`,`minecraft-bedrock → ⛏️`,`terraria → 🌳`),未匹配时回退到名称首字母 +- **卡片展示**:每张卡片作为一个完整的游戏入口,展示 emoji 图标、`name`(游戏名称)、`description`(简介,可截断为 2~3 行)、`category` 分类徽标。**不显示**端口列表、环境变量、选用按钮 +- **卡片点击**:整张卡片可点击,`cursor: pointer` + hover 反馈,点击执行 `router.push({ name: 'ContainerList', query: { imageId: image.id } })` +- **空状态**:无镜像时显示空状态提示 +- **加载态**:`registryStore.loading` 为 `true` 时展示加载占位 +- **样式**:遵循 Create 主题 CSS 变量体系,卡片复用 `--card-*` 系列变量,响应式网格布局(桌面 3 列,平板 2 列,手机 1 列) + +#### [MODIFY] ContainerList.vue (`frontend/src/views/ContainerList.vue`) + +- `onMounted` 中检测 `route.query.imageId`,若存在: + - 等待 `registryStore.fetchImages()` 完成 + - 校验 imageId 在注册表中存在 + - 设置 `selectedImageId` 为该值 + - 自动打开创建弹窗 + - 清除 URL query 参数(`router.replace({ query: {} })`)防止刷新重触发 + +--- + +### 侧边栏 + +#### [MODIFY] Sidebar.vue (`frontend/src/components/Sidebar.vue`) + +- 在「容器列表」菜单项下方新增「镜像市场」菜单项 +- 路由指向 `/registry` +- 图标使用网格/市场风格 SVG(4 格方块或拼图图标) +- 移动端和桌面端菜单同步新增 + +--- + +### 国际化 + +#### [MODIFY] zh-CN.json (`frontend/src/locales/zh-CN.json`) + +新增翻译 key: + +```json +{ + "registry": { + "title": "镜像市场", + "filterAll": "全部", + "emptyState": "暂无可用镜像。", + "loading": "正在加载镜像列表...", + "loadError": "镜像列表加载失败,请稍后重试。" + }, + "sidebar": { + "imageRegistry": "镜像市场" + } +} +``` + +#### [MODIFY] en-US.json (`frontend/src/locales/en-US.json`) + +```json +{ + "registry": { + "title": "Image Marketplace", + "filterAll": "All", + "emptyState": "No images available.", + "loading": "Loading image list...", + "loadError": "Failed to load image list. Please try again later." + }, + "sidebar": { + "imageRegistry": "Images" + } +} +``` + +--- + +### Store 层(可选增强) + +#### [MODIFY] registry.ts (`frontend/src/stores/registry.ts`) + +- 新增 `categories` computed getter:从 `images` 中提取去重的 category 列表 +- 新增 `error` ref:`fetchImages` 失败时记录错误状态,视图层据此渲染错误提示 + +--- + +### 文档 + +#### [MODIFY] frontend.md (`docs/standards/frontend.md`) + +- 更新路由表:新增 `/registry` → `ImageRegistry.vue` 说明 + +--- + +## 执行步骤 + +- [x] Store 层增强 + - [x] 修改 `frontend/src/stores/registry.ts`:新增 `categories` computed 和 `error` ref +- [x] 路由注册 + - [x] 修改 `frontend/src/router/index.ts`:新增 `/registry` 路由(懒加载) +- [x] 镜像市场页面 + - [x] 新建 `frontend/src/views/ImageRegistry.vue` + - [x] 实现页面标题区(复用 `.page-header` / `.page-title` 样式模式) + - [x] 实现分类筛选栏(category tabs) + - [x] 实现镜像卡片网格(响应式 grid) + - [x] 实现卡片内容:emoji 图标、名称、描述、分类徽标 + - [x] 实现加载态和空状态 + - [x] 实现整张卡片点击 → 跳转到容器列表页并传入 `imageId` query +- [x] 容器列表页适配 + - [x] 修改 `frontend/src/views/ContainerList.vue`:检测 `route.query.imageId`,自动预填镜像并弹出创建弹窗 +- [x] 侧边栏更新 + - [x] 修改 `frontend/src/components/Sidebar.vue`:添加「镜像市场」导航项(桌面端 + 移动端) +- [x] 国际化 + - [x] 修改 `frontend/src/locales/zh-CN.json`:新增 `registry.*` 和 `sidebar.imageRegistry` + - [x] 修改 `frontend/src/locales/en-US.json`:同步英文翻译 +- [x] 文档 + - [x] 修改 `docs/standards/frontend.md`:更新路由说明 + +## 已确认的决策 + +- ✅ 镜像图标方案:Emoji / Unicode 映射(`minecraft-java → ⛏️`,`minecraft-bedrock → ⛏️`,`terraria → 🌳`),未匹配时回退首字母 +- ✅ 卡片只展示:emoji 图标 + 游戏名称 + 描述 + 分类徽标 +- ✅ 不显示:端口列表、环境变量、独立选用按钮 +- ✅ 整张卡片可点击,直接跳转到创建容器流程 + +## 验证计划 + +### 自动化测试 + +- 前端:`npm run lint`(ESLint + Prettier + TypeScript 检查) + +### 手动验证 + +- 启动前后端,在侧边栏点击「镜像市场」,验证页面正常渲染 +- 验证分类筛选栏:点击各分类 tab,卡片正确过滤 +- 验证卡片点击:点击整张卡片后跳转到容器列表页,创建弹窗自动弹出且镜像已预填 +- 创建弹窗内完成创建,验证容器使用了预选的镜像 +- 手动刷新 `/registry` 页面,验证数据正常重新加载 +- 验证响应式布局:桌面端 3 列 → 平板 2 列 → 手机 1 列 +- 验证 i18n:切换中英文,所有新增文案正确显示 +- 验证空状态:后端返回空数组时,页面展示友好提示 diff --git a/docs/standards/frontend.md b/docs/standards/frontend.md index d2904af..3702197 100644 --- a/docs/standards/frontend.md +++ b/docs/standards/frontend.md @@ -67,7 +67,9 @@ ## 6. 路由与鉴权 -- 当前仅包含基础路由 `/`,映射容器列表页。 +- 当前路由: + - `/` 映射容器列表页(`ContainerList.vue`) + - `/registry` 映射镜像市场页(`ImageRegistry.vue`) - 当前版本无登录鉴权。 - 新增页面时要求: - 在 `router/index.ts` 显式注册路由 diff --git a/frontend/src/components/Sidebar.vue b/frontend/src/components/Sidebar.vue index f379614..57e0537 100644 --- a/frontend/src/components/Sidebar.vue +++ b/frontend/src/components/Sidebar.vue @@ -30,6 +30,14 @@ const isOpen = ref(false); + + + + @@ -54,6 +62,14 @@ const isOpen = ref(false); + + + + diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index 3915270..2c9d085 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -46,7 +46,15 @@ "unknown": "An unknown error occurred. Please try again later." }, "sidebar": { - "containerList": "Containers" + "containerList": "Containers", + "imageRegistry": "Images" + }, + "registry": { + "title": "Image Marketplace", + "filterAll": "All", + "emptyState": "No images available.", + "loading": "Loading image list...", + "loadError": "Failed to load image list. Please try again later." }, "topbar": { "switchLanguage": "Switch Language", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 8bb8c4c..01cb977 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -46,7 +46,15 @@ "unknown": "发生未知错误,请稍后重试。" }, "sidebar": { - "containerList": "容器列表" + "containerList": "容器列表", + "imageRegistry": "镜像市场" + }, + "registry": { + "title": "镜像市场", + "filterAll": "全部", + "emptyState": "暂无可用镜像。", + "loading": "正在加载镜像列表...", + "loadError": "镜像列表加载失败,请稍后重试。" }, "topbar": { "switchLanguage": "切换语言", diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index e276063..78059f3 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -9,6 +9,11 @@ const router = createRouter({ name: "ContainerList", component: ContainerList, }, + { + path: "/registry", + name: "ImageRegistry", + component: () => import("../views/ImageRegistry.vue"), + }, ], }); diff --git a/frontend/src/stores/registry.ts b/frontend/src/stores/registry.ts index c438f38..184b00e 100644 --- a/frontend/src/stores/registry.ts +++ b/frontend/src/stores/registry.ts @@ -1,19 +1,65 @@ -import { ref } from "vue"; +import { computed, ref } from "vue"; import { defineStore } from "pinia"; import type { RegistryImage } from "../api/index"; import { listRegistryImages } from "../api/index"; +const DEFAULT_CACHE_WINDOW_MS = 60_000; + +type FetchImagesOptions = { + force?: boolean; + cacheWindowMs?: number; +}; + export const useRegistryStore = defineStore("registry", () => { const images = ref([]); const loading = ref(false); + const error = ref(null); + const hasFetched = ref(false); + const fetchedAt = ref(0); + let inflightRequest: Promise | null = null; - async function fetchImages(): Promise { - loading.value = true; - try { - images.value = await listRegistryImages(); - } finally { - loading.value = false; + const categories = computed(() => { + const values = new Set(); + for (const image of images.value) { + const category = image.category?.trim(); + if (category) { + values.add(category); + } + } + return Array.from(values); + }); + + async function fetchImages(options: FetchImagesOptions = {}): Promise { + const { force = false, cacheWindowMs = DEFAULT_CACHE_WINDOW_MS } = options; + const cacheAge = Date.now() - fetchedAt.value; + const canUseCache = hasFetched.value && cacheAge < cacheWindowMs; + + if (!force && canUseCache) { + return; } + + if (inflightRequest) { + return inflightRequest; + } + + loading.value = true; + error.value = null; + + inflightRequest = (async () => { + try { + images.value = await listRegistryImages(); + hasFetched.value = true; + fetchedAt.value = Date.now(); + } catch (err) { + error.value = err; + throw err; + } finally { + loading.value = false; + inflightRequest = null; + } + })(); + + return inflightRequest; } function getById(id: string): RegistryImage | undefined { @@ -27,6 +73,9 @@ export const useRegistryStore = defineStore("registry", () => { return { images, loading, + error, + hasFetched, + categories, fetchImages, getById, }; diff --git a/frontend/src/views/ContainerList.vue b/frontend/src/views/ContainerList.vue index a11ac59..adec7e0 100644 --- a/frontend/src/views/ContainerList.vue +++ b/frontend/src/views/ContainerList.vue @@ -1,10 +1,13 @@ + + + + From 2d12a42b77d6e3145f14575ab2db3d80508a7987 Mon Sep 17 00:00:00 2001 From: mf1bzz-desktop Date: Wed, 1 Apr 2026 17:57:06 +0800 Subject: [PATCH 24/28] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=AE=B9?= =?UTF-8?q?=E5=99=A8=E8=BF=90=E8=A1=8C=E7=8A=B6=E6=80=81=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Readme.md | 2 +- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/api/handlers_test.go | 4 +- backend/internal/api/router.go | 15 +- backend/internal/api/ws_handler.go | 46 +++ backend/internal/api/ws_handler_test.go | 85 +++++ backend/internal/service/event_hub.go | 317 +++++++++++++++++ backend/internal/service/event_hub_test.go | 175 ++++++++++ backend/main.go | 10 +- docs/api/contracts.md | 17 + docs/design-docs/instance_lifecycle.md | 3 +- .../active/20260401-realtime-status-sync.md | 330 ++++++++++++++++++ frontend/src/api/index.ts | 18 + frontend/src/components/TopBar.vue | 101 ++++-- frontend/src/composables/useInstanceSync.ts | 178 ++++++++++ frontend/src/locales/en-US.json | 2 + frontend/src/locales/zh-CN.json | 2 + frontend/src/stores/containers.ts | 38 +- frontend/src/views/ContainerList.vue | 9 +- frontend/vite.config.js | 10 +- 21 files changed, 1324 insertions(+), 41 deletions(-) create mode 100644 backend/internal/api/ws_handler.go create mode 100644 backend/internal/api/ws_handler_test.go create mode 100644 backend/internal/service/event_hub.go create mode 100644 backend/internal/service/event_hub_test.go create mode 100644 docs/exec-plans/active/20260401-realtime-status-sync.md create mode 100644 frontend/src/composables/useInstanceSync.ts diff --git a/Readme.md b/Readme.md index 0397bc2..b684028 100644 --- a/Readme.md +++ b/Readme.md @@ -20,7 +20,7 @@ task dev # 一键启动前后端开发服务 ### 实例生命周期 - [x] 容器基础生命周期:创建、删除、开启、停止 -- [ ] 容器运行状态实时同步 +- [x] 容器运行状态实时同步 - [ ] 创建容器时支持镜像选择 - [ ] 创建容器时支持环境变量注入 - [ ] 创建容器时支持 Volume 持久化目录挂载 diff --git a/backend/go.mod b/backend/go.mod index 880d95e..1a2c65b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,6 +3,7 @@ module minedock/backend go 1.25.0 require ( + github.com/coder/websocket v1.8.14 github.com/docker/docker v28.0.1+incompatible modernc.org/sqlite v1.47.0 ) diff --git a/backend/go.sum b/backend/go.sum index 3064815..9361e19 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -6,6 +6,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/backend/internal/api/handlers_test.go b/backend/internal/api/handlers_test.go index 508ea2d..68f5783 100644 --- a/backend/internal/api/handlers_test.go +++ b/backend/internal/api/handlers_test.go @@ -54,7 +54,7 @@ func newTestRouter(m *mockService) http.Handler { return []model.RegistryImage{} }, }) - return NewRouter(h, rh) + return NewRouter(h, rh, nil) } // --- GET /api/instances 场景 --- @@ -108,7 +108,7 @@ func TestGetRegistryImages_Success(t *testing.T) { return []model.RegistryImage{{ID: "minecraft-java", Image: "itzg/minecraft-server:latest"}} }, }) - router := NewRouter(h, rh) + router := NewRouter(h, rh, nil) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/api/registry/images", nil) diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index d73bc2c..b69b3bb 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -1,9 +1,12 @@ package api -import "net/http" +import ( + "net/http" + "strings" +) // NewRouter 注册 API 路由并包装中间件。 -func NewRouter(h *Handler, registry *RegistryHandler) http.Handler { +func NewRouter(h *Handler, registry *RegistryHandler, ws *WsHandler) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /api/instances", h.GetInstances) @@ -11,6 +14,9 @@ func NewRouter(h *Handler, registry *RegistryHandler) http.Handler { mux.HandleFunc("POST /api/instances/{id}/start", h.StartInstance) mux.HandleFunc("POST /api/instances/{id}/stop", h.StopInstance) mux.HandleFunc("DELETE /api/instances/{id}", h.DeleteInstance) + if ws != nil { + mux.HandleFunc("GET /api/ws/events", ws.HandleEvents) + } if registry != nil { mux.HandleFunc("GET /api/registry/images", registry.GetImages) } @@ -21,6 +27,11 @@ func NewRouter(h *Handler, registry *RegistryHandler) http.Handler { // withCORS 添加宽松的 CORS 响应头并处理 OPTIONS 预检请求。 func withCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/ws/") { + next.ServeHTTP(w, r) + return + } + w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") diff --git a/backend/internal/api/ws_handler.go b/backend/internal/api/ws_handler.go new file mode 100644 index 0000000..7c53a9d --- /dev/null +++ b/backend/internal/api/ws_handler.go @@ -0,0 +1,46 @@ +package api + +import ( + "net/http" + + "github.com/coder/websocket" +) + +// EventBroadcaster 定义 WebSocket Handler 依赖的事件广播操作。 +type EventBroadcaster interface { + AddClient(conn *websocket.Conn) + RemoveClient(conn *websocket.Conn) +} + +// WsHandler 暴露 WebSocket 相关 HTTP 处理器。 +type WsHandler struct { + hub EventBroadcaster +} + +// NewWsHandler 创建 WsHandler。 +func NewWsHandler(hub EventBroadcaster) *WsHandler { + return &WsHandler{hub: hub} +} + +// HandleEvents 处理 GET /api/ws/events,将 HTTP 连接升级为 WebSocket。 +func (h *WsHandler) HandleEvents(w http.ResponseWriter, r *http.Request) { + if h == nil || h.hub == nil { + writeJSON(w, http.StatusServiceUnavailable, statusResponse{Status: "error", Error: "event hub unavailable"}) + return + } + + // 仅支持同源 WebSocket,保持默认 Origin 校验行为。 + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{}) + if err != nil { + return + } + + h.hub.AddClient(conn) + defer h.hub.RemoveClient(conn) + + for { + if _, _, err := conn.Read(r.Context()); err != nil { + return + } + } +} diff --git a/backend/internal/api/ws_handler_test.go b/backend/internal/api/ws_handler_test.go new file mode 100644 index 0000000..504f44d --- /dev/null +++ b/backend/internal/api/ws_handler_test.go @@ -0,0 +1,85 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" +) + +type mockEventBroadcaster struct { + mu sync.Mutex + addCount int + removeCount int + addCh chan struct{} + removeCh chan struct{} +} + +func (m *mockEventBroadcaster) AddClient(_ *websocket.Conn) { + m.mu.Lock() + m.addCount++ + m.mu.Unlock() + select { + case m.addCh <- struct{}{}: + default: + } +} + +func (m *mockEventBroadcaster) RemoveClient(_ *websocket.Conn) { + m.mu.Lock() + m.removeCount++ + m.mu.Unlock() + select { + case m.removeCh <- struct{}{}: + default: + } +} + +func TestWsHandler_HubUnavailable(t *testing.T) { + h := NewWsHandler(nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/ws/events", nil) + + h.HandleEvents(w, r) + + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", w.Code) + } +} + +func TestWsHandler_HandleEvents_AddAndRemoveClient(t *testing.T) { + mockHub := &mockEventBroadcaster{ + addCh: make(chan struct{}, 1), + removeCh: make(chan struct{}, 1), + } + + server := httptest.NewServer(http.HandlerFunc(NewWsHandler(mockHub).HandleEvents)) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + conn, _, err := websocket.Dial(context.Background(), wsURL, nil) + if err != nil { + t.Fatalf("dial websocket: %v", err) + } + + select { + case <-mockHub.addCh: + case <-time.After(time.Second): + t.Fatal("timeout waiting AddClient") + } + + if err := conn.Close(websocket.StatusNormalClosure, "test done"); err != nil { + t.Fatalf("close websocket: %v", err) + } + + select { + case <-mockHub.removeCh: + case <-time.After(time.Second): + t.Fatal("timeout waiting RemoveClient") + } +} diff --git a/backend/internal/service/event_hub.go b/backend/internal/service/event_hub.go new file mode 100644 index 0000000..24dc292 --- /dev/null +++ b/backend/internal/service/event_hub.go @@ -0,0 +1,317 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "sort" + "sync" + "time" + + "github.com/coder/websocket" + "github.com/docker/docker/api/types/events" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/client" + + "minedock/backend/internal/model" +) + +const ( + eventHubDebounceWindow = 150 * time.Millisecond + eventHubWriteTimeout = 3 * time.Second + eventHubMaxBackoff = 30 * time.Second +) + +// dockerEventClient 定义 EventHub 依赖的 Docker Events 能力。 +type dockerEventClient interface { + Events(ctx context.Context, options events.ListOptions) (<-chan events.Message, <-chan error) +} + +type instancesUpdatedMessage struct { + Type string `json:"type"` + Data []model.Instance `json:"data"` +} + +// EventHub 管理 WebSocket 连接并将 Docker 事件广播给所有客户端。 +type EventHub struct { + cli dockerEventClient + listFn func(ctx context.Context) ([]model.Instance, error) + + mu sync.RWMutex + clients map[*websocket.Conn]struct{} + lastSnapshot []byte + lastPayload []byte + + debounceWindow time.Duration + writeTimeout time.Duration +} + +// NewEventHub 创建 EventHub。 +// listFn 是获取完整实例列表的回调(注入 DockerService.ListInstances)。 +func NewEventHub(cli *client.Client, listFn func(ctx context.Context) ([]model.Instance, error)) *EventHub { + return newEventHub(cli, listFn) +} + +func newEventHub(cli dockerEventClient, listFn func(ctx context.Context) ([]model.Instance, error)) *EventHub { + return &EventHub{ + cli: cli, + listFn: listFn, + clients: make(map[*websocket.Conn]struct{}), + debounceWindow: eventHubDebounceWindow, + writeTimeout: eventHubWriteTimeout, + } +} + +// Run 启动 Docker Events 监听循环,ctx 取消时退出。 +func (h *EventHub) Run(ctx context.Context) { + if h == nil { + return + } + defer h.closeAllClients(websocket.StatusGoingAway, "server shutdown") + + backoff := time.Second + for { + healthy, err := h.runOnce(ctx) + if ctx.Err() != nil { + return + } + if healthy { + backoff = time.Second + } + if err != nil { + log.Printf("event hub run once: %v", err) + } + + timer := time.NewTimer(backoff) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + + if backoff < eventHubMaxBackoff { + backoff *= 2 + if backoff > eventHubMaxBackoff { + backoff = eventHubMaxBackoff + } + } + } +} + +func (h *EventHub) runOnce(ctx context.Context) (bool, error) { + if h.cli == nil { + return false, fmt.Errorf("docker client is nil") + } + if h.listFn == nil { + return false, fmt.Errorf("list function is nil") + } + + healthy := false + + args := filters.NewArgs() + args.Add("type", "container") + args.Add("label", managedLabelKey+"="+managedLabelValue) + for _, action := range []string{"start", "stop", "die", "destroy", "kill"} { + args.Add("event", action) + } + + msgCh, errCh := h.cli.Events(ctx, events.ListOptions{Filters: args}) + + // 每次连接(含重连)成功后立即推送一次全量快照。 + if err := h.refreshAndBroadcast(ctx); err != nil && ctx.Err() == nil { + log.Printf("event hub initial snapshot: %v", err) + } else if err == nil { + healthy = true + } + + timer := time.NewTimer(time.Hour) + if !timer.Stop() { + <-timer.C + } + timerCh := (<-chan time.Time)(nil) + pending := false + + for { + select { + case <-ctx.Done(): + if timerCh != nil { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + } + return healthy, nil + case _, ok := <-msgCh: + if !ok { + return healthy, fmt.Errorf("docker events channel closed") + } + pending = true + healthy = true + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(h.debounceWindow) + timerCh = timer.C + case err, ok := <-errCh: + if !ok { + return healthy, fmt.Errorf("docker events error channel closed") + } + if err != nil { + return healthy, fmt.Errorf("docker events stream error: %w", err) + } + case <-timerCh: + timerCh = nil + if !pending { + continue + } + pending = false + if err := h.refreshAndBroadcast(ctx); err != nil && ctx.Err() == nil { + log.Printf("event hub broadcast snapshot: %v", err) + } else if err == nil { + healthy = true + } + } + } +} + +func (h *EventHub) refreshAndBroadcast(ctx context.Context) error { + instances, err := h.listFn(ctx) + if err != nil { + return fmt.Errorf("list instances: %w", err) + } + + snapshot, payload, err := buildEventPayload(instances) + if err != nil { + return fmt.Errorf("marshal snapshot: %w", err) + } + + if h.shouldSkipSnapshot(snapshot) { + return nil + } + + h.updateSnapshot(snapshot, payload) + h.broadcastPayload(payload) + return nil +} + +func buildEventPayload(instances []model.Instance) ([]byte, []byte, error) { + ordered := append([]model.Instance(nil), instances...) + sort.Slice(ordered, func(i, j int) bool { + if ordered[i].ContainerID != ordered[j].ContainerID { + return ordered[i].ContainerID < ordered[j].ContainerID + } + return ordered[i].Name < ordered[j].Name + }) + + snapshot, err := json.Marshal(ordered) + if err != nil { + return nil, nil, err + } + + payload, err := json.Marshal(instancesUpdatedMessage{Type: "instances_updated", Data: ordered}) + if err != nil { + return nil, nil, err + } + + return snapshot, payload, nil +} + +func (h *EventHub) shouldSkipSnapshot(snapshot []byte) bool { + h.mu.RLock() + defer h.mu.RUnlock() + return bytes.Equal(h.lastSnapshot, snapshot) +} + +func (h *EventHub) updateSnapshot(snapshot []byte, payload []byte) { + h.mu.Lock() + h.lastSnapshot = append([]byte(nil), snapshot...) + h.lastPayload = append([]byte(nil), payload...) + h.mu.Unlock() +} + +// AddClient 注册一个 WebSocket 客户端连接。 +func (h *EventHub) AddClient(conn *websocket.Conn) { + if h == nil || conn == nil { + return + } + + h.mu.Lock() + h.clients[conn] = struct{}{} + payload := append([]byte(nil), h.lastPayload...) + h.mu.Unlock() + + if len(payload) == 0 { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), h.writeTimeout) + defer cancel() + if err := conn.Write(ctx, websocket.MessageText, payload); err != nil { + h.removeClient(conn, websocket.StatusInternalError, "send initial snapshot failed") + } +} + +// RemoveClient 移除一个 WebSocket 客户端连接。 +func (h *EventHub) RemoveClient(conn *websocket.Conn) { + if h == nil || conn == nil { + return + } + h.removeClient(conn, websocket.StatusNormalClosure, "client disconnected") +} + +func (h *EventHub) removeClient(conn *websocket.Conn, code websocket.StatusCode, reason string) { + h.mu.Lock() + _, exists := h.clients[conn] + if exists { + delete(h.clients, conn) + } + h.mu.Unlock() + + if exists { + _ = conn.Close(code, reason) + } +} + +func (h *EventHub) broadcastPayload(payload []byte) { + clients := h.snapshotClients() + for _, conn := range clients { + ctx, cancel := context.WithTimeout(context.Background(), h.writeTimeout) + err := conn.Write(ctx, websocket.MessageText, payload) + cancel() + if err != nil { + h.removeClient(conn, websocket.StatusInternalError, "write failed") + } + } +} + +func (h *EventHub) snapshotClients() []*websocket.Conn { + h.mu.RLock() + defer h.mu.RUnlock() + clients := make([]*websocket.Conn, 0, len(h.clients)) + for conn := range h.clients { + clients = append(clients, conn) + } + return clients +} + +func (h *EventHub) closeAllClients(code websocket.StatusCode, reason string) { + h.mu.Lock() + clients := make([]*websocket.Conn, 0, len(h.clients)) + for conn := range h.clients { + clients = append(clients, conn) + } + h.clients = make(map[*websocket.Conn]struct{}) + h.mu.Unlock() + + for _, conn := range clients { + _ = conn.Close(code, reason) + } +} diff --git a/backend/internal/service/event_hub_test.go b/backend/internal/service/event_hub_test.go new file mode 100644 index 0000000..58e1c60 --- /dev/null +++ b/backend/internal/service/event_hub_test.go @@ -0,0 +1,175 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "minedock/backend/internal/model" +) + +func TestEventHub_AddRemoveClient(t *testing.T) { + hub := newEventHub(nil, nil) + serverConn, clientConn, cleanup := newWebSocketPair(t) + defer cleanup() + + hub.AddClient(serverConn) + if got := len(hub.snapshotClients()); got != 1 { + t.Fatalf("expected 1 client, got %d", got) + } + + hub.RemoveClient(serverConn) + if got := len(hub.snapshotClients()); got != 0 { + t.Fatalf("expected 0 clients, got %d", got) + } + + readCtx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + _, _, err := clientConn.Read(readCtx) + if websocket.CloseStatus(err) != websocket.StatusNormalClosure { + t.Fatalf("expected normal closure, got %v", err) + } +} + +func TestEventHub_RefreshAndBroadcast_DeduplicateSnapshot(t *testing.T) { + instances := []model.Instance{{ContainerID: "c1", Name: "alpha", Status: "Running"}} + hub := newEventHub(nil, func(context.Context) ([]model.Instance, error) { + return append([]model.Instance(nil), instances...), nil + }) + + serverConn, clientConn, cleanup := newWebSocketPair(t) + defer cleanup() + hub.AddClient(serverConn) + + if err := hub.refreshAndBroadcast(context.Background()); err != nil { + t.Fatalf("first refresh: %v", err) + } + + first := readWSMessage(t, clientConn) + assertInstancesUpdatedPayload(t, first, "Running") + + nextMessageCh := readWSMessageAsync(clientConn) + + if err := hub.refreshAndBroadcast(context.Background()); err != nil { + t.Fatalf("duplicate refresh: %v", err) + } + + select { + case result := <-nextMessageCh: + if result.err != nil { + t.Fatalf("unexpected websocket error: %v", result.err) + } + t.Fatalf("expected no message for duplicate snapshot, got %s", string(result.payload)) + case <-time.After(120 * time.Millisecond): + } + + instances[0].Status = "Stopped" + if err := hub.refreshAndBroadcast(context.Background()); err != nil { + t.Fatalf("third refresh: %v", err) + } + + select { + case result := <-nextMessageCh: + if result.err != nil { + t.Fatalf("read websocket payload: %v", result.err) + } + assertInstancesUpdatedPayload(t, result.payload, "Stopped") + case <-time.After(time.Second): + t.Fatal("timeout waiting message after snapshot change") + } +} + +func newWebSocketPair(t *testing.T) (*websocket.Conn, *websocket.Conn, func()) { + t.Helper() + + serverConnCh := make(chan *websocket.Conn, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + t.Errorf("accept websocket: %v", err) + return + } + serverConnCh <- conn + for { + if _, _, err := conn.Read(r.Context()); err != nil { + return + } + } + })) + + clientURL := "ws" + strings.TrimPrefix(server.URL, "http") + clientConn, _, err := websocket.Dial(context.Background(), clientURL, nil) + if err != nil { + server.Close() + t.Fatalf("dial websocket: %v", err) + } + + var serverConn *websocket.Conn + select { + case serverConn = <-serverConnCh: + case <-time.After(time.Second): + _ = clientConn.Close(websocket.StatusAbnormalClosure, "timeout") + server.Close() + t.Fatal("timeout waiting for server websocket connection") + } + + cleanup := func() { + _ = clientConn.Close(websocket.StatusNormalClosure, "test cleanup") + _ = serverConn.Close(websocket.StatusNormalClosure, "test cleanup") + server.Close() + } + + return serverConn, clientConn, cleanup +} + +func readWSMessage(t *testing.T, conn *websocket.Conn) []byte { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _, payload, err := conn.Read(ctx) + if err != nil { + t.Fatalf("read websocket payload: %v", err) + } + return payload +} + +type wsReadResult struct { + payload []byte + err error +} + +func readWSMessageAsync(conn *websocket.Conn) <-chan wsReadResult { + ch := make(chan wsReadResult, 1) + go func() { + _, payload, err := conn.Read(context.Background()) + ch <- wsReadResult{payload: payload, err: err} + }() + return ch +} + +func assertInstancesUpdatedPayload(t *testing.T, payload []byte, wantStatus string) { + t.Helper() + + var msg struct { + Type string `json:"type"` + Data []model.Instance `json:"data"` + } + if err := json.Unmarshal(payload, &msg); err != nil { + t.Fatalf("decode websocket payload: %v", err) + } + if msg.Type != "instances_updated" { + t.Fatalf("unexpected message type: %s", msg.Type) + } + if len(msg.Data) != 1 { + t.Fatalf("unexpected data size: %d", len(msg.Data)) + } + if msg.Data[0].Status != wantStatus { + t.Fatalf("unexpected status: %s", msg.Data[0].Status) + } +} diff --git a/backend/main.go b/backend/main.go index 9d2182f..76a2694 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "log" "net/http" "os" @@ -40,10 +41,17 @@ func main() { log.Fatalf("init registry service: %v", err) } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + svc := service.NewDockerService(cli, sqliteStore, registrySvc) + hub := service.NewEventHub(cli, svc.ListInstances) + go hub.Run(ctx) + h := api.NewHandler(svc) registryHandler := api.NewRegistryHandler(registrySvc) - router := api.NewRouter(h, registryHandler) + wsHandler := api.NewWsHandler(hub) + router := api.NewRouter(h, registryHandler, wsHandler) addr := ":8080" log.Printf("MineDock backend listening on %s", addr) diff --git a/docs/api/contracts.md b/docs/api/contracts.md index c777f7b..cff9459 100644 --- a/docs/api/contracts.md +++ b/docs/api/contracts.md @@ -102,3 +102,20 @@ ```json { "status": "success" } ``` + +### GET /api/ws/events (WebSocket) + +- 说明:建立 WebSocket 连接,实时接收容器状态变更推送 +- 协议:WebSocket(HTTP Upgrade) +- 同源限制:仅支持同源连接(`Origin` 必须与请求 `Host` 一致),当前版本不支持跨域 WebSocket +- 消息格式(服务端 -> 客户端): + +```json +{ + "type": "instances_updated", + "data": [{ "container_id": "xxx", "name": "xxx", "status": "Running" }] +} +``` + +- 触发时机:任一托管容器状态发生变化(`start` / `stop` / `die` / `destroy` / `kill`) +- 降级方案:客户端连接失败时应回退到轮询 `GET /api/instances` diff --git a/docs/design-docs/instance_lifecycle.md b/docs/design-docs/instance_lifecycle.md index 73e136a..20235b0 100644 --- a/docs/design-docs/instance_lifecycle.md +++ b/docs/design-docs/instance_lifecycle.md @@ -43,7 +43,8 @@ stateDiagram-v2 - 事实来源:Docker Daemon。 - 缓存来源:SQLite(用于实例名、状态缓存和恢复)。 -- 收敛机制:`ListInstances` 周期性/调用时同步 Docker -> SQLite。 +- 收敛机制(主路径):监听 Docker Events,在容器状态变化后通过 WebSocket 推送最新实例快照。 +- 收敛机制(降级路径):`ListInstances` 按需对账 Docker -> SQLite;当前端 WebSocket 不可用时回退到轮询接口。 ## 失败与回滚策略 diff --git a/docs/exec-plans/active/20260401-realtime-status-sync.md b/docs/exec-plans/active/20260401-realtime-status-sync.md new file mode 100644 index 0000000..b9e4908 --- /dev/null +++ b/docs/exec-plans/active/20260401-realtime-status-sync.md @@ -0,0 +1,330 @@ +# 容器运行状态实时同步(WebSocket + Docker Events) + +## 背景 + +当前 MineDock 的容器状态同步依赖请求驱动的拉取模式:前端每次执行操作(create / start / stop / delete)后主动调用 `fetchInstances()` 刷新列表,后端 `ListInstances` 遍历 Docker API 并逐条同步到 SQLite。这意味着: + +- **外部状态变更不可见**:若容器被 Docker CLI / Portainer 等外部工具操作(或容器自行崩溃退出),前端无法感知,直到用户手动触发刷新 +- **状态延迟**:start / stop 操作返回 HTTP 200 时,Docker 可能尚未完成状态切换,前端显示的状态可能短暂不一致 +- **轮询浪费**:若改为定时轮询弥补,会产生大量无意义的 HTTP 请求 + +本计划引入 **WebSocket + Docker Events** 方案:后端启动一个常驻 Goroutine 监听 Docker Events(`container start`、`stop`、`die` 等),当托管容器状态变化时,通过 WebSocket 将增量事件推送给所有已连接的前端客户端,实现实时状态同步。 + +## 需要评审的内容 + +> [!IMPORTANT] +> **WebSocket 库选型** +> +> Go 标准库不内置 WebSocket 支持。推荐使用 `github.com/coder/websocket`(`nhooyr.io/websocket` 的社区延续版本,API 兼容、`net/http` 原生兼容、支持 context 取消)。也可选择 `github.com/gorilla/websocket`(社区更成熟但已归档维护模式)。 +> +> 本计划默认使用 `github.com/coder/websocket`。 + +> [!IMPORTANT] +> **推送消息格式** +> +> WebSocket 推送的消息为 JSON,采用最简的「全量快照」模式:当任一托管容器状态变化时,后端重新查询完整实例列表并推送给所有客户端。这与当前 `GET /api/instances` 返回格式一致。 +> +> ```json +> { +> "type": "instances_updated", +> "data": [ +> { "container_id": "xxx", "name": "xxx", "status": "Running" }, +> { "container_id": "yyy", "name": "yyy", "status": "Stopped" } +> ] +> } +> ``` +> +> 选择全量快照而非增量事件的原因: +> +> - 前端无需维护复杂的增量合并逻辑,直接替换 `instances` 数组 +> - 容器数量有限(游戏服务器场景通常 < 50),全量传输开销可忽略 +> - 避免增量事件丢失或乱序导致的状态不一致 + +> [!IMPORTANT] +> **前端降级策略** +> +> WebSocket 连接失败或断线时,前端自动降级为定时轮询(如每 5 秒一次 `GET /api/instances`),恢复连接后停止轮询。这保证即使 WebSocket 不可用,功能依然正常。 + +## 拟定更改 + +### 后端 Service 层 + +#### [NEW] event_hub.go (`backend/internal/service/event_hub.go`) + +新增 `EventHub`,职责:管理 WebSocket 客户端连接、监听 Docker Events、广播状态变更。 + +```go +// EventHub 管理 WebSocket 连接并将 Docker 事件广播给所有客户端。 +type EventHub struct { + cli *client.Client + listFn func(ctx context.Context) ([]model.Instance, error) + mu sync.RWMutex + clients map[*websocket.Conn]struct{} + lastSnapshot []byte // 上一次广播的 JSON 快照,用于去重比对 +} + +// NewEventHub 创建 EventHub。 +// listFn 是获取完整实例列表的回调(注入 DockerService.ListInstances)。 +func NewEventHub(cli *client.Client, listFn func(ctx context.Context) ([]model.Instance, error)) *EventHub { ... } + +// Run 启动 Docker Events 监听循环,ctx 取消时退出。 +func (h *EventHub) Run(ctx context.Context) { ... } + +// AddClient 注册一个 WebSocket 客户端连接。 +func (h *EventHub) AddClient(conn *websocket.Conn) { ... } + +// RemoveClient 移除一个 WebSocket 客户端连接。 +func (h *EventHub) RemoveClient(conn *websocket.Conn) { ... } +``` + +核心逻辑: + +- **`Run` 方法**: + - 调用 `cli.Events(ctx, events.ListOptions{...})` 获取 Docker Events 流 + - 通过 `filters` 仅监听 `container` 类型、`start / stop / die / destroy / kill` 动作、带有 `minedock.managed=true` 标签的容器 + - 收到事件后,调用 `listFn(ctx)` 获取最新实例列表 + - **快照去重**:将列表序列化为 JSON 后与上一次广播的 `lastSnapshot []byte` 对比,内容完全一致则跳过本次推送,避免无意义的全量传输 + - 内容变化时,将列表封装为 `{ "type": "instances_updated", "data": [...] }` JSON + - 遍历所有 `clients`,写入 WebSocket 消息;写入失败则移除该连接 + - **防抖**:短时间内收到多个事件时(如 stop 后紧接 die),合并为一次推送(100~200ms 窗口) + +- **退出机制**:`ctx` 取消时,Events 流自动关闭,`Run` 退出。关闭所有客户端连接。 + +- **重连**:Docker Events 流断开时(网络闪断、Docker 重启),自动重连(指数退避,最大间隔 30s),并在重连后立即推送一次全量快照。 + +--- + +### 后端 API 层 + +#### [NEW] ws_handler.go (`backend/internal/api/ws_handler.go`) + +新增 WebSocket Handler: + +```go +// EventBroadcaster 定义 WebSocket Handler 依赖的事件广播操作。 +type EventBroadcaster interface { + AddClient(conn *websocket.Conn) + RemoveClient(conn *websocket.Conn) +} + +// WsHandler 暴露 WebSocket 相关 HTTP 处理器。 +type WsHandler struct { + hub EventBroadcaster +} + +// NewWsHandler 创建 WsHandler。 +func NewWsHandler(hub EventBroadcaster) *WsHandler { ... } + +// HandleEvents 处理 GET /api/ws/events,将 HTTP 连接升级为 WebSocket。 +func (h *WsHandler) HandleEvents(w http.ResponseWriter, r *http.Request) { ... } +``` + +`HandleEvents` 逻辑: + +1. 使用 `websocket.Accept(w, r, &websocket.AcceptOptions{...})` 升级连接 +2. 调用 `hub.AddClient(conn)` 注册连接 +3. 阻塞读循环(等待客户端关闭或 ctx 取消) +4. `defer hub.RemoveClient(conn)` 清理 + +#### [MODIFY] router.go (`backend/internal/api/router.go`) + +- `NewRouter` 签名变更:增加 `*WsHandler` 参数 +- 注册新路由:`GET /api/ws/events`(不受 `withCORS` 中间件影响,WebSocket 有自身的 Origin 校验) + +--- + +### 后端入口 + +#### [MODIFY] main.go + +- 创建 `EventHub`,注入 Docker client 和 `DockerService.ListInstances` +- 启动 `EventHub.Run` Goroutine(使用 `context.WithCancel` 控制生命周期) +- 创建 `WsHandler` 并注入 `EventHub` +- 更新 `NewRouter` 调用 + +```go +// main.go 新增部分(伪代码) +ctx, cancel := context.WithCancel(context.Background()) +defer cancel() + +hub := service.NewEventHub(cli, svc.ListInstances) +go hub.Run(ctx) + +wsHandler := api.NewWsHandler(hub) +router := api.NewRouter(h, registryHandler, wsHandler) +``` + +--- + +### 后端依赖 + +#### [MODIFY] go.mod + +- 新增依赖:`github.com/coder/websocket` + +--- + +### 前端 Composable 层 + +#### [NEW] useInstanceSync.ts (`frontend/src/composables/useInstanceSync.ts`) + +新增 Composable,封装原生 WebSocket 连接管理和降级轮询逻辑。 + +> [!NOTE] +> 本计划使用原生 `new WebSocket()` API 自实现,**不引入 VueUse 的 `useWebSocket`**。原因: +> +> - 当前项目未依赖 VueUse,仅为一个 Composable 引入整个库不合算 +> - 自实现可以精确控制降级轮询、重连退避、store 集成等业务逻辑 +> - 原生 WebSocket API 本身已足够简洁,封装量很小 + +```typescript +// useInstanceSync 管理与后端的 WebSocket 实时连接, +// 收到 instances_updated 消息时自动更新 container store。 +// 连接失败或断开时降级为定时轮询。 +export function useInstanceSync(): { + /** WebSocket 连接状态 */ + connected: Ref; + /** 启动同步(在 onMounted 中调用) */ + start: () => void; + /** 停止同步(在 onUnmounted 中调用) */ + stop: () => void; +}; +``` + +核心逻辑: + +- **WebSocket URL 构建**:根据当前页面协议自动选择 `ws://` 或 `wss://`,路径为 `/api/ws/events` +- **连接成功**:设置 `connected = true`,停止轮询定时器 +- **收到消息**:解析 JSON,若 `type === "instances_updated"`,直接将 `data` 写入 `useContainerStore().instances` +- **连接断开 / 失败**:设置 `connected = false`,启动降级轮询(每 5 秒调用 `fetchInstances()`),并启动重连定时器(指数退避 1s → 2s → 4s → ... → 最大 30s) +- **重连成功**:停止轮询,重新进入 WebSocket 模式 +- **`stop`**:关闭 WebSocket 连接,清除所有定时器 + +#### [MODIFY] containers.ts (`frontend/src/stores/containers.ts`) + +- 新增 `applySnapshot(instances: Instance[])` 方法:直接替换 `instances` 数组(供 WebSocket 消息使用,绕过 `fetchInstances` 的 HTTP 调用) +- 现有 `create` / `remove` / `toggle` action 中的 `await fetchInstances()` 保留不变(WebSocket 推送会自然覆盖,但 HTTP 回调提供了及时的首次状态更新) + +--- + +### 前端视图层 + +#### [MODIFY] ContainerList.vue (`frontend/src/views/ContainerList.vue`) + +- 引入 `useInstanceSync` composable +- `onMounted` 中调用 `start()` +- `onUnmounted` 中调用 `stop()` + +#### [MODIFY] TopBar.vue (`frontend/src/components/TopBar.vue`) + +- 在语言切换按钮左侧新增 WebSocket 连接状态指示器: + - 绿色圆点(`connected === true`)+ tooltip「实时同步已连接」 + - 灰色圆点(`connected === false`)+ tooltip「实时同步已断开,轮询模式」 + - 圆点使用 CSS 变量 `--success` / `--text-muted` +- `useInstanceSync` 的 `connected` ref 需要在 store 或 composable 中暴露为全局可访问(因为 TopBar 和 ContainerList 是不同组件) + - 方案:在 `useContainerStore` 中新增 `wsConnected` ref,由 `useInstanceSync` 写入,TopBar 读取 + +--- + +### 前端 API 层 + +#### [MODIFY] index.ts (`frontend/src/api/index.ts`) + +- 新增 `WS_BASE_URL` 常量:根据 `VITE_API_BASE_URL` 和当前协议构建 WebSocket URL +- 新增 `WsMessage` 类型定义: + +```typescript +export interface WsInstancesUpdated { + type: "instances_updated"; + data: Instance[]; +} + +export type WsMessage = WsInstancesUpdated; +``` + +--- + +### 文档 + +#### [MODIFY] contracts.md (`docs/api/contracts.md`) + +新增 WebSocket 接口文档: + +````markdown +### GET /api/ws/events (WebSocket) + +- 说明:建立 WebSocket 连接,实时接收容器状态变更推送 +- 协议:WebSocket(HTTP Upgrade) +- 消息格式(服务端 → 客户端): + +\```json +{ +"type": "instances_updated", +"data": [{ "container_id": "xxx", "name": "xxx", "status": "Running" }] +} +\``` + +- 触发时机:任一托管容器状态发生变化(start / stop / die / destroy) +- 降级方案:客户端连接失败时应回退到轮询 GET /api/instances +```` + +#### [MODIFY] instance_lifecycle.md (`docs/design-docs/instance_lifecycle.md`) + +- 更新一致性策略:新增 Docker Events 实时推送机制说明 +- 收敛机制更新为:Docker Events 实时推送(主路径)+ `ListInstances` 按需对账(降级路径) + +--- + +## 执行步骤 + +- [ ] 后端依赖 + - [ ] 执行 `go get github.com/coder/websocket`,更新 `go.mod` 和 `go.sum` +- [ ] 后端 Service 层 + - [ ] 新建 `backend/internal/service/event_hub.go`,实现 `EventHub` + - [ ] 实现 Docker Events 监听循环(过滤托管容器、防抖合并、重连退避) + - [ ] 实现快照去重(`lastSnapshot` 对比,内容一致时跳过推送) + - [ ] 实现客户端连接管理(AddClient / RemoveClient / 广播) + - [ ] 编写 `event_hub_test.go` 单元测试(广播逻辑、客户端增删) +- [ ] 后端 API 层 + - [ ] 新建 `backend/internal/api/ws_handler.go`,实现 `WsHandler` + - [ ] 修改 `backend/internal/api/router.go`:注册 `GET /api/ws/events` 路由 +- [ ] 后端入口 + - [ ] 修改 `backend/main.go`:创建 `EventHub`、启动 Run Goroutine、创建 `WsHandler`、更新 Router +- [ ] 前端 API 层 + - [ ] 修改 `frontend/src/api/index.ts`:新增 `WS_BASE_URL` 和 `WsMessage` 类型 +- [ ] 前端 Composable 层 + - [ ] 新建 `frontend/src/composables/useInstanceSync.ts`,实现 WebSocket 连接管理和降级轮询 +- [ ] 前端 Store 层 + - [ ] 修改 `frontend/src/stores/containers.ts`:新增 `applySnapshot` 方法和 `wsConnected` ref +- [ ] 前端视图层 + - [ ] 修改 `frontend/src/views/ContainerList.vue`:接入 `useInstanceSync` + - [ ] 修改 `frontend/src/components/TopBar.vue`:新增 WebSocket 连接状态圆点指示器 +- [ ] 文档更新 + - [ ] 修改 `docs/api/contracts.md`:新增 WebSocket 接口文档 + - [ ] 修改 `docs/design-docs/instance_lifecycle.md`:更新一致性策略 + +## 已确认的决策 + +- ✅ WebSocket 库:使用 `github.com/coder/websocket` +- ✅ 连接状态 UI:在 TopBar 语言按钮左侧添加绿色/灰色圆点指示器 +- ✅ 快照去重:EventHub 缓存上一次广播的 JSON,内容一致时跳过推送 +- ✅ 前端 WebSocket:使用原生 `new WebSocket()` 自实现 composable,不引入 VueUse + +## 验证计划 + +### 自动化测试 + +- `task backend:test` — 覆盖: + - `EventHub`:客户端注册/移除、广播消息格式、并发安全 + - `WsHandler`:HTTP → WebSocket 升级 +- `task backend:vet && task backend:lint` +- 前端:`npm run lint` + +### 手动验证 + +- 启动前后端,打开容器列表页,验证 WebSocket 连接建立(DevTools → Network → WS) +- 通过 Docker CLI 执行 `docker stop `,验证前端状态**自动**更新为 Stopped(无需手动刷新) +- 通过 Docker CLI 执行 `docker start `,验证前端状态自动更新为 Running +- 断开 WebSocket(如关闭后端),验证前端降级为轮询模式 +- 重启后端,验证前端自动重连 WebSocket 并恢复实时推送 +- 同时打开两个浏览器标签页,验证状态变更在两个页面同步更新 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index b3dd974..866452b 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,6 +1,17 @@ const BASE_URL: string = (typeof import.meta !== "undefined" && import.meta.env?.VITE_API_BASE_URL) || "/api"; +const runtimeOrigin = typeof window === "undefined" ? "http://localhost" : window.location.origin; +const resolvedBaseURL: URL = new URL(BASE_URL, runtimeOrigin); + +// WebSocket 仅支持同源连接:固定使用当前页面 origin,只复用 API 基础路径。 +const pageProtocol = + typeof window === "undefined" ? resolvedBaseURL.protocol : window.location.protocol; +const wsProtocol = pageProtocol === "https:" ? "wss:" : "ws:"; +const wsHost = typeof window === "undefined" ? resolvedBaseURL.host : window.location.host; +const wsPath = resolvedBaseURL.pathname.replace(/\/+$/, ""); +export const WS_BASE_URL = `${wsProtocol}//${wsHost}${wsPath}`; + type JsonObject = Record; interface RequestOptions extends Omit { @@ -92,6 +103,13 @@ export interface Instance { status: string; } +export interface WsInstancesUpdated { + type: "instances_updated"; + data: Instance[]; +} + +export type WsMessage = WsInstancesUpdated; + export interface RegistryImage { id: string; name: string; diff --git a/frontend/src/components/TopBar.vue b/frontend/src/components/TopBar.vue index db625d4..b46db1b 100644 --- a/frontend/src/components/TopBar.vue +++ b/frontend/src/components/TopBar.vue @@ -1,10 +1,15 @@ + + + + diff --git a/frontend/src/views/ImageRegistry.vue b/frontend/src/views/ImageRegistry.vue index 82b846c..b3161e4 100644 --- a/frontend/src/views/ImageRegistry.vue +++ b/frontend/src/views/ImageRegistry.vue @@ -2,12 +2,12 @@ import { computed, onMounted, ref, watch } from "vue"; import { useRouter } from "vue-router"; import { useI18n } from "vue-i18n"; -import type { RegistryImage } from "../api/index"; -import { useRegistryStore } from "../stores/registry"; +import type { Game } from "../api/index"; +import { useGameStore } from "../stores/games"; const router = useRouter(); const { t } = useI18n(); -const registryStore = useRegistryStore(); +const gameStore = useGameStore(); const ALL_CATEGORY = "__all__"; @@ -22,15 +22,15 @@ const selectedCategory = ref(ALL_CATEGORY); const categoryTabs = computed(() => { return [ { key: ALL_CATEGORY, label: t("registry.filterAll") }, - ...registryStore.categories.map((category) => ({ key: category, label: category })), + ...gameStore.categories.map((category) => ({ key: category, label: category })), ]; }); -const filteredImages = computed(() => { +const filteredGames = computed(() => { if (selectedCategory.value === ALL_CATEGORY) { - return registryStore.images; + return gameStore.games; } - return registryStore.images.filter((image) => image.category === selectedCategory.value); + return gameStore.games.filter((game) => game.category === selectedCategory.value); }); watch(categoryTabs, (tabs) => { @@ -40,30 +40,30 @@ watch(categoryTabs, (tabs) => { }); onMounted(() => { - void loadImages(); + void loadGames(); }); -async function loadImages(): Promise { +async function loadGames(): Promise { try { - await registryStore.fetchImages(); + await gameStore.fetchGames(); } catch { - // error state is captured by registry store and rendered by the view. + // error state is captured by game store and rendered by the view. } } -function getImageEmoji(image: RegistryImage): string { - const mapped = iconEmojiMap[image.icon]; +function getGameEmoji(game: Game): string { + const mapped = iconEmojiMap[game.icon]; if (mapped) { return mapped; } - const fallback = image.name.trim().charAt(0); + const fallback = game.name.trim().charAt(0); return fallback ? fallback.toUpperCase() : "?"; } -function selectImage(imageId: string): void { +function goToCreatePage(gameID: string): void { void router.push({ - name: "ContainerList", - query: { imageId }, + name: "CreateInstance", + params: { gameId: gameID }, }); } @@ -89,30 +89,30 @@ function selectImage(imageId: string): void { -
+
{{ $t("registry.loading") }}
{{ $t("registry.loadError") }}
-
+
{{ $t("registry.emptyState") }}
-
+