+
setOpen(false)} />
+
{
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ setActiveIndex((i) => Math.min(i + 1, results.length - 1));
+ } else if (event.key === "ArrowUp") {
+ event.preventDefault();
+ setActiveIndex((i) => Math.max(i - 1, 0));
+ } else if (event.key === "Enter") {
+ event.preventDefault();
+ runAt(activeIndex);
+ }
+ }}
+ >
+
+
+ setQuery(e.target.value)}
+ placeholder="Type a command or search…"
+ className="flex-1 bg-transparent text-sm outline-none placeholder:text-[var(--fg-subtle)]"
+ aria-label="Search"
+ />
+ Esc
+
+
+ {results.length === 0 ? (
+ - No matches
+ ) : (
+ results.map((item, i) => (
+ -
+
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/dashboard-next/src/components/layout/header.tsx b/dashboard-next/src/components/layout/header.tsx
new file mode 100644
index 0000000..5f41fa3
--- /dev/null
+++ b/dashboard-next/src/components/layout/header.tsx
@@ -0,0 +1,33 @@
+import { Search } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Kbd } from "@/components/ui/kbd";
+import { useCommandPalette } from "@/providers/command-palette-provider";
+import { RefreshControl } from "./refresh-control";
+import { ThemeToggle } from "./theme-toggle";
+
+export function Header() {
+ const { setOpen } = useCommandPalette();
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/dashboard-next/src/components/layout/index.ts b/dashboard-next/src/components/layout/index.ts
new file mode 100644
index 0000000..7bf00ad
--- /dev/null
+++ b/dashboard-next/src/components/layout/index.ts
@@ -0,0 +1,7 @@
+export { AppShell } from "./app-shell";
+export { CommandPalette } from "./command-palette";
+export { Header } from "./header";
+export { PageHeader } from "./page-header";
+export { RefreshControl } from "./refresh-control";
+export { Sidebar } from "./sidebar";
+export { ThemeToggle } from "./theme-toggle";
diff --git a/dashboard-next/src/components/layout/page-header.tsx b/dashboard-next/src/components/layout/page-header.tsx
new file mode 100644
index 0000000..f608a5a
--- /dev/null
+++ b/dashboard-next/src/components/layout/page-header.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode } from "react";
+import { cn } from "@/lib/cn";
+
+interface PageHeaderProps {
+ eyebrow?: string;
+ title: string;
+ description?: string;
+ actions?: ReactNode;
+ className?: string;
+}
+
+export function PageHeader({ eyebrow, title, description, actions, className }: PageHeaderProps) {
+ return (
+
+
+ {eyebrow ? (
+
+ {eyebrow}
+
+ ) : null}
+
{title}
+ {description ?
{description}
: null}
+
+ {actions ?
{actions}
: null}
+
+ );
+}
diff --git a/dashboard-next/src/components/layout/refresh-control.tsx b/dashboard-next/src/components/layout/refresh-control.tsx
new file mode 100644
index 0000000..5b9ee52
--- /dev/null
+++ b/dashboard-next/src/components/layout/refresh-control.tsx
@@ -0,0 +1,35 @@
+import { cn } from "@/lib/cn";
+import { type RefreshOption, useRefreshInterval } from "@/providers/refresh-interval-provider";
+
+const OPTIONS: RefreshOption[] = ["2s", "5s", "10s", "off"];
+
+export function RefreshControl() {
+ const { option, setOption } = useRefreshInterval();
+ return (
+
+ {OPTIONS.map((value) => {
+ const active = option === value;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/dashboard-next/src/components/layout/sidebar.tsx b/dashboard-next/src/components/layout/sidebar.tsx
new file mode 100644
index 0000000..30910a7
--- /dev/null
+++ b/dashboard-next/src/components/layout/sidebar.tsx
@@ -0,0 +1,108 @@
+import { Link, useLocation } from "@tanstack/react-router";
+import {
+ Activity,
+ AlertOctagon,
+ BarChart3,
+ Box,
+ CircuitBoard,
+ LayoutDashboard,
+ ListTree,
+ type LucideIcon,
+ ScrollText,
+ Server,
+ Settings2,
+ Skull,
+} from "lucide-react";
+import { cn } from "@/lib/cn";
+import { site } from "@/lib/site";
+
+interface NavItem {
+ to: string;
+ label: string;
+ icon: LucideIcon;
+}
+
+interface NavGroup {
+ title: string;
+ items: NavItem[];
+}
+
+const NAV: NavGroup[] = [
+ {
+ title: "Monitoring",
+ items: [
+ { to: "/", label: "Overview", icon: LayoutDashboard },
+ { to: "/jobs", label: "Jobs", icon: ListTree },
+ { to: "/metrics", label: "Metrics", icon: BarChart3 },
+ { to: "/logs", label: "Logs", icon: ScrollText },
+ ],
+ },
+ {
+ title: "Infrastructure",
+ items: [
+ { to: "/queues", label: "Queues", icon: Box },
+ { to: "/workers", label: "Workers", icon: Server },
+ { to: "/resources", label: "Resources", icon: Activity },
+ ],
+ },
+ {
+ title: "Reliability",
+ items: [
+ { to: "/dead-letters", label: "Dead letters", icon: Skull },
+ { to: "/circuit-breakers", label: "Circuit breakers", icon: CircuitBoard },
+ { to: "/system", label: "System", icon: Settings2 },
+ ],
+ },
+];
+
+export function Sidebar() {
+ const { pathname } = useLocation();
+ return (
+
+ );
+}
diff --git a/dashboard-next/src/components/layout/theme-toggle.tsx b/dashboard-next/src/components/layout/theme-toggle.tsx
new file mode 100644
index 0000000..649ae35
--- /dev/null
+++ b/dashboard-next/src/components/layout/theme-toggle.tsx
@@ -0,0 +1,43 @@
+import { Laptop, Moon, Sun } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/cn";
+import { type Theme, useTheme } from "@/providers/theme-provider";
+
+const OPTIONS: Array<{ value: Theme; label: string; icon: typeof Sun }> = [
+ { value: "light", label: "Light", icon: Sun },
+ { value: "dark", label: "Dark", icon: Moon },
+ { value: "system", label: "System", icon: Laptop },
+];
+
+export function ThemeToggle() {
+ const { theme, setTheme } = useTheme();
+ return (
+
+ {OPTIONS.map(({ value, label, icon: Icon }) => {
+ const active = theme === value;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/dashboard-next/src/components/ui/badge.tsx b/dashboard-next/src/components/ui/badge.tsx
new file mode 100644
index 0000000..2cfe305
--- /dev/null
+++ b/dashboard-next/src/components/ui/badge.tsx
@@ -0,0 +1,33 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import type { HTMLAttributes } from "react";
+import { cn } from "@/lib/cn";
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium tracking-tight",
+ {
+ variants: {
+ tone: {
+ neutral:
+ "bg-[var(--surface-3)] text-[var(--fg-muted)] ring-1 ring-inset ring-[var(--border-strong)]",
+ accent: "bg-accent-dim text-accent ring-1 ring-inset ring-accent/30",
+ info: "bg-info-dim text-info ring-1 ring-inset ring-info/30",
+ success: "bg-success-dim text-success ring-1 ring-inset ring-success/30",
+ warning: "bg-warning-dim text-warning ring-1 ring-inset ring-warning/30",
+ danger: "bg-danger-dim text-danger ring-1 ring-inset ring-danger/30",
+ },
+ },
+ defaultVariants: {
+ tone: "neutral",
+ },
+ },
+);
+
+export interface BadgeProps
+ extends HTMLAttributes
,
+ VariantProps {}
+
+export function Badge({ className, tone, ...props }: BadgeProps) {
+ return ;
+}
+
+export { badgeVariants };
diff --git a/dashboard-next/src/components/ui/button.tsx b/dashboard-next/src/components/ui/button.tsx
new file mode 100644
index 0000000..cc24c0d
--- /dev/null
+++ b/dashboard-next/src/components/ui/button.tsx
@@ -0,0 +1,49 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { type ButtonHTMLAttributes, forwardRef } from "react";
+import { cn } from "@/lib/cn";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "bg-accent text-accent-fg hover:bg-accent/90 shadow-sm",
+ secondary:
+ "bg-[var(--surface-2)] text-[var(--fg)] hover:bg-[var(--surface-3)] ring-1 ring-inset ring-[var(--border-strong)]",
+ ghost: "text-[var(--fg-muted)] hover:bg-[var(--surface-2)] hover:text-[var(--fg)]",
+ outline:
+ "ring-1 ring-inset ring-[var(--border-strong)] bg-transparent text-[var(--fg)] hover:bg-[var(--surface-2)]",
+ danger: "bg-danger text-white hover:bg-danger/90 shadow-sm",
+ link: "text-accent underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-3.5 py-2",
+ sm: "h-8 rounded-md px-3 text-xs",
+ lg: "h-10 rounded-md px-5",
+ icon: "size-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+export interface ButtonProps
+ extends ButtonHTMLAttributes,
+ VariantProps {}
+
+export const Button = forwardRef(
+ ({ className, variant, size, type = "button", ...props }, ref) => (
+
+ ),
+);
+Button.displayName = "Button";
+
+export { buttonVariants };
diff --git a/dashboard-next/src/components/ui/card.tsx b/dashboard-next/src/components/ui/card.tsx
new file mode 100644
index 0000000..8e98f9f
--- /dev/null
+++ b/dashboard-next/src/components/ui/card.tsx
@@ -0,0 +1,60 @@
+import { forwardRef, type HTMLAttributes } from "react";
+import { cn } from "@/lib/cn";
+
+export const Card = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+Card.displayName = "Card";
+
+export const CardHeader = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardHeader.displayName = "CardHeader";
+
+export const CardTitle = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardTitle.displayName = "CardTitle";
+
+export const CardDescription = forwardRef<
+ HTMLParagraphElement,
+ HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardDescription.displayName = "CardDescription";
+
+export const CardContent = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardContent.displayName = "CardContent";
+
+export const CardFooter = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardFooter.displayName = "CardFooter";
diff --git a/dashboard-next/src/components/ui/empty-state.tsx b/dashboard-next/src/components/ui/empty-state.tsx
new file mode 100644
index 0000000..e4fee69
--- /dev/null
+++ b/dashboard-next/src/components/ui/empty-state.tsx
@@ -0,0 +1,35 @@
+import type { LucideIcon } from "lucide-react";
+import type { ReactNode } from "react";
+import { cn } from "@/lib/cn";
+
+interface EmptyStateProps {
+ icon?: LucideIcon;
+ title: string;
+ description?: string;
+ action?: ReactNode;
+ className?: string;
+}
+
+export function EmptyState({ icon: Icon, title, description, action, className }: EmptyStateProps) {
+ return (
+
+ {Icon ? (
+
+
+
+ ) : null}
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+ {action}
+
+ );
+}
diff --git a/dashboard-next/src/components/ui/index.ts b/dashboard-next/src/components/ui/index.ts
new file mode 100644
index 0000000..e7dc9e9
--- /dev/null
+++ b/dashboard-next/src/components/ui/index.ts
@@ -0,0 +1,8 @@
+export { Badge, badgeVariants } from "./badge";
+export { Button, buttonVariants } from "./button";
+export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./card";
+export { EmptyState } from "./empty-state";
+export { Input } from "./input";
+export { Kbd } from "./kbd";
+export { Separator } from "./separator";
+export { Skeleton } from "./skeleton";
diff --git a/dashboard-next/src/components/ui/input.tsx b/dashboard-next/src/components/ui/input.tsx
new file mode 100644
index 0000000..0964453
--- /dev/null
+++ b/dashboard-next/src/components/ui/input.tsx
@@ -0,0 +1,21 @@
+import { forwardRef, type InputHTMLAttributes } from "react";
+import { cn } from "@/lib/cn";
+
+export const Input = forwardRef>(
+ ({ className, type = "text", ...props }, ref) => (
+
+ ),
+);
+Input.displayName = "Input";
diff --git a/dashboard-next/src/components/ui/kbd.tsx b/dashboard-next/src/components/ui/kbd.tsx
new file mode 100644
index 0000000..a606f46
--- /dev/null
+++ b/dashboard-next/src/components/ui/kbd.tsx
@@ -0,0 +1,15 @@
+import type { HTMLAttributes } from "react";
+import { cn } from "@/lib/cn";
+
+export function Kbd({ className, ...props }: HTMLAttributes) {
+ return (
+
+ );
+}
diff --git a/dashboard-next/src/components/ui/separator.tsx b/dashboard-next/src/components/ui/separator.tsx
new file mode 100644
index 0000000..f1b856c
--- /dev/null
+++ b/dashboard-next/src/components/ui/separator.tsx
@@ -0,0 +1,20 @@
+import type { HTMLAttributes } from "react";
+import { cn } from "@/lib/cn";
+
+interface SeparatorProps extends HTMLAttributes {
+ orientation?: "horizontal" | "vertical";
+}
+
+export function Separator({ className, orientation = "horizontal", ...props }: SeparatorProps) {
+ return (
+
+ );
+}
diff --git a/dashboard-next/src/components/ui/skeleton.tsx b/dashboard-next/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..b9966c2
--- /dev/null
+++ b/dashboard-next/src/components/ui/skeleton.tsx
@@ -0,0 +1,8 @@
+import type { HTMLAttributes } from "react";
+import { cn } from "@/lib/cn";
+
+export function Skeleton({ className, ...props }: HTMLAttributes) {
+ return (
+
+ );
+}
diff --git a/dashboard-next/src/routes/__root.tsx b/dashboard-next/src/routes/__root.tsx
new file mode 100644
index 0000000..8392366
--- /dev/null
+++ b/dashboard-next/src/routes/__root.tsx
@@ -0,0 +1,57 @@
+import { createRootRoute, Link, Outlet } from "@tanstack/react-router";
+import { AlertTriangle, ArrowLeft, Home } from "lucide-react";
+import { AppShell } from "@/components/layout";
+import { Button, buttonVariants } from "@/components/ui/button";
+import { cn } from "@/lib/cn";
+
+export const Route = createRootRoute({
+ component: RootLayout,
+ errorComponent: ErrorView,
+ notFoundComponent: NotFoundView,
+});
+
+function RootLayout() {
+ return (
+
+
+
+ );
+}
+
+function ErrorView({ error }: { error: Error }) {
+ return (
+
+
+
+
+
Something went wrong
+
{error.message}
+
+
+
+
+ );
+}
+
+function NotFoundView() {
+ return (
+
+
+
+ 404
+
+
Page not found
+
+ The page you are looking for doesn't exist or has been moved.
+
+
+
Go home
+
+
+
+ );
+}
diff --git a/dashboard-next/src/routes/circuit-breakers.tsx b/dashboard-next/src/routes/circuit-breakers.tsx
new file mode 100644
index 0000000..0183002
--- /dev/null
+++ b/dashboard-next/src/routes/circuit-breakers.tsx
@@ -0,0 +1,20 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { CircuitBoard } from "lucide-react";
+import { PageHeader } from "@/components/layout";
+import { EmptyState } from "@/components/ui/empty-state";
+
+export const Route = createFileRoute("/circuit-breakers")({
+ component: CircuitBreakersPage,
+});
+
+function CircuitBreakersPage() {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/dashboard-next/src/routes/dead-letters.tsx b/dashboard-next/src/routes/dead-letters.tsx
new file mode 100644
index 0000000..fbfec2a
--- /dev/null
+++ b/dashboard-next/src/routes/dead-letters.tsx
@@ -0,0 +1,17 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { Skull } from "lucide-react";
+import { PageHeader } from "@/components/layout";
+import { EmptyState } from "@/components/ui/empty-state";
+
+export const Route = createFileRoute("/dead-letters")({
+ component: DeadLettersPage,
+});
+
+function DeadLettersPage() {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/dashboard-next/src/routes/index.tsx b/dashboard-next/src/routes/index.tsx
new file mode 100644
index 0000000..e81ee58
--- /dev/null
+++ b/dashboard-next/src/routes/index.tsx
@@ -0,0 +1,42 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { Clock, ListTree, Pause, Play, Skull } from "lucide-react";
+import { PageHeader } from "@/components/layout";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export const Route = createFileRoute("/")({
+ component: OverviewPage,
+});
+
+const STATS = [
+ { key: "pending", label: "Pending", icon: Clock, tone: "text-[var(--fg-muted)]" },
+ { key: "running", label: "Running", icon: Play, tone: "text-info" },
+ { key: "completed", label: "Completed", icon: ListTree, tone: "text-success" },
+ { key: "failed", label: "Failed / dead", icon: Skull, tone: "text-danger" },
+ { key: "paused", label: "Paused queues", icon: Pause, tone: "text-warning" },
+] as const;
+
+function OverviewPage() {
+ return (
+ <>
+
+
+ {STATS.map(({ key, label, icon: Icon, tone }) => (
+
+
+ {label}
+
+
+
+
+
+
+ ))}
+
+ >
+ );
+}
diff --git a/dashboard-next/src/routes/jobs/$id.tsx b/dashboard-next/src/routes/jobs/$id.tsx
new file mode 100644
index 0000000..972a5c9
--- /dev/null
+++ b/dashboard-next/src/routes/jobs/$id.tsx
@@ -0,0 +1,31 @@
+import { createFileRoute, Link } from "@tanstack/react-router";
+import { ArrowLeft } from "lucide-react";
+import { PageHeader } from "@/components/layout";
+import { buttonVariants } from "@/components/ui/button";
+import { EmptyState } from "@/components/ui/empty-state";
+import { cn } from "@/lib/cn";
+
+export const Route = createFileRoute("/jobs/$id")({
+ component: JobDetailPage,
+});
+
+function JobDetailPage() {
+ const { id } = Route.useParams();
+ return (
+ <>
+
+ All jobs
+
+ }
+ />
+
+ >
+ );
+}
diff --git a/dashboard-next/src/routes/jobs/index.tsx b/dashboard-next/src/routes/jobs/index.tsx
new file mode 100644
index 0000000..113ae2c
--- /dev/null
+++ b/dashboard-next/src/routes/jobs/index.tsx
@@ -0,0 +1,21 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { ListTree } from "lucide-react";
+import { PageHeader } from "@/components/layout";
+import { EmptyState } from "@/components/ui/empty-state";
+
+export const Route = createFileRoute("/jobs/")({
+ component: JobsPage,
+});
+
+function JobsPage() {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/dashboard-next/src/routes/logs.tsx b/dashboard-next/src/routes/logs.tsx
new file mode 100644
index 0000000..a11706b
--- /dev/null
+++ b/dashboard-next/src/routes/logs.tsx
@@ -0,0 +1,17 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { ScrollText } from "lucide-react";
+import { PageHeader } from "@/components/layout";
+import { EmptyState } from "@/components/ui/empty-state";
+
+export const Route = createFileRoute("/logs")({
+ component: LogsPage,
+});
+
+function LogsPage() {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/dashboard-next/src/routes/metrics.tsx b/dashboard-next/src/routes/metrics.tsx
new file mode 100644
index 0000000..1d7d432
--- /dev/null
+++ b/dashboard-next/src/routes/metrics.tsx
@@ -0,0 +1,17 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { BarChart3 } from "lucide-react";
+import { PageHeader } from "@/components/layout";
+import { EmptyState } from "@/components/ui/empty-state";
+
+export const Route = createFileRoute("/metrics")({
+ component: MetricsPage,
+});
+
+function MetricsPage() {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/dashboard-next/src/routes/queues.tsx b/dashboard-next/src/routes/queues.tsx
new file mode 100644
index 0000000..d2db4db
--- /dev/null
+++ b/dashboard-next/src/routes/queues.tsx
@@ -0,0 +1,17 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { Box } from "lucide-react";
+import { PageHeader } from "@/components/layout";
+import { EmptyState } from "@/components/ui/empty-state";
+
+export const Route = createFileRoute("/queues")({
+ component: QueuesPage,
+});
+
+function QueuesPage() {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/dashboard-next/src/routes/resources.tsx b/dashboard-next/src/routes/resources.tsx
new file mode 100644
index 0000000..cb70f7f
--- /dev/null
+++ b/dashboard-next/src/routes/resources.tsx
@@ -0,0 +1,17 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { Activity } from "lucide-react";
+import { PageHeader } from "@/components/layout";
+import { EmptyState } from "@/components/ui/empty-state";
+
+export const Route = createFileRoute("/resources")({
+ component: ResourcesPage,
+});
+
+function ResourcesPage() {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/dashboard-next/src/routes/system.tsx b/dashboard-next/src/routes/system.tsx
new file mode 100644
index 0000000..1823e95
--- /dev/null
+++ b/dashboard-next/src/routes/system.tsx
@@ -0,0 +1,17 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { Settings2 } from "lucide-react";
+import { PageHeader } from "@/components/layout";
+import { EmptyState } from "@/components/ui/empty-state";
+
+export const Route = createFileRoute("/system")({
+ component: SystemPage,
+});
+
+function SystemPage() {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/dashboard-next/src/routes/workers.tsx b/dashboard-next/src/routes/workers.tsx
new file mode 100644
index 0000000..d1534ff
--- /dev/null
+++ b/dashboard-next/src/routes/workers.tsx
@@ -0,0 +1,17 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { Server } from "lucide-react";
+import { PageHeader } from "@/components/layout";
+import { EmptyState } from "@/components/ui/empty-state";
+
+export const Route = createFileRoute("/workers")({
+ component: WorkersPage,
+});
+
+function WorkersPage() {
+ return (
+ <>
+
+
+ >
+ );
+}
From e249809a987a8616437bd7249cb196d0f129ed75 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 25 Apr 2026 00:32:34 +0530
Subject: [PATCH 04/42] ci: add dashboard-next lint job
---
.github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 66d0ad0..c0e5b8d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -82,6 +82,35 @@ jobs:
- name: Build check
run: cd dashboard && pnpm exec vite build
+ dashboard-next-lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: "10.30.3"
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: "22"
+ cache: "pnpm"
+ cache-dependency-path: dashboard-next/pnpm-lock.yaml
+
+ - name: Install dependencies
+ run: cd dashboard-next && pnpm install --frozen-lockfile
+
+ - name: Biome lint + format check
+ run: cd dashboard-next && pnpm exec biome ci src/
+
+ - name: TypeScript check
+ run: cd dashboard-next && pnpm exec tsc --noEmit
+
+ - name: Build check
+ run: cd dashboard-next && pnpm exec vite build
+
rust-test:
runs-on: ubuntu-latest
steps:
From e56617b2e212e19ca7eb26a49c76682be75ae394 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 25 Apr 2026 00:40:03 +0530
Subject: [PATCH 05/42] feat(dashboard): serve multi-file SPA with legacy
fallback
---
py_src/taskito/dashboard.py | 170 +++++++++++++++++++++++++++++++-----
1 file changed, 149 insertions(+), 21 deletions(-)
diff --git a/py_src/taskito/dashboard.py b/py_src/taskito/dashboard.py
index a32178c..6b677ae 100644
--- a/py_src/taskito/dashboard.py
+++ b/py_src/taskito/dashboard.py
@@ -9,12 +9,22 @@
from taskito.dashboard import serve_dashboard
serve_dashboard(queue, host="0.0.0.0", port=8080)
+
+Static asset delivery supports two layouts:
+
+- **Multi-file SPA** at ``py_src/taskito/static/dashboard/`` with
+ ``index.html`` plus hashed ``assets/`` produced by the new Vite build.
+- **Legacy single-file HTML** at ``py_src/taskito/templates/dashboard.html``
+ as a fallback for older wheels that don't ship the multi-file layout.
+
+Whichever layout is present at startup wins; there's no runtime switch.
"""
from __future__ import annotations
import json
import logging
+import os
import re
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -35,27 +45,115 @@ def __init__(self, message: str) -> None:
self.message = message
+class _NotFound(Exception):
+ """Raised by route handlers to signal a 404 response."""
+
+ def __init__(self, message: str) -> None:
+ self.message = message
+
+
+# ── Static asset delivery ────────────────────────────────────────────
+
+_CONTENT_TYPES: dict[str, str] = {
+ ".html": "text/html; charset=utf-8",
+ ".js": "application/javascript; charset=utf-8",
+ ".mjs": "application/javascript; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".json": "application/json; charset=utf-8",
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ ".ico": "image/x-icon",
+ ".webmanifest": "application/manifest+json",
+ ".woff2": "font/woff2",
+ ".woff": "font/woff",
+ ".ttf": "font/ttf",
+ ".txt": "text/plain; charset=utf-8",
+ ".map": "application/json; charset=utf-8",
+}
+
+_STATIC_ROOT_REL = "static/dashboard"
+_IMMUTABLE_PREFIX = "/assets/"
+
+
+def _content_type_for(path: str) -> str:
+ """Return the Content-Type for a request path by extension."""
+ ext = os.path.splitext(path)[1].lower()
+ return _CONTENT_TYPES.get(ext, "application/octet-stream")
+
+
+def _resolve_static_node(base: Any, rel_path: str) -> Any | None:
+ """Resolve a request path to a file node under ``base``.
+
+ Rejects traversal attempts, null bytes, and backslash escapes. Returns
+ ``None`` if the resolved node is not an existing regular file.
+
+ ``base`` must support ``joinpath(name)``; the returned node must
+ support ``is_file()`` and ``read_bytes()``. Works with both
+ ``pathlib.Path`` and ``importlib.resources.abc.Traversable``.
+ """
+ clean = rel_path.lstrip("/")
+ if not clean:
+ return None
+ parts = clean.split("/")
+ for part in parts:
+ if part in ("", ".", ".."):
+ return None
+ if "\x00" in part or "\\" in part:
+ return None
+ node = base
+ for part in parts:
+ node = node.joinpath(part)
+ return node if node.is_file() else None
+
+
+_static_root_lock = threading.Lock()
+_static_root_resolved = False
+_static_root: Any | None = None
+
+
+def _get_static_root() -> Any | None:
+ """Return the Traversable root of the bundled SPA, or ``None``.
+
+ Cached after the first call. Returns ``None`` if the package doesn't
+ ship a multi-file dashboard layout (in which case callers should fall
+ back to the legacy single-file template).
+ """
+ global _static_root_resolved, _static_root
+ if _static_root_resolved:
+ return _static_root
+ with _static_root_lock:
+ if not _static_root_resolved:
+ try:
+ candidate = resources.files("taskito").joinpath(_STATIC_ROOT_REL)
+ if candidate.joinpath("index.html").is_file():
+ _static_root = candidate
+ except (ModuleNotFoundError, FileNotFoundError, AttributeError):
+ _static_root = None
+ _static_root_resolved = True
+ return _static_root
+
+
def _read_template(path: str) -> str:
"""Read a file from the bundled templates directory."""
return resources.files("taskito").joinpath(path).read_text(encoding="utf-8")
-def _load_spa_html() -> str:
- """Load the pre-built dashboard SPA (single-file Vite output)."""
+def _load_legacy_html() -> str:
+ """Load the pre-built single-file dashboard (legacy layout)."""
return _read_template("templates/dashboard.html")
-_SPA_HTML: str | None = None
-_spa_lock = threading.Lock()
+_LEGACY_HTML: str | None = None
+_legacy_lock = threading.Lock()
-def _get_spa_html() -> str:
- global _SPA_HTML
- if _SPA_HTML is None:
- with _spa_lock:
- if _SPA_HTML is None:
- _SPA_HTML = _load_spa_html()
- return _SPA_HTML
+def _get_legacy_html() -> str:
+ global _LEGACY_HTML
+ if _LEGACY_HTML is None:
+ with _legacy_lock:
+ if _LEGACY_HTML is None:
+ _LEGACY_HTML = _load_legacy_html()
+ return _LEGACY_HTML
def _parse_int_qs(qs: dict, key: str, default: int) -> int:
@@ -139,13 +237,6 @@ def _handle_stats_queues(queue: Queue, qs: dict) -> dict:
return queue.stats_all_queues()
-class _NotFound(Exception):
- """Raised by route handlers to signal a 404 response."""
-
- def __init__(self, message: str) -> None:
- self.message = message
-
-
def _handle_get_job(queue: Queue, _qs: dict, job_id: str) -> dict:
job = queue.get_job(job_id)
if job is None:
@@ -338,7 +429,7 @@ def _handle_get(self) -> None:
elif path == "/metrics":
self._serve_prometheus_metrics()
else:
- self._serve_spa()
+ self._serve_spa(path)
def do_POST(self) -> None:
try:
@@ -389,11 +480,48 @@ def _serve_prometheus_metrics(self) -> None:
except ImportError:
self._json_response({"error": "prometheus-client not installed"}, status=501)
- def _serve_spa(self) -> None:
- body = _get_spa_html().encode()
+ def _serve_spa(self, req_path: str) -> None:
+ """Serve the SPA: an asset under static/dashboard/, the index.html
+ fallback for client-side routes, or the legacy single-file HTML.
+ """
+ static_root = _get_static_root()
+ if static_root is None:
+ self._serve_legacy_html()
+ return
+
+ node = _resolve_static_node(static_root, req_path)
+ if node is not None:
+ immutable = req_path.startswith(_IMMUTABLE_PREFIX)
+ self._send_asset(node, _content_type_for(req_path), immutable=immutable)
+ return
+
+ if req_path.startswith(_IMMUTABLE_PREFIX):
+ self._json_response({"error": "Not found"}, status=404)
+ return
+
+ index = static_root.joinpath("index.html")
+ if index.is_file():
+ self._send_asset(index, "text/html; charset=utf-8", immutable=False)
+ return
+
+ self._serve_legacy_html()
+
+ def _send_asset(self, node: Any, content_type: str, *, immutable: bool) -> None:
+ body: bytes = node.read_bytes()
+ cache = "public, max-age=31536000, immutable" if immutable else "no-cache"
+ self.send_response(200)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("Cache-Control", cache)
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _serve_legacy_html(self) -> None:
+ body = _get_legacy_html().encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
+ self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(body)
From 0c52ec8e205410f77128dbd824c730741ba6f206 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 25 Apr 2026 00:40:07 +0530
Subject: [PATCH 06/42] test(dashboard): unit tests for static asset resolver
---
tests/python/test_dashboard_static.py | 111 ++++++++++++++++++++++++++
1 file changed, 111 insertions(+)
create mode 100644 tests/python/test_dashboard_static.py
diff --git a/tests/python/test_dashboard_static.py b/tests/python/test_dashboard_static.py
new file mode 100644
index 0000000..0f7eb84
--- /dev/null
+++ b/tests/python/test_dashboard_static.py
@@ -0,0 +1,111 @@
+"""Tests for dashboard static asset resolution and Content-Type mapping."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from taskito.dashboard import _content_type_for, _resolve_static_node
+
+
+@pytest.fixture
+def static_root(tmp_path: Path) -> Path:
+ """Layout mirroring a built Vite SPA tree."""
+ (tmp_path / "index.html").write_text("")
+ assets = tmp_path / "assets"
+ assets.mkdir()
+ (assets / "index-abc.js").write_text("// js")
+ (assets / "index-abc.css").write_text("/* css */")
+ (assets / "nested").mkdir()
+ (assets / "nested" / "deep.png").write_bytes(b"\x89PNG")
+ return tmp_path
+
+
+# ── _resolve_static_node ────────────────────────────────────────────
+
+
+def test_resolve_index_html(static_root: Path) -> None:
+ node = _resolve_static_node(static_root, "/index.html")
+ assert node is not None
+ assert node.read_text() == ""
+
+
+def test_resolve_hashed_asset(static_root: Path) -> None:
+ node = _resolve_static_node(static_root, "/assets/index-abc.js")
+ assert node is not None
+ assert node.read_text() == "// js"
+
+
+def test_resolve_nested_asset(static_root: Path) -> None:
+ node = _resolve_static_node(static_root, "/assets/nested/deep.png")
+ assert node is not None
+ assert node.read_bytes() == b"\x89PNG"
+
+
+def test_resolve_missing_file_returns_none(static_root: Path) -> None:
+ assert _resolve_static_node(static_root, "/assets/missing.js") is None
+
+
+def test_resolve_directory_returns_none(static_root: Path) -> None:
+ # A directory matches joinpath but is_file() is False
+ assert _resolve_static_node(static_root, "/assets") is None
+
+
+def test_resolve_empty_path_returns_none(static_root: Path) -> None:
+ assert _resolve_static_node(static_root, "") is None
+ assert _resolve_static_node(static_root, "/") is None
+
+
+def test_resolve_rejects_parent_traversal(static_root: Path) -> None:
+ assert _resolve_static_node(static_root, "/../secret") is None
+ assert _resolve_static_node(static_root, "/assets/../../secret") is None
+
+
+def test_resolve_rejects_current_directory(static_root: Path) -> None:
+ assert _resolve_static_node(static_root, "/./index.html") is None
+
+
+def test_resolve_rejects_null_byte(static_root: Path) -> None:
+ assert _resolve_static_node(static_root, "/index.html\x00.png") is None
+
+
+def test_resolve_rejects_backslash(static_root: Path) -> None:
+ # Windows-style separators should be rejected to avoid ambiguity
+ assert _resolve_static_node(static_root, "/assets\\index-abc.js") is None
+
+
+def test_resolve_rejects_double_slash(static_root: Path) -> None:
+ # Empty segments from double slashes are rejected
+ assert _resolve_static_node(static_root, "/assets//index-abc.js") is None
+
+
+# ── _content_type_for ──────────────────────────────────────────────
+
+
+@pytest.mark.parametrize(
+ ("path", "expected"),
+ [
+ ("/index.html", "text/html; charset=utf-8"),
+ ("/assets/index-abc.js", "application/javascript; charset=utf-8"),
+ ("/assets/index-abc.mjs", "application/javascript; charset=utf-8"),
+ ("/assets/index-abc.css", "text/css; charset=utf-8"),
+ ("/icon.svg", "image/svg+xml"),
+ ("/icon.png", "image/png"),
+ ("/favicon.ico", "image/x-icon"),
+ ("/fonts/inter.woff2", "font/woff2"),
+ ("/fonts/inter.woff", "font/woff"),
+ ("/app.webmanifest", "application/manifest+json"),
+ ("/data.json", "application/json; charset=utf-8"),
+ ("/unknown.bin", "application/octet-stream"),
+ ("/no-extension", "application/octet-stream"),
+ ],
+)
+def test_content_type_for(path: str, expected: str) -> None:
+ assert _content_type_for(path) == expected
+
+
+def test_content_type_case_insensitive() -> None:
+ # Uppercase extensions should still match
+ assert _content_type_for("/IMAGE.PNG") == "image/png"
+ assert _content_type_for("/script.JS") == "application/javascript; charset=utf-8"
From 0083e1f53dcdcbcc4c9abedfbe036777db39383c Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 25 Apr 2026 00:49:13 +0530
Subject: [PATCH 07/42] chore(dashboard-next): add radix, cmdk, react-table,
error boundary deps
---
dashboard-next/package.json | 10 +
dashboard-next/pnpm-lock.yaml | 979 ++++++++++++++++++++++++++++++++++
2 files changed, 989 insertions(+)
diff --git a/dashboard-next/package.json b/dashboard-next/package.json
index e8eaf28..29fc195 100644
--- a/dashboard-next/package.json
+++ b/dashboard-next/package.json
@@ -15,14 +15,24 @@
"ci": "biome ci src/ && tsc --noEmit && vite build"
},
"dependencies": {
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-tabs": "^1.1.13",
+ "@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-router": "^1.95.1",
+ "@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
"lucide-react": "^0.577.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-error-boundary": "^6.1.1",
"sonner": "^1.7.1",
"tailwind-merge": "^2.6.0"
},
diff --git a/dashboard-next/pnpm-lock.yaml b/dashboard-next/pnpm-lock.yaml
index cfa5f48..6557e9b 100644
--- a/dashboard-next/pnpm-lock.yaml
+++ b/dashboard-next/pnpm-lock.yaml
@@ -8,6 +8,27 @@ importers:
.:
dependencies:
+ '@radix-ui/react-dialog':
+ specifier: ^1.1.15
+ version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-dropdown-menu':
+ specifier: ^2.1.16
+ version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-scroll-area':
+ specifier: ^1.2.10
+ version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-select':
+ specifier: ^2.2.6
+ version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot':
+ specifier: ^1.2.4
+ version: 1.2.4(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-tabs':
+ specifier: ^1.1.13
+ version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-tooltip':
+ specifier: ^1.2.8
+ version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tanstack/react-query':
specifier: ^5.62.7
version: 5.100.1(react@18.3.1)
@@ -17,12 +38,18 @@ importers:
'@tanstack/react-router':
specifier: ^1.95.1
version: 1.168.23(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@tanstack/react-table':
+ specifier: ^8.21.3
+ version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
+ cmdk:
+ specifier: ^1.1.1
+ version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@18.3.1)
@@ -32,6 +59,9 @@ importers:
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
+ react-error-boundary:
+ specifier: ^6.1.1
+ version: 6.1.1(react@18.3.1)
sonner:
specifier: ^1.7.1
version: 1.7.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -536,6 +566,21 @@ packages:
cpu: [x64]
os: [win32]
+ '@floating-ui/core@1.7.5':
+ resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
+
+ '@floating-ui/dom@1.7.6':
+ resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
+
+ '@floating-ui/react-dom@2.1.8':
+ resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@floating-ui/utils@0.2.11':
+ resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
+
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -552,6 +597,384 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@radix-ui/number@1.1.1':
+ resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
+
+ '@radix-ui/primitive@1.1.3':
+ resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
+
+ '@radix-ui/react-arrow@1.1.7':
+ resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-collection@1.1.7':
+ resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-compose-refs@1.1.2':
+ resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-context@1.1.2':
+ resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-dialog@1.1.15':
+ resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-direction@1.1.1':
+ resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-dismissable-layer@1.1.11':
+ resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-dropdown-menu@2.1.16':
+ resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-focus-guards@1.1.3':
+ resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-focus-scope@1.1.7':
+ resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-id@1.1.1':
+ resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-menu@2.1.16':
+ resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-popper@1.2.8':
+ resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-portal@1.1.9':
+ resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-presence@1.1.5':
+ resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-primitive@2.1.3':
+ resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-primitive@2.1.4':
+ resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-roving-focus@1.1.11':
+ resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-scroll-area@1.2.10':
+ resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-select@2.2.6':
+ resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-slot@1.2.3':
+ resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-slot@1.2.4':
+ resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-tabs@1.1.13':
+ resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-tooltip@1.2.8':
+ resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-use-callback-ref@1.1.1':
+ resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-controllable-state@1.2.2':
+ resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-effect-event@0.0.2':
+ resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-escape-keydown@1.1.1':
+ resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-layout-effect@1.1.1':
+ resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-previous@1.1.1':
+ resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-rect@1.1.1':
+ resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-size@1.1.1':
+ resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-visually-hidden@1.2.3':
+ resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/rect@1.1.1':
+ resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
+
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -821,6 +1244,13 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ '@tanstack/react-table@8.21.3':
+ resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
+ engines: {node: '>=12'}
+ peerDependencies:
+ react: '>=16.8'
+ react-dom: '>=16.8'
+
'@tanstack/router-core@1.168.15':
resolution: {integrity: sha512-Wr0424NDtD8fT/uALobMZ9DdcfsTyXtW5IPR++7zvW8/7RaIOeaqXpVDId8ywaGtqPWLWOfaUg2zUtYtukoXYA==}
engines: {node: '>=20.19'}
@@ -859,6 +1289,10 @@ packages:
'@tanstack/store@0.9.3':
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
+ '@tanstack/table-core@8.21.3':
+ resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
+ engines: {node: '>=12'}
+
'@tanstack/virtual-file-routes@1.161.7':
resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==}
engines: {node: '>=20.19'}
@@ -912,6 +1346,10 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
+ aria-hidden@1.2.6:
+ resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
+ engines: {node: '>=10'}
+
babel-dead-code-elimination@1.0.12:
resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
@@ -947,6 +1385,12 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
+ cmdk@1.1.1:
+ resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
+ peerDependencies:
+ react: ^18 || ^19 || ^19.0.0-rc
+ react-dom: ^18 || ^19 || ^19.0.0-rc
+
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -969,6 +1413,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
+ detect-node-es@1.1.0:
+ resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+
diff@8.0.4:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
@@ -1016,6 +1463,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
+ get-nonce@1.0.1:
+ resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
+ engines: {node: '>=6'}
+
get-tsconfig@4.14.0:
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
@@ -1195,10 +1646,45 @@ packages:
peerDependencies:
react: ^18.3.1
+ react-error-boundary@6.1.1:
+ resolution: {integrity: sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0
+
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
+ react-remove-scroll-bar@2.3.8:
+ resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react-remove-scroll@2.7.2:
+ resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react-style-singleton@2.2.3:
+ resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
@@ -1260,6 +1746,9 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
@@ -1283,6 +1772,26 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
+ use-callback-ref@1.3.3:
+ resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ use-sidecar@1.1.3:
+ resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
@@ -1652,6 +2161,23 @@ snapshots:
'@esbuild/win32-x64@0.27.7':
optional: true
+ '@floating-ui/core@1.7.5':
+ dependencies:
+ '@floating-ui/utils': 0.2.11
+
+ '@floating-ui/dom@1.7.6':
+ dependencies:
+ '@floating-ui/core': 1.7.5
+ '@floating-ui/utils': 0.2.11
+
+ '@floating-ui/react-dom@2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@floating-ui/dom': 1.7.6
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ '@floating-ui/utils@0.2.11': {}
+
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1671,6 +2197,383 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@radix-ui/number@1.1.1': {}
+
+ '@radix-ui/primitive@1.1.3': {}
+
+ '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
+ '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
+ '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.28)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ '@radix-ui/react-context@1.1.2(@types/react@18.3.28)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1)
+ aria-hidden: 1.2.6
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
+ '@radix-ui/react-direction@1.1.1(@types/react@18.3.28)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
+ '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
+ '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.28)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
+ '@radix-ui/react-id@1.1.1(@types/react@18.3.28)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ aria-hidden: 1.2.6
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
+ '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/rect': 1.1.1
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
+ '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
+ '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
+ '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
+ '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-slot': 1.2.4(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
+ '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
+ '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/number': 1.1.1
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
+ '@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/number': 1.1.1
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ aria-hidden: 1.2.6
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
+ '@radix-ui/react-slot@1.2.3(@types/react@18.3.28)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ '@radix-ui/react-slot@1.2.4(@types/react@18.3.28)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
+ '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
+ '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.28)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.28)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.28)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.28)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.28)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.28)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.28)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/rect': 1.1.1
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ '@radix-ui/react-use-size@1.1.1(@types/react@18.3.28)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
+ '@radix-ui/rect@1.1.1': {}
+
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.60.2':
@@ -1849,6 +2752,12 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
use-sync-external-store: 1.6.0(react@18.3.1)
+ '@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@tanstack/table-core': 8.21.3
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
'@tanstack/router-core@1.168.15':
dependencies:
'@tanstack/history': 1.161.6
@@ -1906,6 +2815,8 @@ snapshots:
'@tanstack/store@0.9.3': {}
+ '@tanstack/table-core@8.21.3': {}
+
'@tanstack/virtual-file-routes@1.161.7': {}
'@types/babel__core@7.20.5':
@@ -1967,6 +2878,10 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.2
+ aria-hidden@1.2.6:
+ dependencies:
+ tslib: 2.8.1
+
babel-dead-code-elimination@1.0.12:
dependencies:
'@babel/core': 7.29.0
@@ -2012,6 +2927,18 @@ snapshots:
clsx@2.1.1: {}
+ cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ transitivePeerDependencies:
+ - '@types/react'
+ - '@types/react-dom'
+
convert-source-map@2.0.0: {}
cookie-es@3.1.1: {}
@@ -2024,6 +2951,8 @@ snapshots:
detect-libc@2.1.2: {}
+ detect-node-es@1.1.0: {}
+
diff@8.0.4: {}
electron-to-chromium@1.5.344: {}
@@ -2106,6 +3035,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
+ get-nonce@1.0.1: {}
+
get-tsconfig@4.14.0:
dependencies:
resolve-pkg-maps: 1.0.0
@@ -2233,8 +3164,39 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
+ react-error-boundary@6.1.1(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
react-refresh@0.17.0: {}
+ react-remove-scroll-bar@2.3.8(@types/react@18.3.28)(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ react-style-singleton: 2.2.3(@types/react@18.3.28)(react@18.3.1)
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ react-remove-scroll@2.7.2(@types/react@18.3.28)(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ react-remove-scroll-bar: 2.3.8(@types/react@18.3.28)(react@18.3.1)
+ react-style-singleton: 2.2.3(@types/react@18.3.28)(react@18.3.1)
+ tslib: 2.8.1
+ use-callback-ref: 1.3.3(@types/react@18.3.28)(react@18.3.1)
+ use-sidecar: 1.1.3(@types/react@18.3.28)(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ react-style-singleton@2.2.3(@types/react@18.3.28)(react@18.3.1):
+ dependencies:
+ get-nonce: 1.0.1
+ react: 18.3.1
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
react@18.3.1:
dependencies:
loose-envify: 1.4.0
@@ -2310,6 +3272,8 @@ snapshots:
dependencies:
is-number: 7.0.0
+ tslib@2.8.1: {}
+
tsx@4.21.0:
dependencies:
esbuild: 0.27.7
@@ -2334,6 +3298,21 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
+ use-callback-ref@1.3.3(@types/react@18.3.28)(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ use-sidecar@1.1.3(@types/react@18.3.28)(react@18.3.1):
+ dependencies:
+ detect-node-es: 1.1.0
+ react: 18.3.1
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
use-sync-external-store@1.6.0(react@18.3.1):
dependencies:
react: 18.3.1
From 2251617af8ea25b7832bc5d219156b68d3c325df Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 25 Apr 2026 00:49:20 +0530
Subject: [PATCH 08/42] feat(dashboard-next): add shadcn ui primitives +
DataTable
---
dashboard-next/src/components/ui/command.tsx | 143 ++++++++++++++
.../src/components/ui/confirm-dialog.tsx | 62 ++++++
.../src/components/ui/data-table.tsx | 121 ++++++++++++
dashboard-next/src/components/ui/dialog.tsx | 103 ++++++++++
.../src/components/ui/dropdown-menu.tsx | 180 ++++++++++++++++++
.../src/components/ui/error-state.tsx | 49 +++++
dashboard-next/src/components/ui/index.ts | 91 ++++++++-
.../src/components/ui/pagination.tsx | 49 +++++
.../src/components/ui/scroll-area.tsx | 43 +++++
dashboard-next/src/components/ui/select.tsx | 154 +++++++++++++++
dashboard-next/src/components/ui/sheet.tsx | 106 +++++++++++
.../src/components/ui/stat-card.tsx | 37 ++++
dashboard-next/src/components/ui/table.tsx | 100 ++++++++++
dashboard-next/src/components/ui/tabs.tsx | 55 ++++++
dashboard-next/src/components/ui/toaster.tsx | 27 +++
dashboard-next/src/components/ui/tooltip.tsx | 28 +++
16 files changed, 1347 insertions(+), 1 deletion(-)
create mode 100644 dashboard-next/src/components/ui/command.tsx
create mode 100644 dashboard-next/src/components/ui/confirm-dialog.tsx
create mode 100644 dashboard-next/src/components/ui/data-table.tsx
create mode 100644 dashboard-next/src/components/ui/dialog.tsx
create mode 100644 dashboard-next/src/components/ui/dropdown-menu.tsx
create mode 100644 dashboard-next/src/components/ui/error-state.tsx
create mode 100644 dashboard-next/src/components/ui/pagination.tsx
create mode 100644 dashboard-next/src/components/ui/scroll-area.tsx
create mode 100644 dashboard-next/src/components/ui/select.tsx
create mode 100644 dashboard-next/src/components/ui/sheet.tsx
create mode 100644 dashboard-next/src/components/ui/stat-card.tsx
create mode 100644 dashboard-next/src/components/ui/table.tsx
create mode 100644 dashboard-next/src/components/ui/tabs.tsx
create mode 100644 dashboard-next/src/components/ui/toaster.tsx
create mode 100644 dashboard-next/src/components/ui/tooltip.tsx
diff --git a/dashboard-next/src/components/ui/command.tsx b/dashboard-next/src/components/ui/command.tsx
new file mode 100644
index 0000000..04e116a
--- /dev/null
+++ b/dashboard-next/src/components/ui/command.tsx
@@ -0,0 +1,143 @@
+import { Command as CommandPrimitive } from "cmdk";
+import { Search } from "lucide-react";
+import { type ComponentPropsWithoutRef, forwardRef } from "react";
+import { Dialog, DialogContent } from "@/components/ui/dialog";
+import { cn } from "@/lib/cn";
+
+const Command = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+Command.displayName = CommandPrimitive.displayName;
+
+interface CommandDialogProps extends ComponentPropsWithoutRef {
+ label?: string;
+}
+
+function CommandDialog({ children, label = "Command menu", ...props }: CommandDialogProps) {
+ return (
+
+ );
+}
+
+const CommandInput = forwardRef<
+ HTMLInputElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+CommandInput.displayName = CommandPrimitive.Input.displayName;
+
+const CommandList = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+CommandList.displayName = CommandPrimitive.List.displayName;
+
+const CommandEmpty = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>((props, ref) => (
+
+));
+CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
+
+const CommandGroup = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+CommandGroup.displayName = CommandPrimitive.Group.displayName;
+
+const CommandSeparator = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
+
+const CommandItem = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+CommandItem.displayName = CommandPrimitive.Item.displayName;
+
+function CommandShortcut({ className, ...props }: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+export {
+ Command,
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+ CommandShortcut,
+};
diff --git a/dashboard-next/src/components/ui/confirm-dialog.tsx b/dashboard-next/src/components/ui/confirm-dialog.tsx
new file mode 100644
index 0000000..1b8122e
--- /dev/null
+++ b/dashboard-next/src/components/ui/confirm-dialog.tsx
@@ -0,0 +1,62 @@
+import type { ReactNode } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+interface ConfirmDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: ReactNode;
+ description?: ReactNode;
+ confirmLabel?: string;
+ cancelLabel?: string;
+ variant?: "default" | "danger";
+ onConfirm: () => void | Promise;
+ pending?: boolean;
+}
+
+export function ConfirmDialog({
+ open,
+ onOpenChange,
+ title,
+ description,
+ confirmLabel = "Confirm",
+ cancelLabel = "Cancel",
+ variant = "default",
+ onConfirm,
+ pending = false,
+}: ConfirmDialogProps) {
+ async function handleConfirm() {
+ await onConfirm();
+ onOpenChange(false);
+ }
+
+ return (
+
+ );
+}
diff --git a/dashboard-next/src/components/ui/data-table.tsx b/dashboard-next/src/components/ui/data-table.tsx
new file mode 100644
index 0000000..85d8d0f
--- /dev/null
+++ b/dashboard-next/src/components/ui/data-table.tsx
@@ -0,0 +1,121 @@
+import {
+ type ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getSortedRowModel,
+ type Row,
+ type SortingState,
+ useReactTable,
+} from "@tanstack/react-table";
+import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react";
+import { type ReactNode, useState } from "react";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { cn } from "@/lib/cn";
+
+interface DataTableProps {
+ columns: ColumnDef[];
+ data: TData[];
+ empty?: ReactNode;
+ onRowClick?: (row: TData) => void;
+ rowKey?: (row: TData, index: number) => string;
+ className?: string;
+ initialSorting?: SortingState;
+}
+
+export function DataTable({
+ columns,
+ data,
+ empty,
+ onRowClick,
+ rowKey,
+ className,
+ initialSorting = [],
+}: DataTableProps) {
+ const [sorting, setSorting] = useState(initialSorting);
+
+ const table = useReactTable({
+ data,
+ columns,
+ state: { sorting },
+ onSortingChange: setSorting,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ });
+
+ return (
+
+
+
+ {table.getHeaderGroups().map((group) => (
+
+ {group.headers.map((header) => {
+ const canSort = header.column.getCanSort();
+ const sorted = header.column.getIsSorted();
+ return (
+
+ {header.isPlaceholder ? null : canSort ? (
+
+ ) : (
+ flexRender(header.column.columnDef.header, header.getContext())
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows.length === 0 ? (
+
+
+ {empty ?? "No data"}
+
+
+ ) : (
+ table.getRowModel().rows.map((row: Row, index) => (
+ onRowClick(row.original) : undefined}
+ >
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/dashboard-next/src/components/ui/dialog.tsx b/dashboard-next/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..be09639
--- /dev/null
+++ b/dashboard-next/src/components/ui/dialog.tsx
@@ -0,0 +1,103 @@
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+import { type ComponentPropsWithoutRef, forwardRef, type HTMLAttributes } from "react";
+import { cn } from "@/lib/cn";
+
+const Dialog = DialogPrimitive.Root;
+const DialogTrigger = DialogPrimitive.Trigger;
+const DialogPortal = DialogPrimitive.Portal;
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+
+
+
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+function DialogHeader({ className, ...props }: HTMLAttributes) {
+ return (
+
+ );
+}
+
+function DialogFooter({ className, ...props }: HTMLAttributes) {
+ return (
+
+ );
+}
+
+const DialogTitle = forwardRef<
+ HTMLHeadingElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = forwardRef<
+ HTMLParagraphElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/dashboard-next/src/components/ui/dropdown-menu.tsx b/dashboard-next/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..332b72e
--- /dev/null
+++ b/dashboard-next/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,180 @@
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
+import { Check, ChevronRight, Circle } from "lucide-react";
+import { type ComponentPropsWithoutRef, forwardRef, type HTMLAttributes } from "react";
+import { cn } from "@/lib/cn";
+
+const DropdownMenu = DropdownMenuPrimitive.Root;
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+const DropdownMenuGroup = DropdownMenuPrimitive.Group;
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
+const DropdownMenuSub = DropdownMenuPrimitive.Sub;
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
+
+const DropdownMenuSubTrigger = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef & { inset?: boolean }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+));
+DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
+
+const DropdownMenuSubContent = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
+
+const DropdownMenuContent = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, sideOffset = 6, ...props }, ref) => (
+
+
+
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+
+const DropdownMenuItem = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef & { inset?: boolean }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+
+const DropdownMenuCheckboxItem = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
+
+const DropdownMenuRadioItem = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
+
+const DropdownMenuLabel = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef & { inset?: boolean }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
+
+const DropdownMenuSeparator = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+
+function DropdownMenuShortcut({ className, ...props }: HTMLAttributes) {
+ return (
+
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuPortal,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+};
diff --git a/dashboard-next/src/components/ui/error-state.tsx b/dashboard-next/src/components/ui/error-state.tsx
new file mode 100644
index 0000000..3822ece
--- /dev/null
+++ b/dashboard-next/src/components/ui/error-state.tsx
@@ -0,0 +1,49 @@
+import { AlertTriangle, type LucideIcon } from "lucide-react";
+import type { ReactNode } from "react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/cn";
+
+interface ErrorStateProps {
+ icon?: LucideIcon;
+ title?: string;
+ description?: string;
+ onRetry?: () => void;
+ retryLabel?: string;
+ action?: ReactNode;
+ className?: string;
+}
+
+export function ErrorState({
+ icon: Icon = AlertTriangle,
+ title = "Something went wrong",
+ description,
+ onRetry,
+ retryLabel = "Retry",
+ action,
+ className,
+}: ErrorStateProps) {
+ return (
+
+
+
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+ {action ??
+ (onRetry ? (
+
+ ) : null)}
+
+ );
+}
diff --git a/dashboard-next/src/components/ui/index.ts b/dashboard-next/src/components/ui/index.ts
index e7dc9e9..dc98ac2 100644
--- a/dashboard-next/src/components/ui/index.ts
+++ b/dashboard-next/src/components/ui/index.ts
@@ -1,8 +1,97 @@
export { Badge, badgeVariants } from "./badge";
export { Button, buttonVariants } from "./button";
-export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./card";
+export {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "./card";
+export {
+ Command,
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+ CommandShortcut,
+} from "./command";
+export { ConfirmDialog } from "./confirm-dialog";
+export { DataTable } from "./data-table";
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+} from "./dialog";
+export {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuPortal,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "./dropdown-menu";
export { EmptyState } from "./empty-state";
+export { ErrorState } from "./error-state";
export { Input } from "./input";
export { Kbd } from "./kbd";
+export { Pagination } from "./pagination";
+export { ScrollArea, ScrollBar } from "./scroll-area";
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+} from "./select";
export { Separator } from "./separator";
+export {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetOverlay,
+ SheetPortal,
+ SheetTitle,
+ SheetTrigger,
+} from "./sheet";
export { Skeleton } from "./skeleton";
+export { StatCard } from "./stat-card";
+export {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableFooter,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "./table";
+export { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
+export { Toaster, toast } from "./toaster";
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
diff --git a/dashboard-next/src/components/ui/pagination.tsx b/dashboard-next/src/components/ui/pagination.tsx
new file mode 100644
index 0000000..e6238fe
--- /dev/null
+++ b/dashboard-next/src/components/ui/pagination.tsx
@@ -0,0 +1,49 @@
+import { ChevronLeft, ChevronRight } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/cn";
+
+interface PaginationProps {
+ page: number;
+ pageCount?: number;
+ hasMore?: boolean;
+ onChange: (page: number) => void;
+ className?: string;
+}
+
+export function Pagination({ page, pageCount, hasMore, onChange, className }: PaginationProps) {
+ const canNext = pageCount != null ? page < pageCount - 1 : Boolean(hasMore);
+ const canPrev = page > 0;
+ return (
+
+
+ Page {page + 1}
+ {pageCount != null ? (
+ <>
+ {" "}
+ of {pageCount}
+ >
+ ) : null}
+
+
+
+
+
+
+ );
+}
diff --git a/dashboard-next/src/components/ui/scroll-area.tsx b/dashboard-next/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..caa266a
--- /dev/null
+++ b/dashboard-next/src/components/ui/scroll-area.tsx
@@ -0,0 +1,43 @@
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
+import { type ComponentPropsWithoutRef, forwardRef } from "react";
+import { cn } from "@/lib/cn";
+
+const ScrollArea = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+ {children}
+
+
+
+
+));
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
+
+const ScrollBar = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, orientation = "vertical", ...props }, ref) => (
+
+
+
+));
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
+
+export { ScrollArea, ScrollBar };
diff --git a/dashboard-next/src/components/ui/select.tsx b/dashboard-next/src/components/ui/select.tsx
new file mode 100644
index 0000000..bfdda0b
--- /dev/null
+++ b/dashboard-next/src/components/ui/select.tsx
@@ -0,0 +1,154 @@
+import * as SelectPrimitive from "@radix-ui/react-select";
+import { Check, ChevronDown, ChevronUp } from "lucide-react";
+import { type ComponentPropsWithoutRef, forwardRef } from "react";
+import { cn } from "@/lib/cn";
+
+const Select = SelectPrimitive.Root;
+const SelectGroup = SelectPrimitive.Group;
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = forwardRef<
+ HTMLButtonElement,
+ ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+};
diff --git a/dashboard-next/src/components/ui/sheet.tsx b/dashboard-next/src/components/ui/sheet.tsx
new file mode 100644
index 0000000..1178628
--- /dev/null
+++ b/dashboard-next/src/components/ui/sheet.tsx
@@ -0,0 +1,106 @@
+import * as SheetPrimitive from "@radix-ui/react-dialog";
+import { cva, type VariantProps } from "class-variance-authority";
+import { X } from "lucide-react";
+import { type ComponentPropsWithoutRef, forwardRef, type HTMLAttributes } from "react";
+import { cn } from "@/lib/cn";
+
+const Sheet = SheetPrimitive.Root;
+const SheetTrigger = SheetPrimitive.Trigger;
+const SheetClose = SheetPrimitive.Close;
+const SheetPortal = SheetPrimitive.Portal;
+
+const SheetOverlay = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
+
+const sheetVariants = cva(
+ "fixed z-50 gap-4 bg-[var(--surface)] p-6 shadow-xl transition ease-in-out duration-300 data-[state=open]:animate-slide-up",
+ {
+ variants: {
+ side: {
+ top: "inset-x-0 top-0 border-b border-[var(--border)]",
+ bottom: "inset-x-0 bottom-0 border-t border-[var(--border)]",
+ left: "inset-y-0 left-0 h-full w-3/4 max-w-sm border-r border-[var(--border)]",
+ right: "inset-y-0 right-0 h-full w-3/4 max-w-sm border-l border-[var(--border)]",
+ },
+ },
+ defaultVariants: { side: "right" },
+ },
+);
+
+interface SheetContentProps
+ extends ComponentPropsWithoutRef,
+ VariantProps {}
+
+const SheetContent = forwardRef(
+ ({ side, className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+
+
+
+ ),
+);
+SheetContent.displayName = SheetPrimitive.Content.displayName;
+
+function SheetHeader({ className, ...props }: HTMLAttributes) {
+ return ;
+}
+
+const SheetTitle = forwardRef<
+ HTMLHeadingElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetTitle.displayName = SheetPrimitive.Title.displayName;
+
+const SheetDescription = forwardRef<
+ HTMLParagraphElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetDescription.displayName = SheetPrimitive.Description.displayName;
+
+export {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetOverlay,
+ SheetPortal,
+ SheetTitle,
+ SheetTrigger,
+};
diff --git a/dashboard-next/src/components/ui/stat-card.tsx b/dashboard-next/src/components/ui/stat-card.tsx
new file mode 100644
index 0000000..33f8bb8
--- /dev/null
+++ b/dashboard-next/src/components/ui/stat-card.tsx
@@ -0,0 +1,37 @@
+import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
+import { Card } from "@/components/ui/card";
+import { cn } from "@/lib/cn";
+
+interface StatCardProps extends HTMLAttributes {
+ label: string;
+ value: ReactNode;
+ hint?: ReactNode;
+ icon?: ReactNode;
+ trend?: "up" | "down" | "flat";
+ tone?: "neutral" | "accent" | "success" | "warning" | "danger" | "info";
+}
+
+const TONE_RING: Record, string> = {
+ neutral: "text-[var(--fg-muted)]",
+ accent: "text-accent",
+ info: "text-info",
+ success: "text-success",
+ warning: "text-warning",
+ danger: "text-danger",
+};
+
+export const StatCard = forwardRef(
+ ({ label, value, hint, icon, tone = "neutral", className, ...props }, ref) => (
+
+
+
+ {label}
+
+ {icon ?
{icon}
: null}
+
+ {value}
+ {hint ? {hint}
: null}
+
+ ),
+);
+StatCard.displayName = "StatCard";
diff --git a/dashboard-next/src/components/ui/table.tsx b/dashboard-next/src/components/ui/table.tsx
new file mode 100644
index 0000000..5483f2e
--- /dev/null
+++ b/dashboard-next/src/components/ui/table.tsx
@@ -0,0 +1,100 @@
+import {
+ forwardRef,
+ type HTMLAttributes,
+ type TdHTMLAttributes,
+ type ThHTMLAttributes,
+} from "react";
+import { cn } from "@/lib/cn";
+
+const Table = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+Table.displayName = "Table";
+
+const TableHeader = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+TableHeader.displayName = "TableHeader";
+
+const TableBody = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+TableBody.displayName = "TableBody";
+
+const TableFooter = forwardRef>(
+ ({ className, ...props }, ref) => (
+ tr]:last:border-b-0",
+ className,
+ )}
+ {...props}
+ />
+ ),
+);
+TableFooter.displayName = "TableFooter";
+
+const TableRow = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+TableRow.displayName = "TableRow";
+
+const TableHead = forwardRef>(
+ ({ className, ...props }, ref) => (
+ |
+ ),
+);
+TableHead.displayName = "TableHead";
+
+const TableCell = forwardRef>(
+ ({ className, ...props }, ref) => (
+ |
+ ),
+);
+TableCell.displayName = "TableCell";
+
+const TableCaption = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+TableCaption.displayName = "TableCaption";
+
+export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow };
diff --git a/dashboard-next/src/components/ui/tabs.tsx b/dashboard-next/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..c5a26be
--- /dev/null
+++ b/dashboard-next/src/components/ui/tabs.tsx
@@ -0,0 +1,55 @@
+import * as TabsPrimitive from "@radix-ui/react-tabs";
+import { type ComponentPropsWithoutRef, forwardRef } from "react";
+import { cn } from "@/lib/cn";
+
+const Tabs = TabsPrimitive.Root;
+
+const TabsList = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+TabsList.displayName = TabsPrimitive.List.displayName;
+
+const TabsTrigger = forwardRef<
+ HTMLButtonElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
+
+const TabsContent = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsContent.displayName = TabsPrimitive.Content.displayName;
+
+export { Tabs, TabsContent, TabsList, TabsTrigger };
diff --git a/dashboard-next/src/components/ui/toaster.tsx b/dashboard-next/src/components/ui/toaster.tsx
new file mode 100644
index 0000000..0006c87
--- /dev/null
+++ b/dashboard-next/src/components/ui/toaster.tsx
@@ -0,0 +1,27 @@
+import { Toaster as Sonner, type ToasterProps } from "sonner";
+import { useTheme } from "@/providers/theme-provider";
+
+export function Toaster(props: ToasterProps) {
+ const { resolved } = useTheme();
+ return (
+
+ );
+}
+
+export { toast } from "sonner";
diff --git a/dashboard-next/src/components/ui/tooltip.tsx b/dashboard-next/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..635c85e
--- /dev/null
+++ b/dashboard-next/src/components/ui/tooltip.tsx
@@ -0,0 +1,28 @@
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+import { type ComponentPropsWithoutRef, forwardRef } from "react";
+import { cn } from "@/lib/cn";
+
+const TooltipProvider = TooltipPrimitive.Provider;
+const Tooltip = TooltipPrimitive.Root;
+const TooltipTrigger = TooltipPrimitive.Trigger;
+
+const TooltipContent = forwardRef<
+ HTMLDivElement,
+ ComponentPropsWithoutRef
+>(({ className, sideOffset = 6, ...props }, ref) => (
+
+
+
+));
+TooltipContent.displayName = TooltipPrimitive.Content.displayName;
+
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
From c07f89af204e12e4d9de733e3dc2a2c1802c2fda Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 25 Apr 2026 00:49:24 +0530
Subject: [PATCH 09/42] feat(dashboard-next): shell polish (mobile nav,
breadcrumbs, refresh indicator, error boundary)
---
.../src/components/layout/app-shell.tsx | 24 +-
.../src/components/layout/breadcrumbs.tsx | 41 ++++
.../src/components/layout/command-palette.tsx | 229 +++++++++---------
.../src/components/layout/header.tsx | 17 +-
dashboard-next/src/components/layout/index.ts | 5 +
.../src/components/layout/last-refreshed.tsx | 43 ++++
.../src/components/layout/mobile-menu.tsx | 109 +++++++++
.../src/components/layout/page-header.tsx | 17 +-
.../layout/route-error-boundary.tsx | 36 +++
dashboard-next/src/hooks/index.ts | 2 +
.../src/hooks/use-last-refreshed.ts | 17 ++
dashboard-next/src/hooks/use-media-query.ts | 17 ++
dashboard-next/src/providers/index.tsx | 6 +-
dashboard-next/src/routes/jobs/$id.tsx | 15 +-
14 files changed, 431 insertions(+), 147 deletions(-)
create mode 100644 dashboard-next/src/components/layout/breadcrumbs.tsx
create mode 100644 dashboard-next/src/components/layout/last-refreshed.tsx
create mode 100644 dashboard-next/src/components/layout/mobile-menu.tsx
create mode 100644 dashboard-next/src/components/layout/route-error-boundary.tsx
create mode 100644 dashboard-next/src/hooks/index.ts
create mode 100644 dashboard-next/src/hooks/use-last-refreshed.ts
create mode 100644 dashboard-next/src/hooks/use-media-query.ts
diff --git a/dashboard-next/src/components/layout/app-shell.tsx b/dashboard-next/src/components/layout/app-shell.tsx
index 2430183..3a48e84 100644
--- a/dashboard-next/src/components/layout/app-shell.tsx
+++ b/dashboard-next/src/components/layout/app-shell.tsx
@@ -1,19 +1,25 @@
import type { ReactNode } from "react";
+import { TooltipProvider } from "@/components/ui/tooltip";
import { CommandPalette } from "./command-palette";
import { Header } from "./header";
+import { RouteErrorBoundary } from "./route-error-boundary";
import { Sidebar } from "./sidebar";
export function AppShell({ children }: { children: ReactNode }) {
return (
-
-
-
-
-
- {children}
-
+
+
-
-
+
);
}
diff --git a/dashboard-next/src/components/layout/breadcrumbs.tsx b/dashboard-next/src/components/layout/breadcrumbs.tsx
new file mode 100644
index 0000000..c8c1bd0
--- /dev/null
+++ b/dashboard-next/src/components/layout/breadcrumbs.tsx
@@ -0,0 +1,41 @@
+import { Link } from "@tanstack/react-router";
+import { ChevronRight } from "lucide-react";
+import type { ReactNode } from "react";
+import { cn } from "@/lib/cn";
+
+export interface Crumb {
+ label: ReactNode;
+ to?: string;
+}
+
+interface BreadcrumbsProps {
+ items: Crumb[];
+ className?: string;
+}
+
+export function Breadcrumbs({ items, className }: BreadcrumbsProps) {
+ if (items.length === 0) return null;
+ return (
+
+ );
+}
diff --git a/dashboard-next/src/components/layout/command-palette.tsx b/dashboard-next/src/components/layout/command-palette.tsx
index 4e2b859..9d960e4 100644
--- a/dashboard-next/src/components/layout/command-palette.tsx
+++ b/dashboard-next/src/components/layout/command-palette.tsx
@@ -1,139 +1,130 @@
import { useNavigate } from "@tanstack/react-router";
-import { Search } from "lucide-react";
-import { useCallback, useEffect, useRef, useState } from "react";
-import { Kbd } from "@/components/ui/kbd";
-import { cn } from "@/lib/cn";
+import {
+ Activity,
+ BarChart3,
+ Box,
+ CircuitBoard,
+ LayoutDashboard,
+ ListTree,
+ type LucideIcon,
+ Moon,
+ ScrollText,
+ Server,
+ Settings2,
+ Skull,
+ Sun,
+} from "lucide-react";
+import { useCallback } from "react";
+import {
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+ CommandShortcut,
+} from "@/components/ui/command";
import { useCommandPalette } from "@/providers/command-palette-provider";
+import { type RefreshOption, useRefreshInterval } from "@/providers/refresh-interval-provider";
+import { useTheme } from "@/providers/theme-provider";
-interface CommandItem {
- id: string;
+interface NavCmd {
label: string;
- hint?: string;
to: string;
- keywords?: string;
+ icon: LucideIcon;
+ hint?: string;
}
-const COMMANDS: CommandItem[] = [
- { id: "nav.overview", label: "Go to Overview", to: "/", hint: "Home" },
- { id: "nav.jobs", label: "Go to Jobs", to: "/jobs", keywords: "tasks list" },
- { id: "nav.metrics", label: "Go to Metrics", to: "/metrics", keywords: "graphs charts" },
- { id: "nav.logs", label: "Go to Logs", to: "/logs" },
- { id: "nav.queues", label: "Go to Queues", to: "/queues", keywords: "pause resume" },
- { id: "nav.workers", label: "Go to Workers", to: "/workers" },
- { id: "nav.resources", label: "Go to Resources", to: "/resources" },
- {
- id: "nav.dead-letters",
- label: "Go to Dead letters",
- to: "/dead-letters",
- keywords: "dlq retry",
- },
- {
- id: "nav.circuit-breakers",
- label: "Go to Circuit breakers",
- to: "/circuit-breakers",
- keywords: "fault tolerance",
- },
- { id: "nav.system", label: "Go to System", to: "/system", keywords: "proxy interception" },
+const NAV_COMMANDS: NavCmd[] = [
+ { label: "Overview", to: "/", icon: LayoutDashboard, hint: "Home" },
+ { label: "Jobs", to: "/jobs", icon: ListTree },
+ { label: "Metrics", to: "/metrics", icon: BarChart3 },
+ { label: "Logs", to: "/logs", icon: ScrollText },
+ { label: "Queues", to: "/queues", icon: Box },
+ { label: "Workers", to: "/workers", icon: Server },
+ { label: "Resources", to: "/resources", icon: Activity },
+ { label: "Dead letters", to: "/dead-letters", icon: Skull },
+ { label: "Circuit breakers", to: "/circuit-breakers", icon: CircuitBoard },
+ { label: "System", to: "/system", icon: Settings2 },
];
+const REFRESH_COMMANDS: RefreshOption[] = ["2s", "5s", "10s", "off"];
+
export function CommandPalette() {
const { open, setOpen } = useCommandPalette();
const navigate = useNavigate();
- const inputRef = useRef
(null);
- const [query, setQuery] = useState("");
- const [activeIndex, setActiveIndex] = useState(0);
-
- useEffect(() => {
- if (open) {
- setQuery("");
- setActiveIndex(0);
- queueMicrotask(() => inputRef.current?.focus());
- }
- }, [open]);
+ const { setTheme } = useTheme();
+ const { setOption } = useRefreshInterval();
- const normalized = query.trim().toLowerCase();
- const results = normalized
- ? COMMANDS.filter((c) => `${c.label} ${c.keywords ?? ""}`.toLowerCase().includes(normalized))
- : COMMANDS;
-
- const runAt = useCallback(
- (index: number) => {
- const item = results[index];
- if (!item) return;
+ const go = useCallback(
+ (to: string) => {
setOpen(false);
- navigate({ to: item.to });
+ navigate({ to });
},
- [navigate, results, setOpen],
+ [navigate, setOpen],
);
- useEffect(() => {
- if (activeIndex >= results.length) setActiveIndex(Math.max(0, results.length - 1));
- }, [activeIndex, results.length]);
-
- if (!open) return null;
-
return (
-
-
setOpen(false)} />
-
{
- if (event.key === "ArrowDown") {
- event.preventDefault();
- setActiveIndex((i) => Math.min(i + 1, results.length - 1));
- } else if (event.key === "ArrowUp") {
- event.preventDefault();
- setActiveIndex((i) => Math.max(i - 1, 0));
- } else if (event.key === "Enter") {
- event.preventDefault();
- runAt(activeIndex);
- }
- }}
- >
-
-
- setQuery(e.target.value)}
- placeholder="Type a command or search…"
- className="flex-1 bg-transparent text-sm outline-none placeholder:text-[var(--fg-subtle)]"
- aria-label="Search"
- />
- Esc
-
-
- {results.length === 0 ? (
- - No matches
- ) : (
- results.map((item, i) => (
- -
-
-
- ))
- )}
-
-
-
+
+
+
+ No results found.
+
+ {NAV_COMMANDS.map(({ label, to, icon: Icon, hint }) => (
+ go(to)}>
+
+ {label}
+ {hint ? {hint} : null}
+
+ ))}
+
+
+
+ {
+ setTheme("light");
+ setOpen(false);
+ }}
+ >
+ Light theme
+
+ {
+ setTheme("dark");
+ setOpen(false);
+ }}
+ >
+ Dark theme
+
+ {
+ setTheme("system");
+ setOpen(false);
+ }}
+ >
+ System theme
+
+
+
+
+ {REFRESH_COMMANDS.map((option) => (
+ {
+ setOption(option);
+ setOpen(false);
+ }}
+ >
+ {option === "off" ? "Off" : `Every ${option}`}
+
+ ))}
+
+
+
);
}
diff --git a/dashboard-next/src/components/layout/header.tsx b/dashboard-next/src/components/layout/header.tsx
index 5f41fa3..8f5de82 100644
--- a/dashboard-next/src/components/layout/header.tsx
+++ b/dashboard-next/src/components/layout/header.tsx
@@ -2,18 +2,21 @@ import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import { useCommandPalette } from "@/providers/command-palette-provider";
+import { LastRefreshed } from "./last-refreshed";
+import { MobileMenu } from "./mobile-menu";
import { RefreshControl } from "./refresh-control";
import { ThemeToggle } from "./theme-toggle";
export function Header() {
const { setOpen } = useCommandPalette();
return (
-
+
+
+
+
diff --git a/dashboard-next/src/components/layout/index.ts b/dashboard-next/src/components/layout/index.ts
index 7bf00ad..c842370 100644
--- a/dashboard-next/src/components/layout/index.ts
+++ b/dashboard-next/src/components/layout/index.ts
@@ -1,7 +1,12 @@
export { AppShell } from "./app-shell";
+export type { Crumb } from "./breadcrumbs";
+export { Breadcrumbs } from "./breadcrumbs";
export { CommandPalette } from "./command-palette";
export { Header } from "./header";
+export { LastRefreshed } from "./last-refreshed";
+export { MobileMenu } from "./mobile-menu";
export { PageHeader } from "./page-header";
export { RefreshControl } from "./refresh-control";
+export { RouteErrorBoundary } from "./route-error-boundary";
export { Sidebar } from "./sidebar";
export { ThemeToggle } from "./theme-toggle";
diff --git a/dashboard-next/src/components/layout/last-refreshed.tsx b/dashboard-next/src/components/layout/last-refreshed.tsx
new file mode 100644
index 0000000..8e49a67
--- /dev/null
+++ b/dashboard-next/src/components/layout/last-refreshed.tsx
@@ -0,0 +1,43 @@
+import { Loader2 } from "lucide-react";
+import { useEffect, useState } from "react";
+import { useLastRefreshed } from "@/hooks";
+import { cn } from "@/lib/cn";
+
+function formatAgo(ms: number): string {
+ const seconds = Math.floor(ms / 1000);
+ if (seconds < 5) return "just now";
+ if (seconds < 60) return `${seconds}s ago`;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ return `${hours}h ago`;
+}
+
+export function LastRefreshed({ className }: { className?: string }) {
+ const { lastRefreshedAt, isFetching } = useLastRefreshed();
+ const [, setTick] = useState(0);
+
+ useEffect(() => {
+ const id = setInterval(() => setTick((n) => n + 1), 1000);
+ return () => clearInterval(id);
+ }, []);
+
+ const label = isFetching ? "Refreshing…" : `Updated ${formatAgo(Date.now() - lastRefreshedAt)}`;
+
+ return (
+
+ {isFetching ? (
+
+ ) : (
+
+ )}
+ {label}
+
+ );
+}
diff --git a/dashboard-next/src/components/layout/mobile-menu.tsx b/dashboard-next/src/components/layout/mobile-menu.tsx
new file mode 100644
index 0000000..9b67ae9
--- /dev/null
+++ b/dashboard-next/src/components/layout/mobile-menu.tsx
@@ -0,0 +1,109 @@
+import { Link, useLocation } from "@tanstack/react-router";
+import {
+ Activity,
+ BarChart3,
+ Box,
+ CircuitBoard,
+ LayoutDashboard,
+ ListTree,
+ type LucideIcon,
+ Menu,
+ ScrollText,
+ Server,
+ Settings2,
+ Skull,
+} from "lucide-react";
+import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
+import { cn } from "@/lib/cn";
+import { site } from "@/lib/site";
+
+interface NavItem {
+ to: string;
+ label: string;
+ icon: LucideIcon;
+}
+
+const NAV: Array<{ title: string; items: NavItem[] }> = [
+ {
+ title: "Monitoring",
+ items: [
+ { to: "/", label: "Overview", icon: LayoutDashboard },
+ { to: "/jobs", label: "Jobs", icon: ListTree },
+ { to: "/metrics", label: "Metrics", icon: BarChart3 },
+ { to: "/logs", label: "Logs", icon: ScrollText },
+ ],
+ },
+ {
+ title: "Infrastructure",
+ items: [
+ { to: "/queues", label: "Queues", icon: Box },
+ { to: "/workers", label: "Workers", icon: Server },
+ { to: "/resources", label: "Resources", icon: Activity },
+ ],
+ },
+ {
+ title: "Reliability",
+ items: [
+ { to: "/dead-letters", label: "Dead letters", icon: Skull },
+ { to: "/circuit-breakers", label: "Circuit breakers", icon: CircuitBoard },
+ { to: "/system", label: "System", icon: Settings2 },
+ ],
+ },
+];
+
+export function MobileMenu() {
+ const { pathname } = useLocation();
+ const [open, setOpen] = useState(false);
+
+ // Close on navigation
+ useEffect(() => {
+ setOpen(false);
+ }, [pathname]);
+
+ return (
+
+
+
+
+
+
+ {site.name} Dashboard
+
+
+
+
+ );
+}
diff --git a/dashboard-next/src/components/layout/page-header.tsx b/dashboard-next/src/components/layout/page-header.tsx
index f608a5a..387b78b 100644
--- a/dashboard-next/src/components/layout/page-header.tsx
+++ b/dashboard-next/src/components/layout/page-header.tsx
@@ -1,15 +1,24 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/cn";
+import { Breadcrumbs, type Crumb } from "./breadcrumbs";
interface PageHeaderProps {
eyebrow?: string;
title: string;
description?: string;
actions?: ReactNode;
+ breadcrumbs?: Crumb[];
className?: string;
}
-export function PageHeader({ eyebrow, title, description, actions, className }: PageHeaderProps) {
+export function PageHeader({
+ eyebrow,
+ title,
+ description,
+ actions,
+ breadcrumbs,
+ className,
+}: PageHeaderProps) {
return (
- {eyebrow ? (
+ {breadcrumbs && breadcrumbs.length > 0 ? (
+
+ ) : eyebrow ? (
{eyebrow}
@@ -26,7 +37,7 @@ export function PageHeader({ eyebrow, title, description, actions, className }:
{title}
{description ?
{description}
: null}
- {actions ?
{actions}
: null}
+ {actions ?
{actions}
: null}
);
}
diff --git a/dashboard-next/src/components/layout/route-error-boundary.tsx b/dashboard-next/src/components/layout/route-error-boundary.tsx
new file mode 100644
index 0000000..685bdc5
--- /dev/null
+++ b/dashboard-next/src/components/layout/route-error-boundary.tsx
@@ -0,0 +1,36 @@
+import { QueryErrorResetBoundary } from "@tanstack/react-query";
+import { AlertTriangle } from "lucide-react";
+import type { ReactNode } from "react";
+import { ErrorBoundary, type FallbackProps } from "react-error-boundary";
+import { Button } from "@/components/ui/button";
+
+function RouteErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
+ return (
+
+
+
+
Something went wrong
+
+ {error instanceof Error ? error.message : String(error)}
+
+
+
+
+ );
+}
+
+export function RouteErrorBoundary({ children }: { children: ReactNode }) {
+ return (
+
+ {({ reset }) => (
+
+ {children}
+
+ )}
+
+ );
+}
diff --git a/dashboard-next/src/hooks/index.ts b/dashboard-next/src/hooks/index.ts
new file mode 100644
index 0000000..66ebcac
--- /dev/null
+++ b/dashboard-next/src/hooks/index.ts
@@ -0,0 +1,2 @@
+export { useLastRefreshed } from "./use-last-refreshed";
+export { useMediaQuery } from "./use-media-query";
diff --git a/dashboard-next/src/hooks/use-last-refreshed.ts b/dashboard-next/src/hooks/use-last-refreshed.ts
new file mode 100644
index 0000000..74f0350
--- /dev/null
+++ b/dashboard-next/src/hooks/use-last-refreshed.ts
@@ -0,0 +1,17 @@
+import { useIsFetching } from "@tanstack/react-query";
+import { useEffect, useState } from "react";
+
+/**
+ * Tracks the timestamp of the last moment no queries were actively fetching,
+ * providing an approximate "last refreshed at" time for the dashboard.
+ */
+export function useLastRefreshed(): { lastRefreshedAt: number; isFetching: boolean } {
+ const fetching = useIsFetching();
+ const [lastRefreshedAt, setLastRefreshedAt] = useState(() => Date.now());
+
+ useEffect(() => {
+ if (fetching === 0) setLastRefreshedAt(Date.now());
+ }, [fetching]);
+
+ return { lastRefreshedAt, isFetching: fetching > 0 };
+}
diff --git a/dashboard-next/src/hooks/use-media-query.ts b/dashboard-next/src/hooks/use-media-query.ts
new file mode 100644
index 0000000..118185c
--- /dev/null
+++ b/dashboard-next/src/hooks/use-media-query.ts
@@ -0,0 +1,17 @@
+import { useEffect, useState } from "react";
+
+export function useMediaQuery(query: string): boolean {
+ const [matches, setMatches] = useState(() =>
+ typeof window === "undefined" ? false : window.matchMedia(query).matches,
+ );
+
+ useEffect(() => {
+ const media = window.matchMedia(query);
+ const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
+ setMatches(media.matches);
+ media.addEventListener("change", handler);
+ return () => media.removeEventListener("change", handler);
+ }, [query]);
+
+ return matches;
+}
diff --git a/dashboard-next/src/providers/index.tsx b/dashboard-next/src/providers/index.tsx
index 8ea3d73..bdffd57 100644
--- a/dashboard-next/src/providers/index.tsx
+++ b/dashboard-next/src/providers/index.tsx
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
+import { Toaster } from "@/components/ui/toaster";
import { CommandPaletteProvider } from "./command-palette-provider";
import { QueryProvider } from "./query-provider";
import { RefreshIntervalProvider } from "./refresh-interval-provider";
@@ -9,7 +10,10 @@ export function Providers({ children }: { children: ReactNode }) {
- {children}
+
+ {children}
+
+
diff --git a/dashboard-next/src/routes/jobs/$id.tsx b/dashboard-next/src/routes/jobs/$id.tsx
index 972a5c9..4656eaf 100644
--- a/dashboard-next/src/routes/jobs/$id.tsx
+++ b/dashboard-next/src/routes/jobs/$id.tsx
@@ -1,9 +1,6 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
-import { ArrowLeft } from "lucide-react";
+import { createFileRoute } from "@tanstack/react-router";
import { PageHeader } from "@/components/layout";
-import { buttonVariants } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
-import { cn } from "@/lib/cn";
export const Route = createFileRoute("/jobs/$id")({
component: JobDetailPage,
@@ -13,15 +10,7 @@ function JobDetailPage() {
const { id } = Route.useParams();
return (
<>
-
- All jobs
-
- }
- />
+
Date: Sat, 25 Apr 2026 01:00:52 +0530
Subject: [PATCH 10/42] feat(dashboard-next): add shared api types
---
dashboard-next/src/lib/api-types.ts | 172 ++++++++++++++++++++++++++++
dashboard-next/src/lib/status.ts | 56 +++++----
2 files changed, 198 insertions(+), 30 deletions(-)
create mode 100644 dashboard-next/src/lib/api-types.ts
diff --git a/dashboard-next/src/lib/api-types.ts b/dashboard-next/src/lib/api-types.ts
new file mode 100644
index 0000000..51208af
--- /dev/null
+++ b/dashboard-next/src/lib/api-types.ts
@@ -0,0 +1,172 @@
+/**
+ * Response shapes for the Taskito dashboard API.
+ *
+ * These mirror the `to_dict()` output of the underlying Rust/Python models.
+ * Keep in sync with:
+ * - `py_src/taskito/dashboard.py` route handlers
+ * - `docs/guide/observability/dashboard-api.md` (documented contract)
+ */
+
+export type JobStatus = "pending" | "running" | "complete" | "failed" | "dead" | "cancelled";
+
+export interface QueueStats {
+ pending: number;
+ running: number;
+ completed: number;
+ failed: number;
+ dead: number;
+ cancelled: number;
+}
+
+export type QueueStatsMap = Record<
+ string,
+ {
+ pending: number;
+ running: number;
+ completed?: number;
+ failed?: number;
+ dead?: number;
+ cancelled?: number;
+ }
+>;
+
+export interface Job {
+ id: string;
+ task_name: string;
+ queue: string;
+ status: JobStatus;
+ priority: number;
+ progress: number | null;
+ retry_count: number;
+ max_retries: number;
+ created_at: number;
+ scheduled_at: number;
+ started_at: number | null;
+ completed_at: number | null;
+ timeout_ms: number;
+ error: string | null;
+ unique_key: string | null;
+ metadata: string | null;
+}
+
+export interface JobError {
+ attempt: number;
+ error: string;
+ failed_at: number;
+}
+
+export interface TaskLog {
+ job_id: string;
+ task_name: string;
+ level: string;
+ message: string;
+ extra: string | null;
+ logged_at: number;
+}
+
+export interface ReplayEntry {
+ replay_job_id: string;
+ replayed_at: number;
+ original_error: string | null;
+ replay_error: string | null;
+}
+
+export interface DagNode {
+ id: string;
+ task_name: string;
+ status: JobStatus;
+}
+
+export interface DagEdge {
+ from: string;
+ to: string;
+}
+
+export interface DagData {
+ nodes: DagNode[];
+ edges: DagEdge[];
+}
+
+export interface DeadLetter {
+ id: string;
+ original_job_id: string;
+ task_name: string;
+ queue: string;
+ error: string | null;
+ retry_count: number;
+ failed_at: number;
+}
+
+export interface TaskMetrics {
+ count: number;
+ success_count: number;
+ failure_count: number;
+ avg_ms: number;
+ p50_ms: number;
+ p95_ms: number;
+ p99_ms: number;
+ min_ms: number;
+ max_ms: number;
+}
+
+export type MetricsResponse = Record;
+
+export interface TimeseriesBucket {
+ timestamp: number;
+ count: number;
+ success: number;
+ failure: number;
+ avg_ms: number;
+}
+
+export interface Worker {
+ worker_id: string;
+ queues: string;
+ last_heartbeat: number;
+ registered_at: number;
+ tags: string | null;
+}
+
+export interface CircuitBreaker {
+ task_name: string;
+ state: "closed" | "open" | "half_open";
+ failure_count: number;
+ threshold: number;
+ window_ms: number;
+ cooldown_ms: number;
+ last_failure_at: number | null;
+}
+
+export interface ResourcePoolStats {
+ active: number;
+ idle: number;
+ size: number;
+ total_timeouts: number;
+}
+
+export interface ResourceStatus {
+ name: string;
+ scope: string;
+ health: string;
+ init_duration_ms: number;
+ recreations: number;
+ depends_on: string[];
+ pool?: ResourcePoolStats;
+}
+
+export type ProxyStats = Record<
+ string,
+ {
+ reconstructions: number;
+ avg_ms: number;
+ errors: number;
+ }
+>;
+
+export type InterceptionStats = Record<
+ string,
+ {
+ count: number;
+ avg_ms: number;
+ }
+>;
diff --git a/dashboard-next/src/lib/status.ts b/dashboard-next/src/lib/status.ts
index 1be36e9..5d3d964 100644
--- a/dashboard-next/src/lib/status.ts
+++ b/dashboard-next/src/lib/status.ts
@@ -1,51 +1,47 @@
-export type JobStatus =
- | "pending"
- | "running"
- | "completed"
- | "failed"
- | "dead"
- | "cancelled"
- | "scheduled";
+import type { JobStatus } from "@/lib/api-types";
+
+export type { JobStatus };
export type CircuitState = "closed" | "open" | "half_open";
export type ResourceHealth = "healthy" | "degraded" | "unhealthy" | "unknown";
-type ToneKey = "neutral" | "accent" | "info" | "success" | "warning" | "danger";
-
-const TONE_CLASS: Record = {
- neutral:
- "bg-[var(--surface-3)] text-[var(--fg-muted)] ring-1 ring-inset ring-[var(--border-strong)]",
- accent: "bg-accent-dim text-accent ring-1 ring-inset ring-accent/30",
- info: "bg-info-dim text-info ring-1 ring-inset ring-info/30",
- success: "bg-success-dim text-success ring-1 ring-inset ring-success/30",
- warning: "bg-warning-dim text-warning ring-1 ring-inset ring-warning/30",
- danger: "bg-danger-dim text-danger ring-1 ring-inset ring-danger/30",
-};
+export type Tone = "neutral" | "accent" | "info" | "success" | "warning" | "danger";
-export const JOB_STATUS_TONE: Record = {
+export const JOB_STATUS_TONE: Record = {
pending: "neutral",
running: "info",
- scheduled: "accent",
- completed: "success",
+ complete: "success",
failed: "danger",
dead: "danger",
cancelled: "warning",
};
-export const CIRCUIT_TONE: Record = {
+export const JOB_STATUS_LABEL: Record = {
+ pending: "Pending",
+ running: "Running",
+ complete: "Completed",
+ failed: "Failed",
+ dead: "Dead",
+ cancelled: "Cancelled",
+};
+
+export const CIRCUIT_TONE: Record = {
closed: "success",
half_open: "warning",
open: "danger",
};
-export const RESOURCE_TONE: Record = {
- healthy: "success",
- degraded: "warning",
- unhealthy: "danger",
- unknown: "neutral",
+export const CIRCUIT_LABEL: Record = {
+ closed: "Closed",
+ half_open: "Half open",
+ open: "Open",
};
-export function toneClasses(tone: ToneKey): string {
- return TONE_CLASS[tone];
+export function resourceTone(health: string): Tone {
+ const key = health.toLowerCase();
+ if (key === "healthy") return "success";
+ if (key === "degraded") return "warning";
+ if (key === "unhealthy") return "danger";
+ return "neutral";
}
From c8930c9316c35d28245d36d3836127abd235179b Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 25 Apr 2026 01:00:59 +0530
Subject: [PATCH 11/42] feat(dashboard-next): overview page with stats, queues,
recent jobs, sparkline
---
dashboard-next/src/features/overview/api.ts | 29 +++++
.../src/features/overview/components/index.ts | 4 +
.../overview/components/queue-breakdown.tsx | 122 ++++++++++++++++++
.../overview/components/recent-jobs.tsx | 91 +++++++++++++
.../overview/components/stats-grid.tsx | 60 +++++++++
.../components/throughput-sparkline.tsx | 90 +++++++++++++
dashboard-next/src/features/overview/hooks.ts | 54 ++++++++
dashboard-next/src/features/overview/index.ts | 2 +
dashboard-next/src/routes/index.tsx | 70 ++++++----
9 files changed, 499 insertions(+), 23 deletions(-)
create mode 100644 dashboard-next/src/features/overview/api.ts
create mode 100644 dashboard-next/src/features/overview/components/index.ts
create mode 100644 dashboard-next/src/features/overview/components/queue-breakdown.tsx
create mode 100644 dashboard-next/src/features/overview/components/recent-jobs.tsx
create mode 100644 dashboard-next/src/features/overview/components/stats-grid.tsx
create mode 100644 dashboard-next/src/features/overview/components/throughput-sparkline.tsx
create mode 100644 dashboard-next/src/features/overview/hooks.ts
create mode 100644 dashboard-next/src/features/overview/index.ts
diff --git a/dashboard-next/src/features/overview/api.ts b/dashboard-next/src/features/overview/api.ts
new file mode 100644
index 0000000..e1adbe2
--- /dev/null
+++ b/dashboard-next/src/features/overview/api.ts
@@ -0,0 +1,29 @@
+import { api } from "@/lib/api-client";
+import type { Job, QueueStats, QueueStatsMap, TimeseriesBucket } from "@/lib/api-types";
+
+export function fetchStats(signal?: AbortSignal): Promise {
+ return api.get("/api/stats", { signal });
+}
+
+export function fetchQueueStats(signal?: AbortSignal): Promise {
+ return api.get("/api/stats/queues", { signal });
+}
+
+export function fetchPausedQueues(signal?: AbortSignal): Promise {
+ return api.get("/api/queues/paused", { signal });
+}
+
+export function fetchRecentJobs(limit: number, signal?: AbortSignal): Promise {
+ return api.get("/api/jobs", { signal, params: { limit } });
+}
+
+export function fetchThroughput(
+ bucketSeconds: number,
+ sinceSeconds: number,
+ signal?: AbortSignal,
+): Promise {
+ return api.get("/api/metrics/timeseries", {
+ signal,
+ params: { bucket: bucketSeconds, since: sinceSeconds },
+ });
+}
diff --git a/dashboard-next/src/features/overview/components/index.ts b/dashboard-next/src/features/overview/components/index.ts
new file mode 100644
index 0000000..3500593
--- /dev/null
+++ b/dashboard-next/src/features/overview/components/index.ts
@@ -0,0 +1,4 @@
+export { QueueBreakdown } from "./queue-breakdown";
+export { RecentJobs } from "./recent-jobs";
+export { StatsGrid } from "./stats-grid";
+export { ThroughputSparkline } from "./throughput-sparkline";
diff --git a/dashboard-next/src/features/overview/components/queue-breakdown.tsx b/dashboard-next/src/features/overview/components/queue-breakdown.tsx
new file mode 100644
index 0000000..6ca568e
--- /dev/null
+++ b/dashboard-next/src/features/overview/components/queue-breakdown.tsx
@@ -0,0 +1,122 @@
+import type { ColumnDef } from "@tanstack/react-table";
+import { useMemo } from "react";
+import { Badge } from "@/components/ui/badge";
+import { DataTable } from "@/components/ui/data-table";
+import { ErrorState } from "@/components/ui/error-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { QueueStatsMap } from "@/lib/api-types";
+import { formatCount } from "@/lib/number";
+
+interface Row {
+ name: string;
+ pending: number;
+ running: number;
+ completed: number;
+ failed: number;
+ dead: number;
+ paused: boolean;
+}
+
+interface QueueBreakdownProps {
+ queueStats: QueueStatsMap | undefined;
+ paused: string[] | undefined;
+ loading: boolean;
+ error: Error | null;
+ onRetry: () => void;
+}
+
+export function QueueBreakdown({
+ queueStats,
+ paused,
+ loading,
+ error,
+ onRetry,
+}: QueueBreakdownProps) {
+ const rows = useMemo(() => {
+ if (!queueStats) return [];
+ const pausedSet = new Set(paused ?? []);
+ return Object.entries(queueStats)
+ .map(([name, s]) => ({
+ name,
+ pending: s.pending ?? 0,
+ running: s.running ?? 0,
+ completed: s.completed ?? 0,
+ failed: s.failed ?? 0,
+ dead: s.dead ?? 0,
+ paused: pausedSet.has(name),
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ }, [queueStats, paused]);
+
+ const columns = useMemo[]>(
+ () => [
+ {
+ accessorKey: "name",
+ header: "Queue",
+ cell: ({ row }) => (
+
+ {row.original.name}
+ {row.original.paused ? Paused : null}
+
+ ),
+ },
+ {
+ accessorKey: "pending",
+ header: "Pending",
+ cell: ({ getValue }) => (
+ {formatCount(getValue())}
+ ),
+ },
+ {
+ accessorKey: "running",
+ header: "Running",
+ cell: ({ getValue }) => (
+ {formatCount(getValue())}
+ ),
+ },
+ {
+ accessorKey: "completed",
+ header: "Completed",
+ cell: ({ getValue }) => (
+
+ {formatCount(getValue())}
+
+ ),
+ },
+ {
+ accessorKey: "failed",
+ header: "Failed",
+ cell: ({ row }) => {
+ const total = row.original.failed + row.original.dead;
+ return (
+ 0 ? "text-danger" : "text-[var(--fg-muted)]"}`}
+ >
+ {formatCount(total)}
+
+ );
+ },
+ },
+ ],
+ [],
+ );
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (loading && rows.length === 0) {
+ return ;
+ }
+
+ return (
+ r.name}
+ empty="No queues with activity yet"
+ />
+ );
+}
diff --git a/dashboard-next/src/features/overview/components/recent-jobs.tsx b/dashboard-next/src/features/overview/components/recent-jobs.tsx
new file mode 100644
index 0000000..898df4d
--- /dev/null
+++ b/dashboard-next/src/features/overview/components/recent-jobs.tsx
@@ -0,0 +1,91 @@
+import { Link, useNavigate } from "@tanstack/react-router";
+import type { ColumnDef } from "@tanstack/react-table";
+import { useMemo } from "react";
+import { Badge } from "@/components/ui/badge";
+import { DataTable } from "@/components/ui/data-table";
+import { ErrorState } from "@/components/ui/error-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { Job } from "@/lib/api-types";
+import { JOB_STATUS_LABEL, JOB_STATUS_TONE } from "@/lib/status";
+import { formatRelative } from "@/lib/time";
+
+interface RecentJobsProps {
+ jobs: Job[] | undefined;
+ loading: boolean;
+ error: Error | null;
+ onRetry: () => void;
+}
+
+export function RecentJobs({ jobs, loading, error, onRetry }: RecentJobsProps) {
+ const navigate = useNavigate();
+
+ const columns = useMemo[]>(
+ () => [
+ {
+ accessorKey: "id",
+ header: "Job",
+ cell: ({ row }) => (
+ e.stopPropagation()}
+ >
+ {row.original.id.slice(0, 8)}…
+
+ ),
+ },
+ {
+ accessorKey: "task_name",
+ header: "Task",
+ cell: ({ getValue }) => (
+ {getValue()}
+ ),
+ },
+ {
+ accessorKey: "queue",
+ header: "Queue",
+ cell: ({ getValue }) => (
+ {getValue()}
+ ),
+ },
+ {
+ accessorKey: "status",
+ header: "Status",
+ cell: ({ row }) => (
+
+ {JOB_STATUS_LABEL[row.original.status]}
+
+ ),
+ },
+ {
+ accessorKey: "created_at",
+ header: "Created",
+ cell: ({ getValue }) => (
+
+ {formatRelative(getValue() * 1000)}
+
+ ),
+ },
+ ],
+ [],
+ );
+
+ if (error) {
+ return ;
+ }
+
+ if (loading && !jobs) {
+ return ;
+ }
+
+ return (
+ j.id}
+ empty="No jobs yet"
+ onRowClick={(job) => navigate({ to: "/jobs/$id", params: { id: job.id } })}
+ />
+ );
+}
diff --git a/dashboard-next/src/features/overview/components/stats-grid.tsx b/dashboard-next/src/features/overview/components/stats-grid.tsx
new file mode 100644
index 0000000..00ba7d8
--- /dev/null
+++ b/dashboard-next/src/features/overview/components/stats-grid.tsx
@@ -0,0 +1,60 @@
+import { CheckCircle2, Clock, Pause, Play, Skull } from "lucide-react";
+import { Skeleton } from "@/components/ui/skeleton";
+import { StatCard } from "@/components/ui/stat-card";
+import type { QueueStats } from "@/lib/api-types";
+import { formatCount } from "@/lib/number";
+
+interface StatsGridProps {
+ stats: QueueStats | undefined;
+ pausedCount: number | undefined;
+ loading?: boolean;
+}
+
+export function StatsGrid({ stats, pausedCount, loading }: StatsGridProps) {
+ const failedTotal = (stats?.failed ?? 0) + (stats?.dead ?? 0);
+ return (
+
+ }
+ value={loading ? : formatCount(stats?.pending ?? 0)}
+ />
+ }
+ value={loading ? : formatCount(stats?.running ?? 0)}
+ />
+ }
+ value={loading ? : formatCount(stats?.completed ?? 0)}
+ />
+ }
+ value={loading ? : formatCount(failedTotal)}
+ hint={
+ stats
+ ? `${formatCount(stats.dead)} dead · ${formatCount(stats.cancelled)} cancelled`
+ : undefined
+ }
+ />
+ }
+ value={
+ loading || pausedCount == null ? (
+
+ ) : (
+ formatCount(pausedCount)
+ )
+ }
+ />
+
+ );
+}
diff --git a/dashboard-next/src/features/overview/components/throughput-sparkline.tsx b/dashboard-next/src/features/overview/components/throughput-sparkline.tsx
new file mode 100644
index 0000000..c4c66e3
--- /dev/null
+++ b/dashboard-next/src/features/overview/components/throughput-sparkline.tsx
@@ -0,0 +1,90 @@
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { TimeseriesBucket } from "@/lib/api-types";
+import { formatCount } from "@/lib/number";
+
+interface ThroughputSparklineProps {
+ buckets: TimeseriesBucket[] | undefined;
+ loading?: boolean;
+}
+
+export function ThroughputSparkline({ buckets, loading }: ThroughputSparklineProps) {
+ const points = buckets ?? [];
+ const total = points.reduce((sum, b) => sum + b.count, 0);
+ const peak = points.reduce((max, b) => Math.max(max, b.count), 0);
+
+ return (
+
+
+ Throughput — last hour
+ {loading ? (
+
+ ) : (
+
+ {formatCount(total)} runs · peak {formatCount(peak)}/min
+
+ )}
+
+
+ {loading ? (
+
+ ) : points.length === 0 ? (
+
+ No activity in this window
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function Sparkline({ buckets }: { buckets: TimeseriesBucket[] }) {
+ const width = 800;
+ const height = 80;
+ const maxCount = Math.max(1, ...buckets.map((b) => b.count));
+ const step = buckets.length > 1 ? width / (buckets.length - 1) : 0;
+
+ const areaPath = buckets
+ .map((b, i) => {
+ const x = i * step;
+ const y = height - (b.count / maxCount) * height;
+ return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
+ })
+ .concat([`L ${width} ${height}`, `L 0 ${height}`, "Z"])
+ .join(" ");
+
+ const linePath = buckets
+ .map((b, i) => {
+ const x = i * step;
+ const y = height - (b.count / maxCount) * height;
+ return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
+ })
+ .join(" ");
+
+ return (
+
+ );
+}
diff --git a/dashboard-next/src/features/overview/hooks.ts b/dashboard-next/src/features/overview/hooks.ts
new file mode 100644
index 0000000..6a296e4
--- /dev/null
+++ b/dashboard-next/src/features/overview/hooks.ts
@@ -0,0 +1,54 @@
+import { useQuery } from "@tanstack/react-query";
+import { useRefreshInterval } from "@/providers/refresh-interval-provider";
+import {
+ fetchPausedQueues,
+ fetchQueueStats,
+ fetchRecentJobs,
+ fetchStats,
+ fetchThroughput,
+} from "./api";
+
+export function useStats() {
+ const { intervalMs } = useRefreshInterval();
+ return useQuery({
+ queryKey: ["stats"],
+ queryFn: ({ signal }) => fetchStats(signal),
+ refetchInterval: intervalMs,
+ });
+}
+
+export function useQueueStats() {
+ const { intervalMs } = useRefreshInterval();
+ return useQuery({
+ queryKey: ["stats", "queues"],
+ queryFn: ({ signal }) => fetchQueueStats(signal),
+ refetchInterval: intervalMs,
+ });
+}
+
+export function usePausedQueues() {
+ const { intervalMs } = useRefreshInterval();
+ return useQuery({
+ queryKey: ["queues", "paused"],
+ queryFn: ({ signal }) => fetchPausedQueues(signal),
+ refetchInterval: intervalMs,
+ });
+}
+
+export function useRecentJobs(limit = 10) {
+ const { intervalMs } = useRefreshInterval();
+ return useQuery({
+ queryKey: ["jobs", "recent", limit],
+ queryFn: ({ signal }) => fetchRecentJobs(limit, signal),
+ refetchInterval: intervalMs,
+ });
+}
+
+export function useThroughput(bucketSeconds = 60, sinceSeconds = 3600) {
+ const { intervalMs } = useRefreshInterval();
+ return useQuery({
+ queryKey: ["metrics", "throughput", bucketSeconds, sinceSeconds],
+ queryFn: ({ signal }) => fetchThroughput(bucketSeconds, sinceSeconds, signal),
+ refetchInterval: intervalMs,
+ });
+}
diff --git a/dashboard-next/src/features/overview/index.ts b/dashboard-next/src/features/overview/index.ts
new file mode 100644
index 0000000..a234113
--- /dev/null
+++ b/dashboard-next/src/features/overview/index.ts
@@ -0,0 +1,2 @@
+export * from "./components";
+export * from "./hooks";
diff --git a/dashboard-next/src/routes/index.tsx b/dashboard-next/src/routes/index.tsx
index e81ee58..d07b57e 100644
--- a/dashboard-next/src/routes/index.tsx
+++ b/dashboard-next/src/routes/index.tsx
@@ -1,22 +1,28 @@
import { createFileRoute } from "@tanstack/react-router";
-import { Clock, ListTree, Pause, Play, Skull } from "lucide-react";
import { PageHeader } from "@/components/layout";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Skeleton } from "@/components/ui/skeleton";
+import {
+ QueueBreakdown,
+ RecentJobs,
+ StatsGrid,
+ ThroughputSparkline,
+ usePausedQueues,
+ useQueueStats,
+ useRecentJobs,
+ useStats,
+ useThroughput,
+} from "@/features/overview";
export const Route = createFileRoute("/")({
component: OverviewPage,
});
-const STATS = [
- { key: "pending", label: "Pending", icon: Clock, tone: "text-[var(--fg-muted)]" },
- { key: "running", label: "Running", icon: Play, tone: "text-info" },
- { key: "completed", label: "Completed", icon: ListTree, tone: "text-success" },
- { key: "failed", label: "Failed / dead", icon: Skull, tone: "text-danger" },
- { key: "paused", label: "Paused queues", icon: Pause, tone: "text-warning" },
-] as const;
-
function OverviewPage() {
+ const stats = useStats();
+ const queueStats = useQueueStats();
+ const paused = usePausedQueues();
+ const jobs = useRecentJobs(10);
+ const throughput = useThroughput(60, 3600);
+
return (
<>
-
- {STATS.map(({ key, label, icon: Icon, tone }) => (
-
-
- {label}
-
-
-
-
-
-
- ))}
+
+
+
+
+
+
+
+
+
Queues
+
+ queueStats.refetch()}
+ />
+
+
+
+
+
Recent jobs
+
+ jobs.refetch()}
+ />
+
>
);
From 2e272af77b22f96ba86a91718c4f57505c3a311b Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 25 Apr 2026 01:01:05 +0530
Subject: [PATCH 12/42] feat(dashboard-next): port workers, circuit breakers,
resources, system
---
.../src/features/circuit-breakers/api.ts | 6 +
.../components/circuit-breakers-table.tsx | 116 ++++++++++++++++
.../circuit-breakers/components/index.ts | 1 +
.../src/features/circuit-breakers/hooks.ts | 12 ++
.../src/features/circuit-breakers/index.ts | 2 +
dashboard-next/src/features/resources/api.ts | 6 +
.../features/resources/components/index.ts | 1 +
.../resources/components/resources-table.tsx | 128 ++++++++++++++++++
.../src/features/resources/hooks.ts | 12 ++
.../src/features/resources/index.ts | 2 +
dashboard-next/src/features/system/api.ts | 10 ++
.../src/features/system/components/index.ts | 2 +
.../system/components/interception-table.tsx | 79 +++++++++++
.../system/components/proxy-table.tsx | 88 ++++++++++++
dashboard-next/src/features/system/hooks.ts | 21 +++
dashboard-next/src/features/system/index.ts | 2 +
dashboard-next/src/features/workers/api.ts | 6 +
.../src/features/workers/components/index.ts | 1 +
.../workers/components/workers-table.tsx | 111 +++++++++++++++
dashboard-next/src/features/workers/hooks.ts | 12 ++
dashboard-next/src/features/workers/index.ts | 2 +
.../src/routes/circuit-breakers.tsx | 12 +-
dashboard-next/src/routes/resources.tsx | 17 ++-
dashboard-next/src/routes/system.tsx | 39 +++++-
dashboard-next/src/routes/workers.tsx | 23 +++-
25 files changed, 696 insertions(+), 15 deletions(-)
create mode 100644 dashboard-next/src/features/circuit-breakers/api.ts
create mode 100644 dashboard-next/src/features/circuit-breakers/components/circuit-breakers-table.tsx
create mode 100644 dashboard-next/src/features/circuit-breakers/components/index.ts
create mode 100644 dashboard-next/src/features/circuit-breakers/hooks.ts
create mode 100644 dashboard-next/src/features/circuit-breakers/index.ts
create mode 100644 dashboard-next/src/features/resources/api.ts
create mode 100644 dashboard-next/src/features/resources/components/index.ts
create mode 100644 dashboard-next/src/features/resources/components/resources-table.tsx
create mode 100644 dashboard-next/src/features/resources/hooks.ts
create mode 100644 dashboard-next/src/features/resources/index.ts
create mode 100644 dashboard-next/src/features/system/api.ts
create mode 100644 dashboard-next/src/features/system/components/index.ts
create mode 100644 dashboard-next/src/features/system/components/interception-table.tsx
create mode 100644 dashboard-next/src/features/system/components/proxy-table.tsx
create mode 100644 dashboard-next/src/features/system/hooks.ts
create mode 100644 dashboard-next/src/features/system/index.ts
create mode 100644 dashboard-next/src/features/workers/api.ts
create mode 100644 dashboard-next/src/features/workers/components/index.ts
create mode 100644 dashboard-next/src/features/workers/components/workers-table.tsx
create mode 100644 dashboard-next/src/features/workers/hooks.ts
create mode 100644 dashboard-next/src/features/workers/index.ts
diff --git a/dashboard-next/src/features/circuit-breakers/api.ts b/dashboard-next/src/features/circuit-breakers/api.ts
new file mode 100644
index 0000000..0ced3f0
--- /dev/null
+++ b/dashboard-next/src/features/circuit-breakers/api.ts
@@ -0,0 +1,6 @@
+import { api } from "@/lib/api-client";
+import type { CircuitBreaker } from "@/lib/api-types";
+
+export function fetchCircuitBreakers(signal?: AbortSignal): Promise
{
+ return api.get("/api/circuit-breakers", { signal });
+}
diff --git a/dashboard-next/src/features/circuit-breakers/components/circuit-breakers-table.tsx b/dashboard-next/src/features/circuit-breakers/components/circuit-breakers-table.tsx
new file mode 100644
index 0000000..87d4d05
--- /dev/null
+++ b/dashboard-next/src/features/circuit-breakers/components/circuit-breakers-table.tsx
@@ -0,0 +1,116 @@
+import type { ColumnDef } from "@tanstack/react-table";
+import { CircuitBoard } from "lucide-react";
+import { useMemo } from "react";
+import { Badge } from "@/components/ui/badge";
+import { DataTable } from "@/components/ui/data-table";
+import { EmptyState } from "@/components/ui/empty-state";
+import { ErrorState } from "@/components/ui/error-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { CircuitBreaker } from "@/lib/api-types";
+import { CIRCUIT_LABEL, CIRCUIT_TONE } from "@/lib/status";
+import { formatDuration } from "@/lib/time";
+
+interface CircuitBreakersTableProps {
+ breakers: CircuitBreaker[] | undefined;
+ loading: boolean;
+ error: Error | null;
+ onRetry: () => void;
+}
+
+export function CircuitBreakersTable({
+ breakers,
+ loading,
+ error,
+ onRetry,
+}: CircuitBreakersTableProps) {
+ const columns = useMemo[]>(
+ () => [
+ {
+ accessorKey: "task_name",
+ header: "Task",
+ cell: ({ getValue }) => (
+ {getValue()}
+ ),
+ },
+ {
+ accessorKey: "state",
+ header: "State",
+ cell: ({ row }) => (
+ {CIRCUIT_LABEL[row.original.state]}
+ ),
+ },
+ {
+ accessorKey: "failure_count",
+ header: "Failures / Threshold",
+ cell: ({ row }) => {
+ const { failure_count, threshold } = row.original;
+ const over = failure_count >= threshold;
+ return (
+
+ {failure_count} / {threshold}
+
+ );
+ },
+ },
+ {
+ accessorKey: "window_ms",
+ header: "Window",
+ cell: ({ getValue }) => (
+
+ {formatDuration(getValue())}
+
+ ),
+ },
+ {
+ accessorKey: "cooldown_ms",
+ header: "Cooldown",
+ cell: ({ getValue }) => (
+
+ {formatDuration(getValue())}
+
+ ),
+ },
+ {
+ accessorKey: "last_failure_at",
+ header: "Last failure",
+ cell: ({ getValue }) => {
+ const v = getValue();
+ if (!v) return —;
+ const ago = Math.round((Date.now() - v * 1000) / 1000);
+ return (
+
+ {ago < 60 ? `${ago}s ago` : `${Math.round(ago / 60)}m ago`}
+
+ );
+ },
+ },
+ ],
+ [],
+ );
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (loading && !breakers) {
+ return ;
+ }
+
+ if (!breakers || breakers.length === 0) {
+ return (
+
+ );
+ }
+
+ return b.task_name} />;
+}
diff --git a/dashboard-next/src/features/circuit-breakers/components/index.ts b/dashboard-next/src/features/circuit-breakers/components/index.ts
new file mode 100644
index 0000000..3b8515a
--- /dev/null
+++ b/dashboard-next/src/features/circuit-breakers/components/index.ts
@@ -0,0 +1 @@
+export { CircuitBreakersTable } from "./circuit-breakers-table";
diff --git a/dashboard-next/src/features/circuit-breakers/hooks.ts b/dashboard-next/src/features/circuit-breakers/hooks.ts
new file mode 100644
index 0000000..546447e
--- /dev/null
+++ b/dashboard-next/src/features/circuit-breakers/hooks.ts
@@ -0,0 +1,12 @@
+import { useQuery } from "@tanstack/react-query";
+import { useRefreshInterval } from "@/providers/refresh-interval-provider";
+import { fetchCircuitBreakers } from "./api";
+
+export function useCircuitBreakers() {
+ const { intervalMs } = useRefreshInterval();
+ return useQuery({
+ queryKey: ["circuit-breakers"],
+ queryFn: ({ signal }) => fetchCircuitBreakers(signal),
+ refetchInterval: intervalMs,
+ });
+}
diff --git a/dashboard-next/src/features/circuit-breakers/index.ts b/dashboard-next/src/features/circuit-breakers/index.ts
new file mode 100644
index 0000000..a234113
--- /dev/null
+++ b/dashboard-next/src/features/circuit-breakers/index.ts
@@ -0,0 +1,2 @@
+export * from "./components";
+export * from "./hooks";
diff --git a/dashboard-next/src/features/resources/api.ts b/dashboard-next/src/features/resources/api.ts
new file mode 100644
index 0000000..066202b
--- /dev/null
+++ b/dashboard-next/src/features/resources/api.ts
@@ -0,0 +1,6 @@
+import { api } from "@/lib/api-client";
+import type { ResourceStatus } from "@/lib/api-types";
+
+export function fetchResources(signal?: AbortSignal): Promise {
+ return api.get("/api/resources", { signal });
+}
diff --git a/dashboard-next/src/features/resources/components/index.ts b/dashboard-next/src/features/resources/components/index.ts
new file mode 100644
index 0000000..1d9e5c3
--- /dev/null
+++ b/dashboard-next/src/features/resources/components/index.ts
@@ -0,0 +1 @@
+export { ResourcesTable } from "./resources-table";
diff --git a/dashboard-next/src/features/resources/components/resources-table.tsx b/dashboard-next/src/features/resources/components/resources-table.tsx
new file mode 100644
index 0000000..7cce0d9
--- /dev/null
+++ b/dashboard-next/src/features/resources/components/resources-table.tsx
@@ -0,0 +1,128 @@
+import type { ColumnDef } from "@tanstack/react-table";
+import { Activity } from "lucide-react";
+import { useMemo } from "react";
+import { Badge } from "@/components/ui/badge";
+import { DataTable } from "@/components/ui/data-table";
+import { EmptyState } from "@/components/ui/empty-state";
+import { ErrorState } from "@/components/ui/error-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { ResourceStatus } from "@/lib/api-types";
+import { resourceTone } from "@/lib/status";
+import { formatDuration } from "@/lib/time";
+
+interface ResourcesTableProps {
+ resources: ResourceStatus[] | undefined;
+ loading: boolean;
+ error: Error | null;
+ onRetry: () => void;
+}
+
+export function ResourcesTable({ resources, loading, error, onRetry }: ResourcesTableProps) {
+ const columns = useMemo[]>(
+ () => [
+ {
+ accessorKey: "name",
+ header: "Resource",
+ cell: ({ getValue }) => (
+ {getValue()}
+ ),
+ },
+ {
+ accessorKey: "scope",
+ header: "Scope",
+ cell: ({ getValue }) => (
+
+ {getValue()}
+
+ ),
+ },
+ {
+ accessorKey: "health",
+ header: "Health",
+ cell: ({ getValue }) => {
+ const health = getValue();
+ return {health};
+ },
+ },
+ {
+ accessorKey: "init_duration_ms",
+ header: "Init",
+ cell: ({ getValue }) => (
+
+ {formatDuration(getValue())}
+
+ ),
+ },
+ {
+ accessorKey: "recreations",
+ header: "Recreations",
+ cell: ({ getValue }) => {
+ const n = getValue();
+ return (
+ 0 ? "text-warning" : "text-[var(--fg-muted)]"}`}>
+ {n}
+
+ );
+ },
+ },
+ {
+ id: "pool",
+ header: "Pool",
+ cell: ({ row }) => {
+ const p = row.original.pool;
+ if (!p) return —;
+ return (
+
+ {p.active}/{p.size} active · {p.idle} idle
+ {p.total_timeouts > 0 ? (
+ · {p.total_timeouts} timeouts
+ ) : null}
+
+ );
+ },
+ },
+ {
+ accessorKey: "depends_on",
+ header: "Depends on",
+ cell: ({ getValue }) => {
+ const deps = getValue();
+ if (!deps || deps.length === 0) {
+ return —;
+ }
+ return (
+
+ {deps.map((d) => (
+
+ {d}
+
+ ))}
+
+ );
+ },
+ },
+ ],
+ [],
+ );
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (loading && !resources) {
+ return ;
+ }
+
+ if (!resources || resources.length === 0) {
+ return (
+
+ );
+ }
+
+ return r.name} />;
+}
diff --git a/dashboard-next/src/features/resources/hooks.ts b/dashboard-next/src/features/resources/hooks.ts
new file mode 100644
index 0000000..ac7a822
--- /dev/null
+++ b/dashboard-next/src/features/resources/hooks.ts
@@ -0,0 +1,12 @@
+import { useQuery } from "@tanstack/react-query";
+import { useRefreshInterval } from "@/providers/refresh-interval-provider";
+import { fetchResources } from "./api";
+
+export function useResources() {
+ const { intervalMs } = useRefreshInterval();
+ return useQuery({
+ queryKey: ["resources"],
+ queryFn: ({ signal }) => fetchResources(signal),
+ refetchInterval: intervalMs,
+ });
+}
diff --git a/dashboard-next/src/features/resources/index.ts b/dashboard-next/src/features/resources/index.ts
new file mode 100644
index 0000000..a234113
--- /dev/null
+++ b/dashboard-next/src/features/resources/index.ts
@@ -0,0 +1,2 @@
+export * from "./components";
+export * from "./hooks";
diff --git a/dashboard-next/src/features/system/api.ts b/dashboard-next/src/features/system/api.ts
new file mode 100644
index 0000000..656a0e2
--- /dev/null
+++ b/dashboard-next/src/features/system/api.ts
@@ -0,0 +1,10 @@
+import { api } from "@/lib/api-client";
+import type { InterceptionStats, ProxyStats } from "@/lib/api-types";
+
+export function fetchProxyStats(signal?: AbortSignal): Promise {
+ return api.get("/api/proxy-stats", { signal });
+}
+
+export function fetchInterceptionStats(signal?: AbortSignal): Promise {
+ return api.get("/api/interception-stats", { signal });
+}
diff --git a/dashboard-next/src/features/system/components/index.ts b/dashboard-next/src/features/system/components/index.ts
new file mode 100644
index 0000000..60c802c
--- /dev/null
+++ b/dashboard-next/src/features/system/components/index.ts
@@ -0,0 +1,2 @@
+export { InterceptionTable } from "./interception-table";
+export { ProxyTable } from "./proxy-table";
diff --git a/dashboard-next/src/features/system/components/interception-table.tsx b/dashboard-next/src/features/system/components/interception-table.tsx
new file mode 100644
index 0000000..22160c3
--- /dev/null
+++ b/dashboard-next/src/features/system/components/interception-table.tsx
@@ -0,0 +1,79 @@
+import type { ColumnDef } from "@tanstack/react-table";
+import { useMemo } from "react";
+import { DataTable } from "@/components/ui/data-table";
+import { ErrorState } from "@/components/ui/error-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { InterceptionStats } from "@/lib/api-types";
+import { formatCount } from "@/lib/number";
+
+interface Row {
+ strategy: string;
+ count: number;
+ avg_ms: number;
+}
+
+interface InterceptionTableProps {
+ stats: InterceptionStats | undefined;
+ loading: boolean;
+ error: Error | null;
+ onRetry: () => void;
+}
+
+export function InterceptionTable({ stats, loading, error, onRetry }: InterceptionTableProps) {
+ const rows = useMemo(() => {
+ if (!stats) return [];
+ return Object.entries(stats)
+ .map(([strategy, v]) => ({ strategy, ...v }))
+ .sort((a, b) => b.count - a.count);
+ }, [stats]);
+
+ const columns = useMemo[]>(
+ () => [
+ {
+ accessorKey: "strategy",
+ header: "Strategy",
+ cell: ({ getValue }) => (
+ {getValue()}
+ ),
+ },
+ {
+ accessorKey: "count",
+ header: "Count",
+ cell: ({ getValue }) => (
+ {formatCount(getValue())}
+ ),
+ },
+ {
+ accessorKey: "avg_ms",
+ header: "Avg",
+ cell: ({ getValue }) => (
+
+ {getValue().toFixed(1)}ms
+
+ ),
+ },
+ ],
+ [],
+ );
+
+ if (error) {
+ return (
+
+ );
+ }
+ if (loading && rows.length === 0) {
+ return ;
+ }
+ return (
+ r.strategy}
+ empty="No interceptions recorded"
+ />
+ );
+}
diff --git a/dashboard-next/src/features/system/components/proxy-table.tsx b/dashboard-next/src/features/system/components/proxy-table.tsx
new file mode 100644
index 0000000..b7002f7
--- /dev/null
+++ b/dashboard-next/src/features/system/components/proxy-table.tsx
@@ -0,0 +1,88 @@
+import type { ColumnDef } from "@tanstack/react-table";
+import { useMemo } from "react";
+import { DataTable } from "@/components/ui/data-table";
+import { ErrorState } from "@/components/ui/error-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { ProxyStats } from "@/lib/api-types";
+import { formatCount } from "@/lib/number";
+
+interface Row {
+ handler: string;
+ reconstructions: number;
+ avg_ms: number;
+ errors: number;
+}
+
+interface ProxyTableProps {
+ stats: ProxyStats | undefined;
+ loading: boolean;
+ error: Error | null;
+ onRetry: () => void;
+}
+
+export function ProxyTable({ stats, loading, error, onRetry }: ProxyTableProps) {
+ const rows = useMemo(() => {
+ if (!stats) return [];
+ return Object.entries(stats)
+ .map(([handler, v]) => ({ handler, ...v }))
+ .sort((a, b) => b.reconstructions - a.reconstructions);
+ }, [stats]);
+
+ const columns = useMemo[]>(
+ () => [
+ {
+ accessorKey: "handler",
+ header: "Handler",
+ cell: ({ getValue }) => (
+ {getValue()}
+ ),
+ },
+ {
+ accessorKey: "reconstructions",
+ header: "Reconstructions",
+ cell: ({ getValue }) => (
+ {formatCount(getValue())}
+ ),
+ },
+ {
+ accessorKey: "avg_ms",
+ header: "Avg",
+ cell: ({ getValue }) => (
+
+ {getValue().toFixed(1)}ms
+
+ ),
+ },
+ {
+ accessorKey: "errors",
+ header: "Errors",
+ cell: ({ getValue }) => {
+ const n = getValue();
+ return (
+ 0 ? "text-danger" : "text-[var(--fg-muted)]"}`}>
+ {n}
+
+ );
+ },
+ },
+ ],
+ [],
+ );
+
+ if (error) {
+ return (
+
+ );
+ }
+ if (loading && rows.length === 0) {
+ return ;
+ }
+ return (
+ r.handler}
+ empty="No proxy reconstructions recorded"
+ />
+ );
+}
diff --git a/dashboard-next/src/features/system/hooks.ts b/dashboard-next/src/features/system/hooks.ts
new file mode 100644
index 0000000..a0f2e4f
--- /dev/null
+++ b/dashboard-next/src/features/system/hooks.ts
@@ -0,0 +1,21 @@
+import { useQuery } from "@tanstack/react-query";
+import { useRefreshInterval } from "@/providers/refresh-interval-provider";
+import { fetchInterceptionStats, fetchProxyStats } from "./api";
+
+export function useProxyStats() {
+ const { intervalMs } = useRefreshInterval();
+ return useQuery({
+ queryKey: ["system", "proxy-stats"],
+ queryFn: ({ signal }) => fetchProxyStats(signal),
+ refetchInterval: intervalMs,
+ });
+}
+
+export function useInterceptionStats() {
+ const { intervalMs } = useRefreshInterval();
+ return useQuery({
+ queryKey: ["system", "interception-stats"],
+ queryFn: ({ signal }) => fetchInterceptionStats(signal),
+ refetchInterval: intervalMs,
+ });
+}
diff --git a/dashboard-next/src/features/system/index.ts b/dashboard-next/src/features/system/index.ts
new file mode 100644
index 0000000..a234113
--- /dev/null
+++ b/dashboard-next/src/features/system/index.ts
@@ -0,0 +1,2 @@
+export * from "./components";
+export * from "./hooks";
diff --git a/dashboard-next/src/features/workers/api.ts b/dashboard-next/src/features/workers/api.ts
new file mode 100644
index 0000000..bbf8810
--- /dev/null
+++ b/dashboard-next/src/features/workers/api.ts
@@ -0,0 +1,6 @@
+import { api } from "@/lib/api-client";
+import type { Worker } from "@/lib/api-types";
+
+export function fetchWorkers(signal?: AbortSignal): Promise {
+ return api.get("/api/workers", { signal });
+}
diff --git a/dashboard-next/src/features/workers/components/index.ts b/dashboard-next/src/features/workers/components/index.ts
new file mode 100644
index 0000000..9b5939d
--- /dev/null
+++ b/dashboard-next/src/features/workers/components/index.ts
@@ -0,0 +1 @@
+export { WorkersTable } from "./workers-table";
diff --git a/dashboard-next/src/features/workers/components/workers-table.tsx b/dashboard-next/src/features/workers/components/workers-table.tsx
new file mode 100644
index 0000000..00a8b86
--- /dev/null
+++ b/dashboard-next/src/features/workers/components/workers-table.tsx
@@ -0,0 +1,111 @@
+import type { ColumnDef } from "@tanstack/react-table";
+import { Server } from "lucide-react";
+import { useMemo } from "react";
+import { Badge } from "@/components/ui/badge";
+import { DataTable } from "@/components/ui/data-table";
+import { EmptyState } from "@/components/ui/empty-state";
+import { ErrorState } from "@/components/ui/error-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { Worker } from "@/lib/api-types";
+import { formatRelative } from "@/lib/time";
+
+const STALE_AFTER_MS = 30_000;
+
+interface WorkersTableProps {
+ workers: Worker[] | undefined;
+ loading: boolean;
+ error: Error | null;
+ onRetry: () => void;
+}
+
+export function WorkersTable({ workers, loading, error, onRetry }: WorkersTableProps) {
+ const columns = useMemo[]>(
+ () => [
+ {
+ accessorKey: "worker_id",
+ header: "Worker",
+ cell: ({ getValue }) => (
+ {getValue()}
+ ),
+ },
+ {
+ accessorKey: "queues",
+ header: "Queues",
+ cell: ({ getValue }) => {
+ const raw = getValue();
+ const parts = raw
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+ return (
+
+ {parts.map((q) => (
+
+ {q}
+
+ ))}
+
+ );
+ },
+ },
+ {
+ accessorKey: "last_heartbeat",
+ header: "Heartbeat",
+ cell: ({ getValue }) => {
+ const ts = getValue();
+ const stale = Date.now() - ts * 1000 > STALE_AFTER_MS;
+ return (
+
+
+ {formatRelative(ts * 1000)}
+
+ );
+ },
+ },
+ {
+ accessorKey: "registered_at",
+ header: "Registered",
+ cell: ({ getValue }) => (
+
+ {formatRelative(getValue() * 1000)}
+
+ ),
+ },
+ {
+ accessorKey: "tags",
+ header: "Tags",
+ cell: ({ getValue }) => {
+ const tags = getValue();
+ if (!tags) return —;
+ return {tags};
+ },
+ },
+ ],
+ [],
+ );
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (loading && !workers) {
+ return ;
+ }
+
+ if (!workers || workers.length === 0) {
+ return (
+
+ );
+ }
+
+ return w.worker_id} />;
+}
diff --git a/dashboard-next/src/features/workers/hooks.ts b/dashboard-next/src/features/workers/hooks.ts
new file mode 100644
index 0000000..4ab8baf
--- /dev/null
+++ b/dashboard-next/src/features/workers/hooks.ts
@@ -0,0 +1,12 @@
+import { useQuery } from "@tanstack/react-query";
+import { useRefreshInterval } from "@/providers/refresh-interval-provider";
+import { fetchWorkers } from "./api";
+
+export function useWorkers() {
+ const { intervalMs } = useRefreshInterval();
+ return useQuery({
+ queryKey: ["workers"],
+ queryFn: ({ signal }) => fetchWorkers(signal),
+ refetchInterval: intervalMs,
+ });
+}
diff --git a/dashboard-next/src/features/workers/index.ts b/dashboard-next/src/features/workers/index.ts
new file mode 100644
index 0000000..a234113
--- /dev/null
+++ b/dashboard-next/src/features/workers/index.ts
@@ -0,0 +1,2 @@
+export * from "./components";
+export * from "./hooks";
diff --git a/dashboard-next/src/routes/circuit-breakers.tsx b/dashboard-next/src/routes/circuit-breakers.tsx
index 0183002..14f0fde 100644
--- a/dashboard-next/src/routes/circuit-breakers.tsx
+++ b/dashboard-next/src/routes/circuit-breakers.tsx
@@ -1,20 +1,26 @@
import { createFileRoute } from "@tanstack/react-router";
-import { CircuitBoard } from "lucide-react";
import { PageHeader } from "@/components/layout";
-import { EmptyState } from "@/components/ui/empty-state";
+import { CircuitBreakersTable, useCircuitBreakers } from "@/features/circuit-breakers";
export const Route = createFileRoute("/circuit-breakers")({
component: CircuitBreakersPage,
});
function CircuitBreakersPage() {
+ const breakers = useCircuitBreakers();
+
return (
<>
-
+ breakers.refetch()}
+ />
>
);
}
diff --git a/dashboard-next/src/routes/resources.tsx b/dashboard-next/src/routes/resources.tsx
index cb70f7f..7b19639 100644
--- a/dashboard-next/src/routes/resources.tsx
+++ b/dashboard-next/src/routes/resources.tsx
@@ -1,17 +1,26 @@
import { createFileRoute } from "@tanstack/react-router";
-import { Activity } from "lucide-react";
import { PageHeader } from "@/components/layout";
-import { EmptyState } from "@/components/ui/empty-state";
+import { ResourcesTable, useResources } from "@/features/resources";
export const Route = createFileRoute("/resources")({
component: ResourcesPage,
});
function ResourcesPage() {
+ const resources = useResources();
+
return (
<>
-
-
+
+ resources.refetch()}
+ />
>
);
}
diff --git a/dashboard-next/src/routes/system.tsx b/dashboard-next/src/routes/system.tsx
index 1823e95..46a0410 100644
--- a/dashboard-next/src/routes/system.tsx
+++ b/dashboard-next/src/routes/system.tsx
@@ -1,17 +1,48 @@
import { createFileRoute } from "@tanstack/react-router";
-import { Settings2 } from "lucide-react";
import { PageHeader } from "@/components/layout";
-import { EmptyState } from "@/components/ui/empty-state";
+import {
+ InterceptionTable,
+ ProxyTable,
+ useInterceptionStats,
+ useProxyStats,
+} from "@/features/system";
export const Route = createFileRoute("/system")({
component: SystemPage,
});
function SystemPage() {
+ const proxy = useProxyStats();
+ const interception = useInterceptionStats();
+
return (
<>
-
-
+
+
+
+ Proxy handlers
+ proxy.refetch()}
+ />
+
+
+
+ Interception strategies
+
+ interception.refetch()}
+ />
+
+
>
);
}
diff --git a/dashboard-next/src/routes/workers.tsx b/dashboard-next/src/routes/workers.tsx
index d1534ff..2cd1a25 100644
--- a/dashboard-next/src/routes/workers.tsx
+++ b/dashboard-next/src/routes/workers.tsx
@@ -1,17 +1,32 @@
import { createFileRoute } from "@tanstack/react-router";
-import { Server } from "lucide-react";
import { PageHeader } from "@/components/layout";
-import { EmptyState } from "@/components/ui/empty-state";
+import { useWorkers, WorkersTable } from "@/features/workers";
+import { formatCount } from "@/lib/number";
export const Route = createFileRoute("/workers")({
component: WorkersPage,
});
function WorkersPage() {
+ const workers = useWorkers();
+ const count = workers.data?.length;
+
return (
<>
-
-
+
+ workers.refetch()}
+ />
>
);
}
From 24667cbb572d0cc1045a5a7ad5d9d27529c5ee4a Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 25 Apr 2026 01:08:22 +0530
Subject: [PATCH 13/42] chore(dashboard-next): add debounced-value hook and
widen page header
---
dashboard-next/src/components/layout/page-header.tsx | 2 +-
dashboard-next/src/hooks/index.ts | 1 +
dashboard-next/src/hooks/use-debounced-value.ts | 10 ++++++++++
3 files changed, 12 insertions(+), 1 deletion(-)
create mode 100644 dashboard-next/src/hooks/use-debounced-value.ts
diff --git a/dashboard-next/src/components/layout/page-header.tsx b/dashboard-next/src/components/layout/page-header.tsx
index 387b78b..9867135 100644
--- a/dashboard-next/src/components/layout/page-header.tsx
+++ b/dashboard-next/src/components/layout/page-header.tsx
@@ -5,7 +5,7 @@ import { Breadcrumbs, type Crumb } from "./breadcrumbs";
interface PageHeaderProps {
eyebrow?: string;
title: string;
- description?: string;
+ description?: ReactNode;
actions?: ReactNode;
breadcrumbs?: Crumb[];
className?: string;
diff --git a/dashboard-next/src/hooks/index.ts b/dashboard-next/src/hooks/index.ts
index 66ebcac..84ccbe6 100644
--- a/dashboard-next/src/hooks/index.ts
+++ b/dashboard-next/src/hooks/index.ts
@@ -1,2 +1,3 @@
+export { useDebouncedValue } from "./use-debounced-value";
export { useLastRefreshed } from "./use-last-refreshed";
export { useMediaQuery } from "./use-media-query";
diff --git a/dashboard-next/src/hooks/use-debounced-value.ts b/dashboard-next/src/hooks/use-debounced-value.ts
new file mode 100644
index 0000000..fbf8290
--- /dev/null
+++ b/dashboard-next/src/hooks/use-debounced-value.ts
@@ -0,0 +1,10 @@
+import { useEffect, useState } from "react";
+
+export function useDebouncedValue(value: T, delay = 300): T {
+ const [debounced, setDebounced] = useState(value);
+ useEffect(() => {
+ const id = setTimeout(() => setDebounced(value), delay);
+ return () => clearTimeout(id);
+ }, [value, delay]);
+ return debounced;
+}
From fac4dcaec8389d5c91bb161b4ac7e849f974ddf8 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 25 Apr 2026 01:08:29 +0530
Subject: [PATCH 14/42] feat(dashboard-next): jobs api layer with optimistic
mutations
---
dashboard-next/src/features/jobs/api.ts | 38 +++++
dashboard-next/src/features/jobs/hooks.ts | 171 ++++++++++++++++++++++
dashboard-next/src/features/jobs/types.ts | 21 +++
dashboard-next/src/features/jobs/utils.ts | 97 ++++++++++++
4 files changed, 327 insertions(+)
create mode 100644 dashboard-next/src/features/jobs/api.ts
create mode 100644 dashboard-next/src/features/jobs/hooks.ts
create mode 100644 dashboard-next/src/features/jobs/types.ts
create mode 100644 dashboard-next/src/features/jobs/utils.ts
diff --git a/dashboard-next/src/features/jobs/api.ts b/dashboard-next/src/features/jobs/api.ts
new file mode 100644
index 0000000..2d3b6ec
--- /dev/null
+++ b/dashboard-next/src/features/jobs/api.ts
@@ -0,0 +1,38 @@
+import { api } from "@/lib/api-client";
+import type { DagData, Job, JobError, ReplayEntry, TaskLog } from "@/lib/api-types";
+import type { JobListQuery } from "./types";
+import { toApiParams } from "./utils";
+
+export function fetchJobs(query: JobListQuery, signal?: AbortSignal): Promise {
+ return api.get("/api/jobs", { signal, params: toApiParams(query) });
+}
+
+export function fetchJob(id: string, signal?: AbortSignal): Promise {
+ return api.get(`/api/jobs/${encodeURIComponent(id)}`, { signal });
+}
+
+export function fetchJobLogs(id: string, signal?: AbortSignal): Promise {
+ return api.get(`/api/jobs/${encodeURIComponent(id)}/logs`, { signal });
+}
+
+export function fetchJobErrors(id: string, signal?: AbortSignal): Promise {
+ return api.get(`/api/jobs/${encodeURIComponent(id)}/errors`, { signal });
+}
+
+export function fetchReplayHistory(id: string, signal?: AbortSignal): Promise {
+ return api.get(`/api/jobs/${encodeURIComponent(id)}/replay-history`, {
+ signal,
+ });
+}
+
+export function fetchJobDag(id: string, signal?: AbortSignal): Promise {
+ return api.get(`/api/jobs/${encodeURIComponent(id)}/dag`, { signal });
+}
+
+export function cancelJob(id: string): Promise<{ cancelled: boolean }> {
+ return api.post<{ cancelled: boolean }>(`/api/jobs/${encodeURIComponent(id)}/cancel`);
+}
+
+export function replayJob(id: string): Promise<{ replay_job_id: string }> {
+ return api.post<{ replay_job_id: string }>(`/api/jobs/${encodeURIComponent(id)}/replay`);
+}
diff --git a/dashboard-next/src/features/jobs/hooks.ts b/dashboard-next/src/features/jobs/hooks.ts
new file mode 100644
index 0000000..e696ea7
--- /dev/null
+++ b/dashboard-next/src/features/jobs/hooks.ts
@@ -0,0 +1,171 @@
+import {
+ keepPreviousData,
+ type Query,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
+import { toast } from "sonner";
+import type { Job } from "@/lib/api-types";
+import { useRefreshInterval } from "@/providers/refresh-interval-provider";
+import {
+ cancelJob,
+ fetchJob,
+ fetchJobDag,
+ fetchJobErrors,
+ fetchJobLogs,
+ fetchJobs,
+ fetchReplayHistory,
+ replayJob,
+} from "./api";
+import type { JobListQuery } from "./types";
+import { isTerminalStatus } from "./utils";
+
+const KEY = {
+ all: ["jobs"] as const,
+ list: (q: JobListQuery) => ["jobs", "list", q] as const,
+ detail: (id: string) => ["jobs", "detail", id] as const,
+ logs: (id: string) => ["jobs", "detail", id, "logs"] as const,
+ errors: (id: string) => ["jobs", "detail", id, "errors"] as const,
+ replays: (id: string) => ["jobs", "detail", id, "replays"] as const,
+ dag: (id: string) => ["jobs", "detail", id, "dag"] as const,
+};
+
+/**
+ * Paginated job list. Uses `keepPreviousData` to avoid flashing empty state
+ * between pages, and polls at the dashboard-wide refresh cadence.
+ */
+export function useJobs(query: JobListQuery) {
+ const { intervalMs } = useRefreshInterval();
+ return useQuery({
+ queryKey: KEY.list(query),
+ queryFn: ({ signal }) => fetchJobs(query, signal),
+ placeholderData: keepPreviousData,
+ refetchInterval: intervalMs,
+ });
+}
+
+/**
+ * Single job detail. Polling stops once the job reaches a terminal state so
+ * we don't keep hammering the API for data that won't change again.
+ */
+export function useJob(id: string, enabled = true) {
+ const { intervalMs } = useRefreshInterval();
+ return useQuery({
+ queryKey: KEY.detail(id),
+ queryFn: ({ signal }) => fetchJob(id, signal),
+ enabled,
+ refetchInterval: (query: Query) => {
+ const data = query.state.data;
+ if (data && isTerminalStatus(data.status)) return false;
+ return intervalMs;
+ },
+ });
+}
+
+export function useJobLogs(id: string, enabled = true) {
+ const { intervalMs } = useRefreshInterval();
+ return useQuery({
+ queryKey: KEY.logs(id),
+ queryFn: ({ signal }) => fetchJobLogs(id, signal),
+ enabled,
+ refetchInterval: intervalMs,
+ });
+}
+
+export function useJobErrors(id: string, enabled = true) {
+ const { intervalMs } = useRefreshInterval();
+ return useQuery({
+ queryKey: KEY.errors(id),
+ queryFn: ({ signal }) => fetchJobErrors(id, signal),
+ enabled,
+ refetchInterval: intervalMs,
+ });
+}
+
+export function useReplayHistory(id: string, enabled = true) {
+ return useQuery({
+ queryKey: KEY.replays(id),
+ queryFn: ({ signal }) => fetchReplayHistory(id, signal),
+ enabled,
+ });
+}
+
+export function useJobDag(id: string, enabled = true) {
+ return useQuery({
+ queryKey: KEY.dag(id),
+ queryFn: ({ signal }) => fetchJobDag(id, signal),
+ enabled,
+ });
+}
+
+interface MutationContext {
+ prev: Job | undefined;
+}
+
+/**
+ * Cancel a pending/running job.
+ *
+ * Optimistically flips local status to "cancelled" so the UI feels instant;
+ * if the request fails we roll back and surface a toast. On settle we
+ * invalidate every cached list so pagination/stats refresh.
+ */
+export function useCancelJob() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => cancelJob(id),
+ onMutate: async (id) => {
+ await qc.cancelQueries({ queryKey: KEY.detail(id) });
+ const prev = qc.getQueryData(KEY.detail(id));
+ if (prev) {
+ qc.setQueryData(KEY.detail(id), { ...prev, status: "cancelled" });
+ }
+ return { prev } satisfies MutationContext;
+ },
+ onError: (error, id, ctx) => {
+ if (ctx?.prev) qc.setQueryData(KEY.detail(id), ctx.prev);
+ toast.error("Couldn't cancel job", {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ },
+ onSuccess: (result) => {
+ if (result.cancelled) {
+ toast.success("Job cancelled");
+ } else {
+ toast.info("Job wasn't in a cancellable state");
+ }
+ },
+ onSettled: (_data, _err, id) => {
+ qc.invalidateQueries({ queryKey: KEY.detail(id) });
+ qc.invalidateQueries({ queryKey: KEY.all });
+ qc.invalidateQueries({ queryKey: ["stats"] });
+ },
+ });
+}
+
+/**
+ * Replay a terminal job. Returns the new job ID to the caller so the route
+ * can navigate to it.
+ */
+export function useReplayJob() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => replayJob(id),
+ onError: (error) => {
+ toast.error("Couldn't replay job", {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ },
+ onSuccess: (result) => {
+ toast.success("Job re-enqueued", {
+ description: `New job: ${result.replay_job_id.slice(0, 8)}…`,
+ });
+ },
+ onSettled: (_data, _err, id) => {
+ qc.invalidateQueries({ queryKey: KEY.detail(id) });
+ qc.invalidateQueries({ queryKey: KEY.replays(id) });
+ qc.invalidateQueries({ queryKey: KEY.all });
+ qc.invalidateQueries({ queryKey: ["stats"] });
+ },
+ });
+}
diff --git a/dashboard-next/src/features/jobs/types.ts b/dashboard-next/src/features/jobs/types.ts
new file mode 100644
index 0000000..c8ed0fb
--- /dev/null
+++ b/dashboard-next/src/features/jobs/types.ts
@@ -0,0 +1,21 @@
+import type { JobStatus } from "@/lib/api-types";
+
+/**
+ * Filter criteria for listing jobs. Every field is optional and narrowing
+ * — pagination lives alongside so a single `JobListQuery` round-trips
+ * through the URL search params.
+ */
+export interface JobFilters {
+ status?: JobStatus;
+ queue?: string;
+ task?: string;
+ metadata?: string;
+ error?: string;
+ createdAfter?: number;
+ createdBefore?: number;
+}
+
+export interface JobListQuery extends JobFilters {
+ page: number;
+ pageSize: number;
+}
diff --git a/dashboard-next/src/features/jobs/utils.ts b/dashboard-next/src/features/jobs/utils.ts
new file mode 100644
index 0000000..866a44b
--- /dev/null
+++ b/dashboard-next/src/features/jobs/utils.ts
@@ -0,0 +1,97 @@
+import type { JobStatus } from "@/lib/api-types";
+import type { JobFilters, JobListQuery } from "./types";
+
+const STATUS_VALUES: readonly JobStatus[] = [
+ "pending",
+ "running",
+ "complete",
+ "failed",
+ "dead",
+ "cancelled",
+] as const;
+
+const DEFAULT_PAGE_SIZE = 25;
+
+function asTrimmedString(value: unknown): string | undefined {
+ if (typeof value !== "string") return undefined;
+ const trimmed = value.trim();
+ return trimmed === "" ? undefined : trimmed;
+}
+
+function asNonNegativeInteger(value: unknown): number | undefined {
+ const n = Number(value);
+ if (!Number.isFinite(n)) return undefined;
+ const int = Math.floor(n);
+ return int >= 0 ? int : undefined;
+}
+
+function asJobStatus(value: unknown): JobStatus | undefined {
+ return STATUS_VALUES.find((s) => s === value);
+}
+
+/**
+ * Parse raw search params (from TanStack Router's `validateSearch`) into a
+ * strongly-typed query. Missing/invalid fields are dropped silently so deep
+ * links never crash the route — an over-strict parser would be a footgun.
+ */
+export function parseJobListSearch(raw: Record): JobListQuery {
+ return {
+ status: asJobStatus(raw.status),
+ queue: asTrimmedString(raw.queue),
+ task: asTrimmedString(raw.task),
+ metadata: asTrimmedString(raw.metadata),
+ error: asTrimmedString(raw.error),
+ createdAfter: asNonNegativeInteger(raw.createdAfter),
+ createdBefore: asNonNegativeInteger(raw.createdBefore),
+ page: asNonNegativeInteger(raw.page) ?? 0,
+ pageSize: Math.min(Math.max(asNonNegativeInteger(raw.pageSize) ?? DEFAULT_PAGE_SIZE, 10), 200),
+ };
+}
+
+/**
+ * Map a `JobListQuery` to the `/api/jobs` query-string shape understood by
+ * the Python dashboard handler. Empty fields are omitted so the URL stays
+ * clean.
+ */
+export function toApiParams(query: JobListQuery): Record {
+ const params: Record = {
+ limit: query.pageSize,
+ offset: query.page * query.pageSize,
+ };
+ if (query.status) params.status = query.status;
+ if (query.queue) params.queue = query.queue;
+ if (query.task) params.task = query.task;
+ if (query.metadata) params.metadata = query.metadata;
+ if (query.error) params.error = query.error;
+ if (query.createdAfter != null) params.created_after = query.createdAfter;
+ if (query.createdBefore != null) params.created_before = query.createdBefore;
+ return params;
+}
+
+export function countActiveFilters(filters: JobFilters): number {
+ let n = 0;
+ if (filters.status) n++;
+ if (filters.queue) n++;
+ if (filters.task) n++;
+ if (filters.metadata) n++;
+ if (filters.error) n++;
+ if (filters.createdAfter != null) n++;
+ if (filters.createdBefore != null) n++;
+ return n;
+}
+
+const TERMINAL_STATUSES = new Set(["complete", "failed", "dead", "cancelled"]);
+
+export function isTerminalStatus(status: JobStatus | undefined): boolean {
+ if (!status) return false;
+ return TERMINAL_STATUSES.has(status);
+}
+
+export function canCancel(status: JobStatus | undefined): boolean {
+ return status === "pending" || status === "running";
+}
+
+export function canReplay(status: JobStatus | undefined): boolean {
+ if (!status) return false;
+ return TERMINAL_STATUSES.has(status);
+}
From 67241cff226d06750c7a7da59d91fa328ff5fd14 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 25 Apr 2026 01:08:35 +0530
Subject: [PATCH 15/42] feat(dashboard-next): jobs list with url-synced filters
and actions
---
.../features/jobs/components/job-actions.tsx | 89 ++++++++
.../features/jobs/components/job-filters.tsx | 196 ++++++++++++++++++
.../jobs/components/job-search-bar.tsx | 52 +++++
.../features/jobs/components/job-table.tsx | 121 +++++++++++
dashboard-next/src/routes/jobs/index.tsx | 54 ++++-
5 files changed, 503 insertions(+), 9 deletions(-)
create mode 100644 dashboard-next/src/features/jobs/components/job-actions.tsx
create mode 100644 dashboard-next/src/features/jobs/components/job-filters.tsx
create mode 100644 dashboard-next/src/features/jobs/components/job-search-bar.tsx
create mode 100644 dashboard-next/src/features/jobs/components/job-table.tsx
diff --git a/dashboard-next/src/features/jobs/components/job-actions.tsx b/dashboard-next/src/features/jobs/components/job-actions.tsx
new file mode 100644
index 0000000..767ef6a
--- /dev/null
+++ b/dashboard-next/src/features/jobs/components/job-actions.tsx
@@ -0,0 +1,89 @@
+import { useNavigate } from "@tanstack/react-router";
+import { Ban, Copy, RotateCcw } from "lucide-react";
+import { useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { ConfirmDialog } from "@/components/ui/confirm-dialog";
+import type { Job } from "@/lib/api-types";
+import { useCancelJob, useReplayJob } from "../hooks";
+import { canCancel, canReplay } from "../utils";
+
+interface JobActionsProps {
+ job: Job;
+}
+
+export function JobActions({ job }: JobActionsProps) {
+ const navigate = useNavigate();
+ const cancelMutation = useCancelJob();
+ const replayMutation = useReplayJob();
+ const [confirmCancel, setConfirmCancel] = useState(false);
+ const [confirmReplay, setConfirmReplay] = useState(false);
+
+ const onCopyId = async () => {
+ try {
+ await navigator.clipboard.writeText(job.id);
+ toast.success("Job ID copied");
+ } catch {
+ toast.error("Couldn't copy to clipboard");
+ }
+ };
+
+ const onReplay = async () => {
+ const result = await replayMutation.mutateAsync(job.id);
+ if (result.replay_job_id) {
+ navigate({ to: "/jobs/$id", params: { id: result.replay_job_id } });
+ }
+ };
+
+ return (
+ <>
+
+ {canCancel(job.status) ? (
+
+ ) : null}
+ {canReplay(job.status) ? (
+
+ ) : null}
+
+ {
+ await cancelMutation.mutateAsync(job.id);
+ }}
+ />
+
+
+ >
+ );
+}
diff --git a/dashboard-next/src/features/jobs/components/job-filters.tsx b/dashboard-next/src/features/jobs/components/job-filters.tsx
new file mode 100644
index 0000000..c7db126
--- /dev/null
+++ b/dashboard-next/src/features/jobs/components/job-filters.tsx
@@ -0,0 +1,196 @@
+import { X } from "lucide-react";
+import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useDebouncedValue } from "@/hooks/use-debounced-value";
+import type { JobStatus } from "@/lib/api-types";
+import { cn } from "@/lib/cn";
+import { JOB_STATUS_LABEL } from "@/lib/status";
+import type { JobFilters } from "../types";
+import { countActiveFilters } from "../utils";
+
+const STATUS_OPTIONS: JobStatus[] = [
+ "pending",
+ "running",
+ "complete",
+ "failed",
+ "dead",
+ "cancelled",
+];
+
+interface JobFiltersBarProps {
+ filters: JobFilters;
+ onChange: (filters: JobFilters) => void;
+ className?: string;
+}
+
+/**
+ * Debounced filter bar for the jobs list.
+ *
+ * Text inputs (queue/task/metadata/error) keep local state and push through
+ * after a 300ms idle window; the Status select and date range are applied
+ * immediately since they're deliberate clicks. This keeps the API from
+ * being hammered while the user is still typing.
+ */
+export function JobFiltersBar({ filters, onChange, className }: JobFiltersBarProps) {
+ const [local, setLocal] = useState(() => ({
+ queue: filters.queue ?? "",
+ task: filters.task ?? "",
+ metadata: filters.metadata ?? "",
+ error: filters.error ?? "",
+ }));
+
+ // Reflect external changes (e.g., URL navigation) back into local state.
+ useEffect(() => {
+ setLocal({
+ queue: filters.queue ?? "",
+ task: filters.task ?? "",
+ metadata: filters.metadata ?? "",
+ error: filters.error ?? "",
+ });
+ }, [filters.queue, filters.task, filters.metadata, filters.error]);
+
+ const debouncedQueue = useDebouncedValue(local.queue, 300);
+ const debouncedTask = useDebouncedValue(local.task, 300);
+ const debouncedMetadata = useDebouncedValue(local.metadata, 300);
+ const debouncedError = useDebouncedValue(local.error, 300);
+
+ useEffect(() => {
+ const next: JobFilters = {
+ ...filters,
+ queue: debouncedQueue || undefined,
+ task: debouncedTask || undefined,
+ metadata: debouncedMetadata || undefined,
+ error: debouncedError || undefined,
+ };
+ if (
+ next.queue !== filters.queue ||
+ next.task !== filters.task ||
+ next.metadata !== filters.metadata ||
+ next.error !== filters.error
+ ) {
+ onChange(next);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: propagate only debounced values
+ }, [debouncedQueue, debouncedTask, debouncedMetadata, debouncedError]);
+
+ const activeCount = countActiveFilters(filters);
+
+ return (
+
+ );
+}
+
+function DateInput({
+ label,
+ value,
+ onChange,
+}: {
+ label: string;
+ value: number | undefined;
+ onChange: (ts: number | undefined) => void;
+}) {
+ // Backend stores unix seconds; uses "YYYY-MM-DDTHH:mm".
+ const localValue = value ? unixToLocalDatetime(value) : "";
+ return (
+
+ );
+}
+
+function unixToLocalDatetime(unixSeconds: number): string {
+ const date = new Date(unixSeconds * 1000);
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
+}
diff --git a/dashboard-next/src/features/jobs/components/job-search-bar.tsx b/dashboard-next/src/features/jobs/components/job-search-bar.tsx
new file mode 100644
index 0000000..b4e65f1
--- /dev/null
+++ b/dashboard-next/src/features/jobs/components/job-search-bar.tsx
@@ -0,0 +1,52 @@
+import { useNavigate } from "@tanstack/react-router";
+import { ArrowRight, Search } from "lucide-react";
+import { type FormEvent, useState } from "react";
+import { Input } from "@/components/ui/input";
+import { cn } from "@/lib/cn";
+
+interface JobSearchBarProps {
+ className?: string;
+}
+
+/**
+ * Jump-to-job-by-id input. Submits navigate to /jobs/$id.
+ * Designed to complement the filter bar without cluttering it: paste an ID
+ * and press Enter.
+ */
+export function JobSearchBar({ className }: JobSearchBarProps) {
+ const [id, setId] = useState("");
+ const navigate = useNavigate();
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ const trimmed = id.trim();
+ if (!trimmed) return;
+ navigate({ to: "/jobs/$id", params: { id: trimmed } });
+ setId("");
+ }
+
+ return (
+
+ );
+}
diff --git a/dashboard-next/src/features/jobs/components/job-table.tsx b/dashboard-next/src/features/jobs/components/job-table.tsx
new file mode 100644
index 0000000..cd46b3f
--- /dev/null
+++ b/dashboard-next/src/features/jobs/components/job-table.tsx
@@ -0,0 +1,121 @@
+import { useNavigate } from "@tanstack/react-router";
+import type { ColumnDef } from "@tanstack/react-table";
+import { useMemo } from "react";
+import { Badge } from "@/components/ui/badge";
+import { DataTable } from "@/components/ui/data-table";
+import { EmptyState } from "@/components/ui/empty-state";
+import { ErrorState } from "@/components/ui/error-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { Job } from "@/lib/api-types";
+import { JOB_STATUS_LABEL, JOB_STATUS_TONE } from "@/lib/status";
+import { formatRelative } from "@/lib/time";
+
+interface JobTableProps {
+ jobs: Job[] | undefined;
+ loading: boolean;
+ error: Error | null;
+ onRetry: () => void;
+}
+
+export function JobTable({ jobs, loading, error, onRetry }: JobTableProps) {
+ const navigate = useNavigate();
+
+ const columns = useMemo[]>(
+ () => [
+ {
+ accessorKey: "id",
+ header: "Job",
+ cell: ({ getValue }) => (
+ {getValue().slice(0, 8)}…
+ ),
+ },
+ {
+ accessorKey: "task_name",
+ header: "Task",
+ cell: ({ getValue }) => (
+ {getValue()}
+ ),
+ },
+ {
+ accessorKey: "queue",
+ header: "Queue",
+ cell: ({ getValue }) => (
+ {getValue()}
+ ),
+ },
+ {
+ accessorKey: "status",
+ header: "Status",
+ cell: ({ row }) => (
+
+ {JOB_STATUS_LABEL[row.original.status]}
+
+ ),
+ },
+ {
+ accessorKey: "retry_count",
+ header: "Retries",
+ cell: ({ row }) => {
+ const { retry_count, max_retries } = row.original;
+ return (
+ 0 ? "text-warning" : "text-[var(--fg-muted)]"}`}
+ >
+ {retry_count}
+ {max_retries > 0 ? ` / ${max_retries}` : null}
+
+ );
+ },
+ },
+ {
+ accessorKey: "created_at",
+ header: "Created",
+ cell: ({ getValue }) => (
+
+ {formatRelative(getValue() * 1000)}
+
+ ),
+ },
+ {
+ accessorKey: "error",
+ header: "Error",
+ cell: ({ getValue }) => {
+ const err = getValue();
+ if (!err) return —;
+ return (
+
+ {err}
+
+ );
+ },
+ },
+ ],
+ [],
+ );
+
+ if (error) {
+ return ;
+ }
+
+ if (loading && !jobs) {
+ return ;
+ }
+
+ if (!jobs || jobs.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+ j.id}
+ onRowClick={(job) => navigate({ to: "/jobs/$id", params: { id: job.id } })}
+ />
+ );
+}
diff --git a/dashboard-next/src/routes/jobs/index.tsx b/dashboard-next/src/routes/jobs/index.tsx
index 113ae2c..abe676b 100644
--- a/dashboard-next/src/routes/jobs/index.tsx
+++ b/dashboard-next/src/routes/jobs/index.tsx
@@ -1,21 +1,57 @@
import { createFileRoute } from "@tanstack/react-router";
-import { ListTree } from "lucide-react";
import { PageHeader } from "@/components/layout";
-import { EmptyState } from "@/components/ui/empty-state";
+import { Pagination } from "@/components/ui/pagination";
+import { JobFiltersBar, JobSearchBar, JobTable, useJobs } from "@/features/jobs";
+import type { JobFilters, JobListQuery } from "@/features/jobs/types";
+import { parseJobListSearch } from "@/features/jobs/utils";
export const Route = createFileRoute("/jobs/")({
- component: JobsPage,
+ component: JobsListPage,
+ validateSearch: (search) => parseJobListSearch(search),
});
-function JobsPage() {
+function JobsListPage() {
+ const search = Route.useSearch() as JobListQuery;
+ const navigate = Route.useNavigate();
+
+ const updateFilters = (filters: JobFilters) => {
+ navigate({
+ search: (prev) => ({
+ ...(prev as JobListQuery),
+ ...filters,
+ // Reset to first page whenever filters change
+ page: 0,
+ }),
+ replace: true,
+ });
+ };
+
+ const setPage = (page: number) => {
+ navigate({ search: (prev) => ({ ...(prev as JobListQuery), page }) });
+ };
+
+ const jobs = useJobs(search);
+ const data = jobs.data;
+ const hasMore = data ? data.length >= search.pageSize : false;
+
return (
<>
-
- }
/>
+
+
+
+
jobs.refetch()}
+ />
+
+
>
);
}
From 9c1da53b3ab372d54fc9b140e7c0f0bbdbca1a96 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 25 Apr 2026 01:08:40 +0530
Subject: [PATCH 16/42] feat(dashboard-next): job detail with tabs and DAG
viewer
---
.../src/features/jobs/components/index.ts | 9 +
.../features/jobs/components/job-dag-tab.tsx | 256 ++++++++++++++++++
.../jobs/components/job-errors-tab.tsx | 56 ++++
.../features/jobs/components/job-logs-tab.tsx | 58 ++++
.../jobs/components/job-overview-tab.tsx | 135 +++++++++
.../jobs/components/job-replay-tab.tsx | 82 ++++++
dashboard-next/src/features/jobs/index.ts | 4 +
dashboard-next/src/routes/jobs/$id.tsx | 117 +++++++-
8 files changed, 712 insertions(+), 5 deletions(-)
create mode 100644 dashboard-next/src/features/jobs/components/index.ts
create mode 100644 dashboard-next/src/features/jobs/components/job-dag-tab.tsx
create mode 100644 dashboard-next/src/features/jobs/components/job-errors-tab.tsx
create mode 100644 dashboard-next/src/features/jobs/components/job-logs-tab.tsx
create mode 100644 dashboard-next/src/features/jobs/components/job-overview-tab.tsx
create mode 100644 dashboard-next/src/features/jobs/components/job-replay-tab.tsx
create mode 100644 dashboard-next/src/features/jobs/index.ts
diff --git a/dashboard-next/src/features/jobs/components/index.ts b/dashboard-next/src/features/jobs/components/index.ts
new file mode 100644
index 0000000..13a74d8
--- /dev/null
+++ b/dashboard-next/src/features/jobs/components/index.ts
@@ -0,0 +1,9 @@
+export { JobActions } from "./job-actions";
+export { JobDagTab } from "./job-dag-tab";
+export { JobErrorsTab } from "./job-errors-tab";
+export { JobFiltersBar } from "./job-filters";
+export { JobLogsTab } from "./job-logs-tab";
+export { JobOverviewTab } from "./job-overview-tab";
+export { JobReplayTab } from "./job-replay-tab";
+export { JobSearchBar } from "./job-search-bar";
+export { JobTable } from "./job-table";
diff --git a/dashboard-next/src/features/jobs/components/job-dag-tab.tsx b/dashboard-next/src/features/jobs/components/job-dag-tab.tsx
new file mode 100644
index 0000000..1c5a5c3
--- /dev/null
+++ b/dashboard-next/src/features/jobs/components/job-dag-tab.tsx
@@ -0,0 +1,256 @@
+import { Link } from "@tanstack/react-router";
+import { Workflow } from "lucide-react";
+import { useMemo } from "react";
+import { EmptyState } from "@/components/ui/empty-state";
+import { ErrorState } from "@/components/ui/error-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { DagData, DagEdge, DagNode, JobStatus } from "@/lib/api-types";
+import { JOB_STATUS_LABEL } from "@/lib/status";
+
+interface JobDagTabProps {
+ dag: DagData | undefined;
+ loading: boolean;
+ error: Error | null;
+ onRetry: () => void;
+}
+
+const STATUS_FILL: Record = {
+ pending: "var(--surface-3)",
+ running: "var(--color-info-dim)",
+ complete: "var(--color-success-dim)",
+ failed: "var(--color-danger-dim)",
+ dead: "var(--color-danger-dim)",
+ cancelled: "var(--color-warning-dim)",
+};
+
+const STATUS_STROKE: Record = {
+ pending: "var(--border-strong)",
+ running: "var(--color-info)",
+ complete: "var(--color-success)",
+ failed: "var(--color-danger)",
+ dead: "var(--color-danger)",
+ cancelled: "var(--color-warning)",
+};
+
+const NODE_WIDTH = 180;
+const NODE_HEIGHT = 58;
+const LAYER_GAP_X = 80;
+const NODE_GAP_Y = 24;
+const PADDING = 32;
+
+interface LaidOutNode extends DagNode {
+ x: number;
+ y: number;
+ layer: number;
+}
+
+/**
+ * Lay out DAG nodes in BFS layers from sources (nodes with no inbound edges).
+ * Each layer's nodes are centered vertically so the graph is readable even
+ * for lopsided fan-outs. Returns positioned nodes plus canvas dimensions.
+ */
+function layout(data: DagData): { nodes: LaidOutNode[]; width: number; height: number } {
+ const nodes = data.nodes;
+ const edges = data.edges;
+ if (nodes.length === 0) return { nodes: [], width: 0, height: 0 };
+
+ const incoming = new Map();
+ const outgoing = new Map();
+ for (const n of nodes) incoming.set(n.id, 0);
+ for (const e of edges) {
+ incoming.set(e.to, (incoming.get(e.to) ?? 0) + 1);
+ const arr = outgoing.get(e.from);
+ if (arr) arr.push(e.to);
+ else outgoing.set(e.from, [e.to]);
+ }
+
+ const layerOf = new Map();
+ const queue: string[] = [];
+ for (const n of nodes) {
+ if ((incoming.get(n.id) ?? 0) === 0) {
+ layerOf.set(n.id, 0);
+ queue.push(n.id);
+ }
+ }
+ // Handle cycles defensively: any node we haven't ranked sits on layer 0.
+ if (queue.length === 0 && nodes.length > 0) {
+ layerOf.set(nodes[0]!.id, 0);
+ queue.push(nodes[0]!.id);
+ }
+
+ while (queue.length > 0) {
+ const id = queue.shift()!;
+ const depth = layerOf.get(id) ?? 0;
+ for (const child of outgoing.get(id) ?? []) {
+ const nextDepth = depth + 1;
+ if ((layerOf.get(child) ?? -1) < nextDepth) {
+ layerOf.set(child, nextDepth);
+ queue.push(child);
+ }
+ }
+ }
+
+ const layers = new Map();
+ for (const n of nodes) {
+ const layer = layerOf.get(n.id) ?? 0;
+ const bucket = layers.get(layer) ?? [];
+ bucket.push(n.id);
+ layers.set(layer, bucket);
+ }
+
+ const sortedLayers = [...layers.entries()].sort(([a], [b]) => a - b);
+ const tallestLayerSize = sortedLayers.reduce((max, [, ids]) => Math.max(max, ids.length), 0);
+ const canvasHeight =
+ PADDING * 2 + tallestLayerSize * NODE_HEIGHT + (tallestLayerSize - 1) * NODE_GAP_Y;
+ const canvasWidth =
+ PADDING * 2 + sortedLayers.length * NODE_WIDTH + (sortedLayers.length - 1) * LAYER_GAP_X;
+
+ const laidOut: LaidOutNode[] = [];
+ const byId = new Map(nodes.map((n) => [n.id, n]));
+ for (const [layer, ids] of sortedLayers) {
+ const layerHeight = ids.length * NODE_HEIGHT + (ids.length - 1) * NODE_GAP_Y;
+ const startY = (canvasHeight - layerHeight) / 2;
+ ids.forEach((id, i) => {
+ const base = byId.get(id);
+ if (!base) return;
+ laidOut.push({
+ ...base,
+ layer,
+ x: PADDING + layer * (NODE_WIDTH + LAYER_GAP_X),
+ y: startY + i * (NODE_HEIGHT + NODE_GAP_Y),
+ });
+ });
+ }
+
+ return { nodes: laidOut, width: canvasWidth, height: canvasHeight };
+}
+
+export function JobDagTab({ dag, loading, error, onRetry }: JobDagTabProps) {
+ const layoutResult = useMemo(
+ () => (dag ? layout(dag) : { nodes: [], width: 0, height: 0 }),
+ [dag],
+ );
+
+ if (error) {
+ return ;
+ }
+
+ if (loading && !dag) return ;
+
+ if (!dag || dag.nodes.length === 0) {
+ return (
+
+ );
+ }
+
+ const positions = new Map(layoutResult.nodes.map((n) => [n.id, n]));
+
+ return (
+
+
+
+ );
+}
+
+function Edge({ edge, positions }: { edge: DagEdge; positions: Map }) {
+ const from = positions.get(edge.from);
+ const to = positions.get(edge.to);
+ if (!from || !to) return null;
+
+ const x1 = from.x + NODE_WIDTH;
+ const y1 = from.y + NODE_HEIGHT / 2;
+ const x2 = to.x;
+ const y2 = to.y + NODE_HEIGHT / 2;
+ const mid = (x1 + x2) / 2;
+ const d = `M ${x1},${y1} C ${mid},${y1} ${mid},${y2} ${x2},${y2}`;
+ return (
+
+ );
+}
+
+function Node({ node }: { node: LaidOutNode }) {
+ return (
+
+
+
+
+ {truncate(node.task_name, 22)}
+
+
+ {node.id.slice(0, 8)}…
+
+
+ {JOB_STATUS_LABEL[node.status].toUpperCase()}
+
+
+
+ );
+}
+
+function truncate(value: string, max: number): string {
+ return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
+}
diff --git a/dashboard-next/src/features/jobs/components/job-errors-tab.tsx b/dashboard-next/src/features/jobs/components/job-errors-tab.tsx
new file mode 100644
index 0000000..342ef3a
--- /dev/null
+++ b/dashboard-next/src/features/jobs/components/job-errors-tab.tsx
@@ -0,0 +1,56 @@
+import { AlertOctagon } from "lucide-react";
+import { EmptyState } from "@/components/ui/empty-state";
+import { ErrorState } from "@/components/ui/error-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { JobError } from "@/lib/api-types";
+import { formatAbsolute } from "@/lib/time";
+
+interface JobErrorsTabProps {
+ errors: JobError[] | undefined;
+ loading: boolean;
+ error: Error | null;
+ onRetry: () => void;
+}
+
+export function JobErrorsTab({ errors, loading, error, onRetry }: JobErrorsTabProps) {
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (loading && !errors) {
+ return ;
+ }
+
+ if (!errors || errors.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {errors.map((err) => (
+
+
+ Attempt {err.attempt}
+
+ {formatAbsolute(err.failed_at * 1000)}
+
+
+
+ {err.error}
+
+
+ ))}
+
+ );
+}
diff --git a/dashboard-next/src/features/jobs/components/job-logs-tab.tsx b/dashboard-next/src/features/jobs/components/job-logs-tab.tsx
new file mode 100644
index 0000000..0637357
--- /dev/null
+++ b/dashboard-next/src/features/jobs/components/job-logs-tab.tsx
@@ -0,0 +1,58 @@
+import { ScrollText } from "lucide-react";
+import { EmptyState } from "@/components/ui/empty-state";
+import { ErrorState } from "@/components/ui/error-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { TaskLog } from "@/lib/api-types";
+import { cn } from "@/lib/cn";
+import { formatAbsolute } from "@/lib/time";
+
+interface JobLogsTabProps {
+ logs: TaskLog[] | undefined;
+ loading: boolean;
+ error: Error | null;
+ onRetry: () => void;
+}
+
+const LEVEL_STYLE: Record = {
+ error: "text-danger",
+ warning: "text-warning",
+ warn: "text-warning",
+ info: "text-info",
+ debug: "text-[var(--fg-subtle)]",
+};
+
+export function JobLogsTab({ logs, loading, error, onRetry }: JobLogsTabProps) {
+ if (error) {
+ return ;
+ }
+
+ if (loading && !logs) {
+ return ;
+ }
+
+ if (!logs || logs.length === 0) {
+ return ;
+ }
+
+ return (
+
+
+ {logs.map((log, i) => {
+ const levelClass = LEVEL_STYLE[log.level.toLowerCase()] ?? "text-[var(--fg-muted)]";
+ const key = `${log.logged_at}-${log.level}-${i}`;
+ return (
+ -
+
+ {formatAbsolute(log.logged_at * 1000)}
+
+ {log.level}
+
+ {log.message}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/dashboard-next/src/features/jobs/components/job-overview-tab.tsx b/dashboard-next/src/features/jobs/components/job-overview-tab.tsx
new file mode 100644
index 0000000..43f3b06
--- /dev/null
+++ b/dashboard-next/src/features/jobs/components/job-overview-tab.tsx
@@ -0,0 +1,135 @@
+import type { ReactNode } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import type { Job } from "@/lib/api-types";
+import { formatAbsolute, formatDuration, formatRelative } from "@/lib/time";
+
+interface JobOverviewTabProps {
+ job: Job;
+}
+
+export function JobOverviewTab({ job }: JobOverviewTabProps) {
+ const startedToCompleted =
+ job.started_at && job.completed_at ? job.completed_at - job.started_at : null;
+
+ return (
+
+
+
+ Identity
+
+
+
+ {job.task_name}
+ {job.queue}
+ {job.priority}
+ {job.unique_key ? {job.unique_key}
: null}
+
+
+
+
+
+
+ Execution
+
+
+
+
+ {job.retry_count}
+ {job.max_retries > 0 ? ` / ${job.max_retries}` : null}
+
+ {job.progress != null ? (
+ {(job.progress * 100).toFixed(0)}%
+ ) : null}
+ {formatDuration(job.timeout_ms)}
+ {startedToCompleted != null ? (
+ {formatDuration(startedToCompleted * 1000)}
+ ) : null}
+
+
+
+
+
+
+ Timeline
+
+
+
+
+
+
+
+
+
+ {job.started_at ? (
+
+
+
+ ) : null}
+ {job.completed_at ? (
+
+
+
+ ) : null}
+
+
+
+
+ {job.metadata ? (
+
+
+ Metadata
+
+
+
+ {tryPrettyJson(job.metadata)}
+
+
+
+ ) : null}
+
+ {job.error ? (
+
+
+ Last error
+
+
+
+ {job.error}
+
+
+
+ ) : null}
+
+ );
+}
+
+function Dl({ children }: { children: ReactNode }) {
+ return {children}
;
+}
+
+function Row({ label, children }: { label: string; children: ReactNode }) {
+ return (
+ <>
+ {label}
+ {children}
+ >
+ );
+}
+
+function Timestamp({ unix }: { unix: number }) {
+ const ms = unix * 1000;
+ return (
+
+ {formatAbsolute(ms)}
+ {formatRelative(ms)}
+
+ );
+}
+
+function tryPrettyJson(raw: string): string {
+ try {
+ return JSON.stringify(JSON.parse(raw), null, 2);
+ } catch {
+ return raw;
+ }
+}
diff --git a/dashboard-next/src/features/jobs/components/job-replay-tab.tsx b/dashboard-next/src/features/jobs/components/job-replay-tab.tsx
new file mode 100644
index 0000000..76063e0
--- /dev/null
+++ b/dashboard-next/src/features/jobs/components/job-replay-tab.tsx
@@ -0,0 +1,82 @@
+import { Link } from "@tanstack/react-router";
+import { RotateCcw } from "lucide-react";
+import { EmptyState } from "@/components/ui/empty-state";
+import { ErrorState } from "@/components/ui/error-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { ReplayEntry } from "@/lib/api-types";
+import { formatAbsolute, formatRelative } from "@/lib/time";
+
+interface JobReplayTabProps {
+ replays: ReplayEntry[] | undefined;
+ loading: boolean;
+ error: Error | null;
+ onRetry: () => void;
+}
+
+export function JobReplayTab({ replays, loading, error, onRetry }: JobReplayTabProps) {
+ if (error) {
+ return (
+
+ );
+ }
+ if (loading && !replays) return ;
+
+ if (!replays || replays.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/dashboard-next/src/features/jobs/index.ts b/dashboard-next/src/features/jobs/index.ts
new file mode 100644
index 0000000..a230ecf
--- /dev/null
+++ b/dashboard-next/src/features/jobs/index.ts
@@ -0,0 +1,4 @@
+export * from "./components";
+export * from "./hooks";
+export type { JobFilters, JobListQuery } from "./types";
+export { canCancel, canReplay, isTerminalStatus } from "./utils";
diff --git a/dashboard-next/src/routes/jobs/$id.tsx b/dashboard-next/src/routes/jobs/$id.tsx
index 4656eaf..7d22b26 100644
--- a/dashboard-next/src/routes/jobs/$id.tsx
+++ b/dashboard-next/src/routes/jobs/$id.tsx
@@ -1,6 +1,23 @@
import { createFileRoute } from "@tanstack/react-router";
import { PageHeader } from "@/components/layout";
-import { EmptyState } from "@/components/ui/empty-state";
+import { Badge } from "@/components/ui/badge";
+import { ErrorState } from "@/components/ui/error-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import {
+ JobActions,
+ JobDagTab,
+ JobErrorsTab,
+ JobLogsTab,
+ JobOverviewTab,
+ JobReplayTab,
+ useJob,
+ useJobDag,
+ useJobErrors,
+ useJobLogs,
+ useReplayHistory,
+} from "@/features/jobs";
+import { JOB_STATUS_LABEL, JOB_STATUS_TONE } from "@/lib/status";
export const Route = createFileRoute("/jobs/$id")({
component: JobDetailPage,
@@ -8,13 +25,103 @@ export const Route = createFileRoute("/jobs/$id")({
function JobDetailPage() {
const { id } = Route.useParams();
+ const job = useJob(id);
+ const logs = useJobLogs(id);
+ const errors = useJobErrors(id);
+ const replays = useReplayHistory(id);
+ const dag = useJobDag(id);
+
+ if (job.isLoading && !job.data) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ if (job.error || !job.data) {
+ return (
+ <>
+
+ job.refetch()}
+ />
+ >
+ );
+ }
+
+ const data = job.data;
+ const statusTone = JOB_STATUS_TONE[data.status];
+
return (
<>
-
-
+ {data.id}
+
+ }
+ breadcrumbs={[{ label: "Jobs", to: "/jobs" }, { label: data.id.slice(0, 8) }]}
+ actions={
+
+ {JOB_STATUS_LABEL[data.status]}
+
+
+ }
/>
+
+
+
+ Overview
+ Logs
+ Errors
+ Replays
+ DAG
+
+
+
+
+
+
+ logs.refetch()}
+ />
+
+
+ errors.refetch()}
+ />
+
+
+ replays.refetch()}
+ />
+
+
+ dag.refetch()}
+ />
+
+
>
);
}
From a0bfc042712d7728521d7f0e4c799ce153ca0ee9 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 25 Apr 2026 01:27:55 +0530
Subject: [PATCH 17/42] chore(dashboard-next): add recharts, react-virtual,
vitest
---
dashboard-next/package.json | 10 +-
dashboard-next/pnpm-lock.yaml | 669 ++++++++++++++++++++++++++++++++
dashboard-next/vitest.config.ts | 17 +
3 files changed, 694 insertions(+), 2 deletions(-)
create mode 100644 dashboard-next/vitest.config.ts
diff --git a/dashboard-next/package.json b/dashboard-next/package.json
index 29fc195..5c893d3 100644
--- a/dashboard-next/package.json
+++ b/dashboard-next/package.json
@@ -12,7 +12,9 @@
"lint:fix": "biome check --fix src/",
"format": "biome format --write src/",
"format:check": "biome format src/",
- "ci": "biome ci src/ && tsc --noEmit && vite build"
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "ci": "biome ci src/ && tsc --noEmit && vitest run && vite build"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
@@ -26,6 +28,7 @@
"@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-router": "^1.95.1",
"@tanstack/react-table": "^8.21.3",
+ "@tanstack/react-virtual": "^3.13.24",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -33,6 +36,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^6.1.1",
+ "recharts": "^3.8.1",
"sonner": "^1.7.1",
"tailwind-merge": "^2.6.0"
},
@@ -44,9 +48,11 @@
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
+ "@vitest/coverage-v8": "^4.1.5",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
- "vite": "^6.4.2"
+ "vite": "^6.4.2",
+ "vitest": "^4.1.5"
},
"packageManager": "pnpm@10.30.3"
}
diff --git a/dashboard-next/pnpm-lock.yaml b/dashboard-next/pnpm-lock.yaml
index 6557e9b..7eeca12 100644
--- a/dashboard-next/pnpm-lock.yaml
+++ b/dashboard-next/pnpm-lock.yaml
@@ -41,6 +41,9 @@ importers:
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@tanstack/react-virtual':
+ specifier: ^3.13.24
+ version: 3.13.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -62,6 +65,9 @@ importers:
react-error-boundary:
specifier: ^6.1.1
version: 6.1.1(react@18.3.1)
+ recharts:
+ specifier: ^3.8.1
+ version: 3.8.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@19.2.5)(react@18.3.1)(redux@5.0.1)
sonner:
specifier: ^1.7.1
version: 1.7.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -90,6 +96,9 @@ importers:
'@vitejs/plugin-react':
specifier: ^4.3.4
version: 4.7.0(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
+ '@vitest/coverage-v8':
+ specifier: ^4.1.5
+ version: 4.1.5(vitest@4.1.5)
tailwindcss:
specifier: ^4.0.0
version: 4.2.4
@@ -99,6 +108,9 @@ importers:
vite:
specifier: ^6.4.2
version: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
+ vitest:
+ specifier: ^4.1.5
+ version: 4.1.5(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
packages:
@@ -197,6 +209,10 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
+ '@bcoe/v8-coverage@1.0.2':
+ resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
+ engines: {node: '>=18'}
+
'@biomejs/biome@2.4.13':
resolution: {integrity: sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==}
engines: {node: '>=14.21.3'}
@@ -975,6 +991,17 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
+ '@reduxjs/toolkit@2.11.2':
+ resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
+ peerDependencies:
+ react: ^16.9.0 || ^17.0.0 || ^18 || ^19
+ react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
+ peerDependenciesMeta:
+ react:
+ optional: true
+ react-redux:
+ optional: true
+
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -1116,6 +1143,12 @@ packages:
cpu: [x64]
os: [win32]
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
+ '@standard-schema/utils@0.3.0':
+ resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
+
'@tailwindcss/node@4.2.4':
resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==}
@@ -1251,6 +1284,12 @@ packages:
react: '>=16.8'
react-dom: '>=16.8'
+ '@tanstack/react-virtual@3.13.24':
+ resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
'@tanstack/router-core@1.168.15':
resolution: {integrity: sha512-Wr0424NDtD8fT/uALobMZ9DdcfsTyXtW5IPR++7zvW8/7RaIOeaqXpVDId8ywaGtqPWLWOfaUg2zUtYtukoXYA==}
engines: {node: '>=20.19'}
@@ -1293,6 +1332,9 @@ packages:
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
+ '@tanstack/virtual-core@3.14.0':
+ resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==}
+
'@tanstack/virtual-file-routes@1.161.7':
resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==}
engines: {node: '>=20.19'}
@@ -1310,6 +1352,39 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
+ '@types/d3-array@3.2.2':
+ resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
+
+ '@types/d3-color@3.1.3':
+ resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
+
+ '@types/d3-ease@3.0.2':
+ resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
+
+ '@types/d3-interpolate@3.0.4':
+ resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
+
+ '@types/d3-path@3.1.1':
+ resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
+
+ '@types/d3-scale@4.0.9':
+ resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
+
+ '@types/d3-shape@3.1.8':
+ resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
+
+ '@types/d3-time@3.0.4':
+ resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
+
+ '@types/d3-timer@3.0.2':
+ resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1327,12 +1402,53 @@ packages:
'@types/react@18.3.28':
resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==}
+ '@types/use-sync-external-store@0.0.6':
+ resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
+
'@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+ '@vitest/coverage-v8@4.1.5':
+ resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==}
+ peerDependencies:
+ '@vitest/browser': 4.1.5
+ vitest: 4.1.5
+ peerDependenciesMeta:
+ '@vitest/browser':
+ optional: true
+
+ '@vitest/expect@4.1.5':
+ resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==}
+
+ '@vitest/mocker@4.1.5':
+ resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/pretty-format@4.1.5':
+ resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==}
+
+ '@vitest/runner@4.1.5':
+ resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==}
+
+ '@vitest/snapshot@4.1.5':
+ resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==}
+
+ '@vitest/spy@4.1.5':
+ resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==}
+
+ '@vitest/utils@4.1.5':
+ resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==}
+
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
@@ -1350,6 +1466,13 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
+ ast-v8-to-istanbul@1.0.0:
+ resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==}
+
babel-dead-code-elimination@1.0.12:
resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
@@ -1374,6 +1497,10 @@ packages:
caniuse-lite@1.0.30001790:
resolution: {integrity: sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==}
+ chai@6.2.2:
+ resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
+ engines: {node: '>=18'}
+
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@@ -1400,6 +1527,50 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ d3-array@3.2.4:
+ resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
+ engines: {node: '>=12'}
+
+ d3-color@3.1.0:
+ resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
+ engines: {node: '>=12'}
+
+ d3-ease@3.0.1:
+ resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
+ engines: {node: '>=12'}
+
+ d3-format@3.1.2:
+ resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
+ engines: {node: '>=12'}
+
+ d3-interpolate@3.0.1:
+ resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
+ engines: {node: '>=12'}
+
+ d3-path@3.1.0:
+ resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
+ engines: {node: '>=12'}
+
+ d3-scale@4.0.2:
+ resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
+ engines: {node: '>=12'}
+
+ d3-shape@3.2.0:
+ resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
+ engines: {node: '>=12'}
+
+ d3-time-format@4.1.0:
+ resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
+ engines: {node: '>=12'}
+
+ d3-time@3.1.0:
+ resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
+ engines: {node: '>=12'}
+
+ d3-timer@3.0.1:
+ resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+ engines: {node: '>=12'}
+
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -1409,6 +1580,9 @@ packages:
supports-color:
optional: true
+ decimal.js-light@2.5.1:
+ resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
+
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -1427,6 +1601,12 @@ packages:
resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==}
engines: {node: '>=10.13.0'}
+ es-module-lexer@2.0.0:
+ resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
+
+ es-toolkit@1.46.0:
+ resolution: {integrity: sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==}
+
esbuild@0.25.12:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
engines: {node: '>=18'}
@@ -1441,6 +1621,16 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
+ eventemitter3@5.0.4:
+ resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+
+ expect-type@1.3.0:
+ resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
+ engines: {node: '>=12.0.0'}
+
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -1477,6 +1667,23 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
+ html-escaper@2.0.2:
+ resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+
+ immer@10.2.0:
+ resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
+
+ immer@11.1.4:
+ resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
+
+ internmap@2.0.3:
+ resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
+ engines: {node: '>=12'}
+
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
@@ -1497,10 +1704,25 @@ packages:
resolution: {integrity: sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw==}
engines: {node: '>=18'}
+ istanbul-lib-coverage@3.2.2:
+ resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
+ engines: {node: '>=8'}
+
+ istanbul-lib-report@3.0.1:
+ resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
+ engines: {node: '>=10'}
+
+ istanbul-reports@3.2.0:
+ resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
+ engines: {node: '>=8'}
+
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
+ js-tokens@10.0.0:
+ resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1603,6 +1825,13 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ magicast@0.5.2:
+ resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
+
+ make-dir@4.0.0:
+ resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
+ engines: {node: '>=10'}
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1618,6 +1847,9 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
+ obug@2.1.1:
+ resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
+
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -1651,6 +1883,21 @@ packages:
peerDependencies:
react: ^18.0.0 || ^19.0.0
+ react-is@19.2.5:
+ resolution: {integrity: sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==}
+
+ react-redux@9.2.0:
+ resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
+ peerDependencies:
+ '@types/react': ^18.2.25 || ^19
+ react: ^18.0 || ^19
+ redux: ^5.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ redux:
+ optional: true
+
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -1693,6 +1940,25 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
+ recharts@3.8.1:
+ resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ redux-thunk@3.1.0:
+ resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
+ peerDependencies:
+ redux: ^5.0.0
+
+ redux@5.0.1:
+ resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
+
+ reselect@5.1.1:
+ resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
+
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -1708,6 +1974,11 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
+ semver@7.7.4:
+ resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
seroval-plugins@1.5.2:
resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==}
engines: {node: '>=10'}
@@ -1718,6 +1989,9 @@ packages:
resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==}
engines: {node: '>=10'}
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
sonner@1.7.4:
resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==}
peerDependencies:
@@ -1728,6 +2002,16 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
+ std-env@4.1.0:
+ resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
+
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
tailwind-merge@2.6.1:
resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==}
@@ -1738,10 +2022,24 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
+ tiny-invariant@1.3.3:
+ resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+ tinyexec@1.1.1:
+ resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==}
+ engines: {node: '>=18'}
+
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
+ tinyrainbow@3.1.0:
+ resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
+ engines: {node: '>=14.0.0'}
+
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@@ -1797,6 +2095,9 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ victory-vendor@37.3.6:
+ resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
+
vite@6.4.2:
resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -1837,9 +2138,55 @@ packages:
yaml:
optional: true
+ vitest@4.1.5:
+ resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==}
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@opentelemetry/api': ^1.9.0
+ '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
+ '@vitest/browser-playwright': 4.1.5
+ '@vitest/browser-preview': 4.1.5
+ '@vitest/browser-webdriverio': 4.1.5
+ '@vitest/coverage-istanbul': 4.1.5
+ '@vitest/coverage-v8': 4.1.5
+ '@vitest/ui': 4.1.5
+ happy-dom: '*'
+ jsdom: '*'
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser-playwright':
+ optional: true
+ '@vitest/browser-preview':
+ optional: true
+ '@vitest/browser-webdriverio':
+ optional: true
+ '@vitest/coverage-istanbul':
+ optional: true
+ '@vitest/coverage-v8':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -1970,6 +2317,8 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
+ '@bcoe/v8-coverage@1.0.2': {}
+
'@biomejs/biome@2.4.13':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.4.13
@@ -2574,6 +2923,18 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
+ '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1)':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@standard-schema/utils': 0.3.0
+ immer: 11.1.4
+ redux: 5.0.1
+ redux-thunk: 3.1.0(redux@5.0.1)
+ reselect: 5.1.1
+ optionalDependencies:
+ react: 18.3.1
+ react-redux: 9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1)
+
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.60.2':
@@ -2651,6 +3012,10 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.60.2':
optional: true
+ '@standard-schema/spec@1.1.0': {}
+
+ '@standard-schema/utils@0.3.0': {}
+
'@tailwindcss/node@4.2.4':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -2758,6 +3123,12 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
+ '@tanstack/react-virtual@3.13.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@tanstack/virtual-core': 3.14.0
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
'@tanstack/router-core@1.168.15':
dependencies:
'@tanstack/history': 1.161.6
@@ -2817,6 +3188,8 @@ snapshots:
'@tanstack/table-core@8.21.3': {}
+ '@tanstack/virtual-core@3.14.0': {}
+
'@tanstack/virtual-file-routes@1.161.7': {}
'@types/babel__core@7.20.5':
@@ -2840,6 +3213,37 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
+ '@types/d3-array@3.2.2': {}
+
+ '@types/d3-color@3.1.3': {}
+
+ '@types/d3-ease@3.0.2': {}
+
+ '@types/d3-interpolate@3.0.4':
+ dependencies:
+ '@types/d3-color': 3.1.3
+
+ '@types/d3-path@3.1.1': {}
+
+ '@types/d3-scale@4.0.9':
+ dependencies:
+ '@types/d3-time': 3.0.4
+
+ '@types/d3-shape@3.1.8':
+ dependencies:
+ '@types/d3-path': 3.1.1
+
+ '@types/d3-time@3.0.4': {}
+
+ '@types/d3-timer@3.0.2': {}
+
+ '@types/deep-eql@4.0.2': {}
+
'@types/estree@1.0.8': {}
'@types/node@22.19.17':
@@ -2857,6 +3261,8 @@ snapshots:
'@types/prop-types': 15.7.15
csstype: 3.2.3
+ '@types/use-sync-external-store@0.0.6': {}
+
'@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))':
dependencies:
'@babel/core': 7.29.0
@@ -2869,6 +3275,61 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@vitest/coverage-v8@4.1.5(vitest@4.1.5)':
+ dependencies:
+ '@bcoe/v8-coverage': 1.0.2
+ '@vitest/utils': 4.1.5
+ ast-v8-to-istanbul: 1.0.0
+ istanbul-lib-coverage: 3.2.2
+ istanbul-lib-report: 3.0.1
+ istanbul-reports: 3.2.0
+ magicast: 0.5.2
+ obug: 2.1.1
+ std-env: 4.1.0
+ tinyrainbow: 3.1.0
+ vitest: 4.1.5(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
+
+ '@vitest/expect@4.1.5':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@types/chai': 5.2.3
+ '@vitest/spy': 4.1.5
+ '@vitest/utils': 4.1.5
+ chai: 6.2.2
+ tinyrainbow: 3.1.0
+
+ '@vitest/mocker@4.1.5(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))':
+ dependencies:
+ '@vitest/spy': 4.1.5
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
+
+ '@vitest/pretty-format@4.1.5':
+ dependencies:
+ tinyrainbow: 3.1.0
+
+ '@vitest/runner@4.1.5':
+ dependencies:
+ '@vitest/utils': 4.1.5
+ pathe: 2.0.3
+
+ '@vitest/snapshot@4.1.5':
+ dependencies:
+ '@vitest/pretty-format': 4.1.5
+ '@vitest/utils': 4.1.5
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
+ '@vitest/spy@4.1.5': {}
+
+ '@vitest/utils@4.1.5':
+ dependencies:
+ '@vitest/pretty-format': 4.1.5
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.1.0
+
acorn@8.16.0: {}
ansis@4.2.0: {}
@@ -2882,6 +3343,14 @@ snapshots:
dependencies:
tslib: 2.8.1
+ assertion-error@2.0.1: {}
+
+ ast-v8-to-istanbul@1.0.0:
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.31
+ estree-walker: 3.0.3
+ js-tokens: 10.0.0
+
babel-dead-code-elimination@1.0.12:
dependencies:
'@babel/core': 7.29.0
@@ -2909,6 +3378,8 @@ snapshots:
caniuse-lite@1.0.30001790: {}
+ chai@6.2.2: {}
+
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
@@ -2945,10 +3416,50 @@ snapshots:
csstype@3.2.3: {}
+ d3-array@3.2.4:
+ dependencies:
+ internmap: 2.0.3
+
+ d3-color@3.1.0: {}
+
+ d3-ease@3.0.1: {}
+
+ d3-format@3.1.2: {}
+
+ d3-interpolate@3.0.1:
+ dependencies:
+ d3-color: 3.1.0
+
+ d3-path@3.1.0: {}
+
+ d3-scale@4.0.2:
+ dependencies:
+ d3-array: 3.2.4
+ d3-format: 3.1.2
+ d3-interpolate: 3.0.1
+ d3-time: 3.1.0
+ d3-time-format: 4.1.0
+
+ d3-shape@3.2.0:
+ dependencies:
+ d3-path: 3.1.0
+
+ d3-time-format@4.1.0:
+ dependencies:
+ d3-time: 3.1.0
+
+ d3-time@3.1.0:
+ dependencies:
+ d3-array: 3.2.4
+
+ d3-timer@3.0.1: {}
+
debug@4.4.3:
dependencies:
ms: 2.1.3
+ decimal.js-light@2.5.1: {}
+
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
@@ -2962,6 +3473,10 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.3
+ es-module-lexer@2.0.0: {}
+
+ es-toolkit@1.46.0: {}
+
esbuild@0.25.12:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.12
@@ -3022,6 +3537,14 @@ snapshots:
escalade@3.2.0: {}
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.8
+
+ eventemitter3@5.0.4: {}
+
+ expect-type@1.3.0: {}
+
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -3047,6 +3570,16 @@ snapshots:
graceful-fs@4.2.11: {}
+ has-flag@4.0.0: {}
+
+ html-escaper@2.0.2: {}
+
+ immer@10.2.0: {}
+
+ immer@11.1.4: {}
+
+ internmap@2.0.3: {}
+
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
@@ -3061,8 +3594,23 @@ snapshots:
isbot@5.1.39: {}
+ istanbul-lib-coverage@3.2.2: {}
+
+ istanbul-lib-report@3.0.1:
+ dependencies:
+ istanbul-lib-coverage: 3.2.2
+ make-dir: 4.0.0
+ supports-color: 7.2.0
+
+ istanbul-reports@3.2.0:
+ dependencies:
+ html-escaper: 2.0.2
+ istanbul-lib-report: 3.0.1
+
jiti@2.6.1: {}
+ js-tokens@10.0.0: {}
+
js-tokens@4.0.0: {}
jsesc@3.1.0: {}
@@ -3134,6 +3682,16 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ magicast@0.5.2:
+ dependencies:
+ '@babel/parser': 7.29.2
+ '@babel/types': 7.29.0
+ source-map-js: 1.2.1
+
+ make-dir@4.0.0:
+ dependencies:
+ semver: 7.7.4
+
ms@2.1.3: {}
nanoid@3.3.11: {}
@@ -3142,6 +3700,8 @@ snapshots:
normalize-path@3.0.0: {}
+ obug@2.1.1: {}
+
pathe@2.0.3: {}
picocolors@1.1.1: {}
@@ -3168,6 +3728,17 @@ snapshots:
dependencies:
react: 18.3.1
+ react-is@19.2.5: {}
+
+ react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1):
+ dependencies:
+ '@types/use-sync-external-store': 0.0.6
+ react: 18.3.1
+ use-sync-external-store: 1.6.0(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ redux: 5.0.1
+
react-refresh@0.17.0: {}
react-remove-scroll-bar@2.3.8(@types/react@18.3.28)(react@18.3.1):
@@ -3205,6 +3776,34 @@ snapshots:
dependencies:
picomatch: 2.3.2
+ recharts@3.8.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@19.2.5)(react@18.3.1)(redux@5.0.1):
+ dependencies:
+ '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1)
+ clsx: 2.1.1
+ decimal.js-light: 2.5.1
+ es-toolkit: 1.46.0
+ eventemitter3: 5.0.4
+ immer: 10.2.0
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-is: 19.2.5
+ react-redux: 9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1)
+ reselect: 5.1.1
+ tiny-invariant: 1.3.3
+ use-sync-external-store: 1.6.0(react@18.3.1)
+ victory-vendor: 37.3.6
+ transitivePeerDependencies:
+ - '@types/react'
+ - redux
+
+ redux-thunk@3.1.0(redux@5.0.1):
+ dependencies:
+ redux: 5.0.1
+
+ redux@5.0.1: {}
+
+ reselect@5.1.1: {}
+
resolve-pkg-maps@1.0.0: {}
rollup@4.60.2:
@@ -3244,12 +3843,16 @@ snapshots:
semver@6.3.1: {}
+ semver@7.7.4: {}
+
seroval-plugins@1.5.2(seroval@1.5.2):
dependencies:
seroval: 1.5.2
seroval@1.5.2: {}
+ siginfo@2.0.0: {}
+
sonner@1.7.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
@@ -3257,17 +3860,33 @@ snapshots:
source-map-js@1.2.1: {}
+ stackback@0.0.2: {}
+
+ std-env@4.1.0: {}
+
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
tailwind-merge@2.6.1: {}
tailwindcss@4.2.4: {}
tapable@2.3.3: {}
+ tiny-invariant@1.3.3: {}
+
+ tinybench@2.9.0: {}
+
+ tinyexec@1.1.1: {}
+
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
+ tinyrainbow@3.1.0: {}
+
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@@ -3317,6 +3936,23 @@ snapshots:
dependencies:
react: 18.3.1
+ victory-vendor@37.3.6:
+ dependencies:
+ '@types/d3-array': 3.2.2
+ '@types/d3-ease': 3.0.2
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-scale': 4.0.9
+ '@types/d3-shape': 3.1.8
+ '@types/d3-time': 3.0.4
+ '@types/d3-timer': 3.0.2
+ d3-array: 3.2.4
+ d3-ease: 3.0.1
+ d3-interpolate: 3.0.1
+ d3-scale: 4.0.2
+ d3-shape: 3.2.0
+ d3-time: 3.1.0
+ d3-timer: 3.0.1
+
vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0):
dependencies:
esbuild: 0.25.12
@@ -3332,8 +3968,41 @@ snapshots:
lightningcss: 1.32.0
tsx: 4.21.0
+ vitest@4.1.5(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)):
+ dependencies:
+ '@vitest/expect': 4.1.5
+ '@vitest/mocker': 4.1.5(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
+ '@vitest/pretty-format': 4.1.5
+ '@vitest/runner': 4.1.5
+ '@vitest/snapshot': 4.1.5
+ '@vitest/spy': 4.1.5
+ '@vitest/utils': 4.1.5
+ es-module-lexer: 2.0.0
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ obug: 2.1.1
+ pathe: 2.0.3
+ picomatch: 4.0.4
+ std-env: 4.1.0
+ tinybench: 2.9.0
+ tinyexec: 1.1.1
+ tinyglobby: 0.2.16
+ tinyrainbow: 3.1.0
+ vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 22.19.17
+ '@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
+ transitivePeerDependencies:
+ - msw
+
webpack-virtual-modules@0.6.2: {}
+ why-is-node-running@2.3.0:
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
+
yallist@3.1.1: {}
zod@3.25.76: {}
diff --git a/dashboard-next/vitest.config.ts b/dashboard-next/vitest.config.ts
new file mode 100644
index 0000000..d58df91
--- /dev/null
+++ b/dashboard-next/vitest.config.ts
@@ -0,0 +1,17 @@
+import path from "node:path";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ test: {
+ environment: "node",
+ include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
+ coverage: {
+ reporter: ["text", "json"],
+ },
+ },
+});
From 99cab14b984491f47988b32f27f1ef710296f90b Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 25 Apr 2026 01:28:00 +0530
Subject: [PATCH 18/42] feat(dashboard-next): queues with pause/resume
mutations
---
dashboard-next/src/features/overview/api.ts | 10 +-
dashboard-next/src/features/overview/hooks.ts | 30 +---
dashboard-next/src/features/queues/api.ts | 18 ++
.../src/features/queues/components/index.ts | 1 +
.../queues/components/queues-table.tsx | 161 ++++++++++++++++++
dashboard-next/src/features/queues/hooks.ts | 87 ++++++++++
dashboard-next/src/features/queues/index.ts | 2 +
dashboard-next/src/routes/queues.tsx | 22 ++-
8 files changed, 293 insertions(+), 38 deletions(-)
create mode 100644 dashboard-next/src/features/queues/api.ts
create mode 100644 dashboard-next/src/features/queues/components/index.ts
create mode 100644 dashboard-next/src/features/queues/components/queues-table.tsx
create mode 100644 dashboard-next/src/features/queues/hooks.ts
create mode 100644 dashboard-next/src/features/queues/index.ts
diff --git a/dashboard-next/src/features/overview/api.ts b/dashboard-next/src/features/overview/api.ts
index e1adbe2..f12e9e1 100644
--- a/dashboard-next/src/features/overview/api.ts
+++ b/dashboard-next/src/features/overview/api.ts
@@ -1,18 +1,10 @@
import { api } from "@/lib/api-client";
-import type { Job, QueueStats, QueueStatsMap, TimeseriesBucket } from "@/lib/api-types";
+import type { Job, QueueStats, TimeseriesBucket } from "@/lib/api-types";
export function fetchStats(signal?: AbortSignal): Promise {
return api.get("/api/stats", { signal });
}
-export function fetchQueueStats(signal?: AbortSignal): Promise {
- return api.get("/api/stats/queues", { signal });
-}
-
-export function fetchPausedQueues(signal?: AbortSignal): Promise {
- return api.get("/api/queues/paused", { signal });
-}
-
export function fetchRecentJobs(limit: number, signal?: AbortSignal): Promise {
return api.get("/api/jobs", { signal, params: { limit } });
}
diff --git a/dashboard-next/src/features/overview/hooks.ts b/dashboard-next/src/features/overview/hooks.ts
index 6a296e4..edb1622 100644
--- a/dashboard-next/src/features/overview/hooks.ts
+++ b/dashboard-next/src/features/overview/hooks.ts
@@ -1,12 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { useRefreshInterval } from "@/providers/refresh-interval-provider";
-import {
- fetchPausedQueues,
- fetchQueueStats,
- fetchRecentJobs,
- fetchStats,
- fetchThroughput,
-} from "./api";
+import { fetchRecentJobs, fetchStats, fetchThroughput } from "./api";
+
+// Re-export queue hooks so the Overview route can import everything it needs
+// from one module without reaching into a sibling feature directly.
+export { usePausedQueues, useQueueStats } from "../queues/hooks";
export function useStats() {
const { intervalMs } = useRefreshInterval();
@@ -17,24 +15,6 @@ export function useStats() {
});
}
-export function useQueueStats() {
- const { intervalMs } = useRefreshInterval();
- return useQuery({
- queryKey: ["stats", "queues"],
- queryFn: ({ signal }) => fetchQueueStats(signal),
- refetchInterval: intervalMs,
- });
-}
-
-export function usePausedQueues() {
- const { intervalMs } = useRefreshInterval();
- return useQuery({
- queryKey: ["queues", "paused"],
- queryFn: ({ signal }) => fetchPausedQueues(signal),
- refetchInterval: intervalMs,
- });
-}
-
export function useRecentJobs(limit = 10) {
const { intervalMs } = useRefreshInterval();
return useQuery({
diff --git a/dashboard-next/src/features/queues/api.ts b/dashboard-next/src/features/queues/api.ts
new file mode 100644
index 0000000..01c8f03
--- /dev/null
+++ b/dashboard-next/src/features/queues/api.ts
@@ -0,0 +1,18 @@
+import { api } from "@/lib/api-client";
+import type { QueueStatsMap } from "@/lib/api-types";
+
+export function fetchQueueStats(signal?: AbortSignal): Promise {
+ return api.get("/api/stats/queues", { signal });
+}
+
+export function fetchPausedQueues(signal?: AbortSignal): Promise {
+ return api.get("/api/queues/paused", { signal });
+}
+
+export function pauseQueue(name: string): Promise<{ paused: string }> {
+ return api.post<{ paused: string }>(`/api/queues/${encodeURIComponent(name)}/pause`);
+}
+
+export function resumeQueue(name: string): Promise<{ resumed: string }> {
+ return api.post<{ resumed: string }>(`/api/queues/${encodeURIComponent(name)}/resume`);
+}
diff --git a/dashboard-next/src/features/queues/components/index.ts b/dashboard-next/src/features/queues/components/index.ts
new file mode 100644
index 0000000..646e0c8
--- /dev/null
+++ b/dashboard-next/src/features/queues/components/index.ts
@@ -0,0 +1 @@
+export { QueuesTable } from "./queues-table";
diff --git a/dashboard-next/src/features/queues/components/queues-table.tsx b/dashboard-next/src/features/queues/components/queues-table.tsx
new file mode 100644
index 0000000..ee61a18
--- /dev/null
+++ b/dashboard-next/src/features/queues/components/queues-table.tsx
@@ -0,0 +1,161 @@
+import type { ColumnDef } from "@tanstack/react-table";
+import { Box, Pause, Play } from "lucide-react";
+import { useMemo } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { DataTable } from "@/components/ui/data-table";
+import { EmptyState } from "@/components/ui/empty-state";
+import { ErrorState } from "@/components/ui/error-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { QueueStatsMap } from "@/lib/api-types";
+import { formatCount } from "@/lib/number";
+import { usePauseQueue, useResumeQueue } from "../hooks";
+
+interface QueueRow {
+ name: string;
+ paused: boolean;
+ pending: number;
+ running: number;
+ completed: number;
+ failed: number;
+ dead: number;
+}
+
+interface QueuesTableProps {
+ stats: QueueStatsMap | undefined;
+ paused: string[] | undefined;
+ loading: boolean;
+ error: Error | null;
+ onRetry: () => void;
+}
+
+export function QueuesTable({ stats, paused, loading, error, onRetry }: QueuesTableProps) {
+ const pauseMutation = usePauseQueue();
+ const resumeMutation = useResumeQueue();
+
+ const rows = useMemo(() => {
+ if (!stats) return [];
+ const pausedSet = new Set(paused ?? []);
+ return Object.entries(stats)
+ .map(([name, s]) => ({
+ name,
+ paused: pausedSet.has(name),
+ pending: s.pending ?? 0,
+ running: s.running ?? 0,
+ completed: s.completed ?? 0,
+ failed: s.failed ?? 0,
+ dead: s.dead ?? 0,
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ }, [stats, paused]);
+
+ const columns = useMemo[]>(
+ () => [
+ {
+ accessorKey: "name",
+ header: "Queue",
+ cell: ({ row }) => (
+
+ {row.original.name}
+ {row.original.paused ? Paused : null}
+
+ ),
+ },
+ {
+ accessorKey: "pending",
+ header: "Pending",
+ cell: ({ getValue }) => (
+ {formatCount(getValue())}
+ ),
+ },
+ {
+ accessorKey: "running",
+ header: "Running",
+ cell: ({ getValue }) => (
+ {formatCount(getValue())}
+ ),
+ },
+ {
+ accessorKey: "completed",
+ header: "Completed",
+ cell: ({ getValue }) => (
+
+ {formatCount(getValue())}
+
+ ),
+ },
+ {
+ accessorKey: "failed",
+ header: "Failed",
+ cell: ({ row }) => {
+ const total = row.original.failed + row.original.dead;
+ return (
+ 0 ? "text-danger" : "text-[var(--fg-muted)]"}`}
+ >
+ {formatCount(total)}
+
+ );
+ },
+ },
+ {
+ id: "actions",
+ header: "",
+ cell: ({ row }) => {
+ const name = row.original.name;
+ if (row.original.paused) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ },
+ },
+ ],
+ [pauseMutation, resumeMutation],
+ );
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (loading && rows.length === 0) {
+ return ;
+ }
+
+ if (rows.length === 0) {
+ return (
+
+ );
+ }
+
+ return r.name} />;
+}
diff --git a/dashboard-next/src/features/queues/hooks.ts b/dashboard-next/src/features/queues/hooks.ts
new file mode 100644
index 0000000..055df10
--- /dev/null
+++ b/dashboard-next/src/features/queues/hooks.ts
@@ -0,0 +1,87 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { useRefreshInterval } from "@/providers/refresh-interval-provider";
+import { fetchPausedQueues, fetchQueueStats, pauseQueue, resumeQueue } from "./api";
+
+const KEY = {
+ stats: ["queues", "stats"] as const,
+ paused: ["queues", "paused"] as const,
+ all: ["queues"] as const,
+};
+
+export function useQueueStats() {
+ const { intervalMs } = useRefreshInterval();
+ return useQuery({
+ queryKey: KEY.stats,
+ queryFn: ({ signal }) => fetchQueueStats(signal),
+ refetchInterval: intervalMs,
+ });
+}
+
+export function usePausedQueues() {
+ const { intervalMs } = useRefreshInterval();
+ return useQuery({
+ queryKey: KEY.paused,
+ queryFn: ({ signal }) => fetchPausedQueues(signal),
+ refetchInterval: intervalMs,
+ });
+}
+
+interface OptimisticContext {
+ prev: string[] | undefined;
+}
+
+/**
+ * Pause a queue.
+ *
+ * Optimistically adds the queue name to the paused set so the UI reflects
+ * the change instantly; rolls back on error.
+ */
+export function usePauseQueue() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (name: string) => pauseQueue(name),
+ onMutate: async (name) => {
+ await qc.cancelQueries({ queryKey: KEY.paused });
+ const prev = qc.getQueryData(KEY.paused);
+ qc.setQueryData(KEY.paused, [...(prev ?? []), name]);
+ return { prev } satisfies OptimisticContext;
+ },
+ onError: (error, _name, ctx) => {
+ if (ctx?.prev !== undefined) qc.setQueryData(KEY.paused, ctx.prev);
+ toast.error("Couldn't pause queue", {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ },
+ onSuccess: (result) => toast.success(`Paused ${result.paused}`),
+ onSettled: () => qc.invalidateQueries({ queryKey: KEY.all }),
+ });
+}
+
+/**
+ * Resume a paused queue. Mirrors `usePauseQueue` with an inverted optimistic
+ * update.
+ */
+export function useResumeQueue() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (name: string) => resumeQueue(name),
+ onMutate: async (name) => {
+ await qc.cancelQueries({ queryKey: KEY.paused });
+ const prev = qc.getQueryData(KEY.paused);
+ qc.setQueryData(
+ KEY.paused,
+ (prev ?? []).filter((n) => n !== name),
+ );
+ return { prev } satisfies OptimisticContext;
+ },
+ onError: (error, _name, ctx) => {
+ if (ctx?.prev !== undefined) qc.setQueryData(KEY.paused, ctx.prev);
+ toast.error("Couldn't resume queue", {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ },
+ onSuccess: (result) => toast.success(`Resumed ${result.resumed}`),
+ onSettled: () => qc.invalidateQueries({ queryKey: KEY.all }),
+ });
+}
diff --git a/dashboard-next/src/features/queues/index.ts b/dashboard-next/src/features/queues/index.ts
new file mode 100644
index 0000000..a234113
--- /dev/null
+++ b/dashboard-next/src/features/queues/index.ts
@@ -0,0 +1,2 @@
+export * from "./components";
+export * from "./hooks";
diff --git a/dashboard-next/src/routes/queues.tsx b/dashboard-next/src/routes/queues.tsx
index d2db4db..09e5731 100644
--- a/dashboard-next/src/routes/queues.tsx
+++ b/dashboard-next/src/routes/queues.tsx
@@ -1,17 +1,31 @@
import { createFileRoute } from "@tanstack/react-router";
-import { Box } from "lucide-react";
import { PageHeader } from "@/components/layout";
-import { EmptyState } from "@/components/ui/empty-state";
+import { QueuesTable, usePausedQueues, useQueueStats } from "@/features/queues";
export const Route = createFileRoute("/queues")({
component: QueuesPage,
});
function QueuesPage() {
+ const stats = useQueueStats();
+ const paused = usePausedQueues();
+
return (
<>
-
-
+
+ {
+ stats.refetch();
+ paused.refetch();
+ }}
+ />
>
);
}
From 0fb07d669bd30ea75a8cb446f1cfc9cb965c94ad Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 25 Apr 2026 01:28:06 +0530
Subject: [PATCH 19/42] feat(dashboard-next): dead letters with grouping and
typed purge
---
.../ui/destructive-confirm-dialog.tsx | 93 ++++++++++++++
dashboard-next/src/components/ui/index.ts | 1 +
.../src/features/dead-letters/api.ts | 25 ++++
.../components/dead-letter-group-row.tsx | 53 ++++++++
.../components/dead-letter-list.tsx | 68 ++++++++++
.../components/dead-letter-row.tsx | 54 ++++++++
.../features/dead-letters/components/index.ts | 3 +
.../src/features/dead-letters/hooks.ts | 61 +++++++++
.../src/features/dead-letters/index.ts | 3 +
.../src/features/dead-letters/utils.test.ts | 70 ++++++++++
.../src/features/dead-letters/utils.ts | 48 +++++++
dashboard-next/src/routes/dead-letters.tsx | 121 +++++++++++++++++-
12 files changed, 596 insertions(+), 4 deletions(-)
create mode 100644 dashboard-next/src/components/ui/destructive-confirm-dialog.tsx
create mode 100644 dashboard-next/src/features/dead-letters/api.ts
create mode 100644 dashboard-next/src/features/dead-letters/components/dead-letter-group-row.tsx
create mode 100644 dashboard-next/src/features/dead-letters/components/dead-letter-list.tsx
create mode 100644 dashboard-next/src/features/dead-letters/components/dead-letter-row.tsx
create mode 100644 dashboard-next/src/features/dead-letters/components/index.ts
create mode 100644 dashboard-next/src/features/dead-letters/hooks.ts
create mode 100644 dashboard-next/src/features/dead-letters/index.ts
create mode 100644 dashboard-next/src/features/dead-letters/utils.test.ts
create mode 100644 dashboard-next/src/features/dead-letters/utils.ts
diff --git a/dashboard-next/src/components/ui/destructive-confirm-dialog.tsx b/dashboard-next/src/components/ui/destructive-confirm-dialog.tsx
new file mode 100644
index 0000000..c7fc4d2
--- /dev/null
+++ b/dashboard-next/src/components/ui/destructive-confirm-dialog.tsx
@@ -0,0 +1,93 @@
+import { type ReactNode, useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+
+interface DestructiveConfirmDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: ReactNode;
+ description?: ReactNode;
+ /**
+ * Word the user must type to unlock the destructive button. Matched case
+ * sensitively so a trained eye has to confirm intent.
+ */
+ confirmPhrase: string;
+ confirmLabel?: string;
+ cancelLabel?: string;
+ pending?: boolean;
+ onConfirm: () => void | Promise;
+}
+
+/**
+ * Confirmation dialog for irreversible destructive actions.
+ *
+ * Instead of a single click, requires typing a specific phrase. Meant for
+ * actions like purge-all, drop-table, delete-workspace — the stakes are
+ * high enough that the friction is the feature.
+ */
+export function DestructiveConfirmDialog({
+ open,
+ onOpenChange,
+ title,
+ description,
+ confirmPhrase,
+ confirmLabel = "Confirm",
+ cancelLabel = "Cancel",
+ pending = false,
+ onConfirm,
+}: DestructiveConfirmDialogProps) {
+ const [typed, setTyped] = useState("");
+
+ useEffect(() => {
+ if (!open) setTyped("");
+ }, [open]);
+
+ const ready = typed === confirmPhrase;
+
+ async function handleConfirm() {
+ if (!ready) return;
+ await onConfirm();
+ onOpenChange(false);
+ }
+
+ return (
+
+ );
+}
diff --git a/dashboard-next/src/components/ui/index.ts b/dashboard-next/src/components/ui/index.ts
index dc98ac2..c8dcd7e 100644
--- a/dashboard-next/src/components/ui/index.ts
+++ b/dashboard-next/src/components/ui/index.ts
@@ -21,6 +21,7 @@ export {
} from "./command";
export { ConfirmDialog } from "./confirm-dialog";
export { DataTable } from "./data-table";
+export { DestructiveConfirmDialog } from "./destructive-confirm-dialog";
export {
Dialog,
DialogClose,
diff --git a/dashboard-next/src/features/dead-letters/api.ts b/dashboard-next/src/features/dead-letters/api.ts
new file mode 100644
index 0000000..da8b748
--- /dev/null
+++ b/dashboard-next/src/features/dead-letters/api.ts
@@ -0,0 +1,25 @@
+import { api } from "@/lib/api-client";
+import type { DeadLetter } from "@/lib/api-types";
+
+export interface DeadLettersPage {
+ items: DeadLetter[];
+}
+
+export function fetchDeadLetters(
+ page: number,
+ pageSize: number,
+ signal?: AbortSignal,
+): Promise {
+ return api.get("/api/dead-letters", {
+ signal,
+ params: { limit: pageSize, offset: page * pageSize },
+ });
+}
+
+export function retryDeadLetter(id: string): Promise<{ new_job_id: string }> {
+ return api.post<{ new_job_id: string }>(`/api/dead-letters/${encodeURIComponent(id)}/retry`);
+}
+
+export function purgeDeadLetters(): Promise<{ purged: number }> {
+ return api.post<{ purged: number }>("/api/dead-letters/purge");
+}
diff --git a/dashboard-next/src/features/dead-letters/components/dead-letter-group-row.tsx b/dashboard-next/src/features/dead-letters/components/dead-letter-group-row.tsx
new file mode 100644
index 0000000..80c8331
--- /dev/null
+++ b/dashboard-next/src/features/dead-letters/components/dead-letter-group-row.tsx
@@ -0,0 +1,53 @@
+import { ChevronDown, ChevronRight } from "lucide-react";
+import { useState } from "react";
+import { Badge } from "@/components/ui/badge";
+import type { DeadLetterGroup } from "../utils";
+import { DeadLetterRow } from "./dead-letter-row";
+
+interface DeadLetterGroupRowProps {
+ group: DeadLetterGroup;
+}
+
+export function DeadLetterGroupRow({ group }: DeadLetterGroupRowProps) {
+ const [open, setOpen] = useState(false);
+ const count = group.entries.length;
+ return (
+
+
+ {open ? (
+
+ {group.entries.map((entry) => (
+ -
+
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/dashboard-next/src/features/dead-letters/components/dead-letter-list.tsx b/dashboard-next/src/features/dead-letters/components/dead-letter-list.tsx
new file mode 100644
index 0000000..62a3b79
--- /dev/null
+++ b/dashboard-next/src/features/dead-letters/components/dead-letter-list.tsx
@@ -0,0 +1,68 @@
+import { Skull } from "lucide-react";
+import { useMemo } from "react";
+import { EmptyState } from "@/components/ui/empty-state";
+import { ErrorState } from "@/components/ui/error-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { DeadLetter } from "@/lib/api-types";
+import { groupByError } from "../utils";
+import { DeadLetterGroupRow } from "./dead-letter-group-row";
+import { DeadLetterRow } from "./dead-letter-row";
+
+export type DeadLetterView = "flat" | "grouped";
+
+interface DeadLetterListProps {
+ items: DeadLetter[] | undefined;
+ view: DeadLetterView;
+ loading: boolean;
+ error: Error | null;
+ onRetry: () => void;
+}
+
+export function DeadLetterList({ items, view, loading, error, onRetry }: DeadLetterListProps) {
+ const groups = useMemo(
+ () => (view === "grouped" && items ? groupByError(items) : []),
+ [view, items],
+ );
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (loading && !items) {
+ return ;
+ }
+
+ if (!items || items.length === 0) {
+ return (
+
+ );
+ }
+
+ if (view === "grouped") {
+ return (
+
+ {groups.map((g) => (
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ );
+}
diff --git a/dashboard-next/src/features/dead-letters/components/dead-letter-row.tsx b/dashboard-next/src/features/dead-letters/components/dead-letter-row.tsx
new file mode 100644
index 0000000..abf2f48
--- /dev/null
+++ b/dashboard-next/src/features/dead-letters/components/dead-letter-row.tsx
@@ -0,0 +1,54 @@
+import { Link } from "@tanstack/react-router";
+import { RotateCcw } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import type { DeadLetter } from "@/lib/api-types";
+import { formatRelative } from "@/lib/time";
+import { useRetryDeadLetter } from "../hooks";
+
+interface DeadLetterRowProps {
+ item: DeadLetter;
+}
+
+export function DeadLetterRow({ item }: DeadLetterRowProps) {
+ const retry = useRetryDeadLetter();
+ return (
+
+
+
+
+ {item.original_job_id.slice(0, 10)}…
+
+ ·
+ {item.task_name}
+ {item.queue}
+ {item.retry_count > 0 ? (
+
+ {item.retry_count} {item.retry_count === 1 ? "retry" : "retries"}
+
+ ) : null}
+
+ {formatRelative(item.failed_at * 1000)}
+
+
+ {item.error ? (
+
+ {item.error}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/dashboard-next/src/features/dead-letters/components/index.ts b/dashboard-next/src/features/dead-letters/components/index.ts
new file mode 100644
index 0000000..e1f897a
--- /dev/null
+++ b/dashboard-next/src/features/dead-letters/components/index.ts
@@ -0,0 +1,3 @@
+export { DeadLetterGroupRow } from "./dead-letter-group-row";
+export { DeadLetterList, type DeadLetterView } from "./dead-letter-list";
+export { DeadLetterRow } from "./dead-letter-row";
diff --git a/dashboard-next/src/features/dead-letters/hooks.ts b/dashboard-next/src/features/dead-letters/hooks.ts
new file mode 100644
index 0000000..9ad5d21
--- /dev/null
+++ b/dashboard-next/src/features/dead-letters/hooks.ts
@@ -0,0 +1,61 @@
+import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { useRefreshInterval } from "@/providers/refresh-interval-provider";
+import { fetchDeadLetters, purgeDeadLetters, retryDeadLetter } from "./api";
+
+const KEY = {
+ all: ["dead-letters"] as const,
+ list: (page: number, pageSize: number) => ["dead-letters", "list", page, pageSize] as const,
+};
+
+export function useDeadLetters(page: number, pageSize: number) {
+ const { intervalMs } = useRefreshInterval();
+ return useQuery({
+ queryKey: KEY.list(page, pageSize),
+ queryFn: ({ signal }) => fetchDeadLetters(page, pageSize, signal),
+ placeholderData: keepPreviousData,
+ refetchInterval: intervalMs,
+ });
+}
+
+export function useRetryDeadLetter() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => retryDeadLetter(id),
+ onError: (error) => {
+ toast.error("Couldn't retry dead letter", {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ },
+ onSuccess: (result) => {
+ toast.success("Re-enqueued", {
+ description: `New job: ${result.new_job_id.slice(0, 8)}…`,
+ });
+ },
+ onSettled: () => {
+ qc.invalidateQueries({ queryKey: KEY.all });
+ qc.invalidateQueries({ queryKey: ["stats"] });
+ },
+ });
+}
+
+export function usePurgeDeadLetters() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: () => purgeDeadLetters(),
+ onError: (error) => {
+ toast.error("Couldn't purge dead letters", {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ },
+ onSuccess: (result) => {
+ toast.success("Dead letters purged", {
+ description: `${result.purged} entries removed`,
+ });
+ },
+ onSettled: () => {
+ qc.invalidateQueries({ queryKey: KEY.all });
+ qc.invalidateQueries({ queryKey: ["stats"] });
+ },
+ });
+}
diff --git a/dashboard-next/src/features/dead-letters/index.ts b/dashboard-next/src/features/dead-letters/index.ts
new file mode 100644
index 0000000..bbb8e24
--- /dev/null
+++ b/dashboard-next/src/features/dead-letters/index.ts
@@ -0,0 +1,3 @@
+export * from "./components";
+export * from "./hooks";
+export { type DeadLetterGroup, groupByError } from "./utils";
diff --git a/dashboard-next/src/features/dead-letters/utils.test.ts b/dashboard-next/src/features/dead-letters/utils.test.ts
new file mode 100644
index 0000000..886a250
--- /dev/null
+++ b/dashboard-next/src/features/dead-letters/utils.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from "vitest";
+import type { DeadLetter } from "@/lib/api-types";
+import { groupByError } from "./utils";
+
+function dl(overrides: Partial): DeadLetter {
+ return {
+ id: overrides.id ?? "id-1",
+ original_job_id: overrides.original_job_id ?? "job-1",
+ task_name: overrides.task_name ?? "send_email",
+ queue: overrides.queue ?? "default",
+ error: overrides.error ?? null,
+ retry_count: overrides.retry_count ?? 3,
+ failed_at: overrides.failed_at ?? 1_700_000_000,
+ };
+}
+
+describe("groupByError", () => {
+ it("returns empty array when no items", () => {
+ expect(groupByError([])).toEqual([]);
+ });
+
+ it("collapses entries with the same error into one group", () => {
+ const items = [
+ dl({ id: "a", error: "timeout", failed_at: 1000 }),
+ dl({ id: "b", error: "timeout", failed_at: 2000 }),
+ dl({ id: "c", error: "divide by zero", failed_at: 1500 }),
+ ];
+ const groups = groupByError(items);
+ expect(groups).toHaveLength(2);
+ const timeout = groups.find((g) => g.error === "timeout")!;
+ expect(timeout.entries).toHaveLength(2);
+ });
+
+ it("sorts entries within a group newest-first", () => {
+ const items = [
+ dl({ id: "old", error: "oops", failed_at: 100 }),
+ dl({ id: "new", error: "oops", failed_at: 900 }),
+ dl({ id: "middle", error: "oops", failed_at: 500 }),
+ ];
+ const groups = groupByError(items);
+ expect(groups[0]!.entries.map((e) => e.id)).toEqual(["new", "middle", "old"]);
+ });
+
+ it("orders groups by the freshest failure first", () => {
+ const items = [
+ dl({ id: "a", error: "E1", failed_at: 10 }),
+ dl({ id: "b", error: "E2", failed_at: 50 }),
+ dl({ id: "c", error: "E3", failed_at: 30 }),
+ ];
+ const groups = groupByError(items);
+ expect(groups.map((g) => g.error)).toEqual(["E2", "E3", "E1"]);
+ });
+
+ it("treats null and whitespace-only errors as one labeled group", () => {
+ const items = [dl({ id: "a", error: null }), dl({ id: "b", error: " " })];
+ const groups = groupByError(items);
+ expect(groups).toHaveLength(1);
+ expect(groups[0]!.error).toMatch(/no error captured/i);
+ });
+
+ it("dedupes queues across entries in a group", () => {
+ const items = [
+ dl({ id: "a", error: "boom", queue: "emails" }),
+ dl({ id: "b", error: "boom", queue: "emails" }),
+ dl({ id: "c", error: "boom", queue: "reports" }),
+ ];
+ const [group] = groupByError(items);
+ expect(group!.queues).toEqual(["emails", "reports"]);
+ });
+});
diff --git a/dashboard-next/src/features/dead-letters/utils.ts b/dashboard-next/src/features/dead-letters/utils.ts
new file mode 100644
index 0000000..839a6db
--- /dev/null
+++ b/dashboard-next/src/features/dead-letters/utils.ts
@@ -0,0 +1,48 @@
+import type { DeadLetter } from "@/lib/api-types";
+
+export interface DeadLetterGroup {
+ /** Canonical error message shared by every entry in the group. */
+ error: string;
+ /** Sample task name used as the group label. */
+ sampleTask: string;
+ /** Queues represented in the group (deduped). */
+ queues: string[];
+ /** All dead letters in this group, newest first. */
+ entries: DeadLetter[];
+}
+
+const EMPTY_ERROR_LABEL = "(no error captured)";
+
+/**
+ * Group dead letters by error message.
+ *
+ * Items with the same error are collapsed into a single group so an operator
+ * isn't drowning in 500 identical SQL timeouts. Groups are returned ordered
+ * by most recent failure first.
+ */
+export function groupByError(items: DeadLetter[]): DeadLetterGroup[] {
+ const byError = new Map();
+ for (const item of items) {
+ const key = (item.error ?? "").trim() || EMPTY_ERROR_LABEL;
+ const existing = byError.get(key);
+ if (existing) {
+ existing.entries.push(item);
+ if (!existing.queues.includes(item.queue)) existing.queues.push(item.queue);
+ } else {
+ byError.set(key, {
+ error: key,
+ sampleTask: item.task_name,
+ queues: [item.queue],
+ entries: [item],
+ });
+ }
+ }
+ // Ensure entries within a group are newest-first, then order groups by
+ // the most recent failure across all entries.
+ for (const group of byError.values()) {
+ group.entries.sort((a, b) => b.failed_at - a.failed_at);
+ }
+ return [...byError.values()].sort(
+ (a, b) => (b.entries[0]?.failed_at ?? 0) - (a.entries[0]?.failed_at ?? 0),
+ );
+}
diff --git a/dashboard-next/src/routes/dead-letters.tsx b/dashboard-next/src/routes/dead-letters.tsx
index fbfec2a..4df5bc7 100644
--- a/dashboard-next/src/routes/dead-letters.tsx
+++ b/dashboard-next/src/routes/dead-letters.tsx
@@ -1,17 +1,130 @@
import { createFileRoute } from "@tanstack/react-router";
-import { Skull } from "lucide-react";
+import { List, Rows3, Trash2 } from "lucide-react";
+import { useState } from "react";
import { PageHeader } from "@/components/layout";
-import { EmptyState } from "@/components/ui/empty-state";
+import { Button } from "@/components/ui/button";
+import { DestructiveConfirmDialog } from "@/components/ui/destructive-confirm-dialog";
+import { Pagination } from "@/components/ui/pagination";
+import {
+ DeadLetterList,
+ type DeadLetterView,
+ useDeadLetters,
+ usePurgeDeadLetters,
+} from "@/features/dead-letters";
+import { cn } from "@/lib/cn";
+
+const PAGE_SIZE = 25;
export const Route = createFileRoute("/dead-letters")({
component: DeadLettersPage,
});
function DeadLettersPage() {
+ const [page, setPage] = useState(0);
+ const [view, setView] = useState("grouped");
+ const [confirmPurge, setConfirmPurge] = useState(false);
+
+ const query = useDeadLetters(page, PAGE_SIZE);
+ const purge = usePurgeDeadLetters();
+
+ const items = query.data;
+ const hasMore = items ? items.length >= PAGE_SIZE : false;
+
return (
<>
-
-
+
+
+
+ >
+ }
+ />
+
+
+
query.refetch()}
+ />
+
+
+
+ {
+ await purge.mutateAsync();
+ }}
+ />
>
);
}
+
+function ViewToggle({
+ value,
+ onChange,
+}: {
+ value: DeadLetterView;
+ onChange: (v: DeadLetterView) => void;
+}) {
+ return (
+
+ onChange("grouped")} label="Grouped">
+
+
+ onChange("flat")} label="Flat">
+
+
+
+ );
+}
+
+function ToggleBtn({
+ active,
+ onClick,
+ label,
+ children,
+}: {
+ active: boolean;
+ onClick: () => void;
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
From a0dce622a5fcede92ad6c47bfd4115bc66fd6648 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 25 Apr 2026 01:28:10 +0530
Subject: [PATCH 20/42] feat(dashboard-next): metrics with lazy-loaded charts
---
dashboard-next/src/features/metrics/api.ts | 37 +++++
.../src/features/metrics/components/index.ts | 5 +
.../metrics/components/latency-chart.tsx | 106 ++++++++++++++
.../metrics/components/metrics-table.tsx | 128 +++++++++++++++++
.../metrics/components/task-selector.tsx | 34 +++++
.../metrics/components/throughput-chart.tsx | 135 ++++++++++++++++++
.../components/time-range-selector.tsx | 41 ++++++
dashboard-next/src/features/metrics/hooks.ts | 31 ++++
dashboard-next/src/features/metrics/index.ts | 4 +
dashboard-next/src/features/metrics/page.tsx | 64 +++++++++
dashboard-next/src/features/metrics/types.ts | 24 ++++
dashboard-next/src/routes/metrics.lazy.tsx | 6 +
dashboard-next/src/routes/metrics.tsx | 19 +--
13 files changed, 619 insertions(+), 15 deletions(-)
create mode 100644 dashboard-next/src/features/metrics/api.ts
create mode 100644 dashboard-next/src/features/metrics/components/index.ts
create mode 100644 dashboard-next/src/features/metrics/components/latency-chart.tsx
create mode 100644 dashboard-next/src/features/metrics/components/metrics-table.tsx
create mode 100644 dashboard-next/src/features/metrics/components/task-selector.tsx
create mode 100644 dashboard-next/src/features/metrics/components/throughput-chart.tsx
create mode 100644 dashboard-next/src/features/metrics/components/time-range-selector.tsx
create mode 100644 dashboard-next/src/features/metrics/hooks.ts
create mode 100644 dashboard-next/src/features/metrics/index.ts
create mode 100644 dashboard-next/src/features/metrics/page.tsx
create mode 100644 dashboard-next/src/features/metrics/types.ts
create mode 100644 dashboard-next/src/routes/metrics.lazy.tsx
diff --git a/dashboard-next/src/features/metrics/api.ts b/dashboard-next/src/features/metrics/api.ts
new file mode 100644
index 0000000..df0e90d
--- /dev/null
+++ b/dashboard-next/src/features/metrics/api.ts
@@ -0,0 +1,37 @@
+import { api } from "@/lib/api-client";
+import type { MetricsResponse, TimeseriesBucket } from "@/lib/api-types";
+
+interface MetricsParams {
+ task?: string;
+ sinceSeconds: number;
+}
+
+export function fetchMetrics(
+ params: MetricsParams,
+ signal?: AbortSignal,
+): Promise {
+ return api.get("/api/metrics", {
+ signal,
+ params: { since: params.sinceSeconds, ...(params.task ? { task: params.task } : {}) },
+ });
+}
+
+interface TimeseriesParams {
+ task?: string;
+ sinceSeconds: number;
+ bucketSeconds: number;
+}
+
+export function fetchTimeseries(
+ params: TimeseriesParams,
+ signal?: AbortSignal,
+): Promise {
+ return api.get("/api/metrics/timeseries", {
+ signal,
+ params: {
+ since: params.sinceSeconds,
+ bucket: params.bucketSeconds,
+ ...(params.task ? { task: params.task } : {}),
+ },
+ });
+}
diff --git a/dashboard-next/src/features/metrics/components/index.ts b/dashboard-next/src/features/metrics/components/index.ts
new file mode 100644
index 0000000..7c55e25
--- /dev/null
+++ b/dashboard-next/src/features/metrics/components/index.ts
@@ -0,0 +1,5 @@
+export { LatencyChart } from "./latency-chart";
+export { MetricsTable } from "./metrics-table";
+export { TaskSelector } from "./task-selector";
+export { ThroughputChart } from "./throughput-chart";
+export { TimeRangeSelector } from "./time-range-selector";
diff --git a/dashboard-next/src/features/metrics/components/latency-chart.tsx b/dashboard-next/src/features/metrics/components/latency-chart.tsx
new file mode 100644
index 0000000..1e73735
--- /dev/null
+++ b/dashboard-next/src/features/metrics/components/latency-chart.tsx
@@ -0,0 +1,106 @@
+import { useMemo } from "react";
+import {
+ CartesianGrid,
+ Line,
+ LineChart,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { TimeseriesBucket } from "@/lib/api-types";
+
+interface LatencyChartProps {
+ buckets: TimeseriesBucket[] | undefined;
+ loading: boolean;
+}
+
+interface Row {
+ t: number;
+ avg_ms: number;
+}
+
+export function LatencyChart({ buckets, loading }: LatencyChartProps) {
+ const data = useMemo(
+ () => (buckets ?? []).map((b) => ({ t: b.timestamp * 1000, avg_ms: b.avg_ms })),
+ [buckets],
+ );
+
+ return (
+
+
+ Average latency
+
+
+ {loading && data.length === 0 ? (
+
+ ) : data.length === 0 ? (
+
+ No data in the selected window
+
+ ) : (
+
+
+
+
+ `${v}ms`}
+ width={54}
+ />
+ } />
+
+
+
+ )}
+
+
+ );
+}
+
+interface LatencyTooltipPayload {
+ value?: number;
+}
+
+function LatencyTooltip({
+ active,
+ payload,
+ label,
+}: {
+ active?: boolean;
+ payload?: LatencyTooltipPayload[];
+ label?: unknown;
+}) {
+ if (!active || !payload || payload.length === 0) return null;
+ const ts = typeof label === "number" ? label : Number(label);
+ const avg = payload[0]?.value ?? 0;
+ return (
+
+
{new Date(ts).toLocaleTimeString()}
+
{avg.toFixed(1)} ms avg
+
+ );
+}
+
+function formatAxisTime(value: number): string {
+ return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+}
diff --git a/dashboard-next/src/features/metrics/components/metrics-table.tsx b/dashboard-next/src/features/metrics/components/metrics-table.tsx
new file mode 100644
index 0000000..b4a4f7f
--- /dev/null
+++ b/dashboard-next/src/features/metrics/components/metrics-table.tsx
@@ -0,0 +1,128 @@
+import type { ColumnDef } from "@tanstack/react-table";
+import { useMemo } from "react";
+import { DataTable } from "@/components/ui/data-table";
+import { EmptyState } from "@/components/ui/empty-state";
+import { ErrorState } from "@/components/ui/error-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { MetricsResponse, TaskMetrics } from "@/lib/api-types";
+import { formatCount, formatPercent } from "@/lib/number";
+
+interface Row extends TaskMetrics {
+ task: string;
+ successRate: number;
+}
+
+interface MetricsTableProps {
+ metrics: MetricsResponse | undefined;
+ loading: boolean;
+ error: Error | null;
+ onRetry: () => void;
+}
+
+export function MetricsTable({ metrics, loading, error, onRetry }: MetricsTableProps) {
+ const rows = useMemo(() => {
+ if (!metrics) return [];
+ return Object.entries(metrics)
+ .map(([task, m]) => ({
+ ...m,
+ task,
+ successRate: m.count > 0 ? m.success_count / m.count : 0,
+ }))
+ .sort((a, b) => b.count - a.count);
+ }, [metrics]);
+
+ const columns = useMemo[]>(
+ () => [
+ {
+ accessorKey: "task",
+ header: "Task",
+ cell: ({ getValue }) => (
+ {getValue()}
+ ),
+ },
+ {
+ accessorKey: "count",
+ header: "Runs",
+ cell: ({ getValue }) => (
+ {formatCount(getValue())}
+ ),
+ },
+ {
+ accessorKey: "successRate",
+ header: "Success",
+ cell: ({ row }) => {
+ const rate = row.original.successRate;
+ const tone = rate >= 0.99 ? "text-success" : rate >= 0.9 ? "text-warning" : "text-danger";
+ return {formatPercent(rate, 1)};
+ },
+ },
+ {
+ accessorKey: "failure_count",
+ header: "Failures",
+ cell: ({ getValue }) => {
+ const n = getValue();
+ return (
+ 0 ? "text-danger" : "text-[var(--fg-muted)]"}`}>
+ {formatCount(n)}
+
+ );
+ },
+ },
+ {
+ accessorKey: "p50_ms",
+ header: "p50",
+ cell: ({ getValue }) => ()} />,
+ },
+ {
+ accessorKey: "p95_ms",
+ header: "p95",
+ cell: ({ getValue }) => ()} />,
+ },
+ {
+ accessorKey: "p99_ms",
+ header: "p99",
+ cell: ({ getValue }) => ()} />,
+ },
+ {
+ accessorKey: "avg_ms",
+ header: "avg",
+ cell: ({ getValue }) => ()} />,
+ },
+ {
+ accessorKey: "max_ms",
+ header: "max",
+ cell: ({ getValue }) => ()} />,
+ },
+ ],
+ [],
+ );
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (loading && rows.length === 0) {
+ return ;
+ }
+
+ if (rows.length === 0) {
+ return (
+
+ );
+ }
+
+ return r.task} />;
+}
+
+function Ms({ value }: { value: number }) {
+ return (
+
+ {value > 0 ? `${value.toFixed(1)}ms` : "—"}
+
+ );
+}
diff --git a/dashboard-next/src/features/metrics/components/task-selector.tsx b/dashboard-next/src/features/metrics/components/task-selector.tsx
new file mode 100644
index 0000000..5b7c314
--- /dev/null
+++ b/dashboard-next/src/features/metrics/components/task-selector.tsx
@@ -0,0 +1,34 @@
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+
+const ALL = "__all__";
+
+interface TaskSelectorProps {
+ value: string | undefined;
+ tasks: string[];
+ onChange: (v: string | undefined) => void;
+ className?: string;
+}
+
+export function TaskSelector({ value, tasks, onChange, className }: TaskSelectorProps) {
+ return (
+
+ );
+}
diff --git a/dashboard-next/src/features/metrics/components/throughput-chart.tsx b/dashboard-next/src/features/metrics/components/throughput-chart.tsx
new file mode 100644
index 0000000..f9969a8
--- /dev/null
+++ b/dashboard-next/src/features/metrics/components/throughput-chart.tsx
@@ -0,0 +1,135 @@
+import { useMemo } from "react";
+import {
+ Area,
+ AreaChart,
+ CartesianGrid,
+ Legend,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { TimeseriesBucket } from "@/lib/api-types";
+
+interface ThroughputChartProps {
+ buckets: TimeseriesBucket[] | undefined;
+ loading: boolean;
+}
+
+interface Row {
+ t: number;
+ success: number;
+ failure: number;
+}
+
+export function ThroughputChart({ buckets, loading }: ThroughputChartProps) {
+ const data = useMemo(
+ () =>
+ (buckets ?? []).map((b) => ({
+ t: b.timestamp * 1000,
+ success: b.success,
+ failure: b.failure,
+ })),
+ [buckets],
+ );
+
+ return (
+
+
+ Throughput
+
+
+ {loading && data.length === 0 ? (
+
+ ) : data.length === 0 ? (
+
+ No data in the selected window
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } />
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+interface TooltipPayload {
+ name?: string;
+ value?: number;
+ color?: string;
+}
+
+function ChartTooltip({
+ active,
+ payload,
+ label,
+}: {
+ active?: boolean;
+ payload?: TooltipPayload[];
+ label?: unknown;
+}) {
+ if (!active || !payload || payload.length === 0) return null;
+ const ts = typeof label === "number" ? label : Number(label);
+ return (
+
+
{new Date(ts).toLocaleTimeString()}
+ {payload.map((entry) => (
+
+
+ {entry.name}
+ {entry.value}
+
+ ))}
+
+ );
+}
+
+function formatAxisTime(value: number): string {
+ return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+}
diff --git a/dashboard-next/src/features/metrics/components/time-range-selector.tsx b/dashboard-next/src/features/metrics/components/time-range-selector.tsx
new file mode 100644
index 0000000..618807e
--- /dev/null
+++ b/dashboard-next/src/features/metrics/components/time-range-selector.tsx
@@ -0,0 +1,41 @@
+import { cn } from "@/lib/cn";
+import { TIME_RANGES, type TimeRange } from "../types";
+
+interface TimeRangeSelectorProps {
+ value: TimeRange;
+ onChange: (v: TimeRange) => void;
+ className?: string;
+}
+
+export function TimeRangeSelector({ value, onChange, className }: TimeRangeSelectorProps) {
+ return (
+
+ {TIME_RANGES.map((r) => {
+ const active = r.value === value;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/dashboard-next/src/features/metrics/hooks.ts b/dashboard-next/src/features/metrics/hooks.ts
new file mode 100644
index 0000000..ebf1995
--- /dev/null
+++ b/dashboard-next/src/features/metrics/hooks.ts
@@ -0,0 +1,31 @@
+import { useQuery } from "@tanstack/react-query";
+import { useRefreshInterval } from "@/providers/refresh-interval-provider";
+import { fetchMetrics, fetchTimeseries } from "./api";
+import { type TimeRange, timeRangeConfig } from "./types";
+
+const KEY = {
+ summary: (task: string | undefined, range: TimeRange) =>
+ ["metrics", "summary", task ?? "", range] as const,
+ timeseries: (task: string | undefined, range: TimeRange) =>
+ ["metrics", "timeseries", task ?? "", range] as const,
+};
+
+export function useMetricsSummary(range: TimeRange, task?: string) {
+ const { intervalMs } = useRefreshInterval();
+ const { sinceSeconds } = timeRangeConfig(range);
+ return useQuery({
+ queryKey: KEY.summary(task, range),
+ queryFn: ({ signal }) => fetchMetrics({ task, sinceSeconds }, signal),
+ refetchInterval: intervalMs,
+ });
+}
+
+export function useMetricsTimeseries(range: TimeRange, task?: string) {
+ const { intervalMs } = useRefreshInterval();
+ const { sinceSeconds, bucketSeconds } = timeRangeConfig(range);
+ return useQuery({
+ queryKey: KEY.timeseries(task, range),
+ queryFn: ({ signal }) => fetchTimeseries({ task, sinceSeconds, bucketSeconds }, signal),
+ refetchInterval: intervalMs,
+ });
+}
diff --git a/dashboard-next/src/features/metrics/index.ts b/dashboard-next/src/features/metrics/index.ts
new file mode 100644
index 0000000..6e9b3aa
--- /dev/null
+++ b/dashboard-next/src/features/metrics/index.ts
@@ -0,0 +1,4 @@
+export * from "./components";
+export * from "./hooks";
+export { default } from "./page";
+export { DEFAULT_TIME_RANGE, TIME_RANGES, type TimeRange, timeRangeConfig } from "./types";
diff --git a/dashboard-next/src/features/metrics/page.tsx b/dashboard-next/src/features/metrics/page.tsx
new file mode 100644
index 0000000..c602ca2
--- /dev/null
+++ b/dashboard-next/src/features/metrics/page.tsx
@@ -0,0 +1,64 @@
+import { useMemo, useState } from "react";
+import { PageHeader } from "@/components/layout";
+import {
+ LatencyChart,
+ MetricsTable,
+ TaskSelector,
+ ThroughputChart,
+ TimeRangeSelector,
+} from "./components";
+import { useMetricsSummary, useMetricsTimeseries } from "./hooks";
+import { DEFAULT_TIME_RANGE, type TimeRange } from "./types";
+
+/**
+ * The metrics dashboard.
+ *
+ * Exported as a default component so the route can dynamically import it
+ * and keep Recharts off the main bundle. Time range + task filter live as
+ * local state; if they turn out to be shareable we can promote to URL.
+ */
+export default function MetricsPage() {
+ const [range, setRange] = useState(DEFAULT_TIME_RANGE);
+ const [task, setTask] = useState(undefined);
+
+ const summary = useMetricsSummary(range, task);
+ const series = useMetricsTimeseries(range, task);
+
+ const taskOptions = useMemo(() => {
+ // When a task filter is set the summary will only contain that one key,
+ // so we keep a stale list in state to avoid the selector collapsing.
+ return summary.data ? Object.keys(summary.data).sort() : [];
+ }, [summary.data]);
+
+ return (
+ <>
+
+
+
+ >
+ }
+ />
+
+
+
+
+
+
summary.refetch()}
+ />
+
+ >
+ );
+}
diff --git a/dashboard-next/src/features/metrics/types.ts b/dashboard-next/src/features/metrics/types.ts
new file mode 100644
index 0000000..3815f30
--- /dev/null
+++ b/dashboard-next/src/features/metrics/types.ts
@@ -0,0 +1,24 @@
+export type TimeRange = "15m" | "1h" | "6h" | "24h";
+
+export const DEFAULT_TIME_RANGE: TimeRange = "1h";
+
+export interface TimeRangeConfig {
+ value: TimeRange;
+ sinceSeconds: number;
+ bucketSeconds: number;
+}
+
+/**
+ * Bucket sizes chosen so every range produces ~60 points on the chart — the
+ * sweet spot for resolution vs. readability.
+ */
+export const TIME_RANGES: TimeRangeConfig[] = [
+ { value: "15m", sinceSeconds: 15 * 60, bucketSeconds: 15 },
+ { value: "1h", sinceSeconds: 60 * 60, bucketSeconds: 60 },
+ { value: "6h", sinceSeconds: 6 * 60 * 60, bucketSeconds: 6 * 60 },
+ { value: "24h", sinceSeconds: 24 * 60 * 60, bucketSeconds: 24 * 60 },
+];
+
+export function timeRangeConfig(value: TimeRange): TimeRangeConfig {
+ return TIME_RANGES.find((r) => r.value === value) ?? TIME_RANGES[1]!;
+}
diff --git a/dashboard-next/src/routes/metrics.lazy.tsx b/dashboard-next/src/routes/metrics.lazy.tsx
new file mode 100644
index 0000000..51aa50f
--- /dev/null
+++ b/dashboard-next/src/routes/metrics.lazy.tsx
@@ -0,0 +1,6 @@
+import { createLazyFileRoute } from "@tanstack/react-router";
+import MetricsPage from "@/features/metrics";
+
+export const Route = createLazyFileRoute("/metrics")({
+ component: MetricsPage,
+});
diff --git a/dashboard-next/src/routes/metrics.tsx b/dashboard-next/src/routes/metrics.tsx
index 1d7d432..3113294 100644
--- a/dashboard-next/src/routes/metrics.tsx
+++ b/dashboard-next/src/routes/metrics.tsx
@@ -1,17 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
-import { BarChart3 } from "lucide-react";
-import { PageHeader } from "@/components/layout";
-import { EmptyState } from "@/components/ui/empty-state";
-export const Route = createFileRoute("/metrics")({
- component: MetricsPage,
-});
-
-function MetricsPage() {
- return (
- <>
-
-
- >
- );
-}
+// Component lives in the `.lazy.tsx` counterpart so Recharts only loads when
+// the user actually opens the metrics screen. Keeping this file
+// component-less makes TanStack's lazy-route convention kick in.
+export const Route = createFileRoute("/metrics")({});
From a76fc9cdb4329b353b97f34f46503a06e00fec7a Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Sat, 25 Apr 2026 01:28:18 +0530
Subject: [PATCH 21/42] feat(dashboard-next): virtualized live-tail logs
---
dashboard-next/src/features/logs/api.ts | 23 +++
.../src/features/logs/components/index.ts | 2 +
.../features/logs/components/log-filters.tsx | 66 +++++++++
.../features/logs/components/log-stream.tsx | 133 ++++++++++++++++++
dashboard-next/src/features/logs/hooks.ts | 15 ++
dashboard-next/src/features/logs/index.ts | 4 +
dashboard-next/src/features/logs/page.tsx | 46 ++++++
dashboard-next/src/routes/logs.lazy.tsx | 6 +
dashboard-next/src/routes/logs.tsx | 18 +--
9 files changed, 298 insertions(+), 15 deletions(-)
create mode 100644 dashboard-next/src/features/logs/api.ts
create mode 100644 dashboard-next/src/features/logs/components/index.ts
create mode 100644 dashboard-next/src/features/logs/components/log-filters.tsx
create mode 100644 dashboard-next/src/features/logs/components/log-stream.tsx
create mode 100644 dashboard-next/src/features/logs/hooks.ts
create mode 100644 dashboard-next/src/features/logs/index.ts
create mode 100644 dashboard-next/src/features/logs/page.tsx
create mode 100644 dashboard-next/src/routes/logs.lazy.tsx
diff --git a/dashboard-next/src/features/logs/api.ts b/dashboard-next/src/features/logs/api.ts
new file mode 100644
index 0000000..c52731f
--- /dev/null
+++ b/dashboard-next/src/features/logs/api.ts
@@ -0,0 +1,23 @@
+import { api } from "@/lib/api-client";
+import type { TaskLog } from "@/lib/api-types";
+
+export const LOG_LEVELS = ["debug", "info", "warning", "error"] as const;
+
+export type LogLevel = (typeof LOG_LEVELS)[number];
+
+export interface LogsQuery {
+ task?: string;
+ level?: LogLevel;
+ sinceSeconds: number;
+ limit: number;
+}
+
+export function fetchLogs(query: LogsQuery, signal?: AbortSignal): Promise {
+ const params: Record = {
+ since: query.sinceSeconds,
+ limit: query.limit,
+ };
+ if (query.task) params.task = query.task;
+ if (query.level) params.level = query.level;
+ return api.get("/api/logs", { signal, params });
+}
diff --git a/dashboard-next/src/features/logs/components/index.ts b/dashboard-next/src/features/logs/components/index.ts
new file mode 100644
index 0000000..c545b1d
--- /dev/null
+++ b/dashboard-next/src/features/logs/components/index.ts
@@ -0,0 +1,2 @@
+export { LogFilters } from "./log-filters";
+export { LogStream } from "./log-stream";
diff --git a/dashboard-next/src/features/logs/components/log-filters.tsx b/dashboard-next/src/features/logs/components/log-filters.tsx
new file mode 100644
index 0000000..e5a26ff
--- /dev/null
+++ b/dashboard-next/src/features/logs/components/log-filters.tsx
@@ -0,0 +1,66 @@
+import { useEffect, useState } from "react";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useDebouncedValue } from "@/hooks/use-debounced-value";
+import { cn } from "@/lib/cn";
+import { LOG_LEVELS, type LogLevel } from "../api";
+
+const ALL_LEVELS = "__all__";
+
+interface LogFiltersProps {
+ task: string | undefined;
+ level: LogLevel | undefined;
+ onChange: (next: { task?: string; level?: LogLevel }) => void;
+ className?: string;
+}
+
+export function LogFilters({ task, level, onChange, className }: LogFiltersProps) {
+ const [localTask, setLocalTask] = useState(task ?? "");
+ const debounced = useDebouncedValue(localTask, 300);
+
+ useEffect(() => {
+ setLocalTask(task ?? "");
+ }, [task]);
+
+ useEffect(() => {
+ const next = debounced.trim() || undefined;
+ if (next !== task) {
+ onChange({ task: next, level });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- propagate only debounced value
+ }, [debounced]);
+
+ return (
+
+ setLocalTask(e.target.value)}
+ placeholder="Filter by task name…"
+ />
+