diff --git a/app/src-tauri/src/commands/docker_commands.rs b/app/src-tauri/src/commands/docker_commands.rs index d8f7a32..6dcc687 100644 --- a/app/src-tauri/src/commands/docker_commands.rs +++ b/app/src-tauri/src/commands/docker_commands.rs @@ -54,3 +54,80 @@ pub async fn list_sibling_containers() -> Result, String> .collect(); Ok(result) } + +// --------------------------------------------------------------------------- +// Disk +// --------------------------------------------------------------------------- +// +// The disk view's IPC surface. It lives here rather than in a module of its own +// for the same reason `check_image_exists` does: these are thin shims over +// `crate::docker`, and the logic they call is in `docker/disk.rs` where it can +// be unit-tested without a daemon. + +/// Measure where the daemon's bytes have gone. +/// +/// **Expensive on purpose.** This is `GET /system/df` plus an `image_history` +/// per distinct image, and `df()` walks every image, container and volume on +/// the daemon to compute shared-layer sizes. On a 100 GB store that is seconds. +/// The frontend must keep it behind an explicit Scan button — never on panel +/// open, never on a timer. +#[tauri::command] +pub async fn get_docker_disk_usage( + state: State<'_, AppState>, +) -> Result { + let projects = state.projects_store.list(); + docker::disk::scan(&projects).await +} + +/// Everything that could be reclaimed, each with its measured cost. +/// +/// Takes the report from [`get_docker_disk_usage`] rather than re-measuring, so +/// a user who re-plans after ticking a box does not pay for a second `df()`. +#[tauri::command] +pub async fn list_reclaimable( + report: docker::disk::DiskUsageReport, + state: State<'_, AppState>, +) -> Result { + let projects = state.projects_store.list(); + docker::disk::list_reclaimable(&projects, &report).await +} + +/// Run the ticked targets and report what each one actually freed. +/// +/// `ReclaimTarget` cannot express a destructive action — that is a different +/// type, reached only through [`destroy_project_disk_object`] with a typed +/// confirmation — so there is no selection a user can build here that deletes a +/// live project's data. +#[tauri::command] +pub async fn reclaim( + targets: Vec, + state: State<'_, AppState>, +) -> Result { + let projects = state.projects_store.list(); + Ok(docker::disk::reclaim(&targets, &projects).await) +} + +/// Delete one object that has no other copy, against a typed confirmation of +/// the project's name. +/// +/// Deliberately one target per call: this is never part of a bulk action. +#[tauri::command] +pub async fn destroy_project_disk_object( + target: docker::disk::DestructiveTarget, + confirmation: String, + state: State<'_, AppState>, +) -> Result { + let projects = state.projects_store.list(); + docker::disk::destroy(&target, &confirmation, &projects).await +} + +/// Run the orphaned-snapshot sweep on demand and return its report. +/// +/// The sweep already runs at startup, after every recreation and after a +/// migration settles, but every one of those callers throws the report away — +/// so a user has never been able to see that 11.9 GB of superseded images were +/// found and left because a stopped container still pinned them. +#[tauri::command] +pub async fn sweep_orphaned_snapshots() -> Result { + Ok(docker::sweep_orphaned_snapshots().await) +} diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 71dbe84..a495855 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -216,13 +216,13 @@ pub const SECRET_ENV_KEYS: &[&str] = &[ /// `docker commit` copies a container's labels onto the image, every snapshot it /// commits. [`sweep_orphaned_snapshots`] treats it as the mark of provenance, /// which is what keeps the sweep away from the user's own images. -const LABEL_MANAGED: &str = "triple-c.managed"; +pub(crate) const LABEL_MANAGED: &str = "triple-c.managed"; /// Marks the image built from `container/Dockerfile` itself, as opposed to a /// project snapshot committed from a container. Only ever `"true"` on a base /// image; `create_container` writes it explicitly empty so an inherited value /// cannot travel onto a snapshot. See the `LABEL` block in the Dockerfile. -const LABEL_BASE: &str = "triple-c.base"; +pub(crate) const LABEL_BASE: &str = "triple-c.base"; const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; @@ -1550,7 +1550,7 @@ pub async fn create_container( // container stop/start cycles. mounts.push(Mount { target: Some("/home/claude".to_string()), - source: Some(format!("triple-c-home-{}", project.id)), + source: Some(home_volume_name(&project.id)), typ: Some(MountTypeEnum::VOLUME), read_only: Some(false), ..Default::default() @@ -1560,7 +1560,7 @@ pub async fn create_container( // inside the home volume; Docker gives the more-specific mount precedence. mounts.push(Mount { target: Some("/home/claude/.claude".to_string()), - source: Some(format!("triple-c-claude-config-{}", project.id)), + source: Some(config_volume_name(&project.id)), typ: Some(MountTypeEnum::VOLUME), read_only: Some(false), ..Default::default() @@ -1886,6 +1886,29 @@ pub fn get_snapshot_image_name(project: &Project) -> String { format!("triple-c-snapshot-{}:latest", project.id) } +/// Name of the named volume mounted at `/home/claude`. +/// +/// Takes the id rather than the `Project` because the disk view runs this +/// mapping backwards: it reads volume names off the daemon and has to decide +/// which project — if any — each one belongs to. See [`HOME_VOLUME_PREFIX`]. +pub fn home_volume_name(project_id: &str) -> String { + format!("{}{}", HOME_VOLUME_PREFIX, project_id) +} + +/// Name of the named volume mounted at `/home/claude/.claude`, nested inside +/// the home volume. This is the one holding the OAuth credential, the plugins +/// and every session transcript. +pub fn config_volume_name(project_id: &str) -> String { + format!("{}{}", CONFIG_VOLUME_PREFIX, project_id) +} + +/// Prefix of [`home_volume_name`]. Split out because orphan detection scans the +/// daemon's volume list for these prefixes and strips them back to a project id. +pub const HOME_VOLUME_PREFIX: &str = "triple-c-home-"; + +/// Prefix of [`config_volume_name`]. See [`HOME_VOLUME_PREFIX`]. +pub const CONFIG_VOLUME_PREFIX: &str = "triple-c-claude-config-"; + /// Keep the container's `~/.aws/credentials` in sync with the project's Bedrock /// auth on every container start: /// - **Bedrock + static credentials**: (re)write `~/.aws/credentials` from the @@ -2067,7 +2090,7 @@ const SCRUB_MARKER: &str = "###TRIPLE-C-SCRUBBED "; /// matches nothing is a no-op rather than an `rm` of a literal path. /// Inside the loop `$p` is quoted, so a filename containing whitespace is one /// argument. -fn snapshot_scrub_script() -> String { +pub(crate) fn snapshot_scrub_script() -> String { format!( r#"total=0 for p in {paths}; do @@ -2659,8 +2682,8 @@ pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> { pub async fn remove_project_volumes(project: &Project) -> Result<(), String> { let docker = get_docker()?; for vol in [ - format!("triple-c-home-{}", project.id), - format!("triple-c-claude-config-{}", project.id), + home_volume_name(&project.id), + config_volume_name(&project.id), ] { match docker.remove_volume(&vol, None).await { Ok(_) => log::info!("Removed volume {}", vol), diff --git a/app/src-tauri/src/docker/disk.rs b/app/src-tauri/src/docker/disk.rs new file mode 100644 index 0000000..43d7b65 --- /dev/null +++ b/app/src-tauri/src/docker/disk.rs @@ -0,0 +1,3223 @@ +//! Disk accounting and reclaim for the objects Triple-C creates. +//! +//! ## The problem this exists to make visible +//! +//! Every recreation runs `docker commit`, and a commit **stacks a new layer** +//! rather than rewriting one. A file deleted after it has been committed does +//! not give its bytes back — the layer above records a whiteout and the +//! original bytes stay below it forever. `container_needs_recreation` has 24 +//! conditions, so changing one settings field costs a multi-gigabyte layer that +//! nothing in the app ever reclaims. One project was measured at 14 stacked +//! commit layers, ~5.1 GB above its base, 12.3 GB total. +//! +//! Prevention already landed (the pre-commit scrub, capped container logs, +//! base-image labels, the startup sweep, the migration-pin reaper). What was +//! missing is the half a user can act on: *seeing* where the bytes are, and +//! being able to get them back. That is this module. +//! +//! **Making the layer count visible is the point.** "Snapshot 12.3 GB / 14 +//! layers / next commit adds 868 MB" explains the growth mechanism in one row, +//! which no total ever does. +//! +//! ## Why the scan is explicit +//! +//! [`scan`] is built on `Docker::df()` (`GET /system/df`), which walks every +//! image, container and volume on the daemon and computes shared-layer sizes. +//! On a 100 GB store that is seconds, not milliseconds, and it is the *only* +//! call that populates `ImageSummary::shared_size`, `ContainerSummary::size_rw` +//! and `VolumeUsageData::size` at all. So it sits behind a Scan button and is +//! never run on panel open or on a timer. +//! +//! ## Safety, which is the whole design +//! +//! Reclaim targets are split across **two Rust types that cannot be confused +//! for one another**: +//! +//! * [`ReclaimTarget`] — safe and semi-safe work. Everything here is either +//! already unreachable (dangling images, ownerless rollback pins, probe and +//! scrub leftovers), regenerable (build cache, package caches), or a rewrite +//! that preserves content (snapshot compaction). [`reclaim`] accepts these. +//! * [`DestructiveTarget`] — a live project's home volume, config volume, +//! snapshot image, or a rollback pin whose migration is still awaiting +//! confirmation. [`destroy`] accepts these, one at a time, and only against a +//! typed confirmation of the project name. +//! +//! `reclaim` does not have a code path that can reach a `DestructiveTarget` — +//! it cannot be passed one. That is deliberate: the guarantee is in the type +//! system rather than in a runtime check somebody can forget to write. +//! +//! Two rules apply throughout, and both are inherited from +//! `sweep_orphaned_snapshots`: +//! +//! * **Never call an unfiltered prune.** `prune_images`/`prune_volumes` without +//! filters would reach the user's own postgres, mysql and site-builder work +//! on the same daemon. Every removal here names one object we created. +//! * **Only ever touch a `triple-c*` name or a `triple-c.*` label.** + +use std::collections::{HashMap, HashSet}; + +use bollard::container::ListContainersOptions; +use bollard::image::{ListImagesOptions, RemoveImageOptions}; +use bollard::models::{BuildCache, ContainerSummary, ImageSummary, Volume}; +use serde::{Deserialize, Serialize}; + +use super::client::get_docker; +use super::container::{ + self, config_volume_name, home_volume_name, get_snapshot_image_name, CONFIG_VOLUME_PREFIX, + HOME_VOLUME_PREFIX, LABEL_BASE, LABEL_MANAGED, +}; +use super::migration; +use crate::models::Project; +use crate::storage::migration_store; + +/// Default age filter for a build-cache prune, matching `docker builder prune +/// --filter until=168h`. A week is long enough that an active build tree keeps +/// its warm cache and short enough that abandoned trees are collected. +pub const BUILD_CACHE_DEFAULT_UNTIL_HOURS: i64 = 168; + +// --------------------------------------------------------------------------- +// Scan result +// --------------------------------------------------------------------------- + +/// One row of the per-project table — the mental model users actually have. +/// +/// Serde is plain snake_case, matching every other IPC struct in this crate and +/// `app/src/lib/types.ts`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct ProjectDiskRow { + pub project_id: String, + pub project_name: String, + /// `triple-c-snapshot-{id}:latest`, present whether or not it exists yet. + pub snapshot_image: String, + pub snapshot_exists: bool, + /// Total size of the snapshot image, base image included. + pub snapshot_bytes: i64, + /// Bytes of the snapshot that are *also* in some other image — almost + /// always the shared base. Only `df()` computes this. + pub snapshot_shared_bytes: i64, + /// How many layers the snapshot has stacked **above its base image**. This + /// is the number that explains the growth: one per recreation. + /// + /// Only means that when [`Self::base_lineage_known`] is true. Otherwise it + /// is every layer carrying bytes, base included — an upper bound, and a + /// misleading one to present as a recreation count. + pub snapshot_commit_layers: u32, + /// Whether the base image this snapshot descends from could be identified. + /// + /// False when `triple-c.base-image-id` is absent, which is the **normal** + /// case for a project created before that label existed. The UI must not + /// present `snapshot_commit_layers` as a recreation count in that state, + /// and compaction is not offered, because a never-recreated project would + /// otherwise report its base's ~15 layers and qualify. + pub base_lineage_known: bool, + /// Bytes those stacked layers account for. `None` when the base image the + /// snapshot descends from is no longer on the daemon, so the split cannot + /// be measured and must not be guessed. + pub snapshot_above_base_bytes: Option, + pub container_exists: bool, + pub container_running: bool, + /// The container's writable layer — i.e. **exactly what the next commit + /// will add** to the snapshot. Surfaced under that name in the UI. + pub container_writable_bytes: i64, + pub home_volume_bytes: i64, + pub home_volume_present: bool, + pub config_volume_bytes: i64, + pub config_volume_present: bool, + pub total_bytes: i64, + /// A migration is in flight; every action on this row is blocked. + pub migrating: bool, +} + +/// A base image, shared by every project built from it. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct BaseImageRow { + pub reference: String, + pub bytes: i64, + pub shared_bytes: i64, + /// Containers still built from it, as `df()` counts them. A base with + /// `containers > 0` cannot be removed and is not offered. + pub containers: i64, + /// Carries `triple-c.base=true`. + pub is_labelled_base: bool, +} + +/// Where the daemon actually keeps its bytes, and whether the Windows/WSL2 +/// caveat applies. See [`WSL2_VHDX_NOTE`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct HostStorage { + /// `docker info`'s `DockerRootDir`. On Docker Desktop this is a path + /// *inside the VM*, not something the host can stat — which is the whole + /// reason the WSL2 note exists. + pub docker_root_dir: String, + /// `docker info`'s `OperatingSystem`, e.g. `"Docker Desktop"`. + pub operating_system: String, + pub is_docker_desktop: bool, + /// The app itself is running on Windows. + pub is_windows_host: bool, + /// Windows + Docker Desktop: pruning frees space *inside* `ext4.vhdx` and + /// returns nothing to `C:` until the disk is compacted. + pub vhdx_applies: bool, + /// [`WSL2_VHDX_NOTE`], [`WSL2_VHDX_FIX`] and [`WSL2_VHDX_FIX_GUI`], carried + /// over IPC rather than restated in the frontend. + /// + /// A second copy of this copy in TypeScript would drift from the one the + /// Rust tests pin, and this is the paragraph that stops a user reporting + /// "I pruned and C: did not change" as a bug. Empty when the caveat does + /// not apply, so the UI has nothing to decide. + pub vhdx_note: String, + pub vhdx_fix: Vec, + pub vhdx_fix_gui: String, +} + +/// Everything one Scan produces. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct DiskUsageReport { + /// RFC3339. The UI shows how stale the numbers are rather than refreshing + /// them, because a refresh costs another `df()`. + pub scanned_at: String, + pub projects: Vec, + pub base_images: Vec, + pub base_images_bytes: i64, + /// Dangling `triple-c.managed=true` images — superseded snapshot commits. + pub orphan_image_bytes: i64, + pub orphan_image_count: usize, + /// `triple-c-home-*` / `triple-c-claude-config-*` volumes belonging to no + /// project in the store. Empty — and `orphan_volumes_unavailable` set — + /// when the store could not be read. + pub orphan_volumes: Vec, + pub orphan_volume_bytes: i64, + pub orphan_volumes_unavailable: Option, + pub build_cache: BuildCacheUsage, + /// Daemon-wide totals, for context: the user's unrelated work lives here + /// too, and the per-project rows will not add up to `docker system df`. + pub images_total_bytes: i64, + pub containers_total_bytes: i64, + pub volumes_total_bytes: i64, + /// Sum of the per-project rows — the part of the daemon that is ours. + pub triple_c_total_bytes: i64, + pub host: HostStorage, +} + +/// Build-cache figures, and where they came from. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct BuildCacheUsage { + pub total_bytes: i64, + pub reclaimable_bytes: i64, + /// Bytes a `--filter until=168h` prune would reach. + pub stale_bytes: i64, + /// `"buildx du"` or `"system df"`. + /// + /// **`docker system df` under-reports build-cache reclaimable while + /// `docker buildx du` reports it correctly** (df only counts records with + /// no parent as reclaimable). The buildx figure is preferred whenever the + /// CLI is reachable; the field says which one is on screen so a user + /// comparing against their terminal is not left guessing. + pub source: String, + /// Set when the `docker` CLI could not be run, so `source` fell back. + pub cli_error: Option, +} + +/// A per-project volume whose project id is not in Triple-C's project store. +/// +/// **Not "a volume with no container".** See [`orphan_volumes`] for why that +/// distinction is the whole safety property. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct OrphanVolume { + pub name: String, + /// The project id parsed out of the name. Shown, because a user who + /// recognises it may want to recover it rather than delete it. + pub project_id: String, + pub bytes: i64, + /// `"home"` or `"config"`. The config volume is the one that held Claude + /// credentials and transcripts, so it is worth saying which is which. + pub role: String, + /// When Docker created the volume, from `df()`'s own metadata. + /// + /// Evidence, not bookkeeping: a size and a UUID identify nothing, and this + /// is the only cheap fact that lets a user recognise which project a + /// candidate was before deleting it. It costs no extra call. + /// + /// **Never inspect a volume by mounting it.** `docker run -v :/path` + /// *creates* the volume when it does not exist, so a "just look inside" + /// probe can conjure the very thing it was checking for. Everything shown + /// about a volume here comes from `df()` metadata. + pub created_at: Option, +} + +// --------------------------------------------------------------------------- +// Reclaim targets +// --------------------------------------------------------------------------- + +/// How much trust an action needs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Safety { + /// Already unreachable, or regenerated on demand. One button, no + /// confirmation. + Safe, + /// Reversible in substance but not in time — a rewrite, or a cache the user + /// pays to refill. One clear confirmation. + SemiSafe, +} + +/// Work [`reclaim`] is allowed to do. +/// +/// **This type cannot express a destructive action.** Adding a variant that +/// deletes a live project's data would be the mistake this split exists to +/// prevent; put it on [`DestructiveTarget`] instead. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ReclaimTarget { + /// Dangling `triple-c.managed=true` images that are *not* base images — + /// the superseded snapshot commits every recreation leaves behind. This is + /// `sweep_orphaned_snapshots`, made visible and runnable on demand. + DanglingSnapshots, + /// Dangling `triple-c.managed=true` images that *are* base images + /// (`triple-c.base=true`). Same sweep, reported separately because a user + /// recognises "the old sandbox image" and not "a dangling commit". + SupersededBaseImages, + /// `docker builder prune`. **Daemon-wide, not Triple-C-only.** + BuildCache { + /// `true` prunes everything; `false` filters `until=168h`. + all: bool, + }, + /// `pre-migration-*` tags no migration record claims. Untagged only — the + /// image becomes dangling and the sweep collects it under its own rules. + MigrationPins, + /// `{id}-payload.tar` staging files with no record beside them. These are + /// host files under the user's data dir, **not** inside the daemon's + /// storage — on Windows they sit on `C:` directly rather than in the vhdx, + /// so this is the one bucket that gives space back to `C:` immediately. + MigrationStaging, + /// Containers labelled `triple-c.probe=migration`. + ProbeContainers, + /// `triple-c-scrub-*` containers left by an interrupted secret rewrite. + ScrubContainers, + /// One orphaned volume, ticked individually by name. + OrphanVolume { name: String }, + /// Rewrite a project's stacked commit layers into a single layer. The + /// highest-yield action in this module. + CompactSnapshot { project_id: String }, + /// `rm -rf` the regenerable package caches in a running container's home + /// volume. + ClearCaches { + project_id: String, + /// `~/.rustup/toolchains` — regenerable, but expensive to re-download, + /// so it is a separate tick rather than part of the set. + include_rustup: bool, + }, +} + +/// Work [`destroy`] is allowed to do, one item at a time, against a typed +/// confirmation. Every variant deletes something with no other copy. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum DestructiveTarget { + /// The project's home volume: shell history, dotfiles, installed + /// toolchains, Playwright browsers. + HomeVolume { project_id: String }, + /// The project's config volume: **Claude credentials, plugins and every + /// conversation transcript**. + ConfigVolume { project_id: String }, + /// The project's snapshot image: every package the agent ever installed. + /// The project falls back to the base image on its next start. + SnapshotImage { project_id: String }, + /// A rollback pin whose migration is still awaiting confirmation — the only + /// copy of that migration's rollback target. + RollbackPin { project_id: String, tag: String }, +} + +impl ReclaimTarget { + /// How much confirmation the UI must ask for. Pure, and pinned by a test + /// that walks every variant. + pub fn safety(&self) -> Safety { + match self { + // Unreachable already, or a host file nothing refers to. + ReclaimTarget::DanglingSnapshots + | ReclaimTarget::SupersededBaseImages + | ReclaimTarget::MigrationPins + | ReclaimTarget::MigrationStaging + | ReclaimTarget::ProbeContainers + | ReclaimTarget::ScrubContainers + | ReclaimTarget::OrphanVolume { .. } + | ReclaimTarget::BuildCache { .. } => Safety::Safe, + // A rewrite and a cache flush: nothing is lost, but time is. + ReclaimTarget::CompactSnapshot { .. } | ReclaimTarget::ClearCaches { .. } => { + Safety::SemiSafe + } + } + } + + /// Whether the action reaches beyond Triple-C's own objects. + /// + /// Only the build cache does, and the UI has to say so out loud: the same + /// daemon holds the user's unrelated postgres/mysql/site-builder work, and + /// a prune takes their warm cache with ours. + pub fn is_daemon_wide(&self) -> bool { + matches!(self, ReclaimTarget::BuildCache { .. }) + } + + /// The project this acts on, when it acts on one. + pub fn project_id(&self) -> Option<&str> { + match self { + ReclaimTarget::CompactSnapshot { project_id } + | ReclaimTarget::ClearCaches { project_id, .. } => Some(project_id), + _ => None, + } + } +} + +impl DestructiveTarget { + pub fn project_id(&self) -> &str { + match self { + DestructiveTarget::HomeVolume { project_id } + | DestructiveTarget::ConfigVolume { project_id } + | DestructiveTarget::SnapshotImage { project_id } + | DestructiveTarget::RollbackPin { project_id, .. } => project_id, + } + } +} + +/// One offered action, with its measured cost. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ReclaimItem { + pub target: ReclaimTarget, + pub safety: Safety, + pub daemon_wide: bool, + pub label: String, + pub detail: String, + /// Bytes this would free, **measured**, never estimated. + pub bytes: i64, + /// `false` when `bytes` is a bound rather than a measurement — set only by + /// snapshot compaction, whose real yield cannot be known until it runs. + /// The UI must say "up to" whenever this is false. + pub bytes_are_exact: bool, + /// For compaction: the lower bound of the range, when `bytes` is the upper. + pub bytes_floor: Option, + /// Populated when the action cannot run right now (migration in flight, + /// container in the wrong state). The UI disables the tick and shows this. + pub blocked: Option, +} + +/// Everything [`list_reclaimable`] found. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct ReclaimPlan { + pub items: Vec, + /// Destructive per-project objects, surfaced for display only. These are + /// never in `items` and [`reclaim`] cannot act on them. + pub destructive: Vec, + /// Set when the project store could not be read. Orphan detection is + /// suppressed entirely in that case — see [`orphan_volumes`]. + pub store_error: Option, +} + +/// A destructive object, described so the UI can offer it per project. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DestructiveItem { + pub target: DestructiveTarget, + pub project_id: String, + pub project_name: String, + pub label: String, + /// Spelled out in full — this is the copy the confirmation shows. + pub loses: String, + pub bytes: i64, + pub blocked: Option, +} + +/// What actually happened. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct ReclaimOutcome { + pub results: Vec, + /// Sum of the measured `freed_bytes` below. + pub total_freed_bytes: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ReclaimResult { + /// The reclaim target this reports on, or `None` when it reports a + /// [`destroy`]. + /// + /// Deliberately not reused to carry a destructive action: an earlier + /// version returned `OrphanVolume { name }` for a home-volume deletion, + /// which named a volume that was never an orphan and would attribute the + /// outcome to a plan row the user never ticked. `destroyed` carries it + /// instead, and exactly one of the two is ever set. + pub target: Option, + /// The destructive action this reports on, when it is one. + #[serde(default)] + pub destroyed: Option, + pub ok: bool, + /// Bytes actually freed, measured after the fact. + pub freed_bytes: i64, + /// What was projected before the run, for the one action that projects. + pub projected_bytes: Option, + pub message: String, +} + +// --------------------------------------------------------------------------- +// Pure helpers — everything below is unit-tested without a daemon +// --------------------------------------------------------------------------- + +/// Classification of a dangling `triple-c.managed=true` image. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DanglingClass { + /// Built from `container/Dockerfile`, which stamps `triple-c.base=true`. + Base, + /// A superseded `docker commit` from some project's recreation. + SnapshotCommit, +} + +/// Split the dangling managed images into the two buckets the UI shows. +/// +/// The base label is the only thing separating them, and it is reliable for the +/// same reason `triple-c.managed` is: `create_container` writes +/// `triple-c.base` **explicitly empty**, so a container built from a base +/// cannot inherit `true` and have its commit claim to be a base image. +pub fn classify_dangling(labels: &HashMap) -> DanglingClass { + if labels.get(LABEL_BASE).map(String::as_str) == Some("true") { + DanglingClass::Base + } else { + DanglingClass::SnapshotCommit + } +} + +/// A volume as far as orphan detection is concerned. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct VolumeFacts { + pub name: String, + pub bytes: i64, + /// `VolumeUsageData::ref_count` — containers currently referencing it. + /// `-1` means the daemon did not compute it, which is **not** zero. + /// + /// Note what this is *not* used for: a zero ref count is not evidence that + /// a volume is unclaimed. An idle project whose container has been removed + /// has exactly this shape. It is only ever an extra brake on top of the + /// store check. + pub links: i64, + pub created_at: Option, +} + +/// Volumes that look like ours and belong to no project in the store. +/// +/// ## The only authority is the project store +/// +/// From the daemon's side an **idle live project and a deleted one are +/// indistinguishable**. A project that has not been opened for a while has had +/// its container removed and may have no snapshot image either — volumes alone, +/// no container, nothing running. That is the *normal* resting state of a live +/// project, not a signal. +/// +/// This is not hypothetical: the heuristic "no container and no snapshot image +/// means orphaned" was tried against a real project list and flagged two live +/// projects whose volumes held `.credentials.json`, Claude transcripts and +/// shell history. So nothing in this function looks at containers, images or +/// activity. The test is membership in the project store, and only that. +/// +/// ## Why a store-load failure returns nothing +/// +/// The whole test is "in the store? then live". If the store failed to load, +/// *every* project's volumes look unclaimed — a blanket delete would wipe the +/// credentials, transcripts and toolchains of every project the user has. So a +/// failure returns an empty set and the caller says why, rather than returning +/// what would look like a very productive reclaim. +/// +/// `links == 0` is required on top of that. A volume with a container attached +/// belongs to something, whatever the store says, and `-1` (not computed) is +/// treated as "attached" for the same reason: unknown is never permission. +pub fn orphan_volumes( + volumes: &[VolumeFacts], + known_project_ids: &HashSet, + store_loaded: bool, +) -> Vec { + if !store_loaded { + return Vec::new(); + } + let mut out = Vec::new(); + for volume in volumes { + let (project_id, role) = match parse_project_volume_name(&volume.name) { + Some(parsed) => parsed, + None => continue, + }; + if known_project_ids.contains(project_id) { + continue; + } + if volume.links != 0 { + continue; + } + out.push(OrphanVolume { + name: volume.name.clone(), + project_id: project_id.to_string(), + bytes: volume.bytes.max(0), + role: role.to_string(), + created_at: volume.created_at.clone(), + }); + } + out.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name))); + out +} + +/// Split a project volume name into `(project_id, role)`. +/// +/// Order matters and is not interchangeable: `triple-c-claude-config-` is +/// checked first because `triple-c-home-` does not prefix it, but a future +/// prefix that *does* nest would silently mis-attribute if this were reversed. +pub fn parse_project_volume_name(name: &str) -> Option<(&str, &'static str)> { + if let Some(id) = name.strip_prefix(CONFIG_VOLUME_PREFIX) { + if id.is_empty() { + return None; + } + return Some((id, "config")); + } + if let Some(id) = name.strip_prefix(HOME_VOLUME_PREFIX) { + if id.is_empty() { + return None; + } + return Some((id, "home")); + } + None +} + +/// What a snapshot's layer stack looks like relative to its base. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct LayerStats { + /// Layers stacked above the base image — one per recreation. + pub commit_layers: u32, + /// Bytes those layers account for, or `None` when the base is unknown. + pub above_base_bytes: Option, +} + +/// Work out how much of a snapshot is stacked commits. +/// +/// `Docker::image_history` returns entries **newest first**, and a snapshot's +/// history is its base's history with the commits appended — so the base is the +/// tail, and the commits are the first `len - base_len` entries. Comparing +/// lengths rather than layer digests keeps this a pure function over two +/// vectors of sizes, and is exactly as accurate: the base is by construction a +/// prefix of the snapshot's chain. +/// +/// `base_history_len` is `None` when the base image is no longer on the daemon. +/// In that case the count falls back to "layers that carry bytes", which is an +/// honest upper bound on the commits, and the byte split is reported as unknown +/// rather than guessed. +pub fn layer_stats(snapshot_history_sizes: &[i64], base_history_len: Option) -> LayerStats { + match base_history_len { + Some(base_len) if base_len <= snapshot_history_sizes.len() => { + let commits = &snapshot_history_sizes[..snapshot_history_sizes.len() - base_len]; + LayerStats { + commit_layers: commits.len() as u32, + above_base_bytes: Some(commits.iter().sum()), + } + } + // Either no base, or a base longer than the snapshot's own history, + // which means they are not in the same lineage at all. + _ => LayerStats { + commit_layers: snapshot_history_sizes.iter().filter(|s| **s > 0).count() as u32, + above_base_bytes: None, + }, + } +} + +/// The range a compaction can land in, given the stacked layers it will merge. +/// +/// Nothing can measure the real figure in advance: it depends on how much of +/// each layer is superseded by a later one, which is exactly what flattening +/// discovers. But it is bounded, and both bounds are computable: +/// +/// * **Floor: zero.** Every byte may still be live, in which case flattening +/// frees nothing — and can even cost a little, since the merged layer +/// recompresses independently. Verified on a synthetic stack with nothing +/// superseded: 29.8 MB → 30.8 MB. +/// * **Ceiling: everything but the largest layer.** The result cannot be +/// smaller than the biggest single layer's worth of content, so at most +/// `sum - max` is superseded. +/// +/// Reporting a range is why [`ReclaimItem::bytes_are_exact`] exists. A single +/// invented number here would be the one place in this module that shows a +/// guess as if it were a measurement. +pub fn compaction_bounds(commit_layer_sizes: &[i64]) -> (i64, i64) { + let sum: i64 = commit_layer_sizes.iter().filter(|s| **s > 0).sum(); + let max = commit_layer_sizes.iter().copied().max().unwrap_or(0).max(0); + (0, (sum - max).max(0)) +} + +/// Bytes a `docker builder prune --filter until={hours}h` would reach. +/// +/// Records still in use are never reclaimable however old they are, and a +/// record the daemon gave no `last_used_at` is treated as too young to touch — +/// unknown is not permission here either. +pub fn stale_build_cache_bytes( + entries: &[BuildCacheFacts], + until_hours: i64, + now: chrono::DateTime, +) -> i64 { + let cutoff = now - chrono::Duration::hours(until_hours); + entries + .iter() + .filter(|e| !e.in_use) + .filter(|e| e.last_used_at.map(|t| t < cutoff).unwrap_or(false)) + .map(|e| e.size.max(0)) + .sum() +} + +/// A build-cache record, reduced to what the age filter needs. +#[derive(Debug, Clone, PartialEq)] +pub struct BuildCacheFacts { + pub size: i64, + pub in_use: bool, + pub last_used_at: Option>, +} + +impl From<&BuildCache> for BuildCacheFacts { + fn from(entry: &BuildCache) -> Self { + BuildCacheFacts { + size: entry.size.unwrap_or(0), + in_use: entry.in_use.unwrap_or(false), + last_used_at: entry.last_used_at.as_ref().and_then(parse_bollard_date), + } + } +} + +/// bollard's `BollardDate` is a `chrono` type behind a feature flag and a +/// string otherwise; going through its `Display` keeps this working either way +/// without pinning the feature. +fn parse_bollard_date(date: &bollard::models::BollardDate) -> Option> { + chrono::DateTime::parse_from_rfc3339(&date.to_string()) + .ok() + .map(|d| d.with_timezone(&chrono::Utc)) +} + +/// Parse a size the `docker` CLI printed, e.g. `"46.88GB"`, `"0B"`, `"1.5kB"`. +/// +/// Docker formats these with `units.HumanSize`, which is **base 1000**, not +/// 1024 — using 1024 here would overstate a 28 GB build cache by ~7%. Returns +/// `None` for anything unrecognised so a CLI output change degrades to "fall +/// back to `df()`" rather than to a wrong number. +pub fn parse_docker_size(raw: &str) -> Option { + let raw = raw.trim(); + let split = raw.find(|c: char| c.is_ascii_alphabetic())?; + let (number, unit) = raw.split_at(split); + let value: f64 = number.trim().parse().ok()?; + // Docker never prints a negative size, and a negative `freed_bytes` reaching + // the UI would subtract from the running total. Fail rather than propagate. + if !value.is_finite() || value < 0.0 { + return None; + } + let multiplier: f64 = match unit.trim() { + "B" => 1.0, + "kB" | "KB" => 1e3, + "MB" => 1e6, + "GB" => 1e9, + "TB" => 1e12, + "PB" => 1e15, + _ => return None, + }; + Some((value * multiplier) as i64) +} + +/// Pull `Reclaimable:` and `Total:` out of `docker buildx du`'s trailing +/// summary. Returns `(total, reclaimable)`. +pub fn parse_buildx_du(output: &str) -> Option<(i64, i64)> { + let mut total = None; + let mut reclaimable = None; + for line in output.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("Reclaimable:") { + reclaimable = parse_docker_size(rest); + } else if let Some(rest) = line.strip_prefix("Total:") { + total = parse_docker_size(rest); + } + } + Some((total?, reclaimable.unwrap_or(0))) +} + +/// Pull the figure a prune reports out of its output. +/// +/// **Two different wordings, and `builder prune` uses the less obvious one.** +/// Verified against Docker 29.7.2: `docker builder prune` ends with a bare +/// `Total:\t20.59MB`, while `docker system prune` and `docker image prune` end +/// with `Total reclaimed space: 20.59MB`. A parser that only knew the second +/// form would silently report every build-cache prune as having freed nothing — +/// which is exactly what the first draft of this did. +/// +/// The scan is last-line-first so the summary wins over any record line that +/// happens to contain the word. +pub fn parse_reclaimed_space(output: &str) -> i64 { + output + .lines() + .rev() + .find_map(|line| { + let line = line.trim(); + let rest = line + .strip_prefix("Total reclaimed space:") + .or_else(|| line.strip_prefix("Total:"))?; + parse_docker_size(rest) + }) + .unwrap_or(0) +} + +/// The Dockerfile that flattens a snapshot into a single layer. +/// +/// ## Why a build and not `docker commit --squash` or export/import +/// +/// `commit` cannot squash — squashing is the one thing it does not do, and it +/// is why the stack grows. `--squash` on the classic builder needs an +/// experimental daemon. `docker export | docker import` moves the whole +/// filesystem through this process, and bollard's import takes a fully +/// buffered `Bytes` — a 12 GB image in RAM. +/// +/// A two-stage build keeps every byte inside the daemon. `COPY --from` a whole +/// root into `FROM scratch` collapses the chain to one layer, and it preserves +/// uid/gid and setuid bits — **verified against Docker 29.7.2**: a 192.6 MB / +/// 4-layer synthetic came out 45.7 MB / 1 layer with `-rwsr-xr-x root` and +/// `uid 1000` intact, through the plain `POST /build` endpoint bollard uses +/// (no BuildKit session). +/// +/// ## Why the scrub runs in the first stage +/// +/// The bytes are only free to drop *before* the layer that captures them is +/// written, and here that layer is the flattened one. Running +/// [`container::snapshot_scrub_script`] in the `src` stage costs a throwaway +/// layer on a stage that is discarded, and reuses the one reviewed path list — +/// `SNAPSHOT_SCRUB_PATHS` — rather than forking a second copy of it, which is +/// the failure mode a list like that invites. +/// +/// The image's config (env, cmd, entrypoint, labels, workdir) does **not** +/// survive `FROM scratch`; it is replayed afterwards by +/// [`restore_image_config`], which is why this function does not try to emit it +/// as Dockerfile instructions. A multi-line `CLAUDE_INSTRUCTIONS` env var alone +/// makes that escaping a bad bet. +/// +/// The one label it *does* emit is `triple-c.managed=true`, and it is not +/// decoration. Everything that cleans up after this build — the discard path +/// when the result is not smaller, the untag after a successful commit — relies +/// on `sweep_orphaned_snapshots` collecting the intermediate, and that sweep +/// filters on `dangling=true` **and** this label. Without it the sweep can +/// never match, and the flattened intermediate is left to whatever `untag_image` +/// happens to delete on its own. +pub fn compaction_dockerfile(snapshot_ref: &str, scrub_script: &str) -> String { + // The scrub script is multi-line shell. `RUN` takes it verbatim only if the + // newlines are escaped, so it is folded onto one line with `;` separators — + // the script is already a sequence of statements and a `for` loop, both of + // which survive that. + let folded = fold_shell_script(scrub_script); + format!( + "FROM {snapshot_ref} AS src\n\ + RUN {folded}\n\ + FROM scratch\n\ + COPY --from=src / /\n\ + LABEL {LABEL_MANAGED}=true\n" + ) +} + +/// Collapse a multi-line `/bin/sh` program into a single `RUN` line. +/// +/// Blank lines go; every other line is joined with a space. The script's own +/// syntax already terminates its statements (`;` inside the `for`, newlines +/// after each simple command are not load-bearing because each line here is a +/// complete word sequence), so this is a join and not a rewrite — but it is +/// pinned by a test against the real script for exactly that reason. +fn fold_shell_script(script: &str) -> String { + script + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" ") +} + +/// The `rm -rf` program that clears a project's regenerable package caches. +/// +/// Every path here is a cache a tool refills on its next run. None of them is +/// user data, none is a bind mount (all live under `$HOME`, i.e. the project's +/// own home volume), and none overlaps `SNAPSHOT_SCRUB_PATHS` — that list +/// covers `/tmp` and `/var` debris in the *writable layer*, this one covers the +/// *volume*, and the two never see the same bytes. +/// +/// Measured on one 8.4 GB home volume: ~6 GB. `~/go/pkg/mod` is read-only by +/// default, so it needs `chmod` before `rm` can touch it — `go clean -modcache` +/// is the supported route and is tried first. +/// +/// `~/.rustup/toolchains` is behind `include_rustup` rather than in the set: it +/// is just as regenerable, but a re-download is hundreds of megabytes over the +/// network rather than a rebuild from a local cache. +/// +/// Playwright is the one entry that is *not* a blanket delete. The current +/// revision is what an installed browser resolves to; deleting it turns a +/// working browser-view project into one that downloads 400 MB on next use. So +/// only the revisions that are not the newest are dropped. +pub fn cache_clear_script(include_rustup: bool) -> String { + let mut paths: Vec<&str> = vec![ + "$HOME/.npm/_cacache", + "$HOME/.npm/_npx", + "$HOME/.cache/go-build", + "$HOME/.cache/pip", + "$HOME/.cache/uv", + "$HOME/.cache/act", + "$HOME/.cache/chrome-devtools-mcp", + ]; + if include_rustup { + paths.push("$HOME/.rustup/toolchains"); + } + + // `du -sb` before each `rm` so the reported total is measured rather than + // inferred from a df() delta, which a concurrently running agent would + // corrupt. + let mut script = String::from("total=0\n"); + for path in &paths { + script.push_str(&format!( + "if [ -e \"{path}\" ]; then sz=$(du -sb \"{path}\" 2>/dev/null | cut -f1); \ + case \"$sz\" in ''|*[!0-9]*) sz=0 ;; esac; \ + rm -rf -- \"{path}\" 2>/dev/null && total=$((total + sz)); fi\n" + )); + } + + // Go's module cache is written read-only; `go clean -modcache` handles that + // properly, and the chmod fallback covers a container with no Go on PATH. + script.push_str( + "if [ -d \"$HOME/go/pkg/mod\" ]; then \ + sz=$(du -sb \"$HOME/go/pkg/mod\" 2>/dev/null | cut -f1); \ + case \"$sz\" in ''|*[!0-9]*) sz=0 ;; esac; \ + (command -v go >/dev/null 2>&1 && go clean -modcache 2>/dev/null) || \ + (chmod -R u+w \"$HOME/go/pkg/mod\" 2>/dev/null; rm -rf -- \"$HOME/go/pkg/mod\" 2>/dev/null); \ + [ -d \"$HOME/go/pkg/mod\" ] || total=$((total + sz)); fi\n", + ); + + // Playwright: keep the newest chromium revision, drop the rest. `ls -1` + // sorts lexically, which is right here because the revision suffix is a + // fixed-width zero-padded number. + script.push_str( + "if [ -d \"$HOME/.cache/ms-playwright\" ]; then \ + keep=$(ls -1 \"$HOME/.cache/ms-playwright\" 2>/dev/null | grep '^chromium' | sort | tail -n 1); \ + for d in \"$HOME/.cache/ms-playwright\"/chromium*; do \ + [ -d \"$d\" ] || continue; \ + [ \"$(basename \"$d\")\" = \"$keep\" ] && continue; \ + sz=$(du -sb \"$d\" 2>/dev/null | cut -f1); \ + case \"$sz\" in ''|*[!0-9]*) sz=0 ;; esac; \ + rm -rf -- \"$d\" 2>/dev/null && total=$((total + sz)); done; fi\n", + ); + + script.push_str(&format!("echo \"{CACHE_MARKER}$total\"\nexit 0\n")); + script +} + +/// Marker the cache-clear script prints so the byte total can be read back out +/// of the exec's interleaved output. Same trick as the snapshot scrub's. +const CACHE_MARKER: &str = "###TRIPLE-C-CACHE-CLEARED "; + +/// Read the byte total back. `None` means the script never reached its final +/// line, which is how a killed exec is told apart from one that freed nothing. +pub fn parse_cache_total(output: &str) -> Option { + output + .lines() + .rev() + .find_map(|line| line.trim().strip_prefix(CACHE_MARKER)?.trim().parse().ok()) +} + +/// Whether a typed confirmation matches the project it claims to. +/// +/// Trimmed, because a trailing space from a paste is not a different intent, +/// but **case-sensitive**: two projects called `Api` and `api` are different +/// projects, and this is the only thing standing between a user and their +/// transcripts. +pub fn confirmation_matches(expected_project_name: &str, typed: &str) -> bool { + !expected_project_name.is_empty() && typed.trim() == expected_project_name.trim() +} + +/// The Windows/WSL2 caveat, in one place so the UI and the logs cannot drift. +/// +/// Docker Desktop keeps the whole daemon inside `ext4.vhdx` under +/// `docker-desktop-data`. That file grows to a high-water mark and **never +/// shrinks on its own**. Everything this module reclaims frees space *inside* +/// the vhdx — real, and it is what stops the file growing further — but `C:` +/// does not change until the disk is compacted. Users who are not told this +/// report it as a bug. +pub const WSL2_VHDX_NOTE: &str = concat!( + "Docker Desktop keeps this daemon inside ext4.vhdx on C:. That file grows to a ", + "high-water mark and never shrinks by itself, so reclaiming here frees space inside ", + "the vhdx — which is what stops it growing — but C: will not change until the disk ", + "is compacted." +); + +/// The two ways to actually shrink the vhdx, in the order to try them. +pub const WSL2_VHDX_FIX: &[&str] = &[ + "wsl --shutdown", + "Optimize-VHD -Path \"$env:LOCALAPPDATA\\Docker\\wsl\\disk\\docker_data.vhdx\" -Mode Full", +]; + +/// The GUI route, for users without Hyper-V's `Optimize-VHD`. +pub const WSL2_VHDX_FIX_GUI: &str = + "Docker Desktop → Settings → Resources → Advanced → Clean up / Purge data"; + +/// Whether the vhdx caveat applies to this host. +/// +/// Both halves are required. Docker Desktop on macOS has the same +/// never-shrinks property but a different file and a different fix, and a +/// Windows host talking to a remote or native daemon has neither. +pub fn vhdx_applies(is_windows_host: bool, operating_system: &str) -> bool { + is_windows_host && is_docker_desktop(operating_system) +} + +/// Docker Desktop reports exactly `"Docker Desktop"` in `docker info`'s +/// `OperatingSystem` on every platform it ships for. Matched loosely so a +/// future suffix does not silently flip the answer — the same test +/// `docker/gateway.rs` already makes. +pub fn is_docker_desktop(operating_system: &str) -> bool { + operating_system.to_ascii_lowercase().contains("docker desktop") +} + +/// Whether the project store can be trusted to say which volumes are live. +/// +/// ## Why "the list is empty" is not the same as "there are no projects" +/// +/// `ProjectsStore::new()` treats an unparseable `projects.json` as recoverable: +/// it copies the file to `.bak`, **starts with an empty list**, and the app runs +/// normally. That is the right call for the app — and it is catastrophic for +/// orphan detection, because in that state every live project's home and config +/// volume looks unclaimed. Deleting them would take the user's credentials, +/// transcripts and toolchains for every project they have. +/// +/// So an empty list is only believed when the file is *also* absent, which is +/// the genuine fresh-install case and the one where there is nothing on the +/// daemon to mis-attribute anyway. Anything else returns the reason, and orphan +/// detection is suppressed rather than run optimistically. +pub fn project_store_trust( + projects: &[Project], + json_exists: bool, + json_parsed: bool, +) -> Result, String> { + if !json_parsed { + return Err( + "projects.json could not be read, so there is no way to tell an orphaned volume from a \ + live project's. Nothing is listed here until it can be." + .to_string(), + ); + } + if projects.is_empty() && json_exists { + return Err( + "The project list loaded empty from a projects.json that exists, which is what a \ + recovered-from-corrupt store looks like. Orphan detection is suppressed rather than \ + treat every project's volumes as unclaimed." + .to_string(), + ); + } + Ok(projects.iter().map(|p| p.id.clone()).collect()) +} + +/// Re-read `projects.json` from disk to answer [`project_store_trust`]'s two +/// questions. The in-memory store cannot answer them: by the time it is +/// consulted, a corrupt file has already been swallowed into an empty list. +fn projects_json_health() -> (bool, bool) { + let Some(path) = dirs::data_dir().map(|d| d.join("triple-c").join("projects.json")) else { + return (false, false); + }; + if !path.exists() { + return (false, true); + } + let parsed = std::fs::read_to_string(&path) + .ok() + .and_then(|data| serde_json::from_str::>(&data).ok()) + .is_some(); + (true, parsed) +} + +// --------------------------------------------------------------------------- +// Scan +// --------------------------------------------------------------------------- + +/// Names of the base images a project can be built from, for the globals block. +/// +/// Deletion never keys off this list — that stays on `dangling` + +/// `triple-c.managed` + `triple-c.base`, exactly as the sweep does. This is for +/// *display*: a base image pulled before `container/Dockerfile` grew its +/// `LABEL` lines carries neither label, and a globals block that could not name +/// the 4.7 GB image every project sits on would be missing the obvious. +fn is_base_image_reference(reference: &str) -> bool { + // Split on the *tag*, not the first colon: `localhost:5000/triple-c-sandbox:latest` + // has a registry port, and splitting on the first colon would yield + // `localhost`. A tag never contains `/`, which is what tells the two apart. + let repo = match reference.rsplit_once(':') { + Some((repo, tag)) if !tag.contains('/') => repo, + _ => reference, + }; + repo == "triple-c" + || repo.ends_with("/triple-c-sandbox") + || repo == "triple-c-sandbox" +} + +/// One `df()`, one `info()`, and one `image_history` per distinct image, joined +/// against the project store. +/// +/// Expensive on purpose — see the module docs. Everything the UI needs comes +/// out of this single call so a user never pays for it twice by accident. +pub async fn scan(projects: &[Project]) -> Result { + let docker = get_docker()?; + + let usage = docker + .df() + .await + .map_err(|e| format!("Could not read Docker disk usage: {}", e))?; + let images = usage.images.unwrap_or_default(); + let containers = usage.containers.unwrap_or_default(); + let volumes = usage.volumes.unwrap_or_default(); + let build_cache_records = usage.build_cache.unwrap_or_default(); + + let info = docker.info().await.ok(); + let operating_system = info + .as_ref() + .and_then(|i| i.operating_system.clone()) + .unwrap_or_default(); + let applies = vhdx_applies(cfg!(target_os = "windows"), &operating_system); + let host = HostStorage { + docker_root_dir: info + .as_ref() + .and_then(|i| i.docker_root_dir.clone()) + .unwrap_or_default(), + is_docker_desktop: is_docker_desktop(&operating_system), + is_windows_host: cfg!(target_os = "windows"), + vhdx_applies: applies, + vhdx_note: if applies { WSL2_VHDX_NOTE.to_string() } else { String::new() }, + vhdx_fix: if applies { + WSL2_VHDX_FIX.iter().map(|s| (*s).to_string()).collect() + } else { + Vec::new() + }, + vhdx_fix_gui: if applies { WSL2_VHDX_FIX_GUI.to_string() } else { String::new() }, + operating_system, + }; + + // Index by tag and by name so the per-project join is a lookup rather than a + // scan per project — 123 images × 8 projects is otherwise 1,000 comparisons + // for no reason. + let mut image_by_tag: HashMap<&str, &ImageSummary> = HashMap::new(); + for image in &images { + for tag in &image.repo_tags { + image_by_tag.insert(tag.as_str(), image); + } + } + let mut container_by_name: HashMap = HashMap::new(); + for container in &containers { + for name in container.names.as_deref().unwrap_or(&[]) { + // Docker prefixes every name with `/`. + container_by_name.insert(name.trim_start_matches('/').to_string(), container); + } + } + let volume_by_name: HashMap<&str, &Volume> = + volumes.iter().map(|v| (v.name.as_str(), v)).collect(); + + // `image_history` is one round trip per image, so base histories are shared + // across every project on the same base — which is all of them, normally. + let mut base_history_len: HashMap> = HashMap::new(); + + let mut rows = Vec::new(); + for project in projects { + let snapshot_image = get_snapshot_image_name(project); + let snapshot = image_by_tag.get(snapshot_image.as_str()).copied(); + + let (snapshot_bytes, snapshot_shared_bytes) = match snapshot { + Some(image) => (image.size, image.shared_size.max(0)), + None => (0, 0), + }; + + let mut stats = LayerStats::default(); + let mut base_lineage_known = false; + if let Some(image) = snapshot { + let history = docker + .image_history(&image.id) + .await + .map(|entries| entries.into_iter().map(|e| e.size).collect::>()) + .unwrap_or_default(); + + // The lineage label names the base by image id, which survives the + // base being retagged. `triple-c.create-image` does not work here: + // on a project that has been recreated it names the project's own + // snapshot, not the base. + let base_ref = image + .labels + .get(migration::LABEL_BASE_IMAGE_ID) + .filter(|id| !id.is_empty()) + .cloned(); + let base_len = match base_ref { + Some(base_ref) => match base_history_len.get(&base_ref) { + Some(cached) => *cached, + None => { + let len = docker + .image_history(&base_ref) + .await + .ok() + .map(|entries| entries.len()); + base_history_len.insert(base_ref, len); + len + } + }, + None => None, + }; + base_lineage_known = base_len.is_some(); + stats = layer_stats(&history, base_len); + } + + let container = container_by_name.get(&project.container_name()).copied(); + let home = volume_by_name + .get(home_volume_name(&project.id).as_str()) + .copied(); + let config = volume_by_name + .get(config_volume_name(&project.id).as_str()) + .copied(); + + let home_volume_bytes = home.and_then(volume_bytes).unwrap_or(0); + let config_volume_bytes = config.and_then(volume_bytes).unwrap_or(0); + let container_writable_bytes = container.and_then(|c| c.size_rw).unwrap_or(0).max(0); + + // The snapshot's *unique* bytes, not its total: the base is shared with + // every other project, so counting it per row would show ~4.7 GB of the + // same image once per project and make the column meaningless. + let snapshot_unique = (snapshot_bytes - snapshot_shared_bytes).max(0); + + rows.push(ProjectDiskRow { + project_id: project.id.clone(), + project_name: project.name.clone(), + snapshot_image, + snapshot_exists: snapshot.is_some(), + snapshot_bytes, + snapshot_shared_bytes, + snapshot_commit_layers: stats.commit_layers, + base_lineage_known, + // Prefer the daemon's own measurement of what is unique to this + // image over layer arithmetic; fall back to the layer sum when + // `df()` did not compute a shared size. + snapshot_above_base_bytes: if snapshot_shared_bytes > 0 { + Some(snapshot_unique) + } else { + stats.above_base_bytes + }, + container_exists: container.is_some(), + container_running: container.and_then(|c| c.state.as_deref()) == Some("running"), + container_writable_bytes, + home_volume_bytes, + home_volume_present: home.is_some(), + config_volume_bytes, + config_volume_present: config.is_some(), + total_bytes: snapshot_unique + + container_writable_bytes + + home_volume_bytes + + config_volume_bytes, + migrating: crate::commands::migration_commands::is_migrating(&project.id), + }); + } + rows.sort_by(|a, b| b.total_bytes.cmp(&a.total_bytes)); + + // Globals. + let mut base_images = Vec::new(); + let mut orphan_image_bytes = 0i64; + let mut orphan_image_count = 0usize; + for image in &images { + let managed = image.labels.get(LABEL_MANAGED).map(String::as_str) == Some("true"); + let labelled_base = image.labels.get(LABEL_BASE).map(String::as_str) == Some("true"); + if image.repo_tags.is_empty() || image.repo_tags.iter().all(|t| t == ":") { + if managed { + orphan_image_bytes += (image.size - image.shared_size.max(0)).max(0); + orphan_image_count += 1; + } + continue; + } + // One row per *image*, not per tag. A base carrying both + // `triple-c-sandbox:latest` and `ghcr.io/shadowdao/triple-c-sandbox:latest` + // is one 4.7 GB image, and pushing a row per tag would count it twice + // in the total below. + let matching: Vec<&String> = image + .repo_tags + .iter() + .filter(|tag| labelled_base || is_base_image_reference(tag)) + .collect(); + if let Some(primary) = matching.first() { + base_images.push(BaseImageRow { + reference: if matching.len() > 1 { + // Name the aliases rather than hiding them; a user looking + // for "the sandbox image" should find it under whichever + // name they know it by. + matching + .iter() + .map(|tag| tag.as_str()) + .collect::>() + .join(", ") + } else { + (*primary).clone() + }, + bytes: image.size, + shared_bytes: image.shared_size.max(0), + containers: image.containers, + is_labelled_base: labelled_base, + }); + } + } + base_images.sort_by(|a, b| b.bytes.cmp(&a.bytes)); + // Full size per base, not `size - shared_size`: a base's shared bytes are + // shared with *its own snapshots*, so netting them out would report the + // 4.7 GB image every project sits on as ~0. The residual imprecision is two + // *different* bases that share lower layers with each other, whose common + // layers are counted twice here — worth knowing before treating this total + // as exact. + let base_images_bytes = base_images.iter().map(|b| b.bytes).sum(); + + let (json_exists, json_parsed) = projects_json_health(); + let (orphan_volumes_list, orphan_volumes_unavailable) = + match project_store_trust(projects, json_exists, json_parsed) { + Ok(known) => { + let facts: Vec = volumes.iter().map(volume_facts).collect(); + (orphan_volumes(&facts, &known, true), None) + } + Err(reason) => (Vec::new(), Some(reason)), + }; + let orphan_volume_bytes = orphan_volumes_list.iter().map(|v| v.bytes).sum(); + + let build_cache = build_cache_usage(&build_cache_records).await; + + let triple_c_total_bytes = rows.iter().map(|r| r.total_bytes).sum::() + + base_images_bytes + + orphan_image_bytes + + orphan_volume_bytes; + + Ok(DiskUsageReport { + scanned_at: chrono::Utc::now().to_rfc3339(), + projects: rows, + base_images, + base_images_bytes, + orphan_image_bytes, + orphan_image_count, + orphan_volumes: orphan_volumes_list, + orphan_volume_bytes, + orphan_volumes_unavailable, + build_cache, + images_total_bytes: images.iter().map(|i| i.size).sum(), + containers_total_bytes: containers + .iter() + .map(|c| c.size_rw.unwrap_or(0).max(0)) + .sum(), + volumes_total_bytes: volumes.iter().filter_map(volume_bytes).sum(), + triple_c_total_bytes, + host, + }) +} + +fn volume_bytes(volume: &Volume) -> Option { + volume + .usage_data + .as_ref() + .map(|u| u.size) + .filter(|size| *size >= 0) +} + +fn volume_facts(volume: &Volume) -> VolumeFacts { + VolumeFacts { + name: volume.name.clone(), + bytes: volume.usage_data.as_ref().map(|u| u.size).unwrap_or(0), + // A daemon that did not compute the ref count reports `-1`, and that is + // deliberately *not* folded into 0 — `orphan_volumes` requires exactly + // zero, so unknown fails closed. + links: volume.usage_data.as_ref().map(|u| u.ref_count).unwrap_or(-1), + created_at: volume.created_at.as_ref().map(|d| d.to_string()), + } +} + +/// Build-cache figures, preferring `docker buildx du` over `df()`. +/// +/// `docker system df` counts only records with no parent as reclaimable, which +/// under-reports a real build tree badly; `buildx du` reports it correctly. The +/// CLI is not a hard dependency though — it ships with Docker Desktop and every +/// normal engine install, but a daemon reached over TCP might have no local +/// binary at all, so a failure falls back to the `df()` numbers and says so. +async fn build_cache_usage(records: &[BuildCache]) -> BuildCacheUsage { + let facts: Vec = records.iter().map(BuildCacheFacts::from).collect(); + let df_total: i64 = facts.iter().map(|f| f.size.max(0)).sum(); + let df_reclaimable: i64 = facts + .iter() + .filter(|f| !f.in_use) + .map(|f| f.size.max(0)) + .sum(); + let stale_bytes = stale_build_cache_bytes( + &facts, + BUILD_CACHE_DEFAULT_UNTIL_HOURS, + chrono::Utc::now(), + ); + + match docker_cli(&["buildx", "du"]).await { + Ok(output) => match parse_buildx_du(&output) { + Some((total, reclaimable)) => BuildCacheUsage { + total_bytes: total, + reclaimable_bytes: reclaimable, + // The age split only exists in `df()`'s records, so it is kept + // from there even when the totals come from buildx. It is a + // lower bound against the buildx total, which is the safe + // direction for a number attached to a prune button. + stale_bytes: stale_bytes.min(reclaimable), + source: "buildx du".to_string(), + cli_error: None, + }, + None => BuildCacheUsage { + total_bytes: df_total, + reclaimable_bytes: df_reclaimable, + stale_bytes, + source: "system df".to_string(), + cli_error: Some("`docker buildx du` output could not be parsed".to_string()), + }, + }, + Err(e) => BuildCacheUsage { + total_bytes: df_total, + reclaimable_bytes: df_reclaimable, + stale_bytes, + source: "system df".to_string(), + cli_error: Some(e), + }, + } +} + +/// Run the `docker` CLI and return its stdout. +/// +/// Used for exactly one thing: the build cache. bollard 0.18 has no wrapper for +/// `POST /build/prune` and no `buildx du` equivalent — the endpoint simply is +/// not in the crate — and adding a second HTTP client to reach a unix socket +/// (or a Windows named pipe) for one endpoint is a worse trade than shelling +/// out, which `commands/aws_commands.rs` already does for the AWS CLI. +async fn docker_cli(args: &[&str]) -> Result { + let output = tokio::process::Command::new("docker") + .args(args) + .output() + .await + .map_err(|e| format!("Could not run `docker {}`: {}", args.join(" "), e))?; + if !output.status.success() { + return Err(format!( + "`docker {}` failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +// --------------------------------------------------------------------------- +// Planning +// --------------------------------------------------------------------------- + +/// Everything that could be reclaimed, with the bytes measured rather than +/// guessed. +/// +/// Takes the already-computed [`DiskUsageReport`] so a plan costs no second +/// `df()` — the UI scans once and then plans, re-plans and re-plans again off +/// the same measurement. +pub async fn list_reclaimable( + projects: &[Project], + report: &DiskUsageReport, +) -> Result { + let docker = get_docker()?; + let mut items = Vec::new(); + + // --- Dangling managed images, split by whether they are bases ----------- + // + // Both come from the same two conditions the startup sweep runs under — + // dangling *and* `triple-c.managed=true` — and they are mutually exclusive + // because `triple-c.base` is only ever `true` on an image built from + // `container/Dockerfile`. + let dangling = docker + .list_images(Some(ListImagesOptions { + all: false, + filters: HashMap::from([ + ("dangling".to_string(), vec!["true".to_string()]), + ("label".to_string(), vec![format!("{}=true", LABEL_MANAGED)]), + ]), + ..Default::default() + })) + .await + .map_err(|e| format!("Could not list orphaned images: {}", e))?; + + let mut commit_bytes = 0i64; + let mut commit_count = 0usize; + let mut base_bytes = 0i64; + let mut base_count = 0usize; + for image in &dangling { + // `list_images` leaves `shared_size` at -1, so the scan's own figure is + // the measured one; this loop only needs the split by class. + match classify_dangling(&image.labels) { + DanglingClass::Base => { + base_bytes += image.size; + base_count += 1; + } + DanglingClass::SnapshotCommit => { + commit_bytes += image.size; + commit_count += 1; + } + } + } + + if commit_count > 0 { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::DanglingSnapshots; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: format!("Superseded snapshot layers ({} images)", commit_count), + detail: "Untagged images left behind by past container recreations. Nothing can \ + start from them and no project refers to them." + .to_string(), + bytes: commit_bytes, + bytes_are_exact: true, + bytes_floor: None, + blocked: None, + } + }); + } + if base_count > 0 { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::SupersededBaseImages; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: format!("Superseded base images ({} images)", base_count), + detail: "Older Triple-C sandbox images that a newer build or pull replaced. Docker \ + refuses to remove one that a stopped project still needs, so a project \ + that has not been migrated keeps its own." + .to_string(), + bytes: base_bytes, + bytes_are_exact: true, + bytes_floor: None, + blocked: None, + } + }); + } + + // --- Build cache -------------------------------------------------------- + if report.build_cache.total_bytes > 0 { + if report.build_cache.stale_bytes > 0 { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::BuildCache { all: false }; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: "Build cache older than 7 days".to_string(), + detail: "Docker's BuildKit cache, for the WHOLE daemon — not just Triple-C. \ + Anything else you build here loses its warm cache too and rebuilds \ + from scratch once." + .to_string(), + bytes: report.build_cache.stale_bytes, + bytes_are_exact: true, + bytes_floor: None, + blocked: None, + } + }); + } + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::BuildCache { all: true }; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: "Build cache, all of it".to_string(), + detail: "Every reclaimable BuildKit record on the daemon, at any age. Same \ + daemon-wide caveat, with nothing held back." + .to_string(), + bytes: report.build_cache.reclaimable_bytes, + bytes_are_exact: true, + bytes_floor: None, + blocked: None, + } + }); + } + + // --- Migration leftovers ------------------------------------------------ + let (pin_bytes, pin_count, live_pins) = survey_rollback_pins(projects).await; + if pin_count > 0 { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::MigrationPins; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: format!("Ownerless rollback pins ({} images)", pin_count), + detail: "`pre-migration-*` tags whose migration record is gone, so nothing can roll \ + back to them. Untagged here; the image is then collected as a superseded \ + snapshot layer under the same rules." + .to_string(), + bytes: pin_bytes, + bytes_are_exact: true, + bytes_floor: None, + blocked: None, + } + }); + } + + let staging_bytes = survey_migration_staging(projects); + if staging_bytes > 0 { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::MigrationStaging; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: "Migration staging files".to_string(), + detail: "Half-finished `*-payload.tar` files with no migration record beside them. \ + These are ordinary files in your data directory, not Docker storage — on \ + Windows they are on C: itself, so this is the one item here that gives \ + space back to C: immediately." + .to_string(), + bytes: staging_bytes, + bytes_are_exact: true, + bytes_floor: None, + blocked: None, + } + }); + } + + // --- Leftover throwaway containers -------------------------------------- + let probes = survey_containers_by_filter( + HashMap::from([( + "label".to_string(), + vec![format!( + "{}={}", + migration::LABEL_PROBE, + migration::PROBE_LABEL_MIGRATION + )], + )]), + is_migration_probe, + ) + .await; + if probes.1 > 0 { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::ProbeContainers; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: format!("Migration probe containers ({})", probes.1), + detail: "Throwaway containers a migration used to read a filesystem manifest and \ + did not get to remove." + .to_string(), + bytes: probes.0, + bytes_are_exact: true, + bytes_floor: None, + blocked: None, + } + }); + } + + let scrubs = survey_containers_by_filter( + HashMap::from([("name".to_string(), vec!["triple-c-scrub-".to_string()])]), + is_scrub_container, + ) + .await; + if scrubs.1 > 0 { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::ScrubContainers; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: format!("Secret-scrub scratch containers ({})", scrubs.1), + detail: "`triple-c-scrub-*` containers left by an interrupted rewrite of a snapshot's \ + baked-in environment." + .to_string(), + bytes: scrubs.0, + bytes_are_exact: true, + bytes_floor: None, + blocked: None, + } + }); + } + + // --- Orphaned volumes, one tick each ------------------------------------ + // + // Deliberately not aggregated. Each of these was some project's home or + // `.claude` directory, and the user is the only one who can say whether the + // project it belonged to is really gone. + for volume in &report.orphan_volumes { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::OrphanVolume { + name: volume.name.clone(), + }; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: format!("{} ({} volume)", volume.name, volume.role), + detail: format!( + "Named for project id {}, which is not in Triple-C's project list, and no \ + container is attached to it.{} {}", + volume.project_id, + match &volume.created_at { + Some(created) => format!(" Docker created it on {}.", created), + None => String::new(), + }, + if volume.role == "config" { + "This is a `.claude` volume — it held that project's Claude credential, \ + plugins and session transcripts." + } else { + "This is a home volume — it held that project's dotfiles, shell history and \ + installed toolchains." + } + ), + bytes: volume.bytes, + bytes_are_exact: true, + bytes_floor: None, + blocked: None, + } + }); + } + + // --- Per-project semi-safe work ----------------------------------------- + for row in &report.projects { + let blocked_by_migration = row + .migrating + .then(|| "A base-image migration is in flight for this project.".to_string()); + + // A ceiling of zero means flattening this snapshot cannot come out + // ahead — almost always because its unique delta is smaller than the + // base it would have to re-duplicate. Offering it anyway would be + // offering a loss, so it is simply not in the list. + let ceiling = row + .snapshot_above_base_bytes + .map(|unique| { + compaction_ceiling_for( + unique, + row.snapshot_shared_bytes, + row.snapshot_commit_layers, + ) + }) + .unwrap_or(0); + if row.snapshot_exists + && row.base_lineage_known + && row.snapshot_commit_layers > 1 + && ceiling > 0 + { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::CompactSnapshot { + project_id: row.project_id.clone(), + }; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: format!("Compact {}'s snapshot", row.project_name), + detail: format!( + "{} stacked commit layers are rewritten into one, dropping every byte a \ + later layer already superseded. Nothing installed is lost. The container \ + must be stopped, and the rewrite needs room for a second copy while it \ + runs. Note that the result no longer shares the base image with your other \ + projects — that cost is already subtracted from the figure here, and the \ + rewrite is abandoned if it turns out not to come out ahead.", + row.snapshot_commit_layers + ), + bytes: ceiling, + bytes_are_exact: false, + bytes_floor: Some(0), + blocked: blocked_by_migration.clone().or_else(|| { + row.container_running.then(|| { + "Stop this project's container first — compaction rewrites the image it \ + is running from." + .to_string() + }) + }), + } + }); + } + + if row.container_exists { + for include_rustup in [false, true] { + items.push({ + // Safety and reach are read off the target, never restated: a literal + // here that disagreed with the classifier is exactly the drift this + // module cannot afford. + let target = ReclaimTarget::ClearCaches { + project_id: row.project_id.clone(), + include_rustup, + }; + ReclaimItem { + safety: target.safety(), + daemon_wide: target.is_daemon_wide(), + target, + label: if include_rustup { + format!("Clear {}'s caches, Rust toolchains included", row.project_name) + } else { + format!("Clear {}'s package caches", row.project_name) + }, + detail: if include_rustup { + "Everything below, plus `~/.rustup/toolchains`. Also regenerable, but \ + re-downloading a toolchain is hundreds of megabytes over the network \ + rather than a rebuild from a local cache." + .to_string() + } else { + "npm, npx, pip, uv, Go build and module caches, act, chrome-devtools-mcp, \ + and the Playwright browser revisions that are not the newest. All \ + refilled by the next command that needs them." + .to_string() + }, + // Measured by the script itself, inside the container, + // after the fact — a `df()` delta would be corrupted by + // whatever the agent is doing at the same moment. + bytes: 0, + bytes_are_exact: false, + bytes_floor: Some(0), + blocked: blocked_by_migration.clone().or_else(|| { + (!row.container_running).then(|| { + "Start this project's container first — the caches live in its home \ + volume and are cleared from inside." + .to_string() + }) + }), + } + }); + } + } + } + + // --- Destructive, for display only -------------------------------------- + let mut destructive = Vec::new(); + for row in &report.projects { + let blocked = row + .migrating + .then(|| "A base-image migration is in flight for this project.".to_string()); + if row.home_volume_present { + destructive.push(DestructiveItem { + target: DestructiveTarget::HomeVolume { + project_id: row.project_id.clone(), + }, + project_id: row.project_id.clone(), + project_name: row.project_name.clone(), + label: "Home volume".to_string(), + loses: "Shell history, dotfiles, every toolchain installed under $HOME, and any \ + Playwright browsers. Not recoverable. The project's container is removed \ + too, because a stopped container still holds its volumes open — it is \ + rebuilt from the snapshot on the next start." + .to_string(), + bytes: row.home_volume_bytes, + blocked: blocked.clone(), + }); + } + if row.config_volume_present { + destructive.push(DestructiveItem { + target: DestructiveTarget::ConfigVolume { + project_id: row.project_id.clone(), + }, + project_id: row.project_id.clone(), + project_name: row.project_name.clone(), + label: "Claude config volume".to_string(), + loses: "The Claude login credential, installed plugins and skills, and EVERY \ + conversation transcript for this project. Not recoverable. The project's \ + container is removed too, because a stopped container still holds its \ + volumes open — it is rebuilt from the snapshot on the next start." + .to_string(), + bytes: row.config_volume_bytes, + blocked: blocked.clone(), + }); + } + if row.snapshot_exists { + destructive.push(DestructiveItem { + target: DestructiveTarget::SnapshotImage { + project_id: row.project_id.clone(), + }, + project_id: row.project_id.clone(), + project_name: row.project_name.clone(), + label: "Snapshot image".to_string(), + loses: "Every package the agent ever installed in this project's system layer. \ + The project falls back to the base image next time it starts, and \ + rebuilds from there. Volumes are untouched." + .to_string(), + bytes: row.snapshot_above_base_bytes.unwrap_or(row.snapshot_bytes), + blocked: blocked.clone(), + }); + } + } + for (project_id, project_name, tag, bytes) in live_pins { + destructive.push(DestructiveItem { + target: DestructiveTarget::RollbackPin { + project_id: project_id.clone(), + tag: tag.clone(), + }, + project_id, + project_name, + label: format!("Rollback pin {}", tag), + loses: "The only copy of this project's pre-migration system layer. Its migration is \ + still waiting to be confirmed or rolled back — delete this and rolling back \ + becomes impossible." + .to_string(), + bytes, + blocked: None, + }); + } + destructive.sort_by(|a, b| b.bytes.cmp(&a.bytes)); + + items.sort_by(|a, b| { + // Safe first (that is the one-button group), then by size. Compaction + // ends up beneath the safe list even when it is the biggest number, + // which is the right reading order: try the free wins first. + (a.safety == Safety::SemiSafe) + .cmp(&(b.safety == Safety::SemiSafe)) + .then_with(|| b.bytes.cmp(&a.bytes)) + }); + + Ok(ReclaimPlan { + items, + destructive, + store_error: report.orphan_volumes_unavailable.clone(), + }) +} + +/// Upper bound on a compaction's yield. +/// +/// ## Flattening breaks base-layer sharing, and that is the dominant term +/// +/// `FROM scratch` + `COPY --from` produces an image that shares **nothing**. +/// The base image stays on disk, because every other project is still built +/// from it — so the flattened snapshot now carries its own private copy of all +/// ~4.7 GB of it. That cost is paid whatever the layers held. +/// +/// Measured on a real daemon: eight of ten projects had a unique delta between +/// 0.10 GB and 1.32 GB over a 4.72 GB shared base. Flattening any of those +/// turns a 0.63 GB cost into a ~4.7 GB one — **a net loss of about 4 GB**, on +/// an action sold as reclaiming space. Only the project carrying 8.44 GB across +/// 14 layers was plausibly a win. +/// +/// So the yield is bounded by two independent things and it is the smaller that +/// binds: +/// +/// * **Superseded bytes.** At most everything but the largest layer, which +/// without per-layer sizes to hand is approximated by the even split +/// `unique * (n-1)/n`. See [`compaction_bounds`]. +/// * **What is left after re-duplicating the base**: `unique - shared`. A +/// project whose unique delta is smaller than its shared base can never come +/// out ahead, and this returns zero for it — which keeps it out of the plan +/// entirely rather than offering a loss as a saving. +fn compaction_ceiling_for(unique_bytes: i64, shared_bytes: i64, commit_layers: u32) -> i64 { + if commit_layers < 2 { + return 0; + } + let n = commit_layers as i64; + let unique = unique_bytes.max(0); + let superseded_ceiling = (unique / n) * (n - 1); + // What survives re-duplicating the base. Negative means the flattened image + // would be bigger than what it replaces. + let after_base_penalty = unique - shared_bytes.max(0); + superseded_ceiling.min(after_base_penalty).max(0) +} + +/// Rollback pins, split into the ones nothing claims and the ones a live +/// migration still needs. +/// +/// Returns `(ownerless_bytes, ownerless_count, live_pins)` where each live pin +/// is `(project_id, project_name, tag, bytes)`. +async fn survey_rollback_pins( + projects: &[Project], +) -> (i64, usize, Vec<(String, String, String, i64)>) { + let Ok(docker) = get_docker() else { + return (0, 0, Vec::new()); + }; + let names: HashMap<&str, &str> = projects + .iter() + .map(|p| (p.id.as_str(), p.name.as_str())) + .collect(); + + let images = docker + .list_images(Some(ListImagesOptions { + all: false, + filters: HashMap::from([( + "reference".to_string(), + vec!["triple-c-snapshot-*:pre-migration-*".to_string()], + )]), + ..Default::default() + })) + .await + .unwrap_or_default(); + + let mut ownerless_bytes = 0; + let mut ownerless_count = 0; + let mut live = Vec::new(); + for image in images { + for reference in &image.repo_tags { + let Some((project_id, tag)) = migration::parse_snapshot_reference(reference) else { + continue; + }; + // Filesystem presence, not `load`: a record that cannot be parsed + // must still count as "somebody may want this back". Same rule the + // pin reaper uses, for the same reason. + let has_record = migration_store::has_record(&project_id).unwrap_or(true); + if has_record { + let name = names + .get(project_id.as_str()) + .map(|n| (*n).to_string()) + .unwrap_or_else(|| project_id.clone()); + live.push((project_id, name, tag, image.size)); + } else { + ownerless_bytes += image.size; + ownerless_count += 1; + } + } + } + (ownerless_bytes, ownerless_count, live) +} + +/// Total bytes of `*-payload.tar` staging files no migration record claims. +fn survey_migration_staging(projects: &[Project]) -> i64 { + let Ok(dir) = migration_store::migrations_dir() else { + return 0; + }; + let Ok(entries) = std::fs::read_dir(&dir) else { + return 0; + }; + let mut total = 0i64; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let Some(project_id) = name.strip_suffix("-payload.tar") else { + continue; + }; + // A record beside it means a migration may still be mid-flight and this + // tar is its input. Only ownerless ones are offered. + if migration_store::has_record(project_id).unwrap_or(true) { + continue; + } + // A project the store still knows about, currently migrating, is + // covered by the record check above; this is belt and braces for the + // window where the record has been cleared but the run has not + // unwound. + if projects + .iter() + .any(|p| p.id == project_id && crate::commands::migration_commands::is_migrating(&p.id)) + { + continue; + } + if let Ok(meta) = entry.metadata() { + total += meta.len() as i64; + } + } + total +} + +/// A migration probe: a throwaway container carrying +/// `triple-c.probe=migration`. +/// +/// Re-checked on the summary rather than trusted from the daemon's filter. +/// Docker's `label=k=v` filter is an exact match and would be enough — but +/// "enough" is not the standard for a function that removes containers, and a +/// filter is a string built somewhere else in the file. +fn is_migration_probe(summary: &ContainerSummary) -> bool { + summary + .labels + .as_ref() + .and_then(|labels| labels.get(migration::LABEL_PROBE)) + .map(String::as_str) + == Some(migration::PROBE_LABEL_MIGRATION) +} + +/// A scratch container from an interrupted secret rewrite. +/// +/// **Docker's `name` filter is a substring match**, so it also returns a user's +/// own `my-triple-c-scrub-notes`. The full name is what decides. +fn is_scrub_container(summary: &ContainerSummary) -> bool { + summary + .names + .as_deref() + .unwrap_or(&[]) + .iter() + .any(|name| name.trim_start_matches('/').starts_with("triple-c-scrub-")) +} + +/// `(writable_bytes, count)` for containers matching a filter *and* a predicate +/// re-checked on each summary. See [`is_migration_probe`] and +/// [`is_scrub_container`] for why the second check is not redundant. +async fn survey_containers_by_filter( + filters: HashMap>, + predicate: fn(&ContainerSummary) -> bool, +) -> (i64, usize) { + let Ok(docker) = get_docker() else { + return (0, 0); + }; + let containers = docker + .list_containers(Some(ListContainersOptions { + all: true, + size: true, + filters, + ..Default::default() + })) + .await + .unwrap_or_default(); + let mut bytes = 0; + let mut count = 0; + for container in containers { + if !predicate(&container) { + continue; + } + bytes += container.size_rw.unwrap_or(0).max(0); + count += 1; + } + (bytes, count) +} + +// --------------------------------------------------------------------------- +// Execution +// --------------------------------------------------------------------------- + +/// Run the ticked targets, in order, and report what each one actually freed. +/// +/// One failing target never stops the others: these are independent housekeeping +/// jobs, and a build cache the daemon refuses to prune is no reason to leave a +/// dangling image behind. Every failure becomes a `ReclaimResult { ok: false }` +/// the UI shows beside the item. +pub async fn reclaim(targets: &[ReclaimTarget], projects: &[Project]) -> ReclaimOutcome { + let mut outcome = ReclaimOutcome::default(); + for target in targets { + // Anything that stops, removes or rewrites a project's container or + // image consults `is_migrating` first — the same rule the rest of the + // app obeys. Checked once here so a new project-scoped target cannot + // be added without it; the executors re-check for their own callers. + if let Some(project_id) = target.project_id() { + if crate::commands::migration_commands::is_migrating(project_id) { + let result = failed( + target.clone(), + "A base-image migration is in flight for this project.".to_string(), + ); + outcome.results.push(result); + continue; + } + } + let result = match target { + ReclaimTarget::DanglingSnapshots | ReclaimTarget::SupersededBaseImages => { + reclaim_dangling(target).await + } + ReclaimTarget::BuildCache { all } => reclaim_build_cache(*all).await, + ReclaimTarget::MigrationPins => reclaim_migration_pins().await, + ReclaimTarget::MigrationStaging => reclaim_migration_staging(projects), + ReclaimTarget::ProbeContainers => { + reclaim_containers( + HashMap::from([( + "label".to_string(), + vec![format!( + "{}={}", + migration::LABEL_PROBE, + migration::PROBE_LABEL_MIGRATION + )], + )]), + is_migration_probe, + ReclaimTarget::ProbeContainers, + ) + .await + } + ReclaimTarget::ScrubContainers => { + reclaim_containers( + HashMap::from([("name".to_string(), vec!["triple-c-scrub-".to_string()])]), + is_scrub_container, + ReclaimTarget::ScrubContainers, + ) + .await + } + ReclaimTarget::OrphanVolume { name } => reclaim_orphan_volume(name, projects).await, + ReclaimTarget::CompactSnapshot { project_id } => { + match find_project(projects, project_id) { + Ok(project) => compact_snapshot(project).await, + Err(e) => failed(target.clone(), e), + } + } + ReclaimTarget::ClearCaches { + project_id, + include_rustup, + } => match find_project(projects, project_id) { + Ok(project) => clear_caches(project, *include_rustup).await, + Err(e) => failed(target.clone(), e), + }, + }; + outcome.total_freed_bytes += result.freed_bytes; + outcome.results.push(result); + } + outcome +} + +fn find_project<'a>(projects: &'a [Project], project_id: &str) -> Result<&'a Project, String> { + projects + .iter() + .find(|p| p.id == project_id) + .ok_or_else(|| format!("Project {} is not in the project list", project_id)) +} + +fn failed(target: ReclaimTarget, message: String) -> ReclaimResult { + ReclaimResult { + target: Some(target), + destroyed: None, + ok: false, + freed_bytes: 0, + projected_bytes: None, + message, + } +} + +/// Remove the dangling managed images of one class. +/// +/// `force: false` is load-bearing and is the same choice `sweep_orphaned_ +/// snapshots` documents: Docker refuses with a 409 while any container is still +/// built from an image, including the stopped container of a project that is +/// not running. Forcing would untag that image out from under the project and +/// leave a container that cannot start. Those are counted and left. +async fn reclaim_dangling(target: &ReclaimTarget) -> ReclaimResult { + let want = match target { + ReclaimTarget::SupersededBaseImages => DanglingClass::Base, + _ => DanglingClass::SnapshotCommit, + }; + let docker = match get_docker() { + Ok(d) => d, + Err(e) => return failed(target.clone(), e), + }; + let images = match docker + .list_images(Some(ListImagesOptions { + all: false, + filters: HashMap::from([ + ("dangling".to_string(), vec!["true".to_string()]), + ("label".to_string(), vec![format!("{}=true", LABEL_MANAGED)]), + ]), + ..Default::default() + })) + .await + { + Ok(images) => images, + Err(e) => return failed(target.clone(), format!("Could not list images: {}", e)), + }; + + let mut freed = 0i64; + let mut removed = 0usize; + let mut in_use = 0usize; + let mut errors = 0usize; + for image in images { + if classify_dangling(&image.labels) != want { + continue; + } + match docker + .remove_image( + &image.id, + Some(RemoveImageOptions { + force: false, + noprune: false, + }), + None, + ) + .await + { + Ok(_) => { + freed += image.size; + removed += 1; + } + Err(bollard::errors::Error::DockerResponseServerError { + status_code: 409, .. + }) => in_use += 1, + Err(e) => { + log::warn!("Could not remove dangling image {}: {}", image.id, e); + errors += 1; + } + } + } + + let mut message = format!("Removed {} image(s).", removed); + if in_use > 0 { + message.push_str(&format!( + " {} still had a container built from it and were left alone — start and stop, or \ + recreate, that project and they become collectable.", + in_use + )); + } + if errors > 0 { + message.push_str(&format!(" {} could not be removed; see the log.", errors)); + } + ReclaimResult { + target: Some(target.clone()), + destroyed: None, + ok: errors == 0, + freed_bytes: freed, + projected_bytes: None, + message, + } +} + +/// Prune the BuildKit cache through the `docker` CLI. +/// +/// bollard 0.18 has no wrapper for `POST /build/prune` — see [`docker_cli`]. +/// The freed figure comes from the CLI's own `Total reclaimed space:` line, +/// which is a measurement rather than a re-`df()` that a concurrent build could +/// have moved underneath us. +async fn reclaim_build_cache(all: bool) -> ReclaimResult { + let target = ReclaimTarget::BuildCache { all }; + let filter = format!("until={}h", BUILD_CACHE_DEFAULT_UNTIL_HOURS); + let mut args: Vec<&str> = vec!["builder", "prune", "--force"]; + if all { + args.push("--all"); + } else { + args.push("--filter"); + args.push(&filter); + } + match docker_cli(&args).await { + Ok(output) => ReclaimResult { + target: Some(target), + destroyed: None, + ok: true, + freed_bytes: parse_reclaimed_space(&output), + projected_bytes: None, + message: if all { + "Pruned the whole build cache.".to_string() + } else { + "Pruned build cache records older than 7 days.".to_string() + }, + }, + Err(e) => failed( + target, + format!( + "{e}. Pruning the build cache needs the `docker` command line tool, which the \ + Docker Engine API does not expose an equivalent for." + ), + ), + } +} + +/// Untag the rollback pins nothing claims. +/// +/// Untag only, never `rmi`: dropping the tag makes the image dangling, and it +/// already carries `triple-c.managed=true` because `docker commit` created it, +/// so the sweep collects it under its own two conditions with the daemon's +/// still-in-use refusal in front. Nothing here removes a reachable image. +async fn reclaim_migration_pins() -> ReclaimResult { + let target = ReclaimTarget::MigrationPins; + let docker = match get_docker() { + Ok(d) => d, + Err(e) => return failed(target, e), + }; + let images = match docker + .list_images(Some(ListImagesOptions { + all: false, + filters: HashMap::from([( + "reference".to_string(), + vec!["triple-c-snapshot-*:pre-migration-*".to_string()], + )]), + ..Default::default() + })) + .await + { + Ok(images) => images, + Err(e) => return failed(target, format!("Could not list rollback pins: {}", e)), + }; + + let mut freed = 0i64; + let mut dropped = 0usize; + for image in images { + for reference in &image.repo_tags { + let Some((project_id, _tag)) = migration::parse_snapshot_reference(reference) else { + continue; + }; + if migration_store::has_record(&project_id).unwrap_or(true) { + continue; + } + match migration::untag_image(reference).await { + Ok(()) => { + freed += image.size; + dropped += 1; + } + Err(e) => log::warn!("Could not drop rollback pin {}: {}", reference, e), + } + } + } + + // Untagging alone frees nothing — it only makes the image dangling. The + // sweep that follows is the single thing that actually removed layers, so + // its figure is the only measurement there is. `freed` above is the size of + // what was *untagged*, which the sweep may well have been refused on + // because a stopped container still pins it. + let sweep = container::sweep_orphaned_snapshots().await; + log::info!( + "Dropped {} ownerless rollback pin(s) covering {} bytes; the sweep reclaimed {}", + dropped, + freed, + sweep.reclaimed_bytes + ); + ReclaimResult { + target: Some(target), + destroyed: None, + ok: true, + freed_bytes: sweep.reclaimed_bytes, + projected_bytes: None, + message: format!( + "Dropped {} ownerless pin(s) and swept {} image(s).", + dropped, + sweep.removed.len() + ), + } +} + +/// Delete ownerless `*-payload.tar` staging files. +fn reclaim_migration_staging(projects: &[Project]) -> ReclaimResult { + let target = ReclaimTarget::MigrationStaging; + let dir = match migration_store::migrations_dir() { + Ok(dir) => dir, + Err(e) => return failed(target, e), + }; + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) => return failed(target, format!("Could not read {}: {}", dir.display(), e)), + }; + + let mut freed = 0i64; + let mut removed = 0usize; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let Some(project_id) = name.strip_suffix("-payload.tar") else { + continue; + }; + if migration_store::has_record(project_id).unwrap_or(true) { + continue; + } + if projects + .iter() + .any(|p| p.id == project_id && crate::commands::migration_commands::is_migrating(&p.id)) + { + continue; + } + let size = entry.metadata().map(|m| m.len() as i64).unwrap_or(0); + match std::fs::remove_file(entry.path()) { + Ok(()) => { + freed += size; + removed += 1; + } + Err(e) => log::warn!("Could not remove {}: {}", entry.path().display(), e), + } + } + ReclaimResult { + target: Some(target), + destroyed: None, + ok: true, + freed_bytes: freed, + projected_bytes: None, + message: format!("Removed {} staging file(s).", removed), + } +} + +/// Remove throwaway containers matching a filter *and* a predicate re-checked +/// on each summary. +async fn reclaim_containers( + filters: HashMap>, + predicate: fn(&ContainerSummary) -> bool, + target: ReclaimTarget, +) -> ReclaimResult { + let docker = match get_docker() { + Ok(d) => d, + Err(e) => return failed(target, e), + }; + let containers = match docker + .list_containers(Some(ListContainersOptions { + all: true, + size: true, + filters, + ..Default::default() + })) + .await + { + Ok(containers) => containers, + Err(e) => return failed(target, format!("Could not list containers: {}", e)), + }; + + let mut freed = 0i64; + let mut removed = 0usize; + let mut errors = 0usize; + for summary in containers { + // The daemon's filter is never the only guard on a removal. + if !predicate(&summary) { + continue; + } + let Some(id) = summary.id.as_deref() else { + continue; + }; + match container::remove_container(id).await { + Ok(()) => { + freed += summary.size_rw.unwrap_or(0).max(0); + removed += 1; + } + Err(e) => { + log::warn!("Could not remove container {}: {}", id, e); + errors += 1; + } + } + } + ReclaimResult { + target: Some(target), + destroyed: None, + ok: errors == 0, + freed_bytes: freed, + projected_bytes: None, + message: format!("Removed {} container(s).", removed), + } +} + +/// Remove one orphaned volume, re-checking every safety condition first. +/// +/// The plan that offered this was computed against a `df()` from some seconds +/// ago. A project could have been added since, and a container could have +/// attached. So the name is re-parsed, the store is re-consulted and the live +/// ref count is re-read here — the tick is permission to act, not a promise +/// that the world stood still. +async fn reclaim_orphan_volume(name: &str, projects: &[Project]) -> ReclaimResult { + let target = ReclaimTarget::OrphanVolume { + name: name.to_string(), + }; + let docker = match get_docker() { + Ok(d) => d, + Err(e) => return failed(target, e), + }; + + let (json_exists, json_parsed) = projects_json_health(); + let known = match project_store_trust(projects, json_exists, json_parsed) { + Ok(known) => known, + Err(reason) => return failed(target, reason), + }; + + let usage = match docker.df().await { + Ok(usage) => usage, + Err(e) => return failed(target, format!("Could not re-check volume usage: {}", e)), + }; + let volumes = usage.volumes.unwrap_or_default(); + let facts: Vec = volumes.iter().map(volume_facts).collect(); + let still_orphaned = orphan_volumes(&facts, &known, true) + .into_iter() + .find(|v| v.name == name); + let Some(volume) = still_orphaned else { + return failed( + target, + format!( + "{} now matches a project in your project list, or a container has attached to \ + it, so it is no longer unclaimed. Nothing was removed.", + name + ), + ); + }; + + match docker.remove_volume(name, None).await { + Ok(()) => ReclaimResult { + target: Some(target), + destroyed: None, + ok: true, + freed_bytes: volume.bytes, + projected_bytes: None, + message: format!("Removed volume {}.", name), + }, + Err(e) => failed( + ReclaimTarget::OrphanVolume { + name: name.to_string(), + }, + format!("Could not remove volume {}: {}", name, e), + ), + } +} + +/// Rewrite a project's stacked commit layers into a single layer. +/// +/// ## The sequence, and why each step is where it is +/// +/// 1. **Refuse while a migration is in flight or the container runs.** The same +/// rule everything else that touches a project's image obeys: the window +/// between a migration's `remove_container` and the create that follows +/// looks exactly like "no container", and rewriting `:latest` underneath a +/// running container's image is not something Docker protects you from. +/// 2. **Capture the image config** — env, cmd, entrypoint, labels, workdir. It +/// does not survive `FROM scratch` and has to be replayed. +/// 3. **Build to a temporary tag**, never over `:latest`. If anything below +/// fails, the project's snapshot is exactly as it was. +/// 4. **Compare sizes.** Compaction is not always a win: with nothing +/// superseded, the merged layer can recompress *larger* — measured at 29.8 +/// MB → 30.8 MB on a synthetic stack with no waste in it. A result that is +/// not smaller is discarded and reported as "nothing to reclaim", rather +/// than shipped as an improvement. +/// 5. **Replay the config** by creating a container from the flat image with +/// that config and committing it. `docker commit` bakes the container's +/// config into the image, and it round-trips values a Dockerfile `ENV` could +/// not survive — verified with a multi-line env var and a label containing a +/// double quote. +/// 6. **Move `:latest` last.** Until the final tag, the old snapshot is still +/// what the project starts from, so every failure above self-heals. +/// +/// Nothing here forces a removal, and nothing touches a volume. +pub async fn compact_snapshot(project: &Project) -> ReclaimResult { + let target = ReclaimTarget::CompactSnapshot { + project_id: project.id.clone(), + }; + if crate::commands::migration_commands::is_migrating(&project.id) { + return failed( + target, + "A base-image migration is in flight for this project.".to_string(), + ); + } + + let docker = match get_docker() { + Ok(d) => d, + Err(e) => return failed(target, e), + }; + let snapshot_ref = get_snapshot_image_name(project); + + // The container must be stopped: this rewrites the image it is running + // from, and a running container also means the writable layer holds work + // that has not been committed and would be stranded. + if let Ok(Some(container_id)) = container::find_existing_container(project).await { + if container::is_container_running(&container_id) + .await + .unwrap_or(false) + { + return failed( + target, + "Stop this project's container before compacting its snapshot.".to_string(), + ); + } + } + + let before = match docker.inspect_image(&snapshot_ref).await { + Ok(image) => image, + Err(e) => { + return failed(target, format!("Could not inspect {}: {}", snapshot_ref, e)); + } + }; + + // **What this snapshot actually costs is its *unique* bytes, not its size.** + // Its size includes the base image, which every other project is still + // built from and which is not going anywhere. The flattened replacement, + // by contrast, shares nothing — so it is charged in full. Comparing size to + // size would score a project with a 0.63 GB delta over a 4.72 GB base as a + // 0.5 GB saving while it in fact cost 4.1 GB. Measured on a real daemon: + // eight of ten projects were in exactly that shape. + let before_bytes = image_unique_bytes(&snapshot_ref).await; + let before_total = before.size.unwrap_or(0); + + // Projected before the run, so the outcome can be read against it. The + // plan's figure came from an even-split approximation over the aggregate; + // here the per-layer sizes are to hand, so the bound is exact — see + // [`compaction_bounds`] for why it is a bound at all. + let projected = docker + .image_history(&snapshot_ref) + .await + .ok() + .map(|entries| { + let sizes: Vec = entries.iter().map(|e| e.size).collect(); + // Bounded by the superseded bytes *and* by what survives + // re-duplicating the shared base — the same two terms + // `compaction_ceiling_for` weighs. The history here covers the base + // layers too, so the first term is looser than it could be; the + // second is what binds in practice and it caps the result anyway. + let superseded = compaction_bounds(&sizes).1; + let shared = before_total - before_bytes; + superseded.min(before_bytes - shared).max(0) + }); + let Some(config) = before.config.clone() else { + return failed( + target, + format!( + "{} has no image config to preserve, which means it is not a snapshot this app \ + committed. Refusing to rewrite it.", + snapshot_ref + ), + ); + }; + + // `:compacting` rather than `:latest`. Nothing starts from this tag, and it + // is removed on every exit path below. + let staging_ref = format!( + "triple-c-snapshot-{}:compacting", + project.id + ); + let dockerfile = compaction_dockerfile(&snapshot_ref, &container::snapshot_scrub_script()); + + if let Err(e) = build_from_dockerfile(&dockerfile, &staging_ref).await { + let _ = migration::untag_image(&staging_ref).await; + return failed(target, format!("Compaction build failed: {}", e)); + } + + // The flat image shares nothing, so its unique cost *is* its size. + let after_bytes = match docker.inspect_image(&staging_ref).await { + Ok(_) => image_unique_bytes(&staging_ref).await, + Err(e) => { + let _ = migration::untag_image(&staging_ref).await; + return failed(target, format!("Could not measure the result: {}", e)); + } + }; + + if after_bytes >= before_bytes { + let _ = migration::untag_image(&staging_ref).await; + let _ = container::sweep_orphaned_snapshots().await; + return ReclaimResult { + target: Some(target), + destroyed: None, + ok: true, + freed_bytes: 0, + projected_bytes: projected, + message: format!( + "Left untouched — flattening would have made it bigger. This snapshot costs {} \ + beyond the base image it shares with your other projects, and a flattened copy \ + would cost {} because it shares nothing. That happens when the layers hold \ + little superseded data relative to the base.", + human(before_bytes), + human(after_bytes) + ), + }; + } + + // Replay the config onto the flat image. + if let Err(e) = restore_image_config(&staging_ref, &snapshot_ref, config).await { + let _ = migration::untag_image(&staging_ref).await; + return failed( + target, + format!("Could not restore the snapshot's configuration: {}", e), + ); + } + + // The staging tag has served its purpose; dropping it leaves the flat + // intermediate dangling and labelled, so the sweep collects it. + let _ = migration::untag_image(&staging_ref).await; + let sweep = container::sweep_orphaned_snapshots().await; + + let freed = (before_bytes - after_bytes).max(0); + log::info!( + "Compacted {} ({} total): unique cost went from {} to {} bytes, {} reclaimed, and the \ + sweep collected {} superseded image(s)", + snapshot_ref, + before_total, + before_bytes, + after_bytes, + freed, + sweep.removed.len() + ); + ReclaimResult { + target: Some(target), + destroyed: None, + ok: true, + freed_bytes: freed, + projected_bytes: projected, + message: format!( + "Rewrote {} into a single layer. Its cost on disk went from {} to {}.", + snapshot_ref, + human(before_bytes), + human(after_bytes) + ), + } +} + +/// Bytes as a short human string, for a message a user reads. +/// +/// Base 1000, matching `docker system df` and the frontend's `formatBytes` — +/// a message saying 4.4 GiB beside a table saying 4.7 GB would read as two +/// different numbers for the same thing. +fn human(bytes: i64) -> String { + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value.abs() >= 1000.0 && unit < UNITS.len() - 1 { + value /= 1000.0; + unit += 1; + } + if unit == 0 { + format!("{} {}", bytes, UNITS[0]) + } else { + format!("{:.1} {}", value, UNITS[unit]) + } +} + +/// Build a one-file context and hand it to the daemon. +/// +/// The context holds nothing but the Dockerfile — every byte the build touches +/// is already inside the daemon, which is the entire point of doing this as a +/// build rather than an export/import round trip through this process. +async fn build_from_dockerfile(dockerfile: &str, tag: &str) -> Result<(), String> { + use bollard::image::BuildImageOptions; + use futures_util::StreamExt; + + let mut context = Vec::new(); + { + let mut archive = tar::Builder::new(&mut context); + let mut header = tar::Header::new_gnu(); + header.set_path("Dockerfile").map_err(|e| e.to_string())?; + header.set_size(dockerfile.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + archive + .append(&header, dockerfile.as_bytes()) + .map_err(|e| e.to_string())?; + archive.finish().map_err(|e| e.to_string())?; + } + + let docker = get_docker()?; + let options = BuildImageOptions { + dockerfile: "Dockerfile".to_string(), + t: tag.to_string(), + rm: true, + forcerm: true, + ..Default::default() + }; + + let mut stream = docker.build_image(options, None, Some(context.into())); + while let Some(item) = stream.next().await { + match item { + Ok(info) => { + if let Some(error) = info.error { + return Err(error); + } + } + Err(e) => return Err(e.to_string()), + } + } + Ok(()) +} + +/// Name prefix for the throwaway container that replays a compacted image's +/// config. Distinct from `triple-c-scrub-*` on purpose — see +/// [`restore_image_config`]. +const COMPACTION_CONTAINER_PREFIX: &str = "triple-c-compact-"; + +/// Remove any container left behind by an interrupted compaction. +/// +/// Runs at the start of a compaction rather than from a reclaim bucket, so +/// nothing can ever remove the container of a compaction that is still running: +/// by the time this is called, this task owns the compaction path. +async fn remove_stale_compaction_containers() { + let Ok(docker) = get_docker() else { + return; + }; + let containers = docker + .list_containers(Some(ListContainersOptions { + all: true, + size: false, + filters: HashMap::from([( + "name".to_string(), + vec![COMPACTION_CONTAINER_PREFIX.to_string()], + )]), + ..Default::default() + })) + .await + .unwrap_or_default(); + for summary in containers { + // Docker's `name` filter is a substring match; the full name decides. + if !is_compaction_container(&summary) { + continue; + } + if let Some(id) = summary.id.as_deref() { + match container::remove_container(id).await { + Ok(()) => log::info!("Removed stale compaction container {}", id), + Err(e) => log::warn!("Could not remove stale compaction container {}: {}", id, e), + } + } + } +} + +/// Whether a container is one of ours from an interrupted compaction. +fn is_compaction_container(summary: &ContainerSummary) -> bool { + summary + .names + .as_deref() + .unwrap_or(&[]) + .iter() + .any(|name| { + name.trim_start_matches('/') + .starts_with(COMPACTION_CONTAINER_PREFIX) + }) +} + +/// Put a captured image config back onto a flattened image, under the original +/// tag. +/// +/// `FROM scratch` discards env, cmd, entrypoint, workdir, user, labels and +/// exposed ports, and there is no way to hand them to a build without rendering +/// them as Dockerfile instructions — which a multi-line `CLAUDE_INSTRUCTIONS` +/// or a label containing a quote would not survive. Creating a container with +/// the config and committing it round-trips the structured values instead. +/// Verified against Docker 29.7.2 with both of those cases. +/// +/// The container is never started. `docker commit` on a created container is +/// well defined and adds an empty layer, so the result is still one layer of +/// content. +async fn restore_image_config( + flat_ref: &str, + final_ref: &str, + config: bollard::models::ImageConfig, +) -> Result<(), String> { + use bollard::container::{Config, CreateContainerOptions, RemoveContainerOptions}; + use bollard::image::CommitContainerOptions; + + let docker = get_docker()?; + + // **Its own prefix, not `triple-c-scrub-*`.** An earlier version reused the + // secret-rewrite name on the grounds that the existing reclaim bucket would + // then collect any leftover. It would — including the live one: that bucket + // removes with `force: true`, so a "remove scrub containers" reclaim fired + // from a second window while a compaction was mid-flight would destroy the + // container the commit is about to run against. Sequential execution inside + // one `reclaim` call is not a guarantee when two can be in flight. + // + // Stale ones are instead swept here, at the start of the next compaction — + // a created-but-never-started container has no writable layer, so a + // leftover costs almost nothing until then. + remove_stale_compaction_containers().await; + let scratch_name = format!("{}{}", COMPACTION_CONTAINER_PREFIX, uuid::Uuid::new_v4().simple()); + + // `image` is the flat build; everything else is copied from the original so + // the committed image is byte-for-byte the same configuration. + let create_config = Config:: { + image: Some(flat_ref.to_string()), + env: config.env.clone(), + cmd: config.cmd.clone(), + entrypoint: config.entrypoint.clone(), + working_dir: config.working_dir.clone(), + user: config.user.clone(), + labels: config.labels.clone(), + exposed_ports: config.exposed_ports.clone(), + volumes: config.volumes.clone(), + stop_signal: config.stop_signal.clone(), + shell: config.shell.clone(), + healthcheck: config.healthcheck.clone(), + ..Default::default() + }; + + docker + .create_container( + Some(CreateContainerOptions { + name: scratch_name.clone(), + platform: None, + }), + create_config, + ) + .await + .map_err(|e| format!("Could not create the config-restore container: {}", e))?; + + let (repo, tag) = migration::split_image_ref(final_ref); + let commit = docker + .commit_container( + CommitContainerOptions { + container: scratch_name.clone(), + repo, + tag, + // Never started, so there is nothing to pause. + pause: false, + ..Default::default() + }, + // Deliberately empty: the container was created with the config + // already on it, and commit inherits every field it is not told + // about. Passing the config twice would be the only way to get the + // two copies out of step. + Config::::default(), + ) + .await + .map_err(|e| format!("Could not commit the compacted snapshot: {}", e)); + + // Remove the scratch container whether or not the commit worked — a + // leftover would pin the flat image and show up in this very panel as a + // scrub container. + if let Err(e) = docker + .remove_container( + &scratch_name, + Some(RemoveContainerOptions { + v: false, + force: true, + ..Default::default() + }), + ) + .await + { + log::warn!( + "Could not remove the config-restore container {}: {}", + scratch_name, + e + ); + } + + commit.map(|_| ()) +} + +/// Delete the regenerable package caches inside a running container. +/// +/// A `docker exec`, so the container has to be running — the caches are in the +/// home *volume*, and the only way to size and delete them accurately is from +/// inside, where `du` can see them. +/// +/// Runs as `claude`, not root: every path is under that user's `$HOME`, and a +/// root `rm` that got a path wrong would have the authority to act on it. +pub async fn clear_caches(project: &Project, include_rustup: bool) -> ReclaimResult { + let target = ReclaimTarget::ClearCaches { + project_id: project.id.clone(), + include_rustup, + }; + if crate::commands::migration_commands::is_migrating(&project.id) { + return failed( + target, + "A base-image migration is in flight for this project.".to_string(), + ); + } + + let container_id = match container::find_existing_container(project).await { + Ok(Some(id)) => id, + Ok(None) => { + return failed( + target, + "This project has no container. Start it first — the caches are cleared from \ + inside." + .to_string(), + ) + } + Err(e) => return failed(target, e), + }; + if !container::is_container_running(&container_id) + .await + .unwrap_or(false) + { + return failed( + target, + "Start this project's container first — the caches live in its home volume and are \ + cleared from inside." + .to_string(), + ); + } + + let script = cache_clear_script(include_rustup); + let cmd = vec!["/bin/sh".to_string(), "-c".to_string(), script]; + match super::exec::exec_oneshot_as(&container_id, "claude", cmd, Vec::new()).await { + Ok((output, _exit)) => match parse_cache_total(&output) { + Some(bytes) => ReclaimResult { + target: Some(target), + destroyed: None, + ok: true, + freed_bytes: bytes as i64, + projected_bytes: None, + message: "Cleared. Every one of these refills itself the next time a tool needs \ + it." + .to_string(), + }, + None => failed( + target, + format!( + "The cache clear did not report a total, so nothing can be confirmed. \ + Output: {}", + output.trim() + ), + ), + }, + Err(e) => failed(target, format!("Could not clear caches: {}", e)), + } +} + +// --------------------------------------------------------------------------- +// Destructive +// --------------------------------------------------------------------------- + +/// Delete one object that has no other copy. +/// +/// Takes a [`DestructiveTarget`], which [`reclaim`] cannot construct or be +/// handed — a bulk selection has no way to reach this function. `confirmation` +/// must be the project's name, typed. +/// +/// Everything here is refused while a migration is in flight, and while the +/// project's container exists in a state that would be broken by the removal. +pub async fn destroy( + target: &DestructiveTarget, + confirmation: &str, + projects: &[Project], +) -> Result { + let project = find_project(projects, target.project_id())?; + if !confirmation_matches(&project.name, confirmation) { + return Err(format!( + "Type the project name ({}) exactly to confirm. Nothing was removed.", + project.name + )); + } + if crate::commands::migration_commands::is_migrating(&project.id) { + return Err( + "A base-image migration is in flight for this project. Finish or roll it back first." + .to_string(), + ); + } + + let docker = get_docker()?; + + // A running container holds all three of these open, and Docker's refusal + // is not something to lean on for the volumes: it would happily leave a + // half-removed project behind. + let existing_container = container::find_existing_container(project).await.ok().flatten(); + if let Some(container_id) = existing_container.as_deref() { + if container::is_container_running(container_id) + .await + .unwrap_or(false) + { + return Err( + "Stop this project's container first. Nothing was removed.".to_string(), + ); + } + } + + match target { + DestructiveTarget::HomeVolume { .. } | DestructiveTarget::ConfigVolume { .. } => { + let name = match target { + DestructiveTarget::HomeVolume { .. } => home_volume_name(&project.id), + _ => config_volume_name(&project.id), + }; + // Size it before it goes, so the report is a measurement. + let bytes = volume_size(&name).await; + + // **A stopped container still pins its volumes.** Docker refuses + // `remove_volume` with a 409 while any container references one, + // and every project that has ever been started has exactly that — + // a stopped container is the resting state, not an edge case. So + // the container is removed first rather than letting the user type + // a project name and then meet a raw 409. It is regenerable from + // the snapshot; `DestructiveItem::loses` says so. + if let Some(container_id) = existing_container.as_deref() { + container::remove_container(container_id).await.map_err(|e| { + format!( + "Could not remove this project's container, which still holds the volume \ + open: {}. Nothing was removed.", + e + ) + })?; + log::info!( + "Removed container {} so {} could be deleted", + container_id, + name + ); + } + + docker + .remove_volume(&name, None) + .await + .map_err(|e| format!("Could not remove volume {}: {}", name, e))?; + log::info!("Removed volume {} on explicit confirmation", name); + Ok(ReclaimResult { + target: None, + destroyed: Some(target.clone()), + ok: true, + freed_bytes: bytes, + projected_bytes: None, + message: format!("Removed volume {}.", name), + }) + } + DestructiveTarget::SnapshotImage { .. } => { + let reference = get_snapshot_image_name(project); + // Measured before the removal, and net of the shared base — which + // other projects are still built from and which is not freed. + let bytes = image_unique_bytes(&reference).await; + container::remove_snapshot_image(project).await?; + let sweep = container::sweep_orphaned_snapshots().await; + Ok(ReclaimResult { + target: None, + destroyed: Some(target.clone()), + ok: true, + freed_bytes: bytes + sweep.reclaimed_bytes, + projected_bytes: None, + message: format!( + "Removed {}. This project will build from the base image next time it starts.", + reference + ), + }) + } + DestructiveTarget::RollbackPin { tag, .. } => { + // **The one destructive variant carrying a free-form string.** + // Every other arm builds its target from constants; this one takes + // a tag over IPC and interpolates it into an image reference that + // is then removed. Unvalidated, `tag: "latest"` names the project's + // live snapshot — deleted under a dialog that says "rollback pin". + // `parse_rollback_tag` accepts only `pre-migration-`, + // which is exactly what `rollback_tag` produces and nothing else. + if migration::parse_rollback_tag(tag).is_none() { + return Err(format!( + "{:?} is not a rollback pin tag. Nothing was removed.", + tag + )); + } + let reference = format!("triple-c-snapshot-{}:{}", project.id, tag); + migration::untag_image(&reference).await?; + // Untagging only makes the image dangling. Whatever came back came + // back through the sweep, under its own refusal rules. + let sweep = container::sweep_orphaned_snapshots().await; + log::info!("Dropped rollback pin {} on explicit confirmation", reference); + Ok(ReclaimResult { + target: None, + destroyed: Some(target.clone()), + ok: true, + freed_bytes: sweep.reclaimed_bytes, + projected_bytes: None, + message: format!( + "Dropped {}. Rolling that migration back is no longer possible.", + reference + ), + }) + } + } +} + +/// Bytes an image would actually give back if it were removed: its size minus +/// what it shares with other images. +/// +/// The plain `Size` from `inspect_image` includes the base, which several other +/// projects are still built from and which is not going anywhere. Reporting it +/// as freed would overstate a snapshot removal by ~4.7 GB every time. Only +/// `df()` computes `SharedSize`, so each call costs a full daemon walk. +/// +/// That is three `df()`s on a compaction (before, after, and the scan that +/// planned it) and one per destructive removal. Acceptable because both are +/// single-object, user-initiated actions that already take seconds to minutes — +/// but it is why nothing in the *scan* path calls this: `scan` gets shared +/// sizes from the one `df()` it already makes. +async fn image_unique_bytes(reference: &str) -> i64 { + let Ok(docker) = get_docker() else { + return 0; + }; + let Ok(usage) = docker.df().await else { + return 0; + }; + usage + .images + .unwrap_or_default() + .iter() + .find(|image| image.repo_tags.iter().any(|tag| tag == reference)) + .map(|image| (image.size - image.shared_size.max(0)).max(0)) + .unwrap_or(0) +} + +/// One volume's size, or 0 when the daemon will not say. Costs a `df()`, which +/// is why it is only used on the destructive path where there is exactly one. +async fn volume_size(name: &str) -> i64 { + let Ok(docker) = get_docker() else { + return 0; + }; + let Ok(usage) = docker.df().await else { + return 0; + }; + usage + .volumes + .unwrap_or_default() + .iter() + .find(|v| v.name == name) + .and_then(volume_bytes) + .unwrap_or(0) +} + +#[cfg(test)] +#[path = "disk_tests.rs"] +mod tests; diff --git a/app/src-tauri/src/docker/disk_tests.rs b/app/src-tauri/src/docker/disk_tests.rs new file mode 100644 index 0000000..8ec636a --- /dev/null +++ b/app/src-tauri/src/docker/disk_tests.rs @@ -0,0 +1,938 @@ +//! Tests for the disk view's pure logic. +//! +//! Split into its own file because `disk.rs` is already long and because +//! everything here has to stay runnable without a daemon — which is the point +//! of keeping the classification, the orphan set and the script builders pure. +//! +//! The blast radius of a mistake in this module is a user's credentials, +//! transcripts and toolchains, so the tests below are deliberately about +//! *refusing*, not about succeeding. + +use super::*; + +// --------------------------------------------------------------------------- +// Safety classification +// --------------------------------------------------------------------------- + +/// Every `ReclaimTarget` variant, so the walks below cannot silently skip a new +/// one. A variant added without a line here fails `every_variant_is_covered`. +fn all_reclaim_targets() -> Vec { + vec![ + ReclaimTarget::DanglingSnapshots, + ReclaimTarget::SupersededBaseImages, + ReclaimTarget::BuildCache { all: false }, + ReclaimTarget::BuildCache { all: true }, + ReclaimTarget::MigrationPins, + ReclaimTarget::MigrationStaging, + ReclaimTarget::ProbeContainers, + ReclaimTarget::ScrubContainers, + ReclaimTarget::OrphanVolume { + name: "triple-c-home-gone".to_string(), + }, + ReclaimTarget::CompactSnapshot { + project_id: "p1".to_string(), + }, + ReclaimTarget::ClearCaches { + project_id: "p1".to_string(), + include_rustup: false, + }, + ReclaimTarget::ClearCaches { + project_id: "p1".to_string(), + include_rustup: true, + }, + ] +} + +#[test] +fn every_variant_is_covered_by_the_safety_walk() { + // `ReclaimTarget` has no way to enumerate itself, so this pins the count by + // hand. Bumping it is the prompt to add the new variant above *and* decide + // its safety deliberately rather than by whatever the match arm falls into. + let discriminants: HashSet = all_reclaim_targets() + .iter() + .map(|t| serde_json::to_value(t).unwrap()["kind"].as_str().unwrap().to_string()) + .collect(); + assert_eq!( + discriminants.len(), + 10, + "a ReclaimTarget variant was added or removed; update all_reclaim_targets() and check its \ + safety: {:?}", + discriminants + ); +} + +#[test] +fn nothing_destructive_can_land_in_the_safe_bucket() { + // The strongest form of this guarantee is structural: `reclaim` takes + // `&[ReclaimTarget]` and `DestructiveTarget` is a different type, so a + // destructive action cannot be passed to a bulk reclaim at all. What this + // test pins is the second half — that no *safe*-classified target names a + // live project's data either. + for target in all_reclaim_targets() { + match &target { + // These act on a project, and both are rewrites or cache flushes. + // Neither may ever be classified Safe: one rebuilds an image and + // the other costs a re-download. + ReclaimTarget::CompactSnapshot { .. } | ReclaimTarget::ClearCaches { .. } => { + assert_eq!( + target.safety(), + Safety::SemiSafe, + "{:?} must ask for confirmation", + target + ); + } + // A safe target may name a *volume*, but only ever one that orphan + // detection produced — which by construction belongs to no project + // in the store. + other => assert_eq!( + other.safety(), + Safety::Safe, + "{:?} was expected to need no confirmation", + other + ), + } + } +} + +#[test] +fn only_the_build_cache_reaches_outside_triple_c() { + // The user's daemon also holds their unrelated postgres, mysql and + // site-builder work. Exactly one action here touches it, and the UI has to + // say so — so if a second one ever does, this fails loudly. + let daemon_wide: Vec<_> = all_reclaim_targets() + .into_iter() + .filter(ReclaimTarget::is_daemon_wide) + .collect(); + assert_eq!(daemon_wide.len(), 2, "expected only the two BuildCache variants"); + assert!(daemon_wide + .iter() + .all(|t| matches!(t, ReclaimTarget::BuildCache { .. }))); +} + +#[test] +fn destructive_targets_all_name_a_project() { + // The typed confirmation is "type the project name". A destructive target + // that could not name a project would have nothing to confirm against. + for target in [ + DestructiveTarget::HomeVolume { + project_id: "p1".to_string(), + }, + DestructiveTarget::ConfigVolume { + project_id: "p1".to_string(), + }, + DestructiveTarget::SnapshotImage { + project_id: "p1".to_string(), + }, + DestructiveTarget::RollbackPin { + project_id: "p1".to_string(), + tag: "pre-migration-20260101-101500".to_string(), + }, + ] { + assert_eq!(target.project_id(), "p1"); + } +} + +#[test] +fn a_dangling_image_is_a_base_only_when_it_says_so() { + let base = HashMap::from([(LABEL_BASE.to_string(), "true".to_string())]); + assert_eq!(classify_dangling(&base), DanglingClass::Base); + + // `create_container` writes `triple-c.base` explicitly *empty* precisely so + // an inherited `true` cannot ride a commit onto a snapshot and make it + // claim to be a base image. + let commit = HashMap::from([(LABEL_BASE.to_string(), String::new())]); + assert_eq!(classify_dangling(&commit), DanglingClass::SnapshotCommit); + + // Images committed before the label existed carry it not at all. + assert_eq!( + classify_dangling(&HashMap::new()), + DanglingClass::SnapshotCommit + ); + + // Anything other than the exact string `true` is not a base. + let liar = HashMap::from([(LABEL_BASE.to_string(), "yes".to_string())]); + assert_eq!(classify_dangling(&liar), DanglingClass::SnapshotCommit); +} + +// --------------------------------------------------------------------------- +// Orphan detection — the part that can delete a user's transcripts +// --------------------------------------------------------------------------- + +fn vol(name: &str, bytes: i64, links: i64) -> VolumeFacts { + VolumeFacts { + name: name.to_string(), + bytes, + links, + created_at: Some("2026-03-14T09:00:00Z".to_string()), + } +} + +#[test] +fn orphan_detection_skips_every_project_in_the_store() { + let volumes = vec![ + vol("triple-c-home-live", 1_000, 0), + vol("triple-c-claude-config-live", 2_000, 0), + vol("triple-c-home-gone", 3_000, 0), + vol("triple-c-claude-config-gone", 4_000, 0), + ]; + let known = HashSet::from(["live".to_string()]); + let orphans = orphan_volumes(&volumes, &known, true); + + let names: Vec<&str> = orphans.iter().map(|o| o.name.as_str()).collect(); + assert_eq!( + names, + vec!["triple-c-claude-config-gone", "triple-c-home-gone"], + "sorted biggest first" + ); + assert!( + !names.iter().any(|n| n.contains("live")), + "a live project's volumes were offered for deletion" + ); +} + +#[test] +fn a_store_that_did_not_load_yields_no_orphans_at_all() { + // This is the case the whole design turns on, and the one that would wipe + // every project's credentials, transcripts and toolchains at once. With the + // store unreadable, *every* project's volumes look unclaimed — so the + // answer has to be "nothing, and here is why", never "everything". + let volumes = vec![ + vol("triple-c-home-a", 1_000, 0), + vol("triple-c-claude-config-a", 2_000, 0), + vol("triple-c-home-b", 3_000, 0), + ]; + assert!(orphan_volumes(&volumes, &HashSet::new(), false).is_empty()); + + // And with the store loaded but genuinely empty, they *are* orphans — the + // distinction is the flag, not the emptiness of the set. + assert_eq!(orphan_volumes(&volumes, &HashSet::new(), true).len(), 3); +} + +#[test] +fn an_idle_live_project_is_never_mistaken_for_a_deleted_one() { + // The exact mistake this guard exists for. An "orphan" heuristic of "no + // container and no snapshot image" was tried against a real project list + // and flagged two live projects — `site-builder` and `cal-dav-mcp` — that + // had simply been idle long enough for their containers to be removed. + // Their volumes held `.credentials.json`, Claude transcripts and shell + // history. + // + // From the daemon's side those look identical to a deleted project's + // leftovers: volumes present, ref count zero, no container, no image. The + // *only* thing that tells them apart is membership in Triple-C's own + // project store, so that is the only thing consulted. + let idle_but_live = vec![ + vol("triple-c-home-site-builder", 8_400_000_000, 0), + vol("triple-c-claude-config-site-builder", 427_000_000, 0), + vol("triple-c-home-cal-dav-mcp", 1_200_000_000, 0), + vol("triple-c-claude-config-cal-dav-mcp", 44_000_000, 0), + vol("triple-c-home-really-gone", 900_000, 0), + ]; + let store = HashSet::from(["site-builder".to_string(), "cal-dav-mcp".to_string()]); + + let orphans = orphan_volumes(&idle_but_live, &store, true); + assert_eq!( + orphans.iter().map(|o| o.name.as_str()).collect::>(), + vec!["triple-c-home-really-gone"], + "an idle live project's volumes were offered for deletion" + ); + + // And nothing in the signature even *offers* container or image state, so a + // future change cannot quietly start inferring from it. + assert_eq!( + orphans[0].created_at.as_deref(), + Some("2026-03-14T09:00:00Z"), + "the creation date is the evidence a user recognises the project by" + ); +} + +#[test] +fn a_volume_with_a_container_attached_is_never_an_orphan() { + let volumes = vec![ + vol("triple-c-home-gone", 1_000, 1), + // -1 is "the daemon did not compute it", which must fail closed: an + // unknown ref count is not permission. + vol("triple-c-claude-config-gone", 2_000, -1), + vol("triple-c-home-other", 3_000, 0), + ]; + let orphans = orphan_volumes(&volumes, &HashSet::new(), true); + assert_eq!(orphans.len(), 1); + assert_eq!(orphans[0].name, "triple-c-home-other"); +} + +#[test] +fn orphan_detection_ignores_volumes_that_are_not_ours() { + let volumes = vec![ + vol("nfc-profile-mysql", 183_926_366, 0), + vol("postgres_data", 9_000_000, 0), + vol("triple-c-stt-model-cache", 900_000_000, 0), + vol("triple-c-gateway-config", 1_000, 0), + vol("triple-c-home-gone", 5_000, 0), + ]; + let orphans = orphan_volumes(&volumes, &HashSet::new(), true); + assert_eq!(orphans.len(), 1, "{:?}", orphans); + assert_eq!(orphans[0].name, "triple-c-home-gone"); + + // The STT model cache and the gateway config are ours by name but are not + // per-project volumes; they belong to features, not projects, and nothing + // here may reach them. + assert!(parse_project_volume_name("triple-c-stt-model-cache").is_none()); + assert!(parse_project_volume_name("triple-c-gateway-config").is_none()); +} + +#[test] +fn a_volume_name_splits_into_the_right_project_and_role() { + assert_eq!( + parse_project_volume_name("triple-c-home-abc-123"), + Some(("abc-123", "home")) + ); + assert_eq!( + parse_project_volume_name("triple-c-claude-config-abc-123"), + Some(("abc-123", "config")) + ); + // A bare prefix names no project, so it is not ours to delete. + assert!(parse_project_volume_name("triple-c-home-").is_none()); + assert!(parse_project_volume_name("triple-c-claude-config-").is_none()); + assert!(parse_project_volume_name("triple-c-").is_none()); + assert!(parse_project_volume_name("").is_none()); +} + +#[test] +fn the_config_role_is_reported_because_it_is_the_one_holding_credentials() { + let orphans = orphan_volumes( + &[vol("triple-c-claude-config-gone", 7, 0)], + &HashSet::new(), + true, + ); + assert_eq!(orphans[0].role, "config"); + assert_eq!(orphans[0].project_id, "gone"); +} + +// --------------------------------------------------------------------------- +// Throwaway-container predicates — these gate a `docker rm` +// --------------------------------------------------------------------------- + +fn summary(names: &[&str], labels: &[(&str, &str)]) -> ContainerSummary { + ContainerSummary { + names: Some(names.iter().map(|n| (*n).to_string()).collect()), + labels: Some( + labels + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(), + ), + ..Default::default() + } +} + +#[test] +fn a_scrub_container_is_matched_on_its_whole_name_not_a_substring() { + // Docker's `name` filter is a *substring* match, so the daemon happily + // returns a user's own container whose name merely contains ours. The + // predicate is what decides, and it anchors at the start. + assert!(is_scrub_container(&summary(&["/triple-c-scrub-abc123"], &[]))); + + assert!(!is_scrub_container(&summary(&["/my-triple-c-scrub-notes"], &[]))); + assert!(!is_scrub_container(&summary(&["/triple-c-scrubber"], &[]))); + assert!(!is_scrub_container(&summary(&["/triple-c-abc"], &[]))); + assert!(!is_scrub_container(&summary(&[], &[]))); +} + +#[test] +fn a_compaction_container_is_never_matched_by_the_scrub_bucket() { + // These had the same `triple-c-scrub-*` prefix once. The scrub bucket + // removes with `force: true`, so a reclaim fired from a second window while + // a compaction was mid-flight would have destroyed the container the commit + // was about to run against. Separate prefixes, and neither predicate may + // reach the other's containers. + let compaction = summary(&["/triple-c-compact-abc123"], &[]); + let scrub = summary(&["/triple-c-scrub-abc123"], &[]); + + assert!(is_compaction_container(&compaction)); + assert!(!is_scrub_container(&compaction), "the scrub bucket must not reach it"); + + assert!(is_scrub_container(&scrub)); + assert!(!is_compaction_container(&scrub)); + + // Same substring-filter hazard applies to the new prefix. + assert!(!is_compaction_container(&summary(&["/my-triple-c-compact-notes"], &[]))); +} + +#[test] +fn a_probe_container_is_matched_on_its_label_not_on_the_daemons_filter() { + // The `label=triple-c.probe=migration` filter is an exact match and would + // be enough on its own — but a filter is a string assembled elsewhere in + // the file, and "enough" is not the standard for something that runs + // `docker rm`. + assert!(is_migration_probe(&summary( + &["/nervous_curie"], + &[(migration::LABEL_PROBE, migration::PROBE_LABEL_MIGRATION)] + ))); + + // A different probe kind, a truncated value, and no label at all. + assert!(!is_migration_probe(&summary( + &["/x"], + &[(migration::LABEL_PROBE, "something-else")] + ))); + assert!(!is_migration_probe(&summary(&["/x"], &[]))); + assert!(!is_migration_probe(&summary( + &["/x"], + &[("triple-c.managed", "true")] + ))); +} + +// --------------------------------------------------------------------------- +// Store trust +// --------------------------------------------------------------------------- + +fn project(id: &str, name: &str) -> Project { + let mut p = Project::new(name.to_string(), Vec::new()); + p.id = id.to_string(); + p +} + +#[test] +fn an_unreadable_projects_json_is_never_trusted() { + let err = project_store_trust(&[project("a", "api")], true, false).unwrap_err(); + assert!(err.contains("could not be read"), "{}", err); +} + +#[test] +fn an_empty_list_from_an_existing_file_is_treated_as_a_failed_load() { + // `ProjectsStore::new()` swallows a corrupt projects.json: it backs the file + // up and starts empty. That is right for the app and catastrophic here, so + // the combination "empty list + file present" is refused rather than read as + // "the user has no projects". + let err = project_store_trust(&[], true, true).unwrap_err(); + assert!(err.contains("suppressed"), "{}", err); + + // No file at all is a genuine fresh install, and there is nothing on the + // daemon to mis-attribute in that state. + assert!(project_store_trust(&[], false, true).unwrap().is_empty()); +} + +#[test] +fn a_healthy_store_yields_its_ids() { + let ids = project_store_trust(&[project("a", "api"), project("b", "web")], true, true).unwrap(); + assert_eq!(ids, HashSet::from(["a".to_string(), "b".to_string()])); +} + +// --------------------------------------------------------------------------- +// Layer accounting — the number the whole UI exists to show +// --------------------------------------------------------------------------- + +#[test] +fn commit_layers_are_the_history_a_snapshot_has_beyond_its_base() { + // `image_history` returns newest first, and a snapshot's history is its + // base's history with the commits appended — so the commits are the head. + let snapshot = vec![0, 868_000_000, 500_000_000, 4_000_000_000, 0]; + let stats = layer_stats(&snapshot, Some(2)); + assert_eq!(stats.commit_layers, 3); + assert_eq!(stats.above_base_bytes, Some(1_368_000_000)); +} + +#[test] +fn a_missing_base_reports_a_count_but_refuses_to_split_the_bytes() { + // A base image that has been swept is common — the project keeps running + // from its own snapshot. The layer count is still useful; the byte split is + // not knowable, and a guess there would be the one number in this UI that + // is not measured. + let stats = layer_stats(&[10, 20, 0, 30], None); + assert_eq!(stats.commit_layers, 3, "zero-byte layers are metadata, not commits"); + assert_eq!(stats.above_base_bytes, None); +} + +#[test] +fn a_base_longer_than_the_snapshot_means_they_are_not_the_same_lineage() { + let stats = layer_stats(&[10, 20], Some(5)); + assert_eq!(stats.above_base_bytes, None); +} + +#[test] +fn a_snapshot_that_is_exactly_its_base_has_no_commits() { + let stats = layer_stats(&[10, 20, 30], Some(3)); + assert_eq!(stats.commit_layers, 0); + assert_eq!(stats.above_base_bytes, Some(0)); +} + +#[test] +fn compaction_is_bounded_and_the_floor_is_zero() { + // Verified on Docker 29.7.2: a stack with nothing superseded came out + // *larger* (29.8 MB -> 30.8 MB), because the merged layer recompresses on + // its own. So the floor is zero and never a fraction of the total. + let (floor, ceiling) = compaction_bounds(&[100, 100, 100]); + assert_eq!(floor, 0); + assert_eq!(ceiling, 200, "at most everything but the largest layer"); + + // One layer can supersede nothing, so there is no upside at all. + assert_eq!(compaction_bounds(&[500]), (0, 0)); + assert_eq!(compaction_bounds(&[]), (0, 0)); +} + +#[test] +fn the_ceiling_shown_in_the_plan_matches_the_bound() { + // With no shared base to re-duplicate, the bound is the superseded-bytes + // one: an even split approximating "everything but the largest layer". + assert_eq!(compaction_ceiling_for(300, 0, 3), 200); + assert_eq!(compaction_ceiling_for(300, 0, 1), 0, "one layer supersedes nothing"); + assert_eq!(compaction_ceiling_for(0, 0, 14), 0); + assert_eq!(compaction_ceiling_for(-5, 0, 3), 0, "never negative"); +} + +#[test] +fn compacting_a_thin_snapshot_over_a_fat_base_is_never_offered() { + // The bug this exists to stop, with the real numbers that exposed it. + // + // `FROM scratch` + `COPY --from` produces an image that shares nothing, so + // the flattened snapshot carries its own private copy of the base — which + // stays on disk regardless, because every other project is still built from + // it. Eight of ten projects on a real daemon had a unique delta of + // 0.10–1.32 GB over a 4.72 GB shared base: flattening any of them turns a + // sub-gigabyte cost into a ~4.7 GB one. + // + // A ceiling of zero keeps them out of the plan entirely, rather than + // offering a 4 GB loss as a saving. + let shared_base = 4_723_860_394; + for unique in [100_000_000i64, 630_000_000, 1_320_000_000] { + assert_eq!( + compaction_ceiling_for(unique, shared_base, 6), + 0, + "a {}-byte delta over a {}-byte base must not be offered", + unique, + shared_base + ); + } + + // The one project that *was* worth it: 8.44 GB unique across 14 layers over + // a 3.83 GB base. The base penalty still binds — 8.44 - 3.83 = 4.61 GB is + // smaller than the 7.84 GB the even split allows — so that is the figure. + let ceiling = compaction_ceiling_for(8_440_966_715, 3_832_425_659, 14); + assert_eq!(ceiling, 8_440_966_715 - 3_832_425_659); + assert!(ceiling < (8_440_966_715 / 14) * 13, "the base penalty must bind here"); +} + +#[test] +fn the_superseded_bound_still_binds_when_the_base_is_small() { + // With a tiny base, the limit on what can come back is how much the layers + // superseded, not the duplication cost. Both terms have to be live. + let ceiling = compaction_ceiling_for(300, 10, 3); + assert_eq!(ceiling, 200, "the even split binds, not 300 - 10"); +} + +// --------------------------------------------------------------------------- +// Build cache +// --------------------------------------------------------------------------- + +fn cache(size: i64, in_use: bool, age_hours: i64) -> BuildCacheFacts { + BuildCacheFacts { + size, + in_use, + last_used_at: Some(chrono::Utc::now() - chrono::Duration::hours(age_hours)), + } +} + +#[test] +fn the_age_filter_leaves_in_use_and_recent_records_alone() { + let now = chrono::Utc::now(); + let entries = vec![ + cache(1_000, false, 200), // old and free -> counted + cache(2_000, true, 200), // old but in use -> never + cache(4_000, false, 10), // free but recent -> not by this filter + BuildCacheFacts { + size: 8_000, + in_use: false, + // No timestamp at all: unknown age fails closed, same rule as an + // unknown volume ref count. + last_used_at: None, + }, + ]; + assert_eq!(stale_build_cache_bytes(&entries, 168, now), 1_000); +} + +#[test] +fn docker_sizes_parse_in_base_1000_because_that_is_what_docker_prints() { + // `units.HumanSize` is base 1000. Reading "28.0GB" as 1024-based would + // overstate the single biggest win in this panel by about 7%. + assert_eq!(parse_docker_size("0B"), Some(0)); + assert_eq!(parse_docker_size("28.0GB"), Some(28_000_000_000)); + assert_eq!(parse_docker_size("1.5MB"), Some(1_500_000)); + assert_eq!(parse_docker_size(" 46.88GB "), Some(46_880_000_000)); + assert_eq!(parse_docker_size("12kB"), Some(12_000)); + + // Anything unrecognised is None, so the caller falls back to `df()` rather + // than showing a wrong number. + assert_eq!(parse_docker_size("lots"), None); + assert_eq!(parse_docker_size(""), None); + assert_eq!(parse_docker_size("12GiB"), None); + + // A space before the unit is fine — `docker builder prune` uses a tab. + assert_eq!(parse_docker_size("1.5 kB"), Some(1_500)); + assert_eq!(parse_docker_size("\t20.59MB"), Some(20_590_000)); + + // A negative would subtract from the running freed total if it got through. + assert_eq!(parse_docker_size("-5GB"), None); +} + +#[test] +fn buildx_du_output_parses_into_total_and_reclaimable() { + // Real shape, taken from `docker buildx du` on Docker 29.7.2. + let output = "ID RECLAIMABLE SIZE LAST ACCESSED\n\ + abc123 true 29.78MB 36 seconds ago\n\ + Reclaimable:\t28.0GB\n\ + Total:\t\t33.57MB\n"; + assert_eq!(parse_buildx_du(output), Some((33_570_000, 28_000_000_000))); + + // An empty cache still reports both lines. + assert_eq!( + parse_buildx_du("Reclaimable:\t0B\nTotal:\t\t0B\n"), + Some((0, 0)) + ); + // No Total line means the output is not what we expect; fall back rather + // than invent. + assert_eq!(parse_buildx_du("nothing here"), None); +} + +#[test] +fn the_reclaimed_figure_comes_from_the_prunes_own_report() { + // `docker system prune` / `image prune` wording. + let output = "deleted: sha256:abc\ndeleted: sha256:def\nTotal reclaimed space: 12.3GB\n"; + assert_eq!(parse_reclaimed_space(output), 12_300_000_000); + assert_eq!(parse_reclaimed_space("Total reclaimed space: 0B"), 0); + + // `docker builder prune` wording — the one this module actually runs, and + // the one an earlier draft of the parser missed entirely, reporting every + // build-cache prune as having freed nothing. Verbatim from Docker 29.7.2. + let builder = "2zp7lsfz2me0jtqe8rio6s4eq*\ttrue\t\t8.192kB\tLess than a second ago\n\ + rmonzx1v6jrrlgxt783dmfb3k\ttrue\t16.79MB\t1 second ago\n\ + Total:\t20.59MB\n"; + assert_eq!(parse_reclaimed_space(builder), 20_590_000); + + // A filtered prune that matched nothing still prints the summary. + assert_eq!(parse_reclaimed_space("Total:\t0B\n"), 0); + + // A prune that printed nothing recognisable freed nothing we can claim. + assert_eq!(parse_reclaimed_space("nothing to do"), 0); +} + +// --------------------------------------------------------------------------- +// Scripts — shell strings, so pinned by test +// --------------------------------------------------------------------------- + +#[test] +fn the_compaction_dockerfile_reuses_the_one_scrub_list() { + let df = compaction_dockerfile( + "triple-c-snapshot-p1:latest", + &container::snapshot_scrub_script(), + ); + + assert!(df.starts_with("FROM triple-c-snapshot-p1:latest AS src\n")); + assert!( + df.contains("\nFROM scratch\nCOPY --from=src / /\n"), + "the flatten is the whole point: {}", + df + ); + + // Every path in the reviewed list has to appear, and it has to be *that* + // list rather than a second copy — a forked list is the failure mode a + // hardcoded set of `rm -rf` paths invites. + for path in container::SNAPSHOT_SCRUB_PATHS { + assert!(df.contains(path), "scrub path {} missing from {}", path, df); + } + + // The RUN must be one line: a Dockerfile instruction does not continue over + // a bare newline, and a script folded wrongly would silently truncate to + // its first statement. + let run_lines: Vec<&str> = df.lines().filter(|l| l.starts_with("RUN ")).collect(); + assert_eq!(run_lines.len(), 1, "{}", df); + assert!(!run_lines[0].contains('\n')); +} + +#[test] +fn the_compaction_build_is_labelled_so_the_sweep_can_collect_it() { + // Everything that cleans up after this build — the discard path when the + // result is not smaller, the untag after a successful commit — leans on + // `sweep_orphaned_snapshots`, and that sweep filters on `dangling=true` + // AND `triple-c.managed=true`. Without the label it can never match, and + // the flattened intermediate is stranded. + let df = compaction_dockerfile("x:latest", &container::snapshot_scrub_script()); + assert!( + df.contains("LABEL triple-c.managed=true"), + "the sweep filters on this label and would never match: {}", + df + ); + // It has to be on the *final* stage, not the discarded `src` one. + let after_scratch = df.split("FROM scratch").nth(1).expect("no final stage"); + assert!(after_scratch.contains("LABEL triple-c.managed=true"), "{}", df); +} + +#[test] +fn the_compaction_dockerfile_never_reaches_a_bind_mount() { + let df = compaction_dockerfile("x:latest", &container::snapshot_scrub_script()); + // `/workspace/{mount_name}` subtrees are the user's real project + // directories, mounted from the host. Nothing in a scrub may name one, and + // the two read-only host mounts under /tmp are dot-prefixed so no glob + // reaches them either. + assert!(!df.contains("/workspace"), "{}", df); + assert!(!df.contains(".host-ca"), "{}", df); + assert!(!df.contains(".host-aws"), "{}", df); +} + +#[test] +fn the_cache_script_only_ever_names_paths_under_home() { + for include_rustup in [false, true] { + let script = cache_clear_script(include_rustup); + for line in script.lines() { + // Every deletion in this script is anchored to $HOME. A path that + // is not would be operating on the system layer, or worse on a + // bind mount. + if line.contains("rm -rf") { + assert!( + line.contains("$HOME") || line.contains("$d"), + "unanchored deletion: {}", + line + ); + } + } + assert!(!script.contains("/workspace"), "{}", script); + assert!(!script.contains(" / "), "{}", script); + } +} + +#[test] +fn rustup_is_only_cleared_when_it_is_asked_for() { + // Regenerable, but a re-download rather than a rebuild from a local cache — + // which is why it is a separate tick and not part of the set. + assert!(!cache_clear_script(false).contains(".rustup")); + assert!(cache_clear_script(true).contains("$HOME/.rustup/toolchains")); +} + +#[test] +fn the_cache_script_keeps_the_newest_playwright_revision() { + // Deleting the current revision turns a working browser-view project into + // one that downloads 400 MB on next use, so only superseded revisions go. + let script = cache_clear_script(false); + assert!(script.contains("keep=$("), "{}", script); + assert!(script.contains("= \"$keep\" ] && continue"), "{}", script); + // ...and it must not simply remove the whole directory. + assert!(!script.contains("rm -rf -- \"$HOME/.cache/ms-playwright\""), "{}", script); +} + +#[test] +fn the_cache_script_covers_every_documented_cache() { + let script = cache_clear_script(false); + for path in [ + "$HOME/.npm/_cacache", + "$HOME/.npm/_npx", + "$HOME/.cache/go-build", + "$HOME/.cache/pip", + "$HOME/.cache/uv", + "$HOME/.cache/act", + "$HOME/.cache/chrome-devtools-mcp", + "$HOME/go/pkg/mod", + "$HOME/.cache/ms-playwright", + ] { + assert!(script.contains(path), "{} missing from the cache script", path); + } +} + +#[test] +fn the_cache_script_reports_a_total_that_can_be_read_back() { + let script = cache_clear_script(false); + assert!(script.contains(CACHE_MARKER)); + assert_eq!( + parse_cache_total(&format!("noise\n{}6291456\nmore noise\n", CACHE_MARKER)), + Some(6_291_456) + ); + // No marker means the script never reached its last line — a killed exec, + // not a run that freed nothing. + assert_eq!(parse_cache_total("permission denied"), None); + assert_eq!(parse_cache_total(&format!("{}0", CACHE_MARKER)), Some(0)); +} + +// --------------------------------------------------------------------------- +// Confirmation +// --------------------------------------------------------------------------- + +#[test] +fn a_typed_confirmation_must_match_the_project_name_exactly() { + assert!(confirmation_matches("whp", "whp")); + // A trailing space from a paste is not a different intent. + assert!(confirmation_matches("whp", " whp ")); + + // Case is not negotiable: `Api` and `api` are different projects, and this + // is the only thing between a user and their transcripts. + assert!(!confirmation_matches("Api", "api")); + assert!(!confirmation_matches("whp", "wh")); + assert!(!confirmation_matches("whp", "")); + // An empty expected name would otherwise be satisfied by an empty box. + assert!(!confirmation_matches("", "")); +} + +#[test] +fn only_a_real_rollback_tag_can_name_an_image_to_delete() { + // `DestructiveTarget::RollbackPin` is the one destructive variant carrying + // a free-form string from the frontend, and `destroy` interpolates it into + // an image reference it then removes. Unguarded, `tag: "latest"` names the + // project's *live snapshot* — deleted under a dialog that says "rollback + // pin". The guard is `parse_rollback_tag`, so this pins what it accepts. + assert!(migration::parse_rollback_tag("pre-migration-20260101-101500").is_some()); + + for hostile in [ + "latest", + "", + "pre-migration-", + "pre-migration-notatimestamp", + "../latest", + "latest\npre-migration-20260101-101500", + ] { + assert!( + migration::parse_rollback_tag(hostile).is_none(), + "{:?} must not be accepted as a rollback pin tag", + hostile + ); + } +} + +#[test] +fn a_destroy_result_never_claims_to_be_reclaim_work() { + // An earlier version returned `OrphanVolume { name }` for a home-volume + // deletion — naming a volume that was never an orphan, and attributing the + // outcome to a plan row the user never ticked. Exactly one of the two + // fields is ever set. + let reclaim_shaped = ReclaimResult { + target: Some(ReclaimTarget::DanglingSnapshots), + destroyed: None, + ok: true, + freed_bytes: 1, + projected_bytes: None, + message: String::new(), + }; + let destroy_shaped = ReclaimResult { + target: None, + destroyed: Some(DestructiveTarget::HomeVolume { + project_id: "p1".to_string(), + }), + ..reclaim_shaped.clone() + }; + assert!(reclaim_shaped.target.is_some() != reclaim_shaped.destroyed.is_some()); + assert!(destroy_shaped.target.is_some() != destroy_shaped.destroyed.is_some()); + + // And both shapes survive the wire. + let json = serde_json::to_string(&destroy_shaped).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), destroy_shaped); +} + +#[test] +fn a_snapshot_with_no_known_base_is_not_offered_for_compaction() { + // With `triple-c.base-image-id` absent — the normal case for a project + // created before that label existed — `layer_stats` counts every layer that + // carries bytes, base included. A never-recreated project then reports ~15 + // "commit layers" and would sail past a `> 1` check. `base_lineage_known` + // is what stops the plan offering a rewrite sized from a number that does + // not mean what its name says. + let unknown = layer_stats(&[10, 20, 30, 40], None); + assert_eq!(unknown.commit_layers, 4); + assert_eq!(unknown.above_base_bytes, None, "the split must not be guessed"); + + let known = layer_stats(&[10, 20, 30, 40], Some(3)); + assert_eq!(known.commit_layers, 1); + assert_eq!(known.above_base_bytes, Some(10)); +} + +// --------------------------------------------------------------------------- +// Host detection +// --------------------------------------------------------------------------- + +#[test] +fn the_vhdx_caveat_needs_both_windows_and_docker_desktop() { + assert!(vhdx_applies(true, "Docker Desktop")); + assert!(vhdx_applies(true, "Docker Desktop 4.30.0"), "matched loosely"); + + // macOS Docker Desktop has the same never-shrinks property but a different + // file and a different fix, so this note would be wrong there. + assert!(!vhdx_applies(false, "Docker Desktop")); + // A Windows host talking to a native or remote engine has neither. + assert!(!vhdx_applies(true, "Ubuntu 24.04.1 LTS")); +} + +#[test] +fn the_vhdx_note_spells_out_the_fix() { + // Users otherwise report "I pruned and C: did not change" as a bug, so both + // routes have to be on screen, not in a doc. + assert!(WSL2_VHDX_NOTE.contains("never shrinks")); + assert!(WSL2_VHDX_FIX[0].contains("wsl --shutdown")); + assert!(WSL2_VHDX_FIX[1].contains("Optimize-VHD")); + assert!(WSL2_VHDX_FIX[1].contains("docker_data.vhdx")); + assert!(WSL2_VHDX_FIX_GUI.contains("Purge data")); +} + +#[test] +fn base_images_are_recognised_by_reference_for_display_only() { + assert!(is_base_image_reference("ghcr.io/shadowdao/triple-c-sandbox:latest")); + assert!(is_base_image_reference("triple-c-sandbox:latest")); + assert!(is_base_image_reference("triple-c:latest")); + + // A registry port must not be mistaken for a tag separator. + assert!(is_base_image_reference("localhost:5000/triple-c-sandbox:latest")); + assert!(is_base_image_reference("registry.example.com:8443/triple-c-sandbox")); + + // A project's own snapshot is not a base image, and neither is anything of + // the user's. + assert!(!is_base_image_reference("triple-c-snapshot-abc:latest")); + assert!(!is_base_image_reference("localhost:5000/postgres:17")); + assert!(!is_base_image_reference("triple-c-gateway:latest")); + assert!(!is_base_image_reference("postgres:17-alpine")); +} + +// --------------------------------------------------------------------------- +// IPC contract +// --------------------------------------------------------------------------- + +#[test] +fn reclaim_targets_round_trip_through_the_wire_format() { + // The frontend ticks an item and hands the very same `target` object back, + // so the tagged representation has to survive the trip unchanged in both + // directions. + for target in all_reclaim_targets() { + let json = serde_json::to_string(&target).unwrap(); + let back: ReclaimTarget = serde_json::from_str(&json).unwrap(); + assert_eq!(target, back, "{}", json); + assert!(json.contains("\"kind\""), "{}", json); + } +} + +#[test] +fn destructive_targets_round_trip_too() { + let target = DestructiveTarget::RollbackPin { + project_id: "p1".to_string(), + tag: "pre-migration-20260101-101500".to_string(), + }; + let json = serde_json::to_string(&target).unwrap(); + assert!(json.contains("\"kind\":\"rollback_pin\""), "{}", json); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + target + ); +} + +#[test] +fn the_report_serialises_as_snake_case_like_every_other_ipc_struct() { + let report = DiskUsageReport { + projects: vec![ProjectDiskRow { + project_id: "p1".to_string(), + project_name: "whp".to_string(), + snapshot_commit_layers: 14, + container_writable_bytes: 868_000_000, + ..Default::default() + }], + ..Default::default() + }; + let json = serde_json::to_value(&report).unwrap(); + assert_eq!(json["projects"][0]["snapshot_commit_layers"], 14); + assert_eq!(json["projects"][0]["base_lineage_known"], false); + assert_eq!(json["projects"][0]["container_writable_bytes"], 868_000_000i64); + assert!(json["orphan_volumes_unavailable"].is_null()); + // `Option` must reach the frontend as null, not be omitted — the TS + // type is `number | null`, matching every other optional in `types.ts`. + assert!(json["projects"][0]["snapshot_above_base_bytes"].is_null()); +} diff --git a/app/src-tauri/src/docker/mod.rs b/app/src-tauri/src/docker/mod.rs index 97113fe..ad2b5ac 100644 --- a/app/src-tauri/src/docker/mod.rs +++ b/app/src-tauri/src/docker/mod.rs @@ -1,6 +1,7 @@ pub mod ca_certs; pub mod client; pub mod container; +pub mod disk; pub mod image; pub mod exec; pub mod gateway; @@ -24,6 +25,10 @@ pub use exec::*; pub use legacy_cleanup::*; #[allow(unused_imports)] pub use migration::*; +// `disk` is also deliberately kept namespaced. Its `scan`, `reclaim` and +// `destroy` are meaningless as bare names, and `disk::destroy` reading as what +// it is at every call site is worth more than the brevity. + // Deliberately *not* re-exported flat: `ca_certs::resolve` and // `ca_certs::CA_MOUNT_DIR` are far clearer than bare `resolve` in a module that // already re-exports five other namespaces. diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 9a92154..6661d2a 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -464,6 +464,12 @@ pub fn run() { commands::docker_commands::build_image, commands::docker_commands::get_container_info, commands::docker_commands::list_sibling_containers, + // Disk + commands::docker_commands::get_docker_disk_usage, + commands::docker_commands::list_reclaimable, + commands::docker_commands::reclaim, + commands::docker_commands::destroy_project_disk_object, + commands::docker_commands::sweep_orphaned_snapshots, // Projects commands::project_commands::list_projects, commands::project_commands::add_project, diff --git a/app/src/components/projects/home/format.ts b/app/src/components/projects/home/format.ts index 171c8e0..2f85c06 100644 --- a/app/src/components/projects/home/format.ts +++ b/app/src/components/projects/home/format.ts @@ -1,10 +1,16 @@ /** Shared formatting helpers for the Project Home views. */ +import { formatBytes as shared } from "../../../lib/formatBytes"; + +/** + * File sizes in Project Home, ÷1024 with `KB`/`MB`/`GB` labels. + * + * Kept as a named re-export rather than deleted: three modules import it from + * here, and the binary/decimal-label pairing is a Project Home convention + * rather than the app-wide default. The implementation is `lib/formatBytes`. + */ export function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + return shared(bytes, { binary: true }); } /** "2h ago" / "3d ago". Returns null for unparseable timestamps. */ diff --git a/app/src/components/projects/migrationCopy.ts b/app/src/components/projects/migrationCopy.ts index 026d541..d5e8169 100644 --- a/app/src/components/projects/migrationCopy.ts +++ b/app/src/components/projects/migrationCopy.ts @@ -7,6 +7,7 @@ */ import type { PackageFailure } from "../../lib/types"; +import { formatBytes } from "../../lib/formatBytes"; /** * What re-attaches untouched. These are not copied, rebuilt or re-authenticated @@ -62,14 +63,7 @@ export const REPLAY_COST = /** `41.0 MB`. Sizes here are informational, so the friendlier decimal unit. */ export function formatDataSize(bytes: number): string { - const units = ["B", "KB", "MB", "GB", "TB"]; - let value = bytes; - let unit = 0; - while (value >= 1000 && unit < units.length - 1) { - value /= 1000; - unit += 1; - } - return unit === 0 ? `${bytes} B` : `${value.toFixed(1)} ${units[unit]}`; + return formatBytes(bytes); } /** `1 Mar` — short enough to sit inline in the banner sentence. */ diff --git a/app/src/components/settings/DiskProjectTable.tsx b/app/src/components/settings/DiskProjectTable.tsx new file mode 100644 index 0000000..0c9b9e3 --- /dev/null +++ b/app/src/components/settings/DiskProjectTable.tsx @@ -0,0 +1,196 @@ +import OverflowMenu from "../ui/OverflowMenu"; +import Tooltip from "../ui/Tooltip"; +import StatusIndicator from "../ui/StatusIndicator"; +import { formatBytes, formatBytesDelta } from "../../lib/formatBytes"; +import type { DestructiveItem, ProjectDiskRow } from "../../lib/types"; + +interface Props { + rows: ProjectDiskRow[]; + /** Per-project destructive objects, keyed off the same rows. */ + destructive: DestructiveItem[]; + onDestroy: (item: DestructiveItem) => void; +} + +const LAYERS_HELP = + "Commit layers stacked above the base image — one for every time this project's container was recreated. Nothing merges them, so each one is paid for permanently until the snapshot is compacted."; + +const NEXT_COMMIT_HELP = + "The container's writable layer. This is exactly what the next recreation will stack onto the snapshot, and it never comes back after that."; + +/** `—` for a column with nothing in it, so an empty cell never reads as zero. */ +function cell(bytes: number, present: boolean) { + return present ? formatBytes(bytes) : "—"; +} + +/** + * The per-project table — the mental model users actually have of this app. + * + * ## Why "Layers" is a column and not a detail + * + * A total tells a user their disk is full. The layer count tells them *why*: + * every container recreation runs `docker commit`, a commit stacks a layer and + * never rewrites one, and 24 different settings changes trigger a recreation. + * A project sitting at 14 layers has paid for fourteen full copies of whatever + * changed, and no total on its own ever says that. + * + * "Next commit adds" is the same fact from the other end: it is the container's + * writable layer, i.e. exactly what the *next* recreation will bake in + * permanently. Seeing 868 MB there is what makes Compact worth doing before the + * next settings change rather than after it. + */ +export default function DiskProjectTable({ rows, destructive, onDestroy }: Props) { + if (rows.length === 0) { + return ( +

