Add a Disk section: see where the bytes went, and get them back

Every recreation runs `docker commit`, which stacks a layer and never
rewrites one, and 24 conditions in `container_needs_recreation` trigger a
recreation. Prevention landed earlier on this branch; this is the half a
user can act on.

The per-project table leads with the two numbers that explain the
mechanism rather than just the total: how many commit layers a snapshot
has stacked above its base, and what the container's writable layer will
add at the next commit.

Backend (`docker/disk.rs`, commands in `docker_commands.rs`):
- `get_docker_disk_usage` — one `df()` joined against the project store,
  behind an explicit Scan button because it walks the whole daemon.
- `list_reclaimable` / `reclaim` — classified buckets with measured bytes,
  planned off the existing report so re-planning costs no second scan.
- `destroy_project_disk_object` — one object, typed confirmation.
- `sweep_orphaned_snapshots` — exposed, so its report is finally visible.

Safety is structural: `reclaim` takes `ReclaimTarget`, which has no
variant that can name a live project's data. Destructive work is a
separate type reached only through `destroy`. No unfiltered prune is
called anywhere, and nothing outside a `triple-c*` name or `triple-c.*`
label is touched.

Orphan detection subtracts ids from the project store and consults
nothing else. From the daemon's side an idle live project and a deleted
one are indistinguishable — volumes present, no container, no image — so
inferring from container or image absence would offer a live project's
credentials and transcripts for deletion. A store that loaded empty from
an existing `projects.json` is treated as a failed load, not as "no
projects", because `ProjectsStore::new()` recovers from a corrupt file by
starting empty.

Three things verified against a live Docker 29.7.2 rather than assumed:

- Compaction is a two-stage build (`FROM scratch` + `COPY --from`), which
  keeps every byte inside the daemon; bollard's import buffers a whole
  image into memory. uid/gid and setuid survive; a 192.6 MB/4-layer
  synthetic came out 45.7 MB/1 layer. Image config does not survive, so it
  is replayed via create+commit, which round-trips a multi-line env var
  that a Dockerfile `ENV` could not.
- Flattening breaks base-layer sharing, so the result carries its own copy
  of the base. Eight of ten real projects had a 0.10–1.32 GB delta over a
  4.72 GB shared base — compacting those costs ~4 GB. The bound now
  subtracts that penalty, such projects are not offered at all, and the
  run compares unique bytes and abandons a rewrite that would grow.
- `docker builder prune` reports `Total:`, not `Total reclaimed space:`,
  so the first parser scored every prune as freeing nothing.

The Windows/WSL2 note is mandatory and its copy lives in Rust beside the
tests that pin it: pruning frees space inside `ext4.vhdx`, which never
shrinks on its own, so C: does not change until the disk is compacted.

Also adds `lib/formatBytes.ts` — the app had four disagreeing copies, and
`projects/home/format.ts` and `migrationCopy.ts` now delegate to it with
byte-identical output. Base 1000 by default, matching what Docker prints.

