- {/* Refresh interval + indicator */}
-
- 0 ? "text-accent" : "text-muted"}`}
- strokeWidth={refreshInterval.value > 0 ? 2 : 1.5}
- />
-
-
-
-
-
-
- {/* Theme toggle */}
-
+
+
+
+
+
+
);
diff --git a/dashboard/src/components/layout/index.ts b/dashboard/src/components/layout/index.ts
index 70b058c..c842370 100644
--- a/dashboard/src/components/layout/index.ts
+++ b/dashboard/src/components/layout/index.ts
@@ -1,3 +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 { Shell } from "./shell";
+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/src/components/layout/last-refreshed.tsx b/dashboard/src/components/layout/last-refreshed.tsx
new file mode 100644
index 0000000..8e49a67
--- /dev/null
+++ b/dashboard/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/src/components/layout/mobile-menu.tsx b/dashboard/src/components/layout/mobile-menu.tsx
new file mode 100644
index 0000000..5141219
--- /dev/null
+++ b/dashboard/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);
+ }, []);
+
+ return (
+
+
+
+
+
+
+ {site.name} Dashboard
+
+
+
+
+ );
+}
diff --git a/dashboard/src/components/layout/page-header.tsx b/dashboard/src/components/layout/page-header.tsx
new file mode 100644
index 0000000..9867135
--- /dev/null
+++ b/dashboard/src/components/layout/page-header.tsx
@@ -0,0 +1,43 @@
+import type { ReactNode } from "react";
+import { cn } from "@/lib/cn";
+import { Breadcrumbs, type Crumb } from "./breadcrumbs";
+
+interface PageHeaderProps {
+ eyebrow?: string;
+ title: string;
+ description?: ReactNode;
+ actions?: ReactNode;
+ breadcrumbs?: Crumb[];
+ className?: string;
+}
+
+export function PageHeader({
+ eyebrow,
+ title,
+ description,
+ actions,
+ breadcrumbs,
+ className,
+}: PageHeaderProps) {
+ return (
+
+
+ {breadcrumbs && breadcrumbs.length > 0 ? (
+
+ ) : eyebrow ? (
+
+ {eyebrow}
+
+ ) : null}
+
{title}
+ {description ?
{description}
: null}
+
+ {actions ?
{actions}
: null}
+
+ );
+}
diff --git a/dashboard/src/components/layout/refresh-control.tsx b/dashboard/src/components/layout/refresh-control.tsx
new file mode 100644
index 0000000..5b9ee52
--- /dev/null
+++ b/dashboard/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/src/components/layout/route-error-boundary.tsx b/dashboard/src/components/layout/route-error-boundary.tsx
new file mode 100644
index 0000000..685bdc5
--- /dev/null
+++ b/dashboard/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/src/components/layout/shell.tsx b/dashboard/src/components/layout/shell.tsx
deleted file mode 100644
index 0d65daf..0000000
--- a/dashboard/src/components/layout/shell.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { ComponentChildren } from "preact";
-import { Header } from "./header";
-import { Sidebar } from "./sidebar";
-
-interface ShellProps {
- children: ComponentChildren;
-}
-
-export function Shell({ children }: ShellProps) {
- return (
-
- );
-}
diff --git a/dashboard/src/components/layout/sidebar.tsx b/dashboard/src/components/layout/sidebar.tsx
index e913590..30910a7 100644
--- a/dashboard/src/components/layout/sidebar.tsx
+++ b/dashboard/src/components/layout/sidebar.tsx
@@ -1,21 +1,23 @@
-import type { LucideIcon } from "lucide-preact";
+import { Link, useLocation } from "@tanstack/react-router";
import {
+ Activity,
+ AlertOctagon,
BarChart3,
Box,
- Cog,
- Layers,
+ CircuitBoard,
LayoutDashboard,
- ListTodo,
+ ListTree,
+ type LucideIcon,
ScrollText,
Server,
- ShieldAlert,
+ Settings2,
Skull,
-} from "lucide-preact";
-import { useEffect, useState } from "preact/hooks";
-import { getCurrentUrl } from "preact-router";
+} from "lucide-react";
+import { cn } from "@/lib/cn";
+import { site } from "@/lib/site";
interface NavItem {
- path: string;
+ to: string;
label: string;
icon: LucideIcon;
}
@@ -25,89 +27,82 @@ interface NavGroup {
items: NavItem[];
}
-const NAV_GROUPS: NavGroup[] = [
+const NAV: NavGroup[] = [
{
title: "Monitoring",
items: [
- { path: "/", label: "Overview", icon: LayoutDashboard },
- { path: "/jobs", label: "Jobs", icon: ListTodo },
- { path: "/metrics", label: "Metrics", icon: BarChart3 },
- { path: "/logs", label: "Logs", icon: ScrollText },
+ { 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: [
- { path: "/workers", label: "Workers", icon: Server },
- { path: "/queues", label: "Queues", icon: Layers },
- { path: "/resources", label: "Resources", icon: Box },
- { path: "/circuit-breakers", label: "Circuit Breakers", icon: ShieldAlert },
+ { to: "/queues", label: "Queues", icon: Box },
+ { to: "/workers", label: "Workers", icon: Server },
+ { to: "/resources", label: "Resources", icon: Activity },
],
},
{
- title: "Advanced",
+ title: "Reliability",
items: [
- { path: "/dead-letters", label: "Dead Letters", icon: Skull },
- { path: "/system", label: "System", icon: Cog },
+ { to: "/dead-letters", label: "Dead letters", icon: Skull },
+ { to: "/circuit-breakers", label: "Circuit breakers", icon: CircuitBoard },
+ { to: "/system", label: "System", icon: Settings2 },
],
},
];
-function isActive(current: string, path: string): boolean {
- if (path === "/") return current === "/";
- return current === path || current.startsWith(`${path}/`);
-}
-
export function Sidebar() {
- const [currentPath, setCurrentPath] = useState(getCurrentUrl());
-
- useEffect(() => {
- const handler = () => setCurrentPath(getCurrentUrl());
- addEventListener("popstate", handler);
- addEventListener("pushstate", handler);
- return () => {
- removeEventListener("popstate", handler);
- removeEventListener("pushstate", handler);
- };
- }, []);
-
+ const { pathname } = useLocation();
return (
-