1/** The Settings tab: API keys, storage stats, compaction, delete. */
2import { useEffect, useState } from "react";
3import {
4 compactRepo,
5 createApiKey,
6 deleteApiKey,
7 deleteRepo,
8 listApiKeys,
9 updateRepo,
10 type ApiKey,
11} from "../client.ts";
12import {
13 Badge,
14 Button,
15 CopyButton,
16 ErrorBox,
17 Input,
18 Spinner,
19} from "../components.tsx";
20import { formatBytes, timeAgo } from "../format.ts";
21import { useRouter } from "../router.tsx";
22import type { RepoContext } from "./Repo.tsx";
23
24const StorageCard = ({ context }: { context: RepoContext }) => {
25 const { objects, lastPush } = context.repo;
26 const [compacting, setCompacting] = useState(false);
27 const [error, setError] = useState<unknown>(null);
28 return (
29 <section className="rounded-md border border-border-muted p-4">
30 <h2 className="mb-3 font-semibold">Storage</h2>
31 <dl className="grid grid-cols-2 gap-x-8 gap-y-1 text-sm sm:grid-cols-4">
32 <dt className="text-fg-muted">Total</dt>
33 <dd>{formatBytes(objects.bytes)}</dd>
34 <dt className="text-fg-muted">Loose objects</dt>
35 <dd>{objects.loose}</dd>
36 <dt className="text-fg-muted">Packed (R2)</dt>
37 <dd>{objects.packed}</dd>
38 <dt className="text-fg-muted">Oversize (R2)</dt>
39 <dd>{objects.r2}</dd>
40 </dl>
41 {lastPush !== null && (
42 <p className="mt-3 text-xs text-fg-muted">
43 Last push: {lastPush.objects} objects, {formatBytes(lastPush.bytes)} —
44 server ingest {lastPush.ingestMs}ms (sql {lastPush.stageMs}ms), total{" "}
45 {lastPush.totalMs}ms
46 </p>
47 )}
48 <div className="mt-3">
49 <Button
50 disabled={compacting || objects.loose === 0}
51 onClick={() => {
52 setCompacting(true);
53 setError(null);
54 compactRepo(
55 context.connection,
56 context.repo.owner,
57 context.repo.name,
58 )
59 .then(() => setCompacting(false))
60 .catch((cause) => {
61 setError(cause);
62 setCompacting(false);
63 });
64 }}
65 >
66 {compacting ? "Compaction scheduled ✓" : "Compact loose objects"}
67 </Button>
68 </div>
69 {error != null && <ErrorBox error={error} />}
70 </section>
71 );
72};
73
74const VisibilityCard = ({ context }: { context: RepoContext }) => {
75 const [isPublic, setIsPublic] = useState(context.repo.public);
76 const [error, setError] = useState<unknown>(null);
77 const [busy, setBusy] = useState(false);
78 return (
79 <section className="rounded-md border border-border-muted p-4">
80 <h2 className="mb-2 font-semibold">Visibility</h2>
81 <p className="mb-3 text-sm text-fg-muted">
82 {isPublic
83 ? "Public — anyone can browse and clone this repository without a token."
84 : "Private — reads and clones require a token."}
85 </p>
86 <Button
87 disabled={busy}
88 onClick={() => {
89 setBusy(true);
90 setError(null);
91 updateRepo(
92 context.connection,
93 context.repo.owner,
94 context.repo.name,
95 {
96 public: !isPublic,
97 },
98 )
99 .then((updated) => {
100 setIsPublic(updated.public);
101 setBusy(false);
102 })
103 .catch((cause) => {
104 setError(cause);
105 setBusy(false);
106 });
107 }}
108 >
109 {busy ? "Saving…" : isPublic ? "Make private" : "Make public"}
110 </Button>
111 {error != null && <ErrorBox error={error} />}
112 </section>
113 );
114};
115
116const ApiKeysCard = ({ context }: { context: RepoContext }) => {
117 const [keys, setKeys] = useState<ApiKey[] | null>(null);
118 const [error, setError] = useState<unknown>(null);
119 const [name, setName] = useState("");
120 const [minted, setMinted] = useState<(ApiKey & { key: string }) | null>(null);
121 const { connection, repo } = context;
122 const remote = `${connection.url}/${repo.owner}/${repo.name}.git`;
123
124 const load = () =>
125 listApiKeys(connection)
126 .then((items) => setKeys(items))
127 .catch(setError);
128
129 useEffect(() => {
130 void load();
131 // eslint-disable-next-line react-hooks/exhaustive-deps
132 }, []);
133
134 return (
135 <section className="rounded-md border border-border-muted p-4">
136 <h2 className="mb-1 font-semibold">API keys</h2>
137 <p className="mb-3 text-sm text-fg-muted">
138 A key is the password of your git remote. Keys belong to your account
139 and work on every repository you own.
140 </p>
141 {minted !== null && (
142 <div className="mb-3 space-y-2 rounded-md border border-success/40 bg-success/5 p-3 text-sm">
143 <p className="font-medium text-success">
144 Key “{minted.name}” created — shown exactly once:
145 </p>
146 <div className="flex items-center gap-2">
147 <code className="grow overflow-x-auto rounded bg-canvas-subtle px-2 py-1 font-mono text-xs">
148 git remote add origin{" "}
149 {remote.replace("://", `://x:${minted.key}@`)}
150 </code>
151 <CopyButton
152 text={`git remote add origin ${remote.replace("://", `://x:${minted.key}@`)}`}
153 />
154 </div>
155 </div>
156 )}
157 <form
158 className="mb-4 flex flex-wrap items-center gap-2"
159 onSubmit={(event) => {
160 event.preventDefault();
161 createApiKey(connection, name.trim())
162 .then((key) => {
163 setMinted(key);
164 setName("");
165 void load();
166 })
167 .catch(setError);
168 }}
169 >
170 <div className="w-48">
171 <Input value={name} onChange={setName} placeholder="Key name" />
172 </div>
173 <Button
174 kind="primary"
175 type="submit"
176 disabled={name.trim().length === 0}
177 >
178 Generate key
179 </Button>
180 </form>
181 {error != null && <ErrorBox error={error} />}
182 {keys === null ? (
183 <Spinner />
184 ) : keys.length === 0 ? (
185 <p className="text-sm text-fg-muted">No keys.</p>
186 ) : (
187 <ul className="divide-y divide-border-muted">
188 {keys.map((key) => (
189 <li
190 key={key.id}
191 className="flex items-center justify-between gap-4 py-2 text-sm"
192 >
193 <div className="flex items-center gap-2">
194 <span className="font-medium">{key.name ?? "unnamed"}</span>
195 {key.start !== null && <Badge>{key.start}…</Badge>}
196 <span className="text-xs text-fg-muted">
197 created {timeAgo(new Date(key.createdAt).getTime())}
198 </span>
199 </div>
200 <Button
201 kind="danger"
202 onClick={() => {
203 deleteApiKey(connection, key.id)
204 .then(() => void load())
205 .catch(setError);
206 }}
207 >
208 Revoke
209 </Button>
210 </li>
211 ))}
212 </ul>
213 )}
214 </section>
215 );
216};
217
218const DangerCard = ({ context }: { context: RepoContext }) => {
219 const { navigate } = useRouter();
220 const [confirm, setConfirm] = useState("");
221 const [error, setError] = useState<unknown>(null);
222 const [busy, setBusy] = useState(false);
223 const full = `${context.repo.owner}/${context.repo.name}`;
224 return (
225 <section className="rounded-md border border-danger/40 p-4">
226 <h2 className="mb-2 font-semibold text-danger">Danger zone</h2>
227 <p className="mb-3 text-sm text-fg-muted">
228 Deleting a repository destroys its refs and objects. Type{" "}
229 <code className="font-mono">{full}</code> to confirm.
230 </p>
231 <div className="flex items-center gap-2">
232 <div className="w-64">
233 <Input
234 value={confirm}
235 onChange={setConfirm}
236 placeholder={full}
237 mono
238 />
239 </div>
240 <Button
241 kind="danger"
242 disabled={confirm !== full || busy}
243 onClick={() => {
244 setBusy(true);
245 deleteRepo(
246 context.connection,
247 context.repo.owner,
248 context.repo.name,
249 )
250 .then(() => navigate("/"))
251 .catch((cause) => {
252 setError(cause);
253 setBusy(false);
254 });
255 }}
256 >
257 {busy ? "Deleting…" : "Delete this repository"}
258 </Button>
259 </div>
260 {error != null && <ErrorBox error={error} />}
261 </section>
262 );
263};
264
265export const SettingsTab = ({ context }: { context: RepoContext }) => (
266 <div className="space-y-4">
267 <StorageCard context={context} />
268 <VisibilityCard context={context} />
269 <ApiKeysCard context={context} />
270 <DangerCard context={context} />
271 </div>
272);