Skip to content

Commit 2d85c99

Browse files
authored
feat(ui): files-uploaded stat card with sparkline and smoother Activity load-in (#157)
## Feature A: files-uploaded stat card with sparkline A new **Files uploaded** tile sits immediately after the throughput tile in the Activity header, in the same Grafana stat-panel style: a headline count with the last 5 minutes of per-bucket file counts drawn behind it. The chart itself was extracted rather than duplicated. `SparklineStatTile.vue` now owns the geometry, the marks, the hover crosshair/readout and the empty state; `ThroughputStatTile.vue` became a thin wrapper that turns bytes into a rate, and `FilesUploadedStatTile.vue` is its sibling that keeps a count a count (a bucket reads as "6 files", never "0.6 files/s"). Counts are pluralized and grouped through `Intl.NumberFormat`, all strings via vue-i18n. Both tiles read one window: the headline is the summary's new `throughputWindowFiles`, the same window `throughputWindowBytes` already covered, and both sparklines come from one query over one bucketisation. ### Wire-shape change (breaking, in-tree only) `activity_throughput_series` now returns `{ bytes: u64[], files: u64[] }` instead of a bare `u64[]`. Both arrays are dense, oldest-first and the same length, so bucket `i` means the same slice of time in each. The frontend is the only caller and is updated in this PR; the store treats a bare-array response (a skewed backend) as "no data" rather than plotting indices. ### Bug fixed along the way `activity_summary` and the series both filtered `event_type = 'upload_done'`, which silently **dropped every `bundle_upload` row** - the V2-bundling rows that pack N small files into one object. A source whose files get bundled therefore under-reported "Uploaded today / this week" and read as idle on the throughput tile. Both queries now match `event_type IN ('upload_done', 'bundle_upload')`, and file counts use `COALESCE(file_count, 1)` so a plain upload counts as one file and a bundle counts as all of its members. Covered by new tests. ## Feature B: smoother Activity load-in Switching to the Activity tab painted empty tiles, an empty filter bar and "Showing 0 of 0", then swapped each one out as its query landed. The view now holds a skeleton of its own shape (tiles, filter bar, table) until the first load settles - in a `finally`, so a failed load falls through to its error state rather than pulsing forever - then fades the real content in with a one-shot CSS animation. `prefers-reduced-motion` disables both the fade and the pulse. A plain keyframe rather than `<Transition>`: the content enters a fragment that was never in the DOM, so there is nothing to transition from, and a keyframe cannot get stuck mid-flight. ## Also `loadEventTypeOptions` now coerces a non-array response to `[]`. An unregistered/skewed command *resolves* `undefined` instead of rejecting, which crashed the view's `eventTypeOptions.length` read on the next render. ## Tests - `cargo test -p driven-core state::` - 83 passed, 0 failed - `vitest run` - 365 passed, 0 failed (38 files) - `cargo clippy --workspace --all-targets` - no warnings - `cargo fmt --all`, `eslint .` (0 errors), `vue-tsc --noEmit` clean - `.sqlx/` offline cache regenerated via `just sqlx-prepare` New: sqlite tests for per-bucket file counts, bundle rows counting all members, and the summary's window file count; mount tests for `SparklineStatTile` and `FilesUploadedStatTile`; an `Activity.vue` test covering the skeleton, the failed-load path and the new tile's position and headline. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent eed80a6 commit 2d85c99

20 files changed

Lines changed: 1444 additions & 451 deletions

.sqlx/query-36b9fd03d07cae393bd05aff90e9e1a082849b4dcb6fac37391d2c94130d0881.json

Lines changed: 0 additions & 28 deletions
This file was deleted.

.sqlx/query-531c3f1a3422d7c59833fc8ca59961d06d7f2863c97936e915226566bf50e02b.json

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.sqlx/query-aef26b053fcf64ed34ac8ebbffff6fb28d1c6334cacd10328157485e988626ae.json renamed to .sqlx/query-c254faa14c27127eb16cf4a2e9748a7fca05224a7b6b421950b85294cc605b41.json

Lines changed: 9 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/driven-core/src/state/mod.rs

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -495,11 +495,36 @@ pub struct ActivitySummary {
495495
/// `activity_log.bytes` at or after `window_start`), used with the window
496496
/// length to render a current bytes/sec rate in the UI.
497497
pub throughput_window_bytes: u64,
498+
/// FILES observed in that same throughput window - the sibling headline of
499+
/// `throughput_window_bytes`, so the dashboard can answer "how many files"
500+
/// and "how many bytes" over one window rather than two.
501+
///
502+
/// A plain upload row is one file (`activity_log.file_count` is NULL there);
503+
/// a `bundle_upload` row carries the number of member files it packed, so it
504+
/// counts as all of them (`COALESCE(file_count, 1)`).
505+
pub throughput_window_files: u64,
498506
/// Length of the throughput window in milliseconds (so the UI computes the
499507
/// rate as `throughput_window_bytes / (throughput_window_ms / 1000)`).
500508
pub throughput_window_ms: u64,
501509
}
502510

511+
/// The bucketed recent-upload series behind the Activity dashboard sparklines
512+
/// (DESIGN s8.3): two parallel, dense, oldest-first vectors over the SAME
513+
/// buckets - bytes uploaded and files uploaded.
514+
///
515+
/// Both are exactly `bucket_count` long and share an index, so bucket `i` of
516+
/// [`Self::bytes`] and bucket `i` of [`Self::files`] describe the same slice of
517+
/// time. They are returned together (one query) precisely so the two tiles can
518+
/// never plot two different windows.
519+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
520+
pub struct ActivityThroughputSeries {
521+
/// Bytes uploaded per bucket, oldest first.
522+
pub bytes: Vec<u64>,
523+
/// Files uploaded per bucket, oldest first (a `bundle_upload` bucket counts
524+
/// every member file it packed, not the single row).
525+
pub files: Vec<u64>,
526+
}
527+
503528
/// M9b (SPEC s16): the anonymous-telemetry 24h aggregate, computed entirely from
504529
/// the durable `activity_log` + `backup_sources` (the `file_state` metadata) over
505530
/// the last 24h.
@@ -1508,8 +1533,8 @@ pub trait StateRepo: Send + Sync {
15081533
/// - `day_start_ms` / `week_start_ms`: inclusive lower bounds for the
15091534
/// today / this-week byte sums.
15101535
/// - `throughput_window_start_ms`: inclusive lower bound for the recent
1511-
/// throughput byte sum; `throughput_window_ms` is its length (carried
1512-
/// straight through so the UI computes bytes/sec).
1536+
/// throughput byte AND file sums; `throughput_window_ms` is its length
1537+
/// (carried straight through so the UI computes bytes/sec).
15131538
///
15141539
/// Default impl returns a zeroed summary; the SQLite repo overrides it with
15151540
/// the real aggregate SQL.
@@ -1529,16 +1554,19 @@ pub trait StateRepo: Send + Sync {
15291554
Ok(ActivitySummary::default())
15301555
}
15311556

1532-
/// The recent upload-throughput SERIES: `bucket_count` consecutive
1533-
/// `bucket_ms`-wide byte sums starting at `window_start_ms`, oldest first
1534-
/// (DESIGN s8.3 header aggregates; backs the Activity dashboard's
1535-
/// last-5-minutes throughput sparkline).
1557+
/// The recent upload SERIES: `bucket_count` consecutive `bucket_ms`-wide
1558+
/// buckets starting at `window_start_ms`, oldest first, each carrying the
1559+
/// bytes AND the files uploaded in it (DESIGN s8.3 header aggregates; backs
1560+
/// the Activity dashboard's last-5-minutes sparklines).
15361561
///
15371562
/// Same source and same row filter as the scalar window in
1538-
/// [`StateRepo::activity_summary`] - `upload_done` rows only - so the
1539-
/// sparkline and the headline rate can never tell different stories. A
1540-
/// bucket with no uploads comes back as `0` rather than being omitted, so
1541-
/// the returned vector is always exactly `bucket_count` long and its index
1563+
/// [`StateRepo::activity_summary`] - upload rows only (`upload_done` plus
1564+
/// the V2 bundling `bundle_upload`) - so a sparkline and its headline can
1565+
/// never tell different stories. Bytes and files come from ONE query over
1566+
/// one bucketisation, so the two tiles cannot drift apart either.
1567+
///
1568+
/// A bucket with no uploads comes back as `0` rather than being omitted, so
1569+
/// each returned vector is always exactly `bucket_count` long and its index
15421570
/// IS elapsed time.
15431571
///
15441572
/// Default impl returns an empty series; the SQLite repo overrides it with
@@ -1548,9 +1576,9 @@ pub trait StateRepo: Send + Sync {
15481576
window_start_ms: UnixMs,
15491577
bucket_ms: u64,
15501578
bucket_count: u32,
1551-
) -> Result<Vec<u64>> {
1579+
) -> Result<ActivityThroughputSeries> {
15521580
let _ = (window_start_ms, bucket_ms, bucket_count);
1553-
Ok(Vec::new())
1581+
Ok(ActivityThroughputSeries::default())
15541582
}
15551583

15561584
/// M9b (SPEC s16): the anonymous-telemetry aggregate, computed from the durable

0 commit comments

Comments
 (0)