|
|
|
@@ -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,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|