diff --git a/apps/api/src/routes/analytics.ts b/apps/api/src/routes/analytics.ts new file mode 100644 index 0000000000..274c1e0471 --- /dev/null +++ b/apps/api/src/routes/analytics.ts @@ -0,0 +1,517 @@ +import { createRoute, OpenAPIHono } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { + and, + apiKeyHourlyModelStats, + apiKeyHourlyStats, + db, + desc, + eq, + gte, + inArray, + lte, + ne, + sql, + tables, +} from "@llmgateway/db"; + +import type { ServerTypes } from "@/vars.js"; + +export const analytics = new OpenAPIHono(); + +const roleSchema = z.enum(["owner", "admin", "developer"]); + +const dateRangeQuery = { + organizationId: z.string(), + from: z.string().optional(), + to: z.string().optional(), +}; + +function resolveDateRange( + from?: string, + to?: string, +): { + startDate: Date; + endDate: Date; +} { + if (from && to) { + const startDate = new Date(from + "T00:00:00Z"); + const endDate = new Date(to + "T00:00:00Z"); + if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) { + throw new HTTPException(400, { + message: "Invalid from/to date (expected YYYY-MM-DD)", + }); + } + startDate.setUTCHours(0, 0, 0, 0); + endDate.setUTCHours(23, 59, 59, 999); + return { startDate, endDate }; + } + const sevenDaysMs = 7 * 24 * 60 * 60 * 1000; + const endDate = new Date(); + const startDate = new Date(endDate.getTime() - sevenDaysMs); + return { startDate, endDate }; +} + +/** + * Ensures the authenticated user is an owner/admin of an enterprise + * organization. Member-level usage analytics expose every member's spend, so + * they are restricted to organization administrators on the enterprise plan. + */ +async function requireEnterpriseAdmin( + userId: string, + organizationId: string, +): Promise<{ role: z.infer }> { + const userOrganization = await db.query.userOrganization.findFirst({ + where: { + userId: { eq: userId }, + organizationId: { eq: organizationId }, + }, + }); + + if (!userOrganization) { + throw new HTTPException(403, { + message: "You do not have access to this organization", + }); + } + + if (userOrganization.role === "developer") { + throw new HTTPException(403, { + message: "Only organization owners and admins can view member usage", + }); + } + + const organization = await db.query.organization.findFirst({ + where: { id: { eq: organizationId } }, + }); + + if (!organization || organization.status === "deleted") { + throw new HTTPException(404, { message: "Organization not found" }); + } + + if (organization.plan !== "enterprise") { + throw new HTTPException(403, { + message: "Member analytics require an enterprise plan", + }); + } + + return { role: userOrganization.role }; +} + +async function getOrgProjectIds(organizationId: string): Promise { + const projects = await db + .select({ id: tables.project.id }) + .from(tables.project) + .where( + and( + eq(tables.project.organizationId, organizationId), + ne(tables.project.status, "deleted"), + ), + ); + return projects.map((p) => p.id); +} + +const memberUsageSchema = z.object({ + userId: z.string(), + name: z.string().nullable(), + email: z.string(), + role: roleSchema, + apiKeyCount: z.number(), + cost: z.number(), + totalTokens: z.number(), + requestCount: z.number(), + errorCount: z.number(), +}); + +const getMembersUsage = createRoute({ + method: "get", + path: "/members", + request: { + query: z.object(dateRangeQuery), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + members: z.array(memberUsageSchema), + plan: z.string(), + }), + }, + }, + description: "Per-member usage statistics for the organization.", + }, + }, +}); + +analytics.openapi(getMembersUsage, async (c) => { + const authUser = c.get("user"); + if (!authUser) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { organizationId, from, to } = c.req.valid("query"); + await requireEnterpriseAdmin(authUser.id, organizationId); + + const { startDate, endDate } = resolveDateRange(from, to); + const projectIds = await getOrgProjectIds(organizationId); + + const members = await db.query.userOrganization.findMany({ + where: { organizationId: { eq: organizationId } }, + with: { + user: { + columns: { id: true, email: true, name: true }, + }, + }, + }); + + if (projectIds.length === 0) { + return c.json({ + members: members.map((m) => ({ + userId: m.userId, + name: m.user?.name ?? null, + email: m.user?.email ?? "", + role: m.role, + apiKeyCount: 0, + cost: 0, + totalTokens: 0, + requestCount: 0, + errorCount: 0, + })), + plan: "enterprise", + }); + } + + const keys = await db + .select({ + id: tables.apiKey.id, + createdBy: tables.apiKey.createdBy, + }) + .from(tables.apiKey) + .where(inArray(tables.apiKey.projectId, projectIds)); + + const keyToCreator = new Map(); + const keyCountByCreator = new Map(); + for (const key of keys) { + keyToCreator.set(key.id, key.createdBy); + keyCountByCreator.set( + key.createdBy, + (keyCountByCreator.get(key.createdBy) ?? 0) + 1, + ); + } + + const usageRows = await db + .select({ + apiKeyId: apiKeyHourlyStats.apiKeyId, + cost: sql`SUM(${apiKeyHourlyStats.cost})`.as("cost"), + totalTokens: + sql`SUM(CAST(${apiKeyHourlyStats.totalTokens} AS NUMERIC))`.as( + "total_tokens", + ), + requestCount: sql`SUM(${apiKeyHourlyStats.requestCount})`.as( + "request_count", + ), + errorCount: sql`SUM(${apiKeyHourlyStats.errorCount})`.as( + "error_count", + ), + }) + .from(apiKeyHourlyStats) + .where( + and( + inArray(apiKeyHourlyStats.projectId, projectIds), + gte(apiKeyHourlyStats.hourTimestamp, startDate), + lte(apiKeyHourlyStats.hourTimestamp, endDate), + ), + ) + .groupBy(apiKeyHourlyStats.apiKeyId); + + const usageByCreator = new Map< + string, + { + cost: number; + totalTokens: number; + requestCount: number; + errorCount: number; + } + >(); + for (const row of usageRows) { + const creator = keyToCreator.get(row.apiKeyId); + if (!creator) { + continue; + } + const agg = usageByCreator.get(creator) ?? { + cost: 0, + totalTokens: 0, + requestCount: 0, + errorCount: 0, + }; + agg.cost += Number(row.cost ?? 0); + agg.totalTokens += Number(row.totalTokens ?? 0); + agg.requestCount += Number(row.requestCount ?? 0); + agg.errorCount += Number(row.errorCount ?? 0); + usageByCreator.set(creator, agg); + } + + const result = members + .map((m) => { + const agg = usageByCreator.get(m.userId); + return { + userId: m.userId, + name: m.user?.name ?? null, + email: m.user?.email ?? "", + role: m.role, + apiKeyCount: keyCountByCreator.get(m.userId) ?? 0, + cost: agg?.cost ?? 0, + totalTokens: agg?.totalTokens ?? 0, + requestCount: agg?.requestCount ?? 0, + errorCount: agg?.errorCount ?? 0, + }; + }) + .sort((a, b) => b.cost - a.cost); + + return c.json({ members: result, plan: "enterprise" }); +}); + +const breakdownEntrySchema = z.object({ + key: z.string(), + cost: z.number(), + requestCount: z.number(), + totalTokens: z.number(), +}); + +const memberDetailSchema = z.object({ + member: z.object({ + userId: z.string(), + name: z.string().nullable(), + email: z.string(), + role: roleSchema, + }), + summary: z.object({ + cost: z.number(), + inputTokens: z.number(), + outputTokens: z.number(), + totalTokens: z.number(), + requestCount: z.number(), + errorCount: z.number(), + cacheCount: z.number(), + apiKeyCount: z.number(), + }), + topModels: z.array(breakdownEntrySchema), + topProviders: z.array(breakdownEntrySchema), + costByModel: z.array(breakdownEntrySchema), +}); + +const getMemberDetail = createRoute({ + method: "get", + path: "/members/{userId}", + request: { + params: z.object({ userId: z.string() }), + query: z.object(dateRangeQuery), + }, + responses: { + 200: { + content: { + "application/json": { + schema: memberDetailSchema, + }, + }, + description: "Detailed usage statistics for a single member.", + }, + 404: { + description: "Member not found.", + }, + }, +}); + +analytics.openapi(getMemberDetail, async (c) => { + const authUser = c.get("user"); + if (!authUser) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { userId } = c.req.valid("param"); + const { organizationId, from, to } = c.req.valid("query"); + await requireEnterpriseAdmin(authUser.id, organizationId); + + const membership = await db.query.userOrganization.findFirst({ + where: { + userId: { eq: userId }, + organizationId: { eq: organizationId }, + }, + with: { + user: { columns: { id: true, email: true, name: true } }, + }, + }); + + if (!membership) { + throw new HTTPException(404, { message: "Member not found" }); + } + + const { startDate, endDate } = resolveDateRange(from, to); + const projectIds = await getOrgProjectIds(organizationId); + + const member = { + userId: membership.userId, + name: membership.user?.name ?? null, + email: membership.user?.email ?? "", + role: membership.role, + }; + + const emptySummary = { + cost: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + requestCount: 0, + errorCount: 0, + cacheCount: 0, + apiKeyCount: 0, + }; + + if (projectIds.length === 0) { + return c.json({ + member, + summary: emptySummary, + topModels: [], + topProviders: [], + costByModel: [], + }); + } + + const memberKeys = await db + .select({ id: tables.apiKey.id }) + .from(tables.apiKey) + .where( + and( + inArray(tables.apiKey.projectId, projectIds), + eq(tables.apiKey.createdBy, userId), + ), + ); + const keyIds = memberKeys.map((k) => k.id); + + if (keyIds.length === 0) { + return c.json({ + member, + summary: emptySummary, + topModels: [], + topProviders: [], + costByModel: [], + }); + } + + const summaryRows = await db + .select({ + cost: sql`COALESCE(SUM(${apiKeyHourlyStats.cost}), 0)`.as("cost"), + inputTokens: + sql`COALESCE(SUM(CAST(${apiKeyHourlyStats.inputTokens} AS NUMERIC)), 0)`.as( + "input_tokens", + ), + outputTokens: + sql`COALESCE(SUM(CAST(${apiKeyHourlyStats.outputTokens} AS NUMERIC)), 0)`.as( + "output_tokens", + ), + totalTokens: + sql`COALESCE(SUM(CAST(${apiKeyHourlyStats.totalTokens} AS NUMERIC)), 0)`.as( + "total_tokens", + ), + requestCount: + sql`COALESCE(SUM(${apiKeyHourlyStats.requestCount}), 0)`.as( + "request_count", + ), + errorCount: + sql`COALESCE(SUM(${apiKeyHourlyStats.errorCount}), 0)`.as( + "error_count", + ), + cacheCount: + sql`COALESCE(SUM(${apiKeyHourlyStats.cacheCount}), 0)`.as( + "cache_count", + ), + }) + .from(apiKeyHourlyStats) + .where( + and( + inArray(apiKeyHourlyStats.apiKeyId, keyIds), + gte(apiKeyHourlyStats.hourTimestamp, startDate), + lte(apiKeyHourlyStats.hourTimestamp, endDate), + ), + ); + + const summaryRow = summaryRows[0]; + const summary = { + cost: Number(summaryRow?.cost ?? 0), + inputTokens: Number(summaryRow?.inputTokens ?? 0), + outputTokens: Number(summaryRow?.outputTokens ?? 0), + totalTokens: Number(summaryRow?.totalTokens ?? 0), + requestCount: Number(summaryRow?.requestCount ?? 0), + errorCount: Number(summaryRow?.errorCount ?? 0), + cacheCount: Number(summaryRow?.cacheCount ?? 0), + apiKeyCount: keyIds.length, + }; + + const modelRows = await db + .select({ + usedModel: apiKeyHourlyModelStats.usedModel, + usedProvider: apiKeyHourlyModelStats.usedProvider, + cost: sql`SUM(${apiKeyHourlyModelStats.cost})`.as("cost"), + requestCount: sql`SUM(${apiKeyHourlyModelStats.requestCount})`.as( + "request_count", + ), + totalTokens: + sql`SUM(CAST(${apiKeyHourlyModelStats.totalTokens} AS NUMERIC))`.as( + "total_tokens", + ), + }) + .from(apiKeyHourlyModelStats) + .where( + and( + inArray(apiKeyHourlyModelStats.apiKeyId, keyIds), + gte(apiKeyHourlyModelStats.hourTimestamp, startDate), + lte(apiKeyHourlyModelStats.hourTimestamp, endDate), + ), + ) + .groupBy( + apiKeyHourlyModelStats.usedModel, + apiKeyHourlyModelStats.usedProvider, + ) + .orderBy(desc(sql`SUM(${apiKeyHourlyModelStats.cost})`)); + + const costByModel = modelRows + .map((r) => ({ + key: r.usedModel, + cost: Number(r.cost ?? 0), + requestCount: Number(r.requestCount ?? 0), + totalTokens: Number(r.totalTokens ?? 0), + })) + .slice(0, 20); + + const providerMap = new Map< + string, + { cost: number; requestCount: number; totalTokens: number } + >(); + for (const r of modelRows) { + const agg = providerMap.get(r.usedProvider) ?? { + cost: 0, + requestCount: 0, + totalTokens: 0, + }; + agg.cost += Number(r.cost ?? 0); + agg.requestCount += Number(r.requestCount ?? 0); + agg.totalTokens += Number(r.totalTokens ?? 0); + providerMap.set(r.usedProvider, agg); + } + const topProviders = [...providerMap.entries()] + .map(([key, v]) => ({ key, ...v })) + .sort((a, b) => b.cost - a.cost) + .slice(0, 5); + + const topModels = costByModel.slice(0, 5); + + return c.json({ + member, + summary, + topModels, + topProviders, + costByModel, + }); +}); diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 084e33c3f0..7807b66914 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -4,6 +4,7 @@ import { apiAuth as auth } from "@/auth/config.js"; import { activity } from "./activity.js"; import admin from "./admin.js"; +import { analytics } from "./analytics.js"; import { auditLogs } from "./audit-logs.js"; import { chatPlans } from "./chat-plans.js"; import { chat } from "./chat.js"; @@ -54,6 +55,8 @@ routes.route("/activity", activity); routes.route("/admin", admin); +routes.route("/analytics", analytics); + routes.route("/keys", keysApi); routes.route("/keys", keysProvider); routes.route("/master-keys", masterKeys); diff --git a/apps/ui/src/app/dashboard/[orgId]/[projectId]/analytics/page.tsx b/apps/ui/src/app/dashboard/[orgId]/[projectId]/analytics/page.tsx new file mode 100644 index 0000000000..dbca694fbf --- /dev/null +++ b/apps/ui/src/app/dashboard/[orgId]/[projectId]/analytics/page.tsx @@ -0,0 +1,14 @@ +import { AnalyticsClient } from "@/components/analytics/analytics-client"; + +export default async function AnalyticsPage({ + params, +}: { + params?: Promise<{ + projectId?: string; + }>; +}) { + const paramsData = await params; + const projectId = paramsData?.projectId; + + return ; +} diff --git a/apps/ui/src/app/dashboard/[orgId]/[projectId]/api-keys/[keyId]/page.tsx b/apps/ui/src/app/dashboard/[orgId]/[projectId]/api-keys/[keyId]/page.tsx new file mode 100644 index 0000000000..e49cf6e1c8 --- /dev/null +++ b/apps/ui/src/app/dashboard/[orgId]/[projectId]/api-keys/[keyId]/page.tsx @@ -0,0 +1,11 @@ +import { ApiKeyStatsClient } from "@/components/api-keys/api-key-stats-client"; + +export default async function ApiKeyStatsPage({ + params, +}: { + params: Promise<{ orgId: string; projectId: string; keyId: string }>; +}) { + const { projectId, keyId } = await params; + + return ; +} diff --git a/apps/ui/src/app/dashboard/[orgId]/org/members/[userId]/member-detail-client.tsx b/apps/ui/src/app/dashboard/[orgId]/org/members/[userId]/member-detail-client.tsx new file mode 100644 index 0000000000..955ffeee02 --- /dev/null +++ b/apps/ui/src/app/dashboard/[orgId]/org/members/[userId]/member-detail-client.tsx @@ -0,0 +1,296 @@ +"use client"; + +import { format, subDays } from "date-fns"; +import { ArrowLeftIcon, Boxes, Mail, Sparkles } from "lucide-react"; +import Link from "next/link"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useEffect } from "react"; + +import { currencyFormatter } from "@/components/analytics/chart-helpers"; +import { CostByModelCard } from "@/components/analytics/cost-by-model-card"; +import { DateRangePicker } from "@/components/date-range-picker"; +import { useDashboardNavigation } from "@/hooks/useDashboardNavigation"; +import { useTeamMembers } from "@/hooks/useTeam"; +import { useUser } from "@/hooks/useUser"; +import { Button } from "@/lib/components/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/lib/components/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/lib/components/table"; +import { useApi } from "@/lib/fetch-client"; + +import type { ActivityRow } from "@/components/analytics/chart-helpers"; +import type { Route } from "next"; + +export function MemberDetailClient() { + const params = useParams(); + const organizationId = params.orgId as string; + const userId = params.userId as string; + const router = useRouter(); + const searchParams = useSearchParams(); + const { buildOrgUrl, selectedOrganization } = useDashboardNavigation(); + const api = useApi(); + const { user } = useUser(); + const { data: teamData } = useTeamMembers(organizationId); + + const currentUserRole = teamData?.members.find( + (member) => member.userId === user?.id, + )?.role; + const isAdmin = currentUserRole === "owner" || currentUserRole === "admin"; + const isEnterprise = selectedOrganization?.plan === "enterprise"; + + useEffect(() => { + if (!searchParams.get("from") || !searchParams.get("to")) { + const params2 = new URLSearchParams(searchParams.toString()); + params2.delete("days"); + const today = new Date(); + params2.set("from", format(subDays(today, 6), "yyyy-MM-dd")); + params2.set("to", format(today, "yyyy-MM-dd")); + router.replace( + `${buildOrgUrl(`org/members/${userId}`)}?${params2.toString()}` as Route, + ); + } + }, [searchParams, router, buildOrgUrl, userId]); + + const fromStr = + searchParams.get("from") ?? format(subDays(new Date(), 6), "yyyy-MM-dd"); + const toStr = searchParams.get("to") ?? format(new Date(), "yyyy-MM-dd"); + + const { data, isLoading } = api.useQuery( + "get", + "/analytics/members/{userId}", + { + params: { + path: { userId }, + query: { organizationId, from: fromStr, to: toStr }, + }, + }, + { enabled: !!organizationId && !!userId && isEnterprise && isAdmin }, + ); + + const summary = data?.summary; + const errorRate = + summary && summary.requestCount > 0 + ? (summary.errorCount / summary.requestCount) * 100 + : 0; + + const activity: ActivityRow[] = data + ? [ + { + date: fromStr, + modelBreakdown: data.costByModel.map((c) => ({ + id: c.key, + provider: "", + requestCount: c.requestCount, + inputTokens: 0, + outputTokens: 0, + totalTokens: c.totalTokens, + cost: c.cost, + })), + }, + ] + : []; + + const stats = [ + { + label: "Total Cost", + value: currencyFormatter.format(summary?.cost ?? 0), + }, + { + label: "Total Tokens", + value: (summary?.totalTokens ?? 0).toLocaleString(), + }, + { label: "Requests", value: (summary?.requestCount ?? 0).toLocaleString() }, + { label: "Error Rate", value: `${errorRate.toFixed(1)}%` }, + { label: "API Keys", value: (summary?.apiKeyCount ?? 0).toLocaleString() }, + ]; + + const mostUsed = [ + { + label: "Most used model", + value: data?.topModels[0]?.key ?? "—", + icon: Sparkles, + }, + { + label: "Most used provider", + value: data?.topProviders[0]?.key ?? "—", + icon: Boxes, + }, + ]; + + const memberName = data?.member.name || data?.member.email || "Member"; + + if (!isEnterprise || !isAdmin) { + return ( +
+
+ + + Back to members + + {!isEnterprise ? ( + + + Enterprise Feature + + Per-member usage analytics are available on the Enterprise + plan + + + + + + + ) : ( + + + Admins only + + Only organization owners and admins can view member usage. + + + + )} +
+
+ ); + } + + return ( +
+
+ + + Back to members + + +
+
+

+ {memberName} +

+ {data?.member.name && ( +

+ {data.member.email} +

+ )} +
+ +
+ +
+ {stats.map((stat) => ( + + + + {stat.label} + + + +
+ {isLoading ? "—" : stat.value} +
+
+
+ ))} +
+ +
+ {mostUsed.map((item) => ( + + + + + {item.label} + + + +
+ {isLoading ? "—" : item.value} +
+
+
+ ))} +
+ + + + + + Top providers + + + + + + Provider + Cost + Requests + + + + {(data?.topProviders.length ?? 0) === 0 ? ( + + + No data + + + ) : ( + data?.topProviders.map((p) => ( + + {p.key} + + {currencyFormatter.format(p.cost)} + + + {p.requestCount.toLocaleString()} + + + )) + )} + +
+
+
+
+
+ ); +} diff --git a/apps/ui/src/app/dashboard/[orgId]/org/members/[userId]/page.tsx b/apps/ui/src/app/dashboard/[orgId]/org/members/[userId]/page.tsx new file mode 100644 index 0000000000..87f0a04b9b --- /dev/null +++ b/apps/ui/src/app/dashboard/[orgId]/org/members/[userId]/page.tsx @@ -0,0 +1,5 @@ +import { MemberDetailClient } from "./member-detail-client"; + +export default function MemberDetailPage() { + return ; +} diff --git a/apps/ui/src/app/dashboard/[orgId]/org/members/members-client.tsx b/apps/ui/src/app/dashboard/[orgId]/org/members/members-client.tsx new file mode 100644 index 0000000000..f07a365420 --- /dev/null +++ b/apps/ui/src/app/dashboard/[orgId]/org/members/members-client.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { format, subDays } from "date-fns"; +import { BarChart3Icon, KeyRound, Mail, Users } from "lucide-react"; +import Link from "next/link"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useEffect } from "react"; + +import { currencyFormatter } from "@/components/analytics/chart-helpers"; +import { DateRangePicker } from "@/components/date-range-picker"; +import { useDashboardNavigation } from "@/hooks/useDashboardNavigation"; +import { useTeamMembers } from "@/hooks/useTeam"; +import { useUser } from "@/hooks/useUser"; +import { Button } from "@/lib/components/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/lib/components/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/lib/components/table"; +import { useApi } from "@/lib/fetch-client"; + +import type { Route } from "next"; + +function EnterpriseUpgradeCard() { + return ( + + + Enterprise Feature + + Per-member usage analytics are available on the Enterprise plan + + + +

+ See exactly how much each team member is spending — cost, tokens, and + the models, providers, and apps they use most — over any time period. +

+ +
+
+ ); +} + +function ApiKeyAnalyticsCallout({ href }: { href: Route }) { + return ( +
+
+
+
+ +
+
+

+ Prefer to track usage by API key? +

+

+ Every API key has the same breakdown you see here — cost, tokens, + requests, and a model-by-model view over time. Handy when your + usage runs through services, not just people. +

+
+
+ +
+
+ ); +} + +export function MembersClient() { + const params = useParams(); + const organizationId = params.orgId as string; + const router = useRouter(); + const searchParams = useSearchParams(); + const { buildUrl, buildOrgUrl, selectedOrganization } = + useDashboardNavigation(); + const api = useApi(); + const { user } = useUser(); + const { data: teamData } = useTeamMembers(organizationId); + + const currentUserRole = teamData?.members.find( + (member) => member.userId === user?.id, + )?.role; + const isAdmin = currentUserRole === "owner" || currentUserRole === "admin"; + const isEnterprise = selectedOrganization?.plan === "enterprise"; + + useEffect(() => { + if (!searchParams.get("from") || !searchParams.get("to")) { + const params2 = new URLSearchParams(searchParams.toString()); + params2.delete("days"); + const today = new Date(); + params2.set("from", format(subDays(today, 6), "yyyy-MM-dd")); + params2.set("to", format(today, "yyyy-MM-dd")); + router.replace( + `${buildOrgUrl("org/members")}?${params2.toString()}` as Route, + ); + } + }, [searchParams, router, buildOrgUrl]); + + const fromStr = + searchParams.get("from") ?? format(subDays(new Date(), 6), "yyyy-MM-dd"); + const toStr = searchParams.get("to") ?? format(new Date(), "yyyy-MM-dd"); + + const { data, isLoading, error } = api.useQuery( + "get", + "/analytics/members", + { params: { query: { organizationId, from: fromStr, to: toStr } } }, + { enabled: !!organizationId && isEnterprise && isAdmin }, + ); + + const members = data?.members ?? []; + + return ( +
+
+
+
+

Members

+

+ Usage by team member for the selected period +

+
+ {isEnterprise && isAdmin && ( + + )} +
+ + {!isEnterprise ? ( + + ) : !isAdmin ? ( + + + Admins only + + Only organization owners and admins can view member usage. + + + + ) : ( + <> + + + + + + Team members + + + Cost is attributed to the member who created each API key. + + + + + + + Member + Role + Cost + Tokens + Requests + Error rate + API keys + + + + {isLoading ? ( + + + Loading… + + + ) : error ? ( + + + Failed to load member usage. Please try again. + + + ) : members.length === 0 ? ( + + + No members found + + + ) : ( + members.map((member) => { + const errorRate = + member.requestCount > 0 + ? (member.errorCount / member.requestCount) * 100 + : 0; + return ( + + + + {member.name || member.email} + + {member.name && ( +
+ {member.email} +
+ )} +
+ + {member.role} + + + {currencyFormatter.format(member.cost)} + + + {member.totalTokens.toLocaleString()} + + + {member.requestCount.toLocaleString()} + + + {errorRate.toFixed(1)}% + + + {member.apiKeyCount} + +
+ ); + }) + )} +
+
+
+
+ + )} +
+
+ ); +} diff --git a/apps/ui/src/app/dashboard/[orgId]/org/members/page.tsx b/apps/ui/src/app/dashboard/[orgId]/org/members/page.tsx new file mode 100644 index 0000000000..ea79ab7af4 --- /dev/null +++ b/apps/ui/src/app/dashboard/[orgId]/org/members/page.tsx @@ -0,0 +1,5 @@ +import { MembersClient } from "./members-client"; + +export default function MembersPage() { + return ; +} diff --git a/apps/ui/src/components/analytics/analytics-client.tsx b/apps/ui/src/components/analytics/analytics-client.tsx new file mode 100644 index 0000000000..4f3485cf5c --- /dev/null +++ b/apps/ui/src/components/analytics/analytics-client.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { format, subDays } from "date-fns"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect } from "react"; + +import { + AnalyticsDateRange, + getAnalyticsRange, +} from "@/components/analytics/analytics-date-range"; +import { CostByModelCard } from "@/components/analytics/cost-by-model-card"; +import { CostByModelOverTimeCard } from "@/components/analytics/cost-by-model-over-time-card"; +import { useDashboardNavigation } from "@/hooks/useDashboardNavigation"; +import { useApi } from "@/lib/fetch-client"; +import { getBrowserTimeZone } from "@/lib/timezone"; + +import type { ActivityRow } from "@/components/analytics/chart-helpers"; + +interface AnalyticsClientProps { + projectId: string | undefined; +} + +export function AnalyticsClient({ projectId }: AnalyticsClientProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const { buildUrl, selectedOrganization } = useDashboardNavigation(); + const api = useApi(); + const isEnterprise = selectedOrganization?.plan === "enterprise"; + + useEffect(() => { + if (!isEnterprise) { + return; + } + if (!searchParams.get("from") || !searchParams.get("to")) { + const params = new URLSearchParams(searchParams.toString()); + params.delete("days"); + const today = new Date(); + params.set("from", format(subDays(today, 6), "yyyy-MM-dd")); + params.set("to", format(today, "yyyy-MM-dd")); + router.replace(`${buildUrl("analytics")}?${params.toString()}`); + } + }, [searchParams, router, buildUrl, isEnterprise]); + + const { fromStr, toStr } = getAnalyticsRange( + isEnterprise, + searchParams.get("from"), + searchParams.get("to"), + ); + + const { data, isLoading } = api.useQuery( + "get", + "/activity", + { + params: { + query: { + from: fromStr, + to: toStr, + timezone: getBrowserTimeZone(), + ...(projectId ? { projectId } : {}), + }, + }, + }, + { + enabled: !!projectId, + refetchOnWindowFocus: false, + staleTime: 1000 * 60 * 5, + }, + ); + + const activity = (data?.activity ?? []) as ActivityRow[]; + + return ( +
+
+
+
+

Analytics

+

+ Cost and usage broken down by model for this project +

+
+ +
+ + + +
+
+ ); +} diff --git a/apps/ui/src/components/analytics/analytics-date-range.tsx b/apps/ui/src/components/analytics/analytics-date-range.tsx new file mode 100644 index 0000000000..c5f8d4e331 --- /dev/null +++ b/apps/ui/src/components/analytics/analytics-date-range.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { format, subDays } from "date-fns"; +import { ChevronDownIcon, Lock, Mail } from "lucide-react"; + +import { DateRangePicker } from "@/components/date-range-picker"; +import { Button } from "@/lib/components/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/lib/components/popover"; + +/** + * Date window non-enterprise plans can see on the new analytics pages. Custom + * ranges (any week/month/quarter) are reserved for the enterprise plan. + */ +export const FREE_PLAN_RANGE_DAYS = 7; + +/** + * Resolves the effective from/to range for an analytics page. Enterprise plans + * honour the URL params (driven by the full date picker); everyone else is + * clamped to the last {@link FREE_PLAN_RANGE_DAYS} days regardless of the URL, + * so the limit can't be bypassed by editing query params. + */ +export function getAnalyticsRange( + isEnterprise: boolean, + searchFrom: string | null, + searchTo: string | null, +): { fromStr: string; toStr: string } { + const today = new Date(); + const defaultFrom = format( + subDays(today, FREE_PLAN_RANGE_DAYS - 1), + "yyyy-MM-dd", + ); + const defaultTo = format(today, "yyyy-MM-dd"); + + if (!isEnterprise) { + return { fromStr: defaultFrom, toStr: defaultTo }; + } + + return { + fromStr: searchFrom ?? defaultFrom, + toStr: searchTo ?? defaultTo, + }; +} + +interface AnalyticsDateRangeProps { + isEnterprise: boolean; + buildUrl: (path?: string) => string; + path: string; +} + +/** + * The full {@link DateRangePicker} for enterprise plans, or a locked + * "Last 7 days" control with an inline upsell for everyone else. + */ +export function AnalyticsDateRange({ + isEnterprise, + buildUrl, + path, +}: AnalyticsDateRangeProps) { + if (isEnterprise) { + return ; + } + + return ( + + + + + +
+
+

Want a longer history?

+

+ Your plan shows the last 7 days. Upgrade to Enterprise to break + usage down across any week, month, or quarter. +

+
+ +
+
+
+ ); +} diff --git a/apps/ui/src/components/analytics/chart-helpers.ts b/apps/ui/src/components/analytics/chart-helpers.ts new file mode 100644 index 0000000000..c201cbdaa3 --- /dev/null +++ b/apps/ui/src/components/analytics/chart-helpers.ts @@ -0,0 +1,183 @@ +export type ChartMetric = "cost" | "requestCount" | "totalTokens"; +export type ModelView = "mapping" | "canonical"; + +export interface ModelBreakdownEntry { + id: string; + provider: string; + requestCount: number; + inputTokens: number; + outputTokens: number; + totalTokens: number; + cost: number; +} + +export interface ActivityRow { + date: string; + modelBreakdown: ModelBreakdownEntry[]; +} + +export const seriesColors = [ + "hsl(221 83% 53%)", + "hsl(142 71% 45%)", + "hsl(262 83% 58%)", + "hsl(32 95% 44%)", + "hsl(0 84% 60%)", + "hsl(199 89% 48%)", + "hsl(291 64% 42%)", + "hsl(48 96% 53%)", + "hsl(160 84% 39%)", + "hsl(340 82% 52%)", +]; + +export const currencyFormatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 4, +}); + +/** + * Mirrors the gateway's canonical model id extraction: drop the provider prefix + * (everything before the first "/") and any version/tag suffix (after ":"). + */ +export function extractCanonicalModelId(usedModel: string): string { + const slashIdx = usedModel.indexOf("/"); + const withoutProvider = + slashIdx === -1 ? usedModel : usedModel.slice(slashIdx + 1); + const colonIdx = withoutProvider.indexOf(":"); + return colonIdx === -1 ? withoutProvider : withoutProvider.slice(0, colonIdx); +} + +/** + * Builds the display key for a model breakdown entry. The "mapping" view shows + * the provider-specific model (e.g. "azure/gpt-image-2"); the "canonical" view + * collapses providers/tags into the base model id. + */ +export function modelKey(entry: ModelBreakdownEntry, view: ModelView): string { + if (view === "canonical") { + return extractCanonicalModelId(entry.id); + } + if (entry.id.includes("/") || !entry.provider) { + return entry.id; + } + return `${entry.provider}/${entry.id}`; +} + +export interface ModelAggregate { + model: string; + cost: number; + requestCount: number; + totalTokens: number; +} + +export interface CostByModelResult { + models: ModelAggregate[]; + totalCost: number; + totalRequests: number; + totalTokens: number; +} + +/** + * Aggregates the per-bucket model breakdowns from /activity into per-model + * totals for the horizontal bar chart. + */ +export function aggregateCostByModel( + activity: ActivityRow[], + view: ModelView, + limit = 20, +): CostByModelResult { + const byModel = new Map(); + let totalCost = 0; + let totalRequests = 0; + let totalTokens = 0; + + for (const row of activity) { + for (const entry of row.modelBreakdown) { + const key = modelKey(entry, view); + const agg = byModel.get(key) ?? { + model: key, + cost: 0, + requestCount: 0, + totalTokens: 0, + }; + agg.cost += entry.cost; + agg.requestCount += entry.requestCount; + agg.totalTokens += entry.totalTokens; + byModel.set(key, agg); + totalCost += entry.cost; + totalRequests += entry.requestCount; + totalTokens += entry.totalTokens; + } + } + + const models = Array.from(byModel.values()) + .sort((a, b) => b.cost - a.cost) + .slice(0, limit); + + return { models, totalCost, totalRequests, totalTokens }; +} + +export interface ModelTimePoint { + timestamp: string; + entries: Record< + string, + { cost: number; requestCount: number; totalTokens: number } + >; +} + +export interface ModelTimeseriesResult { + models: string[]; + data: ModelTimePoint[]; +} + +/** + * Pivots the per-bucket model breakdowns from /activity into a stacked-area + * time series of the top-N models (ranked by total cost over the window). + */ +export function buildModelTimeseries( + activity: ActivityRow[], + view: ModelView, + topN = 10, +): ModelTimeseriesResult { + const totalsByModel = new Map(); + for (const row of activity) { + for (const entry of row.modelBreakdown) { + const key = modelKey(entry, view); + totalsByModel.set(key, (totalsByModel.get(key) ?? 0) + entry.cost); + } + } + + const topModels = Array.from(totalsByModel.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, topN) + .map(([k]) => k); + const topSet = new Set(topModels); + + const data: ModelTimePoint[] = activity.map((row) => { + const entries: ModelTimePoint["entries"] = {}; + for (const entry of row.modelBreakdown) { + const key = modelKey(entry, view); + if (!topSet.has(key)) { + continue; + } + const existing = entries[key] ?? { + cost: 0, + requestCount: 0, + totalTokens: 0, + }; + existing.cost += entry.cost; + existing.requestCount += entry.requestCount; + existing.totalTokens += entry.totalTokens; + entries[key] = existing; + } + return { timestamp: row.date, entries }; + }); + + return { models: topModels, data }; +} + +export function sanitizeKey(model: string): string { + // Encode each non-alphanumeric char as its code point so distinct model ids + // (e.g. "claude-3.5" vs "claude-3-5") can't collapse into the same key and + // overwrite each other in the chart. Output stays CSS-var safe. + return model.replace(/[^a-zA-Z0-9]/g, (c) => `_${c.charCodeAt(0)}_`); +} diff --git a/apps/ui/src/components/analytics/cost-by-model-card.tsx b/apps/ui/src/components/analytics/cost-by-model-card.tsx new file mode 100644 index 0000000000..b7954e578a --- /dev/null +++ b/apps/ui/src/components/analytics/cost-by-model-card.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/lib/components/card"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/lib/components/chart"; +import { cn } from "@/lib/utils"; + +import { + aggregateCostByModel, + currencyFormatter, + type ActivityRow, + type ChartMetric, +} from "./chart-helpers"; + +import type { ChartConfig } from "@/lib/components/chart"; + +const metricConfigs: Record = { + cost: { cost: { label: "Cost ($)", color: "hsl(142 71% 45%)" } }, + requestCount: { + requestCount: { label: "Requests", color: "hsl(221 83% 53%)" }, + }, + totalTokens: { totalTokens: { label: "Tokens", color: "hsl(32 95% 44%)" } }, +}; + +const metricTabs: { key: ChartMetric; label: string }[] = [ + { key: "cost", label: "Cost" }, + { key: "requestCount", label: "Requests" }, + { key: "totalTokens", label: "Tokens" }, +]; + +interface CostByModelCardProps { + activity: ActivityRow[]; + loading?: boolean; + title?: string; + description?: string; +} + +export function CostByModelCard({ + activity, + loading = false, + title = "Cost by Model", + description = "Top 20 models by cost for the selected period", +}: CostByModelCardProps) { + const [activeMetric, setActiveMetric] = useState("cost"); + + const data = useMemo( + () => aggregateCostByModel(activity, "mapping"), + [activity], + ); + + const config = metricConfigs[activeMetric]; + const dataKey = Object.keys(config)[0]; + + return ( + + +
+ {title} + {description} + {!loading && data.models.length > 0 && ( +
+ + Total Cost:{" "} + + {currencyFormatter.format(data.totalCost)} + + + + Total Requests:{" "} + + {data.totalRequests.toLocaleString()} + + +
+ )} +
+
+ {metricTabs.map((tab) => ( + + ))} +
+
+ + {loading ? ( +
+ Loading… +
+ ) : data.models.length === 0 ? ( +
+ No data for this time period +
+ ) : ( + + + + + value.length > 24 ? `${value.slice(0, 22)}…` : value + } + className="text-xs" + /> + { + if (activeMetric === "cost") { + return `$${value >= 1 ? value.toFixed(2) : value.toFixed(4)}`; + } + return value >= 1000 + ? `${(value / 1000).toFixed(1)}k` + : String(value); + }} + /> + { + if (activeMetric === "cost") { + return currencyFormatter.format(Number(value)); + } + return Number(value).toLocaleString(); + }} + /> + } + /> + + + + )} +
+
+ ); +} diff --git a/apps/ui/src/components/analytics/cost-by-model-over-time-card.tsx b/apps/ui/src/components/analytics/cost-by-model-over-time-card.tsx new file mode 100644 index 0000000000..4e6f63446b --- /dev/null +++ b/apps/ui/src/components/analytics/cost-by-model-over-time-card.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { format, parseISO } from "date-fns"; +import { useCallback, useMemo, useState } from "react"; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/lib/components/card"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/lib/components/chart"; +import { cn } from "@/lib/utils"; + +import { + buildModelTimeseries, + currencyFormatter, + sanitizeKey, + seriesColors, + type ActivityRow, + type ChartMetric, + type ModelView, +} from "./chart-helpers"; + +import type { ChartConfig } from "@/lib/components/chart"; + +const metricTabs: { key: ChartMetric; label: string }[] = [ + { key: "cost", label: "Cost" }, + { key: "requestCount", label: "Requests" }, + { key: "totalTokens", label: "Tokens" }, +]; + +const modelViewTabs: { key: ModelView; label: string }[] = [ + { key: "mapping", label: "Mappings" }, + { key: "canonical", label: "Canonical" }, +]; + +interface CostByModelOverTimeCardProps { + activity: ActivityRow[]; + loading?: boolean; + title?: string; + description?: string; +} + +export function CostByModelOverTimeCard({ + activity, + loading = false, + title = "Cost by Model Over Time", + description = "Stacked breakdown of the top 10 models over the selected window", +}: CostByModelOverTimeCardProps) { + const [activeMetric, setActiveMetric] = useState("cost"); + const [modelView, setModelView] = useState("mapping"); + + const series = useMemo( + () => buildModelTimeseries(activity, modelView), + [activity, modelView], + ); + + const bucket = useMemo<"hour" | "day">(() => { + const first = series.data.find((d) => d.timestamp); + return first?.timestamp.includes("T") ? "hour" : "day"; + }, [series.data]); + + const { chartData, config, keyToModel } = useMemo(() => { + const keyToModelLocal = new Map(); + const cfg: ChartConfig = {}; + series.models.forEach((model, index) => { + const key = sanitizeKey(model); + keyToModelLocal.set(key, model); + cfg[key] = { + label: model, + color: seriesColors[index % seriesColors.length], + }; + }); + const rows = series.data.map((point) => { + const row: Record = { + timestamp: point.timestamp, + }; + for (const model of series.models) { + row[sanitizeKey(model)] = 0; + } + for (const [model, value] of Object.entries(point.entries)) { + row[sanitizeKey(model)] = Number(value[activeMetric] ?? 0); + } + return row; + }); + return { chartData: rows, config: cfg, keyToModel: keyToModelLocal }; + }, [series, activeMetric]); + + const hasData = series.models.length > 0; + + const formatTimestamp = useCallback( + (ts: string) => { + // parseISO reads date-only strings ("2026-06-20") as local midnight, + // avoiding the UTC-midnight off-by-one that new Date() causes in + // negative-offset timezones. + const date = parseISO(ts); + return bucket === "hour" + ? format(date, "MMM d HH:mm") + : format(date, "MMM d"); + }, + [bucket], + ); + + return ( + + +
+ {title} + {description} +
+
+
+ {metricTabs.map((tab) => ( + + ))} +
+
+ {modelViewTabs.map((tab) => ( + + ))} +
+
+
+ + {loading ? ( +
+ Loading… +
+ ) : !hasData ? ( +
+ No data for this time period +
+ ) : ( + <> + + + + formatTimestamp(value)} + /> + { + if (activeMetric === "cost") { + return `$${value >= 1 ? value.toFixed(2) : value.toFixed(4)}`; + } + return value >= 1000 + ? `${(value / 1000).toFixed(1)}k` + : String(value); + }} + /> + { + const sortedPayload = [...(props.payload ?? [])] + .filter((item) => Number(item.value ?? 0) > 0) + .sort( + (a, b) => Number(b.value ?? 0) - Number(a.value ?? 0), + ); + return ( + + format( + parseISO(value), + bucket === "hour" ? "MMM d, HH:mm" : "MMM d, yyyy", + ) + } + formatter={(value, name) => { + const label = + keyToModel.get(name as string) ?? String(name); + const formatted = + activeMetric === "cost" + ? currencyFormatter.format(Number(value)) + : Number(value).toLocaleString(); + return ( + + {label}: {formatted} + + ); + }} + /> + ); + }} + /> + {series.models.map((model) => { + const key = sanitizeKey(model); + return ( + + ); + })} + + +
+ {series.models.map((model, i) => ( +
+ + {model} +
+ ))} +
+ + )} +
+
+ ); +} diff --git a/apps/ui/src/components/api-keys/api-key-stats-client.tsx b/apps/ui/src/components/api-keys/api-key-stats-client.tsx new file mode 100644 index 0000000000..9ddb49b255 --- /dev/null +++ b/apps/ui/src/components/api-keys/api-key-stats-client.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { format, subDays } from "date-fns"; +import { ArrowLeftIcon } from "lucide-react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useMemo } from "react"; + +import { + AnalyticsDateRange, + getAnalyticsRange, +} from "@/components/analytics/analytics-date-range"; +import { currencyFormatter } from "@/components/analytics/chart-helpers"; +import { CostByModelCard } from "@/components/analytics/cost-by-model-card"; +import { CostByModelOverTimeCard } from "@/components/analytics/cost-by-model-over-time-card"; +import { useDashboardNavigation } from "@/hooks/useDashboardNavigation"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/lib/components/card"; +import { useApi } from "@/lib/fetch-client"; +import { getBrowserTimeZone } from "@/lib/timezone"; + +import type { ActivityRow } from "@/components/analytics/chart-helpers"; +import type { Route } from "next"; + +interface ApiKeyStatsClientProps { + projectId: string | undefined; + keyId: string; +} + +export function ApiKeyStatsClient({ + projectId, + keyId, +}: ApiKeyStatsClientProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const { buildUrl, selectedOrganization } = useDashboardNavigation(); + const api = useApi(); + const isEnterprise = selectedOrganization?.plan === "enterprise"; + + useEffect(() => { + if (!isEnterprise) { + return; + } + if (!searchParams.get("from") || !searchParams.get("to")) { + const params = new URLSearchParams(searchParams.toString()); + params.delete("days"); + const today = new Date(); + params.set("from", format(subDays(today, 6), "yyyy-MM-dd")); + params.set("to", format(today, "yyyy-MM-dd")); + router.replace( + `${buildUrl(`api-keys/${keyId}`)}?${params.toString()}` as Route, + ); + } + }, [searchParams, router, buildUrl, keyId, isEnterprise]); + + const { fromStr, toStr } = getAnalyticsRange( + isEnterprise, + searchParams.get("from"), + searchParams.get("to"), + ); + + const { data: apiKeysData } = api.useQuery( + "get", + "/keys/api", + { params: { query: { projectId: projectId ?? "" } } }, + { enabled: !!projectId }, + ); + const apiKey = apiKeysData?.apiKeys.find((k) => k.id === keyId); + + const { data, isLoading } = api.useQuery( + "get", + "/activity", + { + params: { + query: { + from: fromStr, + to: toStr, + timezone: getBrowserTimeZone(), + apiKeyId: keyId, + ...(projectId ? { projectId } : {}), + }, + }, + }, + { + enabled: !!projectId, + refetchOnWindowFocus: false, + staleTime: 1000 * 60 * 5, + }, + ); + + const activity = (data?.activity ?? []) as ActivityRow[]; + + const summary = useMemo(() => { + const rows = data?.activity ?? []; + return rows.reduce( + (acc, row) => { + acc.cost += row.cost; + acc.totalTokens += row.totalTokens; + acc.requestCount += row.requestCount; + acc.errorCount += row.errorCount; + return acc; + }, + { cost: 0, totalTokens: 0, requestCount: 0, errorCount: 0 }, + ); + }, [data]); + + const errorRate = + summary.requestCount > 0 + ? (summary.errorCount / summary.requestCount) * 100 + : 0; + + const stats = [ + { label: "Total Cost", value: currencyFormatter.format(summary.cost) }, + { label: "Total Tokens", value: summary.totalTokens.toLocaleString() }, + { label: "Requests", value: summary.requestCount.toLocaleString() }, + { label: "Error Rate", value: `${errorRate.toFixed(1)}%` }, + ]; + + return ( +
+
+ + + Back to API keys + + +
+
+

+ {apiKey?.description || "API Key"} +

+

+ {apiKey?.maskedToken ?? keyId} +

+
+ +
+ +
+ {stats.map((stat) => ( + + + + {stat.label} + + + +
+ {isLoading ? "—" : stat.value} +
+
+
+ ))} +
+ + + +
+
+ ); +} diff --git a/apps/ui/src/components/api-keys/api-keys-list.tsx b/apps/ui/src/components/api-keys/api-keys-list.tsx index 9f96e6ffc3..f00224c845 100644 --- a/apps/ui/src/components/api-keys/api-keys-list.tsx +++ b/apps/ui/src/components/api-keys/api-keys-list.tsx @@ -95,7 +95,7 @@ export function ApiKeysList({ `/dashboard/${orgId}/${projectId}/api-keys/${keyId}/iam` as Route; const getStatisticsUrl = (keyId: string) => - `/dashboard/${orgId}/${projectId}/usage?apiKeyId=${keyId}` as Route; + `/dashboard/${orgId}/${projectId}/api-keys/${keyId}` as Route; // All hooks must be called before any conditional returns const { data, isLoading, error } = api.useQuery( diff --git a/apps/ui/src/components/dashboard/animated-nav-icons.tsx b/apps/ui/src/components/dashboard/animated-nav-icons.tsx index d411d031d9..20d728a6d8 100644 --- a/apps/ui/src/components/dashboard/animated-nav-icons.tsx +++ b/apps/ui/src/components/dashboard/animated-nav-icons.tsx @@ -114,6 +114,21 @@ export function AnimatedBarChart3({ isHovered }: AnimatedIconProps) { ); } +// ChartArea — trend line draws itself across the axes +export function AnimatedChartArea({ isHovered }: AnimatedIconProps) { + return ( + + + + + ); +} + // Key — rotates like turning a lock export function AnimatedKey({ isHovered }: AnimatedIconProps) { return ( diff --git a/apps/ui/src/components/dashboard/dashboard-sidebar.tsx b/apps/ui/src/components/dashboard/dashboard-sidebar.tsx index 1eca6e8cee..438a9cdeeb 100644 --- a/apps/ui/src/components/dashboard/dashboard-sidebar.tsx +++ b/apps/ui/src/components/dashboard/dashboard-sidebar.tsx @@ -29,6 +29,7 @@ import { AnimatedBadgeCheck, AnimatedBarChart3, AnimatedBotMessageSquare, + AnimatedChartArea, AnimatedChartColumnBig, AnimatedExternalLink, AnimatedKey, @@ -116,6 +117,11 @@ const PROJECT_NAVIGATION: readonly { label: "Model Usage", icon: AnimatedChartColumnBig, }, + { + href: "analytics", + label: "Analytics", + icon: AnimatedChartArea, + }, { href: "usage", label: "Usage & Metrics", @@ -169,6 +175,10 @@ const ORGANIZATION_SETTINGS = [ href: "org/team", label: "Team", }, + { + href: "org/members", + label: "Members", + }, { href: "org/audit-logs", label: "Audit Logs", @@ -465,6 +475,7 @@ function OrganizationSection({ isActive("org/policies") || isActive("org/preferences") || isActive("org/team") || + isActive("org/members") || isActive("org/audit-logs") } tooltip="Settings" diff --git a/packages/db/src/seed-demo-analytics.ts b/packages/db/src/seed-demo-analytics.ts new file mode 100644 index 0000000000..8426f1d05b --- /dev/null +++ b/packages/db/src/seed-demo-analytics.ts @@ -0,0 +1,276 @@ +/** + * Demo-data augmentation for the new per-member / per-API-key analytics. + * + * The base seed only populates project-level hourly stats, so the Members and + * API-key statistics pages would be empty. This script backfills api-key-level + * hourly stats (attributed to several members via apiKey.createdBy) for the + * enterprise "DataFlow AI" organization so every new page renders rich data. + * Safe to re-run (idempotent via ON CONFLICT). + */ +import { closeDatabase, db, tables } from "./index.js"; + +const ORG_ID = "org-dataflow"; + +const MODELS = [ + { + model: "claude-3.5-sonnet", + provider: "anthropic", + inPrice: 0.003, + outPrice: 0.015, + }, + { model: "gpt-4o", provider: "openai", inPrice: 0.0025, outPrice: 0.01 }, + { + model: "gpt-4o-mini", + provider: "openai", + inPrice: 0.00015, + outPrice: 0.0006, + }, + { model: "o1", provider: "openai", inPrice: 0.015, outPrice: 0.06 }, + { + model: "claude-3-haiku", + provider: "anthropic", + inPrice: 0.00025, + outPrice: 0.00125, + }, + { model: "gpt-4", provider: "openai", inPrice: 0.03, outPrice: 0.06 }, +]; + +interface KeyDef { + id: string; + projectId: string; + description: string; + create?: boolean; +} + +interface OwnerDef { + userId: string; + weight: number; + keys: KeyDef[]; +} + +const OWNERS: OwnerDef[] = [ + { + userId: "user-carol", + weight: 1, + keys: [ + { + id: "apikey-7", + projectId: "proj-org-dataflow-0", + description: "Primary Key", + }, + { + id: "apikey-8", + projectId: "proj-org-dataflow-0", + description: "CI/CD Key", + }, + ], + }, + { + userId: "user-elena", + weight: 0.62, + keys: [ + { + id: "apikey-demo-elena-1", + projectId: "proj-org-dataflow-0", + description: "Elena Dev Key", + create: true, + }, + { + id: "apikey-demo-elena-2", + projectId: "proj-org-dataflow-1", + description: "Elena Staging Key", + create: true, + }, + ], + }, + { + userId: "user-dave", + weight: 0.4, + keys: [ + { + id: "apikey-demo-dave-1", + projectId: "proj-org-dataflow-0", + description: "Dave Local Key", + create: true, + }, + ], + }, + { + userId: "user-frank", + weight: 0.24, + keys: [ + { + id: "apikey-demo-frank-1", + projectId: "proj-org-dataflow-2", + description: "Frank Test Key", + create: true, + }, + ], + }, +]; + +const HOURS = [1, 4, 7, 10, 13, 16, 19, 22]; + +function hourBucket(dayOffset: number, hour: number): Date { + const now = new Date(); + const d = new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + hour, + 0, + 0, + 0, + ), + ); + d.setUTCDate(d.getUTCDate() - dayOffset); + return d; +} + +function rand(min: number, max: number): number { + return min + (max - min) * Math.random(); // eslint-disable-line no-mixed-operators +} + +async function main(): Promise { + const keyStatsRows: (typeof tables.apiKeyHourlyStats.$inferInsert)[] = []; + const keyModelStatsRows: (typeof tables.apiKeyHourlyModelStats.$inferInsert)[] = + []; + + for (const owner of OWNERS) { + // Create demo API keys for members that don't already own one. + for (const key of owner.keys) { + if (key.create) { + await db + .insert(tables.apiKey) + .values({ + id: key.id, + token: `sk-demo-${key.id}`, + projectId: key.projectId, + description: key.description, + createdBy: owner.userId, + keyType: "user", + status: "active", + }) + .onConflictDoNothing(); + } + } + + for (const key of owner.keys) { + for (let dayOffset = 0; dayOffset <= 6; dayOffset++) { + for (const hour of HOURS) { + const ts = hourBucket(dayOffset, hour); + // Diurnal-ish shape so the over-time chart has texture. + // eslint-disable-next-line no-mixed-operators + const activityFactor = 0.5 + Math.sin((hour / 24) * Math.PI) ** 2; + + let hourRequests = 0; + let hourErrors = 0; + let hourCache = 0; + let hourInputTokens = 0; + let hourOutputTokens = 0; + let hourCost = 0; + let hourInputCost = 0; + let hourOutputCost = 0; + + // Each key uses a rotating subset of models. + const modelCount = 3 + Math.floor(rand(0, 3)); + for (let m = 0; m < modelCount; m++) { + const model = + MODELS[(key.id.length + dayOffset + m) % MODELS.length]; + const requestCount = Math.max( + 1, + Math.round(owner.weight * activityFactor * rand(2, 10)), + ); + const inputTokens = Math.round(requestCount * rand(350, 900)); + const outputTokens = Math.round(requestCount * rand(180, 520)); + const totalTokens = inputTokens + outputTokens; + const inputCost = (inputTokens / 1000) * model.inPrice; + const outputCost = (outputTokens / 1000) * model.outPrice; + const cost = inputCost + outputCost; + const errorCount = Math.random() < 0.15 ? 1 : 0; + const cacheCount = Math.round(requestCount * rand(0, 0.25)); + + hourRequests += requestCount; + hourErrors += errorCount; + hourCache += cacheCount; + hourInputTokens += inputTokens; + hourOutputTokens += outputTokens; + hourCost += cost; + hourInputCost += inputCost; + hourOutputCost += outputCost; + + keyModelStatsRows.push({ + id: `akms-${key.id}-${dayOffset}-${hour}-${model.model}`, + apiKeyId: key.id, + projectId: key.projectId, + hourTimestamp: ts, + usedModel: model.model, + usedProvider: model.provider, + requestCount, + errorCount, + cacheCount, + inputTokens: String(inputTokens), + outputTokens: String(outputTokens), + totalTokens: String(totalTokens), + cost, + inputCost, + outputCost, + }); + } + + keyStatsRows.push({ + id: `aks-${key.id}-${dayOffset}-${hour}`, + apiKeyId: key.id, + projectId: key.projectId, + hourTimestamp: ts, + requestCount: hourRequests, + errorCount: hourErrors, + cacheCount: hourCache, + inputTokens: String(hourInputTokens), + outputTokens: String(hourOutputTokens), + totalTokens: String(hourInputTokens + hourOutputTokens), + cost: hourCost, + inputCost: hourInputCost, + outputCost: hourOutputCost, + }); + } + } + } + } + + const chunk = (arr: T[], size: number): T[][] => { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += size) { + out.push(arr.slice(i, i + size)); + } + return out; + }; + + for (const rows of chunk(keyStatsRows, 500)) { + await db + .insert(tables.apiKeyHourlyStats) + .values(rows) + .onConflictDoNothing(); + } + for (const rows of chunk(keyModelStatsRows, 500)) { + await db + .insert(tables.apiKeyHourlyModelStats) + .values(rows) + .onConflictDoNothing(); + } + + // eslint-disable-next-line no-console + console.log( + `Inserted ${keyStatsRows.length} api-key hourly stats, ${keyModelStatsRows.length} model stats for ${ORG_ID}.`, + ); +} + +main() + .then(() => closeDatabase()) + .catch(async (err) => { + // eslint-disable-next-line no-console + console.error(err); + await closeDatabase(); + process.exit(1); + });