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
17 changes: 14 additions & 3 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions packages/web/rollup.config.js
Original file line number Diff line number Diff line change
@@ -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");
96 changes: 96 additions & 0 deletions packages/web/src/api/app.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
38 changes: 38 additions & 0 deletions packages/web/src/api/app.ts
Original file line number Diff line number Diff line change
@@ -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<ApiApp>(...)`. */
export type ApiApp = ReturnType<typeof createApiApp>;
21 changes: 21 additions & 0 deletions packages/web/src/api/errors.ts
Original file line number Diff line number Diff line change
@@ -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";
}
}
26 changes: 26 additions & 0 deletions packages/web/src/api/filters.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
66 changes: 66 additions & 0 deletions packages/web/src/api/filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { Context } from "hono";
import type { JobListFilters } from "./services/job-service";

const MINUTES_BY_RANGE: Record<string, number> = {
"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),
},
};
}
9 changes: 9 additions & 0 deletions packages/web/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -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";
30 changes: 30 additions & 0 deletions packages/web/src/api/routes/jobs.ts
Original file line number Diff line number Diff line change
@@ -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")))));
8 changes: 8 additions & 0 deletions packages/web/src/api/routes/overview.ts
Original file line number Diff line number Diff line change
@@ -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"))));
8 changes: 8 additions & 0 deletions packages/web/src/api/routes/queues.ts
Original file line number Diff line number Diff line change
@@ -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"))));
Loading