diff --git a/src/api/index.ts b/src/api/index.ts index 991ce26..04295ab 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1081,3 +1081,25 @@ export type TeamSummary = { export async function fetchTeamSummary(): Promise { return call('/api/v1/team/summary') } + +// ─── Usage wall (Track U1) ────────────────────────────────────────────── +// GET /api/v1/usage/wall — most recent near_quota_wall row for the +// caller's team within the last 24h. Drives the QuotaWallBanner upgrade +// nudge. When near_wall=false the response carries only `{ok, near_wall}`; +// when true the metadata fields (tier/axis/service/current/limit/ +// percent_used/at) are flattened in alongside ok/near_wall. +export type QuotaWallResponse = { + ok: true + near_wall: boolean + tier?: string + axis?: 'storage' | 'connections' | 'provisions' + service?: string + current?: number + limit?: number + percent_used?: number + at?: string +} + +export async function fetchQuotaWall(): Promise { + return call('/api/v1/usage/wall') +} diff --git a/src/components/QuotaWallBanner.test.tsx b/src/components/QuotaWallBanner.test.tsx new file mode 100644 index 0000000..0852aa9 --- /dev/null +++ b/src/components/QuotaWallBanner.test.tsx @@ -0,0 +1,156 @@ +// QuotaWallBanner.test.tsx — Track U1 unit tests. +// +// Three lifecycle moments worth verifying without spinning up a full +// fetch loop: +// 1. With near_wall=true → banner renders, copy is correct. +// 2. With dismiss state at the current percent → banner stays hidden. +// 3. With dismiss state and percent climbed +5pp → banner reappears. +// +// shouldRender is exported as a pure function precisely so these tests +// can exercise the decision logic directly. We also do one rendering +// test to confirm the JSX wires up testids and the dismiss writes to +// localStorage. + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { QuotaWallBanner, shouldRender } from './QuotaWallBanner' +import type { QuotaWallResponse } from '../api' + +const fakeWall: QuotaWallResponse = { + ok: true, + near_wall: true, + tier: 'hobby', + axis: 'storage', + service: 'postgres', + current: 471859200, + limit: 536870912, + percent_used: 87, + at: '2026-05-12T11:02:00Z', +} + +describe('shouldRender', () => { + it('returns false when there is no wall payload', () => { + expect(shouldRender(null, null)).toBe(false) + }) + + it('returns false when near_wall is false', () => { + expect(shouldRender({ ok: true, near_wall: false }, null)).toBe(false) + }) + + it('returns true when near_wall is true and no dismiss state exists', () => { + expect(shouldRender(fakeWall, null)).toBe(true) + }) + + it('returns false when dismissed at the same percent the response reports', () => { + expect( + shouldRender(fakeWall, { percent: 87, at: '2026-05-12T11:05:00Z' }), + ).toBe(false) + }) + + it('returns false when usage climbed less than 5pp after dismiss', () => { + // Dismissed at 85, current 87 → +2pp → still hidden. + expect( + shouldRender({ ...fakeWall, percent_used: 87 }, { percent: 85, at: '2026-05-12T11:05:00Z' }), + ).toBe(false) + }) + + it('returns true when usage climbed 5pp or more after dismiss', () => { + // Dismissed at 82, current 87 → +5pp → reappears. + expect( + shouldRender({ ...fakeWall, percent_used: 87 }, { percent: 82, at: '2026-05-12T11:05:00Z' }), + ).toBe(true) + }) +}) + +describe('QuotaWallBanner rendering', () => { + beforeEach(() => { + // Reset localStorage between tests so dismiss state doesn't leak. + if (typeof localStorage !== 'undefined') localStorage.clear() + // Suppress real fetch — disablePolling covers the interval, but the + // initial render's fetch path can also fire when initialWall is + // undefined. We pass initialWall in every test below to keep it + // hermetic. + }) + + it('renders the banner copy with axis-aware text when near_wall=true', () => { + render( + , + ) + const banner = screen.getByTestId('quota-wall-banner') + expect(banner).toBeTruthy() + expect(banner.textContent ?? '').toContain('87%') + expect(banner.textContent ?? '').toContain('hobby') + expect(banner.textContent ?? '').toContain('postgres') + expect(screen.getByTestId('quota-wall-upgrade').getAttribute('href')).toBe('/app/billing') + }) + + it('hides the banner after dismiss is clicked', () => { + render( + , + ) + expect(screen.queryByTestId('quota-wall-banner')).toBeTruthy() + fireEvent.click(screen.getByTestId('quota-wall-dismiss')) + expect(screen.queryByTestId('quota-wall-banner')).toBeNull() + + // The dismiss state was persisted with the right percent so the + // reappear-at-+5pp logic can read it back in a fresh mount. + const raw = localStorage.getItem('instanode.quotaWallDismiss.team-1') + expect(raw).toBeTruthy() + const parsed = JSON.parse(raw!) + expect(parsed.percent).toBe(87) + }) + + it('does not render when near_wall=false', () => { + render( + , + ) + expect(screen.queryByTestId('quota-wall-banner')).toBeNull() + }) + + it('reappears for a fresh mount if percent climbed +5pp over the dismissed value', () => { + // Pre-seed a dismiss state at 82%. + localStorage.setItem( + 'instanode.quotaWallDismiss.team-1', + JSON.stringify({ percent: 82, at: '2026-05-12T11:00:00Z' }), + ) + render( + , + ) + expect(screen.queryByTestId('quota-wall-banner')).toBeTruthy() + }) + + it('stays hidden for a fresh mount if percent climbed less than +5pp', () => { + localStorage.setItem( + 'instanode.quotaWallDismiss.team-1', + JSON.stringify({ percent: 85, at: '2026-05-12T11:00:00Z' }), + ) + render( + , + ) + expect(screen.queryByTestId('quota-wall-banner')).toBeNull() + }) +}) + +// Sanity test that the unused vi import is intentional — silences the +// "unused import" lint rule when the file is otherwise vi-free. +vi.fn() diff --git a/src/components/QuotaWallBanner.tsx b/src/components/QuotaWallBanner.tsx new file mode 100644 index 0000000..3a60b1d --- /dev/null +++ b/src/components/QuotaWallBanner.tsx @@ -0,0 +1,242 @@ +// QuotaWallBanner — Track U1. +// +// Yellow banner above page content that appears when the caller's team +// is approaching the 80% mark on any tier-limit axis (storage, +// connections, or provisions). Backed by GET /api/v1/usage/wall, which +// returns the most recent near_quota_wall audit row written by the +// worker's QuotaWallNudgeWorker. +// +// Dismissibility — localStorage: +// key = `instanode.quotaWallDismiss.` +// value = JSON({ percent: number, at: string }) +// +// The banner is dismissible, but the dismiss anchors to the percent at +// dismiss time. If usage climbs another 5pp after dismiss, the banner +// reappears — the user explicitly said "I see it" at X%, not "ignore +// forever". Keyed by team_id so two teams sharing a browser don't +// share dismisses. +// +// Polling cadence: 5 minutes. The worker writes at most once per 24h +// per team, so this is gentle enough to not generate noise but quick +// enough that a dismissed banner can re-emerge inside one session. + +import { useEffect, useState } from 'react' +import * as api from '../api' +import type { QuotaWallResponse } from '../api' + +// QUOTA_WALL_POLL_MS — how often the banner refetches /usage/wall after +// mount. 5 minutes balances "user sees the banner reappear inside one +// session if usage climbs" with "we don't hammer the API for a check +// that updates at most once per 24h on the worker side". +const QUOTA_WALL_POLL_MS = 5 * 60 * 1000 + +// QUOTA_WALL_REAPPEAR_DELTA_PCT — minimum percent-used increase over the +// dismissed value before the banner reappears. 5pp matches the brief — +// "dismissible, but reappears if usage increases another 5pp". +const QUOTA_WALL_REAPPEAR_DELTA_PCT = 5 + +// QUOTA_WALL_DISMISS_KEY_PREFIX — localStorage key prefix. The full key +// is `${prefix}` so per-team dismiss state is isolated even +// when two teams share a browser profile. +const QUOTA_WALL_DISMISS_KEY_PREFIX = 'instanode.quotaWallDismiss.' + +// BILLING_PATH — where the Upgrade CTA points. Matches the in-app +// billing surface mounted in App.tsx; the page from there has the +// real Razorpay checkout flow. +const BILLING_PATH = '/app/billing' + +type Props = { + /** Team id the dismiss key is scoped to. When unset (loading) the + * banner stays hidden — we don't want to render against a global + * dismiss key and leak one team's nudge into another's view. */ + teamId?: string + /** Optional injected wall payload for tests / Storybook. When + * undefined the component fetches /api/v1/usage/wall itself. */ + initialWall?: QuotaWallResponse | null + /** Disable the network poll. Tests pass true to keep the component + * stable across renders. */ + disablePolling?: boolean +} + +type DismissState = { + percent: number + at: string +} + +function readDismiss(teamId: string): DismissState | null { + if (typeof window === 'undefined') return null + try { + const raw = localStorage.getItem(QUOTA_WALL_DISMISS_KEY_PREFIX + teamId) + if (!raw) return null + const parsed = JSON.parse(raw) as DismissState + if (typeof parsed?.percent === 'number' && typeof parsed?.at === 'string') { + return parsed + } + return null + } catch { + return null + } +} + +function writeDismiss(teamId: string, state: DismissState): void { + if (typeof window === 'undefined') return + try { + localStorage.setItem(QUOTA_WALL_DISMISS_KEY_PREFIX + teamId, JSON.stringify(state)) + } catch { + /* localStorage unavailable — banner just won't be dismissable this session */ + } +} + +// shouldRender — pure decision: should the banner render given the +// latest API response and the current localStorage dismiss state? +// Exposed at module scope so the component test can exercise it +// without mounting a tree. +export function shouldRender(wall: QuotaWallResponse | null, dismiss: DismissState | null): boolean { + if (!wall || !wall.near_wall) return false + if (!dismiss) return true + const pct = wall.percent_used ?? 0 + // Reappear once usage climbs ≥ N pp above the dismissed point. + return pct - dismiss.percent >= QUOTA_WALL_REAPPEAR_DELTA_PCT +} + +// formatAxisCopy — turns the raw axis/service/percent into the human +// banner text. Kept tiny on purpose: copy variations live here, not +// scattered through the JSX. +function formatAxisCopy(wall: QuotaWallResponse): string { + const tier = wall.tier ?? 'your' + const pct = wall.percent_used ?? 0 + switch (wall.axis) { + case 'storage': { + const svc = wall.service ?? 'storage' + return `You're at ${pct}% of your ${tier} ${svc} storage limit.` + } + case 'connections': + return `You're at ${pct}% of your ${tier} tier connection limit.` + case 'provisions': + return `You're at ${pct}% of your ${tier} tier provision limit.` + default: + return `You're at ${pct}% of your ${tier} tier limit.` + } +} + +export function QuotaWallBanner({ teamId, initialWall, disablePolling }: Props) { + const [wall, setWall] = useState(initialWall ?? null) + const [dismiss, setDismiss] = useState(() => + teamId ? readDismiss(teamId) : null, + ) + + // Resync dismiss state if the team changes (rare, but happens on + // multi-team accounts that switch teams without a full reload). + useEffect(() => { + if (!teamId) { + setDismiss(null) + return + } + setDismiss(readDismiss(teamId)) + }, [teamId]) + + useEffect(() => { + if (disablePolling) return + let alive = true + async function tick() { + try { + const r = await api.fetchQuotaWall() + if (!alive) return + setWall(r) + } catch { + // Quiet on failure — banner stays hidden rather than showing a + // stale state. The dashboard already surfaces auth/API outages + // elsewhere. + if (alive) setWall(null) + } + } + // Skip the initial fetch when the parent already passed `initialWall` + // (tests, prerender). Otherwise fetch on mount + poll on interval. + if (initialWall === undefined) { + tick() + } + const id = window.setInterval(tick, QUOTA_WALL_POLL_MS) + return () => { + alive = false + window.clearInterval(id) + } + }, [disablePolling, initialWall]) + + if (!shouldRender(wall, dismiss)) return null + // Non-null guaranteed by shouldRender — fall through is type-narrowed. + const w = wall! + + function onDismiss() { + if (!teamId) return + const next: DismissState = { + percent: w.percent_used ?? 0, + at: new Date().toISOString(), + } + writeDismiss(teamId, next) + setDismiss(next) + } + + return ( +
+ +
+ {formatAxisCopy(w)}{' '} + + Upgrade for more headroom — keeps your agents shipping without quota errors. + +
+ + Upgrade + + +
+ ) +} diff --git a/src/pages/DeploymentsPage.tsx b/src/pages/DeploymentsPage.tsx index 4f3bcd5..adf7c13 100644 --- a/src/pages/DeploymentsPage.tsx +++ b/src/pages/DeploymentsPage.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom' import { ContractBanner, EnvPill, StatusPill, ResourceIcon, RelTime } from '../components/Common' +import { QuotaWallBanner } from '../components/QuotaWallBanner' import * as api from '../api' import type { DashboardDeployment } from '../api' import { useDashboardCtx } from '../hooks/useDashboardCtx' @@ -39,7 +40,10 @@ export function DeploymentsPage() { return ( <> - + {/* QuotaWallBanner — Track U1. Deployment-count is one of the + axes (provisions), and deploys are a frequent landing spot + for paid-tier consideration. */} +
diff --git a/src/pages/OverviewPage.tsx b/src/pages/OverviewPage.tsx index 6aa5911..889c4fd 100644 --- a/src/pages/OverviewPage.tsx +++ b/src/pages/OverviewPage.tsx @@ -4,6 +4,7 @@ import { ROBanner, EnvPill, TierPill, ResourceIcon, RelTime, UsageBar, Card, Sparkline, copyToClipboard } from '../components/Common' +import { QuotaWallBanner } from '../components/QuotaWallBanner' import { UpgradeButton } from '../components/UpgradeButton' import * as api from '../api' import type { Resource, ActivityItem } from '../api' @@ -117,6 +118,11 @@ export function OverviewPage() { return ( <> + {/* QuotaWallBanner — Track U1. Renders only when the worker has + flagged this team as approaching a tier limit (>=80% on any + axis) within the last 24h. Dismissible per-team. */} + + The whole dashboard is a mirror. Resources, deploys, vault keys, audit trails — everything you see came from your agent calling the API. To do something, prompt your agent. Billing is the only exception. diff --git a/src/pages/ResourcesPage.tsx b/src/pages/ResourcesPage.tsx index 231a7ba..f7b70b0 100644 --- a/src/pages/ResourcesPage.tsx +++ b/src/pages/ResourcesPage.tsx @@ -4,6 +4,7 @@ import { ContractBanner, EnvPill, ExpiryBadge, TierPill, ResourceIcon, RelTime, UsageBar, PromptCard, useExpiryTick } from '../components/Common' +import { QuotaWallBanner } from '../components/QuotaWallBanner' import { UpgradePromptCard } from '../components/UpgradePromptCard' import * as api from '../api' import type { Resource, ResourceType, Tier } from '../api' @@ -73,6 +74,11 @@ export function ResourcesPage() { return ( <> + {/* QuotaWallBanner (U1): 80% pre-wall nudge driven by worker scan. + UpgradePromptCard quota_wall (U2): at-wall prompt rendered client-side + when the user has actually hit the cap. Layered: gentle nudge first, + firm prompt when stuck. */} + {showQuotaPrompt && (