Skip to content

Commit 855f7f4

Browse files
dashboard: consume /billing/usage + /team/summary (§10.20) (#33)
Retires the client-side Usage-panel aggregation (resources → useMemo) in favour of the new server-side cached aggregates. The dashboard no longer pulls the full resource list just to compute six numbers; the agent API caches the rollup per team in Redis (30s for billing/usage, 5min for team/summary) and shares it across surfaces. Per the §13 freshness rule, the eventual-consistency tradeoff is now visible to users — the Usage panel renders an "as of Ns ago · cached 30s" footnote so they can tell whether a freshly-provisioned resource should already appear in the figures. api/index.ts — adds fetchBillingUsage() + fetchTeamSummary() typed wrappers, including BillingUsage / TeamSummary / UsageMetric / TeamSummaryCounts types. BillingPage.tsx — replaces the resources useMemo aggregate with the server payload. The Usage panel still renders the same six rows; values now come from `billingUsage.usage.<service>.bytes` (or `.count`) and the seats row finally reads a real member count (closes the §10.7 gap for the Usage panel). Adds formatAsOf() helper + data-testid `billing-usage-as-of` for the eventual-consistency footnote. AppShell.tsx — extends SidebarUpgradeCard to fetch /team/summary once per session-load (5m Redis cache + browser Cache-Control makes this cheap) and render a "N resources · M members" line on paid-tier cards. Skipped for anonymous/free where the card is the upgrade CTA only. Tests: 164 pass, 3 skipped (matches main). New §10.20 coverage in BillingPage.test.tsx asserts: - listResources() is NOT called for usage data (regression guard) - fetchBillingUsage() is called exactly once - the `as_of` footnote renders when the cached payload arrives - zero-usage server response renders zeroes in every row - server bytes → MB conversion (100 MB Postgres → "100 / 1 GB") Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2782f19 commit 855f7f4

4 files changed

Lines changed: 274 additions & 71 deletions

File tree

src/api/index.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,3 +668,77 @@ export type ClaimResp = {
668668
export async function claim(body: { jwt: string; email: string }): Promise<ClaimResp> {
669669
return call('/claim', { method: 'POST', body: JSON.stringify(body) })
670670
}
671+
672+
// ─── §10.20 cached aggregates (LIVE) ────────────────────────────────────
673+
//
674+
// Two server-side cached endpoints replace what the dashboard previously
675+
// computed client-side:
676+
//
677+
// fetchBillingUsage() → GET /api/v1/billing/usage (Redis-cached 30s)
678+
// fetchTeamSummary() → GET /api/v1/team/summary (Redis-cached 5m)
679+
//
680+
// Both responses carry `as_of` (ISO timestamp) + `freshness_seconds` so the
681+
// UI can render an "as of Ns ago" footnote that makes the eventual-
682+
// consistency tradeoff visible to the user (per §13).
683+
//
684+
// These are pure GETs — safe to call on render. The agent API sets
685+
// Cache-Control: private, max-age=N so the browser also caches the
686+
// response within the same window (no double-fetch on remount).
687+
688+
/** Per-metric shape inside `usage` — bytes/limit_bytes for storage services,
689+
* count/limit for everything else. -1 means unlimited. */
690+
export type UsageMetric = {
691+
bytes?: number
692+
limit_bytes?: number
693+
count?: number
694+
limit?: number
695+
}
696+
697+
export type BillingUsage = {
698+
ok: true
699+
freshness_seconds: number
700+
/** ISO-8601 UTC timestamp of when the server computed this snapshot. */
701+
as_of: string
702+
usage: {
703+
postgres: UsageMetric
704+
redis: UsageMetric
705+
mongodb: UsageMetric
706+
deployments: UsageMetric
707+
webhooks: UsageMetric
708+
vault: UsageMetric
709+
members: UsageMetric
710+
}
711+
}
712+
713+
export async function fetchBillingUsage(): Promise<BillingUsage> {
714+
return call<BillingUsage>('/api/v1/billing/usage')
715+
}
716+
717+
export type TeamSummaryCounts = {
718+
resources: {
719+
total: number
720+
postgres: number
721+
redis: number
722+
mongodb: number
723+
webhook: number
724+
queue: number
725+
storage: number
726+
other: number
727+
}
728+
deployments: number
729+
members: number
730+
vault_keys: number
731+
}
732+
733+
export type TeamSummary = {
734+
ok: true
735+
freshness_seconds: number
736+
/** ISO-8601 UTC timestamp of when the server computed this snapshot. */
737+
as_of: string
738+
tier: string
739+
counts: TeamSummaryCounts
740+
}
741+
742+
export async function fetchTeamSummary(): Promise<TeamSummary> {
743+
return call<TeamSummary>('/api/v1/team/summary')
744+
}

src/layout/AppShell.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'
22
import { Brand, ExpiryWarningBanner, ScopePill, useExpiryTick } from '../components/Common'
3-
import { useState, type ReactNode } from 'react'
3+
import { useEffect, useState, type ReactNode } from 'react'
44
import { addEnv, setEnv, useDashboardCtx, type DashboardCtx } from '../hooks/useDashboardCtx'
5+
import * as api from '../api'
6+
import type { TeamSummary } from '../api'
57

68
type Scope = 'read' | 'write' | 'agent'
79

@@ -266,6 +268,26 @@ function SidebarUpgradeCard({ ctx, now }: { ctx: DashboardCtx; now: number }) {
266268
const billing = ctx.billing
267269
const loading = ctx.billingLoading
268270

271+
// §10.20: cached team summary feeds the resource/member counts that
272+
// previously didn't render (the dashboard ctx's `counts` field had no
273+
// live source for `team` — see useDashboardCtx.refreshCounts which
274+
// hardcodes `team: 1`). One fetch per session-load amortises against
275+
// every authenticated page render thanks to the server-side 5-min
276+
// Redis cache + browser Cache-Control: max-age=300.
277+
const [summary, setSummary] = useState<TeamSummary | null>(null)
278+
useEffect(() => {
279+
// Skip the fetch on anon/free where the card never renders counts
280+
// (the upgrade CTA below is the only render path). Also skip when
281+
// there's no team_id yet — the request would 401 anyway.
282+
if (!ctx.me?.team?.id) return
283+
if (tier === 'anonymous' || tier === 'free') return
284+
let alive = true
285+
api.fetchTeamSummary()
286+
.then((s) => { if (alive) setSummary(s) })
287+
.catch(() => { /* sidebar count rendering is non-critical */ })
288+
return () => { alive = false }
289+
}, [ctx.me?.team?.id, tier])
290+
269291
// Loading state — render a quiet skeleton instead of stale fixture text.
270292
if (loading && !billing) {
271293
return (
@@ -304,6 +326,14 @@ function SidebarUpgradeCard({ ctx, now }: { ctx: DashboardCtx; now: number }) {
304326
? `${billing.payment_network.toLowerCase()} · ****${billing.payment_last4}`
305327
: null
306328

329+
// §10.20: live resource + member counts from the cached team summary.
330+
// Hidden until the fetch resolves so we never render "0 resources · 0
331+
// members" misleadingly during load.
332+
const counts = summary?.counts
333+
const countsLine = counts
334+
? `${counts.resources.total} resource${counts.resources.total === 1 ? '' : 's'} · ${counts.members} member${counts.members === 1 ? '' : 's'}`
335+
: null
336+
307337
return (
308338
<div className="upgrade-card" data-testid="sidebar-upgrade-card-paid">
309339
<div className="label">{tier ? `→ ${tier} · live` : ''}</div>
@@ -315,6 +345,18 @@ function SidebarUpgradeCard({ ctx, now }: { ctx: DashboardCtx; now: number }) {
315345
</>
316346
) : null}
317347
{paymentHint ? <span className="dim">{paymentHint}</span> : null}
348+
{countsLine ? (
349+
<>
350+
<br />
351+
<span
352+
className="dim"
353+
data-testid="sidebar-team-counts"
354+
style={{ fontSize: 10.5, opacity: 0.75 }}
355+
>
356+
{countsLine}
357+
</span>
358+
</>
359+
) : null}
318360
</div>
319361
<Link to="/app/billing" className="cta" data-testid="sidebar-manage-plan">
320362
Manage plan →

src/pages/BillingPage.test.tsx

Lines changed: 87 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ vi.mock('../api', async () => {
6868
fetchBilling: vi.fn(),
6969
listInvoices: vi.fn(),
7070
listResources: vi.fn(),
71+
// §10.20: BillingPage's Usage panel now reads fetchBillingUsage() (a
72+
// server-side cached aggregate) instead of listResources(). The
73+
// listResources mock above stays in the module-level mock for the
74+
// pre-§10.20 tests that still reference it; new tests should drive
75+
// fetchBillingUsage.
76+
fetchBillingUsage: vi.fn(),
7177
createCheckout: vi.fn(),
7278
cancelSubscription: vi.fn(),
7379
}
@@ -104,13 +110,45 @@ function mockHappyBilling() {
104110
ok: true,
105111
invoices: FIXTURE_INVOICES,
106112
})
107-
// Default: no resources → Usage panel renders zeroes. Tests that care
108-
// about specific usage figures override this themselves.
113+
// Pre-§10.20 tests still mock listResources; new code path doesn't
114+
// call it, so this resolves to an unused empty list.
109115
;(api.listResources as any).mockResolvedValue({
110116
ok: true,
111117
items: [],
112118
total: 0,
113119
})
120+
// §10.20: default zero-usage server response. Tests that pin specific
121+
// usage figures override this with a payload carrying real bytes/counts.
122+
;(api.fetchBillingUsage as any).mockResolvedValue(makeUsageResp({}))
123+
}
124+
125+
/** §10.20 test helper — build a BillingUsage response with optional overrides
126+
* per metric. Unspecified metrics default to {bytes:0, limit_bytes:-1} or
127+
* {count:0, limit:-1} matching the server's "no row" shape. */
128+
function makeUsageResp(over: Partial<{
129+
postgres_bytes: number
130+
redis_bytes: number
131+
mongodb_bytes: number
132+
deployments: number
133+
webhooks: number
134+
vault: number
135+
members: number
136+
}>) {
137+
return {
138+
ok: true,
139+
freshness_seconds: 30,
140+
// Pin as_of so the "as of Ns ago" footnote renders deterministically.
141+
as_of: new Date(Date.now() - 5000).toISOString(),
142+
usage: {
143+
postgres: { bytes: over.postgres_bytes ?? 0, limit_bytes: 1024 * 1024 * 1024 },
144+
redis: { bytes: over.redis_bytes ?? 0, limit_bytes: 50 * 1024 * 1024 },
145+
mongodb: { bytes: over.mongodb_bytes ?? 0, limit_bytes: 100 * 1024 * 1024 },
146+
deployments: { count: over.deployments ?? 0, limit: 1 },
147+
webhooks: { count: over.webhooks ?? 0, limit: 1000 },
148+
vault: { count: over.vault ?? 0, limit: 20 },
149+
members: { count: over.members ?? 1, limit: 1 },
150+
},
151+
}
114152
}
115153

116154
/** Wait for the page to finish its initial load (skeleton → real content). */
@@ -180,7 +218,7 @@ describe('BillingPage — backend-down error state (§10.21)', () => {
180218
it('renders the billing-error banner when fetchBilling rejects (no fixture fallback)', async () => {
181219
;(api.fetchBilling as any).mockRejectedValue(Object.assign(new Error('Razorpay is not configured'), { status: 503 }))
182220
;(api.listInvoices as any).mockResolvedValue({ ok: true, invoices: [] })
183-
;(api.listResources as any).mockResolvedValue({ ok: true, items: [], total: 0 })
221+
;(api.fetchBillingUsage as any).mockResolvedValue(makeUsageResp({}))
184222
render(<BillingPage />)
185223
await waitFor(() => {
186224
expect(screen.getByTestId('billing-error')).toBeTruthy()
@@ -193,7 +231,7 @@ describe('BillingPage — backend-down error state (§10.21)', () => {
193231
it('surfaces the error message on the billing-error banner', async () => {
194232
;(api.fetchBilling as any).mockRejectedValue(new Error('Razorpay is not configured'))
195233
;(api.listInvoices as any).mockResolvedValue({ ok: true, invoices: [] })
196-
;(api.listResources as any).mockResolvedValue({ ok: true, items: [], total: 0 })
234+
;(api.fetchBillingUsage as any).mockResolvedValue(makeUsageResp({}))
197235
const { container } = render(<BillingPage />)
198236
await waitFor(() => {
199237
expect(screen.getByTestId('billing-error')).toBeTruthy()
@@ -208,6 +246,9 @@ describe('BillingPage — initial render', () => {
208246
;(api.fetchBilling as any).mockReturnValue(new Promise(() => {})) // never resolves
209247
;(api.listInvoices as any).mockReturnValue(new Promise(() => {}))
210248
;(api.listResources as any).mockReturnValue(new Promise(() => {}))
249+
// §10.20: BillingPage calls fetchBillingUsage now; it must return a
250+
// pending promise (never resolves) so the skeleton state holds.
251+
;(api.fetchBillingUsage as any).mockReturnValue(new Promise(() => {}))
211252
const { container } = render(<BillingPage />)
212253
expect(container.querySelector('.skel')).toBeTruthy()
213254
})
@@ -453,61 +494,38 @@ describe('BillingPage — userEvent integration', () => {
453494
})
454495
})
455496

456-
// ─── Usage panel — real data from listResources() (§10.1) ───────────────
457-
// The old Usage panel hardcoded "47 / 500", "163 / 256", "1.64 / 2 GB", etc.
458-
// We now aggregate ctx.resources by type. These tests pin the contract:
459-
// (a) values move when listResources moves,
460-
// (b) the old fixture numbers no longer appear in the DOM.
461-
describe('BillingPage — Usage panel reflects listResources()', () => {
462-
// Minimal Resource fixture factory — keeps the test contained.
463-
function makePgResource(id: string, mb: number) {
464-
return {
465-
id,
466-
token: id,
467-
resource_type: 'postgres',
468-
tier: 'hobby',
469-
status: 'active',
470-
name: id,
471-
env: 'production',
472-
storage_bytes: mb * 1024 * 1024,
473-
storage_limit_bytes: 1024 * 1024 * 1024,
474-
storage_exceeded: false,
475-
expires_at: null,
476-
created_at: new Date().toISOString(),
477-
}
478-
}
479-
480-
it('aggregates two postgres resources totalling 100 MB into one UsageRow', async () => {
497+
// ─── Usage panel — server-side cached aggregate (§10.20) ────────────────
498+
// The Usage panel reads /api/v1/billing/usage (cached 30s in Redis with
499+
// singleflight on the server). These tests pin the contract:
500+
// (a) values reflect the server response, not a client-side aggregate,
501+
// (b) BillingPage does NOT call listResources() for usage data,
502+
// (c) the `as_of` footnote renders so the eventual-consistency tradeoff
503+
// is visible to users.
504+
describe('BillingPage — Usage panel reflects fetchBillingUsage() (§10.20)', () => {
505+
it('renders postgres bytes (100 MB / 1 GB) from the server response', async () => {
481506
mockTier = 'hobby'
482507
mockHappyBilling()
483-
;(api.listResources as any).mockResolvedValue({
484-
ok: true,
485-
items: [makePgResource('p_a', 40), makePgResource('p_b', 60)],
486-
total: 2,
487-
})
508+
;(api.fetchBillingUsage as any).mockResolvedValue(makeUsageResp({
509+
postgres_bytes: 100 * 1024 * 1024,
510+
}))
488511
const { container } = render(<BillingPage />)
489512
await waitForLoaded()
490-
// hobby postgres limit is 1024 MB → renders as "1 GB".
491513
await waitFor(() => {
492514
const text = container.textContent ?? ''
493515
expect(text).toContain('100')
494516
expect(text).toContain('1 GB')
495517
})
496518
})
497519

498-
it('renders 0 for the resource-driven UsageRows when the resource list is empty', async () => {
520+
it('renders zeroes when the server reports no usage', async () => {
499521
mockTier = 'hobby'
500522
mockHappyBilling()
501-
;(api.listResources as any).mockResolvedValue({ ok: true, items: [], total: 0 })
502523
const { container } = render(<BillingPage />)
503524
await waitForLoaded()
504525
await waitFor(() => {
505526
const rows = container.querySelectorAll('.usage-row')
506527
// 6 usage rows: postgres, redis, mongo, deployments, webhooks, team seats.
507528
expect(rows.length).toBe(6)
508-
// Resource-aggregated rows (postgres / redis / mongo / deployments /
509-
// webhooks) must read "0 / …" when the list is empty. Team seats is a
510-
// separate constant for now (no member-list endpoint) and is exempt.
511529
const resourceRowKeys = ['postgres', 'redis', 'mongo', 'deployments', 'webhooks']
512530
resourceRowKeys.forEach((key) => {
513531
const row = Array.from(rows).find((r) => r.querySelector('.k')?.textContent === key)
@@ -525,6 +543,35 @@ describe('BillingPage — Usage panel reflects listResources()', () => {
525543
await waitForLoaded()
526544
expect(container.textContent).not.toMatch(/\b47\b/)
527545
})
546+
547+
// §10.20 / §14: critical contract — the page must NOT round-trip to
548+
// /resources for usage data anymore. Catches accidental reintroductions
549+
// of the client-side aggregate.
550+
it('does not call listResources() for usage data', async () => {
551+
mockTier = 'hobby'
552+
mockHappyBilling()
553+
render(<BillingPage />)
554+
await waitForLoaded()
555+
// Wait a tick to make sure any in-flight effect has a chance to fire.
556+
await new Promise((r) => setTimeout(r, 50))
557+
expect((api.listResources as any).mock?.calls?.length ?? 0).toBe(0)
558+
// The new cached aggregate, on the other hand, must be called exactly once.
559+
expect((api.fetchBillingUsage as any).mock?.calls?.length ?? 0).toBe(1)
560+
})
561+
562+
// §10.20 / §13: the eventual-consistency footnote must render so users
563+
// can see when the snapshot was computed.
564+
it('renders the "as of Ns ago" footnote when the cached payload arrives', async () => {
565+
mockTier = 'hobby'
566+
mockHappyBilling()
567+
const { getByTestId } = render(<BillingPage />)
568+
await waitForLoaded()
569+
await waitFor(() => {
570+
const footnote = getByTestId('billing-usage-as-of')
571+
expect(footnote.textContent).toMatch(/as of/)
572+
expect(footnote.textContent).toMatch(/cached 30s/)
573+
})
574+
})
528575
})
529576

530577
// ─── §10.8 cleanups: card expiry leak, invoice status, update mailto ────

0 commit comments

Comments
 (0)