@@ -108,7 +100,7 @@ export default function Sidebar() {
)}
void logout()}
- className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-muted-foreground hover:text-destructive hover:bg-destructive/10 border border-transparent hover:border-destructive/20 transition-all cursor-pointer"
+ className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-md text-xs font-medium text-muted-foreground hover:text-destructive hover:bg-destructive/10 border border-transparent transition-colors cursor-pointer"
>
Sign Out
diff --git a/dashboard/src/components/ui/badge.tsx b/dashboard/src/components/ui/badge.tsx
index e1997af..681594e 100644
--- a/dashboard/src/components/ui/badge.tsx
+++ b/dashboard/src/components/ui/badge.tsx
@@ -1,21 +1,21 @@
-import { mergeProps } from "@base-ui/react/merge-props";
-import { useRender } from "@base-ui/react/use-render";
+import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
-
-import { cn } from "@/lib/utils";
+import { cn } from "cn";
+import { Slot } from "radix-ui";
const badgeVariants = cva(
- "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+ "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
- default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
- secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
- "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
- outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
- ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
- link: "text-primary underline-offset-4 hover:underline",
+ "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
+ outline:
+ "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
@@ -27,23 +27,19 @@ const badgeVariants = cva(
function Badge({
className,
variant = "default",
- render,
+ asChild = false,
...props
-}: useRender.ComponentProps<"span"> & VariantProps) {
- return useRender({
- defaultTagName: "span",
- props: mergeProps<"span">(
- {
- className: cn(badgeVariants({ variant }), className),
- },
- props,
- ),
- render,
- state: {
- slot: "badge",
- variant,
- },
- });
+}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "span";
+
+ return (
+
+ );
}
export { Badge, badgeVariants };
diff --git a/dashboard/src/components/ui/button.tsx b/dashboard/src/components/ui/button.tsx
index e2ee793..73bce6f 100644
--- a/dashboard/src/components/ui/button.tsx
+++ b/dashboard/src/components/ui/button.tsx
@@ -1,36 +1,31 @@
-import { Button as ButtonPrimitive } from "@base-ui/react/button";
+import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
-
-import { cn } from "@/lib/utils";
+import { cn } from "cn";
+import { Slot } from "radix-ui";
const buttonVariants = cva(
- "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
- default: "bg-primary text-primary-foreground hover:bg-primary/80",
- outline:
- "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
- secondary:
- "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
- ghost:
- "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
- "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
- default:
- "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
- xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
- sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
- lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
- icon: "size-8",
- "icon-xs":
- "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
- "icon-sm":
- "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
- "icon-lg": "size-9",
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
},
},
defaultVariants: {
@@ -44,11 +39,19 @@ function Button({
className,
variant = "default",
size = "default",
+ asChild = false,
...props
-}: ButtonPrimitive.Props & VariantProps) {
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean;
+ }) {
+ const Comp = asChild ? Slot.Root : "button";
+
return (
-
diff --git a/dashboard/src/components/ui/card.tsx b/dashboard/src/components/ui/card.tsx
index a73bb76..01b6263 100644
--- a/dashboard/src/components/ui/card.tsx
+++ b/dashboard/src/components/ui/card.tsx
@@ -1,18 +1,12 @@
import * as React from "react";
+import { cn } from "cn";
-import { cn } from "@/lib/utils";
-
-function Card({
- className,
- size = "default",
- ...props
-}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
+function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ "flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className,
)}
{...props}
@@ -25,7 +19,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
) {
return (
);
@@ -67,19 +58,14 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
+ return
;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
);
diff --git a/dashboard/src/components/ui/dialog.tsx b/dashboard/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..d631cd0
--- /dev/null
+++ b/dashboard/src/components/ui/dialog.tsx
@@ -0,0 +1,144 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "cn";
+import { XIcon } from "lucide-react";
+import { Dialog as DialogPrimitive } from "radix-ui";
+
+import { Button } from "@/components/ui/button";
+
+function Dialog({ ...props }: React.ComponentProps
) {
+ return ;
+}
+
+function DialogTrigger({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DialogPortal({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DialogClose({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean;
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+ Close
+
+ )}
+
+
+ );
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean;
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+
+ Close
+
+ )}
+
+ );
+}
+
+function DialogTitle({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/dashboard/src/components/ui/dropdown-menu.tsx b/dashboard/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..84c4027
--- /dev/null
+++ b/dashboard/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,225 @@
+import * as React from "react";
+import { cn } from "cn";
+import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
+
+function DropdownMenu({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function DropdownMenuGroup({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function DropdownMenuSub({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+};
diff --git a/dashboard/src/components/ui/input.tsx b/dashboard/src/components/ui/input.tsx
new file mode 100644
index 0000000..86342c1
--- /dev/null
+++ b/dashboard/src/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import * as React from "react";
+import { cn } from "cn";
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ );
+}
+
+export { Input };
diff --git a/dashboard/src/components/ui/scroll-area.tsx b/dashboard/src/components/ui/scroll-area.tsx
index 43ecbda..afb33c3 100644
--- a/dashboard/src/components/ui/scroll-area.tsx
+++ b/dashboard/src/components/ui/scroll-area.tsx
@@ -1,11 +1,14 @@
"use client";
import * as React from "react";
-import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
+import { cn } from "cn";
+import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
-import { cn } from "@/lib/utils";
-
-function ScrollArea({ className, children, ...props }: ScrollAreaPrimitive.Root.Props) {
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
return (
) {
return (
-
-
-
+
);
}
diff --git a/dashboard/src/components/ui/select.tsx b/dashboard/src/components/ui/select.tsx
new file mode 100644
index 0000000..7c2eaf0
--- /dev/null
+++ b/dashboard/src/components/ui/select.tsx
@@ -0,0 +1,172 @@
+import * as React from "react";
+import { cn } from "cn";
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+import { Select as SelectPrimitive } from "radix-ui";
+
+function Select({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function SelectGroup({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function SelectValue({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default";
+}) {
+ return (
+
+ {children}
+
+
+
+
+ );
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "item-aligned",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+function SelectLabel({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+};
diff --git a/dashboard/src/components/ui/separator.tsx b/dashboard/src/components/ui/separator.tsx
new file mode 100644
index 0000000..0d52c70
--- /dev/null
+++ b/dashboard/src/components/ui/separator.tsx
@@ -0,0 +1,25 @@
+import * as React from "react";
+import { cn } from "cn";
+import { Separator as SeparatorPrimitive } from "radix-ui";
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Separator };
diff --git a/dashboard/src/components/ui/sonner.tsx b/dashboard/src/components/ui/sonner.tsx
index 7cbe96b..c9cd094 100644
--- a/dashboard/src/components/ui/sonner.tsx
+++ b/dashboard/src/components/ui/sonner.tsx
@@ -1,14 +1,12 @@
-"use client";
-
-import { useTheme } from "next-themes";
-import { Toaster as Sonner, type ToasterProps } from "sonner";
import {
CircleCheckIcon,
InfoIcon,
- TriangleAlertIcon,
- OctagonXIcon,
Loader2Icon,
+ OctagonXIcon,
+ TriangleAlertIcon,
} from "lucide-react";
+import { useTheme } from "next-themes";
+import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
@@ -32,11 +30,6 @@ const Toaster = ({ ...props }: ToasterProps) => {
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
- toastOptions={{
- classNames: {
- toast: "cn-toast",
- },
- }}
{...props}
/>
);
diff --git a/dashboard/src/components/ui/table.tsx b/dashboard/src/components/ui/table.tsx
index 86d90c8..c7c83dd 100644
--- a/dashboard/src/components/ui/table.tsx
+++ b/dashboard/src/components/ui/table.tsx
@@ -1,8 +1,5 @@
-"use client";
-
import * as React from "react";
-
-import { cn } from "@/lib/utils";
+import { cn } from "cn";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
@@ -58,7 +55,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
@@ -70,7 +67,10 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
[role=checkbox]]:translate-y-[2px]",
+ className,
+ )}
{...props}
/>
);
diff --git a/dashboard/src/components/ui/tabs.tsx b/dashboard/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..8b5c133
--- /dev/null
+++ b/dashboard/src/components/ui/tabs.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "cn";
+import { Tabs as TabsPrimitive } from "radix-ui";
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+const tabsListVariants = cva(
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps & VariantProps) {
+ return (
+
+ );
+}
+
+function TabsTrigger({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function TabsContent({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
diff --git a/dashboard/src/components/ui/tooltip.tsx b/dashboard/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..7daab86
--- /dev/null
+++ b/dashboard/src/components/ui/tooltip.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "cn";
+import { Tooltip as TooltipPrimitive } from "radix-ui";
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function Tooltip({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function TooltipTrigger({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/dashboard/src/hooks/useAuth.tsx b/dashboard/src/hooks/useAuth.tsx
index 20c1328..faf60aa 100644
--- a/dashboard/src/hooks/useAuth.tsx
+++ b/dashboard/src/hooks/useAuth.tsx
@@ -1,8 +1,6 @@
-"use client";
-
import type { ReactNode } from "react";
import { createContext, useContext, useState, useEffect, useCallback } from "react";
-import { useRouter, usePathname } from "next/navigation";
+import { useNavigate, useLocation } from "react-router-dom";
export interface AdminUser {
id: string;
@@ -27,13 +25,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState(null);
const [token, setToken] = useState(null);
const [isLoading, setIsLoading] = useState(true);
- const router = useRouter();
- const pathname = usePathname();
+ const navigate = useNavigate();
+ const location = useLocation();
- // If NEXT_PUBLIC_API_URL is configured, use it. Otherwise, use empty string for same-origin calls.
+ // If VITE_API_URL is configured, use it. Otherwise, use empty string for same-origin calls.
const apiUrl =
- process.env.NEXT_PUBLIC_API_URL !== undefined && process.env.NEXT_PUBLIC_API_URL !== ""
- ? process.env.NEXT_PUBLIC_API_URL.replace(/\/$/, "")
+ import.meta.env.VITE_API_URL !== undefined && import.meta.env.VITE_API_URL !== ""
+ ? String(import.meta.env.VITE_API_URL).replace(/\/$/, "")
: "";
const logout = useCallback(async () => {
@@ -56,8 +54,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
setUser(null);
setToken(null);
- router.push("/login/");
- }, [apiUrl, token, router]);
+ void navigate("/login");
+ }, [apiUrl, token, navigate]);
const verifySession = useCallback(
async (existingToken: string) => {
@@ -137,7 +135,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
localStorage.setItem("notifkit_admin_user", JSON.stringify(data.user));
}
- router.push("/");
+ void navigate("/");
return { success: true };
} catch (err: unknown) {
return {
@@ -147,22 +145,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
};
- const isLoginPage =
- pathname === "/login" ||
- pathname === "/login/" ||
- pathname?.endsWith("/login") ||
- pathname?.endsWith("/login/");
+ const isLoginPage = location.pathname === "/login" || location.pathname === "/login/";
// Route protection
useEffect(() => {
if (!isLoading) {
if (!token && !isLoginPage) {
- router.push("/login/");
+ void navigate("/login");
} else if (token && isLoginPage) {
- router.push("/");
+ void navigate("/");
}
}
- }, [isLoading, token, isLoginPage, router]);
+ }, [isLoading, token, isLoginPage, navigate]);
return (
void;
projectApiKey: string | null;
@@ -17,7 +22,7 @@ const ProjectContext = createContext(undefined);
export function ProjectProvider({ children }: { children: ReactNode }) {
const { token, apiUrl, isAuthenticated } = useAuth();
- const [projects, setProjects] = useState([]);
+ const [projects, setProjects] = useState([]);
const [selectedProjectId, setSelectedProjectId] = useState("");
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
@@ -36,12 +41,10 @@ export function ProjectProvider({ children }: { children: ReactNode }) {
});
if (res.ok) {
const data = await res.json();
- const projs = data.projects || [];
+ const projs: ProjectItem[] = data.projects || [];
setProjects(projs);
if (projs.length > 0) {
- setSelectedProjectId((prev) =>
- projs.some((p: any) => p.id === prev) ? prev : projs[0].id,
- );
+ setSelectedProjectId((prev) => (projs.some((p) => p.id === prev) ? prev : projs[0]!.id));
} else {
setSelectedProjectId("");
}
diff --git a/dashboard/src/index.css b/dashboard/src/index.css
new file mode 100644
index 0000000..fa9ee6e
--- /dev/null
+++ b/dashboard/src/index.css
@@ -0,0 +1,99 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 0 0% 3.9%;
+ --card: 0 0% 100%;
+ --card-foreground: 0 0% 3.9%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 0 0% 3.9%;
+ --primary: 0 0% 9%;
+ --primary-foreground: 0 0% 98%;
+ --secondary: 0 0% 96.1%;
+ --secondary-foreground: 0 0% 9%;
+ --muted: 0 0% 96.1%;
+ --muted-foreground: 0 0% 45.1%;
+ --accent: 0 0% 96.1%;
+ --accent-foreground: 0 0% 9%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 0 0% 89.8%;
+ --input: 0 0% 89.8%;
+ --ring: 0 0% 3.9%;
+ --radius: 0.5rem;
+ --chart-1: 12 76% 61%;
+ --chart-2: 173 58% 39%;
+ --chart-3: 197 37% 24%;
+ --chart-4: 43 74% 66%;
+ --chart-5: 27 87% 67%;
+ }
+
+ .dark {
+ --background: 0 0% 3.9%;
+ --foreground: 0 0% 98%;
+ --card: 0 0% 3.9%;
+ --card-foreground: 0 0% 98%;
+ --popover: 0 0% 3.9%;
+ --popover-foreground: 0 0% 98%;
+ --primary: 0 0% 98%;
+ --primary-foreground: 0 0% 9%;
+ --secondary: 0 0% 14.9%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 0 0% 14.9%;
+ --muted-foreground: 0 0% 63.9%;
+ --accent: 0 0% 14.9%;
+ --accent-foreground: 0 0% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 0 0% 14.9%;
+ --input: 0 0% 14.9%;
+ --ring: 0 0% 83.1%;
+ --chart-1: 220 70% 50%;
+ --chart-2: 160 60% 45%;
+ --chart-3: 30 80% 55%;
+ --chart-4: 280 65% 60%;
+ --chart-5: 340 75% 55%;
+ }
+}
+
+@theme inline {
+ --color-background: hsl(var(--background));
+ --color-foreground: hsl(var(--foreground));
+ --color-card: hsl(var(--card));
+ --color-card-foreground: hsl(var(--card-foreground));
+ --color-popover: hsl(var(--popover));
+ --color-popover-foreground: hsl(var(--popover-foreground));
+ --color-primary: hsl(var(--primary));
+ --color-primary-foreground: hsl(var(--primary-foreground));
+ --color-secondary: hsl(var(--secondary));
+ --color-secondary-foreground: hsl(var(--secondary-foreground));
+ --color-muted: hsl(var(--muted));
+ --color-muted-foreground: hsl(var(--muted-foreground));
+ --color-accent: hsl(var(--accent));
+ --color-accent-foreground: hsl(var(--accent-foreground));
+ --color-destructive: hsl(var(--destructive));
+ --color-destructive-foreground: hsl(var(--destructive-foreground));
+ --color-border: hsl(var(--border));
+ --color-input: hsl(var(--input));
+ --color-ring: hsl(var(--ring));
+ --color-chart-1: hsl(var(--chart-1));
+ --color-chart-2: hsl(var(--chart-2));
+ --color-chart-3: hsl(var(--chart-3));
+ --color-chart-4: hsl(var(--chart-4));
+ --color-chart-5: hsl(var(--chart-5));
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx
new file mode 100644
index 0000000..708a6de
--- /dev/null
+++ b/dashboard/src/main.tsx
@@ -0,0 +1,21 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import { AuthProvider } from "@/hooks/useAuth";
+import { ProjectProvider } from "@/hooks/useProjectKey";
+import App from "./App";
+import "./index.css";
+
+const basename = window.location.pathname.startsWith("/admin") ? "/admin" : "/";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+
+
+
+ ,
+);
diff --git a/dashboard/src/app/analytics/page.tsx b/dashboard/src/pages/AnalyticsPage.tsx
similarity index 73%
rename from dashboard/src/app/analytics/page.tsx
rename to dashboard/src/pages/AnalyticsPage.tsx
index 2aac24f..191ffea 100644
--- a/dashboard/src/app/analytics/page.tsx
+++ b/dashboard/src/pages/AnalyticsPage.tsx
@@ -1,5 +1,3 @@
-"use client";
-
import { useEffect, useState, useCallback } from "react";
import DashboardHeader from "@/components/DashboardHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -7,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { BarChart3, RefreshCcw, CheckCircle2, XCircle, Activity, Layers } from "lucide-react";
import { toast } from "sonner";
import { useProject } from "@/hooks/useProjectKey";
+import { useAuth } from "@/hooks/useAuth";
interface MetricsData {
streams: Record;
@@ -22,17 +21,23 @@ export default function AnalyticsPage() {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const { projectApiKey, selectedProjectId } = useProject();
+ const { apiUrl, token } = useAuth();
const fetchMetrics = useCallback(async () => {
+ const authKey = projectApiKey || token;
+ if (!authKey) {
+ setIsLoading(false);
+ return;
+ }
setIsLoading(true);
try {
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
- const res = await fetch(`${apiUrl}/v1/system/metrics`, {
- headers: {
- Authorization: `Bearer ${projectApiKey}`,
- "x-project-id": selectedProjectId,
- },
- });
+ const headers: Record = {
+ Authorization: `Bearer ${authKey}`,
+ };
+ if (selectedProjectId) {
+ headers["x-project-id"] = selectedProjectId;
+ }
+ const res = await fetch(`${apiUrl}/v1/system/metrics`, { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const metricsData = await res.json();
setData(metricsData);
@@ -42,25 +47,26 @@ export default function AnalyticsPage() {
} finally {
setIsLoading(false);
}
- }, [projectApiKey, selectedProjectId]);
+ }, [apiUrl, projectApiKey, selectedProjectId, token]);
useEffect(() => {
- if (projectApiKey && selectedProjectId) {
+ const authKey = projectApiKey || token;
+ if (authKey) {
void fetchMetrics();
const interval = setInterval(() => void fetchMetrics(), 5000);
return () => clearInterval(interval);
}
- }, [projectApiKey, selectedProjectId, fetchMetrics]);
+ }, [projectApiKey, token, fetchMetrics]);
const streamNames = [
- { key: "INBOUND_CRITICAL", label: "Inbound Critical Stream", color: "bg-rose-500" },
- { key: "INBOUND_NORMAL", label: "Inbound Normal Stream", color: "bg-blue-500" },
- { key: "INBOUND_LOW", label: "Inbound Low Stream", color: "bg-slate-500" },
- { key: "OUTBOUND_CRITICAL", label: "Outbound Critical Stream", color: "bg-emerald-500" },
- { key: "OUTBOUND_NORMAL", label: "Outbound Normal Stream", color: "bg-purple-500" },
- { key: "OUTBOUND_LOW", label: "Outbound Low Stream", color: "bg-amber-500" },
- { key: "WORKFLOW_INBOUND", label: "Workflow Inbound Stream", color: "bg-indigo-500" },
- { key: "EVENTS_INBOUND", label: "Events Inbound Stream", color: "bg-sky-500" },
+ { key: "INBOUND_CRITICAL", label: "Inbound Critical Stream" },
+ { key: "INBOUND_NORMAL", label: "Inbound Normal Stream" },
+ { key: "INBOUND_LOW", label: "Inbound Low Stream" },
+ { key: "OUTBOUND_CRITICAL", label: "Outbound Critical Stream" },
+ { key: "OUTBOUND_NORMAL", label: "Outbound Normal Stream" },
+ { key: "OUTBOUND_LOW", label: "Outbound Low Stream" },
+ { key: "WORKFLOW_INBOUND", label: "Workflow Inbound Stream" },
+ { key: "EVENTS_INBOUND", label: "Events Inbound Stream" },
];
return (
@@ -71,7 +77,7 @@ export default function AnalyticsPage() {
-
+
Pipeline Analytics & Queue Depths
@@ -83,7 +89,6 @@ export default function AnalyticsPage() {
size="sm"
onClick={() => void fetchMetrics()}
disabled={isLoading}
- className="border-border/50 hover:bg-muted/50"
>
Refresh
@@ -92,27 +97,27 @@ export default function AnalyticsPage() {
{/* High-level KPI Cards */}
-
+
Delivery Success Rate
-
+
-
+
{data?.deliveryStats?.successRate ?? 100}%
SLA Target > 99.0%
-
+
Total Dispatches
-
+
{data?.deliveryStats?.total ?? 0}
@@ -120,12 +125,12 @@ export default function AnalyticsPage() {
-
+
Delivered Messages
-
+
@@ -135,15 +140,15 @@ export default function AnalyticsPage() {
-
+
Permanent Failures
-
+
-
+
{data?.deliveryStats?.failed ?? 0}
Routed to DLQ
@@ -152,10 +157,10 @@ export default function AnalyticsPage() {
{/* Redis Streams Backlog Gauges */}
-
+
-
+
Stream Queue Depths & Backlog Meters
Real-time item count inside Redis Streams
@@ -170,7 +175,7 @@ export default function AnalyticsPage() {
return (
{st.label}
@@ -178,9 +183,9 @@ export default function AnalyticsPage() {
{depth} items
-
+
diff --git a/dashboard/src/app/dlq/page.tsx b/dashboard/src/pages/DlqPage.tsx
similarity index 73%
rename from dashboard/src/app/dlq/page.tsx
rename to dashboard/src/pages/DlqPage.tsx
index a54752c..0b252e8 100644
--- a/dashboard/src/app/dlq/page.tsx
+++ b/dashboard/src/pages/DlqPage.tsx
@@ -1,5 +1,3 @@
-"use client";
-
import { useEffect, useState, useCallback } from "react";
import DashboardHeader from "@/components/DashboardHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -16,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { AlertTriangle, RefreshCcw, RotateCcw, Trash2, Eye } from "lucide-react";
import { toast } from "sonner";
import { useProject } from "@/hooks/useProjectKey";
+import { useAuth } from "@/hooks/useAuth";
interface DLQMessage {
id: string;
@@ -25,22 +24,28 @@ interface DLQMessage {
timestamp: string;
}
-export default function DeadLetterQueuePage() {
+export default function DlqPage() {
const [messages, setMessages] = useState
([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedMessage, setSelectedMessage] = useState(null);
const { projectApiKey, selectedProjectId } = useProject();
+ const { apiUrl, token } = useAuth();
const fetchDLQ = useCallback(async () => {
+ const authKey = projectApiKey || token;
+ if (!authKey) {
+ setIsLoading(false);
+ return;
+ }
setIsLoading(true);
try {
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
- const res = await fetch(`${apiUrl}/v1/dlq`, {
- headers: {
- Authorization: `Bearer ${projectApiKey}`,
- "x-project-id": selectedProjectId,
- },
- });
+ const headers: Record = {
+ Authorization: `Bearer ${authKey}`,
+ };
+ if (selectedProjectId) {
+ headers["x-project-id"] = selectedProjectId;
+ }
+ const res = await fetch(`${apiUrl}/v1/dlq`, { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setMessages(data.messages || []);
@@ -50,24 +55,29 @@ export default function DeadLetterQueuePage() {
} finally {
setIsLoading(false);
}
- }, [projectApiKey, selectedProjectId]);
+ }, [apiUrl, projectApiKey, selectedProjectId, token]);
useEffect(() => {
- if (projectApiKey && selectedProjectId) {
+ const authKey = projectApiKey || token;
+ if (authKey) {
void fetchDLQ();
}
- }, [projectApiKey, selectedProjectId, fetchDLQ]);
+ }, [projectApiKey, token, fetchDLQ]);
const handleReplay = async (id: string) => {
+ const authKey = projectApiKey || token;
+ if (!authKey) return;
try {
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
+ const headers: Record = {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${authKey}`,
+ };
+ if (selectedProjectId) {
+ headers["x-project-id"] = selectedProjectId;
+ }
const res = await fetch(`${apiUrl}/v1/dlq/replay`, {
method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${projectApiKey}`,
- "x-project-id": selectedProjectId,
- },
+ headers,
body: JSON.stringify({ id }),
});
if (!res.ok) throw new Error("Replay request failed");
@@ -79,14 +89,18 @@ export default function DeadLetterQueuePage() {
};
const handleDelete = async (id: string) => {
+ const authKey = projectApiKey || token;
+ if (!authKey) return;
try {
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
+ const headers: Record = {
+ Authorization: `Bearer ${authKey}`,
+ };
+ if (selectedProjectId) {
+ headers["x-project-id"] = selectedProjectId;
+ }
const res = await fetch(`${apiUrl}/v1/dlq/${id}`, {
method: "DELETE",
- headers: {
- Authorization: `Bearer ${projectApiKey}`,
- "x-project-id": selectedProjectId,
- },
+ headers,
});
if (!res.ok) throw new Error("Delete failed");
toast.success(`Message ${id} purged from DLQ`);
@@ -104,26 +118,20 @@ export default function DeadLetterQueuePage() {
-
+
Dead Letter Queue (DLQ) & Failure Diagnostics
Inspect undeliverable messages, review failure context, and execute stream replays
-
void fetchDLQ()}
- disabled={isLoading}
- className="border-border/50 hover:bg-muted/50"
- >
+ void fetchDLQ()} disabled={isLoading}>
Refresh
-
+
Poison & Failed Stream Messages ({messages.length})
@@ -134,8 +142,8 @@ export default function DeadLetterQueuePage() {
-
-
+
+
Timestamp
Stream Message ID
Event Type
@@ -152,7 +160,7 @@ export default function DeadLetterQueuePage() {
) : (
messages.map((msg) => (
-
+
{new Date(msg.timestamp).toLocaleString()}
@@ -164,10 +172,10 @@ export default function DeadLetterQueuePage() {
{msg.eventType}
-
+
{msg.error}
-
+
void handleReplay(msg.id)}
title="Replay Message"
>
@@ -188,7 +196,7 @@ export default function DeadLetterQueuePage() {
void handleDelete(msg.id)}
title="Purge Message"
>
@@ -206,11 +214,11 @@ export default function DeadLetterQueuePage() {
{/* JSON Payload Inspection Drawer */}
{selectedMessage && (
-
-
+
+
-
-
DLQ Message Payload
+
+
DLQ Message Payload
setSelectedMessage(null)}>
Close
@@ -218,23 +226,25 @@ export default function DeadLetterQueuePage() {
ID: {" "}
- {selectedMessage.id}
+
+ {selectedMessage.id}
+
Error: {" "}
- {selectedMessage.error}
+ {selectedMessage.error}
Raw Data Payload
-
+
{JSON.stringify(selectedMessage.payload, null, 2)}
-
+
(null);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!identifier.trim() || !password) {
+ setErrorMessage("Please enter both your email/user ID and password.");
+ return;
+ }
+
+ setLoading(true);
+ setErrorMessage(null);
+
+ const res = await login(identifier.trim(), password);
+ setLoading(false);
+
+ if (res.success) {
+ toast.success("Welcome back!", { description: "Logged in successfully." });
+ } else {
+ setErrorMessage(res.error || "Invalid email/user ID or password.");
+ toast.error("Authentication failed", { description: res.error });
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
Notifkit Admin
+
+ Sign in to access your notification engine and observability suite.
+
+
+
+
+
+ Admin Login
+
+ Enter your admin credentials below
+
+
+
+ {errorMessage && (
+
+ )}
+
+
+
+
+
+ Protected by salted Scrypt & Redis session auth
+
+
+
+
+
+ );
+}
diff --git a/dashboard/src/app/logs/page.tsx b/dashboard/src/pages/LogsPage.tsx
similarity index 96%
rename from dashboard/src/app/logs/page.tsx
rename to dashboard/src/pages/LogsPage.tsx
index 52953e1..ad91a4b 100644
--- a/dashboard/src/app/logs/page.tsx
+++ b/dashboard/src/pages/LogsPage.tsx
@@ -1,5 +1,3 @@
-"use client";
-
import DashboardHeader from "@/components/DashboardHeader";
import LogsTable from "@/components/LogsTable";
diff --git a/dashboard/src/app/projects/page.tsx b/dashboard/src/pages/ProjectsPage.tsx
similarity index 81%
rename from dashboard/src/app/projects/page.tsx
rename to dashboard/src/pages/ProjectsPage.tsx
index aecdd19..a69f3a2 100644
--- a/dashboard/src/app/projects/page.tsx
+++ b/dashboard/src/pages/ProjectsPage.tsx
@@ -1,9 +1,8 @@
-"use client";
-
import { useEffect, useState } from "react";
import DashboardHeader from "@/components/DashboardHeader";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { FolderKey, Plus, Copy, Trash2, AlertCircle, Eye, EyeOff } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
@@ -166,13 +165,13 @@ export default function ProjectsPage() {
-
+
Projects & API Keys
-
+
Create Project
Add a new tenant to Notifkit
@@ -188,12 +187,11 @@ export default function ProjectsPage() {
Project Name
- setNewProjectName(e.target.value)}
- className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="My Project"
/>
@@ -205,7 +203,7 @@ export default function ProjectsPage() {
-
+
Existing Projects
@@ -217,8 +215,8 @@ export default function ProjectsPage() {
No projects found. Create one to get started.
) : (
-
-
+
+
Name
Rate Limit
Created
@@ -226,9 +224,9 @@ export default function ProjectsPage() {
{projects.map((p) => (
setSelectedProjectId(p.id)}
@@ -236,28 +234,13 @@ export default function ProjectsPage() {
{p.name}
{selectedProjectId === p.id && (
-
+
)}
-
-
- {p.rateLimitRpm ? `${p.rateLimitRpm} RPM Quota` : "600 RPM (Default)"}
-
-
- Sliding Window Active
-
-
-
+
{p.rateLimitRpm ? `${p.rateLimitRpm} RPM` : "600 RPM"}
-
+
{new Date(p.createdAt).toLocaleDateString()}
@@ -266,9 +249,9 @@ export default function ProjectsPage() {
)}
{selectedProjectId && (
-
+
-
API Keys
+
API Keys
{newlyGeneratedKey && (
-
+
-
+
-
+
Please copy your new API key
-
+
For security reasons, this key will not be shown again. Make sure to
copy it now.
-
-
+
+
{showKey
? newlyGeneratedKey
: newlyGeneratedKey.replace(
@@ -306,7 +289,7 @@ export default function ProjectsPage() {
setShowKey(!showKey)}
>
{showKey ? : }
@@ -314,7 +297,7 @@ export default function ProjectsPage() {
{
void navigator.clipboard.writeText(newlyGeneratedKey);
toast.success("Copied to clipboard!");
@@ -342,10 +325,10 @@ export default function ProjectsPage() {
key={k.id}
className="grid grid-cols-4 text-sm py-2 items-center group"
>
-
+
{k.id}
-
{k.role}
+
{k.role}
([]);
const [isLoading, setIsLoading] = useState(true);
const { projectApiKey, selectedProjectId } = useProject();
+ const { apiUrl } = useAuth();
const fetchScheduled = useCallback(async () => {
setIsLoading(true);
try {
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/notifications/scheduled`, {
headers: {
Authorization: `Bearer ${projectApiKey}`,
@@ -47,7 +46,7 @@ export default function ScheduledMessagesPage() {
} finally {
setIsLoading(false);
}
- }, [projectApiKey, selectedProjectId]);
+ }, [apiUrl, projectApiKey, selectedProjectId]);
useEffect(() => {
if (projectApiKey && selectedProjectId) {
@@ -57,7 +56,6 @@ export default function ScheduledMessagesPage() {
const handleCancel = async (taskId: string) => {
try {
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/notifications/${taskId}`, {
method: "DELETE",
headers: {
@@ -81,7 +79,7 @@ export default function ScheduledMessagesPage() {
-
+
Scheduled & Deferred Notification Pipeline
@@ -93,14 +91,13 @@ export default function ScheduledMessagesPage() {
size="sm"
onClick={() => void fetchScheduled()}
disabled={isLoading}
- className="border-border/50 hover:bg-muted/50"
>
Refresh
-
+
Pending Scheduled Messages ({items.length})
@@ -109,8 +106,8 @@ export default function ScheduledMessagesPage() {
-
-
+
+
Created At
Task ID
Template
@@ -136,7 +133,7 @@ export default function ScheduledMessagesPage() {
? JSON.stringify(item.payload?.target)
: String(item.payload?.target || "N/A"));
return (
-
+
{new Date(item.createdAt).toLocaleString()}
@@ -153,10 +150,7 @@ export default function ScheduledMessagesPage() {
{recipientId}
-
+
{sendAt ? new Date(sendAt).toLocaleString() : "Pending Dispatch"}
@@ -164,7 +158,7 @@ export default function ScheduledMessagesPage() {
void handleCancel(item.taskId)}
>
diff --git a/dashboard/src/app/system/page.tsx b/dashboard/src/pages/SystemPage.tsx
similarity index 56%
rename from dashboard/src/app/system/page.tsx
rename to dashboard/src/pages/SystemPage.tsx
index fde2fbe..4b2754a 100644
--- a/dashboard/src/app/system/page.tsx
+++ b/dashboard/src/pages/SystemPage.tsx
@@ -1,5 +1,3 @@
-"use client";
-
import { useEffect, useState, useCallback } from "react";
import DashboardHeader from "@/components/DashboardHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -14,17 +12,22 @@ import {
CheckCircle2,
AlertCircle,
XCircle,
+ Loader2,
} from "lucide-react";
import { toast } from "sonner";
import { useProject } from "@/hooks/useProjectKey";
+import { useAuth } from "@/hooks/useAuth";
interface WorkerHealth {
+ service?: string;
state?: string;
status?: string;
redis?: boolean;
activeTasks?: number;
lastHeartbeat?: number;
+ updatedAt?: string;
error?: string;
+ message?: string;
}
interface SystemHealthData {
@@ -34,21 +37,27 @@ interface SystemHealthData {
workers: Record;
}
-export default function SystemHealthPage() {
+export default function SystemPage() {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const { projectApiKey, selectedProjectId } = useProject();
+ const { apiUrl, token } = useAuth();
const fetchHealth = useCallback(async () => {
- setIsLoading(true);
+ const authKey = projectApiKey || token;
+ if (!authKey) {
+ setIsLoading(false);
+ return;
+ }
+
try {
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
- const res = await fetch(`${apiUrl}/v1/system/health`, {
- headers: {
- Authorization: `Bearer ${projectApiKey}`,
- "x-project-id": selectedProjectId,
- },
- });
+ const headers: Record = {
+ Authorization: `Bearer ${authKey}`,
+ };
+ if (selectedProjectId) {
+ headers["x-project-id"] = selectedProjectId;
+ }
+ const res = await fetch(`${apiUrl}/v1/system/health`, { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const healthData = await res.json();
setData(healthData);
@@ -58,15 +67,16 @@ export default function SystemHealthPage() {
} finally {
setIsLoading(false);
}
- }, [projectApiKey, selectedProjectId]);
+ }, [apiUrl, projectApiKey, selectedProjectId, token]);
useEffect(() => {
- if (projectApiKey && selectedProjectId) {
+ const authKey = projectApiKey || token;
+ if (authKey) {
void fetchHealth();
const interval = setInterval(() => void fetchHealth(), 5000);
return () => clearInterval(interval);
}
- }, [projectApiKey, selectedProjectId, fetchHealth]);
+ }, [projectApiKey, token, fetchHealth]);
const workerNames = ["enricher", "engine", "scheduler", "delivery", "ai", "workflow", "events"];
@@ -78,7 +88,7 @@ export default function SystemHealthPage() {
-
+
System Infrastructure & Worker Health
@@ -88,9 +98,11 @@ export default function SystemHealthPage() {
void fetchHealth()}
+ onClick={() => {
+ setIsLoading(true);
+ void fetchHealth();
+ }}
disabled={isLoading}
- className="border-border/50 hover:bg-muted/50"
>
Refresh
@@ -99,58 +111,78 @@ export default function SystemHealthPage() {
{/* Top KPI Cards: Redis & DB latency */}
-
+
Redis Connection
-
+
-
- {data?.redis?.latencyMs ?? 0} ms
-
- {data?.redis?.ok ? "Connected" : "Disconnected"}
-
-
+ {isLoading && !data ? (
+
+ Checking...
+
+ ) : (
+
+ {data?.redis?.latencyMs ?? 0} ms
+
+ {data?.redis?.ok ? "Connected" : "Disconnected"}
+
+
+ )}
-
+
PostgreSQL Database
-
+
-
- {data?.db?.latencyMs ?? 0} ms
-
- {data?.db?.ok ? "Healthy" : "Unreachable"}
-
-
+ {isLoading && !data ? (
+
+ Checking...
+
+ ) : (
+
+ {data?.db?.latencyMs ?? 0} ms
+
+ {data?.db?.ok ? "Healthy" : "Unreachable"}
+
+
+ )}
-
+
Overall Cluster State
-
+
-
- {data?.status ?? "Unknown"}
-
-
+ {isLoading && !data ? (
+
+ Checking...
+
+ ) : (
+
+
+ {data?.status ?? "Checking"}
+
+
+
+ )}
{/* Worker Microservices Grid */}
-
+
Worker Heartbeats & Concurrency
Monitored active workers in the event pipeline
@@ -159,18 +191,31 @@ export default function SystemHealthPage() {
{workerNames.map((wName) => {
const wInfo = data?.workers?.[wName];
- const isAlive = wInfo && wInfo.status !== "error" && wInfo.status !== "unknown";
+ const isAlive = Boolean(
+ wInfo &&
+ wInfo.status !== "error" &&
+ wInfo.status !== "unknown" &&
+ (wInfo.redis !== false ||
+ wInfo.status === "active" ||
+ wInfo.status === "healthy" ||
+ wInfo.state),
+ );
+
+ const displayState =
+ wInfo?.state ||
+ (wInfo?.status && wInfo.status !== "unknown" ? wInfo.status : null) ||
+ (wInfo?.redis ? "Active" : "Idle / Standby");
return (
{wName} Worker
@@ -179,7 +224,7 @@ export default function SystemHealthPage() {
Status:
- {wInfo?.state || wInfo?.status || "Idle / Standing By"}
+ {displayState}
@@ -201,7 +246,7 @@ export default function SystemHealthPage() {
}
function StatusIcon({ status }: { status?: string }) {
- if (status === "healthy") return
;
- if (status === "degraded") return
;
- return
;
+ if (status === "healthy") return
;
+ if (status === "degraded") return
;
+ return
;
}
diff --git a/dashboard/src/app/templates/page.tsx b/dashboard/src/pages/TemplatesPage.tsx
similarity index 83%
rename from dashboard/src/app/templates/page.tsx
rename to dashboard/src/pages/TemplatesPage.tsx
index 291ee32..a6f9069 100644
--- a/dashboard/src/app/templates/page.tsx
+++ b/dashboard/src/pages/TemplatesPage.tsx
@@ -1,5 +1,3 @@
-"use client";
-
import { useEffect, useState, useCallback } from "react";
import DashboardHeader from "@/components/DashboardHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -16,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { FileCode2, RefreshCcw, Eye, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { useProject } from "@/hooks/useProjectKey";
+import { useAuth } from "@/hooks/useAuth";
interface Template {
id: string;
@@ -32,11 +31,11 @@ export default function TemplatesPage() {
const [isLoading, setIsLoading] = useState(true);
const [selectedTemplate, setSelectedTemplate] = useState
(null);
const { projectApiKey, selectedProjectId } = useProject();
+ const { apiUrl } = useAuth();
const fetchTemplates = useCallback(async () => {
setIsLoading(true);
try {
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/templates`, {
headers: {
Authorization: `Bearer ${projectApiKey}`,
@@ -52,7 +51,7 @@ export default function TemplatesPage() {
} finally {
setIsLoading(false);
}
- }, [projectApiKey, selectedProjectId]);
+ }, [apiUrl, projectApiKey, selectedProjectId]);
useEffect(() => {
if (projectApiKey && selectedProjectId) {
@@ -62,7 +61,6 @@ export default function TemplatesPage() {
const handleDelete = async (id: string) => {
try {
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/templates/${id}`, {
method: "DELETE",
headers: {
@@ -86,7 +84,7 @@ export default function TemplatesPage() {
-
+
Template Directory & Channel Bindings
@@ -98,22 +96,21 @@ export default function TemplatesPage() {
size="sm"
onClick={() => void fetchTemplates()}
disabled={isLoading}
- className="border-border/50 hover:bg-muted/50"
>
Refresh
-
+
Notification Templates ({templates.length})
Managed via PUT /v1/templates API endpoint
-
-
+
+
Template ID
Channel
Topics
@@ -131,7 +128,7 @@ export default function TemplatesPage() {
) : (
templates.map((tpl) => (
-
+
{tpl.id}
@@ -143,11 +140,7 @@ export default function TemplatesPage() {
{(tpl.topics || []).map((t) => (
-
+
{t}
))}
@@ -156,7 +149,7 @@ export default function TemplatesPage() {
{new Date(tpl.updatedAt).toLocaleString()}
-
+
void handleDelete(tpl.id)}
>
@@ -185,10 +178,10 @@ export default function TemplatesPage() {
{/* Template Preview Drawer */}
{selectedTemplate && (
-
-
-
-
Template: {selectedTemplate.id}
+
+
+
+
Template: {selectedTemplate.id}
setSelectedTemplate(null)}>
Close
@@ -202,7 +195,7 @@ export default function TemplatesPage() {
Topics: {" "}
-
+
{(selectedTemplate.topics || []).join(", ")}
@@ -211,7 +204,7 @@ export default function TemplatesPage() {
Template Content
-
+
{JSON.stringify(selectedTemplate.content, null, 2)}
diff --git a/dashboard/src/app/users/page.tsx b/dashboard/src/pages/UsersPage.tsx
similarity index 62%
rename from dashboard/src/app/users/page.tsx
rename to dashboard/src/pages/UsersPage.tsx
index 0b94ac0..751c605 100644
--- a/dashboard/src/app/users/page.tsx
+++ b/dashboard/src/pages/UsersPage.tsx
@@ -1,5 +1,3 @@
-"use client";
-
import { useEffect, useState, useCallback } from "react";
import DashboardHeader from "@/components/DashboardHeader";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
@@ -7,7 +5,16 @@ import { toast } from "sonner";
import { Users, Clock, Mail, History, Eye } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
import { useProject } from "@/hooks/useProjectKey";
+import { useAuth } from "@/hooks/useAuth";
interface User {
id?: string;
@@ -35,32 +42,34 @@ export default function UsersPage() {
const [users, setUsers] = useState
([]);
const [loading, setLoading] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
- const [_loadingDetail, setLoadingDetail] = useState(false);
const { projects, selectedProjectId, projectApiKey, isLoadingProjects } = useProject();
+ const { apiUrl } = useAuth();
- const fetchUsers = useCallback(async (apiKey: string, projectId: string) => {
- setLoading(true);
- try {
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
- const res = await fetch(`${apiUrl}/v1/users`, {
- headers: {
- Authorization: `Bearer ${apiKey}`,
- "x-project-id": projectId,
- },
- });
- if (res.ok) {
- const data = await res.json();
- setUsers(data.users || []);
- } else {
+ const fetchUsers = useCallback(
+ async (apiKey: string, projectId: string) => {
+ setLoading(true);
+ try {
+ const res = await fetch(`${apiUrl}/v1/users`, {
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ "x-project-id": projectId,
+ },
+ });
+ if (res.ok) {
+ const data = await res.json();
+ setUsers(data.users || []);
+ } else {
+ toast.error("Failed to load users");
+ }
+ } catch (err) {
+ console.error(err);
toast.error("Failed to load users");
+ } finally {
+ setLoading(false);
}
- } catch (err) {
- console.error(err);
- toast.error("Failed to load users");
- } finally {
- setLoading(false);
- }
- }, []);
+ },
+ [apiUrl],
+ );
useEffect(() => {
if (projectApiKey && selectedProjectId) {
@@ -73,9 +82,7 @@ export default function UsersPage() {
const handleOpenDetail = async (u: User) => {
const id = u.externalId || u.userId || u.id;
if (!id) return;
- setLoadingDetail(true);
try {
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/users/${encodeURIComponent(id)}/details`, {
headers: {
Authorization: `Bearer ${projectApiKey}`,
@@ -90,8 +97,6 @@ export default function UsersPage() {
}
} catch {
setSelectedUser({ ...u, contacts: [], logs: [] });
- } finally {
- setLoadingDetail(false);
}
};
@@ -102,19 +107,19 @@ export default function UsersPage() {
-
+
User Directory & Contact Preferences
-
+
Registered End-Users
Recipient profiles, channel addresses, and preference overrides
-
+
{isLoadingProjects ? (
@@ -131,62 +136,59 @@ export default function UsersPage() {
No API key found for this project.
) : loading ? (
- Loading users...
+ Loading users...
) : users.length === 0 ? (
- No users found.
+ No users found.
) : (
-
-
-
-
- External User ID
- Primary Contact
- Created At
- Actions
-
-
-
- {users.map((user, index) => {
- const displayId =
- user.externalId || user.userId || user.id || `user-${index}`;
- return (
- void handleOpenDetail(user)}
- >
-
- {displayId}
-
-
- {user.email ? (
- user.email
- ) : (
- Not provided
- )}
-
-
- {new Date(user.createdAt).toLocaleDateString()}
-
-
- {
- e.stopPropagation();
- void handleOpenDetail(user);
- }}
- >
-