Skip to content

Commit 9771d53

Browse files
pmaxhoganclaude
andcommitted
feat(ui): activity dashboard - live tail + paginated history + filters (M7)
Frontend half of the M7 Activity dashboard (ROADMAP M7, DESIGN s8.3 / s8.7). - Activity.vue (replaces the M6 placeholder): a live tail (subscribes to activity:new, prepends new entries, dedups by id), a paginated history via query_activity with pages ACCUMULATED client-side (scroll back through 1000+ events without re-querying earlier pages), filter controls (source / minimum level / event type) that re-query, an empty state, a load-more affordance, and the "Export diagnostic bundle" button (reusing the M6 backend-owned save-dialog flow). Live tail is event-driven (within 500ms), not polled. Every user-facing string flows through vue-i18n t(); new keys added to en-US.json. - Activity Pinia store (stores/activity.ts): state = entries + filter + cursor + loading/error; actions = loadInitial / loadMore (append, no re-query) / applyFilter (re-query from page 0) / onLiveEvent (prepend + dedup, filter-aware) / subscribeLive / unsubscribeLive. - Typed IPC wrappers: queryActivity + clearActivityOlderThan (commands.ts), typed onActivityNew listener (events.ts), and the ActivityEntry / ActivityFilterDto / PageRequestDto / ActivityPageDto TS types mirroring the Rust DTOs (types.ts). - vitest (activity-store.test.ts, 11 tests): pagination appends without re-querying earlier pages, a live event prepends + dedups, filters re-query from page 0, empty-state renders; mocks invoke + the event listener. Gates: pnpm install (lockfile unchanged), pnpm lint / test:unit (54 passed) / build (vue-tsc --noEmit clean) - all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CyiRqk2DVwmJjEu5gcD1m
1 parent e5faa26 commit 9771d53

7 files changed

Lines changed: 898 additions & 15 deletions

