fix: surface database volume in docker view

This commit is contained in:
Codex
2026-05-04 13:51:31 +00:00
parent 2a03f6343c
commit a16fedd9e4
8 changed files with 169 additions and 29 deletions
+68 -1
View File
@@ -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; }
}
+69 -5
View File
@@ -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,
),
);
}