17 Commits

Author SHA1 Message Date
Vectry
32ed6e3f1d docs: update all documentation for subscription auth, billing tiers, and API key management
Some checks failed
Publish npm packages / publish (push) Successful in 50s
Publish PyPI package / publish (push) Failing after 3s
Deploy AgentLens / deploy (push) Successful in 1m21s
2026-02-11 00:35:51 +00:00
Vectry
9e6f6337c0 feat: add CI/CD workflows for npm and PyPI auto-publish on tag
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-11 00:33:24 +00:00
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
61 changed files with 4184 additions and 113 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

@@ -0,0 +1,40 @@
name: Publish npm packages
on:
push:
tags:
- "v*"
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Extract version from tag
run: |
echo "VERSION=$(echo $GITHUB_REF_NAME | sed 's/^v//')" >> $GITHUB_ENV
- name: Configure npm auth
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
- name: Update package versions
run: |
cd packages/sdk-ts && npm version $VERSION --no-git-tag-version
cd ../opencode-plugin && npm version $VERSION --no-git-tag-version
- name: Publish agentlens-sdk
run: |
cd packages/sdk-ts
npm install
npm run build
npm publish --access public
- name: Publish opencode-agentlens
run: |
cd packages/opencode-plugin
npm install
npm run build
npm publish --access public

View File

