diff --git a/eslint.config.js b/eslint.config.js
index 60aac0f..37b925a 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -50,8 +50,8 @@ export default tseslint.config(
},
},
{
- // React UI library lives in its own tsconfig (JSX, DOM libs, bundler resolution).
- files: ["packages/web/src/ui/**/*.{ts,tsx}"],
+ // React UI + dashboard app live in their own tsconfig (JSX, DOM libs, bundler resolution).
+ files: ["packages/web/src/ui/**/*.{ts,tsx}", "packages/web/src/dashboard/**/*.{ts,tsx}"],
plugins: { "react-hooks": reactHooks, "jsx-a11y": jsxA11y },
languageOptions: {
globals: { ...globals.browser },
diff --git a/packages/web/package.json b/packages/web/package.json
index f0ef115..c88658e 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -51,6 +51,7 @@
"@sidequest/backend": "workspace:*",
"@sidequest/core": "workspace:*",
"@sidequest/engine": "workspace:*",
+ "@tanstack/react-query": "^5.101.2",
"hono": "^4.12.0"
},
"devDependencies": {
diff --git a/packages/web/src/dashboard/client.test.tsx b/packages/web/src/dashboard/client.test.tsx
new file mode 100644
index 0000000..2f4703b
--- /dev/null
+++ b/packages/web/src/dashboard/client.test.tsx
@@ -0,0 +1,29 @@
+import { renderHook } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { createApiClient } from "./client";
+import { ApiClientProvider, useApiClient } from "./context";
+
+describe("createApiClient", () => {
+ it("exposes the api resource accessors", () => {
+ const client = createApiClient();
+ expect(client.jobs).toBeDefined();
+ expect(client.queues).toBeDefined();
+ expect(client.overview).toBeDefined();
+ });
+});
+
+describe("useApiClient", () => {
+ it("returns the client from the provider", () => {
+ const custom = createApiClient("/base");
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+ const { result } = renderHook(() => useApiClient(), { wrapper });
+ expect(result.current).toBe(custom);
+ });
+
+ it("defaults to a same-origin client without a provider", () => {
+ const { result } = renderHook(() => useApiClient());
+ expect(result.current.jobs).toBeDefined();
+ });
+});
diff --git a/packages/web/src/dashboard/client.ts b/packages/web/src/dashboard/client.ts
new file mode 100644
index 0000000..3c532b8
--- /dev/null
+++ b/packages/web/src/dashboard/client.ts
@@ -0,0 +1,14 @@
+import { hc } from "hono/client";
+import type { ApiApp } from "../api/app";
+
+/** The typed client for the Sidequest management API. */
+export type ApiClient = ReturnType>;
+
+/**
+ * Creates a typed client for the management API. Requests are relative to `baseUrl`
+ * (default: same-origin root), so the same client hits the OSS server or a Pro superset
+ * serving the same paths without any change.
+ */
+export function createApiClient(baseUrl = "/"): ApiClient {
+ return hc(baseUrl);
+}
diff --git a/packages/web/src/dashboard/context.ts b/packages/web/src/dashboard/context.ts
new file mode 100644
index 0000000..bbde80a
--- /dev/null
+++ b/packages/web/src/dashboard/context.ts
@@ -0,0 +1,12 @@
+import { createContext, useContext } from "react";
+import { type ApiClient, createApiClient } from "./client";
+
+const ApiClientContext = createContext(createApiClient());
+
+/** Provides the {@link ApiClient} to the dashboard hooks. The Pro passes its superset client. */
+export const ApiClientProvider = ApiClientContext.Provider;
+
+/** The {@link ApiClient} from context, defaulting to a same-origin client. */
+export function useApiClient(): ApiClient {
+ return useContext(ApiClientContext);
+}
diff --git a/packages/web/src/dashboard/hooks/query-client.ts b/packages/web/src/dashboard/hooks/query-client.ts
new file mode 100644
index 0000000..6305063
--- /dev/null
+++ b/packages/web/src/dashboard/hooks/query-client.ts
@@ -0,0 +1,24 @@
+import { QueryClient } from "@tanstack/react-query";
+
+/**
+ * The narrow options a dashboard query hook forwards to TanStack Query. Kept small on purpose
+ * (the hooks own their query keys and fetchers); callers only steer polling and enablement.
+ */
+export interface QueryHookOptions {
+ /** If set, refetch on this interval (ms). Replaces the old hand-rolled poll. */
+ refetchInterval?: number;
+ /** Set false to hold the query (e.g. an id not chosen yet). Defaults to enabled. */
+ enabled?: boolean;
+}
+
+/**
+ * The {@link QueryClient} for the dashboard, with defaults tuned for a live ops view:
+ * short stale time (data is always moving), no refetch-on-focus (the poll already covers it).
+ */
+export function createQueryClient(): QueryClient {
+ return new QueryClient({
+ defaultOptions: {
+ queries: { staleTime: 5_000, refetchOnWindowFocus: false },
+ },
+ });
+}
diff --git a/packages/web/src/dashboard/hooks/to-query.test.ts b/packages/web/src/dashboard/hooks/to-query.test.ts
new file mode 100644
index 0000000..9974a6a
--- /dev/null
+++ b/packages/web/src/dashboard/hooks/to-query.test.ts
@@ -0,0 +1,15 @@
+import { toQuery } from "./to-query";
+
+describe("toQuery", () => {
+ it("keeps present values as strings", () => {
+ expect(toQuery({ state: "failed", page: 2 })).toEqual({ state: "failed", page: "2" });
+ });
+
+ it("drops undefined and empty values", () => {
+ expect(toQuery({ state: undefined, queue: "", class: "Email" })).toEqual({ class: "Email" });
+ });
+
+ it("returns an empty object for an empty filter", () => {
+ expect(toQuery({})).toEqual({});
+ });
+});
diff --git a/packages/web/src/dashboard/hooks/to-query.ts b/packages/web/src/dashboard/hooks/to-query.ts
new file mode 100644
index 0000000..1f6fc5e
--- /dev/null
+++ b/packages/web/src/dashboard/hooks/to-query.ts
@@ -0,0 +1,8 @@
+/** Serializes a filter object into string query params, dropping empty/undefined values. */
+export function toQuery(filters: Record): Record {
+ const query: Record = {};
+ for (const [key, value] of Object.entries(filters)) {
+ if (value !== undefined && value !== "") query[key] = String(value);
+ }
+ return query;
+}
diff --git a/packages/web/src/dashboard/hooks/use-jobs.test.tsx b/packages/web/src/dashboard/hooks/use-jobs.test.tsx
new file mode 100644
index 0000000..9616d8f
--- /dev/null
+++ b/packages/web/src/dashboard/hooks/use-jobs.test.tsx
@@ -0,0 +1,63 @@
+import { waitFor } from "@testing-library/react";
+import type { ApiClient } from "../client";
+import { jsonResponse, renderHookWithClient } from "../testing/harness";
+import { useJob, useJobActions, useJobs, useJobsMeta } from "./use-jobs";
+
+describe("useJobs", () => {
+ it("fetches the list, sending the filter as query params", async () => {
+ const $get = vi.fn().mockResolvedValue(
+ jsonResponse({ jobs: [{ id: 1 }], pagination: { page: 2, pageSize: 30, hasNextPage: false } }),
+ );
+ const client = { jobs: { $get } } as unknown as ApiClient;
+
+ const { result } = renderHookWithClient(() => useJobs({ state: "failed", page: 2 }), client);
+
+ await waitFor(() => expect(result.current.data).toBeDefined());
+ expect($get).toHaveBeenCalledWith({ query: { state: "failed", page: "2" } });
+ });
+});
+
+describe("useJob", () => {
+ it("fetches a single job by id", async () => {
+ const $get = vi.fn().mockResolvedValue(jsonResponse({ id: 7 }));
+ const client = { jobs: { ":id": { $get } } } as unknown as ApiClient;
+
+ const { result } = renderHookWithClient(() => useJob(7), client);
+
+ await waitFor(() => expect(result.current.data).toEqual({ id: 7 }));
+ expect($get).toHaveBeenCalledWith({ param: { id: "7" } });
+ });
+});
+
+describe("useJobsMeta", () => {
+ it("fetches the distinct queue names", async () => {
+ const $get = vi.fn().mockResolvedValue(jsonResponse({ queues: ["email"] }));
+ const client = { jobs: { meta: { $get } } } as unknown as ApiClient;
+
+ const { result } = renderHookWithClient(() => useJobsMeta(), client);
+
+ await waitFor(() => expect(result.current.data).toEqual({ queues: ["email"] }));
+ });
+});
+
+describe("useJobActions", () => {
+ it("posts run/cancel/rerun for a job id", async () => {
+ const post = () => vi.fn().mockResolvedValue(jsonResponse({ id: 1 }));
+ const run = post();
+ const cancel = post();
+ const rerun = post();
+ const client = {
+ jobs: { ":id": { run: { $post: run }, cancel: { $post: cancel }, rerun: { $post: rerun } } },
+ } as unknown as ApiClient;
+
+ const { result } = renderHookWithClient(() => useJobActions(), client);
+
+ await result.current.run(1);
+ await result.current.cancel(1);
+ await result.current.rerun(1);
+
+ expect(run).toHaveBeenCalledWith({ param: { id: "1" } });
+ expect(cancel).toHaveBeenCalledWith({ param: { id: "1" } });
+ expect(rerun).toHaveBeenCalledWith({ param: { id: "1" } });
+ });
+});
diff --git a/packages/web/src/dashboard/hooks/use-jobs.ts b/packages/web/src/dashboard/hooks/use-jobs.ts
new file mode 100644
index 0000000..c0db6c7
--- /dev/null
+++ b/packages/web/src/dashboard/hooks/use-jobs.ts
@@ -0,0 +1,82 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useMemo } from "react";
+import { useApiClient } from "../context";
+import type { QueryHookOptions } from "./query-client";
+import { toQuery } from "./to-query";
+
+/** The jobs list query, mirroring the API's query string. */
+export interface JobsFilter {
+ state?: string;
+ queue?: string;
+ class?: string;
+ time?: string;
+ start?: string;
+ end?: string;
+ page?: number;
+ pageSize?: number;
+}
+
+/** Queries the jobs list (with pagination) for the given filter. */
+export function useJobs(filters: JobsFilter = {}, options?: QueryHookOptions) {
+ const client = useApiClient();
+ return useQuery({
+ queryKey: ["jobs", "list", filters],
+ queryFn: async () => {
+ const res = await client.jobs.$get({ query: toQuery(filters) });
+ return res.json();
+ },
+ refetchInterval: options?.refetchInterval,
+ enabled: options?.enabled,
+ });
+}
+
+/** Queries a single job by id. */
+export function useJob(id: number, options?: QueryHookOptions) {
+ const client = useApiClient();
+ return useQuery({
+ queryKey: ["jobs", "detail", id],
+ queryFn: async () => {
+ const res = await client.jobs[":id"].$get({ param: { id: String(id) } });
+ return res.json();
+ },
+ refetchInterval: options?.refetchInterval,
+ enabled: options?.enabled,
+ });
+}
+
+/** Queries the distinct queue names that appear on jobs (for the filter dropdown). */
+export function useJobsMeta(options?: QueryHookOptions) {
+ const client = useApiClient();
+ return useQuery({
+ queryKey: ["jobs", "meta"],
+ queryFn: async () => {
+ const res = await client.jobs.meta.$get();
+ return res.json();
+ },
+ refetchInterval: options?.refetchInterval,
+ enabled: options?.enabled,
+ });
+}
+
+/**
+ * Job mutations (run/cancel/rerun). Each posts and, on success, invalidates the jobs queries so
+ * any mounted list/detail refetches on its own. The call shape stays `{ run, cancel, rerun }` so
+ * callers do not change; the manual refetch they used to do is now redundant.
+ */
+export function useJobActions() {
+ const client = useApiClient();
+ const queryClient = useQueryClient();
+ const { mutateAsync } = useMutation({
+ mutationFn: ({ id, action }: { id: number; action: "run" | "cancel" | "rerun" }) =>
+ client.jobs[":id"][action].$post({ param: { id: String(id) } }).then((res) => res.json()),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["jobs"] }),
+ });
+ return useMemo(
+ () => ({
+ run: (id: number) => mutateAsync({ id, action: "run" }),
+ cancel: (id: number) => mutateAsync({ id, action: "cancel" }),
+ rerun: (id: number) => mutateAsync({ id, action: "rerun" }),
+ }),
+ [mutateAsync],
+ );
+}
diff --git a/packages/web/src/dashboard/hooks/use-overview.test.tsx b/packages/web/src/dashboard/hooks/use-overview.test.tsx
new file mode 100644
index 0000000..4f0a5f4
--- /dev/null
+++ b/packages/web/src/dashboard/hooks/use-overview.test.tsx
@@ -0,0 +1,28 @@
+import { waitFor } from "@testing-library/react";
+import type { ApiClient } from "../client";
+import { jsonResponse, renderHookWithClient } from "../testing/harness";
+import { useOverview, useOverviewTimeseries } from "./use-overview";
+
+describe("useOverview", () => {
+ it("fetches counts, sending the range as a query param", async () => {
+ const $get = vi.fn().mockResolvedValue(jsonResponse({ total: 9 }));
+ const client = { overview: { $get } } as unknown as ApiClient;
+
+ const { result } = renderHookWithClient(() => useOverview("12h"), client);
+
+ await waitFor(() => expect(result.current.data).toEqual({ total: 9 }));
+ expect($get).toHaveBeenCalledWith({ query: { range: "12h" } });
+ });
+});
+
+describe("useOverviewTimeseries", () => {
+ it("fetches the series, sending the range as a query param", async () => {
+ const $get = vi.fn().mockResolvedValue(jsonResponse([{ timestamp: "t", total: 1 }]));
+ const client = { overview: { timeseries: { $get } } } as unknown as ApiClient;
+
+ const { result } = renderHookWithClient(() => useOverviewTimeseries("12h"), client);
+
+ await waitFor(() => expect(result.current.data).toEqual([{ timestamp: "t", total: 1 }]));
+ expect($get).toHaveBeenCalledWith({ query: { range: "12h" } });
+ });
+});
diff --git a/packages/web/src/dashboard/hooks/use-overview.ts b/packages/web/src/dashboard/hooks/use-overview.ts
new file mode 100644
index 0000000..8c2d568
--- /dev/null
+++ b/packages/web/src/dashboard/hooks/use-overview.ts
@@ -0,0 +1,32 @@
+import { useQuery } from "@tanstack/react-query";
+import { useApiClient } from "../context";
+import type { QueryHookOptions } from "./query-client";
+import { toQuery } from "./to-query";
+
+/** Queries the overview counts for a range (`12m` | `12h` | `12d`). */
+export function useOverview(range?: string, options?: QueryHookOptions) {
+ const client = useApiClient();
+ return useQuery({
+ queryKey: ["overview", "counts", range],
+ queryFn: async () => {
+ const res = await client.overview.$get({ query: toQuery({ range }) });
+ return res.json();
+ },
+ refetchInterval: options?.refetchInterval,
+ enabled: options?.enabled,
+ });
+}
+
+/** Queries the overview time series for a range, for the chart. */
+export function useOverviewTimeseries(range?: string, options?: QueryHookOptions) {
+ const client = useApiClient();
+ return useQuery({
+ queryKey: ["overview", "timeseries", range],
+ queryFn: async () => {
+ const res = await client.overview.timeseries.$get({ query: toQuery({ range }) });
+ return res.json();
+ },
+ refetchInterval: options?.refetchInterval,
+ enabled: options?.enabled,
+ });
+}
diff --git a/packages/web/src/dashboard/hooks/use-queues.test.tsx b/packages/web/src/dashboard/hooks/use-queues.test.tsx
new file mode 100644
index 0000000..91eab21
--- /dev/null
+++ b/packages/web/src/dashboard/hooks/use-queues.test.tsx
@@ -0,0 +1,27 @@
+import { waitFor } from "@testing-library/react";
+import type { ApiClient } from "../client";
+import { jsonResponse, renderHookWithClient } from "../testing/harness";
+import { useQueueActions, useQueues } from "./use-queues";
+
+describe("useQueues", () => {
+ it("fetches the queues list", async () => {
+ const $get = vi.fn().mockResolvedValue(jsonResponse([{ name: "email", jobs: { total: 2 } }]));
+ const client = { queues: { $get } } as unknown as ApiClient;
+
+ const { result } = renderHookWithClient(() => useQueues(), client);
+
+ await waitFor(() => expect(result.current.data).toEqual([{ name: "email", jobs: { total: 2 } }]));
+ });
+});
+
+describe("useQueueActions", () => {
+ it("posts toggle for a queue name", async () => {
+ const $post = vi.fn().mockResolvedValue(jsonResponse({ name: "email", state: "paused" }));
+ const client = { queues: { ":name": { toggle: { $post } } } } as unknown as ApiClient;
+
+ const { result } = renderHookWithClient(() => useQueueActions(), client);
+
+ await result.current.toggle("email");
+ expect($post).toHaveBeenCalledWith({ param: { name: "email" } });
+ });
+});
diff --git a/packages/web/src/dashboard/hooks/use-queues.ts b/packages/web/src/dashboard/hooks/use-queues.ts
new file mode 100644
index 0000000..aab1be3
--- /dev/null
+++ b/packages/web/src/dashboard/hooks/use-queues.ts
@@ -0,0 +1,32 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useMemo } from "react";
+import { useApiClient } from "../context";
+import type { QueryHookOptions } from "./query-client";
+
+/** Queries the queues list, each annotated with its job counts. */
+export function useQueues(options?: QueryHookOptions) {
+ const client = useApiClient();
+ return useQuery({
+ queryKey: ["queues"],
+ queryFn: async () => {
+ const res = await client.queues.$get();
+ return res.json();
+ },
+ refetchInterval: options?.refetchInterval,
+ enabled: options?.enabled,
+ });
+}
+
+/**
+ * Queue mutations. Posts a toggle and, on success, invalidates the queues query so the list
+ * refetches on its own. The call shape stays `{ toggle }` so callers do not change.
+ */
+export function useQueueActions() {
+ const client = useApiClient();
+ const queryClient = useQueryClient();
+ const { mutateAsync } = useMutation({
+ mutationFn: (name: string) => client.queues[":name"].toggle.$post({ param: { name } }).then((res) => res.json()),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["queues"] }),
+ });
+ return useMemo(() => ({ toggle: (name: string) => mutateAsync(name) }), [mutateAsync]);
+}
diff --git a/packages/web/src/dashboard/index.ts b/packages/web/src/dashboard/index.ts
new file mode 100644
index 0000000..6aef72b
--- /dev/null
+++ b/packages/web/src/dashboard/index.ts
@@ -0,0 +1,7 @@
+export * from "./client";
+export * from "./context";
+export * from "./hooks/query-client";
+export * from "./hooks/to-query";
+export * from "./hooks/use-jobs";
+export * from "./hooks/use-overview";
+export * from "./hooks/use-queues";
diff --git a/packages/web/src/dashboard/testing/harness.tsx b/packages/web/src/dashboard/testing/harness.tsx
new file mode 100644
index 0000000..a1859ea
--- /dev/null
+++ b/packages/web/src/dashboard/testing/harness.tsx
@@ -0,0 +1,26 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderHook } from "@testing-library/react";
+import type { ReactNode } from "react";
+import type { ApiClient } from "../client";
+import { ApiClientProvider } from "../context";
+
+/** A stand-in for a `fetch` Response whose `.json()` resolves to `body`. */
+export function jsonResponse(body: T) {
+ return { json: () => Promise.resolve(body) };
+}
+
+/**
+ * Renders a hook with a fake API client and a fresh QueryClient supplied through context.
+ * Retries are off so a rejected query/mutation surfaces its error at once instead of backing off.
+ */
+export function renderHookWithClient(hook: () => R, client: ApiClient) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+ return renderHook(hook, { wrapper });
+}
diff --git a/packages/web/tsconfig.ui.json b/packages/web/tsconfig.ui.json
index 66977f6..d33a0e0 100644
--- a/packages/web/tsconfig.ui.json
+++ b/packages/web/tsconfig.ui.json
@@ -9,5 +9,5 @@
"noEmit": true,
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
- "include": ["src/ui/**/*.ts", "src/ui/**/*.tsx"]
+ "include": ["src/ui/**/*.ts", "src/ui/**/*.tsx", "src/dashboard/**/*.ts", "src/dashboard/**/*.tsx"]
}
diff --git a/packages/web/vitest.config.js b/packages/web/vitest.config.js
index 57600d1..04ef5a9 100644
--- a/packages/web/vitest.config.js
+++ b/packages/web/vitest.config.js
@@ -11,7 +11,7 @@ export default {
test: {
...base.test,
environment: "jsdom",
- include: ["src/ui/**/*.test.tsx"],
+ include: ["src/ui/**/*.test.tsx", "src/dashboard/**/*.test.tsx"],
coverage: {
...base.test.coverage,
include: ["src/ui/**/*.tsx"],
diff --git a/vitest.config.js b/vitest.config.js
index 8adcbad..5db86e1 100644
--- a/vitest.config.js
+++ b/vitest.config.js
@@ -24,7 +24,7 @@ export default defineConfig({
name: "ui",
globals: true,
environment: "jsdom",
- include: ["packages/web/src/ui/**/*.test.tsx"],
+ include: ["packages/web/src/ui/**/*.test.tsx", "packages/web/src/dashboard/**/*.test.tsx"],
setupFiles: ["./packages/web/vitest.setup.ts"],
},
},
diff --git a/yarn.lock b/yarn.lock
index 9b4dc59..3861360 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4042,6 +4042,7 @@ __metadata:
"@sidequest/core": "workspace:*"
"@sidequest/engine": "workspace:*"
"@storybook/react-vite": "npm:^10.4.6"
+ "@tanstack/react-query": "npm:^5.101.2"
"@testing-library/dom": "npm:^10.4.1"
"@testing-library/jest-dom": "npm:^6.9.1"
"@testing-library/react": "npm:^16.3.2"
@@ -4469,6 +4470,24 @@ __metadata:
languageName: node
linkType: hard
+"@tanstack/query-core@npm:5.101.2":
+ version: 5.101.2
+ resolution: "@tanstack/query-core@npm:5.101.2"
+ checksum: 10c0/c3288757bfd563ca35cee21bf6ed0edb2f4425a8c92f75d84dcb36a0580a59e48bd97fcdbbc7c8d8e95a4c40376edfc5772b14665b85d183079a0ba7e17793b2
+ languageName: node
+ linkType: hard
+
+"@tanstack/react-query@npm:^5.101.2":
+ version: 5.101.2
+ resolution: "@tanstack/react-query@npm:5.101.2"
+ dependencies:
+ "@tanstack/query-core": "npm:5.101.2"
+ peerDependencies:
+ react: ^18 || ^19
+ checksum: 10c0/076b2db0d85a6dc96d461789715ae0df2e439d9a20a41e629446275f8177a856c3b89d242ad3d31eadaf421f080b9834bcd9f94e5143f4cd7f947a10af9e42b3
+ languageName: node
+ linkType: hard
+
"@testing-library/dom@npm:^10.4.1":
version: 10.4.1
resolution: "@testing-library/dom@npm:10.4.1"