Terminal newlines, OAuth callback, Claude Code settings, and the Files tab #30
@@ -97,7 +97,19 @@ pub struct ProjectDiskRow {
|
||||
pub snapshot_shared_bytes: i64,
|
||||
/// How many layers the snapshot has stacked **above its base image**. This
|
||||
/// is the number that explains the growth: one per recreation.
|
||||
///
|
||||
/// Only means that when [`Self::base_lineage_known`] is true. Otherwise it
|
||||
/// is every layer carrying bytes, base included — an upper bound, and a
|
||||
/// misleading one to present as a recreation count.
|
||||
pub snapshot_commit_layers: u32,
|
||||
/// Whether the base image this snapshot descends from could be identified.
|
||||
///
|
||||
/// False when `triple-c.base-image-id` is absent, which is the **normal**
|
||||
/// case for a project created before that label existed. The UI must not
|
||||
/// present `snapshot_commit_layers` as a recreation count in that state,
|
||||
/// and compaction is not offered, because a never-recreated project would
|
||||
/// otherwise report its base's ~15 layers and qualify.
|
||||
pub base_lineage_known: bool,
|
||||
/// Bytes those stacked layers account for. `None` when the base image the
|
||||
/// snapshot descends from is no longer on the daemon, so the split cannot
|
||||
/// be measured and must not be guessed.
|
||||
@@ -422,7 +434,18 @@ pub struct ReclaimOutcome {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ReclaimResult {
|
||||
pub target: ReclaimTarget,
|
||||
/// The reclaim target this reports on, or `None` when it reports a
|
||||
/// [`destroy`].
|
||||
///
|
||||
/// Deliberately not reused to carry a destructive action: an earlier
|
||||
/// version returned `OrphanVolume { name }` for a home-volume deletion,
|
||||
/// which named a volume that was never an orphan and would attribute the
|
||||
/// outcome to a plan row the user never ticked. `destroyed` carries it
|
||||
/// instead, and exactly one of the two is ever set.
|
||||
pub target: Option<ReclaimTarget>,
|
||||
/// The destructive action this reports on, when it is one.
|
||||
#[serde(default)]
|
||||
pub destroyed: Option<DestructiveTarget>,
|
||||
pub ok: bool,
|
||||
/// Bytes actually freed, measured after the fact.
|
||||
pub freed_bytes: i64,
|
||||
@@ -763,6 +786,14 @@ pub fn parse_reclaimed_space(output: &str) -> i64 {
|
||||
/// [`restore_image_config`], which is why this function does not try to emit it
|
||||
/// as Dockerfile instructions. A multi-line `CLAUDE_INSTRUCTIONS` env var alone
|
||||
/// makes that escaping a bad bet.
|
||||
///
|
||||
/// The one label it *does* emit is `triple-c.managed=true`, and it is not
|
||||
/// decoration. Everything that cleans up after this build — the discard path
|
||||
/// when the result is not smaller, the untag after a successful commit — relies
|
||||
/// on `sweep_orphaned_snapshots` collecting the intermediate, and that sweep
|
||||
/// filters on `dangling=true` **and** this label. Without it the sweep can
|
||||
/// never match, and the flattened intermediate is left to whatever `untag_image`
|
||||
/// happens to delete on its own.
|
||||
pub fn compaction_dockerfile(snapshot_ref: &str, scrub_script: &str) -> String {
|
||||
// The scrub script is multi-line shell. `RUN` takes it verbatim only if the
|
||||
// newlines are escaped, so it is folded onto one line with `;` separators —
|
||||
@@ -773,7 +804,8 @@ pub fn compaction_dockerfile(snapshot_ref: &str, scrub_script: &str) -> String {
|
||||
"FROM {snapshot_ref} AS src\n\
|
||||
RUN {folded}\n\
|
||||
FROM scratch\n\
|
||||
COPY --from=src / /\n"
|
||||
COPY --from=src / /\n\
|
||||
LABEL {LABEL_MANAGED}=true\n"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1000,7 +1032,13 @@ fn projects_json_health() -> (bool, bool) {
|
||||
/// `LABEL` lines carries neither label, and a globals block that could not name
|
||||
/// the 4.7 GB image every project sits on would be missing the obvious.
|
||||
fn is_base_image_reference(reference: &str) -> bool {
|
||||
let repo = reference.split(':').next().unwrap_or(reference);
|
||||
// Split on the *tag*, not the first colon: `localhost:5000/triple-c-sandbox:latest`
|
||||
// has a registry port, and splitting on the first colon would yield
|
||||
// `localhost`. A tag never contains `/`, which is what tells the two apart.
|
||||
let repo = match reference.rsplit_once(':') {
|
||||
Some((repo, tag)) if !tag.contains('/') => repo,
|
||||
_ => reference,
|
||||
};
|
||||
repo == "triple-c"
|
||||
|| repo.ends_with("/triple-c-sandbox")
|
||||
|| repo == "triple-c-sandbox"
|
||||
@@ -1081,6 +1119,7 @@ pub async fn scan(projects: &[Project]) -> Result<DiskUsageReport, String> {
|
||||
};
|
||||
|
||||
let mut stats = LayerStats::default();
|
||||
let mut base_lineage_known = false;
|
||||
if let Some(image) = snapshot {
|
||||
let history = docker
|
||||
.image_history(&image.id)
|
||||
@@ -1112,6 +1151,7 @@ pub async fn scan(projects: &[Project]) -> Result<DiskUsageReport, String> {
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
base_lineage_known = base_len.is_some();
|
||||
stats = layer_stats(&history, base_len);
|
||||
}
|
||||
|
||||
@@ -1140,6 +1180,7 @@ pub async fn scan(projects: &[Project]) -> Result<DiskUsageReport, String> {
|
||||
snapshot_bytes,
|
||||
snapshot_shared_bytes,
|
||||
snapshot_commit_layers: stats.commit_layers,
|
||||
base_lineage_known,
|
||||
// Prefer the daemon's own measurement of what is unique to this
|
||||
// image over layer arithmetic; fall back to the layer sum when
|
||||
// `df()` did not compute a shared size.
|
||||
@@ -1209,6 +1250,12 @@ pub async fn scan(projects: &[Project]) -> Result<DiskUsageReport, String> {
|
||||
}
|
||||
}
|
||||
base_images.sort_by(|a, b| b.bytes.cmp(&a.bytes));
|
||||
// Full size per base, not `size - shared_size`: a base's shared bytes are
|
||||
// shared with *its own snapshots*, so netting them out would report the
|
||||
// 4.7 GB image every project sits on as ~0. The residual imprecision is two
|
||||
// *different* bases that share lower layers with each other, whose common
|
||||
// layers are counted twice here — worth knowing before treating this total
|
||||
// as exact.
|
||||
let base_images_bytes = base_images.iter().map(|b| b.bytes).sum();
|
||||
|
||||
let (json_exists, json_parsed) = projects_json_health();
|
||||
@@ -1663,7 +1710,11 @@ pub async fn list_reclaimable(
|
||||
)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if row.snapshot_exists && row.snapshot_commit_layers > 1 && ceiling > 0 {
|
||||
if row.snapshot_exists
|
||||
&& row.base_lineage_known
|
||||
&& row.snapshot_commit_layers > 1
|
||||
&& ceiling > 0
|
||||
{
|
||||
items.push({
|
||||
// Safety and reach are read off the target, never restated: a literal
|
||||
// here that disagreed with the classifier is exactly the drift this
|
||||
@@ -1763,7 +1814,9 @@ pub async fn list_reclaimable(
|
||||
project_name: row.project_name.clone(),
|
||||
label: "Home volume".to_string(),
|
||||
loses: "Shell history, dotfiles, every toolchain installed under $HOME, and any \
|
||||
Playwright browsers. Not recoverable."
|
||||
Playwright browsers. Not recoverable. The project's container is removed \
|
||||
too, because a stopped container still holds its volumes open — it is \
|
||||
rebuilt from the snapshot on the next start."
|
||||
.to_string(),
|
||||
bytes: row.home_volume_bytes,
|
||||
blocked: blocked.clone(),
|
||||
@@ -1778,7 +1831,9 @@ pub async fn list_reclaimable(
|
||||
project_name: row.project_name.clone(),
|
||||
label: "Claude config volume".to_string(),
|
||||
loses: "The Claude login credential, installed plugins and skills, and EVERY \
|
||||
conversation transcript for this project. Not recoverable."
|
||||
conversation transcript for this project. Not recoverable. The project's \
|
||||
container is removed too, because a stopped container still holds its \
|
||||
volumes open — it is rebuilt from the snapshot on the next start."
|
||||
.to_string(),
|
||||
bytes: row.config_volume_bytes,
|
||||
blocked: blocked.clone(),
|
||||
@@ -2112,7 +2167,8 @@ fn find_project<'a>(projects: &'a [Project], project_id: &str) -> Result<&'a Pro
|
||||
|
||||
fn failed(target: ReclaimTarget, message: String) -> ReclaimResult {
|
||||
ReclaimResult {
|
||||
target,
|
||||
target: Some(target),
|
||||
destroyed: None,
|
||||
ok: false,
|
||||
freed_bytes: 0,
|
||||
projected_bytes: None,
|
||||
@@ -2196,7 +2252,8 @@ async fn reclaim_dangling(target: &ReclaimTarget) -> ReclaimResult {
|
||||
message.push_str(&format!(" {} could not be removed; see the log.", errors));
|
||||
}
|
||||
ReclaimResult {
|
||||
target: target.clone(),
|
||||
target: Some(target.clone()),
|
||||
destroyed: None,
|
||||
ok: errors == 0,
|
||||
freed_bytes: freed,
|
||||
projected_bytes: None,
|
||||
@@ -2222,7 +2279,8 @@ async fn reclaim_build_cache(all: bool) -> ReclaimResult {
|
||||
}
|
||||
match docker_cli(&args).await {
|
||||
Ok(output) => ReclaimResult {
|
||||
target,
|
||||
target: Some(target),
|
||||
destroyed: None,
|
||||
ok: true,
|
||||
freed_bytes: parse_reclaimed_space(&output),
|
||||
projected_bytes: None,
|
||||
@@ -2302,7 +2360,8 @@ async fn reclaim_migration_pins() -> ReclaimResult {
|
||||
sweep.reclaimed_bytes
|
||||
);
|
||||
ReclaimResult {
|
||||
target,
|
||||
target: Some(target),
|
||||
destroyed: None,
|
||||
ok: true,
|
||||
freed_bytes: sweep.reclaimed_bytes,
|
||||
projected_bytes: None,
|
||||
@@ -2352,7 +2411,8 @@ fn reclaim_migration_staging(projects: &[Project]) -> ReclaimResult {
|
||||
}
|
||||
}
|
||||
ReclaimResult {
|
||||
target,
|
||||
target: Some(target),
|
||||
destroyed: None,
|
||||
ok: true,
|
||||
freed_bytes: freed,
|
||||
projected_bytes: None,
|
||||
@@ -2407,7 +2467,8 @@ async fn reclaim_containers(
|
||||
}
|
||||
}
|
||||
ReclaimResult {
|
||||
target,
|
||||
target: Some(target),
|
||||
destroyed: None,
|
||||
ok: errors == 0,
|
||||
freed_bytes: freed,
|
||||
projected_bytes: None,
|
||||
@@ -2459,7 +2520,8 @@ async fn reclaim_orphan_volume(name: &str, projects: &[Project]) -> ReclaimResul
|
||||
|
||||
match docker.remove_volume(name, None).await {
|
||||
Ok(()) => ReclaimResult {
|
||||
target,
|
||||
target: Some(target),
|
||||
destroyed: None,
|
||||
ok: true,
|
||||
freed_bytes: volume.bytes,
|
||||
projected_bytes: None,
|
||||
@@ -2606,7 +2668,8 @@ pub async fn compact_snapshot(project: &Project) -> ReclaimResult {
|
||||
let _ = migration::untag_image(&staging_ref).await;
|
||||
let _ = container::sweep_orphaned_snapshots().await;
|
||||
return ReclaimResult {
|
||||
target,
|
||||
target: Some(target),
|
||||
destroyed: None,
|
||||
ok: true,
|
||||
freed_bytes: 0,
|
||||
projected_bytes: projected,
|
||||
@@ -2647,7 +2710,8 @@ pub async fn compact_snapshot(project: &Project) -> ReclaimResult {
|
||||
sweep.removed.len()
|
||||
);
|
||||
ReclaimResult {
|
||||
target,
|
||||
target: Some(target),
|
||||
destroyed: None,
|
||||
ok: true,
|
||||
freed_bytes: freed,
|
||||
projected_bytes: projected,
|
||||
@@ -2726,6 +2790,59 @@ async fn build_from_dockerfile(dockerfile: &str, tag: &str) -> Result<(), String
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Name prefix for the throwaway container that replays a compacted image's
|
||||
/// config. Distinct from `triple-c-scrub-*` on purpose — see
|
||||
/// [`restore_image_config`].
|
||||
const COMPACTION_CONTAINER_PREFIX: &str = "triple-c-compact-";
|
||||
|
||||
/// Remove any container left behind by an interrupted compaction.
|
||||
///
|
||||
/// Runs at the start of a compaction rather than from a reclaim bucket, so
|
||||
/// nothing can ever remove the container of a compaction that is still running:
|
||||
/// by the time this is called, this task owns the compaction path.
|
||||
async fn remove_stale_compaction_containers() {
|
||||
let Ok(docker) = get_docker() else {
|
||||
return;
|
||||
};
|
||||
let containers = docker
|
||||
.list_containers(Some(ListContainersOptions {
|
||||
all: true,
|
||||
size: false,
|
||||
filters: HashMap::from([(
|
||||
"name".to_string(),
|
||||
vec![COMPACTION_CONTAINER_PREFIX.to_string()],
|
||||
)]),
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for summary in containers {
|
||||
// Docker's `name` filter is a substring match; the full name decides.
|
||||
if !is_compaction_container(&summary) {
|
||||
continue;
|
||||
}
|
||||
if let Some(id) = summary.id.as_deref() {
|
||||
match container::remove_container(id).await {
|
||||
Ok(()) => log::info!("Removed stale compaction container {}", id),
|
||||
Err(e) => log::warn!("Could not remove stale compaction container {}: {}", id, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a container is one of ours from an interrupted compaction.
|
||||
fn is_compaction_container(summary: &ContainerSummary) -> bool {
|
||||
summary
|
||||
.names
|
||||
.as_deref()
|
||||
.unwrap_or(&[])
|
||||
.iter()
|
||||
.any(|name| {
|
||||
name.trim_start_matches('/')
|
||||
.starts_with(COMPACTION_CONTAINER_PREFIX)
|
||||
})
|
||||
}
|
||||
|
||||
/// Put a captured image config back onto a flattened image, under the original
|
||||
/// tag.
|
||||
///
|
||||
@@ -2748,12 +2865,20 @@ async fn restore_image_config(
|
||||
use bollard::image::CommitContainerOptions;
|
||||
|
||||
let docker = get_docker()?;
|
||||
// Deliberately the same `triple-c-scrub-*` name `rewrite_image_without_secrets`
|
||||
// uses. If this process dies between the create and the remove below, the
|
||||
// leftover is already covered by the "secret-scrub scratch containers"
|
||||
// bucket in this very panel rather than needing a second reaper. The two
|
||||
// never run at once: `reclaim` executes its targets in sequence.
|
||||
let scratch_name = format!("triple-c-scrub-{}", uuid::Uuid::new_v4().simple());
|
||||
|
||||
// **Its own prefix, not `triple-c-scrub-*`.** An earlier version reused the
|
||||
// secret-rewrite name on the grounds that the existing reclaim bucket would
|
||||
// then collect any leftover. It would — including the live one: that bucket
|
||||
// removes with `force: true`, so a "remove scrub containers" reclaim fired
|
||||
// from a second window while a compaction was mid-flight would destroy the
|
||||
// container the commit is about to run against. Sequential execution inside
|
||||
// one `reclaim` call is not a guarantee when two can be in flight.
|
||||
//
|
||||
// Stale ones are instead swept here, at the start of the next compaction —
|
||||
// a created-but-never-started container has no writable layer, so a
|
||||
// leftover costs almost nothing until then.
|
||||
remove_stale_compaction_containers().await;
|
||||
let scratch_name = format!("{}{}", COMPACTION_CONTAINER_PREFIX, uuid::Uuid::new_v4().simple());
|
||||
|
||||
// `image` is the flat build; everything else is copied from the original so
|
||||
// the committed image is byte-for-byte the same configuration.
|
||||
@@ -2877,7 +3002,8 @@ pub async fn clear_caches(project: &Project, include_rustup: bool) -> ReclaimRes
|
||||
match super::exec::exec_oneshot_as(&container_id, "claude", cmd, Vec::new()).await {
|
||||
Ok((output, _exit)) => match parse_cache_total(&output) {
|
||||
Some(bytes) => ReclaimResult {
|
||||
target,
|
||||
target: Some(target),
|
||||
destroyed: None,
|
||||
ok: true,
|
||||
freed_bytes: bytes as i64,
|
||||
projected_bytes: None,
|
||||
@@ -2934,8 +3060,9 @@ pub async fn destroy(
|
||||
// A running container holds all three of these open, and Docker's refusal
|
||||
// is not something to lean on for the volumes: it would happily leave a
|
||||
// half-removed project behind.
|
||||
if let Ok(Some(container_id)) = container::find_existing_container(project).await {
|
||||
if container::is_container_running(&container_id)
|
||||
let existing_container = container::find_existing_container(project).await.ok().flatten();
|
||||
if let Some(container_id) = existing_container.as_deref() {
|
||||
if container::is_container_running(container_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
@@ -2953,13 +3080,37 @@ pub async fn destroy(
|
||||
};
|
||||
// Size it before it goes, so the report is a measurement.
|
||||
let bytes = volume_size(&name).await;
|
||||
|
||||
// **A stopped container still pins its volumes.** Docker refuses
|
||||
// `remove_volume` with a 409 while any container references one,
|
||||
// and every project that has ever been started has exactly that —
|
||||
// a stopped container is the resting state, not an edge case. So
|
||||
// the container is removed first rather than letting the user type
|
||||
// a project name and then meet a raw 409. It is regenerable from
|
||||
// the snapshot; `DestructiveItem::loses` says so.
|
||||
if let Some(container_id) = existing_container.as_deref() {
|
||||
container::remove_container(container_id).await.map_err(|e| {
|
||||
format!(
|
||||
"Could not remove this project's container, which still holds the volume \
|
||||
open: {}. Nothing was removed.",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
log::info!(
|
||||
"Removed container {} so {} could be deleted",
|
||||
container_id,
|
||||
name
|
||||
);
|
||||
}
|
||||
|
||||
docker
|
||||
.remove_volume(&name, None)
|
||||
.await
|
||||
.map_err(|e| format!("Could not remove volume {}: {}", name, e))?;
|
||||
log::info!("Removed volume {} on explicit confirmation", name);
|
||||
Ok(ReclaimResult {
|
||||
target: ReclaimTarget::OrphanVolume { name: name.clone() },
|
||||
target: None,
|
||||
destroyed: Some(target.clone()),
|
||||
ok: true,
|
||||
freed_bytes: bytes,
|
||||
projected_bytes: None,
|
||||
@@ -2974,7 +3125,8 @@ pub async fn destroy(
|
||||
container::remove_snapshot_image(project).await?;
|
||||
let sweep = container::sweep_orphaned_snapshots().await;
|
||||
Ok(ReclaimResult {
|
||||
target: ReclaimTarget::DanglingSnapshots,
|
||||
target: None,
|
||||
destroyed: Some(target.clone()),
|
||||
ok: true,
|
||||
freed_bytes: bytes + sweep.reclaimed_bytes,
|
||||
projected_bytes: None,
|
||||
@@ -2985,6 +3137,19 @@ pub async fn destroy(
|
||||
})
|
||||
}
|
||||
DestructiveTarget::RollbackPin { tag, .. } => {
|
||||
// **The one destructive variant carrying a free-form string.**
|
||||
// Every other arm builds its target from constants; this one takes
|
||||
// a tag over IPC and interpolates it into an image reference that
|
||||
// is then removed. Unvalidated, `tag: "latest"` names the project's
|
||||
// live snapshot — deleted under a dialog that says "rollback pin".
|
||||
// `parse_rollback_tag` accepts only `pre-migration-<YYYYmmdd-HHMMSS>`,
|
||||
// which is exactly what `rollback_tag` produces and nothing else.
|
||||
if migration::parse_rollback_tag(tag).is_none() {
|
||||
return Err(format!(
|
||||
"{:?} is not a rollback pin tag. Nothing was removed.",
|
||||
tag
|
||||
));
|
||||
}
|
||||
let reference = format!("triple-c-snapshot-{}:{}", project.id, tag);
|
||||
migration::untag_image(&reference).await?;
|
||||
// Untagging only makes the image dangling. Whatever came back came
|
||||
@@ -2992,7 +3157,8 @@ pub async fn destroy(
|
||||
let sweep = container::sweep_orphaned_snapshots().await;
|
||||
log::info!("Dropped rollback pin {} on explicit confirmation", reference);
|
||||
Ok(ReclaimResult {
|
||||
target: ReclaimTarget::MigrationPins,
|
||||
target: None,
|
||||
destroyed: Some(target.clone()),
|
||||
ok: true,
|
||||
freed_bytes: sweep.reclaimed_bytes,
|
||||
projected_bytes: None,
|
||||
@@ -3011,8 +3177,13 @@ pub async fn destroy(
|
||||
/// The plain `Size` from `inspect_image` includes the base, which several other
|
||||
/// projects are still built from and which is not going anywhere. Reporting it
|
||||
/// as freed would overstate a snapshot removal by ~4.7 GB every time. Only
|
||||
/// `df()` computes `SharedSize`, so this costs one — acceptable on the
|
||||
/// destructive path, which handles exactly one object per call.
|
||||
/// `df()` computes `SharedSize`, so each call costs a full daemon walk.
|
||||
///
|
||||
/// That is three `df()`s on a compaction (before, after, and the scan that
|
||||
/// planned it) and one per destructive removal. Acceptable because both are
|
||||
/// single-object, user-initiated actions that already take seconds to minutes —
|
||||
/// but it is why nothing in the *scan* path calls this: `scan` gets shared
|
||||
/// sizes from the one `df()` it already makes.
|
||||
async fn image_unique_bytes(reference: &str) -> i64 {
|
||||
let Ok(docker) = get_docker() else {
|
||||
return 0;
|
||||
|
||||
@@ -338,6 +338,26 @@ fn a_scrub_container_is_matched_on_its_whole_name_not_a_substring() {
|
||||
assert!(!is_scrub_container(&summary(&[], &[])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_compaction_container_is_never_matched_by_the_scrub_bucket() {
|
||||
// These had the same `triple-c-scrub-*` prefix once. The scrub bucket
|
||||
// removes with `force: true`, so a reclaim fired from a second window while
|
||||
// a compaction was mid-flight would have destroyed the container the commit
|
||||
// was about to run against. Separate prefixes, and neither predicate may
|
||||
// reach the other's containers.
|
||||
let compaction = summary(&["/triple-c-compact-abc123"], &[]);
|
||||
let scrub = summary(&["/triple-c-scrub-abc123"], &[]);
|
||||
|
||||
assert!(is_compaction_container(&compaction));
|
||||
assert!(!is_scrub_container(&compaction), "the scrub bucket must not reach it");
|
||||
|
||||
assert!(is_scrub_container(&scrub));
|
||||
assert!(!is_compaction_container(&scrub));
|
||||
|
||||
// Same substring-filter hazard applies to the new prefix.
|
||||
assert!(!is_compaction_container(&summary(&["/my-triple-c-compact-notes"], &[])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_probe_container_is_matched_on_its_label_not_on_the_daemons_filter() {
|
||||
// The `label=triple-c.probe=migration` filter is an exact match and would
|
||||
@@ -627,6 +647,24 @@ fn the_compaction_dockerfile_reuses_the_one_scrub_list() {
|
||||
assert!(!run_lines[0].contains('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_compaction_build_is_labelled_so_the_sweep_can_collect_it() {
|
||||
// Everything that cleans up after this build — the discard path when the
|
||||
// result is not smaller, the untag after a successful commit — leans on
|
||||
// `sweep_orphaned_snapshots`, and that sweep filters on `dangling=true`
|
||||
// AND `triple-c.managed=true`. Without the label it can never match, and
|
||||
// the flattened intermediate is stranded.
|
||||
let df = compaction_dockerfile("x:latest", &container::snapshot_scrub_script());
|
||||
assert!(
|
||||
df.contains("LABEL triple-c.managed=true"),
|
||||
"the sweep filters on this label and would never match: {}",
|
||||
df
|
||||
);
|
||||
// It has to be on the *final* stage, not the discarded `src` one.
|
||||
let after_scratch = df.split("FROM scratch").nth(1).expect("no final stage");
|
||||
assert!(after_scratch.contains("LABEL triple-c.managed=true"), "{}", df);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_compaction_dockerfile_never_reaches_a_bind_mount() {
|
||||
let df = compaction_dockerfile("x:latest", &container::snapshot_scrub_script());
|
||||
@@ -730,6 +768,77 @@ fn a_typed_confirmation_must_match_the_project_name_exactly() {
|
||||
assert!(!confirmation_matches("", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_real_rollback_tag_can_name_an_image_to_delete() {
|
||||
// `DestructiveTarget::RollbackPin` is the one destructive variant carrying
|
||||
// a free-form string from the frontend, and `destroy` interpolates it into
|
||||
// an image reference it then removes. Unguarded, `tag: "latest"` names the
|
||||
// project's *live snapshot* — deleted under a dialog that says "rollback
|
||||
// pin". The guard is `parse_rollback_tag`, so this pins what it accepts.
|
||||
assert!(migration::parse_rollback_tag("pre-migration-20260101-101500").is_some());
|
||||
|
||||
for hostile in [
|
||||
"latest",
|
||||
"",
|
||||
"pre-migration-",
|
||||
"pre-migration-notatimestamp",
|
||||
"../latest",
|
||||
"latest\npre-migration-20260101-101500",
|
||||
] {
|
||||
assert!(
|
||||
migration::parse_rollback_tag(hostile).is_none(),
|
||||
"{:?} must not be accepted as a rollback pin tag",
|
||||
hostile
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_destroy_result_never_claims_to_be_reclaim_work() {
|
||||
// An earlier version returned `OrphanVolume { name }` for a home-volume
|
||||
// deletion — naming a volume that was never an orphan, and attributing the
|
||||
// outcome to a plan row the user never ticked. Exactly one of the two
|
||||
// fields is ever set.
|
||||
let reclaim_shaped = ReclaimResult {
|
||||
target: Some(ReclaimTarget::DanglingSnapshots),
|
||||
destroyed: None,
|
||||
ok: true,
|
||||
freed_bytes: 1,
|
||||
projected_bytes: None,
|
||||
message: String::new(),
|
||||
};
|
||||
let destroy_shaped = ReclaimResult {
|
||||
target: None,
|
||||
destroyed: Some(DestructiveTarget::HomeVolume {
|
||||
project_id: "p1".to_string(),
|
||||
}),
|
||||
..reclaim_shaped.clone()
|
||||
};
|
||||
assert!(reclaim_shaped.target.is_some() != reclaim_shaped.destroyed.is_some());
|
||||
assert!(destroy_shaped.target.is_some() != destroy_shaped.destroyed.is_some());
|
||||
|
||||
// And both shapes survive the wire.
|
||||
let json = serde_json::to_string(&destroy_shaped).unwrap();
|
||||
assert_eq!(serde_json::from_str::<ReclaimResult>(&json).unwrap(), destroy_shaped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_with_no_known_base_is_not_offered_for_compaction() {
|
||||
// With `triple-c.base-image-id` absent — the normal case for a project
|
||||
// created before that label existed — `layer_stats` counts every layer that
|
||||
// carries bytes, base included. A never-recreated project then reports ~15
|
||||
// "commit layers" and would sail past a `> 1` check. `base_lineage_known`
|
||||
// is what stops the plan offering a rewrite sized from a number that does
|
||||
// not mean what its name says.
|
||||
let unknown = layer_stats(&[10, 20, 30, 40], None);
|
||||
assert_eq!(unknown.commit_layers, 4);
|
||||
assert_eq!(unknown.above_base_bytes, None, "the split must not be guessed");
|
||||
|
||||
let known = layer_stats(&[10, 20, 30, 40], Some(3));
|
||||
assert_eq!(known.commit_layers, 1);
|
||||
assert_eq!(known.above_base_bytes, Some(10));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host detection
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -763,9 +872,14 @@ fn base_images_are_recognised_by_reference_for_display_only() {
|
||||
assert!(is_base_image_reference("triple-c-sandbox:latest"));
|
||||
assert!(is_base_image_reference("triple-c:latest"));
|
||||
|
||||
// A registry port must not be mistaken for a tag separator.
|
||||
assert!(is_base_image_reference("localhost:5000/triple-c-sandbox:latest"));
|
||||
assert!(is_base_image_reference("registry.example.com:8443/triple-c-sandbox"));
|
||||
|
||||
// A project's own snapshot is not a base image, and neither is anything of
|
||||
// the user's.
|
||||
assert!(!is_base_image_reference("triple-c-snapshot-abc:latest"));
|
||||
assert!(!is_base_image_reference("localhost:5000/postgres:17"));
|
||||
assert!(!is_base_image_reference("triple-c-gateway:latest"));
|
||||
assert!(!is_base_image_reference("postgres:17-alpine"));
|
||||
}
|
||||
@@ -815,6 +929,7 @@ fn the_report_serialises_as_snake_case_like_every_other_ipc_struct() {
|
||||
};
|
||||
let json = serde_json::to_value(&report).unwrap();
|
||||
assert_eq!(json["projects"][0]["snapshot_commit_layers"], 14);
|
||||
assert_eq!(json["projects"][0]["base_lineage_known"], false);
|
||||
assert_eq!(json["projects"][0]["container_writable_bytes"], 868_000_000i64);
|
||||
assert!(json["orphan_volumes_unavailable"].is_null());
|
||||
// `Option<i64>` must reach the frontend as null, not be omitted — the TS
|
||||
|
||||
@@ -11,6 +11,12 @@ interface Props {
|
||||
onDestroy: (item: DestructiveItem) => void;
|
||||
}
|
||||
|
||||
const LAYERS_HELP =
|
||||
"Commit layers stacked above the base image — one for every time this project's container was recreated. Nothing merges them, so each one is paid for permanently until the snapshot is compacted.";
|
||||
|
||||
const NEXT_COMMIT_HELP =
|
||||
"The container's writable layer. This is exactly what the next recreation will stack onto the snapshot, and it never comes back after that.";
|
||||
|
||||
/** `—` for a column with nothing in it, so an empty cell never reads as zero. */
|
||||
function cell(bytes: number, present: boolean) {
|
||||
return present ? formatBytes(bytes) : "—";
|
||||
@@ -59,11 +65,18 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props
|
||||
</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." />
|
||||
{/* `Tooltip` renders a portalled div with no `role` and no
|
||||
`aria-describedby`, so its text reaches no assistive tech and
|
||||
the trigger announces as "Help". These two headers are
|
||||
meaningless without their explanation, so it is also emitted
|
||||
as screen-reader-only text. */}
|
||||
<Tooltip text={LAYERS_HELP} />
|
||||
<span className="sr-only"> — {LAYERS_HELP}</span>
|
||||
</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." />
|
||||
<Tooltip text={NEXT_COMMIT_HELP} />
|
||||
<span className="sr-only"> — {NEXT_COMMIT_HELP}</span>
|
||||
</th>
|
||||
<th scope="col" className="font-medium py-1.5 px-3 text-right">
|
||||
Home vol
|
||||
@@ -104,7 +117,12 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props
|
||||
</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)}
|
||||
{/* `null` means the split could not be measured. Rendering it
|
||||
as 0 B would be the one guessed number in this table. */}
|
||||
{cell(
|
||||
row.snapshot_above_base_bytes ?? -1,
|
||||
row.snapshot_exists && row.snapshot_above_base_bytes !== null,
|
||||
)}
|
||||
{row.snapshot_exists && (
|
||||
<span className="block text-[11px] text-[var(--text-secondary)]">
|
||||
{/* The base is shared by every project, so charging it to
|
||||
@@ -117,18 +135,28 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props
|
||||
)}
|
||||
</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>
|
||||
) : (
|
||||
{!row.snapshot_exists ? (
|
||||
"—"
|
||||
) : !row.base_lineage_known ? (
|
||||
// The base this descends from is unknown, so the count
|
||||
// includes the base's own layers and does not mean
|
||||
// "recreations". Saying so beats printing a wrong number.
|
||||
<Tooltip
|
||||
text={`${row.snapshot_commit_layers} layers in total, but this project predates the base-image label, so there is no way to tell which of them are commits. Migrating it to the current base restores the count.`}
|
||||
>
|
||||
<span className="text-[var(--text-secondary)]">unknown</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="text-[var(--text-primary)]">
|
||||
{row.snapshot_commit_layers}
|
||||
{/* Never colour alone: a count worth acting on says so in
|
||||
a word, which is also what a screen reader gets. */}
|
||||
{row.snapshot_commit_layers > 5 && (
|
||||
<span className="ml-1 text-[11px] text-[var(--warning)]">
|
||||
stacked
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
|
||||
|
||||
@@ -35,6 +35,7 @@ const row = (over: Partial<ProjectDiskRow> = {}): ProjectDiskRow => ({
|
||||
snapshot_bytes: 12_273_392_374,
|
||||
snapshot_shared_bytes: 3_832_425_659,
|
||||
snapshot_commit_layers: 14,
|
||||
base_lineage_known: true,
|
||||
snapshot_above_base_bytes: 8_440_966_715,
|
||||
container_exists: true,
|
||||
container_running: false,
|
||||
@@ -170,6 +171,36 @@ describe("DiskSettings", () => {
|
||||
expect(within(projectRow).getByText("14.6 GB")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refuses to present a layer count that does not mean recreations", async () => {
|
||||
// Without `triple-c.base-image-id` — the normal case for a project created
|
||||
// before that label existed — the count includes the base's own ~15 layers.
|
||||
// Printing it beside a header that says "one per recreation" would be a
|
||||
// wrong number in the column the table exists for.
|
||||
getDockerDiskUsage.mockResolvedValue(
|
||||
report({ projects: [row({ base_lineage_known: false, snapshot_commit_layers: 17 })] }),
|
||||
);
|
||||
await renderAndScan();
|
||||
const projectRow = await screen.findByTestId("disk-row-p-whp");
|
||||
expect(within(projectRow).getByText("unknown")).toBeInTheDocument();
|
||||
expect(within(projectRow).queryByText("17")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an unmeasurable snapshot split as a dash, never as zero", async () => {
|
||||
getDockerDiskUsage.mockResolvedValue(
|
||||
report({ projects: [row({ snapshot_above_base_bytes: null })] }),
|
||||
);
|
||||
await renderAndScan();
|
||||
const projectRow = await screen.findByTestId("disk-row-p-whp");
|
||||
expect(within(projectRow).queryByText("0 B")).not.toBeInTheDocument();
|
||||
expect(within(projectRow).getAllByText("—").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("marks a heavily stacked snapshot with a word, not just a colour", async () => {
|
||||
await renderAndScan();
|
||||
const projectRow = await screen.findByTestId("disk-row-p-whp");
|
||||
expect(within(projectRow).getByText("stacked")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("charges the shared base to the globals, not to every project row", async () => {
|
||||
// The base is one 4.7 GB image every project descends from. Counting it per
|
||||
// row would show it eight times and make the column meaningless.
|
||||
@@ -218,6 +249,45 @@ describe("DiskSettings", () => {
|
||||
expect(reclaim).toHaveBeenCalledWith([{ kind: "migration_staging" }]);
|
||||
});
|
||||
|
||||
it("clears the tick list once the reclaim has run", async () => {
|
||||
// The plan's rows describe objects the reclaim just removed; leaving them
|
||||
// ticked lets the user fire the same call again against nothing.
|
||||
await renderAndScan();
|
||||
await screen.findByTestId("disk-safe-bucket");
|
||||
fireEvent.click(screen.getAllByRole("checkbox")[0]);
|
||||
expect(screen.getByText(/1 selected/)).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reclaim" }));
|
||||
});
|
||||
expect(screen.queryByTestId("disk-safe-bucket")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
// And it says why the list is gone rather than claiming nothing was found.
|
||||
expect(screen.getByTestId("disk-plan-stale").textContent).toMatch(
|
||||
/measured before that last action/,
|
||||
);
|
||||
});
|
||||
|
||||
it("says why the build-cache figure is the under-reporting one", async () => {
|
||||
// Without this, a `buildx du` failure silently shows `docker system df`'s
|
||||
// number, which under-reports what a prune would free.
|
||||
getDockerDiskUsage.mockResolvedValue(
|
||||
report({
|
||||
build_cache: {
|
||||
total_bytes: 28_000_000_000,
|
||||
reclaimable_bytes: 1_000_000,
|
||||
stale_bytes: 0,
|
||||
source: "system df",
|
||||
cli_error: "`docker buildx du` failed: executable not found",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await renderAndScan();
|
||||
const globals = await screen.findByTestId("disk-globals");
|
||||
expect(globals.textContent).toMatch(/under-reports what a prune would free/);
|
||||
expect(globals.textContent).toMatch(/executable not found/);
|
||||
});
|
||||
|
||||
it("cannot reclaim with nothing ticked", async () => {
|
||||
await renderAndScan();
|
||||
await screen.findByTestId("disk-safe-bucket");
|
||||
@@ -380,9 +450,7 @@ describe("DiskSettings", () => {
|
||||
);
|
||||
await renderAndScan();
|
||||
const note = await screen.findByTestId("disk-vhdx-note");
|
||||
expect(
|
||||
within(note).getByText("Reclaiming here will not shrink your C: drive"),
|
||||
).toBeInTheDocument();
|
||||
expect(note.textContent).toMatch(/Warning: reclaiming here will not shrink your C: drive/);
|
||||
expect(within(note).getByText(/wsl --shutdown/)).toBeInTheDocument();
|
||||
expect(within(note).getByText(/Optimize-VHD/)).toBeInTheDocument();
|
||||
expect(within(note).getByText(/Purge data/)).toBeInTheDocument();
|
||||
@@ -415,7 +483,8 @@ describe("DiskSettings", () => {
|
||||
}),
|
||||
);
|
||||
destroyProjectDiskObject.mockResolvedValue({
|
||||
target: { kind: "orphan_volume", name: "triple-c-claude-config-p-whp" },
|
||||
target: null,
|
||||
destroyed: { kind: "config_volume", project_id: "p-whp" },
|
||||
ok: true,
|
||||
freed_bytes: 427_000_000,
|
||||
projected_bytes: null,
|
||||
@@ -450,6 +519,57 @@ describe("DiskSettings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the confirmation open and busy while the deletion runs", async () => {
|
||||
// The modal used to be unmounted before the call was awaited, which made
|
||||
// its whole busy path dead code and left a multi-second volume removal with
|
||||
// no indication it was happening.
|
||||
listReclaimable.mockResolvedValue(
|
||||
plan({
|
||||
destructive: [
|
||||
{
|
||||
target: { kind: "home_volume", project_id: "p-whp" },
|
||||
project_id: "p-whp",
|
||||
project_name: "whp",
|
||||
label: "Home volume",
|
||||
loses: "Shell history and toolchains.",
|
||||
bytes: 4_860_000_000,
|
||||
blocked: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
let finish: (value: unknown) => void = () => {};
|
||||
destroyProjectDiskObject.mockReturnValue(new Promise((r) => (finish = r)));
|
||||
|
||||
await renderAndScan();
|
||||
await screen.findByTestId("disk-row-p-whp");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete whp data" }));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Delete home volume/ }));
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "whp" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Delete home volume" }));
|
||||
|
||||
// Still open, and saying so.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Working…" })).toBeDisabled(),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
finish({
|
||||
target: null,
|
||||
destroyed: { kind: "home_volume", project_id: "p-whp" },
|
||||
ok: true,
|
||||
freed_bytes: 4_860_000_000,
|
||||
projected_bytes: null,
|
||||
message: "Removed volume.",
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("never routes a destructive object through the bulk Reclaim button", async () => {
|
||||
listReclaimable.mockResolvedValue(
|
||||
plan({
|
||||
@@ -479,6 +599,7 @@ describe("DiskSettings", () => {
|
||||
results: [
|
||||
{
|
||||
target: { kind: "compact_snapshot", project_id: "p-whp" },
|
||||
destroyed: null,
|
||||
ok: true,
|
||||
freed_bytes: 5_100_000_000,
|
||||
projected_bytes: 7_000_000_000,
|
||||
@@ -499,7 +620,7 @@ describe("DiskSettings", () => {
|
||||
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 () => {
|
||||
it("surfaces a scan failure as an alert", async () => {
|
||||
getDockerDiskUsage.mockRejectedValue("Could not read Docker disk usage: no such host");
|
||||
render(<DiskSettings />);
|
||||
await act(async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Button from "../ui/Button";
|
||||
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
||||
import Modal from "../ui/Modal";
|
||||
@@ -38,12 +38,29 @@ function targetKey(target: ReclaimTarget): string {
|
||||
* 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 {
|
||||
report,
|
||||
plan,
|
||||
scanning,
|
||||
working,
|
||||
error,
|
||||
outcome,
|
||||
scan,
|
||||
runReclaim,
|
||||
destroy,
|
||||
runSweep,
|
||||
clearOutcome,
|
||||
} = useDiskUsage();
|
||||
const [ticked, setTicked] = useState<Set<string>>(new Set());
|
||||
const [confirming, setConfirming] = useState<ReclaimItem | null>(null);
|
||||
const [destroying, setDestroying] = useState<DestructiveItem | null>(null);
|
||||
|
||||
// The plan is dropped after any reclaim, so a tick can never outlive the row
|
||||
// it was made against and be re-fired at an object that is already gone.
|
||||
useEffect(() => {
|
||||
if (!plan) setTicked(new Set());
|
||||
}, [plan]);
|
||||
|
||||
const safeItems = plan?.items.filter((i) => i.safety === "safe") ?? [];
|
||||
const semiItems = plan?.items.filter((i) => i.safety === "semi_safe") ?? [];
|
||||
const selected = safeItems.filter(
|
||||
@@ -112,11 +129,14 @@ export default function DiskSettings() {
|
||||
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"
|
||||
/>
|
||||
{/* `StatusIndicator` has no warning tone — `error` would put a
|
||||
red glyph in a warning-toned panel. This is advisory, so it
|
||||
carries its own glyph beside the words rather than relying on
|
||||
the panel's colour. */}
|
||||
<p className="text-xs font-medium text-[var(--text-primary)]">
|
||||
<span aria-hidden="true">▲</span> Warning: reclaiming here will not
|
||||
shrink your C: drive
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-primary)] leading-relaxed">
|
||||
{report.host.vhdx_note}
|
||||
</p>
|
||||
@@ -203,11 +223,20 @@ export default function DiskSettings() {
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
{report.build_cache.cli_error && (
|
||||
<p className="text-[11px] text-[var(--warning)]">
|
||||
{/* Without this the panel silently shows `docker system df`'s
|
||||
under-reported build-cache figure and the user has no way
|
||||
to know why it disagrees with their terminal. */}
|
||||
Build-cache figures fell back to <code>docker system df</code>, which
|
||||
under-reports what a prune would free: {report.build_cache.cli_error}
|
||||
</p>
|
||||
)}
|
||||
{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
|
||||
“Volumes with no matching project” above 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.
|
||||
@@ -223,7 +252,7 @@ export default function DiskSettings() {
|
||||
{/* --- 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"
|
||||
className="border border-[var(--error)]/40 bg-[var(--error-muted)] rounded-[var(--radius-panel)] px-3.5 py-3"
|
||||
data-testid="disk-store-error"
|
||||
>
|
||||
<StatusIndicator
|
||||
@@ -237,7 +266,16 @@ export default function DiskSettings() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* --- The plan was dropped by a reclaim -------------------------- */}
|
||||
{!plan && (
|
||||
<p className="text-xs text-[var(--text-secondary)]" data-testid="disk-plan-stale">
|
||||
The totals above were measured before that last action. Scan again to see
|
||||
what is left to reclaim.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* --- Safe reclaim ---------------------------------------------- */}
|
||||
{plan && (
|
||||
<section className="space-y-2" data-testid="disk-safe-bucket">
|
||||
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Safe to reclaim
|
||||
@@ -260,7 +298,10 @@ export default function DiskSettings() {
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ticked.has(key)}
|
||||
// A tick that survived onto a now-blocked row is
|
||||
// excluded from `selected`, so showing it checked
|
||||
// would make the count disagree with the screen.
|
||||
checked={item.blocked === null && ticked.has(key)}
|
||||
disabled={item.blocked !== null}
|
||||
onChange={() => toggle(item)}
|
||||
className="mt-0.5 accent-[var(--accent-emphasis)]"
|
||||
@@ -317,6 +358,7 @@ export default function DiskSettings() {
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* --- Semi-safe -------------------------------------------------- */}
|
||||
{semiItems.length > 0 && (
|
||||
@@ -371,16 +413,13 @@ export default function DiskSettings() {
|
||||
|
||||
{/* --- Sweep ------------------------------------------------------ */}
|
||||
<section className="flex items-center gap-3 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={working}
|
||||
onClick={() => runReclaim([{ kind: "dangling_snapshots" }])}
|
||||
>
|
||||
<Button size="sm" disabled={working} onClick={runSweep}>
|
||||
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.
|
||||
The same sweep that runs at startup and after every recreation. Unlike the
|
||||
tick above it also reports what it <em>refused</em> to remove, which is how
|
||||
a superseded image pinned by a stopped project shows itself.
|
||||
</span>
|
||||
</section>
|
||||
</>
|
||||
@@ -394,11 +433,16 @@ export default function DiskSettings() {
|
||||
aria-live="polite"
|
||||
data-testid="disk-outcome"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<StatusIndicator
|
||||
tone={outcome.results.every((r) => r.ok) ? "ok" : "error"}
|
||||
label={`Reclaimed ${formatBytes(outcome.total_freed_bytes)}`}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Button size="sm" variant="ghost" onClick={clearOutcome}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="space-y-1 text-xs text-[var(--text-secondary)]">
|
||||
{outcome.results.map((result, index) => (
|
||||
<li key={index}>
|
||||
@@ -433,10 +477,11 @@ export default function DiskSettings() {
|
||||
size="md"
|
||||
variant="primary"
|
||||
disabled={working}
|
||||
onClick={() => {
|
||||
const target = confirming.target;
|
||||
onClick={async () => {
|
||||
// Same reasoning as the destructive modal: a compaction takes
|
||||
// minutes, and the dialog reporting it beats it vanishing.
|
||||
await runReclaim([confirming.target]);
|
||||
setConfirming(null);
|
||||
void runReclaim([target]);
|
||||
}}
|
||||
>
|
||||
{working ? "Working…" : "Run it"}
|
||||
@@ -487,10 +532,12 @@ export default function DiskSettings() {
|
||||
confirmLabel={`Delete ${destroying.label.toLowerCase()}`}
|
||||
busy={working}
|
||||
onCancel={() => setDestroying(null)}
|
||||
onConfirm={(typed) => {
|
||||
const target = destroying.target;
|
||||
onConfirm={async (typed) => {
|
||||
// The modal stays mounted until the call settles, so its `busy`
|
||||
// state is what the user sees while a multi-second volume removal
|
||||
// runs. Clearing it first made the whole busy path dead code.
|
||||
await destroy(destroying.target, typed);
|
||||
setDestroying(null);
|
||||
void destroy(target, typed);
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { useId, useRef, useState, type ReactNode } from "react";
|
||||
import Modal from "./Modal";
|
||||
import Button from "./Button";
|
||||
import { inputClass } from "./Field";
|
||||
@@ -47,6 +47,9 @@ export default function TypedConfirmModal({
|
||||
}: Props) {
|
||||
const [typed, setTyped] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// Every other `ui/` component uses `useId`; a hardcoded id breaks the
|
||||
// label association as soon as two of these are mounted at once.
|
||||
const inputId = useId();
|
||||
const matches = expected.trim().length > 0 && typed.trim() === expected.trim();
|
||||
|
||||
return (
|
||||
@@ -80,13 +83,13 @@ export default function TypedConfirmModal({
|
||||
{children}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="typed-confirm-input"
|
||||
htmlFor={inputId}
|
||||
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"
|
||||
id={inputId}
|
||||
ref={inputRef}
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
|
||||
@@ -14,9 +14,11 @@ vi.mock("../lib/tauri-commands", () => ({
|
||||
reclaim: (targets: unknown) => reclaim(targets),
|
||||
destroyProjectDiskObject: (target: unknown, confirmation: string) =>
|
||||
destroyProjectDiskObject(target, confirmation),
|
||||
sweepOrphanedSnapshots: vi.fn(),
|
||||
sweepOrphanedSnapshots: () => sweepOrphanedSnapshots(),
|
||||
}));
|
||||
|
||||
const sweepOrphanedSnapshots = vi.fn();
|
||||
|
||||
const report = (scanned_at: string): DiskUsageReport =>
|
||||
({ scanned_at, projects: [] }) as unknown as DiskUsageReport;
|
||||
|
||||
@@ -149,13 +151,94 @@ describe("useDiskUsage", () => {
|
||||
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");
|
||||
it("reports a scan failure and keeps the last good measurement", async () => {
|
||||
// The old report is still an accurate measurement of an earlier moment,
|
||||
// and the error says the refresh failed. Blanking it would leave the panel
|
||||
// with nothing while telling the user nothing more.
|
||||
getDockerDiskUsage.mockResolvedValueOnce(report("first"));
|
||||
const { result } = renderHook(() => useDiskUsage());
|
||||
await act(async () => {
|
||||
await result.current.scan();
|
||||
});
|
||||
|
||||
getDockerDiskUsage.mockRejectedValueOnce("daemon unreachable");
|
||||
await act(async () => {
|
||||
await result.current.scan();
|
||||
});
|
||||
await waitFor(() => expect(result.current.error).toMatch(/daemon unreachable/));
|
||||
expect(result.current.report?.scanned_at).toBe("first");
|
||||
expect(result.current.scanning).toBe(false);
|
||||
});
|
||||
|
||||
it("never shows fresh totals beside a stale tick list", async () => {
|
||||
// `setReport` used to land before the plan call was awaited, so a plan
|
||||
// failure rendered this scan's numbers above the previous scan's rows.
|
||||
getDockerDiskUsage.mockResolvedValueOnce(report("first"));
|
||||
const { result } = renderHook(() => useDiskUsage());
|
||||
await act(async () => {
|
||||
await result.current.scan();
|
||||
});
|
||||
|
||||
getDockerDiskUsage.mockResolvedValueOnce(report("second"));
|
||||
listReclaimable.mockRejectedValueOnce("planner exploded");
|
||||
await act(async () => {
|
||||
await result.current.scan();
|
||||
});
|
||||
expect(result.current.error).toMatch(/planner exploded/);
|
||||
expect(result.current.report?.scanned_at).toBe("first");
|
||||
});
|
||||
|
||||
it("drops the plan after a reclaim so ticks cannot be re-fired at nothing", async () => {
|
||||
getDockerDiskUsage.mockResolvedValue(report("first"));
|
||||
const { result } = renderHook(() => useDiskUsage());
|
||||
await act(async () => {
|
||||
await result.current.scan();
|
||||
});
|
||||
expect(result.current.plan).toEqual(plan);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
|
||||
});
|
||||
expect(result.current.plan).toBeNull();
|
||||
// The totals stay — they were measured before the reclaim and the outcome
|
||||
// says what changed.
|
||||
expect(result.current.report?.scanned_at).toBe("first");
|
||||
});
|
||||
|
||||
it("runs the sweep through its own command and reports what it refused", async () => {
|
||||
// The sweep's `in_use` count — orphans Docker refused to delete because a
|
||||
// stopped project still needs them — is invisible everywhere else in the
|
||||
// app, because every other caller throws the report away.
|
||||
sweepOrphanedSnapshots.mockResolvedValue({
|
||||
removed: ["sha256:a", "sha256:b"],
|
||||
reclaimed_bytes: 11_900_000_000,
|
||||
in_use: 3,
|
||||
failed: [],
|
||||
unavailable: null,
|
||||
});
|
||||
const { result } = renderHook(() => useDiskUsage());
|
||||
await act(async () => {
|
||||
await result.current.runSweep();
|
||||
});
|
||||
expect(sweepOrphanedSnapshots).toHaveBeenCalled();
|
||||
expect(result.current.outcome?.total_freed_bytes).toBe(11_900_000_000);
|
||||
expect(result.current.outcome?.results[0].message).toMatch(/Swept 2 superseded image/);
|
||||
expect(result.current.outcome?.results[0].message).toMatch(/3 were left alone/);
|
||||
});
|
||||
|
||||
it("treats an unreachable daemon in the sweep report as an error", async () => {
|
||||
sweepOrphanedSnapshots.mockResolvedValue({
|
||||
removed: [],
|
||||
reclaimed_bytes: 0,
|
||||
in_use: 0,
|
||||
failed: [],
|
||||
unavailable: "Could not reach the Docker engine",
|
||||
});
|
||||
const { result } = renderHook(() => useDiskUsage());
|
||||
await act(async () => {
|
||||
await result.current.runSweep();
|
||||
});
|
||||
expect(result.current.error).toMatch(/Could not reach the Docker engine/);
|
||||
expect(result.current.outcome).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,16 +17,24 @@ import type {
|
||||
* 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 scan is therefore only ever what the Scan button calls.
|
||||
*
|
||||
* Note what that does *not* buy: this hook lives inside `DiskSettings`, which
|
||||
* the accordion unmounts on collapse, so its state goes with it and reopening
|
||||
* the section shows an unscanned panel again. That is the honest behaviour —
|
||||
* a stale total is worse than an absent one — but it means collapsing and
|
||||
* reopening discards a scan the user paid for. Lifting the report into
|
||||
* `appState` would fix that and is deliberately not done here: it would put a
|
||||
* multi-megabyte, rapidly-stale blob into the app-wide store for one panel.
|
||||
*
|
||||
* ## The generation guard
|
||||
*
|
||||
* A user who hits Scan twice can have two `df()` calls in flight, and they can
|
||||
* land out of order — the second one is not necessarily slower. Every async
|
||||
* write checks it is still the newest before it lands, the same pattern
|
||||
* `useContainerMigration` uses.
|
||||
* write in `scan` checks it is still the newest before it lands, the same
|
||||
* pattern `useContainerMigration` uses. `runReclaim` and `destroy` do not need
|
||||
* it: the UI disables their buttons while `working` is set, so there is never
|
||||
* a second one to race.
|
||||
*/
|
||||
export interface DiskUsageState {
|
||||
report: DiskUsageReport | null;
|
||||
@@ -41,6 +49,8 @@ export interface DiskUsageState {
|
||||
scan: () => Promise<void>;
|
||||
runReclaim: (targets: ReclaimTarget[]) => Promise<void>;
|
||||
destroy: (target: DestructiveTarget, confirmation: string) => Promise<void>;
|
||||
/** Run the orphaned-snapshot sweep and report what it found *and refused*. */
|
||||
runSweep: () => Promise<void>;
|
||||
clearOutcome: () => void;
|
||||
}
|
||||
|
||||
@@ -63,16 +73,24 @@ export function useDiskUsage(): DiskUsageState {
|
||||
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;
|
||||
// Both land together, or neither does. Setting the report before
|
||||
// awaiting the plan would render this scan's totals above the *previous*
|
||||
// scan's still-clickable tick list if the plan call failed.
|
||||
setReport(next);
|
||||
setPlan(nextPlan);
|
||||
} catch (e) {
|
||||
if (generation.current !== mine) return;
|
||||
setError(String(e));
|
||||
// The old report is left on screen deliberately — it is still an
|
||||
// accurate measurement of an earlier moment, and the error says the
|
||||
// refresh failed. What must not survive is a plan describing a scan the
|
||||
// user can no longer see the totals for, but that cannot happen: the two
|
||||
// only ever move together.
|
||||
} finally {
|
||||
if (generation.current === mine) setScanning(false);
|
||||
}
|
||||
@@ -85,9 +103,16 @@ export function useDiskUsage(): DiskUsageState {
|
||||
try {
|
||||
const result = await commands.reclaim(targets);
|
||||
setOutcome(result);
|
||||
// Deliberately no automatic re-scan. It costs another `df()`, and the
|
||||
// **The plan is now stale and must not stay clickable.** Its rows
|
||||
// describe objects this call just removed, so leaving them ticked lets
|
||||
// the user fire the same reclaim again against nothing. Dropping the plan
|
||||
// (not the report) leaves the totals on screen, marked as measured before
|
||||
// the reclaim, with the tick list gone.
|
||||
//
|
||||
// Deliberately no automatic re-scan: it costs another `df()`, and the
|
||||
// outcome already reports measured bytes for every target — a user who
|
||||
// wants the new totals asks for them.
|
||||
setPlan(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
@@ -101,6 +126,53 @@ export function useDiskUsage(): DiskUsageState {
|
||||
try {
|
||||
const result = await commands.destroyProjectDiskObject(target, confirmation);
|
||||
setOutcome({ results: [result], total_freed_bytes: result.freed_bytes });
|
||||
// Same reasoning as `runReclaim`: the destructive list named an object
|
||||
// that is now gone.
|
||||
setPlan(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* The startup sweep, on demand.
|
||||
*
|
||||
* Not the same as ticking "superseded snapshot layers", even though both end
|
||||
* up removing the same images: this reports `in_use` — the orphans Docker
|
||||
* *refused* to delete because a stopped project's container still needs
|
||||
* them. That refusal is the sweep's third safety net and it is invisible
|
||||
* everywhere else in the app, because every existing caller throws the
|
||||
* report away.
|
||||
*/
|
||||
const runSweep = useCallback(async () => {
|
||||
setWorking(true);
|
||||
setError(null);
|
||||
try {
|
||||
const sweep = await commands.sweepOrphanedSnapshots();
|
||||
if (sweep.unavailable) {
|
||||
setError(sweep.unavailable);
|
||||
return;
|
||||
}
|
||||
const refused =
|
||||
sweep.in_use > 0
|
||||
? ` ${sweep.in_use} were left alone because a container is still built from them — start and stop, or recreate, that project and a later sweep gets them.`
|
||||
: "";
|
||||
setOutcome({
|
||||
results: [
|
||||
{
|
||||
target: { kind: "dangling_snapshots" },
|
||||
destroyed: null,
|
||||
ok: sweep.failed.length === 0,
|
||||
freed_bytes: sweep.reclaimed_bytes,
|
||||
projected_bytes: null,
|
||||
message: `Swept ${sweep.removed.length} superseded image(s).${refused}`,
|
||||
},
|
||||
],
|
||||
total_freed_bytes: sweep.reclaimed_bytes,
|
||||
});
|
||||
setPlan(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
@@ -110,5 +182,17 @@ export function useDiskUsage(): DiskUsageState {
|
||||
|
||||
const clearOutcome = useCallback(() => setOutcome(null), []);
|
||||
|
||||
return { report, plan, scanning, working, error, outcome, scan, runReclaim, destroy, clearOutcome };
|
||||
return {
|
||||
report,
|
||||
plan,
|
||||
scanning,
|
||||
working,
|
||||
error,
|
||||
outcome,
|
||||
scan,
|
||||
runReclaim,
|
||||
destroy,
|
||||
runSweep,
|
||||
clearOutcome,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,6 +45,24 @@ describe("formatBytes", () => {
|
||||
expect(formatBytes(2_500_000_000_000)).toBe("2.5 TB");
|
||||
});
|
||||
|
||||
it("promotes the unit when rounding lands on a whole step", () => {
|
||||
// `toFixed` runs after the divide loop, so a value just under a boundary
|
||||
// rounds up into a unit the loop had already ruled out. This is the app's
|
||||
// only byte formatter and the panel is full of near-boundary sizes.
|
||||
expect(formatBytes(999_999)).toBe("1.0 MB");
|
||||
expect(formatBytes(999_999_999)).toBe("1.0 GB");
|
||||
expect(formatBytes(999_999_999_999)).toBe("1.0 TB");
|
||||
expect(formatBytes(1_048_575, { binary: true })).toBe("1.0 MB");
|
||||
|
||||
// Just below the rounding threshold it must NOT promote.
|
||||
expect(formatBytes(999_949)).toBe("999.9 KB");
|
||||
expect(formatBytes(999_400, { precision: 0 })).toBe("999 KB");
|
||||
|
||||
// The top unit has nowhere to go: it renders a whole step rather than
|
||||
// running off the end of the unit array.
|
||||
expect(formatBytes(999_999_999_999_999_999)).toBe("1000.0 PB");
|
||||
});
|
||||
|
||||
it("renders an em dash for a size the daemon did not compute", () => {
|
||||
// Docker reports -1 for "not calculated" on shared sizes and volume ref
|
||||
// counts. `NaN GB` in the middle of a table is worse than nothing.
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
/**
|
||||
* The one byte formatter.
|
||||
*
|
||||
* Before this existed the app had four of them — `projects/home/format.ts`,
|
||||
* 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.
|
||||
* unit labels and the precision. The first two now delegate here.
|
||||
*
|
||||
* The other two deliberately do not, yet: `UpdateDialog` renders KB at
|
||||
* `toFixed(0)`, so re-pointing it would change what a download size reads as,
|
||||
* and neither is on the Disk panel's path. They are the remaining copies.
|
||||
*
|
||||
* ## Why the default is base 1000
|
||||
*
|
||||
@@ -14,8 +18,12 @@
|
||||
* 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:
|
||||
* Both existing conventions are preserved for every size either call site can
|
||||
* realistically produce — a file size or a payload size, i.e. a non-negative
|
||||
* finite number below a terabyte. Outside that range this deliberately differs
|
||||
* from what it replaced: a negative or `NaN` input now renders `—` rather than
|
||||
* `-1 B` or `NaN GB`, and the unit ladder continues past GB instead of
|
||||
* stopping there.
|
||||
*
|
||||
* - `{ }` → `41.0 MB` (decimal, what migration used)
|
||||
* - `{ binary: true }` → `1.5 GB` (÷1024 with decimal-style
|
||||
@@ -56,6 +64,17 @@ export function formatBytes(bytes: number, options: FormatBytesOptions = {}): st
|
||||
value /= step;
|
||||
unit += 1;
|
||||
}
|
||||
|
||||
// **Promote again if rounding pushed the value back up to a whole step.**
|
||||
// `toFixed` runs after the loop, so 999,999 B divides to 999.999 KB and then
|
||||
// renders as "1000.0 KB" — a unit the loop had already decided against. The
|
||||
// same happens at every boundary (999,999,999 → "1000.0 MB", and 1,048,575
|
||||
// → "1024.0 KB" in binary).
|
||||
if (unit < units.length - 1 && Number(value.toFixed(precision)) >= step) {
|
||||
value /= step;
|
||||
unit += 1;
|
||||
}
|
||||
|
||||
// Whole bytes never get a decimal point: `512 B`, not `512.0 B`.
|
||||
return unit === 0
|
||||
? `${Math.round(bytes)} ${units[0]}`
|
||||
@@ -73,7 +92,7 @@ export function formatBytesDelta(bytes: number, options?: FormatBytesOptions): s
|
||||
}
|
||||
|
||||
/**
|
||||
* `up to 12.3 GB` / `nothing` — for a bound rather than a measurement.
|
||||
* `up to 12.3 GB` — for a bound rather than a measurement.
|
||||
*
|
||||
* The Disk panel is careful about this distinction: every figure it shows is
|
||||
* measured except a compaction's yield, which cannot be known until it runs.
|
||||
|
||||
+12
-2
@@ -836,8 +836,14 @@ export interface ProjectDiskRow {
|
||||
/** 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. */
|
||||
* This is the number that explains why a snapshot grows — but only when
|
||||
* `base_lineage_known` is true. Otherwise it counts the base's layers too. */
|
||||
snapshot_commit_layers: number;
|
||||
/** Whether the base image this snapshot descends from could be identified.
|
||||
* False is the normal case for a project created before the
|
||||
* `triple-c.base-image-id` label existed; the layer count must not be
|
||||
* presented as a recreation count then. */
|
||||
base_lineage_known: boolean;
|
||||
/** Bytes those layers account for. `null` when the base image is gone and
|
||||
* the split cannot be measured — never a guess. */
|
||||
snapshot_above_base_bytes: number | null;
|
||||
@@ -989,7 +995,11 @@ export interface ReclaimPlan {
|
||||
}
|
||||
|
||||
export interface ReclaimResult {
|
||||
target: ReclaimTarget;
|
||||
/** The reclaim target this reports on, or `null` when it reports a destroy.
|
||||
* Exactly one of `target` / `destroyed` is ever set — a destroy used to come
|
||||
* back wearing a `ReclaimTarget` that named work it had not done. */
|
||||
target: ReclaimTarget | null;
|
||||
destroyed: DestructiveTarget | null;
|
||||
ok: boolean;
|
||||
freed_bytes: number;
|
||||
/** What was projected beforehand, for the one action that projects. */
|
||||
|
||||
Reference in New Issue
Block a user