page.tsx364 lines · main
1import { DatabaseIcon } from '@/components/ui/database';
2import { FoldersIcon } from '@/components/ui/folders';
3import { LayoutGridIcon } from '@/components/ui/layout-grid';
4import { TriangleAlertIcon } from '@/components/ui/triangle-alert';
5
6import { apiJson } from '@/lib/api';
7
8import { EmptyState } from '../_components/empty-state';
9import { Section } from '../_components/section';
10import { StatCard } from '../_components/stat-card';
11import { EnforcementForm } from './enforcement-form';
12import { ProjectLimitForm } from './project-limit-form';
13import { RecoverFileForm } from './recover-file-form';
14import { TierCapsForm, type Tier, type TierCap } from './tier-caps-form';
15
16interface ProjectStorageUsage {
17 id: string;
18 name: string;
19 tier: Tier;
20 tableCount: number;
21 rowCount: number;
22 maxRows: number;
23 maxTables: number;
24 hasOverride: boolean;
25 enforcement: 'flag' | 'block';
26 overRows: boolean;
27 overTables: boolean;
28 overLimit: boolean;
29}
30
31interface ObjectStorageUsage {
32 id: string;
33 name: string;
34 tier: Tier;
35 usedBytes: number;
36 capBytes: number;
37 recoveryDays: number;
38 keyCount: number;
39 over: boolean;
40}
41
42function formatBytes(n: number): string {
43 if (n < 1024) return `${n} B`;
44 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
45 if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MiB`;
46 return `${(n / 1024 / 1024 / 1024).toFixed(2)} GiB`;
47}
48
49export const dynamic = 'force-dynamic';
50export const metadata = { title: 'admin · storage' };
51
52function publicApiOrigin(): string {
53 return process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? '';
54}
55
56function num(n: number): string {
57 return n > 0 ? n.toLocaleString() : '—';
58}
59
60function pct(used: number, max: number): number {
61 if (max <= 0) return 0;
62 return Math.min(100, Math.round((used / max) * 100));
63}
64
65/**
66 * Compact usage bar. Fills proportionally to used/max and flips to the
67 * error colour once `over` is true, so an operator scanning the table
68 * spots a project at/over its cap without reading the numbers.
69 */
70function UsageBar({ used, max, over }: { used: number; max: number; over: boolean }) {
71 const filled = pct(used, max);
72 return (
73 <div className="flex items-center gap-2">
74 <div className="h-1.5 w-24 overflow-hidden rounded-full bg-[var(--color-surface-raised)]">
75 <div
76 className="h-full rounded-full"
77 style={{
78 width: `${filled}%`,
79 backgroundColor: over ? 'var(--color-error)' : 'var(--color-primary)',
80 }}
81 />
82 </div>
83 <span
84 className={
85 over
86 ? 'font-mono text-[10px] text-[var(--color-error)]'
87 : 'font-mono text-[10px] text-[var(--color-text-subtle)]'
88 }
89 >
90 {filled}%
91 </span>
92 </div>
93 );
94}
95
96export default async function AdminStoragePage() {
97 const apiOrigin = publicApiOrigin();
98 const [{ usage }, { caps }, { usage: objectUsage }] = await Promise.all([
99 apiJson<{ usage: ProjectStorageUsage[] }>('/v1/admin/storage').catch(() => ({ usage: [] as ProjectStorageUsage[] })),
100 apiJson<{ caps: Record<Tier, TierCap> }>('/v1/admin/storage/tier-caps').catch(() => ({ caps: { free: { maxRows: 0, maxTables: 0 }, pro: { maxRows: 0, maxTables: 0 }, team: { maxRows: 0, maxTables: 0 } } as Record<Tier, TierCap> })),
101 apiJson<{ usage: ObjectStorageUsage[] }>('/v1/admin/storage/object').catch(() => ({ usage: [] as ObjectStorageUsage[] })),
102 ]);
103
104 // Real totals derived from the fetched usage list — nothing invented.
105 const overCount = usage.filter((u) => u.overLimit).length;
106 const totalRows = usage.reduce((sum, u) => sum + u.rowCount, 0);
107 const totalTables = usage.reduce((sum, u) => sum + u.tableCount, 0);
108
109 // Object-storage totals derived from the fetched object usage list.
110 const objectOverCount = objectUsage.filter((u) => u.over).length;
111 const totalObjectBytes = objectUsage.reduce((sum, u) => sum + u.usedBytes, 0);
112
113 return (
114 <div className="flex flex-col gap-10">
115 <header className="flex flex-col gap-2">
116 <div className="flex items-center gap-2">
117 <span className="text-[var(--color-primary)]">
118 <DatabaseIcon size={20} />
119 </span>
120 <h1 className="font-mono text-xl tracking-tight">storage</h1>
121 </div>
122 <p className="max-w-prose font-mono text-sm text-[var(--color-text-muted)]">
123 per-project storage usage and limits. usage is counted in rows + tables — the
124 database (DoltGres) can&apos;t report on-disk bytes, so there is no byte figure to
125 show.
126 </p>
127 </header>
128
129 {/* ── the numbers ──────────────────────────────────────────────── */}
130 <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-4">
131 <StatCard
132 label="projects tracked"
133 value={usage.length}
134 icon={<FoldersIcon size={14} />}
135 hint="projects with storage accounting"
136 />
137 <StatCard
138 label="total rows"
139 value={totalRows}
140 icon={<DatabaseIcon size={14} />}
141 tone="primary"
142 hint="across all projects · live count"
143 />
144 <StatCard
145 label="total tables"
146 value={totalTables}
147 icon={<LayoutGridIcon size={14} />}
148 hint="across all projects · live count"
149 />
150 <StatCard
151 label="over limit"
152 value={overCount}
153 icon={<TriangleAlertIcon size={14} />}
154 tone={overCount > 0 ? 'warning' : 'default'}
155 hint="projects at or past their cap"
156 />
157 </div>
158
159 {/* ── tier caps ────────────────────────────────────────────────── */}
160 <Section
161 title="tier caps"
162 icon={<LayoutGridIcon size={16} />}
163 right={
164 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
165 mutations require fresh step-up auth
166 </span>
167 }
168 >
169 <div className="flex flex-col gap-4">
170 <p className="max-w-prose font-mono text-xs text-[var(--color-text-muted)]">
171 default row + table limits per plan tier. saving applies immediately on the api —
172 no redeploy. projects with a per-project override below ignore these.
173 </p>
174 <TierCapsForm apiOrigin={apiOrigin} caps={caps} />
175 </div>
176 </Section>
177
178 {/* ── per-project usage ────────────────────────────────────────── */}
179 <Section
180 title={`project usage · ${usage.length.toLocaleString()}`}
181 icon={<DatabaseIcon size={16} />}
182 right={
183 overCount > 0 ? (
184 <span className="font-mono text-[10px] text-[var(--color-error)]">
185 {overCount} over limit
186 </span>
187 ) : undefined
188 }
189 >
190 {usage.length === 0 ? (
191 <EmptyState
192 icon={<DatabaseIcon size={24} />}
193 title="no projects yet"
194 message="per-project row and table usage appears here as soon as the first project exists."
195 />
196 ) : (
197 <div className="overflow-x-auto rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)]">
198 <table className="w-full border-collapse font-mono text-xs">
199 <thead>
200 <tr className="border-b border-[var(--color-border-subtle)] text-left text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
201 <th className="px-4 py-3 font-normal">project</th>
202 <th className="px-4 py-3 font-normal">tier</th>
203 <th className="px-4 py-3 font-normal">rows</th>
204 <th className="px-4 py-3 font-normal">rows usage</th>
205 <th className="px-4 py-3 font-normal">tables</th>
206 <th className="px-4 py-3 font-normal">tables usage</th>
207 <th className="px-4 py-3 font-normal">limit</th>
208 <th className="px-4 py-3 font-normal">enforcement</th>
209 </tr>
210 </thead>
211 <tbody>
212 {usage.map((u) => (
213 <tr
214 key={u.id}
215 className="border-b border-[var(--color-border-subtle)] align-top transition-colors last:border-0 hover:bg-[var(--color-surface-raised)]"
216 >
217 <td className="px-4 py-4">
218 <span className="text-[var(--color-text)]">{u.name}</span>
219 {u.hasOverride ? (
220 <span className="ml-2 inline-flex rounded-full bg-[var(--color-primary-subtle)] px-2 py-0.5 text-[10px] text-[var(--color-primary)]">
221 override
222 </span>
223 ) : null}
224 {u.overLimit ? (
225 <span className="ml-2 inline-flex rounded-full bg-[var(--color-error)]/10 px-2 py-0.5 text-[10px] text-[var(--color-error)]">
226 over limit
227 </span>
228 ) : null}
229 <p className="mt-1 font-mono text-[10px] text-[var(--color-text-subtle)]">
230 {u.id}
231 </p>
232 </td>
233 <td className="px-4 py-4 text-[var(--color-text-muted)]">{u.tier}</td>
234 <td className="whitespace-nowrap px-4 py-4 text-[var(--color-text)]">
235 {num(u.rowCount)}{' '}
236 <span className="text-[var(--color-text-subtle)]">
237 / {num(u.maxRows)}
238 </span>
239 </td>
240 <td className="px-4 py-4">
241 <UsageBar used={u.rowCount} max={u.maxRows} over={u.overRows} />
242 </td>
243 <td className="whitespace-nowrap px-4 py-4 text-[var(--color-text)]">
244 {num(u.tableCount)}{' '}
245 <span className="text-[var(--color-text-subtle)]">
246 / {num(u.maxTables)}
247 </span>
248 </td>
249 <td className="px-4 py-4">
250 <UsageBar used={u.tableCount} max={u.maxTables} over={u.overTables} />
251 </td>
252 <td className="px-4 py-4">
253 <ProjectLimitForm
254 apiOrigin={apiOrigin}
255 projectId={u.id}
256 projectName={u.name}
257 hasOverride={u.hasOverride}
258 effectiveMaxRows={u.maxRows}
259 effectiveMaxTables={u.maxTables}
260 tierMaxRows={caps[u.tier].maxRows}
261 tierMaxTables={caps[u.tier].maxTables}
262 />
263 </td>
264 <td className="px-4 py-4">
265 <EnforcementForm
266 apiOrigin={apiOrigin}
267 projectId={u.id}
268 projectName={u.name}
269 enforcement={u.enforcement}
270 />
271 </td>
272 </tr>
273 ))}
274 </tbody>
275 </table>
276 </div>
277 )}
278 </Section>
279
280 {/* ── object storage (S3 / file usage) · read-only mirror ──────── */}
281 <Section
282 title={`object storage · ${objectUsage.length.toLocaleString()}`}
283 icon={<DatabaseIcon size={16} />}
284 right={
285 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
286 {formatBytes(totalObjectBytes)} total
287 {objectOverCount > 0 ? (
288 <span className="ml-2 text-[var(--color-error)]">· {objectOverCount} over cap</span>
289 ) : null}
290 </span>
291 }
292 >
293 {objectUsage.length === 0 ? (
294 <EmptyState
295 icon={<DatabaseIcon size={24} />}
296 title="no object storage yet"
297 message="per-project file (S3) usage appears here once a project uploads its first object."
298 />
299 ) : (
300 <div className="overflow-x-auto rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)]">
301 <table className="w-full border-collapse font-mono text-xs">
302 <thead>
303 <tr className="border-b border-[var(--color-border-subtle)] text-left text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
304 <th className="px-4 py-3 font-normal">project</th>
305 <th className="px-4 py-3 font-normal">tier</th>
306 <th className="px-4 py-3 font-normal">used / cap</th>
307 <th className="px-4 py-3 font-normal">usage</th>
308 <th className="px-4 py-3 font-normal">recovery</th>
309 <th className="px-4 py-3 font-normal">active keys</th>
310 </tr>
311 </thead>
312 <tbody>
313 {objectUsage.map((u) => (
314 <tr
315 key={u.id}
316 className="border-b border-[var(--color-border-subtle)] align-top transition-colors last:border-0 hover:bg-[var(--color-surface-raised)]"
317 >
318 <td className="px-4 py-4">
319 <span className="text-[var(--color-text)]">{u.name}</span>
320 {u.over ? (
321 <span className="ml-2 inline-flex rounded-full bg-[var(--color-error)]/10 px-2 py-0.5 text-[10px] text-[var(--color-error)]">
322 over limit
323 </span>
324 ) : null}
325 <p className="mt-1 font-mono text-[10px] text-[var(--color-text-subtle)]">
326 {u.id}
327 </p>
328 </td>
329 <td className="px-4 py-4 text-[var(--color-text-muted)]">{u.tier}</td>
330 <td className="whitespace-nowrap px-4 py-4 text-[var(--color-text)]">
331 {formatBytes(u.usedBytes)}{' '}
332 <span className="text-[var(--color-text-subtle)]">
333 / {formatBytes(u.capBytes)}
334 </span>
335 </td>
336 <td className="px-4 py-4">
337 <UsageBar used={u.usedBytes} max={u.capBytes} over={u.over} />
338 </td>
339 <td className="whitespace-nowrap px-4 py-4 text-[var(--color-text-muted)]">
340 {u.recoveryDays} days
341 </td>
342 <td className="px-4 py-4 text-[var(--color-text)]">{u.keyCount}</td>
343 </tr>
344 ))}
345 </tbody>
346 </table>
347 </div>
348 )}
349
350 <div className="mt-6 flex flex-col gap-3 border-t border-[var(--color-border-subtle)] pt-6">
351 <h3 className="font-mono text-xs uppercase tracking-wider text-[var(--color-text-subtle)]">
352 recover a deleted file (on request)
353 </h3>
354 <p className="max-w-prose font-mono text-[10px] text-[var(--color-text-muted)]">
355 un-delete a single soft-deleted file within its recovery window — the support path when
356 a customer emails asking to restore file X. paste the project id and file id from their
357 message.
358 </p>
359 <RecoverFileForm apiOrigin={apiOrigin} />
360 </div>
361 </Section>
362 </div>
363 );
364}