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 --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN npx prisma generate --schema=packages/database/prisma/schema.prisma RUN npx prisma generate --schema=packages/database/prisma/schema.prisma
RUN npx turbo build RUN npx turbo build --filter=@agentlens/web...
FROM base AS migrate
COPY --from=deps /app/node_modules ./node_modules
COPY packages/database/prisma ./packages/database/prisma
FROM base AS web FROM base AS web
RUN addgroup --system --gid 1001 nodejs && \ RUN addgroup --system --gid 1001 nodejs && \

View File

@@ -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. 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 ## Quick Start
```bash ```bash
@@ -27,6 +36,7 @@ pip install vectry-agentlens
```python ```python
import agentlens import agentlens
# Use the API key you created in Settings > API Keys
agentlens.init(api_key="your-key", endpoint="https://agentlens.vectry.tech") agentlens.init(api_key="your-key", endpoint="https://agentlens.vectry.tech")
with agentlens.trace("my-agent-task", tags=["production"]): with agentlens.trace("my-agent-task", tags=["production"]):
@@ -41,7 +51,7 @@ with agentlens.trace("my-agent-task", tags=["production"]):
agentlens.shutdown() 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 ## 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 ```bash
export AGENTLENS_API_KEY="your-key" export AGENTLENS_API_KEY="your-key"
@@ -90,6 +100,7 @@ npm install agentlens-sdk
```typescript ```typescript
import { init, TraceBuilder, SpanType, SpanStatus } from "agentlens-sdk"; 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" }); init({ apiKey: "your-key", endpoint: "https://agentlens.vectry.tech" });
const trace = new TraceBuilder("my-agent-task", { const trace = new TraceBuilder("my-agent-task", {
@@ -173,8 +184,24 @@ with agentlens.trace("planner"):
| `MEMORY_RETRIEVAL` | Agent chose what context to retrieve | | `MEMORY_RETRIEVAL` | Agent chose what context to retrieve |
| `CUSTOM` | Any other decision type | | `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-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 ```bash
git clone https://gitea.repi.fun/repi/agentlens.git git clone https://gitea.repi.fun/repi/agentlens.git
cd agentlens cd agentlens

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
apps/web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 B

BIN
apps/web/public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Activity, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState("");
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (!emailValid) {
setError("Please enter a valid email address");
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!res.ok) {
const data: { error?: string } = await res.json();
setError(data.error ?? "Something went wrong");
setLoading(false);
return;
}
setSubmitted(true);
} catch {
setError("Something went wrong. Please try again.");
setLoading(false);
}
}
if (submitted) {
return (
<div className="space-y-8">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
<Activity className="w-6 h-6 text-white" />
</div>
<div className="text-center">
<h1 className="text-2xl font-bold text-neutral-100">
Check your email
</h1>
<p className="mt-1 text-sm text-neutral-400">
If an account exists for that email, we sent a password reset
link. It expires in 1 hour.
</p>
</div>
</div>
<p className="text-center text-sm text-neutral-400">
<Link
href="/login"
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
>
Back to sign in
</Link>
</p>
</div>
);
}
return (
<div className="space-y-8">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
<Activity className="w-6 h-6 text-white" />
</div>
<div className="text-center">
<h1 className="text-2xl font-bold text-neutral-100">
Reset your password
</h1>
<p className="mt-1 text-sm text-neutral-400">
Enter your email and we&apos;ll send you a reset link
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
<div className="space-y-2">
<label
htmlFor="email"
className="block text-sm font-medium text-neutral-300"
>
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className={cn(
"w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors",
email && !emailValid
? "border-red-500/50 focus:border-red-500"
: "border-neutral-800 focus:border-emerald-500"
)}
/>
{email && !emailValid && (
<p className="text-xs text-red-400">
Please enter a valid email address
</p>
)}
</div>
</div>
{error && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className={cn(
"w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors",
loading
? "bg-emerald-500/50 text-neutral-950/50 cursor-not-allowed"
: "bg-emerald-500 hover:bg-emerald-400 text-neutral-950"
)}
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? "Sending..." : "Send reset link"}
</button>
</form>
<p className="text-center text-sm text-neutral-400">
Remember your password?{" "}
<Link
href="/login"
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
>
Sign in
</Link>
</p>
</div>
);
}

View File

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

View File

@@ -44,6 +44,13 @@ export default function RegisterPage() {
}), }),
}); });
if (res.status === 429) {
const data: { error?: string } = await res.json();
setError(data.error ?? "Too many attempts. Please try again later.");
setLoading(false);
return;
}
if (!res.ok) { if (!res.ok) {
const data: { error?: string } = await res.json(); const data: { error?: string } = await res.json();
setError(data.error ?? "Registration failed"); setError(data.error ?? "Registration failed");
@@ -58,8 +65,7 @@ export default function RegisterPage() {
}); });
if (result?.error) { if (result?.error) {
setError("Account created but sign-in failed. Please log in manually."); router.push("/login");
setLoading(false);
return; return;
} }

View File

