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"