diff --git a/eslint.config.js b/eslint.config.js index 37b925a..27c3ee0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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/**", ], diff --git a/packages/web/index.html b/packages/web/index.html new file mode 100644 index 0000000..5555db3 --- /dev/null +++ b/packages/web/index.html @@ -0,0 +1,12 @@ + + + + + + Sidequest + + +
+ + + diff --git a/packages/web/package.json b/packages/web/package.json index c88658e..65122ff 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -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" @@ -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": { diff --git a/packages/web/src/api/app.ts b/packages/web/src/api/app.ts index cb3009e..6a8e4d8 100644 --- a/packages/web/src/api/app.ts +++ b/packages/web/src/api/app.ts @@ -4,9 +4,11 @@ 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) => { @@ -14,9 +16,13 @@ export const apiErrorHandler: ErrorHandler = (err, c) => { 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; } /** @@ -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); } diff --git a/packages/web/src/api/routes/system.ts b/packages/web/src/api/routes/system.ts new file mode 100644 index 0000000..9979afa --- /dev/null +++ b/packages/web/src/api/routes/system.ts @@ -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())); diff --git a/packages/web/src/api/services/system-service.test.ts b/packages/web/src/api/services/system-service.test.ts new file mode 100644 index 0000000..6ca5125 --- /dev/null +++ b/packages/web/src/api/services/system-service.test.ts @@ -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 }); + }); +}); diff --git a/packages/web/src/api/services/system-service.ts b/packages/web/src/api/services/system-service.ts new file mode 100644 index 0000000..b0cecf4 --- /dev/null +++ b/packages/web/src/api/services/system-service.ts @@ -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 { + let connected = true; + try { + await this.backend.listQueues(); + } catch { + connected = false; + } + return { version: this.options.version, driver: this.options.driver, connected }; + } +} diff --git a/packages/web/src/dashboard/app/app.tsx b/packages/web/src/dashboard/app/app.tsx new file mode 100644 index 0000000..70e4b88 --- /dev/null +++ b/packages/web/src/dashboard/app/app.tsx @@ -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 ( + + + + + + ); +} diff --git a/packages/web/src/dashboard/app/main.tsx b/packages/web/src/dashboard/app/main.tsx new file mode 100644 index 0000000..e6ae7f8 --- /dev/null +++ b/packages/web/src/dashboard/app/main.tsx @@ -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(); diff --git a/packages/web/src/dashboard/app/pages.tsx b/packages/web/src/dashboard/app/pages.tsx new file mode 100644 index 0000000..37de801 --- /dev/null +++ b/packages/web/src/dashboard/app/pages.tsx @@ -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: , + }, + { + path: "/jobs", + nav: { + label: "Jobs", + icon: "List", + section: "Monitor", + hint: "G J", + subtitle: "Inspect, filter, and reprocess background jobs", + }, + element: , + routes: [{ path: "/jobs/$id", element: }], + }, + { + path: "/queues", + nav: { + label: "Queues", + icon: "Layers", + section: "Monitor", + hint: "G Q", + subtitle: "Concurrency, priority, and throughput per queue", + }, + element: , + }, +]; diff --git a/packages/web/src/dashboard/app/pages/jobs.test.tsx b/packages/web/src/dashboard/app/pages/jobs.test.tsx new file mode 100644 index 0000000..cd30004 --- /dev/null +++ b/packages/web/src/dashboard/app/pages/jobs.test.tsx @@ -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( {}} />, 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(); + }); +}); diff --git a/packages/web/src/dashboard/app/pages/jobs.tsx b/packages/web/src/dashboard/app/pages/jobs.tsx new file mode 100644 index 0000000..bc3d8e3 --- /dev/null +++ b/packages/web/src/dashboard/app/pages/jobs.tsx @@ -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 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 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
Loading job #{id}…
; + } + const act = (promise: Promise) => void promise.then(refetch); + return ( + 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) => 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 ( +
+ + + setPage((current) => Math.max(1, current + delta))} + onOpenJob={onOpenJob} + onRerun={(id) => act(actions.rerun(id))} + onCancel={(id) => act(actions.cancel(id))} + /> +
+ ); +} diff --git a/packages/web/src/dashboard/app/pages/overview.test.tsx b/packages/web/src/dashboard/app/pages/overview.test.tsx new file mode 100644 index 0000000..0b03abe --- /dev/null +++ b/packages/web/src/dashboard/app/pages/overview.test.tsx @@ -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(, 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(); + }); +}); diff --git a/packages/web/src/dashboard/app/pages/overview.tsx b/packages/web/src/dashboard/app/pages/overview.tsx new file mode 100644 index 0000000..10ebe50 --- /dev/null +++ b/packages/web/src/dashboard/app/pages/overview.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { StatTrendCard, type StatTrendTone } from "../../components/StatTrendCard"; +import { SuccessRateCard } from "../../components/SuccessRateCard"; +import { ThroughputChart } from "../../components/ThroughputChart"; +import { useOverview, useOverviewTimeseries } from "../../hooks/use-overview"; + +type CountKey = "running" | "completed" | "failed" | "waiting"; + +const RANGE_LABEL: Record = { "12m": "last 12m", "12h": "last 12h", "12d": "last 12d" }; + +const CARDS: { key: CountKey; label: string; tone: StatTrendTone }[] = [ + { key: "running", label: "Running", tone: "running" }, + { key: "completed", label: "Completed", tone: "completed" }, + { key: "failed", label: "Failed", tone: "failed" }, + { key: "waiting", label: "Scheduled", tone: "scheduled" }, +]; + +/** Percentage trend from the first to the last point of a series. */ +function trend(series: number[]): number { + if (series.length < 2) { + return 0; + } + const first = series[0]; + const last = series[series.length - 1]; + return Math.round(((last - first) / Math.max(1, first)) * 100); +} + +/** The overview home: sparkline stat cards, a throughput chart, and success/worker panels. */ +export function OverviewPage() { + const [range, setRange] = useState("12m"); + const { data: stats } = useOverview(range, { refetchInterval: 2000 }); + const { data: series } = useOverviewTimeseries(range, { refetchInterval: 5000 }); + const points = series ?? []; + + const seriesFor = (key: CountKey): number[] => { + const values = points.map((point) => Number(point[key] ?? 0)); + return values.length > 0 ? values : [Number(stats?.[key] ?? 0)]; + }; + + const completed = stats?.completed ?? 0; + const failed = stats?.failed ?? 0; + const throughput = completed + failed; + const rate = throughput > 0 ? Math.round((completed / throughput) * 100) : 0; + + return ( +
+
+ {CARDS.map((card) => { + const cardSeries = seriesFor(card.key); + return ( + + ); + })} +
+ +
+ ({ timestamp: point.timestamp, completed: point.completed, failed: point.failed }))} + range={range} + onRangeChange={setRange} + /> +
+ +
+
+
+ ); +} diff --git a/packages/web/src/dashboard/app/pages/queues.test.tsx b/packages/web/src/dashboard/app/pages/queues.test.tsx new file mode 100644 index 0000000..c79afb0 --- /dev/null +++ b/packages/web/src/dashboard/app/pages/queues.test.tsx @@ -0,0 +1,31 @@ +import { screen, waitFor } from "@testing-library/react"; +import type { ApiClient } from "../../client"; +import { jsonResponse, renderWithClient } from "../../testing/harness"; +import { QueuesPage } from "./queues"; + +describe("QueuesPage", () => { + it("renders a queue card with its load-bar counts and a pause action", async () => { + const client = { + queues: { + $get: vi.fn().mockResolvedValue( + jsonResponse([ + { + name: "email", + state: "active", + concurrency: 2, + priority: 20, + jobs: { total: 8, waiting: 3, running: 1, completed: 4, failed: 0 }, + }, + ]), + ), + }, + } as unknown as ApiClient; + + renderWithClient(, client); + + await waitFor(() => expect(screen.getByText("email")).toBeInTheDocument()); + // an active queue offers a "Pause" action, and shows its concurrency chip + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("concurrency 2")).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/dashboard/app/pages/queues.tsx b/packages/web/src/dashboard/app/pages/queues.tsx new file mode 100644 index 0000000..3fe2d31 --- /dev/null +++ b/packages/web/src/dashboard/app/pages/queues.tsx @@ -0,0 +1,17 @@ +import { QueueCard } from "../../components/QueueCard"; +import { useQueueActions, useQueues } from "../../hooks/use-queues"; + +/** The queues screen: a card per queue with its load bar and a pause/activate toggle. */ +export function QueuesPage() { + const { data, refetch } = useQueues({ refetchInterval: 3000 }); + const actions = useQueueActions(); + const queues = data ?? []; + + return ( +
+ {queues.map((queue) => ( + void actions.toggle(name).then(refetch)} /> + ))} +
+ ); +} diff --git a/packages/web/src/dashboard/app/shell.test.tsx b/packages/web/src/dashboard/app/shell.test.tsx new file mode 100644 index 0000000..00574c9 --- /dev/null +++ b/packages/web/src/dashboard/app/shell.test.tsx @@ -0,0 +1,59 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ApiClient } from "../client"; +import { jsonResponse, renderWithClient } from "../testing/harness"; +import { DashboardShell, type DashboardPage } from "./shell"; + +const pages: DashboardPage[] = [ + { path: "/", nav: { label: "Home", icon: "LayoutDashboard", subtitle: "the home page" }, element:
home page
}, + { path: "/jobs", nav: { label: "Jobs", icon: "List" }, element:
jobs page
}, +]; + +/** A client stub for the engine-status query the sidebar footer makes. */ +function stubClient() { + return { system: { $get: vi.fn().mockResolvedValue(jsonResponse({ connected: true })) } } as unknown as ApiClient; +} + +describe("DashboardShell", () => { + beforeEach(() => { + window.location.hash = ""; + vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: false })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders the nav entries, header, and the active page", async () => { + renderWithClient(, stubClient()); + // the router mounts its matches on an effect, so wait for the chrome to appear + expect(await screen.findByRole("button", { name: "Home" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Jobs" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Home" })).toBeInTheDocument(); + expect(screen.getByText("the home page")).toBeInTheDocument(); + expect(screen.getByText("home page")).toBeInTheDocument(); + }); + + it("switches the active page when a nav entry is clicked", async () => { + const user = userEvent.setup(); + renderWithClient(, stubClient()); + + await user.click(await screen.findByRole("button", { name: "Jobs" })); + + expect(await screen.findByText("jobs page")).toBeInTheDocument(); + }); + + it("opens the command palette on ⌘K and navigates from it", async () => { + const user = userEvent.setup(); + renderWithClient(, stubClient()); + await screen.findByRole("button", { name: "Home" }); + + await user.keyboard("{Meta>}k{/Meta}"); + expect(screen.getByRole("dialog", { name: "Command palette" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /Go to Jobs/ })); + + await waitFor(() => expect(screen.getByText("jobs page")).toBeInTheDocument()); + }); +}); diff --git a/packages/web/src/dashboard/app/shell.tsx b/packages/web/src/dashboard/app/shell.tsx new file mode 100644 index 0000000..220a216 --- /dev/null +++ b/packages/web/src/dashboard/app/shell.tsx @@ -0,0 +1,148 @@ +import { + createHashHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, + useNavigate, + useRouterState, +} from "@tanstack/react-router"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import type { IconName } from "../ui/Icon"; +import { AppSidebar } from "../components/AppSidebar"; +import { CommandPalette } from "../components/CommandPalette"; +import { DashboardHeader } from "../components/DashboardHeader"; + +/** An extra route a page owns that is not a nav entry (e.g. a detail view like `/jobs/$id`). */ +export interface DashboardRoute { + path: string; + element: ReactNode; +} + +/** A dashboard page: its route, its nav entry, what to render, and any owned detail routes. */ +export interface DashboardPage { + path: string; + nav: { + label: string; + icon?: IconName; + /** Sidebar section header this page groups under (e.g. "Monitor"). */ + section?: string; + /** Keyboard hint shown on hover (e.g. "G J"). */ + hint?: string; + /** Header subtitle for the page. */ + subtitle?: string; + }; + element: ReactNode; + /** Non-nav routes owned by this page, such as detail views. */ + routes?: DashboardRoute[]; +} + +const THEME_STORAGE_KEY = "sq-theme"; + +/** Flips `data-theme` between light and dark and persists it (mirrors {@link ThemeToggle}). */ +function toggleTheme(): void { + const root = document.documentElement; + const next = root.getAttribute("data-theme") === "light" ? "dark" : "light"; + root.setAttribute("data-theme", next); + try { + localStorage.setItem(THEME_STORAGE_KEY, next); + } catch { + /* storage blocked */ + } +} + +/** + * Resolves the active page for a path by longest matching prefix, so detail routes like + * `/jobs/42` still activate the `/jobs` page (for the header/nav). Falls back to the exact + * match, then the root page. + */ +export function resolveActive(pages: DashboardPage[], path: string): DashboardPage { + const prefix = pages + .filter((page) => page.path !== "/" && (path === page.path || path.startsWith(`${page.path}/`))) + .sort((a, b) => b.path.length - a.path.length)[0]; + return prefix ?? pages.find((page) => page.path === path) ?? pages.find((page) => page.path === "/") ?? pages[0]; +} + +/** A route path relative to the root route ("/" stays the index; others drop the leading slash). */ +function relativePath(path: string): string { + return path === "/" ? "/" : path.replace(/^\//, ""); +} + +/** + * The dashboard chrome (brand glow, section sidebar, sticky header, ⌘K command palette) wrapped + * around the active page's ``. Lives inside the router so it can read the active path + * and navigate. + */ +function DashboardChrome({ pages }: { pages: DashboardPage[] }) { + const pathname = useRouterState({ select: (state) => state.location.pathname }); + const navigate = useNavigate(); + const [palette, setPalette] = useState(false); + const active = resolveActive(pages, pathname); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setPalette((open) => !open); + } + if (e.key === "Escape") { + setPalette(false); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + const go = (to: string) => { + void navigate({ to }); + setPalette(false); + }; + + return ( +
+
+ setPalette(true)} /> +
+ +
+ +
+
+ setPalette(false)} + pages={pages} + onNavigate={go} + onToggleTheme={toggleTheme} + /> +
+ ); +} + +/** + * Builds a TanStack Router from the page registry: a root route rendering the chrome, one child + * route per page (plus any detail routes a page owns), on hash history so the dashboard serves + * statically under any base path without server rewrites. This is the page-registry seam: the + * Pro passes a superset `pages` array to add screens without forking. + */ +export function createDashboardRouter(pages: DashboardPage[]) { + const rootRoute = createRootRoute({ component: () => }); + const children = pages.flatMap((page) => [ + createRoute({ getParentRoute: () => rootRoute, path: relativePath(page.path), component: () => <>{page.element} }), + ...(page.routes ?? []).map((route) => + createRoute({ getParentRoute: () => rootRoute, path: relativePath(route.path), component: () => <>{route.element} }), + ), + ]); + const routeTree = rootRoute.addChildren(children); + return createRouter({ routeTree, history: createHashHistory() }); +} + +/** + * The dashboard shell: builds the router from the page registry and renders it. Passing the same + * `pages` keeps the router stable across re-renders. + */ +export function DashboardShell({ pages }: { pages: DashboardPage[] }) { + const router = useMemo(() => createDashboardRouter(pages), [pages]); + return ; +} diff --git a/packages/web/src/dashboard/components/AppSidebar.test.tsx b/packages/web/src/dashboard/components/AppSidebar.test.tsx new file mode 100644 index 0000000..7bfcd04 --- /dev/null +++ b/packages/web/src/dashboard/components/AppSidebar.test.tsx @@ -0,0 +1,35 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { ApiClient } from "../client"; +import type { DashboardPage } from "../app/shell"; +import { jsonResponse, renderWithClient } from "../testing/harness"; +import { AppSidebar } from "./AppSidebar"; + +const pages: DashboardPage[] = [ + { path: "/", nav: { label: "Overview", icon: "LayoutDashboard", section: "Monitor", hint: "G O" }, element: null }, + { path: "/jobs", nav: { label: "Jobs", icon: "List", section: "Monitor" }, element: null }, +]; + +// AppSidebar's footer renders EngineStatus, which queries `system`. +const client = { system: { $get: vi.fn().mockResolvedValue(jsonResponse({ connected: true })) } } as unknown as ApiClient; + +describe("AppSidebar", () => { + it("renders the brand, section header and nav, and reports navigation + palette open", async () => { + const user = userEvent.setup(); + const onNavigate = vi.fn(); + const onOpenPalette = vi.fn(); + renderWithClient( + , + client, + ); + expect(screen.getByText("Sidequest")).toBeInTheDocument(); + expect(screen.getByText("Monitor")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Jobs" })); + expect(onNavigate).toHaveBeenCalledWith("/jobs"); + + await user.click(screen.getByRole("button", { name: /search/i })); + expect(onOpenPalette).toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/dashboard/components/AppSidebar.tsx b/packages/web/src/dashboard/components/AppSidebar.tsx new file mode 100644 index 0000000..cd19edb --- /dev/null +++ b/packages/web/src/dashboard/components/AppSidebar.tsx @@ -0,0 +1,108 @@ +import { cn } from "../../ui/cn"; +import { Icon } from "../../ui/Icon"; +import { Kbd } from "../../ui/Kbd"; +import type { DashboardPage } from "../app/shell"; +import { EngineStatus } from "./EngineStatus"; + +/** Props for the {@link AppSidebar}. */ +export interface AppSidebarProps { + pages: DashboardPage[]; + /** Path of the currently active page (drives the highlight). */ + activePath: string; + onNavigate: (path: string) => void; + /** Opens the command palette (the search trigger). */ + onOpenPalette: () => void; +} + +/** Groups pages by their optional `nav.section`, preserving first-seen order. */ +function groupBySection(pages: DashboardPage[]): { section: string; items: DashboardPage[] }[] { + const groups: { section: string; items: DashboardPage[] }[] = []; + for (const page of pages) { + const section = page.nav.section ?? ""; + const group = groups.find((g) => g.section === section); + if (group) { + group.items.push(page); + } else { + groups.push({ section, items: [page] }); + } + } + return groups; +} + +/** + * AppSidebar — the dashboard's left rail: brand lockup with an OSS tag, a ⌘K search + * trigger, section-grouped navigation with per-item keyboard hints and a brand active + * accent, and a live engine-status footer. Built from the page registry, so the Pro's + * extra pages appear automatically. + */ +export function AppSidebar({ pages, activePath, onNavigate, onOpenPalette }: AppSidebarProps) { + return ( + + ); +} diff --git a/packages/web/src/dashboard/components/CommandPalette.test.tsx b/packages/web/src/dashboard/components/CommandPalette.test.tsx new file mode 100644 index 0000000..8537c25 --- /dev/null +++ b/packages/web/src/dashboard/components/CommandPalette.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { DashboardPage } from "../app/shell"; +import { CommandPalette } from "./CommandPalette"; + +const pages: DashboardPage[] = [ + { path: "/", nav: { label: "Overview", icon: "LayoutDashboard" }, element: null }, + { path: "/jobs", nav: { label: "Jobs", icon: "List" }, element: null }, +]; + +const props = { pages, onClose: () => undefined, onNavigate: () => undefined, onToggleTheme: () => undefined }; + +describe("CommandPalette", () => { + it("renders nothing when closed", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("lists commands, filters by query and navigates", async () => { + const user = userEvent.setup(); + const onNavigate = vi.fn(); + render(); + expect(screen.getByRole("dialog", { name: "Command palette" })).toBeInTheDocument(); + expect(screen.getByText("Go to Overview")).toBeInTheDocument(); + + await user.type(screen.getByPlaceholderText(/command or search/i), "jobs"); + expect(screen.queryByText("Go to Overview")).toBeNull(); + await user.click(screen.getByText("Go to Jobs")); + expect(onNavigate).toHaveBeenCalledWith("/jobs"); + }); + + it("shows an empty state when nothing matches", async () => { + const user = userEvent.setup(); + render(); + await user.type(screen.getByPlaceholderText(/command or search/i), "zzzz"); + expect(screen.getByText("No commands found.")).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/dashboard/components/CommandPalette.tsx b/packages/web/src/dashboard/components/CommandPalette.tsx new file mode 100644 index 0000000..5ba944a --- /dev/null +++ b/packages/web/src/dashboard/components/CommandPalette.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import { Icon, type IconName } from "../../ui/Icon"; +import { Kbd } from "../../ui/Kbd"; +import type { DashboardPage } from "../app/shell"; + +/** Props for the {@link CommandPalette}. */ +export interface CommandPaletteProps { + open: boolean; + onClose: () => void; + pages: DashboardPage[]; + onNavigate: (path: string) => void; + onToggleTheme: () => void; +} + +interface Command { + label: string; + icon: IconName; + run: () => void; +} + +/** + * CommandPalette — the ⌘K command palette: a fuzzy filter over navigation and quick + * actions (theme, docs). Open/close and the keyboard shortcut are owned by the shell; + * this renders the overlay and list when `open`. + */ +export function CommandPalette({ open, onClose, pages, onNavigate, onToggleTheme }: CommandPaletteProps) { + const [query, setQuery] = useState(""); + if (!open) { + return null; + } + + const commands: Command[] = [ + ...pages.map((page) => ({ + label: `Go to ${page.nav.label}`, + icon: page.nav.icon ?? "Circle", + run: () => { + onNavigate(page.path); + onClose(); + }, + })), + { + label: "Toggle theme", + icon: "SunMoon", + run: () => { + onToggleTheme(); + onClose(); + }, + }, + { + label: "Open documentation", + icon: "BookOpen", + run: () => { + window.open("https://docs.sidequestjs.com", "_blank", "noreferrer"); + onClose(); + }, + }, + ]; + const matches = commands.filter((command) => command.label.toLowerCase().includes(query.toLowerCase())); + + return ( +
+
e.stopPropagation()} + className="dash-palette w-[min(560px,92vw)] bg-surface-card border border-edge-strong rounded-xl shadow-lg overflow-hidden" + > +
+ + setQuery(e.target.value)} + placeholder="Type a command or search…" + className="flex-1 bg-transparent border-none outline-none text-fg text-[15px] font-sans" + /> + Esc +
+
+ {matches.length === 0 &&
No commands found.
} + {matches.map((command) => ( + + ))} +
+
+
+ ); +} diff --git a/packages/web/src/dashboard/components/DashboardHeader.test.tsx b/packages/web/src/dashboard/components/DashboardHeader.test.tsx new file mode 100644 index 0000000..dde4dc1 --- /dev/null +++ b/packages/web/src/dashboard/components/DashboardHeader.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DashboardHeader } from "./DashboardHeader"; + +describe("DashboardHeader", () => { + beforeEach(() => { + vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: false })); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders the title, subtitle, theme toggle and docs link", () => { + render(); + expect(screen.getByRole("heading", { name: "Overview" })).toBeInTheDocument(); + expect(screen.getByText("at a glance")).toBeInTheDocument(); + expect(screen.getByRole("switch")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /docs/i })).toHaveAttribute("href", "https://docs.sidequestjs.com"); + }); +}); diff --git a/packages/web/src/dashboard/components/DashboardHeader.tsx b/packages/web/src/dashboard/components/DashboardHeader.tsx new file mode 100644 index 0000000..5e5992c --- /dev/null +++ b/packages/web/src/dashboard/components/DashboardHeader.tsx @@ -0,0 +1,39 @@ +import { Icon } from "../../ui/Icon"; +import { ThemeToggle } from "../../ui/ThemeToggle"; + +/** Props for the {@link DashboardHeader}. */ +export interface DashboardHeaderProps { + title: string; + subtitle?: string; +} + +/** + * DashboardHeader — the sticky, blurred page header: the active page's title and + * subtitle on the left, the theme toggle and a docs link on the right. + */ +export function DashboardHeader({ title, subtitle }: DashboardHeaderProps) { + return ( +
+ {/* Same horizontal padding as the page content so the title and the toggle/docs + actions line up with the content's left and right edges (both full-width). */} +
+
+

{title}

+ {subtitle &&
{subtitle}
} +
+
+ + + + +
+
+
+ ); +} diff --git a/packages/web/src/dashboard/components/EngineStatus.test.tsx b/packages/web/src/dashboard/components/EngineStatus.test.tsx new file mode 100644 index 0000000..f43544d --- /dev/null +++ b/packages/web/src/dashboard/components/EngineStatus.test.tsx @@ -0,0 +1,26 @@ +import { screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { ApiClient } from "../client"; +import { jsonResponse, renderWithClient } from "../testing/harness"; +import { EngineStatus } from "./EngineStatus"; + +describe("EngineStatus", () => { + it("shows connected with the driver and version when reported", async () => { + const client = { + system: { + $get: vi + .fn() + .mockResolvedValue(jsonResponse({ connected: true, driver: "@sidequest/sqlite-backend", version: "1.2.3" })), + }, + } as unknown as ApiClient; + renderWithClient(, client); + await waitFor(() => expect(screen.getByText("Engine connected")).toBeInTheDocument()); + expect(screen.getByText("@sidequest/sqlite-backend · 1.2.3")).toBeInTheDocument(); + }); + + it("shows offline when the query fails", async () => { + const client = { system: { $get: vi.fn().mockRejectedValue(new Error("down")) } } as unknown as ApiClient; + renderWithClient(, client); + await waitFor(() => expect(screen.getByText("Engine offline")).toBeInTheDocument()); + }); +}); diff --git a/packages/web/src/dashboard/components/EngineStatus.tsx b/packages/web/src/dashboard/components/EngineStatus.tsx new file mode 100644 index 0000000..447365a --- /dev/null +++ b/packages/web/src/dashboard/components/EngineStatus.tsx @@ -0,0 +1,30 @@ +import { cn } from "../../ui/cn"; +import { useSystem } from "../hooks/use-system"; + +/** + * EngineStatus — the sidebar footer's live backend indicator: a pulsing dot with the + * connection state, plus the driver and version when the server reports them (the line + * is omitted otherwise, so nothing is faked). + */ +export function EngineStatus() { + const { data, error } = useSystem({ refetchInterval: 5000 }); + const connected = !error && (data?.connected ?? false); + const meta = [data?.driver, data?.version].filter(Boolean).join(" · "); + + return ( +
+
+ + {connected ? "Engine connected" : "Engine offline"} +
+ {meta &&
{meta}
} +
+ ); +} diff --git a/packages/web/src/dashboard/components/JobDetailView.test.tsx b/packages/web/src/dashboard/components/JobDetailView.test.tsx new file mode 100644 index 0000000..0e1c94f --- /dev/null +++ b/packages/web/src/dashboard/components/JobDetailView.test.tsx @@ -0,0 +1,40 @@ +import type { JobData } from "@sidequest/core"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { JobDetailView } from "./JobDetailView"; + +const job = { + id: 7, + class: "SendEmail", + script: "jobs/send-email.js", + state: "failed", + attempt: 3, + max_attempts: 3, + constructor_args: [], + args: ["a@b.com"], + result: null, + errors: [{ message: "boom", stack: "at handler (x.js:1)" }], +} as unknown as JobData; + +describe("JobDetailView", () => { + it("renders the header, details, errors and a re-run action, and fires back", async () => { + const user = userEvent.setup(); + const onBack = vi.fn(); + const onRerun = vi.fn(); + render( undefined} />); + expect(screen.getByRole("heading", { name: /#7 — SendEmail/ })).toBeInTheDocument(); + expect(screen.getByText("boom")).toBeInTheDocument(); + await user.click(screen.getByText("Re-Run")); + expect(onRerun).toHaveBeenCalledWith(7); + await user.click(screen.getByText("Back")); + expect(onBack).toHaveBeenCalled(); + }); + + it("offers Cancel for a non-settled job", () => { + render( + undefined} onRerun={() => undefined} onCancel={() => undefined} />, + ); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/dashboard/components/JobDetailView.tsx b/packages/web/src/dashboard/components/JobDetailView.tsx new file mode 100644 index 0000000..286ca37 --- /dev/null +++ b/packages/web/src/dashboard/components/JobDetailView.tsx @@ -0,0 +1,128 @@ +import type { JobData } from "@sidequest/core"; +import type { ReactNode } from "react"; +import { Badge, type JobState } from "../../ui/Badge"; +import { Button } from "../../ui/Button"; +import { Card } from "../../ui/Card"; +import { CodeBlock } from "../../ui/CodeBlock"; +import { type Step, StepProgress, type StepStatus } from "../../ui/StepProgress"; + +/** Props for the {@link JobDetailView}. */ +export interface JobDetailViewProps { + job: JobData; + onBack: () => void; + onRerun: (id: number) => void; + onCancel: (id: number) => void; +} + +/** state -> the four lifecycle step statuses (Enqueued → Claimed → Running → last). */ +const STEP_MAP: Record = { + completed: ["done", "done", "done", "done"], + failed: ["done", "done", "done", "failed"], + canceled: ["done", "done", "active", "canceled"], + running: ["done", "done", "active", "pending"], + claimed: ["done", "active", "pending", "pending"], + waiting: ["active", "pending", "pending", "pending"], +}; + +function Row({ label, value, mono }: { label: string; value: ReactNode; mono?: boolean }) { + return ( +
+ {label} + {value} +
+ ); +} + +/** + * JobDetailView — a job's lifecycle stepper, metadata, constructor/run arguments, + * result, and error traces. Faithful to the design system's job-detail screen. + */ +export function JobDetailView({ job, onBack, onRerun, onCancel }: JobDetailViewProps) { + const statuses = STEP_MAP[job.state] ?? STEP_MAP.waiting; + const done = ["canceled", "failed", "completed"].includes(job.state); + const lastLabel = job.state === "failed" ? "Failed" : job.state === "canceled" ? "Canceled" : "Completed"; + const lastIcon = job.state === "failed" ? "CircleX" : job.state === "canceled" ? "CircleAlert" : "CircleCheck"; + const steps: Step[] = [ + { label: "Enqueued", icon: "Clock", status: statuses[0] }, + { label: "Claimed", icon: "UserCheck", status: statuses[1] }, + { label: "Running", icon: "Activity", status: statuses[2] }, + { label: lastLabel, icon: lastIcon, status: statuses[3] }, + ]; + + return ( +
+
+ +

+ #{job.id} — {job.class} + +

+
+ {done ? ( + + ) : ( + + )} +
+
+ + + + + + +
+ + + +
+ +

Constructor Arguments

+ {job.constructor_args.length > 0 ? ( + + ) : ( +

No arguments

+ )} + +

Run Arguments

+ {job.args.length > 0 ? ( + + ) : ( +

No arguments

+ )} + + {job.result != null && ( + <> +

Result

+ + + )} + + {job.errors && job.errors.length > 0 && ( + <> +

Errors

+
+ {job.errors.map((error, i) => ( +
+ {error.message} + {error.stack && ( +
+ Stack Trace +
{error.stack}
+
+ )} +
+ ))} +
+ + )} +
+
+ ); +} diff --git a/packages/web/src/dashboard/components/JobsTable.test.tsx b/packages/web/src/dashboard/components/JobsTable.test.tsx new file mode 100644 index 0000000..b225635 --- /dev/null +++ b/packages/web/src/dashboard/components/JobsTable.test.tsx @@ -0,0 +1,55 @@ +import type { JobData } from "@sidequest/core"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { JobsTable } from "./JobsTable"; + +const jobs = [ + { id: 1, class: "SendEmail", queue: "mail", state: "failed", attempt: 5, max_attempts: 5, claimed_by: null }, + { id: 2, class: "Charge", queue: "billing", state: "running", attempt: 1, max_attempts: 3, claimed_by: "w1" }, +] as unknown as JobData[]; + +const noop = () => undefined; + +describe("JobsTable", () => { + it("renders rows with #id, class, attempts and a footer, and opens a job on click", async () => { + const user = userEvent.setup(); + const onOpenJob = vi.fn(); + render( + , + ); + expect(screen.getByText("SendEmail")).toBeInTheDocument(); + expect(screen.getByText("#1")).toBeInTheDocument(); + expect(screen.getByText("5/5")).toBeInTheDocument(); + expect(screen.getByText("1–2 of 2")).toBeInTheDocument(); + await user.click(screen.getByText("SendEmail")); + expect(onOpenJob).toHaveBeenCalledWith(1); + }); + + it("renders the empty state", () => { + render( + , + ); + expect(screen.getByText(/no jobs match/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/dashboard/components/JobsTable.tsx b/packages/web/src/dashboard/components/JobsTable.tsx new file mode 100644 index 0000000..12ca342 --- /dev/null +++ b/packages/web/src/dashboard/components/JobsTable.tsx @@ -0,0 +1,146 @@ +import type { JobData } from "@sidequest/core"; +import { cn } from "../../ui/cn"; +import { Button } from "../../ui/Button"; +import { Icon } from "../../ui/Icon"; +import { Pagination } from "../../ui/Pagination"; +import { relativeTime } from "../../ui/relative-time"; +import { StatusDot, type StatusDotState } from "../../ui/StatusDot"; + +/** Props for the {@link JobsTable}. */ +export interface JobsTableProps { + jobs: JobData[]; + page: number; + pageSize: number; + total: number; + hasNext: boolean; + /** Steps the page by `delta` (−1 / +1). */ + onPage: (delta: number) => void; + onOpenJob: (id: number) => void; + onRerun: (id: number) => void; + onCancel: (id: number) => void; +} + +const HEADERS = ["Job", "Queue", "State", "Attempts", "Available", "Worker", ""]; +const RERUNNABLE = ["canceled", "failed", "completed"]; + +/** + * JobsTable — the refined jobs list: monospace `#id` chips, status dots, attempt ratios, + * relative available time, worker, and row actions revealed on hover. Clicking a row + * opens the job detail. + */ +export function JobsTable({ + jobs, + page, + pageSize, + total, + hasNext, + onPage, + onOpenJob, + onRerun, + onCancel, +}: JobsTableProps) { + const start = (page - 1) * pageSize; + return ( +
+ + + + {HEADERS.map((header, i) => ( + + ))} + + + + {jobs.length === 0 && ( + + + + )} + {jobs.map((job) => { + const rerun = RERUNNABLE.includes(job.state); + return ( + onOpenJob(job.id)} + className="group cursor-pointer border-b border-edge-subtle transition-colors hover:bg-surface-hover" + > + + + + + + + + + ); + })} + +
+ {header} +
+ No jobs match these filters. +
+
+ + #{job.id} + + {job.class} +
+
{job.queue} + + = job.max_attempts && job.state === "failed" ? "text-status-failed" : "text-fg-secondary", + )} + > + {job.attempt}/{job.max_attempts} + + {relativeTime(job.available_at ? new Date(job.available_at).toISOString() : null)} + + {job.claimed_by ?? "—"} + + + {rerun ? ( + + ) : ( + + )} + + +
+
+ + {total === 0 ? 0 : start + 1}–{start + jobs.length} of {total} + + onPage(-1)} onNext={() => onPage(1)} /> +
+
+ ); +} diff --git a/packages/web/src/dashboard/components/JobsToolbar.test.tsx b/packages/web/src/dashboard/components/JobsToolbar.test.tsx new file mode 100644 index 0000000..0a65a17 --- /dev/null +++ b/packages/web/src/dashboard/components/JobsToolbar.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { JobsToolbar } from "./JobsToolbar"; + +describe("JobsToolbar", () => { + it("renders the search box and reports typing", async () => { + const user = userEvent.setup(); + const onQuery = vi.fn(); + render(); + await user.type(screen.getByPlaceholderText(/search by class/i), "a"); + expect(onQuery).toHaveBeenCalledWith("a"); + }); +}); diff --git a/packages/web/src/dashboard/components/JobsToolbar.tsx b/packages/web/src/dashboard/components/JobsToolbar.tsx new file mode 100644 index 0000000..fb35fca --- /dev/null +++ b/packages/web/src/dashboard/components/JobsToolbar.tsx @@ -0,0 +1,28 @@ +import { Icon } from "../../ui/Icon"; + +/** Props for the {@link JobsToolbar}. */ +export interface JobsToolbarProps { + query: string; + onQuery: (query: string) => void; +} + +/** + * JobsToolbar — free-text search over jobs (by class, id, or queue) above the jobs table. + */ +export function JobsToolbar({ query, onQuery }: JobsToolbarProps) { + return ( +
+
+ + + + onQuery(e.target.value)} + placeholder="Search by class, id, or queue…" + className="sq-input h-[38px] w-full pl-[34px] pr-3 bg-surface-raised border border-edge-strong rounded-lg text-fg text-[13px] font-sans transition-[border-color,box-shadow] focus:outline-none focus:border-brand focus:ring-[3px] focus:ring-brand-ring" + /> +
+
+ ); +} diff --git a/packages/web/src/dashboard/components/QueueCard.test.tsx b/packages/web/src/dashboard/components/QueueCard.test.tsx new file mode 100644 index 0000000..71059d7 --- /dev/null +++ b/packages/web/src/dashboard/components/QueueCard.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { QueueWithCounts } from "../../api/services/queue-service"; +import { QueueCard } from "./QueueCard"; + +const queue = { + name: "default", + state: "active", + concurrency: 5, + priority: 0, + jobs: { waiting: 3, running: 1, completed: 40, failed: 2 }, +} as unknown as QueueWithCounts; + +describe("QueueCard", () => { + it("renders the queue name, chips and counts, and toggles pause", async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + render(); + expect(screen.getByText("default")).toBeInTheDocument(); + expect(screen.getByText("concurrency 5")).toBeInTheDocument(); + expect(screen.getByText("priority 0")).toBeInTheDocument(); + await user.click(screen.getByText("Pause")); + expect(onToggle).toHaveBeenCalledWith("default"); + }); + + it("offers Activate for a paused queue", () => { + render( undefined} />); + expect(screen.getByText("Activate")).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/dashboard/components/QueueCard.tsx b/packages/web/src/dashboard/components/QueueCard.tsx new file mode 100644 index 0000000..68da43c --- /dev/null +++ b/packages/web/src/dashboard/components/QueueCard.tsx @@ -0,0 +1,75 @@ +import type { QueueWithCounts } from "../../api/services/queue-service"; +import { Button } from "../../ui/Button"; +import { cn } from "../../ui/cn"; + +/** Props for the {@link QueueCard}. */ +export interface QueueCardProps { + queue: QueueWithCounts; + onToggle: (name: string) => void; +} + +const SEGMENTS: { key: "waiting" | "running" | "completed" | "failed"; color: string }[] = [ + { key: "waiting", color: "bg-status-scheduled" }, + { key: "running", color: "bg-status-running" }, + { key: "completed", color: "bg-status-completed" }, + { key: "failed", color: "bg-status-failed" }, +]; + +/** + * QueueCard — a queue at a glance: a live status pulse, concurrency/priority chips, and + * a load bar segmented by job state with a legend, plus a pause/activate toggle. + */ +export function QueueCard({ queue, onToggle }: QueueCardProps) { + const counts = queue.jobs; + const active = queue.state === "active"; + const total = SEGMENTS.reduce((sum, segment) => sum + (counts?.[segment.key] ?? 0), 0) || 1; + return ( +
+
+
+ + {queue.name} +
+ +
+ +
+ + concurrency {queue.concurrency} + + + priority {queue.priority} + +
+ +
+
+ {SEGMENTS.map((segment) => { + const value = counts?.[segment.key] ?? 0; + return value > 0 ? ( +
+ ) : null; + })} +
+
+ {SEGMENTS.map((segment) => ( + + + {segment.key} + {counts?.[segment.key] ?? 0} + + ))} +
+
+
+ ); +} diff --git a/packages/web/src/dashboard/components/SegmentedFilter.test.tsx b/packages/web/src/dashboard/components/SegmentedFilter.test.tsx new file mode 100644 index 0000000..557629f --- /dev/null +++ b/packages/web/src/dashboard/components/SegmentedFilter.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SegmentedFilter } from "./SegmentedFilter"; + +const segments = [ + { key: "all", label: "All", count: 10 }, + { key: "failed", label: "Failed", count: 2 }, +]; + +describe("SegmentedFilter", () => { + it("renders segments with counts and reports the picked key", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + expect(screen.getByText("All")).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + await user.click(screen.getByText("Failed")); + expect(onChange).toHaveBeenCalledWith("failed"); + }); +}); diff --git a/packages/web/src/dashboard/components/SegmentedFilter.tsx b/packages/web/src/dashboard/components/SegmentedFilter.tsx new file mode 100644 index 0000000..bca88e9 --- /dev/null +++ b/packages/web/src/dashboard/components/SegmentedFilter.tsx @@ -0,0 +1,52 @@ +import { cn } from "../../ui/cn"; + +/** A single segment in a {@link SegmentedFilter}. */ +export interface Segment { + key: string; + label: string; + count: number; +} + +/** Props for the {@link SegmentedFilter}. */ +export interface SegmentedFilterProps { + segments: Segment[]; + value: string; + onChange: (key: string) => void; +} + +/** + * SegmentedFilter — a pill toggle group with a live count chip per segment, for the + * jobs status filter. + */ +export function SegmentedFilter({ segments, value, onChange }: SegmentedFilterProps) { + return ( +
+ {segments.map((segment) => { + const on = value === segment.key; + return ( + + ); + })} +
+ ); +} diff --git a/packages/web/src/dashboard/components/StatTrendCard.test.tsx b/packages/web/src/dashboard/components/StatTrendCard.test.tsx new file mode 100644 index 0000000..699be8e --- /dev/null +++ b/packages/web/src/dashboard/components/StatTrendCard.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { StatTrendCard } from "./StatTrendCard"; + +describe("StatTrendCard", () => { + it("renders a zero-padded numeric value, label, delta and sparkline", () => { + const { container } = render(); + expect(screen.getByText("Running")).toBeInTheDocument(); + expect(screen.getByText("03")).toBeInTheDocument(); + expect(screen.getByText("▲ 8%")).toBeInTheDocument(); + expect(container.querySelector("svg")).not.toBeNull(); + }); + + it("renders string values verbatim and a down delta", () => { + render(); + expect(screen.getByText("1.2k")).toBeInTheDocument(); + expect(screen.getByText("▼ 2%")).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/dashboard/components/StatTrendCard.tsx b/packages/web/src/dashboard/components/StatTrendCard.tsx new file mode 100644 index 0000000..63f1d83 --- /dev/null +++ b/packages/web/src/dashboard/components/StatTrendCard.tsx @@ -0,0 +1,59 @@ +import { cn } from "../../ui/cn"; +import { Delta } from "../../ui/Delta"; +import { Sparkline } from "../../ui/Sparkline"; + +/** Tone of a {@link StatTrendCard} (maps to a status color). */ +export type StatTrendTone = "running" | "completed" | "failed" | "scheduled"; + +/** Props for the {@link StatTrendCard}. */ +export interface StatTrendCardProps { + label: string; + value: number | string; + tone: StatTrendTone; + /** Recent values for the inline sparkline. */ + series: number[]; + /** Percentage trend for the {@link Delta} chip. */ + delta: number; +} + +const BAR: Record = { + running: "bg-status-running", + completed: "bg-status-completed", + failed: "bg-status-failed", + scheduled: "bg-status-scheduled", +}; + +const TEXT: Record = { + running: "text-status-running", + completed: "text-status-completed", + failed: "text-status-failed", + scheduled: "text-status-scheduled", +}; + +const COLOR: Record = { + running: "var(--status-running)", + completed: "var(--status-completed)", + failed: "var(--status-failed)", + scheduled: "var(--status-scheduled)", +}; + +/** + * StatTrendCard — an overview metric tile: a tone top-bar, a mono label with a trend + * delta, and a large mono value paired with an inline sparkline of its recent series. + */ +export function StatTrendCard({ label, value, tone, series, delta }: StatTrendCardProps) { + const display = typeof value === "number" ? String(value).padStart(2, "0") : value; + return ( +
+ +
+ {label} + +
+
+ {display} + +
+
+ ); +} diff --git a/packages/web/src/dashboard/components/SuccessRateCard.test.tsx b/packages/web/src/dashboard/components/SuccessRateCard.test.tsx new file mode 100644 index 0000000..11241be --- /dev/null +++ b/packages/web/src/dashboard/components/SuccessRateCard.test.tsx @@ -0,0 +1,11 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { SuccessRateCard } from "./SuccessRateCard"; + +describe("SuccessRateCard", () => { + it("renders the rate, throughput and range label", () => { + render(); + expect(screen.getByText("94")).toBeInTheDocument(); + expect(screen.getByText("132 jobs · last 12m")).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/dashboard/components/SuccessRateCard.tsx b/packages/web/src/dashboard/components/SuccessRateCard.tsx new file mode 100644 index 0000000..d62ee30 --- /dev/null +++ b/packages/web/src/dashboard/components/SuccessRateCard.tsx @@ -0,0 +1,31 @@ +/** Props for the {@link SuccessRateCard}. */ +export interface SuccessRateCardProps { + /** Success percentage (0–100). */ + rate: number; + /** Number of jobs the rate is computed over. */ + throughput: number; + /** Human label for the window, e.g. "last 12m". */ + rangeLabel: string; +} + +/** + * SuccessRateCard — the completed / (completed + failed) success gauge: a large mono + * percentage over a brand-gradient bar, with the throughput count beneath. + */ +export function SuccessRateCard({ rate, throughput, rangeLabel }: SuccessRateCardProps) { + return ( +
+ Success rate +
+ {rate} + % +
+
+
+
+
+ {throughput} jobs · {rangeLabel} +
+
+ ); +} diff --git a/packages/web/src/dashboard/components/ThroughputChart.test.tsx b/packages/web/src/dashboard/components/ThroughputChart.test.tsx new file mode 100644 index 0000000..9a519e5 --- /dev/null +++ b/packages/web/src/dashboard/components/ThroughputChart.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ThroughputChart } from "./ThroughputChart"; + +describe("ThroughputChart", () => { + it("renders the card and range selector without a canvas context (jsdom)", async () => { + const user = userEvent.setup(); + const onRangeChange = vi.fn(); + render( + , + ); + expect(screen.getByText("Job throughput")).toBeInTheDocument(); + await user.selectOptions(screen.getByRole("combobox"), "12h"); + expect(onRangeChange).toHaveBeenCalledWith("12h"); + }); +}); diff --git a/packages/web/src/dashboard/components/ThroughputChart.tsx b/packages/web/src/dashboard/components/ThroughputChart.tsx new file mode 100644 index 0000000..7a008e5 --- /dev/null +++ b/packages/web/src/dashboard/components/ThroughputChart.tsx @@ -0,0 +1,150 @@ +import { Chart, type ChartConfiguration } from "chart.js/auto"; +import { useEffect, useRef } from "react"; +import { Select } from "../../ui/Select"; + +/** A single time bucket for the {@link ThroughputChart}. */ +export interface ThroughputPoint { + timestamp: string | Date; + completed: number; + failed: number; +} + +/** Props for the {@link ThroughputChart}. */ +export interface ThroughputChartProps { + data: ThroughputPoint[]; + range: string; + onRangeChange: (range: string) => void; +} + +const RANGES = [ + { value: "12m", label: "Last 12 min" }, + { value: "12h", label: "Last 12 hrs" }, + { value: "12d", label: "Last 12 days" }, +]; + +function hhmm(timestamp: string | Date): string { + const date = new Date(timestamp); + return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; +} + +/** + * ThroughputChart — completed vs failed jobs over time, drawn with Chart.js and a range + * selector. The chart is created once and then updated in place as new data arrives, so + * polling gently pushes the series along instead of replaying the entrance animation on + * every refetch. Guards against environments without a 2D canvas context (jsdom). + */ +export function ThroughputChart({ data, range, onRangeChange }: ThroughputChartProps) { + const canvasRef = useRef(null); + const chartRef = useRef(null); + + // Create the chart once (empty), on mount. + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx || typeof ResizeObserver === "undefined") { + return; + } + const config: ChartConfiguration = { + type: "line", + data: { + labels: [], + datasets: [ + { + label: "Completed", + data: [], + borderColor: "#4faf75", + backgroundColor: "rgba(79,175,117,0.10)", + tension: 0.4, + fill: true, + pointRadius: 0, + borderWidth: 2, + }, + { + label: "Failed", + data: [], + borderColor: "#b75252", + backgroundColor: "rgba(183,82,82,0.10)", + tension: 0.4, + fill: true, + pointRadius: 0, + borderWidth: 2, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + // Only the first paint animates; in-place updates use a short, uniform tween so + // new points ease in rather than re-running the entrance reveal. + animation: { duration: 500 }, + animations: { y: { duration: 300 }, x: { duration: 0 } }, + interaction: { mode: "index", intersect: false }, + scales: { + x: { ticks: { color: "#6b7688", font: { size: 10 }, maxRotation: 0 }, grid: { display: false } }, + y: { + beginAtZero: true, + ticks: { color: "#6b7688", font: { size: 10 }, maxTicksLimit: 5 }, + grid: { color: "rgba(255,255,255,0.045)" }, + border: { display: false }, + }, + }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: "#0b0f1a", + borderColor: "#2a3b5f", + borderWidth: 1, + titleColor: "#cdd5e0", + bodyColor: "#9aa6ba", + padding: 10, + cornerRadius: 8, + usePointStyle: true, + boxWidth: 8, + boxHeight: 8, + }, + }, + }, + }; + const chart = new Chart(ctx, config); + chartRef.current = chart; + const box = canvas.parentElement; + const observer = new ResizeObserver(() => chart.resize()); + if (box) { + observer.observe(box); + } + return () => { + observer.disconnect(); + chart.destroy(); + chartRef.current = null; + }; + }, []); + + // Push new data into the existing chart (no teardown → no entrance re-animation). + useEffect(() => { + const chart = chartRef.current; + if (!chart) { + return; + } + chart.data.labels = data.map((point) => hhmm(point.timestamp)); + chart.data.datasets[0].data = data.map((point) => point.completed); + chart.data.datasets[1].data = data.map((point) => point.failed); + chart.update(); + }, [data]); + + return ( +
+
+
+
Job throughput
+
Completed vs failed over time
+
+
+