From a8aefed91d91a26e9af12e12a1ac3a9752c3f288 Mon Sep 17 00:00:00 2001 From: merencia Date: Sat, 4 Jul 2026 12:19:17 -0300 Subject: [PATCH 1/7] feat(web): add the OSS dashboard app (overview, jobs, queues) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the react dashboard that consumes /api via the typed hooks: a hash-routed shell with a page registry (the seam-4 extension point) and three screens built from the /ui components. - shell: sidebar built from a pages[] registry; the pro passes a superset array to add screens without forking. - overview: live stat cards plus a dependency-free svg throughput chart. - jobs: filters (state/queue/class), a paginated table, and per-row run/rerun/cancel actions. - queues: a table with a pause/resume toggle. - hash router via useSyncExternalStore; DashboardApp wires the api client into context. - dev: a vite dev server (dev:app) that proxies /api, plus index.html + vite.app.config.ts. 28 dashboard tests in jsdom (router, shell, pages against a fake client). source only; the ./dashboard subpath export and the static build land with the web façade that serves it. --- eslint.config.js | 1 + packages/web/index.html | 12 +++ packages/web/package.json | 1 + packages/web/src/dashboard/app/app.tsx | 18 ++++ packages/web/src/dashboard/app/main.tsx | 6 ++ packages/web/src/dashboard/app/pages.tsx | 11 +++ .../web/src/dashboard/app/pages/jobs.test.tsx | 25 ++++++ packages/web/src/dashboard/app/pages/jobs.tsx | 86 +++++++++++++++++++ .../src/dashboard/app/pages/overview.test.tsx | 23 +++++ .../web/src/dashboard/app/pages/overview.tsx | 62 +++++++++++++ .../src/dashboard/app/pages/queues.test.tsx | 22 +++++ .../web/src/dashboard/app/pages/queues.tsx | 36 ++++++++ .../web/src/dashboard/app/router.test.tsx | 29 +++++++ packages/web/src/dashboard/app/router.ts | 20 +++++ packages/web/src/dashboard/app/shell.test.tsx | 33 +++++++ packages/web/src/dashboard/app/shell.tsx | 42 +++++++++ packages/web/src/dashboard/index.ts | 8 ++ .../web/src/dashboard/testing/harness.tsx | 23 +++-- packages/web/vite.app.config.ts | 19 ++++ 19 files changed, 470 insertions(+), 7 deletions(-) create mode 100644 packages/web/index.html create mode 100644 packages/web/src/dashboard/app/app.tsx create mode 100644 packages/web/src/dashboard/app/main.tsx create mode 100644 packages/web/src/dashboard/app/pages.tsx create mode 100644 packages/web/src/dashboard/app/pages/jobs.test.tsx create mode 100644 packages/web/src/dashboard/app/pages/jobs.tsx create mode 100644 packages/web/src/dashboard/app/pages/overview.test.tsx create mode 100644 packages/web/src/dashboard/app/pages/overview.tsx create mode 100644 packages/web/src/dashboard/app/pages/queues.test.tsx create mode 100644 packages/web/src/dashboard/app/pages/queues.tsx create mode 100644 packages/web/src/dashboard/app/router.test.tsx create mode 100644 packages/web/src/dashboard/app/router.ts create mode 100644 packages/web/src/dashboard/app/shell.test.tsx create mode 100644 packages/web/src/dashboard/app/shell.tsx create mode 100644 packages/web/vite.app.config.ts 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..e71af5d 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" diff --git a/packages/web/src/dashboard/app/app.tsx b/packages/web/src/dashboard/app/app.tsx new file mode 100644 index 0000000..e5bcfcf --- /dev/null +++ b/packages/web/src/dashboard/app/app.tsx @@ -0,0 +1,18 @@ +import { useMemo } from "react"; +import { type ApiClient, createApiClient } from "../client"; +import { ApiClientProvider } from "../context"; +import { ossPages } from "./pages"; +import { DashboardShell } from "./shell"; + +/** + * The OSS dashboard app: wires the API client 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]); + 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..f756e49 --- /dev/null +++ b/packages/web/src/dashboard/app/main.tsx @@ -0,0 +1,6 @@ +import { createRoot } from "react-dom/client"; +import "../../ui/styles.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..134ddd1 --- /dev/null +++ b/packages/web/src/dashboard/app/pages.tsx @@ -0,0 +1,11 @@ +import { 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: "layout-dashboard" }, element: }, + { path: "/jobs", nav: { label: "Jobs", icon: "list" }, element: }, + { path: "/queues", nav: { label: "Queues", icon: "layers" }, 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..f478b17 --- /dev/null +++ b/packages/web/src/dashboard/app/pages/jobs.test.tsx @@ -0,0 +1,25 @@ +import { screen, waitFor } from "@testing-library/react"; +import type { ApiClient } from "../../client"; +import { jsonResponse, renderWithClient } from "../../testing/harness"; +import { JobsPage } from "./jobs"; + +describe("JobsPage", () => { + it("renders the jobs returned by the api", async () => { + const client = { + jobs: { + $get: vi.fn().mockResolvedValue( + jsonResponse({ + jobs: [{ id: 1, class: "SendEmailJob", queue: "email", state: "failed", attempt: 5, max_attempts: 5 }], + pagination: { page: 1, pageSize: 20, hasNextPage: false }, + }), + ), + meta: { $get: vi.fn().mockResolvedValue(jsonResponse({ queues: ["email"] })) }, + }, + } as unknown as ApiClient; + + renderWithClient(, client); + + await waitFor(() => expect(screen.getByText("SendEmailJob")).toBeInTheDocument()); + expect(screen.getByText("5/5")).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..ad91c29 --- /dev/null +++ b/packages/web/src/dashboard/app/pages/jobs.tsx @@ -0,0 +1,86 @@ +import type { JobData } from "@sidequest/core"; +import { useState } from "react"; +import { Badge, type JobState } from "../../../ui/Badge"; +import { Button } from "../../../ui/Button"; +import { Input } from "../../../ui/Input"; +import { Pagination } from "../../../ui/Pagination"; +import { Select } from "../../../ui/Select"; +import { type Column, Table } from "../../../ui/Table"; +import { type JobsFilter, useJobActions, useJobs, useJobsMeta } from "../../hooks/use-jobs"; + +const STATES = ["waiting", "running", "completed", "failed", "canceled"]; + +/** The jobs screen: filters, a paginated table, and per-row actions. */ +export function JobsPage() { + const [filters, setFilters] = useState({ page: 1, pageSize: 20 }); + const { data, refetch } = useJobs(filters, { refetchInterval: 3000 }); + const { data: meta } = useJobsMeta(); + const actions = useJobActions(); + + const patch = (next: Partial) => setFilters((f) => ({ ...f, page: 1, ...next })); + const act = (fn: Promise) => void fn.then(refetch); + + const columns: Column[] = [ + { key: "id", label: "ID", mono: true, width: "60px" }, + { key: "class", label: "Class" }, + { key: "queue", label: "Queue" }, + { key: "state", label: "State", render: (job) => }, + { key: "attempt", label: "Attempt", render: (job) => `${job.attempt}/${job.max_attempts}` }, + { + key: "actions", + label: "", + align: "right", + render: (job) => ( +
+ + + +
+ ), + }, + ]; + + return ( +
+

Jobs

+ +
+ patch({ queue: e.target.value || undefined })} + options={[{ value: "", label: "All queues" }, ...(meta?.queues ?? []).map((q) => ({ value: q, label: q }))]} + /> + patch({ class: e.target.value || undefined })} + /> +
+ + + +
+ setFilters((f) => ({ ...f, page: Math.max(1, (f.page ?? 1) - 1) }))} + onNext={() => setFilters((f) => ({ ...f, page: (f.page ?? 1) + 1 }))} + /> +
+ + ); +} 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..ff0025e --- /dev/null +++ b/packages/web/src/dashboard/app/pages/overview.test.tsx @@ -0,0 +1,23 @@ +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 stat cards from the overview counts", 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: "t", total: 3 }])) }, + }, + } as unknown as ApiClient; + + renderWithClient(, client); + + await waitFor(() => expect(screen.getByText("17")).toBeInTheDocument()); + expect(screen.getByText("Completed")).toBeInTheDocument(); + expect(screen.getByText("9")).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..c782cab --- /dev/null +++ b/packages/web/src/dashboard/app/pages/overview.tsx @@ -0,0 +1,62 @@ +import type { StatTone } from "../../../ui/StatCard"; +import { StatCard } from "../../../ui/StatCard"; +import { useOverview, useOverviewTimeseries } from "../../hooks/use-overview"; + +const CARDS: { key: string; label: string; tone: StatTone }[] = [ + { key: "waiting", label: "Waiting", tone: "scheduled" }, + { key: "running", label: "Running", tone: "running" }, + { key: "completed", label: "Completed", tone: "completed" }, + { key: "failed", label: "Failed", tone: "failed" }, + { key: "canceled", label: "Canceled", tone: "neutral" }, + { key: "total", label: "Total", tone: "neutral" }, +]; + +/** The overview home: live counts and a small throughput chart. */ +export function OverviewPage() { + const { data, isLoading } = useOverview("12m", { refetchInterval: 2000 }); + + return ( +
+

Overview

+
+ {CARDS.map((card) => ( + + ))} +
+ +
+ ); +} + +/** A minimal SVG bar chart of total jobs per time bucket. No charting dependency. */ +function Timeseries() { + const { data } = useOverviewTimeseries("12m", { refetchInterval: 5000 }); + const points = data ?? []; + if (points.length === 0) return null; + + const max = Math.max(1, ...points.map((p) => p.total)); + const width = 100 / points.length; + + return ( +
+

Throughput

+ + {points.map((point, i) => ( + + ))} + +
+ ); +} 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..c614138 --- /dev/null +++ b/packages/web/src/dashboard/app/pages/queues.test.tsx @@ -0,0 +1,22 @@ +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 queues with a pause/resume toggle", async () => { + const client = { + queues: { + $get: vi.fn().mockResolvedValue( + jsonResponse([{ name: "email", state: "active", concurrency: 2, priority: 20, jobs: { total: 8 } }]), + ), + }, + } as unknown as ApiClient; + + renderWithClient(, client); + + await waitFor(() => expect(screen.getByText("email")).toBeInTheDocument()); + // an active queue offers a "Pause" action + expect(screen.getByText("Pause")).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..7296bcd --- /dev/null +++ b/packages/web/src/dashboard/app/pages/queues.tsx @@ -0,0 +1,36 @@ +import type { QueueWithCounts } from "../../../api/services/queue-service"; +import { Badge, type QueueState } from "../../../ui/Badge"; +import { Button } from "../../../ui/Button"; +import { type Column, Table } from "../../../ui/Table"; +import { useQueueActions, useQueues } from "../../hooks/use-queues"; + +/** The queues screen: each queue with its counts and a pause/resume toggle. */ +export function QueuesPage() { + const { data, refetch } = useQueues({ refetchInterval: 3000 }); + const actions = useQueueActions(); + + const columns: Column[] = [ + { key: "name", label: "Queue" }, + { key: "state", label: "State", render: (queue) => }, + { key: "concurrency", label: "Concurrency", align: "right" }, + { key: "priority", label: "Priority", align: "right" }, + { key: "jobs", label: "Jobs", align: "right", render: (queue) => queue.jobs?.total ?? 0 }, + { + key: "toggle", + label: "", + align: "right", + render: (queue) => ( + + ), + }, + ]; + + return ( +
+

