From 9c9ccaa2968f554747a32beba51944de573b0cbc Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 24 Aug 2026 17:16:07 -0400 Subject: [PATCH 1/2] feat(usage): live OpenMeter usage and signed-ticket calls Session-bound account-usage and account-requests BFFs drive Usage, the sidebar meter, Home consume panel, and /calls. Plan/wallet embeds stay out until those PRs land. --- app/(app)/usage/page.tsx | 29 +- app/api/pymthouse/account-requests/route.ts | 36 + app/api/pymthouse/account-usage/route.ts | 50 ++ components/console/CallsView.tsx | 103 ++- components/console/ConsumedAppsPanel.tsx | 223 +++-- components/console/SidebarUsageCard.tsx | 98 ++- components/console/StackedAreaChart.tsx | 404 +++++++-- components/console/UsageView.tsx | 929 +++++++++++--------- lib/console/account-usage-payload.test.ts | 137 +++ lib/console/account-usage-payload.ts | 97 ++ lib/console/account-usage.ts | 67 ++ lib/console/org-consumption.ts | 181 ++-- lib/console/pymthouse-bff.ts | 246 +++++- lib/console/signed-ticket-activity.ts | 42 + lib/console/usage-capability-display.ts | 175 ++++ lib/console/useAccountRequests.ts | 86 ++ lib/console/useAccountUsage.ts | 92 ++ 17 files changed, 2275 insertions(+), 720 deletions(-) create mode 100644 app/api/pymthouse/account-requests/route.ts create mode 100644 app/api/pymthouse/account-usage/route.ts create mode 100644 lib/console/account-usage-payload.test.ts create mode 100644 lib/console/account-usage-payload.ts create mode 100644 lib/console/account-usage.ts create mode 100644 lib/console/signed-ticket-activity.ts create mode 100644 lib/console/usage-capability-display.ts create mode 100644 lib/console/useAccountRequests.ts create mode 100644 lib/console/useAccountUsage.ts diff --git a/app/(app)/usage/page.tsx b/app/(app)/usage/page.tsx index 02695ca..6369991 100644 --- a/app/(app)/usage/page.tsx +++ b/app/(app)/usage/page.tsx @@ -1,13 +1,9 @@ "use client"; -import { Suspense, useState } from "react"; +import { Suspense } from "react"; import Link from "next/link"; import { BarChart3, Box, ChevronDown } from "lucide-react"; import { useAuth } from "@/components/console/AuthContext"; -import { useEnvironment } from "@/components/console/EnvironmentContext"; -import EnvironmentFilter, { - ALL_ENVIRONMENTS as ALL, -} from "@/components/console/EnvironmentFilter"; import ConsolePageHeader from "@/components/console/ConsolePageHeader"; import ConsolePageSkeleton from "@/components/console/ConsolePageSkeleton"; import SignInWall from "@/components/console/SignInWall"; @@ -23,31 +19,26 @@ export default function UsagePage() { function UsageContent() { const { isConnected, isLoading } = useAuth(); - const { environments } = useEnvironment(); - const [envFilter, setEnvFilter] = useState(ALL); // Avoid flashing the wall while auth hydrates. if (isLoading) return null; - if (!isConnected) return ; - const selected = environments.find((e) => e.id === envFilter); - // Consumption split: production carries the bulk, development the rest. - const weight = - envFilter === ALL ? 1 : selected?.kind === "production" ? 0.91 : 0.09; - const filterName = - envFilter === ALL - ? "all environments" - : (selected?.name ?? "all environments"); + // Workspace-only route — logged-out users see "Usage is workspace-only" + // wall in place of the console. The previous behavior (a hard redirect + // to /login) was wrong per the v4 prototype: it dropped the + // user out of context. The wall keeps them inside the app shell, leaves + // the sidebar in its logged-out variant, and offers an explicit + // "Explore capabilities" escape hatch. + if (!isConnected) return ; return (
-
); diff --git a/app/api/pymthouse/account-requests/route.ts b/app/api/pymthouse/account-requests/route.ts new file mode 100644 index 0000000..4d35ea3 --- /dev/null +++ b/app/api/pymthouse/account-requests/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { fetchAccountRequestsForExternalUser } from "@/lib/console/pymthouse-bff"; +import { requireConsoleSession } from "@/lib/console/session-user"; +import { + PYMTHOUSE_NO_STORE_HEADERS, + pymthouseErrorResponse, +} from "@/app/api/pymthouse/route-helpers"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + const cursor = + request.nextUrl.searchParams.get("cursor")?.trim() || undefined; + const limitRaw = request.nextUrl.searchParams.get("limit"); + const limit = limitRaw ? Number.parseInt(limitRaw, 10) : 50; + if (!Number.isFinite(limit) || limit < 1 || limit > 50) { + return NextResponse.json( + { error: "limit must be between 1 and 50" }, + { status: 400, headers: PYMTHOUSE_NO_STORE_HEADERS } + ); + } + + try { + const session = await requireConsoleSession(); + const payload = await fetchAccountRequestsForExternalUser({ + externalUserId: session.externalUserId, + email: session.email, + cursor, + limit, + }); + return NextResponse.json(payload, { headers: PYMTHOUSE_NO_STORE_HEADERS }); + } catch (error) { + return pymthouseErrorResponse(error, "Requests fetch failed"); + } +} diff --git a/app/api/pymthouse/account-usage/route.ts b/app/api/pymthouse/account-usage/route.ts new file mode 100644 index 0000000..fb6938d --- /dev/null +++ b/app/api/pymthouse/account-usage/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { fetchAccountUsageForExternalUser } from "@/lib/console/pymthouse-bff"; +import { requireConsoleSession } from "@/lib/console/session-user"; +import { + PYMTHOUSE_NO_STORE_HEADERS, + pymthouseErrorResponse, +} from "@/app/api/pymthouse/route-helpers"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + const windowRaw = request.nextUrl.searchParams + .get("window") + ?.trim() + .toLowerCase(); + const window = + windowRaw === "mtd" || windowRaw === "rolling" ? windowRaw : "rolling"; + + const rawDays = request.nextUrl.searchParams.get("days"); + const periodDays = rawDays ? Number.parseInt(rawDays, 10) : 30; + if ( + window === "rolling" && + (!Number.isFinite(periodDays) || periodDays < 1 || periodDays > 90) + ) { + return NextResponse.json( + { error: "days must be between 1 and 90" }, + { status: 400, headers: PYMTHOUSE_NO_STORE_HEADERS } + ); + } + + const includePriorRaw = request.nextUrl.searchParams.get("includePrior"); + const includePrior = + includePriorRaw == null + ? true + : !["0", "false", "no"].includes(includePriorRaw.toLowerCase()); + + try { + const session = await requireConsoleSession(); + const payload = await fetchAccountUsageForExternalUser({ + externalUserId: session.externalUserId, + periodDays, + window, + includePrior, + }); + return NextResponse.json(payload, { headers: PYMTHOUSE_NO_STORE_HEADERS }); + } catch (error) { + return pymthouseErrorResponse(error, "Usage fetch failed"); + } +} diff --git a/components/console/CallsView.tsx b/components/console/CallsView.tsx index e9406f0..73d7767 100644 --- a/components/console/CallsView.tsx +++ b/components/console/CallsView.tsx @@ -9,10 +9,8 @@ import CallDetailDrawer from "@/components/console/CallDetailDrawer"; import EnvironmentFilter, { ALL_ENVIRONMENTS, } from "@/components/console/EnvironmentFilter"; -import { - recentRequestsForEnvironment, - MOCK_RECENT_REQUESTS, -} from "@/lib/console/mock-data"; +import { useAuth } from "@/components/console/AuthContext"; +import { useAccountRequests } from "@/lib/console/useAccountRequests"; import type { AccountActivityRow } from "@/lib/console/types"; type KindFilter = "all" | "batch" | "live"; @@ -23,14 +21,13 @@ const KIND_TABS: { key: KindFilter; label: string }[] = [ { key: "batch", label: "Batch" }, ]; +const EMPTY_ROWS: AccountActivityRow[] = []; + /** - * CallsView — the standalone /calls list: every call this organization made - * across the network (what counts toward its usage). A Batch / Live segmented - * filter splits the two invocation shapes the Runner SDK exposes — batch - * `predict` request/response vs live streaming `session` — and the table's - * metric column follows suit (latency for batch, session duration for live). - * Clicking a row opens the per-call inspector (a right-side drawer) via - * `?request={id}` — useSearchParams needs the Suspense boundary below. + * CallsView — the standalone /calls list: every signed-ticket request this + * account made (PymtHouse OpenMeter history). A Batch / Live segmented filter + * splits invocation shapes inferred from pipeline. Clicking a row opens the + * per-call inspector via `?request={id}`. */ export default function CallsView() { return ( @@ -41,18 +38,20 @@ export default function CallsView() { } function CallsViewInner() { + const { isConnected } = useAuth(); + const requests = useAccountRequests(isConnected); const [query, setQuery] = useState(""); const [envFilter, setEnvFilter] = useState(ALL_ENVIRONMENTS); const [kind, setKind] = useState("all"); - // The open call is URL-addressable (`/calls?request={id}`) so the inspector - // is deep-linkable and the back button closes it. `shownRow` is held through - // the close transition so the drawer animates out with its content intact. const router = useRouter(); const searchParams = useSearchParams(); const requestId = searchParams.get("request"); + + const allRows = requests.status === "ready" ? requests.rows : EMPTY_ROWS; + const openCall = requestId - ? (MOCK_RECENT_REQUESTS.find((r) => r.id === requestId) ?? null) + ? (allRows.find((r) => r.id === requestId) ?? null) : null; const [shownRow, setShownRow] = useState(null); useEffect(() => { @@ -61,11 +60,10 @@ function CallsViewInner() { const allEnvs = envFilter === ALL_ENVIRONMENTS; - // Env-scoped set drives the segmented-filter counts (before the kind filter). const envScoped = useMemo( () => - allEnvs ? MOCK_RECENT_REQUESTS : recentRequestsForEnvironment(envFilter), - [allEnvs, envFilter] + allEnvs ? allRows : allRows.filter((r) => r.environmentId === envFilter), + [allEnvs, allRows, envFilter] ); const counts = useMemo( () => ({ @@ -88,8 +86,6 @@ function CallsViewInner() { r.pipeline.toLowerCase().includes(q) ); } - // Live, in-progress sessions float to the top — they're happening now. - // (Array.sort is stable, so terminal rows keep their newest-first order.) return [...scoped].sort( (a, b) => (a.status === "active" ? 0 : 1) - (b.status === "active" ? 0 : 1) @@ -115,7 +111,6 @@ function CallsViewInner() { } /> - {/* Filter bar — Batch / Live segmented control + search. */}
- {/* Calls list — shared `CallsTable` (cozy density for the full-bleed view) */} - {rows.length === 0 ? ( + {requests.status === "loading" || requests.status === "idle" ? ( +