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
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default tseslint.config(
"**/storybook-static/**",
"packages/docs/.vitepress/cache/**",
"packages/web/vite.lib.config.ts",
"packages/web/vite.app.config.ts",
"packages/web/vitest.setup.ts",
"packages/web/.storybook/**",
],
Expand Down
12 changes: 12 additions & 0 deletions packages/web/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sidequest</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/dashboard/app/main.tsx"></script>
</body>
</html>
3 changes: 3 additions & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"scripts": {
"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",
"dev:app": "npx vite --config vite.app.config.ts",
"test": "yarn vitest run",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
Expand All @@ -52,6 +53,8 @@
"@sidequest/core": "workspace:*",
"@sidequest/engine": "workspace:*",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.17",
"chart.js": "^4.5.1",
"hono": "^4.12.0"
},
"devDependencies": {
Expand Down
9 changes: 8 additions & 1 deletion packages/web/src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@ import { NotFoundError } from "./errors";
import { jobsRoutes } from "./routes/jobs";
import { overviewRoutes } from "./routes/overview";
import { queuesRoutes } from "./routes/queues";
import { systemRoutes } from "./routes/system";
import { JobService } from "./services/job-service";
import { OverviewService } from "./services/overview-service";
import { QueueService } from "./services/queue-service";
import { SystemService } from "./services/system-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. */
/** Dependencies the API needs. */
export interface ApiDeps {
backend: Backend;
/** Sidequest version shown in the dashboard sidebar (the backend doesn't expose it). */
version?: string;
/** Backend driver name shown in the dashboard sidebar (lives in the backend config). */
driver?: string;
}

/**
Expand All @@ -31,6 +37,7 @@ export function createApiApp(deps: ApiDeps) {
.route("/jobs", jobsRoutes(new JobService(deps.backend)))
.route("/queues", queuesRoutes(new QueueService(deps.backend)))
.route("/overview", overviewRoutes(new OverviewService(deps.backend)))
.route("/system", systemRoutes(new SystemService(deps.backend, { version: deps.version, driver: deps.driver })))
.onError(apiErrorHandler);
}

Expand Down
5 changes: 5 additions & 0 deletions packages/web/src/api/routes/system.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Hono } from "hono";
import type { SystemService } from "../services/system-service";

/** Route for system/engine info, mounted under `/api/system`. */
export const systemRoutes = (system: SystemService) => new Hono().get("/", async (c) => c.json(await system.info()));
18 changes: 18 additions & 0 deletions packages/web/src/api/services/system-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { mockBackend } from "../testing/mock-backend";
import { SystemService } from "./system-service";

describe("SystemService", () => {
beforeEach(() => vi.clearAllMocks());

it("reports connected with the configured driver and version", async () => {
const backend = mockBackend({ listQueues: vi.fn().mockResolvedValue([]) });
const info = await new SystemService(backend, { version: "1.2.3", driver: "@sidequest/sqlite-backend" }).info();
expect(info).toEqual({ version: "1.2.3", driver: "@sidequest/sqlite-backend", connected: true });
});

it("reports disconnected when the backend probe throws, with no driver/version by default", async () => {
const backend = mockBackend({ listQueues: vi.fn().mockRejectedValue(new Error("down")) });
const info = await new SystemService(backend).info();
expect(info).toEqual({ version: undefined, driver: undefined, connected: false });
});
});
40 changes: 40 additions & 0 deletions packages/web/src/api/services/system-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Backend } from "@sidequest/backend";

/** System/engine info surfaced in the dashboard sidebar. */
export interface SystemInfo {
/** Sidequest version, when the server provides it. */
version?: string;
/** Backend driver name, when the server provides it. */
driver?: string;
/** Whether the backend is reachable. */
connected: boolean;
}

/** Values the backend interface doesn't expose (driver, version), passed by the server. */
export interface SystemServiceOptions {
version?: string;
driver?: string;
}

/**
* System/engine info for the dashboard: the backend driver and running version (supplied
* by the server, since the backend interface doesn't expose them) plus a live
* connectivity probe. Framework-neutral (no HTTP).
*/
export class SystemService {
constructor(
protected readonly backend: Backend,
protected readonly options: SystemServiceOptions = {},
) {}

/** Probes the backend for connectivity and reports the configured driver/version. */
async info(): Promise<SystemInfo> {
let connected = true;
try {
await this.backend.listQueues();
} catch {
connected = false;
}
return { version: this.options.version, driver: this.options.driver, connected };
}
}
24 changes: 24 additions & 0 deletions packages/web/src/dashboard/app/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { useMemo } from "react";
import { type ApiClient, createApiClient } from "../client";
import { ApiClientProvider } from "../context";
import { createQueryClient } from "../hooks/query-client";
import { ossPages } from "./pages";
import { DashboardShell } from "./shell";

