diff --git a/.sqlx/query-36b9fd03d07cae393bd05aff90e9e1a082849b4dcb6fac37391d2c94130d0881.json b/.sqlx/query-36b9fd03d07cae393bd05aff90e9e1a082849b4dcb6fac37391d2c94130d0881.json deleted file mode 100644 index 7f341f98..00000000 --- a/.sqlx/query-36b9fd03d07cae393bd05aff90e9e1a082849b4dcb6fac37391d2c94130d0881.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT\n ((ts - ?1) / ?2) AS \"bucket!: i64\",\n COALESCE(SUM(bytes), 0) AS \"bytes!: i64\"\n FROM activity_log\n WHERE ts >= ?1 AND event_type = 'upload_done'\n GROUP BY ((ts - ?1) / ?2)\n ORDER BY ((ts - ?1) / ?2) ASC\n ", - "describe": { - "columns": [ - { - "name": "bucket!: i64", - "ordinal": 0, - "type_info": "Integer", - "origin": "Expression" - }, - { - "name": "bytes!: i64", - "ordinal": 1, - "type_info": "Integer", - "origin": "Expression" - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - true, - false - ] - }, - "hash": "36b9fd03d07cae393bd05aff90e9e1a082849b4dcb6fac37391d2c94130d0881" -} diff --git a/.sqlx/query-531c3f1a3422d7c59833fc8ca59961d06d7f2863c97936e915226566bf50e02b.json b/.sqlx/query-531c3f1a3422d7c59833fc8ca59961d06d7f2863c97936e915226566bf50e02b.json new file mode 100644 index 00000000..27780b12 --- /dev/null +++ b/.sqlx/query-531c3f1a3422d7c59833fc8ca59961d06d7f2863c97936e915226566bf50e02b.json @@ -0,0 +1,35 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n ((ts - ?1) / ?2) AS \"bucket!: i64\",\n COALESCE(SUM(bytes), 0) AS \"bytes!: i64\",\n COALESCE(SUM(COALESCE(file_count, 1)), 0) AS \"files!: i64\"\n FROM activity_log\n WHERE ts >= ?1 AND event_type IN ('upload_done', 'bundle_upload')\n GROUP BY ((ts - ?1) / ?2)\n ORDER BY ((ts - ?1) / ?2) ASC\n ", + "describe": { + "columns": [ + { + "name": "bucket!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + }, + { + "name": "bytes!: i64", + "ordinal": 1, + "type_info": "Integer", + "origin": "Expression" + }, + { + "name": "files!: i64", + "ordinal": 2, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true, + false, + false + ] + }, + "hash": "531c3f1a3422d7c59833fc8ca59961d06d7f2863c97936e915226566bf50e02b" +} diff --git a/.sqlx/query-aef26b053fcf64ed34ac8ebbffff6fb28d1c6334cacd10328157485e988626ae.json b/.sqlx/query-c254faa14c27127eb16cf4a2e9748a7fca05224a7b6b421950b85294cc605b41.json similarity index 57% rename from .sqlx/query-aef26b053fcf64ed34ac8ebbffff6fb28d1c6334cacd10328157485e988626ae.json rename to .sqlx/query-c254faa14c27127eb16cf4a2e9748a7fca05224a7b6b421950b85294cc605b41.json index 9494bb80..fe66a01c 100644 --- a/.sqlx/query-aef26b053fcf64ed34ac8ebbffff6fb28d1c6334cacd10328157485e988626ae.json +++ b/.sqlx/query-c254faa14c27127eb16cf4a2e9748a7fca05224a7b6b421950b85294cc605b41.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n COALESCE(SUM(CASE WHEN ts >= ?1 THEN bytes ELSE 0 END), 0) AS \"today!: i64\",\n COALESCE(SUM(CASE WHEN ts >= ?2 THEN bytes ELSE 0 END), 0) AS \"week!: i64\",\n COALESCE(SUM(CASE WHEN ts >= ?3 THEN bytes ELSE 0 END), 0) AS \"window!: i64\"\n FROM activity_log\n WHERE ts >= MIN(?1, ?2, ?3) AND event_type = 'upload_done'\n ", + "query": "\n SELECT\n COALESCE(SUM(CASE WHEN ts >= ?1 THEN bytes ELSE 0 END), 0) AS \"today!: i64\",\n COALESCE(SUM(CASE WHEN ts >= ?2 THEN bytes ELSE 0 END), 0) AS \"week!: i64\",\n COALESCE(SUM(CASE WHEN ts >= ?3 THEN bytes ELSE 0 END), 0) AS \"window!: i64\",\n COALESCE(\n SUM(CASE WHEN ts >= ?3 THEN COALESCE(file_count, 1) ELSE 0 END),\n 0\n ) AS \"window_files!: i64\"\n FROM activity_log\n WHERE ts >= MIN(?1, ?2, ?3) AND event_type IN ('upload_done', 'bundle_upload')\n ", "describe": { "columns": [ { @@ -20,16 +20,23 @@ "ordinal": 2, "type_info": "Integer", "origin": "Expression" + }, + { + "name": "window_files!: i64", + "ordinal": 3, + "type_info": "Integer", + "origin": "Expression" } ], "parameters": { "Right": 3 }, "nullable": [ + false, false, false, false ] }, - "hash": "aef26b053fcf64ed34ac8ebbffff6fb28d1c6334cacd10328157485e988626ae" + "hash": "c254faa14c27127eb16cf4a2e9748a7fca05224a7b6b421950b85294cc605b41" } diff --git a/crates/driven-core/src/state/mod.rs b/crates/driven-core/src/state/mod.rs index f378cf61..17e01be0 100644 --- a/crates/driven-core/src/state/mod.rs +++ b/crates/driven-core/src/state/mod.rs @@ -495,11 +495,36 @@ pub struct ActivitySummary { /// `activity_log.bytes` at or after `window_start`), used with the window /// length to render a current bytes/sec rate in the UI. pub throughput_window_bytes: u64, + /// FILES observed in that same throughput window - the sibling headline of + /// `throughput_window_bytes`, so the dashboard can answer "how many files" + /// and "how many bytes" over one window rather than two. + /// + /// A plain upload row is one file (`activity_log.file_count` is NULL there); + /// a `bundle_upload` row carries the number of member files it packed, so it + /// counts as all of them (`COALESCE(file_count, 1)`). + pub throughput_window_files: u64, /// Length of the throughput window in milliseconds (so the UI computes the /// rate as `throughput_window_bytes / (throughput_window_ms / 1000)`). pub throughput_window_ms: u64, } +/// The bucketed recent-upload series behind the Activity dashboard sparklines +/// (DESIGN s8.3): two parallel, dense, oldest-first vectors over the SAME +/// buckets - bytes uploaded and files uploaded. +/// +/// Both are exactly `bucket_count` long and share an index, so bucket `i` of +/// [`Self::bytes`] and bucket `i` of [`Self::files`] describe the same slice of +/// time. They are returned together (one query) precisely so the two tiles can +/// never plot two different windows. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ActivityThroughputSeries { + /// Bytes uploaded per bucket, oldest first. + pub bytes: Vec, + /// Files uploaded per bucket, oldest first (a `bundle_upload` bucket counts + /// every member file it packed, not the single row). + pub files: Vec, +} + /// M9b (SPEC s16): the anonymous-telemetry 24h aggregate, computed entirely from /// the durable `activity_log` + `backup_sources` (the `file_state` metadata) over /// the last 24h. @@ -1508,8 +1533,8 @@ pub trait StateRepo: Send + Sync { /// - `day_start_ms` / `week_start_ms`: inclusive lower bounds for the /// today / this-week byte sums. /// - `throughput_window_start_ms`: inclusive lower bound for the recent - /// throughput byte sum; `throughput_window_ms` is its length (carried - /// straight through so the UI computes bytes/sec). + /// throughput byte AND file sums; `throughput_window_ms` is its length + /// (carried straight through so the UI computes bytes/sec). /// /// Default impl returns a zeroed summary; the SQLite repo overrides it with /// the real aggregate SQL. @@ -1529,16 +1554,19 @@ pub trait StateRepo: Send + Sync { Ok(ActivitySummary::default()) } - /// The recent upload-throughput SERIES: `bucket_count` consecutive - /// `bucket_ms`-wide byte sums starting at `window_start_ms`, oldest first - /// (DESIGN s8.3 header aggregates; backs the Activity dashboard's - /// last-5-minutes throughput sparkline). + /// The recent upload SERIES: `bucket_count` consecutive `bucket_ms`-wide + /// buckets starting at `window_start_ms`, oldest first, each carrying the + /// bytes AND the files uploaded in it (DESIGN s8.3 header aggregates; backs + /// the Activity dashboard's last-5-minutes sparklines). /// /// Same source and same row filter as the scalar window in - /// [`StateRepo::activity_summary`] - `upload_done` rows only - so the - /// sparkline and the headline rate can never tell different stories. A - /// bucket with no uploads comes back as `0` rather than being omitted, so - /// the returned vector is always exactly `bucket_count` long and its index + /// [`StateRepo::activity_summary`] - upload rows only (`upload_done` plus + /// the V2 bundling `bundle_upload`) - so a sparkline and its headline can + /// never tell different stories. Bytes and files come from ONE query over + /// one bucketisation, so the two tiles cannot drift apart either. + /// + /// A bucket with no uploads comes back as `0` rather than being omitted, so + /// each returned vector is always exactly `bucket_count` long and its index /// IS elapsed time. /// /// Default impl returns an empty series; the SQLite repo overrides it with @@ -1548,9 +1576,9 @@ pub trait StateRepo: Send + Sync { window_start_ms: UnixMs, bucket_ms: u64, bucket_count: u32, - ) -> Result> { + ) -> Result { let _ = (window_start_ms, bucket_ms, bucket_count); - Ok(Vec::new()) + Ok(ActivityThroughputSeries::default()) } /// M9b (SPEC s16): the anonymous-telemetry aggregate, computed from the durable diff --git a/crates/driven-core/src/state/sqlite.rs b/crates/driven-core/src/state/sqlite.rs index 45441610..fe599541 100644 --- a/crates/driven-core/src/state/sqlite.rs +++ b/crates/driven-core/src/state/sqlite.rs @@ -34,10 +34,10 @@ use uuid::Uuid; use super::{ AccountRow, ActivityFilter, ActivityLevel, ActivityPage, ActivityRow, ActivitySummary, - BundleRef, BundleRow, DiscardPendingOutcome, FileSearchHit, FileStateRow, FileStatusCount, - FileVersionRow, ImmediateTreeChildren, NewActivity, NewFileVersion, NewPendingOp, PageRequest, - PendingOpRow, PendingRecoveryAck, PlaceholderPolicy, RestoreFileRow, SourceRow, StateRepo, - TelemetryAggregate, + ActivityThroughputSeries, BundleRef, BundleRow, DiscardPendingOutcome, FileSearchHit, + FileStateRow, FileStatusCount, FileVersionRow, ImmediateTreeChildren, NewActivity, + NewFileVersion, NewPendingOp, PageRequest, PendingOpRow, PendingRecoveryAck, PlaceholderPolicy, + RestoreFileRow, SourceRow, StateRepo, TelemetryAggregate, }; use crate::types::{ AccountId, AccountState, ActivityId, FileStateStatus, PendingOpId, RelativePath, SourceId, @@ -2015,20 +2015,34 @@ impl StateRepo for SqliteStateRepo { // per-sum CASE ran, undercounting throughput at week boundaries. Gate // by `MIN(day, week, throughput)` so every CASE owns its own window. // - // M7-R3-P2 (recheck-3): the byte aggregates count ONLY `upload_done` - // rows. "Uploaded today / this week" + the throughput rate are upload - // metrics; any other byte-carrying event type (a future scan/dry-run/ - // error row that happens to set `bytes`) must NOT inflate them. The - // outer `WHERE event_type = 'upload_done'` constrains every CASE sum to - // upload rows; the `file_state.status` GROUP BY is unaffected. + // M7-R3-P2 (recheck-3): the byte aggregates count ONLY upload rows. + // "Uploaded today / this week" + the throughput rate are upload metrics; + // any other byte-carrying event type (a future scan/dry-run/error row + // that happens to set `bytes`) must NOT inflate them. The outer + // `WHERE event_type IN (...)` constrains every CASE sum to upload rows; + // the `file_state.status` GROUP BY is unaffected. + // + // "Upload row" is BOTH `upload_done` (one file per row) and the V2 + // bundling `bundle_upload` (one row, `file_count` member files, the + // packed object's bytes). A bundle really did upload those bytes, so + // excluding it - as this query did before - silently under-reported + // every aggregate for a source whose small files get bundled. + // + // The file counts use `COALESCE(file_count, 1)`: `upload_done` leaves + // `file_count` NULL and means exactly one file, while a `bundle_upload` + // row carries its member count and means all of them. let byte_sums = sqlx::query!( r#" SELECT COALESCE(SUM(CASE WHEN ts >= ?1 THEN bytes ELSE 0 END), 0) AS "today!: i64", COALESCE(SUM(CASE WHEN ts >= ?2 THEN bytes ELSE 0 END), 0) AS "week!: i64", - COALESCE(SUM(CASE WHEN ts >= ?3 THEN bytes ELSE 0 END), 0) AS "window!: i64" + COALESCE(SUM(CASE WHEN ts >= ?3 THEN bytes ELSE 0 END), 0) AS "window!: i64", + COALESCE( + SUM(CASE WHEN ts >= ?3 THEN COALESCE(file_count, 1) ELSE 0 END), + 0 + ) AS "window_files!: i64" FROM activity_log - WHERE ts >= MIN(?1, ?2, ?3) AND event_type = 'upload_done' + WHERE ts >= MIN(?1, ?2, ?3) AND event_type IN ('upload_done', 'bundle_upload') "#, day_start_ms, week_start_ms, @@ -2063,6 +2077,7 @@ impl StateRepo for SqliteStateRepo { bytes_week: byte_sums.week.max(0) as u64, file_status_counts, throughput_window_bytes: byte_sums.window.max(0) as u64, + throughput_window_files: byte_sums.window_files.max(0) as u64, throughput_window_ms, }) } @@ -2072,28 +2087,36 @@ impl StateRepo for SqliteStateRepo { window_start_ms: UnixMs, bucket_ms: u64, bucket_count: u32, - ) -> Result> { + ) -> Result { // The bucketed sibling of `activity_summary`'s scalar throughput window, - // backing the dashboard's last-5-minutes sparkline. Deliberately the - // SAME `event_type = 'upload_done'` filter, so the sparkline and the - // headline rate stay two views of one number rather than two competing - // definitions of it. + // backing the dashboard's last-5-minutes sparklines. Deliberately the + // SAME upload-row filter as that scalar window (`upload_done` plus the + // V2 bundling `bundle_upload`), so a sparkline and its headline stay two + // views of one number rather than two competing definitions of it. + // + // Bytes and files are aggregated in ONE query over ONE bucketisation: + // the two tiles plot the same buckets by construction, so they cannot + // drift apart the way two separate queries eventually would. Files use + // `COALESCE(file_count, 1)` because an `upload_done` row leaves + // `file_count` NULL and means one file, while a `bundle_upload` row + // carries the number of member files it packed. // // GROUP BY integer division bucketises without a date function, and the // caller supplies every boundary (from a single `now`) so the query is // deterministic + unit-testable. Only non-empty buckets come back; the - // zero-fill below turns them into a dense, index-is-elapsed-time series. + // zero-fill below turns them into dense, index-is-elapsed-time series. if bucket_count == 0 || bucket_ms == 0 { - return Ok(Vec::new()); + return Ok(ActivityThroughputSeries::default()); } let bucket_ms_i64 = i64::try_from(bucket_ms).unwrap_or(i64::MAX); let rows = sqlx::query!( r#" SELECT - ((ts - ?1) / ?2) AS "bucket!: i64", - COALESCE(SUM(bytes), 0) AS "bytes!: i64" + ((ts - ?1) / ?2) AS "bucket!: i64", + COALESCE(SUM(bytes), 0) AS "bytes!: i64", + COALESCE(SUM(COALESCE(file_count, 1)), 0) AS "files!: i64" FROM activity_log - WHERE ts >= ?1 AND event_type = 'upload_done' + WHERE ts >= ?1 AND event_type IN ('upload_done', 'bundle_upload') GROUP BY ((ts - ?1) / ?2) ORDER BY ((ts - ?1) / ?2) ASC "#, @@ -2103,8 +2126,9 @@ impl StateRepo for SqliteStateRepo { .fetch_all(&self.pool) .await?; - let mut series = vec![0u64; bucket_count as usize]; - let last = series.len() - 1; + let mut bytes = vec![0u64; bucket_count as usize]; + let mut files = vec![0u64; bucket_count as usize]; + let last = bytes.len() - 1; for r in rows { // A row can land in the CURRENT (still-filling) bucket or, if the // clock moved between the caller's `now` and this query, just past @@ -2113,9 +2137,10 @@ impl StateRepo for SqliteStateRepo { // impossible under the `ts >= window_start` filter, but clamp // defensively rather than risk an index panic. let idx = usize::try_from(r.bucket).unwrap_or(0).min(last); - series[idx] = series[idx].saturating_add(r.bytes.max(0) as u64); + bytes[idx] = bytes[idx].saturating_add(r.bytes.max(0) as u64); + files[idx] = files[idx].saturating_add(r.files.max(0) as u64); } - Ok(series) + Ok(ActivityThroughputSeries { bytes, files }) } async fn telemetry_events_since( @@ -5755,6 +5780,8 @@ mod tests { assert_eq!(summary.bytes_week, 60); // throughput window = rows with ts >= 1500: 30. assert_eq!(summary.throughput_window_bytes, 30); + // ...carrying one file (each row above is a single-file upload). + assert_eq!(summary.throughput_window_files, 1); assert_eq!(summary.throughput_window_ms, window_ms); // status counts: Pending=1, Synced=2 (sorted ascending by status text). @@ -5773,8 +5800,8 @@ mod tests { ); } - /// Writes one `upload_done` row (the only event type the byte aggregates - /// count) at `ts` carrying `bytes`. + /// Writes one `upload_done` row (a single-file upload, one of the two event + /// types the byte aggregates count) at `ts` carrying `bytes`. async fn write_upload(repo: &SqliteStateRepo, src: SourceId, ts: i64, bytes: u64) { repo.write_activity(NewActivity { ts, @@ -5789,6 +5816,50 @@ mod tests { .unwrap(); } + /// Writes one `upload_done` row with a NULL `file_count` - the shape the + /// orchestrator actually writes for a plain upload (see + /// `outcome_activity_row`), which the file aggregates must read as one file. + async fn write_upload_without_file_count( + repo: &SqliteStateRepo, + src: SourceId, + ts: i64, + bytes: u64, + ) { + repo.write_activity(NewActivity { + ts, + source_id: Some(src), + level: ActivityLevel::Info, + event_type: "upload_done".into(), + file_count: None, + bytes: Some(bytes), + message: None, + }) + .await + .unwrap(); + } + + /// Writes one V2-bundling `bundle_upload` row: ONE row that uploaded `files` + /// member files as a single `bytes`-sized object. + async fn write_bundle_upload( + repo: &SqliteStateRepo, + src: SourceId, + ts: i64, + files: u64, + bytes: u64, + ) { + repo.write_activity(NewActivity { + ts, + source_id: Some(src), + level: ActivityLevel::Info, + event_type: "bundle_upload".into(), + file_count: Some(files), + bytes: Some(bytes), + message: None, + }) + .await + .unwrap(); + } + #[tokio::test] async fn throughput_series_buckets_bytes_by_elapsed_time() { // The dashboard sparkline's data source: a DENSE series where the index @@ -5813,27 +5884,66 @@ mod tests { .activity_throughput_series(window_start, bucket_ms, 5) .await .unwrap(); - assert_eq!(series, vec![100, 0, 55, 0, 11]); + assert_eq!(series.bytes, vec![100, 0, 55, 0, 11]); + // The files series shares those buckets: bucket 0 held two uploads. + assert_eq!(series.files, vec![2, 0, 1, 0, 1]); } #[tokio::test] async fn throughput_series_is_dense_and_zeroed_when_nothing_uploaded() { - // The empty state the tile must render gracefully: a full-length series + // The empty state the tiles must render gracefully: full-length series // of zeros, never a short vector the UI has to pad itself. let (repo, _dir) = temp_repo().await; let series = repo .activity_throughput_series(0, 10_000, 30) .await .unwrap(); - assert_eq!(series.len(), 30); - assert!(series.iter().all(|&b| b == 0)); + assert_eq!(series.bytes.len(), 30); + assert_eq!(series.files.len(), 30); + assert!(series.bytes.iter().all(|&b| b == 0)); + assert!(series.files.iter().all(|&f| f == 0)); + } + + #[tokio::test] + async fn throughput_series_counts_a_bundle_row_as_all_its_member_files() { + // V2 bundling (issue #35) uploads N small files as ONE object, logged as + // ONE `bundle_upload` row carrying `file_count = N`. The files series + // must count all N - counting the row as a single file would make a + // bundling source look idle - while the bytes series counts the packed + // object once. + let (repo, _dir) = temp_repo().await; + let acct = sample_account(); + repo.upsert_account(&acct).await.unwrap(); + let src = sample_source(acct.id); + repo.upsert_source(&src).await.unwrap(); + + // Bucket 0: a plain upload written the way the orchestrator writes it + // (NULL file_count = exactly one file). Bucket 1: a bundle of 7 files. + write_upload_without_file_count(&repo, src.id, 1_100, 40).await; + write_bundle_upload(&repo, src.id, 2_100, 7, 900).await; + + let series = repo + .activity_throughput_series(1_000, 1_000, 3) + .await + .unwrap(); + assert_eq!( + series.files, + vec![1, 7, 0], + "a NULL file_count is one file; a bundle is all its members" + ); + assert_eq!( + series.bytes, + vec![40, 900, 0], + "the bundle's bytes are the packed object, counted once" + ); } #[tokio::test] async fn throughput_series_counts_only_upload_rows() { - // Same filter as the scalar throughput window: a byte-carrying row of - // any other event type must not inflate the sparkline, or the chart and - // the headline rate would tell different stories. + // Same filter as the scalar throughput window: a byte-carrying row of a + // NON-upload event type must not inflate either sparkline, or a chart + // and its headline would tell different stories. A `scan_done` row + // carries both bytes and a file count, so it exercises both series. let (repo, _dir) = temp_repo().await; let acct = sample_account(); repo.upsert_account(&acct).await.unwrap(); @@ -5858,9 +5968,14 @@ mod tests { .await .unwrap(); assert_eq!( - series, + series.bytes, vec![40, 0], - "only the upload_done bytes are counted" + "only the upload bytes are counted" + ); + assert_eq!( + series.files, + vec![1, 0], + "the scan row's 9 files are not uploads" ); } @@ -5883,23 +5998,21 @@ mod tests { .activity_throughput_series(5_000, 1_000, 2) .await .unwrap(); - assert_eq!(series, vec![10, 25]); + assert_eq!(series.bytes, vec![10, 25]); + // The fold applies to BOTH series, so they stay index-aligned. + assert_eq!(series.files, vec![1, 1]); } #[tokio::test] async fn throughput_series_with_no_buckets_is_empty() { - // Degenerate inputs return an empty series instead of dividing by zero. + // Degenerate inputs return empty series instead of dividing by zero. let (repo, _dir) = temp_repo().await; - assert!(repo - .activity_throughput_series(0, 1_000, 0) - .await - .unwrap() - .is_empty()); - assert!(repo - .activity_throughput_series(0, 0, 10) - .await - .unwrap() - .is_empty()); + let no_buckets = repo.activity_throughput_series(0, 1_000, 0).await.unwrap(); + assert!(no_buckets.bytes.is_empty()); + assert!(no_buckets.files.is_empty()); + let no_width = repo.activity_throughput_series(0, 0, 10).await.unwrap(); + assert!(no_width.bytes.is_empty()); + assert!(no_width.files.is_empty()); } #[tokio::test] @@ -5959,12 +6072,18 @@ mod tests { } #[tokio::test] - async fn activity_summary_byte_sums_count_only_upload_done_rows() { + async fn activity_summary_byte_sums_count_only_upload_rows() { // M7-R3-P2 (recheck-3): the "Uploaded today / this week" + throughput - // byte aggregates MUST count only `upload_done` rows. A byte-carrying - // row of any OTHER event type (here a synthetic scan/dry-run row) must - // not inflate them. Before the fix the sums included every row's - // `bytes`, so such a row would over-report uploads. + // byte aggregates MUST count only UPLOAD rows. A byte-carrying row of + // any OTHER event type (here a synthetic scan/dry-run row) must not + // inflate them. Before the fix the sums included every row's `bytes`, + // so such a row would over-report uploads. + // + // "Upload row" spans both `upload_done` and the V2-bundling + // `bundle_upload`: a bundle genuinely uploaded those bytes and those + // member files, so it belongs in the aggregates (it used to be dropped + // by an `event_type = 'upload_done'` filter, silently under-reporting + // every source whose small files get bundled). let (repo, _dir) = temp_repo().await; let acct = sample_account(); repo.upsert_account(&acct).await.unwrap(); @@ -6002,19 +6121,28 @@ mod tests { }) .await .unwrap(); + // A bundle of 3 files inside all three windows: 60 more bytes and 3 + // more files, all of which the aggregates MUST see. + write_bundle_upload(&repo, src.id, 1950, 3, 60).await; let summary = repo .activity_summary(day_start, week_start, window_start, window_ms) .await .unwrap(); - // Every byte aggregate sees ONLY the upload_done row's 40 bytes; the - // 1_000_000-byte scan_done row is excluded. - assert_eq!(summary.bytes_today, 40, "today counts only upload_done"); - assert_eq!(summary.bytes_week, 40, "week counts only upload_done"); + // Every byte aggregate sees the upload_done row's 40 bytes plus the + // bundle's 60; the 1_000_000-byte scan_done row is excluded. + assert_eq!(summary.bytes_today, 100, "today counts only upload rows"); + assert_eq!(summary.bytes_week, 100, "week counts only upload rows"); + assert_eq!( + summary.throughput_window_bytes, 100, + "throughput counts only upload rows" + ); + // Files: 1 from the single upload + 3 bundle members. The scan row's + // file_count is not an upload and must not appear. assert_eq!( - summary.throughput_window_bytes, 40, - "throughput counts only upload_done" + summary.throughput_window_files, 4, + "the bundle contributes all of its member files" ); } diff --git a/src-tauri/src/commands/activity.rs b/src-tauri/src/commands/activity.rs index b31993de..805f2aca 100644 --- a/src-tauri/src/commands/activity.rs +++ b/src-tauri/src/commands/activity.rs @@ -24,7 +24,8 @@ use driven_core::types::{ActivityEntry, ErrorCode, FileStateStatus, SourceId}; use crate::app_state::AppState; use crate::commands::dtos::{ - ActivityFilterDto, ActivityPageDto, ActivitySummaryDto, FileStatusCountDto, PageRequestDto, + ActivityFilterDto, ActivityPageDto, ActivitySummaryDto, ActivityThroughputSeriesDto, + FileStatusCountDto, PageRequestDto, }; use crate::commands::{CommandError, CommandResult}; @@ -258,6 +259,7 @@ pub async fn activity_summary( bytes_week: summary.bytes_week, file_status_counts, throughput_window_bytes: summary.throughput_window_bytes, + throughput_window_files: summary.throughput_window_files, throughput_window_ms: summary.throughput_window_ms, }) } @@ -271,20 +273,23 @@ const MIN_THROUGHPUT_BUCKET_MS: u64 = 1_000; /// Upper bound on the number of buckets one series request may return. const MAX_THROUGHPUT_BUCKETS: u64 = 240; -/// `activity_throughput_series(window_ms, bucket_ms)` - the recent upload -/// throughput as a dense series of `bucket_ms`-wide byte sums, oldest first. +/// `activity_throughput_series(window_ms, bucket_ms)` - the recent uploads as +/// two dense, oldest-first series over the same `bucket_ms`-wide buckets: bytes +/// uploaded and files uploaded. /// -/// Backs the Activity dashboard's last-5-minutes throughput sparkline. Shares -/// `activity_summary`'s window semantics (`now - window_ms`, `upload_done` rows -/// only), so the sparkline and the headline rate are two views of one number. -/// The bucket count is derived here rather than taken from the caller, so the -/// series length and the window can never disagree. +/// Backs the Activity dashboard's last-5-minutes sparklines (the throughput tile +/// and the files-uploaded tile). Shares `activity_summary`'s window semantics +/// (`now - window_ms`, upload rows only), so each sparkline and its headline are +/// two views of one number. Both series come from ONE query, so the two tiles +/// cannot plot different buckets. The bucket count is derived here rather than +/// taken from the caller, so the series length and the window can never +/// disagree. #[tauri::command] pub async fn activity_throughput_series( state: State<'_, AppState>, window_ms: u64, bucket_ms: u64, -) -> CommandResult> { +) -> CommandResult { if !(1..=MAX_THROUGHPUT_WINDOW_MS).contains(&window_ms) { return Err(CommandError::with_code( ErrorCode::InvalidInput, @@ -325,12 +330,15 @@ pub async fn activity_throughput_series( tracing::debug!( target: TARGET, - buckets = series.len(), + buckets = series.bytes.len(), window_ms, bucket_ms, "activity_throughput_series served" ); - Ok(series) + Ok(ActivityThroughputSeriesDto { + bytes: series.bytes, + files: series.files, + }) } /// Map a [`FileStateStatus`] to its stable wire string (matching the SPEC s2 diff --git a/src-tauri/src/commands/dtos.rs b/src-tauri/src/commands/dtos.rs index 5a6e8d2e..97072d3e 100644 --- a/src-tauri/src/commands/dtos.rs +++ b/src-tauri/src/commands/dtos.rs @@ -850,11 +850,31 @@ pub struct ActivitySummaryDto { pub file_status_counts: Vec, /// Bytes observed in the recent throughput window. pub throughput_window_bytes: u64, + /// FILES observed in that same window (a `bundle_upload` row counts every + /// member file it packed), so the files tile's headline and the throughput + /// tile's headline describe one window. + pub throughput_window_files: u64, /// Length of the throughput window in milliseconds (so the UI derives a /// bytes/sec rate). pub throughput_window_ms: u64, } +/// The bucketed recent-upload series behind the Activity dashboard sparklines +/// (DESIGN s8.3). Two parallel, dense, oldest-first arrays over the SAME +/// buckets: bytes uploaded and files uploaded. Mirrors +/// [`driven_core::state::ActivityThroughputSeries`] over the camelCase wire. +/// +/// They travel together (one command, one query) so the throughput tile and the +/// files tile plot the same buckets by construction. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivityThroughputSeriesDto { + /// Bytes uploaded per bucket, oldest first. + pub bytes: Vec, + /// Files uploaded per bucket, oldest first. + pub files: Vec, +} + // ----------------------------------------------------------------------------- // Restore (SPEC s11.5; DESIGN s8.4) // ----------------------------------------------------------------------------- diff --git a/ui/src/__tests__/activity-load-in.test.ts b/ui/src/__tests__/activity-load-in.test.ts new file mode 100644 index 00000000..893f1844 --- /dev/null +++ b/ui/src/__tests__/activity-load-in.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { mount, flushPromises } from "@vue/test-utils"; + +import { i18n } from "../i18n"; + +// Feature B: switching to the Activity tab used to flash - an empty header, an +// empty filter bar and "Showing 0 of 0" painted first, then each swapped out as +// its query landed. The view now holds a skeleton of its own shape until the +// first load settles, then fades the real content in. +// +// This also covers the files-uploaded tile's presence in the view (Feature A): +// it must appear right after the throughput tile, fed from the store's files +// series and the summary's window file count. + +const invokeMock = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (cmd: string, args?: unknown) => invokeMock(cmd, args), +})); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async () => () => undefined), +})); +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(), + save: vi.fn(), +})); +vi.mock("vue-router", () => ({ + useRouter: () => ({ push: vi.fn() }), + useRoute: () => ({ params: {} }), +})); + +import Activity from "../views/Activity.vue"; + +const SUMMARY = '[data-testid="activity-summary"]'; +const SUMMARY_SKELETON = '[data-testid="activity-summary-skeleton"]'; +const FILTERS = '[data-testid="activity-filters"]'; +const FILTERS_SKELETON = '[data-testid="activity-filters-skeleton"]'; +const TABLE_SKELETON = '[data-testid="activity-table-skeleton"]'; +const COUNT = '[data-testid="activity-count"]'; +const FILES_TILE = '[data-testid="files-uploaded-tile"]'; +const FILES_VALUE = '[data-testid="files-uploaded-value"]'; +const FILES_SPARKLINE = '[data-testid="files-uploaded-sparkline"]'; +const THROUGHPUT_TILE = '[data-testid="throughput-tile"]'; + +/** Resolves every on-mount command. `gate`, when supplied, is awaited before the + * first history page resolves, so the test can inspect the still-loading view. */ +function stubBackend(gate?: Promise): void { + invokeMock.mockImplementation(async (cmd: string) => { + switch (cmd) { + case "query_activity": + if (gate) await gate; + return { + entries: [], + total: 0, + limit: 100, + hasMore: false, + nextBeforeTs: null, + nextBeforeId: null, + }; + case "distinct_activity_event_types": + return ["upload_done"]; + case "activity_summary": + return { + bytesToday: 4096, + bytesWeek: 8192, + fileStatusCounts: [], + throughputWindowBytes: 60_000, + throughputWindowFiles: 12, + throughputWindowMs: 60_000, + }; + case "activity_throughput_series": + return { bytes: [0, 1024, 2048], files: [0, 1, 4] }; + case "list_sources": + return []; + default: + return undefined; + } + }); +} + +beforeEach(() => { + setActivePinia(createPinia()); + invokeMock.mockReset(); +}); + +describe("Activity load-in (Feature B)", () => { + it("shows a skeleton of the page's shape while the first load is in flight", async () => { + let release = (): void => undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + stubBackend(gate); + + const wrapper = mount(Activity, { global: { plugins: [i18n] } }); + await flushPromises(); + + // Still loading: skeletons stand in for the tiles, the filter bar and the + // table, and none of the real content (which would be wrong-and-then-right) + // is painted yet. + expect(wrapper.find(SUMMARY_SKELETON).exists()).toBe(true); + expect(wrapper.find(FILTERS_SKELETON).exists()).toBe(true); + expect(wrapper.find(TABLE_SKELETON).exists()).toBe(true); + expect(wrapper.find(SUMMARY).exists()).toBe(false); + expect(wrapper.find(FILTERS).exists()).toBe(false); + expect(wrapper.find(COUNT).exists()).toBe(false); + // The section announces the in-flight load instead of the skeleton bars + // being read out as content. + expect(wrapper.find("section").attributes("aria-busy")).toBe("true"); + expect(wrapper.find(SUMMARY_SKELETON).attributes("aria-hidden")).toBe("true"); + + release(); + await flushPromises(); + + // Settled: the skeletons are gone and the real content is in, carrying the + // one-shot load-in animation class. + expect(wrapper.find(SUMMARY_SKELETON).exists()).toBe(false); + expect(wrapper.find(FILTERS_SKELETON).exists()).toBe(false); + expect(wrapper.find(TABLE_SKELETON).exists()).toBe(false); + expect(wrapper.find(SUMMARY).exists()).toBe(true); + expect(wrapper.find(FILTERS).exists()).toBe(true); + expect(wrapper.find(COUNT).exists()).toBe(true); + expect(wrapper.find(SUMMARY).classes()).toContain("activity-load-in"); + expect(wrapper.find("section").attributes("aria-busy")).toBe("false"); + + wrapper.unmount(); + }); + + it("retires the skeleton even when the first load fails", async () => { + // A failed load must not leave the view pulsing forever - it has to fall + // through to the error / empty state like any other settled load. + invokeMock.mockImplementation(async (cmd: string) => { + if (cmd === "query_activity") throw new Error("backend gone"); + if (cmd === "list_sources") return []; + return undefined; + }); + + const wrapper = mount(Activity, { global: { plugins: [i18n] } }); + await flushPromises(); + + expect(wrapper.find(SUMMARY_SKELETON).exists()).toBe(false); + expect(wrapper.find(TABLE_SKELETON).exists()).toBe(false); + expect(wrapper.find('[data-testid="activity-error"]').exists()).toBe(true); + + wrapper.unmount(); + }); +}); + +describe("Activity files-uploaded tile (Feature A)", () => { + it("renders the files tile right after the throughput tile", async () => { + stubBackend(); + const wrapper = mount(Activity, { global: { plugins: [i18n] } }); + await flushPromises(); + + const tiles = Array.from(wrapper.find(SUMMARY).element.children); + const throughputAt = tiles.findIndex((el) => el.matches(THROUGHPUT_TILE)); + const filesAt = tiles.findIndex((el) => el.matches(FILES_TILE)); + expect(throughputAt).toBeGreaterThanOrEqual(0); + expect(filesAt).toBe(throughputAt + 1); + + wrapper.unmount(); + }); + + it("headlines the window's file count and plots the store's files series", async () => { + stubBackend(); + const wrapper = mount(Activity, { global: { plugins: [i18n] } }); + await flushPromises(); + + // The summary's throughputWindowFiles, not a count derived from the chart. + expect(wrapper.find(FILES_VALUE).text()).toBe("12 files"); + // The files series [0, 1, 4] has a shape, so the sparkline is drawn. + expect(wrapper.find(FILES_SPARKLINE).exists()).toBe(true); + + wrapper.unmount(); + }); +}); diff --git a/ui/src/__tests__/activity-store.test.ts b/ui/src/__tests__/activity-store.test.ts index 3f724432..fbe67b2f 100644 --- a/ui/src/__tests__/activity-store.test.ts +++ b/ui/src/__tests__/activity-store.test.ts @@ -627,9 +627,10 @@ describe("activity store: backend facets + summary (M7-P2-4, P2-5)", () => { }); describe("activity store: throughput sparkline series", () => { - it("loads the rolling series with the window + bucket the tile draws", async () => { - const series = [0, 1024, 2048, 0]; - invokeMock.mockResolvedValueOnce(series); + it("loads both rolling series with the window + bucket the tiles draw", async () => { + const bytes = [0, 1024, 2048, 0]; + const files = [0, 1, 3, 0]; + invokeMock.mockResolvedValueOnce({ bytes, files }); const store = useActivityStore(); await store.loadThroughputSeries(); @@ -637,36 +638,53 @@ describe("activity store: throughput sparkline series", () => { windowMs: SPARKLINE_WINDOW_MS, bucketMs: SPARKLINE_BUCKET_MS, }); - expect(store.throughputSeries).toEqual(series); + expect(store.throughputSeries).toEqual(bytes); + expect(store.filesSeries).toEqual(files); }); - it("degrades to an empty series (the tile's zero state) when the query fails", async () => { + it("degrades to empty series (the tiles' zero state) when the query fails", async () => { const store = useActivityStore(); - invokeMock.mockResolvedValueOnce([1, 2, 3]); + invokeMock.mockResolvedValueOnce({ bytes: [1, 2, 3], files: [1, 1, 2] }); await store.loadThroughputSeries(); expect(store.throughputSeries).toEqual([1, 2, 3]); + expect(store.filesSeries).toEqual([1, 1, 2]); // A later failure must clear the stale shape rather than leave a chart that - // silently stopped updating. + // silently stopped updating - and it must clear BOTH, so the two tiles never + // disagree about whether there is data. invokeMock.mockRejectedValueOnce(new Error("backend gone")); await store.loadThroughputSeries(); expect(store.throughputSeries).toEqual([]); + expect(store.filesSeries).toEqual([]); }); - it("normalizes a malformed response instead of handing the chart NaN", async () => { + it("normalizes a malformed response instead of handing the charts NaN", async () => { const store = useActivityStore(); - // An unregistered / version-skewed command resolves undefined: the chart + // An unregistered / version-skewed command resolves undefined: the charts // must get the empty series, not `undefined` (which crashes the render). invokeMock.mockResolvedValueOnce(undefined); await store.loadThroughputSeries(); expect(store.throughputSeries).toEqual([]); + expect(store.filesSeries).toEqual([]); + + // A pre-2.3 backend answers with a BARE byte array instead of the + // {bytes, files} object. Neither field is there, so both charts fall back to + // their zero state rather than plotting array indices as data. + invokeMock.mockResolvedValueOnce([1024, 2048]); + await store.loadThroughputSeries(); + expect(store.throughputSeries).toEqual([]); + expect(store.filesSeries).toEqual([]); // A ragged array keeps its LENGTH - the index IS elapsed time, so dropping // entries would shift the whole series through time - with bad cells zeroed. - invokeMock.mockResolvedValueOnce([1024, null, "big", NaN, -5, 2048]); + invokeMock.mockResolvedValueOnce({ + bytes: [1024, null, "big", NaN, -5, 2048], + files: [1, undefined, -2, 4], + }); await store.loadThroughputSeries(); expect(store.throughputSeries).toEqual([1024, 0, 0, 0, 0, 2048]); + expect(store.filesSeries).toEqual([1, 0, 0, 4]); }); it("refreshes with the summary on the same debounced upload burst", async () => { @@ -675,7 +693,9 @@ describe("activity store: throughput sparkline series", () => { const store = useActivityStore(); await store.subscribeLive(); invokeMock.mockImplementation((cmd: string) => - cmd === "activity_throughput_series" ? Promise.resolve([4, 5]) : Promise.resolve(undefined) + cmd === "activity_throughput_series" + ? Promise.resolve({ bytes: [4, 5], files: [1, 2] }) + : Promise.resolve(undefined) ); for (let i = 1; i <= 4; i++) { @@ -687,10 +707,11 @@ describe("activity store: throughput sparkline series", () => { await vi.advanceTimersByTimeAsync(1000); await Promise.resolve(); - // ONE refresh for the whole burst - the chart rides the same debounce as - // the headline number, so they cannot disagree by a debounce interval. + // ONE refresh for the whole burst - the charts ride the same debounce as + // the headline numbers, so they cannot disagree by a debounce interval. expect(seriesCalls()).toBe(1); expect(store.throughputSeries).toEqual([4, 5]); + expect(store.filesSeries).toEqual([1, 2]); } finally { vi.useRealTimers(); } diff --git a/ui/src/__tests__/files-uploaded-stat-tile.test.ts b/ui/src/__tests__/files-uploaded-stat-tile.test.ts new file mode 100644 index 00000000..8962ed5c --- /dev/null +++ b/ui/src/__tests__/files-uploaded-stat-tile.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mount } from "@vue/test-utils"; + +import { i18n } from "../i18n"; +import FilesUploadedStatTile from "../components/FilesUploadedStatTile.vue"; + +// FilesUploadedStatTile tests. The tile is a pure render of its props (files per +// bucket + bucket width + headline count), so every branch is drivable without a +// backend. What is specific to THIS tile - as opposed to its throughput sibling, +// which the shared SparklineStatTile tests cover geometrically - is the unit: a +// count, pluralized and locale-grouped, never divided by the bucket seconds. + +const SVG = '[data-testid="files-uploaded-sparkline"]'; +const TILE = '[data-testid="files-uploaded-tile"]'; +const VALUE = '[data-testid="files-uploaded-value"]'; +const HOVER = '[data-testid="files-uploaded-hover"]'; +const UNKNOWN = '[data-testid="files-uploaded-unknown"]'; + +function mountTile(props: { series: number[]; bucketMs?: number; filesUploaded?: number | null }) { + return mount(FilesUploadedStatTile, { + props: { + bucketMs: 10_000, + filesUploaded: 0, + ...props, + }, + global: { plugins: [i18n] }, + }); +} + +/** jsdom gives every element a zero-size rect, which the pointer handler treats + * as "not laid out yet" and ignores. Give the tile a real 200px width so a + * pointer at clientX maps to a bucket the way it would in a browser. */ +function stubLayout(width = 200): void { + vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + width, + height: 60, + top: 0, + left: 0, + right: width, + bottom: 60, + toJSON: () => ({}), + } as DOMRect); +} + +beforeEach(() => { + stubLayout(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("FilesUploadedStatTile", () => { + it("leads with the file count for the window", () => { + const wrapper = mountTile({ series: [0, 2, 5], filesUploaded: 7 }); + expect(wrapper.find(VALUE).text()).toBe("7 files"); + }); + + it("says so when the count is not known yet", () => { + const wrapper = mountTile({ series: [], filesUploaded: null }); + expect(wrapper.find(UNKNOWN).text()).toBe("No recent uploads"); + expect(wrapper.find(VALUE).exists()).toBe(false); + }); + + it("uses the singular form for exactly one file", () => { + expect( + mountTile({ series: [0, 1], filesUploaded: 1 }) + .find(VALUE) + .text() + ).toBe("1 file"); + // Zero is plural in English ("0 files"), which is what the catalog's two + // forms produce - a bare "0 file" would read as a bug. + expect( + mountTile({ series: [0, 1], filesUploaded: 0 }) + .find(VALUE) + .text() + ).toBe("0 files"); + }); + + it("groups a large count with Intl rather than printing raw digits", () => { + const wrapper = mountTile({ series: [0, 4], filesUploaded: 12_345 }); + // en-US grouping; the assertion is that SOME locale grouping happened, not + // that a hand-rolled formatter inserted commas. + expect(wrapper.find(VALUE).text()).toBe( + `${new Intl.NumberFormat("en-US").format(12_345)} files` + ); + }); + + it("plots the series as an area plus a trend line", () => { + const wrapper = mountTile({ series: [0, 3, 9] }); + const paths = wrapper.findAll(`${SVG} path`); + expect(paths).toHaveLength(2); + // The area is closed back down to the baseline; the line is not. + expect(paths[0].attributes("d")).toMatch(/^M0,100 /); + expect(paths[0].attributes("d")).toMatch(/Z$/); + expect(paths[1].attributes("d")).not.toMatch(/Z$/); + }); + + it("draws nothing when no files were uploaded in the window", () => { + const wrapper = mountTile({ series: [0, 0, 0], filesUploaded: 0 }); + expect(wrapper.find(SVG).exists()).toBe(false); + // The tile itself still renders, with its headline. + expect(wrapper.find(TILE).exists()).toBe(true); + expect(wrapper.find(VALUE).text()).toBe("0 files"); + }); + + it("describes the plot for a screen reader in files, not a rate", () => { + const wrapper = mountTile({ series: [0, 12], bucketMs: 10_000 }); + const label = wrapper.find(SVG).attributes("aria-label"); + expect(label).toContain("Files uploaded over the last"); + // The peak reads as a per-interval COUNT - dividing it by the bucket seconds + // (the throughput tile's job) would print a meaningless "1.2 files/s". + expect(label).toContain("peaking at 12 in a single 10-second interval"); + expect(wrapper.find(SVG).attributes("role")).toBe("img"); + }); + + it("reads a hovered bucket as a plain count, not a per-second rate", async () => { + // 5 buckets of 10s; hovering the far left is the oldest (40s ago). + const wrapper = mountTile({ series: [6, 0, 0, 0, 2], bucketMs: 10_000, filesUploaded: 8 }); + await wrapper.find(TILE).trigger("pointermove", { clientX: 0 }); + + const hover = wrapper.find(HOVER); + expect(hover.exists()).toBe(true); + // 6 files in the bucket - NOT 6/10 = 0.6 files per second. + expect(hover.text()).toContain("6 files"); + expect(hover.text()).toContain("40 seconds ago"); + // The steady-state headline is hidden while a bucket is hovered. + expect(wrapper.find(VALUE).exists()).toBe(false); + + await wrapper.find(TILE).trigger("pointerleave"); + expect(wrapper.find(HOVER).exists()).toBe(false); + expect(wrapper.find(VALUE).text()).toBe("8 files"); + }); +}); diff --git a/ui/src/__tests__/sparkline-stat-tile.test.ts b/ui/src/__tests__/sparkline-stat-tile.test.ts new file mode 100644 index 00000000..89325668 --- /dev/null +++ b/ui/src/__tests__/sparkline-stat-tile.test.ts @@ -0,0 +1,159 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mount } from "@vue/test-utils"; + +import { i18n } from "../i18n"; +import SparklineStatTile from "../components/SparklineStatTile.vue"; + +// SparklineStatTile tests. This is the shared chart both header tiles are built +// from, so it is tested on its OWN contract - geometry, hover mapping, the empty +// state, the testid prefixing - with deliberately unit-free props, rather than +// through either wrapper's wording. A regression here would otherwise only show +// up as a subtly wrong shape in two places at once. + +const TILE = '[data-testid="demo-tile"]'; +const SVG = '[data-testid="demo-sparkline"]'; +const VALUE = '[data-testid="demo-value"]'; +const HOVER = '[data-testid="demo-hover"]'; +const UNKNOWN = '[data-testid="demo-unknown"]'; + +/** The plot's baseline and its peak, in viewBox units (PEAK_HEIGHT = 0.72). */ +const BASELINE_Y = 100; +const PEAK_Y = 28; + +function mountTile(props: { + series: number[]; + bucketMs?: number; + headline?: string | null; + formatBucket?: (v: number) => string; + testidPrefix?: string; +}) { + return mount(SparklineStatTile, { + props: { + label: "Demo", + bucketMs: 10_000, + headline: "42", + emptyLabel: "Nothing yet", + formatBucket: (v: number) => `${v} units`, + chartLabel: "Demo chart", + testidPrefix: "demo", + ...props, + }, + global: { plugins: [i18n] }, + }); +} + +/** jsdom gives every element a zero-size rect, which the pointer handler treats + * as "not laid out yet" and ignores. Give the tile a real 200px width so a + * pointer at clientX maps to a bucket the way it would in a browser. */ +function stubLayout(width = 200): void { + vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + width, + height: 60, + top: 0, + left: 0, + right: width, + bottom: 60, + toJSON: () => ({}), + } as DOMRect); +} + +beforeEach(() => { + stubLayout(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("SparklineStatTile", () => { + it("prints the label and the pre-formatted headline", () => { + const wrapper = mountTile({ series: [0, 1], headline: "42" }); + expect(wrapper.find(TILE).text()).toContain("Demo"); + expect(wrapper.find(VALUE).text()).toBe("42"); + }); + + it("falls back to the empty label when the headline is unknown", () => { + const wrapper = mountTile({ series: [], headline: null }); + expect(wrapper.find(UNKNOWN).text()).toBe("Nothing yet"); + expect(wrapper.find(VALUE).exists()).toBe(false); + }); + + it("scales the peak bucket to the top of the plot and idle buckets to the baseline", () => { + const wrapper = mountTile({ series: [0, 250, 1000] }); + const line = wrapper.findAll(`${SVG} path`)[1].attributes("d"); + // Three buckets across a 100-wide viewBox: x = 0, 50, 100. + // y: 0 -> baseline; 1000 (the peak) -> PEAK_Y; 250 -> a quarter up. + expect(line).toBe(`M0.00,${BASELINE_Y}.00 L50.00,82.00 L100.00,${PEAK_Y}.00`); + }); + + it("keeps a 2px non-scaling stroke so the stretched viewBox cannot fatten it", () => { + const wrapper = mountTile({ series: [0, 1000] }); + const line = wrapper.findAll(`${SVG} path`)[1]; + expect(line.attributes("stroke-width")).toBe("2"); + expect(line.attributes("vector-effect")).toBe("non-scaling-stroke"); + }); + + it("draws nothing when every bucket is idle or the series is too short", () => { + // A flat all-zero series must NOT plot a line pinned to the baseline - that + // reads as "the measure is exactly this low" rather than "nothing happened". + expect( + mountTile({ series: [0, 0, 0] }) + .find(SVG) + .exists() + ).toBe(false); + expect(mountTile({ series: [] }).find(SVG).exists()).toBe(false); + expect( + mountTile({ series: [4096] }) + .find(SVG) + .exists() + ).toBe(false); + }); + + it("exposes the caller's chart description to assistive tech", () => { + const wrapper = mountTile({ series: [0, 5] }); + expect(wrapper.find(SVG).attributes("aria-label")).toBe("Demo chart"); + expect(wrapper.find(SVG).attributes("role")).toBe("img"); + }); + + it("snaps hover to the nearest bucket and formats it with the caller's function", async () => { + const wrapper = mountTile({ series: [10, 0, 30], bucketMs: 10_000 }); + + // 200px wide, 3 buckets -> the right edge is the newest bucket. + await wrapper.find(TILE).trigger("pointermove", { clientX: 200 }); + expect(wrapper.find(HOVER).text()).toContain("30 units"); + expect(wrapper.find(HOVER).text()).toContain("now"); + + // The far left is the oldest of 3 buckets: 2 x 10s ago. + await wrapper.find(TILE).trigger("pointermove", { clientX: 0 }); + expect(wrapper.find(HOVER).text()).toContain("10 units"); + expect(wrapper.find(HOVER).text()).toContain("20 seconds ago"); + }); + + it("restores the headline when the pointer leaves", async () => { + const wrapper = mountTile({ series: [10, 20], headline: "42" }); + await wrapper.find(TILE).trigger("pointermove", { clientX: 10 }); + expect(wrapper.find(HOVER).exists()).toBe(true); + + await wrapper.find(TILE).trigger("pointerleave"); + expect(wrapper.find(HOVER).exists()).toBe(false); + expect(wrapper.find(VALUE).text()).toBe("42"); + }); + + it("ignores hover on an empty plot (there is nothing to point at)", async () => { + const wrapper = mountTile({ series: [0, 0, 0] }); + await wrapper.find(TILE).trigger("pointermove", { clientX: 100 }); + expect(wrapper.find(HOVER).exists()).toBe(false); + expect(wrapper.find(VALUE).exists()).toBe(true); + }); + + it("namespaces every testid by the prefix so two tiles stay addressable", () => { + const wrapper = mountTile({ series: [0, 1], testidPrefix: "other" }); + expect(wrapper.find('[data-testid="other-tile"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="other-sparkline"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="other-value"]').exists()).toBe(true); + expect(wrapper.find(TILE).exists()).toBe(false); + }); +}); diff --git a/ui/src/__tests__/throughput-stat-tile.test.ts b/ui/src/__tests__/throughput-stat-tile.test.ts index af5e086a..a861fa28 100644 --- a/ui/src/__tests__/throughput-stat-tile.test.ts +++ b/ui/src/__tests__/throughput-stat-tile.test.ts @@ -10,10 +10,14 @@ import ThroughputStatTile from "../components/ThroughputStatTile.vue"; // state, the unknown-rate state, and the hover readout - is drivable without a // backend. The geometry assertions pin the y-scale: a sparkline whose peak // silently stops scaling is a chart that lies. +// +// The chart itself now lives in the shared SparklineStatTile, so these mount the +// wrapper and assert THROUGH it - which is the point: they pin the behaviour a +// user sees, and would catch the shared tile being wired up wrong. const SVG = '[data-testid="throughput-sparkline"]'; const TILE = '[data-testid="throughput-tile"]'; -const RATE = '[data-testid="throughput-rate"]'; +const RATE = '[data-testid="throughput-value"]'; const HOVER = '[data-testid="throughput-hover"]'; const UNKNOWN = '[data-testid="throughput-unknown"]'; diff --git a/ui/src/components/FilesUploadedStatTile.vue b/ui/src/components/FilesUploadedStatTile.vue new file mode 100644 index 00000000..5eb1468b --- /dev/null +++ b/ui/src/components/FilesUploadedStatTile.vue @@ -0,0 +1,69 @@ + + + diff --git a/ui/src/components/SparklineStatTile.vue b/ui/src/components/SparklineStatTile.vue new file mode 100644 index 00000000..184d2223 --- /dev/null +++ b/ui/src/components/SparklineStatTile.vue @@ -0,0 +1,230 @@ + + + diff --git a/ui/src/components/ThroughputStatTile.vue b/ui/src/components/ThroughputStatTile.vue index 12cb4b20..5590e3f4 100644 --- a/ui/src/components/ThroughputStatTile.vue +++ b/ui/src/components/ThroughputStatTile.vue @@ -1,35 +1,21 @@ diff --git a/ui/src/ipc/commands.ts b/ui/src/ipc/commands.ts index 18607a50..a1f58318 100644 --- a/ui/src/ipc/commands.ts +++ b/ui/src/ipc/commands.ts @@ -11,6 +11,7 @@ import type { ActivityFilterDto, ActivityPageDto, ActivitySummaryDto, + ActivityThroughputSeriesDto, AddAccountWizardSessionId, AddSourceRequest, AddSourceResult, @@ -346,12 +347,17 @@ export function activitySummary( }); } -/** Recent upload throughput as a DENSE series of `bucketMs`-wide byte sums, - * oldest first, covering `now - windowMs .. now`. Backs the throughput tile's - * sparkline. Shares `activity_summary`'s window semantics (upload rows only), so - * the chart and the headline rate stay two views of one number; the backend - * derives the bucket count from the window, so length and span cannot disagree. */ -export function activityThroughputSeries(windowMs: number, bucketMs: number): Promise { +/** Recent uploads as two DENSE, oldest-first series over the same `bucketMs`-wide + * buckets covering `now - windowMs .. now`: bytes uploaded and files uploaded. + * Backs the throughput tile's and the files tile's sparklines. Shares + * `activity_summary`'s window semantics (upload rows only), so each chart and + * its headline stay two views of one number; the backend derives the bucket + * count from the window, so length and span cannot disagree, and returns both + * series from one query so the two tiles cannot plot different buckets. */ +export function activityThroughputSeries( + windowMs: number, + bucketMs: number +): Promise { return invoke("activity_throughput_series", { windowMs, bucketMs }); } diff --git a/ui/src/ipc/types.ts b/ui/src/ipc/types.ts index 9ea40c1f..6c95000d 100644 --- a/ui/src/ipc/types.ts +++ b/ui/src/ipc/types.ts @@ -444,16 +444,28 @@ export interface FileStatusCountDto { /** The Activity dashboard header aggregates (M7-P2-5; mirrors src-tauri * ActivitySummaryDto / DESIGN s8.3): bytes uploaded today / this week, file - * count by status, and the current throughput window (bytes + window length, so - * the UI derives a bytes/sec rate). */ + * count by status, and the current throughput window (bytes + files + window + * length, so the UI derives a bytes/sec rate and a files headline over the SAME + * window). */ export interface ActivitySummaryDto { bytesToday: number; bytesWeek: number; fileStatusCounts: FileStatusCountDto[]; throughputWindowBytes: number; + throughputWindowFiles: number; throughputWindowMs: number; } +/** The bucketed recent-upload series behind the Activity sparklines (mirrors + * src-tauri ActivityThroughputSeriesDto): two dense, oldest-first arrays over + * the SAME buckets - bytes uploaded and files uploaded. One command returns + * both, so the throughput tile and the files tile always plot the same + * buckets. */ +export interface ActivityThroughputSeriesDto { + bytes: number[]; + files: number[]; +} + // --- Sync (SPEC s11.3) - mirrors src-tauri/src/commands/sync.rs --- /** Mirrors the Rust `OrchestratorState` (driven_core::types). Carried as an diff --git a/ui/src/locales/en-US.json b/ui/src/locales/en-US.json index f8e434e1..422908bf 100644 --- a/ui/src/locales/en-US.json +++ b/ui/src/locales/en-US.json @@ -417,6 +417,9 @@ "perSecond": "{rate}/s", "noThroughput": "No recent uploads", "sparklineLabel": "Upload throughput over the last {minutes} minutes, peaking at {peak} per second", + "filesUploaded": "Files uploaded", + "filesValue": "{count} file | {count} files", + "filesSparklineLabel": "Files uploaded over the last {minutes} minutes, peaking at {peak} in a single {seconds}-second interval", "byStatus": "Files by status", "noFiles": "No files tracked yet" }, diff --git a/ui/src/stores/activity.ts b/ui/src/stores/activity.ts index 8aae118a..548d8fbe 100644 --- a/ui/src/stores/activity.ts +++ b/ui/src/stores/activity.ts @@ -129,6 +129,13 @@ export const useActivityStore = defineStore("activity", () => { // way, so an empty array is always a safe value here. const throughputSeries = ref([]); + // FILES uploaded per bucket over that same window and the same buckets, for + // the files-uploaded tile's sparkline. Loaded by the same call as + // `throughputSeries` (the backend returns both from one query), so the two + // tiles can never plot different windows - and, for the same reason, both are + // cleared together when the query fails. + const filesSeries = ref([]); + // Membership index by row id so dedup is O(1) across both lists. A plain Set // (NOT reactive) so the per-event dedup bookkeeping never triggers a render. const seenIds = new Set(); @@ -556,7 +563,14 @@ export const useActivityStore = defineStore("activity", () => { * so the dropdown offers types from history, not just loaded rows. */ async function loadEventTypeOptions(): Promise { try { - eventTypeOptions.value = await ipc.distinctActivityEventTypes(); + // Same untyped-IPC guard as the series below: an unregistered / skewed + // command RESOLVES with `undefined` rather than rejecting, and assigning + // that here would crash the view's `eventTypeOptions.length` read on the + // next render instead of degrading to "no facets". + const types = await ipc.distinctActivityEventTypes(); + eventTypeOptions.value = Array.isArray(types) + ? types.filter((t): t is string => typeof t === "string") + : []; } catch { // Non-fatal: fall back to an empty option list (the dropdown still shows // "all event types"). A page-load error already surfaces the real failure. @@ -588,17 +602,19 @@ export const useActivityStore = defineStore("activity", () => { } } - /** Load the throughput tile's rolling sparkline series (bytes per bucket, - * oldest first). Kept OUT of `loadSummary` so a failure of one never blanks - * the other; an empty series is the tile's documented zero state, so a failure - * degrades to "no chart" rather than to a wrong chart. */ + /** Load the sparkline series behind BOTH header tiles (bytes per bucket and + * files per bucket, oldest first, over the same buckets). Kept OUT of + * `loadSummary` so a failure of one never blanks the other; an empty series is + * the tiles' documented zero state, so a failure degrades to "no chart" rather + * than to a wrong chart. */ async function loadThroughputSeries(): Promise { try { - throughputSeries.value = toByteSeries( - await ipc.activityThroughputSeries(SPARKLINE_WINDOW_MS, SPARKLINE_BUCKET_MS) - ); + const dto = await ipc.activityThroughputSeries(SPARKLINE_WINDOW_MS, SPARKLINE_BUCKET_MS); + throughputSeries.value = toCountSeries(dto?.bytes); + filesSeries.value = toCountSeries(dto?.files); } catch { throughputSeries.value = []; + filesSeries.value = []; } } @@ -613,9 +629,9 @@ export const useActivityStore = defineStore("activity", () => { summaryRefreshTimer = setTimeout(() => { summaryRefreshTimer = null; void loadSummary(); - // The sparkline rides the same debounce: it is the same window of the same - // rows, so refreshing them together keeps the chart and the headline rate - // from ever disagreeing by a debounce interval. + // Both sparklines ride the same debounce: they are the same window of the + // same rows, so refreshing them together keeps the charts and the headline + // numbers from ever disagreeing by a debounce interval. void loadThroughputSeries(); }, SUMMARY_REFRESH_DEBOUNCE_MS); } @@ -685,6 +701,7 @@ export const useActivityStore = defineStore("activity", () => { eventTypeOptions, summary, throughputSeries, + filesSeries, isEmpty, loadInitial, loadMore, @@ -715,16 +732,18 @@ function sameFilter(a: ActivityFilterDto, b: ActivityFilterDto): boolean { return true; } -/** Normalize an `activity_throughput_series` response into a plotted series. +/** Normalize one array of an `activity_throughput_series` response (its bytes or + * its files) into a plotted series. * * The IPC boundary is untyped at runtime: a version-skewed backend, a command * that is not registered, or a transport hiccup can hand back `undefined` or a * ragged array. Feeding that straight to the chart turns a missing number into a * `NaN` coordinate and a broken path (or, for a non-array, a render crash), so * anything that is not a finite non-negative number is dropped to 0 and a - * non-array becomes the empty series - which is the tile's documented zero - * state. */ -function toByteSeries(raw: unknown): number[] { + * non-array (including the whole response arriving as the pre-2.3 bare array, + * where `.bytes` / `.files` are `undefined`) becomes the empty series - which is + * the tiles' documented zero state. */ +function toCountSeries(raw: unknown): number[] { if (!Array.isArray(raw)) return []; return raw.map((v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0)); } diff --git a/ui/src/views/Activity.vue b/ui/src/views/Activity.vue index 597027ec..5a6d8c09 100644 --- a/ui/src/views/Activity.vue +++ b/ui/src/views/Activity.vue @@ -4,6 +4,7 @@ import { useI18n } from "vue-i18n"; import * as ipc from "../ipc/commands"; import { toErrorCode } from "../ipc/errors"; +import FilesUploadedStatTile from "../components/FilesUploadedStatTile.vue"; import ThroughputStatTile from "../components/ThroughputStatTile.vue"; import { activityEventLabel } from "../stores/activityEventLabel"; import { @@ -38,6 +39,18 @@ const SELECT_INPUT = const SECONDARY_BTN = "inline-flex items-center justify-center gap-2 rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-100 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"; +// Skeleton class for one pulsing placeholder bar. `motion-safe:` so a user who +// asked for reduced motion gets a static grey bar instead of a pulse (the +// `