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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.10–1.32 GB over a 4.72 GB shared base: flattening any of them turns a
|
||||
// sub-gigabyte cost into a ~4.7 GB one.
|
||||
//
|
||||
// A ceiling of zero keeps them out of the plan entirely, rather than
|
||||
// offering a 4 GB loss as a saving.
|
||||
let shared_base = 4_723_860_394;
|
||||
for unique in [100_000_000i64, 630_000_000, 1_320_000_000] {
|
||||
assert_eq!(
|
||||
compaction_ceiling_for(unique, shared_base, 6),
|
||||
0,
|
||||
"a {}-byte delta over a {}-byte base must not be offered",
|
||||
unique,
|
||||
shared_base
|
||||
);
|
||||
}
|
||||
|
||||
// The one project that *was* worth it: 8.44 GB unique across 14 layers over
|
||||
// a 3.83 GB base. The base penalty still binds — 8.44 - 3.83 = 4.61 GB is
|
||||
// smaller than the 7.84 GB the even split allows — so that is the figure.
|
||||
let ceiling = compaction_ceiling_for(8_440_966_715, 3_832_425_659, 14);
|
||||
assert_eq!(ceiling, 8_440_966_715 - 3_832_425_659);
|
||||
assert!(ceiling < (8_440_966_715 / 14) * 13, "the base penalty must bind here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_superseded_bound_still_binds_when_the_base_is_small() {
|
||||
// With a tiny base, the limit on what can come back is how much the layers
|
||||
// superseded, not the duplication cost. Both terms have to be live.
|
||||
let ceiling = compaction_ceiling_for(300, 10, 3);
|
||||
assert_eq!(ceiling, 200, "the even split binds, not 300 - 10");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn cache(size: i64, in_use: bool, age_hours: i64) -> BuildCacheFacts {
|
||||
BuildCacheFacts {
|
||||
size,
|
||||
in_use,
|
||||
last_used_at: Some(chrono::Utc::now() - chrono::Duration::hours(age_hours)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_age_filter_leaves_in_use_and_recent_records_alone() {
|
||||
let now = chrono::Utc::now();
|
||||
let entries = vec![
|
||||
cache(1_000, false, 200), // old and free -> counted
|
||||
cache(2_000, true, 200), // old but in use -> never
|
||||
cache(4_000, false, 10), // free but recent -> not by this filter
|
||||
BuildCacheFacts {
|
||||
size: 8_000,
|
||||
in_use: false,
|
||||
// No timestamp at all: unknown age fails closed, same rule as an
|
||||
// unknown volume ref count.
|
||||
last_used_at: None,
|
||||
},
|
||||
];
|
||||
assert_eq!(stale_build_cache_bytes(&entries, 168, now), 1_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_sizes_parse_in_base_1000_because_that_is_what_docker_prints() {
|
||||
// `units.HumanSize` is base 1000. Reading "28.0GB" as 1024-based would
|
||||
// overstate the single biggest win in this panel by about 7%.
|
||||
assert_eq!(parse_docker_size("0B"), Some(0));
|
||||
assert_eq!(parse_docker_size("28.0GB"), Some(28_000_000_000));
|
||||
assert_eq!(parse_docker_size("1.5MB"), Some(1_500_000));
|
||||
assert_eq!(parse_docker_size(" 46.88GB "), Some(46_880_000_000));
|
||||
assert_eq!(parse_docker_size("12kB"), Some(12_000));
|
||||
|
||||
// Anything unrecognised is None, so the caller falls back to `df()` rather
|
||||
// than showing a wrong number.
|
||||
assert_eq!(parse_docker_size("lots"), None);
|
||||
assert_eq!(parse_docker_size(""), None);
|
||||
assert_eq!(parse_docker_size("12GiB"), None);
|
||||
|
||||
// A space before the unit is fine — `docker builder prune` uses a tab.
|
||||
assert_eq!(parse_docker_size("1.5 kB"), Some(1_500));
|
||||
assert_eq!(parse_docker_size("\t20.59MB"), Some(20_590_000));
|
||||
|
||||
// A negative would subtract from the running freed total if it got through.
|
||||
assert_eq!(parse_docker_size("-5GB"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buildx_du_output_parses_into_total_and_reclaimable() {
|
||||
// Real shape, taken from `docker buildx du` on Docker 29.7.2.
|
||||
let output = "ID RECLAIMABLE SIZE LAST ACCESSED\n\
|
||||
abc123 true 29.78MB 36 seconds ago\n\
|
||||
Reclaimable:\t28.0GB\n\
|
||||
Total:\t\t33.57MB\n";
|
||||
assert_eq!(parse_buildx_du(output), Some((33_570_000, 28_000_000_000)));
|
||||
|
||||
// An empty cache still reports both lines.
|
||||
assert_eq!(
|
||||
parse_buildx_du("Reclaimable:\t0B\nTotal:\t\t0B\n"),
|
||||
Some((0, 0))
|
||||
);
|
||||
// No Total line means the output is not what we expect; fall back rather
|
||||
// than invent.
|
||||
assert_eq!(parse_buildx_du("nothing here"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reclaimed_figure_comes_from_the_prunes_own_report() {
|
||||
// `docker system prune` / `image prune` wording.
|
||||
let output = "deleted: sha256:abc\ndeleted: sha256:def\nTotal reclaimed space: 12.3GB\n";
|
||||
assert_eq!(parse_reclaimed_space(output), 12_300_000_000);
|
||||
assert_eq!(parse_reclaimed_space("Total reclaimed space: 0B"), 0);
|
||||
|
||||
// `docker builder prune` wording — the one this module actually runs, and
|
||||
// the one an earlier draft of the parser missed entirely, reporting every
|
||||
// build-cache prune as having freed nothing. Verbatim from Docker 29.7.2.
|
||||
let builder = "2zp7lsfz2me0jtqe8rio6s4eq*\ttrue\t\t8.192kB\tLess than a second ago\n\
|
||||
rmonzx1v6jrrlgxt783dmfb3k\ttrue\t16.79MB\t1 second ago\n\
|
||||
Total:\t20.59MB\n";
|
||||
assert_eq!(parse_reclaimed_space(builder), 20_590_000);
|
||||
|
||||
// A filtered prune that matched nothing still prints the summary.
|
||||
assert_eq!(parse_reclaimed_space("Total:\t0B\n"), 0);
|
||||
|
||||
// A prune that printed nothing recognisable freed nothing we can claim.
|
||||
assert_eq!(parse_reclaimed_space("nothing to do"), 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scripts — shell strings, so pinned by test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn the_compaction_dockerfile_reuses_the_one_scrub_list() {
|
||||
let df = compaction_dockerfile(
|
||||
"triple-c-snapshot-p1:latest",
|
||||
&container::snapshot_scrub_script(),
|
||||
);
|
||||
|
||||
assert!(df.starts_with("FROM triple-c-snapshot-p1:latest AS src\n"));
|
||||
assert!(
|
||||
df.contains("\nFROM scratch\nCOPY --from=src / /\n"),
|
||||
"the flatten is the whole point: {}",
|
||||
df
|
||||
);
|
||||
|
||||
// Every path in the reviewed list has to appear, and it has to be *that*
|
||||
// list rather than a second copy — a forked list is the failure mode a
|
||||
// hardcoded set of `rm -rf` paths invites.
|
||||
for path in container::SNAPSHOT_SCRUB_PATHS {
|
||||
assert!(df.contains(path), "scrub path {} missing from {}", path, df);
|
||||
}
|
||||
|
||||
// The RUN must be one line: a Dockerfile instruction does not continue over
|
||||
// a bare newline, and a script folded wrongly would silently truncate to
|
||||
// its first statement.
|
||||
let run_lines: Vec<&str> = df.lines().filter(|l| l.starts_with("RUN ")).collect();
|
||||
assert_eq!(run_lines.len(), 1, "{}", df);
|
||||
assert!(!run_lines[0].contains('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_compaction_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());
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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’s project id is not in
|
||||
your project list — 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’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 — 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>
|
||||
’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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user