fix(server): count whole storage tree in Fabro-managed bytes

build_disk_usage_response only summed scratch/ run dirs and logs/*.log,
omitting objects/ (SlateDB + artifacts), sessions/, and vaults/ — a ~30x
undercount of "Fabro managed" storage on the resources page.

Measure the whole storage_dir tree for total_size_bytes so it can't drift
as new subdirectories are added. Reclaimable stays a curated estimate that
matches what `fabro system prune` actually frees. A residual "other"
summary row keeps `fabro system df` totals consistent and surfaces as a
"Database & artifacts" table row.

Also add a KiB tier to formatBytesAsMemory so small storage values render
human-readably instead of raw byte counts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-20 20:55:54 -04:00
parent 65eac48d11
commit 37d6b3dbcd
No known key found for this signature in database
7 changed files with 83 additions and 5 deletions

View file

@ -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`;
}

View file

@ -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", () => {

View file

@ -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()

View file

@ -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}"

View file

@ -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
})),

View file

@ -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),
})

View file

@ -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()