Files
shell-rs/src/docker/model.rs
T
XuzandClaude Opus 5.5 e7fcc7ca40 Docker:同一个镜像有多个标签时每个标签一行,按标签查看和删除
docker images 每个「仓库:标签」一行,同一镜像的几行镜像 ID 相同,卡片和删除
按钮的 id 因此重复:调试版构建在镜像页直接崩溃,正式版里详情和删除只认第一
行,按 ID 删多标签镜像还会被 Docker 拒绝。现在每行按 Image::key 认:有标签
用「仓库:标签」(删除只去掉这个标签),没有标签用镜像 ID。只有仓库名的镜像
显示为「仓库:<none>」、按 ID 操作,不再被当成 仓库:latest;valid_id 允许 /,
带仓库地址的镜像可以删除和查看详情。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:03:18 +08:00

874 lines
28 KiB
Rust

//! What the Docker tool reads off a host: containers grouped by their
//! compose project, volumes, images and networks.
use std::collections::BTreeMap;
/// What a container is doing, as `docker ps` says.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ContainerState {
Running,
Paused,
Restarting,
Created,
Exited,
Dead,
Removing,
}
impl ContainerState {
/// `docker ps`'s `State`, or, from a Docker before 20.10 without one,
/// its `Status`: 「Up 3 days」「Exited (0) 2 hours ago」.
pub fn parse(state: &str, status: &str) -> Self {
match state {
"running" => ContainerState::Running,
"paused" => ContainerState::Paused,
"restarting" => ContainerState::Restarting,
"created" => ContainerState::Created,
"exited" => ContainerState::Exited,
"dead" => ContainerState::Dead,
"removing" => ContainerState::Removing,
_ if status.starts_with("Up") && status.contains("(Paused)") => ContainerState::Paused,
_ if status.starts_with("Up") => ContainerState::Running,
_ if status.starts_with("Restarting") => ContainerState::Restarting,
_ if status.starts_with("Created") => ContainerState::Created,
_ if status.starts_with("Dead") => ContainerState::Dead,
_ if status.starts_with("Removal") => ContainerState::Removing,
_ => ContainerState::Exited,
}
}
pub fn label(self) -> &'static str {
match self {
ContainerState::Running => "运行中",
ContainerState::Paused => "已暂停",
ContainerState::Restarting => "正在重启",
ContainerState::Created => "已创建",
ContainerState::Exited => "已停止",
ContainerState::Dead => "异常",
ContainerState::Removing => "正在删除",
}
}
/// Whether it is up: what stops and restarts, rather than starts.
pub fn is_up(self) -> bool {
matches!(
self,
ContainerState::Running | ContainerState::Paused | ContainerState::Restarting
)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Container {
/// The full ID.
pub id: String,
pub name: String,
/// As it was run: 「nginx:1.27」, 「hello-world」.
pub image: String,
pub state: ContainerState,
/// 「56000→9000/tcp, :::56000→9000/tcp」, empty with none published.
pub ports: String,
/// The compose project it belongs to, and that project's directory.
pub project: Option<String>,
pub working_dir: Option<String>,
/// The volumes (by name) and host paths mounted into it.
pub mounts: Vec<String>,
pub networks: Vec<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Volume {
pub name: String,
pub driver: String,
pub mountpoint: String,
/// The containers it is mounted into, running or not, by name: Docker
/// will not remove it while there are any.
pub used_by: Vec<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Image {
/// 「4a3b5c6d7e8f」. Not unique in the list: an image tagged twice is
/// two of its lines, one per tag.
pub id: String,
/// What a command names this line by, unique in the list: 「nginx:1.27」,
/// or the ID for an image without a tag. Removing a tag removes the
/// image only with its last one, as `docker rmi nginx:1.27` does.
pub key: String,
/// 「nginx:1.27」, `None` for a dangling image (「<none>」).
pub reference: Option<String>,
/// 「187.69 MB」.
pub size: String,
/// 「2026-09-07」.
pub created: String,
/// The containers made from it, by name.
pub used_by: Vec<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Network {
pub id: String,
pub name: String,
pub driver: String,
pub scope: String,
/// The containers attached to it, by name.
pub used_by: Vec<String>,
}
impl Network {
/// One of the three Docker makes itself, which cannot be removed.
pub fn builtin(&self) -> bool {
matches!(self.name.as_str(), "bridge" | "host" | "none")
}
}
/// A compose project: the containers sharing its label.
#[derive(Clone, Debug, PartialEq)]
pub struct Project {
pub name: String,
pub working_dir: Option<String>,
/// By their place in `DockerTable::containers`, by name.
pub containers: Vec<usize>,
pub running: usize,
}
impl Project {
/// Whether the list shows its containers until folded or unfolded by
/// hand: only a project of one container, running. A project of
/// several would push the rest of the list down; the card already says
/// how many of them run.
pub fn unfolded_at_first(&self) -> bool {
self.running > 0 && self.containers.len() == 1
}
}
/// What can be done to containers.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ContainerCommand {
Start,
Stop,
Restart,
}
impl ContainerCommand {
pub fn verb(self) -> &'static str {
match self {
ContainerCommand::Start => "start",
ContainerCommand::Stop => "stop",
ContainerCommand::Restart => "restart",
}
}
pub fn label(self) -> &'static str {
match self {
ContainerCommand::Start => "启动",
ContainerCommand::Stop => "停止",
ContainerCommand::Restart => "重启",
}
}
/// What it did, after the name: 「已停止」.
pub fn done(self) -> &'static str {
match self {
ContainerCommand::Start => "已启动",
ContainerCommand::Stop => "已停止",
ContainerCommand::Restart => "已重启",
}
}
}
/// What kind of thing a removal removes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum DockerObject {
Container,
Image,
Volume,
Network,
}
impl DockerObject {
pub fn label(self) -> &'static str {
match self {
DockerObject::Container => "容器",
DockerObject::Image => "镜像",
DockerObject::Volume => "卷",
DockerObject::Network => "网络",
}
}
}
/// The tabs over the list.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DockerTab {
#[default]
Containers,
Volumes,
Images,
Networks,
}
impl DockerTab {
pub const ALL: [DockerTab; 4] = [
DockerTab::Containers,
DockerTab::Volumes,
DockerTab::Images,
DockerTab::Networks,
];
pub fn label(self) -> &'static str {
match self {
DockerTab::Containers => "容器",
DockerTab::Volumes => "卷",
DockerTab::Images => "镜像",
DockerTab::Networks => "网络",
}
}
}
/// A line of the list.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DockerRow {
/// A compose project by its place in `projects()`, with its containers
/// under it when unfolded.
Project {
index: usize,
unfolded: bool,
},
/// The heading over the containers of no project.
Standalone,
Container(usize),
Volume(usize),
Image(usize),
Network(usize),
}
/// One reading of a host's Docker.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct DockerTable {
/// 「26.0.0」.
pub version: Option<String>,
/// 「v2.25.0」.
pub compose: Option<String>,
containers: Vec<Container>,
projects: Vec<Project>,
volumes: Vec<Volume>,
images: Vec<Image>,
networks: Vec<Network>,
}
impl DockerTable {
/// Sorts each kind by name, groups the containers by project, and
/// marks what the containers use.
pub fn new(
version: Option<String>,
compose: Option<String>,
mut containers: Vec<Container>,
mut volumes: Vec<Volume>,
mut images: Vec<Image>,
mut networks: Vec<Network>,
) -> Self {
containers.sort_by(|a, b| a.name.cmp(&b.name));
let mut grouped: BTreeMap<String, Project> = BTreeMap::new();
for (index, container) in containers.iter().enumerate() {
let Some(name) = &container.project else {
continue;
};
let project = grouped.entry(name.clone()).or_insert_with(|| Project {
name: name.clone(),
working_dir: None,
containers: Vec::new(),
running: 0,
});
if project.working_dir.is_none() {
project.working_dir = container.working_dir.clone();
}
project.containers.push(index);
if container.state.is_up() {
project.running += 1;
}
}
let using = |uses: &dyn Fn(&Container) -> bool| -> Vec<String> {
containers
.iter()
.filter(|container| uses(container))
.map(|container| container.name.clone())
.collect()
};
for volume in &mut volumes {
volume.used_by = using(&|container| container.mounts.contains(&volume.name));
}
volumes.sort_by(|a, b| a.name.cmp(&b.name));
for image in &mut images {
image.used_by = using(&|container| {
let used = container.image.as_str();
// A container whose image lost its name since shows its ID.
let id = used.trim_start_matches("sha256:");
let by_id = id.len() >= 12 && id.chars().all(|c| c.is_ascii_hexdigit());
image.reference.as_deref().is_some_and(|reference| {
used == reference || reference.strip_suffix(":latest") == Some(used)
}) || (by_id && (id.starts_with(&image.id) || image.id.starts_with(id)))
});
}
// Tagged ones first, by name; then those without a tag, the ones
// without a repository either last.
images.sort_by(|a, b| {
let order = |image: &Image| {
(
image.key == image.id,
image.reference.is_none(),
image.reference.clone(),
image.id.clone(),
)
};
order(a).cmp(&order(b))
});
// One card per key: an image without a tag in two repositories is
// one image.
let mut seen = std::collections::HashSet::new();
images.retain(|image| seen.insert(image.key.clone()));
for network in &mut networks {
network.used_by = using(&|container| container.networks.contains(&network.name));
}
networks.sort_by(|a, b| a.name.cmp(&b.name));
Self {
version,
compose,
containers,
projects: grouped.into_values().collect(),
volumes,
images,
networks,
}
}
pub fn containers(&self) -> &[Container] {
&self.containers
}
pub fn container(&self, id: &str) -> Option<&Container> {
self.containers.iter().find(|container| container.id == id)
}
/// A volume, an image or a network by what Docker knows it by, as its
/// card and its details show it.
pub fn summary_of(&self, object: DockerObject, id: &str) -> Option<ObjectSummary> {
match object {
DockerObject::Container => None,
DockerObject::Volume => self
.volumes
.iter()
.find(|volume| volume.name == id)
.map(Volume::summary),
DockerObject::Image => self
.images
.iter()
.find(|image| image.key == id)
.map(Image::summary),
DockerObject::Network => self
.networks
.iter()
.find(|network| network.id == id)
.map(Network::summary),
}
}
pub fn projects(&self) -> &[Project] {
&self.projects
}
pub fn volumes(&self) -> &[Volume] {
&self.volumes
}
pub fn images(&self) -> &[Image] {
&self.images
}
pub fn networks(&self) -> &[Network] {
&self.networks
}
/// 「Docker 26.0.0 · Compose v2.25.0」.
pub fn summary(&self) -> String {
let mut parts = vec![
format!("Docker {}", self.version.as_deref().unwrap_or(""))
.trim()
.to_owned(),
];
if let Some(compose) = &self.compose {
parts.push(format!("Compose {compose}"));
}
parts.join(" · ")
}
/// How many of each the tabs count, in their order.
pub fn counts(&self) -> [usize; 4] {
[
self.containers.len(),
self.volumes.len(),
self.images.len(),
self.networks.len(),
]
}
/// The list under `tab`. Containers: each compose project, its
/// containers under it when `unfolded` says so, then the containers
/// of no project under their heading.
pub fn rows(&self, tab: DockerTab, unfolded: impl Fn(&Project) -> bool) -> Vec<DockerRow> {
match tab {
DockerTab::Containers => {
let mut rows: Vec<DockerRow> = self
.projects
.iter()
.enumerate()
.map(|(index, project)| DockerRow::Project {
index,
unfolded: unfolded(project),
})
.collect();
let standalone: Vec<DockerRow> = self
.containers
.iter()
.enumerate()
.filter(|(_, container)| container.project.is_none())
.map(|(index, _)| DockerRow::Container(index))
.collect();
if !standalone.is_empty() {
rows.push(DockerRow::Standalone);
rows.extend(standalone);
}
rows
}
DockerTab::Volumes => (0..self.volumes.len()).map(DockerRow::Volume).collect(),
DockerTab::Images => (0..self.images.len()).map(DockerRow::Image).collect(),
DockerTab::Networks => (0..self.networks.len()).map(DockerRow::Network).collect(),
}
}
}
/// How a tag reads: what is in use stands out, the rest is quiet.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tone {
Good,
Quiet,
}
/// A volume, an image or a network as its card and its details show it.
#[derive(Clone, Debug, PartialEq)]
pub struct ObjectSummary {
pub object: DockerObject,
/// What Docker knows it by, for its commands: the ID, a volume's name.
pub id: String,
pub name: String,
/// A line about it: 「local · /var/lib/docker/volumes/data/_data」.
pub detail: String,
pub tag: Option<(String, Tone)>,
/// Whether it can be removed, and why not.
pub removable: Result<(), &'static str>,
/// The containers using it, by name.
pub used_by: Vec<String>,
}
fn joined(parts: &[&str]) -> String {
parts
.iter()
.filter(|part| !part.is_empty())
.copied()
.collect::<Vec<_>>()
.join(" · ")
}
impl Volume {
pub fn summary(&self) -> ObjectSummary {
let in_use = !self.used_by.is_empty();
ObjectSummary {
object: DockerObject::Volume,
id: self.name.clone(),
name: self.name.clone(),
detail: joined(&[&self.driver, &self.mountpoint]),
tag: Some(if in_use {
("使用中".into(), Tone::Good)
} else {
("未使用".into(), Tone::Quiet)
}),
removable: if in_use {
Err("有容器在用,不能删除")
} else {
Ok(())
},
used_by: self.used_by.clone(),
}
}
}
impl Image {
pub fn summary(&self) -> ObjectSummary {
let in_use = !self.used_by.is_empty();
ObjectSummary {
object: DockerObject::Image,
id: self.key.clone(),
name: self
.reference
.clone()
.unwrap_or_else(|| "未命名镜像".into()),
detail: joined(&[&self.id, &self.size, &self.created]),
tag: in_use.then(|| ("使用中".into(), Tone::Good)),
removable: if in_use {
Err("有容器在用,不能删除")
} else {
Ok(())
},
used_by: self.used_by.clone(),
}
}
}
impl Network {
pub fn summary(&self) -> ObjectSummary {
let (tag, removable) = if self.builtin() {
(
("内置".to_owned(), Tone::Quiet),
Err("Docker 自带的网络,不能删除"),
)
} else if !self.used_by.is_empty() {
(
(format!("{} 个容器", self.used_by.len()), Tone::Good),
Err("有容器连着,不能删除"),
)
} else {
(("未使用".to_owned(), Tone::Quiet), Ok(()))
};
ObjectSummary {
object: DockerObject::Network,
id: self.id.clone(),
name: self.name.clone(),
detail: joined(&[&self.driver, &self.scope]),
tag: Some(tag),
removable,
used_by: self.used_by.clone(),
}
}
}
/// A part of something's details, under its heading.
#[derive(Clone, Debug, PartialEq)]
pub struct DetailSection {
/// For the test ids of its lines: 「basics」.
pub id: &'static str,
pub title: &'static str,
pub body: SectionBody,
}
#[derive(Clone, Debug, PartialEq)]
pub enum SectionBody {
/// Labels and their values: 「挂载点」 「/var/lib/docker/volumes/…」.
Rows(Vec<(String, String)>),
/// Names, one a line: the containers using it.
List(Vec<String>),
/// Lines of text as they are, in a fixed-width face: the environment.
Text(Vec<String>),
}
impl SectionBody {
pub fn is_empty(&self) -> bool {
match self {
SectionBody::Rows(rows) => rows.is_empty(),
SectionBody::List(lines) | SectionBody::Text(lines) => lines.is_empty(),
}
}
}
/// Labels and values, with 「—」 for the empty values.
pub fn rows(rows: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>) -> SectionBody {
SectionBody::Rows(
rows.into_iter()
.map(|(label, value)| {
let value = value.into();
(
label.into(),
if value.is_empty() {
"—".into()
} else {
value
},
)
})
.collect(),
)
}
impl ContainerDetails {
/// Its sections, in the order its dialog shows them.
pub fn sections(&self) -> Vec<DetailSection> {
vec![
DetailSection {
id: "basics",
title: "基础信息",
body: rows([
("名称", self.name.clone()),
("ID", self.id.clone()),
("镜像", self.image.clone()),
("创建时间", self.created.clone()),
("入口", self.entrypoint.clone()),
("命令", self.command.clone()),
]),
},
DetailSection {
id: "ports",
title: "端口映射",
body: rows(self.ports.clone()),
},
DetailSection {
id: "mounts",
title: "挂载",
body: rows(self.mounts.clone()),
},
DetailSection {
id: "environment",
title: "环境变量",
body: SectionBody::Text(self.environment.clone()),
},
DetailSection {
id: "labels",
title: "标签",
body: rows(self.labels.clone()),
},
]
}
}
/// A container's details, as `docker inspect` says.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ContainerDetails {
pub name: String,
pub id: String,
pub image: String,
pub state: String,
/// 「2026-09-07T10:59:16.371325789Z」.
pub created: String,
pub entrypoint: String,
pub command: String,
/// The container's port and where on the host it is published:
/// 「4321/tcp」, 「127.0.0.1:4321」 (empty when it is not).
pub ports: Vec<(String, String)>,
/// Where from and where to: 「/srv/data」, 「/data」, with 「rw」 or 「ro」.
pub mounts: Vec<(String, String)>,
pub environment: Vec<String>,
pub labels: Vec<(String, String)>,
}
#[cfg(test)]
mod tests {
use super::*;
fn container(name: &str, state: ContainerState, project: Option<&str>) -> Container {
Container {
id: format!("{name}-id"),
name: name.into(),
image: format!("{name}:latest"),
state,
ports: String::new(),
project: project.map(str::to_owned),
working_dir: project.map(|project| format!("/srv/{project}")),
mounts: Vec::new(),
networks: vec!["bridge".into()],
}
}
#[test]
fn containers_group_under_their_project_and_the_rest_stand_alone() {
let table = DockerTable::new(
Some("26.0.0".into()),
Some("v2.25.0".into()),
vec![
container("forex-web", ContainerState::Running, None),
container("vaultwarden", ContainerState::Exited, Some("vaultwarden")),
container("php56", ContainerState::Running, Some("php-56")),
container("exciting_tesla", ContainerState::Exited, None),
],
Vec::new(),
Vec::new(),
Vec::new(),
);
assert_eq!(table.summary(), "Docker 26.0.0 · Compose v2.25.0");
let projects: Vec<(&str, usize, usize)> = table
.projects()
.iter()
.map(|project| {
(
project.name.as_str(),
project.running,
project.containers.len(),
)
})
.collect();
assert_eq!(projects, [("php-56", 1, 1), ("vaultwarden", 0, 1)]);
let rows = table.rows(DockerTab::Containers, |project| project.running > 0);
let names: Vec<String> = rows
.iter()
.map(|row| match row {
DockerRow::Project { index, unfolded } => {
format!("{} {unfolded}", table.projects()[*index].name)
}
DockerRow::Standalone => "独立容器".into(),
DockerRow::Container(index) => table.containers()[*index].name.clone(),
_ => unreachable!(),
})
.collect();
assert_eq!(
names,
[
"php-56 true",
"vaultwarden false",
"独立容器",
"exciting_tesla",
"forex-web",
]
);
assert_eq!(table.counts(), [4, 0, 0, 0]);
}
#[test]
fn only_a_running_project_of_one_container_starts_unfolded() {
let table = DockerTable::new(
None,
None,
vec![
container("php56", ContainerState::Running, Some("php-56")),
container("vaultwarden", ContainerState::Exited, Some("vaultwarden")),
container("db", ContainerState::Running, Some("shop")),
container("web", ContainerState::Running, Some("shop")),
container("worker", ContainerState::Exited, Some("shop")),
],
Vec::new(),
Vec::new(),
Vec::new(),
);
let unfolded: Vec<(&str, bool)> = table
.projects()
.iter()
.map(|project| (project.name.as_str(), project.unfolded_at_first()))
.collect();
assert_eq!(
unfolded,
[("php-56", true), ("shop", false), ("vaultwarden", false)]
);
}
#[test]
fn what_the_containers_use_is_marked() {
let mut web = container("web", ContainerState::Running, None);
web.mounts = vec!["web-data".into(), "/srv/web".into()];
web.image = "nginx".into();
web.networks = vec!["web_default".into()];
let volume = |name: &str| Volume {
name: name.into(),
driver: "local".into(),
mountpoint: String::new(),
used_by: Vec::new(),
};
let image = |id: &str, reference: Option<&str>| Image {
id: id.into(),
key: reference.unwrap_or(id).into(),
reference: reference.map(str::to_owned),
size: String::new(),
created: String::new(),
used_by: Vec::new(),
};
let network = |name: &str| Network {
id: name.into(),
name: name.into(),
driver: "bridge".into(),
scope: "local".into(),
used_by: Vec::new(),
};
let table = DockerTable::new(
None,
None,
vec![web],
vec![volume("web-data"), volume("old-data")],
vec![
image("aaa", None),
image("bbb", Some("nginx:latest")),
image("ccc", Some("redis:7")),
],
vec![network("web_default"), network("bridge")],
);
assert_eq!(table.summary(), "Docker");
let volumes: Vec<(&str, Vec<String>)> = table
.volumes()
.iter()
.map(|volume| (volume.name.as_str(), volume.used_by.clone()))
.collect();
assert_eq!(
volumes,
[("old-data", vec![]), ("web-data", vec!["web".to_owned()])]
);
let images: Vec<(&str, usize)> = table
.images()
.iter()
.map(|image| (image.id.as_str(), image.used_by.len()))
.collect();
// Named first; nginx:latest is what 「nginx」 runs.
assert_eq!(images, [("bbb", 1), ("ccc", 0), ("aaa", 0)]);
let networks: Vec<(&str, usize, bool)> = table
.networks()
.iter()
.map(|network| {
(
network.name.as_str(),
network.used_by.len(),
network.builtin(),
)
})
.collect();
assert_eq!(networks, [("bridge", 0, true), ("web_default", 1, false)]);
// The card and the details say what is in use and what can go.
let summary = |object, id: &str| table.summary_of(object, id).expect(id);
let data = summary(DockerObject::Volume, "web-data");
assert_eq!(data.tag, Some(("使用中".into(), Tone::Good)));
assert_eq!(data.removable, Err("有容器在用,不能删除"));
assert_eq!(data.used_by, ["web"]);
assert_eq!(summary(DockerObject::Volume, "old-data").removable, Ok(()));
let dangling = summary(DockerObject::Image, "aaa");
assert_eq!(dangling.name, "未命名镜像");
assert_eq!(dangling.tag, None);
assert_eq!(
summary(DockerObject::Network, "bridge").removable,
Err("Docker 自带的网络,不能删除")
);
assert_eq!(
summary(DockerObject::Network, "web_default").tag,
Some(("1 个容器".into(), Tone::Good))
);
assert_eq!(table.summary_of(DockerObject::Volume, "gone"), None);
}
#[test]
fn a_state_reads_from_an_old_dockers_status_too() {
assert_eq!(
ContainerState::parse("", "Up 3 days"),
ContainerState::Running
);
assert_eq!(
ContainerState::parse("", "Up 3 days (Paused)"),
ContainerState::Paused
);
assert_eq!(
ContainerState::parse("", "Exited (0) 2 hours ago"),
ContainerState::Exited
);
assert_eq!(
ContainerState::parse("running", "Up 1 second"),
ContainerState::Running
);
assert!(ContainerState::Restarting.is_up());
assert!(!ContainerState::Created.is_up());
}
}