page.tsx375 lines · main
1import Link from 'next/link';
2
3import { CopyChip } from '../../../../../components/copy-chip';
4import { apiJson } from '../../../../../lib/api';
5import { InvocationsSparkline } from './invocations-sparkline';
6
7interface Project {
8 id: string;
9 slug: string;
10}
11
12interface Deployment {
13 id: string;
14 status: 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled';
15 createdAt: string;
16 functionCount: string | null;
17}
18
19interface UsageResponse {
20 tier: 'free' | 'pro' | 'team';
21 invocations: { count: number; totalDurationMs: number };
22 storage: { bytes: number; tableCount: number };
23 connection: { seconds: number };
24 limits: {
25 invokesPerMonth: number;
26 storageBytes: number;
27 connectionSecondsPerMonth: number;
28 concurrentSubscriptions: number;
29 };
30}
31
32interface FunctionLog {
33 id: string;
34 functionName: string;
35 status: 'ok' | 'err';
36 durationMs: string;
37 errCode: string | null;
38 errMessage: string | null;
39 createdAt: string;
40}
41
42export const dynamic = 'force-dynamic';
43
44function formatBytes(b: number): string {
45 if (b < 1024) return `${b} B`;
46 if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
47 if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`;
48 return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`;
49}
50
51function formatCount(n: number): string {
52 if (n < 1000) return String(n);
53 if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
54 return `${(n / 1_000_000).toFixed(1)}M`;
55}
56
57function formatSeconds(s: number): string {
58 if (s < 60) return `${s}s`;
59 if (s < 3600) return `${(s / 60).toFixed(1)}m`;
60 if (s < 86_400) return `${(s / 3600).toFixed(1)}h`;
61 return `${(s / 86_400).toFixed(1)}d`;
62}
63
64export default async function ProjectOverviewPage({ params }: { params: Promise<{ id: string }> }) {
65 const { id } = await params;
66 const { project } = await apiJson<{ project: Project }>(`/v1/projects/${id}`);
67 const { deployments } = await apiJson<{ deployments: Deployment[] }>(
68 `/v1/projects/${id}/deployments?limit=5`,
69 ).catch(() => ({ deployments: [] as Deployment[] }));
70 const usage = await apiJson<UsageResponse>(`/v1/projects/${id}/usage`).catch(() => null);
71 const { logs: recentErrors } = await apiJson<{ logs: FunctionLog[] }>(
72 `/v1/projects/${id}/function-logs?status=err&limit=5`,
73 ).catch(() => ({ logs: [] as FunctionLog[] }));
74 const { hours } = await apiJson<{
75 hours: Array<{ hour: string; count: number; errCount: number }>;
76 }>(`/v1/projects/${id}/hourly-invocations`).catch(() => ({
77 hours: [] as Array<{ hour: string; count: number; errCount: number }>,
78 }));
79 // Realtime stats — surfaces a cap-warning banner. Catches the 503 the
80 // api returns when realtime is unconfigured/unreachable so the rest of
81 // the page still renders.
82 const rtStats = await apiJson<{ subscriptions: number; limit: number; fillRatio: number }>(
83 `/v1/projects/${id}/realtime-stats`,
84 ).catch(() => null);
85
86 // The per-project app URL (<slug>.apps.briven.tech) is a v2 feature —
87 // its routing layer isn't built yet (see services/branches.ts), so it
88 // must NOT be presented as the live data endpoint. The working ways to
89 // reach this project's database today are mcp.briven.tech (agents) and
90 // api.briven.tech (sdk/cli), both scoped by the project's key.
91 const appUrl = `${project.slug}.apps.briven.tech`;
92 const latest = deployments[0];
93
94 // Banner copy + colour driven by fill ratio. 75% = yellow (heads-up),
95 // 90% = red (act now). Below 75% we render nothing so the page stays
96 // quiet during normal operation.
97 const rtBanner =
98 rtStats && rtStats.fillRatio >= 0.75
99 ? {
100 severity: rtStats.fillRatio >= 0.9 ? 'red' : 'yellow',
101 message: `using ${rtStats.subscriptions.toLocaleString()} of ${rtStats.limit.toLocaleString()} concurrent realtime subscriptions (${Math.round(rtStats.fillRatio * 100)}% of your tier cap)`,
102 }
103 : null;
104
105 return (
106 <div className="flex flex-col gap-6">
107 {rtBanner ? (
108 <div
109 className={`rounded-md border px-4 py-3 font-mono text-xs ${
110 rtBanner.severity === 'red'
111 ? 'border-red-400/40 bg-red-400/10 text-red-200'
112 : 'border-yellow-400/40 bg-yellow-400/10 text-yellow-200'
113 }`}
114 >
115 <span className="mr-2 font-semibold uppercase tracking-wide">
116 {rtBanner.severity === 'red' ? 'limit' : 'heads up'}
117 </span>
118 {rtBanner.message}. once you hit the cap new subscribes are rejected
119 with{' '}
120 <code className="rounded bg-black/20 px-1">subscription_limit_project</code>.{' '}
121 <a
122 href="/dashboard/billing"
123 className="underline decoration-dotted underline-offset-2"
124 >
125 upgrade your tier
126 </a>{' '}
127 to raise it.
128 </div>
129 ) : null}
130 <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
131 <Card
132 label="mcp endpoint"
133 value="https://mcp.briven.tech/mcp"
134 mono
135 copy
136 hint="agents connect here with this project's mcp key (see the mcp tab)"
137 />
138 <Card
139 label="api endpoint"
140 value="https://api.briven.tech"
141 mono
142 copy
143 hint="sdk / cli connect here with this project's api key"
144 />
145 <Card label="project id" value={project.id} mono copy />
146 <Card
147 label="app url"
148 value={appUrl}
149 mono
150 hint="your deployed app's address — reserved, live in v2"
151 />
152 <Card
153 label="last deploy"
154 value={
155 latest
156 ? `${latest.status} · ${new Date(latest.createdAt).toISOString().slice(0, 10)}`
157 : 'never'
158 }
159 />
160 <Card label="functions (last deploy)" value={latest?.functionCount ?? '—'} />
161 </div>
162
163 {hours.length > 0 ? (
164 <div>
165 <h2 className="mb-3 font-mono text-sm text-[var(--color-text-muted)]">
166 invocations · last 24 hours
167 </h2>
168 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4">
169 <InvocationsSparkline hours={hours} />
170 </div>
171 </div>
172 ) : null}
173
174 {usage ? (
175 <div>
176 <h2 className="mb-3 font-mono text-sm text-[var(--color-text-muted)]">
177 this month so far
178 </h2>
179 <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
180 <QuotaCard
181 label="invocations"
182 current={usage.invocations.count}
183 limit={usage.limits.invokesPerMonth}
184 hint={`${usage.tier} tier`}
185 formatter={formatCount}
186 />
187 <QuotaCard
188 label="storage"
189 current={usage.storage.bytes}
190 limit={usage.limits.storageBytes}
191 hint={`${usage.storage.tableCount} table${usage.storage.tableCount === 1 ? '' : 's'}`}
192 formatter={formatBytes}
193 />
194 <QuotaCard
195 label="realtime"
196 current={usage.connection.seconds}
197 limit={usage.limits.connectionSecondsPerMonth}
198 hint="ws connection-seconds"
199 formatter={formatSeconds}
200 />
201 <Card
202 label="compute"
203 value={`${Math.round(usage.invocations.totalDurationMs / 1000)}s`}
204 hint="aggregated runtime"
205 />
206 </div>
207 </div>
208 ) : null}
209
210 {recentErrors.length > 0 ? (
211 <div>
212 <div className="mb-3 flex items-center justify-between">
213 <h2 className="font-mono text-sm text-[var(--color-text-muted)]">recent errors</h2>
214 <Link
215 href={`/dashboard/projects/${id}/logs?status=err`}
216 className="font-mono text-[10px] text-[var(--color-text-link)] hover:underline"
217 >
218 all logs →
219 </Link>
220 </div>
221 <ul className="flex flex-col gap-2">
222 {recentErrors.map((log) => (
223 <li
224 key={log.id}
225 className="rounded-md border border-red-400/30 bg-red-400/5 px-3 py-2 font-mono text-xs"
226 >
227 <div className="flex items-start justify-between gap-3">
228 <div className="min-w-0">
229 <p className="text-[var(--color-text)]">
230 <span className="text-red-400">err</span> · {log.functionName}
231 <span className="ml-2 text-[var(--color-text-subtle)]">
232 {log.durationMs}ms
233 </span>
234 </p>
235 {log.errMessage ? (
236 <p className="mt-0.5 truncate text-red-400">
237 {log.errCode ? `[${log.errCode}] ` : ''}
238 {log.errMessage}
239 </p>
240 ) : null}
241 </div>
242 <time className="shrink-0 text-[10px] text-[var(--color-text-subtle)]">
243 {new Date(log.createdAt).toISOString().replace('T', ' ').slice(11, 19)}
244 </time>
245 </div>
246 </li>
247 ))}
248 </ul>
249 </div>
250 ) : null}
251
252 <div>
253 <h2 className="mb-3 font-mono text-sm text-[var(--color-text-muted)]">
254 recent deployments
255 </h2>
256 {deployments.length === 0 ? (
257 <p className="rounded-md border border-dashed border-[var(--color-border)] p-6 text-center font-mono text-sm text-[var(--color-text-muted)]">
258 no deployments yet. run <code className="text-[var(--color-text)]">briven deploy</code>.
259 </p>
260 ) : (
261 <ul className="flex flex-col gap-2">
262 {deployments.map((d) => (
263 <li
264 key={d.id}
265 className="flex items-center justify-between rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-4 py-3"
266 >
267 <div>
268 <p className="font-mono text-sm">{d.id}</p>
269 <p className="mt-0.5 font-mono text-xs text-[var(--color-text-subtle)]">
270 {new Date(d.createdAt).toISOString().replace('T', ' ').slice(0, 19)}
271 </p>
272 </div>
273 <span className={`font-mono text-xs ${statusColour(d.status)}`}>{d.status}</span>
274 </li>
275 ))}
276 </ul>
277 )}
278 </div>
279 </div>
280 );
281}
282
283function QuotaCard({
284 label,
285 current,
286 limit,
287 hint,
288 formatter,
289}: {
290 label: string;
291 current: number;
292 limit: number;
293 hint?: string;
294 formatter: (n: number) => string;
295}) {
296 const pct = limit > 0 ? Math.min(100, (current / limit) * 100) : 0;
297 // Three bands: under 80% is fine (green), 80-99% is warning (amber),
298 // 100%+ is over quota — invokes will start returning 429 within the
299 // tier-enforcement cache TTL.
300 const over = current >= limit;
301 const warn = !over && pct >= 80;
302 const barColor = over
303 ? 'bg-[var(--color-error)]'
304 : warn
305 ? 'bg-[var(--color-warning)]'
306 : 'bg-[var(--color-primary)]';
307 return (
308 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4">
309 <p className="font-mono text-xs text-[var(--color-text-subtle)]">{label}</p>
310 <p className="mt-1 font-mono text-sm">
311 {formatter(current)} / {formatter(limit)}
312 </p>
313 <div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-[var(--color-surface-raised)]">
314 <div className={`h-full transition-[width] ${barColor}`} style={{ width: `${pct}%` }} />
315 </div>
316 <p
317 className={`mt-1 font-mono text-[10px] ${
318 over
319 ? 'text-[var(--color-error)]'
320 : warn
321 ? 'text-[var(--color-warning)]'
322 : 'text-[var(--color-text-subtle)]'
323 }`}
324 >
325 {over
326 ? `quota exceeded · new invokes return 429${hint ? ` · ${hint}` : ''}`
327 : warn
328 ? `${Math.round(pct)}% used${hint ? ` · ${hint}` : ''}`
329 : (hint ?? `${Math.round(pct)}% used`)}
330 </p>
331 </div>
332 );
333}
334
335function Card({
336 label,
337 value,
338 mono,
339 hint,
340 copy,
341}: {
342 label: string;
343 value: string;
344 mono?: boolean;
345 hint?: string;
346 /** Show a copy-to-clipboard icon (morphs to a checkmark on copy). */
347 copy?: boolean;
348}) {
349 return (
350 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4">
351 <p className="font-mono text-xs text-[var(--color-text-subtle)]">{label}</p>
352 <div className="mt-1 flex items-center justify-between gap-2">
353 <p className={`min-w-0 truncate text-sm ${mono ? 'font-mono' : ''}`}>{value}</p>
354 {copy ? <CopyChip value={value} label={label} /> : null}
355 </div>
356 {hint ? (
357 <p className="mt-0.5 font-mono text-[10px] text-[var(--color-text-subtle)]">{hint}</p>
358 ) : null}
359 </div>
360 );
361}
362
363function statusColour(status: Deployment['status']): string {
364 switch (status) {
365 case 'succeeded':
366 return 'text-[var(--color-primary)]';
367 case 'failed':
368 return 'text-red-400';
369 case 'running':
370 case 'pending':
371 return 'text-[var(--color-text-muted)]';
372 case 'cancelled':
373 return 'text-[var(--color-text-subtle)]';
374 }
375}