+ No projects to account for. +

+ ); + } + + return ( + // Wide content scrolls inside its own container; the panel itself must + // never scroll sideways. +
+ + + + + + + + + + + + + + + + {rows.map((row) => { + const mine = destructive.filter((d) => d.project_id === row.project_id); + return ( + + + + + + + + + + + ); + })} + +
+ Disk used by each project, largest first +
+ Project + + Snapshot + + Layers + {/* `Tooltip` renders a portalled div with no `role` and no + `aria-describedby`, so its text reaches no assistive tech and + the trigger announces as "Help". These two headers are + meaningless without their explanation, so it is also emitted + as screen-reader-only text. */} + + — {LAYERS_HELP} + + Next commit adds + + — {NEXT_COMMIT_HELP} + + Home vol + + Config vol + + Total + + Actions +
+
+ {row.project_name} + {row.migrating && ( + + )} +
+ + {row.project_id} + +
+ {/* `null` means the split could not be measured. Rendering it + as 0 B would be the one guessed number in this table. */} + {cell( + row.snapshot_above_base_bytes ?? -1, + row.snapshot_exists && row.snapshot_above_base_bytes !== null, + )} + {row.snapshot_exists && ( + + {/* The base is shared by every project, so charging it to + each row would show the same 4.7 GB eight times. The + headline figure is what is unique to this project; + the total is here for anyone reconciling against + `docker images`. */} + {formatBytes(row.snapshot_bytes)} with base + + )} + + {!row.snapshot_exists ? ( + "—" + ) : !row.base_lineage_known ? ( + // The base this descends from is unknown, so the count + // includes the base's own layers and does not mean + // "recreations". Saying so beats printing a wrong number. + + unknown + + ) : ( + + {row.snapshot_commit_layers} + {/* Never colour alone: a count worth acting on says so in + a word, which is also what a screen reader gets. */} + {row.snapshot_commit_layers > 5 && ( + + stacked + + )} + + )} + + {row.container_exists + ? formatBytesDelta(row.container_writable_bytes) + : "—"} + + {cell(row.home_volume_bytes, row.home_volume_present)} + + {cell(row.config_volume_bytes, row.config_volume_present)} + + {formatBytes(row.total_bytes)} + + {mine.length > 0 && ( + ({ + label: `Delete ${item.label.toLowerCase()} (${formatBytes(item.bytes)})…`, + onSelect: () => onDestroy(item), + danger: true, + disabled: item.blocked !== null, + }))} + /> + )} +
+
+ ); +} diff --git a/app/src/components/settings/DiskSettings.test.tsx b/app/src/components/settings/DiskSettings.test.tsx new file mode 100644 index 0000000..07ec58d --- /dev/null +++ b/app/src/components/settings/DiskSettings.test.tsx @@ -0,0 +1,631 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, act, waitFor, within } from "@testing-library/react"; +import DiskSettings from "./DiskSettings"; +import type { + DiskUsageReport, + ProjectDiskRow, + ReclaimItem, + ReclaimPlan, + ReclaimTarget, +} from "../../lib/types"; + +const getDockerDiskUsage = vi.fn(); +const listReclaimable = vi.fn(); +const reclaim = vi.fn(); +const destroyProjectDiskObject = vi.fn(); + +vi.mock("../../lib/tauri-commands", () => ({ + getDockerDiskUsage: () => getDockerDiskUsage(), + listReclaimable: (report: DiskUsageReport) => listReclaimable(report), + reclaim: (targets: ReclaimTarget[]) => reclaim(targets), + destroyProjectDiskObject: (target: unknown, confirmation: string) => + destroyProjectDiskObject(target, confirmation), + sweepOrphanedSnapshots: vi.fn(async () => ({})), +})); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const row = (over: Partial = {}): ProjectDiskRow => ({ + project_id: "p-whp", + project_name: "whp", + snapshot_image: "triple-c-snapshot-p-whp:latest", + snapshot_exists: true, + snapshot_bytes: 12_273_392_374, + snapshot_shared_bytes: 3_832_425_659, + snapshot_commit_layers: 14, + base_lineage_known: true, + snapshot_above_base_bytes: 8_440_966_715, + container_exists: true, + container_running: false, + container_writable_bytes: 868_000_000, + home_volume_bytes: 4_860_000_000, + home_volume_present: true, + config_volume_bytes: 427_000_000, + config_volume_present: true, + total_bytes: 14_595_966_715, + migrating: false, + ...over, +}); + +const report = (over: Partial = {}): DiskUsageReport => ({ + scanned_at: "2026-08-23T10:00:00Z", + projects: [row()], + base_images: [ + { + reference: "ghcr.io/shadowdao/triple-c-sandbox:latest", + bytes: 4_724_062_366, + shared_bytes: 4_723_860_396, + containers: 2, + is_labelled_base: true, + }, + ], + base_images_bytes: 4_724_062_366, + orphan_image_bytes: 11_900_000_000, + orphan_image_count: 3, + orphan_volumes: [], + orphan_volume_bytes: 0, + orphan_volumes_unavailable: null, + build_cache: { + total_bytes: 28_000_000_000, + reclaimable_bytes: 28_000_000_000, + stale_bytes: 20_000_000_000, + source: "buildx du", + cli_error: null, + }, + images_total_bytes: 104_500_000_000, + containers_total_bytes: 7_497_000_000, + volumes_total_bytes: 72_890_000_000, + triple_c_total_bytes: 116_000_000_000, + host: { + docker_root_dir: "/var/lib/docker", + operating_system: "Docker Desktop", + is_docker_desktop: true, + is_windows_host: false, + vhdx_applies: false, + vhdx_note: "", + vhdx_fix: [], + vhdx_fix_gui: "", + }, + ...over, +}); + +const item = (over: Partial = {}): ReclaimItem => ({ + target: { kind: "dangling_snapshots" }, + safety: "safe", + daemon_wide: false, + label: "Superseded snapshot layers (3 images)", + detail: "Untagged images left behind by past container recreations.", + bytes: 11_900_000_000, + bytes_are_exact: true, + bytes_floor: null, + blocked: null, + ...over, +}); + +const plan = (over: Partial = {}): ReclaimPlan => ({ + items: [item()], + destructive: [], + store_error: null, + ...over, +}); + +async function renderAndScan() { + render(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Scan" })); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + getDockerDiskUsage.mockResolvedValue(report()); + listReclaimable.mockResolvedValue(plan()); + reclaim.mockResolvedValue({ results: [], total_freed_bytes: 0 }); +}); + +// --------------------------------------------------------------------------- + +describe("DiskSettings", () => { + it("never scans until the button is pressed", async () => { + // `df()` walks the whole daemon and takes seconds on a large store, and + // AccordionSection unmounts its body when collapsed — so a scan on mount + // would re-run every time the section was opened. + render(); + await act(async () => { + await Promise.resolve(); + }); + expect(getDockerDiskUsage).not.toHaveBeenCalled(); + expect(screen.getByText(/never done for you/)).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Scan" })); + }); + expect(getDockerDiskUsage).toHaveBeenCalledTimes(1); + }); + + it("says it is scanning in words, not only in colour", async () => { + let resolve: (value: DiskUsageReport) => void = () => {}; + getDockerDiskUsage.mockReturnValue( + new Promise((r) => { + resolve = r; + }), + ); + render(); + fireEvent.click(screen.getByRole("button", { name: "Scan" })); + expect(screen.getByText("Scanning")).toBeInTheDocument(); + await act(async () => { + resolve(report()); + }); + await waitFor(() => expect(screen.getByText(/^Scanned /)).toBeInTheDocument()); + }); + + it("shows the layer count and the cost of the next commit", async () => { + // The two numbers that explain the growth mechanism. A total alone never + // says why the disk filled up. + await renderAndScan(); + const projectRow = await screen.findByTestId("disk-row-p-whp"); + expect(within(projectRow).getByText("14")).toBeInTheDocument(); + expect(within(projectRow).getByText("+868.0 MB")).toBeInTheDocument(); + expect(within(projectRow).getByText("14.6 GB")).toBeInTheDocument(); + }); + + it("refuses to present a layer count that does not mean recreations", async () => { + // Without `triple-c.base-image-id` — the normal case for a project created + // before that label existed — the count includes the base's own ~15 layers. + // Printing it beside a header that says "one per recreation" would be a + // wrong number in the column the table exists for. + getDockerDiskUsage.mockResolvedValue( + report({ projects: [row({ base_lineage_known: false, snapshot_commit_layers: 17 })] }), + ); + await renderAndScan(); + const projectRow = await screen.findByTestId("disk-row-p-whp"); + expect(within(projectRow).getByText("unknown")).toBeInTheDocument(); + expect(within(projectRow).queryByText("17")).not.toBeInTheDocument(); + }); + + it("renders an unmeasurable snapshot split as a dash, never as zero", async () => { + getDockerDiskUsage.mockResolvedValue( + report({ projects: [row({ snapshot_above_base_bytes: null })] }), + ); + await renderAndScan(); + const projectRow = await screen.findByTestId("disk-row-p-whp"); + expect(within(projectRow).queryByText("0 B")).not.toBeInTheDocument(); + expect(within(projectRow).getAllByText("—").length).toBeGreaterThan(0); + }); + + it("marks a heavily stacked snapshot with a word, not just a colour", async () => { + await renderAndScan(); + const projectRow = await screen.findByTestId("disk-row-p-whp"); + expect(within(projectRow).getByText("stacked")).toBeInTheDocument(); + }); + + it("charges the shared base to the globals, not to every project row", async () => { + // The base is one 4.7 GB image every project descends from. Counting it per + // row would show it eight times and make the column meaningless. + await renderAndScan(); + const projectRow = await screen.findByTestId("disk-row-p-whp"); + expect(within(projectRow).getByText("8.4 GB")).toBeInTheDocument(); + expect(within(projectRow).getByText(/12\.3 GB with base/)).toBeInTheDocument(); + }); + + it("plans from the report it already has rather than scanning twice", async () => { + await renderAndScan(); + await waitFor(() => expect(listReclaimable).toHaveBeenCalledTimes(1)); + expect(getDockerDiskUsage).toHaveBeenCalledTimes(1); + expect(listReclaimable).toHaveBeenCalledWith(expect.objectContaining({ projects: expect.any(Array) })); + }); + + // ------------------------------------------------------------------------- + // Selection plumbing + // ------------------------------------------------------------------------- + + it("sends exactly the ticked targets and nothing else", async () => { + listReclaimable.mockResolvedValue( + plan({ + items: [ + item(), + item({ + target: { kind: "migration_staging" }, + label: "Migration staging files", + bytes: 500_000_000, + }), + ], + }), + ); + await renderAndScan(); + await screen.findByTestId("disk-safe-bucket"); + + const boxes = screen.getAllByRole("checkbox"); + await act(async () => { + fireEvent.click(boxes[1]); + }); + expect(screen.getByText(/1 selected, 500\.0 MB/)).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Reclaim" })); + }); + expect(reclaim).toHaveBeenCalledWith([{ kind: "migration_staging" }]); + }); + + it("clears the tick list once the reclaim has run", async () => { + // The plan's rows describe objects the reclaim just removed; leaving them + // ticked lets the user fire the same call again against nothing. + await renderAndScan(); + await screen.findByTestId("disk-safe-bucket"); + fireEvent.click(screen.getAllByRole("checkbox")[0]); + expect(screen.getByText(/1 selected/)).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Reclaim" })); + }); + expect(screen.queryByTestId("disk-safe-bucket")).not.toBeInTheDocument(); + expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); + // And it says why the list is gone rather than claiming nothing was found. + expect(screen.getByTestId("disk-plan-stale").textContent).toMatch( + /measured before that last action/, + ); + }); + + it("says why the build-cache figure is the under-reporting one", async () => { + // Without this, a `buildx du` failure silently shows `docker system df`'s + // number, which under-reports what a prune would free. + getDockerDiskUsage.mockResolvedValue( + report({ + build_cache: { + total_bytes: 28_000_000_000, + reclaimable_bytes: 1_000_000, + stale_bytes: 0, + source: "system df", + cli_error: "`docker buildx du` failed: executable not found", + }, + }), + ); + await renderAndScan(); + const globals = await screen.findByTestId("disk-globals"); + expect(globals.textContent).toMatch(/under-reports what a prune would free/); + expect(globals.textContent).toMatch(/executable not found/); + }); + + it("cannot reclaim with nothing ticked", async () => { + await renderAndScan(); + await screen.findByTestId("disk-safe-bucket"); + expect(screen.getByRole("button", { name: "Reclaim" })).toBeDisabled(); + expect(screen.getByText("Nothing ticked.")).toBeInTheDocument(); + }); + + it("refuses to tick a blocked item", async () => { + listReclaimable.mockResolvedValue( + plan({ + items: [item({ blocked: "A base-image migration is in flight for this project." })], + }), + ); + await renderAndScan(); + await screen.findByTestId("disk-safe-bucket"); + const box = screen.getByRole("checkbox"); + expect(box).toBeDisabled(); + expect( + screen.getByText("A base-image migration is in flight for this project."), + ).toBeInTheDocument(); + }); + + it("keeps semi-safe work out of the one-button bucket", async () => { + // Compaction is a rewrite and cache clearing costs a re-download. Neither + // may be swept up by a Reclaim press aimed at the free wins. + listReclaimable.mockResolvedValue( + plan({ + items: [ + item(), + item({ + target: { kind: "compact_snapshot", project_id: "p-whp" }, + safety: "semi_safe", + label: "Compact whp's snapshot", + bytes: 5_100_000_000, + bytes_are_exact: false, + bytes_floor: 0, + }), + ], + }), + ); + await renderAndScan(); + + const safe = await screen.findByTestId("disk-safe-bucket"); + expect(within(safe).getAllByRole("checkbox")).toHaveLength(1); + expect(within(safe).queryByText(/Compact whp/)).not.toBeInTheDocument(); + + const semi = screen.getByTestId("disk-semi-bucket"); + expect(within(semi).getByText("Compact whp's snapshot")).toBeInTheDocument(); + expect(within(semi).queryByRole("checkbox")).not.toBeInTheDocument(); + }); + + it("marks a compaction's yield as a bound, never as a measurement", async () => { + listReclaimable.mockResolvedValue( + plan({ + items: [ + item({ + target: { kind: "compact_snapshot", project_id: "p-whp" }, + safety: "semi_safe", + label: "Compact whp's snapshot", + bytes: 5_100_000_000, + bytes_are_exact: false, + bytes_floor: 0, + }), + ], + }), + ); + await renderAndScan(); + const semi = await screen.findByTestId("disk-semi-bucket"); + expect(within(semi).getByText("up to 5.1 GB")).toBeInTheDocument(); + }); + + it("says out loud when an action reaches the whole daemon", async () => { + // The user's daemon also holds unrelated postgres and site-builder work, + // and a build-cache prune takes their warm cache with ours. + listReclaimable.mockResolvedValue( + plan({ + items: [ + item({ + target: { kind: "build_cache", all: true }, + daemon_wide: true, + label: "Build cache, all of it", + bytes: 28_000_000_000, + }), + ], + }), + ); + await renderAndScan(); + const safe = await screen.findByTestId("disk-safe-bucket"); + expect(within(safe).getByText("whole daemon")).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // Orphan copy — the correction that matters most + // ------------------------------------------------------------------------- + + it("says what a 'no matching project' volume is derived from", async () => { + // An idle live project has volumes, no container and possibly no image — + // indistinguishable from a deleted one unless you consult the project + // store. The copy must not invite the inference that made that mistake. + getDockerDiskUsage.mockResolvedValue( + report({ + orphan_volumes: [ + { + name: "triple-c-home-gone", + project_id: "gone", + bytes: 900_000, + role: "home", + created_at: "2026-03-14T09:00:00Z", + }, + ], + orphan_volume_bytes: 900_000, + }), + ); + await renderAndScan(); + const globals = await screen.findByTestId("disk-globals"); + expect( + within(globals).getByText(/Volumes with no matching project in Triple-C/), + ).toBeInTheDocument(); + // The sentence is split by an , so match on the container's text. + expect(globals.textContent).toMatch(/is not inferred from a project being stopped/i); + expect(globals.textContent).toMatch( + /A project you have not opened in a while has no container and no snapshot either, and that is normal/i, + ); + }); + + it("explains a suppressed orphan list instead of showing an empty one", async () => { + // With the project store unreadable every project's volumes look + // unclaimed. Showing nothing is right; showing nothing *silently* is not. + getDockerDiskUsage.mockResolvedValue( + report({ + orphan_volumes: [], + orphan_volumes_unavailable: + "projects.json could not be read, so there is no way to tell an orphaned volume from a live project's.", + }), + ); + await renderAndScan(); + const banner = await screen.findByTestId("disk-store-error"); + expect(within(banner).getByText("Could not read the project list")).toBeInTheDocument(); + expect(within(banner).getByText(/no way to tell/)).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // Windows / WSL2 + // ------------------------------------------------------------------------- + + it("spells out that pruning will not shrink C: on Docker Desktop for Windows", async () => { + getDockerDiskUsage.mockResolvedValue( + report({ + host: { + docker_root_dir: "/var/lib/docker", + operating_system: "Docker Desktop", + is_docker_desktop: true, + is_windows_host: true, + vhdx_applies: true, + vhdx_note: "Docker Desktop keeps this daemon inside ext4.vhdx on C:.", + vhdx_fix: ["wsl --shutdown", 'Optimize-VHD -Path "…docker_data.vhdx" -Mode Full'], + vhdx_fix_gui: "Docker Desktop → Settings → Resources → Advanced → Clean up / Purge data", + }, + }), + ); + await renderAndScan(); + const note = await screen.findByTestId("disk-vhdx-note"); + expect(note.textContent).toMatch(/Warning: reclaiming here will not shrink your C: drive/); + expect(within(note).getByText(/wsl --shutdown/)).toBeInTheDocument(); + expect(within(note).getByText(/Optimize-VHD/)).toBeInTheDocument(); + expect(within(note).getByText(/Purge data/)).toBeInTheDocument(); + }); + + it("keeps the vhdx note off a host it does not apply to", async () => { + await renderAndScan(); + await screen.findByTestId("disk-globals"); + expect(screen.queryByTestId("disk-vhdx-note")).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // Destructive path + // ------------------------------------------------------------------------- + + it("needs the project name typed before it will delete a config volume", async () => { + listReclaimable.mockResolvedValue( + plan({ + destructive: [ + { + target: { kind: "config_volume", project_id: "p-whp" }, + project_id: "p-whp", + project_name: "whp", + label: "Claude config volume", + loses: "The Claude login credential, plugins, and EVERY conversation transcript.", + bytes: 427_000_000, + blocked: null, + }, + ], + }), + ); + destroyProjectDiskObject.mockResolvedValue({ + target: null, + destroyed: { kind: "config_volume", project_id: "p-whp" }, + ok: true, + freed_bytes: 427_000_000, + projected_bytes: null, + message: "Removed volume.", + }); + + await renderAndScan(); + await screen.findByTestId("disk-row-p-whp"); + + fireEvent.click(screen.getByRole("button", { name: "Delete whp data" })); + await act(async () => { + fireEvent.click(screen.getByRole("menuitem", { name: /Delete claude config volume/ })); + }); + + const dialog = screen.getByRole("dialog"); + const confirm = within(dialog).getByRole("button", { name: "Delete claude config volume" }); + expect(confirm).toBeDisabled(); + expect(within(dialog).getByText(/EVERY conversation transcript/)).toBeInTheDocument(); + + // The wrong name does not open the gate. + fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "who" } }); + expect(confirm).toBeDisabled(); + + fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "whp" } }); + expect(confirm).toBeEnabled(); + await act(async () => { + fireEvent.click(confirm); + }); + expect(destroyProjectDiskObject).toHaveBeenCalledWith( + { kind: "config_volume", project_id: "p-whp" }, + "whp", + ); + }); + + it("keeps the confirmation open and busy while the deletion runs", async () => { + // The modal used to be unmounted before the call was awaited, which made + // its whole busy path dead code and left a multi-second volume removal with + // no indication it was happening. + listReclaimable.mockResolvedValue( + plan({ + destructive: [ + { + target: { kind: "home_volume", project_id: "p-whp" }, + project_id: "p-whp", + project_name: "whp", + label: "Home volume", + loses: "Shell history and toolchains.", + bytes: 4_860_000_000, + blocked: null, + }, + ], + }), + ); + let finish: (value: unknown) => void = () => {}; + destroyProjectDiskObject.mockReturnValue(new Promise((r) => (finish = r))); + + await renderAndScan(); + await screen.findByTestId("disk-row-p-whp"); + fireEvent.click(screen.getByRole("button", { name: "Delete whp data" })); + await act(async () => { + fireEvent.click(screen.getByRole("menuitem", { name: /Delete home volume/ })); + }); + + const dialog = screen.getByRole("dialog"); + fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "whp" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete home volume" })); + + // Still open, and saying so. + await waitFor(() => + expect(screen.getByRole("button", { name: "Working…" })).toBeDisabled(), + ); + + await act(async () => { + finish({ + target: null, + destroyed: { kind: "home_volume", project_id: "p-whp" }, + ok: true, + freed_bytes: 4_860_000_000, + projected_bytes: null, + message: "Removed volume.", + }); + }); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + }); + + it("never routes a destructive object through the bulk Reclaim button", async () => { + listReclaimable.mockResolvedValue( + plan({ + destructive: [ + { + target: { kind: "home_volume", project_id: "p-whp" }, + project_id: "p-whp", + project_name: "whp", + label: "Home volume", + loses: "Shell history, dotfiles, toolchains.", + bytes: 4_860_000_000, + blocked: null, + }, + ], + }), + ); + await renderAndScan(); + const safe = await screen.findByTestId("disk-safe-bucket"); + // One tick, for the dangling images — the home volume is not in this list + // at any price. + expect(within(safe).getAllByRole("checkbox")).toHaveLength(1); + expect(within(safe).queryByText(/Home volume/)).not.toBeInTheDocument(); + }); + + it("reports what was actually freed against what was projected", async () => { + reclaim.mockResolvedValue({ + results: [ + { + target: { kind: "compact_snapshot", project_id: "p-whp" }, + destroyed: null, + ok: true, + freed_bytes: 5_100_000_000, + projected_bytes: 7_000_000_000, + message: "Rewrote the snapshot into a single layer.", + }, + ], + total_freed_bytes: 5_100_000_000, + }); + await renderAndScan(); + await screen.findByTestId("disk-safe-bucket"); + fireEvent.click(screen.getAllByRole("checkbox")[0]); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Reclaim" })); + }); + + const outcome = await screen.findByTestId("disk-outcome"); + expect(within(outcome).getByText("Reclaimed 5.1 GB")).toBeInTheDocument(); + expect(within(outcome).getByText(/projected up to 7\.0 GB, actually 5\.1 GB/)).toBeInTheDocument(); + }); + + it("surfaces a scan failure as an alert", async () => { + getDockerDiskUsage.mockRejectedValue("Could not read Docker disk usage: no such host"); + render(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Scan" })); + }); + expect(screen.getByRole("alert")).toHaveTextContent(/no such host/); + }); +}); diff --git a/app/src/components/settings/DiskSettings.tsx b/app/src/components/settings/DiskSettings.tsx new file mode 100644 index 0000000..720fcfc --- /dev/null +++ b/app/src/components/settings/DiskSettings.tsx @@ -0,0 +1,559 @@ +import { useEffect, useState } from "react"; +import Button from "../ui/Button"; +import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator"; +import Modal from "../ui/Modal"; +import TypedConfirmModal from "../ui/TypedConfirmModal"; +import DiskProjectTable from "./DiskProjectTable"; +import { useDiskUsage } from "../../hooks/useDiskUsage"; +import { formatBytes, formatBytesCeiling } from "../../lib/formatBytes"; +import type { DestructiveItem, ReclaimItem, ReclaimTarget } from "../../lib/types"; + +/** A stable key for a target, so ticks survive a re-plan. */ +function targetKey(target: ReclaimTarget): string { + return JSON.stringify(target); +} + +/** + * Where the disk went, and how to get it back. + * + * ## Why the scan is a button + * + * `getDockerDiskUsage` is `GET /system/df`, which walks every image, container + * and volume on the daemon computing shared-layer sizes — seconds on a 100 GB + * store, and the only call that produces those numbers at all. So nothing here + * runs on open, on a timer, or on a re-render. + * + * ## Why the buckets are separated the way they are + * + * Safe work (dangling images, ownerless pins, build cache, volumes whose + * project id is not in the project store) gets one list of ticks and one + * button, because none of it can lose anything a user has. Note what the last + * of those is derived from: membership in Triple-C's own project list, never + * "this project has no container" — an idle live project looks exactly like a + * deleted one from the daemon's side, and mistaking the two would delete + * credentials and transcripts. Semi-safe work (compaction, cache clearing) is a rewrite or a + * re-download and is confirmed one at a time. Destructive work — a live + * project's volumes, its snapshot, a live rollback pin — is not in either list: + * it is reached only from that project's own row, behind a typed confirmation, + * and the backend refuses it in bulk by taking a different type entirely. + */ +export default function DiskSettings() { + const { + report, + plan, + scanning, + working, + error, + outcome, + scan, + runReclaim, + destroy, + runSweep, + clearOutcome, + } = useDiskUsage(); + const [ticked, setTicked] = useState>(new Set()); + const [confirming, setConfirming] = useState(null); + const [destroying, setDestroying] = useState(null); + + // The plan is dropped after any reclaim, so a tick can never outlive the row + // it was made against and be re-fired at an object that is already gone. + useEffect(() => { + if (!plan) setTicked(new Set()); + }, [plan]); + + const safeItems = plan?.items.filter((i) => i.safety === "safe") ?? []; + const semiItems = plan?.items.filter((i) => i.safety === "semi_safe") ?? []; + const selected = safeItems.filter( + (i) => i.blocked === null && ticked.has(targetKey(i.target)), + ); + const selectedBytes = selected.reduce((sum, i) => sum + i.bytes, 0); + + const toggle = (item: ReclaimItem) => { + setTicked((prev) => { + const next = new Set(prev); + const key = targetKey(item.target); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const tone: StatusTone = scanning ? "unknown" : report ? "ok" : "off"; + const statusLabel = scanning + ? "Scanning" + : report + ? `Scanned ${new Date(report.scanned_at).toLocaleTimeString()}` + : "Not scanned"; + + return ( +
+ {/* --- Why this section exists ------------------------------------- */} +

+ Every time a container is recreated, Triple-C commits it — and a commit{" "} + stacks a new layer rather + than rewriting the old one. Deleting a file afterwards writes a whiteout; the + bytes underneath stay forever. Twenty-four different settings changes trigger a + recreation, so a project can quietly accumulate a dozen multi-gigabyte layers it + no longer uses any of. +

+ + {/* --- Scan --------------------------------------------------------- */} +
+ + + + Reads the whole Docker store; takes a few seconds on a large one. + +
+ + {error && ( +

+ {error} +

+ )} + + {!report && !scanning && ( +

+ Nothing has been measured yet. Scanning is the only thing here that costs + anything, so it is never done for you. +

+ )} + + {report && ( + <> + {/* --- Windows / WSL2, mandatory when it applies ----------------- */} + {report.host.vhdx_applies && ( +
+ {/* `StatusIndicator` has no warning tone — `error` would put a + red glyph in a warning-toned panel. This is advisory, so it + carries its own glyph beside the words rather than relying on + the panel's colour. */} +

+ Warning: reclaiming here will not + shrink your C: drive +

+

+ {report.host.vhdx_note} +

+

+ To actually give the space back to C:, run these in PowerShell as + administrator after reclaiming: +

+
+                {report.host.vhdx_fix.join("\n")}
+              
+

+ Or, without Hyper-V: {report.host.vhdx_fix_gui}. +

+
+ )} + + {/* --- Per-project table ---------------------------------------- */} +
+

+ By project +

+ +
+ + {/* --- Globals --------------------------------------------------- */} +
+

+ Shared and left over +

+
+
+ Base images ({report.base_images.length}) — shared by every project +
+
+ {formatBytes(report.base_images_bytes)} +
+ +
+ Superseded images from past recreations ({report.orphan_image_count}) +
+
+ {formatBytes(report.orphan_image_bytes)} +
+ +
+ Volumes with no matching project in Triple-C ( + {report.orphan_volumes.length}) +
+
+ {formatBytes(report.orphan_volume_bytes)} +
+ +
+ Build cache — whole daemon, + not just Triple-C{" "} + + (via {report.build_cache.source}) + +
+
+ {formatBytes(report.build_cache.reclaimable_bytes)} of{" "} + {formatBytes(report.build_cache.total_bytes)} +
+ +
+ Attributable to Triple-C +
+
+ {formatBytes(report.triple_c_total_bytes)} +
+ +
+ Everything on this daemon, yours included +
+
+ {formatBytes( + report.images_total_bytes + + report.containers_total_bytes + + report.volumes_total_bytes, + )} +
+
+ {report.build_cache.cli_error && ( +

+ {/* Without this the panel silently shows `docker system df`'s + under-reported build-cache figure and the user has no way + to know why it disagrees with their terminal. */} + Build-cache figures fell back to docker system df, which + under-reports what a prune would free: {report.build_cache.cli_error} +

+ )} + {report.orphan_volumes.length > 0 && ( +

+ “Volumes with no matching project” above means only that the + volume’s project id is not in your project list — it is{" "} + not inferred from a project being stopped or having no image. A project you have not opened in a + while has no container and no snapshot either, and that is normal, so + each of these is ticked individually and shows the date Docker created + it. +

+ )} +

+ Docker stores this at{" "} + {report.host.docker_root_dir || "an unknown path"} + {report.host.is_docker_desktop && " — a path inside the Docker Desktop VM, not on your filesystem"}. +

+
+ + {/* --- Store failure, if any ------------------------------------ */} + {report.orphan_volumes_unavailable && ( +
+ +

+ {report.orphan_volumes_unavailable} +

+
+ )} + + {/* --- The plan was dropped by a reclaim -------------------------- */} + {!plan && ( +

+ The totals above were measured before that last action. Scan again to see + what is left to reclaim. +

+ )} + + {/* --- Safe reclaim ---------------------------------------------- */} + {plan && ( +
+

+ Safe to reclaim +

+ {safeItems.length === 0 ? ( +

+ Nothing here — no leftovers were found. +

+ ) : ( + <> +

+ None of this is reachable any more, or all of it regenerates on demand. + Nothing you have made is in this list. +

+
    + {safeItems.map((item) => { + const key = targetKey(item.target); + return ( +
  • + +
  • + ); + })} +
+
+ + + {selected.length === 0 + ? "Nothing ticked." + : `${selected.length} selected, ${formatBytes(selectedBytes)}.`} + +
+ + )} +
+ )} + + {/* --- Semi-safe -------------------------------------------------- */} + {semiItems.length > 0 && ( +
+

+ Worth doing, one at a time +

+

+ Nothing here loses anything you have installed. Compacting rewrites a + project’s stacked layers into one; clearing caches deletes files + that refill themselves. Both take a moment and both are confirmed + separately. +

+
    + {semiItems.map((item) => ( +
  • + + {item.label} + + {item.detail} + + {item.blocked && ( + + {item.blocked} + + )} + + + + {/* A bound, not a measurement — rendered through a + different helper so it cannot read as a promise. */} + {item.bytes_are_exact + ? formatBytes(item.bytes) + : formatBytesCeiling(item.bytes)} + + + +
  • + ))} +
+
+ )} + + {/* --- Sweep ------------------------------------------------------ */} +
+ + + The same sweep that runs at startup and after every recreation. Unlike the + tick above it also reports what it refused to remove, which is how + a superseded image pinned by a stopped project shows itself. + +
+ + )} + + {/* --- Outcome ------------------------------------------------------- */} + {outcome && ( +
+
+ r.ok) ? "ok" : "error"} + label={`Reclaimed ${formatBytes(outcome.total_freed_bytes)}`} + className="text-xs" + /> + +
+
    + {outcome.results.map((result, index) => ( +
  • + {result.message} + {result.projected_bytes !== null && ( + <> + {" "} + + (projected {formatBytesCeiling(result.projected_bytes)}, actually{" "} + {formatBytes(result.freed_bytes)}) + + + )} +
  • + ))} +
+
+ )} + + {/* --- Semi-safe confirmation ---------------------------------------- */} + {confirming && ( + setConfirming(null)} + widthClassName="w-[30rem]" + footer={ + <> + + + + } + > +
+

{confirming.detail}

+ {confirming.target.kind === "compact_snapshot" && ( + <> +

+ The snapshot is rebuilt into a single layer while the old one is left + in place, so a failure at any point leaves this project exactly as it + is now. +

+

+ How much comes back depends on how much of those layers a later one + already replaced — it could be{" "} + {formatBytesCeiling(confirming.bytes)}, and it could be nothing at all. + You will be told the real figure when it finishes. +

+

+ One thing worth knowing: the rewritten image no longer shares the base + image with your other projects, so it carries its own copy of it. That + cost is already subtracted from the figure above, and if the rewrite + turns out not to come out ahead it is thrown away and the snapshot is + left exactly as it is. +

+ + )} + {confirming.target.kind === "clear_caches" && + confirming.target.include_rustup && ( +

+ Rust toolchains are included in this one. They are regenerable, but + getting them back is a download rather than a rebuild. +

+ )} +
+
+ )} + + {/* --- Destructive confirmation --------------------------------------- */} + {destroying && ( + setDestroying(null)} + onConfirm={async (typed) => { + // The modal stays mounted until the call settles, so its `busy` + // state is what the user sees while a multi-second volume removal + // runs. Clearing it first made the whole busy path dead code. + await destroy(destroying.target, typed); + setDestroying(null); + }} + > +

+ This removes{" "} + + {destroying.project_name} + + ’s {destroying.label.toLowerCase()}, freeing{" "} + {formatBytes(destroying.bytes)}. +

+

{destroying.loses}

+

+ Your mounted project folders live on the host and are not affected by this. +

+
+ )} +
+ ); +} diff --git a/app/src/components/settings/SettingsPanel.tsx b/app/src/components/settings/SettingsPanel.tsx index 062a9f4..8dce472 100644 --- a/app/src/components/settings/SettingsPanel.tsx +++ b/app/src/components/settings/SettingsPanel.tsx @@ -19,6 +19,7 @@ import WebTerminalSettings from "./WebTerminalSettings"; import SttSettings from "./SttSettings"; import SharedAuthSettings from "./SharedAuthSettings"; import CertificateSettings from "./CertificateSettings"; +import DiskSettings from "./DiskSettings"; export default function SettingsPanel() { const { appSettings, saveSettings } = useSettings(); @@ -173,6 +174,10 @@ export default function SettingsPanel() { + + + + diff --git a/app/src/components/ui/TypedConfirmModal.test.tsx b/app/src/components/ui/TypedConfirmModal.test.tsx new file mode 100644 index 0000000..bacaec3 --- /dev/null +++ b/app/src/components/ui/TypedConfirmModal.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import TypedConfirmModal from "./TypedConfirmModal"; + +const onConfirm = vi.fn(); +const onCancel = vi.fn(); + +function renderModal(props: Partial> = {}) { + render( + +

Everything goes.

+
, + ); + return { + input: screen.getByLabelText(/Type/), + confirm: screen.getByRole("button", { name: "Delete config volume" }), + }; +} + +beforeEach(() => vi.clearAllMocks()); + +describe("TypedConfirmModal", () => { + it("is a real dialog, from the Modal primitive", () => { + renderModal(); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-modal", "true"); + }); + + it("keeps the confirm button shut until the name is typed exactly", () => { + const { input, confirm } = renderModal(); + expect(confirm).toBeDisabled(); + + fireEvent.change(input, { target: { value: "wh" } }); + expect(confirm).toBeDisabled(); + + fireEvent.change(input, { target: { value: "whp" } }); + expect(confirm).toBeEnabled(); + fireEvent.click(confirm); + expect(onConfirm).toHaveBeenCalledWith("whp"); + }); + + it("is case-sensitive, because Api and api are different projects", () => { + // This gate is the only thing between a misclick on a sorted table of + // numbers and a project's transcripts, so a near-miss is a miss. + const { input, confirm } = renderModal({ expected: "Api" }); + fireEvent.change(input, { target: { value: "api" } }); + expect(confirm).toBeDisabled(); + fireEvent.change(input, { target: { value: "Api" } }); + expect(confirm).toBeEnabled(); + }); + + it("forgives surrounding whitespace from a paste", () => { + const { input, confirm } = renderModal(); + fireEvent.change(input, { target: { value: " whp " } }); + expect(confirm).toBeEnabled(); + }); + + it("announces the gate's state in words rather than only by the button fill", () => { + const { input } = renderModal(); + expect(screen.getByRole("status")).toHaveTextContent( + "Waiting for the exact project name.", + ); + fireEvent.change(input, { target: { value: "whp" } }); + expect(screen.getByRole("status")).toHaveTextContent("Name matches."); + }); + + it("spells out what is lost, from the caller's copy", () => { + renderModal(); + expect(screen.getByText("Everything goes.")).toBeInTheDocument(); + }); + + it("locks itself while the deletion is running", () => { + render( + +

Everything goes.

+
, + ); + // The confirm button reports the work in a word rather than only going + // grey, so it is found by its busy label, not its idle one. + expect(screen.getByLabelText(/Type/)).toBeDisabled(); + expect(screen.getByRole("button", { name: "Working…" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); + }); + + it("cancels without confirming", () => { + renderModal(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onCancel).toHaveBeenCalled(); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it("cannot be satisfied by an empty box when there is no name to type", () => { + const { confirm } = renderModal({ expected: "" }); + expect(confirm).toBeDisabled(); + }); +}); diff --git a/app/src/components/ui/TypedConfirmModal.tsx b/app/src/components/ui/TypedConfirmModal.tsx new file mode 100644 index 0000000..8c7f036 --- /dev/null +++ b/app/src/components/ui/TypedConfirmModal.tsx @@ -0,0 +1,116 @@ +import { useId, useRef, useState, type ReactNode } from "react"; +import Modal from "./Modal"; +import Button from "./Button"; +import { inputClass } from "./Field"; + +interface Props { + title: string; + /** What must be typed, verbatim, before the confirm button enables. */ + expected: string; + /** The verb on the confirm button. Repeat the action — never "OK". */ + confirmLabel: string; + /** What is about to be lost, in full. */ + children: ReactNode; + onConfirm: (typed: string) => void; + onCancel: () => void; + busy?: boolean; +} + +/** + * The confirmation gate for something that has no other copy. + * + * ## Why this exists when `ConfirmResetModal` already did + * + * Reset and Remove are reached from a project's own overflow menu, one project + * at a time, by a user who went looking for them. The Disk panel lists every + * project's volumes side by side in a table of numbers, sorted by size — which + * is exactly the layout that invites a misclick on the wrong row. A two-button + * dialog does not survive that, because the thing being confirmed (*which* + * project) is the thing the user got wrong. + * + * Typing the name fixes the failure mode rather than adding friction to it: the + * gate is not "are you sure", it is "name the project you mean". + * + * The comparison is `expected.trim() === typed.trim()` and **case-sensitive** — + * mirroring `confirmation_matches` in `docker/disk.rs`, which is the check that + * actually holds, since this one is only a UI affordance. The backend refuses a + * mismatch on its own. + */ +export default function TypedConfirmModal({ + title, + expected, + confirmLabel, + children, + onConfirm, + onCancel, + busy = false, +}: Props) { + const [typed, setTyped] = useState(""); + const inputRef = useRef(null); + // Every other `ui/` component uses `useId`; a hardcoded id breaks the + // label association as soon as two of these are mounted at once. + const inputId = useId(); + const matches = expected.trim().length > 0 && typed.trim() === expected.trim(); + + return ( + + + + + } + > +
+ {children} +
+ + setTyped(e.target.value)} + disabled={busy} + autoComplete="off" + spellCheck={false} + className={`${inputClass} font-mono`} + /> + {/* Announced rather than only coloured — the gate's state has to be + readable without relying on the button's fill. */} +

+ {matches ? ( + Name matches. + ) : ( + + Waiting for the exact project name. + + )} +

+
+
+
+ ); +} diff --git a/app/src/hooks/useDiskUsage.test.tsx b/app/src/hooks/useDiskUsage.test.tsx new file mode 100644 index 0000000..bda6e72 --- /dev/null +++ b/app/src/hooks/useDiskUsage.test.tsx @@ -0,0 +1,244 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useDiskUsage } from "./useDiskUsage"; +import type { DiskUsageReport } from "../lib/types"; + +const getDockerDiskUsage = vi.fn(); +const listReclaimable = vi.fn(); +const reclaim = vi.fn(); +const destroyProjectDiskObject = vi.fn(); + +vi.mock("../lib/tauri-commands", () => ({ + getDockerDiskUsage: () => getDockerDiskUsage(), + listReclaimable: (report: DiskUsageReport) => listReclaimable(report), + reclaim: (targets: unknown) => reclaim(targets), + destroyProjectDiskObject: (target: unknown, confirmation: string) => + destroyProjectDiskObject(target, confirmation), + sweepOrphanedSnapshots: () => sweepOrphanedSnapshots(), +})); + +const sweepOrphanedSnapshots = vi.fn(); + +const report = (scanned_at: string): DiskUsageReport => + ({ scanned_at, projects: [] }) as unknown as DiskUsageReport; + +const plan = { items: [], destructive: [], store_error: null }; + +beforeEach(() => { + vi.clearAllMocks(); + listReclaimable.mockResolvedValue(plan); + reclaim.mockResolvedValue({ results: [], total_freed_bytes: 0 }); +}); + +describe("useDiskUsage", () => { + it("holds no report until a scan is asked for", () => { + const { result } = renderHook(() => useDiskUsage()); + expect(result.current.report).toBeNull(); + expect(result.current.plan).toBeNull(); + expect(getDockerDiskUsage).not.toHaveBeenCalled(); + }); + + it("scans, then plans off the same report rather than scanning again", async () => { + getDockerDiskUsage.mockResolvedValue(report("first")); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.scan(); + }); + expect(getDockerDiskUsage).toHaveBeenCalledTimes(1); + expect(listReclaimable).toHaveBeenCalledWith(report("first")); + expect(result.current.report?.scanned_at).toBe("first"); + expect(result.current.plan).toEqual(plan); + }); + + it("lets the newest scan win when two are in flight", async () => { + // A user pressing Scan twice can have two `df()` calls outstanding, and + // the second is not necessarily the slower one. A stale response must not + // overwrite a fresher one. + let resolveFirst: (value: DiskUsageReport) => void = () => {}; + getDockerDiskUsage + .mockReturnValueOnce( + new Promise((r) => { + resolveFirst = r; + }), + ) + .mockResolvedValueOnce(report("second")); + + const { result } = renderHook(() => useDiskUsage()); + let firstScan: Promise = Promise.resolve(); + act(() => { + firstScan = result.current.scan(); + }); + await act(async () => { + await result.current.scan(); + }); + expect(result.current.report?.scanned_at).toBe("second"); + + // The slow first scan lands afterwards and is discarded. + await act(async () => { + resolveFirst(report("first")); + await firstScan; + }); + expect(result.current.report?.scanned_at).toBe("second"); + expect(result.current.scanning).toBe(false); + }); + + it("passes the ticked targets straight through", async () => { + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.runReclaim([ + { kind: "dangling_snapshots" }, + { kind: "build_cache", all: false }, + ]); + }); + expect(reclaim).toHaveBeenCalledWith([ + { kind: "dangling_snapshots" }, + { kind: "build_cache", all: false }, + ]); + }); + + it("does not call the backend for an empty selection", async () => { + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.runReclaim([]); + }); + expect(reclaim).not.toHaveBeenCalled(); + }); + + it("does not re-scan after a reclaim", async () => { + // Another `df()` costs seconds, and the outcome already carries measured + // bytes for every target. A user who wants fresh totals asks for them. + getDockerDiskUsage.mockResolvedValue(report("first")); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.scan(); + }); + await act(async () => { + await result.current.runReclaim([{ kind: "dangling_snapshots" }]); + }); + expect(getDockerDiskUsage).toHaveBeenCalledTimes(1); + }); + + it("clears the previous outcome when a new scan starts", async () => { + getDockerDiskUsage.mockResolvedValue(report("first")); + reclaim.mockResolvedValue({ results: [], total_freed_bytes: 42 }); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.runReclaim([{ kind: "dangling_snapshots" }]); + }); + expect(result.current.outcome?.total_freed_bytes).toBe(42); + await act(async () => { + await result.current.scan(); + }); + expect(result.current.outcome).toBeNull(); + }); + + it("forwards the typed confirmation verbatim", async () => { + destroyProjectDiskObject.mockResolvedValue({ + target: { kind: "dangling_snapshots" }, + ok: true, + freed_bytes: 100, + projected_bytes: null, + message: "gone", + }); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.destroy({ kind: "config_volume", project_id: "p1" }, "whp"); + }); + expect(destroyProjectDiskObject).toHaveBeenCalledWith( + { kind: "config_volume", project_id: "p1" }, + "whp", + ); + expect(result.current.outcome?.total_freed_bytes).toBe(100); + }); + + it("reports a scan failure and keeps the last good measurement", async () => { + // The old report is still an accurate measurement of an earlier moment, + // and the error says the refresh failed. Blanking it would leave the panel + // with nothing while telling the user nothing more. + getDockerDiskUsage.mockResolvedValueOnce(report("first")); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.scan(); + }); + + getDockerDiskUsage.mockRejectedValueOnce("daemon unreachable"); + await act(async () => { + await result.current.scan(); + }); + await waitFor(() => expect(result.current.error).toMatch(/daemon unreachable/)); + expect(result.current.report?.scanned_at).toBe("first"); + expect(result.current.scanning).toBe(false); + }); + + it("never shows fresh totals beside a stale tick list", async () => { + // `setReport` used to land before the plan call was awaited, so a plan + // failure rendered this scan's numbers above the previous scan's rows. + getDockerDiskUsage.mockResolvedValueOnce(report("first")); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.scan(); + }); + + getDockerDiskUsage.mockResolvedValueOnce(report("second")); + listReclaimable.mockRejectedValueOnce("planner exploded"); + await act(async () => { + await result.current.scan(); + }); + expect(result.current.error).toMatch(/planner exploded/); + expect(result.current.report?.scanned_at).toBe("first"); + }); + + it("drops the plan after a reclaim so ticks cannot be re-fired at nothing", async () => { + getDockerDiskUsage.mockResolvedValue(report("first")); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.scan(); + }); + expect(result.current.plan).toEqual(plan); + + await act(async () => { + await result.current.runReclaim([{ kind: "dangling_snapshots" }]); + }); + expect(result.current.plan).toBeNull(); + // The totals stay — they were measured before the reclaim and the outcome + // says what changed. + expect(result.current.report?.scanned_at).toBe("first"); + }); + + it("runs the sweep through its own command and reports what it refused", async () => { + // The sweep's `in_use` count — orphans Docker refused to delete because a + // stopped project still needs them — is invisible everywhere else in the + // app, because every other caller throws the report away. + sweepOrphanedSnapshots.mockResolvedValue({ + removed: ["sha256:a", "sha256:b"], + reclaimed_bytes: 11_900_000_000, + in_use: 3, + failed: [], + unavailable: null, + }); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.runSweep(); + }); + expect(sweepOrphanedSnapshots).toHaveBeenCalled(); + expect(result.current.outcome?.total_freed_bytes).toBe(11_900_000_000); + expect(result.current.outcome?.results[0].message).toMatch(/Swept 2 superseded image/); + expect(result.current.outcome?.results[0].message).toMatch(/3 were left alone/); + }); + + it("treats an unreachable daemon in the sweep report as an error", async () => { + sweepOrphanedSnapshots.mockResolvedValue({ + removed: [], + reclaimed_bytes: 0, + in_use: 0, + failed: [], + unavailable: "Could not reach the Docker engine", + }); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.runSweep(); + }); + expect(result.current.error).toMatch(/Could not reach the Docker engine/); + expect(result.current.outcome).toBeNull(); + }); +}); diff --git a/app/src/hooks/useDiskUsage.ts b/app/src/hooks/useDiskUsage.ts new file mode 100644 index 0000000..f82eb19 --- /dev/null +++ b/app/src/hooks/useDiskUsage.ts @@ -0,0 +1,198 @@ +import { useCallback, useRef, useState } from "react"; +import * as commands from "../lib/tauri-commands"; +import type { + DestructiveTarget, + DiskUsageReport, + ReclaimOutcome, + ReclaimPlan, + ReclaimTarget, +} from "../lib/types"; + +/** + * State for the Disk section. + * + * ## Why nothing here runs on mount + * + * A scan is `GET /system/df`, which walks every image, container and volume on + * the daemon and computes shared-layer sizes. On a 100 GB store that is + * seconds. `AccordionSection` unmounts its body when collapsed, so a + * `useEffect` scan would re-run every single time the user opened the section. + * The scan is therefore only ever what the Scan button calls. + * + * Note what that does *not* buy: this hook lives inside `DiskSettings`, which + * the accordion unmounts on collapse, so its state goes with it and reopening + * the section shows an unscanned panel again. That is the honest behaviour — + * a stale total is worse than an absent one — but it means collapsing and + * reopening discards a scan the user paid for. Lifting the report into + * `appState` would fix that and is deliberately not done here: it would put a + * multi-megabyte, rapidly-stale blob into the app-wide store for one panel. + * + * ## The generation guard + * + * A user who hits Scan twice can have two `df()` calls in flight, and they can + * land out of order — the second one is not necessarily slower. Every async + * write in `scan` checks it is still the newest before it lands, the same + * pattern `useContainerMigration` uses. `runReclaim` and `destroy` do not need + * it: the UI disables their buttons while `working` is set, so there is never + * a second one to race. + */ +export interface DiskUsageState { + report: DiskUsageReport | null; + plan: ReclaimPlan | null; + /** A scan is in flight. */ + scanning: boolean; + /** A reclaim or a destroy is in flight. */ + working: boolean; + error: string | null; + /** The outcome of the last reclaim, kept on screen until the next scan. */ + outcome: ReclaimOutcome | null; + scan: () => Promise; + runReclaim: (targets: ReclaimTarget[]) => Promise; + destroy: (target: DestructiveTarget, confirmation: string) => Promise; + /** Run the orphaned-snapshot sweep and report what it found *and refused*. */ + runSweep: () => Promise; + clearOutcome: () => void; +} + +export function useDiskUsage(): DiskUsageState { + const [report, setReport] = useState(null); + const [plan, setPlan] = useState(null); + const [scanning, setScanning] = useState(false); + const [working, setWorking] = useState(false); + const [error, setError] = useState(null); + const [outcome, setOutcome] = useState(null); + const generation = useRef(0); + + const scan = useCallback(async () => { + const mine = ++generation.current; + setScanning(true); + setError(null); + // The previous outcome describes a state that no longer holds once a new + // scan starts, so it goes rather than sitting beside fresh numbers. + setOutcome(null); + try { + const next = await commands.getDockerDiskUsage(); + if (generation.current !== mine) return; + // Planning is cheap and always wanted: the classification is what makes + // the numbers actionable, and it reuses the report rather than scanning + // again. + const nextPlan = await commands.listReclaimable(next); + if (generation.current !== mine) return; + // Both land together, or neither does. Setting the report before + // awaiting the plan would render this scan's totals above the *previous* + // scan's still-clickable tick list if the plan call failed. + setReport(next); + setPlan(nextPlan); + } catch (e) { + if (generation.current !== mine) return; + setError(String(e)); + // The old report is left on screen deliberately — it is still an + // accurate measurement of an earlier moment, and the error says the + // refresh failed. What must not survive is a plan describing a scan the + // user can no longer see the totals for, but that cannot happen: the two + // only ever move together. + } finally { + if (generation.current === mine) setScanning(false); + } + }, []); + + const runReclaim = useCallback(async (targets: ReclaimTarget[]) => { + if (targets.length === 0) return; + setWorking(true); + setError(null); + try { + const result = await commands.reclaim(targets); + setOutcome(result); + // **The plan is now stale and must not stay clickable.** Its rows + // describe objects this call just removed, so leaving them ticked lets + // the user fire the same reclaim again against nothing. Dropping the plan + // (not the report) leaves the totals on screen, marked as measured before + // the reclaim, with the tick list gone. + // + // Deliberately no automatic re-scan: it costs another `df()`, and the + // outcome already reports measured bytes for every target — a user who + // wants the new totals asks for them. + setPlan(null); + } catch (e) { + setError(String(e)); + } finally { + setWorking(false); + } + }, []); + + const destroy = useCallback(async (target: DestructiveTarget, confirmation: string) => { + setWorking(true); + setError(null); + try { + const result = await commands.destroyProjectDiskObject(target, confirmation); + setOutcome({ results: [result], total_freed_bytes: result.freed_bytes }); + // Same reasoning as `runReclaim`: the destructive list named an object + // that is now gone. + setPlan(null); + } catch (e) { + setError(String(e)); + } finally { + setWorking(false); + } + }, []); + + /** + * The startup sweep, on demand. + * + * Not the same as ticking "superseded snapshot layers", even though both end + * up removing the same images: this reports `in_use` — the orphans Docker + * *refused* to delete because a stopped project's container still needs + * them. That refusal is the sweep's third safety net and it is invisible + * everywhere else in the app, because every existing caller throws the + * report away. + */ + const runSweep = useCallback(async () => { + setWorking(true); + setError(null); + try { + const sweep = await commands.sweepOrphanedSnapshots(); + if (sweep.unavailable) { + setError(sweep.unavailable); + return; + } + const refused = + sweep.in_use > 0 + ? ` ${sweep.in_use} were left alone because a container is still built from them — start and stop, or recreate, that project and a later sweep gets them.` + : ""; + setOutcome({ + results: [ + { + target: { kind: "dangling_snapshots" }, + destroyed: null, + ok: sweep.failed.length === 0, + freed_bytes: sweep.reclaimed_bytes, + projected_bytes: null, + message: `Swept ${sweep.removed.length} superseded image(s).${refused}`, + }, + ], + total_freed_bytes: sweep.reclaimed_bytes, + }); + setPlan(null); + } catch (e) { + setError(String(e)); + } finally { + setWorking(false); + } + }, []); + + const clearOutcome = useCallback(() => setOutcome(null), []); + + return { + report, + plan, + scanning, + working, + error, + outcome, + scan, + runReclaim, + destroy, + runSweep, + clearOutcome, + }; +} diff --git a/app/src/lib/formatBytes.test.ts b/app/src/lib/formatBytes.test.ts new file mode 100644 index 0000000..aabd3d1 --- /dev/null +++ b/app/src/lib/formatBytes.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import { formatBytes, formatBytesCeiling, formatBytesDelta } from "./formatBytes"; + +describe("formatBytes", () => { + it("defaults to base 1000, because that is what Docker prints", () => { + // The Disk panel exists to explain `docker system df`, which formats with + // `units.HumanSize` — base 1000. Showing 26.1 GB against a terminal saying + // 28.0 GB for the same build cache reads as a bug in the panel. + expect(formatBytes(28_000_000_000)).toBe("28.0 GB"); + expect(formatBytes(1_000)).toBe("1.0 KB"); + expect(formatBytes(1_500_000)).toBe("1.5 MB"); + expect(formatBytes(12_273_392_374)).toBe("12.3 GB"); + }); + + it("leaves whole bytes without a decimal point", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(999)).toBe("999 B"); + }); + + it("reproduces the Project Home convention exactly under `binary`", () => { + // Three modules import `projects/home/format.ts#formatBytes`, which is now + // this function. Its output had to be byte-identical or re-pointing it + // would have quietly changed every file listing in the app. + expect(formatBytes(1023, { binary: true })).toBe("1023 B"); + expect(formatBytes(1024, { binary: true })).toBe("1.0 KB"); + expect(formatBytes(1024 * 1024, { binary: true })).toBe("1.0 MB"); + expect(formatBytes(1024 * 1024 * 1024, { binary: true })).toBe("1.0 GB"); + expect(formatBytes(1_610_612_736, { binary: true })).toBe("1.5 GB"); + }); + + it("reproduces the migration convention exactly by default", () => { + // `migrationCopy.formatDataSize` is now a call to this, and its output is + // asserted in MigrateContainerModal.test.tsx. + expect(formatBytes(41_000_000)).toBe("41.0 MB"); + expect(formatBytes(3_800_000_000)).toBe("3.8 GB"); + }); + + it("labels binary units honestly when asked to", () => { + expect(formatBytes(1024, { binary: true, iec: true })).toBe("1.0 KiB"); + expect(formatBytes(1024 ** 3, { binary: true, iec: true })).toBe("1.0 GiB"); + }); + + it("climbs to TB rather than showing five-digit gigabytes", () => { + expect(formatBytes(2_500_000_000_000)).toBe("2.5 TB"); + }); + + it("promotes the unit when rounding lands on a whole step", () => { + // `toFixed` runs after the divide loop, so a value just under a boundary + // rounds up into a unit the loop had already ruled out. This is the app's + // only byte formatter and the panel is full of near-boundary sizes. + expect(formatBytes(999_999)).toBe("1.0 MB"); + expect(formatBytes(999_999_999)).toBe("1.0 GB"); + expect(formatBytes(999_999_999_999)).toBe("1.0 TB"); + expect(formatBytes(1_048_575, { binary: true })).toBe("1.0 MB"); + + // Just below the rounding threshold it must NOT promote. + expect(formatBytes(999_949)).toBe("999.9 KB"); + expect(formatBytes(999_400, { precision: 0 })).toBe("999 KB"); + + // The top unit has nowhere to go: it renders a whole step rather than + // running off the end of the unit array. + expect(formatBytes(999_999_999_999_999_999)).toBe("1000.0 PB"); + }); + + it("renders an em dash for a size the daemon did not compute", () => { + // Docker reports -1 for "not calculated" on shared sizes and volume ref + // counts. `NaN GB` in the middle of a table is worse than nothing. + expect(formatBytes(-1)).toBe("—"); + expect(formatBytes(NaN)).toBe("—"); + expect(formatBytes(Infinity)).toBe("—"); + }); + + it("honours a requested precision", () => { + expect(formatBytes(1_234_567_890, { precision: 2 })).toBe("1.23 GB"); + expect(formatBytes(1_234_567_890, { precision: 0 })).toBe("1 GB"); + }); +}); + +describe("formatBytesDelta", () => { + it("signs a figure that is being added rather than measured", () => { + // "Next commit adds +868.0 MB" — the sign is what makes it read as a cost + // about to be incurred rather than a size already on disk. + expect(formatBytesDelta(868_000_000)).toBe("+868.0 MB"); + expect(formatBytesDelta(0)).toBe("+0 B"); + }); + + it("does not sign an unknown", () => { + expect(formatBytesDelta(-1)).toBe("—"); + }); +}); + +describe("formatBytesCeiling", () => { + it("says 'up to', because a compaction's yield is a bound not a promise", () => { + // Every other figure in the Disk panel is measured. This one cannot be + // known until the rewrite runs, and rendering it through a separate + // function is what stops it being read as a guarantee. + expect(formatBytesCeiling(5_100_000_000)).toBe("up to 5.1 GB"); + }); + + it("refuses to imply a saving when there is no bound to give", () => { + expect(formatBytesCeiling(0)).toBe("an unknown amount"); + expect(formatBytesCeiling(-1)).toBe("an unknown amount"); + }); +}); diff --git a/app/src/lib/formatBytes.ts b/app/src/lib/formatBytes.ts new file mode 100644 index 0000000..a66cb11 --- /dev/null +++ b/app/src/lib/formatBytes.ts @@ -0,0 +1,105 @@ +/** + * The one byte formatter. + * + * The app had four of them — `projects/home/format.ts`, + * `projects/migrationCopy.ts`, `settings/UpdateDialog.tsx` and an inline + * `toFixed(1)` in `useProjectActions.ts` — disagreeing about the divisor, the + * unit labels and the precision. The first two now delegate here. + * + * The other two deliberately do not, yet: `UpdateDialog` renders KB at + * `toFixed(0)`, so re-pointing it would change what a download size reads as, + * and neither is on the Disk panel's path. They are the remaining copies. + * + * ## Why the default is base 1000 + * + * The Disk panel exists to explain what `docker system df` reports, and Docker + * formats every size it prints with `units.HumanSize`, which is **base 1000**. + * A panel that showed 26.1 GB where the user's terminal said 28.0 GB for the + * same build cache would read as a bug in the panel. So decimal is the default + * and binary is opt-in, rather than the other way round. + * + * Both existing conventions are preserved for every size either call site can + * realistically produce — a file size or a payload size, i.e. a non-negative + * finite number below a terabyte. Outside that range this deliberately differs + * from what it replaced: a negative or `NaN` input now renders `—` rather than + * `-1 B` or `NaN GB`, and the unit ladder continues past GB instead of + * stopping there. + * + * - `{ }` → `41.0 MB` (decimal, what migration used) + * - `{ binary: true }` → `1.5 GB` (÷1024 with decimal-style + * labels, what Project Home used + * — technically a misnomer, but + * it is the app's convention and + * changing it is not this + * feature's business) + * - `{ binary: true, iec: true }` → `1.5 GiB` (÷1024 labelled honestly) + */ + +const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"]; +const IEC_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + +export interface FormatBytesOptions { + /** Divide by 1024 instead of 1000. */ + binary?: boolean; + /** Label binary units as `KiB`/`MiB`/`GiB` rather than `KB`/`MB`/`GB`. */ + iec?: boolean; + /** Decimal places above `B`. Bytes are always whole. */ + precision?: number; +} + +export function formatBytes(bytes: number, options: FormatBytesOptions = {}): string { + const { binary = false, iec = false, precision = 1 } = options; + + // A negative or non-finite size is a bug upstream, not something to render as + // `NaN GB` in the middle of a table. Docker reports -1 for "not computed", + // and that is the case this actually catches. + if (!Number.isFinite(bytes) || bytes < 0) return "—"; + + const step = binary ? 1024 : 1000; + const units = binary && iec ? IEC_UNITS : DECIMAL_UNITS; + + let value = bytes; + let unit = 0; + while (value >= step && unit < units.length - 1) { + value /= step; + unit += 1; + } + + // **Promote again if rounding pushed the value back up to a whole step.** + // `toFixed` runs after the loop, so 999,999 B divides to 999.999 KB and then + // renders as "1000.0 KB" — a unit the loop had already decided against. The + // same happens at every boundary (999,999,999 → "1000.0 MB", and 1,048,575 + // → "1024.0 KB" in binary). + if (unit < units.length - 1 && Number(value.toFixed(precision)) >= step) { + value /= step; + unit += 1; + } + + // Whole bytes never get a decimal point: `512 B`, not `512.0 B`. + return unit === 0 + ? `${Math.round(bytes)} ${units[0]}` + : `${value.toFixed(precision)} ${units[unit]}`; +} + +/** + * `12.3 GB` → `+12.3 GB`, for a figure that is being *added* rather than + * measured. Used for "next commit adds …", which is the number that explains + * why a snapshot grows. + */ +export function formatBytesDelta(bytes: number, options?: FormatBytesOptions): string { + const formatted = formatBytes(bytes, options); + return formatted === "—" ? formatted : `+${formatted}`; +} + +/** + * `up to 12.3 GB` — for a bound rather than a measurement. + * + * The Disk panel is careful about this distinction: every figure it shows is + * measured except a compaction's yield, which cannot be known until it runs. + * Rendering that one through a different function is what stops it being read + * as a promise. + */ +export function formatBytesCeiling(bytes: number, options?: FormatBytesOptions): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "an unknown amount"; + return `up to ${formatBytes(bytes, options)}`; +} diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 40e382a..597b4a9 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types"; +import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, DiskUsageReport, ReclaimPlan, ReclaimTarget, ReclaimOutcome, ReclaimResult, DestructiveTarget, SnapshotSweepReport } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -356,3 +356,34 @@ export const rollbackMigration = (projectId: string) => * app crash shows up here as phase "interrupted". */ export const getMigrationState = (projectId: string) => invoke("get_migration_state", { projectId }); + +// Disk + +/** Measure where the daemon's bytes have gone. + * + * **Expensive — keep it behind an explicit Scan button.** This is + * `GET /system/df`, which walks every image, container and volume on the + * daemon to compute shared-layer sizes, plus an `image_history` per image. + * Seconds on a 100 GB store. Never call it on mount and never poll it. */ +export const getDockerDiskUsage = () => invoke("get_docker_disk_usage"); + +/** Classify what could be reclaimed, with measured bytes. Takes the report + * from `getDockerDiskUsage` so re-planning costs no second scan. */ +export const listReclaimable = (report: DiskUsageReport) => + invoke("list_reclaimable", { report }); + +/** Run the ticked targets. `ReclaimTarget` cannot name a destructive action, + * so no selection built here can delete a live project's data. */ +export const reclaim = (targets: ReclaimTarget[]) => + invoke("reclaim", { targets }); + +/** Delete one object that has no other copy. `confirmation` must be the + * project's name, typed by the user. One target per call, never bulk. */ +export const destroyProjectDiskObject = (target: DestructiveTarget, confirmation: string) => + invoke("destroy_project_disk_object", { target, confirmation }); + +/** Run the orphaned-snapshot sweep on demand and see its report — the same + * sweep that runs at startup and after every recreation, whose result every + * existing caller throws away. */ +export const sweepOrphanedSnapshots = () => + invoke("sweep_orphaned_snapshots"); diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index d9cf2d7..4a274ad 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -823,3 +823,208 @@ export interface MigrationState { options: MigrationOptions; plan: MigrationPlan | null; } + +// --------------------------------------------------------------------------- +// Disk +// --------------------------------------------------------------------------- +// +// Mirrors `app/src-tauri/src/docker/disk.rs`. Plain snake_case, like every +// other IPC struct in this app. + +/** One row of the per-project disk table. */ +export interface ProjectDiskRow { + project_id: string; + project_name: string; + snapshot_image: string; + snapshot_exists: boolean; + /** Total size of the snapshot image, base image included. */ + snapshot_bytes: number; + /** Bytes shared with another image — almost always the base. */ + snapshot_shared_bytes: number; + /** Layers stacked above the base image: **one per container recreation**. + * This is the number that explains why a snapshot grows — but only when + * `base_lineage_known` is true. Otherwise it counts the base's layers too. */ + snapshot_commit_layers: number; + /** Whether the base image this snapshot descends from could be identified. + * False is the normal case for a project created before the + * `triple-c.base-image-id` label existed; the layer count must not be + * presented as a recreation count then. */ + base_lineage_known: boolean; + /** Bytes those layers account for. `null` when the base image is gone and + * the split cannot be measured — never a guess. */ + snapshot_above_base_bytes: number | null; + container_exists: boolean; + container_running: boolean; + /** The writable layer, i.e. exactly what the next commit will add. */ + container_writable_bytes: number; + home_volume_bytes: number; + home_volume_present: boolean; + config_volume_bytes: number; + config_volume_present: boolean; + total_bytes: number; + migrating: boolean; +} + +export interface BaseImageRow { + reference: string; + bytes: number; + shared_bytes: number; + containers: number; + is_labelled_base: boolean; +} + +/** Where the daemon keeps its bytes, and the Windows/WSL2 caveat if it applies. + * The vhdx copy comes from Rust so the wording cannot drift from the + * constants its tests pin. */ +export interface HostStorage { + docker_root_dir: string; + operating_system: string; + is_docker_desktop: boolean; + is_windows_host: boolean; + vhdx_applies: boolean; + /** Empty unless `vhdx_applies`. */ + vhdx_note: string; + vhdx_fix: string[]; + vhdx_fix_gui: string; +} + +export interface BuildCacheUsage { + total_bytes: number; + reclaimable_bytes: number; + /** What a `--filter until=168h` prune would reach. */ + stale_bytes: number; + /** `"buildx du"` or `"system df"` — `docker system df` under-reports build + * cache, so which one produced the number is worth showing. */ + source: string; + cli_error: string | null; +} + +/** A per-project volume whose project id is not in Triple-C's project store. + * + * **Not "a volume with no container".** From the daemon's side an idle live + * project and a deleted one look identical — volumes present, no container, + * nothing running — so only the project store can tell them apart. */ +export interface OrphanVolume { + name: string; + project_id: string; + bytes: number; + /** `"home"` or `"config"`. */ + role: string; + /** When Docker created it. Evidence a user can recognise a project by; a + * size and a UUID identify nothing. From `df()` metadata — volumes are + * never mounted to inspect them, because `docker run -v` *creates* a + * volume that does not exist. */ + created_at: string | null; +} + +/** The result of one Scan. Expensive to produce — see `getDockerDiskUsage`. */ +export interface DiskUsageReport { + scanned_at: string; + projects: ProjectDiskRow[]; + base_images: BaseImageRow[]; + base_images_bytes: number; + orphan_image_bytes: number; + orphan_image_count: number; + orphan_volumes: OrphanVolume[]; + orphan_volume_bytes: number; + /** Why orphan detection was suppressed, when it was. */ + orphan_volumes_unavailable: string | null; + build_cache: BuildCacheUsage; + images_total_bytes: number; + containers_total_bytes: number; + volumes_total_bytes: number; + triple_c_total_bytes: number; + host: HostStorage; +} + +/** Mirrors Rust `Safety` (serde snake_case). */ +export type ReclaimSafety = "safe" | "semi_safe"; + +/** Mirrors Rust `ReclaimTarget`, an internally tagged enum. + * + * This type **cannot express a destructive action** — that is + * `DestructiveTarget`, and the Rust `reclaim` command cannot be handed one. + * The separation is structural on both sides on purpose. */ +export type ReclaimTarget = + | { kind: "dangling_snapshots" } + | { kind: "superseded_base_images" } + | { kind: "build_cache"; all: boolean } + | { kind: "migration_pins" } + | { kind: "migration_staging" } + | { kind: "probe_containers" } + | { kind: "scrub_containers" } + | { kind: "orphan_volume"; name: string } + | { kind: "compact_snapshot"; project_id: string } + | { kind: "clear_caches"; project_id: string; include_rustup: boolean }; + +/** Mirrors Rust `DestructiveTarget`. Every one of these deletes something with + * no other copy, and needs the project's name typed to confirm. */ +export type DestructiveTarget = + | { kind: "home_volume"; project_id: string } + | { kind: "config_volume"; project_id: string } + | { kind: "snapshot_image"; project_id: string } + | { kind: "rollback_pin"; project_id: string; tag: string }; + +export interface ReclaimItem { + target: ReclaimTarget; + safety: ReclaimSafety; + /** Reaches beyond Triple-C's own objects — true only for the build cache, + * and the UI must say so. */ + daemon_wide: boolean; + label: string; + detail: string; + bytes: number; + /** `false` means `bytes` is a bound, not a measurement. Render it as + * "up to …" — only snapshot compaction sets this. */ + bytes_are_exact: boolean; + bytes_floor: number | null; + /** Why this cannot run right now. */ + blocked: string | null; +} + +export interface DestructiveItem { + target: DestructiveTarget; + project_id: string; + project_name: string; + label: string; + /** Spelled out in full — this is the confirmation copy. */ + loses: string; + bytes: number; + blocked: string | null; +} + +export interface ReclaimPlan { + items: ReclaimItem[]; + /** Display only. `reclaim` cannot act on these. */ + destructive: DestructiveItem[]; + store_error: string | null; +} + +export interface ReclaimResult { + /** The reclaim target this reports on, or `null` when it reports a destroy. + * Exactly one of `target` / `destroyed` is ever set — a destroy used to come + * back wearing a `ReclaimTarget` that named work it had not done. */ + target: ReclaimTarget | null; + destroyed: DestructiveTarget | null; + ok: boolean; + freed_bytes: number; + /** What was projected beforehand, for the one action that projects. */ + projected_bytes: number | null; + message: string; +} + +export interface ReclaimOutcome { + results: ReclaimResult[]; + total_freed_bytes: number; +} + +/** Mirrors Rust `SnapshotSweepReport`. Note `failed` is a list of + * `[reference, error]` pairs — a Rust tuple serialises as an array. */ +export interface SnapshotSweepReport { + removed: string[]; + reclaimed_bytes: number; + /** Refused because a container is still built from them. Normal. */ + in_use: number; + failed: [string, string][]; + unavailable: string | null; +}