Terminal newlines, OAuth callback, Claude Code settings, and the Files tab #30
@@ -97,7 +97,19 @@ pub struct ProjectDiskRow {
|
|||||||
pub snapshot_shared_bytes: i64,
|
pub snapshot_shared_bytes: i64,
|
||||||
/// How many layers the snapshot has stacked **above its base image**. This
|
/// How many layers the snapshot has stacked **above its base image**. This
|
||||||
/// is the number that explains the growth: one per recreation.
|
/// 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,
|
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
|
/// 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
|
/// snapshot descends from is no longer on the daemon, so the split cannot
|
||||||
/// be measured and must not be guessed.
|
/// be measured and must not be guessed.
|
||||||
@@ -422,7 +434,18 @@ pub struct ReclaimOutcome {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct ReclaimResult {
|
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,
|
pub ok: bool,
|
||||||
/// Bytes actually freed, measured after the fact.
|
/// Bytes actually freed, measured after the fact.
|
||||||
pub freed_bytes: i64,
|
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
|
/// [`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
|
/// as Dockerfile instructions. A multi-line `CLAUDE_INSTRUCTIONS` env var alone
|
||||||
/// makes that escaping a bad bet.
|
/// 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 {
|
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
|
// 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 —
|
// 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\
|
"FROM {snapshot_ref} AS src\n\
|
||||||
RUN {folded}\n\
|
RUN {folded}\n\
|
||||||
FROM scratch\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
|
/// `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.
|
/// the 4.7 GB image every project sits on would be missing the obvious.
|
||||||
fn is_base_image_reference(reference: &str) -> bool {
|
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 == "triple-c"
|
||||||
|| repo.ends_with("/triple-c-sandbox")
|
|| repo.ends_with("/triple-c-sandbox")
|
||||||
|| repo == "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 stats = LayerStats::default();
|
||||||
|
let mut base_lineage_known = false;
|
||||||
if let Some(image) = snapshot {
|
if let Some(image) = snapshot {
|
||||||
let history = docker
|
let history = docker
|
||||||
.image_history(&image.id)
|
.image_history(&image.id)
|
||||||
@@ -1112,6 +1151,7 @@ pub async fn scan(projects: &[Project]) -> Result<DiskUsageReport, String> {
|
|||||||
},
|
},
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
base_lineage_known = base_len.is_some();
|
||||||
stats = layer_stats(&history, base_len);
|
stats = layer_stats(&history, base_len);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1140,6 +1180,7 @@ pub async fn scan(projects: &[Project]) -> Result<DiskUsageReport, String> {
|
|||||||
snapshot_bytes,
|
snapshot_bytes,
|
||||||
snapshot_shared_bytes,
|
snapshot_shared_bytes,
|
||||||
snapshot_commit_layers: stats.commit_layers,
|
snapshot_commit_layers: stats.commit_layers,
|
||||||
|
base_lineage_known,
|
||||||
// Prefer the daemon's own measurement of what is unique to this
|
// Prefer the daemon's own measurement of what is unique to this
|
||||||
// image over layer arithmetic; fall back to the layer sum when
|
// image over layer arithmetic; fall back to the layer sum when
|
||||||
// `df()` did not compute a shared size.
|
// `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));
|
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 base_images_bytes = base_images.iter().map(|b| b.bytes).sum();
|
||||||
|
|
||||||
let (json_exists, json_parsed) = projects_json_health();
|
let (json_exists, json_parsed) = projects_json_health();
|
||||||
@@ -1663,7 +1710,11 @@ pub async fn list_reclaimable(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or(0);
|
.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({
|
items.push({
|
||||||
// Safety and reach are read off the target, never restated: a literal
|
// Safety and reach are read off the target, never restated: a literal
|
||||||
// here that disagreed with the classifier is exactly the drift this
|
// 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(),
|
project_name: row.project_name.clone(),
|
||||||
label: "Home volume".to_string(),
|
label: "Home volume".to_string(),
|
||||||
loses: "Shell history, dotfiles, every toolchain installed under $HOME, and any \
|
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(),
|
.to_string(),
|
||||||
bytes: row.home_volume_bytes,
|
bytes: row.home_volume_bytes,
|
||||||
blocked: blocked.clone(),
|
blocked: blocked.clone(),
|
||||||
@@ -1778,7 +1831,9 @@ pub async fn list_reclaimable(
|
|||||||
project_name: row.project_name.clone(),
|
project_name: row.project_name.clone(),
|
||||||
label: "Claude config volume".to_string(),
|
label: "Claude config volume".to_string(),
|
||||||
loses: "The Claude login credential, installed plugins and skills, and EVERY \
|
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(),
|
.to_string(),
|
||||||
bytes: row.config_volume_bytes,
|
bytes: row.config_volume_bytes,
|
||||||
blocked: blocked.clone(),
|
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 {
|
fn failed(target: ReclaimTarget, message: String) -> ReclaimResult {
|
||||||
ReclaimResult {
|
ReclaimResult {
|
||||||
target,
|
target: Some(target),
|
||||||
|
destroyed: None,
|
||||||
ok: false,
|
ok: false,
|
||||||
freed_bytes: 0,
|
freed_bytes: 0,
|
||||||
projected_bytes: None,
|
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));
|
message.push_str(&format!(" {} could not be removed; see the log.", errors));
|
||||||
}
|
}
|
||||||
ReclaimResult {
|
ReclaimResult {
|
||||||
target: target.clone(),
|
target: Some(target.clone()),
|
||||||
|
destroyed: None,
|
||||||
ok: errors == 0,
|
ok: errors == 0,
|
||||||
freed_bytes: freed,
|
freed_bytes: freed,
|
||||||
projected_bytes: None,
|
projected_bytes: None,
|
||||||
@@ -2222,7 +2279,8 @@ async fn reclaim_build_cache(all: bool) -> ReclaimResult {
|
|||||||
}
|
}
|
||||||
match docker_cli(&args).await {
|
match docker_cli(&args).await {
|
||||||
Ok(output) => ReclaimResult {
|
Ok(output) => ReclaimResult {
|
||||||
target,
|
target: Some(target),
|
||||||
|
destroyed: None,
|
||||||
ok: true,
|
ok: true,
|
||||||
freed_bytes: parse_reclaimed_space(&output),
|
freed_bytes: parse_reclaimed_space(&output),
|
||||||
projected_bytes: None,
|
projected_bytes: None,
|
||||||
@@ -2302,7 +2360,8 @@ async fn reclaim_migration_pins() -> ReclaimResult {
|
|||||||
sweep.reclaimed_bytes
|
sweep.reclaimed_bytes
|
||||||
);
|
);
|
||||||
ReclaimResult {
|
ReclaimResult {
|
||||||
target,
|
target: Some(target),
|
||||||
|
destroyed: None,
|
||||||
ok: true,
|
ok: true,
|
||||||
freed_bytes: sweep.reclaimed_bytes,
|
freed_bytes: sweep.reclaimed_bytes,
|
||||||
projected_bytes: None,
|
projected_bytes: None,
|
||||||
@@ -2352,7 +2411,8 @@ fn reclaim_migration_staging(projects: &[Project]) -> ReclaimResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ReclaimResult {
|
ReclaimResult {
|
||||||
target,
|
target: Some(target),
|
||||||
|
destroyed: None,
|
||||||
ok: true,
|
ok: true,
|
||||||
freed_bytes: freed,
|
freed_bytes: freed,
|
||||||
projected_bytes: None,
|
projected_bytes: None,
|
||||||
@@ -2407,7 +2467,8 @@ async fn reclaim_containers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ReclaimResult {
|
ReclaimResult {
|
||||||
target,
|
target: Some(target),
|
||||||
|
destroyed: None,
|
||||||
ok: errors == 0,
|
ok: errors == 0,
|
||||||
freed_bytes: freed,
|
freed_bytes: freed,
|
||||||
projected_bytes: None,
|
projected_bytes: None,
|
||||||
@@ -2459,7 +2520,8 @@ async fn reclaim_orphan_volume(name: &str, projects: &[Project]) -> ReclaimResul
|
|||||||
|
|
||||||
match docker.remove_volume(name, None).await {
|
match docker.remove_volume(name, None).await {
|
||||||
Ok(()) => ReclaimResult {
|
Ok(()) => ReclaimResult {
|
||||||
target,
|
target: Some(target),
|
||||||
|
destroyed: None,
|
||||||
ok: true,
|
ok: true,
|
||||||
freed_bytes: volume.bytes,
|
freed_bytes: volume.bytes,
|
||||||
projected_bytes: None,
|
projected_bytes: None,
|
||||||
@@ -2606,7 +2668,8 @@ pub async fn compact_snapshot(project: &Project) -> ReclaimResult {
|
|||||||
let _ = migration::untag_image(&staging_ref).await;
|
let _ = migration::untag_image(&staging_ref).await;
|
||||||
let _ = container::sweep_orphaned_snapshots().await;
|
let _ = container::sweep_orphaned_snapshots().await;
|
||||||
return ReclaimResult {
|
return ReclaimResult {
|
||||||
target,
|
target: Some(target),
|
||||||
|
destroyed: None,
|
||||||
ok: true,
|
ok: true,
|
||||||
freed_bytes: 0,
|
freed_bytes: 0,
|
||||||
projected_bytes: projected,
|
projected_bytes: projected,
|
||||||
@@ -2647,7 +2710,8 @@ pub async fn compact_snapshot(project: &Project) -> ReclaimResult {
|
|||||||
sweep.removed.len()
|
sweep.removed.len()
|
||||||
);
|
);
|
||||||
ReclaimResult {
|
ReclaimResult {
|
||||||
target,
|
target: Some(target),
|
||||||
|
destroyed: None,
|
||||||
ok: true,
|
ok: true,
|
||||||
freed_bytes: freed,
|
freed_bytes: freed,
|
||||||
projected_bytes: projected,
|
projected_bytes: projected,
|
||||||
@@ -2726,6 +2790,59 @@ async fn build_from_dockerfile(dockerfile: &str, tag: &str) -> Result<(), String
|
|||||||
Ok(())
|
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
|
/// Put a captured image config back onto a flattened image, under the original
|
||||||
/// tag.
|
/// tag.
|
||||||
///
|
///
|
||||||
@@ -2748,12 +2865,20 @@ async fn restore_image_config(
|
|||||||
use bollard::image::CommitContainerOptions;
|
use bollard::image::CommitContainerOptions;
|
||||||
|
|
||||||
let docker = get_docker()?;
|
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
|
// **Its own prefix, not `triple-c-scrub-*`.** An earlier version reused the
|
||||||
// leftover is already covered by the "secret-scrub scratch containers"
|
// secret-rewrite name on the grounds that the existing reclaim bucket would
|
||||||
// bucket in this very panel rather than needing a second reaper. The two
|
// then collect any leftover. It would — including the live one: that bucket
|
||||||
// never run at once: `reclaim` executes its targets in sequence.
|
// removes with `force: true`, so a "remove scrub containers" reclaim fired
|
||||||
let scratch_name = format!("triple-c-scrub-{}", uuid::Uuid::new_v4().simple());
|
// 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
|
// `image` is the flat build; everything else is copied from the original so
|
||||||
// the committed image is byte-for-byte the same configuration.
|
// 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 {
|
match super::exec::exec_oneshot_as(&container_id, "claude", cmd, Vec::new()).await {
|
||||||
Ok((output, _exit)) => match parse_cache_total(&output) {
|
Ok((output, _exit)) => match parse_cache_total(&output) {
|
||||||
Some(bytes) => ReclaimResult {
|
Some(bytes) => ReclaimResult {
|
||||||
target,
|
target: Some(target),
|
||||||
|
destroyed: None,
|
||||||
ok: true,
|
ok: true,
|
||||||
freed_bytes: bytes as i64,
|
freed_bytes: bytes as i64,
|
||||||
projected_bytes: None,
|
projected_bytes: None,
|
||||||
@@ -2934,8 +3060,9 @@ pub async fn destroy(
|
|||||||
// A running container holds all three of these open, and Docker's refusal
|
// 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
|
// is not something to lean on for the volumes: it would happily leave a
|
||||||
// half-removed project behind.
|
// half-removed project behind.
|
||||||
if let Ok(Some(container_id)) = container::find_existing_container(project).await {
|
let existing_container = container::find_existing_container(project).await.ok().flatten();
|
||||||
if container::is_container_running(&container_id)
|
if let Some(container_id) = existing_container.as_deref() {
|
||||||
|
if container::is_container_running(container_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
@@ -2953,13 +3080,37 @@ pub async fn destroy(
|
|||||||
};
|
};
|
||||||
// Size it before it goes, so the report is a measurement.
|
// Size it before it goes, so the report is a measurement.
|
||||||
let bytes = volume_size(&name).await;
|
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
|
docker
|
||||||
.remove_volume(&name, None)
|
.remove_volume(&name, None)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Could not remove volume {}: {}", name, e))?;
|
.map_err(|e| format!("Could not remove volume {}: {}", name, e))?;
|
||||||
log::info!("Removed volume {} on explicit confirmation", name);
|
log::info!("Removed volume {} on explicit confirmation", name);
|
||||||
Ok(ReclaimResult {
|
Ok(ReclaimResult {
|
||||||
target: ReclaimTarget::OrphanVolume { name: name.clone() },
|
target: None,
|
||||||
|
destroyed: Some(target.clone()),
|
||||||
ok: true,
|
ok: true,
|
||||||
freed_bytes: bytes,
|
freed_bytes: bytes,
|
||||||
projected_bytes: None,
|
projected_bytes: None,
|
||||||
@@ -2974,7 +3125,8 @@ pub async fn destroy(
|
|||||||
container::remove_snapshot_image(project).await?;
|
container::remove_snapshot_image(project).await?;
|
||||||
let sweep = container::sweep_orphaned_snapshots().await;
|
let sweep = container::sweep_orphaned_snapshots().await;
|
||||||
Ok(ReclaimResult {
|
Ok(ReclaimResult {
|
||||||
target: ReclaimTarget::DanglingSnapshots,
|
target: None,
|
||||||
|
destroyed: Some(target.clone()),
|
||||||
ok: true,
|
ok: true,
|
||||||
freed_bytes: bytes + sweep.reclaimed_bytes,
|
freed_bytes: bytes + sweep.reclaimed_bytes,
|
||||||
projected_bytes: None,
|
projected_bytes: None,
|
||||||
@@ -2985,6 +3137,19 @@ pub async fn destroy(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
DestructiveTarget::RollbackPin { tag, .. } => {
|
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);
|
let reference = format!("triple-c-snapshot-{}:{}", project.id, tag);
|
||||||
migration::untag_image(&reference).await?;
|
migration::untag_image(&reference).await?;
|
||||||
// Untagging only makes the image dangling. Whatever came back came
|
// 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;
|
let sweep = container::sweep_orphaned_snapshots().await;
|
||||||
log::info!("Dropped rollback pin {} on explicit confirmation", reference);
|
log::info!("Dropped rollback pin {} on explicit confirmation", reference);
|
||||||
Ok(ReclaimResult {
|
Ok(ReclaimResult {
|
||||||
target: ReclaimTarget::MigrationPins,
|
target: None,
|
||||||
|
destroyed: Some(target.clone()),
|
||||||
ok: true,
|
ok: true,
|
||||||
freed_bytes: sweep.reclaimed_bytes,
|
freed_bytes: sweep.reclaimed_bytes,
|
||||||
projected_bytes: None,
|
projected_bytes: None,
|
||||||
@@ -3011,8 +3177,13 @@ pub async fn destroy(
|
|||||||
/// The plain `Size` from `inspect_image` includes the base, which several other
|
/// 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
|
/// 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
|
/// as freed would overstate a snapshot removal by ~4.7 GB every time. Only
|
||||||
/// `df()` computes `SharedSize`, so this costs one — acceptable on the
|
/// `df()` computes `SharedSize`, so each call costs a full daemon walk.
|
||||||
/// destructive path, which handles exactly one object per call.
|
///
|
||||||
|
/// 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 {
|
async fn image_unique_bytes(reference: &str) -> i64 {
|
||||||
let Ok(docker) = get_docker() else {
|
let Ok(docker) = get_docker() else {
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -338,6 +338,26 @@ fn a_scrub_container_is_matched_on_its_whole_name_not_a_substring() {
|
|||||||
assert!(!is_scrub_container(&summary(&[], &[])));
|
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]
|
#[test]
|
||||||
fn a_probe_container_is_matched_on_its_label_not_on_the_daemons_filter() {
|
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
|
// 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'));
|
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]
|
#[test]
|
||||||
fn the_compaction_dockerfile_never_reaches_a_bind_mount() {
|
fn the_compaction_dockerfile_never_reaches_a_bind_mount() {
|
||||||
let df = compaction_dockerfile("x:latest", &container::snapshot_scrub_script());
|
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("", ""));
|
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
|
// 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-sandbox:latest"));
|
||||||
assert!(is_base_image_reference("triple-c: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
|
// A project's own snapshot is not a base image, and neither is anything of
|
||||||
// the user's.
|
// the user's.
|
||||||
assert!(!is_base_image_reference("triple-c-snapshot-abc:latest"));
|
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("triple-c-gateway:latest"));
|
||||||
assert!(!is_base_image_reference("postgres:17-alpine"));
|
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();
|
let json = serde_json::to_value(&report).unwrap();
|
||||||
assert_eq!(json["projects"][0]["snapshot_commit_layers"], 14);
|
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_eq!(json["projects"][0]["container_writable_bytes"], 868_000_000i64);
|
||||||
assert!(json["orphan_volumes_unavailable"].is_null());
|
assert!(json["orphan_volumes_unavailable"].is_null());
|
||||||
// `Option<i64>` must reach the frontend as null, not be omitted — the TS
|
// `Option<i64>` must reach the frontend as null, not be omitted — the TS
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ interface Props {
|
|||||||
onDestroy: (item: DestructiveItem) => void;
|
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. */
|
/** `—` for a column with nothing in it, so an empty cell never reads as zero. */
|
||||||
function cell(bytes: number, present: boolean) {
|
function cell(bytes: number, present: boolean) {
|
||||||
return present ? formatBytes(bytes) : "—";
|
return present ? formatBytes(bytes) : "—";
|
||||||
@@ -59,11 +65,18 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props
|
|||||||
</th>
|
</th>
|
||||||
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
|
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
|
||||||
Layers
|
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>
|
||||||
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
|
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
|
||||||
Next commit adds
|
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>
|
||||||
<th scope="col" className="font-medium py-1.5 px-3 text-right">
|
<th scope="col" className="font-medium py-1.5 px-3 text-right">
|
||||||
Home vol
|
Home vol
|
||||||
@@ -104,7 +117,12 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props
|
|||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
|
<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 && (
|
{row.snapshot_exists && (
|
||||||
<span className="block text-[11px] text-[var(--text-secondary)]">
|
<span className="block text-[11px] text-[var(--text-secondary)]">
|
||||||
{/* The base is shared by every project, so charging it to
|
{/* 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>
|
||||||
<td className="py-1.5 px-3 text-right tabular-nums">
|
<td className="py-1.5 px-3 text-right tabular-nums">
|
||||||
{row.snapshot_exists ? (
|
{!row.snapshot_exists ? (
|
||||||
<span
|
|
||||||
className={
|
|
||||||
row.snapshot_commit_layers > 5
|
|
||||||
? "text-[var(--warning)]"
|
|
||||||
: "text-[var(--text-primary)]"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{row.snapshot_commit_layers}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"—"
|
"—"
|
||||||
|
) : !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>
|
||||||
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
|
<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_bytes: 12_273_392_374,
|
||||||
snapshot_shared_bytes: 3_832_425_659,
|
snapshot_shared_bytes: 3_832_425_659,
|
||||||
snapshot_commit_layers: 14,
|
snapshot_commit_layers: 14,
|
||||||
|
base_lineage_known: true,
|
||||||
snapshot_above_base_bytes: 8_440_966_715,
|
snapshot_above_base_bytes: 8_440_966_715,
|
||||||
container_exists: true,
|
container_exists: true,
|
||||||
container_running: false,
|
container_running: false,
|
||||||
@@ -170,6 +171,36 @@ describe("DiskSettings", () => {
|
|||||||
expect(within(projectRow).getByText("14.6 GB")).toBeInTheDocument();
|
expect(within(projectRow).getByText("14.6 GB")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refuses to present a layer count that does not mean recreations", async () => {
|
||||||
|
// Without `triple-c.base-image-id` — the normal case for a project created
|
||||||
|
// before that label existed — the count includes the base's own ~15 layers.
|
||||||
|
// Printing it beside a header that says "one per recreation" would be a
|
||||||
|
// wrong number in the column the table exists for.
|
||||||
|
getDockerDiskUsage.mockResolvedValue(
|
||||||
|
report({ projects: [row({ base_lineage_known: false, snapshot_commit_layers: 17 })] }),
|
||||||
|
);
|
||||||
|
await renderAndScan();
|
||||||
|
const projectRow = await screen.findByTestId("disk-row-p-whp");
|
||||||
|
expect(within(projectRow).getByText("unknown")).toBeInTheDocument();
|
||||||
|
expect(within(projectRow).queryByText("17")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an unmeasurable snapshot split as a dash, never as zero", async () => {
|
||||||
|
getDockerDiskUsage.mockResolvedValue(
|
||||||
|
report({ projects: [row({ snapshot_above_base_bytes: null })] }),
|
||||||
|
);
|
||||||
|
await renderAndScan();
|
||||||
|
const projectRow = await screen.findByTestId("disk-row-p-whp");
|
||||||
|
expect(within(projectRow).queryByText("0 B")).not.toBeInTheDocument();
|
||||||
|
expect(within(projectRow).getAllByText("—").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a heavily stacked snapshot with a word, not just a colour", async () => {
|
||||||
|
await renderAndScan();
|
||||||
|
const projectRow = await screen.findByTestId("disk-row-p-whp");
|
||||||
|
expect(within(projectRow).getByText("stacked")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("charges the shared base to the globals, not to every project row", async () => {
|
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
|
// 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.
|
// row would show it eight times and make the column meaningless.
|
||||||
@@ -218,6 +249,45 @@ describe("DiskSettings", () => {
|
|||||||
expect(reclaim).toHaveBeenCalledWith([{ kind: "migration_staging" }]);
|
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 () => {
|
it("cannot reclaim with nothing ticked", async () => {
|
||||||
await renderAndScan();
|
await renderAndScan();
|
||||||
await screen.findByTestId("disk-safe-bucket");
|
await screen.findByTestId("disk-safe-bucket");
|
||||||
@@ -380,9 +450,7 @@ describe("DiskSettings", () => {
|
|||||||
);
|
);
|
||||||
await renderAndScan();
|
await renderAndScan();
|
||||||
const note = await screen.findByTestId("disk-vhdx-note");
|
const note = await screen.findByTestId("disk-vhdx-note");
|
||||||
expect(
|
expect(note.textContent).toMatch(/Warning: reclaiming here will not shrink your C: drive/);
|
||||||
within(note).getByText("Reclaiming here will not shrink your C: drive"),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(within(note).getByText(/wsl --shutdown/)).toBeInTheDocument();
|
expect(within(note).getByText(/wsl --shutdown/)).toBeInTheDocument();
|
||||||
expect(within(note).getByText(/Optimize-VHD/)).toBeInTheDocument();
|
expect(within(note).getByText(/Optimize-VHD/)).toBeInTheDocument();
|
||||||
expect(within(note).getByText(/Purge data/)).toBeInTheDocument();
|
expect(within(note).getByText(/Purge data/)).toBeInTheDocument();
|
||||||
@@ -415,7 +483,8 @@ describe("DiskSettings", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
destroyProjectDiskObject.mockResolvedValue({
|
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,
|
ok: true,
|
||||||
freed_bytes: 427_000_000,
|
freed_bytes: 427_000_000,
|
||||||
projected_bytes: null,
|
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 () => {
|
it("never routes a destructive object through the bulk Reclaim button", async () => {
|
||||||
listReclaimable.mockResolvedValue(
|
listReclaimable.mockResolvedValue(
|
||||||
plan({
|
plan({
|
||||||
@@ -479,6 +599,7 @@ describe("DiskSettings", () => {
|
|||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
target: { kind: "compact_snapshot", project_id: "p-whp" },
|
target: { kind: "compact_snapshot", project_id: "p-whp" },
|
||||||
|
destroyed: null,
|
||||||
ok: true,
|
ok: true,
|
||||||
freed_bytes: 5_100_000_000,
|
freed_bytes: 5_100_000_000,
|
||||||
projected_bytes: 7_000_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();
|
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");
|
getDockerDiskUsage.mockRejectedValue("Could not read Docker disk usage: no such host");
|
||||||
render(<DiskSettings />);
|
render(<DiskSettings />);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Button from "../ui/Button";
|
import Button from "../ui/Button";
|
||||||
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
||||||
import Modal from "../ui/Modal";
|
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.
|
* and the backend refuses it in bulk by taking a different type entirely.
|
||||||
*/
|
*/
|
||||||
export default function DiskSettings() {
|
export default function DiskSettings() {
|
||||||
const { report, plan, scanning, working, error, outcome, scan, runReclaim, destroy } =
|
const {
|
||||||
useDiskUsage();
|
report,
|
||||||
|
plan,
|
||||||
|
scanning,
|
||||||
|
working,
|
||||||
|
error,
|
||||||
|
outcome,
|
||||||
|
scan,
|
||||||
|
runReclaim,
|
||||||
|
destroy,
|
||||||
|
runSweep,
|
||||||
|
clearOutcome,
|
||||||
|
} = useDiskUsage();
|
||||||
const [ticked, setTicked] = useState<Set<string>>(new Set());
|
const [ticked, setTicked] = useState<Set<string>>(new Set());
|
||||||
const [confirming, setConfirming] = useState<ReclaimItem | null>(null);
|
const [confirming, setConfirming] = useState<ReclaimItem | null>(null);
|
||||||
const [destroying, setDestroying] = useState<DestructiveItem | 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 safeItems = plan?.items.filter((i) => i.safety === "safe") ?? [];
|
||||||
const semiItems = plan?.items.filter((i) => i.safety === "semi_safe") ?? [];
|
const semiItems = plan?.items.filter((i) => i.safety === "semi_safe") ?? [];
|
||||||
const selected = safeItems.filter(
|
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"
|
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"
|
data-testid="disk-vhdx-note"
|
||||||
>
|
>
|
||||||
<StatusIndicator
|
{/* `StatusIndicator` has no warning tone — `error` would put a
|
||||||
tone="error"
|
red glyph in a warning-toned panel. This is advisory, so it
|
||||||
label="Reclaiming here will not shrink your C: drive"
|
carries its own glyph beside the words rather than relying on
|
||||||
className="text-xs"
|
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">
|
<p className="text-xs text-[var(--text-primary)] leading-relaxed">
|
||||||
{report.host.vhdx_note}
|
{report.host.vhdx_note}
|
||||||
</p>
|
</p>
|
||||||
@@ -203,11 +223,20 @@ export default function DiskSettings() {
|
|||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</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 && (
|
{report.orphan_volumes.length > 0 && (
|
||||||
<p className="text-[11px] text-[var(--text-secondary)] leading-relaxed">
|
<p className="text-[11px] text-[var(--text-secondary)] leading-relaxed">
|
||||||
That last figure means only that the volume’s project id is not in
|
“Volumes with no matching project” above means only that the
|
||||||
your project list — it is <em>not</em> inferred from a project
|
volume’s project id is not in your project list — it is{" "}
|
||||||
being stopped or having no image. A project you have not opened in a
|
<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
|
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
|
each of these is ticked individually and shows the date Docker created
|
||||||
it.
|
it.
|
||||||
@@ -223,7 +252,7 @@ export default function DiskSettings() {
|
|||||||
{/* --- Store failure, if any ------------------------------------ */}
|
{/* --- Store failure, if any ------------------------------------ */}
|
||||||
{report.orphan_volumes_unavailable && (
|
{report.orphan_volumes_unavailable && (
|
||||||
<section
|
<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"
|
data-testid="disk-store-error"
|
||||||
>
|
>
|
||||||
<StatusIndicator
|
<StatusIndicator
|
||||||
@@ -237,7 +266,16 @@ export default function DiskSettings() {
|
|||||||
</section>
|
</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 ---------------------------------------------- */}
|
{/* --- Safe reclaim ---------------------------------------------- */}
|
||||||
|
{plan && (
|
||||||
<section className="space-y-2" data-testid="disk-safe-bucket">
|
<section className="space-y-2" data-testid="disk-safe-bucket">
|
||||||
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
|
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||||
Safe to reclaim
|
Safe to reclaim
|
||||||
@@ -260,7 +298,10 @@ export default function DiskSettings() {
|
|||||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
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}
|
disabled={item.blocked !== null}
|
||||||
onChange={() => toggle(item)}
|
onChange={() => toggle(item)}
|
||||||
className="mt-0.5 accent-[var(--accent-emphasis)]"
|
className="mt-0.5 accent-[var(--accent-emphasis)]"
|
||||||
@@ -317,6 +358,7 @@ export default function DiskSettings() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* --- Semi-safe -------------------------------------------------- */}
|
{/* --- Semi-safe -------------------------------------------------- */}
|
||||||
{semiItems.length > 0 && (
|
{semiItems.length > 0 && (
|
||||||
@@ -371,16 +413,13 @@ export default function DiskSettings() {
|
|||||||
|
|
||||||
{/* --- Sweep ------------------------------------------------------ */}
|
{/* --- Sweep ------------------------------------------------------ */}
|
||||||
<section className="flex items-center gap-3 flex-wrap">
|
<section className="flex items-center gap-3 flex-wrap">
|
||||||
<Button
|
<Button size="sm" disabled={working} onClick={runSweep}>
|
||||||
size="sm"
|
|
||||||
disabled={working}
|
|
||||||
onClick={() => runReclaim([{ kind: "dangling_snapshots" }])}
|
|
||||||
>
|
|
||||||
Sweep superseded images now
|
Sweep superseded images now
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-xs text-[var(--text-secondary)]">
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
The same sweep that runs at startup and after every recreation — here you
|
The same sweep that runs at startup and after every recreation. Unlike the
|
||||||
can see what it found.
|
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>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
@@ -394,11 +433,16 @@ export default function DiskSettings() {
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
data-testid="disk-outcome"
|
data-testid="disk-outcome"
|
||||||
>
|
>
|
||||||
<StatusIndicator
|
<div className="flex items-center justify-between gap-3">
|
||||||
tone={outcome.results.every((r) => r.ok) ? "ok" : "error"}
|
<StatusIndicator
|
||||||
label={`Reclaimed ${formatBytes(outcome.total_freed_bytes)}`}
|
tone={outcome.results.every((r) => r.ok) ? "ok" : "error"}
|
||||||
className="text-xs"
|
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)]">
|
<ul className="space-y-1 text-xs text-[var(--text-secondary)]">
|
||||||
{outcome.results.map((result, index) => (
|
{outcome.results.map((result, index) => (
|
||||||
<li key={index}>
|
<li key={index}>
|
||||||
@@ -433,10 +477,11 @@ export default function DiskSettings() {
|
|||||||
size="md"
|
size="md"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={working}
|
disabled={working}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
const target = confirming.target;
|
// Same reasoning as the destructive modal: a compaction takes
|
||||||
|
// minutes, and the dialog reporting it beats it vanishing.
|
||||||
|
await runReclaim([confirming.target]);
|
||||||
setConfirming(null);
|
setConfirming(null);
|
||||||
void runReclaim([target]);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{working ? "Working…" : "Run it"}
|
{working ? "Working…" : "Run it"}
|
||||||
@@ -487,10 +532,12 @@ export default function DiskSettings() {
|
|||||||
confirmLabel={`Delete ${destroying.label.toLowerCase()}`}
|
confirmLabel={`Delete ${destroying.label.toLowerCase()}`}
|
||||||
busy={working}
|
busy={working}
|
||||||
onCancel={() => setDestroying(null)}
|
onCancel={() => setDestroying(null)}
|
||||||
onConfirm={(typed) => {
|
onConfirm={async (typed) => {
|
||||||
const target = destroying.target;
|
// 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);
|
setDestroying(null);
|
||||||
void destroy(target, typed);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p>
|
<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 Modal from "./Modal";
|
||||||
import Button from "./Button";
|
import Button from "./Button";
|
||||||
import { inputClass } from "./Field";
|
import { inputClass } from "./Field";
|
||||||
@@ -47,6 +47,9 @@ export default function TypedConfirmModal({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [typed, setTyped] = useState("");
|
const [typed, setTyped] = useState("");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
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();
|
const matches = expected.trim().length > 0 && typed.trim() === expected.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -80,13 +83,13 @@ export default function TypedConfirmModal({
|
|||||||
{children}
|
{children}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="typed-confirm-input"
|
htmlFor={inputId}
|
||||||
className="block text-[13px] text-[var(--text-primary)] mb-1.5"
|
className="block text-[13px] text-[var(--text-primary)] mb-1.5"
|
||||||
>
|
>
|
||||||
Type <strong className="font-mono">{expected}</strong> to confirm
|
Type <strong className="font-mono">{expected}</strong> to confirm
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="typed-confirm-input"
|
id={inputId}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={typed}
|
value={typed}
|
||||||
onChange={(e) => setTyped(e.target.value)}
|
onChange={(e) => setTyped(e.target.value)}
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ vi.mock("../lib/tauri-commands", () => ({
|
|||||||
reclaim: (targets: unknown) => reclaim(targets),
|
reclaim: (targets: unknown) => reclaim(targets),
|
||||||
destroyProjectDiskObject: (target: unknown, confirmation: string) =>
|
destroyProjectDiskObject: (target: unknown, confirmation: string) =>
|
||||||
destroyProjectDiskObject(target, confirmation),
|
destroyProjectDiskObject(target, confirmation),
|
||||||
sweepOrphanedSnapshots: vi.fn(),
|
sweepOrphanedSnapshots: () => sweepOrphanedSnapshots(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const sweepOrphanedSnapshots = vi.fn();
|
||||||
|
|
||||||
const report = (scanned_at: string): DiskUsageReport =>
|
const report = (scanned_at: string): DiskUsageReport =>
|
||||||
({ scanned_at, projects: [] }) as unknown as DiskUsageReport;
|
({ scanned_at, projects: [] }) as unknown as DiskUsageReport;
|
||||||
|
|
||||||
@@ -149,13 +151,94 @@ describe("useDiskUsage", () => {
|
|||||||
expect(result.current.outcome?.total_freed_bytes).toBe(100);
|
expect(result.current.outcome?.total_freed_bytes).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("surfaces a failure rather than leaving a stale report on screen", async () => {
|
it("reports a scan failure and keeps the last good measurement", async () => {
|
||||||
getDockerDiskUsage.mockRejectedValue("daemon unreachable");
|
// 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());
|
const { result } = renderHook(() => useDiskUsage());
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.scan();
|
await result.current.scan();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
getDockerDiskUsage.mockRejectedValueOnce("daemon unreachable");
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.scan();
|
||||||
|
});
|
||||||
await waitFor(() => expect(result.current.error).toMatch(/daemon unreachable/));
|
await waitFor(() => expect(result.current.error).toMatch(/daemon unreachable/));
|
||||||
|
expect(result.current.report?.scanned_at).toBe("first");
|
||||||
expect(result.current.scanning).toBe(false);
|
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
|
* the daemon and computes shared-layer sizes. On a 100 GB store that is
|
||||||
* seconds. `AccordionSection` unmounts its body when collapsed, so a
|
* seconds. `AccordionSection` unmounts its body when collapsed, so a
|
||||||
* `useEffect` scan would re-run every single time the user opened the section.
|
* `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 scan is therefore only ever what the Scan button calls.
|
||||||
* the result lives in this hook rather than in the component so that reopening
|
*
|
||||||
* the section shows the last result instead of paying again.
|
* 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
|
* ## The generation guard
|
||||||
*
|
*
|
||||||
* A user who hits Scan twice can have two `df()` calls in flight, and they can
|
* 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
|
* 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
|
* write in `scan` checks it is still the newest before it lands, the same
|
||||||
* `useContainerMigration` uses.
|
* 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 {
|
export interface DiskUsageState {
|
||||||
report: DiskUsageReport | null;
|
report: DiskUsageReport | null;
|
||||||
@@ -41,6 +49,8 @@ export interface DiskUsageState {
|
|||||||
scan: () => Promise<void>;
|
scan: () => Promise<void>;
|
||||||
runReclaim: (targets: ReclaimTarget[]) => Promise<void>;
|
runReclaim: (targets: ReclaimTarget[]) => Promise<void>;
|
||||||
destroy: (target: DestructiveTarget, confirmation: string) => 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;
|
clearOutcome: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,16 +73,24 @@ export function useDiskUsage(): DiskUsageState {
|
|||||||
try {
|
try {
|
||||||
const next = await commands.getDockerDiskUsage();
|
const next = await commands.getDockerDiskUsage();
|
||||||
if (generation.current !== mine) return;
|
if (generation.current !== mine) return;
|
||||||
setReport(next);
|
|
||||||
// Planning is cheap and always wanted: the classification is what makes
|
// Planning is cheap and always wanted: the classification is what makes
|
||||||
// the numbers actionable, and it reuses the report rather than scanning
|
// the numbers actionable, and it reuses the report rather than scanning
|
||||||
// again.
|
// again.
|
||||||
const nextPlan = await commands.listReclaimable(next);
|
const nextPlan = await commands.listReclaimable(next);
|
||||||
if (generation.current !== mine) return;
|
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);
|
setPlan(nextPlan);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (generation.current !== mine) return;
|
if (generation.current !== mine) return;
|
||||||
setError(String(e));
|
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 {
|
} finally {
|
||||||
if (generation.current === mine) setScanning(false);
|
if (generation.current === mine) setScanning(false);
|
||||||
}
|
}
|
||||||
@@ -85,9 +103,16 @@ export function useDiskUsage(): DiskUsageState {
|
|||||||
try {
|
try {
|
||||||
const result = await commands.reclaim(targets);
|
const result = await commands.reclaim(targets);
|
||||||
setOutcome(result);
|
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
|
// outcome already reports measured bytes for every target — a user who
|
||||||
// wants the new totals asks for them.
|
// wants the new totals asks for them.
|
||||||
|
setPlan(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -101,6 +126,53 @@ export function useDiskUsage(): DiskUsageState {
|
|||||||
try {
|
try {
|
||||||
const result = await commands.destroyProjectDiskObject(target, confirmation);
|
const result = await commands.destroyProjectDiskObject(target, confirmation);
|
||||||
setOutcome({ results: [result], total_freed_bytes: result.freed_bytes });
|
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) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -110,5 +182,17 @@ export function useDiskUsage(): DiskUsageState {
|
|||||||
|
|
||||||
const clearOutcome = useCallback(() => setOutcome(null), []);
|
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");
|
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", () => {
|
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
|
// 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.
|
// counts. `NaN GB` in the middle of a table is worse than nothing.
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* The one byte formatter.
|
* 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
|
* `projects/migrationCopy.ts`, `settings/UpdateDialog.tsx` and an inline
|
||||||
* `toFixed(1)` in `useProjectActions.ts` — disagreeing about the divisor, the
|
* `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
|
* ## 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
|
* 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.
|
* and binary is opt-in, rather than the other way round.
|
||||||
*
|
*
|
||||||
* Both existing conventions are preserved exactly, so re-pointing the old
|
* Both existing conventions are preserved for every size either call site can
|
||||||
* call sites changed no rendered string:
|
* 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)
|
* - `{ }` → `41.0 MB` (decimal, what migration used)
|
||||||
* - `{ binary: true }` → `1.5 GB` (÷1024 with decimal-style
|
* - `{ binary: true }` → `1.5 GB` (÷1024 with decimal-style
|
||||||
@@ -56,6 +64,17 @@ export function formatBytes(bytes: number, options: FormatBytesOptions = {}): st
|
|||||||
value /= step;
|
value /= step;
|
||||||
unit += 1;
|
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`.
|
// Whole bytes never get a decimal point: `512 B`, not `512.0 B`.
|
||||||
return unit === 0
|
return unit === 0
|
||||||
? `${Math.round(bytes)} ${units[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
|
* 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.
|
* 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. */
|
/** Bytes shared with another image — almost always the base. */
|
||||||
snapshot_shared_bytes: number;
|
snapshot_shared_bytes: number;
|
||||||
/** Layers stacked above the base image: **one per container recreation**.
|
/** 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;
|
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
|
/** Bytes those layers account for. `null` when the base image is gone and
|
||||||
* the split cannot be measured — never a guess. */
|
* the split cannot be measured — never a guess. */
|
||||||
snapshot_above_base_bytes: number | null;
|
snapshot_above_base_bytes: number | null;
|
||||||
@@ -989,7 +995,11 @@ export interface ReclaimPlan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ReclaimResult {
|
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;
|
ok: boolean;
|
||||||
freed_bytes: number;
|
freed_bytes: number;
|
||||||
/** What was projected beforehand, for the one action that projects. */
|
/** What was projected beforehand, for the one action that projects. */
|
||||||
|
|||||||
Reference in New Issue
Block a user