File tree

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { createPinia, setActivePinia } from "pinia";
3+
4+
// Activity store tests (SPEC s11.4; DESIGN s8.3). The seams are
5+
// `@tauri-apps/api/core`'s `invoke` (every typed IPC wrapper routes through it)
6+
// and `@tauri-apps/api/event`'s `listen` (the live-tail subscription). Mocking
7+
// both lets us drive the store against a fake backend + manually fire live
8+
// events, asserting: pagination appends without re-querying earlier pages, a
9+
// live event prepends + dedups, filters re-query from page 0, and the empty
10+
// state renders.
11+
12+
const invokeMock = vi.fn();
13+
vi.mock("@tauri-apps/api/core", () => ({
14+
invoke: (cmd: string, args?: unknown) => invokeMock(cmd, args),
15+
}));
16+
17+
// The live-tail seam: capture the handler `onActivityNew` registers so the test
18+
// can fire `activity:new` events on demand. `listen` returns an unlisten fn.
19+
let liveHandler: ((payload: unknown) => void) | null = null;
20+
const unlistenMock = vi.fn();
21+
vi.mock("@tauri-apps/api/event", () => ({
22+
listen: vi.fn((event: string, cb: (e: { payload: unknown }) => void) => {
23+
if (event === "activity:new") {
24+
liveHandler = (payload: unknown) => cb({ payload });
25+
}
26+
return Promise.resolve(unlistenMock);
27+
}),
28+
}));
29+
30+
import { useActivityStore, ACTIVITY_PAGE_SIZE } from "../stores/activity";
31+
import type { ActivityEntry } from "../ipc/types";
32+
33+
function makeEntry(over: Partial<ActivityEntry> = {}): ActivityEntry {
34+
return {
35+
id: 1,
36+
ts: 1000,
37+
sourceId: null,
38+
level: "info",
39+
eventType: "upload_done",
40+
fileCount: null,
41+
bytes: null,
42+
message: null,
43+
...over,
44+
};
45+
}
46+
47+
/** Build a query_activity page DTO for `entries`, with paging metadata. */
48+
function makePage(
49+
entries: ActivityEntry[],
50+
page: number,
51+
total: number,
52+
): {
53+
entries: ActivityEntry[];
54+
total: number;
55+
page: number;
56+
limit: number;
57+
hasMore: boolean;
58+
} {
59+
const consumed = (page + 1) * ACTIVITY_PAGE_SIZE;
60+
return {
61+
entries,
62+
total,
63+
page,
64+
limit: ACTIVITY_PAGE_SIZE,
65+
hasMore: total > consumed,
66+
};
67+
}
68+
69+
beforeEach(() => {
70+
setActivePinia(createPinia());
71+
invokeMock.mockReset();
72+
unlistenMock.mockReset();
73+
liveHandler = null;
74+
});
75+
76+
describe("activity store: pagination", () => {
77+
it("loadInitial fetches page 0 and records paging metadata", async () => {
78+
const rows = [makeEntry({ id: 3 }), makeEntry({ id: 2 }), makeEntry({ id: 1 })];
79+
invokeMock.mockResolvedValueOnce(makePage(rows, 0, 250));
80+
const store = useActivityStore();
81+
await store.loadInitial();
82+
expect(invokeMock).toHaveBeenCalledWith("query_activity", {
83+
filter: {},
84+
page: { page: 0, limit: ACTIVITY_PAGE_SIZE },
85+
});
86+
expect(store.entries).toHaveLength(3);
87+
expect(store.entries[0].id).toBe(3);
88+
expect(store.total).toBe(250);
89+
expect(store.hasMore).toBe(true);
90+
expect(store.loadedPage).toBe(0);
91+
});
92+
93+
it("loadMore appends the next page WITHOUT re-querying earlier pages", async () => {
94+
const page0 = [makeEntry({ id: 200 }), makeEntry({ id: 199 })];
95+
const page1 = [makeEntry({ id: 100 }), makeEntry({ id: 99 })];
96+
invokeMock.mockResolvedValueOnce(makePage(page0, 0, 250));
97+
const store = useActivityStore();
98+
await store.loadInitial();
99+
expect(invokeMock).toHaveBeenCalledTimes(1);
100+
101+
invokeMock.mockResolvedValueOnce(makePage(page1, 1, 250));
102+
await store.loadMore();
103+
104+
// Exactly ONE additional fetch (page 1), never a re-fetch of page 0.
105+
expect(invokeMock).toHaveBeenCalledTimes(2);
106+
expect(invokeMock).toHaveBeenNthCalledWith(2, "query_activity", {
107+
filter: {},
108+
page: { page: 1, limit: ACTIVITY_PAGE_SIZE },
109+
});
110+
// Pages accumulated client-side, in order.
111+
expect(store.entries.map((e) => e.id)).toEqual([200, 199, 100, 99]);
112+
expect(store.loadedPage).toBe(1);
113+
});
114+
115+
it("loadMore is a no-op when no more pages remain", async () => {
116+
invokeMock.mockResolvedValueOnce(makePage([makeEntry({ id: 1 })], 0, 1));
117+
const store = useActivityStore();
118+
await store.loadInitial();
119+
expect(store.hasMore).toBe(false);
120+
await store.loadMore();
121+
// No second fetch fired.
122+
expect(invokeMock).toHaveBeenCalledTimes(1);
123+
});
124+
125+
it("loadMore dedups a row already present from the live tail", async () => {
126+
const page0 = [makeEntry({ id: 200 })];
127+
invokeMock.mockResolvedValueOnce(makePage(page0, 0, 250));
128+
const store = useActivityStore();
129+
await store.subscribeLive();
130+
await store.loadInitial();
131+
132+
// A live event arrives for id 150 before it is paged in.
133+
liveHandler?.(makeEntry({ id: 150, ts: 900 }));
134+
expect(store.entries.map((e) => e.id)).toEqual([150, 200]);
135+
136+
// Page 1 includes id 150 again - it must NOT be duplicated.
137+
invokeMock.mockResolvedValueOnce(
138+
makePage([makeEntry({ id: 150, ts: 900 }), makeEntry({ id: 149 })], 1, 250),
139+
);
140+
await store.loadMore();
141+
const ids = store.entries.map((e) => e.id);
142+
expect(ids.filter((i) => i === 150)).toHaveLength(1);
143+
expect(ids).toEqual([150, 200, 149]);
144+
});
145+
});
146+
147+
describe("activity store: live tail", () => {
148+
it("prepends a live event newest-first and bumps total", async () => {
149+
invokeMock.mockResolvedValueOnce(makePage([makeEntry({ id: 1 })], 0, 1));
150+
const store = useActivityStore();
151+
await store.subscribeLive();
152+
await store.loadInitial();
153+
expect(store.total).toBe(1);
154+
155+
liveHandler?.(makeEntry({ id: 2, ts: 2000 }));
156+
expect(store.entries[0].id).toBe(2);
157+
expect(store.entries.map((e) => e.id)).toEqual([2, 1]);
158+
expect(store.total).toBe(2);
159+
});
160+
161+
it("dedups a live event whose id is already present", async () => {
162+
invokeMock.mockResolvedValueOnce(makePage([makeEntry({ id: 5 })], 0, 1));
163+
const store = useActivityStore();
164+
await store.subscribeLive();
165+
await store.loadInitial();
166+
// Same id fired live: ignored.
167+
liveHandler?.(makeEntry({ id: 5 }));
168+
expect(store.entries).toHaveLength(1);
169+
expect(store.total).toBe(1);
170+
});
171+
172+
it("drops a live event that does not match the active filter", async () => {
173+
invokeMock.mockResolvedValueOnce(makePage([], 0, 0));
174+
const store = useActivityStore();
175+
await store.subscribeLive();
176+
await store.applyFilter({ minLevel: "error" });
177+
// An info-level live event must be dropped under a min-level=error filter.
178+
liveHandler?.(makeEntry({ id: 9, level: "info" }));
179+
expect(store.entries).toHaveLength(0);
180+
// A matching error-level event is kept.
181+
liveHandler?.(makeEntry({ id: 10, level: "error" }));
182+
expect(store.entries.map((e) => e.id)).toEqual([10]);
183+
});
184+
185+
it("unsubscribeLive calls the unlisten fn", async () => {
186+
const store = useActivityStore();
187+
await store.subscribeLive();
188+
store.unsubscribeLive();
189+
expect(unlistenMock).toHaveBeenCalledTimes(1);
190+
});
191+
});
192+
193+
describe("activity store: filters", () => {
194+
it("applyFilter re-queries from page 0 with the new filter and resets state", async () => {
195+
invokeMock.mockResolvedValueOnce(
196+
makePage([makeEntry({ id: 1 }), makeEntry({ id: 2 })], 0, 2),
197+
);
198+
const store = useActivityStore();
199+
await store.loadInitial();
200+
expect(store.entries).toHaveLength(2);
201+
202+
invokeMock.mockResolvedValueOnce(
203+
makePage([makeEntry({ id: 3, level: "error" })], 0, 1),
204+
);
205+
await store.applyFilter({ minLevel: "error", sourceId: "src-1" });
206+
207+
expect(invokeMock).toHaveBeenNthCalledWith(2, "query_activity", {
208+
filter: { minLevel: "error", sourceId: "src-1" },
209+
page: { page: 0, limit: ACTIVITY_PAGE_SIZE },
210+
});
211+
// Old rows cleared; only the re-queried page remains.
212+
expect(store.entries.map((e) => e.id)).toEqual([3]);
213+
expect(store.loadedPage).toBe(0);
214+
});
215+
});
216+
217+
describe("activity store: empty state", () => {
218+
it("isEmpty is true after loading an empty page", async () => {
219+
invokeMock.mockResolvedValueOnce(makePage([], 0, 0));
220+
const store = useActivityStore();
221+
await store.loadInitial();
222+
expect(store.entries).toHaveLength(0);
223+
expect(store.isEmpty).toBe(true);
224+
expect(store.error).toBeNull();
225+
});
226+
227+
it("isEmpty is false when an error occurred", async () => {
228+
invokeMock.mockRejectedValueOnce(new Error("db locked"));
229+
const store = useActivityStore();
230+
await store.loadInitial();
231+
expect(store.error).toContain("db locked");
232+
expect(store.isEmpty).toBe(false);
233+
});
234+
});

