From c5124b809d2a04ebe9c3621e176352e8ff3c5fa1 Mon Sep 17 00:00:00 2001 From: Deng Liu Date: Sun, 8 Nov 2026 23:24:52 -0800 Subject: [PATCH] feat(docker): add Services dashboards, layers, processes, view filters and log editor tabs (#2246) --- crates/ide-docker/src/lib.rs | 26 + crates/ide-docker/src/model.rs | 200 ++- crates/ide-docker/tests/parse.rs | 45 +- src-tauri/src/commands/docker.rs | 25 +- src-tauri/src/commands/mod.rs | 3 + src/bindings.ts | 44 + src/features/services/Services.module.css | 277 +++ src/features/services/ServicesTree.tsx | 14 + src/features/services/dashboard.tsx | 1592 +++++++++++++++++ src/features/services/docker.tsx | 178 +- src/features/services/index.ts | 73 +- src/features/services/logEditor.tsx | 442 +++++ src/features/services/model.ts | 210 ++- src/features/services/ops.ts | 56 +- src/features/services/services.test.ts | 279 ++- src/features/services/store.ts | 43 +- src/platform/locales/text/en.json | 56 +- src/platform/locales/text/zh.json | 56 +- src/ui/LogPane.tsx | 5 +- src/ui/expui/docker/composeProjectStopped.svg | 8 + .../docker/composeProjectStopped_dark.svg | 8 + src/ui/expui/docker/containersList.svg | 6 + src/ui/expui/docker/containersList_dark.svg | 6 + src/ui/expui/docker/containersRunning.svg | 8 + .../expui/docker/containersRunning_dark.svg | 8 + src/ui/expui/docker/devContainers.svg | 4 + src/ui/expui/docker/devContainers_dark.svg | 4 + .../docker/dockerComposeServiceStopped.svg | 5 + .../dockerComposeServiceStopped_dark.svg | 5 + .../expui/docker/dockerComposeServiceUp.svg | 5 + .../docker/dockerComposeServiceUp_dark.svg | 5 + src/ui/expui/docker/dockerConnected.svg | 6 + src/ui/expui/docker/dockerConnected_dark.svg | 6 + src/ui/expui/docker/dockerRegistry.svg | 8 + src/ui/expui/docker/dockerRegistry_dark.svg | 8 + src/ui/expui/docker/imagesList.svg | 6 + src/ui/expui/docker/imagesList_dark.svg | 6 + src/ui/expui/docker/network.svg | 8 + src/ui/expui/docker/network_dark.svg | 8 + src/ui/expui/docker/pullImage.svg | 5 + src/ui/expui/docker/pullImage_dark.svg | 5 + src/ui/expui/docker/pushImage.svg | 5 + src/ui/expui/docker/pushImage_dark.svg | 5 + src/ui/expui/docker/singleImage.svg | 4 + src/ui/expui/docker/singleImage_dark.svg | 4 + .../expui/docker/singleRunningContainer.svg | 3 + .../docker/singleRunningContainer_dark.svg | 3 + .../expui/docker/singleStoppedContainer.svg | 3 + .../docker/singleStoppedContainer_dark.svg | 3 + src/ui/expui/docker/volume.svg | 4 + src/ui/expui/docker/volume_dark.svg | 4 + src/ui/icons.tsx | 16 + 52 files changed, 3679 insertions(+), 137 deletions(-) create mode 100644 src/features/services/dashboard.tsx create mode 100644 src/features/services/logEditor.tsx create mode 100644 src/ui/expui/docker/composeProjectStopped.svg create mode 100644 src/ui/expui/docker/composeProjectStopped_dark.svg create mode 100644 src/ui/expui/docker/containersList.svg create mode 100644 src/ui/expui/docker/containersList_dark.svg create mode 100644 src/ui/expui/docker/containersRunning.svg create mode 100644 src/ui/expui/docker/containersRunning_dark.svg create mode 100644 src/ui/expui/docker/devContainers.svg create mode 100644 src/ui/expui/docker/devContainers_dark.svg create mode 100644 src/ui/expui/docker/dockerComposeServiceStopped.svg create mode 100644 src/ui/expui/docker/dockerComposeServiceStopped_dark.svg create mode 100644 src/ui/expui/docker/dockerComposeServiceUp.svg create mode 100644 src/ui/expui/docker/dockerComposeServiceUp_dark.svg create mode 100644 src/ui/expui/docker/dockerConnected.svg create mode 100644 src/ui/expui/docker/dockerConnected_dark.svg create mode 100644 src/ui/expui/docker/dockerRegistry.svg create mode 100644 src/ui/expui/docker/dockerRegistry_dark.svg create mode 100644 src/ui/expui/docker/imagesList.svg create mode 100644 src/ui/expui/docker/imagesList_dark.svg create mode 100644 src/ui/expui/docker/network.svg create mode 100644 src/ui/expui/docker/network_dark.svg create mode 100644 src/ui/expui/docker/pullImage.svg create mode 100644 src/ui/expui/docker/pullImage_dark.svg create mode 100644 src/ui/expui/docker/pushImage.svg create mode 100644 src/ui/expui/docker/pushImage_dark.svg create mode 100644 src/ui/expui/docker/singleImage.svg create mode 100644 src/ui/expui/docker/singleImage_dark.svg create mode 100644 src/ui/expui/docker/singleRunningContainer.svg create mode 100644 src/ui/expui/docker/singleRunningContainer_dark.svg create mode 100644 src/ui/expui/docker/singleStoppedContainer.svg create mode 100644 src/ui/expui/docker/singleStoppedContainer_dark.svg create mode 100644 src/ui/expui/docker/volume.svg create mode 100644 src/ui/expui/docker/volume_dark.svg diff --git a/crates/ide-docker/src/lib.rs b/crates/ide-docker/src/lib.rs index 6a254aa88..04ccdaf48 100644 --- a/crates/ide-docker/src/lib.rs +++ b/crates/ide-docker/src/lib.rs @@ -253,6 +253,32 @@ pub fn inspect(ep: &DockerEndpoint, object: DockerObject, id: &str) -> IdeResult } } +fn check_id(id: &str) -> IdeResult<&str> { + let id = id.trim(); + if id.is_empty() || id.starts_with('-') { Err(IdeError::invalid("invalid Docker identifier")) } else { Ok(id) } +} + +/// `docker image history --no-trunc --format '{{json .}}'` of one image. +pub fn image_history(ep: &DockerEndpoint, id: &str) -> IdeResult> { + let id = check_id(id)?; + let out = run(ep, &["image", "history", "--no-trunc", "--format", "{{json .}}", id], QUICK)?; + Ok(parse_image_history(&out)) +} + +/// `docker top ` of a running container. +pub fn container_top(ep: &DockerEndpoint, id: &str) -> IdeResult { + let id = check_id(id)?; + let out = run(ep, &["top", id], QUICK)?; + Ok(parse_container_top(&out)) +} + +/// `docker stats --no-stream --no-trunc --format '{{json .}}' ` of a running container. +pub fn container_stats(ep: &DockerEndpoint, id: &str) -> IdeResult { + let id = check_id(id)?; + let out = run(ep, &["stats", "--no-stream", "--no-trunc", "--format", "{{json .}}", id], QUICK)?; + parse_container_stats(&out).ok_or_else(|| IdeError::not_found("no container stats returned")) +} + /// Streams a container's logs (`tail` last lines, then follows while `follow`) /// until it ends or `cancel` fires. pub fn logs( diff --git a/crates/ide-docker/src/model.rs b/crates/ide-docker/src/model.rs index 1cdc1098a..2a1b4201c 100644 --- a/crates/ide-docker/src/model.rs +++ b/crates/ide-docker/src/model.rs @@ -41,6 +41,15 @@ pub struct DockerContainer { pub compose_working_dir: Option, /// `com.docker.compose.project.config_files` label (comma-separated in docker). pub compose_files: Vec, + /// Mounted volume names or host paths reported by `docker ps` (`Mounts`). + #[serde(default)] + pub mounts: Vec, + /// Connected network names reported by `docker ps` (`Networks`). + #[serde(default)] + pub networks: Vec, + /// User and tool labels assigned to the container. + #[serde(default)] + pub labels: BTreeMap, } impl DockerContainer { @@ -80,6 +89,10 @@ pub struct DockerVolume { pub driver: String, pub mountpoint: String, pub compose_project: Option, + #[serde(default)] + pub scope: String, + #[serde(default)] + pub labels: BTreeMap, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] @@ -90,6 +103,46 @@ pub struct DockerNetwork { pub driver: String, pub scope: String, pub compose_project: Option, + #[serde(default)] + pub created_at: String, + #[serde(default)] + pub ipv6: bool, + #[serde(default)] + pub internal: bool, + #[serde(default)] + pub labels: BTreeMap, +} + +/// One layer in `docker image history --no-trunc --format '{{json .}}'`. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DockerImageLayer { + pub id: String, + pub created_by: String, + pub size: String, + pub created_since: String, + pub created_at: String, + pub comment: String, +} + +/// Process table returned by `docker top `. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DockerProcessList { + pub titles: Vec, + pub processes: Vec>, +} + +/// Live resource metrics returned by `docker stats --no-stream --no-trunc --format '{{json .}}'`. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DockerContainerStats { + pub cpu_perc: String, + pub mem_usage: String, + pub mem_perc: String, + pub net_io: String, + pub block_io: String, + pub pids: String, } /// Everything the Services tree shows, fetched in one go. @@ -186,17 +239,20 @@ struct RawContainer { ports: String, created_at: String, labels: String, + mounts: String, + networks: String, +} + +fn split_csv(raw: &str) -> Vec { + raw.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect() } /// `docker ps -a --no-trunc --format '{{json .}}'`. pub fn parse_containers(text: &str) -> Vec { let mut out: Vec = lines::(text) .map(|r| { - let mut labels = parse_labels(&r.labels); - let files = labels - .remove(COMPOSE_FILES) - .map(|f| f.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect()) - .unwrap_or_default(); + let all_labels = parse_labels(&r.labels); + let files = all_labels.get(COMPOSE_FILES).map(|f| split_csv(f)).unwrap_or_default(); // Older docker versions derive `State` from nothing: fall back on the status text. let state = if r.state.is_empty() { if r.status.starts_with("Up") { "running".into() } else { "exited".into() } @@ -212,10 +268,13 @@ pub fn parse_containers(text: &str) -> Vec { status: r.status, ports: r.ports, created_at: r.created_at, - compose_project: labels.remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()), - compose_service: labels.remove(COMPOSE_SERVICE).filter(|s| !s.is_empty()), - compose_working_dir: labels.remove(COMPOSE_DIR).filter(|s| !s.is_empty()), + compose_project: all_labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(), + compose_service: all_labels.get(COMPOSE_SERVICE).filter(|s| !s.is_empty()).cloned(), + compose_working_dir: all_labels.get(COMPOSE_DIR).filter(|s| !s.is_empty()).cloned(), compose_files: files, + mounts: split_csv(&r.mounts), + networks: split_csv(&r.networks), + labels: all_labels, } }) .collect(); @@ -258,17 +317,23 @@ struct RawVolume { name: String, driver: String, mountpoint: String, + scope: String, labels: String, } /// `docker volume ls --format '{{json .}}'`. pub fn parse_volumes(text: &str) -> Vec { let mut out: Vec = lines::(text) - .map(|r| DockerVolume { - compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()), - name: r.name, - driver: r.driver, - mountpoint: r.mountpoint, + .map(|r| { + let labels = parse_labels(&r.labels); + DockerVolume { + compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(), + name: r.name, + driver: r.driver, + mountpoint: r.mountpoint, + scope: r.scope, + labels, + } }) .collect(); out.sort_by(|a, b| a.name.cmp(&b.name)); @@ -283,24 +348,121 @@ struct RawNetwork { name: String, driver: String, scope: String, + created_at: String, + #[serde(rename = "IPv6")] + ipv6: String, + internal: String, labels: String, } /// `docker network ls --no-trunc --format '{{json .}}'`. pub fn parse_networks(text: &str) -> Vec { let mut out: Vec = lines::(text) - .map(|r| DockerNetwork { - compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()), - id: r.id, - name: r.name, - driver: r.driver, - scope: r.scope, + .map(|r| { + let labels = parse_labels(&r.labels); + DockerNetwork { + compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(), + id: r.id, + name: r.name, + driver: r.driver, + scope: r.scope, + created_at: r.created_at, + ipv6: r.ipv6.eq_ignore_ascii_case("true"), + internal: r.internal.eq_ignore_ascii_case("true"), + labels, + } }) .collect(); out.sort_by(|a, b| a.name.cmp(&b.name)); out } +#[derive(Deserialize, Default)] +#[serde(rename_all = "PascalCase", default)] +struct RawImageLayer { + #[serde(rename = "ID")] + id: String, + created_by: String, + size: String, + created_since: String, + created_at: String, + comment: String, +} + +/// `docker image history --no-trunc --format '{{json .}}'`. +pub fn parse_image_history(text: &str) -> Vec { + lines::(text) + .map(|r| DockerImageLayer { + id: r.id, + created_by: r.created_by, + size: r.size, + created_since: r.created_since, + created_at: r.created_at, + comment: r.comment, + }) + .collect() +} + +/// `docker top ` tabular output. +pub fn parse_container_top(text: &str) -> DockerProcessList { + let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); + let Some(header) = lines.next() else { + return DockerProcessList::default(); + }; + let titles: Vec = header.split_whitespace().map(str::to_owned).collect(); + if titles.is_empty() { + return DockerProcessList::default(); + } + let cols = titles.len(); + let processes = lines + .map(|line| { + let mut row = Vec::with_capacity(cols); + let mut rest = line.trim_start(); + for _ in 0..cols.saturating_sub(1) { + match rest.split_once(char::is_whitespace) { + Some((field, tail)) => { + row.push(field.to_owned()); + rest = tail.trim_start(); + } + None => break, + } + } + if !rest.is_empty() || row.len() < cols { + row.push(rest.to_owned()); + } + row + }) + .collect(); + DockerProcessList { titles, processes } +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "PascalCase", default)] +struct RawStats { + #[serde(rename = "CPUPerc")] + cpu_perc: String, + mem_usage: String, + mem_perc: String, + #[serde(rename = "NetIO")] + net_io: String, + #[serde(rename = "BlockIO")] + block_io: String, + #[serde(rename = "PIDs")] + pids: String, +} + +/// `docker stats --no-stream --no-trunc --format '{{json .}}'`. +pub fn parse_container_stats(text: &str) -> Option { + lines::(text).next().map(|r| DockerContainerStats { + cpu_perc: r.cpu_perc, + mem_usage: r.mem_usage, + mem_perc: r.mem_perc, + net_io: r.net_io, + block_io: r.block_io, + pids: r.pids, + }) +} + #[derive(Deserialize, Default)] #[serde(default)] struct RawVersion { diff --git a/crates/ide-docker/tests/parse.rs b/crates/ide-docker/tests/parse.rs index e5f17438e..1c6c47674 100644 --- a/crates/ide-docker/tests/parse.rs +++ b/crates/ide-docker/tests/parse.rs @@ -1,6 +1,9 @@ //! Parsing of recorded `docker … --format '{{json .}}'` output. -use ide_docker::{parse_containers, parse_images, parse_networks, parse_volumes}; +use ide_docker::{ + parse_container_stats, parse_container_top, parse_containers, parse_image_history, parse_images, parse_networks, + parse_volumes, +}; const PS: &str = include_str!("fixtures/ps.jsonl"); const IMAGES: &str = include_str!("fixtures/images.jsonl"); @@ -22,11 +25,16 @@ fn containers_with_compose_labels() { assert_eq!(db.compose_working_dir.as_deref(), Some("/work/shop")); assert_eq!(db.compose_files, ["/work/shop/compose.yaml", "/work/shop/compose.override.yaml"]); assert_eq!(db.ports, "0.0.0.0:5432->5432/tcp"); + assert_eq!(db.mounts, ["shop_pgdata"]); + assert_eq!(db.networks, ["shop_default"]); + assert_eq!(db.labels.get("com.docker.compose.project").map(String::as_str), Some("shop")); let web = &c[1]; assert_eq!(web.name, "web", "leading slash of legacy names is dropped"); assert!(!web.running()); assert_eq!(web.compose_project, None); + assert!(web.mounts.is_empty()); + assert_eq!(web.networks, ["bridge"]); } #[test] @@ -44,9 +52,44 @@ fn volumes_and_networks() { assert_eq!(v.iter().map(|v| v.name.as_str()).collect::>(), ["0a1b", "shop_pgdata"]); assert_eq!(v[1].compose_project.as_deref(), Some("shop")); assert_eq!(v[1].mountpoint, "/var/lib/docker/volumes/shop_pgdata/_data"); + assert_eq!(v[1].scope, "local"); + assert_eq!(v[1].labels.get("com.docker.compose.volume").map(String::as_str), Some("pgdata")); let n = parse_networks(NETWORKS); assert_eq!(n.iter().map(|n| n.name.as_str()).collect::>(), ["bridge", "host", "shop_default"]); assert_eq!(n[2].compose_project.as_deref(), Some("shop")); assert_eq!(n[1].driver, "host"); + assert!(!n[2].ipv6); + assert!(!n[2].internal); + assert_eq!(n[2].labels.get("com.docker.compose.network").map(String::as_str), Some("default")); +} + +#[test] +fn image_history_top_and_stats() { + let history = [ + r#"{"Comment":"buildkit.dockerfile.v0","CreatedAt":"2026-10-01 09:00:00 +0000 UTC","CreatedBy":"CMD [\"postgres\"]","CreatedSince":"5 weeks ago","ID":"sha256:1234567890ab","Size":"0B"}"#, + r#"{"Comment":"","CreatedAt":"2026-10-01 08:59:00 +0000 UTC","CreatedBy":"/bin/sh -c #(nop) COPY file:abc in /usr/local/bin/ ","CreatedSince":"5 weeks ago","ID":"","Size":"42.5MB"}"#, + ] + .join("\n"); + let layers = parse_image_history(&history); + assert_eq!(layers.len(), 2); + assert_eq!(layers[0].created_by, "CMD [\"postgres\"]"); + assert_eq!(layers[1].size, "42.5MB"); + + let top = "UID PID PPID C STIME TTY TIME CMD\nroot 1234 1200 0 09:12 ? 00:00:01 postgres -c config_file=/etc/postgresql.conf\n"; + let parsed_top = parse_container_top(top); + assert_eq!(parsed_top.titles, ["UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"]); + assert_eq!(parsed_top.processes.len(), 1); + assert_eq!(parsed_top.processes[0][0], "root"); + assert_eq!(parsed_top.processes[0][1], "1234"); + assert_eq!(parsed_top.processes[0][7], "postgres -c config_file=/etc/postgresql.conf"); + + let stats_line = r#"{"BlockIO":"1.2MB / 0B","CPUPerc":"1.25%","Container":"shop-db-1","ID":"4f2b8c1d9e0a","MemPerc":"2.50%","MemUsage":"50MiB / 2GiB","Name":"shop-db-1","NetIO":"12kB / 8kB","PIDs":"14"}"#; + let stats = parse_container_stats(stats_line).expect("stats parsed"); + assert_eq!(stats.cpu_perc, "1.25%"); + assert_eq!(stats.mem_usage, "50MiB / 2GiB"); + assert_eq!(stats.mem_perc, "2.50%"); + assert_eq!(stats.net_io, "12kB / 8kB"); + assert_eq!(stats.block_io, "1.2MB / 0B"); + assert_eq!(stats.pids, "14"); } diff --git a/src-tauri/src/commands/docker.rs b/src-tauri/src/commands/docker.rs index 7d2dedfeb..619781767 100644 --- a/src-tauri/src/commands/docker.rs +++ b/src-tauri/src/commands/docker.rs @@ -3,8 +3,8 @@ use crate::state::AppState; use ide_core::{IdeError, IdeResult, RequestId}; use ide_docker::{ - ComposeOp, ContainerOp, DockerEndpoint, DockerEvent, DockerLogLine, DockerObject, DockerRegistry, DockerSettings, - DockerSnapshot, PruneTarget, RegistryAuth, + ComposeOp, ContainerOp, DockerContainerStats, DockerEndpoint, DockerEvent, DockerImageLayer, DockerLogLine, + DockerObject, DockerProcessList, DockerRegistry, DockerSettings, DockerSnapshot, PruneTarget, RegistryAuth, }; use tauri::State; use tauri::ipc::Channel; @@ -56,6 +56,27 @@ pub async fn docker_inspect(endpoint: DockerEndpoint, object: DockerObject, id: blocking(move || ide_docker::inspect(&endpoint, object, &id)).await } +/// Layers of an image (`docker image history`). +#[tauri::command] +#[specta::specta] +pub async fn docker_image_history(endpoint: DockerEndpoint, id: String) -> IdeResult> { + blocking(move || ide_docker::image_history(&endpoint, &id)).await +} + +/// Processes running inside a container (`docker top`). +#[tauri::command] +#[specta::specta] +pub async fn docker_container_top(endpoint: DockerEndpoint, id: String) -> IdeResult { + blocking(move || ide_docker::container_top(&endpoint, &id)).await +} + +/// One-shot resource usage snapshot of a running container (`docker stats --no-stream`). +#[tauri::command] +#[specta::specta] +pub async fn docker_container_stats(endpoint: DockerEndpoint, id: String) -> IdeResult { + blocking(move || ide_docker::container_stats(&endpoint, &id)).await +} + /// Streams a container's log lines until it stops or the request is cancelled. #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4693f9b5a..8b183cee1 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -563,6 +563,9 @@ pub fn builder() -> tauri_specta::Builder { docker::docker_container_op, docker::docker_remove, docker::docker_inspect, + docker::docker_image_history, + docker::docker_container_top, + docker::docker_container_stats, docker::docker_logs, docker::docker_events, docker::docker_pull, diff --git a/src/bindings.ts b/src/bindings.ts index a0cab15bc..d0cb62b75 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1282,6 +1282,12 @@ export const commands = { dockerRemove: (endpoint: DockerEndpoint, object: DockerObject, ids: string[], force: boolean) => typedError(__TAURI_INVOKE("docker_remove", { endpoint, object, ids, force })), /** Pretty-printed `docker inspect` JSON. */ dockerInspect: (endpoint: DockerEndpoint, object: DockerObject, id: string) => typedError(__TAURI_INVOKE("docker_inspect", { endpoint, object, id })), + /** Layers of an image (`docker image history`). */ + dockerImageHistory: (endpoint: DockerEndpoint, id: string) => typedError(__TAURI_INVOKE("docker_image_history", { endpoint, id })), + /** Processes running inside a container (`docker top`). */ + dockerContainerTop: (endpoint: DockerEndpoint, id: string) => typedError(__TAURI_INVOKE("docker_container_top", { endpoint, id })), + /** One-shot resource usage snapshot of a running container (`docker stats --no-stream`). */ + dockerContainerStats: (endpoint: DockerEndpoint, id: string) => typedError(__TAURI_INVOKE("docker_container_stats", { endpoint, id })), /** Streams a container's log lines until it stops or the request is cancelled. */ dockerLogs: (request: RequestId, endpoint: DockerEndpoint, id: string, tail: number, follow: boolean, timestamps: boolean, onLine: Channel) => typedError(__TAURI_INVOKE("docker_logs", { request, endpoint, id, tail, follow, timestamps, onLine })), /** Follows `docker events` until the daemon goes away or the request is cancelled. */ @@ -3546,6 +3552,22 @@ export type DockerContainer = { composeWorkingDir: string | null, /** `com.docker.compose.project.config_files` label (comma-separated in docker). */ composeFiles: string[], + /** Mounted volume names or host paths reported by `docker ps` (`Mounts`). */ + mounts?: string[], + /** Connected network names reported by `docker ps` (`Networks`). */ + networks?: string[], + /** User and tool labels assigned to the container. */ + labels?: { [key in string]: string }, +}; + +/** Live resource metrics returned by `docker stats --no-stream --no-trunc --format '{{json .}}'`. */ +export type DockerContainerStats = { + cpuPerc: string, + memUsage: string, + memPerc: string, + netIo: string, + blockIo: string, + pids: string, }; /** Which daemon a docker command talks to. */ @@ -3580,6 +3602,16 @@ export type DockerImage = { createdSince: string, }; +/** One layer in `docker image history --no-trunc --format '{{json .}}'`. */ +export type DockerImageLayer = { + id: string, + createdBy: string, + size: string, + createdSince: string, + createdAt: string, + comment: string, +}; + /** A line of `docker logs` output. */ export type DockerLogLine = { stderr: boolean, @@ -3592,10 +3624,20 @@ export type DockerNetwork = { driver: string, scope: string, composeProject: string | null, + createdAt?: string, + ipv6?: boolean, + internal?: boolean, + labels?: { [key in string]: string }, }; export type DockerObject = "container" | "image" | "volume" | "network"; +/** Process table returned by `docker top `. */ +export type DockerProcessList = { + titles: string[], + processes: string[][], +}; + /** A registry to log in to (Docker Hub, GHCR, a private one). The password is stored separately. */ export type DockerRegistry = { id: string, @@ -3630,6 +3672,8 @@ export type DockerVolume = { driver: string, mountpoint: string, composeProject: string | null, + scope?: string, + labels?: { [key in string]: string }, }; /** What the UI needs to show an opened document. */ diff --git a/src/features/services/Services.module.css b/src/features/services/Services.module.css index 0e5513711..f29a9d98a 100644 --- a/src/features/services/Services.module.css +++ b/src/features/services/Services.module.css @@ -228,3 +228,280 @@ border: 0; background: none; } + +/* Dashboards, Layers & Processes (Phase 1) */ +.dashboard { + flex: 1; + min-height: 0; + overflow: auto; + padding-bottom: 12px; + -webkit-user-select: text; + user-select: text; +} +.dashboardActions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); +} +.sectionBanner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 12px; + font-size: var(--fs-small); + font-weight: 600; + color: var(--fg-secondary); + background: var(--bg-elev); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} +.propList { + display: flex; + flex-direction: column; +} +.propRow { + display: grid; + grid-template-columns: 140px 1fr; + gap: 8px; + align-items: baseline; + padding: 5px 12px; + border-bottom: 1px solid var(--border); +} +.propName { + color: var(--fg-secondary); + font-size: var(--fs-small); +} +.propVal { + min-width: 0; + overflow-wrap: anywhere; + color: var(--fg); +} +.inlineRow { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} +.mono { + font-family: var(--font-mono); + font-size: var(--fs-code); +} +.codeInline { + display: inline-block; + padding: 1px 6px; + border-radius: var(--radius-small); + background: var(--bg-elev); + font-family: var(--font-mono); + font-size: var(--fs-code); + overflow-wrap: anywhere; +} +.mutedMeta { + color: var(--fg-subtle); + font-size: var(--fs-small); +} +.statusBadge { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: var(--radius-small); + font-size: var(--fs-small); + font-family: var(--font-mono); +} +.badgeOk { + color: var(--success); + background: color-mix(in srgb, var(--success) 16%, transparent); +} +.badgeWarn { + color: var(--warning); + background: color-mix(in srgb, var(--warning) 16%, transparent); +} +.badgeDanger { + color: var(--danger); + background: color-mix(in srgb, var(--danger) 16%, transparent); +} +.badgeMuted { + color: var(--fg-secondary); + background: var(--bg-elev); +} +.statusDot { + width: 7px; + height: 7px; + border-radius: 50%; + flex: none; +} +.dotOk { + background: var(--success); +} +.dotMuted { + background: var(--fg-subtle); +} +.metricBlock { + display: flex; + flex-direction: column; + gap: 4px; + max-width: 320px; +} +.lineProgress { + height: 5px; + width: 100%; + border-radius: 3px; + background: var(--bg-elev); + overflow: hidden; +} +.lineProgressFill { + height: 100%; + border-radius: 3px; +} +.progressNormal { + background: var(--accent); +} +.progressWarning { + background: var(--warning); +} +.progressDanger { + background: var(--danger); +} +.chipGroup { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 12px; +} +.chipGroupInline { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.entityChip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border-radius: var(--radius-small); + border: 1px solid var(--border-strong); + background: var(--bg-elev); + color: var(--fg); + font: inherit; + font-size: var(--fs-small); + cursor: pointer; +} +.entityChip:disabled { + cursor: default; +} +span.entityChip { + cursor: default; +} +button.entityChip:not(:disabled):hover { + background: var(--bg-hover); + border-color: var(--accent); +} +.portChipLink { + color: var(--link); +} +.chipMeta { + color: var(--fg-subtle); + font-size: var(--fs-small); +} +.emptySection { + padding: 8px 12px; + color: var(--fg-subtle); + font-size: var(--fs-small); + display: flex; + align-items: center; + gap: 6px; +} +.kvTable { + display: flex; + flex-direction: column; +} +.kvRow { + display: grid; + grid-template-columns: minmax(140px, 240px) 1fr; + gap: 10px; + padding: 4px 12px; + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: var(--fs-code); +} +.kvKey { + color: var(--fg-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.kvVal { + color: var(--fg); + overflow-wrap: anywhere; +} +.dataTable { + display: flex; + flex-direction: column; +} +.dataRow { + display: grid; + grid-template-columns: 72px minmax(120px, 1fr) minmax(140px, 1.3fr) 56px; + gap: 8px; + align-items: center; + padding: 4px 12px; + border-bottom: 1px solid var(--border); + font-size: var(--fs-small); +} +.dataHeader { + color: var(--fg-secondary); + font-weight: 600; +} +.subToolbar { + height: 28px; + flex: none; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 8px 0 12px; + border-bottom: 1px solid var(--border); +} +.subToolbarTitle { + color: var(--fg-secondary); + font-size: var(--fs-small); + font-weight: 600; +} +.tableScroll { + flex: 1; + min-height: 0; + overflow: auto; + -webkit-user-select: text; + user-select: text; +} +.gridTable { + width: 100%; + border-collapse: collapse; + font-size: var(--fs-small); +} +.gridTable th { + position: sticky; + top: 0; + text-align: left; + padding: 5px 10px; + color: var(--fg-secondary); + background: var(--bg-elev); + border-bottom: 1px solid var(--border); + font-weight: 600; + white-space: nowrap; +} +.gridTable td { + padding: 4px 10px; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +.layerCmd { + max-width: 520px; + overflow-wrap: anywhere; + font-family: var(--font-mono); + font-size: var(--fs-code); +} +.layerSizeCell { + min-width: 96px; + white-space: nowrap; +} diff --git a/src/features/services/ServicesTree.tsx b/src/features/services/ServicesTree.tsx index faa67ba9b..b08bda0ca 100644 --- a/src/features/services/ServicesTree.tsx +++ b/src/features/services/ServicesTree.tsx @@ -40,6 +40,7 @@ import { useServicesTree, } from "./tree"; import styles from "./Services.module.css"; +import { viewFilterMenu } from "./ops"; import { selectionActions, selectionMenu, selectedServices } from "./selection"; export const SERVICES_VIEW_FOCUS = "servicesViewFocus"; @@ -88,6 +89,7 @@ export function ServicesTreeView() { const selectedKeys = useServicesTree((s) => s.selectedKeys); const expanded = useServicesTree((s) => s.expanded); const addRef = useRef(null); + const filterRef = useRef(null); const roots = useMemo(() => orderedRoots(rootsByProvider, providers), [rootsByProvider, providers]); const index = useMemo(() => indexTree(rootsByProvider), [rootsByProvider]); @@ -181,6 +183,18 @@ export function ServicesTreeView() { disabled={!roots.length} onClick={() => setExpanded(new Set())} /> + { + const r = filterRef.current?.getBoundingClientRect(); + showContextMenu({ x: r?.left ?? 140, y: (r?.bottom ?? 100) + 2 }, viewFilterMenu()); + }} + /> {actions.length > 0 && } {actions.map((a) => ( = { + B: 1, + KiB: 1024, + MiB: 1024 ** 2, + GiB: 1024 ** 3, + TiB: 1024 ** 4, + PiB: 1024 ** 5, + Ki: 1024, + Mi: 1024 ** 2, + Gi: 1024 ** 3, + Ti: 1024 ** 4, + Pi: 1024 ** 5, + kB: 1000, + KB: 1000, + MB: 1000 ** 2, + GB: 1000 ** 3, + TB: 1000 ** 4, + PB: 1000 ** 5, + K: 1000, + k: 1000, + M: 1000 ** 2, + G: 1000 ** 3, + T: 1000 ** 4, +}; + +export function unitsToBytes(value: string | number | undefined | null): number { + if (value === undefined || value === null) return 0; + if (typeof value === "number") return Number.isFinite(value) && value > 0 ? value : 0; + const raw = String(value).trim(); + if (!raw) return 0; + const m = /^([0-9]+(?:\.[0-9]+)?)\s*([A-Za-z]+)?$/.exec(raw); + if (!m) return 0; + const num = Number.parseFloat(m[1]!); + if (Number.isNaN(num)) return 0; + const suffix = m[2] ?? ""; + if (!suffix) return num; + const mult = UNIT_MULTIPLIERS[suffix] ?? UNIT_MULTIPLIERS[suffix.replace(/B$/i, "")]; + return mult ? num * mult : num; +} + +export function bytesToUnits(bytes: number, precision = 1): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + let idx = 0; + let val = bytes; + while (val >= 1024 && idx < units.length - 1) { + val /= 1024; + idx++; + } + if (idx === 0) return `${Math.round(val)} B`; + return `${val.toFixed(precision)} ${units[idx]!}`; +} + +export function parsePercent(raw: string | undefined | null): number { + if (!raw) return 0; + const n = Number.parseFloat(raw.replace(/%/g, "").trim()); + return Number.isFinite(n) && n >= 0 ? n : 0; +} + +export interface UsagePair { + used: string; + limit: string; + usedBytes: number; + limitBytes: number; + percent: number; +} + +export function parseUsagePair(raw: string | undefined | null): UsagePair { + const parts = (raw ?? "").split("/").map((s) => s.trim()); + const used = parts[0] || "0 B"; + const limit = parts[1] || "0 B"; + const usedBytes = unitsToBytes(used); + const limitBytes = unitsToBytes(limit); + const percent = limitBytes > 0 ? Math.min(100, (usedBytes / limitBytes) * 100) : 0; + return { used, limit, usedBytes, limitBytes, percent }; +} + +function parseDockerTimestamp(raw?: string | null): number { + if (!raw) return Number.NaN; + const trimmed = raw.trim(); + if (!trimmed || trimmed.startsWith("0001-01-01")) return Number.NaN; + const direct = Date.parse(trimmed); + if (!Number.isNaN(direct)) return direct; + // Docker CLI format: `2026-10-01 09:12:44 +0200 CEST` -> drop trailing timezone name + const cleaned = trimmed.replace(/\s+[A-Z]{2,5}$/, ""); + return Date.parse(cleaned); +} + +/** + * Formats an ISO or Docker CLI timestamp in the user's system local timezone + * (`YYYY-MM-DD HH:mm:ss `) with relative age. + */ +export function formatCreatedDetailed(raw?: string | null, nowMs = Date.now(), timeZone?: string): string { + if (!raw) return "—"; + const trimmed = raw.trim(); + if (!trimmed || trimmed.startsWith("0001-01-01")) return "—"; + const ts = parseDockerTimestamp(trimmed); + if (Number.isNaN(ts)) return trimmed; + const diffSec = Math.max(0, Math.floor((nowMs - ts) / 1000)); + const days = Math.floor(diffSec / 86400); + const hours = Math.floor((diffSec % 86400) / 3600); + const mins = Math.floor((diffSec % 3600) / 60); + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (mins > 0 || parts.length === 0) parts.push(`${mins}m`); + const d = new Date(ts); + try { + const dtf = new Intl.DateTimeFormat(undefined, { + ...(timeZone ? { timeZone } : {}), + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + timeZoneName: "short", + }); + const p = Object.fromEntries(dtf.formatToParts(d).map((x) => [x.type, x.value])); + const datePart = `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`; + const formatted = p.timeZoneName ? `${datePart} ${p.timeZoneName}` : datePart; + return `${parts.join(" ")} ago (${formatted})`; + } catch { + return `${parts.join(" ")} ago (${trimmed})`; + } +} + +export interface PortMapping { + hostIp: string; + hostPort: string | null; + containerPort: string; + protocol: string; + url: string | null; + label: string; +} + +/** Extracts published and exposed ports from `DockerContainer.ports` and `docker inspect` JSON. */ +export function parsePortMappings(portsText: string, inspectPorts?: Record | null): PortMapping[] { + const seen = new Set(); + const out: PortMapping[] = []; + const push = (rawHostIp: string, hostPort: string | null, containerPort: string, protocol: string) => { + const hostIp = rawHostIp === "::" ? "[::]" : rawHostIp; + const key = `${hostIp}:${hostPort ?? ""}->${containerPort}/${protocol}`; + if (seen.has(key)) return; + seen.add(key); + const browseHost = !hostIp || hostIp === "0.0.0.0" || hostIp === "[::]" ? "localhost" : hostIp; + const url = hostPort && protocol === "tcp" ? `http://${browseHost}:${hostPort}` : null; + const label = hostPort + ? `${hostIp || "0.0.0.0"}:${hostPort} -> ${containerPort}/${protocol}` + : `${containerPort}/${protocol}`; + out.push({ hostIp, hostPort, containerPort, protocol, url, label }); + }; + + if (inspectPorts && typeof inspectPorts === "object") { + for (const [spec, bindings] of Object.entries(inspectPorts)) { + const [cPort = spec, proto = "tcp"] = spec.split("/"); + if (Array.isArray(bindings) && bindings.length > 0) { + for (const b of bindings) { + if (b && typeof b === "object") { + const rec = b as Record; + const ip = typeof rec.HostIp === "string" ? rec.HostIp : "0.0.0.0"; + const hp = typeof rec.HostPort === "string" && rec.HostPort ? rec.HostPort : null; + push(ip, hp, cPort, proto); + } + } + } else { + push("", null, cPort, proto); + } + } + } + + for (const seg of (portsText ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean)) { + const arrow = seg.indexOf("->"); + if (arrow > 0) { + const left = seg.slice(0, arrow); + const right = seg.slice(arrow + 2); + const [cPort = right, proto = "tcp"] = right.split("/"); + const colon = left.lastIndexOf(":"); + const hostIp = colon >= 0 ? left.slice(0, colon) : "0.0.0.0"; + const hostPort = colon >= 0 ? left.slice(colon + 1) : left; + push(hostIp, hostPort, cPort, proto); + } else { + const [cPort = seg, proto = "tcp"] = seg.split("/"); + push("", null, cPort, proto); + } + } + return out; +} + +const DOCKERFILE_INSTRUCTIONS = new Set([ + "FROM", + "RUN", + "CMD", + "LABEL", + "MAINTAINER", + "EXPOSE", + "ENV", + "ADD", + "COPY", + "ENTRYPOINT", + "VOLUME", + "USER", + "WORKDIR", + "ARG", + "ONBUILD", + "STOPSIGNAL", + "HEALTHCHECK", + "SHELL", +]); + +/** Cleans `docker image history` `CreatedBy` text into `{ instruction, args }`. */ +export function splitLayerInstruction(createdBy: string): { instruction: string; args: string } { + let text = (createdBy ?? "").trim(); + text = text.replace(/\s*#\s*buildkit\s*$/i, ""); + if (text.startsWith("/bin/sh -c #(nop)")) { + text = text.slice("/bin/sh -c #(nop)".length).trim(); + } else if (text.startsWith("/bin/sh -c")) { + const body = text.slice("/bin/sh -c".length).trim(); + return { instruction: "RUN", args: body || text }; + } + const space = text.search(/\s/); + if (space < 0) { + const upper = text.toUpperCase(); + return DOCKERFILE_INSTRUCTIONS.has(upper) ? { instruction: upper, args: "" } : { instruction: "LAYER", args: text }; + } + const first = text.slice(0, space).toUpperCase(); + const rest = text.slice(space + 1).trim(); + if (DOCKERFILE_INSTRUCTIONS.has(first)) { + return { instruction: first, args: rest }; + } + return { instruction: "RUN", args: text }; +} + +export interface ContainerMountInfo { + type: string; + name?: string; + source: string; + destination: string; + mode: string; + rw: boolean; +} + +export interface ContainerNetworkInfo { + name: string; + ipAddress: string; + gateway: string; + macAddress: string; +} + +export interface ParsedContainerInspect { + created: string; + imageId: string; + restartPolicy: string; + networkMode: string; + privileged: boolean; + pid: number | null; + exitCode: number | null; + startedAt: string; + finishedAt: string; + healthStatus: string | null; + user: string; + workingDir: string; + entrypoint: string[]; + cmd: string[]; + env: Array<[string, string]>; + mounts: ContainerMountInfo[]; + networks: ContainerNetworkInfo[]; + ports: PortMapping[]; + labels: Array<[string, string]>; +} + +function asStringArray(v: unknown): string[] { + return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; +} + +function splitEnvList(raw: unknown): Array<[string, string]> { + return asStringArray(raw).map((entry) => { + const eq = entry.indexOf("="); + return eq >= 0 ? [entry.slice(0, eq), entry.slice(eq + 1)] : [entry, ""]; + }); +} + +function recordEntries(raw: unknown): Array<[string, string]> { + if (!raw || typeof raw !== "object") return []; + return Object.entries(raw as Record) + .filter(([, v]) => typeof v === "string" || typeof v === "number" || typeof v === "boolean") + .map(([k, v]) => [k, String(v)] as [string, string]) + .sort((a, b) => a[0].localeCompare(b[0])); +} + +export function parseContainerInspect(raw: string | null, fallbackPorts = ""): ParsedContainerInspect | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as unknown; + const obj = (Array.isArray(parsed) ? parsed[0] : parsed) as Record | undefined; + if (!obj || typeof obj !== "object") return null; + const state = (obj.State as Record | undefined) ?? {}; + const config = (obj.Config as Record | undefined) ?? {}; + const hostConfig = (obj.HostConfig as Record | undefined) ?? {}; + const netSettings = (obj.NetworkSettings as Record | undefined) ?? {}; + const restart = (hostConfig.RestartPolicy as Record | undefined) ?? {}; + const restartName = typeof restart.Name === "string" && restart.Name ? restart.Name : "no"; + const maxRetry = + typeof restart.MaximumRetryCount === "number" && restart.MaximumRetryCount > 0 + ? `:${restart.MaximumRetryCount}` + : ""; + const health = (state.Health as Record | undefined) ?? {}; + + const mountsRaw = Array.isArray(obj.Mounts) ? obj.Mounts : []; + const mounts: ContainerMountInfo[] = mountsRaw + .filter((m): m is Record => !!m && typeof m === "object") + .map((m) => ({ + type: typeof m.Type === "string" ? m.Type : "volume", + name: typeof m.Name === "string" && m.Name ? m.Name : undefined, + source: typeof m.Source === "string" ? m.Source : "", + destination: typeof m.Destination === "string" ? m.Destination : "", + mode: typeof m.Mode === "string" ? m.Mode : "", + rw: m.RW !== false, + })); + + const netsObj = (netSettings.Networks as Record | undefined) ?? {}; + const networks: ContainerNetworkInfo[] = Object.entries(netsObj) + .filter(([, v]) => !!v && typeof v === "object") + .map(([name, v]) => { + const n = v as Record; + return { + name, + ipAddress: typeof n.IPAddress === "string" ? n.IPAddress : "", + gateway: typeof n.Gateway === "string" ? n.Gateway : "", + macAddress: typeof n.MacAddress === "string" ? n.MacAddress : "", + }; + }); + + const portsObj = + (netSettings.Ports as Record | undefined) ?? + (hostConfig.PortBindings as Record | undefined) ?? + null; + + return { + created: typeof obj.Created === "string" ? obj.Created : "", + imageId: typeof obj.Image === "string" ? obj.Image : "", + restartPolicy: `${restartName}${maxRetry}`, + networkMode: typeof hostConfig.NetworkMode === "string" ? hostConfig.NetworkMode : "", + privileged: hostConfig.Privileged === true, + pid: typeof state.Pid === "number" && state.Pid > 0 ? state.Pid : null, + exitCode: typeof state.ExitCode === "number" ? state.ExitCode : null, + startedAt: typeof state.StartedAt === "string" ? state.StartedAt : "", + finishedAt: typeof state.FinishedAt === "string" ? state.FinishedAt : "", + healthStatus: typeof health.Status === "string" ? health.Status : null, + user: typeof config.User === "string" ? config.User : "", + workingDir: typeof config.WorkingDir === "string" ? config.WorkingDir : "", + entrypoint: asStringArray(config.Entrypoint), + cmd: asStringArray(config.Cmd), + env: splitEnvList(config.Env), + mounts, + networks, + ports: parsePortMappings(fallbackPorts, portsObj), + labels: recordEntries(config.Labels), + }; + } catch { + return null; + } +} + +export interface ParsedImageInspect { + created: string; + size: number; + architecture: string; + os: string; + author: string; + dockerVersion: string; + repoTags: string[]; + repoDigests: string[]; + user: string; + workingDir: string; + entrypoint: string[]; + cmd: string[]; + exposedPorts: string[]; + volumes: string[]; + env: Array<[string, string]>; + labels: Array<[string, string]>; +} + +export function parseImageInspect(raw: string | null): ParsedImageInspect | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as unknown; + const obj = (Array.isArray(parsed) ? parsed[0] : parsed) as Record | undefined; + if (!obj || typeof obj !== "object") return null; + const config = (obj.Config as Record | undefined) ?? {}; + const exposed = + config.ExposedPorts && typeof config.ExposedPorts === "object" + ? Object.keys(config.ExposedPorts as Record).sort() + : []; + const volumes = + config.Volumes && typeof config.Volumes === "object" + ? Object.keys(config.Volumes as Record).sort() + : []; + return { + created: typeof obj.Created === "string" ? obj.Created : "", + size: typeof obj.Size === "number" ? obj.Size : 0, + architecture: typeof obj.Architecture === "string" ? obj.Architecture : "", + os: typeof obj.Os === "string" ? obj.Os : "", + author: typeof obj.Author === "string" ? obj.Author : "", + dockerVersion: typeof obj.DockerVersion === "string" ? obj.DockerVersion : "", + repoTags: asStringArray(obj.RepoTags), + repoDigests: asStringArray(obj.RepoDigests), + user: typeof config.User === "string" ? config.User : "", + workingDir: typeof config.WorkingDir === "string" ? config.WorkingDir : "", + entrypoint: asStringArray(config.Entrypoint), + cmd: asStringArray(config.Cmd), + exposedPorts: exposed, + volumes, + env: splitEnvList(config.Env), + labels: recordEntries(config.Labels), + }; + } catch { + return null; + } +} + +export interface ParsedNetworkInspect { + created: string; + ipamConfigs: Array<{ subnet: string; gateway: string }>; + containers: Array<{ id: string; name: string; ipv4Address: string; macAddress: string }>; + options: Array<[string, string]>; + labels: Array<[string, string]>; +} + +export function parseNetworkInspect(raw: string | null): ParsedNetworkInspect | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as unknown; + const obj = (Array.isArray(parsed) ? parsed[0] : parsed) as Record | undefined; + if (!obj || typeof obj !== "object") return null; + const ipam = (obj.IPAM as Record | undefined) ?? {}; + const ipamRaw = Array.isArray(ipam.Config) ? ipam.Config : []; + const ipamConfigs = ipamRaw + .filter((c): c is Record => !!c && typeof c === "object") + .map((c) => ({ + subnet: typeof c.Subnet === "string" ? c.Subnet : "", + gateway: typeof c.Gateway === "string" ? c.Gateway : "", + })) + .filter((c) => c.subnet || c.gateway); + const containersObj = (obj.Containers as Record | undefined) ?? {}; + const containers = Object.entries(containersObj) + .filter(([, v]) => !!v && typeof v === "object") + .map(([id, v]) => { + const c = v as Record; + return { + id, + name: typeof c.Name === "string" ? c.Name : shortId(id), + ipv4Address: typeof c.IPv4Address === "string" ? c.IPv4Address : "", + macAddress: typeof c.MacAddress === "string" ? c.MacAddress : "", + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + return { + created: typeof obj.Created === "string" ? obj.Created : "", + ipamConfigs, + containers, + options: recordEntries(obj.Options), + labels: recordEntries(obj.Labels), + }; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Shared UI primitives (adapted from openidea-kubernetes ResourceDetails.tsx) + +const copyText = (text: string) => void navigator.clipboard?.writeText(text); + +async function openExternalUrl(url: string): Promise { + try { + if (isTauri()) { + const opener = await import("@tauri-apps/plugin-opener"); + await opener.openUrl(url); + } else { + window.open(url, "_blank", "noopener"); + } + } catch (e) { + notifications().notify({ group: "run", level: "error", title: "Cannot open link", body: String(e) }); + } +} + +export function selectServicesTreeKey(key: string): void { + useServicesTree.setState((s) => { + const expanded = new Set(s.expanded); + // Ensure parent connection and group nodes are expanded so the target row is visible. + const parts = key.split(":"); + if (parts.length >= 2) expanded.add(`${parts[0]!}:${parts[1]!}`); + if (parts.length >= 4) { + const kind = parts[2]!; + const group = + kind === "container" || kind === "compose" + ? "containers" + : kind === "image" + ? "images" + : kind === "volume" + ? "volumes" + : kind === "network" + ? "networks" + : null; + if (group) expanded.add(`${parts[0]!}:${parts[1]!}:group:${group}`); + } + return { selectedKey: key, selectedKeys: new Set([key]), expanded }; + }); +} + +function SectionBanner({ children, action }: { children: ReactNode; action?: ReactNode }) { + return ( +
+ {children} + {action} +
+ ); +} + +function PropRow({ label, children, hidden }: { label: ReactNode; children: ReactNode; hidden?: boolean }) { + if (hidden) return null; + return ( +
+
{label}
+
{children}
+
+ ); +} + +function LineProgress({ percent }: { percent: number }) { + const clamped = Math.min(100, Math.max(0, percent)); + const tone = clamped >= 90 ? styles.progressDanger : clamped >= 75 ? styles.progressWarning : styles.progressNormal; + return ( +
+
0 ? Math.max(2, clamped) : 0}%` }} + /> +
+ ); +} + +function StatusBadge({ state, text }: { state: string; text?: string }) { + const s = state.toLowerCase(); + const tone = + s === "running" || s === "healthy" + ? styles.badgeOk + : s === "paused" || s === "restarting" || s === "starting" + ? styles.badgeWarn + : s === "dead" || s === "unhealthy" || s === "removing" + ? styles.badgeDanger + : styles.badgeMuted; + return {text ?? state}; +} + +function KeyValueTable({ rows, emptyText }: { rows: ReadonlyArray; emptyText?: ReactNode }) { + if (!rows.length) { + return emptyText ?
{emptyText}
: null; + } + return ( +
+ {rows.map(([k, v]) => ( +
+ + {k} + + + {v || "—"} + +
+ ))} +
+ ); +} + +function ContainerLinkChip({ conn, container }: { conn: string; container: DockerContainer }) { + const running = isRunning(container); + const key = treeKey(conn, { kind: "container", key: `container:${container.id}`, container }); + const parentKey = container.composeProject + ? `docker:${conn}:compose:${container.composeProject}` + : isDevContainer(container) + ? `docker:${conn}:group:devContainers` + : `docker:${conn}:group:containers`; + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Container Dashboard & Processes View + +export function ContainerDashboard({ + conn, + container, + snapshot, + inspectText, +}: { + conn: string; + container: DockerContainer; + snapshot: DockerSnapshot; + inspectText: string | null; +}) { + const running = isRunning(container); + const target: DockerTarget = { + conn, + node: { kind: "container", key: `container:${container.id}`, container }, + }; + const inspect = useMemo(() => parseContainerInspect(inspectText, container.ports), [inspectText, container.ports]); + const ports = useMemo(() => inspect?.ports ?? parsePortMappings(container.ports), [inspect, container.ports]); + const matchedImage = useMemo(() => findImageForContainer(snapshot, container), [snapshot, container]); + + const [stats, setStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(false); + + useEffect(() => { + if (!running || !isTauri()) return; + let alive = true; + call(commands.dockerContainerStats(endpointOf(conn), container.id)) + .then((s) => alive && setStats(s)) + .catch(() => alive && setStats(null)); + return () => { + alive = false; + }; + }, [conn, container.id, running]); + + const reloadStats = useCallback(() => { + if (!running || !isTauri()) return; + setStatsLoading(true); + call(commands.dockerContainerStats(endpointOf(conn), container.id)) + .then((s) => setStats(s)) + .catch(() => setStats(null)) + .finally(() => setStatsLoading(false)); + }, [conn, container.id, running]); + + const cpuPct = stats ? parsePercent(stats.cpuPerc) : 0; + const memPair = stats ? parseUsagePair(stats.memUsage) : null; + const labels = inspect?.labels.length ? inspect.labels : Object.entries(container.labels ?? {}); + + return ( +
+
+ + + + {container.state === "paused" ? ( + + ) : ( + + )} + + + + +
+ + + Container Overview + +
+ Name}> + {container.name} + + Container ID}> + + + {shortId(container.id)} + + copyText(container.id)} /> + + + Status}> + + + {inspect?.healthStatus && } + {inspect?.exitCode !== null && inspect?.exitCode !== undefined && !running && ( + Exit code {inspect.exitCode} + )} + {inspect?.pid && PID {inspect.pid}} + + + Image}> + + {matchedImage ? ( + + ) : ( + {container.image} + )} + copyText(container.image)} /> + + + Created}>{formatCreatedDetailed(inspect?.created || container.createdAt)} + Started} hidden={!inspect?.startedAt || inspect.startedAt.startsWith("0001-01-01")}> + {formatCreatedDetailed(inspect?.startedAt)} + + Command} hidden={!container.command && !inspect?.cmd.length}> + + {inspect && (inspect.entrypoint.length || inspect.cmd.length) + ? [...inspect.entrypoint, ...inspect.cmd].join(" ") + : container.command} + + + Working directory} hidden={!inspect?.workingDir}> + {inspect?.workingDir} + + User} hidden={!inspect?.user}> + {inspect?.user} + + Restart policy} hidden={!inspect?.restartPolicy}> + {inspect?.restartPolicy} + + Compose} hidden={!container.composeProject}> + + + {container.composeService && · {container.composeService}} + + +
+ + {running && ( + <> + + } + > + Resource Usage + + {stats ? ( +
+ CPU}> +
+ {stats.cpuPerc} + +
+
+ Memory}> +
+ + {stats.memUsage} ({stats.memPerc}) + + +
+
+ Network I/O} hidden={!stats.netIo}> + {stats.netIo} + + Block I/O} hidden={!stats.blockIo}> + {stats.blockIo} + + PIDs} hidden={!stats.pids}> + {stats.pids} + +
+ ) : ( +
+ {statsLoading ? ( + <> + Loading… + + ) : ( + Metrics unavailable + )} +
+ )} + + )} + + + Port Bindings + + {ports.length > 0 ? ( +
+ {ports.map((p) => + p.url ? ( + + ) : ( + + {p.label} + + ), + )} +
+ ) : ( +
+ No port bindings +
+ )} + + + Volume & Bind Mounts + + {inspect?.mounts && inspect.mounts.length > 0 ? ( +
+
+ + Type + + + Source + + + Destination + + + Mode + +
+ {inspect.mounts.map((m, idx) => { + const volumeObj = m.name ? snapshot.volumes.find((v) => v.name === m.name) : undefined; + return ( +
+ {m.type} + + {volumeObj ? ( + + ) : ( + m.name || m.source || "—" + )} + + + {m.destination} + + {m.rw ? m.mode || "rw" : "ro"} +
+ ); + })} +
+ ) : container.mounts && container.mounts.length > 0 ? ( +
+ {container.mounts.map((m) => { + const volumeObj = snapshot.volumes.find((v) => v.name === m); + return volumeObj ? ( + + ) : ( + + {m} + + ); + })} +
+ ) : ( +
+ No mounts +
+ )} + + {inspect?.networks.length || container.networks?.length ? ( + <> + + Networks + +
+ {(inspect?.networks.length + ? inspect.networks + : (container.networks ?? []).map((name) => ({ name, ipAddress: "", gateway: "", macAddress: "" })) + ).map((n) => { + const netObj = snapshot.networks.find((x) => x.name === n.name); + return ( + + ); + })} +
+ + ) : null} + + + Environment Variables + + No environment variables} /> + + {labels.length > 0 && ( + <> + + Labels + + + + )} +
+ ); +} + +export function ContainerProcessesView({ conn, id, running }: { conn: string; id: string; running: boolean }) { + const tx = useTx(); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!running || !isTauri()) return; + let alive = true; + call(commands.dockerContainerTop(endpointOf(conn), id)) + .then((res) => { + if (!alive) return; + setData(res); + setError(null); + }) + .catch((e) => alive && setError(errorMessage(e))); + return () => { + alive = false; + }; + }, [conn, id, running]); + + const reload = useCallback(() => { + if (!running || !isTauri()) return; + setLoading(true); + setError(null); + call(commands.dockerContainerTop(endpointOf(conn), id)) + .then((res) => setData(res)) + .catch((e) => setError(errorMessage(e))) + .finally(() => setLoading(false)); + }, [conn, id, running]); + + if (!running) { + return ( + + Start the container to view its running processes. + + ); + } + + return ( +
+
+ + Processes + {data ? ` (${data.processes.length})` : ""} + + +
+ {error ? ( +
{error}
+ ) : !data ? ( +
+ Loading… +
+ ) : data.processes.length === 0 ? ( + + ) : ( +
+ + + + {data.titles.map((title, i) => ( + + ))} + + + + {data.processes.map((row, rIdx) => ( + + {data.titles.map((_, cIdx) => ( + + ))} + + ))} + +
{title}
+ {row[cIdx] ?? ""} +
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Image Dashboard & Layers View + +export function ImageDashboard({ + conn, + image, + snapshot, + inspectText, +}: { + conn: string; + image: DockerImage; + snapshot: DockerSnapshot; + inspectText: string | null; +}) { + const target: DockerTarget = { conn, node: { kind: "image", key: `image:${image.id}`, image } }; + const inspect = useMemo(() => parseImageInspect(inspectText), [inspectText]); + const tags = useMemo(() => { + const fromSnap = imageTagsForId(snapshot, image); + const fromInspect = inspect?.repoTags ?? []; + return [...new Set([...fromSnap, ...fromInspect])]; + }, [snapshot, image, inspect]); + const usingContainers = useMemo(() => containersUsingImage(snapshot, image), [snapshot, image]); + + return ( +
+
+ + + +
+ + + Image Overview + +
+ Repository}> + {image.repository} + + Tag}> + {image.tag} + + Image ID}> + + + {shortId(image.id)} + + copyText(shortId(image.id))} /> + + + Size}> + + {inspect?.size ? `${bytesToUnits(inspect.size)} (${image.size})` : image.size} + + + Created}> + {inspect?.created ? formatCreatedDetailed(inspect.created) : image.createdSince} + + Platform} hidden={!inspect?.os && !inspect?.architecture}> + {[inspect?.os, inspect?.architecture].filter(Boolean).join("/")} + + Command} hidden={!inspect?.entrypoint.length && !inspect?.cmd.length}> + + {[...(inspect?.entrypoint ?? []), ...(inspect?.cmd ?? [])].join(" ")} + + + Working directory} hidden={!inspect?.workingDir}> + {inspect?.workingDir} + + User} hidden={!inspect?.user}> + {inspect?.user} + + Exposed ports} hidden={!inspect?.exposedPorts.length}> +
+ {inspect?.exposedPorts.map((p) => ( + + {p} + + ))} +
+
+
+ + {tags.length > 0 && ( + <> + + Tags + +
+ {tags.map((t) => ( + + {t} + + ))} +
+ + )} + + + Containers Using Image + + {usingContainers.length > 0 ? ( +
+ {usingContainers.map((c) => ( + + ))} +
+ ) : ( +
+ No containers are using this image +
+ )} + + {inspect?.env && inspect.env.length > 0 && ( + <> + + Environment Variables + + + + )} + + {inspect?.labels && inspect.labels.length > 0 && ( + <> + + Labels + + + + )} +
+ ); +} + +export function ImageLayersView({ conn, id }: { conn: string; id: string }) { + const tx = useTx(); + const [layers, setLayers] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let alive = true; + call(commands.dockerImageHistory(endpointOf(conn), id)) + .then((res) => alive && setLayers(res)) + .catch((e) => alive && setError(errorMessage(e))); + return () => { + alive = false; + }; + }, [conn, id]); + + const maxBytes = useMemo(() => Math.max(1, ...(layers ?? []).map((l) => unitsToBytes(l.size))), [layers]); + + if (error) return
{error}
; + if (!layers) { + return ( +
+ Loading… +
+ ); + } + if (layers.length === 0) return ; + + return ( +
+
+ + + + + + + + + + + + {layers.map((layer, idx) => { + const { instruction, args } = splitLayerInstruction(layer.createdBy); + const bytes = unitsToBytes(layer.size); + const pct = bytes > 0 ? (bytes / maxBytes) * 100 : 0; + return ( + + + + + + + + ); + })} + +
# + Instruction + + Command + + Size + + Created +
{idx + 1} + {instruction} + + {args || layer.createdBy} + +
+ {layer.size} + {bytes > 0 && } +
+
{layer.createdSince}
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Volume, Network & Compose Project Dashboards + +export function VolumeDashboard({ + conn, + volume, + snapshot, +}: { + conn: string; + volume: DockerVolume; + snapshot: DockerSnapshot; +}) { + const target: DockerTarget = { conn, node: { kind: "volume", key: `volume:${volume.name}`, volume } }; + const usingContainers = useMemo(() => containersUsingVolume(snapshot, volume), [snapshot, volume]); + const labels = Object.entries(volume.labels ?? {}).sort((a, b) => a[0].localeCompare(b[0])); + + return ( +
+
+ + +
+ + + Volume Overview + +
+ Name}> + {volume.name} + + Driver}> + {volume.driver} + + Scope} hidden={!volume.scope}> + {volume.scope} + + Mountpoint} hidden={!volume.mountpoint}> + + + {volume.mountpoint} + + copyText(volume.mountpoint)} /> + + + Compose} hidden={!volume.composeProject}> + {volume.composeProject} + +
+ + + Containers Using Volume + + {usingContainers.length > 0 ? ( +
+ {usingContainers.map((c) => ( + + ))} +
+ ) : ( +
+ No containers are using this volume +
+ )} + + {labels.length > 0 && ( + <> + + Labels + + + + )} +
+ ); +} + +export function NetworkDashboard({ + conn, + network, + snapshot, + inspectText, +}: { + conn: string; + network: DockerNetwork; + snapshot: DockerSnapshot; + inspectText: string | null; +}) { + const target: DockerTarget = { conn, node: { kind: "network", key: `network:${network.id}`, network } }; + const inspect = useMemo(() => parseNetworkInspect(inspectText), [inspectText]); + const connected = useMemo(() => containersInNetwork(snapshot, network), [snapshot, network]); + const labels = inspect?.labels.length + ? inspect.labels + : Object.entries(network.labels ?? {}).sort((a, b) => a[0].localeCompare(b[0])); + + return ( +
+
+ + +
+ + + Network Overview + +
+ Name}> + {network.name} + + Network ID}> + + + {shortId(network.id)} + + copyText(network.id)} /> + + + Driver}> + {network.driver} + + Scope}> + {network.scope} + + Created} hidden={!inspect?.created && !network.createdAt}> + {formatCreatedDetailed(inspect?.created || network.createdAt)} + + Subnet / Gateway} hidden={!inspect?.ipamConfigs.length}> +
+ {inspect?.ipamConfigs.map((cfg, idx) => ( + + + {[cfg.subnet, cfg.gateway ? `gw ${cfg.gateway}` : ""].filter(Boolean).join(" · ")} + + + ))} +
+
+ Compose} hidden={!network.composeProject}> + {network.composeProject} + +
+ + + Connected Containers + + {connected.length > 0 ? ( +
+ {connected.map((c) => { + const ipInfo = inspect?.containers.find((ic) => ic.id === c.id || ic.name === c.name); + return ( + + + {ipInfo?.ipv4Address && {ipInfo.ipv4Address}} + + ); + })} +
+ ) : ( +
+ No containers are connected to this network +
+ )} + + {labels.length > 0 && ( + <> + + Labels + + + + )} +
+ ); +} + +export function ComposeProjectDashboard({ + conn, + project, + containers, +}: { + conn: string; + project: string; + containers: DockerContainer[]; +}) { + const txTemplate = useTxTemplate(); + const target: DockerTarget = { + conn, + node: { kind: "compose", key: `compose:${project}`, project, containers }, + }; + const first = containers[0]; + const runningCount = containers.filter(isRunning).length; + + return ( +
+
+ + + + +
+ + {txTemplate(`Compose project '${project}'`)} +
+ Project}> + {project} + + Status}> + + {runningCount}/{containers.length} running + + + Working directory} hidden={!first?.composeWorkingDir}> + {first?.composeWorkingDir} + + Compose files} hidden={!first?.composeFiles.length}> + {first?.composeFiles.join(", ")} + +
+ + + Services + +
+ {containers.map((c) => ( + + ))} +
+
+ ); +} diff --git a/src/features/services/docker.tsx b/src/features/services/docker.tsx index 8c1fc397d..737e4a777 100644 --- a/src/features/services/docker.tsx +++ b/src/features/services/docker.tsx @@ -10,6 +10,7 @@ import { Channel } from "@tauri-apps/api/core"; import type { DockerAvailability, DockerConnection, + DockerContainer, DockerEndpoint, DockerLogLine, DockerObject, @@ -28,6 +29,7 @@ import { import { confirm } from "@/workbench/messages"; import { openConnectionDialog, openRegistryDialog } from "./dialogs"; import { + DEFAULT_DOCKER_FILTERS, DOCKER_KEY, childrenOf, connectionKey, @@ -40,9 +42,20 @@ import { registryKey, rootNodes, treeKey, + type DockerFilters, type DockerTarget, type ServiceNode, } from "./model"; +import { + ComposeProjectDashboard, + ContainerDashboard, + ContainerProcessesView, + ImageDashboard, + ImageLayersView, + NetworkDashboard, + VolumeDashboard, +} from "./dashboard"; +import { openContainerLogInEditor } from "./logEditor"; import { canRemove, canRestart, @@ -68,6 +81,7 @@ import { useServices, watch, type ConnectionState, + type DetailTab, } from "./store"; import { useServicesTree } from "./tree"; import styles from "./Services.module.css"; @@ -101,11 +115,12 @@ export function dockerItems( connections: readonly DockerConnection[], registries: readonly DockerRegistry[], conns: Record, + filters: DockerFilters = DEFAULT_DOCKER_FILTERS, ): ServiceTreeItem[] { const items: ServiceTreeItem[] = connections.map((c) => { const state = conns[c.id] ?? { snapshot: null, loading: false, error: null }; const snap = state.snapshot; - const children = childrenOf(snap); + const children = childrenOf(snap, filters); const convert = (n: ServiceNode): ServiceTreeItem => { const kids = children(n); return { @@ -121,13 +136,14 @@ export function dockerItems( return { key: connectionKey(c.id), label: c.name, - icon: "file-docker", + icon: snap?.availability.kind === "ready" ? "docker-connected" : "file-docker", detail: connectionDetail(state), dim: snap?.availability.kind !== "ready", children: children(root)?.map(convert) ?? [], }; }); - for (const r of registries) items.push({ key: registryKey(r.id), label: r.name, icon: "package", detail: r.address }); + for (const r of registries) + items.push({ key: registryKey(r.id), label: r.name, icon: "docker-registry", detail: r.address }); return items; } @@ -205,7 +221,7 @@ function actions(item: ServiceTreeItem): ServiceItemAction[] { disabled: !container || !isRunning(container), run: () => execShell(t), }, - { id: "pull", title: "Pull Image…", icon: "git-pull", run: () => void pullPrompt(t.conn) }, + { id: "pull", title: "Pull Image…", icon: "docker-pull", run: () => void pullPrompt(t.conn) }, ]; } @@ -275,14 +291,23 @@ export function DockerTaskBanner() { } /** Follows a container's log while shown. */ -function LogView({ conn, id, running }: { conn: string; id: string; running: boolean }) { +function LogView({ conn, container, running }: { conn: string; container: DockerContainer; running: boolean }) { const tx = useTx(); + const id = container.id; return (
openContainerLogInEditor(conn, container)} + /> + } ariaLabel={tx("Container log")} start={(onLine, onError, on) => { const request = nextRequestId(); @@ -300,21 +325,43 @@ function LogView({ conn, id, running }: { conn: string; id: string; running: boo ); } -function InspectView({ conn, object, id }: { conn: string; object: DockerObject; id: string }) { - const tx = useTx(); - const [text, setText] = useState(null); - const [error, setError] = useState(null); +function useObjectInspect(conn: string, object: DockerObject | null, id: string | null) { + const [state, setState] = useState<{ key: string; text: string | null; error: string | null }>({ + key: "", + text: null, + error: null, + }); + const currentKey = object && id ? `${conn}:${object}:${id}` : ""; useEffect(() => { + if (!object || !id) return; + const reqKey = `${conn}:${object}:${id}`; let alive = true; call(commands.dockerInspect(endpointOf(conn), object, id)) - .then((t) => alive && setText(t)) - .catch((e) => alive && setError(errorMessage(e))); + .then((t) => alive && setState({ key: reqKey, text: t, error: null })) + .catch((e) => alive && setState({ key: reqKey, text: null, error: errorMessage(e) })); return () => { alive = false; }; }, [conn, object, id]); + return state.key === currentKey ? { text: state.text, error: state.error } : { text: null, error: null }; +} + +function InspectView({ text, error }: { text: string | null; error: string | null }) { + const tx = useTx(); return (
+
+ + Inspect JSON + + text && void navigator.clipboard?.writeText(text)} + /> +
 (parsed ? connectionState(parsed.conn, s) : null));
   const target = resolveTarget(item.key);
   const registry = settings?.registries.find((r) => registryKey(r.id) === item.key);
+  const node = target?.node ?? null;
+  const object: DockerObject | null =
+    node && (node.kind === "container" || node.kind === "image" || node.kind === "volume" || node.kind === "network")
+      ? node.kind
+      : null;
+  const objectId =
+    node?.kind === "container"
+      ? node.container.id
+      : node?.kind === "image"
+        ? node.image.id
+        : node?.kind === "volume"
+          ? node.volume.name
+          : node?.kind === "network"
+            ? node.network.id
+            : null;
+  const inspect = useObjectInspect(parsed?.conn ?? "default", object, objectId);
+
   if (registry) return ;
-  if (!target || !parsed || !state) return ;
+  if (!target || !parsed || !state || !node) return ;
   const conn = connectionOf(parsed.conn);
-  const node = target.node;
   const snapshot = state.snapshot;
   if (!snapshot)
     return (
@@ -379,7 +442,14 @@ function DockerDetail({ item }: { item: ServiceTreeItem }) {
     );
   if (snapshot.availability.kind !== "ready")
     return ;
-  if (node.kind === "docker" || node.kind === "group" || node.kind === "compose") {
+  if (node.kind === "compose") {
+    return (
+      
+ +
+ ); + } + if (node.kind === "docker" || node.kind === "group") { const a = snapshot.availability; return (
@@ -388,9 +458,7 @@ function DockerDetail({ item }: { item: ServiceTreeItem }) { {snapshot.errors.join("; ")}
)} - +
{txTemplate(`Engine ${a.serverVersion} · CLI ${a.clientVersion}`)} {a.context ? txTemplate(` · context ${a.context}`) : ""} @@ -403,32 +471,66 @@ function DockerDetail({ item }: { item: ServiceTreeItem }) {
); } - const object: DockerObject = node.kind; - const id = + + const tabItems: Array<{ id: DetailTab; label: string }> = node.kind === "container" - ? node.container.id + ? [ + { id: "dashboard", label: "Dashboard" }, + { id: "log", label: "Log" }, + { id: "processes", label: "Processes" }, + { id: "inspect", label: "Inspect" }, + ] : node.kind === "image" - ? node.image.id - : node.kind === "volume" - ? node.volume.name - : node.network.id; - const showLog = node.kind === "container" && tab === "log"; + ? [ + { id: "dashboard", label: "Dashboard" }, + { id: "layers", label: "Layers" }, + { id: "inspect", label: "Inspect" }, + ] + : [ + { id: "dashboard", label: "Dashboard" }, + { id: "inspect", label: "Inspect" }, + ]; + + const activeId: DetailTab = tabItems.some((t) => t.id === tab) ? tab : "dashboard"; + const id = objectId!; + return (
useServices.setState({ detailTab: t as "log" | "inspect" })} + activeId={activeId} + onSelect={(t) => useServices.setState({ detailTab: t as DetailTab })} aria-label="Details" - items={[ - ...(node.kind === "container" ? [{ id: "log", label: "Log" }] : []), - { id: "inspect", label: "Inspect" }, - ]} + items={tabItems} /> - {showLog ? ( - + {activeId === "log" && node.kind === "container" ? ( + + ) : activeId === "processes" && node.kind === "container" ? ( + + ) : activeId === "layers" && node.kind === "image" ? ( + + ) : activeId === "inspect" ? ( + + ) : node.kind === "container" ? ( + + ) : node.kind === "image" ? ( + + ) : node.kind === "volume" ? ( + ) : ( - + )}
); @@ -437,7 +539,11 @@ function DockerDetail({ item }: { item: ServiceTreeItem }) { function useRoots(): ServiceTreeItem[] { const settings = useServices((s) => s.settings); const conns = useServices((s) => s.conns); - return useMemo(() => dockerItems(settings?.connections ?? [], settings?.registries ?? [], conns), [settings, conns]); + const filters = useServices((s) => s.filters); + return useMemo( + () => dockerItems(settings?.connections ?? [], settings?.registries ?? [], conns, filters), + [settings, conns, filters], + ); } export const dockerProvider: ServiceProviderContribution = { @@ -456,7 +562,7 @@ export const dockerProvider: ServiceProviderContribution = { detail: DockerDetail, banner: DockerTaskBanner, addMenu: () => [ - { label: "Docker Registry…", icon: "package", run: () => openRegistryDialog() }, + { label: "Docker Registry…", icon: "docker-registry", run: () => openRegistryDialog() }, { label: "Docker Connection…", icon: "file-docker", run: () => openConnectionDialog() }, ], }; diff --git a/src/features/services/index.ts b/src/features/services/index.ts index f1b77b9de..63f8aed2a 100644 --- a/src/features/services/index.ts +++ b/src/features/services/index.ts @@ -12,6 +12,7 @@ import { registerActions } from "@/platform/actions"; import { registerEditorContribution } from "@/editor/api"; import { SERVICES_WINDOW, + registerEditorKind, registerOverlay, registerServiceProvider, registerServiceView, @@ -22,6 +23,7 @@ import { dockerGutter } from "./dockerGutter"; import { registerDockerRunTypes } from "./runTypes"; import { ServicesDialogHost } from "./dialogs"; import { dockerProvider, selectedTarget } from "./docker"; +import { DOCKER_LOG_EDITOR_KIND, DockerLogEditor, dockerLogEditorTitle, openContainerLogInEditor } from "./logEditor"; import { canRemove, canStart, @@ -37,7 +39,7 @@ import { } from "./ops"; import { SERVICES_VIEW_FOCUS, ServicesTreeView, showAddServiceMenu } from "./ServicesTree"; import { ServicesHeaderActions, ServicesHost, ServicesTabs } from "./ServicesHost"; -import { connections, refresh } from "./store"; +import { connections, refresh, setDockerFilters, useServices } from "./store"; import { SERVICES_TREE_VIEW } from "./tree"; import { isRunning } from "./model"; import type { PruneTarget } from "@/platform/ipc"; @@ -68,6 +70,13 @@ export function activate(): () => void { component: ServicesTreeView, }), registerServiceProvider(dockerProvider), + registerEditorKind({ + kind: DOCKER_LOG_EDITOR_KIND, + component: DockerLogEditor, + title: dockerLogEditorTitle, + icon: () => "file-log", + keepAlive: true, + }), registerOverlay({ id: "services.dialogs", component: ServicesDialogHost }), registerEditorContribution({ id: "services.dockerGutter", order: 56, extensions: dockerGutter }), registerDockerRunTypes(), @@ -135,6 +144,17 @@ export function activate(): () => void { when: PROJECT, run: () => (canRemove(selectedTarget()) ? remove(selectedTarget()) : undefined), }, + { + id: "docker.showDashboard", + title: "Show Docker Dashboard", + category: "Docker", + icon: "layout", + when: PROJECT, + run: () => { + showDetail("dashboard"); + showServiceView(SERVICES_TREE_VIEW, true); + }, + }, { id: "docker.showLog", title: "Show Container Log", @@ -145,6 +165,39 @@ export function activate(): () => void { showServiceView(SERVICES_TREE_VIEW, true); }, }, + { + id: "docker.openLogInEditor", + title: "Open Container Log in Editor", + category: "Docker", + icon: "file-log", + when: PROJECT, + run: () => { + const t = selectedTarget(); + if (t?.node.kind === "container") openContainerLogInEditor(t.conn, t.node.container); + }, + }, + { + id: "docker.showProcesses", + title: "Show Container Processes", + category: "Docker", + icon: "list", + when: PROJECT, + run: () => { + showDetail("processes"); + showServiceView(SERVICES_TREE_VIEW, true); + }, + }, + { + id: "docker.showLayers", + title: "Show Image Layers", + category: "Docker", + icon: "list", + when: PROJECT, + run: () => { + showDetail("layers"); + showServiceView(SERVICES_TREE_VIEW, true); + }, + }, { id: "docker.inspect", title: "Inspect Docker Object", @@ -156,6 +209,22 @@ export function activate(): () => void { showServiceView(SERVICES_TREE_VIEW, true); }, }, + { + id: "docker.toggleStoppedContainers", + title: "Toggle Stopped Containers", + category: "Docker", + icon: "filter", + when: PROJECT, + run: () => setDockerFilters({ showStoppedContainers: !useServices.getState().filters.showStoppedContainers }), + }, + { + id: "docker.toggleUntaggedImages", + title: "Toggle Untagged Images", + category: "Docker", + icon: "filter", + when: PROJECT, + run: () => setDockerFilters({ showUntaggedImages: !useServices.getState().filters.showUntaggedImages }), + }, { id: "docker.execShell", title: "Create Terminal in Container", @@ -172,7 +241,7 @@ export function activate(): () => void { id: "docker.pullImage", title: "Pull Docker Image…", category: "Docker", - icon: "git-pull", + icon: "docker-pull", when: PROJECT, run: () => { const conn = currentConnection(); diff --git a/src/features/services/logEditor.tsx b/src/features/services/logEditor.tsx new file mode 100644 index 000000000..f41767464 --- /dev/null +++ b/src/features/services/logEditor.tsx @@ -0,0 +1,442 @@ +/** + * Container log in a regular CodeMirror 6 Editor Tab (`DOCKER_LOG_EDITOR_KIND`), + * with log syntax highlighting, line numbers, Timestamps & Soft-wrap toggles, + * Find (`⌘F`) and font zoom (`⌘=` / `⌘-` / `⌘0`), matching `PluginLogEditor`. + */ +import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { Channel } from "@tauri-apps/api/core"; +import { Compartment, EditorState, Prec, type StateEffect } from "@codemirror/state"; +import { EditorView, highlightSpecialChars, keymap } from "@codemirror/view"; +import { defaultKeymap } from "@codemirror/commands"; +import { LanguageSupport, syntaxHighlighting } from "@codemirror/language"; +import { caretRowHighlight } from "@/editor/core/cm/caretRow"; +import { findExtension, findResultLines, findScope, type FindOptions } from "@/editor/core/cm/find"; +import { openideaHighlightStyle } from "@/editor/core/cm/highlight"; +import { overviewLane } from "@/editor/core/cm/lanes"; +import { logLanguage } from "@/editor/core/cm/log"; +import { caretExtension, fontZoom, setFontZoom, softWrapActive, stepFontZoom } from "@/editor/core/cm/prefs"; +import { fontExtension, heavyExtension, lineNumbersExtension, prefsOf } from "@/editor/core/cm/setup"; +import { openideaTheme } from "@/editor/core/cm/theme"; +import { FindPanel, searchAgain, seedFind, type FindTarget } from "@/editor/core/view/FindBar"; +import { editorPrefs } from "@/platform/editorSettings"; +import { tx } from "@/platform/i18n"; +import { call, commands, isCancelled, nextRequestId, type DockerContainer, type DockerLogLine } from "@/platform/ipc"; +import { bindingsFor, normalizeBinding, registerKeyClaim, strokeOf } from "@/platform/keymap"; +import { useProject } from "@/platform/project"; +import { EmptyState, IconButton, Toggle, selectableText } from "@/ui"; +import logStyles from "@/ui/LogPane.module.css"; +import { editorService, type EditorInput } from "@/workbench/contributions"; +import { isRunning } from "./model"; +import { connectionState, endpointOf, errorMessage, useServices } from "./store"; + +export const DOCKER_LOG_EDITOR_KIND = "dockerLog"; + +export interface DockerLogEditorData { + conn: string; + containerId: string; + containerName: string; +} + +export function dockerLogEditorTitle(input: EditorInput): string { + const data = input.data as Partial | undefined; + return data?.containerName ? `${data.containerName} (Log)` : input.key; +} + +export function openContainerLogInEditor(conn: string, container: DockerContainer): void { + const data: DockerLogEditorData = { + conn, + containerId: container.id, + containerName: container.name, + }; + void editorService().openInput({ + kind: DOCKER_LOG_EDITOR_KIND, + key: `${DOCKER_LOG_EDITOR_KIND}:${conn}:${container.id}`, + data, + }); +} + +const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g; // eslint-disable-line no-control-regex + +function boundedLog(text: string): string { + let result = text.slice(-(1 << 20)); + if (/^[\uDC00-\uDFFF]/.test(result)) result = result.slice(1); + const lines = result.split("\n"); + if (lines.length > 5000) result = lines.slice(-5000).join("\n"); + return result; +} + +function isActionStroke(stroke: string | null, actionId: string): boolean { + return !!stroke && bindingsFor(actionId).some((b) => normalizeBinding(b) === stroke); +} + +const wheelZoom = EditorView.domEventHandlers({ + wheel(e, view) { + if (!e.metaKey && !e.ctrlKey) return false; + e.preventDefault(); + stepFontZoom(view, e.deltaY < 0 ? 1 : -1); + return true; + }, +}); + +export function DockerLogEditor({ input }: { input: EditorInput }) { + const data = input.data as DockerLogEditorData | undefined; + if (!data || !data.containerId) { + return ; + } + return ; +} + +function DockerLogEditorBody({ input, data }: { input: EditorInput; data: DockerLogEditorData }) { + const initialWrap = useMemo(() => { + const s = useProject.getState().settings?.editor; + return softWrapActive(s?.softWrap ?? true, prefsOf(s), "stream.log"); + }, []); + const [timestamps, setTimestamps] = useState(false); + const [wrap, setWrap] = useState(initialWrap); + const [find, setFind] = useState<{ seq: number } | null>(null); + const [viewGen, setViewGen] = useState(0); + + const running = useServices((s) => { + const c = connectionState(data.conn, s).snapshot?.containers.find((x) => x.id === data.containerId); + return c ? isRunning(c) : true; + }); + + const rootRef = useRef(null); + const hostRef = useRef(null); + const viewRef = useRef(null); + const wrapSlotRef = useRef(new Compartment()); + const stickRef = useRef(true); + const receivedRef = useRef(false); + const findOpenRef = useRef(false); + const findWatchersRef = useRef(new Set<(u: { docChanged: boolean; selectionSet: boolean }) => void>()); + const findEscapeRef = useRef<() => boolean>(() => false); + const findId = useId(); + const title = `${data.containerName} (Log)`; + + const findTarget = useMemo( + () => ({ + id: `dockerLog:${findId}:${input.key}`, + view: () => viewRef.current, + watch(fn) { + findWatchersRef.current.add(fn); + return () => void findWatchersRef.current.delete(fn); + }, + isOpen: () => findOpenRef.current, + results(options: FindOptions) { + const view = viewRef.current; + if (!view) return null; + const state = view.state; + const scope = state.field(findScope, false) ?? { from: 0, to: state.doc.length }; + return { + title: tx`Occurrences of '${options.search}'`, + query: { + pattern: options.search, + isRegex: options.regexp, + caseSensitive: options.caseSensitive, + wholeWord: options.wholeWord, + context: options.context, + include: [], + exclude: [], + scope: { kind: "files", paths: [title] }, + maxMatches: 0, + }, + read: () => ({ + path: title, + relPath: title, + matches: findResultLines( + viewRef.current?.state ?? state, + options, + state.field(findScope, false) + ? ((viewRef.current?.state ?? state).field(findScope, false) ?? scope) + : { from: 0, to: (viewRef.current?.state ?? state).doc.length }, + ), + }), + }; + }, + }), + [findId, input.key, title], + ); + + const openFind = () => { + if (!viewRef.current) return; + findOpenRef.current = true; + seedFind(findTarget, false); + setFind((f) => ({ seq: (f?.seq ?? 0) + 1 })); + }; + + const closeFind = () => { + findOpenRef.current = false; + setFind(null); + viewRef.current?.focus(); + }; + + findEscapeRef.current = () => { + if (!findOpenRef.current) return false; + closeFind(); + return true; + }; + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + const fontSlot = new Compartment(); + const caretSlot = new Compartment(); + const lineNumbersSlot = new Compartment(); + const heavySlot = new Compartment(); + const wrapSlot = wrapSlotRef.current; + const editorSettings = () => useProject.getState().settings?.editor; + + const view = new EditorView({ + parent: host, + state: EditorState.create({ + doc: tx("Waiting for output…"), + extensions: [ + fontSlot.of(fontExtension(editorSettings())), + caretSlot.of(caretExtension(editorPrefs())), + lineNumbersSlot.of(lineNumbersExtension(editorSettings())), + wrapSlot.of(wrap ? EditorView.lineWrapping : []), + heavySlot.of(heavyExtension(0, editorSettings())), + fontZoom, + wheelZoom, + caretRowHighlight, + openideaTheme, + highlightSpecialChars(), + syntaxHighlighting(openideaHighlightStyle), + new LanguageSupport(logLanguage), + findExtension(), + overviewLane, + Prec.high(keymap.of([{ key: "Escape", run: () => findEscapeRef.current() }])), + keymap.of(defaultKeymap), + EditorState.readOnly.of(true), + EditorView.editable.of(false), + EditorView.contentAttributes.of({ + "aria-label": title, + class: selectableText, + tabindex: "0", + }), + EditorView.updateListener.of((u) => { + if (u.docChanged || u.selectionSet) { + findWatchersRef.current.forEach((fn) => fn(u)); + } + }), + ], + }), + }); + viewRef.current = view; + setViewGen((g) => g + 1); + + const onScroll = () => { + const el = view.scrollDOM; + stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 32; + }; + view.scrollDOM.addEventListener("scroll", onScroll, { passive: true }); + + let prevAppearance = editorPrefs().appearance; + let prevEditor = editorSettings(); + const offSettings = useProject.subscribe(() => { + const effects: StateEffect[] = []; + const nextPrefs = editorPrefs(); + const nextEditor = editorSettings(); + if (JSON.stringify(nextPrefs.appearance) !== JSON.stringify(prevAppearance)) { + prevAppearance = nextPrefs.appearance; + effects.push(caretSlot.reconfigure(caretExtension(nextPrefs))); + } + if (nextEditor !== prevEditor) { + prevEditor = nextEditor; + effects.push( + fontSlot.reconfigure(fontExtension(nextEditor)), + lineNumbersSlot.reconfigure(lineNumbersExtension(nextEditor)), + heavySlot.reconfigure(heavyExtension(view.state.doc.length, nextEditor)), + ); + } + if (effects.length) view.dispatch({ effects }); + }); + + return () => { + offSettings(); + view.scrollDOM.removeEventListener("scroll", onScroll); + view.destroy(); + viewRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [title]); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + view.dispatch({ + effects: wrapSlotRef.current.reconfigure(wrap ? EditorView.lineWrapping : []), + }); + }, [wrap]); + + useEffect(() => { + return registerKeyClaim((e, stroke) => { + if (!rootRef.current?.contains(e.target as Node)) return false; + const view = viewRef.current; + if (!view) return false; + if (isActionStroke(stroke, "find.inFile") || stroke === "Meta+F" || stroke === "Ctrl+F") { + e.preventDefault(); + e.stopPropagation(); + openFind(); + return true; + } + if (isActionStroke(stroke, "find.next") || isActionStroke(stroke, "find.previous")) { + e.preventDefault(); + e.stopPropagation(); + if (!searchAgain(findTarget, isActionStroke(stroke, "find.next"))) openFind(); + return true; + } + if ( + isActionStroke(stroke, "editor.increaseFontSize") || + stroke === "Meta+=" || + stroke === "Meta+Shift+=" || + stroke === "Ctrl+=" || + stroke === "Ctrl+Shift+=" || + stroke === "Meta+NumpadAdd" || + stroke === "Ctrl+NumpadAdd" + ) { + e.preventDefault(); + e.stopPropagation(); + stepFontZoom(view, 1); + return true; + } + if ( + isActionStroke(stroke, "editor.decreaseFontSize") || + stroke === "Meta+-" || + stroke === "Meta+Shift+-" || + stroke === "Ctrl+-" || + stroke === "Ctrl+Shift+-" || + stroke === "Meta+NumpadSubtract" || + stroke === "Ctrl+NumpadSubtract" + ) { + e.preventDefault(); + e.stopPropagation(); + stepFontZoom(view, -1); + return true; + } + if (isActionStroke(stroke, "editor.resetFontSize") || stroke === "Meta+0" || stroke === "Ctrl+0") { + e.preventDefault(); + e.stopPropagation(); + view.dispatch({ effects: setFontZoom.of(0) }); + return true; + } + if (stroke === "Escape" && findOpenRef.current) { + e.preventDefault(); + e.stopPropagation(); + closeFind(); + return true; + } + return false; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [findTarget]); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.defaultPrevented) return; + const stroke = strokeOf(e.nativeEvent); + const view = viewRef.current; + if (!view || !stroke) return; + if (isActionStroke(stroke, "find.inFile") || stroke === "Meta+F" || stroke === "Ctrl+F") { + openFind(); + } else if (isActionStroke(stroke, "find.next") || isActionStroke(stroke, "find.previous")) { + if (!searchAgain(findTarget, isActionStroke(stroke, "find.next"))) openFind(); + } else if ( + isActionStroke(stroke, "editor.increaseFontSize") || + stroke === "Meta+=" || + stroke === "Meta+Shift+=" || + stroke === "Ctrl+=" + ) { + stepFontZoom(view, 1); + } else if ( + isActionStroke(stroke, "editor.decreaseFontSize") || + stroke === "Meta+-" || + stroke === "Meta+Shift+-" || + stroke === "Ctrl+-" + ) { + stepFontZoom(view, -1); + } else if (isActionStroke(stroke, "editor.resetFontSize") || stroke === "Meta+0" || stroke === "Ctrl+0") { + view.dispatch({ effects: setFontZoom.of(0) }); + } else if (e.key === "Escape" && findOpenRef.current) { + closeFind(); + } else { + return; + } + e.preventDefault(); + e.stopPropagation(); + }; + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + receivedRef.current = false; + view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: tx("Waiting for output…") } }); + stickRef.current = true; + let stopped = false; + let buffer: string[] = []; + let frame = 0; + + const append = (chunk: string) => { + if (stopped || !viewRef.current) return; + const v = viewRef.current; + const previous = receivedRef.current ? v.state.doc.toString() : ""; + receivedRef.current = true; + const joined = previous ? `${previous}\n${chunk}` : chunk; + const result = boundedLog(joined); + const stick = stickRef.current; + v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: result } }); + if (stick && !findOpenRef.current) v.scrollDOM.scrollTop = v.scrollDOM.scrollHeight; + }; + + const flush = () => { + frame = 0; + if (!buffer.length) return; + const lines = buffer; + buffer = []; + append(lines.join("\n")); + }; + + const request = nextRequestId(); + const channel = new Channel(); + channel.onmessage = (line) => { + if (stopped) return; + buffer.push(line.text.replace(ANSI, "")); + frame ||= requestAnimationFrame(flush); + }; + call( + commands.dockerLogs(request, endpointOf(data.conn), data.containerId, 1000, running, timestamps, channel), + ).catch((e) => { + if (!stopped && !isCancelled(e)) { + append(`Error: ${errorMessage(e)}`); + } + }); + + return () => { + stopped = true; + if (frame) cancelAnimationFrame(frame); + void commands.cancelRequest(request); + }; + }, [data.conn, data.containerId, running, timestamps]); + + const clearLogs = () => { + const view = viewRef.current; + if (!view) return; + receivedRef.current = true; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: "" }, + }); + }; + + return ( +
+
+ + + + + +
+ {find && } +
+
+ ); +} diff --git a/src/features/services/model.ts b/src/features/services/model.ts index 842a34a50..6a863e776 100644 --- a/src/features/services/model.ts +++ b/src/features/services/model.ts @@ -1,7 +1,17 @@ /** The Services tree (Docker → Containers / Images / Volumes / Networks), built from a snapshot. */ import type { DockerContainer, DockerImage, DockerNetwork, DockerSnapshot, DockerVolume } from "@/platform/ipc"; -export type GroupKind = "containers" | "images" | "volumes" | "networks"; +export type GroupKind = "devContainers" | "containers" | "images" | "networks" | "volumes"; + +export interface DockerFilters { + showStoppedContainers: boolean; + showUntaggedImages: boolean; +} + +export const DEFAULT_DOCKER_FILTERS: DockerFilters = { + showStoppedContainers: true, + showUntaggedImages: true, +}; export type ServiceNode = | { kind: "docker"; key: string } @@ -14,10 +24,11 @@ export type ServiceNode = export const DOCKER_KEY = "docker"; const GROUPS: Array<[GroupKind, string]> = [ + ["devContainers", "Dev Containers"], ["containers", "Containers"], ["images", "Images"], - ["volumes", "Volumes"], ["networks", "Networks"], + ["volumes", "Volumes"], ]; export const shortId = (id: string) => id.replace(/^sha256:/, "").slice(0, 12); @@ -26,6 +37,18 @@ export function isRunning(c: DockerContainer): boolean { return c.state === "running" || c.state === "restarting" || c.state === "paused"; } +export function isDevContainer(c: DockerContainer): boolean { + const labels = c.labels; + if (!labels) return false; + return ( + "devcontainer.local_folder" in labels || "com.intellij.devcontainer.id" in labels || "vsch.local.folder" in labels + ); +} + +export function isUntaggedImage(i: DockerImage): boolean { + return (!i.repository || i.repository === "") && (!i.tag || i.tag === ""); +} + export function imageRef(i: DockerImage): string { if (!i.repository || i.repository === "") return shortId(i.id); return !i.tag || i.tag === "" ? i.repository : `${i.repository}:${i.tag}`; @@ -35,23 +58,49 @@ export function rootNodes(): ServiceNode[] { return [{ kind: "docker", key: DOCKER_KEY }]; } +function composeProjects(snap: DockerSnapshot, filters: DockerFilters): ServiceNode[] { + const projects = new Map(); + for (const c of snap.containers) { + if (c.composeProject) projects.set(c.composeProject, [...(projects.get(c.composeProject) ?? []), c]); + } + return [...projects.entries()] + .filter(([, containers]) => filters.showStoppedContainers || containers.some(isRunning)) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([project, containers]) => ({ + kind: "compose" as const, + key: `compose:${project}`, + project, + containers: [...containers].sort((a, b) => + (a.composeService ?? a.name).localeCompare(b.composeService ?? b.name), + ), + })); +} + /** Children lookup over one snapshot (stable node objects per snapshot). */ -export function childrenOf(snap: DockerSnapshot | null): (n: ServiceNode) => ServiceNode[] | undefined { +export function childrenOf( + snap: DockerSnapshot | null, + filters: DockerFilters = DEFAULT_DOCKER_FILTERS, +): (n: ServiceNode) => ServiceNode[] | undefined { const cache = new Map(); const compute = (n: ServiceNode): ServiceNode[] | undefined => { if (!snap || snap.availability.kind !== "ready") return undefined; switch (n.kind) { case "docker": - return GROUPS.map(([group]) => ({ - kind: "group", - key: `group:${group}`, - group, - count: groupCount(snap, group), - })); + return [ + ...composeProjects(snap, filters), + ...GROUPS.map(([group]) => ({ + kind: "group" as const, + key: `group:${group}`, + group, + count: groupCount(snap, group, filters), + })), + ]; case "group": - return groupChildren(snap, n.group); - case "compose": - return n.containers.map(containerNode); + return groupChildren(snap, n.group, filters); + case "compose": { + const list = filters.showStoppedContainers ? n.containers : n.containers.filter(isRunning); + return list.map(containerNode); + } default: return undefined; } @@ -62,14 +111,21 @@ export function childrenOf(snap: DockerSnapshot | null): (n: ServiceNode) => Ser }; } -function groupCount(snap: DockerSnapshot, g: GroupKind): number { - return g === "containers" - ? snap.containers.length - : g === "images" - ? snap.images.length - : g === "volumes" - ? snap.volumes.length - : snap.networks.length; +function groupCount(snap: DockerSnapshot, g: GroupKind, filters: DockerFilters = DEFAULT_DOCKER_FILTERS): number { + if (g === "devContainers") { + return snap.containers.filter( + (c) => !c.composeProject && isDevContainer(c) && (filters.showStoppedContainers || isRunning(c)), + ).length; + } + if (g === "containers") { + return snap.containers.filter( + (c) => !c.composeProject && !isDevContainer(c) && (filters.showStoppedContainers || isRunning(c)), + ).length; + } + if (g === "images") { + return filters.showUntaggedImages ? snap.images.length : snap.images.filter((i) => !isUntaggedImage(i)).length; + } + return g === "volumes" ? snap.volumes.length : snap.networks.length; } const containerNode = (c: DockerContainer): ServiceNode => ({ @@ -78,30 +134,25 @@ const containerNode = (c: DockerContainer): ServiceNode => ({ container: c, }); -/** Compose projects (by label) first, then standalone containers, as IDEA groups them. */ -export function groupChildren(snap: DockerSnapshot, g: GroupKind): ServiceNode[] { +/** Children of one group node (`Dev Containers`, `Containers`, `Images`, `Networks`, `Volumes`). */ +export function groupChildren( + snap: DockerSnapshot, + g: GroupKind, + filters: DockerFilters = DEFAULT_DOCKER_FILTERS, +): ServiceNode[] { switch (g) { - case "containers": { - const projects = new Map(); - const loose: DockerContainer[] = []; - for (const c of snap.containers) { - if (c.composeProject) projects.set(c.composeProject, [...(projects.get(c.composeProject) ?? []), c]); - else loose.push(c); - } - const compose: ServiceNode[] = [...projects.entries()] - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([project, containers]) => ({ - kind: "compose", - key: `compose:${project}`, - project, - containers: [...containers].sort((a, b) => - (a.composeService ?? a.name).localeCompare(b.composeService ?? b.name), - ), - })); - return [...compose, ...loose.map(containerNode)]; + case "devContainers": + return snap.containers + .filter((c) => !c.composeProject && isDevContainer(c) && (filters.showStoppedContainers || isRunning(c))) + .map(containerNode); + case "containers": + return snap.containers + .filter((c) => !c.composeProject && !isDevContainer(c) && (filters.showStoppedContainers || isRunning(c))) + .map(containerNode); + case "images": { + const imgs = filters.showUntaggedImages ? snap.images : snap.images.filter((i) => !isUntaggedImage(i)); + return imgs.map((image) => ({ kind: "image", key: `image:${image.id}`, image })); } - case "images": - return snap.images.map((image) => ({ kind: "image", key: `image:${image.id}`, image })); case "volumes": return snap.volumes.map((volume) => ({ kind: "volume", key: `volume:${volume.name}`, volume })); case "networks": @@ -109,6 +160,42 @@ export function groupChildren(snap: DockerSnapshot, g: GroupKind): ServiceNode[] } } +/** Whether `container.image` refers to `image`. */ +export function containerMatchesImage(c: DockerContainer, image: DockerImage): boolean { + const ref = imageRef(image); + if (c.image === ref || c.image === image.id || c.image === shortId(image.id)) return true; + if (image.tag === "latest" && c.image === image.repository) return true; + const cleanId = image.id.replace(/^sha256:/, ""); + const cleanTarget = c.image.replace(/^sha256:/, ""); + return cleanTarget.length >= 12 && cleanId.startsWith(cleanTarget); +} + +/** Containers in `snap` created from `image`. */ +export function containersUsingImage(snap: DockerSnapshot, image: DockerImage): DockerContainer[] { + return snap.containers.filter((c) => containerMatchesImage(c, image)); +} + +/** All `repo:tag` references in `snap` that share `image.id`. */ +export function imageTagsForId(snap: DockerSnapshot, image: DockerImage): string[] { + const tags = snap.images.filter((i) => i.id === image.id && !isUntaggedImage(i)).map(imageRef); + return [...new Set(tags)]; +} + +/** The `DockerImage` in `snap` that `container` runs, if present locally. */ +export function findImageForContainer(snap: DockerSnapshot, container: DockerContainer): DockerImage | null { + return snap.images.find((i) => containerMatchesImage(container, i)) ?? null; +} + +/** Containers in `snap` mounting `volume`. */ +export function containersUsingVolume(snap: DockerSnapshot, volume: DockerVolume): DockerContainer[] { + return snap.containers.filter((c) => c.mounts?.includes(volume.name)); +} + +/** Containers in `snap` attached to `network`. */ +export function containersInNetwork(snap: DockerSnapshot, network: DockerNetwork): DockerContainer[] { + return snap.containers.filter((c) => c.networks?.includes(network.name)); +} + export function nodeLabel(n: ServiceNode): string { switch (n.kind) { case "docker": @@ -116,7 +203,7 @@ export function nodeLabel(n: ServiceNode): string { case "group": return GROUPS.find(([g]) => g === n.group)![1]; case "compose": - return n.project; + return `Docker-compose: ${n.project}`; case "container": return n.container.composeService && n.container.composeProject ? n.container.composeService : n.container.name; case "image": @@ -149,22 +236,45 @@ export function nodeDetail(n: ServiceNode): string | undefined { } } +function groupIcon(g: GroupKind): string { + switch (g) { + case "devContainers": + return "docker-devcontainer"; + case "containers": + return "docker-containers"; + case "images": + return "docker-images"; + case "networks": + return "docker-networks"; + case "volumes": + return "docker-volumes"; + } +} + export function nodeIcon(n: ServiceNode): string { switch (n.kind) { case "docker": - return "file-docker"; + return "docker-connected"; case "group": - return "folder"; + return groupIcon(n.group); case "compose": - return "file-yaml"; + return n.containers.some(isRunning) ? "docker-compose-running" : "docker-compose-stopped"; case "container": - return n.container.state === "paused" ? "pause" : isRunning(n.container) ? "run" : "module"; + if (isDevContainer(n.container)) return "docker-devcontainer"; + if (n.container.composeProject) { + return isRunning(n.container) ? "docker-compose-service-running" : "docker-compose-service-stopped"; + } + return n.container.state === "paused" + ? "pause" + : isRunning(n.container) + ? "docker-container-running" + : "docker-container-stopped"; case "image": - return "package"; + return "docker-image"; case "volume": - return "database"; + return "docker-volumes"; case "network": - return "hierarchy"; + return "docker-networks"; } } diff --git a/src/features/services/ops.ts b/src/features/services/ops.ts index c604c2225..f90ad4bef 100644 --- a/src/features/services/ops.ts +++ b/src/features/services/ops.ts @@ -5,6 +5,7 @@ import { executeAction } from "@/platform/actions"; import type { MenuItem } from "@/ui"; import { confirm, confirmChecked } from "@/workbench/messages"; import { promptText } from "./dialogs"; +import { openContainerLogInEditor } from "./logEditor"; import { isRunning, nodeContainers, nodeLabel, shortId, type DockerTarget, type ServiceNode } from "./model"; import { composeOp, @@ -14,7 +15,9 @@ import { pullImage, refresh, removeObjects, + setDockerFilters, useServices, + type DetailTab, } from "./store"; export function canStart(t: DockerTarget | null): boolean { @@ -185,10 +188,26 @@ export function pruneMenu(conn: string): MenuItem[] { const copy = (text: string) => void navigator.clipboard?.writeText(text); -export function showDetail(tab: "log" | "inspect") { +export function showDetail(tab: DetailTab) { useServices.setState({ detailTab: tab }); } +export function viewFilterMenu(): MenuItem[] { + const { filters } = useServices.getState(); + return [ + { + label: "Stopped Containers", + checked: filters.showStoppedContainers, + run: () => setDockerFilters({ showStoppedContainers: !useServices.getState().filters.showStoppedContainers }), + }, + { + label: "Untagged Images", + checked: filters.showUntaggedImages, + run: () => setDockerFilters({ showUntaggedImages: !useServices.getState().filters.showUntaggedImages }), + }, + ]; +} + /** Context menu of a node of a connection's subtree. */ export function nodeMenu(t: DockerTarget): MenuItem[] { const { conn, node: n } = t; @@ -212,7 +231,14 @@ export function nodeMenu(t: DockerTarget): MenuItem[] { run: () => void containerOp(conn, [n.container.id], "pause"), }, { kind: "separator" }, + { label: "Dashboard", icon: "layout", run: () => showDetail("dashboard") }, { label: "Show Log", icon: "file-log", run: () => showDetail("log") }, + { + label: "Open Log in Editor", + icon: "external-link", + run: () => openContainerLogInEditor(conn, n.container), + }, + { label: "Show Processes", icon: "list", disabled: !running, run: () => showDetail("processes") }, { label: "Inspect", icon: "info", run: () => showDetail("inspect") }, { label: "Create Terminal", icon: "terminal", disabled: !running, run: () => execShell(t) }, { kind: "separator" }, @@ -221,12 +247,16 @@ export function nodeMenu(t: DockerTarget): MenuItem[] { ); } if (n.kind === "image" || n.kind === "volume" || n.kind === "network") { + items.push({ label: "Dashboard", icon: "layout", run: () => showDetail("dashboard") }); + if (n.kind === "image") { + items.push({ label: "Show Layers", icon: "list", run: () => showDetail("layers") }); + } items.push({ label: "Inspect", icon: "info", run: () => showDetail("inspect") }); if (n.kind === "image") items.push( { label: "Pull Again", - icon: "git-pull", + icon: "docker-pull", disabled: n.image.repository === "", run: () => void pullImage(conn, nodeLabel(n)), }, @@ -234,10 +264,25 @@ export function nodeMenu(t: DockerTarget): MenuItem[] { ); } if (n.kind === "group" && n.group === "images") - items.push({ label: "Pull Image…", icon: "git-pull", run: () => void pullPrompt(conn) }); + items.push({ label: "Pull Image…", icon: "docker-pull", run: () => void pullPrompt(conn) }); if (n.kind === "group") { + if (n.group === "containers" || n.group === "devContainers") { + const { filters } = useServices.getState(); + items.push({ + label: "Stopped Containers", + checked: filters.showStoppedContainers, + run: () => setDockerFilters({ showStoppedContainers: !useServices.getState().filters.showStoppedContainers }), + }); + } else if (n.group === "images") { + const { filters } = useServices.getState(); + items.push({ + label: "Untagged Images", + checked: filters.showUntaggedImages, + run: () => setDockerFilters({ showUntaggedImages: !useServices.getState().filters.showUntaggedImages }), + }); + } const target: PruneTarget = - n.group === "containers" + n.group === "containers" || n.group === "devContainers" ? "containers" : n.group === "images" ? "images" @@ -252,7 +297,8 @@ export function nodeMenu(t: DockerTarget): MenuItem[] { if (n.kind === "docker") items.push( { label: "Refresh", icon: "refresh", run: () => void refresh(conn) }, - { label: "Pull Image…", icon: "git-pull", run: () => void pullPrompt(conn) }, + { label: "Pull Image…", icon: "docker-pull", run: () => void pullPrompt(conn) }, + { label: "View Options", icon: "filter", submenu: viewFilterMenu() }, { label: "Prune", submenu: pruneMenu(conn) }, ); if (canRemove(t)) { diff --git a/src/features/services/services.test.ts b/src/features/services/services.test.ts index bed466a98..5bca592fc 100644 --- a/src/features/services/services.test.ts +++ b/src/features/services/services.test.ts @@ -12,16 +12,36 @@ import { } from "./runTypes"; import { childrenOf, + containersInNetwork, + containersUsingImage, + containersUsingVolume, + findImageForContainer, findNode, + imageTagsForId, + isUntaggedImage, nodeDetail, + nodeIcon, nodeLabel, parseTreeKey, rootNodes, treeKey, type ServiceNode, } from "./model"; +import { + bytesToUnits, + formatCreatedDetailed, + parseContainerInspect, + parseImageInspect, + parseNetworkInspect, + parsePercent, + parsePortMappings, + parseUsagePair, + splitLayerInstruction, + unitsToBytes, +} from "./dashboard"; import { endpointFrom } from "./dialogs"; import { dockerItems } from "./docker"; +import { setDockerFilters, useServices } from "./store"; import { addServiceMenu, expandableKeys, indexTree, setProviderRoots, useServicesTree } from "./tree"; const container = (name: string, state: string, project?: string, service?: string): DockerContainer => ({ @@ -54,18 +74,36 @@ const snapshot: DockerSnapshot = { }; describe("services tree", () => { - it("groups containers by compose project before standalone ones", () => { + it("groups compose projects at the top, followed by Dev Containers, Containers, Images, Networks and Volumes", () => { const children = childrenOf(snapshot); const [docker] = rootNodes(); const groups = children(docker!)!; - expect(groups.map(nodeLabel)).toEqual(["Containers", "Images", "Volumes", "Networks"]); - expect(nodeDetail(groups[0]!)).toBe("3"); - const containers = children(groups[0]!)!; - expect(containers.map((n) => n.kind)).toEqual(["compose", "container"]); - const shop = containers[0] as Extract; + expect(groups.map(nodeLabel)).toEqual([ + "Docker-compose: shop", + "Dev Containers", + "Containers", + "Images", + "Networks", + "Volumes", + ]); + expect(groups.map(nodeIcon)).toEqual([ + "docker-compose-running", + "docker-devcontainer", + "docker-containers", + "docker-images", + "docker-networks", + "docker-volumes", + ]); + const shop = groups[0] as Extract; expect(nodeDetail(shop)).toBe("1/2 running"); expect(children(shop)!.map(nodeLabel)).toEqual(["api", "db"]); - expect(nodeLabel(children(groups[1]!)![0]!)).toBe("postgres:16"); + expect(children(shop)!.map(nodeIcon)).toEqual(["docker-compose-service-stopped", "docker-compose-service-running"]); + expect(nodeDetail(groups[2]!)).toBe("1"); + const containers = children(groups[2]!)!; + expect(containers.map((n) => n.kind)).toEqual(["container"]); + expect(nodeIcon(containers[0]!)).toBe("docker-container-stopped"); + expect(nodeLabel(children(groups[3]!)![0]!)).toBe("postgres:16"); + expect(nodeIcon(children(groups[3]!)![0]!)).toBe("docker-image"); }); it("finds nodes again in a new snapshot", () => { @@ -88,20 +126,19 @@ describe("services tree host", () => { it("puts every connection and registry at the root, with connection-scoped keys", () => { const items = dockerItems(conns, [{ id: "r1", name: "GHCR", address: "ghcr.io", username: "me" }], state); - expect(items.map((i) => [i.key, i.label, i.detail])).toEqual([ - ["docker:default", "Docker", undefined], - ["docker:c2", "Build box", undefined], - ["docker-registry:r1", "GHCR", "ghcr.io"], + expect(items.map((i) => [i.key, i.label, i.detail, i.icon])).toEqual([ + ["docker:default", "Docker", undefined, "docker-connected"], + ["docker:c2", "Build box", undefined, "file-docker"], + ["docker-registry:r1", "GHCR", "ghcr.io", "docker-registry"], ]); expect(items[1]!.dim).toBe(true); expect(items[1]!.children).toEqual([]); - const containers = items[0]!.children![0]!; + const compose = items[0]!.children![0]!; + expect(compose.key).toBe("docker:default:compose:shop"); + const containers = items[0]!.children![2]!; expect(containers.key).toBe("docker:default:group:containers"); - expect(containers.children!.map((c) => c.key)).toEqual([ - "docker:default:compose:shop", - "docker:default:container:web-id", - ]); - expect(containers.children![1]!.dim).toBe(true); + expect(containers.children!.map((c) => c.key)).toEqual(["docker:default:container:web-id"]); + expect(containers.children![0]!.dim).toBe(true); const index = indexTree({ docker: items }); expect(index.get("docker:default:container:shop-db-1-id")?.item.label).toBe("db"); expect(expandableKeys(items)).toContain("docker:default:compose:shop"); @@ -272,3 +309,211 @@ describe("docker run targets", () => { expect(shellQuote('a "b"', true)).toBe('"a ""b"""'); }); }); + +describe("phase 1: filters, cross-references and dashboard parsers", () => { + const richSnapshot: DockerSnapshot = { + ...snapshot, + containers: [ + { + ...container("web", "exited"), + image: "nginx:1.27", + ports: "0.0.0.0:8080->80/tcp", + mounts: [], + networks: ["bridge"], + }, + { + ...container("shop-db-1", "running", "shop", "db"), + image: "postgres:16", + ports: "127.0.0.1:5432->5432/tcp", + mounts: ["shop_pgdata"], + networks: ["shop_default"], + }, + { + ...container("shop-api-1", "exited", "shop", "api"), + image: "postgres:16", + mounts: [], + networks: ["shop_default"], + }, + ], + images: [ + { id: "sha256:1111111111112222", repository: "postgres", tag: "16", size: "431MB", createdSince: "7 weeks ago" }, + { + id: "sha256:1111111111112222", + repository: "postgres", + tag: "latest", + size: "431MB", + createdSince: "7 weeks ago", + }, + { + id: "sha256:aaaaaaaaaaaa0000", + repository: "", + tag: "", + size: "120MB", + createdSince: "4 weeks ago", + }, + ], + volumes: [ + { + name: "shop_pgdata", + driver: "local", + mountpoint: "/var/lib/docker/volumes/shop_pgdata/_data", + composeProject: "shop", + }, + ], + networks: [{ id: "net-1", name: "shop_default", driver: "bridge", scope: "local", composeProject: "shop" }], + }; + + it("filters stopped containers and untagged images when toggled off", () => { + const filtered = childrenOf(richSnapshot, { showStoppedContainers: false, showUntaggedImages: false }); + const [docker] = rootNodes(); + const groups = filtered(docker!)!; + expect(groups.map(nodeLabel)).toEqual([ + "Docker-compose: shop", + "Dev Containers", + "Containers", + "Images", + "Networks", + "Volumes", + ]); + const shopChildren = filtered(groups[0]!)!; + expect(shopChildren.map(nodeLabel)).toEqual(["db"]); + expect(nodeDetail(groups[2]!)).toBeUndefined(); + expect(nodeDetail(groups[3]!)).toBe("2"); + const imageNodes = filtered(groups[3]!)!; + expect(imageNodes.map(nodeLabel)).toEqual(["postgres:16", "postgres:latest"]); + expect(isUntaggedImage(richSnapshot.images[2]!)).toBe(true); + expect(isUntaggedImage(richSnapshot.images[0]!)).toBe(false); + + setDockerFilters({ showStoppedContainers: false }); + expect(useServices.getState().filters.showStoppedContainers).toBe(false); + setDockerFilters({ showStoppedContainers: true, showUntaggedImages: true }); + }); + + it("cross-references images, volumes and networks with containers", () => { + const pg = richSnapshot.images[0]!; + expect(containersUsingImage(richSnapshot, pg).map((c) => c.name)).toEqual(["shop-db-1", "shop-api-1"]); + expect(imageTagsForId(richSnapshot, pg)).toEqual(["postgres:16", "postgres:latest"]); + expect(findImageForContainer(richSnapshot, richSnapshot.containers[1]!)?.id).toBe(pg.id); + expect(containersUsingVolume(richSnapshot, richSnapshot.volumes[0]!).map((c) => c.name)).toEqual(["shop-db-1"]); + expect(containersInNetwork(richSnapshot, richSnapshot.networks[0]!).map((c) => c.name)).toEqual([ + "shop-db-1", + "shop-api-1", + ]); + }); + + it("parses units, usage pairs, timestamps, ports, layers and inspect payloads", () => { + expect(unitsToBytes("50MiB")).toBe(50 * 1024 * 1024); + expect(unitsToBytes("431MB")).toBe(431 * 1000 * 1000); + expect(bytesToUnits(50 * 1024 * 1024)).toBe("50.0 MiB"); + expect(parsePercent("12.5%")).toBe(12.5); + const pair = parseUsagePair("512MiB / 2GiB"); + expect(pair.percent).toBeCloseTo(25, 1); + + const formatted = formatCreatedDetailed("2026-10-01T15:00:00Z", Date.parse("2026-10-03T17:00:00Z"), "UTC"); + expect(formatted).toContain("2d 2h ago"); + expect(formatted).toContain("2026-10-01 15:00:00"); + const localFormatted = formatCreatedDetailed("2026-10-01T15:00:00Z", Date.parse("2026-10-03T17:00:00Z")); + expect(localFormatted).toContain("2d 2h ago"); + + const ports = parsePortMappings("0.0.0.0:8080->80/tcp, 5432/tcp"); + expect(ports).toEqual([ + { + hostIp: "0.0.0.0", + hostPort: "8080", + containerPort: "80", + protocol: "tcp", + url: "http://localhost:8080", + label: "0.0.0.0:8080 -> 80/tcp", + }, + { + hostIp: "", + hostPort: null, + containerPort: "5432", + protocol: "tcp", + url: null, + label: "5432/tcp", + }, + ]); + + expect(splitLayerInstruction('/bin/sh -c #(nop) CMD ["postgres"]')).toEqual({ + instruction: "CMD", + args: '["postgres"]', + }); + expect(splitLayerInstruction("RUN /bin/sh -c apt-get update # buildkit")).toEqual({ + instruction: "RUN", + args: "/bin/sh -c apt-get update", + }); + + const cInspect = parseContainerInspect( + JSON.stringify([ + { + Created: "2026-10-01T15:00:00Z", + Image: "sha256:1111", + State: { Pid: 4321, ExitCode: 0, StartedAt: "2026-10-01T15:00:01Z", Health: { Status: "healthy" } }, + Config: { + User: "postgres", + WorkingDir: "/var/lib/postgresql", + Entrypoint: ["docker-entrypoint.sh"], + Cmd: ["postgres"], + Env: ["POSTGRES_DB=shop", "PGDATA=/var/lib/postgresql/data"], + Labels: { "com.docker.compose.project": "shop" }, + }, + HostConfig: { RestartPolicy: { Name: "unless-stopped", MaximumRetryCount: 0 } }, + Mounts: [ + { + Type: "volume", + Name: "shop_pgdata", + Source: "/var/lib/docker/volumes/shop_pgdata/_data", + Destination: "/var/lib/postgresql/data", + Mode: "z", + RW: true, + }, + ], + NetworkSettings: { + Ports: { "5432/tcp": [{ HostIp: "127.0.0.1", HostPort: "5432" }] }, + Networks: { shop_default: { IPAddress: "172.18.0.2", Gateway: "172.18.0.1" } }, + }, + }, + ]), + )!; + expect(cInspect.pid).toBe(4321); + expect(cInspect.healthStatus).toBe("healthy"); + expect(cInspect.restartPolicy).toBe("unless-stopped"); + expect(cInspect.env).toEqual([ + ["POSTGRES_DB", "shop"], + ["PGDATA", "/var/lib/postgresql/data"], + ]); + expect(cInspect.mounts[0]!.name).toBe("shop_pgdata"); + expect(cInspect.ports[0]!.url).toBe("http://127.0.0.1:5432"); + expect(cInspect.networks[0]!.ipAddress).toBe("172.18.0.2"); + + const iInspect = parseImageInspect( + JSON.stringify({ + Created: "2026-09-01T08:00:00Z", + Size: 451936256, + Architecture: "arm64", + Os: "linux", + RepoTags: ["postgres:16"], + Config: { + ExposedPorts: { "5432/tcp": {} }, + Entrypoint: ["docker-entrypoint.sh"], + Cmd: ["postgres"], + Env: ["PATH=/usr/local/bin"], + }, + }), + )!; + expect(iInspect.os).toBe("linux"); + expect(iInspect.architecture).toBe("arm64"); + expect(iInspect.exposedPorts).toEqual(["5432/tcp"]); + + const nInspect = parseNetworkInspect( + JSON.stringify({ + Created: "2026-10-01T09:00:00Z", + IPAM: { Config: [{ Subnet: "172.18.0.0/16", Gateway: "172.18.0.1" }] }, + Containers: { c1: { Name: "shop-db-1", IPv4Address: "172.18.0.2/16" } }, + }), + )!; + expect(nInspect.ipamConfigs[0]!.subnet).toBe("172.18.0.0/16"); + expect(nInspect.containers[0]!.name).toBe("shop-db-1"); + }); +}); diff --git a/src/features/services/store.ts b/src/features/services/store.ts index a9f5bd8f8..f88250453 100644 --- a/src/features/services/store.ts +++ b/src/features/services/store.ts @@ -22,8 +22,31 @@ import type { import { tx } from "@/platform/i18n"; import { call, commands, isCancelled, isTauri, nextRequestId } from "@/platform/ipc"; import { notifications } from "@/workbench/contributions"; +import { DEFAULT_DOCKER_FILTERS, type DockerFilters } from "./model"; -export type DetailTab = "log" | "inspect"; +export type DetailTab = "dashboard" | "log" | "processes" | "layers" | "inspect"; + +const FILTERS_STORAGE_KEY = "openidea.docker.filters"; + +function loadStoredFilters(): DockerFilters { + try { + const raw = typeof localStorage !== "undefined" ? localStorage.getItem(FILTERS_STORAGE_KEY) : null; + if (!raw) return DEFAULT_DOCKER_FILTERS; + const parsed = JSON.parse(raw) as Partial; + return { + showStoppedContainers: + typeof parsed.showStoppedContainers === "boolean" + ? parsed.showStoppedContainers + : DEFAULT_DOCKER_FILTERS.showStoppedContainers, + showUntaggedImages: + typeof parsed.showUntaggedImages === "boolean" + ? parsed.showUntaggedImages + : DEFAULT_DOCKER_FILTERS.showUntaggedImages, + }; + } catch { + return DEFAULT_DOCKER_FILTERS; + } +} /** `DockerSettings` with its defaults filled in. */ export interface Settings { @@ -43,6 +66,7 @@ interface DockerState { /** By connection id. */ conns: Record; detailTab: DetailTab; + filters: DockerFilters; /** Long operation in progress (pull, prune, compose…): its title and last output line. */ task: { title: string; line: string; request: RequestId } | null; } @@ -50,10 +74,25 @@ interface DockerState { export const useServices = create(() => ({ settings: null, conns: {}, - detailTab: "log", + detailTab: "dashboard", + filters: loadStoredFilters(), task: null, })); +export function setDockerFilters(patch: Partial): void { + useServices.setState((s) => { + const filters = { ...s.filters, ...patch }; + try { + if (typeof localStorage !== "undefined") { + localStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(filters)); + } + } catch { + // ignore storage quota errors + } + return { filters }; + }); +} + export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)); const IDLE: ConnectionState = { snapshot: null, loading: false, error: null }; diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 2f584353e..c5c79cea9 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -624,5 +624,59 @@ "Debugging a plugin target requires a container root": "Debugging a plugin target requires a container root", "This debugger does not support plugin run targets": "This debugger does not support plugin run targets", "invalid debug launch arguments": "invalid debug launch arguments", - "unknown debug adapter": "unknown debug adapter" + "unknown debug adapter": "unknown debug adapter", + "Container Overview": "Container Overview", + "Container ID": "Container ID", + "Created": "Created", + "Started": "Started", + "Restart policy": "Restart policy", + "Compose": "Compose", + "Resource Usage": "Resource Usage", + "CPU": "CPU", + "Memory": "Memory", + "Network I/O": "Network I/O", + "Block I/O": "Block I/O", + "PIDs": "PIDs", + "Metrics unavailable": "Metrics unavailable", + "Port Bindings": "Port Bindings", + "No port bindings": "No port bindings", + "Volume & Bind Mounts": "Volume & Bind Mounts", + "Destination": "Destination", + "Mode": "Mode", + "No mounts": "No mounts", + "Networks": "Networks", + "No environment variables": "No environment variables", + "Container is not running": "Container is not running", + "Start the container to view its running processes.": "Start the container to view its running processes.", + "No processes reported": "No processes reported", + "Show Layers": "Show Layers", + "Image Overview": "Image Overview", + "Image ID": "Image ID", + "Platform": "Platform", + "Exposed ports": "Exposed ports", + "Containers Using Image": "Containers Using Image", + "No containers are using this image": "No containers are using this image", + "No image layers": "No image layers", + "Instruction": "Instruction", + "Volume Overview": "Volume Overview", + "Mountpoint": "Mountpoint", + "Containers Using Volume": "Containers Using Volume", + "No containers are using this volume": "No containers are using this volume", + "Network Overview": "Network Overview", + "Network ID": "Network ID", + "Subnet / Gateway": "Subnet / Gateway", + "Connected Containers": "Connected Containers", + "No containers are connected to this network": "No containers are connected to this network", + "Inspect JSON": "Inspect JSON", + "Dashboard": "Dashboard", + "Layers": "Layers", + "Show Docker Dashboard": "Show Docker Dashboard", + "Show Container Processes": "Show Container Processes", + "Show Image Layers": "Show Image Layers", + "Toggle Stopped Containers": "Toggle Stopped Containers", + "Toggle Untagged Images": "Toggle Untagged Images", + "Untagged Images": "Untagged Images", + "Show Processes": "Show Processes", + "Open Log in Editor": "Open Log in Editor", + "Open Container Log in Editor": "Open Container Log in Editor" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index f5360d024..4bcf9c13e 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6965,5 +6965,59 @@ "Debugging a plugin target requires a container root": "调试插件目标需要容器根目录", "This debugger does not support plugin run targets": "此调试器不支持插件运行目标", "invalid debug launch arguments": "调试启动参数无效", - "unknown debug adapter": "未知调试适配器" + "unknown debug adapter": "未知调试适配器", + "Container Overview": "容器概览", + "Container ID": "容器 ID", + "Created": "创建时间", + "Started": "启动时间", + "Restart policy": "重启策略", + "Compose": "Compose 项目", + "Resource Usage": "资源使用情况", + "CPU": "处理器 (CPU)", + "Memory": "内存", + "Network I/O": "网络 I/O", + "Block I/O": "磁盘 I/O", + "PIDs": "进程数 (PIDs)", + "Metrics unavailable": "指标不可用", + "Port Bindings": "端口绑定", + "No port bindings": "无端口绑定", + "Volume & Bind Mounts": "卷与绑定挂载", + "Destination": "目标路径", + "Mode": "模式", + "No mounts": "无挂载", + "Networks": "网络", + "No environment variables": "无环境变量", + "Container is not running": "容器未运行", + "Start the container to view its running processes.": "启动容器以查看其运行中的进程。", + "No processes reported": "未报告任何进程", + "Show Layers": "显示镜像层", + "Image Overview": "镜像概览", + "Image ID": "镜像 ID", + "Platform": "平台", + "Exposed ports": "暴露端口", + "Containers Using Image": "使用此镜像的容器", + "No containers are using this image": "没有容器正在使用此镜像", + "No image layers": "无镜像层", + "Instruction": "指令", + "Volume Overview": "数据卷概览", + "Mountpoint": "挂载点", + "Containers Using Volume": "使用此数据卷的容器", + "No containers are using this volume": "没有容器正在使用此数据卷", + "Network Overview": "网络概览", + "Network ID": "网络 ID", + "Subnet / Gateway": "子网 / 网关", + "Connected Containers": "已连接的容器", + "No containers are connected to this network": "没有容器连接到此网络", + "Inspect JSON": "检查 JSON", + "Dashboard": "仪表盘", + "Layers": "镜像层", + "Show Docker Dashboard": "显示 Docker 仪表盘", + "Show Container Processes": "显示容器进程", + "Show Image Layers": "显示镜像层", + "Toggle Stopped Containers": "切换显示已停止的容器", + "Toggle Untagged Images": "切换显示未标记的镜像", + "Untagged Images": "未标记的镜像", + "Show Processes": "显示进程", + "Open Log in Editor": "在编辑器中打开日志", + "Open Container Log in Editor": "在编辑器中打开容器日志" } diff --git a/src/ui/LogPane.tsx b/src/ui/LogPane.tsx index f12083a15..2a9042eed 100644 --- a/src/ui/LogPane.tsx +++ b/src/ui/LogPane.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import clsx from "clsx"; import { useTx } from "@/platform/i18n"; import { IconButton } from "./Button"; @@ -29,6 +29,8 @@ export interface LogPaneProps { ): () => void; sourceKey: string; toggles?: LogToggle[]; + /** Extra toolbar buttons placed before Clear. */ + actions?: ReactNode; /** "Waiting for output…" rather than "No output" while empty. */ live: boolean; ariaLabel: string; @@ -118,6 +120,7 @@ function LogStream( ))} + {props.actions} setLines([])} />
+ + + + + + + diff --git a/src/ui/expui/docker/composeProjectStopped_dark.svg b/src/ui/expui/docker/composeProjectStopped_dark.svg new file mode 100644 index 000000000..0e0ea3c66 --- /dev/null +++ b/src/ui/expui/docker/composeProjectStopped_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/expui/docker/containersList.svg b/src/ui/expui/docker/containersList.svg new file mode 100644 index 000000000..59ddc6ba2 --- /dev/null +++ b/src/ui/expui/docker/containersList.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/docker/containersList_dark.svg b/src/ui/expui/docker/containersList_dark.svg new file mode 100644 index 000000000..bed6c1939 --- /dev/null +++ b/src/ui/expui/docker/containersList_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/docker/containersRunning.svg b/src/ui/expui/docker/containersRunning.svg new file mode 100644 index 000000000..dcb79340f --- /dev/null +++ b/src/ui/expui/docker/containersRunning.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/expui/docker/containersRunning_dark.svg b/src/ui/expui/docker/containersRunning_dark.svg new file mode 100644 index 000000000..832222357 --- /dev/null +++ b/src/ui/expui/docker/containersRunning_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/expui/docker/devContainers.svg b/src/ui/expui/docker/devContainers.svg new file mode 100644 index 000000000..659051999 --- /dev/null +++ b/src/ui/expui/docker/devContainers.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/expui/docker/devContainers_dark.svg b/src/ui/expui/docker/devContainers_dark.svg new file mode 100644 index 000000000..598fe7ae8 --- /dev/null +++ b/src/ui/expui/docker/devContainers_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/expui/docker/dockerComposeServiceStopped.svg b/src/ui/expui/docker/dockerComposeServiceStopped.svg new file mode 100644 index 000000000..32c8e02af --- /dev/null +++ b/src/ui/expui/docker/dockerComposeServiceStopped.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/docker/dockerComposeServiceStopped_dark.svg b/src/ui/expui/docker/dockerComposeServiceStopped_dark.svg new file mode 100644 index 000000000..cfa90b876 --- /dev/null +++ b/src/ui/expui/docker/dockerComposeServiceStopped_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/docker/dockerComposeServiceUp.svg b/src/ui/expui/docker/dockerComposeServiceUp.svg new file mode 100644 index 000000000..9bd3090ef --- /dev/null +++ b/src/ui/expui/docker/dockerComposeServiceUp.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/docker/dockerComposeServiceUp_dark.svg b/src/ui/expui/docker/dockerComposeServiceUp_dark.svg new file mode 100644 index 000000000..a58362af5 --- /dev/null +++ b/src/ui/expui/docker/dockerComposeServiceUp_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/docker/dockerConnected.svg b/src/ui/expui/docker/dockerConnected.svg new file mode 100644 index 000000000..f1882e349 --- /dev/null +++ b/src/ui/expui/docker/dockerConnected.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/docker/dockerConnected_dark.svg b/src/ui/expui/docker/dockerConnected_dark.svg new file mode 100644 index 000000000..8714604d9 --- /dev/null +++ b/src/ui/expui/docker/dockerConnected_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/docker/dockerRegistry.svg b/src/ui/expui/docker/dockerRegistry.svg new file mode 100644 index 000000000..d557cefeb --- /dev/null +++ b/src/ui/expui/docker/dockerRegistry.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/expui/docker/dockerRegistry_dark.svg b/src/ui/expui/docker/dockerRegistry_dark.svg new file mode 100644 index 000000000..cd923c749 --- /dev/null +++ b/src/ui/expui/docker/dockerRegistry_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/expui/docker/imagesList.svg b/src/ui/expui/docker/imagesList.svg new file mode 100644 index 000000000..59ab30e91 --- /dev/null +++ b/src/ui/expui/docker/imagesList.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/docker/imagesList_dark.svg b/src/ui/expui/docker/imagesList_dark.svg new file mode 100644 index 000000000..37e4bfe2c --- /dev/null +++ b/src/ui/expui/docker/imagesList_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/docker/network.svg b/src/ui/expui/docker/network.svg new file mode 100644 index 000000000..d559bc6de --- /dev/null +++ b/src/ui/expui/docker/network.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/expui/docker/network_dark.svg b/src/ui/expui/docker/network_dark.svg new file mode 100644 index 000000000..7d2191853 --- /dev/null +++ b/src/ui/expui/docker/network_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/expui/docker/pullImage.svg b/src/ui/expui/docker/pullImage.svg new file mode 100644 index 000000000..0b2e6e503 --- /dev/null +++ b/src/ui/expui/docker/pullImage.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/docker/pullImage_dark.svg b/src/ui/expui/docker/pullImage_dark.svg new file mode 100644 index 000000000..ef74b230a --- /dev/null +++ b/src/ui/expui/docker/pullImage_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/docker/pushImage.svg b/src/ui/expui/docker/pushImage.svg new file mode 100644 index 000000000..2adb8e8ca --- /dev/null +++ b/src/ui/expui/docker/pushImage.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/docker/pushImage_dark.svg b/src/ui/expui/docker/pushImage_dark.svg new file mode 100644 index 000000000..1ef8648ba --- /dev/null +++ b/src/ui/expui/docker/pushImage_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/docker/singleImage.svg b/src/ui/expui/docker/singleImage.svg new file mode 100644 index 000000000..7f7aa94a5 --- /dev/null +++ b/src/ui/expui/docker/singleImage.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/expui/docker/singleImage_dark.svg b/src/ui/expui/docker/singleImage_dark.svg new file mode 100644 index 000000000..e73ed6f32 --- /dev/null +++ b/src/ui/expui/docker/singleImage_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/expui/docker/singleRunningContainer.svg b/src/ui/expui/docker/singleRunningContainer.svg new file mode 100644 index 000000000..8b55d3ec8 --- /dev/null +++ b/src/ui/expui/docker/singleRunningContainer.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/expui/docker/singleRunningContainer_dark.svg b/src/ui/expui/docker/singleRunningContainer_dark.svg new file mode 100644 index 000000000..3c1e23f47 --- /dev/null +++ b/src/ui/expui/docker/singleRunningContainer_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/expui/docker/singleStoppedContainer.svg b/src/ui/expui/docker/singleStoppedContainer.svg new file mode 100644 index 000000000..591a78e8e --- /dev/null +++ b/src/ui/expui/docker/singleStoppedContainer.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/expui/docker/singleStoppedContainer_dark.svg b/src/ui/expui/docker/singleStoppedContainer_dark.svg new file mode 100644 index 000000000..a41d46073 --- /dev/null +++ b/src/ui/expui/docker/singleStoppedContainer_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/expui/docker/volume.svg b/src/ui/expui/docker/volume.svg new file mode 100644 index 000000000..8e985b37b --- /dev/null +++ b/src/ui/expui/docker/volume.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/expui/docker/volume_dark.svg b/src/ui/expui/docker/volume_dark.svg new file mode 100644 index 000000000..57de2a08a --- /dev/null +++ b/src/ui/expui/docker/volume_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 08822404d..4ec3e57ca 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -151,6 +151,22 @@ const ICONS = { "file-latex": "openidea/fileTypes/latex", "file-rst": "fileTypes/rst", "file-docker": "fileTypes/docker", + "docker-connected": "docker/dockerConnected", + "docker-compose-running": "docker/containersRunning", + "docker-compose-stopped": "docker/composeProjectStopped", + "docker-compose-service-running": "docker/dockerComposeServiceUp", + "docker-compose-service-stopped": "docker/dockerComposeServiceStopped", + "docker-devcontainer": "docker/devContainers", + "docker-containers": "docker/containersList", + "docker-images": "docker/imagesList", + "docker-networks": "docker/network", + "docker-volumes": "docker/volume", + "docker-container-running": "docker/singleRunningContainer", + "docker-container-stopped": "docker/singleStoppedContainer", + "docker-image": "docker/singleImage", + "docker-registry": "docker/dockerRegistry", + "docker-pull": "docker/pullImage", + "docker-push": "docker/pushImage", "file-makefile": "fileTypes/makefile", "file-cmake": "openidea/fileTypes/cmake", "file-bazel": "fileTypes/bazel",