15 Commits

Author SHA1 Message Date
Vectry
f4185364d5 fix: use correct package name @agentlens/web in turbo filter
All checks were successful
Deploy AgentLens / deploy (push) Successful in 1m19s
2026-02-11 00:02:00 +00:00
Vectry
860159ccd0 fix: use turbo --filter=web... to skip sdk-ts build in Docker
Some checks failed
Deploy AgentLens / deploy (push) Failing after 9s
The builder stage was running 'npx turbo build' which builds ALL workspace
packages including sdk-ts (needs tsup). The web app only depends on
@agentlens/database, not sdk-ts. Using --filter=web... builds only web
and its transitive dependencies.
2026-02-10 23:57:47 +00:00
Vectry
b21d8fe52c fix: add lightweight migrate Dockerfile target to avoid tsup build failure in CI
Some checks failed
Deploy AgentLens / deploy (push) Failing after 9s
The migrate service only needs Prisma CLI to run 'prisma db push'. Previously
it used the 'builder' target which runs 'npx turbo build' (including sdk-ts
needing tsup), causing failures in fresh CI builds over TCP where Docker cache
is unavailable. New 'migrate' target copies only node_modules and prisma schema.
2026-02-10 23:56:09 +00:00
Vectry
c6fa25ed47 fix: skip Docker install, use pre-installed CLI from runner image
Some checks failed
Deploy AgentLens / deploy (push) Failing after 6s
2026-02-10 23:38:45 +00:00
Vectry
0e97c23579 fix: use TCP docker host, fix heredoc whitespace, fix health checks in deploy workflow 2026-02-10 23:31:18 +00:00
Vectry
865a1b0081 Fix deploy workflow: use ubuntu-latest with Docker CLI install 2026-02-10 23:22:06 +00:00
Vectry
b3e5119568 Add Gitea Actions deploy-on-tag workflow 2026-02-10 23:18:53 +00:00
Vectry
2ac5fdca30 feat: add favicon, apple icon, og image and icons metadata
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-10 19:25:17 +00:00
Vectry
64c827ee84 feat: add command palette, accessibility, scroll animations, demo workspace, and keyboard navigation
- COMP-139: Command palette for quick navigation
- COMP-140: Accessibility improvements
- COMP-141: Scroll animations with animate-on-scroll component
- COMP-143: Demo workspace with seed data and demo banner
- COMP-145: Keyboard navigation and shortcuts help

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-10 18:06:36 +00:00
Vectry
f9e7956e6f feat: add shared design tokens, JetBrains Mono font, and fix cn() utility
- Add CSS custom properties for surfaces, text, borders, accent, radius, font stacks
- Add JetBrains Mono via next/font/google alongside Inter (both as CSS variables)
- Upgrade cn() from naive filter/join to twMerge(clsx()) for proper Tailwind class merging
- Standardize marketing section containers from max-w-7xl to max-w-6xl
- Install tailwind-merge and clsx dependencies
2026-02-10 17:22:45 +00:00
Vectry
cccb3123ed security: P1/P2 hardening — rate limiting, CORS, Redis auth, network isolation
- Add Redis-based sliding window rate limiting on login, register, forgot-password, reset-password
- Fix user enumeration: register returns generic 200 for both new and existing emails
- Add Redis authentication (requirepass) and password in .env
- Docker network isolation: postgres/redis on internal-only network
- Whitelist Stripe redirect origins (prevent open redirect)
- Add 10MB request size limit on trace ingestion
- Limit API keys to 10 per user
- Add CORS headers via middleware (whitelist agentlens.vectry.tech + localhost)
- Reduce JWT max age from 30 days to 7 days
2026-02-10 17:03:48 +00:00
Vectry
e9cd11735c security: fix trace ownership bypass and externalize secrets to .env
- Add userId guard in trace upsert to prevent cross-user overwrites
- Move AUTH_SECRET, STRIPE_WEBHOOK_SECRET, POSTGRES_PASSWORD to .env
- docker-compose.yml now references env vars instead of hardcoded secrets
- Add .env.example with placeholder values for documentation
2026-02-10 16:53:57 +00:00
Vectry
539d35b649 feat: password reset flow and email verification
- Add forgot-password and reset-password pages and API routes
- Add email verification with token generation on registration
- Add resend-verification endpoint with 60s rate limit
- Add shared email utility (nodemailer, Migadu SMTP)
- Add VerificationBanner in dashboard layout
- Add PasswordResetToken and EmailVerificationToken models
- Add emailVerified field to User model
- Extend NextAuth session with isEmailVerified
- Add forgot-password link to login page
- Wire EMAIL_PASSWORD env var in docker-compose
2026-02-10 16:47:06 +00:00
Vectry
0e4ffce4fa chore: bump SDK versions, add pricing section to landing page 2026-02-10 16:27:32 +00:00
Vectry
1f2484a0bb chore: add Stripe price IDs and webhook secret to docker-compose 2026-02-10 16:19:59 +00:00
50 changed files with 3719 additions and 104 deletions