@@ -0,0 +1,32 @@
name: Publish PyPI package
on:
push:
tags:
- "v*"
jobs:
publish:
runs-on: ubuntu-latest
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Extract version from tag
run: |
echo "VERSION=$(echo $GITHUB_REF_NAME | sed 's/^v//')" >> $GITHUB_ENV
- name: Update version in pyproject.toml
run: |
cd packages/sdk-python
sed -i "s/^version = .*/version = \"$VERSION\"/" pyproject.toml
- name: Build and publish to PyPI
run: |
cd packages/sdk-python
pip install build twine
python -m build
twine upload dist/*

View File

@@ -14,7 +14,11 @@ FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate --schema=packages/database/prisma/schema.prisma
RUN npx turbo build
RUN npx turbo build --filter=@agentlens/web...
FROM base AS migrate
COPY --from=deps /app/node_modules ./node_modules
COPY packages/database/prisma ./packages/database/prisma
FROM base AS web
RUN addgroup --system --gid 1001 nodejs && \

View File

@@ -18,6 +18,15 @@
Existing observability tools show you _what_ LLM calls were made. AgentLens shows you _why_ your agent made each decision along the way -- which tool it picked, what alternatives it rejected, and the reasoning behind every choice.
## Getting Started
1. **Register** at [agentlens.vectry.tech/register](https://agentlens.vectry.tech/register) with your email and password.
2. **Log in** to the dashboard at [agentlens.vectry.tech](https://agentlens.vectry.tech).
3. **Create an API key** in **Settings > API Keys**.
4. **Install the SDK** and start tracing.
> Self-hosting? You do not need to register with the hosted service. See [Self-Hosting](#self-hosting) below.
## Quick Start
```bash
@@ -27,6 +36,7 @@ pip install vectry-agentlens
```python
import agentlens
# Use the API key you created in Settings > API Keys
agentlens.init(api_key="your-key", endpoint="https://agentlens.vectry.tech")
with agentlens.trace("my-agent-task", tags=["production"]):
@@ -41,7 +51,7 @@ with agentlens.trace("my-agent-task", tags=["production"]):
agentlens.shutdown()
```
Open `https://agentlens.vectry.tech/dashboard` to see your traces.
Open `https://agentlens.vectry.tech/dashboard` to see your traces (login required).
## Features
@@ -72,7 +82,7 @@ Add to your `opencode.json`:
}
```
Set environment variables:
Set environment variables (use the API key from your dashboard at **Settings > API Keys**):
```bash
export AGENTLENS_API_KEY="your-key"
@@ -90,6 +100,7 @@ npm install agentlens-sdk
```typescript
import { init, TraceBuilder, SpanType, SpanStatus } from "agentlens-sdk";
// Use the API key from Settings > API Keys in your dashboard
init({ apiKey: "your-key", endpoint: "https://agentlens.vectry.tech" });
const trace = new TraceBuilder("my-agent-task", {
@@ -173,8 +184,24 @@ with agentlens.trace("planner"):
| `MEMORY_RETRIEVAL` | Agent chose what context to retrieve |
| `CUSTOM` | Any other decision type |
## Pricing
AgentLens cloud ([agentlens.vectry.tech](https://agentlens.vectry.tech)) offers three billing tiers. One trace equals one session for billing purposes.
| Plan | Price | Sessions | Details |
|------|-------|----------|---------|
| **Free** | $0 | 20 sessions/day | No credit card required |
| **Starter** | $5/month | 1,000 sessions/month | For individual developers |
| **Pro** | $20/month | 100,000 sessions/month | For teams and production workloads |
Manage your subscription in **Settings > Billing** in the dashboard.
Self-hosted instances are not subject to these limits.
## Self-Hosting
Self-hosted AgentLens instances do not require registration with the hosted SaaS service. You manage your own API keys and have no session limits.
```bash
git clone https://gitea.repi.fun/repi/agentlens.git
cd agentlens

View File

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

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";
import { useState } from "react";
import { Suspense, useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Activity, Loader2 } from "lucide-react";
import { Activity, CheckCircle, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
);
}
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const verified = searchParams.get("verified") === "true";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
@@ -62,6 +72,15 @@ export default function LoginPage() {
</div>
</div>
{verified && (
<div className="rounded-lg bg-emerald-500/10 border border-emerald-500/20 px-4 py-3 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-400 shrink-0" />
<p className="text-sm text-emerald-400">
Email verified! You can now sign in.
</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
<div className="space-y-2">
@@ -123,6 +142,15 @@ export default function LoginPage() {
</div>
</div>
<div className="flex justify-end">
<Link
href="/forgot-password"
className="text-sm text-neutral-500 hover:text-emerald-400 transition-colors"
>
Forgot password?
</Link>
</div>
{error && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3">
<p className="text-sm text-red-400">{error}</p>

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) {
const data: { error?: string } = await res.json();
setError(data.error ?? "Registration failed");
@@ -58,8 +65,7 @@ export default function RegisterPage() {
});
if (result?.error) {
setError("Account created but sign-in failed. Please log in manually.");
setLoading(false);
router.push("/login");
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 { hash } from "bcryptjs";
import crypto from "crypto";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/email";
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
const registerSchema = z.object({
email: z.email("Invalid email address"),
@@ -11,6 +14,15 @@ const registerSchema = z.object({
export async function POST(request: Request) {
try {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rl = await checkRateLimit(`register:${ip}`, AUTH_RATE_LIMITS.register);
if (!rl.allowed) {
return NextResponse.json(
{ error: "Too many registration attempts. Please try again later." },
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
);
}
const body: unknown = await request.json();
const parsed = registerSchema.safeParse(body);
@@ -30,8 +42,8 @@ export async function POST(request: Request) {
if (existing) {
return NextResponse.json(
{ error: "An account with this email already exists" },
{ status: 409 }
{ message: "If this email is available, a confirmation email will be sent." },
{ 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 {
return NextResponse.json(
{ 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)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const MAX_KEYS_PER_USER = 10;
const keyCount = await prisma.apiKey.count({
where: { userId: session.user.id, revoked: false },
});
if (keyCount >= MAX_KEYS_PER_USER) {
return NextResponse.json(
{ error: `Maximum of ${MAX_KEYS_PER_USER} API keys allowed. Revoke an existing key first.` },
{ status: 400 }
);
}
const body = await request.json().catch(() => ({}));
const name =
typeof body.name === "string" && body.name.trim()

View File

@@ -72,8 +72,14 @@ export async function POST(request: Request) {
}
}
const origin =
request.headers.get("origin") ?? "https://agentlens.vectry.tech";
const ALLOWED_ORIGINS = [
"https://agentlens.vectry.tech",
"http://localhost:3000",
];
const requestOrigin = request.headers.get("origin");
const origin = ALLOWED_ORIGINS.includes(requestOrigin ?? "")
? requestOrigin!
: "https://agentlens.vectry.tech";
const checkoutSession = await getStripe().checkout.sessions.create({
customer: stripeCustomerId,

View File

@@ -22,8 +22,14 @@ export async function POST(request: Request) {
);
}
const origin =
request.headers.get("origin") ?? "https://agentlens.vectry.tech";
const ALLOWED_ORIGINS = [
"https://agentlens.vectry.tech",
"http://localhost:3000",
];
const requestOrigin = request.headers.get("origin");
const origin = ALLOWED_ORIGINS.includes(requestOrigin ?? "")
? requestOrigin!
: "https://agentlens.vectry.tech";
const portalSession = await getStripe().billingPortal.sessions.create({
customer: subscription.stripeCustomerId,

View File

@@ -92,6 +92,12 @@ export async function POST(request: NextRequest) {
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);
if (!rawApiKey) {
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) {
const existing = await tx.trace.findUnique({
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 = {
name: trace.name,
sessionId: trace.sessionId,

View File

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

View File

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

View File

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

View File

@@ -55,9 +55,37 @@ export default function ApiReferencePage() {
<section className="mb-6">
<h2 className="text-2xl font-semibold mb-4">Authentication</h2>
<p className="text-neutral-400 leading-relaxed mb-4">
All write endpoints require a Bearer token in the Authorization header:
All API endpoints require a Bearer token in the Authorization header.
To obtain an API key, register at{" "}
<a
href="https://agentlens.vectry.tech/register"
className="text-emerald-400 hover:underline"
>
agentlens.vectry.tech/register
</a>
, log in, and create a key in{" "}
<strong className="text-neutral-200">Settings &rarr; API Keys</strong>
.
</p>
<CodeBlock>{`Authorization: Bearer your-api-key`}</CodeBlock>
<div className="px-4 py-3 rounded-lg bg-neutral-900/50 border border-neutral-800/50 mt-4">
<p className="text-sm text-neutral-400">
<strong className="text-neutral-300">Rate limits:</strong>{" "}
API usage is governed by your billing tier. The Free plan allows 20
sessions per day. Starter ($5/month) allows 1,000 sessions per
month. Pro ($20/month) allows 100,000 sessions per month. One trace
equals one session for billing purposes. Manage your plan in{" "}
<strong className="text-neutral-300">Settings &rarr; Billing</strong>
. See{" "}
<a
href="/docs/authentication-billing"
className="text-emerald-400 hover:underline"
>
Authentication &amp; Billing
</a>{" "}
for details.
</p>
</div>
</section>
<hr className="border-neutral-800/50 my-10" />

View File

@@ -0,0 +1,200 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Authentication & Billing",
description:
"Register for AgentLens, manage API keys, and understand billing tiers and session limits.",
};
export default function AuthenticationBillingPage() {
return (
<div>
<h1 className="text-4xl font-bold tracking-tight mb-4">
Authentication &amp; Billing
</h1>
<p className="text-lg text-neutral-400 mb-10 leading-relaxed">
AgentLens cloud requires an account to access the dashboard and ingest
traces. This page covers registration, API key management, billing
tiers, and session counting.
</p>
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-4">Registration</h2>
<p className="text-neutral-400 leading-relaxed mb-4">
To use AgentLens cloud, create an account at{" "}
<a
href="https://agentlens.vectry.tech/register"
className="text-emerald-400 hover:underline"
>
agentlens.vectry.tech/register
</a>
. You will need to provide an email address and password. Once
registered, log in at{" "}
<a
href="https://agentlens.vectry.tech"
className="text-emerald-400 hover:underline"
>
agentlens.vectry.tech
</a>{" "}
to access the dashboard.
</p>
<p className="text-neutral-400 leading-relaxed">
The dashboard requires authentication -- there is no anonymous access.
All features including trace viewing, analytics, and settings are
available only after login.
</p>
</section>
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-4">API keys</h2>
<p className="text-neutral-400 leading-relaxed mb-4">
API keys authenticate your SDKs and integrations when sending traces
to AgentLens. Keys are generated per-user in the dashboard.
</p>
<h3 className="text-lg font-medium text-neutral-200 mb-2">
Creating an API key
</h3>
<ol className="list-decimal list-inside text-neutral-400 space-y-2 ml-1 mb-4">
<li>Log in to the AgentLens dashboard.</li>
<li>
Navigate to{" "}
<strong className="text-neutral-200">
Settings &rarr; API Keys
</strong>
.
</li>
<li>
Click <strong className="text-neutral-200">Create API Key</strong>,
give it a name, and copy the generated key.
</li>
<li>
Store the key securely. It will not be shown again after you leave
the page.
</li>
</ol>
<h3 className="text-lg font-medium text-neutral-200 mb-2">
Using your API key
</h3>
<p className="text-neutral-400 leading-relaxed mb-4">
Pass the key to the SDK during initialization, or set it as the{" "}
<code className="text-emerald-400 font-mono text-xs bg-emerald-500/5 px-1.5 py-0.5 rounded">
AGENTLENS_API_KEY
</code>{" "}
environment variable. The SDKs will pick it up automatically.
</p>
<div className="px-4 py-3 rounded-lg bg-neutral-900/50 border border-neutral-800/50">
<p className="text-sm text-neutral-400">
<strong className="text-neutral-300">Security note:</strong>{" "}
Treat API keys like passwords. Do not commit them to version control.
Use environment variables or a secrets manager in production.
</p>
</div>
</section>
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-4">Billing tiers</h2>
<p className="text-neutral-400 leading-relaxed mb-4">
AgentLens cloud offers three billing tiers. One trace equals one
session for billing purposes.
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-800">
<th className="text-left py-2 pr-4 text-neutral-400 font-medium">
Plan
</th>
<th className="text-left py-2 pr-4 text-neutral-400 font-medium">
Price
</th>
<th className="text-left py-2 pr-4 text-neutral-400 font-medium">
Sessions
</th>
<th className="text-left py-2 text-neutral-400 font-medium">
Details
</th>
</tr>
</thead>
<tbody className="text-neutral-300">
<tr className="border-b border-neutral-800/50">
<td className="py-2 pr-4 font-medium">Free</td>
<td className="py-2 pr-4">$0</td>
<td className="py-2 pr-4">20 sessions/day</td>
<td className="py-2">
No credit card required. Ideal for experimentation and
personal projects.
</td>
</tr>
<tr className="border-b border-neutral-800/50">
<td className="py-2 pr-4 font-medium">Starter</td>
<td className="py-2 pr-4">$5/month</td>
<td className="py-2 pr-4">1,000 sessions/month</td>
<td className="py-2">
For individual developers with moderate tracing needs.
</td>
</tr>
<tr>
<td className="py-2 pr-4 font-medium">Pro</td>
<td className="py-2 pr-4">$20/month</td>
<td className="py-2 pr-4">100,000 sessions/month</td>
<td className="py-2">
For teams and production workloads with high trace volume.
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-4">Session counting</h2>
<p className="text-neutral-400 leading-relaxed mb-4">
A session is equivalent to a single trace sent to the AgentLens API.
Each call to{" "}
<code className="text-emerald-400 font-mono text-xs bg-emerald-500/5 px-1.5 py-0.5 rounded">
POST /api/traces
</code>{" "}
that includes one trace counts as one session, regardless of how many
spans, decision points, or events are inside that trace.
</p>
<p className="text-neutral-400 leading-relaxed">
If you batch multiple traces in a single API call, each trace in the
batch counts as a separate session.
</p>
</section>
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-4">
Managing your subscription
</h2>
<p className="text-neutral-400 leading-relaxed mb-4">
To view your current plan, upgrade, downgrade, or manage payment
methods, go to{" "}
<strong className="text-neutral-200">
Settings &rarr; Billing
</strong>{" "}
in the dashboard. Changes take effect immediately. If you downgrade
mid-cycle, you retain access to the higher tier until the end of the
current billing period.
</p>
</section>
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-4">Self-hosted instances</h2>
<p className="text-neutral-400 leading-relaxed mb-4">
If you are running a self-hosted AgentLens instance, registration with
the hosted SaaS service is not required. Self-hosted deployments
manage their own authentication and have no session limits or billing
tiers. See the{" "}
<a
href="/docs/self-hosting"
className="text-emerald-400 hover:underline"
>
Self-Hosting guide
</a>{" "}
for setup instructions.
</p>
</section>
</div>
);
}

View File

@@ -63,6 +63,39 @@ export default function GettingStartedPage() {
</ul>
</section>
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-4">
Step 0: Create your account
</h2>
<p className="text-neutral-400 leading-relaxed mb-4">
Before you can send traces, you need an AgentLens account. Register
with your email and password at{" "}
<a
href="https://agentlens.vectry.tech/register"
className="text-emerald-400 hover:underline"
>
agentlens.vectry.tech/register
</a>
. Once registered, log in to the dashboard and navigate to{" "}
<strong className="text-neutral-200">Settings &rarr; API Keys</strong>{" "}
to generate your first API key.
</p>
<div className="px-4 py-3 rounded-lg bg-neutral-900/50 border border-neutral-800/50 mb-4">
<p className="text-sm text-neutral-400">
<strong className="text-neutral-300">Self-hosting?</strong>{" "}
If you are running your own AgentLens instance, you do not need to
register with the hosted service. See the{" "}
<a
href="/docs/self-hosting"
className="text-emerald-400 hover:underline"
>
Self-Hosting guide
</a>{" "}
instead.
</p>
</div>
</section>
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-4">
Step 1: Install the SDK
@@ -194,6 +227,57 @@ await trace.end();`}</CodeBlock>
</a>
</section>
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-4">
Billing and session limits
</h2>
<p className="text-neutral-400 leading-relaxed mb-4">
Each trace you send counts as one session for billing purposes.
AgentLens cloud offers three tiers:
</p>
<div className="overflow-x-auto mb-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-800">
<th className="text-left py-2 pr-4 text-neutral-400 font-medium">Plan</th>
<th className="text-left py-2 pr-4 text-neutral-400 font-medium">Price</th>
<th className="text-left py-2 text-neutral-400 font-medium">Sessions</th>
</tr>
</thead>
<tbody className="text-neutral-300">
<tr className="border-b border-neutral-800/50">
<td className="py-2 pr-4">Free</td>
<td className="py-2 pr-4">$0</td>
<td className="py-2">20 sessions/day</td>
</tr>
<tr className="border-b border-neutral-800/50">
<td className="py-2 pr-4">Starter</td>
<td className="py-2 pr-4">$5/month</td>
<td className="py-2">1,000 sessions/month</td>
</tr>
<tr>
<td className="py-2 pr-4">Pro</td>
<td className="py-2 pr-4">$20/month</td>
<td className="py-2">100,000 sessions/month</td>
</tr>
</tbody>
</table>
</div>
<p className="text-neutral-400 leading-relaxed">
Manage your subscription in{" "}
<strong className="text-neutral-200">Settings &rarr; Billing</strong>{" "}
in the dashboard. Self-hosted instances are not subject to these
limits. See{" "}
<a
href="/docs/authentication-billing"
className="text-emerald-400 hover:underline"
>
Authentication &amp; Billing
</a>{" "}
for full details.
</p>
</section>
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-4">Next steps</h2>
<div className="grid sm:grid-cols-2 gap-4">

View File

@@ -22,6 +22,12 @@ const sections = [
description:
"Understand Traces, Spans, Decision Points, and Events — the four building blocks of AgentLens.",
},
{
title: "Authentication & Billing",
href: "/docs/authentication-billing",
description:
"Register for an account, manage API keys, and understand billing tiers and session limits.",
},
],
},
{

View File

@@ -14,6 +14,9 @@ export default function SelfHostingPage() {
<p className="text-lg text-neutral-400 mb-10 leading-relaxed">
AgentLens is open source and designed to be self-hosted. You can deploy
it with Docker in minutes, or run from source for development.
Self-hosted instances do not require registration with the AgentLens
cloud service and are not subject to any session limits or billing
tiers.
</p>
<section className="mb-12">

View File

@@ -1 +1,74 @@
@import "tailwindcss";
@layer base {
:root {
/* Surfaces */
--surface-page: #0a0a0a;
--surface-card: rgb(23 23 23); /* neutral-900 */
--surface-card-hover: rgb(38 38 38 / 0.5); /* neutral-800/50 */
--surface-elevated: rgb(23 23 23); /* neutral-900 */
--surface-input: rgb(10 10 10); /* neutral-950 */
/* Text */
--text-primary: rgb(245 245 245); /* neutral-100 */
--text-secondary: rgb(163 163 163); /* neutral-400 */
--text-muted: rgb(115 115 115); /* neutral-500 */
/* Borders */
--border-default: rgb(38 38 38); /* neutral-800 */
--border-subtle: rgb(38 38 38 / 0.5); /* neutral-800/50 */
--border-strong: rgb(64 64 64); /* neutral-700 */
/* Accent (AgentLens emerald) */
--accent: #10b981;
--accent-hover: #34d399;
--accent-muted: rgba(16, 185, 129, 0.15);
--accent-foreground: #0a0a0a;
/* Radius */
--radius-card: 1rem;
--radius-button: 0.5rem;
--radius-icon: 0.75rem;
--radius-badge: 9999px;
/* Fonts */
--font-sans: var(--font-inter), system-ui, sans-serif;
--font-mono: var(--font-jetbrains), 'JetBrains Mono', 'Fira Code', monospace;
}
}
[data-animate="hidden"] {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
}
[data-animate="visible"] {
opacity: 1;
transform: translateY(0);
}
[data-animate="hidden"][style*="animation-delay"] {
transition-delay: inherit;
}
@media (prefers-reduced-motion: reduce) {
[data-animate="hidden"] {
opacity: 1;
transform: none;
transition: none;
}
}
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[role="button"]:focus-visible,
[tabindex]:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}

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 { SessionProvider } from "next-auth/react";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains", display: "swap" });
export const metadata: Metadata = {
metadataBase: new URL("https://agentlens.vectry.tech"),
@@ -25,6 +26,13 @@ export const metadata: Metadata = {
],
authors: [{ name: "Vectry" }],
creator: "Vectry",
icons: {
icon: [
{ url: "/favicon.ico", sizes: "any" },
{ url: "/icon.png", sizes: "512x512", type: "image/png" },
],
apple: [{ url: "/apple-icon.png", sizes: "180x180" }],
},
openGraph: {
type: "website",
locale: "en_US",
@@ -72,7 +80,13 @@ export default function RootLayout({
}>) {
return (
<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>
</body>
</html>

View File

@@ -15,11 +15,13 @@ import {
Bot,
Star,
Clipboard,
Shield,
} from "lucide-react";
import { AnimateOnScroll } from "@/components/animate-on-scroll";
export default function HomePage() {
return (
<div className="min-h-screen bg-neutral-950">
<main id="main-content" className="min-h-screen bg-neutral-950">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
@@ -32,7 +34,34 @@ export default function HomePage() {
url: "https://agentlens.vectry.tech",
description:
"Open-source agent observability platform that traces AI agent decisions, not just API calls.",
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
offers: [
{
"@type": "Offer",
name: "Free",
price: "0",
priceCurrency: "USD",
description:
"20 sessions per day, full dashboard access, 1 API key, community support",
},
{
"@type": "Offer",
name: "Starter",
price: "5",
priceCurrency: "USD",
billingIncrement: "P1M",
description:
"1,000 sessions per month, full dashboard access, unlimited API keys, email support",
},
{
"@type": "Offer",
name: "Pro",
price: "20",
priceCurrency: "USD",
billingIncrement: "P1M",
description:
"100,000 sessions per month, full dashboard access, unlimited API keys, priority support",
},
],
featureList: [
"Agent Decision Tracing",
"Real-time Dashboard",
@@ -67,7 +96,7 @@ export default function HomePage() {
{/* Subtle grid pattern for depth */}
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.012)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.012)_1px,transparent_1px)] bg-[size:64px_64px]" />
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-20 pb-24">
<div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pt-20 pb-24">
<div className="text-center">
{/* Top badges row */}
<div className="flex flex-wrap items-center justify-center gap-3 mb-8">
@@ -132,7 +161,8 @@ export default function HomePage() {
{/* Features Section */}
<section className="py-24 border-b border-neutral-800/50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<AnimateOnScroll>
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold mb-4">
Everything you need to understand your agents
@@ -141,9 +171,11 @@ export default function HomePage() {
From decision trees to cost intelligence, get complete visibility into how your AI systems operate
</p>
</div>
</AnimateOnScroll>
<div className="grid md:grid-cols-3 gap-8">
{/* Feature 1: Decision Trees */}
<AnimateOnScroll delay={0}>
<div className="group p-8 rounded-2xl border border-neutral-800/50 bg-gradient-to-b from-neutral-900/50 to-transparent hover:border-emerald-500/30 transition-all duration-300">
<div className="w-14 h-14 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
<GitBranch className="w-7 h-7 text-emerald-400" />
@@ -153,8 +185,10 @@ export default function HomePage() {
Visualize the complete reasoning behind every agent choice. See the branching logic, alternatives considered, and the path chosen.
</p>
</div>
</AnimateOnScroll>
{/* Feature 2: Context Awareness */}
<AnimateOnScroll delay={100}>
<div className="group p-8 rounded-2xl border border-neutral-800/50 bg-gradient-to-b from-neutral-900/50 to-transparent hover:border-emerald-500/30 transition-all duration-300">
<div className="w-14 h-14 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
<Brain className="w-7 h-7 text-emerald-400" />
@@ -164,8 +198,10 @@ export default function HomePage() {
Monitor context window utilization in real-time. Track what&apos;s being fed into your agents and what&apos;s being left behind.
</p>
</div>
</AnimateOnScroll>
{/* Feature 3: Cost Intelligence */}
<AnimateOnScroll delay={200}>
<div className="group p-8 rounded-2xl border border-neutral-800/50 bg-gradient-to-b from-neutral-900/50 to-transparent hover:border-emerald-500/30 transition-all duration-300">
<div className="w-14 h-14 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
<DollarSign className="w-7 h-7 text-emerald-400" />
@@ -175,6 +211,7 @@ export default function HomePage() {
Track spending per decision, per agent, per trace. Get granular insights into where every dollar goes in your AI operations.
</p>
</div>
</AnimateOnScroll>
</div>
</div>
</section>
@@ -182,7 +219,8 @@ export default function HomePage() {
{/* How it Works Section */}
<section className="py-24 border-b border-neutral-800/50 relative">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_60%_40%_at_50%_50%,rgba(16,185,129,0.04),transparent)]" />
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<AnimateOnScroll>
<div className="text-center mb-16">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6">
<Zap className="w-4 h-4" />
@@ -195,9 +233,11 @@ export default function HomePage() {
Go from zero to full agent observability in under five minutes
</p>
</div>
</AnimateOnScroll>
<div className="grid md:grid-cols-3 gap-6 lg:gap-8">
{/* Step 1: Install */}
<AnimateOnScroll delay={0}>
<div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30">
<div className="absolute -top-4 left-8">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-emerald-500 text-neutral-950 text-sm font-bold shadow-lg shadow-emerald-500/25">
@@ -215,8 +255,10 @@ export default function HomePage() {
<code className="text-sm font-mono text-emerald-400">pip install vectry-agentlens</code>
</div>
</div>
</AnimateOnScroll>
{/* Step 2: Instrument */}
<AnimateOnScroll delay={100}>
<div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30">
<div className="absolute -top-4 left-8">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-emerald-500 text-neutral-950 text-sm font-bold shadow-lg shadow-emerald-500/25">
@@ -236,8 +278,10 @@ export default function HomePage() {
<code className="text-sm font-mono text-emerald-400">wrap_openai()</code>
</div>
</div>
</AnimateOnScroll>
{/* Step 3: Observe */}
<AnimateOnScroll delay={200}>
<div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30">
<div className="absolute -top-4 left-8">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-emerald-500 text-neutral-950 text-sm font-bold shadow-lg shadow-emerald-500/25">
@@ -255,6 +299,7 @@ export default function HomePage() {
<code className="text-sm font-mono text-emerald-400">agentlens.vectry.tech</code>
</div>
</div>
</AnimateOnScroll>
</div>
{/* Connecting arrows decoration */}
@@ -272,8 +317,9 @@ export default function HomePage() {
{/* Code Example Section */}
<section className="py-24 border-b border-neutral-800/50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid lg:grid-cols-2 gap-12 items-start">
<AnimateOnScroll>
<div className="lg:sticky lg:top-8">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6">
<Cpu className="w-4 h-4" />
@@ -303,8 +349,10 @@ export default function HomePage() {
))}
</ul>
</div>
</AnimateOnScroll>
{/* Code Blocks - Two patterns stacked */}
<AnimateOnScroll delay={150}>
<div className="space-y-6">
{/* Decorator Pattern */}
<div className="rounded-xl overflow-hidden border border-neutral-800 bg-neutral-900/50 backdrop-blur-sm">
@@ -452,6 +500,7 @@ export default function HomePage() {
</pre>
</div>
</div>
</AnimateOnScroll>
</div>
</div>
</section>
@@ -459,7 +508,8 @@ export default function HomePage() {
{/* Integrations Section */}
<section className="py-24 border-b border-neutral-800/50 relative">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_50%_50%_at_50%_50%,rgba(16,185,129,0.03),transparent)]" />
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<AnimateOnScroll>
<div className="text-center mb-16">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6">
<Link2 className="w-4 h-4" />
@@ -472,7 +522,9 @@ export default function HomePage() {
First-class support for the most popular AI frameworks. Drop in and start tracing.
</p>
</div>
</AnimateOnScroll>
<AnimateOnScroll>
<div className="grid sm:grid-cols-3 gap-6 max-w-3xl mx-auto">
{/* OpenAI */}
<div className="group flex flex-col items-center p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30 hover:border-emerald-500/20 transition-all duration-300">
@@ -510,6 +562,130 @@ export default function HomePage() {
</span>
</div>
</div>
</AnimateOnScroll>
</div>
</section>
{/* Pricing Section */}
<section className="py-24 border-b border-neutral-800/50 relative">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_50%_at_50%_50%,rgba(16,185,129,0.04),transparent)]" />
<div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<AnimateOnScroll>
<div className="text-center mb-16">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6">
<Shield className="w-4 h-4" />
<span>Pricing</span>
</div>
<h2 className="text-3xl sm:text-4xl font-bold mb-4">
Simple, transparent pricing
</h2>
<p className="text-lg text-neutral-400 max-w-2xl mx-auto">
No hidden fees. Start free, scale as you grow. Every plan includes the full dashboard experience.
</p>
</div>
</AnimateOnScroll>
<AnimateOnScroll>
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto">
{/* Free Tier */}
<div className="relative flex flex-col p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30 transition-all duration-300 hover:border-neutral-700">
<div className="mb-6">
<h3 className="text-xl font-semibold mb-1">Free</h3>
<p className="text-sm text-neutral-500">For experimentation</p>
</div>
<div className="mb-6">
<span className="text-4xl font-bold">$0</span>
<span className="text-neutral-500 ml-1">/month</span>
</div>
<ul className="space-y-3 mb-8 flex-1">
{[
"20 sessions per day",
"Full dashboard access",
"1 API key",
"Community support",
].map((feature, i) => (
<li key={i} className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-neutral-600 flex-shrink-0 mt-0.5" />
<span className="text-sm text-neutral-400">{feature}</span>
</li>
))}
</ul>
<a
href="/register"
className="block w-full text-center px-6 py-3 rounded-lg border border-neutral-700 hover:border-neutral-600 text-neutral-300 font-medium transition-all duration-200 hover:bg-neutral-800/50"
>
Get Started Free
</a>
</div>
{/* Starter Tier — Highlighted */}
<div className="relative flex flex-col p-8 rounded-2xl border border-emerald-500/40 bg-gradient-to-b from-emerald-500/[0.07] via-neutral-900/50 to-neutral-900/30 transition-all duration-300 shadow-[0_0_40px_-12px_rgba(16,185,129,0.15)]">
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2">
<span className="inline-flex items-center px-3.5 py-1 rounded-full bg-emerald-500 text-neutral-950 text-xs font-bold tracking-wide shadow-lg shadow-emerald-500/25">
Most Popular
</span>
</div>
<div className="mb-6">
<h3 className="text-xl font-semibold mb-1">Starter</h3>
<p className="text-sm text-neutral-500">For small teams</p>
</div>
<div className="mb-6">
<span className="text-4xl font-bold">$5</span>
<span className="text-neutral-500 ml-1">/month</span>
</div>
<ul className="space-y-3 mb-8 flex-1">
{[
"1,000 sessions per month",
"Full dashboard access",
"Unlimited API keys",
"Email support",
].map((feature, i) => (
<li key={i} className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-emerald-500/70 flex-shrink-0 mt-0.5" />
<span className="text-sm text-neutral-300">{feature}</span>
</li>
))}
</ul>
<a
href="/register"
className="block w-full text-center px-6 py-3 rounded-lg bg-emerald-500 hover:bg-emerald-400 text-neutral-950 font-semibold transition-all duration-200 shadow-lg shadow-emerald-500/25 hover:shadow-emerald-500/40"
>
Start Starter Plan
</a>
</div>
{/* Pro Tier */}
<div className="relative flex flex-col p-8 rounded-2xl border border-neutral-800/50 bg-gradient-to-b from-neutral-900/50 to-transparent transition-all duration-300 hover:border-neutral-700">
<div className="mb-6">
<h3 className="text-xl font-semibold mb-1">Pro</h3>
<p className="text-sm text-neutral-500">For scaling teams</p>
</div>
<div className="mb-6">
<span className="text-4xl font-bold">$20</span>
<span className="text-neutral-500 ml-1">/month</span>
</div>
<ul className="space-y-3 mb-8 flex-1">
{[
"100,000 sessions per month",
"Full dashboard access",
"Unlimited API keys",
"Priority support",
].map((feature, i) => (
<li key={i} className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-neutral-600 flex-shrink-0 mt-0.5" />
<span className="text-sm text-neutral-400">{feature}</span>
</li>
))}
</ul>
<a
href="/register"
className="block w-full text-center px-6 py-3 rounded-lg border border-neutral-700 hover:border-emerald-500/40 text-neutral-300 hover:text-emerald-400 font-medium transition-all duration-200 hover:bg-emerald-500/5"
>
Start Pro Plan
</a>
</div>
</div>
</AnimateOnScroll>
</div>
</section>
@@ -542,6 +718,6 @@ export default function HomePage() {
</div>
</div>
</footer>
</div>
</main>
);
}

View File

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

View File

@@ -3,6 +3,7 @@ import Credentials from "next-auth/providers/credentials";
import { compare } from "bcryptjs";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
import authConfig from "./auth.config";
declare module "next-auth" {
@@ -12,6 +13,7 @@ declare module "next-auth" {
email: string;
name?: string | null;
image?: string | null;
isEmailVerified: boolean;
};
}
}
@@ -19,6 +21,7 @@ declare module "next-auth" {
declare module "@auth/core/jwt" {
interface JWT {
id: string;
isEmailVerified: boolean;
}
}
@@ -35,11 +38,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
async authorize(credentials, request) {
const parsed = loginSchema.safeParse(credentials);
if (!parsed.success) return null;
const { email, password } = parsed.data;
const ip = (request instanceof Request
? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
: undefined) ?? "unknown";
const rl = await checkRateLimit(`login:${ip}`, AUTH_RATE_LIMITS.login);
if (!rl.allowed) return null;
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
@@ -58,14 +67,24 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
}),
],
callbacks: {
jwt({ token, user }) {
async jwt({ token, user, trigger }) {
if (user) {
token.id = user.id as string;
}
if (trigger === "update" || user) {
const dbUser = await prisma.user.findUnique({
where: { id: token.id },
select: { emailVerified: true },
});
if (dbUser) {
token.isEmailVerified = dbUser.emailVerified;
}
}
return token;
},
session({ session, token }) {
session.user.id = token.id;
session.user.isEmailVerified = token.isEmailVerified;
return session;
},
},

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 Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import {
Search,
Filter,
@@ -23,6 +23,7 @@ import {
WifiOff,
} from "lucide-react";
import { cn, formatDuration, formatRelativeTime } from "@/lib/utils";
import { useKeyboardNav } from "@/hooks/use-keyboard-nav";
type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR";
@@ -87,6 +88,7 @@ export function TraceList({
initialTotalPages,
initialPage,
}: TraceListProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [traces, setTraces] = useState<Trace[]>(initialTraces);
const [total, setTotal] = useState(initialTotal);
@@ -283,6 +285,19 @@ export function TraceList({
return matchesSearch && matchesStatus;
});
const { selectedIndex } = useKeyboardNav({
itemCount: filteredTraces.length,
onSelect: useCallback(
(index: number) => {
const trace = filteredTraces[index];
if (trace) {
router.push(`/dashboard/traces/${trace.id}`);
}
},
[filteredTraces, router]
),
});
const filterChips: { value: FilterStatus; label: string }[] = [
{ value: "ALL", label: "All" },
{ value: "RUNNING", label: "Running" },
@@ -376,7 +391,9 @@ export function TraceList({
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-neutral-500" />
<label htmlFor="trace-search" className="sr-only">Search traces</label>
<input
id="trace-search"
type="text"
placeholder="Search traces..."
value={searchQuery}
@@ -422,8 +439,9 @@ export function TraceList({
{showAdvancedFilters && (
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-xs text-neutral-500 font-medium">Sort by</label>
<label htmlFor="sort-filter" className="text-xs text-neutral-500 font-medium">Sort by</label>
<select
id="sort-filter"
value={sortFilter}
onChange={(e) => setSortFilter(e.target.value as SortOption)}
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 transition-all"
@@ -437,8 +455,9 @@ export function TraceList({
</div>
<div className="space-y-2">
<label className="text-xs text-neutral-500 font-medium">Date from</label>
<label htmlFor="date-from" className="text-xs text-neutral-500 font-medium">Date from</label>
<input
id="date-from"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
@@ -447,8 +466,9 @@ export function TraceList({
</div>
<div className="space-y-2">
<label className="text-xs text-neutral-500 font-medium">Date to</label>
<label htmlFor="date-to" className="text-xs text-neutral-500 font-medium">Date to</label>
<input
id="date-to"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
@@ -457,8 +477,9 @@ export function TraceList({
</div>
<div className="sm:col-span-3 space-y-2">
<label className="text-xs text-neutral-500 font-medium">Tags (comma-separated)</label>
<label htmlFor="tags-filter" className="text-xs text-neutral-500 font-medium">Tags (comma-separated)</label>
<input
id="tags-filter"
type="text"
placeholder="e.g., production, critical, api"
value={tagsFilter}
@@ -473,8 +494,13 @@ export function TraceList({
{/* Trace List */}
<div className="space-y-3">
{filteredTraces.map((trace) => (
<TraceCard key={trace.id} trace={trace} />
{filteredTraces.map((trace, index) => (
<TraceCard
key={trace.id}
trace={trace}
index={index}
isSelected={index === selectedIndex}
/>
))}
</div>
@@ -497,6 +523,7 @@ export function TraceList({
<button
disabled={currentPage <= 1}
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
aria-label="Previous page"
className="p-2 rounded-lg border border-neutral-800 text-neutral-400 hover:text-neutral-100 hover:border-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-5 h-5" />
@@ -504,6 +531,7 @@ export function TraceList({
<button
disabled={currentPage >= totalPages}
onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))}
aria-label="Next page"
className="p-2 rounded-lg border border-neutral-800 text-neutral-400 hover:text-neutral-100 hover:border-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-5 h-5" />
@@ -515,13 +543,29 @@ export function TraceList({
);
}
function TraceCard({ trace }: { trace: Trace }) {
function TraceCard({
trace,
index,
isSelected,
}: {
trace: Trace;
index: number;
isSelected: boolean;
}) {
const status = statusConfig[trace.status];
const StatusIcon = status.icon;
return (
<Link href={`/dashboard/traces/${trace.id}`}>
<div className="group p-5 bg-neutral-900 border border-neutral-800 rounded-xl hover:border-emerald-500/30 hover:bg-neutral-800/50 transition-all duration-200 cursor-pointer">
<div
data-keyboard-index={index}
className={cn(
"group p-5 bg-neutral-900 border rounded-xl hover:border-emerald-500/30 hover:bg-neutral-800/50 transition-all duration-200 cursor-pointer",
isSelected
? "border-emerald-500/40 bg-emerald-500/5 ring-1 ring-emerald-500/20"
: "border-neutral-800"
)}
>
<div className="flex flex-col lg:flex-row lg:items-center gap-4">
{/* Left: Name and Status */}
<div className="flex-1 min-w-0">

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`;
}
export function cn(...classes: (string | boolean | undefined | null)[]): string {
return classes.filter(Boolean).join(" ");
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -10,6 +10,10 @@ const publicPaths = [
"/api/auth",
"/api/traces",
"/api/health",
"/api/stripe/webhook",
"/forgot-password",
"/reset-password",
"/verify-email",
];
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) => {
const { pathname } = req.nextUrl;
const isLoggedIn = !!req.auth;
const origin = req.headers.get("origin");
if (req.method === "OPTIONS") {
return new NextResponse(null, { status: 204, headers: corsHeaders(origin) });
}
const response = (() => {
if (isPublicPath(pathname)) {
if (isLoggedIn && (pathname === "/login" || pathname === "/register")) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
@@ -43,6 +70,16 @@ export default auth((req) => {
}
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 = {

View File

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

735
package-lock.json generated
View File

@@ -25,13 +25,18 @@
"@dagrejs/dagre": "^2.0.4",
"@xyflow/react": "^12.10.0",
"bcryptjs": "^3.0.3",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"ioredis": "^5.9.2",
"lucide-react": "^0.469.0",
"next": "^15.1.0",
"next-auth": "^5.0.0-beta.30",
"nodemailer": "^6.10.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"shiki": "^3.22.0",
"stripe": "^20.3.1",
"tailwind-merge": "^3.4.0",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -39,6 +44,7 @@
"@types/bcryptjs": "^2.4.6",
"@types/dagre": "^0.7.53",
"@types/node": "^22.0.0",
"@types/nodemailer": "^7.0.9",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"postcss": "^8.5.0",
@@ -1038,6 +1044,12 @@
"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": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1334,6 +1346,447 @@
"@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": {
"version": "4.57.1",
"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"
}
},
"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": {
"version": "19.2.13",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
@@ -2150,7 +2613,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -2224,6 +2687,18 @@
"dev": true,
"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": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
@@ -2376,6 +2851,40 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"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": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -2529,7 +3038,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -2560,6 +3068,15 @@
"devOptional": true,
"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": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -2586,6 +3103,12 @@
"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": {
"version": "1.1.0",
"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_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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
@@ -2835,6 +3367,30 @@
"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": {
"version": "2.6.1",
"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_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": {
"version": "0.469.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz",
@@ -3320,7 +3888,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/mz": {
@@ -3467,6 +4034,15 @@
"devOptional": true,
"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": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
@@ -3770,6 +4346,75 @@
"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": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -3784,6 +4429,27 @@
"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": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
@@ -3972,6 +4638,12 @@
"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": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -4049,6 +4721,16 @@
"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": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@@ -4351,6 +5033,49 @@
"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": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@@ -4449,7 +5174,7 @@
},
"packages/opencode-plugin": {
"name": "opencode-agentlens",
"version": "0.1.6",
"version": "0.1.7",
"license": "MIT",
"dependencies": {
"agentlens-sdk": "*"
@@ -4525,7 +5250,7 @@
},
"packages/sdk-ts": {
"name": "agentlens-sdk",
"version": "0.1.3",
"version": "0.1.4",
"license": "MIT",
"devDependencies": {
"tsup": "^8.3.0",

View File

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

View File

@@ -8,6 +8,8 @@ OpenCode plugin for AgentLens — trace your coding agent's decisions, tool call
## Requirements
- OpenCode >= 1.1.0
- An AgentLens account -- register at [agentlens.vectry.tech/register](https://agentlens.vectry.tech/register)
- An API key created in **Settings > API Keys** in the AgentLens dashboard
## Install
@@ -44,7 +46,7 @@ Add the plugin to your OpenCode configuration at `~/.config/opencode/opencode.js
}
```
Set your API key:
Set your API key (create one at **Settings > API Keys** in the [AgentLens dashboard](https://agentlens.vectry.tech)):
```bash
export AGENTLENS_API_KEY="your-api-key"
@@ -52,6 +54,8 @@ export AGENTLENS_API_KEY="your-api-key"
The plugin activates automatically when OpenCode starts. No code changes required.
Each OpenCode session counts as one trace (one session) for billing purposes. See the [billing documentation](https://agentlens.vectry.tech/docs/authentication-billing) for plan details.
## What Gets Captured
The plugin hooks into OpenCode's event system and records:

View File

@@ -1,6 +1,6 @@
{
"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",
"type": "module",
"main": "./dist/index.cjs",

View File

@@ -12,6 +12,8 @@ AgentLens is an observability SDK for AI agents. Unlike generic LLM tracing tool
## Quick Start
First, create an account at [agentlens.vectry.tech/register](https://agentlens.vectry.tech/register) and generate an API key in **Settings > API Keys** in the dashboard.
```bash
pip install vectry-agentlens
```
@@ -19,7 +21,7 @@ pip install vectry-agentlens
```python
import agentlens
# Initialize once at startup
# Initialize with the API key from Settings > API Keys
agentlens.init(api_key="your-api-key")
# Trace any function with a decorator
@@ -160,7 +162,7 @@ Initialize the SDK. Call once at application startup.
```python
agentlens.init(
api_key="your-api-key", # Required. Your AgentLens API key.
api_key="your-api-key", # Required. Create at Settings > API Keys in the dashboard.
endpoint="https://...", # API endpoint (default: https://agentlens.vectry.tech)
flush_interval=5.0, # Seconds between batch flushes (default: 5.0)
max_batch_size=10, # Traces per batch before auto-flush (default: 10)
@@ -168,6 +170,8 @@ agentlens.init(
)
```
You can also set the API key via the `AGENTLENS_API_KEY` environment variable instead of passing it directly.
### `agentlens.trace()`
Decorator or context manager that creates a trace (or a nested span if already inside a trace).
@@ -264,13 +268,25 @@ The SDK is lightweight and non-blocking. Traces are serialized and batched in a
## Dashboard
View your traces at [agentlens.vectry.tech](https://agentlens.vectry.tech):
View your traces at [agentlens.vectry.tech](https://agentlens.vectry.tech) (login required):
- **Decision Trees** - Visualize the full decision path of every agent run
- **Analytics** - Token usage, cost breakdowns, latency percentiles
- **Real-time Streaming** - Watch agent decisions as they happen
- **Session Grouping** - Track multi-turn conversations by session ID
## Billing
Each trace counts as one session for billing. AgentLens cloud offers three tiers:
| Plan | Price | Sessions |
|------|-------|----------|
| Free | $0 | 20 sessions/day |
| Starter | $5/month | 1,000 sessions/month |
| Pro | $20/month | 100,000 sessions/month |
Manage your subscription in **Settings > Billing**. Self-hosted instances have no session limits.
## Development
```bash

View File

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

View File

@@ -13,10 +13,12 @@ npm install agentlens-sdk
## Quick Start
First, create an account at [agentlens.vectry.tech/register](https://agentlens.vectry.tech/register) and generate an API key in **Settings > API Keys** in the dashboard.
```typescript
import { init, TraceBuilder, shutdown } from "agentlens-sdk";
// Initialize the SDK
// Initialize with the API key from Settings > API Keys
init({
apiKey: "your-api-key",
endpoint: "https://agentlens.vectry.tech/api",
@@ -104,17 +106,31 @@ Pass `InitOptions` to `init()`:
```typescript
init({
apiKey: "your-api-key", // Required. Your AgentLens API key.
apiKey: "your-api-key", // Required. Create at Settings > API Keys in the dashboard.
endpoint: "https://...", // API endpoint. Defaults to AgentLens cloud.
maxBatchSize: 100, // Max items per batch before auto-flush.
flushInterval: 5000, // Auto-flush interval in milliseconds.
});
```
You can also set the API key via the `AGENTLENS_API_KEY` environment variable instead of passing it directly.
## Transport
The SDK ships with `BatchTransport`, which batches payloads and flushes them on an interval or when the batch size threshold is reached. This is used internally by `init()` — you typically do not need to instantiate it directly.
## Billing
Each trace counts as one session for billing. AgentLens cloud offers three tiers:
| Plan | Price | Sessions |
|------|-------|----------|
| Free | $0 | 20 sessions/day |
| Starter | $5/month | 1,000 sessions/month |
| Pro | $20/month | 100,000 sessions/month |
Manage your subscription in **Settings > Billing**. Self-hosted instances have no session limits.
## Documentation
Full documentation: [agentlens.vectry.tech/docs/typescript-sdk](https://agentlens.vectry.tech/docs/typescript-sdk)

View File

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