Compare commits
17 Commits
638a5d2640
...
v0.2.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4185364d5 | ||
|
|
860159ccd0 | ||
|
|
b21d8fe52c | ||
|
|
c6fa25ed47 | ||
|
|
0e97c23579 | ||
|
|
865a1b0081 | ||
|
|
b3e5119568 | ||
|
|
2ac5fdca30 | ||
|
|
64c827ee84 | ||
|
|
f9e7956e6f | ||
|
|
cccb3123ed | ||
|
|
e9cd11735c | ||
|
|
539d35b649 | ||
|
|
0e4ffce4fa | ||
|
|
1f2484a0bb | ||
|
|
61268f870f | ||
|
|
07cf717c15 |
19
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
# Authentication
|
||||
AUTH_SECRET= # Generate with: openssl rand -base64 32
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY= # sk_live_... or sk_test_...
|
||||
STRIPE_WEBHOOK_SECRET= # whsec_...
|
||||
STRIPE_STARTER_PRICE_ID=price_1SzJUlR8i0An4Wz7gZeYgzBY
|
||||
STRIPE_PRO_PRICE_ID=price_1SzJVWR8i0An4Wz755hBrxzn
|
||||
|
||||
# Database (optional — defaults to agentlens/agentlens/agentlens)
|
||||
POSTGRES_USER=agentlens
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_DB=agentlens
|
||||
|
||||
# Redis
|
||||
REDIS_PASSWORD= # Generate with: openssl rand -base64 24
|
||||
|
||||
# Email (optional — email features disabled if not set)
|
||||
EMAIL_PASSWORD=
|
||||
64
.gitea/workflows/deploy.yml
Normal file
64
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Deploy AgentLens
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
COMPOSE_PROJECT_NAME: agentlens
|
||||
DOCKER_HOST: tcp://192.168.1.133:2375
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Verify Docker access
|
||||
run: |
|
||||
docker version
|
||||
docker compose version
|
||||
|
||||
- name: Write environment file
|
||||
run: |
|
||||
cat > .env <<'ENVEOF'
|
||||
AUTH_SECRET=${{ secrets.AUTH_SECRET }}
|
||||
STRIPE_SECRET_KEY=${{ secrets.STRIPE_SECRET_KEY }}
|
||||
STRIPE_WEBHOOK_SECRET=${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
|
||||
REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}
|
||||
EMAIL_PASSWORD=${{ secrets.EMAIL_PASSWORD }}
|
||||
ENVEOF
|
||||
sed -i 's/^[[:space:]]*//' .env
|
||||
|
||||
- name: Build and deploy
|
||||
run: |
|
||||
echo "Deploying AgentLens ${{ gitea.ref_name }}..."
|
||||
docker compose build web migrate
|
||||
docker compose up -d --no-deps --remove-orphans web migrate redis postgres
|
||||
echo "Waiting for migration and startup..."
|
||||
sleep 25
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
for i in 1 2 3 4 5; do
|
||||
STATUS=$(docker inspect --format='{{.State.Running}}' agentlens-web-1 2>/dev/null || true)
|
||||
if [ "$STATUS" = "true" ]; then
|
||||
echo "Container running (attempt $i)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $i/5 — retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
echo "Health check failed after 5 attempts"
|
||||
docker compose logs web --tail 50
|
||||
exit 1
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: docker image prune -f
|
||||
@@ -14,7 +14,11 @@ FROM base AS builder
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npx prisma generate --schema=packages/database/prisma/schema.prisma
|
||||
RUN npx turbo build
|
||||
RUN npx turbo build --filter=@agentlens/web...
|
||||
|
||||
FROM base AS migrate
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY packages/database/prisma ./packages/database/prisma
|
||||
|
||||
FROM base AS web
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
|
||||
@@ -15,16 +15,27 @@
|
||||
"@agentlens/database": "*",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"ioredis": "^5.9.2",
|
||||
"lucide-react": "^0.469.0",
|
||||
"next": "^15.1.0",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"nodemailer": "^6.10.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"shiki": "^3.22.0"
|
||||
"shiki": "^3.22.0",
|
||||
"stripe": "^20.3.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/nodemailer": "^7.0.9",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"postcss": "^8.5.0",
|
||||
|
||||
BIN
apps/web/public/apple-icon.png
Normal file
BIN
apps/web/public/apple-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/web/public/favicon.ico
Normal file
BIN
apps/web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 557 B |
BIN
apps/web/public/icon.png
Normal file
BIN
apps/web/public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
BIN
apps/web/public/og-image.png
Normal file
BIN
apps/web/public/og-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
158
apps/web/src/app/(auth)/forgot-password/page.tsx
Normal file
158
apps/web/src/app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!emailValid) {
|
||||
setError("Please enter a valid email address");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/forgot-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data: { error?: string } = await res.json();
|
||||
setError(data.error ?? "Something went wrong");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
setError("Something went wrong. Please try again.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||
<Activity className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-neutral-100">
|
||||
Check your email
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-400">
|
||||
If an account exists for that email, we sent a password reset
|
||||
link. It expires in 1 hour.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-neutral-400">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||
<Activity className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-neutral-100">
|
||||
Reset your password
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-400">
|
||||
Enter your email and we'll send you a reset link
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-neutral-300"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className={cn(
|
||||
"w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors",
|
||||
email && !emailValid
|
||||
? "border-red-500/50 focus:border-red-500"
|
||||
: "border-neutral-800 focus:border-emerald-500"
|
||||
)}
|
||||
/>
|
||||
{email && !emailValid && (
|
||||
<p className="text-xs text-red-400">
|
||||
Please enter a valid email address
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors",
|
||||
loading
|
||||
? "bg-emerald-500/50 text-neutral-950/50 cursor-not-allowed"
|
||||
: "bg-emerald-500 hover:bg-emerald-400 text-neutral-950"
|
||||
)}
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{loading ? "Sending..." : "Send reset link"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-neutral-400">
|
||||
Remember your password?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
apps/web/src/app/(auth)/layout.tsx
Normal file
11
apps/web/src/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-950 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
apps/web/src/app/(auth)/login/page.tsx
Normal file
186
apps/web/src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Activity, CheckCircle, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const verified = searchParams.get("verified") === "true";
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
const passwordValid = password.length >= 8;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!emailValid) {
|
||||
setError("Please enter a valid email address");
|
||||
return;
|
||||
}
|
||||
if (!passwordValid) {
|
||||
setError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setError("Invalid email or password");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||
<Activity className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-neutral-100">Welcome back</h1>
|
||||
<p className="mt-1 text-sm text-neutral-400">
|
||||
Sign in to your AgentLens account
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{verified && (
|
||||
<div className="rounded-lg bg-emerald-500/10 border border-emerald-500/20 px-4 py-3 flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
<p className="text-sm text-emerald-400">
|
||||
Email verified! You can now sign in.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-neutral-300"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className={cn(
|
||||
"w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors",
|
||||
email && !emailValid
|
||||
? "border-red-500/50 focus:border-red-500"
|
||||
: "border-neutral-800 focus:border-emerald-500"
|
||||
)}
|
||||
/>
|
||||
{email && !emailValid && (
|
||||
<p className="text-xs text-red-400">
|
||||
Please enter a valid email address
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-neutral-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className={cn(
|
||||
"w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors",
|
||||
password && !passwordValid
|
||||
? "border-red-500/50 focus:border-red-500"
|
||||
: "border-neutral-800 focus:border-emerald-500"
|
||||
)}
|
||||
/>
|
||||
{password && !passwordValid && (
|
||||
<p className="text-xs text-red-400">
|
||||
Password must be at least 8 characters
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-neutral-500 hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors",
|
||||
loading
|
||||
? "bg-emerald-500/50 text-neutral-950/50 cursor-not-allowed"
|
||||
: "bg-emerald-500 hover:bg-emerald-400 text-neutral-950"
|
||||
)}
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{loading ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-neutral-400">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
|
||||
>
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
apps/web/src/app/(auth)/register/page.tsx
Normal file
208
apps/web/src/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
const passwordValid = password.length >= 8;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!emailValid) {
|
||||
setError("Please enter a valid email address");
|
||||
return;
|
||||
}
|
||||
if (!passwordValid) {
|
||||
setError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
...(name.trim() ? { name: name.trim() } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
const data: { error?: string } = await res.json();
|
||||
setError(data.error ?? "Too many attempts. Please try again later.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const data: { error?: string } = await res.json();
|
||||
setError(data.error ?? "Registration failed");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Something went wrong. Please try again.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||
<Activity className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-neutral-100">
|
||||
Create your account
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-400">
|
||||
Start monitoring your AI agents with AgentLens
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium text-neutral-300"
|
||||
>
|
||||
Name{" "}
|
||||
<span className="text-neutral-500 font-normal">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Jane Doe"
|
||||
className="w-full px-3 py-2.5 bg-neutral-950 border border-neutral-800 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-neutral-300"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className={cn(
|
||||
"w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors",
|
||||
email && !emailValid
|
||||
? "border-red-500/50 focus:border-red-500"
|
||||
: "border-neutral-800 focus:border-emerald-500"
|
||||
)}
|
||||
/>
|
||||
{email && !emailValid && (
|
||||
<p className="text-xs text-red-400">
|
||||
Please enter a valid email address
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-neutral-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className={cn(
|
||||
"w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors",
|
||||
password && !passwordValid
|
||||
? "border-red-500/50 focus:border-red-500"
|
||||
: "border-neutral-800 focus:border-emerald-500"
|
||||
)}
|
||||
/>
|
||||
{password && !passwordValid && (
|
||||
<p className="text-xs text-red-400">
|
||||
Password must be at least 8 characters
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors",
|
||||
loading
|
||||
? "bg-emerald-500/50 text-neutral-950/50 cursor-not-allowed"
|
||||
: "bg-emerald-500 hover:bg-emerald-400 text-neutral-950"
|
||||
)}
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{loading ? "Creating account…" : "Create account"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-neutral-400">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
apps/web/src/app/(auth)/reset-password/page.tsx
Normal file
235
apps/web/src/app/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ResetPasswordForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetPasswordForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const passwordValid = password.length >= 8;
|
||||
const passwordsMatch = password === confirmPassword;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!passwordValid) {
|
||||
setError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
if (!passwordsMatch) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (!token) {
|
||||
setError("Invalid reset link");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/reset-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data: { error?: string } = await res.json();
|
||||
setError(data.error ?? "Something went wrong");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
} catch {
|
||||
setError("Something went wrong. Please try again.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||
<Activity className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-neutral-100">
|
||||
Invalid reset link
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-400">
|
||||
This password reset link is invalid or has expired.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-neutral-400">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
|
||||
>
|
||||
Request a new reset link
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||
<Activity className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-neutral-100">
|
||||
Password reset
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-400">
|
||||
Your password has been successfully reset.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-neutral-400">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
|
||||
>
|
||||
Sign in with your new password
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||
<Activity className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-neutral-100">
|
||||
Set new password
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-400">
|
||||
Enter your new password below
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-neutral-300"
|
||||
>
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className={cn(
|
||||
"w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors",
|
||||
password && !passwordValid
|
||||
? "border-red-500/50 focus:border-red-500"
|
||||
: "border-neutral-800 focus:border-emerald-500"
|
||||
)}
|
||||
/>
|
||||
{password && !passwordValid && (
|
||||
<p className="text-xs text-red-400">
|
||||
Password must be at least 8 characters
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-neutral-300"
|
||||
>
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className={cn(
|
||||
"w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors",
|
||||
confirmPassword && !passwordsMatch
|
||||
? "border-red-500/50 focus:border-red-500"
|
||||
: "border-neutral-800 focus:border-emerald-500"
|
||||
)}
|
||||
/>
|
||||
{confirmPassword && !passwordsMatch && (
|
||||
<p className="text-xs text-red-400">Passwords do not match</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors",
|
||||
loading
|
||||
? "bg-emerald-500/50 text-neutral-950/50 cursor-not-allowed"
|
||||
: "bg-emerald-500 hover:bg-emerald-400 text-neutral-950"
|
||||
)}
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{loading ? "Resetting..." : "Reset password"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-neutral-400">
|
||||
Remember your password?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
apps/web/src/app/(auth)/verify-email/page.tsx
Normal file
103
apps/web/src/app/(auth)/verify-email/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Activity, Loader2, Mail } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleResend() {
|
||||
setLoading(true);
|
||||
setMessage("");
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/resend-verification", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data: { error?: string } = await res.json();
|
||||
setError(data.error ?? "Failed to resend email");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage("Verification email sent! Check your inbox.");
|
||||
} catch {
|
||||
setError("Something went wrong. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||
<Activity className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-neutral-100">
|
||||
Check your email
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-400">
|
||||
We sent a verification link to your inbox
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="w-16 h-16 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center">
|
||||
<Mail className="w-8 h-8 text-emerald-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-400 text-center leading-relaxed">
|
||||
Click the link in the email to verify your account. The link expires
|
||||
in 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className="rounded-lg bg-emerald-500/10 border border-emerald-500/20 px-4 py-3">
|
||||
<p className="text-sm text-emerald-400">{message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleResend}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors",
|
||||
loading
|
||||
? "bg-emerald-500/50 text-neutral-950/50 cursor-not-allowed"
|
||||
: "bg-emerald-500 hover:bg-emerald-400 text-neutral-950"
|
||||
)}
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{loading ? "Sending..." : "Resend verification email"}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-neutral-400">
|
||||
Already verified?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
apps/web/src/app/api/auth/[...nextauth]/route.ts
Normal file
3
apps/web/src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
105
apps/web/src/app/api/auth/forgot-password/route.ts
Normal file
105
apps/web/src/app/api/auth/forgot-password/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomBytes, createHash } from "crypto";
|
||||
import { z } from "zod";
|
||||
import nodemailer from "nodemailer";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
|
||||
|
||||
const forgotPasswordSchema = z.object({
|
||||
email: z.email("Invalid email address"),
|
||||
});
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: "smtp.migadu.com",
|
||||
port: 465,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: "hunter@repi.fun",
|
||||
pass: process.env.EMAIL_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
const rl = await checkRateLimit(`forgot:${ip}`, AUTH_RATE_LIMITS.forgotPassword);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests. Please try again later." },
|
||||
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
|
||||
);
|
||||
}
|
||||
|
||||
const body: unknown = await request.json();
|
||||
const parsed = forgotPasswordSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { email } = parsed.data;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
});
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
if (!user) {
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
await prisma.passwordResetToken.updateMany({
|
||||
where: { userId: user.id, used: false },
|
||||
data: { used: true },
|
||||
});
|
||||
|
||||
const rawToken = randomBytes(32).toString("hex");
|
||||
const tokenHash = hashToken(rawToken);
|
||||
|
||||
await prisma.passwordResetToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token: tokenHash,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
const resetUrl = `https://agentlens.vectry.tech/reset-password?token=${rawToken}`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: '"AgentLens" <hunter@repi.fun>',
|
||||
to: normalizedEmail,
|
||||
subject: "Reset your AgentLens password",
|
||||
text: `You requested a password reset for your AgentLens account.\n\nClick the link below to set a new password:\n${resetUrl}\n\nThis link expires in 1 hour.\n\nIf you did not request this, you can safely ignore this email.`,
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||
<h2 style="color: #f5f5f5; font-size: 20px; margin-bottom: 16px;">Reset your password</h2>
|
||||
<p style="color: #a3a3a3; font-size: 14px; line-height: 1.6; margin-bottom: 24px;">
|
||||
You requested a password reset for your AgentLens account. Click the button below to set a new password.
|
||||
</p>
|
||||
<a href="${resetUrl}" style="display: inline-block; background-color: #10b981; color: #0a0a0a; font-weight: 600; font-size: 14px; padding: 12px 24px; border-radius: 8px; text-decoration: none;">
|
||||
Reset password
|
||||
</a>
|
||||
<p style="color: #737373; font-size: 12px; line-height: 1.5; margin-top: 32px;">
|
||||
This link expires in 1 hour. If you did not request this, you can safely ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
120
apps/web/src/app/api/auth/register/route.ts
Normal file
120
apps/web/src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { hash } from "bcryptjs";
|
||||
import crypto from "crypto";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendEmail } from "@/lib/email";
|
||||
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
name: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
const rl = await checkRateLimit(`register:${ip}`, AUTH_RATE_LIMITS.register);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many registration attempts. Please try again later." },
|
||||
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
|
||||
);
|
||||
}
|
||||
|
||||
const body: unknown = await request.json();
|
||||
const parsed = registerSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { email, password, name } = parsed.data;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ message: "If this email is available, a confirmation email will be sent." },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await hash(password, 12);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: normalizedEmail,
|
||||
passwordHash,
|
||||
name: name ?? null,
|
||||
subscription: {
|
||||
create: {
|
||||
tier: "FREE",
|
||||
sessionsLimit: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const rawToken = crypto.randomBytes(32).toString("hex");
|
||||
const tokenHash = crypto
|
||||
.createHash("sha256")
|
||||
.update(rawToken)
|
||||
.digest("hex");
|
||||
|
||||
await prisma.emailVerificationToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token: tokenHash,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
const verifyUrl = `https://agentlens.vectry.tech/verify-email?token=${rawToken}`;
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Verify your AgentLens email",
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||
<h2 style="color: #e5e5e5; margin-bottom: 16px;">Verify your email</h2>
|
||||
<p style="color: #a3a3a3; line-height: 1.6;">
|
||||
Thanks for signing up for AgentLens. Click the link below to verify your email address.
|
||||
</p>
|
||||
<a href="${verifyUrl}" style="display: inline-block; margin-top: 24px; padding: 12px 24px; background-color: #10b981; color: #000; text-decoration: none; border-radius: 8px; font-weight: 600;">
|
||||
Verify Email
|
||||
</a>
|
||||
<p style="color: #737373; font-size: 13px; margin-top: 32px;">
|
||||
This link expires in 24 hours. If you didn't create an account, you can safely ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.error("[register] Failed to send verification email:", emailError);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "If this email is available, a confirmation email will be sent." },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
78
apps/web/src/app/api/auth/resend-verification/route.ts
Normal file
78
apps/web/src/app/api/auth/resend-verification/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendEmail } from "@/lib/email";
|
||||
|
||||
export async function POST() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
return NextResponse.json({ error: "Email already verified" }, { status: 400 });
|
||||
}
|
||||
|
||||
const latestToken = await prisma.emailVerificationToken.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (latestToken && Date.now() - latestToken.createdAt.getTime() < 60_000) {
|
||||
return NextResponse.json(
|
||||
{ error: "Please wait 60 seconds before requesting another email" },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.emailVerificationToken.updateMany({
|
||||
where: { userId: user.id, used: false },
|
||||
data: { used: true },
|
||||
});
|
||||
|
||||
const rawToken = crypto.randomBytes(32).toString("hex");
|
||||
const tokenHash = crypto
|
||||
.createHash("sha256")
|
||||
.update(rawToken)
|
||||
.digest("hex");
|
||||
|
||||
await prisma.emailVerificationToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token: tokenHash,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
const verifyUrl = `https://agentlens.vectry.tech/verify-email?token=${rawToken}`;
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Verify your AgentLens email",
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||
<h2 style="color: #e5e5e5; margin-bottom: 16px;">Verify your email</h2>
|
||||
<p style="color: #a3a3a3; line-height: 1.6;">
|
||||
Click the link below to verify your email address for AgentLens.
|
||||
</p>
|
||||
<a href="${verifyUrl}" style="display: inline-block; margin-top: 24px; padding: 12px 24px; background-color: #10b981; color: #000; text-decoration: none; border-radius: 8px; font-weight: 600;">
|
||||
Verify Email
|
||||
</a>
|
||||
<p style="color: #737373; font-size: 13px; margin-top: 32px;">
|
||||
This link expires in 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
73
apps/web/src/app/api/auth/reset-password/route.ts
Normal file
73
apps/web/src/app/api/auth/reset-password/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createHash } from "crypto";
|
||||
import { hash } from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
|
||||
|
||||
const resetPasswordSchema = z.object({
|
||||
token: z.string().min(1, "Token is required"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
});
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
const rl = await checkRateLimit(`reset:${ip}`, AUTH_RATE_LIMITS.resetPassword);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many attempts. Please try again later." },
|
||||
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
|
||||
);
|
||||
}
|
||||
|
||||
const body: unknown = await request.json();
|
||||
const parsed = resetPasswordSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { token, password } = parsed.data;
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
const resetToken = await prisma.passwordResetToken.findUnique({
|
||||
where: { token: tokenHash },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!resetToken || resetToken.used || resetToken.expiresAt < new Date()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid or expired reset link" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await hash(password, 12);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: resetToken.userId },
|
||||
data: { passwordHash },
|
||||
}),
|
||||
prisma.passwordResetToken.update({
|
||||
where: { id: resetToken.id },
|
||||
data: { used: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
54
apps/web/src/app/api/auth/verify-email/route.ts
Normal file
54
apps/web/src/app/api/auth/verify-email/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const rawToken = request.nextUrl.searchParams.get("token");
|
||||
|
||||
if (!rawToken) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/login?error=missing-token", request.url)
|
||||
);
|
||||
}
|
||||
|
||||
const tokenHash = crypto
|
||||
.createHash("sha256")
|
||||
.update(rawToken)
|
||||
.digest("hex");
|
||||
|
||||
const verificationToken = await prisma.emailVerificationToken.findUnique({
|
||||
where: { token: tokenHash },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!verificationToken) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/login?error=invalid-token", request.url)
|
||||
);
|
||||
}
|
||||
|
||||
if (verificationToken.used) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/login?verified=true", request.url)
|
||||
);
|
||||
}
|
||||
|
||||
if (verificationToken.expiresAt < new Date()) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/login?error=token-expired", request.url)
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: verificationToken.userId },
|
||||
data: { emailVerified: true },
|
||||
}),
|
||||
prisma.emailVerificationToken.update({
|
||||
where: { id: verificationToken.id },
|
||||
data: { used: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.redirect(new URL("/login?verified=true", request.url));
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@agentlens/database";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") ?? "1", 10);
|
||||
const limit = parseInt(searchParams.get("limit") ?? "20", 10);
|
||||
@@ -51,8 +57,9 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Build where clause
|
||||
const where: Prisma.DecisionPointWhereInput = {};
|
||||
const where: Prisma.DecisionPointWhereInput = {
|
||||
trace: { userId: session.user.id },
|
||||
};
|
||||
if (type) {
|
||||
where.type = type as Prisma.EnumDecisionTypeFilter["equals"];
|
||||
}
|
||||
|
||||
33
apps/web/src/app/api/demo/seed/route.ts
Normal file
33
apps/web/src/app/api/demo/seed/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { seedDemoData } from "@/lib/demo-data";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { demoSeeded: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (user.demoSeeded) {
|
||||
return NextResponse.json({ error: "Demo data already seeded" }, { status: 409 });
|
||||
}
|
||||
|
||||
await seedDemoData(session.user.id);
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error seeding demo data:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
38
apps/web/src/app/api/keys/[id]/route.ts
Normal file
38
apps/web/src/app/api/keys/[id]/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const apiKey = await prisma.apiKey.findFirst({
|
||||
where: { id, userId: session.user.id, revoked: false },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { id: apiKey.id },
|
||||
data: { revoked: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error revoking API key:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
88
apps/web/src/app/api/keys/route.ts
Normal file
88
apps/web/src/app/api/keys/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomBytes, createHash } from "crypto";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const keys = await prisma.apiKey.findMany({
|
||||
where: { userId: session.user.id, revoked: false },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
keyPrefix: true,
|
||||
createdAt: true,
|
||||
lastUsedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json(keys, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error listing API keys:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const MAX_KEYS_PER_USER = 10;
|
||||
const keyCount = await prisma.apiKey.count({
|
||||
where: { userId: session.user.id, revoked: false },
|
||||
});
|
||||
if (keyCount >= MAX_KEYS_PER_USER) {
|
||||
return NextResponse.json(
|
||||
{ error: `Maximum of ${MAX_KEYS_PER_USER} API keys allowed. Revoke an existing key first.` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const name =
|
||||
typeof body.name === "string" && body.name.trim()
|
||||
? body.name.trim()
|
||||
: "Default";
|
||||
|
||||
const rawHex = randomBytes(24).toString("hex");
|
||||
const fullKey = `al_${rawHex}`;
|
||||
const keyPrefix = fullKey.slice(0, 10);
|
||||
const keyHash = createHash("sha256").update(fullKey).digest("hex");
|
||||
|
||||
const apiKey = await prisma.apiKey.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
name,
|
||||
keyHash,
|
||||
keyPrefix,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
keyPrefix: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ ...apiKey, key: fullKey },
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error creating API key:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
59
apps/web/src/app/api/settings/account/route.ts
Normal file
59
apps/web/src/app/api/settings/account/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
subscription: {
|
||||
select: {
|
||||
tier: true,
|
||||
status: true,
|
||||
sessionsUsed: true,
|
||||
sessionsLimit: true,
|
||||
currentPeriodStart: true,
|
||||
currentPeriodEnd: true,
|
||||
stripeCustomerId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Don't expose raw Stripe customer ID to the client
|
||||
const { subscription, ...rest } = user;
|
||||
const safeSubscription = subscription
|
||||
? {
|
||||
tier: subscription.tier,
|
||||
status: subscription.status,
|
||||
sessionsUsed: subscription.sessionsUsed,
|
||||
sessionsLimit: subscription.sessionsLimit,
|
||||
currentPeriodStart: subscription.currentPeriodStart,
|
||||
currentPeriodEnd: subscription.currentPeriodEnd,
|
||||
hasStripeSubscription: !!subscription.stripeCustomerId,
|
||||
}
|
||||
: null;
|
||||
|
||||
return NextResponse.json({ ...rest, subscription: safeSubscription }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error fetching account:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const traceFilter = { trace: { userId } };
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.event.deleteMany(),
|
||||
prisma.decisionPoint.deleteMany(),
|
||||
prisma.span.deleteMany(),
|
||||
prisma.trace.deleteMany(),
|
||||
prisma.event.deleteMany({ where: traceFilter }),
|
||||
prisma.decisionPoint.deleteMany({ where: traceFilter }),
|
||||
prisma.span.deleteMany({ where: traceFilter }),
|
||||
prisma.trace.deleteMany({ where: { userId } }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const traceFilter = { userId };
|
||||
const childFilter = { trace: { userId } };
|
||||
|
||||
const [totalTraces, totalSpans, totalDecisions, totalEvents] =
|
||||
await Promise.all([
|
||||
prisma.trace.count(),
|
||||
prisma.span.count(),
|
||||
prisma.decisionPoint.count(),
|
||||
prisma.event.count(),
|
||||
prisma.trace.count({ where: traceFilter }),
|
||||
prisma.span.count({ where: childFilter }),
|
||||
prisma.decisionPoint.count({ where: childFilter }),
|
||||
prisma.event.count({ where: childFilter }),
|
||||
]);
|
||||
|
||||
return NextResponse.json(
|
||||
|
||||
101
apps/web/src/app/api/stripe/checkout/route.ts
Normal file
101
apps/web/src/app/api/stripe/checkout/route.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getStripe, TIER_CONFIG } from "@/lib/stripe";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { priceId, tierKey } = body as {
|
||||
priceId?: string;
|
||||
tierKey?: string;
|
||||
};
|
||||
|
||||
let resolvedPriceId = priceId;
|
||||
|
||||
if (!resolvedPriceId && tierKey) {
|
||||
const tierConfig =
|
||||
TIER_CONFIG[tierKey as keyof typeof TIER_CONFIG];
|
||||
if (tierConfig && "priceId" in tierConfig) {
|
||||
resolvedPriceId = tierConfig.priceId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedPriceId) {
|
||||
return NextResponse.json(
|
||||
{ error: "priceId or tierKey is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const validPriceIds = [TIER_CONFIG.STARTER.priceId, TIER_CONFIG.PRO.priceId];
|
||||
if (!validPriceIds.includes(resolvedPriceId)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid priceId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
let subscription = await prisma.subscription.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
let stripeCustomerId = subscription?.stripeCustomerId;
|
||||
|
||||
if (!stripeCustomerId) {
|
||||
const customer = await getStripe().customers.create({
|
||||
email: session.user.email,
|
||||
name: session.user.name ?? undefined,
|
||||
metadata: { userId },
|
||||
});
|
||||
stripeCustomerId = customer.id;
|
||||
|
||||
if (subscription) {
|
||||
await prisma.subscription.update({
|
||||
where: { userId },
|
||||
data: { stripeCustomerId },
|
||||
});
|
||||
} else {
|
||||
subscription = await prisma.subscription.create({
|
||||
data: {
|
||||
userId,
|
||||
stripeCustomerId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_ORIGINS = [
|
||||
"https://agentlens.vectry.tech",
|
||||
"http://localhost:3000",
|
||||
];
|
||||
const requestOrigin = request.headers.get("origin");
|
||||
const origin = ALLOWED_ORIGINS.includes(requestOrigin ?? "")
|
||||
? requestOrigin!
|
||||
: "https://agentlens.vectry.tech";
|
||||
|
||||
const checkoutSession = await getStripe().checkout.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
mode: "subscription",
|
||||
line_items: [{ price: resolvedPriceId, quantity: 1 }],
|
||||
success_url: `${origin}/dashboard/settings?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${origin}/dashboard/settings`,
|
||||
metadata: { userId },
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: checkoutSession.url }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error creating checkout session:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
47
apps/web/src/app/api/stripe/portal/route.ts
Normal file
47
apps/web/src/app/api/stripe/portal/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getStripe } from "@/lib/stripe";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
select: { stripeCustomerId: true },
|
||||
});
|
||||
|
||||
if (!subscription?.stripeCustomerId) {
|
||||
return NextResponse.json(
|
||||
{ error: "No active subscription to manage" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const ALLOWED_ORIGINS = [
|
||||
"https://agentlens.vectry.tech",
|
||||
"http://localhost:3000",
|
||||
];
|
||||
const requestOrigin = request.headers.get("origin");
|
||||
const origin = ALLOWED_ORIGINS.includes(requestOrigin ?? "")
|
||||
? requestOrigin!
|
||||
: "https://agentlens.vectry.tech";
|
||||
|
||||
const portalSession = await getStripe().billingPortal.sessions.create({
|
||||
customer: subscription.stripeCustomerId,
|
||||
return_url: `${origin}/dashboard/settings`,
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: portalSession.url }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error creating portal session:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
179
apps/web/src/app/api/stripe/webhook/route.ts
Normal file
179
apps/web/src/app/api/stripe/webhook/route.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getStripe, TIER_CONFIG } from "@/lib/stripe";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function tierFromPriceId(priceId: string | null): "FREE" | "STARTER" | "PRO" {
|
||||
if (priceId === TIER_CONFIG.STARTER.priceId) return "STARTER";
|
||||
if (priceId === TIER_CONFIG.PRO.priceId) return "PRO";
|
||||
return "FREE";
|
||||
}
|
||||
|
||||
function sessionsLimitForTier(tier: "FREE" | "STARTER" | "PRO"): number {
|
||||
return TIER_CONFIG[tier].sessionsLimit;
|
||||
}
|
||||
|
||||
async function handleCheckoutCompleted(
|
||||
checkoutSession: Stripe.Checkout.Session
|
||||
) {
|
||||
const userId = checkoutSession.metadata?.userId;
|
||||
if (!userId) return;
|
||||
|
||||
const subscriptionId = checkoutSession.subscription as string;
|
||||
const customerId = checkoutSession.customer as string;
|
||||
|
||||
const sub = await getStripe().subscriptions.retrieve(subscriptionId);
|
||||
const firstItem = sub.items.data[0];
|
||||
const priceId = firstItem?.price?.id ?? null;
|
||||
const tier = tierFromPriceId(priceId);
|
||||
const periodStart = firstItem?.current_period_start
|
||||
? new Date(firstItem.current_period_start * 1000)
|
||||
: new Date();
|
||||
const periodEnd = firstItem?.current_period_end
|
||||
? new Date(firstItem.current_period_end * 1000)
|
||||
: new Date();
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: { userId },
|
||||
update: {
|
||||
stripeCustomerId: customerId,
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
stripePriceId: priceId,
|
||||
tier,
|
||||
sessionsLimit: sessionsLimitForTier(tier),
|
||||
sessionsUsed: 0,
|
||||
status: "ACTIVE",
|
||||
currentPeriodStart: periodStart,
|
||||
currentPeriodEnd: periodEnd,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
stripeCustomerId: customerId,
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
stripePriceId: priceId,
|
||||
tier,
|
||||
sessionsLimit: sessionsLimitForTier(tier),
|
||||
sessionsUsed: 0,
|
||||
status: "ACTIVE",
|
||||
currentPeriodStart: periodStart,
|
||||
currentPeriodEnd: periodEnd,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubscriptionUpdated(sub: Stripe.Subscription) {
|
||||
const firstItem = sub.items.data[0];
|
||||
const priceId = firstItem?.price?.id ?? null;
|
||||
const tier = tierFromPriceId(priceId);
|
||||
|
||||
const statusMap: Record<string, "ACTIVE" | "PAST_DUE" | "CANCELED" | "UNPAID"> = {
|
||||
active: "ACTIVE",
|
||||
past_due: "PAST_DUE",
|
||||
canceled: "CANCELED",
|
||||
unpaid: "UNPAID",
|
||||
};
|
||||
|
||||
const dbStatus = statusMap[sub.status] ?? "ACTIVE";
|
||||
const periodStart = firstItem?.current_period_start
|
||||
? new Date(firstItem.current_period_start * 1000)
|
||||
: undefined;
|
||||
const periodEnd = firstItem?.current_period_end
|
||||
? new Date(firstItem.current_period_end * 1000)
|
||||
: undefined;
|
||||
|
||||
await prisma.subscription.updateMany({
|
||||
where: { stripeSubscriptionId: sub.id },
|
||||
data: {
|
||||
tier,
|
||||
stripePriceId: priceId,
|
||||
sessionsLimit: sessionsLimitForTier(tier),
|
||||
status: dbStatus,
|
||||
...(periodStart && { currentPeriodStart: periodStart }),
|
||||
...(periodEnd && { currentPeriodEnd: periodEnd }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubscriptionDeleted(sub: Stripe.Subscription) {
|
||||
await prisma.subscription.updateMany({
|
||||
where: { stripeSubscriptionId: sub.id },
|
||||
data: {
|
||||
status: "CANCELED",
|
||||
tier: "FREE",
|
||||
sessionsLimit: TIER_CONFIG.FREE.sessionsLimit,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleInvoicePaid(invoice: Stripe.Invoice) {
|
||||
const subDetail = invoice.parent?.subscription_details?.subscription;
|
||||
const subscriptionId =
|
||||
typeof subDetail === "string" ? subDetail : subDetail?.id;
|
||||
|
||||
if (!subscriptionId) return;
|
||||
|
||||
await prisma.subscription.updateMany({
|
||||
where: { stripeSubscriptionId: subscriptionId },
|
||||
data: { sessionsUsed: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.text();
|
||||
const sig = request.headers.get("stripe-signature");
|
||||
|
||||
if (!sig) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing stripe-signature header" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = getStripe().webhooks.constructEvent(
|
||||
body,
|
||||
sig,
|
||||
process.env.STRIPE_WEBHOOK_SECRET!
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Webhook signature verification failed");
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid signature" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed":
|
||||
await handleCheckoutCompleted(
|
||||
event.data.object as Stripe.Checkout.Session
|
||||
);
|
||||
break;
|
||||
case "customer.subscription.updated":
|
||||
await handleSubscriptionUpdated(
|
||||
event.data.object as Stripe.Subscription
|
||||
);
|
||||
break;
|
||||
case "customer.subscription.deleted":
|
||||
await handleSubscriptionDeleted(
|
||||
event.data.object as Stripe.Subscription
|
||||
);
|
||||
break;
|
||||
case "invoice.paid":
|
||||
await handleInvoicePaid(event.data.object as Stripe.Invoice);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error handling ${event.type}:`, error);
|
||||
return NextResponse.json(
|
||||
{ error: "Webhook handler failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true }, { status: 200 });
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -23,14 +24,19 @@ export async function GET(
|
||||
{ params }: RouteParams
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
if (!id || typeof id !== "string") {
|
||||
return NextResponse.json({ error: "Invalid trace ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const trace = await prisma.trace.findUnique({
|
||||
where: { id },
|
||||
const trace = await prisma.trace.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
include: {
|
||||
decisionPoints: {
|
||||
orderBy: {
|
||||
@@ -106,14 +112,19 @@ export async function DELETE(
|
||||
{ params }: RouteParams
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
if (!id || typeof id !== "string") {
|
||||
return NextResponse.json({ error: "Invalid trace ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const trace = await prisma.trace.findUnique({
|
||||
where: { id },
|
||||
const trace = await prisma.trace.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@agentlens/database";
|
||||
import { validateApiKey } from "@/lib/api-key";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
// Types
|
||||
interface DecisionPointPayload {
|
||||
@@ -90,11 +92,61 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Missing or invalid Authorization header" }, { status: 401 });
|
||||
}
|
||||
|
||||
const apiKey = authHeader.slice(7);
|
||||
if (!apiKey) {
|
||||
const contentLength = parseInt(request.headers.get("content-length") ?? "0", 10);
|
||||
const MAX_BODY_SIZE = 10 * 1024 * 1024;
|
||||
if (contentLength > MAX_BODY_SIZE) {
|
||||
return NextResponse.json({ error: "Request body too large (max 10MB)" }, { status: 413 });
|
||||
}
|
||||
|
||||
const rawApiKey = authHeader.slice(7);
|
||||
if (!rawApiKey) {
|
||||
return NextResponse.json({ error: "Missing API key in Authorization header" }, { status: 401 });
|
||||
}
|
||||
|
||||
const keyValidation = await validateApiKey(rawApiKey);
|
||||
if (!keyValidation) {
|
||||
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { userId, subscription } = keyValidation;
|
||||
|
||||
if (!subscription) {
|
||||
return NextResponse.json({ error: "No subscription found for this user" }, { status: 403 });
|
||||
}
|
||||
|
||||
const tier = subscription.tier;
|
||||
const sessionsLimit = subscription.sessionsLimit;
|
||||
|
||||
if (tier === "FREE") {
|
||||
const startOfToday = new Date();
|
||||
startOfToday.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const dailyCount = await prisma.trace.count({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: { gte: startOfToday },
|
||||
},
|
||||
});
|
||||
|
||||
if (dailyCount >= sessionsLimit) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Rate limit exceeded. Current plan: ${tier}. Limit: ${sessionsLimit}/day. Upgrade at /settings/billing`,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (subscription.sessionsUsed >= sessionsLimit) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Rate limit exceeded. Current plan: ${tier}. Limit: ${sessionsLimit}/month. Upgrade at /settings/billing`,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and validate request body
|
||||
const body: BatchTracesRequest = await request.json();
|
||||
if (!body.traces || !Array.isArray(body.traces)) {
|
||||
@@ -190,8 +242,19 @@ export async function POST(request: NextRequest) {
|
||||
// final flushes both work seamlessly.
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const upserted: string[] = [];
|
||||
let newTraceCount = 0;
|
||||
|
||||
for (const trace of body.traces) {
|
||||
const existing = await tx.trace.findUnique({
|
||||
where: { id: trace.id },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
|
||||
// Security: prevent cross-user trace overwrite
|
||||
if (existing && existing.userId !== userId) {
|
||||
continue; // skip traces owned by other users
|
||||
}
|
||||
|
||||
const traceData = {
|
||||
name: trace.name,
|
||||
sessionId: trace.sessionId,
|
||||
@@ -205,13 +268,16 @@ export async function POST(request: NextRequest) {
|
||||
endedAt: trace.endedAt ? new Date(trace.endedAt) : null,
|
||||
};
|
||||
|
||||
// 1. Upsert the trace record
|
||||
await tx.trace.upsert({
|
||||
where: { id: trace.id },
|
||||
create: { id: trace.id, ...traceData },
|
||||
create: { id: trace.id, userId, ...traceData },
|
||||
update: traceData,
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
newTraceCount++;
|
||||
}
|
||||
|
||||
// 2. Delete existing child records (order matters for FK constraints:
|
||||
// decision points reference spans, so delete decisions first)
|
||||
await tx.decisionPoint.deleteMany({ where: { traceId: trace.id } });
|
||||
@@ -283,6 +349,13 @@ export async function POST(request: NextRequest) {
|
||||
upserted.push(trace.id);
|
||||
}
|
||||
|
||||
if (newTraceCount > 0 && tier !== "FREE") {
|
||||
await tx.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: { sessionsUsed: { increment: newTraceCount } },
|
||||
});
|
||||
}
|
||||
|
||||
return upserted;
|
||||
});
|
||||
|
||||
@@ -300,6 +373,11 @@ export async function POST(request: NextRequest) {
|
||||
// GET /api/traces — List traces with pagination
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") ?? "1", 10);
|
||||
const limit = parseInt(searchParams.get("limit") ?? "20", 10);
|
||||
@@ -339,8 +417,7 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Invalid dateTo parameter. Must be a valid ISO date string." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build where clause
|
||||
const where: Record<string, unknown> = {};
|
||||
const where: Record<string, unknown> = { userId: session.user.id };
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -22,6 +23,13 @@ interface TraceUpdateData {
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const currentUserId = session.user.id;
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", "text/event-stream");
|
||||
headers.set("Cache-Control", "no-cache");
|
||||
@@ -43,6 +51,7 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const newTraces = await prisma.trace.findMany({
|
||||
where: {
|
||||
userId: currentUserId,
|
||||
OR: [
|
||||
{ createdAt: { gt: lastCheck } },
|
||||
{ updatedAt: { gt: lastCheck } },
|
||||
|
||||
364
apps/web/src/app/dashboard/keys/page.tsx
Normal file
364
apps/web/src/app/dashboard/keys/page.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Key,
|
||||
Plus,
|
||||
Copy,
|
||||
Check,
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
interface NewKeyResponse extends ApiKey {
|
||||
key: string;
|
||||
}
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<NewKeyResponse | null>(
|
||||
null
|
||||
);
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
const [revokingId, setRevokingId] = useState<string | null>(null);
|
||||
const [confirmRevokeId, setConfirmRevokeId] = useState<string | null>(null);
|
||||
|
||||
const handleKeySelect = useCallback(
|
||||
(index: number) => {
|
||||
const key = keys[index];
|
||||
if (key) {
|
||||
setConfirmRevokeId(key.id);
|
||||
}
|
||||
},
|
||||
[keys]
|
||||
);
|
||||
|
||||
const { selectedIndex } = useKeyboardNav({
|
||||
itemCount: keys.length,
|
||||
onSelect: handleKeySelect,
|
||||
});
|
||||
|
||||
const fetchKeys = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/keys", { cache: "no-store" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setKeys(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch API keys:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchKeys();
|
||||
}, [fetchKeys]);
|
||||
|
||||
const copyToClipboard = async (text: string, field: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedField(field);
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
} catch {
|
||||
console.error("Failed to copy");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const res = await fetch("/api/keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newKeyName.trim() || undefined }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: NewKeyResponse = await res.json();
|
||||
setNewlyCreatedKey(data);
|
||||
setShowCreateForm(false);
|
||||
setNewKeyName("");
|
||||
fetchKeys();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create API key:", error);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (id: string) => {
|
||||
setRevokingId(id);
|
||||
try {
|
||||
const res = await fetch(`/api/keys/${id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setConfirmRevokeId(null);
|
||||
fetchKeys();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to revoke API key:", error);
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-3xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-100">API Keys</h1>
|
||||
<p className="text-neutral-400 mt-1">
|
||||
Manage API keys for SDK authentication
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateForm(true);
|
||||
setNewlyCreatedKey(null);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-400 text-neutral-950 rounded-lg text-sm font-semibold transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create New Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{newlyCreatedKey && (
|
||||
<div className="bg-emerald-500/5 border border-emerald-500/20 rounded-xl p-6 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center shrink-0">
|
||||
<Key className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-emerald-300">
|
||||
API Key Created
|
||||
</h3>
|
||||
<p className="text-xs text-emerald-400/60 mt-0.5">
|
||||
{newlyCreatedKey.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 px-4 py-3 bg-neutral-950 border border-neutral-800 rounded-lg font-mono text-sm text-neutral-200 truncate select-all">
|
||||
{newlyCreatedKey.key}
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
copyToClipboard(newlyCreatedKey.key, "new-key")
|
||||
}
|
||||
aria-label="Copy API key to clipboard"
|
||||
className={cn(
|
||||
"p-3 rounded-lg border transition-all shrink-0",
|
||||
copiedField === "new-key"
|
||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400"
|
||||
: "bg-neutral-800 border-neutral-700 text-neutral-400 hover:text-neutral-200"
|
||||
)}
|
||||
>
|
||||
{copiedField === "new-key" ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 bg-amber-500/5 border border-amber-500/20 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
|
||||
<p className="text-xs text-amber-300/80">
|
||||
This key won't be shown again. Copy it now and store it
|
||||
securely.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setNewlyCreatedKey(null)}
|
||||
className="text-xs text-neutral-500 hover:text-neutral-300 transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreateForm && !newlyCreatedKey && (
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
||||
<div className="flex items-center gap-2 text-neutral-300">
|
||||
<Plus className="w-5 h-5 text-emerald-400" />
|
||||
<h2 className="text-sm font-semibold">Create New API Key</h2>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="key-name" className="text-xs text-neutral-500 font-medium block mb-1.5">
|
||||
Key Name (optional)
|
||||
</label>
|
||||
<input
|
||||
id="key-name"
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="e.g. Production, Staging, CI/CD"
|
||||
className="w-full px-4 py-2.5 bg-neutral-950 border border-neutral-800 rounded-lg text-sm text-neutral-200 placeholder:text-neutral-600 focus:outline-none focus:border-emerald-500/40 focus:ring-1 focus:ring-emerald-500/20 transition-colors"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreate();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-500 hover:bg-emerald-400 text-neutral-950 rounded-lg text-sm font-semibold disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isCreating ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Key className="w-4 h-4" />
|
||||
)}
|
||||
Generate Key
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setNewKeyName("");
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-neutral-300">
|
||||
<Shield className="w-5 h-5 text-emerald-400" />
|
||||
<h2 className="text-lg font-semibold">Active Keys</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-6 space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 animate-pulse">
|
||||
<div className="w-8 h-8 bg-neutral-800 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-32 bg-neutral-800 rounded" />
|
||||
<div className="h-3 w-48 bg-neutral-800 rounded" />
|
||||
</div>
|
||||
<div className="h-8 w-20 bg-neutral-800 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<div className="w-12 h-12 rounded-xl bg-neutral-800/50 border border-neutral-700/50 flex items-center justify-center mx-auto mb-4">
|
||||
<Key className="w-6 h-6 text-neutral-600" />
|
||||
</div>
|
||||
<p className="text-sm text-neutral-400 font-medium">
|
||||
No API keys yet
|
||||
</p>
|
||||
<p className="text-xs text-neutral-600 mt-1">
|
||||
Create one to authenticate your SDK
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-neutral-800">
|
||||
{keys.map((apiKey, index) => (
|
||||
<div
|
||||
key={apiKey.id}
|
||||
data-keyboard-index={index}
|
||||
className={cn(
|
||||
"flex items-center gap-4 px-6 py-4 group transition-colors",
|
||||
index === selectedIndex
|
||||
? "bg-emerald-500/5 ring-1 ring-inset ring-emerald-500/20"
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg bg-neutral-800/50 border border-neutral-700/50 flex items-center justify-center shrink-0">
|
||||
<Key className="w-4 h-4 text-neutral-500" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-neutral-200 truncate">
|
||||
{apiKey.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
<code className="text-xs font-mono text-neutral-500">
|
||||
{apiKey.keyPrefix}••••••••
|
||||
</code>
|
||||
<span className="text-xs text-neutral-600">
|
||||
Created {formatDate(apiKey.createdAt)}
|
||||
</span>
|
||||
{apiKey.lastUsedAt && (
|
||||
<span className="text-xs text-neutral-600">
|
||||
Last used {formatDate(apiKey.lastUsedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmRevokeId === apiKey.id ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => setConfirmRevokeId(null)}
|
||||
className="px-3 py-1.5 text-xs text-neutral-400 hover:text-neutral-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRevoke(apiKey.id)}
|
||||
disabled={revokingId === apiKey.id}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500/20 border border-red-500/30 text-red-400 rounded-lg text-xs font-medium hover:bg-red-500/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{revokingId === apiKey.id ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
)}
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmRevokeId(apiKey.id)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-neutral-800 border border-neutral-700 text-neutral-500 rounded-lg text-xs font-medium opacity-0 group-hover:opacity-100 hover:text-red-400 hover:border-red-500/30 transition-all shrink-0"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,21 @@
|
||||
import { ReactNode, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import {
|
||||
Activity,
|
||||
GitBranch,
|
||||
Key,
|
||||
Settings,
|
||||
Menu,
|
||||
ChevronRight,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { KeyboardShortcutsHelp, ShortcutsHint } from "@/components/keyboard-shortcuts-help";
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
@@ -22,6 +29,7 @@ interface NavItem {
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/dashboard", label: "Traces", icon: Activity },
|
||||
{ href: "/dashboard/decisions", label: "Decisions", icon: GitBranch },
|
||||
{ href: "/dashboard/keys", label: "API Keys", icon: Key },
|
||||
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
@@ -99,11 +107,70 @@ function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function VerificationBanner() {
|
||||
const { data: session } = useSession();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [resending, setResending] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
if (dismissed || !session?.user || session.user.isEmailVerified) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleResend() {
|
||||
setResending(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/resend-verification", { method: "POST" });
|
||||
if (res.ok) {
|
||||
setSent(true);
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
setResending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-amber-500/10 border-b border-amber-500/20 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
|
||||
<p className="text-sm text-amber-200 truncate">
|
||||
{sent
|
||||
? "Verification email sent! Check your inbox."
|
||||
: "Please verify your email address. Check your inbox or"}
|
||||
</p>
|
||||
{!sent && (
|
||||
<button
|
||||
onClick={handleResend}
|
||||
disabled={resending}
|
||||
className="text-sm font-medium text-amber-400 hover:text-amber-300 transition-colors whitespace-nowrap inline-flex items-center gap-1"
|
||||
>
|
||||
{resending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{resending ? "sending..." : "click to resend."}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label="Dismiss verification banner"
|
||||
className="p-1 rounded text-amber-400/60 hover:text-amber-300 transition-colors shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-950 flex">
|
||||
<CommandPalette />
|
||||
<KeyboardShortcutsHelp />
|
||||
<ShortcutsHint />
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="hidden lg:block w-64 h-screen sticky top-0">
|
||||
<Sidebar />
|
||||
@@ -128,12 +195,14 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 min-w-0">
|
||||
<main id="main-content" className="flex-1 min-w-0">
|
||||
<VerificationBanner />
|
||||
{/* Mobile Header */}
|
||||
<header className="lg:hidden sticky top-0 z-30 bg-neutral-950/80 backdrop-blur-md border-b border-neutral-800 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Open navigation menu"
|
||||
className="p-2 rounded-lg bg-neutral-900 border border-neutral-800 text-neutral-400 hover:text-neutral-100 transition-colors"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Suspense } from "react";
|
||||
import { TraceList } from "@/components/trace-list";
|
||||
import { DemoSeedTrigger } from "@/components/demo-seed-trigger";
|
||||
import { DemoBanner } from "@/components/demo-banner";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface TracesResponse {
|
||||
traces: Array<{
|
||||
interface TraceItem {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "RUNNING" | "COMPLETED" | "ERROR";
|
||||
@@ -13,12 +14,16 @@ interface TracesResponse {
|
||||
durationMs: number | null;
|
||||
tags: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
isDemo?: boolean;
|
||||
_count: {
|
||||
decisionPoints: number;
|
||||
spans: number;
|
||||
events: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TracesResponse {
|
||||
traces: TraceItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
@@ -55,7 +60,13 @@ async function getTraces(
|
||||
export default async function DashboardPage() {
|
||||
const data = await getTraces(50, 1);
|
||||
|
||||
const hasTraces = data.traces.length > 0;
|
||||
const allTracesAreDemo =
|
||||
hasTraces && data.traces.every((t) => t.isDemo === true);
|
||||
|
||||
return (
|
||||
<DemoSeedTrigger hasTraces={hasTraces}>
|
||||
{allTracesAreDemo && <DemoBanner allTracesAreDemo={allTracesAreDemo} />}
|
||||
<Suspense fallback={<div className="p-8 text-zinc-400">Loading traces...</div>}>
|
||||
<TraceList
|
||||
initialTraces={data.traces}
|
||||
@@ -64,5 +75,6 @@ export default async function DashboardPage() {
|
||||
initialPage={data.page}
|
||||
/>
|
||||
</Suspense>
|
||||
</DemoSeedTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
Database,
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
CreditCard,
|
||||
Crown,
|
||||
Zap,
|
||||
ArrowUpRight,
|
||||
User,
|
||||
Calendar,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -21,12 +28,71 @@ interface Stats {
|
||||
totalEvents: number;
|
||||
}
|
||||
|
||||
interface AccountData {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
createdAt: string;
|
||||
subscription: {
|
||||
tier: "FREE" | "STARTER" | "PRO";
|
||||
status: "ACTIVE" | "PAST_DUE" | "CANCELED" | "UNPAID";
|
||||
sessionsUsed: number;
|
||||
sessionsLimit: number;
|
||||
currentPeriodStart: string | null;
|
||||
currentPeriodEnd: string | null;
|
||||
hasStripeSubscription: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const TIERS = [
|
||||
{
|
||||
key: "FREE" as const,
|
||||
name: "Free",
|
||||
price: 0,
|
||||
period: "day",
|
||||
sessions: 20,
|
||||
description: "For getting started",
|
||||
features: ["20 sessions per day", "Basic trace viewing", "Community support"],
|
||||
},
|
||||
{
|
||||
key: "STARTER" as const,
|
||||
name: "Starter",
|
||||
price: 5,
|
||||
period: "month",
|
||||
sessions: 1000,
|
||||
description: "For small teams",
|
||||
features: [
|
||||
"1,000 sessions per month",
|
||||
"Advanced analytics",
|
||||
"Priority support",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "PRO" as const,
|
||||
name: "Pro",
|
||||
price: 20,
|
||||
period: "month",
|
||||
sessions: 100000,
|
||||
description: "For production workloads",
|
||||
features: [
|
||||
"100,000 sessions per month",
|
||||
"Full analytics suite",
|
||||
"Dedicated support",
|
||||
"Custom retention",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [account, setAccount] = useState<AccountData | null>(null);
|
||||
const [isLoadingStats, setIsLoadingStats] = useState(true);
|
||||
const [isLoadingAccount, setIsLoadingAccount] = useState(true);
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
const [isPurging, setIsPurging] = useState(false);
|
||||
const [showPurgeConfirm, setShowPurgeConfirm] = useState(false);
|
||||
const [upgradingTier, setUpgradingTier] = useState<string | null>(null);
|
||||
const [isOpeningPortal, setIsOpeningPortal] = useState(false);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
setIsLoadingStats(true);
|
||||
@@ -43,9 +109,25 @@ export default function SettingsPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchAccount = useCallback(async () => {
|
||||
setIsLoadingAccount(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings/account", { cache: "no-store" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAccount(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch account:", error);
|
||||
} finally {
|
||||
setIsLoadingAccount(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, [fetchStats]);
|
||||
fetchAccount();
|
||||
}, [fetchStats, fetchAccount]);
|
||||
|
||||
const copyToClipboard = async (text: string, field: string) => {
|
||||
try {
|
||||
@@ -72,6 +154,48 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpgrade = async (tierKey: string) => {
|
||||
setUpgradingTier(tierKey);
|
||||
try {
|
||||
const res = await fetch("/api/stripe/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tierKey }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create checkout:", error);
|
||||
} finally {
|
||||
setUpgradingTier(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManageSubscription = async () => {
|
||||
setIsOpeningPortal(true);
|
||||
try {
|
||||
const res = await fetch("/api/stripe/portal", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to open portal:", error);
|
||||
} finally {
|
||||
setIsOpeningPortal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentTier = account?.subscription?.tier ?? "FREE";
|
||||
const sessionsUsed = account?.subscription?.sessionsUsed ?? 0;
|
||||
const sessionsLimit = account?.subscription?.sessionsLimit ?? 20;
|
||||
const usagePercent =
|
||||
sessionsLimit > 0
|
||||
? Math.min((sessionsUsed / sessionsLimit) * 100, 100)
|
||||
: 0;
|
||||
|
||||
const endpointUrl =
|
||||
typeof window !== "undefined"
|
||||
? `${window.location.origin}/api/traces`
|
||||
@@ -82,10 +206,225 @@ export default function SettingsPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-100">Settings</h1>
|
||||
<p className="text-neutral-400 mt-1">
|
||||
Configuration and SDK connection details
|
||||
Account, billing, and configuration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Account */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-neutral-300">
|
||||
<User className="w-5 h-5 text-emerald-400" />
|
||||
<h2 className="text-lg font-semibold">Account</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6">
|
||||
{isLoadingAccount ? (
|
||||
<div className="flex items-center gap-3 text-neutral-500">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">Loading account...</span>
|
||||
</div>
|
||||
) : account ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 font-medium mb-1">
|
||||
Email
|
||||
</p>
|
||||
<p className="text-sm text-neutral-200 font-medium">
|
||||
{account.email}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 font-medium mb-1">
|
||||
Name
|
||||
</p>
|
||||
<p className="text-sm text-neutral-200 font-medium">
|
||||
{account.name ?? "\u2014"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 font-medium mb-1">
|
||||
Member since
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 text-sm text-neutral-200 font-medium">
|
||||
<Calendar className="w-3.5 h-3.5 text-neutral-500" />
|
||||
{new Date(account.createdAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">
|
||||
Unable to load account info
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Subscription & Billing */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-neutral-300">
|
||||
<CreditCard className="w-5 h-5 text-emerald-400" />
|
||||
<h2 className="text-lg font-semibold">Subscription & Billing</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-neutral-400">Current plan</span>
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-500/10 border border-emerald-500/20 text-emerald-400">
|
||||
{currentTier === "PRO" && <Crown className="w-3 h-3" />}
|
||||
{currentTier === "STARTER" && <Zap className="w-3 h-3" />}
|
||||
{currentTier}
|
||||
</span>
|
||||
</div>
|
||||
{currentTier !== "FREE" &&
|
||||
account?.subscription?.hasStripeSubscription && (
|
||||
<button
|
||||
onClick={handleManageSubscription}
|
||||
disabled={isOpeningPortal}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-neutral-400 bg-neutral-800 border border-neutral-700 rounded-lg hover:text-neutral-200 hover:border-neutral-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isOpeningPortal ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<ArrowUpRight className="w-3 h-3" />
|
||||
)}
|
||||
Manage Subscription
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-neutral-400">
|
||||
{sessionsUsed.toLocaleString()} of{" "}
|
||||
{sessionsLimit.toLocaleString()} sessions used
|
||||
</span>
|
||||
<span className="text-neutral-500 text-xs">
|
||||
{currentTier === "FREE"
|
||||
? "20 sessions/day"
|
||||
: "This billing period"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-neutral-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-500",
|
||||
usagePercent > 90 ? "bg-amber-500" : "bg-emerald-500"
|
||||
)}
|
||||
style={{ width: `${usagePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{currentTier !== "FREE" &&
|
||||
account?.subscription?.currentPeriodStart &&
|
||||
account?.subscription?.currentPeriodEnd && (
|
||||
<p className="text-xs text-neutral-600">
|
||||
Period:{" "}
|
||||
{new Date(
|
||||
account.subscription.currentPeriodStart
|
||||
).toLocaleDateString()}{" "}
|
||||
\u2014{" "}
|
||||
{new Date(
|
||||
account.subscription.currentPeriodEnd
|
||||
).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{TIERS.map((tier) => {
|
||||
const isCurrent = currentTier === tier.key;
|
||||
const tierOrder = { FREE: 0, STARTER: 1, PRO: 2 };
|
||||
const isUpgrade = tierOrder[tier.key] > tierOrder[currentTier];
|
||||
const isDowngrade = tierOrder[tier.key] < tierOrder[currentTier];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tier.key}
|
||||
className={cn(
|
||||
"relative bg-neutral-900 border rounded-xl p-5 flex flex-col transition-colors",
|
||||
isCurrent
|
||||
? "border-emerald-500/40 shadow-[0_0_24px_-6px_rgba(16,185,129,0.12)]"
|
||||
: "border-neutral-800 hover:border-neutral-700"
|
||||
)}
|
||||
>
|
||||
{isCurrent && (
|
||||
<div className="absolute -top-2.5 left-4">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-emerald-500 text-neutral-950">
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<h3 className="text-base font-semibold text-neutral-100">
|
||||
{tier.name}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-500 mt-0.5">
|
||||
{tier.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<span className="text-2xl font-bold text-neutral-100">
|
||||
${tier.price}
|
||||
</span>
|
||||
<span className="text-sm text-neutral-500">
|
||||
/{tier.period}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2 mb-5 flex-1">
|
||||
{tier.features.map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="flex items-start gap-2 text-xs text-neutral-400"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5 text-emerald-500 mt-0.5 shrink-0" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{isCurrent ? (
|
||||
<div className="py-2 text-center text-xs font-medium text-emerald-400 bg-emerald-500/5 border border-emerald-500/10 rounded-lg">
|
||||
Active plan
|
||||
</div>
|
||||
) : isUpgrade ? (
|
||||
<button
|
||||
onClick={() => handleUpgrade(tier.key)}
|
||||
disabled={upgradingTier === tier.key}
|
||||
className="flex items-center justify-center gap-1.5 py-2 text-sm font-medium bg-emerald-500 hover:bg-emerald-400 text-neutral-950 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{upgradingTier === tier.key ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Upgrade
|
||||
</button>
|
||||
) : isDowngrade ? (
|
||||
<button
|
||||
onClick={handleManageSubscription}
|
||||
disabled={isOpeningPortal}
|
||||
className="flex items-center justify-center gap-1.5 py-2 text-sm font-medium bg-neutral-800 border border-neutral-700 text-neutral-300 rounded-lg hover:text-neutral-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isOpeningPortal && (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
)}
|
||||
Downgrade
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* SDK Connection */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-neutral-300">
|
||||
@@ -102,25 +441,17 @@ export default function SettingsPage() {
|
||||
onCopy={copyToClipboard}
|
||||
/>
|
||||
|
||||
<SettingField
|
||||
label="API Key"
|
||||
value="any-value-accepted"
|
||||
hint="Authentication is not enforced yet. Use any non-empty string as your Bearer token."
|
||||
copiedField={copiedField}
|
||||
fieldKey="apikey"
|
||||
onCopy={copyToClipboard}
|
||||
/>
|
||||
|
||||
<div className="pt-4 border-t border-neutral-800">
|
||||
<p className="text-xs text-neutral-500 mb-3">Quick start</p>
|
||||
<div className="bg-neutral-950 border border-neutral-800 rounded-lg p-4 font-mono text-sm text-neutral-300 overflow-x-auto">
|
||||
<pre>{`from agentlens import init
|
||||
|
||||
init(
|
||||
api_key="your-api-key",
|
||||
endpoint="${endpointUrl.replace("/api/traces", "")}",
|
||||
)`}</pre>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mb-1.5">API Key</p>
|
||||
<p className="text-xs text-neutral-600">
|
||||
Manage your API keys from the{" "}
|
||||
<a
|
||||
href="/dashboard/keys"
|
||||
className="text-emerald-400 hover:text-emerald-300 transition-colors underline underline-offset-2"
|
||||
>
|
||||
API Keys page
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -275,9 +606,7 @@ function SettingField({
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{hint && (
|
||||
<p className="text-xs text-neutral-600 mt-1.5">{hint}</p>
|
||||
)}
|
||||
{hint && <p className="text-xs text-neutral-600 mt-1.5">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,26 @@ export default function GettingStartedPage() {
|
||||
</a>
|
||||
)
|
||||
</li>
|
||||
<li>An API key for authentication</li>
|
||||
<li>
|
||||
An AgentLens account —{" "}
|
||||
<a
|
||||
href="/register"
|
||||
className="text-emerald-400 hover:underline"
|
||||
>
|
||||
sign up here
|
||||
</a>{" "}
|
||||
if you haven{"'"}t already
|
||||
</li>
|
||||
<li>
|
||||
An API key (create one in{" "}
|
||||
<a
|
||||
href="/dashboard/keys"
|
||||
className="text-emerald-400 hover:underline"
|
||||
>
|
||||
Dashboard → API Keys
|
||||
</a>
|
||||
)
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -62,6 +81,23 @@ export default function GettingStartedPage() {
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
Step 2: Initialize AgentLens
|
||||
</h2>
|
||||
<p className="text-neutral-400 leading-relaxed mb-4">
|
||||
Sign up at{" "}
|
||||
<a
|
||||
href="https://agentlens.vectry.tech/register"
|
||||
className="text-emerald-400 hover:underline"
|
||||
>
|
||||
agentlens.vectry.tech
|
||||
</a>
|
||||
, then go to{" "}
|
||||
<a
|
||||
href="/dashboard/keys"
|
||||
className="text-emerald-400 hover:underline"
|
||||
>
|
||||
Dashboard → API Keys
|
||||
</a>{" "}
|
||||
to create your key. Pass it to the SDK during initialization:
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-medium text-neutral-200 mb-2">Python</h3>
|
||||
<CodeBlock title="main.py" language="python">{`import agentlens
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function PythonSdkPage() {
|
||||
<ApiSection
|
||||
name="init()"
|
||||
signature="agentlens.init(api_key, endpoint, *, flush_interval=5.0, max_batch_size=100, enabled=True)"
|
||||
description="Initialize the AgentLens SDK. Must be called before any tracing functions. Typically called once at application startup."
|
||||
description="Initialize the AgentLens SDK. Must be called before any tracing functions. Typically called once at application startup. Your API key can be created after registering at agentlens.vectry.tech — go to Dashboard > API Keys to generate one."
|
||||
>
|
||||
<h4 className="text-sm font-medium text-neutral-300 mb-2">
|
||||
Parameters
|
||||
@@ -70,7 +70,7 @@ export default function PythonSdkPage() {
|
||||
<td className="py-2 pr-4 font-mono text-emerald-400 text-xs">api_key</td>
|
||||
<td className="py-2 pr-4 text-neutral-500">str</td>
|
||||
<td className="py-2 pr-4 text-neutral-500">required</td>
|
||||
<td className="py-2">Your AgentLens API key</td>
|
||||
<td className="py-2">Your AgentLens API key (from <a href="/dashboard/keys" className="text-emerald-400 hover:underline">Dashboard → API Keys</a>)</td>
|
||||
</tr>
|
||||
<tr className="border-b border-neutral-800/50">
|
||||
<td className="py-2 pr-4 font-mono text-emerald-400 text-xs">endpoint</td>
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function TypeScriptSdkPage() {
|
||||
<ApiSection
|
||||
name="init()"
|
||||
signature='init({ apiKey, endpoint, flushInterval?, maxBatchSize?, enabled? })'
|
||||
description="Initialize the SDK. Must be called once before creating any traces."
|
||||
description="Initialize the SDK. Must be called once before creating any traces. Your API key can be created after registering at agentlens.vectry.tech — go to Dashboard > API Keys to generate one."
|
||||
>
|
||||
<h4 className="text-sm font-medium text-neutral-300 mb-2">
|
||||
Options
|
||||
@@ -72,7 +72,7 @@ export default function TypeScriptSdkPage() {
|
||||
<td className="py-2 pr-4 font-mono text-emerald-400 text-xs">apiKey</td>
|
||||
<td className="py-2 pr-4 text-neutral-500">string</td>
|
||||
<td className="py-2 pr-4 text-neutral-500">required</td>
|
||||
<td className="py-2">Your AgentLens API key</td>
|
||||
<td className="py-2">Your AgentLens API key (from <a href="/dashboard/keys" className="text-emerald-400 hover:underline">Dashboard → API Keys</a>)</td>
|
||||
</tr>
|
||||
<tr className="border-b border-neutral-800/50">
|
||||
<td className="py-2 pr-4 font-mono text-emerald-400 text-xs">endpoint</td>
|
||||
|
||||
@@ -1 +1,74 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Surfaces */
|
||||
--surface-page: #0a0a0a;
|
||||
--surface-card: rgb(23 23 23); /* neutral-900 */
|
||||
--surface-card-hover: rgb(38 38 38 / 0.5); /* neutral-800/50 */
|
||||
--surface-elevated: rgb(23 23 23); /* neutral-900 */
|
||||
--surface-input: rgb(10 10 10); /* neutral-950 */
|
||||
|
||||
/* Text */
|
||||
--text-primary: rgb(245 245 245); /* neutral-100 */
|
||||
--text-secondary: rgb(163 163 163); /* neutral-400 */
|
||||
--text-muted: rgb(115 115 115); /* neutral-500 */
|
||||
|
||||
/* Borders */
|
||||
--border-default: rgb(38 38 38); /* neutral-800 */
|
||||
--border-subtle: rgb(38 38 38 / 0.5); /* neutral-800/50 */
|
||||
--border-strong: rgb(64 64 64); /* neutral-700 */
|
||||
|
||||
/* Accent (AgentLens emerald) */
|
||||
--accent: #10b981;
|
||||
--accent-hover: #34d399;
|
||||
--accent-muted: rgba(16, 185, 129, 0.15);
|
||||
--accent-foreground: #0a0a0a;
|
||||
|
||||
/* Radius */
|
||||
--radius-card: 1rem;
|
||||
--radius-button: 0.5rem;
|
||||
--radius-icon: 0.75rem;
|
||||
--radius-badge: 9999px;
|
||||
|
||||
/* Fonts */
|
||||
--font-sans: var(--font-inter), system-ui, sans-serif;
|
||||
--font-mono: var(--font-jetbrains), 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
[data-animate="hidden"] {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
transition: opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
[data-animate="visible"] {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
[data-animate="hidden"][style*="animation-delay"] {
|
||||
transition-delay: inherit;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-animate="hidden"] {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Inter } from "next/font/google";
|
||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||
import type { Metadata } from "next";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
|
||||
const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains", display: "swap" });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://agentlens.vectry.tech"),
|
||||
@@ -24,6 +26,13 @@ export const metadata: Metadata = {
|
||||
],
|
||||
authors: [{ name: "Vectry" }],
|
||||
creator: "Vectry",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.ico", sizes: "any" },
|
||||
{ url: "/icon.png", sizes: "512x512", type: "image/png" },
|
||||
],
|
||||
apple: [{ url: "/apple-icon.png", sizes: "180x180" }],
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
@@ -71,8 +80,14 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className={`${inter.className} bg-neutral-950 text-neutral-100 antialiased`}>
|
||||
{children}
|
||||
<body className={`${inter.variable} ${jetbrainsMono.variable} bg-neutral-950 text-neutral-100 antialiased`}>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[200] focus:px-4 focus:py-2 focus:rounded-lg focus:bg-emerald-500 focus:text-neutral-950 focus:font-semibold focus:text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:ring-offset-2 focus:ring-offset-neutral-950"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
Bot,
|
||||
Star,
|
||||
Clipboard,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { AnimateOnScroll } from "@/components/animate-on-scroll";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-950">
|
||||
<main id="main-content" className="min-h-screen bg-neutral-950">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
@@ -32,7 +34,34 @@ export default function HomePage() {
|
||||
url: "https://agentlens.vectry.tech",
|
||||
description:
|
||||
"Open-source agent observability platform that traces AI agent decisions, not just API calls.",
|
||||
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
||||
offers: [
|
||||
{
|
||||
"@type": "Offer",
|
||||
name: "Free",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
description:
|
||||
"20 sessions per day, full dashboard access, 1 API key, community support",
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
name: "Starter",
|
||||
price: "5",
|
||||
priceCurrency: "USD",
|
||||
billingIncrement: "P1M",
|
||||
description:
|
||||
"1,000 sessions per month, full dashboard access, unlimited API keys, email support",
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
name: "Pro",
|
||||
price: "20",
|
||||
priceCurrency: "USD",
|
||||
billingIncrement: "P1M",
|
||||
description:
|
||||
"100,000 sessions per month, full dashboard access, unlimited API keys, priority support",
|
||||
},
|
||||
],
|
||||
featureList: [
|
||||
"Agent Decision Tracing",
|
||||
"Real-time Dashboard",
|
||||
@@ -67,7 +96,7 @@ export default function HomePage() {
|
||||
{/* Subtle grid pattern for depth */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.012)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.012)_1px,transparent_1px)] bg-[size:64px_64px]" />
|
||||
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-20 pb-24">
|
||||
<div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pt-20 pb-24">
|
||||
<div className="text-center">
|
||||
{/* Top badges row */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 mb-8">
|
||||
@@ -132,7 +161,8 @@ export default function HomePage() {
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-24 border-b border-neutral-800/50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<AnimateOnScroll>
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold mb-4">
|
||||
Everything you need to understand your agents
|
||||
@@ -141,9 +171,11 @@ export default function HomePage() {
|
||||
From decision trees to cost intelligence, get complete visibility into how your AI systems operate
|
||||
</p>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{/* Feature 1: Decision Trees */}
|
||||
<AnimateOnScroll delay={0}>
|
||||
<div className="group p-8 rounded-2xl border border-neutral-800/50 bg-gradient-to-b from-neutral-900/50 to-transparent hover:border-emerald-500/30 transition-all duration-300">
|
||||
<div className="w-14 h-14 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<GitBranch className="w-7 h-7 text-emerald-400" />
|
||||
@@ -153,8 +185,10 @@ export default function HomePage() {
|
||||
Visualize the complete reasoning behind every agent choice. See the branching logic, alternatives considered, and the path chosen.
|
||||
</p>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
|
||||
{/* Feature 2: Context Awareness */}
|
||||
<AnimateOnScroll delay={100}>
|
||||
<div className="group p-8 rounded-2xl border border-neutral-800/50 bg-gradient-to-b from-neutral-900/50 to-transparent hover:border-emerald-500/30 transition-all duration-300">
|
||||
<div className="w-14 h-14 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<Brain className="w-7 h-7 text-emerald-400" />
|
||||
@@ -164,8 +198,10 @@ export default function HomePage() {
|
||||
Monitor context window utilization in real-time. Track what's being fed into your agents and what's being left behind.
|
||||
</p>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
|
||||
{/* Feature 3: Cost Intelligence */}
|
||||
<AnimateOnScroll delay={200}>
|
||||
<div className="group p-8 rounded-2xl border border-neutral-800/50 bg-gradient-to-b from-neutral-900/50 to-transparent hover:border-emerald-500/30 transition-all duration-300">
|
||||
<div className="w-14 h-14 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<DollarSign className="w-7 h-7 text-emerald-400" />
|
||||
@@ -175,6 +211,7 @@ export default function HomePage() {
|
||||
Track spending per decision, per agent, per trace. Get granular insights into where every dollar goes in your AI operations.
|
||||
</p>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -182,7 +219,8 @@ export default function HomePage() {
|
||||
{/* How it Works Section */}
|
||||
<section className="py-24 border-b border-neutral-800/50 relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_60%_40%_at_50%_50%,rgba(16,185,129,0.04),transparent)]" />
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<AnimateOnScroll>
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6">
|
||||
<Zap className="w-4 h-4" />
|
||||
@@ -195,9 +233,11 @@ export default function HomePage() {
|
||||
Go from zero to full agent observability in under five minutes
|
||||
</p>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 lg:gap-8">
|
||||
{/* Step 1: Install */}
|
||||
<AnimateOnScroll delay={0}>
|
||||
<div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30">
|
||||
<div className="absolute -top-4 left-8">
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-emerald-500 text-neutral-950 text-sm font-bold shadow-lg shadow-emerald-500/25">
|
||||
@@ -215,8 +255,10 @@ export default function HomePage() {
|
||||
<code className="text-sm font-mono text-emerald-400">pip install vectry-agentlens</code>
|
||||
</div>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
|
||||
{/* Step 2: Instrument */}
|
||||
<AnimateOnScroll delay={100}>
|
||||
<div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30">
|
||||
<div className="absolute -top-4 left-8">
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-emerald-500 text-neutral-950 text-sm font-bold shadow-lg shadow-emerald-500/25">
|
||||
@@ -236,8 +278,10 @@ export default function HomePage() {
|
||||
<code className="text-sm font-mono text-emerald-400">wrap_openai()</code>
|
||||
</div>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
|
||||
{/* Step 3: Observe */}
|
||||
<AnimateOnScroll delay={200}>
|
||||
<div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30">
|
||||
<div className="absolute -top-4 left-8">
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-emerald-500 text-neutral-950 text-sm font-bold shadow-lg shadow-emerald-500/25">
|
||||
@@ -255,6 +299,7 @@ export default function HomePage() {
|
||||
<code className="text-sm font-mono text-emerald-400">agentlens.vectry.tech</code>
|
||||
</div>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
</div>
|
||||
|
||||
{/* Connecting arrows decoration */}
|
||||
@@ -272,8 +317,9 @@ export default function HomePage() {
|
||||
|
||||
{/* Code Example Section */}
|
||||
<section className="py-24 border-b border-neutral-800/50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid lg:grid-cols-2 gap-12 items-start">
|
||||
<AnimateOnScroll>
|
||||
<div className="lg:sticky lg:top-8">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6">
|
||||
<Cpu className="w-4 h-4" />
|
||||
@@ -303,8 +349,10 @@ export default function HomePage() {
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
|
||||
{/* Code Blocks - Two patterns stacked */}
|
||||
<AnimateOnScroll delay={150}>
|
||||
<div className="space-y-6">
|
||||
{/* Decorator Pattern */}
|
||||
<div className="rounded-xl overflow-hidden border border-neutral-800 bg-neutral-900/50 backdrop-blur-sm">
|
||||
@@ -452,6 +500,7 @@ export default function HomePage() {
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -459,7 +508,8 @@ export default function HomePage() {
|
||||
{/* Integrations Section */}
|
||||
<section className="py-24 border-b border-neutral-800/50 relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_50%_50%_at_50%_50%,rgba(16,185,129,0.03),transparent)]" />
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<AnimateOnScroll>
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6">
|
||||
<Link2 className="w-4 h-4" />
|
||||
@@ -472,7 +522,9 @@ export default function HomePage() {
|
||||
First-class support for the most popular AI frameworks. Drop in and start tracing.
|
||||
</p>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
|
||||
<AnimateOnScroll>
|
||||
<div className="grid sm:grid-cols-3 gap-6 max-w-3xl mx-auto">
|
||||
{/* OpenAI */}
|
||||
<div className="group flex flex-col items-center p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30 hover:border-emerald-500/20 transition-all duration-300">
|
||||
@@ -510,6 +562,130 @@ export default function HomePage() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing Section */}
|
||||
<section className="py-24 border-b border-neutral-800/50 relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_50%_at_50%_50%,rgba(16,185,129,0.04),transparent)]" />
|
||||
<div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<AnimateOnScroll>
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Pricing</span>
|
||||
</div>
|
||||
<h2 className="text-3xl sm:text-4xl font-bold mb-4">
|
||||
Simple, transparent pricing
|
||||
</h2>
|
||||
<p className="text-lg text-neutral-400 max-w-2xl mx-auto">
|
||||
No hidden fees. Start free, scale as you grow. Every plan includes the full dashboard experience.
|
||||
</p>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
|
||||
<AnimateOnScroll>
|
||||
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
{/* Free Tier */}
|
||||
<div className="relative flex flex-col p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30 transition-all duration-300 hover:border-neutral-700">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-semibold mb-1">Free</h3>
|
||||
<p className="text-sm text-neutral-500">For experimentation</p>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<span className="text-4xl font-bold">$0</span>
|
||||
<span className="text-neutral-500 ml-1">/month</span>
|
||||
</div>
|
||||
<ul className="space-y-3 mb-8 flex-1">
|
||||
{[
|
||||
"20 sessions per day",
|
||||
"Full dashboard access",
|
||||
"1 API key",
|
||||
"Community support",
|
||||
].map((feature, i) => (
|
||||
<li key={i} className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-neutral-600 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-neutral-400">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
href="/register"
|
||||
className="block w-full text-center px-6 py-3 rounded-lg border border-neutral-700 hover:border-neutral-600 text-neutral-300 font-medium transition-all duration-200 hover:bg-neutral-800/50"
|
||||
>
|
||||
Get Started Free
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Starter Tier — Highlighted */}
|
||||
<div className="relative flex flex-col p-8 rounded-2xl border border-emerald-500/40 bg-gradient-to-b from-emerald-500/[0.07] via-neutral-900/50 to-neutral-900/30 transition-all duration-300 shadow-[0_0_40px_-12px_rgba(16,185,129,0.15)]">
|
||||
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2">
|
||||
<span className="inline-flex items-center px-3.5 py-1 rounded-full bg-emerald-500 text-neutral-950 text-xs font-bold tracking-wide shadow-lg shadow-emerald-500/25">
|
||||
Most Popular
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-semibold mb-1">Starter</h3>
|
||||
<p className="text-sm text-neutral-500">For small teams</p>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<span className="text-4xl font-bold">$5</span>
|
||||
<span className="text-neutral-500 ml-1">/month</span>
|
||||
</div>
|
||||
<ul className="space-y-3 mb-8 flex-1">
|
||||
{[
|
||||
"1,000 sessions per month",
|
||||
"Full dashboard access",
|
||||
"Unlimited API keys",
|
||||
"Email support",
|
||||
].map((feature, i) => (
|
||||
<li key={i} className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500/70 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-neutral-300">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
href="/register"
|
||||
className="block w-full text-center px-6 py-3 rounded-lg bg-emerald-500 hover:bg-emerald-400 text-neutral-950 font-semibold transition-all duration-200 shadow-lg shadow-emerald-500/25 hover:shadow-emerald-500/40"
|
||||
>
|
||||
Start Starter Plan
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Pro Tier */}
|
||||
<div className="relative flex flex-col p-8 rounded-2xl border border-neutral-800/50 bg-gradient-to-b from-neutral-900/50 to-transparent transition-all duration-300 hover:border-neutral-700">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-semibold mb-1">Pro</h3>
|
||||
<p className="text-sm text-neutral-500">For scaling teams</p>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<span className="text-4xl font-bold">$20</span>
|
||||
<span className="text-neutral-500 ml-1">/month</span>
|
||||
</div>
|
||||
<ul className="space-y-3 mb-8 flex-1">
|
||||
{[
|
||||
"100,000 sessions per month",
|
||||
"Full dashboard access",
|
||||
"Unlimited API keys",
|
||||
"Priority support",
|
||||
].map((feature, i) => (
|
||||
<li key={i} className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-neutral-600 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-neutral-400">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
href="/register"
|
||||
className="block w-full text-center px-6 py-3 rounded-lg border border-neutral-700 hover:border-emerald-500/40 text-neutral-300 hover:text-emerald-400 font-medium transition-all duration-200 hover:bg-emerald-500/5"
|
||||
>
|
||||
Start Pro Plan
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -542,6 +718,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
9
apps/web/src/auth.config.ts
Normal file
9
apps/web/src/auth.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { NextAuthConfig } from "next-auth";
|
||||
|
||||
export default {
|
||||
providers: [],
|
||||
session: { strategy: "jwt", maxAge: 7 * 24 * 60 * 60 },
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
} satisfies NextAuthConfig;
|
||||
91
apps/web/src/auth.ts
Normal file
91
apps/web/src/auth.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { compare } from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
|
||||
import authConfig from "./auth.config";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string | null;
|
||||
image?: string | null;
|
||||
isEmailVerified: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@auth/core/jwt" {
|
||||
interface JWT {
|
||||
id: string;
|
||||
isEmailVerified: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
});
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
...authConfig,
|
||||
providers: [
|
||||
Credentials({
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials, request) {
|
||||
const parsed = loginSchema.safeParse(credentials);
|
||||
if (!parsed.success) return null;
|
||||
|
||||
const { email, password } = parsed.data;
|
||||
const ip = (request instanceof Request
|
||||
? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||
: undefined) ?? "unknown";
|
||||
|
||||
const rl = await checkRateLimit(`login:${ip}`, AUTH_RATE_LIMITS.login);
|
||||
if (!rl.allowed) return null;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
if (!user) return null;
|
||||
|
||||
const isValid = await compare(password, user.passwordHash);
|
||||
if (!isValid) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user, trigger }) {
|
||||
if (user) {
|
||||
token.id = user.id as string;
|
||||
}
|
||||
if (trigger === "update" || user) {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: token.id },
|
||||
select: { emailVerified: true },
|
||||
});
|
||||
if (dbUser) {
|
||||
token.isEmailVerified = dbUser.emailVerified;
|
||||
}
|
||||
}
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
session.user.id = token.id;
|
||||
session.user.isEmailVerified = token.isEmailVerified;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
});
|
||||
60
apps/web/src/components/animate-on-scroll.tsx
Normal file
60
apps/web/src/components/animate-on-scroll.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, ReactNode } from "react";
|
||||
|
||||
interface AnimateOnScrollProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export function AnimateOnScroll({
|
||||
children,
|
||||
className = "",
|
||||
delay = 0,
|
||||
threshold = 0.15,
|
||||
}: AnimateOnScrollProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const prefersReduced = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)"
|
||||
).matches;
|
||||
|
||||
if (prefersReduced) {
|
||||
el.setAttribute("data-animate", "visible");
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.setAttribute("data-animate", "visible");
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [threshold]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-animate="hidden"
|
||||
className={className}
|
||||
style={{ animationDelay: delay ? `${delay}ms` : undefined }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
258
apps/web/src/components/command-palette.tsx
Normal file
258
apps/web/src/components/command-palette.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { Command } from "cmdk";
|
||||
import {
|
||||
Activity,
|
||||
GitBranch,
|
||||
Key,
|
||||
Settings,
|
||||
LogOut,
|
||||
Plus,
|
||||
Search,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RecentTrace {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [recentTraces, setRecentTraces] = useState<RecentTrace[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchRecentTraces = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/traces?limit=5", { cache: "no-store" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setRecentTraces(data.traces ?? []);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail -- palette still works for navigation
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchRecentTraces();
|
||||
}
|
||||
}, [open, fetchRecentTraces]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
function runCommand(command: () => void) {
|
||||
setOpen(false);
|
||||
command();
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Palette */}
|
||||
<div className="absolute inset-0 flex items-start justify-center pt-[20vh] px-4">
|
||||
<Command
|
||||
className="w-full max-w-xl rounded-xl border border-neutral-800 bg-neutral-900/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
loop
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 border-b border-neutral-800 px-4">
|
||||
<Search className="w-4 h-4 text-neutral-500 shrink-0" />
|
||||
<Command.Input
|
||||
placeholder="Search traces, navigate, or run actions..."
|
||||
className="w-full py-4 bg-transparent text-sm text-neutral-100 placeholder-neutral-500 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<kbd className="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-[10px] font-mono text-neutral-500">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<Command.List className="max-h-80 overflow-y-auto p-2">
|
||||
<Command.Empty className="py-8 text-center text-sm text-neutral-500">
|
||||
No results found.
|
||||
</Command.Empty>
|
||||
|
||||
{/* Recent Traces */}
|
||||
{recentTraces.length > 0 && (
|
||||
<Command.Group
|
||||
heading="Recent Traces"
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="px-2 py-3 text-xs text-neutral-500">
|
||||
Loading traces...
|
||||
</div>
|
||||
) : (
|
||||
recentTraces.map((trace) => (
|
||||
<Command.Item
|
||||
key={trace.id}
|
||||
value={`trace ${trace.name} ${trace.id}`}
|
||||
onSelect={() =>
|
||||
runCommand(() =>
|
||||
router.push(`/dashboard/traces/${trace.id}`)
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer transition-colors",
|
||||
"text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400"
|
||||
)}
|
||||
>
|
||||
<Activity className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1 truncate">{trace.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-1.5 py-0.5 rounded",
|
||||
trace.status === "COMPLETED" &&
|
||||
"bg-emerald-500/10 text-emerald-400",
|
||||
trace.status === "ERROR" &&
|
||||
"bg-red-500/10 text-red-400",
|
||||
trace.status === "RUNNING" &&
|
||||
"bg-amber-500/10 text-amber-400"
|
||||
)}
|
||||
>
|
||||
{trace.status.toLowerCase()}
|
||||
</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 shrink-0 opacity-0 group-data-[selected=true]:opacity-100" />
|
||||
</Command.Item>
|
||||
))
|
||||
)}
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<Command.Group
|
||||
heading="Navigation"
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500"
|
||||
>
|
||||
<Command.Item
|
||||
value="Dashboard Traces"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<Activity className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">Dashboard</span>
|
||||
</Command.Item>
|
||||
|
||||
<Command.Item
|
||||
value="Decisions"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard/decisions"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<GitBranch className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">Decisions</span>
|
||||
</Command.Item>
|
||||
|
||||
<Command.Item
|
||||
value="API Keys"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard/keys"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<Key className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">API Keys</span>
|
||||
</Command.Item>
|
||||
|
||||
<Command.Item
|
||||
value="Settings"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard/settings"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">Settings</span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
{/* Actions */}
|
||||
<Command.Group
|
||||
heading="Actions"
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500"
|
||||
>
|
||||
<Command.Item
|
||||
value="Create New API Key"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard/keys"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">New API Key</span>
|
||||
</Command.Item>
|
||||
|
||||
<Command.Item
|
||||
value="Sign Out Logout"
|
||||
onSelect={() =>
|
||||
runCommand(() => signOut({ callbackUrl: "/" }))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-red-500/10 data-[selected=true]:text-red-400 transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">Logout</span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
</Command.List>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-neutral-800 px-4 py-2.5">
|
||||
<div className="flex items-center gap-3 text-[11px] text-neutral-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 font-mono">
|
||||
↑↓
|
||||
</kbd>
|
||||
Navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 font-mono">
|
||||
↵
|
||||
</kbd>
|
||||
Select
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 font-mono">
|
||||
esc
|
||||
</kbd>
|
||||
Close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
apps/web/src/components/demo-banner.tsx
Normal file
65
apps/web/src/components/demo-banner.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Beaker, ArrowRight, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DISMISS_KEY = "agentlens-demo-banner-dismissed";
|
||||
|
||||
interface DemoBannerProps {
|
||||
allTracesAreDemo: boolean;
|
||||
}
|
||||
|
||||
export function DemoBanner({ allTracesAreDemo }: DemoBannerProps) {
|
||||
const [dismissed, setDismissed] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setDismissed(localStorage.getItem(DISMISS_KEY) === "true");
|
||||
}, []);
|
||||
|
||||
if (dismissed || !allTracesAreDemo) return null;
|
||||
|
||||
function handleDismiss() {
|
||||
setDismissed(true);
|
||||
localStorage.setItem(DISMISS_KEY, "true");
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative mb-6 rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4",
|
||||
"flex items-center gap-4"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-emerald-500/10 border border-emerald-500/20 shrink-0">
|
||||
<Beaker className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-emerald-200 font-medium">
|
||||
You are viewing sample data.
|
||||
</p>
|
||||
<p className="text-xs text-emerald-400/60 mt-0.5">
|
||||
Connect your agent to start collecting real traces.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/docs/getting-started"
|
||||
className="hidden sm:flex items-center gap-1.5 px-4 py-2 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-sm font-medium text-emerald-400 hover:bg-emerald-500/20 transition-colors shrink-0"
|
||||
>
|
||||
View Setup Guide
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss demo banner"
|
||||
className="p-1.5 rounded-lg text-emerald-400/40 hover:text-emerald-400/80 hover:bg-emerald-500/10 transition-colors shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
apps/web/src/components/demo-seed-trigger.tsx
Normal file
43
apps/web/src/components/demo-seed-trigger.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface DemoSeedTriggerProps {
|
||||
hasTraces: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DemoSeedTrigger({ hasTraces, children }: DemoSeedTriggerProps) {
|
||||
const [seeding, setSeeding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasTraces || seeding) return;
|
||||
|
||||
async function seedIfNeeded() {
|
||||
setSeeding(true);
|
||||
try {
|
||||
const res = await fetch("/api/demo/seed", { method: "POST" });
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
} catch {
|
||||
// Seed failed, continue showing empty state
|
||||
} finally {
|
||||
setSeeding(false);
|
||||
}
|
||||
}
|
||||
|
||||
seedIfNeeded();
|
||||
}, [hasTraces, seeding]);
|
||||
|
||||
if (!hasTraces && seeding) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-10 h-10 rounded-xl border-2 border-emerald-500/30 border-t-emerald-500 animate-spin mb-4" />
|
||||
<p className="text-sm text-neutral-400">Setting up your workspace with sample data...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
113
apps/web/src/components/keyboard-shortcuts-help.tsx
Normal file
113
apps/web/src/components/keyboard-shortcuts-help.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: ["j"], description: "Move selection down" },
|
||||
{ keys: ["k"], description: "Move selection up" },
|
||||
{ keys: ["Enter"], description: "Open selected item" },
|
||||
{ keys: ["Escape"], description: "Clear selection / go back" },
|
||||
{ keys: ["g", "h"], description: "Go to Dashboard" },
|
||||
{ keys: ["g", "s"], description: "Go to Settings" },
|
||||
{ keys: ["g", "k"], description: "Go to API Keys" },
|
||||
{ keys: ["g", "d"], description: "Go to Decisions" },
|
||||
{ keys: ["Cmd", "K"], description: "Open command palette" },
|
||||
{ keys: ["?"], description: "Show this help" },
|
||||
];
|
||||
|
||||
export function KeyboardShortcutsHelp() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const el = document.activeElement;
|
||||
if (el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") return;
|
||||
if ((el as HTMLElement).isContentEditable) return;
|
||||
}
|
||||
|
||||
if (e.key === "?" && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
|
||||
if (e.key === "Escape" && open) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90]">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-neutral-800 bg-neutral-900/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800">
|
||||
<h2 className="text-sm font-semibold text-neutral-100">
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close shortcuts help"
|
||||
className="p-1.5 rounded-lg text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-2 max-h-[60vh] overflow-y-auto">
|
||||
{shortcuts.map((shortcut, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-neutral-800/50"
|
||||
>
|
||||
<span className="text-sm text-neutral-400">
|
||||
{shortcut.description}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{shortcut.keys.map((key, j) => (
|
||||
<span key={j}>
|
||||
{j > 0 && (
|
||||
<span className="text-neutral-600 text-xs mx-0.5">
|
||||
then
|
||||
</span>
|
||||
)}
|
||||
<kbd className="inline-flex items-center justify-center min-w-[24px] px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-xs font-mono text-neutral-300">
|
||||
{key}
|
||||
</kbd>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShortcutsHint() {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-30">
|
||||
<span className="text-xs text-neutral-600 flex items-center gap-1.5">
|
||||
Press
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 text-[10px] font-mono text-neutral-500">
|
||||
?
|
||||
</kbd>
|
||||
for shortcuts
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { cn, formatDuration, formatRelativeTime } from "@/lib/utils";
|
||||
import { useKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
|
||||
type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR";
|
||||
|
||||
@@ -87,6 +88,7 @@ export function TraceList({
|
||||
initialTotalPages,
|
||||
initialPage,
|
||||
}: TraceListProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [traces, setTraces] = useState<Trace[]>(initialTraces);
|
||||
const [total, setTotal] = useState(initialTotal);
|
||||
@@ -283,6 +285,19 @@ export function TraceList({
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const { selectedIndex } = useKeyboardNav({
|
||||
itemCount: filteredTraces.length,
|
||||
onSelect: useCallback(
|
||||
(index: number) => {
|
||||
const trace = filteredTraces[index];
|
||||
if (trace) {
|
||||
router.push(`/dashboard/traces/${trace.id}`);
|
||||
}
|
||||
},
|
||||
[filteredTraces, router]
|
||||
),
|
||||
});
|
||||
|
||||
const filterChips: { value: FilterStatus; label: string }[] = [
|
||||
{ value: "ALL", label: "All" },
|
||||
{ value: "RUNNING", label: "Running" },
|
||||
@@ -376,7 +391,9 @@ export function TraceList({
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-neutral-500" />
|
||||
<label htmlFor="trace-search" className="sr-only">Search traces</label>
|
||||
<input
|
||||
id="trace-search"
|
||||
type="text"
|
||||
placeholder="Search traces..."
|
||||
value={searchQuery}
|
||||
@@ -422,8 +439,9 @@ export function TraceList({
|
||||
{showAdvancedFilters && (
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-neutral-500 font-medium">Sort by</label>
|
||||
<label htmlFor="sort-filter" className="text-xs text-neutral-500 font-medium">Sort by</label>
|
||||
<select
|
||||
id="sort-filter"
|
||||
value={sortFilter}
|
||||
onChange={(e) => setSortFilter(e.target.value as SortOption)}
|
||||
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 transition-all"
|
||||
@@ -437,8 +455,9 @@ export function TraceList({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-neutral-500 font-medium">Date from</label>
|
||||
<label htmlFor="date-from" className="text-xs text-neutral-500 font-medium">Date from</label>
|
||||
<input
|
||||
id="date-from"
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
@@ -447,8 +466,9 @@ export function TraceList({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-neutral-500 font-medium">Date to</label>
|
||||
<label htmlFor="date-to" className="text-xs text-neutral-500 font-medium">Date to</label>
|
||||
<input
|
||||
id="date-to"
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
@@ -457,8 +477,9 @@ export function TraceList({
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3 space-y-2">
|
||||
<label className="text-xs text-neutral-500 font-medium">Tags (comma-separated)</label>
|
||||
<label htmlFor="tags-filter" className="text-xs text-neutral-500 font-medium">Tags (comma-separated)</label>
|
||||
<input
|
||||
id="tags-filter"
|
||||
type="text"
|
||||
placeholder="e.g., production, critical, api"
|
||||
value={tagsFilter}
|
||||
@@ -473,8 +494,13 @@ export function TraceList({
|
||||
|
||||
{/* Trace List */}
|
||||
<div className="space-y-3">
|
||||
{filteredTraces.map((trace) => (
|
||||
<TraceCard key={trace.id} trace={trace} />
|
||||
{filteredTraces.map((trace, index) => (
|
||||
<TraceCard
|
||||
key={trace.id}
|
||||
trace={trace}
|
||||
index={index}
|
||||
isSelected={index === selectedIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -497,6 +523,7 @@ export function TraceList({
|
||||
<button
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
|
||||
aria-label="Previous page"
|
||||
className="p-2 rounded-lg border border-neutral-800 text-neutral-400 hover:text-neutral-100 hover:border-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
@@ -504,6 +531,7 @@ export function TraceList({
|
||||
<button
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))}
|
||||
aria-label="Next page"
|
||||
className="p-2 rounded-lg border border-neutral-800 text-neutral-400 hover:text-neutral-100 hover:border-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
@@ -515,13 +543,29 @@ export function TraceList({
|
||||
);
|
||||
}
|
||||
|
||||
function TraceCard({ trace }: { trace: Trace }) {
|
||||
function TraceCard({
|
||||
trace,
|
||||
index,
|
||||
isSelected,
|
||||
}: {
|
||||
trace: Trace;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
const status = statusConfig[trace.status];
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/traces/${trace.id}`}>
|
||||
<div className="group p-5 bg-neutral-900 border border-neutral-800 rounded-xl hover:border-emerald-500/30 hover:bg-neutral-800/50 transition-all duration-200 cursor-pointer">
|
||||
<div
|
||||
data-keyboard-index={index}
|
||||
className={cn(
|
||||
"group p-5 bg-neutral-900 border rounded-xl hover:border-emerald-500/30 hover:bg-neutral-800/50 transition-all duration-200 cursor-pointer",
|
||||
isSelected
|
||||
? "border-emerald-500/40 bg-emerald-500/5 ring-1 ring-emerald-500/20"
|
||||
: "border-neutral-800"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-4">
|
||||
{/* Left: Name and Status */}
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
123
apps/web/src/hooks/use-keyboard-nav.ts
Normal file
123
apps/web/src/hooks/use-keyboard-nav.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
function isInputFocused(): boolean {
|
||||
const el = document.activeElement;
|
||||
if (!el) return false;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") return true;
|
||||
if ((el as HTMLElement).isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
interface UseKeyboardNavOptions {
|
||||
itemCount: number;
|
||||
onSelect: (index: number) => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useKeyboardNav({
|
||||
itemCount,
|
||||
onSelect,
|
||||
enabled = true,
|
||||
}: UseKeyboardNavOptions) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const router = useRouter();
|
||||
const gPressedRef = useRef(false);
|
||||
const gTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const resetSelection = useCallback(() => {
|
||||
setSelectedIndex(-1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (isInputFocused()) return;
|
||||
|
||||
if (gPressedRef.current) {
|
||||
gPressedRef.current = false;
|
||||
clearTimeout(gTimerRef.current);
|
||||
|
||||
if (e.key === "h") {
|
||||
e.preventDefault();
|
||||
router.push("/dashboard");
|
||||
return;
|
||||
}
|
||||
if (e.key === "s") {
|
||||
e.preventDefault();
|
||||
router.push("/dashboard/settings");
|
||||
return;
|
||||
}
|
||||
if (e.key === "k") {
|
||||
e.preventDefault();
|
||||
router.push("/dashboard/keys");
|
||||
return;
|
||||
}
|
||||
if (e.key === "d") {
|
||||
e.preventDefault();
|
||||
router.push("/dashboard/decisions");
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "g" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
gPressedRef.current = true;
|
||||
gTimerRef.current = setTimeout(() => {
|
||||
gPressedRef.current = false;
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "j" && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => {
|
||||
const next = prev + 1;
|
||||
return next >= itemCount ? itemCount - 1 : next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "k" && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => {
|
||||
const next = prev - 1;
|
||||
return next < 0 ? 0 : next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && selectedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
onSelect(selectedIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
setSelectedIndex(-1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
clearTimeout(gTimerRef.current);
|
||||
};
|
||||
}, [enabled, itemCount, selectedIndex, onSelect, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIndex < 0) return;
|
||||
|
||||
const row = document.querySelector(`[data-keyboard-index="${selectedIndex}"]`);
|
||||
if (row) {
|
||||
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
return { selectedIndex, setSelectedIndex, resetSelection };
|
||||
}
|
||||
33
apps/web/src/lib/api-key.ts
Normal file
33
apps/web/src/lib/api-key.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createHash } from "crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function validateApiKey(bearerToken: string) {
|
||||
const keyHash = createHash("sha256").update(bearerToken).digest("hex");
|
||||
|
||||
const apiKey = await prisma.apiKey.findFirst({
|
||||
where: { keyHash, revoked: false },
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiKey) return null;
|
||||
|
||||
prisma.apiKey
|
||||
.update({
|
||||
where: { id: apiKey.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return {
|
||||
userId: apiKey.userId,
|
||||
user: apiKey.user,
|
||||
subscription: apiKey.user.subscription,
|
||||
apiKey,
|
||||
};
|
||||
}
|
||||
554
apps/web/src/lib/demo-data.ts
Normal file
554
apps/web/src/lib/demo-data.ts
Normal file
@@ -0,0 +1,554 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { Prisma, SpanType } from "@agentlens/database";
|
||||
|
||||
type EventCreate = Prisma.EventCreateWithoutTraceInput;
|
||||
type DecisionCreate = Prisma.DecisionPointCreateWithoutTraceInput;
|
||||
|
||||
interface DemoSpan {
|
||||
id: string;
|
||||
name: string;
|
||||
type: SpanType;
|
||||
status: "RUNNING" | "COMPLETED" | "ERROR";
|
||||
parentSpanId?: string;
|
||||
input?: Prisma.InputJsonValue;
|
||||
output?: Prisma.InputJsonValue;
|
||||
tokenCount?: number;
|
||||
costUsd?: number;
|
||||
durationMs?: number;
|
||||
startedAt: Date;
|
||||
endedAt?: Date;
|
||||
metadata?: Prisma.InputJsonValue;
|
||||
statusMessage?: string;
|
||||
}
|
||||
|
||||
function daysAgo(days: number, offsetMs = 0): Date {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
d.setMilliseconds(d.getMilliseconds() + offsetMs);
|
||||
return d;
|
||||
}
|
||||
|
||||
function endDate(start: Date, durationMs: number): Date {
|
||||
return new Date(start.getTime() + durationMs);
|
||||
}
|
||||
|
||||
export async function seedDemoData(userId: string) {
|
||||
const traces = [
|
||||
createSimpleChatTrace(userId),
|
||||
createMultiToolAgentTrace(userId),
|
||||
createRagPipelineTrace(userId),
|
||||
createErrorHandlingTrace(userId),
|
||||
createLongRunningWorkflowTrace(userId),
|
||||
createCodeAnalysisTrace(userId),
|
||||
createWebSearchTrace(userId),
|
||||
];
|
||||
|
||||
for (const traceFn of traces) {
|
||||
const { trace, spans, events, decisions } = traceFn;
|
||||
|
||||
await prisma.trace.create({
|
||||
data: {
|
||||
...trace,
|
||||
spans: { create: spans },
|
||||
events: { create: events },
|
||||
decisionPoints: { create: decisions },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { demoSeeded: true },
|
||||
});
|
||||
}
|
||||
|
||||
function createSimpleChatTrace(userId: string) {
|
||||
const start = daysAgo(1);
|
||||
const duration = 1240;
|
||||
const spanId = `demo-span-chat-${userId.slice(0, 8)}`;
|
||||
|
||||
return {
|
||||
trace: {
|
||||
name: "Simple Chat Completion",
|
||||
userId,
|
||||
status: "COMPLETED" as const,
|
||||
isDemo: true,
|
||||
tags: ["openai", "chat"],
|
||||
metadata: { model: "gpt-4o", temperature: 0.7 },
|
||||
totalCost: 0.0032,
|
||||
totalTokens: 245,
|
||||
totalDuration: duration,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, duration),
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
id: spanId,
|
||||
name: "chat.completions.create",
|
||||
type: "LLM_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
input: { messages: [{ role: "user", content: "Explain quantum computing in simple terms" }] },
|
||||
output: { content: "Quantum computing uses quantum bits (qubits) that can exist in multiple states simultaneously..." },
|
||||
tokenCount: 245,
|
||||
costUsd: 0.0032,
|
||||
durationMs: duration,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, duration),
|
||||
metadata: { model: "gpt-4o", provider: "openai" },
|
||||
},
|
||||
],
|
||||
events: [] as EventCreate[],
|
||||
decisions: [] as DecisionCreate[],
|
||||
};
|
||||
}
|
||||
|
||||
function createMultiToolAgentTrace(userId: string) {
|
||||
const start = daysAgo(2);
|
||||
const parentId = `demo-span-agent-${userId.slice(0, 8)}`;
|
||||
const toolIds = [
|
||||
`demo-span-tool1-${userId.slice(0, 8)}`,
|
||||
`demo-span-tool2-${userId.slice(0, 8)}`,
|
||||
`demo-span-tool3-${userId.slice(0, 8)}`,
|
||||
];
|
||||
const llmId = `demo-span-llm-${userId.slice(0, 8)}`;
|
||||
|
||||
return {
|
||||
trace: {
|
||||
name: "Multi-Tool Agent Run",
|
||||
userId,
|
||||
status: "COMPLETED" as const,
|
||||
isDemo: true,
|
||||
tags: ["agent", "tools", "production"],
|
||||
metadata: { agent: "research-assistant", run_id: "demo-run-001" },
|
||||
totalCost: 0.0187,
|
||||
totalTokens: 1823,
|
||||
totalDuration: 8420,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 8420),
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
id: parentId,
|
||||
name: "research-assistant",
|
||||
type: "AGENT" as const,
|
||||
status: "COMPLETED" as const,
|
||||
durationMs: 8420,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 8420),
|
||||
metadata: { max_iterations: 5 },
|
||||
},
|
||||
{
|
||||
id: toolIds[0],
|
||||
name: "web_search",
|
||||
type: "TOOL_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
parentSpanId: parentId,
|
||||
input: { query: "latest AI research papers 2026" },
|
||||
output: { results: [{ title: "Scaling Laws for Neural Language Models", url: "https://arxiv.org/..." }] },
|
||||
durationMs: 2100,
|
||||
startedAt: endDate(start, 200),
|
||||
endedAt: endDate(start, 2300),
|
||||
},
|
||||
{
|
||||
id: toolIds[1],
|
||||
name: "document_reader",
|
||||
type: "TOOL_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
parentSpanId: parentId,
|
||||
input: { url: "https://arxiv.org/..." },
|
||||
output: { content: "Abstract: We study empirical scaling laws for language model performance..." },
|
||||
durationMs: 1800,
|
||||
startedAt: endDate(start, 2400),
|
||||
endedAt: endDate(start, 4200),
|
||||
},
|
||||
{
|
||||
id: toolIds[2],
|
||||
name: "summarizer",
|
||||
type: "TOOL_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
parentSpanId: parentId,
|
||||
input: { text: "Abstract: We study empirical scaling laws..." },
|
||||
output: { summary: "The paper examines how language model performance scales with compute, data, and model size." },
|
||||
durationMs: 1500,
|
||||
startedAt: endDate(start, 4300),
|
||||
endedAt: endDate(start, 5800),
|
||||
},
|
||||
{
|
||||
id: llmId,
|
||||
name: "gpt-4o-synthesis",
|
||||
type: "LLM_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
parentSpanId: parentId,
|
||||
input: { messages: [{ role: "system", content: "Synthesize research findings" }] },
|
||||
output: { content: "Based on the latest research, AI scaling laws suggest..." },
|
||||
tokenCount: 1823,
|
||||
costUsd: 0.0187,
|
||||
durationMs: 2400,
|
||||
startedAt: endDate(start, 5900),
|
||||
endedAt: endDate(start, 8300),
|
||||
metadata: { model: "gpt-4o" },
|
||||
},
|
||||
],
|
||||
events: [] as EventCreate[],
|
||||
decisions: [
|
||||
{
|
||||
type: "TOOL_SELECTION" as const,
|
||||
reasoning: "User asked about latest AI research, need web search to get current information",
|
||||
chosen: { tool: "web_search", args: { query: "latest AI research papers 2026" } },
|
||||
alternatives: [{ tool: "memory_lookup" }, { tool: "knowledge_base" }],
|
||||
parentSpanId: parentId,
|
||||
durationMs: 150,
|
||||
costUsd: 0.001,
|
||||
timestamp: endDate(start, 100),
|
||||
},
|
||||
{
|
||||
type: "ROUTING" as const,
|
||||
reasoning: "Search results contain arxiv links, routing to document reader for full content",
|
||||
chosen: { next_step: "document_reader" },
|
||||
alternatives: [{ next_step: "direct_response" }, { next_step: "ask_clarification" }],
|
||||
parentSpanId: parentId,
|
||||
durationMs: 80,
|
||||
costUsd: 0.0005,
|
||||
timestamp: endDate(start, 2350),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createRagPipelineTrace(userId: string) {
|
||||
const start = daysAgo(3);
|
||||
const retrievalId = `demo-span-retrieval-${userId.slice(0, 8)}`;
|
||||
const embeddingId = `demo-span-embed-${userId.slice(0, 8)}`;
|
||||
const genId = `demo-span-gen-${userId.slice(0, 8)}`;
|
||||
|
||||
return {
|
||||
trace: {
|
||||
name: "RAG Pipeline",
|
||||
userId,
|
||||
status: "COMPLETED" as const,
|
||||
isDemo: true,
|
||||
tags: ["rag", "retrieval", "embeddings"],
|
||||
metadata: { pipeline: "knowledge-qa", version: "2.1" },
|
||||
totalCost: 0.0091,
|
||||
totalTokens: 892,
|
||||
totalDuration: 4350,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 4350),
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
id: embeddingId,
|
||||
name: "embed_query",
|
||||
type: "LLM_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
input: { text: "How does our refund policy work?" },
|
||||
output: { embedding: [0.023, -0.041, 0.089] },
|
||||
tokenCount: 12,
|
||||
costUsd: 0.00001,
|
||||
durationMs: 320,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 320),
|
||||
metadata: { model: "text-embedding-3-small" },
|
||||
},
|
||||
{
|
||||
id: retrievalId,
|
||||
name: "vector_search",
|
||||
type: "MEMORY_OP" as const,
|
||||
status: "COMPLETED" as const,
|
||||
input: { embedding: [0.023, -0.041, 0.089], top_k: 5 },
|
||||
output: { documents: [{ id: "doc-1", score: 0.92, title: "Refund Policy v3" }] },
|
||||
durationMs: 180,
|
||||
startedAt: endDate(start, 400),
|
||||
endedAt: endDate(start, 580),
|
||||
metadata: { index: "company-docs", results_count: 5 },
|
||||
},
|
||||
{
|
||||
id: genId,
|
||||
name: "generate_answer",
|
||||
type: "LLM_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
input: { messages: [{ role: "system", content: "Answer using the provided context" }] },
|
||||
output: { content: "Our refund policy allows returns within 30 days of purchase..." },
|
||||
tokenCount: 880,
|
||||
costUsd: 0.009,
|
||||
durationMs: 3600,
|
||||
startedAt: endDate(start, 650),
|
||||
endedAt: endDate(start, 4250),
|
||||
metadata: { model: "gpt-4o-mini" },
|
||||
},
|
||||
],
|
||||
events: [] as EventCreate[],
|
||||
decisions: [
|
||||
{
|
||||
type: "MEMORY_RETRIEVAL" as const,
|
||||
reasoning: "Query about refund policy matched knowledge base with high confidence",
|
||||
chosen: { source: "vector_search", confidence: 0.92 },
|
||||
alternatives: [{ source: "web_search" }, { source: "ask_human" }],
|
||||
durationMs: 50,
|
||||
timestamp: endDate(start, 350),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createErrorHandlingTrace(userId: string) {
|
||||
const start = daysAgo(5);
|
||||
const spanId = `demo-span-err-${userId.slice(0, 8)}`;
|
||||
|
||||
return {
|
||||
trace: {
|
||||
name: "Error Handling Example",
|
||||
userId,
|
||||
status: "ERROR" as const,
|
||||
isDemo: true,
|
||||
tags: ["error", "rate-limit"],
|
||||
metadata: { error_type: "RateLimitError", retries: 3 },
|
||||
totalCost: 0.0,
|
||||
totalTokens: 0,
|
||||
totalDuration: 15200,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 15200),
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
id: spanId,
|
||||
name: "chat.completions.create",
|
||||
type: "LLM_CALL" as const,
|
||||
status: "ERROR" as const,
|
||||
statusMessage: "RateLimitError: Rate limit exceeded. Retry after 30s.",
|
||||
input: { messages: [{ role: "user", content: "Analyze this dataset" }] },
|
||||
durationMs: 15200,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 15200),
|
||||
metadata: { model: "gpt-4o", retry_count: 3 },
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{
|
||||
type: "ERROR" as const,
|
||||
name: "RateLimitError",
|
||||
spanId,
|
||||
metadata: { message: "Rate limit exceeded", status_code: 429 },
|
||||
timestamp: endDate(start, 5000),
|
||||
},
|
||||
{
|
||||
type: "RETRY" as const,
|
||||
name: "Retry attempt 1",
|
||||
spanId,
|
||||
metadata: { attempt: 1, backoff_ms: 2000 },
|
||||
timestamp: endDate(start, 7000),
|
||||
},
|
||||
{
|
||||
type: "RETRY" as const,
|
||||
name: "Retry attempt 2",
|
||||
spanId,
|
||||
metadata: { attempt: 2, backoff_ms: 4000 },
|
||||
timestamp: endDate(start, 11000),
|
||||
},
|
||||
{
|
||||
type: "ERROR" as const,
|
||||
name: "Max retries exceeded",
|
||||
spanId,
|
||||
metadata: { message: "Giving up after 3 retries", final_status: 429 },
|
||||
timestamp: endDate(start, 15200),
|
||||
},
|
||||
],
|
||||
decisions: [
|
||||
{
|
||||
type: "RETRY" as const,
|
||||
reasoning: "Received 429 rate limit error, exponential backoff strategy selected",
|
||||
chosen: { action: "retry", strategy: "exponential_backoff", max_retries: 3 },
|
||||
alternatives: [{ action: "fail_immediately" }, { action: "switch_model" }],
|
||||
durationMs: 20,
|
||||
timestamp: endDate(start, 5100),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createLongRunningWorkflowTrace(userId: string) {
|
||||
const start = daysAgo(6);
|
||||
const totalDuration = 34500;
|
||||
const chainId = `demo-span-chain-${userId.slice(0, 8)}`;
|
||||
const spanPrefix = `demo-span-wf-${userId.slice(0, 8)}`;
|
||||
|
||||
const stepNames = [
|
||||
"data_ingestion",
|
||||
"preprocessing",
|
||||
"feature_extraction",
|
||||
"model_inference",
|
||||
"post_processing",
|
||||
"validation",
|
||||
"output_formatting",
|
||||
];
|
||||
|
||||
const spans: DemoSpan[] = [
|
||||
{
|
||||
id: chainId,
|
||||
name: "data-processing-pipeline",
|
||||
type: "CHAIN",
|
||||
status: "COMPLETED",
|
||||
durationMs: totalDuration,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, totalDuration),
|
||||
metadata: { pipeline: "batch-analysis", version: "1.4" },
|
||||
},
|
||||
];
|
||||
|
||||
let elapsed = 200;
|
||||
for (let i = 0; i < stepNames.length; i++) {
|
||||
const stepDuration = 2000 + Math.floor(Math.random() * 5000);
|
||||
spans.push({
|
||||
id: `${spanPrefix}-${i}`,
|
||||
name: stepNames[i],
|
||||
type: i === 3 ? "LLM_CALL" : "CUSTOM",
|
||||
status: "COMPLETED",
|
||||
durationMs: stepDuration,
|
||||
startedAt: endDate(start, elapsed),
|
||||
endedAt: endDate(start, elapsed + stepDuration),
|
||||
metadata: { step: i + 1, total_steps: stepNames.length },
|
||||
});
|
||||
elapsed += stepDuration + 100;
|
||||
}
|
||||
|
||||
return {
|
||||
trace: {
|
||||
name: "Long-Running Workflow",
|
||||
userId,
|
||||
status: "COMPLETED" as const,
|
||||
isDemo: true,
|
||||
tags: ["pipeline", "batch", "production"],
|
||||
metadata: { pipeline: "batch-analysis", records_processed: 1250 },
|
||||
totalCost: 0.042,
|
||||
totalTokens: 4200,
|
||||
totalDuration: totalDuration,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, totalDuration),
|
||||
},
|
||||
spans: spans.map((s) => ({
|
||||
...s,
|
||||
parentSpanId: s.id === chainId ? undefined : chainId,
|
||||
})),
|
||||
events: [] as EventCreate[],
|
||||
decisions: [
|
||||
{
|
||||
type: "PLANNING" as const,
|
||||
reasoning: "Large dataset detected, selecting batch processing strategy with parallel feature extraction",
|
||||
chosen: { strategy: "batch_parallel", batch_size: 50 },
|
||||
alternatives: [{ strategy: "sequential" }, { strategy: "streaming" }],
|
||||
parentSpanId: chainId,
|
||||
durationMs: 100,
|
||||
timestamp: endDate(start, 100),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createCodeAnalysisTrace(userId: string) {
|
||||
const start = daysAgo(4);
|
||||
const agentId = `demo-span-codeagent-${userId.slice(0, 8)}`;
|
||||
const readId = `demo-span-read-${userId.slice(0, 8)}`;
|
||||
const analyzeId = `demo-span-analyze-${userId.slice(0, 8)}`;
|
||||
|
||||
return {
|
||||
trace: {
|
||||
name: "Code Review Agent",
|
||||
userId,
|
||||
status: "COMPLETED" as const,
|
||||
isDemo: true,
|
||||
tags: ["code-review", "agent"],
|
||||
metadata: { repo: "acme/backend", pr_number: 142 },
|
||||
totalCost: 0.015,
|
||||
totalTokens: 1450,
|
||||
totalDuration: 6200,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 6200),
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
id: agentId,
|
||||
name: "code-review-agent",
|
||||
type: "AGENT" as const,
|
||||
status: "COMPLETED" as const,
|
||||
durationMs: 6200,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 6200),
|
||||
},
|
||||
{
|
||||
id: readId,
|
||||
name: "read_diff",
|
||||
type: "TOOL_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
parentSpanId: agentId,
|
||||
input: { pr_number: 142 },
|
||||
output: { files_changed: 5, additions: 120, deletions: 30 },
|
||||
durationMs: 800,
|
||||
startedAt: endDate(start, 100),
|
||||
endedAt: endDate(start, 900),
|
||||
},
|
||||
{
|
||||
id: analyzeId,
|
||||
name: "analyze_code",
|
||||
type: "LLM_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
parentSpanId: agentId,
|
||||
input: { diff: "...", instructions: "Review for bugs and style issues" },
|
||||
output: { review: "Found 2 potential issues: 1) Missing null check on line 45, 2) Unused import" },
|
||||
tokenCount: 1450,
|
||||
costUsd: 0.015,
|
||||
durationMs: 5100,
|
||||
startedAt: endDate(start, 1000),
|
||||
endedAt: endDate(start, 6100),
|
||||
metadata: { model: "gpt-4o" },
|
||||
},
|
||||
],
|
||||
events: [] as EventCreate[],
|
||||
decisions: [
|
||||
{
|
||||
type: "TOOL_SELECTION" as const,
|
||||
reasoning: "Need to read PR diff before analyzing code",
|
||||
chosen: { tool: "read_diff", args: { pr_number: 142 } },
|
||||
alternatives: [{ tool: "read_file" }, { tool: "list_files" }],
|
||||
parentSpanId: agentId,
|
||||
durationMs: 60,
|
||||
timestamp: endDate(start, 50),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createWebSearchTrace(userId: string) {
|
||||
const start = daysAgo(0, -3600000);
|
||||
const searchId = `demo-span-websearch-${userId.slice(0, 8)}`;
|
||||
|
||||
return {
|
||||
trace: {
|
||||
name: "Web Search Agent",
|
||||
userId,
|
||||
status: "COMPLETED" as const,
|
||||
isDemo: true,
|
||||
tags: ["search", "web"],
|
||||
metadata: { query: "AgentLens observability" },
|
||||
totalCost: 0.002,
|
||||
totalTokens: 180,
|
||||
totalDuration: 2800,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 2800),
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
id: searchId,
|
||||
name: "web_search",
|
||||
type: "TOOL_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
input: { query: "AgentLens observability platform" },
|
||||
output: { results_count: 10, top_result: "https://agentlens.vectry.tech" },
|
||||
durationMs: 2800,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 2800),
|
||||
},
|
||||
],
|
||||
events: [] as EventCreate[],
|
||||
decisions: [] as DecisionCreate[],
|
||||
};
|
||||
}
|
||||
36
apps/web/src/lib/email.ts
Normal file
36
apps/web/src/lib/email.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
interface SendEmailOptions {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export async function sendEmail({ to, subject, html }: SendEmailOptions) {
|
||||
const password = process.env.EMAIL_PASSWORD;
|
||||
|
||||
if (!password) {
|
||||
console.warn(
|
||||
"[email] EMAIL_PASSWORD not set — skipping email send to:",
|
||||
to
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: "smtp.migadu.com",
|
||||
port: 465,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: "hunter@repi.fun",
|
||||
pass: password,
|
||||
},
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: "AgentLens <hunter@repi.fun>",
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
}
|
||||
52
apps/web/src/lib/rate-limit.ts
Normal file
52
apps/web/src/lib/rate-limit.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { redis } from "./redis";
|
||||
|
||||
interface RateLimitConfig {
|
||||
windowMs: number;
|
||||
maxAttempts: number;
|
||||
}
|
||||
|
||||
interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
export async function checkRateLimit(
|
||||
key: string,
|
||||
config: RateLimitConfig
|
||||
): Promise<RateLimitResult> {
|
||||
const now = Date.now();
|
||||
const windowStart = now - config.windowMs;
|
||||
const redisKey = `rl:${key}`;
|
||||
|
||||
try {
|
||||
await redis.zremrangebyscore(redisKey, 0, windowStart);
|
||||
const count = await redis.zcard(redisKey);
|
||||
|
||||
if (count >= config.maxAttempts) {
|
||||
const oldestEntry = await redis.zrange(redisKey, 0, 0, "WITHSCORES");
|
||||
const resetAt = oldestEntry.length >= 2
|
||||
? parseInt(oldestEntry[1], 10) + config.windowMs
|
||||
: now + config.windowMs;
|
||||
return { allowed: false, remaining: 0, resetAt };
|
||||
}
|
||||
|
||||
await redis.zadd(redisKey, now, `${now}:${Math.random()}`);
|
||||
await redis.pexpire(redisKey, config.windowMs);
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: config.maxAttempts - count - 1,
|
||||
resetAt: now + config.windowMs,
|
||||
};
|
||||
} catch {
|
||||
return { allowed: true, remaining: config.maxAttempts, resetAt: now + config.windowMs };
|
||||
}
|
||||
}
|
||||
|
||||
export const AUTH_RATE_LIMITS = {
|
||||
login: { windowMs: 15 * 60 * 1000, maxAttempts: 10 },
|
||||
register: { windowMs: 60 * 60 * 1000, maxAttempts: 5 },
|
||||
forgotPassword: { windowMs: 60 * 60 * 1000, maxAttempts: 5 },
|
||||
resetPassword: { windowMs: 15 * 60 * 1000, maxAttempts: 5 },
|
||||
} as const;
|
||||
14
apps/web/src/lib/redis.ts
Normal file
14
apps/web/src/lib/redis.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import Redis from "ioredis";
|
||||
|
||||
const globalForRedis = globalThis as unknown as { redis?: Redis };
|
||||
|
||||
export const redis =
|
||||
globalForRedis.redis ??
|
||||
new Redis(process.env.REDIS_URL ?? "redis://localhost:6379", {
|
||||
maxRetriesPerRequest: 3,
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForRedis.redis = redis;
|
||||
}
|
||||
35
apps/web/src/lib/stripe.ts
Normal file
35
apps/web/src/lib/stripe.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import Stripe from "stripe";
|
||||
|
||||
let _stripe: Stripe | null = null;
|
||||
|
||||
export function getStripe(): Stripe {
|
||||
if (!_stripe) {
|
||||
const key = process.env.STRIPE_SECRET_KEY;
|
||||
if (!key) throw new Error("STRIPE_SECRET_KEY is not set");
|
||||
_stripe = new Stripe(key, { apiVersion: "2026-01-28.clover" });
|
||||
}
|
||||
return _stripe;
|
||||
}
|
||||
|
||||
export const TIER_CONFIG = {
|
||||
FREE: {
|
||||
name: "Free",
|
||||
sessionsLimit: 20,
|
||||
period: "day",
|
||||
price: 0,
|
||||
},
|
||||
STARTER: {
|
||||
name: "Starter",
|
||||
priceId: process.env.STRIPE_STARTER_PRICE_ID!,
|
||||
sessionsLimit: 1000,
|
||||
period: "month",
|
||||
price: 5,
|
||||
},
|
||||
PRO: {
|
||||
name: "Pro",
|
||||
priceId: process.env.STRIPE_PRO_PRICE_ID!,
|
||||
sessionsLimit: 100000,
|
||||
period: "month",
|
||||
price: 20,
|
||||
},
|
||||
} as const;
|
||||
@@ -19,6 +19,9 @@ export function formatRelativeTime(date: string | Date): string {
|
||||
return `${diffDay}d ago`;
|
||||
}
|
||||
|
||||
export function cn(...classes: (string | boolean | undefined | null)[]): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
87
apps/web/src/middleware.ts
Normal file
87
apps/web/src/middleware.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import authConfig from "./auth.config";
|
||||
|
||||
const { auth } = NextAuth(authConfig);
|
||||
|
||||
const publicPaths = [
|
||||
"/",
|
||||
"/docs",
|
||||
"/api/auth",
|
||||
"/api/traces",
|
||||
"/api/health",
|
||||
"/api/stripe/webhook",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
"/verify-email",
|
||||
];
|
||||
|
||||
function isPublicPath(pathname: string): boolean {
|
||||
return publicPaths.some(
|
||||
(p) => pathname === p || pathname.startsWith(`${p}/`)
|
||||
);
|
||||
}
|
||||
|
||||
const ALLOWED_ORIGINS = new Set([
|
||||
"https://agentlens.vectry.tech",
|
||||
"http://localhost:3000",
|
||||
]);
|
||||
|
||||
function corsHeaders(origin: string | null): Record<string, string> {
|
||||
const allowedOrigin = origin && ALLOWED_ORIGINS.has(origin)
|
||||
? origin
|
||||
: "https://agentlens.vectry.tech";
|
||||
return {
|
||||
"Access-Control-Allow-Origin": allowedOrigin,
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
};
|
||||
}
|
||||
|
||||
export default auth((req) => {
|
||||
const { pathname } = req.nextUrl;
|
||||
const isLoggedIn = !!req.auth;
|
||||
const origin = req.headers.get("origin");
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return new NextResponse(null, { status: 204, headers: corsHeaders(origin) });
|
||||
}
|
||||
|
||||
const response = (() => {
|
||||
if (isPublicPath(pathname)) {
|
||||
if (isLoggedIn && (pathname === "/login" || pathname === "/register")) {
|
||||
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (pathname === "/login" || pathname === "/register") {
|
||||
if (isLoggedIn) {
|
||||
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/dashboard") && !isLoggedIn) {
|
||||
const loginUrl = new URL("/login", req.nextUrl.origin);
|
||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
})();
|
||||
|
||||
if (pathname.startsWith("/api/")) {
|
||||
const headers = corsHeaders(origin);
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
response.headers.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|og-image.png).*)"],
|
||||
};
|
||||
@@ -7,15 +7,25 @@ services:
|
||||
- "4200:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- DATABASE_URL=postgresql://agentlens:agentlens@postgres:5432/agentlens
|
||||
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-agentlens}:${POSTGRES_PASSWORD:-agentlens}@postgres:5432/${POSTGRES_DB:-agentlens}
|
||||
- AUTH_SECRET=${AUTH_SECRET}
|
||||
- AUTH_TRUST_HOST=true
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
|
||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-price_1SzJUlR8i0An4Wz7gZeYgzBY}
|
||||
- STRIPE_PRO_PRICE_ID=${STRIPE_PRO_PRICE_ID:-price_1SzJVWR8i0An4Wz755hBrxzn}
|
||||
- EMAIL_PASSWORD=${EMAIL_PASSWORD:-}
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "--quiet", "http://127.0.0.1:3000/api/health"]
|
||||
interval: 30s
|
||||
@@ -38,11 +48,13 @@ services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=agentlens
|
||||
- POSTGRES_PASSWORD=agentlens
|
||||
- POSTGRES_DB=agentlens
|
||||
- POSTGRES_USER=${POSTGRES_USER:-agentlens}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-agentlens}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-agentlens}
|
||||
volumes:
|
||||
- agentlens_postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U agentlens"]
|
||||
interval: 10s
|
||||
@@ -62,22 +74,26 @@ services:
|
||||
migrate:
|
||||
build:
|
||||
context: .
|
||||
target: builder
|
||||
command: npx prisma migrate deploy --schema=packages/database/prisma/schema.prisma
|
||||
target: migrate
|
||||
command: npx prisma db push --schema=packages/database/prisma/schema.prisma --skip-generate
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://agentlens:agentlens@postgres:5432/agentlens
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-agentlens}:${POSTGRES_PASSWORD:-agentlens}@postgres:5432/${POSTGRES_DB:-agentlens}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- backend
|
||||
restart: "no"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --maxmemory 64mb --maxmemory-policy allkeys-lru
|
||||
command: redis-server --maxmemory 64mb --maxmemory-policy allkeys-lru --requirepass ${REDIS_PASSWORD}
|
||||
volumes:
|
||||
- agentlens_redis_data:/data
|
||||
networks:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -92,6 +108,11 @@ services:
|
||||
max-file: "3"
|
||||
restart: always
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
backend:
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
agentlens_postgres_data:
|
||||
agentlens_redis_data:
|
||||
|
||||
890
package-lock.json
generated
890
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,116 @@ generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
// ─── Auth & Billing ────────────────────────────────────────────
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String?
|
||||
emailVerified Boolean @default(false)
|
||||
demoSeeded Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
subscription Subscription?
|
||||
apiKeys ApiKey[]
|
||||
traces Trace[]
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
emailVerificationTokens EmailVerificationToken[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model PasswordResetToken {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
token String @unique // SHA-256 hash of the raw token
|
||||
expiresAt DateTime
|
||||
used Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([token])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model EmailVerificationToken {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
token String @unique // SHA-256 hash of the raw token
|
||||
expiresAt DateTime
|
||||
used Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([token])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model ApiKey {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
name String @default("Default")
|
||||
keyHash String @unique // SHA-256 hash of the actual key
|
||||
keyPrefix String // First 8 chars for display: "al_xxxx..."
|
||||
lastUsedAt DateTime?
|
||||
|
||||
revoked Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([keyHash])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
tier SubscriptionTier @default(FREE)
|
||||
stripeCustomerId String? @unique
|
||||
stripeSubscriptionId String? @unique
|
||||
stripePriceId String?
|
||||
|
||||
currentPeriodStart DateTime?
|
||||
currentPeriodEnd DateTime?
|
||||
|
||||
// Usage tracking for the current billing period
|
||||
sessionsUsed Int @default(0)
|
||||
sessionsLimit Int @default(20) // Free tier: 20/day, paid: per month
|
||||
|
||||
status SubscriptionStatus @default(ACTIVE)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([stripeCustomerId])
|
||||
@@index([stripeSubscriptionId])
|
||||
}
|
||||
|
||||
enum SubscriptionTier {
|
||||
FREE // 20 sessions/day
|
||||
STARTER // $5/mo — 1,000 sessions/mo
|
||||
PRO // $20/mo — 100,000 sessions/mo
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
ACTIVE
|
||||
PAST_DUE
|
||||
CANCELED
|
||||
UNPAID
|
||||
}
|
||||
|
||||
// ─── Observability ─────────────────────────────────────────────
|
||||
|
||||
model Trace {
|
||||
id String @id @default(cuid())
|
||||
sessionId String?
|
||||
@@ -15,6 +125,11 @@ model Trace {
|
||||
tags String[] @default([])
|
||||
metadata Json?
|
||||
|
||||
isDemo Boolean @default(false)
|
||||
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
totalCost Float?
|
||||
totalTokens Int?
|
||||
totalDuration Int?
|
||||
@@ -32,6 +147,7 @@ model Trace {
|
||||
@@index([status])
|
||||
@@index([createdAt])
|
||||
@@index([name])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model DecisionPoint {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencode-agentlens",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.7",
|
||||
"description": "OpenCode plugin for AgentLens — trace your coding agent's decisions, tool calls, and sessions",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
|
||||
@@ -22,11 +22,10 @@ const plugin: Plugin = async ({ project, directory, worktree }) => {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config.enabled || !config.apiKey) {
|
||||
console.log("[agentlens] Plugin disabled — missing AGENTLENS_API_KEY");
|
||||
return {};
|
||||
}
|
||||
|
||||
console.log(`[agentlens] Plugin enabled — endpoint: ${config.endpoint}`);
|
||||
|
||||
|
||||
init({
|
||||
apiKey: config.apiKey,
|
||||
@@ -62,7 +61,7 @@ const plugin: Plugin = async ({ project, directory, worktree }) => {
|
||||
worktree,
|
||||
title: info?.["title"] as string | undefined,
|
||||
});
|
||||
console.log(`[agentlens] Session started: ${sessionId}`);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +106,7 @@ const plugin: Plugin = async ({ project, directory, worktree }) => {
|
||||
if (sessionId) {
|
||||
state.endSession(sessionId);
|
||||
await flush();
|
||||
console.log(`[agentlens] Session ended and flushed: ${sessionId}`);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,9 +156,7 @@ const plugin: Plugin = async ({ project, directory, worktree }) => {
|
||||
directory,
|
||||
worktree,
|
||||
});
|
||||
console.log(
|
||||
`[agentlens] Auto-created session from tool call: ${input.sessionID}`,
|
||||
);
|
||||
|
||||
}
|
||||
state.startToolCall(
|
||||
input.callID,
|
||||
@@ -186,9 +183,7 @@ const plugin: Plugin = async ({ project, directory, worktree }) => {
|
||||
directory,
|
||||
worktree,
|
||||
});
|
||||
console.log(
|
||||
`[agentlens] Auto-created session from chat.message: ${input.sessionID}`,
|
||||
);
|
||||
|
||||
}
|
||||
if (input.model) {
|
||||
state.recordLLMCall(input.sessionID, {
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "vectry-agentlens"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
description = "Agent observability that traces decisions, not just API calls"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agentlens-sdk",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.4",
|
||||
"description": "AgentLens TypeScript SDK — Agent observability that traces decisions, not just API calls.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
|
||||
@@ -68,15 +68,9 @@ export class BatchTransport {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
console.warn(
|
||||
`AgentLens: Failed to send traces (HTTP ${response.status}): ${text.slice(0, 200)}`,
|
||||
);
|
||||
await response.text().catch(() => "");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.warn(`AgentLens: Failed to send traces: ${message}`);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user