19
.env.example Normal file
View 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=

View 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

View File

@@ -14,7 +14,11 @@ FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN npx prisma generate --schema=packages/database/prisma/schema.prisma 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 FROM base AS web
RUN addgroup --system --gid 1001 nodejs && \ RUN addgroup --system --gid 1001 nodejs && \

View File

@@ -16,13 +16,18 @@
"@dagrejs/dagre": "^2.0.4", "@dagrejs/dagre": "^2.0.4",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"ioredis": "^5.9.2",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"next": "^15.1.0", "next": "^15.1.0",
"next-auth": "^5.0.0-beta.30", "next-auth": "^5.0.0-beta.30",
"nodemailer": "^6.10.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"shiki": "^3.22.0", "shiki": "^3.22.0",
"stripe": "^20.3.1", "stripe": "^20.3.1",
"tailwind-merge": "^3.4.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
@@ -30,6 +35,7 @@
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/dagre": "^0.7.53", "@types/dagre": "^0.7.53",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/nodemailer": "^7.0.9",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"postcss": "^8.5.0", "postcss": "^8.5.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View 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&apos;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>
);
}

View File

@@ -1,14 +1,24 @@
"use client"; "use client";
import { useState } from "react"; import { Suspense, useState } from "react";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Activity, Loader2 } from "lucide-react"; import { Activity, CheckCircle, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export default function LoginPage() { export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
);
}
function LoginForm() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const verified = searchParams.get("verified") === "true";
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -62,6 +72,15 @@ export default function LoginPage() {
</div> </div>
</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"> <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="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -123,6 +142,15 @@ export default function LoginPage() {
</div> </div>
</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 && ( {error && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3"> <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> <p className="text-sm text-red-400">{error}</p>

View File

@@ -44,6 +44,13 @@ export default function RegisterPage() {
}), }),
}); });
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) { if (!res.ok) {
const data: { error?: string } = await res.json(); const data: { error?: string } = await res.json();
setError(data.error ?? "Registration failed"); setError(data.error ?? "Registration failed");
@@ -58,8 +65,7 @@ export default function RegisterPage() {
}); });
if (result?.error) { if (result?.error) {
setError("Account created but sign-in failed. Please log in manually."); router.push("/login");
setLoading(false);
return; return;
} }

View 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>
);
}

View 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>
);
}

View 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 }
);
}
}

View File

@@ -1,7 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { hash } from "bcryptjs"; import { hash } from "bcryptjs";
import crypto from "crypto";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/email";
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
const registerSchema = z.object({ const registerSchema = z.object({
email: z.email("Invalid email address"), email: z.email("Invalid email address"),
@@ -11,6 +14,15 @@ const registerSchema = z.object({
export async function POST(request: Request) { export async function POST(request: Request) {
try { 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 body: unknown = await request.json();
const parsed = registerSchema.safeParse(body); const parsed = registerSchema.safeParse(body);
@@ -30,8 +42,8 @@ export async function POST(request: Request) {
if (existing) { if (existing) {
return NextResponse.json( return NextResponse.json(
{ error: "An account with this email already exists" }, { message: "If this email is available, a confirmation email will be sent." },
{ status: 409 } { status: 200 }
); );
} }
@@ -57,7 +69,48 @@ export async function POST(request: Request) {
}, },
}); });
return NextResponse.json(user, { status: 201 }); 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 { } catch {
return NextResponse.json( return NextResponse.json(
{ error: "Internal server error" }, { error: "Internal server error" },

View 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 });
}

View 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 }
);
}
}

View 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));
}

View 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 });
}
}

View File