Queues

+
+ + ); +} diff --git a/packages/web/src/dashboard/app/router.test.tsx b/packages/web/src/dashboard/app/router.test.tsx new file mode 100644 index 0000000..e695091 --- /dev/null +++ b/packages/web/src/dashboard/app/router.test.tsx @@ -0,0 +1,29 @@ +import { act, renderHook } from "@testing-library/react"; +import { useHashRoute } from "./router"; + +describe("useHashRoute", () => { + beforeEach(() => { + window.location.hash = ""; + }); + + it("defaults to '/'", () => { + const { result } = renderHook(() => useHashRoute()); + expect(result.current.path).toBe("/"); + }); + + it("reflects the current hash", () => { + window.location.hash = "/queues"; + const { result } = renderHook(() => useHashRoute()); + expect(result.current.path).toBe("/queues"); + }); + + it("navigate updates the hash and the reported path", () => { + const { result } = renderHook(() => useHashRoute()); + act(() => { + result.current.navigate("/jobs"); + window.dispatchEvent(new Event("hashchange")); + }); + expect(window.location.hash).toBe("#/jobs"); + expect(result.current.path).toBe("/jobs"); + }); +}); diff --git a/packages/web/src/dashboard/app/router.ts b/packages/web/src/dashboard/app/router.ts new file mode 100644 index 0000000..d034f84 --- /dev/null +++ b/packages/web/src/dashboard/app/router.ts @@ -0,0 +1,20 @@ +import { useCallback, useSyncExternalStore } from "react"; + +function subscribe(onChange: () => void) { + window.addEventListener("hashchange", onChange); + return () => window.removeEventListener("hashchange", onChange); +} + +/** The current route path from the URL hash, defaulting to "/". */ +export function currentPath(): string { + return window.location.hash.replace(/^#/, "") || "/"; +} + +/** Reads the hash route and lets you navigate. Hash-based, so no server rewrites are needed. */ +export function useHashRoute() { + const path = useSyncExternalStore(subscribe, currentPath, () => "/"); + const navigate = useCallback((to: string) => { + window.location.hash = to; + }, []); + return { path, navigate }; +} 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..d14047b --- /dev/null +++ b/packages/web/src/dashboard/app/shell.test.tsx @@ -0,0 +1,33 @@ +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { DashboardShell, type DashboardPage } from "./shell"; + +const pages: DashboardPage[] = [ + { path: "/", nav: { label: "Home" }, element:
home page
}, + { path: "/jobs", nav: { label: "Jobs" }, element:
jobs page
}, +]; + +describe("DashboardShell", () => { + beforeEach(() => { + window.location.hash = ""; + }); + + it("renders the nav entries and the active page", () => { + render(); + expect(screen.getByText("Home")).toBeInTheDocument(); + expect(screen.getByText("Jobs")).toBeInTheDocument(); + expect(screen.getByText("home page")).toBeInTheDocument(); + }); + + it("switches the active page when a nav entry is clicked", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("Jobs")); + act(() => { + window.dispatchEvent(new Event("hashchange")); + }); + + 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..ecaf1a6 --- /dev/null +++ b/packages/web/src/dashboard/app/shell.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import { NavItem } from "../../ui/NavItem"; +import { Sidebar } from "../../ui/Sidebar"; +import { useHashRoute } from "./router"; + +/** A dashboard page: its route, its nav entry, and what to render. */ +export interface DashboardPage { + path: string; + nav: { label: string; icon?: string }; + element: ReactNode; +} + +/** + * The dashboard shell: a sidebar built from `pages[].nav` and a content area that renders the + * active page. This is the page-registry seam: the Pro passes a superset `pages` array (OSS + * pages plus its own) to add screens without forking. + */ +export function DashboardShell({ pages }: { pages: DashboardPage[] }) { + const { path, navigate } = useHashRoute(); + const active = pages.find((page) => page.path === path) ?? pages[0]; + + return ( +
+ + {pages.map((page) => ( + { + e.preventDefault(); + navigate(page.path); + }} + /> + ))} + +
{active.element}
+
+ ); +} diff --git a/packages/web/src/dashboard/index.ts b/packages/web/src/dashboard/index.ts index 6aef72b..f4e2e25 100644 --- a/packages/web/src/dashboard/index.ts +++ b/packages/web/src/dashboard/index.ts @@ -5,3 +5,11 @@ export * from "./hooks/to-query"; export * from "./hooks/use-jobs"; export * from "./hooks/use-overview"; export * from "./hooks/use-queues"; + +export * from "./app/app"; +export * from "./app/pages"; +export * from "./app/pages/jobs"; +export * from "./app/pages/overview"; +export * from "./app/pages/queues"; +export * from "./app/router"; +export * from "./app/shell"; diff --git a/packages/web/src/dashboard/testing/harness.tsx b/packages/web/src/dashboard/testing/harness.tsx index a1859ea..6f381dc 100644 --- a/packages/web/src/dashboard/testing/harness.tsx +++ b/packages/web/src/dashboard/testing/harness.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderHook } from "@testing-library/react"; -import type { ReactNode } from "react"; +import { render, renderHook } from "@testing-library/react"; +import type { ReactElement, ReactNode } from "react"; import type { ApiClient } from "../client"; import { ApiClientProvider } from "../context"; @@ -10,17 +10,26 @@ export function jsonResponse(body: T) { } /** - * Renders a hook with a fake API client and a fresh QueryClient supplied through context. - * Retries are off so a rejected query/mutation surfaces its error at once instead of backing off. + * A wrapper supplying a fake API client and a fresh QueryClient through context. Retries are off + * so a rejected query/mutation surfaces its error at once instead of backing off. */ -export function renderHookWithClient(hook: () => R, client: ApiClient) { +function withClient(client: ApiClient) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); - const wrapper = ({ children }: { children: ReactNode }) => ( + return ({ children }: { children: ReactNode }) => ( {children} ); - return renderHook(hook, { wrapper }); +} + +/** Renders a hook with a fake API client supplied through context. */ +export function renderHookWithClient(hook: () => R, client: ApiClient) { + return renderHook(hook, { wrapper: withClient(client) }); +} + +/** Renders a component tree with a fake API client supplied through context. */ +export function renderWithClient(ui: ReactElement, client: ApiClient) { + return render(ui, { wrapper: withClient(client) }); } diff --git a/packages/web/vite.app.config.ts b/packages/web/vite.app.config.ts new file mode 100644 index 0000000..623a98e --- /dev/null +++ b/packages/web/vite.app.config.ts @@ -0,0 +1,19 @@ +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; +import { defineConfig } from "vite"; + +// Dev/build config for the OSS dashboard SPA. In dev it proxies /api to a running API server +// (SQ_API, default the demo on :4137). The production static build (dist/app) is what the +// web façade will serve. +export default defineConfig({ + root: import.meta.dirname, + plugins: [react()], + server: { + port: 5199, + proxy: { "/api": process.env.SQ_API ?? "http://localhost:4137" }, + }, + build: { + outDir: resolve(import.meta.dirname, "dist/app"), + emptyOutDir: false, + }, +}); From 935e0096550adf1777f45082bc2841f162b2b231 Mon Sep 17 00:00:00 2001 From: merencia Date: Sun, 5 Jul 2026 06:32:41 -0300 Subject: [PATCH 2/7] feat(web): rebuild the dashboard to the modern design system kit reworks the oss dashboard to match the design system's modern kit. promotes the kit's reusable primitives (statusdot, sparkline, delta, kbd, relative-time) into ui/ as tested tailwind components, and adds a layer of stateful business components (sidebar with cmd-k palette, sparkline stat cards, throughput chart, segmented job filters, refined jobs table, queue load-bar cards, job detail) wired to the existing hooks. pages just compose those; the shell keeps the page-registry seam for the pro. adds chart.js for the throughput chart. no placeholder data: the sidebar footer shows real engine connectivity from a new /api/system endpoint (driver and version surface once the server passes them), and the decorative workers/settings pages and filter/enqueue buttons are dropped. --- packages/web/package.json | 1 + packages/web/src/api/app.ts | 9 +- packages/web/src/api/routes/system.ts | 5 + .../src/api/services/system-service.test.ts | 18 ++ .../web/src/api/services/system-service.ts | 40 +++++ packages/web/src/dashboard/app/main.tsx | 1 + packages/web/src/dashboard/app/pages.tsx | 36 +++- .../web/src/dashboard/app/pages/jobs.test.tsx | 39 +++-- packages/web/src/dashboard/app/pages/jobs.tsx | 156 ++++++++++-------- .../src/dashboard/app/pages/overview.test.tsx | 17 +- .../web/src/dashboard/app/pages/overview.tsx | 107 ++++++------ .../src/dashboard/app/pages/queues.test.tsx | 15 +- .../web/src/dashboard/app/pages/queues.tsx | 33 +--- packages/web/src/dashboard/app/shell.test.tsx | 47 +++++- packages/web/src/dashboard/app/shell.tsx | 104 +++++++++--- .../src/dashboard/components/AppSidebar.tsx | 108 ++++++++++++ .../dashboard/components/CommandPalette.tsx | 101 ++++++++++++ .../dashboard/components/DashboardHeader.tsx | 35 ++++ .../src/dashboard/components/EngineStatus.tsx | 30 ++++ .../dashboard/components/JobDetailView.tsx | 128 ++++++++++++++ .../src/dashboard/components/JobsTable.tsx | 146 ++++++++++++++++ .../src/dashboard/components/JobsToolbar.tsx | 28 ++++ .../src/dashboard/components/QueueCard.tsx | 75 +++++++++ .../dashboard/components/SegmentedFilter.tsx | 52 ++++++ .../dashboard/components/StatTrendCard.tsx | 59 +++++++ .../dashboard/components/SuccessRateCard.tsx | 31 ++++ .../dashboard/components/ThroughputChart.tsx | 129 +++++++++++++++ packages/web/src/dashboard/dashboard.css | 32 ++++ .../web/src/dashboard/hooks/use-system.ts | 15 ++ packages/web/src/dashboard/index.ts | 1 + packages/web/src/ui/Delta.test.tsx | 16 ++ packages/web/src/ui/Delta.tsx | 27 +++ packages/web/src/ui/Kbd.test.tsx | 12 ++ packages/web/src/ui/Kbd.tsx | 25 +++ packages/web/src/ui/Sparkline.test.tsx | 30 ++++ packages/web/src/ui/Sparkline.tsx | 63 +++++++ packages/web/src/ui/StatusDot.test.tsx | 18 ++ packages/web/src/ui/StatusDot.tsx | 59 +++++++ packages/web/src/ui/index.ts | 9 + packages/web/src/ui/relative-time.test.ts | 23 +++ packages/web/src/ui/relative-time.ts | 23 +++ packages/web/src/ui/styles.css | 4 +- packages/web/src/ui/tokens/base.css | 10 ++ yarn.lock | 17 ++ 44 files changed, 1730 insertions(+), 204 deletions(-) create mode 100644 packages/web/src/api/routes/system.ts create mode 100644 packages/web/src/api/services/system-service.test.ts create mode 100644 packages/web/src/api/services/system-service.ts create mode 100644 packages/web/src/dashboard/components/AppSidebar.tsx create mode 100644 packages/web/src/dashboard/components/CommandPalette.tsx create mode 100644 packages/web/src/dashboard/components/DashboardHeader.tsx create mode 100644 packages/web/src/dashboard/components/EngineStatus.tsx create mode 100644 packages/web/src/dashboard/components/JobDetailView.tsx create mode 100644 packages/web/src/dashboard/components/JobsTable.tsx create mode 100644 packages/web/src/dashboard/components/JobsToolbar.tsx create mode 100644 packages/web/src/dashboard/components/QueueCard.tsx create mode 100644 packages/web/src/dashboard/components/SegmentedFilter.tsx create mode 100644 packages/web/src/dashboard/components/StatTrendCard.tsx create mode 100644 packages/web/src/dashboard/components/SuccessRateCard.tsx create mode 100644 packages/web/src/dashboard/components/ThroughputChart.tsx create mode 100644 packages/web/src/dashboard/dashboard.css create mode 100644 packages/web/src/dashboard/hooks/use-system.ts create mode 100644 packages/web/src/ui/Delta.test.tsx create mode 100644 packages/web/src/ui/Delta.tsx create mode 100644 packages/web/src/ui/Kbd.test.tsx create mode 100644 packages/web/src/ui/Kbd.tsx create mode 100644 packages/web/src/ui/Sparkline.test.tsx create mode 100644 packages/web/src/ui/Sparkline.tsx create mode 100644 packages/web/src/ui/StatusDot.test.tsx create mode 100644 packages/web/src/ui/StatusDot.tsx create mode 100644 packages/web/src/ui/relative-time.test.ts create mode 100644 packages/web/src/ui/relative-time.ts diff --git a/packages/web/package.json b/packages/web/package.json index e71af5d..bdbe2ec 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -53,6 +53,7 @@ "@sidequest/core": "workspace:*", "@sidequest/engine": "workspace:*", "@tanstack/react-query": "^5.101.2", + "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/main.tsx b/packages/web/src/dashboard/app/main.tsx index f756e49..e6ae7f8 100644 --- a/packages/web/src/dashboard/app/main.tsx +++ b/packages/web/src/dashboard/app/main.tsx @@ -1,5 +1,6 @@ import { createRoot } from "react-dom/client"; import "../../ui/styles.css"; +import "../dashboard.css"; import { DashboardApp } from "./app"; const root = document.getElementById("root"); diff --git a/packages/web/src/dashboard/app/pages.tsx b/packages/web/src/dashboard/app/pages.tsx index 134ddd1..b082748 100644 --- a/packages/web/src/dashboard/app/pages.tsx +++ b/packages/web/src/dashboard/app/pages.tsx @@ -5,7 +5,37 @@ 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: "layout-dashboard" }, element: }, - { path: "/jobs", nav: { label: "Jobs", icon: "list" }, element: }, - { path: "/queues", nav: { label: "Queues", icon: "layers" }, element: }, + { + path: "/", + nav: { + label: "Overview", + icon: "layout-dashboard", + 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: , + }, + { + 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 index f478b17..a1047b2 100644 --- a/packages/web/src/dashboard/app/pages/jobs.test.tsx +++ b/packages/web/src/dashboard/app/pages/jobs.test.tsx @@ -3,23 +3,36 @@ import type { ApiClient } from "../../client"; import { jsonResponse, renderWithClient } from "../../testing/harness"; import { JobsPage } 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", () => { - it("renders the jobs returned by the api", async () => { - const client = { - jobs: { - $get: vi.fn().mockResolvedValue( - jsonResponse({ - jobs: [{ id: 1, class: "SendEmailJob", queue: "email", state: "failed", attempt: 5, max_attempts: 5 }], - pagination: { page: 1, pageSize: 20, hasNextPage: false }, - }), - ), - meta: { $get: vi.fn().mockResolvedValue(jsonResponse({ queues: ["email"] })) }, - }, - } as unknown as ApiClient; + beforeEach(() => { + window.location.hash = ""; + }); - renderWithClient(, client); + 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 index ad91c29..0748240 100644 --- a/packages/web/src/dashboard/app/pages/jobs.tsx +++ b/packages/web/src/dashboard/app/pages/jobs.tsx @@ -1,86 +1,98 @@ -import type { JobData } from "@sidequest/core"; import { useState } from "react"; -import { Badge, type JobState } from "../../../ui/Badge"; -import { Button } from "../../../ui/Button"; -import { Input } from "../../../ui/Input"; -import { Pagination } from "../../../ui/Pagination"; -import { Select } from "../../../ui/Select"; -import { type Column, Table } from "../../../ui/Table"; -import { type JobsFilter, useJobActions, useJobs, useJobsMeta } from "../../hooks/use-jobs"; +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"; +import { useHashRoute } from "../router"; -const STATES = ["waiting", "running", "completed", "failed", "canceled"]; +const PAGE_SIZE = 11; -/** The jobs screen: filters, a paginated table, and per-row actions. */ +/** The jobs screen: routes between the list and a single job's detail (`#/jobs/`). */ export function JobsPage() { - const [filters, setFilters] = useState({ page: 1, pageSize: 20 }); - const { data, refetch } = useJobs(filters, { refetchInterval: 3000 }); - const { data: meta } = useJobsMeta(); - const actions = useJobActions(); + const { path, navigate } = useHashRoute(); + const match = /^\/jobs\/(\d+)$/.exec(path); + if (match) { + return navigate("/jobs")} />; + } + return navigate(`/jobs/${id}`)} />; +} - const patch = (next: Partial) => setFilters((f) => ({ ...f, page: 1, ...next })); - const act = (fn: Promise) => void fn.then(refetch); +/** 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 columns: Column[] = [ - { key: "id", label: "ID", mono: true, width: "60px" }, - { key: "class", label: "Class" }, - { key: "queue", label: "Queue" }, - { key: "state", label: "State", render: (job) => }, - { key: "attempt", label: "Attempt", render: (job) => `${job.attempt}/${job.max_attempts}` }, - { - key: "actions", - label: "", - align: "right", - render: (job) => ( -
- - - -
- ), - }, +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" }, ]; - return ( -
-

