diff --git a/crates/database/src/backups.rs b/crates/database/src/backups.rs index d031bd10..e21a94d7 100644 --- a/crates/database/src/backups.rs +++ b/crates/database/src/backups.rs @@ -806,6 +806,12 @@ pub struct BackupRun { pub snapshot_id: Option, #[diesel(deserialize_as = jiff_diesel::Timestamp, serialize_as = jiff_diesel::Timestamp)] pub reported_at: Timestamp, + /// S3 traffic tallied by bestool's proxy: `raw` counts the full HTTP message + /// (incl. SigV4 chunk framing), `payload` the decoded object data. + pub s3_sent_raw_bytes: Option, + pub s3_sent_payload_bytes: Option, + pub s3_received_raw_bytes: Option, + pub s3_received_payload_bytes: Option, } #[derive(Debug, Clone, Insertable)] @@ -824,6 +830,10 @@ pub struct NewBackupRun { pub error: Option, pub bytes_uploaded: Option, pub snapshot_id: Option, + pub s3_sent_raw_bytes: Option, + pub s3_sent_payload_bytes: Option, + pub s3_received_raw_bytes: Option, + pub s3_received_payload_bytes: Option, } impl BackupRun { diff --git a/crates/database/src/schema.rs b/crates/database/src/schema.rs index 209938cd..38552e2c 100644 --- a/crates/database/src/schema.rs +++ b/crates/database/src/schema.rs @@ -109,6 +109,10 @@ diesel::table! { bytes_uploaded -> Nullable, snapshot_id -> Nullable, reported_at -> Timestamptz, + s3_sent_raw_bytes -> Nullable, + s3_sent_payload_bytes -> Nullable, + s3_received_raw_bytes -> Nullable, + s3_received_payload_bytes -> Nullable, } } diff --git a/crates/database/tests/backup_detection.rs b/crates/database/tests/backup_detection.rs index 1b0b996e..b174a142 100644 --- a/crates/database/tests/backup_detection.rs +++ b/crates/database/tests/backup_detection.rs @@ -158,6 +158,10 @@ async fn insert_backup_success_aged( error: None, bytes_uploaded: Some(42), snapshot_id: Some("kopia-snap".into()), + s3_sent_raw_bytes: None, + s3_sent_payload_bytes: None, + s3_received_raw_bytes: None, + s3_received_payload_bytes: None, }, ) .await diff --git a/crates/database/tests/backups.rs b/crates/database/tests/backups.rs index 037c75b5..38e1f981 100644 --- a/crates/database/tests/backups.rs +++ b/crates/database/tests/backups.rs @@ -223,6 +223,10 @@ fn new_run( error: None, bytes_uploaded: Some(42), snapshot_id: Some("kopia-snap-1".into()), + s3_sent_raw_bytes: None, + s3_sent_payload_bytes: None, + s3_received_raw_bytes: None, + s3_received_payload_bytes: None, } } diff --git a/crates/public-server/openapi.json b/crates/public-server/openapi.json index 7960d0ae..fc20b205 100644 --- a/crates/public-server/openapi.json +++ b/crates/public-server/openapi.json @@ -1470,6 +1470,35 @@ "format": "uuid", "description": "The run-uuid bestool minted at run start (becomes `backup_runs.id`)." }, + "s3_received_payload_bytes": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "s3_received_raw_bytes": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "s3_sent_payload_bytes": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "s3_sent_raw_bytes": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "S3 traffic the proxy tallied during the run: `raw` is the full HTTP\nmessage (incl. SigV4 chunk framing), `payload` the decoded object data.\nReported on both success and failure; absent from older clients." + }, "snapshot_id": { "type": [ "string", diff --git a/crates/public-server/src/backup.rs b/crates/public-server/src/backup.rs index 7d2619b2..bce6de23 100644 --- a/crates/public-server/src/backup.rs +++ b/crates/public-server/src/backup.rs @@ -471,6 +471,13 @@ pub struct ReportArgs { pub error: Option, pub bytes_uploaded: Option, pub snapshot_id: Option, + /// S3 traffic the proxy tallied during the run: `raw` is the full HTTP + /// message (incl. SigV4 chunk framing), `payload` the decoded object data. + /// Reported on both success and failure; absent from older clients. + pub s3_sent_raw_bytes: Option, + pub s3_sent_payload_bytes: Option, + pub s3_received_raw_bytes: Option, + pub s3_received_payload_bytes: Option, } #[utoipa::path( @@ -512,6 +519,10 @@ async fn report( error: rep.error, bytes_uploaded: rep.bytes_uploaded, snapshot_id: rep.snapshot_id, + s3_sent_raw_bytes: rep.s3_sent_raw_bytes, + s3_sent_payload_bytes: rep.s3_sent_payload_bytes, + s3_received_raw_bytes: rep.s3_received_raw_bytes, + s3_received_payload_bytes: rep.s3_received_payload_bytes, }, ) .await?; diff --git a/crates/public-server/tests/backup.rs b/crates/public-server/tests/backup.rs index 4fc59e78..a00e931b 100644 --- a/crates/public-server/tests/backup.rs +++ b/crates/public-server/tests/backup.rs @@ -294,6 +294,10 @@ async fn report_writes_run_with_context_attribution_and_204() { "group_id": bogus_group, "bytes_uploaded": 4096, "snapshot_id": "k0pia123", + "s3_sent_raw_bytes": 5000, + "s3_sent_payload_bytes": 4096, + "s3_received_raw_bytes": 320, + "s3_received_payload_bytes": 128, })) .await; resp.assert_status(http::StatusCode::NO_CONTENT); @@ -311,6 +315,10 @@ async fn report_writes_run_with_context_attribution_and_204() { assert_eq!(run.device_id, device_id); assert_eq!(run.server_id, Some(server)); assert_eq!(run.bytes_uploaded, Some(4096)); + assert_eq!(run.s3_sent_raw_bytes, Some(5000)); + assert_eq!(run.s3_sent_payload_bytes, Some(4096)); + assert_eq!(run.s3_received_raw_bytes, Some(320)); + assert_eq!(run.s3_received_payload_bytes, Some(128)); }, ) .await; diff --git a/migrations/2026-06-25-192736-0000_add_s3_traffic_to_backup_runs/down.sql b/migrations/2026-06-25-192736-0000_add_s3_traffic_to_backup_runs/down.sql new file mode 100644 index 00000000..238f961b --- /dev/null +++ b/migrations/2026-06-25-192736-0000_add_s3_traffic_to_backup_runs/down.sql @@ -0,0 +1,5 @@ +ALTER TABLE backup_runs + DROP COLUMN s3_sent_raw_bytes, + DROP COLUMN s3_sent_payload_bytes, + DROP COLUMN s3_received_raw_bytes, + DROP COLUMN s3_received_payload_bytes; diff --git a/migrations/2026-06-25-192736-0000_add_s3_traffic_to_backup_runs/up.sql b/migrations/2026-06-25-192736-0000_add_s3_traffic_to_backup_runs/up.sql new file mode 100644 index 00000000..e28d33e8 --- /dev/null +++ b/migrations/2026-06-25-192736-0000_add_s3_traffic_to_backup_runs/up.sql @@ -0,0 +1,8 @@ +-- S3 traffic tallied by bestool's proxy during a run: raw counts the full HTTP +-- message (incl. SigV4 chunk framing), payload counts the decoded object data. +-- Nullable: older clients omit them; both backup and restore runs may report. +ALTER TABLE backup_runs + ADD COLUMN s3_sent_raw_bytes BIGINT, + ADD COLUMN s3_sent_payload_bytes BIGINT, + ADD COLUMN s3_received_raw_bytes BIGINT, + ADD COLUMN s3_received_payload_bytes BIGINT; diff --git a/private-web/e2e/backups.spec.ts b/private-web/e2e/backups.spec.ts index d57a12e4..4f663875 100644 --- a/private-web/e2e/backups.spec.ts +++ b/private-web/e2e/backups.spec.ts @@ -327,10 +327,54 @@ test.describe("backups ready: stats + backup-now", () => { // Error detail is hidden until the row is expanded. await expect(page.getByText(/disk quota exceeded/i)).toBeHidden(); - await runs.getByRole("button", { name: /show error/i }).click(); + await runs.getByRole("button", { name: /show details/i }).click(); await expect(page.getByText(/disk quota exceeded/i)).toBeVisible(); }); + test("run with S3 traffic but no upload size shows ~payload and expandable traffic detail", async ({ + page, + sql, + }) => { + const group = await seedServerGroup(sql, { name: "s3-group" }); + const device = await seedDevice(sql); + const server = await seedServer(sql, { + name: "s3-srv", + groupId: group.id, + deviceId: device.id, + }); + await seedServerGroupBackupConfig(sql, { + groupId: group.id, + status: "ready", + intervalSeconds: 3600, + }); + // No explicit upload size, but the proxy tallied S3 traffic → the Uploaded + // column falls back to the payload-sent figure, marked approximate. + await seedBackupRun(sql, { + deviceId: device.id, + groupId: group.id, + serverId: server.id, + outcome: "success", + bytesUploaded: null, + s3SentPayloadBytes: 2048, // 2.0 KiB → the Uploaded approximation + s3SentRawBytes: 3072, // 3.0 KiB + s3ReceivedPayloadBytes: 512, // 512 B + s3ReceivedRawBytes: 1024, // 1.0 KiB + }); + + await page.goto(`/groups/${group.id}/backups`); + const runs = page.getByRole("table").last(); + await expect(runs.getByText("s3-srv")).toBeVisible(); + // Uploaded falls back to the payload-sent figure, prefixed "~". + await expect(runs.getByText("~2.0 KiB")).toBeVisible(); + + // The S3 traffic breakdown is hidden until the row is expanded. + await expect(page.getByText(/s3 traffic/i)).toBeHidden(); + await runs.getByRole("button", { name: /show details/i }).click(); + await expect(page.getByText(/s3 traffic/i)).toBeVisible(); + await expect(page.getByText(/2\.0 KiB payload \/ 3\.0 KiB raw/i)).toBeVisible(); + await expect(page.getByText(/512 B payload \/ 1\.0 KiB raw/i)).toBeVisible(); + }); + test("backup-now writes a request row; cancel deletes it", async ({ page, sql, diff --git a/private-web/e2e/seed.ts b/private-web/e2e/seed.ts index 89840028..748bb084 100644 --- a/private-web/e2e/seed.ts +++ b/private-web/e2e/seed.ts @@ -411,13 +411,18 @@ export async function seedBackupRun( error?: string | null; bytesUploaded?: number | null; snapshotId?: string | null; + s3SentRawBytes?: number | null; + s3SentPayloadBytes?: number | null; + s3ReceivedRawBytes?: number | null; + s3ReceivedPayloadBytes?: number | null; }, ): Promise<{ id: string }> { const id = randomUUID(); await sql.query( `INSERT INTO backup_runs - (id, device_id, group_id, server_id, type, purpose, outcome, error, bytes_uploaded, snapshot_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + (id, device_id, group_id, server_id, type, purpose, outcome, error, bytes_uploaded, snapshot_id, + s3_sent_raw_bytes, s3_sent_payload_bytes, s3_received_raw_bytes, s3_received_payload_bytes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, [ id, opts.deviceId, @@ -429,6 +434,10 @@ export async function seedBackupRun( opts.error ?? null, opts.bytesUploaded ?? null, opts.snapshotId ?? null, + opts.s3SentRawBytes ?? null, + opts.s3SentPayloadBytes ?? null, + opts.s3ReceivedRawBytes ?? null, + opts.s3ReceivedPayloadBytes ?? null, ], ); return { id }; diff --git a/private-web/openapi.json b/private-web/openapi.json index e76c9c7e..410ad000 100644 --- a/private-web/openapi.json +++ b/private-web/openapi.json @@ -5508,6 +5508,35 @@ "type": "string", "format": "date-time" }, + "s3_received_payload_bytes": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "s3_received_raw_bytes": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "s3_sent_payload_bytes": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "s3_sent_raw_bytes": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "S3 traffic tallied by bestool's proxy: `raw` counts the full HTTP message\n(incl. SigV4 chunk framing), `payload` the decoded object data." + }, "server_id": { "type": [ "string", diff --git a/private-web/src/api-types.ts b/private-web/src/api-types.ts index 07793c14..7b07dda2 100644 --- a/private-web/src/api-types.ts +++ b/private-web/src/api-types.ts @@ -2279,6 +2279,18 @@ export interface components { purpose: components["schemas"]["BackupPurpose"]; /** Format: date-time */ reported_at: string; + /** Format: int64 */ + s3_received_payload_bytes?: number | null; + /** Format: int64 */ + s3_received_raw_bytes?: number | null; + /** Format: int64 */ + s3_sent_payload_bytes?: number | null; + /** + * Format: int64 + * @description S3 traffic tallied by bestool's proxy: `raw` counts the full HTTP message + * (incl. SigV4 chunk framing), `payload` the decoded object data. + */ + s3_sent_raw_bytes?: number | null; /** Format: uuid */ server_id?: string | null; snapshot_id?: string | null; diff --git a/private-web/src/routes/BackupPanel.tsx b/private-web/src/routes/BackupPanel.tsx index b6363688..5ff4ae8c 100644 --- a/private-web/src/routes/BackupPanel.tsx +++ b/private-web/src/routes/BackupPanel.tsx @@ -619,19 +619,36 @@ function serverLabel( return m?.name || m?.display_host || serverId.slice(0, 8); } -/// One row of the recent-runs table. Failed runs get an expand toggle that -/// reveals the device-reported error detail in a collapsible sub-row. +/// True when the run carries any of bestool's four S3 traffic tallies. +function hasS3Traffic(run: BackupRun): boolean { + return ( + run.s3_sent_raw_bytes != null || + run.s3_sent_payload_bytes != null || + run.s3_received_raw_bytes != null || + run.s3_received_payload_bytes != null + ); +} + +/// One row of the recent-runs table. Runs with an error or reported S3 traffic +/// get an expand toggle that reveals the detail in a collapsible sub-row. function RunRow({ run, members }: { run: BackupRun; members: ServerInfo[] }) { const [open, setOpen] = useState(false); const hasError = Boolean(run.error); + const hasS3 = hasS3Traffic(run); + const expandable = hasError || hasS3; + // bestool reports an explicit upload size for some backup types; when it's + // absent, the S3 payload-sent tally is the closest proxy (marked approximate). + const uploadedApprox = + run.bytes_uploaded == null && run.s3_sent_payload_bytes != null; + const uploaded = run.bytes_uploaded ?? run.s3_sent_payload_bytes ?? null; return ( <> - *": { borderBottom: "unset" } } : undefined}> + *": { borderBottom: "unset" } } : undefined}> - {hasError && ( + {expandable && ( setOpen((o) => !o)} > {open ? : } @@ -652,32 +669,41 @@ function RunRow({ run, members }: { run: BackupRun; members: ServerInfo[] }) { /> - {run.bytes_uploaded == null - ? "—" - : formatBytes(run.bytes_uploaded)} + {uploaded == null ? ( + "—" + ) : uploadedApprox ? ( + + ~{formatBytes(uploaded)} + + ) : ( + formatBytes(uploaded) + )} - {hasError && ( + {expandable && ( - - - {run.error} - - + {hasError && ( + + + {run.error} + + + )} + {hasS3 && } @@ -686,6 +712,24 @@ function RunRow({ run, members }: { run: BackupRun; members: ServerInfo[] }) { ); } +/// S3 traffic the proxy tallied during a run, shown in the expand row: raw is +/// the full HTTP message (incl. SigV4 chunk framing), payload the object data. +function S3TrafficDetail({ run }: { run: BackupRun }) { + return ( + + S3 traffic + + + + ); +} + /// Repository stats (top, beside the config summary). Read-only snapshot of the /// kopia repo's size/counts as of the last inspection. function RepoStatsPanel({ groupId }: { groupId: string }) {