diff --git a/TEST.md b/TEST.md index 7f76690f..81ca6c42 100644 --- a/TEST.md +++ b/TEST.md @@ -46,7 +46,7 @@ ## T11 资源节点 Docker 状态 -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `provider:docker-status` 和 `frontend:docker-status-visible` passed;再用浏览器登录 frontend,进入左侧 `资源节点` 和顶部 `Docker 状态` 子标签,确认可以像 Docker Desktop 一样看到当前节点的 Containers、Images、Volumes、Networks 指标、容器表格、镜像/卷/网络侧栏和 Docker daemon 摘要。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `provider:docker-status` 和 `frontend:docker-status-visible` passed;再用浏览器登录 frontend,进入左侧 `资源节点` 和顶部 `Docker 状态` 子标签,确认可以像 Docker Desktop 一样看到当前节点的 Containers、Images、Volumes、Networks 指标、容器表格、镜像/卷/网络侧栏和 Docker daemon 摘要,并确认数据库命名卷 `unidesk_pgdata_10gb` 在 Volumes 区域和数据库命名卷卡片中显式可见。 ## T12 前端 TypeScript + React 源码约束 diff --git a/docs/reference/e2e.md b/docs/reference/e2e.md index ec925909..47306a76 100644 --- a/docs/reference/e2e.md +++ b/docs/reference/e2e.md @@ -17,7 +17,7 @@ UniDesk delivery is not complete until the public frontend, public provider ingr - Provider self-connection: internal `GET /api/nodes` must contain `main-server` with `status: online`; internal `GET /api/nodes/system-status` must contain CPU/memory/disk samples; internal `GET /api/nodes/docker-status` must contain a Docker snapshot for `main-server`; public provider ingress `/health` must return ok. - Provider remote control: internal `/api/dispatch` must successfully complete a real `provider.upgrade` task in `mode: "plan"` so the upgrade path is validated without recreating the running gateway during E2E. - Database: the command writes an `unidesk_e2e_markers` row through `docker exec unidesk-database psql` and confirms provider state is stored in PostgreSQL. -- Frontend: Playwright opens the public frontend URL, logs in with the configured account, waits for `核心在线`, asserts that `main-server` and `Main Server Provider` are visible, confirms no raw JSON is visible before clicking `查看原始JSON`, opens resource nodes `资源监控` to verify CPU/Memory/Disk curves and provider upgrade precheck dispatch, then opens `Docker 状态` and verifies the Docker Desktop-style container view. +- Frontend: Playwright opens the public frontend URL, logs in with the configured account, waits for `核心在线`, asserts that `main-server` and `Main Server Provider` are visible, confirms no raw JSON is visible before clicking `查看原始JSON`, opens resource nodes `资源监控` to verify CPU/Memory/Disk curves and provider upgrade precheck dispatch, then opens `Docker 状态` and verifies the Docker Desktop-style container view including the database named volume `unidesk_pgdata_10gb`. ## Frontend JSON Rule diff --git a/docs/reference/frontend.md b/docs/reference/frontend.md index 140a13d3..d009c62f 100644 --- a/docs/reference/frontend.md +++ b/docs/reference/frontend.md @@ -16,7 +16,7 @@ frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components ## Resource Node Docker View -资源节点模块必须提供 `Docker 状态` 子标签,用类似 Docker Desktop 的结构展示每个 provider 节点的 Docker daemon 状态。该页面应包含节点切换、daemon 摘要、Containers/Images/Volumes/Networks 指标、容器表格、镜像/卷/网络侧栏,并通过状态徽标区分 running、paused、exited 等状态。 +资源节点模块必须提供 `Docker 状态` 子标签,用类似 Docker Desktop 的结构展示每个 provider 节点的 Docker daemon 状态。该页面应包含节点切换、daemon 摘要、Containers/Images/Volumes/Networks 指标、容器表格、镜像/卷/网络侧栏,并通过状态徽标区分 running、paused、exited 等状态。数据库命名卷 `unidesk_pgdata_10gb` 必须在 Volumes 区域和数据库命名卷卡片中显式可见,不得因为列表截断或匿名卷排序被隐藏。 ## Provider Gateway Upgrade Control diff --git a/scripts/src/e2e.ts b/scripts/src/e2e.ts index 19fac4be..03a740c6 100644 --- a/scripts/src/e2e.ts +++ b/scripts/src/e2e.ts @@ -1,4 +1,5 @@ import { mkdirSync } from "node:fs"; +import { connect } from "node:net"; import { join } from "node:path"; import { chromium } from "playwright"; import { runCommand } from "./command"; @@ -48,6 +49,23 @@ async function fetchProbe(url: string, timeoutMs = 8000): Promise { } } +function tcpProbe(host: string, port: number, timeoutMs = 2500): Promise { + return new Promise((resolve) => { + const socket = connect({ host, port }); + let settled = false; + const finish = (detail: unknown): void => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(detail); + }; + socket.setTimeout(timeoutMs); + socket.once("connect", () => finish({ reachable: true, ok: true, host, port })); + socket.once("timeout", () => finish({ reachable: false, ok: false, host, port, error: "timeout" })); + socket.once("error", (error) => finish({ reachable: false, ok: false, host, port, error: error.message })); + }); +} + function addCheck(checks: E2ECheck[], name: string, passed: boolean, detail: unknown): void { checks.push({ name, status: passed ? "passed" : "failed", detail }); } @@ -178,26 +196,10 @@ async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E const portSummary = dockerPortSummary() as { rows?: Array<{ name: string; ports: string }> }; const portsText = (portSummary.rows ?? []).map((row) => `${row.name} ${row.ports}`).join("\n"); const corePublic = await fetchProbe(`${urls.blockedCoreUrl}/health`, 2500); - const databasePublic = runCommand([ - "docker", - "run", - "--rm", - "postgres:16-alpine", - "pg_isready", - "-h", - urls.blockedDatabaseHost, - "-p", - String(urls.blockedDatabasePort), - "-U", - config.database.user, - ], repoRoot); + const databasePublic = await tcpProbe(urls.blockedDatabaseHost, urls.blockedDatabasePort); addCheck(checks, "network:only-frontend-provider-ports", !portsText.includes(`:${config.network.core.port}->`) && !portsText.includes(`:${config.network.database.port}->`), portSummary); addCheck(checks, "network:core-public-blocked", (corePublic as { reachable?: boolean }).reachable === false, corePublic); - addCheck(checks, "network:database-public-blocked", databasePublic.exitCode !== 0, { - exitCode: databasePublic.exitCode, - stdout: databasePublic.stdout.trim(), - stderr: databasePublic.stderr.trim(), - }); + addCheck(checks, "network:database-public-blocked", (databasePublic as { reachable?: boolean }).reachable === false, databasePublic); } async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise { @@ -292,9 +294,10 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 await page.getByRole("button", { name: /Docker 状态/ }).click(); await page.waitForSelector('[data-testid="docker-status-page"]', { timeout: 10000 }); await page.waitForSelector('[data-testid="docker-container-table"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="database-volume-card"]', { timeout: 10000 }); await page.waitForFunction(() => { const text = document.body.innerText.toLowerCase(); - return text.includes("docker desktop 视图") && text.includes("containers"); + return text.includes("docker desktop 视图") && text.includes("containers") && text.includes("unidesk_pgdata_10gb"); }, undefined, { timeout: 10000 }); const dockerText = await page.locator('[data-testid="docker-status-page"]').innerText({ timeout: 5000 }); addCheck(checks, "frontend:login-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && bodyText.includes("核心在线"), { screenshotPath }); @@ -302,7 +305,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 addCheck(checks, "frontend:raw-json-explicit-button", rawText.includes('"providerId"') && rawText.includes(config.providerGateway.id), { rawTextPreview: rawText.slice(0, 400) }); addCheck(checks, "frontend:system-monitor-visible", monitorText.includes("任务管理器视图") && monitorText.includes("CPU") && monitorText.includes("Memory") && monitorText.includes("Disk"), { monitorTextPreview: monitorText.slice(0, 800) }); addCheck(checks, "frontend:upgrade-plan-dispatch", upgradeControlText.includes("预检升级 已下发"), { providerId: config.providerGateway.id, upgradeControlPreview: upgradeControlText.slice(0, 500) }); - addCheck(checks, "frontend:docker-status-visible", dockerText.toLowerCase().includes("docker desktop 视图") && dockerText.toLowerCase().includes("containers") && (dockerText.includes("unidesk-frontend") || dockerText.includes("unidesk-backend-core")), { dockerTextPreview: dockerText.slice(0, 800) }); + addCheck(checks, "frontend:docker-status-visible", dockerText.toLowerCase().includes("docker desktop 视图") && dockerText.toLowerCase().includes("containers") && dockerText.includes("unidesk_pgdata_10gb") && (dockerText.includes("unidesk-frontend") || dockerText.includes("unidesk-backend-core")), { dockerTextPreview: dockerText.slice(0, 800) }); addCheck(checks, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors }); return { screenshotPath, bodyText, consoleErrors }; } finally { diff --git a/src/components/frontend/public/style.css b/src/components/frontend/public/style.css index 2f803794..cbb32e47 100644 --- a/src/components/frontend/public/style.css +++ b/src/components/frontend/public/style.css @@ -377,6 +377,57 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; } margin-bottom: 8px; } +.docker-volume-focus { + display: grid; + gap: 8px; + padding: 10px; + margin-bottom: 8px; + border: 1px solid rgba(113, 191, 120, 0.34); + background: + linear-gradient(90deg, rgba(113, 191, 120, 0.11), transparent 55%), + var(--panel-3); +} +.docker-volume-focus.missing { + border-color: rgba(215, 161, 58, 0.45); + background: + linear-gradient(90deg, rgba(215, 161, 58, 0.12), transparent 55%), + var(--panel-3); +} +.volume-focus-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} +.volume-focus-body { + display: grid; + gap: 5px; +} +.volume-focus-body strong { + font-size: 15px; + letter-spacing: 0.03em; +} +.volume-focus-body > span { + color: var(--muted); +} +.volume-route { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 0.82fr); + gap: 8px; + align-items: center; + color: var(--muted); +} +.volume-route code { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #c7ddd8; +} +.docker-meta.compact { + margin-top: 2px; +} + .docker-section-head { display: flex; justify-content: space-between; @@ -433,6 +484,22 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; } overflow: hidden; text-overflow: ellipsis; } +.docker-side-row.database-volume { + border-color: rgba(113, 191, 120, 0.54); + background: + linear-gradient(90deg, rgba(113, 191, 120, 0.14), transparent 70%), + var(--panel-3); +} +.docker-side-row.database-volume strong { + color: #d6f1df; +} +.docker-side-more { + padding: 6px 7px; + border: 1px dashed var(--line-soft); + color: var(--muted); + text-align: center; + background: rgba(255,255,255,0.02); +} .monitor-page { display: grid; @@ -791,7 +858,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } .topbar { align-items: flex-start; flex-direction: column; } .status-strip { flex-wrap: wrap; white-space: normal; } .metric-grid, .policy-grid, .security-board, .dispatch-form, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid { grid-template-columns: 1fr; } - .compact-row, .heartbeat-row, .log-row, .endpoint-list article { grid-template-columns: 1fr; align-items: start; } + .compact-row, .heartbeat-row, .log-row, .endpoint-list article, .volume-route { grid-template-columns: 1fr; align-items: start; } .docker-hero, .monitor-hero { flex-direction: column; } .tab { min-width: 104px; } } diff --git a/src/components/frontend/src/app.tsx b/src/components/frontend/src/app.tsx index 992a81e7..0d08b421 100644 --- a/src/components/frontend/src/app.tsx +++ b/src/components/frontend/src/app.tsx @@ -555,6 +555,33 @@ function dockerStateTone(state: string): string { return "internal"; } +function isHashVolumeName(name: string): boolean { + return /^[a-f0-9]{48,64}$/i.test(name); +} + +function isDatabaseVolume(volume: AnyRecord): boolean { + const name = String(volume?.name || ""); + const labels = String(volume?.labels || ""); + return name === "unidesk_pgdata_10gb" || labels.includes("com.docker.compose.volume=unidesk_pgdata_10gb") || name.toLowerCase().includes("pgdata"); +} + +function volumeRank(volume: AnyRecord): number { + const name = String(volume?.name || ""); + const labels = String(volume?.labels || ""); + if (isDatabaseVolume(volume)) return 0; + if (labels.includes("com.docker.compose.project=unidesk")) return 1; + if (!isHashVolumeName(name)) return 2; + return 3; +} + +function sortVolumes(volumes: AnyRecord[]): AnyRecord[] { + return [...volumes].sort((left, right) => { + const rankDelta = volumeRank(left) - volumeRank(right); + if (rankDelta !== 0) return rankDelta; + return String(left.name || "").localeCompare(String(right.name || "")); + }); +} + function DockerStatusPage({ nodes, dockerStatuses, onRaw }: AnyRecord) { const [selectedProvider, setSelectedProvider] = useState(""); const merged = useMemo(() => nodes.map((node: any) => { @@ -575,7 +602,8 @@ function DockerStatusPage({ nodes, dockerStatuses, onRaw }: AnyRecord) { const daemon = status?.daemon || {}; const containers = status?.containers || []; const images = status?.images || []; - const volumes = status?.volumes || []; + const volumes = sortVolumes(status?.volumes || []); + const databaseVolume = volumes.find(isDatabaseVolume); const networks = status?.networks || []; const runningContainers = containers.filter((item: any) => item.state === "running"); const stoppedContainers = containers.filter((item: any) => item.state !== "running"); @@ -619,9 +647,10 @@ function DockerStatusPage({ nodes, dockerStatuses, onRaw }: AnyRecord) { h("div", { className: "docker-metrics" }, h(MetricCard, { label: "Containers", value: counts.containers ?? containers.length, hint: `${counts.running ?? runningContainers.length} running / ${counts.stopped ?? stoppedContainers.length} stopped`, tone: "ok" }), h(MetricCard, { label: "Images", value: counts.images ?? images.length, hint: `${counts.daemonImages ?? counts.images ?? images.length} daemon images` }), - h(MetricCard, { label: "Volumes", value: counts.volumes ?? volumes.length, hint: "local volumes" }), + h(MetricCard, { label: "Volumes", value: counts.volumes ?? volumes.length, hint: databaseVolume ? "database volume visible" : "local volumes", tone: databaseVolume ? "ok" : "" }), h(MetricCard, { label: "Networks", value: counts.networks ?? networks.length, hint: daemon.driver ? `driver ${daemon.driver}` : "docker networks" }), ), + h(DatabaseVolumeCard, { volume: databaseVolume, volumeCount: volumes.length }), h("div", { className: "docker-section-head" }, h("h3", null, "Containers"), h("span", null, `updated ${fmtDate(active.dockerUpdatedAt || status.collectedAt)}`), @@ -641,17 +670,52 @@ function DockerStatusPage({ nodes, dockerStatuses, onRaw }: AnyRecord) { ), h("div", { className: "docker-side-stack" }, h(DockerSidePanel, { title: "Images", items: images, render: (image: any) => h("article", { key: `${image.id}-${image.repository}`, className: "docker-side-row" }, h("strong", null, `${image.repository}:${image.tag}`), h("span", null, image.size || "--"), h("code", null, image.id || "--")) }), - h(DockerSidePanel, { title: "Volumes", items: volumes, render: (volume: any) => h("article", { key: volume.name, className: "docker-side-row" }, h("strong", null, volume.name), h("span", null, volume.driver || "--"), h("code", null, volume.scope || "--")) }), + h(DockerSidePanel, { title: "Volumes", items: volumes, limit: volumes.length, render: (volume: any) => h("article", { key: volume.name, className: `docker-side-row volume-row ${isDatabaseVolume(volume) ? "database-volume" : ""}`, "data-testid": isDatabaseVolume(volume) ? "database-volume-row" : undefined }, + h("strong", null, volume.name), + h("span", null, isDatabaseVolume(volume) ? "PostgreSQL" : isHashVolumeName(String(volume.name || "")) ? "anonymous" : "named"), + h("code", null, volume.mountpoint || volume.driver || volume.scope || "--"), + ) }), h(DockerSidePanel, { title: "Networks", items: networks, render: (network: any) => h("article", { key: network.id || network.name, className: "docker-side-row" }, h("strong", null, network.name), h("span", null, network.driver || "--"), h("code", null, network.id || "--")) }), ), ), ); } -function DockerSidePanel({ title, items, render }: AnyRecord) { +function DatabaseVolumeCard({ volume, volumeCount }: AnyRecord) { + return h("section", { className: `docker-volume-focus ${volume ? "ready" : "missing"}`, "data-testid": "database-volume-card" }, + h("div", { className: "volume-focus-head" }, + h("span", { className: "panel-eyebrow" }, "Database Named Volume"), + h(StatusBadge, { status: volume ? "online" : "warn" }, volume ? "FOUND" : "MISSING"), + ), + volume ? h("div", { className: "volume-focus-body" }, + h("strong", null, volume.name), + h("span", null, "PostgreSQL data volume for unidesk-database"), + h("div", { className: "volume-route" }, + h("code", null, volume.mountpoint || "/var/lib/docker/volumes/unidesk_pgdata_10gb/_data"), + h("span", null, "->"), + h("code", null, "unidesk-database:/var/lib/postgresql/data"), + ), + h("div", { className: "docker-meta compact" }, + h("span", null, `driver ${volume.driver || "--"}`), + h("span", null, `scope ${volume.scope || "--"}`), + h("span", null, `${volumeCount} volumes reported`), + ), + ) : h("div", { className: "volume-focus-body" }, + h("strong", null, "unidesk_pgdata_10gb"), + h("span", null, "当前 Docker 快照没有发现数据库命名卷;请检查 provider-gateway 的 Docker volume 上报。"), + ), + ); +} + +function DockerSidePanel({ title, items, render, limit }: AnyRecord) { + const visibleItems = items.slice(0, limit ?? 12); + const hiddenCount = Math.max(0, items.length - visibleItems.length); return h(Panel, { title, eyebrow: `${items.length} items`, className: "docker-side-panel" }, items.length === 0 ? h(EmptyState, { title: `暂无 ${title}`, text: "等待 Docker 状态采集" }) : - h("div", { className: "docker-side-list" }, items.slice(0, 12).map(render)), + h("div", { className: "docker-side-list" }, + visibleItems.map(render), + hiddenCount > 0 ? h("div", { className: "docker-side-more" }, `+ ${hiddenCount} more`) : null, + ), ); } diff --git a/src/components/provider-gateway/src/index.ts b/src/components/provider-gateway/src/index.ts index b738da9e..dcd7b732 100644 --- a/src/components/provider-gateway/src/index.ts +++ b/src/components/provider-gateway/src/index.ts @@ -281,6 +281,9 @@ function toVolume(row: Record): DockerVolumeSummary { driver: stringField(row, "Driver"), scope: stringField(row, "Scope"), mountpoint: stringField(row, "Mountpoint"), + labels: stringField(row, "Labels"), + size: stringField(row, "Size"), + status: stringField(row, "Status"), }; } diff --git a/src/components/shared/src/index.ts b/src/components/shared/src/index.ts index 8bddb2aa..312bd121 100644 --- a/src/components/shared/src/index.ts +++ b/src/components/shared/src/index.ts @@ -61,6 +61,9 @@ export interface DockerVolumeSummary { driver: string; scope: string; mountpoint: string; + labels: string; + size: string; + status: string; } export interface DockerNetworkSummary {