diff --git a/packages/web/package.json b/packages/web/package.json index f793393..f0ef115 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -29,19 +29,30 @@ "types": "./dist/ui/index.d.ts", "import": "./dist/ui/index.js" }, - "./ui/styles.css": "./dist/ui/styles.css" + "./ui/styles.css": "./dist/ui/styles.css", + "./api": { + "types": "./dist/api/index.d.ts", + "import": "./dist/api/index.js", + "require": "./dist/api/index.cjs" + } }, "files": [ "dist" ], "scripts": { - "build": "npx vite build --config vite.lib.config.ts && npx @tailwindcss/cli -i ./src/ui/styles.css -o ./dist/ui/styles.css --minify", - "dev": "npx vite build --watch --config vite.lib.config.ts", + "build": "npx rollup -c && npx vite build --config vite.lib.config.ts && npx @tailwindcss/cli -i ./src/ui/styles.css -o ./dist/ui/styles.css --minify", + "dev": "npx rollup -c -w", "test": "yarn vitest run", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, "license": "LGPL-3.0-or-later", + "dependencies": { + "@sidequest/backend": "workspace:*", + "@sidequest/core": "workspace:*", + "@sidequest/engine": "workspace:*", + "hono": "^4.12.0" + }, "devDependencies": { "@storybook/react-vite": "^10.4.6", "@testing-library/dom": "^10.4.1", diff --git a/packages/web/rollup.config.js b/packages/web/rollup.config.js new file mode 100644 index 0000000..79443d7 --- /dev/null +++ b/packages/web/rollup.config.js @@ -0,0 +1,6 @@ +import createConfig from "../../rollup.config.base.js"; +import pkg from "./package.json" with { type: "json" }; + +// Node build for the management API (`@sidequest/web/api`). The React UI library keeps +// its own Vite build (see vite.lib.config.ts); both write into dist/ without clobbering. +export default createConfig(pkg, "src/api/index.ts"); diff --git a/packages/web/src/api/app.test.ts b/packages/web/src/api/app.test.ts new file mode 100644 index 0000000..84b7736 --- /dev/null +++ b/packages/web/src/api/app.test.ts @@ -0,0 +1,96 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import type { JobData } from "@sidequest/core"; +import { createApiApp } from "./app"; +import { mockBackend, zeroCounts } from "./testing/mock-backend"; + +vi.mock("@sidequest/engine", () => ({ + JobTransitioner: { apply: vi.fn((_backend, job: JobData) => Promise.resolve({ ...job, state: "waiting" })) }, +})); + +describe("createApiApp", () => { + beforeEach(() => vi.clearAllMocks()); + + describe("GET /jobs", () => { + it("returns the list and pagination metadata", async () => { + const backend = mockBackend({ + listJobs: vi + .fn() + .mockResolvedValueOnce([{ id: 1 }, { id: 2 }]) // the page + .mockResolvedValueOnce([{ id: 3 }]), // next-page probe + }); + const res = await createApiApp({ backend }).request("/jobs?pageSize=2"); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + jobs: [{ id: 1 }, { id: 2 }], + pagination: { page: 1, pageSize: 2, hasNextPage: true }, + }); + }); + + it("reports hasNextPage false when the probe is empty", async () => { + const backend = mockBackend({ + listJobs: vi.fn().mockResolvedValueOnce([{ id: 1 }]).mockResolvedValueOnce([]), + }); + const res = await createApiApp({ backend }).request("/jobs"); + const body = (await res.json()) as { pagination: { hasNextPage: boolean } }; + expect(body.pagination.hasNextPage).toBe(false); + }); + }); + + it("GET /jobs/meta returns distinct queue names", async () => { + const backend = mockBackend({ getQueuesFromJobs: vi.fn().mockResolvedValue(["email"]) }); + const res = await createApiApp({ backend }).request("/jobs/meta"); + expect(await res.json()).toEqual({ queues: ["email"] }); + }); + + it("GET /jobs/:id returns the job, or 404 when missing", async () => { + const found = mockBackend({ getJob: vi.fn().mockResolvedValue({ id: 7 }) }); + expect(await (await createApiApp({ backend: found }).request("/jobs/7")).json()).toEqual({ id: 7 }); + + const missing = mockBackend({ getJob: vi.fn().mockResolvedValue(undefined) }); + const res = await createApiApp({ backend: missing }).request("/jobs/7"); + expect(res.status).toBe(404); + }); + + it("POST /jobs/:id/cancel applies the transition", async () => { + const backend = mockBackend({ getJob: vi.fn().mockResolvedValue({ id: 1, state: "waiting" }) }); + const res = await createApiApp({ backend }).request("/jobs/1/cancel", { method: "POST" }); + expect(res.status).toBe(200); + }); + + it("maps a not-found mutation to a 404 JSON error", async () => { + const backend = mockBackend({ getJob: vi.fn().mockResolvedValue(undefined) }); + const res = await createApiApp({ backend }).request("/jobs/1/cancel", { method: "POST" }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "Job 1 not found" }); + }); + + it("GET /queues returns queues with counts", async () => { + const backend = mockBackend({ + listQueues: vi.fn().mockResolvedValue([{ name: "email", state: "active" }]), + countJobsByQueues: vi.fn().mockResolvedValue({ email: { total: 2 } }), + }); + const res = await createApiApp({ backend }).request("/queues"); + expect(await res.json()).toEqual([{ name: "email", state: "active", jobs: { total: 2 } }]); + }); + + it("POST /queues/:name/toggle flips the state", async () => { + const backend = mockBackend({ getQueue: vi.fn().mockResolvedValue({ name: "email", state: "active" }) }); + const res = await createApiApp({ backend }).request("/queues/email/toggle", { method: "POST" }); + expect(await res.json()).toMatchObject({ state: "paused" }); + }); + + it("GET /overview returns counts", async () => { + const backend = mockBackend({ countJobs: vi.fn().mockResolvedValue({ ...zeroCounts(), total: 9 }) }); + const res = await createApiApp({ backend }).request("/overview?range=12h"); + const body = (await res.json()) as { total: number }; + expect(body.total).toBe(9); + }); + + it("GET /overview/timeseries proxies the backend", async () => { + const backend = mockBackend({ countJobsOverTime: vi.fn().mockResolvedValue([{ timestamp: "t", total: 1 }]) }); + const res = await createApiApp({ backend }).request("/overview/timeseries?range=12h"); + expect(await res.json()).toEqual([{ timestamp: "t", total: 1 }]); + expect(backend.countJobsOverTime).toHaveBeenCalledWith("12h"); + }); +}); diff --git a/packages/web/src/api/app.ts b/packages/web/src/api/app.ts new file mode 100644 index 0000000..cb3009e --- /dev/null +++ b/packages/web/src/api/app.ts @@ -0,0 +1,38 @@ +import type { Backend } from "@sidequest/backend"; +import { type ErrorHandler, Hono } from "hono"; +import { NotFoundError } from "./errors"; +import { jobsRoutes } from "./routes/jobs"; +import { overviewRoutes } from "./routes/overview"; +import { queuesRoutes } from "./routes/queues"; +import { JobService } from "./services/job-service"; +import { OverviewService } from "./services/overview-service"; +import { QueueService } from "./services/queue-service"; + +/** Maps API errors to JSON responses: {@link NotFoundError} to 404, anything else to 500. */ +export const apiErrorHandler: ErrorHandler = (err, c) => { + if (err instanceof NotFoundError) return c.json({ error: err.message }, 404); + return c.json({ error: "Internal server error" }, 500); +}; + +/** Dependencies the API needs. Just the backend for now. */ +export interface ApiDeps { + backend: Backend; +} + +/** + * Builds the OSS management API tree, mounted at `/api` by the server. Paths are relative + * so the same front-end works against the OSS server and the Pro superset. + * + * The Pro composes its own tree by reusing the exported route factories with superset + * services, adding routes, or overriding a service. It never forks this function. + */ +export function createApiApp(deps: ApiDeps) { + return new Hono() + .route("/jobs", jobsRoutes(new JobService(deps.backend))) + .route("/queues", queuesRoutes(new QueueService(deps.backend))) + .route("/overview", overviewRoutes(new OverviewService(deps.backend))) + .onError(apiErrorHandler); +} + +/** The assembled API type, for the Hono RPC client: `hc(...)`. */ +export type ApiApp = ReturnType; diff --git a/packages/web/src/api/errors.ts b/packages/web/src/api/errors.ts new file mode 100644 index 0000000..165ac46 --- /dev/null +++ b/packages/web/src/api/errors.ts @@ -0,0 +1,21 @@ +/** + * Base class for API errors that should surface as an HTTP 404. + * The Hono error handler maps any subclass to a 404 JSON response. + */ +export class NotFoundError extends Error {} + +/** Thrown when a job id does not resolve to a job. */ +export class JobNotFoundError extends NotFoundError { + constructor(id: number) { + super(`Job ${id} not found`); + this.name = "JobNotFoundError"; + } +} + +/** Thrown when a queue name does not resolve to a queue. */ +export class QueueNotFoundError extends NotFoundError { + constructor(name: string) { + super(`Queue '${name}' not found`); + this.name = "QueueNotFoundError"; + } +} diff --git a/packages/web/src/api/filters.test.ts b/packages/web/src/api/filters.test.ts new file mode 100644 index 0000000..eb54f72 --- /dev/null +++ b/packages/web/src/api/filters.test.ts @@ -0,0 +1,26 @@ +import { computeTimeRange } from "./filters"; + +describe("computeTimeRange", () => { + it("returns undefined for no range or 'any'", () => { + expect(computeTimeRange()).toBeUndefined(); + expect(computeTimeRange("any")).toBeUndefined(); + expect(computeTimeRange("unknown")).toBeUndefined(); + }); + + it("maps a relative token to a 'from' in the past", () => { + const range = computeTimeRange("5m"); + expect(range?.from).toBeInstanceOf(Date); + expect(range?.from!.getTime()).toBeLessThan(Date.now()); + expect(range?.to).toBeUndefined(); + }); + + it("maps 'custom' + valid start/end to an absolute range", () => { + const range = computeTimeRange("custom", "2026-01-01T00:00:00Z", "2026-01-02T00:00:00Z"); + expect(range?.from?.toISOString()).toBe("2026-01-01T00:00:00.000Z"); + expect(range?.to?.toISOString()).toBe("2026-01-02T00:00:00.000Z"); + }); + + it("ignores 'custom' with invalid dates", () => { + expect(computeTimeRange("custom", "nope", "nope")).toBeUndefined(); + }); +}); diff --git a/packages/web/src/api/filters.ts b/packages/web/src/api/filters.ts new file mode 100644 index 0000000..37562ca --- /dev/null +++ b/packages/web/src/api/filters.ts @@ -0,0 +1,66 @@ +import type { Context } from "hono"; +import type { JobListFilters } from "./services/job-service"; + +const MINUTES_BY_RANGE: Record = { + "5m": 5, + "15m": 15, + "30m": 30, + "1h": 60, + "4h": 240, + "12h": 720, + "24h": 1440, + "2d": 2880, + "7d": 10080, + "30d": 43200, +}; + +/** + * Turns a relative range token (`5m`..`30d`), `custom` + `start`/`end`, or nothing into an + * absolute time range. Returns `undefined` for "any"/unknown so no time filter is applied. + */ +export function computeTimeRange(time?: string, start?: string, end?: string): { from?: Date; to?: Date } | undefined { + if (!time || time === "any") return undefined; + + const minutes = MINUTES_BY_RANGE[time]; + if (minutes) return { from: new Date(Date.now() - minutes * 60_000) }; + + if (time === "custom" && start && end) { + const from = new Date(start); + const to = new Date(end); + if (!isNaN(from.getTime()) && !isNaN(to.getTime())) return { from, to }; + } + + return undefined; +} + +/** The parsed `/jobs` query: service filters plus pagination. */ +export interface ParsedJobQuery { + filters: JobListFilters; + page: number; + pageSize: number; +} + +/** Parses the `/jobs` query string into service filters + pagination. */ +export function parseJobQuery(c: Context): ParsedJobQuery { + const q = c.req.query(); + const trimmed = (v?: string) => { + const t = v?.trim(); + return t === "" ? undefined : t; + }; + + const pageSize = q.pageSize ? parseInt(q.pageSize, 10) : 30; + const page = q.page ? Math.max(parseInt(q.page, 10), 1) : 1; + const jobClass = trimmed(q.class); + + return { + page, + pageSize, + filters: { + queue: trimmed(q.queue), + // substring match, mirroring the old dashboard filter + jobClass: jobClass ? `%${jobClass}%` : undefined, + state: trimmed(q.state) as JobListFilters["state"], + timeRange: computeTimeRange(trimmed(q.time), q.start, q.end), + }, + }; +} diff --git a/packages/web/src/api/index.ts b/packages/web/src/api/index.ts new file mode 100644 index 0000000..19b377a --- /dev/null +++ b/packages/web/src/api/index.ts @@ -0,0 +1,9 @@ +export * from "./app"; +export * from "./errors"; +export * from "./filters"; +export * from "./routes/jobs"; +export * from "./routes/overview"; +export * from "./routes/queues"; +export * from "./services/job-service"; +export * from "./services/overview-service"; +export * from "./services/queue-service"; diff --git a/packages/web/src/api/routes/jobs.ts b/packages/web/src/api/routes/jobs.ts new file mode 100644 index 0000000..cb7efe2 --- /dev/null +++ b/packages/web/src/api/routes/jobs.ts @@ -0,0 +1,30 @@ +import { Hono } from "hono"; +import { parseJobQuery } from "../filters"; +import type { JobService } from "../services/job-service"; + +/** + * Routes for jobs, mounted under `/api/jobs`. Built by chaining so the Hono RPC client + * (`hc`) can infer the types. The Pro reuses this factory with a superset service. + */ +export const jobsRoutes = (jobs: JobService) => + new Hono() + .get("/", async (c) => { + const { filters, page, pageSize } = parseJobQuery(c); + const offset = (page - 1) * pageSize; + // A one-row probe at the next offset tells us whether another page exists. + const [list, next] = await Promise.all([ + jobs.list({ ...filters, limit: pageSize, offset }), + jobs.list({ ...filters, limit: 1, offset: page * pageSize }), + ]); + return c.json({ jobs: list, pagination: { page, pageSize, hasNextPage: next.length > 0 } }); + }) + // Registered before "/:id" so it is not captured as an id. + .get("/meta", async (c) => c.json({ queues: await jobs.queueNames() })) + .get("/:id", async (c) => { + const job = await jobs.get(Number(c.req.param("id"))); + if (!job) return c.json({ error: "Job not found" }, 404); + return c.json(job); + }) + .post("/:id/run", async (c) => c.json(await jobs.run(Number(c.req.param("id"))))) + .post("/:id/cancel", async (c) => c.json(await jobs.cancel(Number(c.req.param("id"))))) + .post("/:id/rerun", async (c) => c.json(await jobs.rerun(Number(c.req.param("id"))))); diff --git a/packages/web/src/api/routes/overview.ts b/packages/web/src/api/routes/overview.ts new file mode 100644 index 0000000..4801b33 --- /dev/null +++ b/packages/web/src/api/routes/overview.ts @@ -0,0 +1,8 @@ +import { Hono } from "hono"; +import type { OverviewService } from "../services/overview-service"; + +/** Routes for the overview home, mounted under `/api/overview`. */ +export const overviewRoutes = (overview: OverviewService) => + new Hono() + .get("/", async (c) => c.json(await overview.counts(c.req.query("range")))) + .get("/timeseries", async (c) => c.json(await overview.timeseries(c.req.query("range")))); diff --git a/packages/web/src/api/routes/queues.ts b/packages/web/src/api/routes/queues.ts new file mode 100644 index 0000000..c22c02b --- /dev/null +++ b/packages/web/src/api/routes/queues.ts @@ -0,0 +1,8 @@ +import { Hono } from "hono"; +import type { QueueService } from "../services/queue-service"; + +/** Routes for queues, mounted under `/api/queues`. */ +export const queuesRoutes = (queues: QueueService) => + new Hono() + .get("/", async (c) => c.json(await queues.list())) + .post("/:name/toggle", async (c) => c.json(await queues.toggle(c.req.param("name")))); diff --git a/packages/web/src/api/services/job-service.test.ts b/packages/web/src/api/services/job-service.test.ts new file mode 100644 index 0000000..d210911 --- /dev/null +++ b/packages/web/src/api/services/job-service.test.ts @@ -0,0 +1,63 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import type { JobData } from "@sidequest/core"; +import { CancelTransition, RerunTransition, SnoozeTransition } from "@sidequest/core"; +import { JobTransitioner } from "@sidequest/engine"; +import { JobNotFoundError } from "../errors"; +import { mockBackend } from "../testing/mock-backend"; +import { JobService } from "./job-service"; + +vi.mock("@sidequest/engine", () => ({ + JobTransitioner: { apply: vi.fn((_backend, job: JobData) => Promise.resolve(job)) }, +})); + +const job = (over: Partial = {}) => ({ id: 1, state: "waiting", ...over }) as JobData; + +describe("JobService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lists jobs through the backend", async () => { + const backend = mockBackend({ listJobs: vi.fn().mockResolvedValue([job()]) }); + await expect(new JobService(backend).list({ queue: "email" })).resolves.toEqual([job()]); + expect(backend.listJobs).toHaveBeenCalledWith({ queue: "email" }); + }); + + it("gets a job by id", async () => { + const backend = mockBackend({ getJob: vi.fn().mockResolvedValue(job()) }); + await expect(new JobService(backend).get(1)).resolves.toEqual(job()); + expect(backend.getJob).toHaveBeenCalledWith(1); + }); + + it("returns distinct queue names", async () => { + const backend = mockBackend({ getQueuesFromJobs: vi.fn().mockResolvedValue(["email", "default"]) }); + await expect(new JobService(backend).queueNames()).resolves.toEqual(["email", "default"]); + }); + + it("cancels via a CancelTransition", async () => { + const backend = mockBackend({ getJob: vi.fn().mockResolvedValue(job()) }); + await new JobService(backend).cancel(1); + expect(JobTransitioner.apply).toHaveBeenCalledWith(backend, job(), expect.any(CancelTransition)); + }); + + it("reruns via a RerunTransition", async () => { + const backend = mockBackend({ getJob: vi.fn().mockResolvedValue(job()) }); + await new JobService(backend).rerun(1); + expect(JobTransitioner.apply).toHaveBeenCalledWith(backend, job(), expect.any(RerunTransition)); + }); + + it("run() snoozes a non-canceled job to zero", async () => { + const backend = mockBackend({ getJob: vi.fn().mockResolvedValue(job({ state: "failed" })) }); + await new JobService(backend).run(1); + expect(JobTransitioner.apply).toHaveBeenCalledWith(backend, expect.anything(), expect.any(SnoozeTransition)); + }); + + it("run() reruns a canceled job", async () => { + const backend = mockBackend({ getJob: vi.fn().mockResolvedValue(job({ state: "canceled" })) }); + await new JobService(backend).run(1); + expect(JobTransitioner.apply).toHaveBeenCalledWith(backend, expect.anything(), expect.any(RerunTransition)); + }); + + it("throws JobNotFoundError when the job is missing", async () => { + const backend = mockBackend({ getJob: vi.fn().mockResolvedValue(undefined) }); + await expect(new JobService(backend).cancel(99)).rejects.toBeInstanceOf(JobNotFoundError); + }); +}); diff --git a/packages/web/src/api/services/job-service.ts b/packages/web/src/api/services/job-service.ts new file mode 100644 index 0000000..9a44d43 --- /dev/null +++ b/packages/web/src/api/services/job-service.ts @@ -0,0 +1,72 @@ +import type { Backend } from "@sidequest/backend"; +import { CancelTransition, type JobData, type JobState, type JobTransition, RerunTransition, SnoozeTransition } from "@sidequest/core"; +import { JobTransitioner } from "@sidequest/engine"; +import { JobNotFoundError } from "../errors"; + +/** Filters accepted by {@link JobService.list}, passed straight through to the backend. */ +export interface JobListFilters { + queue?: string | string[]; + jobClass?: string | string[]; + state?: JobState | `${string}%${string}` | JobState[]; + limit?: number; + offset?: number; + timeRange?: { from?: Date; to?: Date }; +} + +/** + * Read/mutation operations over jobs, framework-neutral (no HTTP). + * + * This is the override seam for the Pro: subclass it, override a method (calling `super` + * to reuse the OSS query), and mount the same route factory with the subclass. + */ +export class JobService { + constructor(protected readonly backend: Backend) {} + + /** Lists jobs matching the given filters. */ + list(filters: JobListFilters = {}): Promise { + return this.backend.listJobs(filters); + } + + /** Gets a single job by id, or `undefined` if it does not exist. */ + get(id: number): Promise { + return this.backend.getJob(id); + } + + /** Distinct queue names that appear on jobs (used by the list filter dropdown). */ + queueNames(): Promise { + return this.backend.getQueuesFromJobs(); + } + + /** Cancels a job. Throws {@link JobNotFoundError} if it does not exist. */ + cancel(id: number): Promise { + return this.applyTransition(id, new CancelTransition()); + } + + /** Re-runs a job from scratch. Throws {@link JobNotFoundError} if it does not exist. */ + rerun(id: number): Promise { + return this.applyTransition(id, new RerunTransition()); + } + + /** + * Makes a job runnable now. A canceled job is reset via a rerun; anything else is snoozed + * to zero (available immediately). Mirrors the old dashboard "run" action. + */ + async run(id: number): Promise { + const job = await this.require(id); + const transition = job.state === "canceled" ? new RerunTransition() : new SnoozeTransition(0); + return JobTransitioner.apply(this.backend, job, transition); + } + + /** Loads a job and applies a transition, or throws {@link JobNotFoundError}. */ + protected async applyTransition(id: number, transition: JobTransition): Promise { + const job = await this.require(id); + return JobTransitioner.apply(this.backend, job, transition); + } + + /** Loads a job or throws {@link JobNotFoundError}. */ + protected async require(id: number): Promise { + const job = await this.backend.getJob(id); + if (!job) throw new JobNotFoundError(id); + return job; + } +} diff --git a/packages/web/src/api/services/overview-service.test.ts b/packages/web/src/api/services/overview-service.test.ts new file mode 100644 index 0000000..227ba6e --- /dev/null +++ b/packages/web/src/api/services/overview-service.test.ts @@ -0,0 +1,38 @@ +/* eslint-disable @typescript-eslint/unbound-method, @typescript-eslint/no-unsafe-assignment */ +import { mockBackend, zeroCounts } from "../testing/mock-backend"; +import { OverviewService, rangeToMs } from "./overview-service"; + +describe("rangeToMs", () => { + it("defaults to 12 minutes and maps known ranges", () => { + expect(rangeToMs()).toBe(12 * 60 * 1000); + expect(rangeToMs("12m")).toBe(12 * 60 * 1000); + expect(rangeToMs("12h")).toBe(12 * 60 * 60 * 1000); + expect(rangeToMs("12d")).toBe(12 * 24 * 60 * 60 * 1000); + expect(rangeToMs("bogus")).toBe(12 * 60 * 1000); + }); +}); + +describe("OverviewService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("uses windowed counts but all-time waiting/running", async () => { + const backend = mockBackend({ + countJobs: vi + .fn() + .mockResolvedValueOnce({ ...zeroCounts(), total: 5, completed: 4, waiting: 1, running: 1 }) // windowed + .mockResolvedValueOnce({ ...zeroCounts(), total: 50, completed: 40, waiting: 10, running: 3 }), // all-time + }); + + const stats = await new OverviewService(backend).counts("12h"); + + expect(stats).toMatchObject({ total: 5, completed: 4, waiting: 10, running: 3 }); + expect(backend.countJobs).toHaveBeenNthCalledWith(1, { from: expect.any(Date) }); + expect(backend.countJobs).toHaveBeenNthCalledWith(2); + }); + + it("proxies the time series with a default range", async () => { + const backend = mockBackend({ countJobsOverTime: vi.fn().mockResolvedValue([{ timestamp: new Date() }]) }); + await new OverviewService(backend).timeseries(); + expect(backend.countJobsOverTime).toHaveBeenCalledWith("12m"); + }); +}); diff --git a/packages/web/src/api/services/overview-service.ts b/packages/web/src/api/services/overview-service.ts new file mode 100644 index 0000000..4e3896e --- /dev/null +++ b/packages/web/src/api/services/overview-service.ts @@ -0,0 +1,32 @@ +import type { Backend, JobCounts } from "@sidequest/backend"; + +const MS_BY_RANGE: Record = { + "12m": 12 * 60 * 1000, + "12h": 12 * 60 * 60 * 1000, + "12d": 12 * 24 * 60 * 60 * 1000, +}; + +/** Overview range token (`12m` | `12h` | `12d`) to milliseconds. Defaults to 12 minutes. */ +export function rangeToMs(range?: string): number { + return MS_BY_RANGE[range ?? "12m"] ?? MS_BY_RANGE["12m"]; +} + +/** Overview stats + time series for the dashboard home, framework-neutral (no HTTP). */ +export class OverviewService { + constructor(protected readonly backend: Backend) {} + + /** + * Counts for the requested window, except `waiting`/`running`, which are reported + * all-time: they are current-state gauges, not windowed events. Mirrors the old dashboard. + */ + async counts(range?: string): Promise { + const from = new Date(Date.now() - rangeToMs(range)); + const [windowed, allTime] = await Promise.all([this.backend.countJobs({ from }), this.backend.countJobs()]); + return { ...windowed, waiting: allTime.waiting, running: allTime.running }; + } + + /** Job counts bucketed over time, for the overview chart. */ + timeseries(range?: string): Promise<({ timestamp: Date } & JobCounts)[]> { + return this.backend.countJobsOverTime(range ?? "12m"); + } +} diff --git a/packages/web/src/api/services/queue-service.test.ts b/packages/web/src/api/services/queue-service.test.ts new file mode 100644 index 0000000..ba14374 --- /dev/null +++ b/packages/web/src/api/services/queue-service.test.ts @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import type { QueueConfig } from "@sidequest/core"; +import { QueueNotFoundError } from "../errors"; +import { mockBackend } from "../testing/mock-backend"; +import { QueueService } from "./queue-service"; + +const queue = (over: Partial = {}) => ({ name: "email", state: "active", ...over }) as QueueConfig; + +describe("QueueService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("annotates each queue with its job counts, ordered by name", async () => { + const backend = mockBackend({ + listQueues: vi.fn().mockResolvedValue([queue()]), + countJobsByQueues: vi.fn().mockResolvedValue({ email: { total: 3 } }), + }); + const [row] = await new QueueService(backend).list(); + expect(row).toMatchObject({ name: "email", jobs: { total: 3 } }); + expect(backend.listQueues).toHaveBeenCalledWith({ column: "name", order: "asc" }); + }); + + it("toggles an active queue to paused", async () => { + const backend = mockBackend({ getQueue: vi.fn().mockResolvedValue(queue({ state: "active" })) }); + await expect(new QueueService(backend).toggle("email")).resolves.toMatchObject({ state: "paused" }); + }); + + it("toggles a paused queue to active", async () => { + const backend = mockBackend({ getQueue: vi.fn().mockResolvedValue(queue({ state: "paused" })) }); + await expect(new QueueService(backend).toggle("email")).resolves.toMatchObject({ state: "active" }); + }); + + it("throws QueueNotFoundError for a missing queue", async () => { + const backend = mockBackend({ getQueue: vi.fn().mockResolvedValue(undefined) }); + await expect(new QueueService(backend).toggle("nope")).rejects.toBeInstanceOf(QueueNotFoundError); + }); +}); diff --git a/packages/web/src/api/services/queue-service.ts b/packages/web/src/api/services/queue-service.ts new file mode 100644 index 0000000..13ffed8 --- /dev/null +++ b/packages/web/src/api/services/queue-service.ts @@ -0,0 +1,33 @@ +import type { Backend, JobCounts } from "@sidequest/backend"; +import type { QueueConfig } from "@sidequest/core"; +import { QueueNotFoundError } from "../errors"; + +/** A queue plus the job counts for it (as shown in the queues table). */ +export interface QueueWithCounts extends QueueConfig { + jobs?: JobCounts; +} + +/** + * Read/mutation operations over queues, framework-neutral (no HTTP). + * Override seam for the Pro, same pattern as {@link JobService}. + */ +export class QueueService { + constructor(protected readonly backend: Backend) {} + + /** Lists queues ordered by name, each annotated with its job counts. */ + async list(): Promise { + const [queues, counts] = await Promise.all([ + this.backend.listQueues({ column: "name", order: "asc" }), + this.backend.countJobsByQueues(), + ]); + return queues.map((queue) => ({ ...queue, jobs: counts[queue.name] })); + } + + /** Flips a queue between active and paused. Throws {@link QueueNotFoundError} if missing. */ + async toggle(name: string): Promise { + const queue = await this.backend.getQueue(name); + if (!queue) throw new QueueNotFoundError(name); + const state = queue.state === "active" ? "paused" : "active"; + return this.backend.updateQueue({ ...queue, state }); + } +} diff --git a/packages/web/src/api/testing/mock-backend.ts b/packages/web/src/api/testing/mock-backend.ts new file mode 100644 index 0000000..79ef063 --- /dev/null +++ b/packages/web/src/api/testing/mock-backend.ts @@ -0,0 +1,26 @@ +import type { Backend, JobCounts } from "@sidequest/backend"; +import { vi } from "vitest"; + +/** An all-zero {@link JobCounts}, for defaults in tests. */ +export function zeroCounts(): JobCounts { + return { total: 0, waiting: 0, claimed: 0, running: 0, completed: 0, failed: 0, canceled: 0 } as JobCounts; +} + +/** + * Minimal {@link Backend} test double: every method used by the API is a `vi.fn` with a + * harmless default. Pass overrides for the methods a given test cares about. + */ +export function mockBackend(overrides: Partial = {}): Backend { + return { + getJob: vi.fn().mockResolvedValue(undefined), + listJobs: vi.fn().mockResolvedValue([]), + getQueuesFromJobs: vi.fn().mockResolvedValue([]), + countJobs: vi.fn().mockResolvedValue(zeroCounts()), + countJobsByQueues: vi.fn().mockResolvedValue({}), + countJobsOverTime: vi.fn().mockResolvedValue([]), + listQueues: vi.fn().mockResolvedValue([]), + getQueue: vi.fn().mockResolvedValue(undefined), + updateQueue: vi.fn().mockImplementation((queue) => Promise.resolve(queue)), + ...overrides, + } as unknown as Backend; +} diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json new file mode 100644 index 0000000..ce3ec64 --- /dev/null +++ b/packages/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/api/**/*.ts"] +} diff --git a/yarn.lock b/yarn.lock index bfdd95e..9b4dc59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4038,6 +4038,9 @@ __metadata: version: 0.0.0-use.local resolution: "@sidequest/web@workspace:packages/web" dependencies: + "@sidequest/backend": "workspace:*" + "@sidequest/core": "workspace:*" + "@sidequest/engine": "workspace:*" "@storybook/react-vite": "npm:^10.4.6" "@testing-library/dom": "npm:^10.4.1" "@testing-library/jest-dom": "npm:^6.9.1" @@ -4048,6 +4051,7 @@ __metadata: "@vitejs/plugin-react": "npm:^6.0.3" clsx: "npm:^2.1.1" daisyui: "npm:5.1.7" + hono: "npm:^4.12.0" jsdom: "npm:^29.1.1" lucide-react: "npm:^1.23.0" react: "npm:^19.2.7" @@ -9344,6 +9348,13 @@ __metadata: languageName: node linkType: hard +"hono@npm:^4.12.0": + version: 4.12.27 + resolution: "hono@npm:4.12.27" + checksum: 10c0/1c62a52ccafde751656d065bbdacffbd421d3cfaedfbffefe80ecdb8ccf96c2209af732a74064cd33ac6d187a91ec32b1dc7ddde2416a53052fe7c0a94352a89 + languageName: node + linkType: hard + "hook-std@npm:^4.0.0": version: 4.0.0 resolution: "hook-std@npm:4.0.0"