ui/src/ipc/commands.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { invoke } from "@tauri-apps/api/core";
88

99
import type {
1010
AccountDto,
11+
ActivityFilterDto,
12+
ActivityPageDto,
1113
AddAccountWizardSessionId,
1214
AddSourceRequest,
1315
AddSourceResult,
@@ -17,6 +19,7 @@ import type {
1719
GlobalSyncStatus,
1820
OAuthAuthUrl,
1921
OAuthStatus,
22+
PageRequestDto,
2023
PickedPath,
2124
ReauthSession,
2225
ReleaseDto,
@@ -167,3 +170,21 @@ export function checkForUpdates(): Promise<UpdateInfo | null> {
167170
export function listReleases(page: number): Promise<ReleaseDto[]> {
168171
return invoke("list_releases", { page });
169172
}
173+
174+
// --- Activity (SPEC s11.4) ---
175+
176+
/** Query a paginated, filtered page of the activity log (SPEC s11.4). The
177+
* frontend accumulates pages client-side for the history view; the live tail is
178+
* event-driven via `onActivityNew` (SPEC s11.7), not polled here. */
179+
export function queryActivity(
180+
filter: ActivityFilterDto,
181+
page: PageRequestDto,
182+
): Promise<ActivityPageDto> {
183+
return invoke("query_activity", { filter, page });
184+
}
185+
186+
/** Prune activity-log rows older than `beforeTs` (Unix ms); returns the count
187+
* deleted (SPEC s11.4). */
188+
export function clearActivityOlderThan(beforeTs: number): Promise<number> {
189+
return invoke("clear_activity_older_than", { beforeTs });
190+
}

ui/src/ipc/events.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
88

9-
import type { GlobalSyncStatus, UpdateInfo } from "./types";
9+
import type { ActivityEntry, GlobalSyncStatus, UpdateInfo } from "./types";
1010

1111
/** `sync:status_changed` payload: GlobalSyncStatus (SPEC s11.7). */
1212
export function onSyncStatusChanged(
@@ -31,11 +31,12 @@ export function onSyncSourceProgress(
3131
);
3232
}
3333

34-
/** `activity:new` payload: ActivityEntry (typed in M7) (SPEC s11.7). */
34+
/** `activity:new` payload: ActivityEntry (SPEC s11.7). The Activity dashboard's
35+
* live tail subscribes to this and prepends new entries (deduped by id). */
3536
export function onActivityNew(
36-
handler: (entry: unknown) => void,
37+
handler: (entry: ActivityEntry) => void,
3738
): Promise<UnlistenFn> {
38-
return listen<unknown>("activity:new", (e) => handler(e.payload));
39+
return listen<ActivityEntry>("activity:new", (e) => handler(e.payload));
3940
}
4041

4142
/** `account:needs_reauth` payload: { account_id, email } (SPEC s11.7). */

ui/src/ipc/types.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,51 @@ export interface ReleaseDto {
236236
url: string;
237237
}
238238

239+
// --- Activity (SPEC s11.4) - mirrors driven-core ActivityEntry + the
240+
// src-tauri activity DTOs ---
241+
242+
/** `activity_log.level` serialized form (mirrors driven_core ActivityLevel). */
243+
export type ActivityLevel = "info" | "warn" | "error";
244+
245+
/** One activity-log entry: the per-row element of an ActivityPage AND the
246+
* `activity:new` event payload (mirrors driven_core::types::ActivityEntry). */
247+
export interface ActivityEntry {
248+
id: number;
249+
ts: number;
250+
sourceId: string | null;
251+
level: ActivityLevel;
252+
eventType: string;
253+
fileCount: number | null;
254+
bytes: number | null;
255+
message: string | null;
256+
}
257+
258+
/** Filter body for `query_activity` (mirrors src-tauri ActivityFilterDto). All
259+
* fields optional; present fields combine with AND. */
260+
export interface ActivityFilterDto {
261+
sourceId?: string | null;
262+
sinceMs?: number | null;
263+
beforeMs?: number | null;
264+
minLevel?: ActivityLevel | null;
265+
eventTypes?: string[];
266+
}
267+
268+
/** Page selector for `query_activity` (mirrors src-tauri PageRequestDto). */
269+
export interface PageRequestDto {
270+
page: number;
271+
limit: number;
272+
}
273+
274+
/** One page of activity returned by `query_activity` (mirrors src-tauri
275+
* ActivityPageDto): newest-first entries + paging metadata. */
276+
export interface ActivityPageDto {
277+
entries: ActivityEntry[];
278+
total: number;
279+
page: number;
280+
limit: number;
281+
hasMore: boolean;
282+
}
283+
239284
// --- Sync (SPEC s11.3) - mirrors src-tauri/src/commands/sync.rs ---
240285

241286
/** Mirrors the Rust `OrchestratorState` (driven_core::types). Carried as an

ui/src/locales/en-US.json

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,39 @@
195195
},
196196
"activity": {
197197
"title": "Activity",
198-
"placeholder": "The activity dashboard arrives in a later release."
198+
"subtitle": "Live feed and history of every backup event.",
199+
"empty": "No activity yet. Events appear here as Driven backs up your files.",
200+
"loadMore": "Load older events",
201+
"loadingMore": "Loading more...",
202+
"allLoaded": "You have reached the start of the activity log.",
203+
"countSummary": "Showing {shown} of {total} events",
204+
"filters": {
205+
"title": "Filters",
206+
"source": "Source",
207+
"allSources": "All sources",
208+
"level": "Minimum level",
209+
"allLevels": "All levels",
210+
"eventType": "Event type",
211+
"allEventTypes": "All event types",
212+
"clear": "Clear filters"
213+
},
214+
"level": {
215+
"info": "Info",
216+
"warn": "Warning",
217+
"error": "Error"
218+
},
219+
"column": {
220+
"time": "Time",
221+
"level": "Level",
222+
"event": "Event",
223+
"source": "Source",
224+
"details": "Details"
225+
},
226+
"noSource": "Global",
227+
"files": "{count} files",
228+
"exportBundleButton": "Export diagnostic bundle",
229+
"exporting": "Exporting...",
230+
"exportedTo": "Saved diagnostic bundle to {path}"
199231
},
200232
"restore": {
201233
"title": "Restore",

0 commit comments

Comments
 (0)