Tests: 502 frontend (was 453), 365 Rust (was 322).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 09:39:54 -07:00
co-authored by Claude Opus 5
parent bb41275cea
commit 77ef2291d7
20 changed files with 6098 additions and 20 deletions
@@ -54,3 +54,80 @@ pub async fn list_sibling_containers() -> Result<Vec<serde_json::Value>, 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<docker::disk::DiskUsageReport, String> {
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<docker::disk::ReclaimPlan, String> {
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<docker::disk::ReclaimTarget>,
state: State<'_, AppState>,
) -> Result<docker::disk::ReclaimOutcome, String> {
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<docker::disk::ReclaimResult, String> {
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<docker::SnapshotSweepReport, String> {
Ok(docker::sweep_orphaned_snapshots().await)
}
+30 -7
View File
@@ -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_"];
@@ -1541,7 +1541,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()
@@ -1551,7 +1551,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()
@@ -1877,6 +1877,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
@@ -2058,7 +2081,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
@@ -2650,8 +2673,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),
File diff suppressed because it is too large Load Diff
+823
View File
@@ -0,0 +1,823 @@
//! 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<ReclaimTarget> {
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<String> = 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<_>>(),
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_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.101.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_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("", ""));
}
// ---------------------------------------------------------------------------
// 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 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("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::<DestructiveTarget>(&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]["container_writable_bytes"], 868_000_000i64);
assert!(json["orphan_volumes_unavailable"].is_null());
// `Option<i64>` 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());
}
+5
View File
@@ -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.
+6
View File
@@ -436,6 +436,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,
+10 -4
View File
@@ -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. */
+2 -8
View File
@@ -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. */
@@ -0,0 +1,168 @@
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;
}
/** `—` 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 (
<p className="text-xs text-[var(--text-secondary)]">
No projects to account for.
</p>
);
}
return (
// Wide content scrolls inside its own container; the panel itself must
// never scroll sideways.
<div className="overflow-x-auto">
<table className="w-full text-[13px] border-collapse">
<caption className="sr-only">
Disk used by each project, largest first
</caption>
<thead>
<tr className="text-left text-xs text-[var(--text-secondary)]">
<th scope="col" className="font-medium py-1.5 pr-3">
Project
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right">
Snapshot
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
Layers
<Tooltip text="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." />
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
Next commit adds
<Tooltip text="The container's writable layer. This is exactly what the next recreation will stack onto the snapshot, and it never comes back after that." />
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right">
Home vol
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right">
Config vol
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right">
Total
</th>
<th scope="col" className="font-medium py-1.5 pl-3">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const mine = destructive.filter((d) => d.project_id === row.project_id);
return (
<tr
key={row.project_id}
className="border-t border-[var(--border-color)] align-top"
data-testid={`disk-row-${row.project_id}`}
>
<th scope="row" className="font-normal py-1.5 pr-3 text-[var(--text-primary)]">
<div className="flex items-center gap-1.5">
<span className="truncate max-w-[10rem]">{row.project_name}</span>
{row.migrating && (
<StatusIndicator
tone="busy"
label="Migrating"
className="text-[11px]"
/>
)}
</div>
<span className="block text-[11px] text-[var(--text-secondary)] font-mono truncate max-w-[12rem]">
{row.project_id}
</span>
</th>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
{cell(row.snapshot_above_base_bytes ?? 0, row.snapshot_exists)}
{row.snapshot_exists && (
<span className="block text-[11px] text-[var(--text-secondary)]">
{/* 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
</span>
)}
</td>
<td className="py-1.5 px-3 text-right tabular-nums">
{row.snapshot_exists ? (
<span
className={
row.snapshot_commit_layers > 5
? "text-[var(--warning)]"
: "text-[var(--text-primary)]"
}
>
{row.snapshot_commit_layers}
</span>
) : (
"—"
)}
</td>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
{row.container_exists
? formatBytesDelta(row.container_writable_bytes)
: "—"}
</td>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
{cell(row.home_volume_bytes, row.home_volume_present)}
</td>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
{cell(row.config_volume_bytes, row.config_volume_present)}
</td>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap text-[var(--text-primary)] font-medium">
{formatBytes(row.total_bytes)}
</td>
<td className="py-1.5 pl-3">
{mine.length > 0 && (
<OverflowMenu
label={`Delete ${row.project_name} data`}
items={mine.map((item) => ({
label: `Delete ${item.label.toLowerCase()} (${formatBytes(item.bytes)})…`,
onSelect: () => onDestroy(item),
danger: true,
disabled: item.blocked !== null,
}))}
/>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,510 @@
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> = {}): 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,
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> = {}): 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> = {}): 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> = {}): ReclaimPlan => ({
items: [item()],
destructive: [],
store_error: null,
...over,
});
async function renderAndScan() {
render(<DiskSettings />);
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(<DiskSettings />);
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<DiskUsageReport>((r) => {
resolve = r;
}),
);
render(<DiskSettings />);
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("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("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 <em>, 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(
within(note).getByText("Reclaiming here will not shrink your C: drive"),
).toBeInTheDocument();
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: { kind: "orphan_volume", name: "triple-c-claude-config-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("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" },
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 rather than showing stale numbers", async () => {
getDockerDiskUsage.mockRejectedValue("Could not read Docker disk usage: no such host");
render(<DiskSettings />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Scan" }));
});
expect(screen.getByRole("alert")).toHaveTextContent(/no such host/);
});
});
@@ -0,0 +1,512 @@
import { 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 } =
useDiskUsage();
const [ticked, setTicked] = useState<Set<string>>(new Set());
const [confirming, setConfirming] = useState<ReclaimItem | null>(null);
const [destroying, setDestroying] = useState<DestructiveItem | null>(null);
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 (
<div className="space-y-4 text-[13px]">
{/* --- Why this section exists ------------------------------------- */}
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
Every time a container is recreated, Triple-C commits it and a commit{" "}
<strong className="text-[var(--text-primary)]">stacks a new layer</strong> 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.
</p>
{/* --- Scan --------------------------------------------------------- */}
<div className="flex items-center gap-3 flex-wrap">
<Button variant="primary" size="md" onClick={scan} disabled={scanning}>
{scanning ? "Scanning…" : report ? "Scan again" : "Scan"}
</Button>
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
<span className="text-xs text-[var(--text-secondary)]">
Reads the whole Docker store; takes a few seconds on a large one.
</span>
</div>
{error && (
<p className="text-xs text-[var(--error)]" role="alert">
{error}
</p>
)}
{!report && !scanning && (
<p className="text-xs text-[var(--text-secondary)]">
Nothing has been measured yet. Scanning is the only thing here that costs
anything, so it is never done for you.
</p>
)}
{report && (
<>
{/* --- Windows / WSL2, mandatory when it applies ----------------- */}
{report.host.vhdx_applies && (
<section
className="border border-[var(--warning)]/40 bg-[var(--warning-muted)] rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2"
data-testid="disk-vhdx-note"
>
<StatusIndicator
tone="error"
label="Reclaiming here will not shrink your C: drive"
className="text-xs"
/>
<p className="text-xs text-[var(--text-primary)] leading-relaxed">
{report.host.vhdx_note}
</p>
<p className="text-xs text-[var(--text-secondary)]">
To actually give the space back to C:, run these in PowerShell as
administrator after reclaiming:
</p>
<pre className="text-[11px] font-mono bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] px-2.5 py-2 overflow-x-auto select-text">
{report.host.vhdx_fix.join("\n")}
</pre>
<p className="text-xs text-[var(--text-secondary)]">
Or, without Hyper-V: {report.host.vhdx_fix_gui}.
</p>
</section>
)}
{/* --- Per-project table ---------------------------------------- */}
<section className="space-y-2">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
By project
</h3>
<DiskProjectTable
rows={report.projects}
destructive={plan?.destructive ?? []}
onDestroy={setDestroying}
/>
</section>
{/* --- Globals --------------------------------------------------- */}
<section className="space-y-2" data-testid="disk-globals">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
Shared and left over
</h3>
<dl className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-1 text-xs">
<dt className="text-[var(--text-secondary)]">
Base images ({report.base_images.length}) shared by every project
</dt>
<dd className="text-right tabular-nums">
{formatBytes(report.base_images_bytes)}
</dd>
<dt className="text-[var(--text-secondary)]">
Superseded images from past recreations ({report.orphan_image_count})
</dt>
<dd className="text-right tabular-nums">
{formatBytes(report.orphan_image_bytes)}
</dd>
<dt className="text-[var(--text-secondary)]">
Volumes with no matching project in Triple-C (
{report.orphan_volumes.length})
</dt>
<dd className="text-right tabular-nums">
{formatBytes(report.orphan_volume_bytes)}
</dd>
<dt className="text-[var(--text-secondary)]">
Build cache <strong className="text-[var(--warning)]">whole daemon</strong>,
not just Triple-C{" "}
<span className="text-[var(--text-disabled)]">
(via {report.build_cache.source})
</span>
</dt>
<dd className="text-right tabular-nums">
{formatBytes(report.build_cache.reclaimable_bytes)} of{" "}
{formatBytes(report.build_cache.total_bytes)}
</dd>
<dt className="text-[var(--text-primary)] font-medium pt-1 border-t border-[var(--border-color)]">
Attributable to Triple-C
</dt>
<dd className="text-right tabular-nums text-[var(--text-primary)] font-medium pt-1 border-t border-[var(--border-color)]">
{formatBytes(report.triple_c_total_bytes)}
</dd>
<dt className="text-[var(--text-secondary)]">
Everything on this daemon, yours included
</dt>
<dd className="text-right tabular-nums">
{formatBytes(
report.images_total_bytes +
report.containers_total_bytes +
report.volumes_total_bytes,
)}
</dd>
</dl>
{report.orphan_volumes.length > 0 && (
<p className="text-[11px] text-[var(--text-secondary)] leading-relaxed">
That last figure means only that the volume&rsquo;s project id is not in
your project list &mdash; it is <em>not</em> 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.
</p>
)}
<p className="text-[11px] text-[var(--text-secondary)]">
Docker stores this at{" "}
<span className="font-mono">{report.host.docker_root_dir || "an unknown path"}</span>
{report.host.is_docker_desktop && " — a path inside the Docker Desktop VM, not on your filesystem"}.
</p>
</section>
{/* --- Store failure, if any ------------------------------------ */}
{report.orphan_volumes_unavailable && (
<section
className="border border-[var(--warning)]/40 bg-[var(--warning-muted)] rounded-[var(--radius-panel)] px-3.5 py-3"
data-testid="disk-store-error"
>
<StatusIndicator
tone="error"
label="Could not read the project list"
className="text-xs"
/>
<p className="mt-1.5 text-xs text-[var(--text-primary)] leading-relaxed">
{report.orphan_volumes_unavailable}
</p>
</section>
)}
{/* --- Safe reclaim ---------------------------------------------- */}
<section className="space-y-2" data-testid="disk-safe-bucket">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
Safe to reclaim
</h3>
{safeItems.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">
Nothing here no leftovers were found.
</p>
) : (
<>
<p className="text-xs text-[var(--text-secondary)]">
None of this is reachable any more, or all of it regenerates on demand.
Nothing you have made is in this list.
</p>
<ul className="space-y-1.5">
{safeItems.map((item) => {
const key = targetKey(item.target);
return (
<li key={key}>
<label className="flex items-start gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={ticked.has(key)}
disabled={item.blocked !== null}
onChange={() => toggle(item)}
className="mt-0.5 accent-[var(--accent-emphasis)]"
/>
<span className="flex-1 min-w-0">
<span className="flex items-baseline justify-between gap-3">
<span
className={
item.blocked
? "text-[var(--text-disabled)]"
: "text-[var(--text-primary)]"
}
>
{item.label}
{item.daemon_wide && (
<span className="ml-1.5 text-[11px] text-[var(--warning)] border border-[var(--warning)]/40 rounded-[var(--radius-control)] px-1 py-px">
whole daemon
</span>
)}
</span>
<span className="tabular-nums whitespace-nowrap text-[var(--text-secondary)]">
{formatBytes(item.bytes)}
</span>
</span>
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
{item.detail}
</span>
{item.blocked && (
<span className="block text-xs text-[var(--text-disabled)]">
{item.blocked}
</span>
)}
</span>
</label>
</li>
);
})}
</ul>
<div className="flex items-center gap-3">
<Button
variant="primary"
size="md"
disabled={selected.length === 0 || working}
onClick={() => runReclaim(selected.map((i) => i.target))}
>
{working ? "Reclaiming…" : "Reclaim"}
</Button>
<span className="text-xs text-[var(--text-secondary)]">
{selected.length === 0
? "Nothing ticked."
: `${selected.length} selected, ${formatBytes(selectedBytes)}.`}
</span>
</div>
</>
)}
</section>
{/* --- Semi-safe -------------------------------------------------- */}
{semiItems.length > 0 && (
<section className="space-y-2" data-testid="disk-semi-bucket">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
Worth doing, one at a time
</h3>
<p className="text-xs text-[var(--text-secondary)]">
Nothing here loses anything you have installed. Compacting rewrites a
project&rsquo;s stacked layers into one; clearing caches deletes files
that refill themselves. Both take a moment and both are confirmed
separately.
</p>
<ul className="space-y-1.5">
{semiItems.map((item) => (
<li
key={targetKey(item.target)}
className="flex items-start justify-between gap-3"
>
<span className="flex-1 min-w-0">
<span className="block text-[var(--text-primary)]">{item.label}</span>
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
{item.detail}
</span>
{item.blocked && (
<span className="block text-xs text-[var(--text-disabled)]">
{item.blocked}
</span>
)}
</span>
<span className="flex items-center gap-2 whitespace-nowrap">
<span className="text-xs text-[var(--text-secondary)] tabular-nums">
{/* 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)}
</span>
<Button
size="sm"
disabled={item.blocked !== null || working}
onClick={() => setConfirming(item)}
>
Run
</Button>
</span>
</li>
))}
</ul>
</section>
)}
{/* --- Sweep ------------------------------------------------------ */}
<section className="flex items-center gap-3 flex-wrap">
<Button
size="sm"
disabled={working}
onClick={() => runReclaim([{ kind: "dangling_snapshots" }])}
>
Sweep superseded images now
</Button>
<span className="text-xs text-[var(--text-secondary)]">
The same sweep that runs at startup and after every recreation here you
can see what it found.
</span>
</section>
</>
)}
{/* --- Outcome ------------------------------------------------------- */}
{outcome && (
<section
className="border border-[var(--border-color)] bg-[var(--bg-primary)] rounded-[var(--radius-panel)] px-3.5 py-3 space-y-1.5"
role="status"
aria-live="polite"
data-testid="disk-outcome"
>
<StatusIndicator
tone={outcome.results.every((r) => r.ok) ? "ok" : "error"}
label={`Reclaimed ${formatBytes(outcome.total_freed_bytes)}`}
className="text-xs"
/>
<ul className="space-y-1 text-xs text-[var(--text-secondary)]">
{outcome.results.map((result, index) => (
<li key={index}>
{result.message}
{result.projected_bytes !== null && (
<>
{" "}
<span className="text-[var(--text-disabled)]">
(projected {formatBytesCeiling(result.projected_bytes)}, actually{" "}
{formatBytes(result.freed_bytes)})
</span>
</>
)}
</li>
))}
</ul>
</section>
)}
{/* --- Semi-safe confirmation ---------------------------------------- */}
{confirming && (
<Modal
title={confirming.label}
onClose={() => setConfirming(null)}
widthClassName="w-[30rem]"
footer={
<>
<Button size="md" variant="ghost" onClick={() => setConfirming(null)}>
Cancel
</Button>
<Button
size="md"
variant="primary"
disabled={working}
onClick={() => {
const target = confirming.target;
setConfirming(null);
void runReclaim([target]);
}}
>
{working ? "Working…" : "Run it"}
</Button>
</>
}
>
<div className="space-y-2.5 text-[13px] text-[var(--text-secondary)]">
<p>{confirming.detail}</p>
{confirming.target.kind === "compact_snapshot" && (
<>
<p>
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.
</p>
<p>
How much comes back depends on how much of those layers a later one
already replaced &mdash; it could be{" "}
{formatBytesCeiling(confirming.bytes)}, and it could be nothing at all.
You will be told the real figure when it finishes.
</p>
<p>
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.
</p>
</>
)}
{confirming.target.kind === "clear_caches" &&
confirming.target.include_rustup && (
<p>
Rust toolchains are included in this one. They are regenerable, but
getting them back is a download rather than a rebuild.
</p>
)}
</div>
</Modal>
)}
{/* --- Destructive confirmation --------------------------------------- */}
{destroying && (
<TypedConfirmModal
title={`Delete ${destroying.label.toLowerCase()}`}
expected={destroying.project_name}
confirmLabel={`Delete ${destroying.label.toLowerCase()}`}
busy={working}
onCancel={() => setDestroying(null)}
onConfirm={(typed) => {
const target = destroying.target;
setDestroying(null);
void destroy(target, typed);
}}
>
<p>
This removes{" "}
<strong className="text-[var(--text-primary)]">
{destroying.project_name}
</strong>
&rsquo;s {destroying.label.toLowerCase()}, freeing{" "}
{formatBytes(destroying.bytes)}.
</p>
<p className="text-[var(--error)]">{destroying.loses}</p>
<p>
Your mounted project folders live on the host and are not affected by this.
</p>
</TypedConfirmModal>
)}
</div>
);
}
@@ -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() {
<DockerSettings />
</AccordionSection>
<AccordionSection id="disk" title="Disk" defaultOpen={false}>
<DiskSettings />
</AccordionSection>
<AccordionSection id="certificates" title="Certificates" defaultOpen={false}>
<CertificateSettings />
</AccordionSection>
@@ -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<React.ComponentProps<typeof TypedConfirmModal>> = {}) {
render(
<TypedConfirmModal
title="Delete claude config volume"
expected="whp"
confirmLabel="Delete config volume"
onConfirm={onConfirm}
onCancel={onCancel}
{...props}
>
<p>Everything goes.</p>
</TypedConfirmModal>,
);
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(
<TypedConfirmModal
title="Delete claude config volume"
expected="whp"
confirmLabel="Delete config volume"
onConfirm={onConfirm}
onCancel={onCancel}
busy
>
<p>Everything goes.</p>
</TypedConfirmModal>,
);
// 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();
});
});
+113
View File
@@ -0,0 +1,113 @@
import { 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<HTMLInputElement>(null);
const matches = expected.trim().length > 0 && typed.trim() === expected.trim();
return (
<Modal
title={title}
onClose={onCancel}
widthClassName="w-[30rem]"
initialFocusRef={inputRef}
dismissible={!busy}
footer={
<>
<Button size="md" variant="ghost" onClick={onCancel} disabled={busy}>
Cancel
</Button>
<Button
size="md"
onClick={() => onConfirm(typed)}
disabled={!matches || busy}
className={
matches && !busy
? "bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
: "bg-[var(--bg-tertiary)] text-[var(--text-disabled)] border border-[var(--border-color)]"
}
>
{busy ? "Working…" : confirmLabel}
</Button>
</>
}
>
<div className="space-y-3 text-[13px] text-[var(--text-secondary)]">
{children}
<div>
<label
htmlFor="typed-confirm-input"
className="block text-[13px] text-[var(--text-primary)] mb-1.5"
>
Type <strong className="font-mono">{expected}</strong> to confirm
</label>
<input
id="typed-confirm-input"
ref={inputRef}
value={typed}
onChange={(e) => 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. */}
<p role="status" aria-live="polite" className="mt-1.5 text-xs">
{matches ? (
<span className="text-[var(--text-secondary)]">Name matches.</span>
) : (
<span className="text-[var(--text-disabled)]">
Waiting for the exact project name.
</span>
)}
</p>
</div>
</div>
</Modal>
);
}
+161
View File
@@ -0,0 +1,161 @@
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: 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<DiskUsageReport>((r) => {
resolveFirst = r;
}),
)
.mockResolvedValueOnce(report("second"));
const { result } = renderHook(() => useDiskUsage());
let firstScan: Promise<void> = 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("surfaces a failure rather than leaving a stale report on screen", async () => {
getDockerDiskUsage.mockRejectedValue("daemon unreachable");
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
await waitFor(() => expect(result.current.error).toMatch(/daemon unreachable/));
expect(result.current.scanning).toBe(false);
});
});
+114
View File
@@ -0,0 +1,114 @@
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 a `scan()` the Scan button calls and nothing else, and
* the result lives in this hook rather than in the component so that reopening
* the section shows the last result instead of paying again.
*
* ## 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 checks it is still the newest before it lands, the same pattern
* `useContainerMigration` uses.
*/
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<void>;
runReclaim: (targets: ReclaimTarget[]) => Promise<void>;
destroy: (target: DestructiveTarget, confirmation: string) => Promise<void>;
clearOutcome: () => void;
}
export function useDiskUsage(): DiskUsageState {
const [report, setReport] = useState<DiskUsageReport | null>(null);
const [plan, setPlan] = useState<ReclaimPlan | null>(null);
const [scanning, setScanning] = useState(false);
const [working, setWorking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [outcome, setOutcome] = useState<ReclaimOutcome | null>(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;
setReport(next);
// 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;
setPlan(nextPlan);
} catch (e) {
if (generation.current !== mine) return;
setError(String(e));
} 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);
// 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.
} 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 });
} catch (e) {
setError(String(e));
} finally {
setWorking(false);
}
}, []);
const clearOutcome = useCallback(() => setOutcome(null), []);
return { report, plan, scanning, working, error, outcome, scan, runReclaim, destroy, clearOutcome };
}
+87
View File
@@ -0,0 +1,87 @@
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("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");
});
});
+86
View File
@@ -0,0 +1,86 @@
/**
* The one byte formatter.
*
* Before this existed 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. They are now expressed in terms of this.
*
* ## 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 exactly, so re-pointing the old
* call sites changed no rendered string:
*
* - `{ }` `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;
}
// 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` / `nothing` 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)}`;
}
+32 -1
View File
@@ -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<boolean>("check_docker");
@@ -349,3 +349,34 @@ export const rollbackMigration = (projectId: string) =>
* app crash shows up here as phase "interrupted". */
export const getMigrationState = (projectId: string) =>
invoke<MigrationState | null>("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<DiskUsageReport>("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<ReclaimPlan>("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<ReclaimOutcome>("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<ReclaimResult>("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<SnapshotSweepReport>("sweep_orphaned_snapshots");
+195
View File
@@ -817,3 +817,198 @@ 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. */
snapshot_commit_layers: number;
/** 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 {
target: ReclaimTarget;
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;
}