Every recreation runs `docker commit`, which stacks a layer and never rewrites one, and 24 conditions in `container_needs_recreation` trigger a recreation. Prevention landed earlier on this branch; this is the half a user can act on. The per-project table leads with the two numbers that explain the mechanism rather than just the total: how many commit layers a snapshot has stacked above its base, and what the container's writable layer will add at the next commit. Backend (`docker/disk.rs`, commands in `docker_commands.rs`): - `get_docker_disk_usage` — one `df()` joined against the project store, behind an explicit Scan button because it walks the whole daemon. - `list_reclaimable` / `reclaim` — classified buckets with measured bytes, planned off the existing report so re-planning costs no second scan. - `destroy_project_disk_object` — one object, typed confirmation. - `sweep_orphaned_snapshots` — exposed, so its report is finally visible. Safety is structural: `reclaim` takes `ReclaimTarget`, which has no variant that can name a live project's data. Destructive work is a separate type reached only through `destroy`. No unfiltered prune is called anywhere, and nothing outside a `triple-c*` name or `triple-c.*` label is touched. Orphan detection subtracts ids from the project store and consults nothing else. From the daemon's side an idle live project and a deleted one are indistinguishable — volumes present, no container, no image — so inferring from container or image absence would offer a live project's credentials and transcripts for deletion. A store that loaded empty from an existing `projects.json` is treated as a failed load, not as "no projects", because `ProjectsStore::new()` recovers from a corrupt file by starting empty. Three things verified against a live Docker 29.7.2 rather than assumed: - Compaction is a two-stage build (`FROM scratch` + `COPY --from`), which keeps every byte inside the daemon; bollard's import buffers a whole image into memory. uid/gid and setuid survive; a 192.6 MB/4-layer synthetic came out 45.7 MB/1 layer. Image config does not survive, so it is replayed via create+commit, which round-trips a multi-line env var that a Dockerfile `ENV` could not. - Flattening breaks base-layer sharing, so the result carries its own copy of the base. Eight of ten real projects had a 0.10–1.32 GB delta over a 4.72 GB shared base — compacting those costs ~4 GB. The bound now subtracts that penalty, such projects are not offered at all, and the run compares unique bytes and abandons a rewrite that would grow. - `docker builder prune` reports `Total:`, not `Total reclaimed space:`, so the first parser scored every prune as freeing nothing. The Windows/WSL2 note is mandatory and its copy lives in Rust beside the tests that pin it: pruning frees space inside `ext4.vhdx`, which never shrinks on its own, so C: does not change until the disk is compacted. Also adds `lib/formatBytes.ts` — the app had four disagreeing copies, and `projects/home/format.ts` and `migrationCopy.ts` now delegate to it with byte-identical output. Base 1000 by default, matching what Docker prints. Tests: 502 frontend (was 453), 365 Rust (was 322). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
61 lines
2.4 KiB
TypeScript
61 lines
2.4 KiB
TypeScript
/** Shared formatting helpers for the Project Home views. */
|
|
|
|
import { formatBytes as shared } from "../../../lib/formatBytes";
|
|
|
|
/**
|
|
* File sizes in Project Home, ÷1024 with `KB`/`MB`/`GB` labels.
|
|
*
|
|
* Kept as a named re-export rather than deleted: three modules import it from
|
|
* here, and the binary/decimal-label pairing is a Project Home convention
|
|
* rather than the app-wide default. The implementation is `lib/formatBytes`.
|
|
*/
|
|
export function formatBytes(bytes: number): string {
|
|
return shared(bytes, { binary: true });
|
|
}
|
|
|
|
/** "2h ago" / "3d ago". Returns null for unparseable timestamps. */
|
|
export function formatAge(iso: string | null | undefined): string | null {
|
|
if (!iso) return null;
|
|
const then = Date.parse(iso);
|
|
if (Number.isNaN(then)) return null;
|
|
return formatElapsed(Date.now() - then);
|
|
}
|
|
|
|
export function formatElapsed(ms: number): string {
|
|
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
if (seconds < 60) return "just now";
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h ${minutes % 60}m ago`;
|
|
const days = Math.floor(hours / 24);
|
|
return `${days}d ago`;
|
|
}
|
|
|
|
/** "for 42s" / "for 4m" / "for 1h 12m" — elapsed phrasing for a run in flight.
|
|
* Seconds are kept below a minute because the first thing anyone wants from a
|
|
* freshly triggered run is evidence that it started at all. */
|
|
export function formatRunningFor(iso: string | null | undefined): string | null {
|
|
if (!iso) return null;
|
|
const started = Date.parse(iso);
|
|
if (Number.isNaN(started)) return null;
|
|
const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000));
|
|
if (seconds < 60) return `for ${seconds}s`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `for ${minutes}m`;
|
|
const hours = Math.floor(minutes / 60);
|
|
return `for ${hours}h ${minutes % 60}m`;
|
|
}
|
|
|
|
/** Uptime phrasing for a known start timestamp. */
|
|
export function formatUptime(startedAtMs: number | undefined): string | null {
|
|
if (startedAtMs === undefined) return null;
|
|
const seconds = Math.floor((Date.now() - startedAtMs) / 1000);
|
|
if (seconds < 60) return "just started";
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `up ${minutes}m`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `up ${hours}h ${minutes % 60}m`;
|
|
return `up ${Math.floor(hours / 24)}d`;
|
|
}
|