@@ -0,0 +1,235 @@
"use client";
import { Suspense, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { Activity, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
export default function ResetPasswordPage() {
return (
<Suspense>
<ResetPasswordForm />
</Suspense>
);
}
function ResetPasswordForm() {
const searchParams = useSearchParams();
const token = searchParams.get("token");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const passwordValid = password.length >= 8;
const passwordsMatch = password === confirmPassword;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (!passwordValid) {
setError("Password must be at least 8 characters");
return;
}
if (!passwordsMatch) {
setError("Passwords do not match");
return;
}
if (!token) {
setError("Invalid reset link");
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
if (!res.ok) {
const data: { error?: string } = await res.json();
setError(data.error ?? "Something went wrong");
setLoading(false);
return;
}
setSuccess(true);
} catch {
setError("Something went wrong. Please try again.");
setLoading(false);
}
}
if (!token) {
return (
<div className="space-y-8">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
<Activity className="w-6 h-6 text-white" />
</div>
<div className="text-center">
<h1 className="text-2xl font-bold text-neutral-100">
Invalid reset link
</h1>
<p className="mt-1 text-sm text-neutral-400">
This password reset link is invalid or has expired.
</p>
</div>
</div>
<p className="text-center text-sm text-neutral-400">
<Link
href="/forgot-password"
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
>
Request a new reset link
</Link>
</p>
</div>
);
}
if (success) {
return (
<div className="space-y-8">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
<Activity className="w-6 h-6 text-white" />
</div>
<div className="text-center">
<h1 className="text-2xl font-bold text-neutral-100">
Password reset
</h1>
<p className="mt-1 text-sm text-neutral-400">
Your password has been successfully reset.
</p>
</div>
</div>
<p className="text-center text-sm text-neutral-400">
<Link
href="/login"
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
>
Sign in with your new password
</Link>
</p>
</div>
);
}
return (
<div className="space-y-8">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
<Activity className="w-6 h-6 text-white" />
</div>
<div className="text-center">
<h1 className="text-2xl font-bold text-neutral-100">
Set new password
</h1>
<p className="mt-1 text-sm text-neutral-400">
Enter your new password below
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
<div className="space-y-2">
<label
htmlFor="password"
className="block text-sm font-medium text-neutral-300"
>
New password
</label>
<input
id="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className={cn(
"w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors",
password && !passwordValid
? "border-red-500/50 focus:border-red-500"
: "border-neutral-800 focus:border-emerald-500"
)}
/>
{password && !passwordValid && (
<p className="text-xs text-red-400">
Password must be at least 8 characters
</p>
)}
</div>
<div className="space-y-2">
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-neutral-300"
>
Confirm password
</label>
<input
id="confirmPassword"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
className={cn(
"w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors",
confirmPassword && !passwordsMatch
? "border-red-500/50 focus:border-red-500"
: "border-neutral-800 focus:border-emerald-500"
)}
/>
{confirmPassword && !passwordsMatch && (
<p className="text-xs text-red-400">Passwords do not match</p>
)}
</div>
</div>
{error && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className={cn(
"w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors",
loading
? "bg-emerald-500/50 text-neutral-950/50 cursor-not-allowed"
: "bg-emerald-500 hover:bg-emerald-400 text-neutral-950"
)}
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? "Resetting..." : "Reset password"}
</button>
</form>
<p className="text-center text-sm text-neutral-400">
Remember your password?{" "}
<Link
href="/login"
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
>
Sign in
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Activity, Loader2, Mail } from "lucide-react";
import { cn } from "@/lib/utils";
export default function VerifyEmailPage() {
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
async function handleResend() {
setLoading(true);
setMessage("");
setError("");
try {
const res = await fetch("/api/auth/resend-verification", {
method: "POST",
});
if (!res.ok) {
const data: { error?: string } = await res.json();
setError(data.error ?? "Failed to resend email");
setLoading(false);
return;
}
setMessage("Verification email sent! Check your inbox.");
} catch {
setError("Something went wrong. Please try again.");
} finally {
setLoading(false);
}
}
return (
<div className="space-y-8">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
<Activity className="w-6 h-6 text-white" />
</div>
<div className="text-center">
<h1 className="text-2xl font-bold text-neutral-100">
Check your email
</h1>
<p className="mt-1 text-sm text-neutral-400">
We sent a verification link to your inbox
</p>
</div>
</div>
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
<div className="flex items-center justify-center">
<div className="w-16 h-16 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center">
<Mail className="w-8 h-8 text-emerald-400" />
</div>
</div>
<p className="text-sm text-neutral-400 text-center leading-relaxed">
Click the link in the email to verify your account. The link expires
in 24 hours.
</p>
</div>
{message && (
<div className="rounded-lg bg-emerald-500/10 border border-emerald-500/20 px-4 py-3">
<p className="text-sm text-emerald-400">{message}</p>
</div>
)}
{error && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<button
onClick={handleResend}
disabled={loading}
className={cn(
"w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors",
loading
? "bg-emerald-500/50 text-neutral-950/50 cursor-not-allowed"
: "bg-emerald-500 hover:bg-emerald-400 text-neutral-950"
)}
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? "Sending..." : "Resend verification email"}
</button>
<p className="text-center text-sm text-neutral-400">
Already verified?{" "}
<Link
href="/login"
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
>
Sign in
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { NextResponse } from "next/server";
import { randomBytes, createHash } from "crypto";
import { z } from "zod";
import nodemailer from "nodemailer";
import { prisma } from "@/lib/prisma";
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
const forgotPasswordSchema = z.object({
email: z.email("Invalid email address"),
});
const transporter = nodemailer.createTransport({
host: "smtp.migadu.com",
port: 465,
secure: true,
auth: {
user: "hunter@repi.fun",
pass: process.env.EMAIL_PASSWORD,
},
});
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export async function POST(request: Request) {
try {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rl = await checkRateLimit(`forgot:${ip}`, AUTH_RATE_LIMITS.forgotPassword);
if (!rl.allowed) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
);
}
const body: unknown = await request.json();
const parsed = forgotPasswordSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
{ status: 400 }
);
}
const { email } = parsed.data;
const normalizedEmail = email.toLowerCase();
const user = await prisma.user.findUnique({
where: { email: normalizedEmail },
});
// Always return success to prevent email enumeration
if (!user) {
return NextResponse.json({ success: true });
}
await prisma.passwordResetToken.updateMany({
where: { userId: user.id, used: false },
data: { used: true },
});
const rawToken = randomBytes(32).toString("hex");
const tokenHash = hashToken(rawToken);
await prisma.passwordResetToken.create({
data: {
userId: user.id,
token: tokenHash,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
const resetUrl = `https://agentlens.vectry.tech/reset-password?token=${rawToken}`;
await transporter.sendMail({
from: '"AgentLens" <hunter@repi.fun>',
to: normalizedEmail,
subject: "Reset your AgentLens password",
text: `You requested a password reset for your AgentLens account.\n\nClick the link below to set a new password:\n${resetUrl}\n\nThis link expires in 1 hour.\n\nIf you did not request this, you can safely ignore this email.`,
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
<h2 style="color: #f5f5f5; font-size: 20px; margin-bottom: 16px;">Reset your password</h2>
<p style="color: #a3a3a3; font-size: 14px; line-height: 1.6; margin-bottom: 24px;">
You requested a password reset for your AgentLens account. Click the button below to set a new password.
</p>
<a href="${resetUrl}" style="display: inline-block; background-color: #10b981; color: #0a0a0a; font-weight: 600; font-size: 14px; padding: 12px 24px; border-radius: 8px; text-decoration: none;">
Reset password
</a>
<p style="color: #737373; font-size: 12px; line-height: 1.5; margin-top: 32px;">
This link expires in 1 hour. If you did not request this, you can safely ignore this email.
</p>
</div>
`,
});
return NextResponse.json({ success: true });
} catch {
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -1,7 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { hash } from "bcryptjs"; import { hash } from "bcryptjs";
import crypto from "crypto";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/email";
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
const registerSchema = z.object({ const registerSchema = z.object({
email: z.email("Invalid email address"), email: z.email("Invalid email address"),
@@ -11,6 +14,15 @@ const registerSchema = z.object({
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rl = await checkRateLimit(`register:${ip}`, AUTH_RATE_LIMITS.register);
if (!rl.allowed) {
return NextResponse.json(
{ error: "Too many registration attempts. Please try again later." },
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
);
}
const body: unknown = await request.json(); const body: unknown = await request.json();
const parsed = registerSchema.safeParse(body); const parsed = registerSchema.safeParse(body);
@@ -30,8 +42,8 @@ export async function POST(request: Request) {
if (existing) { if (existing) {
return NextResponse.json( return NextResponse.json(
{ error: "An account with this email already exists" }, { message: "If this email is available, a confirmation email will be sent." },
{ status: 409 } { status: 200 }
); );
} }
@@ -57,7 +69,48 @@ export async function POST(request: Request) {
}, },
}); });
return NextResponse.json(user, { status: 201 }); try {
const rawToken = crypto.randomBytes(32).toString("hex");
const tokenHash = crypto
.createHash("sha256")
.update(rawToken)
.digest("hex");
await prisma.emailVerificationToken.create({
data: {
userId: user.id,
token: tokenHash,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
});
const verifyUrl = `https://agentlens.vectry.tech/verify-email?token=${rawToken}`;
await sendEmail({
to: user.email,
subject: "Verify your AgentLens email",
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
<h2 style="color: #e5e5e5; margin-bottom: 16px;">Verify your email</h2>
<p style="color: #a3a3a3; line-height: 1.6;">
Thanks for signing up for AgentLens. Click the link below to verify your email address.
</p>
<a href="${verifyUrl}" style="display: inline-block; margin-top: 24px; padding: 12px 24px; background-color: #10b981; color: #000; text-decoration: none; border-radius: 8px; font-weight: 600;">
Verify Email
</a>
<p style="color: #737373; font-size: 13px; margin-top: 32px;">
This link expires in 24 hours. If you didn't create an account, you can safely ignore this email.
</p>
</div>
`,
});
} catch (emailError) {
console.error("[register] Failed to send verification email:", emailError);
}
return NextResponse.json(
{ message: "If this email is available, a confirmation email will be sent." },
{ status: 200 }
);
} catch { } catch {
return NextResponse.json( return NextResponse.json(
{ error: "Internal server error" }, { error: "Internal server error" },

View File

@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import crypto from "crypto";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/email";
export async function POST() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
if (user.emailVerified) {
return NextResponse.json({ error: "Email already verified" }, { status: 400 });
}
const latestToken = await prisma.emailVerificationToken.findFirst({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
});
if (latestToken && Date.now() - latestToken.createdAt.getTime() < 60_000) {
return NextResponse.json(
{ error: "Please wait 60 seconds before requesting another email" },
{ status: 429 }
);
}
await prisma.emailVerificationToken.updateMany({
where: { userId: user.id, used: false },
data: { used: true },
});
const rawToken = crypto.randomBytes(32).toString("hex");
const tokenHash = crypto
.createHash("sha256")
.update(rawToken)
.digest("hex");
await prisma.emailVerificationToken.create({
data: {
userId: user.id,
token: tokenHash,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
});
const verifyUrl = `https://agentlens.vectry.tech/verify-email?token=${rawToken}`;
await sendEmail({
to: user.email,
subject: "Verify your AgentLens email",
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
<h2 style="color: #e5e5e5; margin-bottom: 16px;">Verify your email</h2>
<p style="color: #a3a3a3; line-height: 1.6;">
Click the link below to verify your email address for AgentLens.
</p>
<a href="${verifyUrl}" style="display: inline-block; margin-top: 24px; padding: 12px 24px; background-color: #10b981; color: #000; text-decoration: none; border-radius: 8px; font-weight: 600;">
Verify Email
</a>
<p style="color: #737373; font-size: 13px; margin-top: 32px;">
This link expires in 24 hours.
</p>
</div>
`,
});
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import { createHash } from "crypto";
import { hash } from "bcryptjs";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
const resetPasswordSchema = z.object({
token: z.string().min(1, "Token is required"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export async function POST(request: Request) {
try {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rl = await checkRateLimit(`reset:${ip}`, AUTH_RATE_LIMITS.resetPassword);
if (!rl.allowed) {
return NextResponse.json(
{ error: "Too many attempts. Please try again later." },
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
);
}
const body: unknown = await request.json();
const parsed = resetPasswordSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
{ status: 400 }
);
}
const { token, password } = parsed.data;
const tokenHash = hashToken(token);
const resetToken = await prisma.passwordResetToken.findUnique({
where: { token: tokenHash },
include: { user: true },
});
if (!resetToken || resetToken.used || resetToken.expiresAt < new Date()) {
return NextResponse.json(
{ error: "Invalid or expired reset link" },
{ status: 400 }
);
}
const passwordHash = await hash(password, 12);
await prisma.$transaction([
prisma.user.update({
where: { id: resetToken.userId },
data: { passwordHash },
}),
prisma.passwordResetToken.update({
where: { id: resetToken.id },
data: { used: true },
}),
]);
return NextResponse.json({ success: true });
} catch {
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { prisma } from "@/lib/prisma";
export async function GET(request: NextRequest) {
const rawToken = request.nextUrl.searchParams.get("token");
if (!rawToken) {
return NextResponse.redirect(
new URL("/login?error=missing-token", request.url)
);
}
const tokenHash = crypto
.createHash("sha256")
.update(rawToken)
.digest("hex");
const verificationToken = await prisma.emailVerificationToken.findUnique({
where: { token: tokenHash },
include: { user: true },
});
if (!verificationToken) {
return NextResponse.redirect(
new URL("/login?error=invalid-token", request.url)
);
}
if (verificationToken.used) {
return NextResponse.redirect(
new URL("/login?verified=true", request.url)
);
}
if (verificationToken.expiresAt < new Date()) {
return NextResponse.redirect(
new URL("/login?error=token-expired", request.url)
);
}
await prisma.$transaction([
prisma.user.update({
where: { id: verificationToken.userId },
data: { emailVerified: true },
}),
prisma.emailVerificationToken.update({
where: { id: verificationToken.id },
data: { used: true },
}),
]);
return NextResponse.redirect(new URL("/login?verified=true", request.url));
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { seedDemoData } from "@/lib/demo-data";
export async function POST() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { demoSeeded: true },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
if (user.demoSeeded) {
return NextResponse.json({ error: "Demo data already seeded" }, { status: 409 });
}
await seedDemoData(session.user.id);
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("Error seeding demo data:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -37,6 +37,17 @@ export async function POST(request: Request) {
if (!session?.user?.id) if (!session?.user?.id)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const MAX_KEYS_PER_USER = 10;
const keyCount = await prisma.apiKey.count({
where: { userId: session.user.id, revoked: false },
});
if (keyCount >= MAX_KEYS_PER_USER) {
return NextResponse.json(
{ error: `Maximum of ${MAX_KEYS_PER_USER} API keys allowed. Revoke an existing key first.` },
{ status: 400 }
);
}
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const name = const name =
typeof body.name === "string" && body.name.trim() typeof body.name === "string" && body.name.trim()

View File

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

View File

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

View File

@@ -92,6 +92,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Missing or invalid Authorization header" }, { status: 401 }); return NextResponse.json({ error: "Missing or invalid Authorization header" }, { status: 401 });
} }
const contentLength = parseInt(request.headers.get("content-length") ?? "0", 10);
const MAX_BODY_SIZE = 10 * 1024 * 1024;
if (contentLength > MAX_BODY_SIZE) {
return NextResponse.json({ error: "Request body too large (max 10MB)" }, { status: 413 });
}
const rawApiKey = authHeader.slice(7); const rawApiKey = authHeader.slice(7);
if (!rawApiKey) { if (!rawApiKey) {
return NextResponse.json({ error: "Missing API key in Authorization header" }, { status: 401 }); return NextResponse.json({ error: "Missing API key in Authorization header" }, { status: 401 });
@@ -241,9 +247,14 @@ export async function POST(request: NextRequest) {
for (const trace of body.traces) { for (const trace of body.traces) {
const existing = await tx.trace.findUnique({ const existing = await tx.trace.findUnique({
where: { id: trace.id }, where: { id: trace.id },
select: { id: true }, select: { id: true, userId: true },
}); });
// Security: prevent cross-user trace overwrite
if (existing && existing.userId !== userId) {
continue; // skip traces owned by other users
}
const traceData = { const traceData = {
name: trace.name, name: trace.name,
sessionId: trace.sessionId, sessionId: trace.sessionId,

View File

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

View File

@@ -3,6 +3,7 @@
import { ReactNode, useState } from "react"; import { ReactNode, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import { import {
Activity, Activity,
GitBranch, GitBranch,
@@ -10,8 +11,13 @@ import {
Settings, Settings,
Menu, Menu,
ChevronRight, ChevronRight,
X,
AlertTriangle,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CommandPalette } from "@/components/command-palette";
import { KeyboardShortcutsHelp, ShortcutsHint } from "@/components/keyboard-shortcuts-help";
interface NavItem { interface NavItem {
href: string; href: string;
@@ -101,11 +107,70 @@ function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
); );
} }
function VerificationBanner() {
const { data: session } = useSession();
const [dismissed, setDismissed] = useState(false);
const [resending, setResending] = useState(false);
const [sent, setSent] = useState(false);
if (dismissed || !session?.user || session.user.isEmailVerified) {
return null;
}
async function handleResend() {
setResending(true);
try {
const res = await fetch("/api/auth/resend-verification", { method: "POST" });
if (res.ok) {
setSent(true);
}
} catch {
} finally {
setResending(false);
}
}
return (
<div className="bg-amber-500/10 border-b border-amber-500/20 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
<p className="text-sm text-amber-200 truncate">
{sent
? "Verification email sent! Check your inbox."
: "Please verify your email address. Check your inbox or"}
</p>
{!sent && (
<button
onClick={handleResend}
disabled={resending}
className="text-sm font-medium text-amber-400 hover:text-amber-300 transition-colors whitespace-nowrap inline-flex items-center gap-1"
>
{resending && <Loader2 className="w-3 h-3 animate-spin" />}
{resending ? "sending..." : "click to resend."}
</button>
)}
</div>
<button
onClick={() => setDismissed(true)}
aria-label="Dismiss verification banner"
className="p-1 rounded text-amber-400/60 hover:text-amber-300 transition-colors shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}
export default function DashboardLayout({ children }: { children: ReactNode }) { export default function DashboardLayout({ children }: { children: ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
return ( return (
<div className="min-h-screen bg-neutral-950 flex"> <div className="min-h-screen bg-neutral-950 flex">
<CommandPalette />
<KeyboardShortcutsHelp />
<ShortcutsHint />
{/* Desktop Sidebar */} {/* Desktop Sidebar */}
<aside className="hidden lg:block w-64 h-screen sticky top-0"> <aside className="hidden lg:block w-64 h-screen sticky top-0">
<Sidebar /> <Sidebar />
@@ -130,12 +195,14 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
</aside> </aside>
{/* Main Content */} {/* Main Content */}
<main className="flex-1 min-w-0"> <main id="main-content" className="flex-1 min-w-0">
<VerificationBanner />
{/* Mobile Header */} {/* Mobile Header */}
<header className="lg:hidden sticky top-0 z-30 bg-neutral-950/80 backdrop-blur-md border-b border-neutral-800 px-4 py-3"> <header className="lg:hidden sticky top-0 z-30 bg-neutral-950/80 backdrop-blur-md border-b border-neutral-800 px-4 py-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<button <button
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
aria-label="Open navigation menu"
className="p-2 rounded-lg bg-neutral-900 border border-neutral-800 text-neutral-400 hover:text-neutral-100 transition-colors" className="p-2 rounded-lg bg-neutral-900 border border-neutral-800 text-neutral-400 hover:text-neutral-100 transition-colors"
> >
<Menu className="w-5 h-5" /> <Menu className="w-5 h-5" />

View File

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

View File

@@ -55,9 +55,37 @@ export default function ApiReferencePage() {
<section className="mb-6"> <section className="mb-6">
<h2 className="text-2xl font-semibold mb-4">Authentication</h2> <h2 className="text-2xl font-semibold mb-4">Authentication</h2>
<p className="text-neutral-400 leading-relaxed mb-4"> <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> </p>
<CodeBlock>{`Authorization: Bearer your-api-key`}</CodeBlock> <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> </section>
<hr className="border-neutral-800/50 my-10" /> <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> </ul>
</section> </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"> <section className="mb-12">
<h2 className="text-2xl font-semibold mb-4"> <h2 className="text-2xl font-semibold mb-4">
Step 1: Install the SDK Step 1: Install the SDK
@@ -194,6 +227,57 @@ await trace.end();`}</CodeBlock>
</a> </a>
</section> </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"> <section className="mb-12">
<h2 className="text-2xl font-semibold mb-4">Next steps</h2> <h2 className="text-2xl font-semibold mb-4">Next steps</h2>
<div className="grid sm:grid-cols-2 gap-4"> <div className="grid sm:grid-cols-2 gap-4">

View File

@@ -22,6 +22,12 @@ const sections = [
description: description:
"Understand Traces, Spans, Decision Points, and Events — the four building blocks of AgentLens.", "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"> <p className="text-lg text-neutral-400 mb-10 leading-relaxed">
AgentLens is open source and designed to be self-hosted. You can deploy AgentLens is open source and designed to be self-hosted. You can deploy
it with Docker in minutes, or run from source for development. 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> </p>
<section className="mb-12"> <section className="mb-12">

View File

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

View File

@@ -1,9 +1,10 @@
import { Inter } from "next/font/google"; import { Inter, JetBrains_Mono } from "next/font/google";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";
import "./globals.css"; import "./globals.css";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains", display: "swap" });
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL("https://agentlens.vectry.tech"), metadataBase: new URL("https://agentlens.vectry.tech"),
@@ -25,6 +26,13 @@ export const metadata: Metadata = {
], ],
authors: [{ name: "Vectry" }], authors: [{ name: "Vectry" }],
creator: "Vectry", creator: "Vectry",
icons: {
icon: [
{ url: "/favicon.ico", sizes: "any" },
{ url: "/icon.png", sizes: "512x512", type: "image/png" },
],
apple: [{ url: "/apple-icon.png", sizes: "180x180" }],
},
openGraph: { openGraph: {
type: "website", type: "website",
locale: "en_US", locale: "en_US",
@@ -72,7 +80,13 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en" className="dark"> <html lang="en" className="dark">
<body className={`${inter.className} bg-neutral-950 text-neutral-100 antialiased`}> <body className={`${inter.variable} ${jetbrainsMono.variable} bg-neutral-950 text-neutral-100 antialiased`}>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[200] focus:px-4 focus:py-2 focus:rounded-lg focus:bg-emerald-500 focus:text-neutral-950 focus:font-semibold focus:text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:ring-offset-2 focus:ring-offset-neutral-950"
>
Skip to content
</a>
<SessionProvider>{children}</SessionProvider> <SessionProvider>{children}</SessionProvider>
</body> </body>
</html> </html>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
"use client";
import { useEffect, useRef, ReactNode } from "react";
interface AnimateOnScrollProps {
children: ReactNode;
className?: string;
delay?: number;
threshold?: number;
}
export function AnimateOnScroll({
children,
className = "",
delay = 0,
threshold = 0.15,
}: AnimateOnScrollProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (prefersReduced) {
el.setAttribute("data-animate", "visible");
return;
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.setAttribute("data-animate", "visible");
observer.unobserve(entry.target);
}
}
},
{ threshold }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return (
<div
ref={ref}
data-animate="hidden"
className={className}
style={{ animationDelay: delay ? `${delay}ms` : undefined }}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,258 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { signOut } from "next-auth/react";
import { Command } from "cmdk";
import {
Activity,
GitBranch,
Key,
Settings,
LogOut,
Plus,
Search,
ArrowRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface RecentTrace {
id: string;
name: string;
status: string;
startedAt: string;
}
export function CommandPalette() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [recentTraces, setRecentTraces] = useState<RecentTrace[]>([]);
const [loading, setLoading] = useState(false);
const fetchRecentTraces = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/traces?limit=5", { cache: "no-store" });
if (res.ok) {
const data = await res.json();
setRecentTraces(data.traces ?? []);
}
} catch {
// Silently fail -- palette still works for navigation
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
fetchRecentTraces();
}
}, [open, fetchRecentTraces]);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen((prev) => !prev);
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
function runCommand(command: () => void) {
setOpen(false);
command();
}
if (!open) return null;
return (
<div className="fixed inset-0 z-[100]">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
{/* Palette */}
<div className="absolute inset-0 flex items-start justify-center pt-[20vh] px-4">
<Command
className="w-full max-w-xl rounded-xl border border-neutral-800 bg-neutral-900/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
loop
>
{/* Search input */}
<div className="flex items-center gap-3 border-b border-neutral-800 px-4">
<Search className="w-4 h-4 text-neutral-500 shrink-0" />
<Command.Input
placeholder="Search traces, navigate, or run actions..."
className="w-full py-4 bg-transparent text-sm text-neutral-100 placeholder-neutral-500 outline-none"
autoFocus
/>
<kbd className="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-[10px] font-mono text-neutral-500">
ESC
</kbd>
</div>
{/* Results */}
<Command.List className="max-h-80 overflow-y-auto p-2">
<Command.Empty className="py-8 text-center text-sm text-neutral-500">
No results found.
</Command.Empty>
{/* Recent Traces */}
{recentTraces.length > 0 && (
<Command.Group
heading="Recent Traces"
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500"
>
{loading ? (
<div className="px-2 py-3 text-xs text-neutral-500">
Loading traces...
</div>
) : (
recentTraces.map((trace) => (
<Command.Item
key={trace.id}
value={`trace ${trace.name} ${trace.id}`}
onSelect={() =>
runCommand(() =>
router.push(`/dashboard/traces/${trace.id}`)
)
}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer transition-colors",
"text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400"
)}
>
<Activity className="w-4 h-4 shrink-0 text-neutral-500" />
<span className="flex-1 truncate">{trace.name}</span>
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded",
trace.status === "COMPLETED" &&
"bg-emerald-500/10 text-emerald-400",
trace.status === "ERROR" &&
"bg-red-500/10 text-red-400",
trace.status === "RUNNING" &&
"bg-amber-500/10 text-amber-400"
)}
>
{trace.status.toLowerCase()}
</span>
<ArrowRight className="w-3.5 h-3.5 shrink-0 opacity-0 group-data-[selected=true]:opacity-100" />
</Command.Item>
))
)}
</Command.Group>
)}
{/* Navigation */}
<Command.Group
heading="Navigation"
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500"
>
<Command.Item
value="Dashboard Traces"
onSelect={() =>
runCommand(() => router.push("/dashboard"))
}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
>
<Activity className="w-4 h-4 shrink-0 text-neutral-500" />
<span className="flex-1">Dashboard</span>
</Command.Item>
<Command.Item
value="Decisions"
onSelect={() =>
runCommand(() => router.push("/dashboard/decisions"))
}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
>
<GitBranch className="w-4 h-4 shrink-0 text-neutral-500" />
<span className="flex-1">Decisions</span>
</Command.Item>
<Command.Item
value="API Keys"
onSelect={() =>
runCommand(() => router.push("/dashboard/keys"))
}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
>
<Key className="w-4 h-4 shrink-0 text-neutral-500" />
<span className="flex-1">API Keys</span>
</Command.Item>
<Command.Item
value="Settings"
onSelect={() =>
runCommand(() => router.push("/dashboard/settings"))
}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
>
<Settings className="w-4 h-4 shrink-0 text-neutral-500" />
<span className="flex-1">Settings</span>
</Command.Item>
</Command.Group>
{/* Actions */}
<Command.Group
heading="Actions"
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500"
>
<Command.Item
value="Create New API Key"
onSelect={() =>
runCommand(() => router.push("/dashboard/keys"))
}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
>
<Plus className="w-4 h-4 shrink-0 text-neutral-500" />
<span className="flex-1">New API Key</span>
</Command.Item>
<Command.Item
value="Sign Out Logout"
onSelect={() =>
runCommand(() => signOut({ callbackUrl: "/" }))
}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-red-500/10 data-[selected=true]:text-red-400 transition-colors"
>
<LogOut className="w-4 h-4 shrink-0 text-neutral-500" />
<span className="flex-1">Logout</span>
</Command.Item>
</Command.Group>
</Command.List>
{/* Footer */}
<div className="flex items-center justify-between border-t border-neutral-800 px-4 py-2.5">
<div className="flex items-center gap-3 text-[11px] text-neutral-500">
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 font-mono">
&uarr;&darr;
</kbd>
Navigate
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 font-mono">
&crarr;
</kbd>
Select
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 font-mono">
esc
</kbd>
Close
</span>
</div>
</div>
</Command>
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Beaker, ArrowRight, X } from "lucide-react";
import { cn } from "@/lib/utils";
const DISMISS_KEY = "agentlens-demo-banner-dismissed";
interface DemoBannerProps {
allTracesAreDemo: boolean;
}
export function DemoBanner({ allTracesAreDemo }: DemoBannerProps) {
const [dismissed, setDismissed] = useState(true);
useEffect(() => {
setDismissed(localStorage.getItem(DISMISS_KEY) === "true");
}, []);
if (dismissed || !allTracesAreDemo) return null;
function handleDismiss() {
setDismissed(true);
localStorage.setItem(DISMISS_KEY, "true");
}
return (
<div
className={cn(
"relative mb-6 rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4",
"flex items-center gap-4"
)}
>
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-emerald-500/10 border border-emerald-500/20 shrink-0">
<Beaker className="w-5 h-5 text-emerald-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-emerald-200 font-medium">
You are viewing sample data.
</p>
<p className="text-xs text-emerald-400/60 mt-0.5">
Connect your agent to start collecting real traces.
</p>
</div>
<Link
href="/docs/getting-started"
className="hidden sm:flex items-center gap-1.5 px-4 py-2 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-sm font-medium text-emerald-400 hover:bg-emerald-500/20 transition-colors shrink-0"
>
View Setup Guide
<ArrowRight className="w-3.5 h-3.5" />
</Link>
<button
onClick={handleDismiss}
aria-label="Dismiss demo banner"
className="p-1.5 rounded-lg text-emerald-400/40 hover:text-emerald-400/80 hover:bg-emerald-500/10 transition-colors shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { useEffect, useState } from "react";
interface DemoSeedTriggerProps {
hasTraces: boolean;
children: React.ReactNode;
}
export function DemoSeedTrigger({ hasTraces, children }: DemoSeedTriggerProps) {
const [seeding, setSeeding] = useState(false);
useEffect(() => {
if (hasTraces || seeding) return;
async function seedIfNeeded() {
setSeeding(true);
try {
const res = await fetch("/api/demo/seed", { method: "POST" });
if (res.ok) {
window.location.reload();
}
} catch {
// Seed failed, continue showing empty state
} finally {
setSeeding(false);
}
}
seedIfNeeded();
}, [hasTraces, seeding]);
if (!hasTraces && seeding) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-10 h-10 rounded-xl border-2 border-emerald-500/30 border-t-emerald-500 animate-spin mb-4" />
<p className="text-sm text-neutral-400">Setting up your workspace with sample data...</p>
</div>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,113 @@
"use client";
import { useState, useEffect } from "react";
import { X } from "lucide-react";
const shortcuts = [
{ keys: ["j"], description: "Move selection down" },
{ keys: ["k"], description: "Move selection up" },
{ keys: ["Enter"], description: "Open selected item" },
{ keys: ["Escape"], description: "Clear selection / go back" },
{ keys: ["g", "h"], description: "Go to Dashboard" },
{ keys: ["g", "s"], description: "Go to Settings" },
{ keys: ["g", "k"], description: "Go to API Keys" },
{ keys: ["g", "d"], description: "Go to Decisions" },
{ keys: ["Cmd", "K"], description: "Open command palette" },
{ keys: ["?"], description: "Show this help" },
];
export function KeyboardShortcutsHelp() {
const [open, setOpen] = useState(false);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
const el = document.activeElement;
if (el) {
const tag = el.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") return;
if ((el as HTMLElement).isContentEditable) return;
}
if (e.key === "?" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
setOpen((prev) => !prev);
}
if (e.key === "Escape" && open) {
setOpen(false);
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-[90]">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
<div className="absolute inset-0 flex items-center justify-center px-4">
<div className="w-full max-w-md rounded-xl border border-neutral-800 bg-neutral-900/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800">
<h2 className="text-sm font-semibold text-neutral-100">
Keyboard Shortcuts
</h2>
<button
onClick={() => setOpen(false)}
aria-label="Close shortcuts help"
className="p-1.5 rounded-lg text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-2 max-h-[60vh] overflow-y-auto">
{shortcuts.map((shortcut, i) => (
<div
key={i}
className="flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-neutral-800/50"
>
<span className="text-sm text-neutral-400">
{shortcut.description}
</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, j) => (
<span key={j}>
{j > 0 && (
<span className="text-neutral-600 text-xs mx-0.5">
then
</span>
)}
<kbd className="inline-flex items-center justify-center min-w-[24px] px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-xs font-mono text-neutral-300">
{key}
</kbd>
</span>
))}
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
export function ShortcutsHint() {
return (
<div className="fixed bottom-4 right-4 z-30">
<span className="text-xs text-neutral-600 flex items-center gap-1.5">
Press
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 text-[10px] font-mono text-neutral-500">
?
</kbd>
for shortcuts
</span>
</div>
);
}

View File

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

View File

@@ -0,0 +1,123 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
function isInputFocused(): boolean {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") return true;
if ((el as HTMLElement).isContentEditable) return true;
return false;
}
interface UseKeyboardNavOptions {
itemCount: number;
onSelect: (index: number) => void;
enabled?: boolean;
}
export function useKeyboardNav({
itemCount,
onSelect,
enabled = true,
}: UseKeyboardNavOptions) {
const [selectedIndex, setSelectedIndex] = useState(-1);
const router = useRouter();
const gPressedRef = useRef(false);
const gTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const resetSelection = useCallback(() => {
setSelectedIndex(-1);
}, []);
useEffect(() => {
if (!enabled) return;
function handleKeyDown(e: KeyboardEvent) {
if (isInputFocused()) return;
if (gPressedRef.current) {
gPressedRef.current = false;
clearTimeout(gTimerRef.current);
if (e.key === "h") {
e.preventDefault();
router.push("/dashboard");
return;
}
if (e.key === "s") {
e.preventDefault();
router.push("/dashboard/settings");
return;
}
if (e.key === "k") {
e.preventDefault();
router.push("/dashboard/keys");
return;
}
if (e.key === "d") {
e.preventDefault();
router.push("/dashboard/decisions");
return;
}
return;
}
if (e.key === "g" && !e.metaKey && !e.ctrlKey && !e.altKey) {
gPressedRef.current = true;
gTimerRef.current = setTimeout(() => {
gPressedRef.current = false;
}, 500);
return;
}
if (e.key === "j" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
setSelectedIndex((prev) => {
const next = prev + 1;
return next >= itemCount ? itemCount - 1 : next;
});
return;
}
if (e.key === "k" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
setSelectedIndex((prev) => {
const next = prev - 1;
return next < 0 ? 0 : next;
});
return;
}
if (e.key === "Enter" && selectedIndex >= 0) {
e.preventDefault();
onSelect(selectedIndex);
return;
}
if (e.key === "Escape") {
setSelectedIndex(-1);
return;
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
clearTimeout(gTimerRef.current);
};
}, [enabled, itemCount, selectedIndex, onSelect, router]);
useEffect(() => {
if (selectedIndex < 0) return;
const row = document.querySelector(`[data-keyboard-index="${selectedIndex}"]`);
if (row) {
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [selectedIndex]);
return { selectedIndex, setSelectedIndex, resetSelection };
}

View File

@@ -0,0 +1,554 @@
import { prisma } from "@/lib/prisma";
import type { Prisma, SpanType } from "@agentlens/database";
type EventCreate = Prisma.EventCreateWithoutTraceInput;
type DecisionCreate = Prisma.DecisionPointCreateWithoutTraceInput;
interface DemoSpan {
id: string;
name: string;
type: SpanType;
status: "RUNNING" | "COMPLETED" | "ERROR";
parentSpanId?: string;
input?: Prisma.InputJsonValue;
output?: Prisma.InputJsonValue;
tokenCount?: number;
costUsd?: number;
durationMs?: number;
startedAt: Date;
endedAt?: Date;
metadata?: Prisma.InputJsonValue;
statusMessage?: string;
}
function daysAgo(days: number, offsetMs = 0): Date {
const d = new Date();
d.setDate(d.getDate() - days);
d.setMilliseconds(d.getMilliseconds() + offsetMs);
return d;
}
function endDate(start: Date, durationMs: number): Date {
return new Date(start.getTime() + durationMs);
}
export async function seedDemoData(userId: string) {
const traces = [
createSimpleChatTrace(userId),
createMultiToolAgentTrace(userId),
createRagPipelineTrace(userId),
createErrorHandlingTrace(userId),
createLongRunningWorkflowTrace(userId),
createCodeAnalysisTrace(userId),
createWebSearchTrace(userId),
];
for (const traceFn of traces) {
const { trace, spans, events, decisions } = traceFn;
await prisma.trace.create({
data: {
...trace,
spans: { create: spans },
events: { create: events },
decisionPoints: { create: decisions },
},
});
}
await prisma.user.update({
where: { id: userId },
data: { demoSeeded: true },
});
}
function createSimpleChatTrace(userId: string) {
const start = daysAgo(1);
const duration = 1240;
const spanId = `demo-span-chat-${userId.slice(0, 8)}`;
return {
trace: {
name: "Simple Chat Completion",
userId,
status: "COMPLETED" as const,
isDemo: true,
tags: ["openai", "chat"],
metadata: { model: "gpt-4o", temperature: 0.7 },
totalCost: 0.0032,
totalTokens: 245,
totalDuration: duration,
startedAt: start,
endedAt: endDate(start, duration),
},
spans: [
{
id: spanId,
name: "chat.completions.create",
type: "LLM_CALL" as const,
status: "COMPLETED" as const,
input: { messages: [{ role: "user", content: "Explain quantum computing in simple terms" }] },
output: { content: "Quantum computing uses quantum bits (qubits) that can exist in multiple states simultaneously..." },
tokenCount: 245,
costUsd: 0.0032,
durationMs: duration,
startedAt: start,
endedAt: endDate(start, duration),
metadata: { model: "gpt-4o", provider: "openai" },
},
],
events: [] as EventCreate[],
decisions: [] as DecisionCreate[],
};
}
function createMultiToolAgentTrace(userId: string) {
const start = daysAgo(2);
const parentId = `demo-span-agent-${userId.slice(0, 8)}`;
const toolIds = [
`demo-span-tool1-${userId.slice(0, 8)}`,
`demo-span-tool2-${userId.slice(0, 8)}`,
`demo-span-tool3-${userId.slice(0, 8)}`,
];
const llmId = `demo-span-llm-${userId.slice(0, 8)}`;
return {
trace: {
name: "Multi-Tool Agent Run",
userId,
status: "COMPLETED" as const,
isDemo: true,
tags: ["agent", "tools", "production"],
metadata: { agent: "research-assistant", run_id: "demo-run-001" },
totalCost: 0.0187,
totalTokens: 1823,
totalDuration: 8420,
startedAt: start,
endedAt: endDate(start, 8420),
},
spans: [
{
id: parentId,
name: "research-assistant",
type: "AGENT" as const,
status: "COMPLETED" as const,
durationMs: 8420,
startedAt: start,
endedAt: endDate(start, 8420),
metadata: { max_iterations: 5 },
},
{
id: toolIds[0],
name: "web_search",
type: "TOOL_CALL" as const,
status: "COMPLETED" as const,
parentSpanId: parentId,
input: { query: "latest AI research papers 2026" },
output: { results: [{ title: "Scaling Laws for Neural Language Models", url: "https://arxiv.org/..." }] },
durationMs: 2100,
startedAt: endDate(start, 200),
endedAt: endDate(start, 2300),
},
{
id: toolIds[1],
name: "document_reader",
type: "TOOL_CALL" as const,
status: "COMPLETED" as const,
parentSpanId: parentId,
input: { url: "https://arxiv.org/..." },
output: { content: "Abstract: We study empirical scaling laws for language model performance..." },
durationMs: 1800,
startedAt: endDate(start, 2400),
endedAt: endDate(start, 4200),
},
{
id: toolIds[2],
name: "summarizer",
type: "TOOL_CALL" as const,
status: "COMPLETED" as const,
parentSpanId: parentId,
input: { text: "Abstract: We study empirical scaling laws..." },
output: { summary: "The paper examines how language model performance scales with compute, data, and model size." },
durationMs: 1500,
startedAt: endDate(start, 4300),
endedAt: endDate(start, 5800),
},
{
id: llmId,
name: "gpt-4o-synthesis",
type: "LLM_CALL" as const,
status: "COMPLETED" as const,
parentSpanId: parentId,
input: { messages: [{ role: "system", content: "Synthesize research findings" }] },
output: { content: "Based on the latest research, AI scaling laws suggest..." },
tokenCount: 1823,
costUsd: 0.0187,
durationMs: 2400,
startedAt: endDate(start, 5900),
endedAt: endDate(start, 8300),
metadata: { model: "gpt-4o" },
},
],
events: [] as EventCreate[],
decisions: [
{
type: "TOOL_SELECTION" as const,
reasoning: "User asked about latest AI research, need web search to get current information",
chosen: { tool: "web_search", args: { query: "latest AI research papers 2026" } },
alternatives: [{ tool: "memory_lookup" }, { tool: "knowledge_base" }],
parentSpanId: parentId,
durationMs: 150,
costUsd: 0.001,
timestamp: endDate(start, 100),
},
{
type: "ROUTING" as const,
reasoning: "Search results contain arxiv links, routing to document reader for full content",
chosen: { next_step: "document_reader" },
alternatives: [{ next_step: "direct_response" }, { next_step: "ask_clarification" }],
parentSpanId: parentId,
durationMs: 80,
costUsd: 0.0005,
timestamp: endDate(start, 2350),
},
],
};
}
function createRagPipelineTrace(userId: string) {
const start = daysAgo(3);
const retrievalId = `demo-span-retrieval-${userId.slice(0, 8)}`;
const embeddingId = `demo-span-embed-${userId.slice(0, 8)}`;
const genId = `demo-span-gen-${userId.slice(0, 8)}`;
return {
trace: {
name: "RAG Pipeline",
userId,
status: "COMPLETED" as const,
isDemo: true,
tags: ["rag", "retrieval", "embeddings"],
metadata: { pipeline: "knowledge-qa", version: "2.1" },
totalCost: 0.0091,
totalTokens: 892,
totalDuration: 4350,
startedAt: start,
endedAt: endDate(start, 4350),
},
spans: [
{
id: embeddingId,
name: "embed_query",
type: "LLM_CALL" as const,
status: "COMPLETED" as const,
input: { text: "How does our refund policy work?" },
output: { embedding: [0.023, -0.041, 0.089] },
tokenCount: 12,
costUsd: 0.00001,
durationMs: 320,
startedAt: start,
endedAt: endDate(start, 320),
metadata: { model: "text-embedding-3-small" },
},
{
id: retrievalId,
name: "vector_search",
type: "MEMORY_OP" as const,
status: "COMPLETED" as const,
input: { embedding: [0.023, -0.041, 0.089], top_k: 5 },
output: { documents: [{ id: "doc-1", score: 0.92, title: "Refund Policy v3" }] },
durationMs: 180,
startedAt: endDate(start, 400),
endedAt: endDate(start, 580),
metadata: { index: "company-docs", results_count: 5 },
},
{
id: genId,
name: "generate_answer",
type: "LLM_CALL" as const,
status: "COMPLETED" as const,
input: { messages: [{ role: "system", content: "Answer using the provided context" }] },
output: { content: "Our refund policy allows returns within 30 days of purchase..." },
tokenCount: 880,
costUsd: 0.009,
durationMs: 3600,
startedAt: endDate(start, 650),
endedAt: endDate(start, 4250),
metadata: { model: "gpt-4o-mini" },
},
],
events: [] as EventCreate[],
decisions: [
{
type: "MEMORY_RETRIEVAL" as const,
reasoning: "Query about refund policy matched knowledge base with high confidence",
chosen: { source: "vector_search", confidence: 0.92 },
alternatives: [{ source: "web_search" }, { source: "ask_human" }],
durationMs: 50,
timestamp: endDate(start, 350),
},
],
};
}
function createErrorHandlingTrace(userId: string) {
const start = daysAgo(5);
const spanId = `demo-span-err-${userId.slice(0, 8)}`;
return {
trace: {
name: "Error Handling Example",
userId,
status: "ERROR" as const,
isDemo: true,
tags: ["error", "rate-limit"],
metadata: { error_type: "RateLimitError", retries: 3 },
totalCost: 0.0,
totalTokens: 0,
totalDuration: 15200,
startedAt: start,
endedAt: endDate(start, 15200),
},
spans: [
{
id: spanId,
name: "chat.completions.create",
type: "LLM_CALL" as const,
status: "ERROR" as const,
statusMessage: "RateLimitError: Rate limit exceeded. Retry after 30s.",
input: { messages: [{ role: "user", content: "Analyze this dataset" }] },
durationMs: 15200,
startedAt: start,
endedAt: endDate(start, 15200),
metadata: { model: "gpt-4o", retry_count: 3 },
},
],
events: [
{
type: "ERROR" as const,
name: "RateLimitError",
spanId,
metadata: { message: "Rate limit exceeded", status_code: 429 },
timestamp: endDate(start, 5000),
},
{
type: "RETRY" as const,
name: "Retry attempt 1",
spanId,
metadata: { attempt: 1, backoff_ms: 2000 },
timestamp: endDate(start, 7000),
},
{
type: "RETRY" as const,
name: "Retry attempt 2",
spanId,
metadata: { attempt: 2, backoff_ms: 4000 },
timestamp: endDate(start, 11000),
},
{
type: "ERROR" as const,
name: "Max retries exceeded",
spanId,
metadata: { message: "Giving up after 3 retries", final_status: 429 },
timestamp: endDate(start, 15200),
},
],
decisions: [
{
type: "RETRY" as const,
reasoning: "Received 429 rate limit error, exponential backoff strategy selected",
chosen: { action: "retry", strategy: "exponential_backoff", max_retries: 3 },
alternatives: [{ action: "fail_immediately" }, { action: "switch_model" }],
durationMs: 20,
timestamp: endDate(start, 5100),
},
],
};
}
function createLongRunningWorkflowTrace(userId: string) {
const start = daysAgo(6);
const totalDuration = 34500;
const chainId = `demo-span-chain-${userId.slice(0, 8)}`;
const spanPrefix = `demo-span-wf-${userId.slice(0, 8)}`;
const stepNames = [
"data_ingestion",
"preprocessing",
"feature_extraction",
"model_inference",
"post_processing",
"validation",
"output_formatting",
];
const spans: DemoSpan[] = [
{
id: chainId,
name: "data-processing-pipeline",
type: "CHAIN",
status: "COMPLETED",
durationMs: totalDuration,
startedAt: start,
endedAt: endDate(start, totalDuration),
metadata: { pipeline: "batch-analysis", version: "1.4" },
},
];
let elapsed = 200;
for (let i = 0; i < stepNames.length; i++) {
const stepDuration = 2000 + Math.floor(Math.random() * 5000);
spans.push({
id: `${spanPrefix}-${i}`,
name: stepNames[i],
type: i === 3 ? "LLM_CALL" : "CUSTOM",
status: "COMPLETED",
durationMs: stepDuration,
startedAt: endDate(start, elapsed),
endedAt: endDate(start, elapsed + stepDuration),
metadata: { step: i + 1, total_steps: stepNames.length },
});
elapsed += stepDuration + 100;
}
return {
trace: {
name: "Long-Running Workflow",
userId,
status: "COMPLETED" as const,
isDemo: true,
tags: ["pipeline", "batch", "production"],
metadata: { pipeline: "batch-analysis", records_processed: 1250 },
totalCost: 0.042,
totalTokens: 4200,
totalDuration: totalDuration,
startedAt: start,
endedAt: endDate(start, totalDuration),
},
spans: spans.map((s) => ({
...s,
parentSpanId: s.id === chainId ? undefined : chainId,
})),
events: [] as EventCreate[],
decisions: [
{
type: "PLANNING" as const,
reasoning: "Large dataset detected, selecting batch processing strategy with parallel feature extraction",
chosen: { strategy: "batch_parallel", batch_size: 50 },
alternatives: [{ strategy: "sequential" }, { strategy: "streaming" }],
parentSpanId: chainId,
durationMs: 100,
timestamp: endDate(start, 100),
},
],
};
}
function createCodeAnalysisTrace(userId: string) {
const start = daysAgo(4);
const agentId = `demo-span-codeagent-${userId.slice(0, 8)}`;
const readId = `demo-span-read-${userId.slice(0, 8)}`;
const analyzeId = `demo-span-analyze-${userId.slice(0, 8)}`;
return {
trace: {
name: "Code Review Agent",
userId,
status: "COMPLETED" as const,
isDemo: true,
tags: ["code-review", "agent"],
metadata: { repo: "acme/backend", pr_number: 142 },
totalCost: 0.015,
totalTokens: 1450,
totalDuration: 6200,
startedAt: start,
endedAt: endDate(start, 6200),
},
spans: [
{
id: agentId,
name: "code-review-agent",
type: "AGENT" as const,
status: "COMPLETED" as const,
durationMs: 6200,
startedAt: start,
endedAt: endDate(start, 6200),
},
{
id: readId,
name: "read_diff",
type: "TOOL_CALL" as const,
status: "COMPLETED" as const,
parentSpanId: agentId,
input: { pr_number: 142 },
output: { files_changed: 5, additions: 120, deletions: 30 },
durationMs: 800,
startedAt: endDate(start, 100),
endedAt: endDate(start, 900),
},
{
id: analyzeId,
name: "analyze_code",
type: "LLM_CALL" as const,
status: "COMPLETED" as const,
parentSpanId: agentId,
input: { diff: "...", instructions: "Review for bugs and style issues" },
output: { review: "Found 2 potential issues: 1) Missing null check on line 45, 2) Unused import" },
tokenCount: 1450,
costUsd: 0.015,
durationMs: 5100,
startedAt: endDate(start, 1000),
endedAt: endDate(start, 6100),
metadata: { model: "gpt-4o" },
},
],
events: [] as EventCreate[],
decisions: [
{
type: "TOOL_SELECTION" as const,
reasoning: "Need to read PR diff before analyzing code",
chosen: { tool: "read_diff", args: { pr_number: 142 } },
alternatives: [{ tool: "read_file" }, { tool: "list_files" }],
parentSpanId: agentId,
durationMs: 60,
timestamp: endDate(start, 50),
},
],
};
}
function createWebSearchTrace(userId: string) {
const start = daysAgo(0, -3600000);
const searchId = `demo-span-websearch-${userId.slice(0, 8)}`;
return {
trace: {
name: "Web Search Agent",
userId,
status: "COMPLETED" as const,
isDemo: true,
tags: ["search", "web"],
metadata: { query: "AgentLens observability" },
totalCost: 0.002,
totalTokens: 180,
totalDuration: 2800,
startedAt: start,
endedAt: endDate(start, 2800),
},
spans: [
{
id: searchId,
name: "web_search",
type: "TOOL_CALL" as const,
status: "COMPLETED" as const,
input: { query: "AgentLens observability platform" },
output: { results_count: 10, top_result: "https://agentlens.vectry.tech" },
durationMs: 2800,
startedAt: start,
endedAt: endDate(start, 2800),
},
],
events: [] as EventCreate[],
decisions: [] as DecisionCreate[],
};
}

36
apps/web/src/lib/email.ts Normal file
View File

@@ -0,0 +1,36 @@
import nodemailer from "nodemailer";
interface SendEmailOptions {
to: string;
subject: string;
html: string;
}
export async function sendEmail({ to, subject, html }: SendEmailOptions) {
const password = process.env.EMAIL_PASSWORD;
if (!password) {
console.warn(
"[email] EMAIL_PASSWORD not set — skipping email send to:",
to
);
return;
}
const transporter = nodemailer.createTransport({
host: "smtp.migadu.com",
port: 465,
secure: true,
auth: {
user: "hunter@repi.fun",
pass: password,
},
});
await transporter.sendMail({
from: "AgentLens <hunter@repi.fun>",
to,
subject,
html,
});
}

View File

@@ -0,0 +1,52 @@
import { redis } from "./redis";
interface RateLimitConfig {
windowMs: number;
maxAttempts: number;
}
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
}
export async function checkRateLimit(
key: string,
config: RateLimitConfig
): Promise<RateLimitResult> {
const now = Date.now();
const windowStart = now - config.windowMs;
const redisKey = `rl:${key}`;
try {
await redis.zremrangebyscore(redisKey, 0, windowStart);
const count = await redis.zcard(redisKey);
if (count >= config.maxAttempts) {
const oldestEntry = await redis.zrange(redisKey, 0, 0, "WITHSCORES");
const resetAt = oldestEntry.length >= 2
? parseInt(oldestEntry[1], 10) + config.windowMs
: now + config.windowMs;
return { allowed: false, remaining: 0, resetAt };
}
await redis.zadd(redisKey, now, `${now}:${Math.random()}`);
await redis.pexpire(redisKey, config.windowMs);
return {
allowed: true,
remaining: config.maxAttempts - count - 1,
resetAt: now + config.windowMs,
};
} catch {
return { allowed: true, remaining: config.maxAttempts, resetAt: now + config.windowMs };
}
}
export const AUTH_RATE_LIMITS = {
login: { windowMs: 15 * 60 * 1000, maxAttempts: 10 },
register: { windowMs: 60 * 60 * 1000, maxAttempts: 5 },
forgotPassword: { windowMs: 60 * 60 * 1000, maxAttempts: 5 },
resetPassword: { windowMs: 15 * 60 * 1000, maxAttempts: 5 },
} as const;

14
apps/web/src/lib/redis.ts Normal file
View File

@@ -0,0 +1,14 @@
import Redis from "ioredis";
const globalForRedis = globalThis as unknown as { redis?: Redis };
export const redis =
globalForRedis.redis ??
new Redis(process.env.REDIS_URL ?? "redis://localhost:6379", {
maxRetriesPerRequest: 3,
lazyConnect: true,
});
if (process.env.NODE_ENV !== "production") {
globalForRedis.redis = redis;
}

View File

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

View File

@@ -10,6 +10,10 @@ const publicPaths = [
"/api/auth", "/api/auth",
"/api/traces", "/api/traces",
"/api/health", "/api/health",
"/api/stripe/webhook",
"/forgot-password",
"/reset-password",
"/verify-email",
]; ];
function isPublicPath(pathname: string): boolean { function isPublicPath(pathname: string): boolean {
@@ -18,10 +22,33 @@ function isPublicPath(pathname: string): boolean {
); );
} }
const ALLOWED_ORIGINS = new Set([
"https://agentlens.vectry.tech",
"http://localhost:3000",
]);
function corsHeaders(origin: string | null): Record<string, string> {
const allowedOrigin = origin && ALLOWED_ORIGINS.has(origin)
? origin
: "https://agentlens.vectry.tech";
return {
"Access-Control-Allow-Origin": allowedOrigin,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
};
}
export default auth((req) => { export default auth((req) => {
const { pathname } = req.nextUrl; const { pathname } = req.nextUrl;
const isLoggedIn = !!req.auth; const isLoggedIn = !!req.auth;
const origin = req.headers.get("origin");
if (req.method === "OPTIONS") {
return new NextResponse(null, { status: 204, headers: corsHeaders(origin) });
}
const response = (() => {
if (isPublicPath(pathname)) { if (isPublicPath(pathname)) {
if (isLoggedIn && (pathname === "/login" || pathname === "/register")) { if (isLoggedIn && (pathname === "/login" || pathname === "/register")) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin)); return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
@@ -43,6 +70,16 @@ export default auth((req) => {
} }
return NextResponse.next(); return NextResponse.next();
})();
if (pathname.startsWith("/api/")) {
const headers = corsHeaders(origin);
for (const [key, value] of Object.entries(headers)) {
response.headers.set(key, value);
}
}
return response;
}); });
export const config = { export const config = {

View File

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

735
package-lock.json generated
View File

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

View File

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

View File

@@ -8,6 +8,8 @@ OpenCode plugin for AgentLens — trace your coding agent's decisions, tool call
## Requirements ## Requirements
- OpenCode >= 1.1.0 - 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 ## 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 ```bash
export AGENTLENS_API_KEY="your-api-key" 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. 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 ## What Gets Captured
The plugin hooks into OpenCode's event system and records: The plugin hooks into OpenCode's event system and records:

View File

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

View File

@@ -12,6 +12,8 @@ AgentLens is an observability SDK for AI agents. Unlike generic LLM tracing tool
## Quick Start ## 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 ```bash
pip install vectry-agentlens pip install vectry-agentlens
``` ```
@@ -19,7 +21,7 @@ pip install vectry-agentlens
```python ```python
import agentlens import agentlens
# Initialize once at startup # Initialize with the API key from Settings > API Keys
agentlens.init(api_key="your-api-key") agentlens.init(api_key="your-api-key")
# Trace any function with a decorator # Trace any function with a decorator
@@ -160,7 +162,7 @@ Initialize the SDK. Call once at application startup.
```python ```python
agentlens.init( 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) endpoint="https://...", # API endpoint (default: https://agentlens.vectry.tech)
flush_interval=5.0, # Seconds between batch flushes (default: 5.0) flush_interval=5.0, # Seconds between batch flushes (default: 5.0)
max_batch_size=10, # Traces per batch before auto-flush (default: 10) 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()` ### `agentlens.trace()`
Decorator or context manager that creates a trace (or a nested span if already inside a 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 ## 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 - **Decision Trees** - Visualize the full decision path of every agent run
- **Analytics** - Token usage, cost breakdowns, latency percentiles - **Analytics** - Token usage, cost breakdowns, latency percentiles
- **Real-time Streaming** - Watch agent decisions as they happen - **Real-time Streaming** - Watch agent decisions as they happen
- **Session Grouping** - Track multi-turn conversations by session ID - **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 ## Development
```bash ```bash

View File

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

View File

@@ -13,10 +13,12 @@ npm install agentlens-sdk
## Quick Start ## 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 ```typescript
import { init, TraceBuilder, shutdown } from "agentlens-sdk"; import { init, TraceBuilder, shutdown } from "agentlens-sdk";
// Initialize the SDK // Initialize with the API key from Settings > API Keys
init({ init({
apiKey: "your-api-key", apiKey: "your-api-key",
endpoint: "https://agentlens.vectry.tech/api", endpoint: "https://agentlens.vectry.tech/api",
@@ -104,17 +106,31 @@ Pass `InitOptions` to `init()`:
```typescript ```typescript
init({ 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. endpoint: "https://...", // API endpoint. Defaults to AgentLens cloud.
maxBatchSize: 100, // Max items per batch before auto-flush. maxBatchSize: 100, // Max items per batch before auto-flush.
flushInterval: 5000, // Auto-flush interval in milliseconds. 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 ## 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. 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 ## Documentation
Full documentation: [agentlens.vectry.tech/docs/typescript-sdk](https://agentlens.vectry.tech/docs/typescript-sdk) Full documentation: [agentlens.vectry.tech/docs/typescript-sdk](https://agentlens.vectry.tech/docs/typescript-sdk)

View File

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