fix: surface database volume in docker view
This commit is contained in:
@@ -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 源码约束
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+23
-20
@@ -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<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
function tcpProbe(host: string, port: number, timeoutMs = 2500): Promise<unknown> {
|
||||
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<void> {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -281,6 +281,9 @@ function toVolume(row: Record<string, unknown>): 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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ export interface DockerVolumeSummary {
|
||||
driver: string;
|
||||
scope: string;
|
||||
mountpoint: string;
|
||||
labels: string;
|
||||
size: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface DockerNetworkSummary {
|
||||
|
||||
Reference in New Issue
Block a user