diff --git a/.env.example b/.env.example index afec430..62c2d42 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,9 @@ THEGRAPH_API_KEY= # Auth0 (Regular Web Application). Generate AUTH0_SECRET with: # openssl rand -hex 32 AUTH0_SECRET= -# Local only. On Vercel Preview this is overridden from VERCEL_BRANCH_URL. -# On Production set it to the stable origin (https://your-domain). +# Local only. Omit on Vercel Preview so Auth0 infers the request host +# (branch alias and unique deployment URLs both work). On Production set +# it to the stable origin (https://your-domain). APP_BASE_URL=http://localhost:3000 # Tenant host only — no https:// (e.g. your-tenant.us.auth0.com) AUTH0_DOMAIN= 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" ? ( +