Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@sidequest/backend": "workspace:*",
"@sidequest/core": "workspace:*",
"@sidequest/engine": "workspace:*",
"@tanstack/react-query": "^5.101.2",
"hono": "^4.12.0"
},
"devDependencies": {
Expand Down
29 changes: 29 additions & 0 deletions packages/web/src/dashboard/client.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<ApiClientProvider value={custom}>{children}</ApiClientProvider>
);
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();
});
});
14 changes: 14 additions & 0 deletions packages/web/src/dashboard/client.ts
Original file line number Diff line number Diff line change
@@ -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<typeof hc<ApiApp>>;

/**
* 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<ApiApp>(baseUrl);
}
12 changes: 12 additions & 0 deletions packages/web/src/dashboard/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createContext, useContext } from "react";
import { type ApiClient, createApiClient } from "./client";

const ApiClientContext = createContext<ApiClient>(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);
}
24 changes: 24 additions & 0 deletions packages/web/src/dashboard/hooks/query-client.ts
Original file line number Diff line number Diff line change
@@ -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 },
},
});
}
15 changes: 15 additions & 0 deletions packages/web/src/dashboard/hooks/to-query.test.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
8 changes: 8 additions & 0 deletions packages/web/src/dashboard/hooks/to-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** Serializes a filter object into string query params, dropping empty/undefined values. */
export function toQuery(filters: Record<string, string | number | undefined>): Record<string, string> {
const query: Record<string, string> = {};
for (const [key, value] of Object.entries(filters)) {
if (value !== undefined && value !== "") query[key] = String(value);
}
return query;
}
63 changes: 63 additions & 0 deletions packages/web/src/dashboard/hooks/use-jobs.test.tsx
Original file line number Diff line number Diff line change
@@ -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" } });
});
});
82 changes: 82 additions & 0 deletions packages/web/src/dashboard/hooks/use-jobs.ts
Original file line number Diff line number Diff line change
@@ -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],
);
}
28 changes: 28 additions & 0 deletions packages/web/src/dashboard/hooks/use-overview.test.tsx
Original file line number Diff line number Diff line change
@@ -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" } });
});
});
32 changes: 32 additions & 0 deletions packages/web/src/dashboard/hooks/use-overview.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
27 changes: 27 additions & 0 deletions packages/web/src/dashboard/hooks/use-queues.test.tsx
Original file line number Diff line number Diff line change
@@ -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" } });
});
});
32 changes: 32 additions & 0 deletions packages/web/src/dashboard/hooks/use-queues.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
Loading