page.tsx203 lines · main
1import Link from 'next/link';
2
3import { DatabaseIcon } from '@/components/ui/database';
4import { FoldersIcon } from '@/components/ui/folders';
5
6import { apiJson } from '@/lib/api';
7
8import { EmptyState } from '../../../(admin)/admin/_components/empty-state';
9import { Section } from '../../../(admin)/admin/_components/section';
10import { StatCard } from '../../../(admin)/admin/_components/stat-card';
11
12/**
13 * Cross-project "S3 bucket" home. Lists every project the signed-in user
14 * belongs to with its object-storage usage vs tier cap + active key count,
15 * and a "manage" link into that project's per-project S3 tab. Read-only
16 * aggregation — files are managed inside each project's storage tab.
17 */
18interface MyStorage {
19 id: string;
20 name: string;
21 tier: 'free' | 'pro' | 'team';
22 usedBytes: number;
23 capBytes: number;
24 keyCount: number;
25 over: boolean;
26}
27
28// Copied from the admin storage page to match the codebase's style — the
29// byte formatter, percent helper, and usage bar are intentionally identical
30// so this page reads the same as the operator view.
31function formatBytes(n: number): string {
32 if (n < 1024) return `${n} B`;
33 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
34 if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MiB`;
35 return `${(n / 1024 / 1024 / 1024).toFixed(2)} GiB`;
36}
37
38function pct(used: number, max: number): number {
39 if (max <= 0) return 0;
40 return Math.min(100, Math.round((used / max) * 100));
41}
42
43/**
44 * Compact usage bar. Fills proportionally to used/max and flips to the
45 * error colour once `over` is true, so a project at/over its cap stands
46 * out without reading the numbers.
47 */
48function UsageBar({ used, max, over }: { used: number; max: number; over: boolean }) {
49 const filled = pct(used, max);
50 return (
51 <div className="flex items-center gap-2">
52 <div className="h-1.5 w-24 overflow-hidden rounded-full bg-[var(--color-surface-raised)]">
53 <div
54 className="h-full rounded-full"
55 style={{
56 width: `${filled}%`,
57 backgroundColor: over ? 'var(--color-error)' : 'var(--color-primary)',
58 }}
59 />
60 </div>
61 <span
62 className={
63 over
64 ? 'font-mono text-[10px] text-[var(--color-error)]'
65 : 'font-mono text-[10px] text-[var(--color-text-subtle)]'
66 }
67 >
68 {filled}%
69 </span>
70 </div>
71 );
72}
73
74export const dynamic = 'force-dynamic';
75export const metadata = { title: 'dashboard · s3 bucket' };
76
77export default async function DashboardS3Page() {
78 const { usage } = await apiJson<{ usage: MyStorage[] }>('/v1/me/storage').catch(() => ({
79 usage: [] as MyStorage[],
80 }));
81
82 // Totals derived only from the fetched list — nothing invented.
83 const totalUsedBytes = usage.reduce((sum, u) => sum + u.usedBytes, 0);
84 const overCount = usage.filter((u) => u.over).length;
85
86 return (
87 <div className="flex flex-col gap-10">
88 <header className="flex flex-col gap-2">
89 <div className="flex items-center gap-2">
90 <span className="text-[var(--color-primary)]">
91 <DatabaseIcon size={20} />
92 </span>
93 <h1 className="font-mono text-xl tracking-tight">s3 bucket</h1>
94 </div>
95 <p className="max-w-prose font-mono text-sm text-[var(--color-text-muted)]">
96 your object storage across all projects — files are managed inside each
97 project&apos;s s3 bucket tab.
98 </p>
99 </header>
100
101 {/* ── the numbers ──────────────────────────────────────────────── */}
102 <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
103 <StatCard
104 label="projects"
105 value={usage.length}
106 icon={<FoldersIcon size={14} />}
107 hint="projects you belong to"
108 />
109 <StatCard
110 label="total used"
111 value={totalUsedBytes}
112 suffix=" B"
113 icon={<DatabaseIcon size={14} />}
114 tone="primary"
115 hint={`${formatBytes(totalUsedBytes)} across all projects`}
116 />
117 <StatCard
118 label="over cap"
119 value={overCount}
120 icon={<DatabaseIcon size={14} />}
121 tone={overCount > 0 ? 'warning' : 'default'}
122 hint="projects at or past their storage cap"
123 />
124 </div>
125
126 {/* ── per-project storage ──────────────────────────────────────── */}
127 <Section
128 title={`project storage · ${usage.length.toLocaleString()}`}
129 icon={<DatabaseIcon size={16} />}
130 right={
131 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
132 {formatBytes(totalUsedBytes)} total
133 {overCount > 0 ? (
134 <span className="ml-2 text-[var(--color-error)]">· {overCount} over cap</span>
135 ) : null}
136 </span>
137 }
138 >
139 {usage.length === 0 ? (
140 <EmptyState
141 icon={<DatabaseIcon size={24} />}
142 title="no projects yet"
143 message="create a project to get an s3 bucket — your storage across every project shows up here."
144 />
145 ) : (
146 <div className="overflow-x-auto rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)]">
147 <table className="w-full border-collapse font-mono text-xs">
148 <thead>
149 <tr className="border-b border-[var(--color-border-subtle)] text-left text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
150 <th className="px-4 py-3 font-normal">project</th>
151 <th className="px-4 py-3 font-normal">tier</th>
152 <th className="px-4 py-3 font-normal">used / cap</th>
153 <th className="px-4 py-3 font-normal">usage</th>
154 <th className="px-4 py-3 font-normal">active keys</th>
155 <th className="px-4 py-3 font-normal" />
156 </tr>
157 </thead>
158 <tbody>
159 {usage.map((u) => (
160 <tr
161 key={u.id}
162 className="border-b border-[var(--color-border-subtle)] align-top transition-colors last:border-0 hover:bg-[var(--color-surface-raised)]"
163 >
164 <td className="px-4 py-4">
165 <span className="text-[var(--color-text)]">{u.name}</span>
166 {u.over ? (
167 <span className="ml-2 inline-flex rounded-full bg-[var(--color-error)]/10 px-2 py-0.5 text-[10px] text-[var(--color-error)]">
168 over cap
169 </span>
170 ) : null}
171 <p className="mt-1 font-mono text-[10px] text-[var(--color-text-subtle)]">
172 {u.id}
173 </p>
174 </td>
175 <td className="px-4 py-4 text-[var(--color-text-muted)]">{u.tier}</td>
176 <td className="whitespace-nowrap px-4 py-4 text-[var(--color-text)]">
177 {formatBytes(u.usedBytes)}{' '}
178 <span className="text-[var(--color-text-subtle)]">
179 / {formatBytes(u.capBytes)}
180 </span>
181 </td>
182 <td className="px-4 py-4">
183 <UsageBar used={u.usedBytes} max={u.capBytes} over={u.over} />
184 </td>
185 <td className="px-4 py-4 text-[var(--color-text)]">{u.keyCount}</td>
186 <td className="px-4 py-4 text-right">
187 <Link
188 href={`/dashboard/projects/${u.id}/storage`}
189 className="inline-flex rounded-md border border-[var(--color-border-subtle)] px-3 py-1.5 font-mono text-[11px] text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border)] hover:text-[var(--color-primary)]"
190 >
191 manage
192 </Link>
193 </td>
194 </tr>
195 ))}
196 </tbody>
197 </table>
198 </div>
199 )}
200 </Section>
201 </div>
202 );
203}