Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
29 changes: 10 additions & 19 deletions app/(app)/usage/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 <SignInWall route="usage" />;

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 <SignInWall route="usage" />;

return (
<main id="main-content" className="flex flex-1 flex-col bg-dark">
<ConsolePageHeader
title="Usage"
icon={BarChart3}
description="Requests, latency, errors, and spend across your API tokens."
description="Signed requests, network cost, and prepaid balance usage from PymtHouse OpenMeter."
actions={
<>
<EnvironmentFilter value={envFilter} onChange={setEnvFilter} />
<button
type="button"
className="inline-flex h-[26px] items-center gap-1.5 rounded-[4px] border border-transparent px-2.5 text-[12.5px] text-fg-strong transition-colors hover:border-hairline hover:bg-hover hover:text-fg"
Expand All @@ -67,7 +58,7 @@ function UsageContent() {
}
/>
<div className="flex flex-1 flex-col overflow-y-auto">
<UsageView weight={weight} filterName={filterName} />
<UsageView />
</div>
</main>
);
Expand Down
36 changes: 36 additions & 0 deletions app/api/pymthouse/account-requests/route.ts
Original file line number Diff line number Diff line change
@@ -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");
}
}
50 changes: 50 additions & 0 deletions app/api/pymthouse/account-usage/route.ts
Original file line number Diff line number Diff line change
@@ -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");
}
}
103 changes: 72 additions & 31 deletions components/console/CallsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
Expand All @@ -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<KindFilter>("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<AccountActivityRow | null>(null);
useEffect(() => {
Expand All @@ -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(
() => ({
Expand All @@ -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)
Expand All @@ -115,7 +111,6 @@ function CallsViewInner() {
}
/>

{/* Filter bar — Batch / Live segmented control + search. */}
<div className="flex flex-wrap items-center gap-2 border-b border-hairline bg-dark px-5 py-2.5">
<div
className="inline-flex items-center rounded-[5px] border border-hairline bg-dark-card p-0.5"
Expand Down Expand Up @@ -159,23 +154,69 @@ function CallsViewInner() {
</div>
</div>

{/* Calls list — shared `CallsTable` (cozy density for the full-bleed view) */}
{rows.length === 0 ? (
{requests.status === "loading" || requests.status === "idle" ? (
<div className="space-y-0 px-5 py-4" aria-hidden="true">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="flex animate-pulse items-center justify-between border-t border-hairline py-3 first:border-t-0"
>
<div className="h-4 w-48 rounded bg-tint" />
<div className="h-3 w-16 rounded bg-tint" />
</div>
))}
</div>
) : requests.status === "error" ? (
<div className="px-5 py-16 text-center">
<p className="text-[13px] text-fg-muted">
Could not load signed-ticket requests.
</p>
<p className="mt-2 font-mono text-[11px] text-fg-faint">
{requests.message}
</p>
<button
type="button"
onClick={() => void requests.reload()}
className="mt-4 font-mono text-[11.5px] uppercase tracking-[0.04em] text-fg-faint transition-colors hover:text-fg"
>
Retry
</button>
</div>
) : !requests.openMeterConfigured ? (
<div className="px-5 py-16 text-center">
<p className="text-[13px] text-fg-faint">
OpenMeter is not configured, so per-request history is unavailable.
</p>
</div>
) : rows.length === 0 ? (
<div className="px-5 py-16 text-center">
<p className="text-[13px] text-fg-faint">
{query
? `No calls match “${query}”`
: `No ${kind === "all" ? "" : kind + " "}calls in this view`}
: `No ${kind === "all" ? "" : kind + " "}signed-ticket requests this billing cycle`}
</p>
</div>
) : (
<CallsTable
rows={rows}
showHeader
bordered={false}
density="cozy"
showEnvironment={allEnvs}
/>
<>
<CallsTable
rows={rows}
showHeader
bordered={false}
density="cozy"
showEnvironment={allEnvs}
/>
{requests.nextCursor ? (
<div className="flex justify-center border-t border-hairline px-5 py-4">
<button
type="button"
onClick={() => void requests.loadMore()}
className="font-mono text-[11.5px] uppercase tracking-[0.04em] text-fg-faint transition-colors hover:text-fg"
>
Load more
</button>
</div>
) : null}
</>
)}

<CallDetailDrawer
Expand Down
Loading