diff --git a/src/components/partners/PartnerStatsPanel.tsx b/src/components/partners/PartnerStatsPanel.tsx
index 1894ed93..9cde6268 100644
--- a/src/components/partners/PartnerStatsPanel.tsx
+++ b/src/components/partners/PartnerStatsPanel.tsx
@@ -1,63 +1,429 @@
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { format, parseISO } from "date-fns";
+import {
+ Area,
+ AreaChart,
+ Bar,
+ BarChart,
+ CartesianGrid,
+ XAxis,
+ YAxis,
+} from "recharts";
+import {
+ IconAlertTriangle,
+ IconApi,
+ IconClock,
+ IconUsers,
+} from "@tabler/icons-react";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from "@/components/ui/chart";
+import { Badge } from "@/components/ui/badge";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import type { PartnerStats } from "@/lib/partners/client";
-function Stat({ label, value, hint }: { label: string; value: string; hint?: string }) {
+const callsConfig = {
+ calls: { label: "Calls", color: "hsl(var(--chart-4))" },
+} satisfies ChartConfig;
+
+const errorsConfig = {
+ errors: { label: "Errors", color: "hsl(var(--chart-5))" },
+} satisfies ChartConfig;
+
+const usersConfig = {
+ signIns: { label: "Sign-ins", color: "hsl(var(--chart-1))" },
+ revocations: { label: "Revocations", color: "hsl(var(--chart-5))" },
+} satisfies ChartConfig;
+
+const formatPercent = (value: number) => `${(value * 100).toFixed(1)}%`;
+const formatLatency = (value: number) => (value > 0 ? `${Math.round(value)} ms` : "—");
+
+const formatDay = (date: string) => {
+ const parsed = parseISO(date);
+ return Number.isNaN(parsed.getTime()) ? date : format(parsed, "MMM d");
+};
+
+const formatEventTime = (ts: string) => {
+ const parsed = parseISO(ts);
+ return Number.isNaN(parsed.getTime()) ? ts : format(parsed, "MMM d, HH:mm:ss");
+};
+
+function Stat({
+ label,
+ value,
+ hint,
+ icon: Icon,
+ accent,
+}: {
+ label: string;
+ value: string;
+ hint?: string;
+ icon: typeof IconUsers;
+ accent: string;
+}) {
return (
-
{label}
+
{value}
{hint &&
{hint}
}
);
}
+function StatusBadge({ status }: { status: number | null }) {
+ if (status === null) return
Unknown;
+ if (status >= 500) return
{status};
+ if (status >= 400) return
{status};
+ return
{status};
+}
+
export function PartnerStatsPanel({ stats }: { stats: PartnerStats }) {
- const max = Math.max(1, ...stats.dailyCalls.map((day) => day.calls));
+ const daily = stats.daily.map((point) => ({
+ ...point,
+ label: formatDay(point.date),
+ }));
+
+ const userDaily = stats.userDaily.map((point) => ({
+ ...point,
+ label: formatDay(point.date),
+ }));
+
+ const hasUsage = stats.calls > 0;
return (
-
-
+
+ 0 ? `${stats.errors.toLocaleString()} errors` : "No errors recorded"}
+ icon={IconApi}
+ accent="text-chart-4"
+ />
0 ? undefined : "No calls recorded yet"}
+ value={formatPercent(stats.errorRate)}
+ hint={hasUsage ? "Non-2xx responses" : "No calls recorded yet"}
+ icon={IconAlertTriangle}
+ accent="text-chart-5"
/>
0 ? `${Math.round(stats.p95LatencyMs)} ms` : "—"}
+ value={formatLatency(stats.p95LatencyMs)}
hint={`${stats.activeKeys} active ${stats.activeKeys === 1 ? "key" : "keys"}`}
+ icon={IconClock}
+ accent="text-chart-2"
/>
-
-
- Calls over the last 30 days
-
-
- {stats.calls === 0 ? (
-
- No calls yet. Once your key is in use, daily calls show up here.
-
- ) : (
-
- {stats.dailyCalls.map((day) => (
-
0 && (
+
+
+ Sign-ins per day
+ New authorizations and revocations over the last 30 days.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } />
+
+
- ))}
-
- )}
-
-
+
+
+
+
+ )}
+
+ {!hasUsage ? (
+
+
No API activity yet
+
+ No API call telemetry has been recorded for this app in the last 30 days. Calls, errors,
+ per-endpoint breakdowns and latency appear here once usage is recorded.
+
+
+ ) : (
+ <>
+
+
+
+ Calls per day
+ Requests made with your keys over the last 30 days.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } />
+
+
+
+
+
+
+
+
+ Errors per day
+ Non-2xx responses returned to your app.
+
+
+
+
+
+
+
+ } />
+
+
+
+
+
+
+
+
+
+
+ Top endpoints
+ Most requested endpoints in the selected window.
+
+
+ {stats.endpoints.length === 0 ? (
+ No endpoint data yet.
+ ) : (
+
+
+
+ Endpoint
+ Calls
+ Errors
+ p95
+
+
+
+ {stats.endpoints.map((endpoint) => (
+
+
+ {endpoint.endpoint}
+
+
+ {endpoint.calls.toLocaleString()}
+
+
+ {endpoint.errors.toLocaleString()}
+
+
+ {formatLatency(endpoint.p95LatencyMs)}
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ Response statuses
+ HTTP status codes returned by your API calls.
+
+
+ {stats.statuses.length === 0 ? (
+ No status data yet.
+ ) : (
+
+
+
+ Status
+ Calls
+ Share
+
+
+
+ {stats.statuses.map((entry) => (
+
+
+
+
+
+ {entry.calls.toLocaleString()}
+
+
+ {formatPercent(entry.calls / stats.calls)}
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+ Daily breakdown
+ Per day over the last 30 days (UTC).
+
+
+
+
+
+ Day
+ Calls
+ Errors
+ Error rate
+ p95 latency
+
+
+
+ {[...daily].reverse().map((point) => (
+
+ {point.label}
+
+ {point.calls.toLocaleString()}
+
+
+ {point.errors.toLocaleString()}
+
+
+ {point.calls > 0 ? formatPercent(point.errors / point.calls) : "—"}
+
+
+ {formatLatency(point.p95LatencyMs)}
+
+
+ ))}
+
+
+
+
+
+
+
+ Recent API activity
+ The latest requests recorded for your organization.
+
+
+ {stats.recentEvents.length === 0 ? (
+ No events recorded yet.
+ ) : (
+
+
+
+ Time
+ Type
+ Endpoint
+ Status
+ Latency
+
+
+
+ {stats.recentEvents.map((event, index) => (
+
+
+ {formatEventTime(event.ts)}
+
+ {event.type}
+
+ {event.endpoint ?? "—"}
+
+
+
+
+
+ {formatLatency(event.latency_ms ?? 0)}
+
+
+ ))}
+
+
+ )}
+
+
+ >
+ )}
);
}
diff --git a/src/lib/partners/client.ts b/src/lib/partners/client.ts
index 68536e4c..b64440af 100644
--- a/src/lib/partners/client.ts
+++ b/src/lib/partners/client.ts
@@ -193,17 +193,57 @@ export async function listPartnerUsage(orgId: string, days = 30): Promise a - b);
+ return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
}
+const isErrorStatus = (status: number | null) => status !== null && status >= 400;
+
export function summarizeUsage(
consents: PartnerConsent[],
keys: PartnerApiKey[],
@@ -221,27 +261,99 @@ export function summarizeUsage(
const activeKeys = keys.filter((key) => key.status === "active").length;
const calls = events.length;
- const errors = events.filter((event) => event.status !== null && event.status >= 400).length;
+ const errors = events.filter((event) => isErrorStatus(event.status)).length;
const errorRate = calls > 0 ? errors / calls : 0;
+ const p95LatencyMs = percentile95(
+ events
+ .map((event) => event.latency_ms)
+ .filter((value): value is number => typeof value === "number"),
+ );
- const latencies = events
- .map((event) => event.latency_ms)
- .filter((value): value is number => typeof value === "number")
- .sort((a, b) => a - b);
- const p95LatencyMs = latencies.length > 0 ? latencies[Math.floor(latencies.length * 0.95)] : 0;
-
- const buckets = new Map();
+ const buckets = new Map();
+ const userBuckets = new Map();
for (let i = days - 1; i >= 0; i -= 1) {
const date = new Date(now - i * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
- buckets.set(date, 0);
+ buckets.set(date, { calls: 0, errors: 0, latencies: [] });
+ userBuckets.set(date, { signIns: 0, revocations: 0 });
}
+
+ for (const consent of consents) {
+ const granted = userBuckets.get(consent.granted_at.slice(0, 10));
+ if (granted) granted.signIns += 1;
+ if (consent.revoked_at) {
+ const revoked = userBuckets.get(consent.revoked_at.slice(0, 10));
+ if (revoked) revoked.revocations += 1;
+ }
+ }
+
+ const endpointMap = new Map();
+ const statusMap = new Map();
+
for (const event of events) {
- const date = event.ts.slice(0, 10);
- if (buckets.has(date)) buckets.set(date, (buckets.get(date) ?? 0) + 1);
+ const bucket = buckets.get(event.ts.slice(0, 10));
+ if (bucket) {
+ bucket.calls += 1;
+ if (isErrorStatus(event.status)) bucket.errors += 1;
+ if (typeof event.latency_ms === "number") bucket.latencies.push(event.latency_ms);
+ }
+
+ const endpoint = event.endpoint?.trim() || "Unattributed";
+ const endpointEntry = endpointMap.get(endpoint) ?? { calls: 0, errors: 0, latencies: [] };
+ endpointEntry.calls += 1;
+ if (isErrorStatus(event.status)) endpointEntry.errors += 1;
+ if (typeof event.latency_ms === "number") endpointEntry.latencies.push(event.latency_ms);
+ endpointMap.set(endpoint, endpointEntry);
+
+ statusMap.set(event.status, (statusMap.get(event.status) ?? 0) + 1);
}
- const dailyCalls = [...buckets.entries()].map(([date, count]) => ({ date, calls: count }));
- return { totalUsers, activeUsers, revokedUsers, activeKeys, calls, errorRate, p95LatencyMs, dailyCalls };
+ const daily: PartnerDailyStats[] = [...buckets.entries()].map(([date, bucket]) => ({
+ date,
+ calls: bucket.calls,
+ errors: bucket.errors,
+ p95LatencyMs: percentile95(bucket.latencies),
+ }));
+
+ const userDaily: PartnerUserDayStats[] = [...userBuckets.entries()].map(([date, bucket]) => ({
+ date,
+ signIns: bucket.signIns,
+ revocations: bucket.revocations,
+ }));
+
+ const endpoints: PartnerEndpointStats[] = [...endpointMap.entries()]
+ .map(([endpoint, entry]) => ({
+ endpoint,
+ calls: entry.calls,
+ errors: entry.errors,
+ errorRate: entry.calls > 0 ? entry.errors / entry.calls : 0,
+ p95LatencyMs: percentile95(entry.latencies),
+ }))
+ .sort((a, b) => b.calls - a.calls)
+ .slice(0, 10);
+
+ const statuses: PartnerStatusStats[] = [...statusMap.entries()]
+ .map(([status, count]) => ({ status, calls: count }))
+ .sort((a, b) => b.calls - a.calls);
+
+ const recentEvents = [...events]
+ .sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())
+ .slice(0, 15);
+
+ return {
+ totalUsers,
+ activeUsers,
+ revokedUsers,
+ activeKeys,
+ calls,
+ errors,
+ errorRate,
+ p95LatencyMs,
+ daily,
+ userDaily,
+ endpoints,
+ statuses,
+ recentEvents,
+ };
}
export interface PublicPartnerApp {
diff --git a/src/pages/PartnerDashboard.tsx b/src/pages/PartnerDashboard.tsx
index 4dd4926c..9a886e88 100644
--- a/src/pages/PartnerDashboard.tsx
+++ b/src/pages/PartnerDashboard.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { Helmet } from "react-helmet-async";
import { Link } from "react-router-dom";
import { IconLoader2, IconArrowRight, IconKey } from "@tabler/icons-react";
@@ -14,6 +14,7 @@ import { PartnerUsers } from "@/components/partners/PartnerUsers";
import { PartnerKeys } from "@/components/partners/PartnerKeys";
import { PartnerStatsPanel } from "@/components/partners/PartnerStatsPanel";
import { PartnerOnboarding } from "@/components/partners/PartnerOnboarding";
+import { ClientId } from "@/components/partners/ClientId";
import {
getPartnerContext,
listPartnerConsents,
@@ -33,9 +34,14 @@ const EMPTY_STATS: PartnerStats = {
revokedUsers: 0,
activeKeys: 0,
calls: 0,
+ errors: 0,
errorRate: 0,
p95LatencyMs: 0,
- dailyCalls: [],
+ daily: [],
+ userDaily: [],
+ endpoints: [],
+ statuses: [],
+ recentEvents: [],
};
export default function PartnerDashboard() {
@@ -46,9 +52,12 @@ export default function PartnerDashboard() {
const [keys, setKeys] = useState([]);
const [stats, setStats] = useState(EMPTY_STATS);
const [authOpen, setAuthOpen] = useState(false);
+ const hasLoadedRef = useRef(false);
const load = useCallback(async () => {
- setLoading(true);
+ // Only show the full-page loader on the first fetch. Refreshes (e.g. after a
+ // token refresh on tab focus) must not unmount the onboarding wizard.
+ if (!hasLoadedRef.current) setLoading(true);
try {
const ctx = await getPartnerContext();
setContext(ctx);
@@ -63,14 +72,23 @@ export default function PartnerDashboard() {
setStats(summarizeUsage(consentRows, keyRows, usage));
}
} finally {
+ hasLoadedRef.current = true;
setLoading(false);
}
}, []);
+ const userId = user?.id;
+
useEffect(() => {
- if (!authLoading && user) void load();
- if (!authLoading && !user) setLoading(false);
- }, [authLoading, user, load]);
+ if (authLoading) return;
+ if (userId) {
+ // Depend on the id, not the whole user object: Supabase replaces the user
+ // object on TOKEN_REFRESHED (tab focus), which would otherwise re-run this.
+ void load();
+ } else {
+ setLoading(false);
+ }
+ }, [authLoading, userId, load]);
// First-time setup takes over the whole screen — no dashboard chrome.
if (!authLoading && !loading && user && context?.app && !context.app.onboarded_at) {
@@ -178,7 +196,7 @@ export default function PartnerDashboard() {
Stats
-
+
@@ -187,8 +205,14 @@ export default function PartnerDashboard() {
{context.app.description || "No description yet."}
+ {!context.app.supabase_client_id && (
+
+ Your OAuth client hasn't been registered yet. Add at least one redirect URI in the
+ App tab and we'll issue the client id.
+
+ )}
-
+
} />
-
+
-
+
-
+
-
+
@@ -247,11 +271,11 @@ function Metric({ label, value }: { label: string; value: string }) {
);
}
-function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
+function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
-
- {label}
- - {value}
+ - {label}
+ - {value}
);
}