Jobs

+/** The jobs list: search, segmented status filters, and the paginated table. */ +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(); -
- patch({ queue: e.target.value || undefined })} - options={[{ value: "", label: "All queues" }, ...(meta?.queues ?? []).map((q) => ({ value: q, label: q }))]} - /> - patch({ class: e.target.value || undefined })} - /> -
+ 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, + })); -
- setFilters((f) => ({ ...f, page: Math.max(1, (f.page ?? 1) - 1) }))} - onNext={() => setFilters((f) => ({ ...f, page: (f.page ?? 1) + 1 }))} - /> -
+ 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 index ff0025e..0b03abe 100644 --- a/packages/web/src/dashboard/app/pages/overview.test.tsx +++ b/packages/web/src/dashboard/app/pages/overview.test.tsx @@ -4,20 +4,29 @@ import { jsonResponse, renderWithClient } from "../../testing/harness"; import { OverviewPage } from "./overview"; describe("OverviewPage", () => { - it("renders the stat cards from the overview counts", async () => { + 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: "t", total: 3 }])) }, + 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); - await waitFor(() => expect(screen.getByText("17")).toBeInTheDocument()); + // padded mono values: completed 9 -> "09", running 1 -> "01" + await waitFor(() => expect(screen.getByText("09")).toBeInTheDocument()); expect(screen.getByText("Completed")).toBeInTheDocument(); - expect(screen.getByText("9")).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 index c782cab..10ebe50 100644 --- a/packages/web/src/dashboard/app/pages/overview.tsx +++ b/packages/web/src/dashboard/app/pages/overview.tsx @@ -1,62 +1,75 @@ -import type { StatTone } from "../../../ui/StatCard"; -import { StatCard } from "../../../ui/StatCard"; +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"; -const CARDS: { key: string; label: string; tone: StatTone }[] = [ - { key: "waiting", label: "Waiting", tone: "scheduled" }, +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: "canceled", label: "Canceled", tone: "neutral" }, - { key: "total", label: "Total", tone: "neutral" }, + { key: "waiting", label: "Scheduled", tone: "scheduled" }, ]; -/** The overview home: live counts and a small throughput chart. */ -export function OverviewPage() { - const { data, isLoading } = useOverview("12m", { refetchInterval: 2000 }); - - return ( -
-

