page.tsx375 lines · main
| 1 | import Link from 'next/link'; |
| 2 | |
| 3 | import { CopyChip } from '../../../../../components/copy-chip'; |
| 4 | import { apiJson } from '../../../../../lib/api'; |
| 5 | import { InvocationsSparkline } from './invocations-sparkline'; |
| 6 | |
| 7 | interface Project { |
| 8 | id: string; |
| 9 | slug: string; |
| 10 | } |
| 11 | |
| 12 | interface Deployment { |
| 13 | id: string; |
| 14 | status: 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled'; |
| 15 | createdAt: string; |
| 16 | functionCount: string | null; |
| 17 | } |
| 18 | |
| 19 | interface 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 | |
| 32 | interface 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 | |
| 42 | export const dynamic = 'force-dynamic'; |
| 43 | |
| 44 | function 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 | |
| 51 | function 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 | |
| 57 | function 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 | |
| 64 | export 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 | |
| 283 | function 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 | |
| 335 | function 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 | |
| 363 | function 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 | } |