/**
* The OSS dashboard app: wires the API client and the QueryClient into context and mounts the
* shell with the OSS pages. Pass `client` to point it at a custom base URL (or a Pro superset
* client).
*/
export function DashboardApp({ client }: { client?: ApiClient }) {
const resolved = useMemo(() => client ?? createApiClient(), [client]);
const queryClient = useMemo(() => createQueryClient(), []);
return (
<QueryClientProvider client={queryClient}>
<ApiClientProvider value={resolved}>
<DashboardShell pages={ossPages} />
</ApiClientProvider>
</QueryClientProvider>
);
}
7 changes: 7 additions & 0 deletions packages/web/src/dashboard/app/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createRoot } from "react-dom/client";
import "../../ui/styles.css";
import "../dashboard.css";
import { DashboardApp } from "./app";

const root = document.getElementById("root");
if (root) createRoot(root).render(<DashboardApp />);
42 changes: 42 additions & 0 deletions packages/web/src/dashboard/app/pages.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { JobDetailPage, JobsPage } from "./pages/jobs";
import { OverviewPage } from "./pages/overview";
import { QueuesPage } from "./pages/queues";
import type { DashboardPage } from "./shell";

/** The OSS dashboard pages. The Pro spreads these and appends its own before mounting the shell. */
export const ossPages: DashboardPage[] = [
{
path: "/",
nav: {
label: "Overview",
icon: "LayoutDashboard",
section: "Monitor",
hint: "G O",
subtitle: "Real-time job processing at a glance",
},
element: <OverviewPage />,
},
{
path: "/jobs",
nav: {
label: "Jobs",
icon: "List",
section: "Monitor",
hint: "G J",
subtitle: "Inspect, filter, and reprocess background jobs",
},
element: <JobsPage />,
routes: [{ path: "/jobs/$id", element: <JobDetailPage /> }],
},
{
path: "/queues",
nav: {
label: "Queues",
icon: "Layers",
section: "Monitor",
hint: "G Q",
subtitle: "Concurrency, priority, and throughput per queue",
},
element: <QueuesPage />,
},
];
38 changes: 38 additions & 0 deletions packages/web/src/dashboard/app/pages/jobs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { screen, waitFor } from "@testing-library/react";
import type { ApiClient } from "../../client";
import { jsonResponse, renderWithClient } from "../../testing/harness";
import { JobsListView } from "./jobs";

function makeClient() {
return {
jobs: {
$get: vi.fn().mockResolvedValue(
jsonResponse({
jobs: [{ id: 1, class: "SendEmailJob", queue: "email", state: "failed", attempt: 5, max_attempts: 5 }],
pagination: { page: 1, pageSize: 11, hasNextPage: false },
}),
),
},
overview: {
$get: vi.fn().mockResolvedValue(
jsonResponse({ total: 12, waiting: 3, running: 1, completed: 6, failed: 1, canceled: 1, claimed: 0 }),
),
},
} as unknown as ApiClient;
}

describe("JobsPage", () => {
beforeEach(() => {
window.location.hash = "";
});

it("renders the jobs list with status dots and segment counts", async () => {
renderWithClient(<JobsListView onOpenJob={() => {}} />, makeClient());

await waitFor(() => expect(screen.getByText("SendEmailJob")).toBeInTheDocument());
expect(screen.getByText("5/5")).toBeInTheDocument();
expect(screen.getByText("#1")).toBeInTheDocument();
// segmented filter shows the "All" segment
expect(screen.getByText("All")).toBeInTheDocument();
});
});
101 changes: 101 additions & 0 deletions packages/web/src/dashboard/app/pages/jobs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { useNavigate, useParams } from "@tanstack/react-router";
import { useState } from "react";
import { JobDetailView } from "../../components/JobDetailView";
import { JobsTable } from "../../components/JobsTable";
import { JobsToolbar } from "../../components/JobsToolbar";
import { SegmentedFilter, type Segment } from "../../components/SegmentedFilter";
import { useJob, useJobActions, useJobs } from "../../hooks/use-jobs";
import { useOverview } from "../../hooks/use-overview";

const PAGE_SIZE = 11;

/** The jobs list route (`/jobs`): opens a job by navigating to its detail route. */
export function JobsPage() {
const navigate = useNavigate();
return <JobsListView onOpenJob={(id) => void navigate({ to: `/jobs/${id}` })} />;
}