Overview

-
- {CARDS.map((card) => ( - - ))} -
- -
- ); +/** 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); } -/** A minimal SVG bar chart of total jobs per time bucket. No charting dependency. */ -function Timeseries() { - const { data } = useOverviewTimeseries("12m", { refetchInterval: 5000 }); - const points = data ?? []; - if (points.length === 0) return null; +/** 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 max = Math.max(1, ...points.map((p) => p.total)); - const width = 100 / points.length; + 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 ( -
-

Throughput

- - {points.map((point, i) => ( - - ))} - +
+
+ {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 index c614138..c79afb0 100644 --- a/packages/web/src/dashboard/app/pages/queues.test.tsx +++ b/packages/web/src/dashboard/app/pages/queues.test.tsx @@ -4,11 +4,19 @@ import { jsonResponse, renderWithClient } from "../../testing/harness"; import { QueuesPage } from "./queues"; describe("QueuesPage", () => { - it("renders queues with a pause/resume toggle", async () => { + 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 } }]), + jsonResponse([ + { + name: "email", + state: "active", + concurrency: 2, + priority: 20, + jobs: { total: 8, waiting: 3, running: 1, completed: 4, failed: 0 }, + }, + ]), ), }, } as unknown as ApiClient; @@ -16,7 +24,8 @@ describe("QueuesPage", () => { renderWithClient(, client); await waitFor(() => expect(screen.getByText("email")).toBeInTheDocument()); - // an active queue offers a "Pause" action + // 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 index 7296bcd..3fe2d31 100644 --- a/packages/web/src/dashboard/app/pages/queues.tsx +++ b/packages/web/src/dashboard/app/pages/queues.tsx @@ -1,36 +1,17 @@ -import type { QueueWithCounts } from "../../../api/services/queue-service"; -import { Badge, type QueueState } from "../../../ui/Badge"; -import { Button } from "../../../ui/Button"; -import { type Column, Table } from "../../../ui/Table"; +import { QueueCard } from "../../components/QueueCard"; import { useQueueActions, useQueues } from "../../hooks/use-queues"; -/** The queues screen: each queue with its counts and a pause/resume toggle. */ +/** 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 columns: Column[] = [ - { key: "name", label: "Queue" }, - { key: "state", label: "State", render: (queue) => }, - { key: "concurrency", label: "Concurrency", align: "right" }, - { key: "priority", label: "Priority", align: "right" }, - { key: "jobs", label: "Jobs", align: "right", render: (queue) => queue.jobs?.total ?? 0 }, - { - key: "toggle", - label: "", - align: "right", - render: (queue) => ( - - ), - }, - ]; + const queues = data ?? []; return ( -
-

Queues

-
+
+ {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 index d14047b..b05425f 100644 --- a/packages/web/src/dashboard/app/shell.test.tsx +++ b/packages/web/src/dashboard/app/shell.test.tsx @@ -1,33 +1,62 @@ -import { act, render, screen } from "@testing-library/react"; +import { act, screen } 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" }, element:
home page
}, - { path: "/jobs", nav: { label: "Jobs" }, element:
jobs page
}, + { path: "/", nav: { label: "Home", icon: "layout-dashboard", 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 and the active page", () => { - render(); - expect(screen.getByText("Home")).toBeInTheDocument(); - expect(screen.getByText("Jobs")).toBeInTheDocument(); + it("renders the nav entries, header, and the active page", () => { + renderWithClient(, stubClient()); + expect(screen.getByRole("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(); - render(); + renderWithClient(, stubClient()); - await user.click(screen.getByText("Jobs")); + await user.click(screen.getByRole("button", { name: "Jobs" })); act(() => { window.dispatchEvent(new Event("hashchange")); }); expect(screen.getByText("jobs page")).toBeInTheDocument(); }); + + it("opens the command palette on ⌘K and navigates from it", async () => { + const user = userEvent.setup(); + renderWithClient(, stubClient()); + + await user.keyboard("{Meta>}k{/Meta}"); + expect(screen.getByRole("dialog", { name: "Command palette" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /Go to Jobs/ })); + act(() => { + window.dispatchEvent(new Event("hashchange")); + }); + expect(screen.getByText("jobs page")).toBeInTheDocument(); + }); }); diff --git a/packages/web/src/dashboard/app/shell.tsx b/packages/web/src/dashboard/app/shell.tsx index ecaf1a6..df2d0dc 100644 --- a/packages/web/src/dashboard/app/shell.tsx +++ b/packages/web/src/dashboard/app/shell.tsx @@ -1,42 +1,96 @@ -import type { ReactNode } from "react"; -import { NavItem } from "../../ui/NavItem"; -import { Sidebar } from "../../ui/Sidebar"; +import { useEffect, useState, type ReactNode } from "react"; +import { AppSidebar } from "../components/AppSidebar"; +import { CommandPalette } from "../components/CommandPalette"; +import { DashboardHeader } from "../components/DashboardHeader"; import { useHashRoute } from "./router"; /** A dashboard page: its route, its nav entry, and what to render. */ export interface DashboardPage { path: string; - nav: { label: string; icon?: string }; + nav: { + label: string; + icon?: string; + /** 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; } +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 */ + } +} + /** - * The dashboard shell: a sidebar built from `pages[].nav` and a content area that renders the - * active page. This is the page-registry seam: the Pro passes a superset `pages` array (OSS - * pages plus its own) to add screens without forking. + * Resolves the active page for a path by longest matching prefix, so detail routes like + * `/jobs/42` still activate the `/jobs` page. Falls back to the exact match, then the + * root page. + */ +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]; +} + +/** + * The dashboard shell: the modern chrome (brand glow, section sidebar, sticky header, + * ⌘K command palette) wrapped around the active page. This is the page-registry seam: + * the Pro passes a superset `pages` array (OSS pages plus its own) to add screens + * without forking. */ export function DashboardShell({ pages }: { pages: DashboardPage[] }) { const { path, navigate } = useHashRoute(); - const active = pages.find((page) => page.path === path) ?? pages[0]; + const [palette, setPalette] = useState(false); + const active = resolveActive(pages, path); + + 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) => { + navigate(to); + setPalette(false); + }; return ( -
- - {pages.map((page) => ( - { - e.preventDefault(); - navigate(page.path); - }} - /> - ))} - -
{active.element}
+
+
+ setPalette(true)} /> +
+ +
{active.element}
+
+ setPalette(false)} + pages={pages} + onNavigate={go} + onToggleTheme={toggleTheme} + />
); } diff --git a/packages/web/src/dashboard/components/AppSidebar.tsx b/packages/web/src/dashboard/components/AppSidebar.tsx new file mode 100644 index 0000000..f44ba03 --- /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.tsx b/packages/web/src/dashboard/components/CommandPalette.tsx new file mode 100644 index 0000000..9259fa7 --- /dev/null +++ b/packages/web/src/dashboard/components/CommandPalette.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import { Icon } 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: string; + 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: "sun-moon", + run: () => { + onToggleTheme(); + onClose(); + }, + }, + { + label: "Open documentation", + icon: "book-open", + 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.tsx b/packages/web/src/dashboard/components/DashboardHeader.tsx new file mode 100644 index 0000000..b606c37 --- /dev/null +++ b/packages/web/src/dashboard/components/DashboardHeader.tsx @@ -0,0 +1,35 @@ +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 ( +
+
+

{title}

+ {subtitle &&
{subtitle}
} +
+
+ + + + +
+
+ ); +} 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.tsx b/packages/web/src/dashboard/components/JobDetailView.tsx new file mode 100644 index 0000000..4b7acc8 --- /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" ? "x-circle" : job.state === "canceled" ? "alert-circle" : "check-circle"; + const steps: Step[] = [ + { label: "Enqueued", icon: "clock", status: statuses[0] }, + { label: "Claimed", icon: "user-check", 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.tsx b/packages/web/src/dashboard/components/JobsTable.tsx new file mode 100644 index 0000000..0c6a2de --- /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.tsx b/packages/web/src/dashboard/components/JobsToolbar.tsx new file mode 100644 index 0000000..838aed2 --- /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.tsx b/packages/web/src/dashboard/components/QueueCard.tsx new file mode 100644 index 0000000..98c8eaa --- /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.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.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.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.tsx b/packages/web/src/dashboard/components/ThroughputChart.tsx new file mode 100644 index 0000000..fa5e983 --- /dev/null +++ b/packages/web/src/dashboard/components/ThroughputChart.tsx @@ -0,0 +1,129 @@ +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, plus a + * range selector. Guards against environments without a 2D canvas context or + * ResizeObserver (jsdom) so it renders inertly under test. + */ +export function ThroughputChart({ data, range, onRangeChange }: ThroughputChartProps) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx || typeof ResizeObserver === "undefined") { + return; + } + const config: ChartConfiguration = { + type: "line", + data: { + labels: data.map((point) => hhmm(point.timestamp)), + datasets: [ + { + label: "Completed", + data: data.map((point) => point.completed), + borderColor: "#4faf75", + backgroundColor: "rgba(79,175,117,0.10)", + tension: 0.4, + fill: true, + pointRadius: 0, + borderWidth: 2, + }, + { + label: "Failed", + data: data.map((point) => point.failed), + borderColor: "#b75252", + backgroundColor: "rgba(183,82,82,0.10)", + tension: 0.4, + fill: true, + pointRadius: 0, + borderWidth: 2, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + 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); + const box = canvas.parentElement; + const observer = new ResizeObserver(() => chart.resize()); + if (box) { + observer.observe(box); + } + return () => { + observer.disconnect(); + chart.destroy(); + }; + }, [data]); + + return ( +
+
+
+
Job throughput
+
Completed vs failed over time
+
+
+ {command.label} - + ))}
diff --git a/packages/web/src/dashboard/components/DashboardHeader.tsx b/packages/web/src/dashboard/components/DashboardHeader.tsx index a44def9..5e5992c 100644 --- a/packages/web/src/dashboard/components/DashboardHeader.tsx +++ b/packages/web/src/dashboard/components/DashboardHeader.tsx @@ -30,7 +30,7 @@ export function DashboardHeader({ title, subtitle }: DashboardHeaderProps) { title="Docs" className="w-9 h-9 grid place-items-center rounded-lg bg-surface-raised border border-edge cursor-pointer text-fg-secondary no-underline transition-colors hover:bg-surface-hover hover:text-fg-strong" > - +
diff --git a/packages/web/src/dashboard/components/JobDetailView.tsx b/packages/web/src/dashboard/components/JobDetailView.tsx index 4b7acc8..286ca37 100644 --- a/packages/web/src/dashboard/components/JobDetailView.tsx +++ b/packages/web/src/dashboard/components/JobDetailView.tsx @@ -41,18 +41,18 @@ export function JobDetailView({ job, onBack, onRerun, onCancel }: JobDetailViewP 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" ? "x-circle" : job.state === "canceled" ? "alert-circle" : "check-circle"; + const lastIcon = job.state === "failed" ? "CircleX" : job.state === "canceled" ? "CircleAlert" : "CircleCheck"; const steps: Step[] = [ - { label: "Enqueued", icon: "clock", status: statuses[0] }, - { label: "Claimed", icon: "user-check", status: statuses[1] }, - { label: "Running", icon: "activity", status: statuses[2] }, + { 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 (
-

@@ -61,11 +61,11 @@ export function JobDetailView({ job, onBack, onRerun, onCancel }: JobDetailViewP

{done ? ( - ) : ( - )} diff --git a/packages/web/src/dashboard/components/JobsTable.tsx b/packages/web/src/dashboard/components/JobsTable.tsx index 0c6a2de..12ca342 100644 --- a/packages/web/src/dashboard/components/JobsTable.tsx +++ b/packages/web/src/dashboard/components/JobsTable.tsx @@ -106,7 +106,7 @@ export function JobsTable({ )} - + diff --git a/packages/web/src/dashboard/components/JobsToolbar.tsx b/packages/web/src/dashboard/components/JobsToolbar.tsx index 838aed2..fb35fca 100644 --- a/packages/web/src/dashboard/components/JobsToolbar.tsx +++ b/packages/web/src/dashboard/components/JobsToolbar.tsx @@ -14,7 +14,7 @@ export function JobsToolbar({ query, onQuery }: JobsToolbarProps) {
- + {queue.name}
-