diff --git a/apps/fabro-web/app/lib/format.ts b/apps/fabro-web/app/lib/format.ts index 8f699a8cc..b55872f1d 100644 --- a/apps/fabro-web/app/lib/format.ts +++ b/apps/fabro-web/app/lib/format.ts @@ -106,9 +106,10 @@ export function formatTokenCount( const BYTES_PER_GIB = 1024 * 1024 * 1024; const BYTES_PER_MIB = 1024 * 1024; +const BYTES_PER_KIB = 1024; /** - * Format a byte count as a memory/disk size (e.g. "8 GiB", "512 MiB", "1024 B"). + * Format a byte count as a memory/disk size (e.g. "8 GiB", "512 MiB", "4 KiB", "742 B"). */ export function formatBytesAsMemory(bytes: number): string { if (bytes >= BYTES_PER_GIB) { @@ -119,6 +120,10 @@ export function formatBytesAsMemory(bytes: number): string { const mib = bytes / BYTES_PER_MIB; return `${Number.isInteger(mib) ? mib : mib.toFixed(1)} MiB`; } + if (bytes >= BYTES_PER_KIB) { + const kib = bytes / BYTES_PER_KIB; + return `${Number.isInteger(kib) ? kib : kib.toFixed(1)} KiB`; + } return `${bytes} B`; } diff --git a/apps/fabro-web/app/routes/run-sandbox.test.tsx b/apps/fabro-web/app/routes/run-sandbox.test.tsx index c40763916..8e9f14376 100644 --- a/apps/fabro-web/app/routes/run-sandbox.test.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.test.tsx @@ -182,6 +182,15 @@ describe("formatBytesAsMemory", () => { test("falls back to mebibytes when below a gibibyte", () => { expect(formatBytesAsMemory(512 * 1024 * 1024)).toBe("512 MiB"); }); + + test("falls back to kibibytes when below a mebibyte", () => { + expect(formatBytesAsMemory(4 * 1024)).toBe("4 KiB"); + expect(formatBytesAsMemory(1536)).toBe("1.5 KiB"); + }); + + test("renders raw bytes when below a kibibyte", () => { + expect(formatBytesAsMemory(742)).toBe("742 B"); + }); }); describe("RunSandbox route", () => { diff --git a/lib/crates/fabro-cli/src/commands/system/df.rs b/lib/crates/fabro-cli/src/commands/system/df.rs index fd4e43eab..fe4da7e4e 100644 --- a/lib/crates/fabro-cli/src/commands/system/df.rs +++ b/lib/crates/fabro-cli/src/commands/system/df.rs @@ -43,6 +43,10 @@ fn df_from( .summary .iter() .find(|row| row.type_.as_deref() == Some("logs")); + let other_summary = output + .summary + .iter() + .find(|row| row.type_.as_deref() == Some("other")); let run_count = runs_summary.and_then(|row| row.count).map_or(0, as_u64); let active_count = runs_summary.and_then(|row| row.active).map_or(0, as_u64); @@ -58,6 +62,10 @@ fn df_from( .and_then(|row| row.size_bytes) .map_or(0, as_u64); + let total_other_size = other_summary + .and_then(|row| row.size_bytes) + .map_or(0, as_u64); + let run_reclaim_pct = if total_run_size > 0 { #[allow( clippy::cast_possible_truncation, @@ -111,6 +119,15 @@ fn df_from( .cell() .justify(Justify::Right), ], + vec![ + "Database & artifacts".cell(), + "-".cell().justify(Justify::Right), + "-".cell().justify(Justify::Right), + format_size(total_other_size).cell().justify(Justify::Right), + format!("{} (0%)", format_size(0)) + .cell() + .justify(Justify::Right), + ], ]; let summary_table = summary_rows .table() diff --git a/lib/crates/fabro-cli/tests/it/cmd/system_df.rs b/lib/crates/fabro-cli/tests/it/cmd/system_df.rs index b6d0fc560..9928de75c 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/system_df.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/system_df.rs @@ -57,6 +57,10 @@ fn system_df_summarizes_runs_and_logs() { stdout.contains("Logs"), "system df should summarize logs: {stdout}" ); + assert!( + stdout.contains("Database & artifacts"), + "system df should summarize database and artifact storage: {stdout}" + ); assert!( stdout.contains("Data directory:"), "system df should print the storage directory: {stdout}" diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index a95f00fac..e3e502bb0 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -962,9 +962,16 @@ pub(crate) async fn get_system_disk_usage( "active": null, "size_bytes": 256, "reclaimable_bytes": 256 + }, + { + "type": "other", + "count": null, + "active": null, + "size_bytes": 16_777_216, + "reclaimable_bytes": 0 } ], - "total_size_bytes": 1280, + "total_size_bytes": 16_778_496, "total_reclaimable_bytes": 1280, "runs": runs })), diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 561f4c2db..b5258fa65 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -1468,6 +1468,12 @@ fn build_disk_usage_response( } } + // Measure the whole storage tree so the managed total can't drift as new + // subdirectories are added. "other" is the residual (database, artifacts, + // sessions, vaults) — everything that isn't an enumerated run or log file. + let managed_size = dir_size(storage_dir); + let other_size = managed_size.saturating_sub(total_run_size + total_log_size); + Ok(DiskUsageResponse { summary: vec![ DiskUsageSummaryRow { @@ -1484,8 +1490,15 @@ fn build_disk_usage_response( size_bytes: Some(to_i64(total_log_size)), reclaimable_bytes: Some(to_i64(total_log_size)), }, + DiskUsageSummaryRow { + type_: Some("other".to_string()), + count: None, + active: None, + size_bytes: Some(to_i64(other_size)), + reclaimable_bytes: Some(0), + }, ], - total_size_bytes: Some(to_i64(total_run_size + total_log_size)), + total_size_bytes: Some(to_i64(managed_size)), total_reclaimable_bytes: Some(to_i64(reclaimable_run_size + total_log_size)), runs: verbose.then_some(run_rows), }) diff --git a/lib/crates/fabro-server/tests/it/api/system.rs b/lib/crates/fabro-server/tests/it/api/system.rs index 923959db6..e13a6e18d 100644 --- a/lib/crates/fabro-server/tests/it/api/system.rs +++ b/lib/crates/fabro-server/tests/it/api/system.rs @@ -207,6 +207,15 @@ async fn get_system_resources_returns_server_visible_metrics() { &body["disk"]["fabro_reclaimable_bytes"], "disk.fabro_reclaimable_bytes", ); + assert!( + body["disk"]["fabro_managed_bytes"] + .as_i64() + .unwrap_or_default() + >= body["disk"]["fabro_reclaimable_bytes"] + .as_i64() + .unwrap_or_default(), + "managed bytes must include everything reclaimable: {body}" + ); assert!( body["notes"] .as_array() @@ -286,8 +295,22 @@ async fn get_system_disk_usage_returns_summary_and_verbose_rows() { "GET /api/v1/system/df?verbose=true", ) .await; - assert!(body["summary"].is_array()); - assert!(body["total_size_bytes"].as_i64().unwrap_or_default() > 0); + let summary = body["summary"].as_array().expect("summary array"); + assert!(summary.iter().any(|row| row["type"] == "other")); + + let row_size = |type_: &str| { + summary + .iter() + .find(|row| row["type"] == type_) + .and_then(|row| row["size_bytes"].as_i64()) + .unwrap_or_default() + }; + let total_size = body["total_size_bytes"].as_i64().unwrap_or_default(); + assert!(total_size > 0); + assert_eq!( + row_size("runs") + row_size("logs") + row_size("other"), + total_size, + ); assert!( body["runs"] .as_array()