/** The job detail route (`/jobs/$id`): reads the id from the route and wires rerun/cancel. */
export function JobDetailPage() {
const { id } = useParams({ strict: false });
const navigate = useNavigate();
return <JobDetailContainer id={Number(id)} onBack={() => void navigate({ to: "/jobs" })} />;
}

/** Loads a job by id and renders its detail, wiring the rerun/cancel actions. */
function JobDetailContainer({ id, onBack }: { id: number; onBack: () => void }) {
const { data: job, refetch } = useJob(id, { refetchInterval: 3000 });
const actions = useJobActions();
if (!job) {
return <div className="font-mono text-sm text-fg-muted py-10">Loading job #{id}…</div>;
}
const act = (promise: Promise<unknown>) => void promise.then(refetch);
return (
<JobDetailView
job={job}
onBack={onBack}
onRerun={(jobId) => act(actions.rerun(jobId))}
onCancel={(jobId) => act(actions.cancel(jobId))}
/>
);
}

const SEGMENTS: { key: string; label: string; countKey: "total" | "running" | "completed" | "failed" | "waiting" | "canceled" }[] =
[
{ key: "all", label: "All", countKey: "total" },
{ key: "running", label: "Running", countKey: "running" },
{ key: "completed", label: "Completed", countKey: "completed" },
{ key: "failed", label: "Failed", countKey: "failed" },
{ key: "waiting", label: "Waiting", countKey: "waiting" },
{ key: "canceled", label: "Canceled", countKey: "canceled" },
];

/** The jobs list: search, segmented status filters, and the paginated table. */
export function JobsListView({ onOpenJob }: { onOpenJob: (id: number) => void }) {
const [status, setStatus] = useState("all");
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const { data, refetch } = useJobs(
{ state: status === "all" ? undefined : status, class: query || undefined, page, pageSize: PAGE_SIZE },
{ refetchInterval: 3000 },
);
const { data: counts } = useOverview(undefined, { refetchInterval: 5000 });
const actions = useJobActions();

const changeStatus = (key: string) => {
setStatus(key);
setPage(1);
};
const changeQuery = (value: string) => {
setQuery(value);
setPage(1);
};
const act = (promise: Promise<unknown>) => void promise.then(refetch);

const segments: Segment[] = SEGMENTS.map((segment) => ({
key: segment.key,
label: segment.label,
count: counts ? Number(counts[segment.countKey] ?? 0) : 0,
}));

const jobs = data?.jobs ?? [];
const total = counts ? Number(counts[status === "all" ? "total" : (status as "running")] ?? jobs.length) : jobs.length;

return (
<div className="flex flex-col gap-4">
<JobsToolbar query={query} onQuery={changeQuery} />
<SegmentedFilter segments={segments} value={status} onChange={changeStatus} />
<JobsTable
jobs={jobs}
page={page}
pageSize={PAGE_SIZE}
total={total}
hasNext={data?.pagination.hasNextPage ?? false}
onPage={(delta) => setPage((current) => Math.max(1, current + delta))}
onOpenJob={onOpenJob}
onRerun={(id) => act(actions.rerun(id))}
onCancel={(id) => act(actions.cancel(id))}
/>
</div>
);
}
32 changes: 32 additions & 0 deletions packages/web/src/dashboard/app/pages/overview.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { screen, waitFor } from "@testing-library/react";
import type { ApiClient } from "../../client";
import { jsonResponse, renderWithClient } from "../../testing/harness";
import { OverviewPage } from "./overview";

describe("OverviewPage", () => {
it("renders the sparkline stat cards and throughput panel from the overview data", async () => {
const client = {
overview: {
$get: vi.fn().mockResolvedValue(
jsonResponse({ total: 17, waiting: 4, running: 1, completed: 9, failed: 2, canceled: 1, claimed: 0 }),
),
timeseries: {
$get: vi.fn().mockResolvedValue(
jsonResponse([
{ timestamp: "2026-07-05T12:00:00Z", completed: 3, failed: 1, running: 1, waiting: 4 },
{ timestamp: "2026-07-05T12:01:00Z", completed: 9, failed: 2, running: 1, waiting: 4 },
]),
),
},
},
} as unknown as ApiClient;

renderWithClient(<OverviewPage />, client);

// padded mono values: completed 9 -> "09", running 1 -> "01"
await waitFor(() => expect(screen.getByText("09")).toBeInTheDocument());
expect(screen.getByText("Completed")).toBeInTheDocument();
expect(screen.getByText("Job throughput")).toBeInTheDocument();
expect(screen.getByText("Success rate")).toBeInTheDocument();
});
});
Loading