@@ -37,6 +37,17 @@ export async function POST(request: Request) {
if (!session?.user?.id) if (!session?.user?.id)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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 body = await request.json().catch(() => ({}));
const name = const name =
typeof body.name === "string" && body.name.trim() typeof body.name === "string" && body.name.trim()

View File

@@ -72,8 +72,14 @@ export async function POST(request: Request) {
} }
} }
const origin = const ALLOWED_ORIGINS = [
request.headers.get("origin") ?? "https://agentlens.vectry.tech"; "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({ const checkoutSession = await getStripe().checkout.sessions.create({
customer: stripeCustomerId, customer: stripeCustomerId,

View File

@@ -22,8 +22,14 @@ export async function POST(request: Request) {
); );
} }
const origin = const ALLOWED_ORIGINS = [
request.headers.get("origin") ?? "https://agentlens.vectry.tech"; "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({ const portalSession = await getStripe().billingPortal.sessions.create({
customer: subscription.stripeCustomerId, customer: subscription.stripeCustomerId,

View File

@@ -92,6 +92,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Missing or invalid Authorization header" }, { status: 401 }); return NextResponse.json({ error: "Missing or invalid Authorization header" }, { status: 401 });
} }
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); const rawApiKey = authHeader.slice(7);
if (!rawApiKey) { if (!rawApiKey) {
return NextResponse.json({ error: "Missing API key in Authorization header" }, { status: 401 }); return NextResponse.json({ error: "Missing API key in Authorization header" }, { status: 401 });
@@ -241,9 +247,14 @@ export async function POST(request: NextRequest) {
for (const trace of body.traces) { for (const trace of body.traces) {
const existing = await tx.trace.findUnique({ const existing = await tx.trace.findUnique({
where: { id: trace.id }, where: { id: trace.id },
select: { id: true }, 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 = { const traceData = {
name: trace.name, name: trace.name,
sessionId: trace.sessionId, sessionId: trace.sessionId,

View File

@@ -12,6 +12,7 @@ import {
Shield, Shield,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useKeyboardNav } from "@/hooks/use-keyboard-nav";
interface ApiKey { interface ApiKey {
id: string; id: string;
@@ -38,6 +39,21 @@ export default function ApiKeysPage() {
const [revokingId, setRevokingId] = useState<string | null>(null); const [revokingId, setRevokingId] = useState<string | null>(null);
const [confirmRevokeId, setConfirmRevokeId] = 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 () => { const fetchKeys = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
try { try {
@@ -157,6 +173,7 @@ export default function ApiKeysPage() {
onClick={() => onClick={() =>
copyToClipboard(newlyCreatedKey.key, "new-key") copyToClipboard(newlyCreatedKey.key, "new-key")
} }
aria-label="Copy API key to clipboard"
className={cn( className={cn(
"p-3 rounded-lg border transition-all shrink-0", "p-3 rounded-lg border transition-all shrink-0",
copiedField === "new-key" copiedField === "new-key"
@@ -196,10 +213,11 @@ export default function ApiKeysPage() {
<h2 className="text-sm font-semibold">Create New API Key</h2> <h2 className="text-sm font-semibold">Create New API Key</h2>
</div> </div>
<div> <div>
<label className="text-xs text-neutral-500 font-medium block mb-1.5"> <label htmlFor="key-name" className="text-xs text-neutral-500 font-medium block mb-1.5">
Key Name (optional) Key Name (optional)
</label> </label>
<input <input
id="key-name"
type="text" type="text"
value={newKeyName} value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)} onChange={(e) => setNewKeyName(e.target.value)}
@@ -271,10 +289,16 @@ export default function ApiKeysPage() {
</div> </div>
) : ( ) : (
<div className="divide-y divide-neutral-800"> <div className="divide-y divide-neutral-800">
{keys.map((apiKey) => ( {keys.map((apiKey, index) => (
<div <div
key={apiKey.id} key={apiKey.id}
className="flex items-center gap-4 px-6 py-4 group" 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"> <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" /> <Key className="w-4 h-4 text-neutral-500" />

View File

@@ -3,6 +3,7 @@
import { ReactNode, useState } from "react"; import { ReactNode, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import { import {
Activity, Activity,
GitBranch, GitBranch,
@@ -10,8 +11,13 @@ import {
Settings, Settings,
Menu, Menu,
ChevronRight, ChevronRight,
X,
AlertTriangle,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CommandPalette } from "@/components/command-palette";
import { KeyboardShortcutsHelp, ShortcutsHint } from "@/components/keyboard-shortcuts-help";
interface NavItem { interface NavItem {
href: string; href: string;
@@ -101,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 }) { export default function DashboardLayout({ children }: { children: ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
return ( return (
<div className="min-h-screen bg-neutral-950 flex"> <div className="min-h-screen bg-neutral-950 flex">
<CommandPalette />
<KeyboardShortcutsHelp />
<ShortcutsHint />
{/* Desktop Sidebar */} {/* Desktop Sidebar */}
<aside className="hidden lg:block w-64 h-screen sticky top-0"> <aside className="hidden lg:block w-64 h-screen sticky top-0">
<Sidebar /> <Sidebar />
@@ -130,12 +195,14 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
</aside> </aside>
{/* Main Content */} {/* Main Content */}
<main className="flex-1 min-w-0"> <main id="main-content" className="flex-1 min-w-0">
<VerificationBanner />
{/* Mobile Header */} {/* 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"> <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"> <div className="flex items-center justify-between">
<button <button
onClick={() => setSidebarOpen(true)} 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" 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" /> <Menu className="w-5 h-5" />

View File

@@ -1,10 +1,11 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { TraceList } from "@/components/trace-list"; import { TraceList } from "@/components/trace-list";
import { DemoSeedTrigger } from "@/components/demo-seed-trigger";
import { DemoBanner } from "@/components/demo-banner";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
interface TracesResponse { interface TraceItem {
traces: Array<{
id: string; id: string;
name: string; name: string;
status: "RUNNING" | "COMPLETED" | "ERROR"; status: "RUNNING" | "COMPLETED" | "ERROR";
@@ -13,12 +14,16 @@ interface TracesResponse {
durationMs: number | null; durationMs: number | null;
tags: string[]; tags: string[];
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
isDemo?: boolean;
_count: { _count: {
decisionPoints: number; decisionPoints: number;
spans: number; spans: number;
events: number; events: number;
}; };
}>; }
interface TracesResponse {
traces: TraceItem[];
total: number; total: number;
page: number; page: number;
limit: number; limit: number;
@@ -55,7 +60,13 @@ async function getTraces(
export default async function DashboardPage() { export default async function DashboardPage() {
const data = await getTraces(50, 1); const data = await getTraces(50, 1);
const hasTraces = data.traces.length > 0;
const allTracesAreDemo =
hasTraces && data.traces.every((t) => t.isDemo === true);
return ( return (
<DemoSeedTrigger hasTraces={hasTraces}>
{allTracesAreDemo && <DemoBanner allTracesAreDemo={allTracesAreDemo} />}
<Suspense fallback={<div className="p-8 text-zinc-400">Loading traces...</div>}> <Suspense fallback={<div className="p-8 text-zinc-400">Loading traces...</div>}>
<TraceList <TraceList
initialTraces={data.traces} initialTraces={data.traces}
@@ -64,5 +75,6 @@ export default async function DashboardPage() {
initialPage={data.page} initialPage={data.page}
/> />
</Suspense> </Suspense>
</DemoSeedTrigger>
); );
} }

View File

@@ -1 +1,74 @@
@import "tailwindcss"; @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;
}

View File

@@ -1,9 +1,10 @@
import { Inter } from "next/font/google"; import { Inter, JetBrains_Mono } from "next/font/google";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";
import "./globals.css"; 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 = { export const metadata: Metadata = {
metadataBase: new URL("https://agentlens.vectry.tech"), metadataBase: new URL("https://agentlens.vectry.tech"),
@@ -25,6 +26,13 @@ export const metadata: Metadata = {
], ],
authors: [{ name: "Vectry" }], authors: [{ name: "Vectry" }],
creator: "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: { openGraph: {
type: "website", type: "website",
locale: "en_US", locale: "en_US",
@@ -72,7 +80,13 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en" className="dark"> <html lang="en" className="dark">
<body className={`${inter.className} bg-neutral-950 text-neutral-100 antialiased`}> <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> <SessionProvider>{children}</SessionProvider>
</body> </body>
</html> </html>

View File

@@ -15,11 +15,13 @@ import {
Bot, Bot,
Star, Star,
Clipboard, Clipboard,
Shield,
} from "lucide-react"; } from "lucide-react";
import { AnimateOnScroll } from "@/components/animate-on-scroll";
export default function HomePage() { export default function HomePage() {
return ( return (
<div className="min-h-screen bg-neutral-950"> <main id="main-content" className="min-h-screen bg-neutral-950">
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
@@ -32,7 +34,34 @@ export default function HomePage() {
url: "https://agentlens.vectry.tech", url: "https://agentlens.vectry.tech",
description: description:
"Open-source agent observability platform that traces AI agent decisions, not just API calls.", "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: [ featureList: [
"Agent Decision Tracing", "Agent Decision Tracing",
"Real-time Dashboard", "Real-time Dashboard",
@@ -67,7 +96,7 @@ export default function HomePage() {
{/* Subtle grid pattern for depth */} {/* 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="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"> <div className="text-center">
{/* Top badges row */} {/* Top badges row */}
<div className="flex flex-wrap items-center justify-center gap-3 mb-8"> <div className="flex flex-wrap items-center justify-center gap-3 mb-8">
@@ -132,7 +161,8 @@ export default function HomePage() {
{/* Features Section */} {/* Features Section */}
<section className="py-24 border-b border-neutral-800/50"> <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"> <div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold mb-4"> <h2 className="text-3xl sm:text-4xl font-bold mb-4">
Everything you need to understand your agents 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 From decision trees to cost intelligence, get complete visibility into how your AI systems operate
</p> </p>
</div> </div>
</AnimateOnScroll>
<div className="grid md:grid-cols-3 gap-8"> <div className="grid md:grid-cols-3 gap-8">
{/* Feature 1: Decision Trees */} {/* 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="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"> <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" /> <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. Visualize the complete reasoning behind every agent choice. See the branching logic, alternatives considered, and the path chosen.
</p> </p>
</div> </div>
</AnimateOnScroll>
{/* Feature 2: Context Awareness */} {/* 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="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"> <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" /> <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&apos;s being fed into your agents and what&apos;s being left behind. Monitor context window utilization in real-time. Track what&apos;s being fed into your agents and what&apos;s being left behind.
</p> </p>
</div> </div>
</AnimateOnScroll>
{/* Feature 3: Cost Intelligence */} {/* 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="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"> <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" /> <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. Track spending per decision, per agent, per trace. Get granular insights into where every dollar goes in your AI operations.
</p> </p>
</div> </div>
</AnimateOnScroll>
</div> </div>
</div> </div>
</section> </section>
@@ -182,7 +219,8 @@ export default function HomePage() {
{/* How it Works Section */} {/* How it Works Section */}
<section className="py-24 border-b border-neutral-800/50 relative"> <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="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="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"> <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" /> <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 Go from zero to full agent observability in under five minutes
</p> </p>
</div> </div>
</AnimateOnScroll>
<div className="grid md:grid-cols-3 gap-6 lg:gap-8"> <div className="grid md:grid-cols-3 gap-6 lg:gap-8">
{/* Step 1: Install */} {/* Step 1: Install */}
<AnimateOnScroll delay={0}>
<div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30"> <div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30">
<div className="absolute -top-4 left-8"> <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"> <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> <code className="text-sm font-mono text-emerald-400">pip install vectry-agentlens</code>
</div> </div>
</div> </div>
</AnimateOnScroll>
{/* Step 2: Instrument */} {/* Step 2: Instrument */}
<AnimateOnScroll delay={100}>
<div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30"> <div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30">
<div className="absolute -top-4 left-8"> <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"> <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> <code className="text-sm font-mono text-emerald-400">wrap_openai()</code>
</div> </div>
</div> </div>
</AnimateOnScroll>
{/* Step 3: Observe */} {/* Step 3: Observe */}
<AnimateOnScroll delay={200}>
<div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30"> <div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30">
<div className="absolute -top-4 left-8"> <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"> <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> <code className="text-sm font-mono text-emerald-400">agentlens.vectry.tech</code>
</div> </div>
</div> </div>
</AnimateOnScroll>
</div> </div>
{/* Connecting arrows decoration */} {/* Connecting arrows decoration */}
@@ -272,8 +317,9 @@ export default function HomePage() {
{/* Code Example Section */} {/* Code Example Section */}
<section className="py-24 border-b border-neutral-800/50"> <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"> <div className="grid lg:grid-cols-2 gap-12 items-start">
<AnimateOnScroll>
<div className="lg:sticky lg:top-8"> <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"> <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" /> <Cpu className="w-4 h-4" />
@@ -303,8 +349,10 @@ export default function HomePage() {
))} ))}
</ul> </ul>
</div> </div>
</AnimateOnScroll>
{/* Code Blocks - Two patterns stacked */} {/* Code Blocks - Two patterns stacked */}
<AnimateOnScroll delay={150}>
<div className="space-y-6"> <div className="space-y-6">
{/* Decorator Pattern */} {/* Decorator Pattern */}
<div className="rounded-xl overflow-hidden border border-neutral-800 bg-neutral-900/50 backdrop-blur-sm"> <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> </pre>
</div> </div>
</div> </div>
</AnimateOnScroll>
</div> </div>
</div> </div>
</section> </section>
@@ -459,7 +508,8 @@ export default function HomePage() {
{/* Integrations Section */} {/* Integrations Section */}
<section className="py-24 border-b border-neutral-800/50 relative"> <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="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="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"> <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" /> <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. First-class support for the most popular AI frameworks. Drop in and start tracing.
</p> </p>
</div> </div>
</AnimateOnScroll>
<AnimateOnScroll>
<div className="grid sm:grid-cols-3 gap-6 max-w-3xl mx-auto"> <div className="grid sm:grid-cols-3 gap-6 max-w-3xl mx-auto">
{/* OpenAI */} {/* 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"> <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> </span>
</div> </div>
</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> </div>
</section> </section>
@@ -542,6 +718,6 @@ export default function HomePage() {
</div> </div>
</div> </div>
</footer> </footer>
</div> </main>
); );
} }

View File

@@ -2,7 +2,7 @@ import type { NextAuthConfig } from "next-auth";
export default { export default {
providers: [], providers: [],
session: { strategy: "jwt" }, session: { strategy: "jwt", maxAge: 7 * 24 * 60 * 60 },
pages: { pages: {
signIn: "/login", signIn: "/login",
}, },

View File

@@ -3,6 +3,7 @@ import Credentials from "next-auth/providers/credentials";
import { compare } from "bcryptjs"; import { compare } from "bcryptjs";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
import authConfig from "./auth.config"; import authConfig from "./auth.config";
declare module "next-auth" { declare module "next-auth" {
@@ -12,6 +13,7 @@ declare module "next-auth" {
email: string; email: string;
name?: string | null; name?: string | null;
image?: string | null; image?: string | null;
isEmailVerified: boolean;
}; };
} }
} }
@@ -19,6 +21,7 @@ declare module "next-auth" {
declare module "@auth/core/jwt" { declare module "@auth/core/jwt" {
interface JWT { interface JWT {
id: string; id: string;
isEmailVerified: boolean;
} }
} }
@@ -35,11 +38,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
email: { label: "Email", type: "email" }, email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }, password: { label: "Password", type: "password" },
}, },
async authorize(credentials) { async authorize(credentials, request) {
const parsed = loginSchema.safeParse(credentials); const parsed = loginSchema.safeParse(credentials);
if (!parsed.success) return null; if (!parsed.success) return null;
const { email, password } = parsed.data; 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({ const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() }, where: { email: email.toLowerCase() },
@@ -58,14 +67,24 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
}), }),
], ],
callbacks: { callbacks: {
jwt({ token, user }) { async jwt({ token, user, trigger }) {
if (user) { if (user) {
token.id = user.id as string; 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; return token;
}, },
session({ session, token }) { session({ session, token }) {
session.user.id = token.id; session.user.id = token.id;
session.user.isEmailVerified = token.isEmailVerified;
return session; return session;
}, },
}, },

View 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>
);
}

View 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">
&uarr;&darr;
</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">
&crarr;
</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>
);
}

View 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>
);
}

View 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}</>;
}

View 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>
);
}

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { import {
Search, Search,
Filter, Filter,
@@ -23,6 +23,7 @@ import {
WifiOff, WifiOff,
} from "lucide-react"; } from "lucide-react";
import { cn, formatDuration, formatRelativeTime } from "@/lib/utils"; import { cn, formatDuration, formatRelativeTime } from "@/lib/utils";
import { useKeyboardNav } from "@/hooks/use-keyboard-nav";
type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR"; type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR";
@@ -87,6 +88,7 @@ export function TraceList({
initialTotalPages, initialTotalPages,
initialPage, initialPage,
}: TraceListProps) { }: TraceListProps) {
const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [traces, setTraces] = useState<Trace[]>(initialTraces); const [traces, setTraces] = useState<Trace[]>(initialTraces);
const [total, setTotal] = useState(initialTotal); const [total, setTotal] = useState(initialTotal);
@@ -283,6 +285,19 @@ export function TraceList({
return matchesSearch && matchesStatus; 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 }[] = [ const filterChips: { value: FilterStatus; label: string }[] = [
{ value: "ALL", label: "All" }, { value: "ALL", label: "All" },
{ value: "RUNNING", label: "Running" }, { value: "RUNNING", label: "Running" },
@@ -376,7 +391,9 @@ export function TraceList({
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-neutral-500" /> <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 <input
id="trace-search"
type="text" type="text"
placeholder="Search traces..." placeholder="Search traces..."
value={searchQuery} value={searchQuery}
@@ -422,8 +439,9 @@ export function TraceList({
{showAdvancedFilters && ( {showAdvancedFilters && (
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2"> <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 <select
id="sort-filter"
value={sortFilter} value={sortFilter}
onChange={(e) => setSortFilter(e.target.value as SortOption)} 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" 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>
<div className="space-y-2"> <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 <input
id="date-from"
type="date" type="date"
value={dateFrom} value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)} onChange={(e) => setDateFrom(e.target.value)}
@@ -447,8 +466,9 @@ export function TraceList({
</div> </div>
<div className="space-y-2"> <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 <input
id="date-to"
type="date" type="date"
value={dateTo} value={dateTo}
onChange={(e) => setDateTo(e.target.value)} onChange={(e) => setDateTo(e.target.value)}
@@ -457,8 +477,9 @@ export function TraceList({
</div> </div>
<div className="sm:col-span-3 space-y-2"> <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 <input
id="tags-filter"
type="text" type="text"
placeholder="e.g., production, critical, api" placeholder="e.g., production, critical, api"
value={tagsFilter} value={tagsFilter}
@@ -473,8 +494,13 @@ export function TraceList({
{/* Trace List */} {/* Trace List */}
<div className="space-y-3"> <div className="space-y-3">
{filteredTraces.map((trace) => ( {filteredTraces.map((trace, index) => (
<TraceCard key={trace.id} trace={trace} /> <TraceCard
key={trace.id}
trace={trace}
index={index}
isSelected={index === selectedIndex}
/>
))} ))}
</div> </div>
@@ -497,6 +523,7 @@ export function TraceList({
<button <button
disabled={currentPage <= 1} disabled={currentPage <= 1}
onClick={() => handlePageChange(Math.max(1, 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" 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" /> <ChevronLeft className="w-5 h-5" />
@@ -504,6 +531,7 @@ export function TraceList({
<button <button
disabled={currentPage >= totalPages} disabled={currentPage >= totalPages}
onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))} 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" 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" /> <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 status = statusConfig[trace.status];
const StatusIcon = status.icon; const StatusIcon = status.icon;
return ( return (
<Link href={`/dashboard/traces/${trace.id}`}> <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"> <div className="flex flex-col lg:flex-row lg:items-center gap-4">
{/* Left: Name and Status */} {/* Left: Name and Status */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">

View 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 };
}

View 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
View 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,
});
}

View 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
View 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;
}

View File

@@ -19,6 +19,9 @@ export function formatRelativeTime(date: string | Date): string {
return `${diffDay}d ago`; return `${diffDay}d ago`;
} }
export function cn(...classes: (string | boolean | undefined | null)[]): string { import { type ClassValue, clsx } from "clsx";
return classes.filter(Boolean).join(" "); import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
} }

View File

@@ -10,6 +10,10 @@ const publicPaths = [
"/api/auth", "/api/auth",
"/api/traces", "/api/traces",
"/api/health", "/api/health",
"/api/stripe/webhook",
"/forgot-password",
"/reset-password",
"/verify-email",
]; ];
function isPublicPath(pathname: string): boolean { function isPublicPath(pathname: string): boolean {
@@ -18,10 +22,33 @@ function isPublicPath(pathname: string): boolean {
); );
} }
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) => { export default auth((req) => {
const { pathname } = req.nextUrl; const { pathname } = req.nextUrl;
const isLoggedIn = !!req.auth; 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 (isPublicPath(pathname)) {
if (isLoggedIn && (pathname === "/login" || pathname === "/register")) { if (isLoggedIn && (pathname === "/login" || pathname === "/register")) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin)); return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
@@ -43,6 +70,16 @@ export default auth((req) => {
} }
return NextResponse.next(); 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 = { export const config = {

View File

@@ -7,21 +7,25 @@ services:
- "4200:3000" - "4200:3000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
- DATABASE_URL=postgresql://agentlens:agentlens@postgres:5432/agentlens - DATABASE_URL=postgresql://${POSTGRES_USER:-agentlens}:${POSTGRES_PASSWORD:-agentlens}@postgres:5432/${POSTGRES_DB:-agentlens}
- AUTH_SECRET=Ge0Gh6bObko0Gdrzv+l0qKHgvut3M7Av8mDFQG9fYzs= - AUTH_SECRET=${AUTH_SECRET}
- AUTH_TRUST_HOST=true - AUTH_TRUST_HOST=true
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-} - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-} - STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-price_1SzJUlR8i0An4Wz7gZeYgzBY}
- STRIPE_PRO_PRICE_ID=${STRIPE_PRO_PRICE_ID:-} - STRIPE_PRO_PRICE_ID=${STRIPE_PRO_PRICE_ID:-price_1SzJVWR8i0An4Wz755hBrxzn}
- EMAIL_PASSWORD=${EMAIL_PASSWORD:-}
depends_on: depends_on:
redis: redis:
condition: service_started condition: service_healthy
postgres: postgres:
condition: service_healthy condition: service_healthy
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
networks:
- frontend
- backend
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "--quiet", "http://127.0.0.1:3000/api/health"] test: ["CMD", "wget", "--spider", "--quiet", "http://127.0.0.1:3000/api/health"]
interval: 30s interval: 30s
@@ -44,11 +48,13 @@ services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
environment: environment:
- POSTGRES_USER=agentlens - POSTGRES_USER=${POSTGRES_USER:-agentlens}
- POSTGRES_PASSWORD=agentlens - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-agentlens}
- POSTGRES_DB=agentlens - POSTGRES_DB=${POSTGRES_DB:-agentlens}
volumes: volumes:
- agentlens_postgres_data:/var/lib/postgresql/data - agentlens_postgres_data:/var/lib/postgresql/data
networks:
- backend
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U agentlens"] test: ["CMD-SHELL", "pg_isready -U agentlens"]
interval: 10s interval: 10s
@@ -68,22 +74,26 @@ services:
migrate: migrate:
build: build:
context: . context: .
target: builder target: migrate
command: npx prisma db push --schema=packages/database/prisma/schema.prisma --skip-generate command: npx prisma db push --schema=packages/database/prisma/schema.prisma --skip-generate
environment: 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: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
networks:
- backend
restart: "no" restart: "no"
redis: redis:
image: redis:7-alpine 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: volumes:
- agentlens_redis_data:/data - agentlens_redis_data:/data
networks:
- backend
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 3
@@ -98,6 +108,11 @@ services:
max-file: "3" max-file: "3"
restart: always restart: always
networks:
frontend:
backend:
internal: true
volumes: volumes:
agentlens_postgres_data: agentlens_postgres_data:
agentlens_redis_data: agentlens_redis_data:

735
package-lock.json generated
View File

@@ -25,13 +25,18 @@
"@dagrejs/dagre": "^2.0.4", "@dagrejs/dagre": "^2.0.4",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"ioredis": "^5.9.2",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"next": "^15.1.0", "next": "^15.1.0",
"next-auth": "^5.0.0-beta.30", "next-auth": "^5.0.0-beta.30",
"nodemailer": "^6.10.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"shiki": "^3.22.0", "shiki": "^3.22.0",
"stripe": "^20.3.1", "stripe": "^20.3.1",
"tailwind-merge": "^3.4.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
@@ -39,6 +44,7 @@
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/dagre": "^0.7.53", "@types/dagre": "^0.7.53",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/nodemailer": "^7.0.9",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"postcss": "^8.5.0", "postcss": "^8.5.0",
@@ -1038,6 +1044,12 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@ioredis/commands": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
"integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1334,6 +1346,447 @@
"@prisma/debug": "6.19.2" "@prisma/debug": "6.19.2"
} }
}, },
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
@@ -2136,6 +2589,16 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz",
"integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.13", "version": "19.2.13",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
@@ -2150,7 +2613,7 @@
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
@@ -2224,6 +2687,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/bcryptjs": { "node_modules/bcryptjs": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
@@ -2376,6 +2851,40 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/comma-separated-tokens": { "node_modules/comma-separated-tokens": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -2529,7 +3038,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -2560,6 +3068,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/dequal": { "node_modules/dequal": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -2586,6 +3103,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/devlop": { "node_modules/devlop": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -2764,6 +3287,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/giget": { "node_modules/giget": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
@@ -2835,6 +3367,30 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/ioredis": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz",
"integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "1.5.0",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -3155,6 +3711,18 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0" "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
} }
}, },
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "0.469.0", "version": "0.469.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz",
@@ -3320,7 +3888,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mz": { "node_modules/mz": {
@@ -3467,6 +4034,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nypm": { "node_modules/nypm": {
"version": "0.6.5", "version": "0.6.5",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
@@ -3770,6 +4346,75 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -3784,6 +4429,27 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/regex": { "node_modules/regex": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
@@ -3972,6 +4638,12 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/stringify-entities": { "node_modules/stringify-entities": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -4049,6 +4721,16 @@
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
}, },
"node_modules/tailwind-merge": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@@ -4351,6 +5033,49 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sync-external-store": { "node_modules/use-sync-external-store": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@@ -4449,7 +5174,7 @@
}, },
"packages/opencode-plugin": { "packages/opencode-plugin": {
"name": "opencode-agentlens", "name": "opencode-agentlens",
"version": "0.1.6", "version": "0.1.7",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"agentlens-sdk": "*" "agentlens-sdk": "*"
@@ -4525,7 +5250,7 @@
}, },
"packages/sdk-ts": { "packages/sdk-ts": {
"name": "agentlens-sdk", "name": "agentlens-sdk",
"version": "0.1.3", "version": "0.1.4",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"tsup": "^8.3.0", "tsup": "^8.3.0",

View File

@@ -14,6 +14,8 @@ model User {
email String @unique email String @unique
passwordHash String passwordHash String
name String? name String?
emailVerified Boolean @default(false)
demoSeeded Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -21,10 +23,42 @@ model User {
subscription Subscription? subscription Subscription?
apiKeys ApiKey[] apiKeys ApiKey[]
traces Trace[] traces Trace[]
passwordResetTokens PasswordResetToken[]
emailVerificationTokens EmailVerificationToken[]
@@index([email]) @@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 { model ApiKey {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
@@ -91,7 +125,8 @@ model Trace {
tags String[] @default([]) tags String[] @default([])
metadata Json? metadata Json?
// Owner — nullable for backward compat with existing unowned traces isDemo Boolean @default(false)
userId String? userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull) user User? @relation(fields: [userId], references: [id], onDelete: SetNull)

View File

@@ -1,6 +1,6 @@
{ {
"name": "opencode-agentlens", "name": "opencode-agentlens",
"version": "0.1.6", "version": "0.1.7",
"description": "OpenCode plugin for AgentLens — trace your coding agent's decisions, tool calls, and sessions", "description": "OpenCode plugin for AgentLens — trace your coding agent's decisions, tool calls, and sessions",
"type": "module", "type": "module",
"main": "./dist/index.cjs", "main": "./dist/index.cjs",

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "vectry-agentlens" name = "vectry-agentlens"
version = "0.1.2" version = "0.1.3"
description = "Agent observability that traces decisions, not just API calls" description = "Agent observability that traces decisions, not just API calls"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"

View File

@@ -1,6 +1,6 @@
{ {
"name": "agentlens-sdk", "name": "agentlens-sdk",
"version": "0.1.3", "version": "0.1.4",
"description": "AgentLens TypeScript SDK — Agent observability that traces decisions, not just API calls.", "description": "AgentLens TypeScript SDK — Agent observability that traces decisions, not just API calls.",
"type": "module", "type": "module",
"main": "./dist/index.cjs", "main": "./dist/index.cjs",