diff --git a/apps/web/app/(dashboard)/dashboard/analytics/page.tsx b/apps/web/app/(dashboard)/dashboard/analytics/page.tsx index 83eda65..1740db0 100644 --- a/apps/web/app/(dashboard)/dashboard/analytics/page.tsx +++ b/apps/web/app/(dashboard)/dashboard/analytics/page.tsx @@ -1,11 +1,13 @@ +"use client"; + +import { useEffect, useState } from "react"; import { Activity, - ArrowDownRight, - ArrowUpRight, BarChart3, CheckCircle, Clock, - FileText, + Layers, + Loader2, } from "lucide-react"; import { @@ -25,81 +27,174 @@ import { TableRow, } from "@/components/ui/table"; import { PageHeader } from "@/components/dashboard/PageHeader"; +import { sourceMeta } from "@/lib/usage-sources"; -const STATS = [ - { - label: "Total requests", - value: "48,291", - delta: "+12.4%", - deltaUp: true, - description: "vs. last 30 days", - icon: Activity, - tone: "from-brand-blue/12 to-brand-blue/0", - }, - { - label: "Avg latency", - value: "142ms", - delta: "-8ms", - deltaUp: true, - description: "p50 response time", - icon: Clock, - tone: "from-emerald-400/12 to-emerald-400/0", - }, - { - label: "Success rate", - value: "99.7%", - delta: "+0.1%", - deltaUp: true, - description: "2xx of all responses", - icon: CheckCircle, - tone: "from-amber-400/12 to-amber-400/0", - }, - { - label: "Active documents", - value: "21", - delta: "+3", - deltaUp: true, - description: "with ready status", - icon: FileText, - tone: "from-brand-pink/12 to-brand-pink/0", - }, -]; - -const RECENT_CALLS = [ - { id: "req_1a2b3c", endpoint: "/v1/query", method: "POST", status: 200, latency: "128ms", ago: "2m ago" }, - { id: "req_4d5e6f", endpoint: "/v1/documents", method: "GET", status: 200, latency: "45ms", ago: "5m ago" }, - { id: "req_7g8h9i", endpoint: "/v1/query", method: "POST", status: 200, latency: "312ms", ago: "8m ago" }, - { id: "req_0j1k2l", endpoint: "/v1/documents/upload", method: "POST", status: 201, latency: "1.2s", ago: "12m ago" }, - { id: "req_3m4n5o", endpoint: "/v1/query", method: "POST", status: 429, latency: "12ms", ago: "15m ago" }, - { id: "req_6p7q8r", endpoint: "/v1/sections", method: "GET", status: 200, latency: "67ms", ago: "22m ago" }, -]; - -const SPARK = [12, 18, 14, 22, 20, 26, 24, 30, 36, 32, 40, 38, 44, 42, 50, 56, 52, 60, 64, 70, 66, 72, 78, 74, 80, 86, 82, 88, 92, 96]; +interface SourceUsage { + source: string; + requests: number; + queries: number; + documents: number; + tokens_in: number; + tokens_out: number; + error_requests: number; +} +interface DailyPoint { + date: string; + requests: number; + errors: number; +} +interface RecentEvent { + endpoint: string; + method: string; + status_code: number; + source: string; + duration_ms: number; + created_at: string; +} +interface AnalyticsResponse { + from: string; + to: string; + summary: { + total_requests: number; + success_requests: number; + error_requests: number; + success_rate: number; + avg_duration_ms: number; + tokens_in: number; + tokens_out: number; + }; + daily: DailyPoint[]; + by_source: SourceUsage[]; + recent: RecentEvent[]; +} function statusTone(status: number) { - if (status >= 200 && status < 300) return "bg-emerald-500/10 text-emerald-600 border-emerald-500/20"; - if (status >= 400 && status < 500) return "bg-amber-500/10 text-amber-600 border-amber-500/20"; + if (status >= 200 && status < 300) + return "bg-emerald-500/10 text-emerald-600 border-emerald-500/20"; + if (status >= 400 && status < 500) + return "bg-amber-500/10 text-amber-600 border-amber-500/20"; return "bg-red-500/10 text-red-600 border-red-500/20"; } +function fmt(n: number): string { + return n.toLocaleString(); +} + +function timeAgo(iso: string): string { + const then = new Date(iso).getTime(); + const secs = Math.max(0, Math.round((Date.now() - then) / 1000)); + if (secs < 60) return `${secs}s ago`; + const mins = Math.round(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.round(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.round(hrs / 24)}d ago`; +} + export default function AnalyticsPage() { - const max = Math.max(...SPARK); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch("/api/dashboard/analytics"); + const json = await res.json(); + if (!res.ok) throw new Error(json?.error ?? "Failed to load analytics"); + if (!cancelled) setData(json as AnalyticsResponse); + } catch (e) { + if (!cancelled) + setError(e instanceof Error ? e.message : "Failed to load analytics"); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error || !data) { + return ( +
+ + + + {error ?? "No analytics available yet."} + + +
+ ); + } + + const { summary, daily, by_source, recent } = data; + const totalTokens = summary.tokens_in + summary.tokens_out; + const maxDaily = Math.max(1, ...daily.map((d) => d.requests)); + const maxSource = Math.max(1, ...by_source.map((s) => s.requests)); + const hasTraffic = summary.total_requests > 0; + + const stats = [ + { + label: "Total requests", + value: fmt(summary.total_requests), + description: `${data.from} → ${data.to}`, + icon: Activity, + tone: "from-brand-blue/12 to-brand-blue/0", + }, + { + label: "Success rate", + value: hasTraffic ? `${(summary.success_rate * 100).toFixed(1)}%` : "—", + description: `${fmt(summary.error_requests)} errors`, + icon: CheckCircle, + tone: "from-emerald-400/12 to-emerald-400/0", + }, + { + label: "Avg latency", + value: hasTraffic ? `${fmt(summary.avg_duration_ms)}ms` : "—", + description: "mean response time", + icon: Clock, + tone: "from-amber-400/12 to-amber-400/0", + }, + { + label: "Tokens used", + value: fmt(totalTokens), + description: `${fmt(summary.tokens_in)} in · ${fmt(summary.tokens_out)} out`, + icon: Layers, + tone: "from-brand-pink/12 to-brand-pink/0", + }, + ]; return (
{/* Stat cards */}
- {STATS.map((s) => { + {stats.map((s) => { const Icon = s.icon; - const DeltaIcon = s.deltaUp ? ArrowUpRight : ArrowDownRight; return ( - +
-
- - {s.value} - - - - {s.delta} - -
-

{s.description}

+ + {s.value} + +

+ {s.description} +

); })}
- {/* Chart */} + {/* Usage by source */} + + + + + Usage by source + + + Where your requests come from — dashboard, SDKs, MCP, and API keys. + + + + {by_source.map((s) => { + const meta = sourceMeta(s.source); + const pct = hasTraffic + ? Math.round((s.requests / summary.total_requests) * 100) + : 0; + return ( +
+
+ + + {meta.label} + + {meta.blurb} + + + + {fmt(s.requests)} req · {pct}% + +
+
+
+
+
+ {fmt(s.queries)} queries + {fmt(s.documents)} docs + {fmt(s.tokens_in + s.tokens_out)} tokens + {s.error_requests > 0 && ( + + {fmt(s.error_requests)} errors + + )} +
+
+ ); + })} + + + + {/* Request volume */} @@ -143,20 +283,28 @@ export default function AnalyticsPage() { -
- {SPARK.map((v, i) => ( -
- ))} -
-
- 30 days ago - Today -
+ {daily.length === 0 ? ( +
+ No requests in this period yet. +
+ ) : ( + <> +
+ {daily.map((d) => ( +
+ ))} +
+
+ {daily[0]?.date} + {daily[daily.length - 1]?.date} +
+ + )} @@ -171,47 +319,78 @@ export default function AnalyticsPage() {
- - - - Request ID - Endpoint - Method - Status - Latency - When - - - - {RECENT_CALLS.map((call) => ( - - - {call.id} - - - {call.endpoint} - - - - {call.method} - - - - - {call.status} - - - {call.latency} - - {call.ago} - + {recent.length === 0 ? ( +
+ No requests recorded yet. +
+ ) : ( +
+ + + + Endpoint + + + Method + + + Source + + + Status + + + Latency + + + When + - ))} - -
+ + + {recent.map((call, i) => { + const meta = sourceMeta(call.source); + return ( + + + {call.endpoint} + + + + {call.method} + + + + + + {meta.label} + + + + + {call.status_code} + + + + {call.duration_ms}ms + + + {timeAgo(call.created_at)} + + + ); + })} + + + )}
diff --git a/apps/web/app/(dashboard)/dashboard/billing/page.tsx b/apps/web/app/(dashboard)/dashboard/billing/page.tsx index 2c7458f..3931f1f 100644 --- a/apps/web/app/(dashboard)/dashboard/billing/page.tsx +++ b/apps/web/app/(dashboard)/dashboard/billing/page.tsx @@ -12,89 +12,27 @@ import { import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; -import { Check, CreditCard, Sparkles, Building2 } from "lucide-react"; +import { Check, CreditCard, Sparkles, Building2, Zap } from "lucide-react"; import { useSession } from "@/lib/auth-client"; +import { PLANS, type Plan } from "@/lib/pricing"; -interface PlanFeature { - label: string; - included: boolean; -} +const PLAN_ICONS: Record = { + free: , + pro: , + scale: , + enterprise: , +}; -interface PlanCard { - key: string; - name: string; - price: string; - period: string; - description: string; - features: PlanFeature[]; - cta: string; - popular?: boolean; - icon: React.ReactNode; +function ctaFor(key: string, current: string): string { + if (key === current) return "Current Plan"; + if (key === "enterprise") return "Contact Sales"; + return `Upgrade to ${PLANS.find((p) => p.key === key)?.name ?? key}`; } -const plans: PlanCard[] = [ - { - key: "free", - name: "Free", - price: "$0", - period: "forever", - description: "Perfect for exploring Vectorless and building prototypes.", - icon: , - features: [ - { label: "500 queries / month", included: true }, - { label: "500 pages ingested / month", included: true }, - { label: "25 documents stored", included: true }, - { label: "100 MB storage", included: true }, - { label: "20 queries / min rate limit", included: true }, - { label: "Community support", included: true }, - { label: "MCP server access", included: true }, - ], - cta: "Current Plan", - }, - { - key: "pro", - name: "Pro", - price: "$49", - period: "/ month", - description: - "For teams and individuals using Vectorless in production.", - icon: , - popular: true, - features: [ - { label: "20,000 queries / month", included: true }, - { label: "10,000 pages ingested / month", included: true }, - { label: "500 documents stored", included: true }, - { label: "10 GB storage", included: true }, - { label: "200 queries / min rate limit", included: true }, - { label: "Priority email support", included: true }, - { label: "OAuth app integrations", included: true }, - ], - cta: "Upgrade to Pro", - }, - { - key: "enterprise", - name: "Enterprise", - price: "Custom", - period: "", - description: - "For organizations with custom requirements, SLAs, and dedicated support.", - icon: , - features: [ - { label: "Unlimited queries", included: true }, - { label: "Unlimited ingestion", included: true }, - { label: "Unlimited documents", included: true }, - { label: "Unlimited storage", included: true }, - { label: "1,000 queries / min rate limit", included: true }, - { label: "Dedicated support + SLA", included: true }, - { label: "Custom integrations", included: true }, - ], - cta: "Contact Sales", - }, -]; - export default function BillingPage() { const { data: session } = useSession(); const [currentPlan] = useState("free"); // TODO: Fetch from API + const plans: Plan[] = PLANS; return (
@@ -133,7 +71,7 @@ export default function BillingPage() { {/* Plan Cards */} -
+
{plans.map((plan) => { const isCurrent = plan.key === currentPlan; @@ -156,7 +94,7 @@ export default function BillingPage() {
- {plan.icon} + {PLAN_ICONS[plan.key]}
{plan.name} @@ -181,13 +119,11 @@ export default function BillingPage() {
    {plan.features.map((feature) => (
  • - - {feature.label} - + {feature}
  • ))}
@@ -205,7 +141,7 @@ export default function BillingPage() { } disabled={isCurrent} > - {isCurrent ? "Current Plan" : plan.cta} + {ctaFor(plan.key, currentPlan)} diff --git a/apps/web/app/(dashboard)/dashboard/usage/page.tsx b/apps/web/app/(dashboard)/dashboard/usage/page.tsx index 130d8a6..13443bf 100644 --- a/apps/web/app/(dashboard)/dashboard/usage/page.tsx +++ b/apps/web/app/(dashboard)/dashboard/usage/page.tsx @@ -10,76 +10,76 @@ import { } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Progress } from "@/components/ui/progress"; -import { Loader2, Activity, FileUp, Search, TreePine } from "lucide-react"; -import { useSession } from "@/lib/auth-client"; +import { Loader2, Activity, FileText, Search, Layers } from "lucide-react"; +import { PLAN_BY_KEY } from "@/lib/pricing"; +import { sourceMeta } from "@/lib/usage-sources"; -interface UsageSummary { - queriesUsed: number; - queriesLimit: number; - ingestPagesUsed: number; - ingestPagesLimit: number; - documentsStored: number; - documentsLimit: number; +interface QuotaStatus { plan: string; + documents_used: number; + documents_limit: number; + queries_used: number; + queries_limit: number; + tokens_used: number; + tokens_limit: number; + allows_overage: boolean; +} +interface SourceUsage { + source: string; + requests: number; + queries: number; + documents: number; + tokens_in: number; + tokens_out: number; + error_requests: number; +} +interface UsageResponse { + quota: QuotaStatus; + bySource: SourceUsage[]; } -const PLAN_LIMITS: Record< - string, - { queries: number; ingest: number; documents: number } -> = { - free: { queries: 500, ingest: 500, documents: 25 }, - pro: { queries: 20_000, ingest: 10_000, documents: 500 }, - enterprise: { queries: Infinity, ingest: Infinity, documents: Infinity }, -}; - -function usagePercent(used: number, limit: number): number { - if (limit === Infinity || limit === 0) return 0; +function pct(used: number, limit: number): number { + if (limit === 0) return 0; // 0 = unlimited return Math.min(100, Math.round((used / limit) * 100)); } - -function formatNumber(n: number): string { - if (n === Infinity) return "Unlimited"; +function fmtLimit(n: number): string { + return n === 0 ? "Unlimited" : n.toLocaleString(); +} +function fmt(n: number): string { return n.toLocaleString(); } - -function progressColor(percent: number): string { - if (percent >= 90) return "bg-destructive"; - if (percent >= 75) return "bg-amber-500"; - return "bg-primary"; +function barColor(p: number): string { + if (p >= 90) return "[&>*]:bg-destructive"; + if (p >= 75) return "[&>*]:bg-amber-500"; + return ""; } export default function UsagePage() { - const { data: session } = useSession(); - const [isLoading, setIsLoading] = useState(true); - const [usage, setUsage] = useState({ - queriesUsed: 0, - queriesLimit: 500, - ingestPagesUsed: 0, - ingestPagesLimit: 500, - documentsStored: 0, - documentsLimit: 25, - plan: "free", - }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [data, setData] = useState(null); useEffect(() => { - // In production, this would fetch from the API - // For now, show placeholder data based on default free plan - const timer = setTimeout(() => { - setUsage({ - queriesUsed: 0, - queriesLimit: PLAN_LIMITS.free.queries, - ingestPagesUsed: 0, - ingestPagesLimit: PLAN_LIMITS.free.ingest, - documentsStored: 0, - documentsLimit: PLAN_LIMITS.free.documents, - plan: "free", - }); - setIsLoading(false); - }, 500); - return () => clearTimeout(timer); - }, [session]); + let cancelled = false; + (async () => { + try { + const res = await fetch("/api/dashboard/usage"); + const json = await res.json(); + if (!res.ok) throw new Error(json?.error ?? "Failed to load usage"); + if (!cancelled) setData(json as UsageResponse); + } catch (e) { + if (!cancelled) + setError(e instanceof Error ? e.message : "Failed to load usage"); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); - if (isLoading) { + if (loading) { return (
@@ -87,16 +87,54 @@ export default function UsagePage() { ); } - const queryPercent = usagePercent(usage.queriesUsed, usage.queriesLimit); - const ingestPercent = usagePercent( - usage.ingestPagesUsed, - usage.ingestPagesLimit - ); - const docsPercent = usagePercent( - usage.documentsStored, - usage.documentsLimit + if (error || !data) { + return ( +
+
+

+ Usage +

+

+ Track your monthly usage across queries, documents, and tokens. +

+
+ + + {error ?? "No usage available yet."} + + +
+ ); + } + + const { quota, bySource } = data; + const plan = PLAN_BY_KEY[quota.plan]; + const totalReqBySource = Math.max( + 1, + bySource.reduce((a, s) => a + s.requests, 0), ); + const meters = [ + { + label: "Queries This Month", + icon: Search, + used: quota.queries_used, + limit: quota.queries_limit, + }, + { + label: "Documents Stored", + icon: FileText, + used: quota.documents_used, + limit: quota.documents_limit, + }, + { + label: "Tokens This Month", + icon: Layers, + used: quota.tokens_used, + limit: quota.tokens_limit, + }, + ]; + return (
{/* Header */} @@ -106,151 +144,130 @@ export default function UsagePage() { Usage

- Track your monthly usage across queries, document ingestion, and - storage. + Track your monthly usage across queries, documents, and tokens.

- - {usage.plan} Plan + + {plan?.name ?? quota.plan} Plan
- {/* Usage Cards */} + {/* Quota meters */}
- {/* Queries */} - - - - Queries This Month - - - - -
- {formatNumber(usage.queriesUsed)} -
-

- of {formatNumber(usage.queriesLimit)} limit -

- -

- {queryPercent}% used -

-
-
- - {/* Ingest Pages */} - - - - Pages Ingested This Month - - - - -
- {formatNumber(usage.ingestPagesUsed)} -
-

- of {formatNumber(usage.ingestPagesLimit)} limit -

- -

- {ingestPercent}% used -

-
-
- - {/* Documents Stored */} - - - - Documents Stored - - - - -
- {formatNumber(usage.documentsStored)} -
-

- of {formatNumber(usage.documentsLimit)} limit -

- -

- {docsPercent}% used -

-
-
+ {meters.map((m) => { + const Icon = m.icon; + const p = pct(m.used, m.limit); + return ( + + + {m.label} + + + +
+ {fmt(m.used)} +
+

+ of {fmtLimit(m.limit)} {m.limit === 0 ? "" : "limit"} +

+ +

+ {m.limit === 0 ? "Unlimited" : `${p}% used`} +

+
+
+ ); + })}
- {/* Rate limits info */} + {/* Usage by source */} - - Rate Limits + + Usage by source - Request limits per minute for your current plan. + Requests over the last 30 days, split by dashboard, SDKs, MCP, and + API keys. - -
-
-

Queries

-

- {usage.plan === "free" - ? "20" - : usage.plan === "pro" - ? "200" - : "1,000"} -

-

- requests per minute -

-
-
-

Document Upload

-

- {usage.plan === "free" - ? "5" - : usage.plan === "pro" - ? "30" - : "200"} -

-

- requests per minute -

-
-
-

Tree Navigation

-

- {usage.plan === "free" - ? "120" - : usage.plan === "pro" - ? "1,200" - : "10,000"} -

-

- requests per minute -

-
-
+ + {bySource.every((s) => s.requests === 0) ? ( +

+ No requests recorded yet — start querying to see the breakdown. +

+ ) : ( + bySource.map((s) => { + const meta = sourceMeta(s.source); + const share = Math.round((s.requests / totalReqBySource) * 100); + return ( +
+
+ + + {meta.label} + + + {fmt(s.requests)} req · {share}% + +
+
+
+
+
+ ); + }) + )} + + {/* Plan limits */} + {plan && ( + + + + + Plan limits + + + Your {plan.name} plan ceilings. Upgrade for more headroom. + + + +
+
+

Documents

+

+ {fmtLimit(plan.limits.documents)} +

+

stored

+
+
+

Storage

+

+ {plan.limits.storageGB === 0 + ? "Unlimited" + : `${plan.limits.storageGB} GB`} +

+

total

+
+
+

Rate limit

+

+ {plan.limits.requestsPerSecond === 0 + ? "Custom" + : plan.limits.requestsPerSecond} +

+

requests / sec

+
+
+
+
+ )}
); } diff --git a/apps/web/app/api/dashboard/analytics/route.ts b/apps/web/app/api/dashboard/analytics/route.ts new file mode 100644 index 0000000..a36e576 --- /dev/null +++ b/apps/web/app/api/dashboard/analytics/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { forwardToCP, getPrimaryOrgId } from "@/lib/cp-proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// GET /api/dashboard/analytics?from=YYYY-MM-DD&to=YYYY-MM-DD +// Proxies to the CP analytics endpoint (summary + daily series + +// per-source breakdown + recent feed) for the caller's primary org. +export async function GET(request: NextRequest) { + const orgId = await getPrimaryOrgId(); + if (!orgId) { + return NextResponse.json( + { error: "No organization found on your account." }, + { status: 400 }, + ); + } + + const { searchParams } = new URL(request.url); + const qs = new URLSearchParams(); + const from = searchParams.get("from"); + const to = searchParams.get("to"); + if (from) qs.set("from", from); + if (to) qs.set("to", to); + const suffix = qs.toString() ? `?${qs.toString()}` : ""; + + const { ok, status, data } = await forwardToCP( + `/admin/v1/orgs/${orgId}/analytics${suffix}`, + { orgId }, + ); + if (!ok) { + return NextResponse.json(data ?? { error: "Upstream error" }, { status }); + } + return NextResponse.json(data); +} diff --git a/apps/web/app/api/dashboard/analytics/usage/route.ts b/apps/web/app/api/dashboard/analytics/usage/route.ts index 141202f..62d71eb 100644 --- a/apps/web/app/api/dashboard/analytics/usage/route.ts +++ b/apps/web/app/api/dashboard/analytics/usage/route.ts @@ -1,14 +1,56 @@ import { NextRequest, NextResponse } from "next/server"; -import { forwardToCP } from "@/lib/cp-proxy"; +import { forwardToCP, getPrimaryOrgId } from "@/lib/cp-proxy"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// GET /api/dashboard/analytics/usage?range=7d|30d|90d +// Endpoint-breakdown view: maps the CP analytics payload to the shape the +// analytics/usage detail page expects. export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const range = searchParams.get("range") || "30d"; + const orgId = await getPrimaryOrgId(); + if (!orgId) { + return NextResponse.json( + { error: "No organization found on your account." }, + { status: 400 }, + ); + } + + const range = new URL(request.url).searchParams.get("range") || "30d"; + const days = range === "7d" ? 7 : range === "90d" ? 90 : 30; + const to = new Date(); + const from = new Date(to.getTime() - days * 24 * 60 * 60 * 1000); + const iso = (d: Date) => d.toISOString().slice(0, 10); + const { ok, status, data } = await forwardToCP( - `/v1/analytics/usage?range=${encodeURIComponent(range)}`, + `/admin/v1/orgs/${orgId}/analytics?from=${iso(from)}&to=${iso(to)}`, + { orgId }, ); if (!ok) { return NextResponse.json(data ?? { error: "Upstream error" }, { status }); } - return NextResponse.json(data); + + const a = data as { + summary?: { total_requests?: number; success_rate?: number }; + daily?: { date: string; requests: number }[]; + endpoints?: { + endpoint: string; + method: string; + count: number; + avg_latency_ms: number; + error_rate: number; + }[]; + }; + const daily = a.daily ?? []; + const total = a.summary?.total_requests ?? 0; + + return NextResponse.json({ + range, + total_requests: total, + avg_per_day: daily.length ? Math.round(total / daily.length) : 0, + peak_hour: "—", + success_rate: a.summary?.success_rate ?? 0, + endpoints: a.endpoints ?? [], + daily, + }); } diff --git a/apps/web/app/api/dashboard/usage/route.ts b/apps/web/app/api/dashboard/usage/route.ts new file mode 100644 index 0000000..6a4e02c --- /dev/null +++ b/apps/web/app/api/dashboard/usage/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { forwardToCP, getPrimaryOrgId } from "@/lib/cp-proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// GET /api/dashboard/usage +// Returns the current-month quota status (meters) plus the trailing +// 30-day usage-by-source breakdown, for the Usage page. +export async function GET() { + const orgId = await getPrimaryOrgId(); + if (!orgId) { + return NextResponse.json( + { error: "No organization found on your account." }, + { status: 400 }, + ); + } + + const [quotaRes, analyticsRes] = await Promise.all([ + forwardToCP(`/admin/v1/orgs/${orgId}/quota`, { orgId }), + forwardToCP(`/admin/v1/orgs/${orgId}/analytics`, { orgId }), + ]); + + if (!quotaRes.ok) { + return NextResponse.json( + quotaRes.data ?? { error: "Failed to load quota" }, + { status: quotaRes.status }, + ); + } + + const analytics = analyticsRes.ok + ? (analyticsRes.data as { by_source?: unknown; summary?: unknown } | null) + : null; + + return NextResponse.json({ + quota: quotaRes.data, + bySource: analytics?.by_source ?? [], + summary: analytics?.summary ?? null, + }); +} diff --git a/apps/web/components/Pricing.tsx b/apps/web/components/Pricing.tsx index b689bb9..2102266 100644 --- a/apps/web/components/Pricing.tsx +++ b/apps/web/components/Pricing.tsx @@ -3,60 +3,30 @@ import Link from 'next/link'; import { Check } from 'lucide-react'; import { useGsapEffect, useScopedRef, gsap } from '@/hooks/useGsap'; +import { PLANS } from '@/lib/pricing'; -const TIERS = [ - { - name: 'Hobby', - tag: 'Free, forever', - price: '$0', - cadence: 'free up to 100 docs', - desc: 'Everything you need to ship a real agent.', - cta: 'Start free', - href: '/register', - highlight: false, - features: [ - '100 documents', - 'Unlimited section fetches', - 'TypeScript + Python SDK', - 'Hybrid retrieval (BYO embeddings)', - 'Community support', - ], - }, - { - name: 'Pro', - tag: 'For shipping teams', - price: '$29', - cadence: 'per seat / month', - desc: 'Scale past prototype without paging an SRE.', - cta: 'Start 14-day trial', - href: '/register?plan=pro', - highlight: true, - features: [ - '10,000 documents', - 'Priority parallel fetch', - 'MCP server integration', - 'Document analytics + audit log', - 'Email support · 24h SLA', - ], - }, - { - name: 'Enterprise', - tag: 'For regulated work', - price: 'Custom', - cadence: 'talk to us', - desc: 'SSO, on-prem, contracts, and people who pick up the phone.', - cta: 'Contact sales', - href: 'mailto:sales@vectorless.dev', - highlight: false, - features: [ - 'Unlimited documents', - 'SSO + SCIM', - 'Self-hosted / VPC', - 'SOC 2 + DPA', - 'Dedicated Slack channel', - ], - }, -]; +// Marketing view of the canonical pricing ladder (lib/pricing.ts). Only +// the presentation (cadence phrasing, CTA copy, href) lives here; the +// tiers, prices, limits and features come from the shared source so the +// landing page can never drift from the dashboard or the quota engine. +const CTA: Record = { + free: { cta: 'Start free', href: '/register', cadence: 'free up to 100 docs' }, + pro: { cta: 'Start 14-day trial', href: '/register?plan=pro', cadence: 'per workspace / month' }, + scale: { cta: 'Start 14-day trial', href: '/register?plan=scale', cadence: 'per workspace / month' }, + enterprise: { cta: 'Contact sales', href: 'mailto:sales@vectorless.store', cadence: 'talk to us' }, +}; + +const TIERS = PLANS.map((p) => ({ + name: p.name, + tag: p.tag, + price: p.price, + cadence: CTA[p.key].cadence, + desc: p.description, + cta: CTA[p.key].cta, + href: CTA[p.key].href, + highlight: !!p.popular, + features: p.features, +})); export default function Pricing() { const ref = useScopedRef(); @@ -151,7 +121,7 @@ export default function Pricing() {
-
+
{TIERS.map((tier) => (
= Object.fromEntries( + PLANS.map((p) => [p.key, p]), +); diff --git a/apps/web/lib/usage-sources.ts b/apps/web/lib/usage-sources.ts new file mode 100644 index 0000000..394e1af --- /dev/null +++ b/apps/web/lib/usage-sources.ts @@ -0,0 +1,50 @@ +// Presentation metadata for the four usage surfaces the CP attributes +// every request to (web / sdk / mcp / api). Shared by the Analytics and +// Usage pages so labels + colours stay consistent. No purple (brand rule). + +export interface SourceMeta { + label: string; + /** Tailwind text colour class. */ + text: string; + /** Tailwind background colour class (solid, for bars/dots). */ + bg: string; + blurb: string; +} + +export const SOURCE_META: Record = { + web: { + label: "Dashboard", + text: "text-brand-blue", + bg: "bg-brand-blue", + blurb: "Requests from the web dashboard", + }, + sdk: { + label: "SDK", + text: "text-brand-pink", + bg: "bg-brand-pink", + blurb: "Official Python / TypeScript / Go SDKs", + }, + mcp: { + label: "MCP", + text: "text-amber-500", + bg: "bg-amber-500", + blurb: "Model Context Protocol server", + }, + api: { + label: "API keys", + text: "text-emerald-500", + bg: "bg-emerald-500", + blurb: "Direct API-key requests (curl, custom code)", + }, +}; + +export function sourceMeta(source: string): SourceMeta { + return ( + SOURCE_META[source] ?? { + label: source, + text: "text-muted-foreground", + bg: "bg-muted-foreground", + blurb: "", + } + ); +}