refactor(dashboard): migrate to vite spa and standard shadcn ui
- migrate dashboard from Next.js to Vite SPA with React Router - install standard shadcn/ui components using shadcn CLI - use standard shadcn theme with default CSS variables - handle optional project scoping in system metrics and health handlers - update admin-static service to serve Vite dist and proxy dev server
@@ -1,9 +0,0 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -1,36 +1,29 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# Notifkit Admin & Observability Dashboard
|
||||
|
||||
## Getting Started
|
||||
This is the single-page application (SPA) admin console and real-time delivery observability dashboard for **Notifkit**, built with **Vite**, **React 19**, **React Router**, **Tailwind CSS v4**, and **shadcn/ui**.
|
||||
|
||||
First, run the development server:
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Start Vite development server
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
|
||||
# Build production bundle to dist/
|
||||
npm run build
|
||||
|
||||
# Preview built production bundle
|
||||
npm run preview
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
## Features
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
- **Live Activity Feed**: Real-time SSE streaming of delivery statuses and handoffs.
|
||||
- **Historical Audit Logs**: Queryable, paginated notification lifecycle trails and payload inspector.
|
||||
- **System Health**: PostgreSQL and Redis latencies and worker heartbeat monitoring.
|
||||
- **Analytics & Queues**: Real-time Redis Streams queue depth gauges and delivery SLA metrics.
|
||||
- **Dead Letter Queue (DLQ)**: Poison message diagnostics with stream replay and purge capabilities.
|
||||
- **Scheduled Pipeline**: Future dispatch scheduling and quiet hours deferrals.
|
||||
- **Workflows**: Multi-step notification sequence diagrams and instance step execution tracing.
|
||||
- **Templates**: Channel bindings, dynamic variables, and prompt previewing.
|
||||
- **User Directory**: Contact channels and opt-out preferences.
|
||||
- **Projects & API Keys**: Tenant workspace configuration and scoped token provisioning.
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
@@ -19,7 +17,5 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Notifkit Observability & Admin</title>
|
||||
</head>
|
||||
<body class="h-screen bg-background text-foreground antialiased overflow-hidden">
|
||||
<div id="root" class="h-full w-full"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
basePath: "/admin",
|
||||
output: "export",
|
||||
trailingSlash: true,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -2,34 +2,42 @@
|
||||
"name": "dashboard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cn": "^0.3.0",
|
||||
"lucide-react": "^1.28.0",
|
||||
"next": "16.3.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"shadcn": "^4.16.1",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwind-merge": "^3.0.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 269 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
Before Width: | Height: | Size: 385 B |
@@ -0,0 +1,39 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import HomePage from "@/pages/HomePage";
|
||||
import LoginPage from "@/pages/LoginPage";
|
||||
import LogsPage from "@/pages/LogsPage";
|
||||
import SystemPage from "@/pages/SystemPage";
|
||||
import AnalyticsPage from "@/pages/AnalyticsPage";
|
||||
import DlqPage from "@/pages/DlqPage";
|
||||
import ScheduledPage from "@/pages/ScheduledPage";
|
||||
import WorkflowsPage from "@/pages/WorkflowsPage";
|
||||
import TemplatesPage from "@/pages/TemplatesPage";
|
||||
import UsersPage from "@/pages/UsersPage";
|
||||
import ProjectsPage from "@/pages/ProjectsPage";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="h-screen flex bg-background text-foreground overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/system" element={<SystemPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/dlq" element={<DlqPage />} />
|
||||
<Route path="/scheduled" element={<ScheduledPage />} />
|
||||
<Route path="/workflows" element={<WorkflowsPage />} />
|
||||
<Route path="/templates" element={<TemplatesPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="*" element={<HomePage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Toaster theme="dark" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,130 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import { AuthProvider } from "@/hooks/useAuth";
|
||||
import { ProjectProvider } from "@/hooks/useProjectKey";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Notifkit Observability & Admin",
|
||||
description: "Real-time delivery observability dashboard and admin console for Notifkit",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased dark`}
|
||||
>
|
||||
<body className="h-screen flex bg-background text-foreground bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-zinc-900 via-background to-background overflow-hidden">
|
||||
<AuthProvider>
|
||||
<ProjectProvider>
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto">{children}</main>
|
||||
<Toaster theme="dark" />
|
||||
</ProjectProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Bell, Lock, User, Loader2, AlertCircle, ArrowRight, ShieldCheck } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
|
||||
<div className="min-h-screen w-full flex items-center justify-center p-4 relative overflow-hidden bg-background">
|
||||
{/* Background Decorative Gradients */}
|
||||
<div className="absolute -top-40 -left-40 w-96 h-96 bg-primary/20 rounded-full blur-[128px] pointer-events-none" />
|
||||
<div className="absolute -bottom-40 -right-40 w-96 h-96 bg-primary/10 rounded-full blur-[128px] pointer-events-none" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(#27272a_1px,transparent_1px)] [background-size:24px_24px] opacity-25 pointer-events-none" />
|
||||
|
||||
<div className="w-full max-w-md relative z-10">
|
||||
<div className="flex flex-col items-center mb-8 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-tr from-primary/30 to-primary/10 border border-primary/20 shadow-lg shadow-primary/10 mb-4">
|
||||
<Bell className="h-7 w-7 text-primary animate-pulse" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-extrabold tracking-tight text-foreground">
|
||||
Notifkit <span className="text-primary font-normal">Admin</span>
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Sign in to access your notification engine and observability suite.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/60 bg-card/60 backdrop-blur-xl shadow-2xl">
|
||||
<CardHeader className="space-y-1 pb-4">
|
||||
<CardTitle className="text-lg font-semibold tracking-tight">Admin Login</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground">
|
||||
Enter your admin credentials below
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{errorMessage && (
|
||||
<div className="mb-4 flex items-center gap-2 p-3 text-xs rounded-lg bg-destructive/15 text-destructive border border-destructive/30 animate-shake">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void handleSubmit(e);
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-foreground/80 flex items-center gap-1.5">
|
||||
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Email or Username
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
placeholder="admin@example.com"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
className="w-full rounded-lg border border-border/60 bg-muted/30 px-3.5 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-foreground/80 flex items-center gap-1.5">
|
||||
<Lock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
placeholder="••••••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-border/60 bg-muted/30 px-3.5 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full mt-2 h-10 font-medium text-sm flex items-center justify-center gap-2 transition-all shadow-md hover:shadow-primary/25"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Signing in...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Sign In</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-border/40 flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="h-3.5 w-3.5 text-emerald-500" />
|
||||
<span>Protected by salted Scrypt & Redis session auth</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Activity, Bell, FolderKey, AlertTriangle } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -8,19 +6,21 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
export default function DashboardHeader({ isConnected }: { isConnected: boolean }) {
|
||||
const { projects, selectedProjectId, setSelectedProjectId, projectApiKey } = useProject();
|
||||
const { apiUrl } = useAuth();
|
||||
const { apiUrl, token } = useAuth();
|
||||
const [highBackpressure, setHighBackpressure] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectApiKey || !selectedProjectId) return;
|
||||
const authKey = projectApiKey || token;
|
||||
if (!authKey) return;
|
||||
const checkMetrics = async () => {
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/v1/system/metrics`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${projectApiKey}`,
|
||||
"x-project-id": selectedProjectId,
|
||||
},
|
||||
});
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${authKey}`,
|
||||
};
|
||||
if (selectedProjectId) {
|
||||
headers["x-project-id"] = selectedProjectId;
|
||||
}
|
||||
const res = await fetch(`${apiUrl}/v1/system/metrics`, { headers });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const streams = data.streams || {};
|
||||
@@ -35,24 +35,21 @@ export default function DashboardHeader({ isConnected }: { isConnected: boolean
|
||||
void checkMetrics();
|
||||
const interval = setInterval(() => void checkMetrics(), 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [apiUrl, projectApiKey, selectedProjectId]);
|
||||
}, [apiUrl, projectApiKey, selectedProjectId, token]);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<header className="sticky top-0 z-40 w-full border-b border-border bg-background">
|
||||
<div className="container mx-auto flex h-14 items-center justify-between px-4">
|
||||
<div className="flex items-center gap-2 font-bold text-lg tracking-tight">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-muted text-foreground border border-border">
|
||||
<Bell className="h-4 w-4 text-foreground" />
|
||||
</div>
|
||||
Notifkit <span className="text-muted-foreground font-normal">Observability</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{highBackpressure && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 text-xs animate-bounce"
|
||||
>
|
||||
<Badge variant="destructive" className="flex items-center gap-1.5 px-2.5 py-1 text-xs">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
Queue Backpressure Warning (>80% Cap)
|
||||
</Badge>
|
||||
@@ -62,7 +59,7 @@ export default function DashboardHeader({ isConnected }: { isConnected: boolean
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderKey className="h-4 w-4 text-muted-foreground" />
|
||||
<select
|
||||
className="bg-muted/50 border border-border/50 text-sm rounded-lg p-1.5 outline-none cursor-pointer text-foreground max-w-[200px] truncate"
|
||||
className="bg-muted border border-border text-sm rounded-md px-2.5 py-1.5 outline-none cursor-pointer text-foreground max-w-[200px] truncate"
|
||||
value={selectedProjectId}
|
||||
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||
>
|
||||
@@ -75,30 +72,13 @@ export default function DashboardHeader({ isConnected }: { isConnected: boolean
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1.5 px-3 py-1 shadow-sm text-muted-foreground border-dashed border-border"
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
No Project Created
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant={isConnected ? "default" : "destructive"}
|
||||
className="flex items-center gap-1.5 px-3 py-1 shadow-sm transition-all"
|
||||
>
|
||||
{isConnected ? (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
) : (
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{isConnected ? "Live Stream Active" : "Disconnected"}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant={isConnected ? "default" : "destructive"}
|
||||
className="flex items-center gap-1.5 px-3 py-1"
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
{isConnected ? "Live Stream Active" : "Disconnected"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -12,6 +10,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
RefreshCcw,
|
||||
Database,
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useProject } from "@/hooks/useProjectKey";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
@@ -55,12 +55,12 @@ export default function LogsTable() {
|
||||
const [loadingLifecycle, setLoadingLifecycle] = useState(false);
|
||||
|
||||
const { projects, selectedProjectId, projectApiKey, isLoadingProjects } = useProject();
|
||||
const { apiUrl } = useAuth();
|
||||
|
||||
const fetchLogs = useCallback(
|
||||
async (apiKey: string, projectId: string, cursor?: string | null) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
const query = new URLSearchParams({ limit: "25" });
|
||||
if (cursor) query.set("cursor", cursor);
|
||||
if (search) query.set("search", search);
|
||||
@@ -88,7 +88,7 @@ export default function LogsTable() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[search, channelFilter, statusFilter],
|
||||
[apiUrl, search, channelFilter, statusFilter],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -104,7 +104,6 @@ export default function LogsTable() {
|
||||
useEffect(() => {
|
||||
if (selectedLog && projectApiKey && selectedProjectId) {
|
||||
setLoadingLifecycle(true);
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
fetch(`${apiUrl}/v1/notifications/${selectedLog.taskId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${projectApiKey}`,
|
||||
@@ -114,7 +113,6 @@ export default function LogsTable() {
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (data && data.logs) {
|
||||
// Sort ascending by timestamp for chronological lifecycle timeline
|
||||
const sorted = [...data.logs].sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
@@ -128,7 +126,7 @@ export default function LogsTable() {
|
||||
} else {
|
||||
setTaskLifecycle([]);
|
||||
}
|
||||
}, [selectedLog, projectApiKey, selectedProjectId]);
|
||||
}, [apiUrl, selectedLog, projectApiKey, selectedProjectId]);
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (nextCursor && projectApiKey && selectedProjectId) {
|
||||
@@ -148,12 +146,12 @@ export default function LogsTable() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col border-border/50 shadow-sm bg-card/50 backdrop-blur-sm">
|
||||
<CardHeader className="pb-3 border-b border-border/20 space-y-3">
|
||||
<Card className="h-full flex flex-col border-border bg-card">
|
||||
<CardHeader className="pb-3 border-b border-border space-y-3">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-primary" />
|
||||
<Database className="h-5 w-5 text-foreground" />
|
||||
Historical Dispatches & Audit Logs
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -168,7 +166,7 @@ export default function LogsTable() {
|
||||
void fetchLogs(projectApiKey, selectedProjectId, cursorHistory[pageIndex]);
|
||||
}}
|
||||
disabled={isLoading || !projectApiKey || !selectedProjectId}
|
||||
className="h-8 w-8 rounded-full border-border/50 hover:bg-primary/10 hover:text-primary transition-colors"
|
||||
className="h-8 w-8 rounded-md border-border"
|
||||
>
|
||||
<RefreshCcw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
@@ -178,19 +176,19 @@ export default function LogsTable() {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pt-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search Task ID or Template..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-1.5 bg-muted/30 border border-border/40 rounded-lg text-xs font-mono focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
className="pl-9 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={channelFilter}
|
||||
onChange={(e) => setChannelFilter(e.target.value)}
|
||||
className="px-3 py-1.5 bg-muted/30 border border-border/40 rounded-lg text-xs bg-card focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
className="px-3 py-1.5 bg-background border border-input rounded-md text-xs focus:outline-none focus:ring-1 focus:ring-ring text-foreground"
|
||||
>
|
||||
<option value="">All Channels</option>
|
||||
<option value="email">Email</option>
|
||||
@@ -201,7 +199,7 @@ export default function LogsTable() {
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-1.5 bg-muted/30 border border-border/40 rounded-lg text-xs bg-card focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
className="px-3 py-1.5 bg-background border border-input rounded-md text-xs focus:outline-none focus:ring-1 focus:ring-ring text-foreground"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
@@ -214,8 +212,8 @@ export default function LogsTable() {
|
||||
|
||||
<CardContent className="flex-1 p-0 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30 sticky top-0 backdrop-blur-md">
|
||||
<TableRow className="border-border/20">
|
||||
<TableHeader className="bg-muted/50 sticky top-0">
|
||||
<TableRow>
|
||||
<TableHead className="font-medium">Timestamp</TableHead>
|
||||
<TableHead className="font-medium">Task ID</TableHead>
|
||||
<TableHead className="font-medium">Template</TableHead>
|
||||
@@ -261,7 +259,7 @@ export default function LogsTable() {
|
||||
).map((log) => (
|
||||
<TableRow
|
||||
key={log.id}
|
||||
className="border-border/10 hover:bg-muted/20 transition-colors cursor-pointer"
|
||||
className="hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
onClick={() => setSelectedLog(log)}
|
||||
>
|
||||
<TableCell className="text-muted-foreground whitespace-nowrap text-xs">
|
||||
@@ -272,10 +270,7 @@ export default function LogsTable() {
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-xs">{log.templateId}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="capitalize font-normal text-xs bg-secondary/50"
|
||||
>
|
||||
<Badge variant="secondary" className="capitalize font-normal text-xs">
|
||||
{log.channel}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
@@ -295,7 +290,7 @@ export default function LogsTable() {
|
||||
</CardContent>
|
||||
|
||||
{/* Pagination Footer */}
|
||||
<div className="p-3 border-t border-border/20 flex items-center justify-between text-xs text-muted-foreground bg-muted/10">
|
||||
<div className="p-3 border-t border-border flex items-center justify-between text-xs text-muted-foreground bg-muted/20">
|
||||
<span>Page {pageIndex + 1}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
@@ -303,7 +298,7 @@ export default function LogsTable() {
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={pageIndex === 0 || isLoading}
|
||||
className="h-7 text-xs border-border/40"
|
||||
className="h-7 text-xs border-border"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5 mr-1" /> Previous
|
||||
</Button>
|
||||
@@ -312,7 +307,7 @@ export default function LogsTable() {
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={!nextCursor || isLoading}
|
||||
className="h-7 text-xs border-border/40"
|
||||
className="h-7 text-xs border-border"
|
||||
>
|
||||
Next <ChevronRight className="h-3.5 w-3.5 ml-1" />
|
||||
</Button>
|
||||
@@ -321,11 +316,11 @@ export default function LogsTable() {
|
||||
|
||||
{/* JSON Log Detail & Event Lifecycle Timeline Drawer */}
|
||||
{selectedLog && (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex justify-end">
|
||||
<div className="w-full max-w-xl bg-card border-l border-border/40 p-6 overflow-y-auto space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-border/40 pb-3">
|
||||
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-xs flex justify-end">
|
||||
<div className="w-full max-w-xl bg-card border-l border-border p-6 overflow-y-auto space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Event Lifecycle & Detail</h2>
|
||||
<h2 className="text-lg font-bold text-foreground">Event Lifecycle & Detail</h2>
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
Task ID: {selectedLog.taskId}
|
||||
</p>
|
||||
@@ -338,39 +333,38 @@ export default function LogsTable() {
|
||||
{/* Complete Lifecycle Timeline */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
|
||||
<GitCommit className="h-4 w-4 text-primary" />
|
||||
Notification Event Lifecycle (Dispatched → Delivered)
|
||||
<GitCommit className="h-4 w-4 text-foreground" />
|
||||
Notification Event Lifecycle
|
||||
</h3>
|
||||
|
||||
{loadingLifecycle ? (
|
||||
<div className="p-4 rounded-lg bg-muted/20 text-xs text-muted-foreground animate-pulse">
|
||||
<div className="p-4 rounded-lg bg-muted text-xs text-muted-foreground animate-pulse">
|
||||
Loading complete event lifecycle logs...
|
||||
</div>
|
||||
) : taskLifecycle.length === 0 ? (
|
||||
<div className="p-4 rounded-lg bg-muted/20 text-xs text-muted-foreground">
|
||||
<div className="p-4 rounded-lg bg-muted text-xs text-muted-foreground">
|
||||
No lifecycle history recorded.
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative pl-6 space-y-4 before:absolute before:left-2.5 before:top-2 before:bottom-2 before:w-0.5 before:bg-border/60">
|
||||
{taskLifecycle.map((item, _idx) => {
|
||||
<div className="relative pl-6 space-y-4 before:absolute before:left-2.5 before:top-2 before:bottom-2 before:w-px before:bg-border">
|
||||
{taskLifecycle.map((item) => {
|
||||
const firstTime = new Date(taskLifecycle[0]!.timestamp).getTime();
|
||||
const itemTime = new Date(item.timestamp).getTime();
|
||||
const deltaMs = itemTime - firstTime;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="relative group">
|
||||
{/* Timeline Node Icon */}
|
||||
<div className="absolute -left-6 top-0.5 h-5 w-5 rounded-full bg-background border-2 border-primary flex items-center justify-center">
|
||||
<div className="absolute -left-6 top-0.5 h-5 w-5 rounded-full bg-background border border-border flex items-center justify-center">
|
||||
{item.status === "delivered" ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-emerald-400" />
|
||||
<CheckCircle2 className="h-3 w-3 text-foreground" />
|
||||
) : item.status === "failed" ? (
|
||||
<AlertCircle className="h-3 w-3 text-rose-400" />
|
||||
<AlertCircle className="h-3 w-3 text-destructive" />
|
||||
) : (
|
||||
<Send className="h-2.5 w-2.5 text-blue-400" />
|
||||
<Send className="h-2.5 w-2.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-muted/20 border border-border/30 text-xs space-y-1.5">
|
||||
<div className="p-3 rounded-lg bg-muted/40 border border-border text-xs space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={item.status} />
|
||||
@@ -408,11 +402,11 @@ export default function LogsTable() {
|
||||
</div>
|
||||
|
||||
{/* Selected Record Details */}
|
||||
<div className="space-y-2 text-xs border-t border-border/40 pt-4">
|
||||
<div className="space-y-2 text-xs border-t border-border pt-4">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Selected Log Record Metadata
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2 p-3 rounded-lg bg-muted/20 border border-border/30">
|
||||
<div className="grid grid-cols-2 gap-2 p-3 rounded-lg bg-muted/40 border border-border">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Log UUID:</span>
|
||||
<div className="font-mono text-foreground truncate">{selectedLog.id}</div>
|
||||
@@ -440,7 +434,7 @@ export default function LogsTable() {
|
||||
<label className="text-xs font-semibold text-muted-foreground">
|
||||
Full JSON Context
|
||||
</label>
|
||||
<pre className="mt-1 p-4 rounded-lg bg-muted/30 border border-border/30 text-xs font-mono overflow-x-auto text-emerald-400">
|
||||
<pre className="mt-1 p-4 rounded-lg bg-muted border border-border text-xs font-mono overflow-x-auto text-foreground">
|
||||
{JSON.stringify(selectedLog, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -452,30 +446,28 @@ export default function LogsTable() {
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: LogEntry["status"] }) {
|
||||
let colorClass = "bg-gray-500/10 text-gray-400 border-gray-500/20";
|
||||
let variant: "default" | "secondary" | "destructive" | "outline" = "outline";
|
||||
|
||||
switch (status) {
|
||||
case "delivered":
|
||||
colorClass = "bg-green-500/10 text-green-400 border-green-500/20";
|
||||
variant = "default";
|
||||
break;
|
||||
case "failed":
|
||||
colorClass = "bg-red-500/10 text-red-400 border-red-500/20";
|
||||
variant = "destructive";
|
||||
break;
|
||||
case "pending":
|
||||
case "dispatched":
|
||||
colorClass = "bg-blue-500/10 text-blue-400 border-blue-500/20";
|
||||
variant = "secondary";
|
||||
break;
|
||||
case "canceled":
|
||||
case "skipped":
|
||||
colorClass = "bg-orange-500/10 text-orange-400 border-orange-500/20";
|
||||
variant = "outline";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-[10px] uppercase font-bold tracking-wider border ${colorClass}`}
|
||||
>
|
||||
<Badge variant={variant} className="capitalize text-[10px] font-medium">
|
||||
{status}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -7,6 +5,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { CheckCircle2, XCircle, Clock, Settings2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useProject } from "@/hooks/useProjectKey";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
interface DeliveryEvent {
|
||||
id: string;
|
||||
@@ -25,12 +24,12 @@ export default function RealTimeFeed({
|
||||
}) {
|
||||
const [events, setEvents] = useState<DeliveryEvent[]>([]);
|
||||
const { projects, selectedProjectId, projectApiKey, isLoadingProjects } = useProject();
|
||||
const { apiUrl } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectApiKey || !selectedProjectId) return;
|
||||
setIsConnected(false);
|
||||
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
const es = new EventSource(
|
||||
`${apiUrl}/v1/events/stream?token=${encodeURIComponent(projectApiKey)}&projectId=${encodeURIComponent(selectedProjectId)}`,
|
||||
);
|
||||
@@ -86,11 +85,11 @@ export default function RealTimeFeed({
|
||||
es.removeEventListener("delivery:failed", handleFailed);
|
||||
es.close();
|
||||
};
|
||||
}, [projectApiKey, selectedProjectId, setIsConnected]);
|
||||
}, [apiUrl, projectApiKey, selectedProjectId, setIsConnected]);
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col border-border/50 shadow-sm bg-card/50 backdrop-blur-sm">
|
||||
<CardHeader className="pb-3 border-b border-border/20 flex flex-row items-center justify-between">
|
||||
<Card className="h-full flex flex-col border-border bg-card">
|
||||
<CardHeader className="pb-3 border-b border-border flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<ActivityPulse />
|
||||
@@ -100,9 +99,9 @@ export default function RealTimeFeed({
|
||||
Real-time delivery events from the notification pipeline
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm bg-muted/50 p-1.5 rounded-lg border border-border/50 px-3">
|
||||
<div className="flex items-center gap-2 text-sm bg-muted p-1.5 rounded-md border border-border px-3">
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">Instant Mode</span>
|
||||
<span className="text-xs font-medium text-foreground">Instant Stream</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 p-0 overflow-hidden">
|
||||
@@ -132,14 +131,14 @@ export default function RealTimeFeed({
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="animate-in fade-in slide-in-from-top-4 duration-500 rounded-lg border border-border/40 p-4 bg-background/40 hover:bg-background/80 transition-colors shadow-sm"
|
||||
className="rounded-lg border border-border p-4 bg-card hover:bg-muted/50 transition-colors shadow-xs"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{event.type === "delivered" ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
<CheckCircle2 className="h-4 w-4 text-foreground" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
)}
|
||||
<span className="font-semibold text-sm capitalize">{event.type}</span>
|
||||
</div>
|
||||
@@ -150,25 +149,28 @@ export default function RealTimeFeed({
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-sm mt-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
Channel
|
||||
</span>
|
||||
<Badge variant="outline" className="w-fit mt-1">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="w-fit mt-1 text-xs capitalize font-normal"
|
||||
>
|
||||
{event.channel}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
Task ID
|
||||
</span>
|
||||
<span className="font-mono text-xs truncate mt-1 text-foreground/80">
|
||||
<span className="font-mono text-xs truncate mt-1 text-foreground">
|
||||
{event.taskId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event.error && (
|
||||
<div className="mt-3 text-xs bg-red-500/10 text-red-400 p-2 rounded border border-red-500/20">
|
||||
<div className="mt-3 text-xs bg-destructive/10 text-destructive p-2.5 rounded-md border border-destructive/20 font-mono">
|
||||
{event.error}
|
||||
</div>
|
||||
)}
|
||||
@@ -184,9 +186,9 @@ export default function RealTimeFeed({
|
||||
|
||||
function ActivityPulse() {
|
||||
return (
|
||||
<div className="relative flex h-3 w-3">
|
||||
<div className="relative flex h-2.5 w-2.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-primary"></span>
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-primary"></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Activity,
|
||||
History,
|
||||
@@ -34,55 +31,50 @@ const navigation = [
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const location = useLocation();
|
||||
const { user, logout, isAuthenticated } = useAuth();
|
||||
|
||||
const isLoginPage =
|
||||
pathname === "/login" ||
|
||||
pathname === "/login/" ||
|
||||
pathname?.endsWith("/login") ||
|
||||
pathname?.endsWith("/login/");
|
||||
const isLoginPage = location.pathname === "/login" || location.pathname === "/login/";
|
||||
if (isLoginPage || !isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-64 border-r border-border bg-card/30 backdrop-blur-md h-full shrink-0">
|
||||
<div className="p-6 border-b border-border/50 flex items-center gap-3">
|
||||
<div className="bg-primary/20 p-2 rounded-lg">
|
||||
<LayoutDashboard className="w-6 h-6 text-primary" />
|
||||
<div className="flex flex-col w-64 border-r border-border bg-card h-full shrink-0">
|
||||
<div className="p-6 border-b border-border flex items-center gap-3">
|
||||
<div className="bg-primary text-primary-foreground p-2 rounded-lg">
|
||||
<LayoutDashboard className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-bold text-lg tracking-tight bg-gradient-to-br from-foreground to-foreground/70 bg-clip-text text-transparent">
|
||||
Notifkit
|
||||
</h1>
|
||||
<h1 className="font-bold text-lg tracking-tight text-foreground">Notifkit</h1>
|
||||
<span className="text-[10px] uppercase font-semibold tracking-wider text-muted-foreground">
|
||||
Admin Console
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto p-4 space-y-1.5">
|
||||
<nav className="flex-1 overflow-y-auto p-4 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
const isActive =
|
||||
item.href === "/" ? location.pathname === "/" : location.pathname.startsWith(item.href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
to={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group relative overflow-hidden",
|
||||
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors group relative",
|
||||
isActive
|
||||
? "text-primary bg-primary/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50",
|
||||
? "text-primary-foreground bg-primary shadow-xs"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-primary rounded-r-md" />
|
||||
)}
|
||||
<item.icon
|
||||
className={cn(
|
||||
"w-5 h-5 transition-colors",
|
||||
isActive ? "text-primary" : "text-muted-foreground group-hover:text-foreground",
|
||||
"w-4 h-4 transition-colors",
|
||||
isActive
|
||||
? "text-primary-foreground"
|
||||
: "text-muted-foreground group-hover:text-foreground",
|
||||
)}
|
||||
/>
|
||||
{item.name}
|
||||
@@ -92,11 +84,11 @@ export default function Sidebar() {
|
||||
</nav>
|
||||
|
||||
{/* Admin User info and logout */}
|
||||
<div className="p-4 border-t border-border/50 bg-muted/20 space-y-3">
|
||||
<div className="p-4 border-t border-border bg-muted/40 space-y-3">
|
||||
{user && (
|
||||
<div className="flex items-center gap-2.5 px-2 py-1.5">
|
||||
<div className="h-7 w-7 rounded-full bg-primary/20 flex items-center justify-center shrink-0 border border-primary/30">
|
||||
<UserCheck className="h-3.5 w-3.5 text-primary" />
|
||||
<div className="h-7 w-7 rounded-full bg-muted flex items-center justify-center shrink-0 border border-border">
|
||||
<UserCheck className="h-3.5 w-3.5 text-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold text-foreground truncate">
|
||||
@@ -108,7 +100,7 @@ export default function Sidebar() {
|
||||
)}
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
<span>Sign Out</span>
|
||||
|
||||
@@ -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<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
});
|
||||
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -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<typeof buttonVariants>) {
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>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">) {
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -37,10 +31,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className,
|
||||
)}
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -67,19 +58,14 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="card-content" className={cn("px-(--card-spacing)", className)} {...props} />
|
||||
);
|
||||
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||
className,
|
||||
)}
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -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<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "cn";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
@@ -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<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
@@ -28,23 +31,24 @@ function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
@@ -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<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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">) {
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -70,7 +67,10 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn("p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn("group/tabs flex gap-2 data-[orientation=horizontal]:flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
||||
@@ -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<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -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<AdminUser | null>(null);
|
||||
const [token, setToken] = useState<string | null>(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 (
|
||||
<AuthContext.Provider
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from "react";
|
||||
import { useAuth } from "./useAuth";
|
||||
|
||||
export interface ProjectItem {
|
||||
id: string;
|
||||
name: string;
|
||||
rateLimitRpm?: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ProjectContextType {
|
||||
projects: any[];
|
||||
projects: ProjectItem[];
|
||||
selectedProjectId: string;
|
||||
setSelectedProjectId: (id: string) => void;
|
||||
projectApiKey: string | null;
|
||||
@@ -17,7 +22,7 @@ const ProjectContext = createContext<ProjectContextType | undefined>(undefined);
|
||||
|
||||
export function ProjectProvider({ children }: { children: ReactNode }) {
|
||||
const { token, apiUrl, isAuthenticated } = useAuth();
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
const [projects, setProjects] = useState<ProjectItem[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
|
||||
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("");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<BrowserRouter basename={basename}>
|
||||
<AuthProvider>
|
||||
<ProjectProvider>
|
||||
<App />
|
||||
</ProjectProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -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<string, number>;
|
||||
@@ -22,17 +21,23 @@ export default function AnalyticsPage() {
|
||||
const [data, setData] = useState<MetricsData | null>(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<string, string> = {
|
||||
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() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<BarChart3 className="h-6 w-6 text-purple-400" />
|
||||
<BarChart3 className="h-6 w-6 text-foreground" />
|
||||
Pipeline Analytics & Queue Depths
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -83,7 +89,6 @@ export default function AnalyticsPage() {
|
||||
size="sm"
|
||||
onClick={() => void fetchMetrics()}
|
||||
disabled={isLoading}
|
||||
className="border-border/50 hover:bg-muted/50"
|
||||
>
|
||||
<RefreshCcw className={`h-4 w-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
@@ -92,27 +97,27 @@ export default function AnalyticsPage() {
|
||||
|
||||
{/* High-level KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="border-border/50 bg-card/40 backdrop-blur-md">
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Delivery Success Rate
|
||||
</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
|
||||
<CheckCircle2 className="h-4 w-4 text-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-extrabold text-emerald-400">
|
||||
<div className="text-3xl font-bold text-foreground">
|
||||
{data?.deliveryStats?.successRate ?? 100}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">SLA Target > 99.0%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50 bg-card/40 backdrop-blur-md">
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Dispatches
|
||||
</CardTitle>
|
||||
<Activity className="h-4 w-4 text-blue-400" />
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{data?.deliveryStats?.total ?? 0}</div>
|
||||
@@ -120,12 +125,12 @@ export default function AnalyticsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50 bg-card/40 backdrop-blur-md">
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Delivered Messages
|
||||
</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
|
||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-foreground">
|
||||
@@ -135,15 +140,15 @@ export default function AnalyticsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50 bg-card/40 backdrop-blur-md">
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Permanent Failures
|
||||
</CardTitle>
|
||||
<XCircle className="h-4 w-4 text-rose-400" />
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-rose-400">
|
||||
<div className="text-3xl font-bold text-destructive">
|
||||
{data?.deliveryStats?.failed ?? 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Routed to DLQ</p>
|
||||
@@ -152,10 +157,10 @@ export default function AnalyticsPage() {
|
||||
</div>
|
||||
|
||||
{/* Redis Streams Backlog Gauges */}
|
||||
<Card className="border-border/50 bg-card/40 backdrop-blur-md">
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Layers className="h-5 w-5 text-indigo-400" />
|
||||
<Layers className="h-5 w-5 text-foreground" />
|
||||
Stream Queue Depths & Backlog Meters
|
||||
</CardTitle>
|
||||
<CardDescription>Real-time item count inside Redis Streams</CardDescription>
|
||||
@@ -170,7 +175,7 @@ export default function AnalyticsPage() {
|
||||
return (
|
||||
<div
|
||||
key={st.key}
|
||||
className="space-y-2 p-4 rounded-xl bg-muted/20 border border-border/30"
|
||||
className="space-y-2 p-4 rounded-lg bg-muted/40 border border-border"
|
||||
>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="font-semibold text-foreground">{st.label}</span>
|
||||
@@ -178,9 +183,9 @@ export default function AnalyticsPage() {
|
||||
{depth} items
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-3 rounded-full bg-muted overflow-hidden relative">
|
||||
<div className="w-full h-2.5 rounded-full bg-muted overflow-hidden relative">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 ${st.color}`}
|
||||
className="h-full bg-primary transition-all duration-300 rounded-full"
|
||||
style={{ width: `${Math.max(4, pct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -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<DLQMessage[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedMessage, setSelectedMessage] = useState<DLQMessage | null>(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<string, string> = {
|
||||
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<string, string> = {
|
||||
"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<string, string> = {
|
||||
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() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<AlertTriangle className="h-6 w-6 text-rose-400" />
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
Dead Letter Queue (DLQ) & Failure Diagnostics
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Inspect undeliverable messages, review failure context, and execute stream replays
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void fetchDLQ()}
|
||||
disabled={isLoading}
|
||||
className="border-border/50 hover:bg-muted/50"
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => void fetchDLQ()} disabled={isLoading}>
|
||||
<RefreshCcw className={`h-4 w-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/50 bg-card/40 backdrop-blur-md">
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Poison & Failed Stream Messages ({messages.length})
|
||||
@@ -134,8 +142,8 @@ export default function DeadLetterQueuePage() {
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow className="border-border/20">
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>Timestamp</TableHead>
|
||||
<TableHead>Stream Message ID</TableHead>
|
||||
<TableHead>Event Type</TableHead>
|
||||
@@ -152,7 +160,7 @@ export default function DeadLetterQueuePage() {
|
||||
</TableRow>
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<TableRow key={msg.id} className="border-border/10 hover:bg-muted/20">
|
||||
<TableRow key={msg.id} className="hover:bg-muted/50">
|
||||
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{new Date(msg.timestamp).toLocaleString()}
|
||||
</TableCell>
|
||||
@@ -164,10 +172,10 @@ export default function DeadLetterQueuePage() {
|
||||
{msg.eventType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-rose-400 text-xs truncate max-w-[250px] font-mono">
|
||||
<TableCell className="text-destructive text-xs truncate max-w-[250px] font-mono">
|
||||
{msg.error}
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<TableCell className="text-right space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -179,7 +187,7 @@ export default function DeadLetterQueuePage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-emerald-400 hover:bg-emerald-500/10"
|
||||
className="h-8 w-8 text-foreground"
|
||||
onClick={() => void handleReplay(msg.id)}
|
||||
title="Replay Message"
|
||||
>
|
||||
@@ -188,7 +196,7 @@ export default function DeadLetterQueuePage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-rose-400 hover:bg-rose-500/10"
|
||||
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => void handleDelete(msg.id)}
|
||||
title="Purge Message"
|
||||
>
|
||||
@@ -206,11 +214,11 @@ export default function DeadLetterQueuePage() {
|
||||
|
||||
{/* JSON Payload Inspection Drawer */}
|
||||
{selectedMessage && (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex justify-end">
|
||||
<div className="w-full max-w-xl bg-card border-l border-border/40 p-6 overflow-y-auto space-y-4 flex flex-col justify-between">
|
||||
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-xs flex justify-end">
|
||||
<div className="w-full max-w-xl bg-card border-l border-border p-6 overflow-y-auto space-y-4 flex flex-col justify-between">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-border/40 pb-3">
|
||||
<h2 className="text-lg font-bold">DLQ Message Payload</h2>
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<h2 className="text-lg font-bold text-foreground">DLQ Message Payload</h2>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedMessage(null)}>
|
||||
Close
|
||||
</Button>
|
||||
@@ -218,23 +226,25 @@ export default function DeadLetterQueuePage() {
|
||||
<div className="space-y-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">ID:</span>{" "}
|
||||
<span className="font-mono text-foreground">{selectedMessage.id}</span>
|
||||
<span className="font-mono text-foreground font-medium">
|
||||
{selectedMessage.id}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Error:</span>{" "}
|
||||
<span className="font-mono text-rose-400">{selectedMessage.error}</span>
|
||||
<span className="font-mono text-destructive">{selectedMessage.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">
|
||||
Raw Data Payload
|
||||
</label>
|
||||
<pre className="mt-1 p-4 rounded-lg bg-muted/30 border border-border/30 text-xs font-mono overflow-x-auto text-emerald-400">
|
||||
<pre className="mt-1 p-4 rounded-md bg-muted border border-border text-xs font-mono overflow-x-auto text-foreground">
|
||||
{JSON.stringify(selectedMessage.payload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<div className="flex gap-2 justify-end pt-4 border-t border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import DashboardHeader from "@/components/DashboardHeader";
|
||||
import RealTimeFeed from "@/components/RealTimeFeed";
|
||||
|
||||
export default function Home() {
|
||||
export default function HomePage() {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Bell, Lock, User, Loader2, AlertCircle, ArrowRight, ShieldCheck } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
|
||||
<div className="min-h-screen w-full flex items-center justify-center p-4 bg-background">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex flex-col items-center mb-8 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary text-primary-foreground mb-4">
|
||||
<Bell className="h-6 w-6" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">Notifkit Admin</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Sign in to access your notification engine and observability suite.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-border bg-card shadow-sm">
|
||||
<CardHeader className="space-y-1 pb-4">
|
||||
<CardTitle className="text-lg font-semibold">Admin Login</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Enter your admin credentials below
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{errorMessage && (
|
||||
<div className="mb-4 flex items-center gap-2 p-3 text-xs rounded-md bg-destructive/10 text-destructive border border-destructive/20">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void handleSubmit(e);
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-foreground flex items-center gap-1.5">
|
||||
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Email or Username
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
placeholder="admin@example.com"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-foreground flex items-center gap-1.5">
|
||||
<Lock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
required
|
||||
placeholder="••••••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loading} className="w-full mt-2">
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
<span>Signing in...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Sign In</span>
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-border flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="h-3.5 w-3.5 text-foreground" />
|
||||
<span>Protected by salted Scrypt & Redis session auth</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import DashboardHeader from "@/components/DashboardHeader";
|
||||
import LogsTable from "@/components/LogsTable";
|
||||
|
||||
@@ -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() {
|
||||
<main className="flex-1 container mx-auto p-4 md:p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<FolderKey className="w-6 h-6 text-primary" />
|
||||
<FolderKey className="w-6 h-6 text-foreground" />
|
||||
Projects & API Keys
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-1 bg-card/50 backdrop-blur-sm border-border/50 h-fit">
|
||||
<Card className="lg:col-span-1 bg-card border-border h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>Create Project</CardTitle>
|
||||
<CardDescription>Add a new tenant to Notifkit</CardDescription>
|
||||
@@ -188,12 +187,11 @@ export default function ProjectsPage() {
|
||||
<label htmlFor="name" className="text-sm font-medium">
|
||||
Project Name
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={newProjectName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
@@ -205,7 +203,7 @@ export default function ProjectsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-2 bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<Card className="lg:col-span-2 bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle>Existing Projects</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -217,8 +215,8 @@ export default function ProjectsPage() {
|
||||
No projects found. Create one to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 text-xs font-semibold text-muted-foreground uppercase pb-2 border-b border-border/50">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 text-xs font-semibold text-muted-foreground uppercase pb-2 border-b border-border">
|
||||
<div>Name</div>
|
||||
<div>Rate Limit</div>
|
||||
<div>Created</div>
|
||||
@@ -226,9 +224,9 @@ export default function ProjectsPage() {
|
||||
{projects.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`grid grid-cols-3 text-sm py-2 px-3 -mx-3 rounded-md cursor-pointer transition-colors ${
|
||||
className={`grid grid-cols-3 text-sm py-2 px-3 rounded-md cursor-pointer transition-colors ${
|
||||
selectedProjectId === p.id
|
||||
? "bg-primary/10 border border-primary/20"
|
||||
? "bg-muted text-foreground border border-border"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => setSelectedProjectId(p.id)}
|
||||
@@ -236,28 +234,13 @@ export default function ProjectsPage() {
|
||||
<div className="font-medium text-foreground flex items-center gap-2">
|
||||
{p.name}
|
||||
{selectedProjectId === p.id && (
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500 shadow-[0_0_6px_#10b981]" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground font-mono text-xs flex flex-col gap-1 justify-center">
|
||||
<div className="flex justify-between items-center pr-2">
|
||||
<span>
|
||||
{p.rateLimitRpm ? `${p.rateLimitRpm} RPM Quota` : "600 RPM (Default)"}
|
||||
</span>
|
||||
<span className="text-[10px] text-emerald-400 font-bold">
|
||||
Sliding Window Active
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-32 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-400 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(12, ((p.rateLimitRpm || 600) / 1000) * 100))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span>{p.rateLimitRpm ? `${p.rateLimitRpm} RPM` : "600 RPM"}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{new Date(p.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,9 +249,9 @@ export default function ProjectsPage() {
|
||||
)}
|
||||
|
||||
{selectedProjectId && (
|
||||
<div className="mt-8 pt-6 border-t border-border/50 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="mt-8 pt-6 border-t border-border">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-semibold text-lg">API Keys</h3>
|
||||
<h3 className="font-semibold text-base">API Keys</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -281,21 +264,21 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
|
||||
{newlyGeneratedKey && (
|
||||
<div className="mb-6 p-4 rounded-lg bg-primary/10 border border-primary/20 text-primary-foreground space-y-3">
|
||||
<div className="mb-6 p-4 rounded-lg bg-muted border border-border space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="w-5 h-5 mt-0.5 text-primary" />
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 text-foreground" />
|
||||
<div>
|
||||
<p className="font-medium text-sm text-primary">
|
||||
<p className="font-medium text-sm text-foreground">
|
||||
Please copy your new API key
|
||||
</p>
|
||||
<p className="text-xs text-primary/80 mt-1">
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
For security reasons, this key will not be shown again. Make sure to
|
||||
copy it now.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-background/50 rounded p-2 border border-border/50">
|
||||
<code className="flex-1 text-sm font-mono break-all text-foreground">
|
||||
<div className="flex items-center gap-2 bg-background rounded-md p-2 border border-border">
|
||||
<code className="flex-1 text-xs font-mono break-all text-foreground">
|
||||
{showKey
|
||||
? newlyGeneratedKey
|
||||
: newlyGeneratedKey.replace(
|
||||
@@ -306,7 +289,7 @@ export default function ProjectsPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 hover:bg-background/80"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
>
|
||||
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
@@ -314,7 +297,7 @@ export default function ProjectsPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 hover:bg-background/80"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
<div className="col-span-2 font-mono text-muted-foreground truncate pr-4">
|
||||
<div className="col-span-2 font-mono text-muted-foreground truncate pr-4 text-xs">
|
||||
{k.id}
|
||||
</div>
|
||||
<div className="capitalize text-muted-foreground">{k.role}</div>
|
||||
<div className="capitalize text-muted-foreground text-xs">{k.role}</div>
|
||||
<div className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -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 { Clock, RefreshCcw, XCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useProject } from "@/hooks/useProjectKey";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
interface ScheduledItem {
|
||||
taskId: string;
|
||||
@@ -23,15 +22,15 @@ interface ScheduledItem {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function ScheduledMessagesPage() {
|
||||
export default function ScheduledPage() {
|
||||
const [items, setItems] = useState<ScheduledItem[]>([]);
|
||||
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() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Clock className="h-6 w-6 text-amber-400" />
|
||||
<Clock className="h-6 w-6 text-foreground" />
|
||||
Scheduled & Deferred Notification Pipeline
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -93,14 +91,13 @@ export default function ScheduledMessagesPage() {
|
||||
size="sm"
|
||||
onClick={() => void fetchScheduled()}
|
||||
disabled={isLoading}
|
||||
className="border-border/50 hover:bg-muted/50"
|
||||
>
|
||||
<RefreshCcw className={`h-4 w-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/50 bg-card/40 backdrop-blur-md">
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Pending Scheduled Messages ({items.length})</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -109,8 +106,8 @@ export default function ScheduledMessagesPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow className="border-border/20">
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>Created At</TableHead>
|
||||
<TableHead>Task ID</TableHead>
|
||||
<TableHead>Template</TableHead>
|
||||
@@ -136,7 +133,7 @@ export default function ScheduledMessagesPage() {
|
||||
? JSON.stringify(item.payload?.target)
|
||||
: String(item.payload?.target || "N/A"));
|
||||
return (
|
||||
<TableRow key={item.taskId} className="border-border/10 hover:bg-muted/20">
|
||||
<TableRow key={item.taskId} className="hover:bg-muted/50">
|
||||
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
@@ -153,10 +150,7 @@ export default function ScheduledMessagesPage() {
|
||||
{recipientId}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-mono text-[11px] border-amber-500/30 text-amber-400"
|
||||
>
|
||||
<Badge variant="outline" className="font-mono text-[11px]">
|
||||
{sendAt ? new Date(sendAt).toLocaleString() : "Pending Dispatch"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
@@ -164,7 +158,7 @@ export default function ScheduledMessagesPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-rose-400 hover:bg-rose-500/10"
|
||||
className="h-8 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => void handleCancel(item.taskId)}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
@@ -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<string, WorkerHealth>;
|
||||
}
|
||||
|
||||
export default function SystemHealthPage() {
|
||||
export default function SystemPage() {
|
||||
const [data, setData] = useState<SystemHealthData | null>(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<string, string> = {
|
||||
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() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<HeartPulse className="h-6 w-6 text-emerald-400" />
|
||||
<HeartPulse className="h-6 w-6 text-foreground" />
|
||||
System Infrastructure & Worker Health
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -88,9 +98,11 @@ export default function SystemHealthPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void fetchHealth()}
|
||||
onClick={() => {
|
||||
setIsLoading(true);
|
||||
void fetchHealth();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className="border-border/50 hover:bg-muted/50"
|
||||
>
|
||||
<RefreshCcw className={`h-4 w-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
@@ -99,58 +111,78 @@ export default function SystemHealthPage() {
|
||||
|
||||
{/* Top KPI Cards: Redis & DB latency */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="border-border/50 bg-card/40 backdrop-blur-md">
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Redis Connection
|
||||
</CardTitle>
|
||||
<Server className="h-4 w-4 text-rose-400" />
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xl font-bold">{data?.redis?.latencyMs ?? 0} ms</span>
|
||||
<Badge variant={data?.redis?.ok ? "default" : "destructive"}>
|
||||
{data?.redis?.ok ? "Connected" : "Disconnected"}
|
||||
</Badge>
|
||||
</div>
|
||||
{isLoading && !data ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Checking...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xl font-bold">{data?.redis?.latencyMs ?? 0} ms</span>
|
||||
<Badge variant={data?.redis?.ok ? "default" : "destructive"}>
|
||||
{data?.redis?.ok ? "Connected" : "Disconnected"}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50 bg-card/40 backdrop-blur-md">
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
PostgreSQL Database
|
||||
</CardTitle>
|
||||
<Database className="h-4 w-4 text-blue-400" />
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xl font-bold">{data?.db?.latencyMs ?? 0} ms</span>
|
||||
<Badge variant={data?.db?.ok ? "default" : "destructive"}>
|
||||
{data?.db?.ok ? "Healthy" : "Unreachable"}
|
||||
</Badge>
|
||||
</div>
|
||||
{isLoading && !data ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Checking...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xl font-bold">{data?.db?.latencyMs ?? 0} ms</span>
|
||||
<Badge variant={data?.db?.ok ? "default" : "destructive"}>
|
||||
{data?.db?.ok ? "Healthy" : "Unreachable"}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50 bg-card/40 backdrop-blur-md">
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Overall Cluster State
|
||||
</CardTitle>
|
||||
<Cpu className="h-4 w-4 text-purple-400" />
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xl font-bold capitalize">{data?.status ?? "Unknown"}</span>
|
||||
<StatusIcon status={data?.status} />
|
||||
</div>
|
||||
{isLoading && !data ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Checking...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xl font-bold capitalize">
|
||||
{data?.status ?? "Checking"}
|
||||
</span>
|
||||
<StatusIcon status={data?.status} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Worker Microservices Grid */}
|
||||
<Card className="border-border/50 bg-card/40 backdrop-blur-md">
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Worker Heartbeats & Concurrency</CardTitle>
|
||||
<CardDescription>Monitored active workers in the event pipeline</CardDescription>
|
||||
@@ -159,18 +191,31 @@ export default function SystemHealthPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{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 (
|
||||
<div
|
||||
key={wName}
|
||||
className="p-4 rounded-xl border border-border/30 bg-muted/20 flex flex-col justify-between space-y-3 hover:border-border/60 transition-colors"
|
||||
className="p-4 rounded-lg border border-border bg-muted/40 flex flex-col justify-between space-y-3 hover:border-border transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-sm capitalize">{wName} Worker</span>
|
||||
<span
|
||||
className={`h-2.5 w-2.5 rounded-full ${
|
||||
isAlive ? "bg-emerald-500 shadow-[0_0_8px_#10b981]" : "bg-rose-500"
|
||||
isAlive ? "bg-foreground" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
@@ -179,7 +224,7 @@ export default function SystemHealthPage() {
|
||||
<div className="flex justify-between">
|
||||
<span>Status:</span>
|
||||
<span className="font-medium text-foreground capitalize">
|
||||
{wInfo?.state || wInfo?.status || "Idle / Standing By"}
|
||||
{displayState}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
@@ -201,7 +246,7 @@ export default function SystemHealthPage() {
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status?: string }) {
|
||||
if (status === "healthy") return <CheckCircle2 className="h-6 w-6 text-emerald-400" />;
|
||||
if (status === "degraded") return <AlertCircle className="h-6 w-6 text-amber-400" />;
|
||||
return <XCircle className="h-6 w-6 text-rose-400" />;
|
||||
if (status === "healthy") return <CheckCircle2 className="h-5 w-5 text-foreground" />;
|
||||
if (status === "degraded") return <AlertCircle className="h-5 w-5 text-muted-foreground" />;
|
||||
return <XCircle className="h-5 w-5 text-destructive" />;
|
||||
}
|
||||
@@ -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<Template | null>(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() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<FileCode2 className="h-6 w-6 text-sky-400" />
|
||||
<FileCode2 className="h-6 w-6 text-foreground" />
|
||||
Template Directory & Channel Bindings
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -98,22 +96,21 @@ export default function TemplatesPage() {
|
||||
size="sm"
|
||||
onClick={() => void fetchTemplates()}
|
||||
disabled={isLoading}
|
||||
className="border-border/50 hover:bg-muted/50"
|
||||
>
|
||||
<RefreshCcw className={`h-4 w-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/50 bg-card/40 backdrop-blur-md">
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Notification Templates ({templates.length})</CardTitle>
|
||||
<CardDescription>Managed via PUT /v1/templates API endpoint</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow className="border-border/20">
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>Template ID</TableHead>
|
||||
<TableHead>Channel</TableHead>
|
||||
<TableHead>Topics</TableHead>
|
||||
@@ -131,7 +128,7 @@ export default function TemplatesPage() {
|
||||
</TableRow>
|
||||
) : (
|
||||
templates.map((tpl) => (
|
||||
<TableRow key={tpl.id} className="border-border/10 hover:bg-muted/20">
|
||||
<TableRow key={tpl.id} className="hover:bg-muted/50">
|
||||
<TableCell className="font-mono text-sm font-bold text-foreground">
|
||||
{tpl.id}
|
||||
</TableCell>
|
||||
@@ -143,11 +140,7 @@ export default function TemplatesPage() {
|
||||
<TableCell>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(tpl.topics || []).map((t) => (
|
||||
<Badge
|
||||
key={t}
|
||||
variant="outline"
|
||||
className="text-[10px] border-sky-500/30 text-sky-400"
|
||||
>
|
||||
<Badge key={t} variant="outline" className="text-[10px]">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
@@ -156,7 +149,7 @@ export default function TemplatesPage() {
|
||||
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{new Date(tpl.updatedAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<TableCell className="text-right space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -168,7 +161,7 @@ export default function TemplatesPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-rose-400 hover:bg-rose-500/10"
|
||||
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => void handleDelete(tpl.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -185,10 +178,10 @@ export default function TemplatesPage() {
|
||||
|
||||
{/* Template Preview Drawer */}
|
||||
{selectedTemplate && (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex justify-end">
|
||||
<div className="w-full max-w-xl bg-card border-l border-border/40 p-6 overflow-y-auto space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-border/40 pb-3">
|
||||
<h2 className="text-lg font-bold">Template: {selectedTemplate.id}</h2>
|
||||
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-xs flex justify-end">
|
||||
<div className="w-full max-w-xl bg-card border-l border-border p-6 overflow-y-auto space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<h2 className="text-lg font-bold text-foreground">Template: {selectedTemplate.id}</h2>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedTemplate(null)}>
|
||||
Close
|
||||
</Button>
|
||||
@@ -202,7 +195,7 @@ export default function TemplatesPage() {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Topics:</span>{" "}
|
||||
<span className="font-mono text-sky-400">
|
||||
<span className="font-mono text-foreground">
|
||||
{(selectedTemplate.topics || []).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
@@ -211,7 +204,7 @@ export default function TemplatesPage() {
|
||||
<label className="text-xs font-semibold text-muted-foreground">
|
||||
Template Content
|
||||
</label>
|
||||
<pre className="mt-1 p-4 rounded-lg bg-muted/30 border border-border/30 text-xs font-mono overflow-x-auto text-sky-300">
|
||||
<pre className="mt-1 p-4 rounded-md bg-muted border border-border text-xs font-mono overflow-x-auto text-foreground">
|
||||
{JSON.stringify(selectedTemplate.content, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -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<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserDetail | null>(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() {
|
||||
<main className="flex-1 container mx-auto p-4 md:p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Users className="w-6 h-6 text-primary" />
|
||||
<Users className="w-6 h-6 text-foreground" />
|
||||
User Directory & Contact Preferences
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle>Registered End-Users</CardTitle>
|
||||
<CardDescription>
|
||||
Recipient profiles, channel addresses, and preference overrides
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-0 overflow-auto">
|
||||
{isLoadingProjects ? (
|
||||
<div className="flex flex-col items-center justify-center h-40 text-muted-foreground gap-3">
|
||||
<Clock className="h-8 w-8 opacity-50" />
|
||||
@@ -131,62 +136,59 @@ export default function UsersPage() {
|
||||
<p className="text-sm">No API key found for this project.</p>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="text-muted-foreground text-sm py-4">Loading users...</div>
|
||||
<div className="text-muted-foreground text-sm p-6 text-center">Loading users...</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm text-center py-8">No users found.</div>
|
||||
<div className="text-muted-foreground text-sm text-center p-8">No users found.</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-border/50 overflow-hidden">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="text-xs text-muted-foreground uppercase bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-medium">External User ID</th>
|
||||
<th className="px-6 py-3 font-medium">Primary Contact</th>
|
||||
<th className="px-6 py-3 font-medium">Created At</th>
|
||||
<th className="px-6 py-3 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{users.map((user, index) => {
|
||||
const displayId =
|
||||
user.externalId || user.userId || user.id || `user-${index}`;
|
||||
return (
|
||||
<tr
|
||||
key={displayId}
|
||||
className="hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
onClick={() => void handleOpenDetail(user)}
|
||||
>
|
||||
<td className="px-6 py-4 font-mono font-bold text-foreground">
|
||||
{displayId}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-muted-foreground">
|
||||
{user.email ? (
|
||||
user.email
|
||||
) : (
|
||||
<span className="italic opacity-50">Not provided</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-muted-foreground text-xs">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 border-primary/30 text-primary hover:bg-primary/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleOpenDetail(user);
|
||||
}}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 mr-1" /> View Profile
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>External User ID</TableHead>
|
||||
<TableHead>Primary Contact</TableHead>
|
||||
<TableHead>Created At</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user, index) => {
|
||||
const displayId = user.externalId || user.userId || user.id || `user-${index}`;
|
||||
return (
|
||||
<TableRow
|
||||
key={displayId}
|
||||
className="hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
onClick={() => void handleOpenDetail(user)}
|
||||
>
|
||||
<TableCell className="font-mono font-bold text-foreground">
|
||||
{displayId}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{user.email ? (
|
||||
user.email
|
||||
) : (
|
||||
<span className="italic opacity-50">Not provided</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleOpenDetail(user);
|
||||
}}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 mr-1" /> View Profile
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -194,9 +196,9 @@ export default function UsersPage() {
|
||||
|
||||
{/* User Profile Deep-Dive Modal */}
|
||||
{selectedUser && (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex justify-end">
|
||||
<div className="w-full max-w-xl bg-card border-l border-border/40 p-6 overflow-y-auto space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-border/40 pb-3">
|
||||
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-xs flex justify-end">
|
||||
<div className="w-full max-w-xl bg-card border-l border-border p-6 overflow-y-auto space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold font-mono text-foreground">
|
||||
User: {selectedUser.externalId || selectedUser.userId || selectedUser.id}
|
||||
@@ -212,8 +214,8 @@ export default function UsersPage() {
|
||||
|
||||
{/* Registered Channels */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Mail className="h-4 w-4 text-blue-400" />
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2 text-foreground">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
Contact Channels ({selectedUser.contacts?.length ?? 0})
|
||||
</h3>
|
||||
{!selectedUser.contacts || selectedUser.contacts.length === 0 ? (
|
||||
@@ -225,7 +227,7 @@ export default function UsersPage() {
|
||||
{selectedUser.contacts.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="p-3 rounded-lg bg-muted/20 border border-border/30 flex items-center justify-between text-xs"
|
||||
className="p-3 rounded-md bg-muted/40 border border-border flex items-center justify-between text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="capitalize font-mono text-[10px]">
|
||||
@@ -244,8 +246,8 @@ export default function UsersPage() {
|
||||
|
||||
{/* Recent Notifications for User */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<History className="h-4 w-4 text-emerald-400" />
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2 text-foreground">
|
||||
<History className="h-4 w-4 text-muted-foreground" />
|
||||
Per-User Delivery Audit Trail
|
||||
</h3>
|
||||
{!selectedUser.logs || selectedUser.logs.length === 0 ? (
|
||||
@@ -257,7 +259,7 @@ export default function UsersPage() {
|
||||
{selectedUser.logs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="p-2.5 rounded-lg bg-muted/20 border border-border/30 flex items-center justify-between text-xs"
|
||||
className="p-2.5 rounded-md bg-muted/40 border border-border flex items-center justify-between text-xs"
|
||||
>
|
||||
<div>
|
||||
<span className="font-mono text-foreground font-semibold">
|
||||
@@ -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";
|
||||
@@ -13,9 +11,11 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { GitFork, RefreshCcw, Play, Search, Eye, Layers } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useProject } from "@/hooks/useProjectKey";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
interface WorkflowDef {
|
||||
id: string;
|
||||
@@ -46,11 +46,11 @@ export default function WorkflowsPage() {
|
||||
const [searchingInstance, setSearchingInstance] = useState(false);
|
||||
|
||||
const { projectApiKey, selectedProjectId } = useProject();
|
||||
const { apiUrl } = useAuth();
|
||||
|
||||
const fetchWorkflows = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
const res = await fetch(`${apiUrl}/v1/workflows`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${projectApiKey}`,
|
||||
@@ -66,7 +66,7 @@ export default function WorkflowsPage() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectApiKey, selectedProjectId]);
|
||||
}, [apiUrl, projectApiKey, selectedProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectApiKey && selectedProjectId) {
|
||||
@@ -76,7 +76,6 @@ export default function WorkflowsPage() {
|
||||
|
||||
const handleTrigger = async (name: string) => {
|
||||
try {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
const res = await fetch(`${apiUrl}/v1/workflows/trigger`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -103,7 +102,6 @@ export default function WorkflowsPage() {
|
||||
if (!targetId) return;
|
||||
setSearchingInstance(true);
|
||||
try {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
const res = await fetch(`${apiUrl}/v1/workflows/instances/${encodeURIComponent(targetId)}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${projectApiKey}`,
|
||||
@@ -129,7 +127,7 @@ export default function WorkflowsPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<GitFork className="h-6 w-6 text-indigo-400" />
|
||||
<GitFork className="h-6 w-6 text-foreground" />
|
||||
Workflows & Event Orchestration
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -142,7 +140,6 @@ export default function WorkflowsPage() {
|
||||
size="sm"
|
||||
onClick={() => void fetchWorkflows()}
|
||||
disabled={isLoading}
|
||||
className="border-border/50 hover:bg-muted/50"
|
||||
>
|
||||
<RefreshCcw className={`h-4 w-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
@@ -150,10 +147,10 @@ export default function WorkflowsPage() {
|
||||
</div>
|
||||
|
||||
{/* Workflow Instance Tracing Search */}
|
||||
<Card className="border-border/50 bg-card/40 backdrop-blur-md">
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2 text-indigo-400">
|
||||
<Search className="h-4 w-4" />
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
Workflow Instance Lookup & Step Execution Visualizer
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
@@ -162,12 +159,12 @@ export default function WorkflowsPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2 max-w-lg">
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter Workflow Instance ID..."
|
||||
value={instanceSearchId}
|
||||
onChange={(e) => setInstanceSearchId(e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 bg-muted/30 border border-border/40 rounded-lg text-xs font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -181,7 +178,7 @@ export default function WorkflowsPage() {
|
||||
</div>
|
||||
|
||||
{searchedInstance && (
|
||||
<div className="p-4 rounded-xl bg-muted/20 border border-border/30 space-y-3">
|
||||
<div className="p-4 rounded-lg bg-muted/40 border border-border space-y-3">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Instance ID:</span>{" "}
|
||||
@@ -209,16 +206,16 @@ export default function WorkflowsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Registered Workflows Grid / Table */}
|
||||
<Card className="border-border/50 bg-card/40 backdrop-blur-md">
|
||||
{/* Registered Workflows Table */}
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Registered Workflows ({workflows.length})</CardTitle>
|
||||
<CardDescription>Definitions registered for background execution</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow className="border-border/20">
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>Created At</TableHead>
|
||||
<TableHead>Workflow Name</TableHead>
|
||||
<TableHead>Steps Count</TableHead>
|
||||
@@ -235,7 +232,7 @@ export default function WorkflowsPage() {
|
||||
</TableRow>
|
||||
) : (
|
||||
workflows.map((wf) => (
|
||||
<TableRow key={wf.id} className="border-border/10 hover:bg-muted/20">
|
||||
<TableRow key={wf.id} className="hover:bg-muted/50">
|
||||
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{new Date(wf.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
@@ -263,7 +260,7 @@ export default function WorkflowsPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 border-indigo-500/30 text-indigo-400 hover:bg-indigo-500/10"
|
||||
className="h-8"
|
||||
onClick={() => void handleTrigger(wf.name)}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 mr-1" />
|
||||
@@ -281,11 +278,13 @@ export default function WorkflowsPage() {
|
||||
|
||||
{/* Visual Step Execution Graph Modal */}
|
||||
{selectedWorkflow && (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex justify-end">
|
||||
<div className="w-full max-w-xl bg-card border-l border-border/40 p-6 overflow-y-auto space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-border/40 pb-3">
|
||||
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-xs flex justify-end">
|
||||
<div className="w-full max-w-xl bg-card border-l border-border p-6 overflow-y-auto space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Workflow Diagram: {selectedWorkflow.name}</h2>
|
||||
<h2 className="text-lg font-bold text-foreground">
|
||||
Workflow Diagram: {selectedWorkflow.name}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground font-mono">ID: {selectedWorkflow.id}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedWorkflow(null)}>
|
||||
@@ -295,18 +294,18 @@ export default function WorkflowsPage() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Layers className="h-4 w-4 text-indigo-400" />
|
||||
<Layers className="h-4 w-4 text-foreground" />
|
||||
Execution Step Graph Flow
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3 relative pl-4 before:absolute before:left-2 before:top-2 before:bottom-2 before:w-0.5 before:bg-indigo-500/30">
|
||||
<div className="space-y-3 relative pl-4 before:absolute before:left-2 before:top-2 before:bottom-2 before:w-px before:bg-border">
|
||||
{(selectedWorkflow.steps || []).map((step: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-3 rounded-lg bg-muted/20 border border-border/30 text-xs space-y-1"
|
||||
className="p-3 rounded-lg bg-muted/40 border border-border text-xs space-y-1"
|
||||
>
|
||||
<div className="flex items-center justify-between font-bold">
|
||||
<span className="text-indigo-400">
|
||||
<div className="flex items-center justify-between font-semibold">
|
||||
<span className="text-foreground">
|
||||
Step {idx + 1}: {step.type || step.name || "Action"}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px] font-mono">
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,34 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"include": ["src", "vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "node:path";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
base: "./",
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
@@ -25,6 +25,7 @@ const MIME_TYPES: Record<string, string> = {
|
||||
|
||||
// Possible output directories where built dashboard static files might reside
|
||||
const POSSIBLE_DASHBOARD_DIRS = [
|
||||
resolve(process.cwd(), "dashboard", "dist"),
|
||||
resolve(process.cwd(), "dashboard", "out"),
|
||||
resolve(process.cwd(), "dist", "admin"),
|
||||
resolve(process.cwd(), "dist", "dashboard"),
|
||||
@@ -40,7 +41,7 @@ function getDashboardDir(): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxies request to Next.js dev server if running.
|
||||
* Proxies request to frontend dev server if running.
|
||||
*/
|
||||
function proxyToDevServer(
|
||||
req: IncomingMessage,
|
||||
@@ -78,8 +79,8 @@ function proxyToDevServer(
|
||||
|
||||
/**
|
||||
* Handles incoming HTTP requests for `/admin` and `/admin/*`.
|
||||
* Serves static exported assets from Next.js with SPA fallback,
|
||||
* or proxies to Next dev server if running in development mode.
|
||||
* Serves static exported assets from Vite SPA build with index.html fallback,
|
||||
* or proxies to Vite dev server if running in development mode.
|
||||
*/
|
||||
export async function handleAdminRequest(
|
||||
req: IncomingMessage,
|
||||
@@ -97,10 +98,11 @@ export async function handleAdminRequest(
|
||||
return true;
|
||||
}
|
||||
|
||||
// In development, attempt to proxy to local Next.js dev server on port 3001 if active
|
||||
// In development, attempt to proxy to local Vite dev server on port 5173 (or VITE_DEV_URL) if active
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const devProxyUrl = process.env.NEXT_DEV_URL;
|
||||
const targetPort = devProxyUrl ? parseInt(new URL(devProxyUrl).port, 10) : 3001;
|
||||
const devProxyUrl =
|
||||
process.env.VITE_DEV_URL || process.env.ADMIN_DEV_URL || process.env.NEXT_DEV_URL;
|
||||
const targetPort = devProxyUrl ? parseInt(new URL(devProxyUrl).port, 10) : 5173;
|
||||
const targetHost = devProxyUrl ? new URL(devProxyUrl).hostname : "127.0.0.1";
|
||||
|
||||
const proxied = await proxyToDevServer(req, res, targetHost, targetPort);
|
||||
|
||||
@@ -1115,19 +1115,35 @@ export function createHandlers(deps: Deps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Scoped to the caller's project: an unscoped count reports every tenant's
|
||||
// traffic to whoever asks.
|
||||
const totalTasksRes = await deps.db
|
||||
.select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
|
||||
.from(messageLogs)
|
||||
.where(eq(messageLogs.projectId, ctx.projectId!));
|
||||
const deliveredTasksRes = await deps.db
|
||||
.select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
|
||||
.from(messageLogs)
|
||||
.where(and(eq(messageLogs.projectId, ctx.projectId!), eq(messageLogs.status, "delivered")));
|
||||
// Scoped to the caller's project if provided, otherwise aggregate across all projects
|
||||
let total = 0;
|
||||
let delivered = 0;
|
||||
|
||||
if (ctx.projectId) {
|
||||
const totalTasksRes = await deps.db
|
||||
.select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
|
||||
.from(messageLogs)
|
||||
.where(eq(messageLogs.projectId, ctx.projectId));
|
||||
const deliveredTasksRes = await deps.db
|
||||
.select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
|
||||
.from(messageLogs)
|
||||
.where(and(eq(messageLogs.projectId, ctx.projectId), eq(messageLogs.status, "delivered")));
|
||||
|
||||
total = Number(totalTasksRes[0]?.count ?? 0);
|
||||
delivered = Number(deliveredTasksRes[0]?.count ?? 0);
|
||||
} else {
|
||||
const totalTasksRes = await deps.db
|
||||
.select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
|
||||
.from(messageLogs);
|
||||
const deliveredTasksRes = await deps.db
|
||||
.select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
|
||||
.from(messageLogs)
|
||||
.where(eq(messageLogs.status, "delivered"));
|
||||
|
||||
total = Number(totalTasksRes[0]?.count ?? 0);
|
||||
delivered = Number(deliveredTasksRes[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
const total = Number(totalTasksRes[0]?.count ?? 0);
|
||||
const delivered = Number(deliveredTasksRes[0]?.count ?? 0);
|
||||
const failed = streamDepths.DEAD_LETTER || 0;
|
||||
const successRate = total > 0 ? Number(((delivered / total) * 100).toFixed(2)) : 100;
|
||||
|
||||
|
||||