Skip to content
File

Blob: ui/client/App.svelte

19.2 KB
1<script lang="ts">
2 import { onMount, tick } from "svelte";
3 import Button from "../shared/Button.svelte";
4 import Shell from "../shared/Shell.svelte";
5 import ScrollRegion from "../shared/ScrollRegion.svelte";
6 import DomainTokens from "./DomainTokens.svelte";
7 import CustomDomains from "./CustomDomains.svelte";
8 import {
9 APIError,
10 createAPI,
11 errorMessage,
12 type ClientStatus,
13 type RegisteredTunnel,
14 type TunnelSyncResult,
15 } from "./api";
16 
17 interface TunnelRow extends Partial<TunnelSyncResult> {
18 hostname: string;
19 target: string;
20 headerHost?: string;
21 registered: boolean;
22 }
23 
24 type RemovalAction = "unpublish" | "release";
25 type Confirmation = { action: RemovalAction; tunnel: TunnelRow; trigger: HTMLButtonElement };
26 type Notice = {
27 tone: "success" | "warning" | "error";
28 title: string;
29 detail?: string;
30 saveFailed?: boolean;
31 restore?: string | null;
32 };
33 
34 let status = $state<ClientStatus | null>(null);
35 let registered = $state<RegisteredTunnel[] | null>(null);
36 let statusError = $state("");
37 let listError = $state("");
38 let statusCheckedAt = $state<Date | null>(null);
39 let listCheckedAt = $state<Date | null>(null);
40 let refreshing = $state(false);
41 let checkingStatus = $state(false);
42 let checkingHostnames = $state(false);
43 let pendingAction = $state<RemovalAction | "synchronize" | "">("");
44 let confirmation = $state<Confirmation | null>(null);
45 let confirmButton = $state<HTMLButtonElement>();
46 let tunnelsHeading = $state<HTMLHeadingElement>();
47 let tunnelNotice = $state<Notice | null>(null);
48 let statusRequestID = 0;
49 let hostnameRequestID = 0;
50 const api = createAPI();
51 
52 const synchronization = $derived(status?.synchronization);
53 const connectedNodes = $derived(status?.connectedNodes ?? []);
54 const actionBusy = $derived(Boolean(pendingAction));
55 const unsavedChanges = $derived(Boolean(synchronization?.attemptedAt && !synchronization.saved));
56 const tunnelRows = $derived(mergeTunnels(registered, synchronization?.tunnels));
57 const configuredCount = $derived(synchronization?.tunnels?.length ?? 0);
58 const syncIssue = $derived<Notice | null>(
59 synchronization?.error || status?.pending || unsavedChanges
60 ? {
61 tone: "warning",
62 title: unsavedChanges ? "Changes not saved." : "Synchronization incomplete.",
63 detail: synchronization?.error,
64 }
65 : null,
66 );
67 const syncNotice = $derived(
68 syncIssue && tunnelNotice?.tone === "success" ? syncIssue : tunnelNotice || syncIssue,
69 );
70 
71 function mergeTunnels(hostnames: RegisteredTunnel[] | null, outcomes?: TunnelSyncResult[]): TunnelRow[] {
72 const rows = new Map<string, TunnelRow>();
73 for (const [index, tunnel] of (outcomes ?? []).entries()) {
74 rows.set(tunnel.hostname || `unassigned:${index}`, { ...tunnel, registered: false });
75 }
76 for (const tunnel of hostnames ?? []) {
77 const outcome = rows.get(tunnel.hostname);
78 rows.set(tunnel.hostname, {
79 ...tunnel,
80 target: outcomes ? "" : tunnel.target === "(unused)" ? "" : tunnel.target,
81 headerHost: outcome && outcome.target !== tunnel.target ? "" : tunnel.headerHost,
82 ...outcome,
83 registered: true,
84 });
85 }
86 return [...rows.values()];
87 }
88 
89 function timeLabel(value: string | Date | null | undefined): string {
90 return value
91 ? new Date(value).toLocaleTimeString([], {
92 hour: "2-digit",
93 minute: "2-digit",
94 second: "2-digit",
95 })
96 : "Not checked";
97 }
98 
99 function publicationLabel(tunnel: TunnelRow): string {
100 if (!tunnel.target) return tunnel.registered ? "Reserved" : "No target";
101 if (tunnel.published && tunnel.error) return "Partially published";
102 if (tunnel.published) return "Published";
103 if (tunnel.error) return "Not published";
104 return "Not checked";
105 }
106 
107 function restorationSnippet(tunnel: TunnelRow): string {
108 return ` - hostname: ${JSON.stringify(tunnel.hostname)}\n target: ${JSON.stringify(tunnel.target || "http://127.0.0.1:8080")}${tunnel.headerHost ? `\n headerHost: ${JSON.stringify(tunnel.headerHost)}` : ""}`;
109 }
110 
111 async function loadStatus(force = false) {
112 if (checkingStatus && !force) return;
113 const requestID = ++statusRequestID;
114 checkingStatus = true;
115 try {
116 const nextStatus = await api.request<ClientStatus>("/api/status");
117 if (requestID !== statusRequestID) return;
118 status = nextStatus;
119 statusCheckedAt = new Date();
120 statusError = "";
121 } catch (error) {
122 if (requestID === statusRequestID) statusError = errorMessage(error);
123 } finally {
124 if (requestID === statusRequestID) checkingStatus = false;
125 }
126 }
127 
128 async function loadHostnames(force = false) {
129 if (checkingHostnames && !force) return;
130 const requestID = ++hostnameRequestID;
131 checkingHostnames = true;
132 try {
133 const next = await api.request<RegisteredTunnel[]>("/api/ls");
134 if (requestID !== hostnameRequestID) return;
135 registered = next;
136 listCheckedAt = new Date();
137 listError = "";
138 } catch (error) {
139 if (requestID === hostnameRequestID) listError = errorMessage(error);
140 } finally {
141 if (requestID === hostnameRequestID) checkingHostnames = false;
142 }
143 }
144 
145 async function refresh() {
146 if (refreshing || pendingAction) return;
147 refreshing = true;
148 await Promise.allSettled([loadStatus(true), loadHostnames(true)]);
149 refreshing = false;
150 }
151 
152 async function synchronize() {
153 if (pendingAction || refreshing) return;
154 pendingAction = "synchronize";
155 tunnelNotice = null;
156 try {
157 await api.request("/api/reload", { method: "POST" });
158 tunnelNotice = { tone: "success", title: "Synchronized and saved." };
159 } catch (error) {
160 const outcome = error instanceof APIError ? error.outcome : undefined;
161 if (outcome?.applied && outcome.tunnels && status) {
162 status = {
163 ...status,
164 synchronization: {
165 applied: true,
166 saved: outcome.saved ?? false,
167 error: outcome.error,
168 attemptedAt: outcome.attemptedAt,
169 tunnels: outcome.tunnels,
170 },
171 };
172 }
173 tunnelNotice = {
174 tone: outcome?.applied ? "warning" : "error",
175 title: outcome?.applied
176 ? outcome.saved
177 ? "Saved; publication incomplete."
178 : "Applied; save failed."
179 : outcome
180 ? "Configuration not applied."
181 : "Synchronization result unknown.",
182 detail: errorMessage(error),
183 };
184 } finally {
185 pendingAction = "";
186 await refresh();
187 }
188 }
189 
190 async function askRemoval(action: RemovalAction, tunnel: TunnelRow, trigger: HTMLButtonElement) {
191 if (pendingAction || refreshing) return;
192 confirmation = { action, tunnel, trigger };
193 await tick();
194 confirmButton?.focus();
195 }
196 
197 function cancelRemoval() {
198 if (pendingAction) return;
199 const trigger = confirmation?.trigger;
200 confirmation = null;
201 trigger?.focus();
202 }
203 
204 async function removeTunnel() {
205 if (!confirmation || pendingAction || refreshing) return;
206 const { action, tunnel } = confirmation;
207 pendingAction = action;
208 tunnelNotice = null;
209 try {
210 await api.request(`/api/${action}/${encodeURIComponent(tunnel.hostname)}`, { method: "POST" });
211 tunnelNotice = {
212 tone: "success",
213 title: `${tunnel.hostname} ${action === "unpublish" ? "unpublished" : "released"}.`,
214 restore: action === "unpublish" ? restorationSnippet(tunnel) : null,
215 };
216 confirmation = null;
217 } catch (error) {
218 const outcome = error instanceof APIError ? error.outcome : undefined;
219 const applied = outcome?.applied;
220 tunnelNotice = {
221 tone: applied ? "warning" : "error",
222 title: applied
223 ? `${tunnel.hostname} ${action === "unpublish" ? "unpublished" : "released"}; saving failed.`
224 : outcome
225 ? `Could not ${action} ${tunnel.hostname}.`
226 : `Could not confirm ${action} for ${tunnel.hostname}.`,
227 detail: errorMessage(error),
228 saveFailed: applied,
229 restore: applied && action === "unpublish" ? restorationSnippet(tunnel) : null,
230 };
231 if (applied) confirmation = null;
232 } finally {
233 pendingAction = "";
234 await refresh();
235 if (!confirmation) tunnelsHeading?.focus();
236 }
237 }
238 
239 onMount(() => {
240 void refresh();
241 const timer = setInterval(() => {
242 if (!refreshing && !pendingAction && !document.hidden) {
243 void loadStatus();
244 void loadHostnames();
245 }
246 }, 15_000);
247 return () => {
248 clearInterval(timer);
249 api.abort();
250 };
251 });
252</script>
253 
254<svelte:head>
255 <title>Specter · Client manager</title>
256 <meta name="description" content="Manage Specter tunnels and custom domains." />
257</svelte:head>
258 
259<svelte:window onkeydown={(event) => { if (event.key === "Escape") cancelRemoval(); }} />
260 
261<Shell label="Client manager" home="#main-content">
262 <section id="tunnels" class="min-w-0 scroll-mt-6" aria-labelledby="tunnels-heading">
263 <div class="mb-3.5 flex flex-col items-start justify-between gap-3.5 min-[600px]:flex-row min-[600px]:items-center min-[600px]:gap-4">
264 <h1 id="tunnels-heading" class="text-2xl leading-tight font-bold tracking-tight" bind:this={tunnelsHeading} tabindex="-1">
265 Tunnels <span class="ms-1.5 text-sm font-medium text-muted">{tunnelRows.length}</span>
266 </h1>
267 <div class="flex flex-wrap items-center gap-2">
268 <Button onclick={refresh} disabled={refreshing || actionBusy}>{refreshing ? "Refreshing…" : "Refresh"}</Button>
269 <Button variant="primary" onclick={synchronize} disabled={actionBusy || refreshing}>{pendingAction === "synchronize" ? "Synchronizing…" : "Synchronize"}</Button>
270 </div>
271 </div>
272 
273 <div class="mb-4 flex flex-col gap-2.5 border-y border-line py-2.5 min-[600px]:flex-row min-[600px]:flex-wrap min-[600px]:gap-x-8 min-[600px]:gap-y-4 [&>div]:flex [&>div]:min-w-0 [&>div]:flex-wrap [&>div]:items-center [&>div]:gap-2.5" aria-busy={checkingStatus}>
274 <div><span class="min-w-[7.625rem] text-[0.8125rem] text-muted min-[600px]:min-w-0">Server</span><span class="font-mono text-[0.8125rem] wrap-anywhere">{status?.apex || (statusError ? "Unavailable" : "Loading…")}</span></div>
275 <div>
276 <span class="min-w-[7.625rem] text-[0.8125rem] text-muted min-[600px]:min-w-0">Connected</span>
277 <span class={status && !connectedNodes.length ? 'text-warning' : ''}>
278 {#if status}<span class={['me-1.5 inline-block size-1.5 rounded-full align-[1px]', connectedNodes.length ? 'bg-success' : 'bg-warning']} aria-hidden="true"></span>{connectedNodes.length} {connectedNodes.length === 1 ? "node" : "nodes"}{:else}Not checked{/if}
279 </span>
280 </div>
281 <div><span class="min-w-[7.625rem] text-[0.8125rem] text-muted min-[600px]:min-w-0">Last sync</span><span>{synchronization?.attemptedAt ? timeLabel(synchronization.attemptedAt) : "Not attempted"}</span></div>
282 </div>
283 
284 {#if syncNotice}
285 <div class={[
286 'my-2.5 rounded-md border bg-chrome px-3 py-2 wrap-anywhere',
287 syncNotice.tone === 'success' && 'border-success/40 text-success',
288 syncNotice.tone === 'warning' && 'border-warning/40 text-warning',
289 syncNotice.tone === 'error' && 'border-danger/40 text-danger',
290 ]} role="status">
291 <strong class="font-semibold">{syncNotice.title}</strong>
292 {#if status?.pending}<span> Retry {status.retryAt ? timeLabel(status.retryAt) : "pending"}.</span>{/if}
293 {#if unsavedChanges || (syncNotice.saveFailed && statusError)}<p class="mt-1">Review the YAML before reloading or restarting.</p>{/if}
294 {#if syncNotice.detail}<details class="mt-1"><summary class="cursor-pointer text-[0.8125rem] text-zinc-200">Error details</summary><p class="mt-2">{syncNotice.detail}</p></details>{/if}
295 {#if syncNotice.restore}
296 <details class="my-2"><summary class="cursor-pointer py-1 font-medium text-zinc-200">Restore tunnel</summary><p class="mt-2">Add under <code>tunnels:</code>, restore other options, then synchronize.</p><pre class="my-3 overflow-auto rounded-md border border-line bg-chrome px-3.5 py-3 font-mono text-[0.8125rem] leading-relaxed whitespace-pre-wrap">{syncNotice.restore}</pre></details>
297 {/if}
298 </div>
299 {/if}
300 {#if statusError || listError}
301 <div class="my-2.5 rounded-md border border-danger/40 bg-chrome px-3 py-2 text-danger wrap-anywhere" role="status">
302 {#if statusError}<div><strong class="font-semibold">Status unavailable.</strong>{#if status} Snapshot {timeLabel(statusCheckedAt)}.{/if}<details class="mt-1"><summary class="cursor-pointer text-[0.8125rem] text-zinc-200">Status error</summary><p class="mt-2">{statusError}</p></details></div>{/if}
303 {#if listError}<div><strong class="font-semibold">Hostnames unavailable.</strong>{#if registered} Snapshot {timeLabel(listCheckedAt)}.{/if}<details class="mt-1"><summary class="cursor-pointer text-[0.8125rem] text-zinc-200">Hostname error</summary><p class="mt-2">{listError}</p></details></div>{/if}
304 </div>
305 {/if}
306 
307 <div aria-busy={refreshing}>
308 {#if registered === null && refreshing && !tunnelRows.length}
309 <div class="rounded-md border border-line bg-chrome px-4 py-4.5" role="status">Loading tunnels…</div>
310 {:else if tunnelRows.length === 0}
311 <div class="rounded-md border border-line bg-chrome px-4 py-4.5">{#if statusError || listError}No data. Refresh to retry.{:else}No tunnels. <a href="#configuration" class="text-accent-300 underline underline-offset-4 hover:text-accent-200">Add a target</a>, then synchronize.{/if}</div>
312 {:else}
313 <ScrollRegion label="Tunnels" class="bg-chrome">
314 <table class="w-full min-w-[760px] table-fixed border-collapse text-start [&_td]:px-3.5 [&_td]:py-2.5 [&_td]:align-top [&_td]:wrap-anywhere [&_th]:px-3.5 [&_th]:py-2.5 [&_th]:text-start [&_th]:align-top [&_th]:wrap-anywhere">
315 <thead class="text-xs text-muted [&_th]:font-medium"><tr><th class="w-1/4" scope="col">Hostname</th><th class="w-[27%]" scope="col">Local target</th><th class="w-1/4" scope="col">Publication</th><th class="w-[23%]" scope="col">Actions</th></tr></thead>
316 <tbody>
317 {#each tunnelRows as tunnel, index (tunnel.hostname || `unassigned:${index}`)}
318 <tr class="border-t border-line hover:bg-surface/50">
319 <th scope="row" class="font-medium"><span class="font-mono text-[0.8125rem]">{tunnel.hostname || "Awaiting hostname"}</span></th>
320 <td><span class={['font-mono text-[0.8125rem]', !tunnel.target && 'text-muted']}>{tunnel.target || "None"}</span>{#if tunnel.headerHost}<span class="mt-1 block text-xs text-muted">Host: <span class="font-mono">{tunnel.headerHost}</span></span>{/if}</td>
321 <td>
322 <span class={['text-[0.8125rem]', tunnel.published && !tunnel.error && 'text-success', tunnel.error && 'text-warning']}>{publicationLabel(tunnel)}</span>
323 {#if tunnel.published}<span class="mt-1 block text-xs text-muted">{tunnel.publishedEndpoints} {tunnel.publishedEndpoints === 1 ? "endpoint" : "endpoints"}</span>{/if}
324 {#if tunnel.error}<details class="mt-1"><summary class="cursor-pointer text-[0.8125rem] text-zinc-200">Error</summary><p class="mt-2 text-xs text-danger">{tunnel.error}</p></details>{/if}
325 </td>
326 <td>
327 <div class="flex flex-wrap items-center justify-end gap-1.5">
328 <Button size="sm" disabled={actionBusy || refreshing || !tunnel.hostname || !tunnel.target} aria-label={`Unpublish ${tunnel.hostname || "unassigned tunnel"}`} onclick={(event) => askRemoval("unpublish", tunnel, event.currentTarget)}>Unpublish</Button>
329 <Button size="sm" variant="danger" disabled={actionBusy || refreshing || !tunnel.hostname} aria-label={`Release ${tunnel.hostname || "unassigned tunnel"}`} onclick={(event) => askRemoval("release", tunnel, event.currentTarget)}>Release</Button>
330 </div>
331 </td>
332 </tr>
333 {#if confirmation?.tunnel.hostname === tunnel.hostname}
334 <tr class="border-t border-line bg-surface"><td colspan="4">
335 <div class="flex flex-wrap items-center justify-between gap-4" role="group" aria-label={`${confirmation.action === "unpublish" ? "Unpublish" : "Release"} ${tunnel.hostname}`}>
336 <p class="max-w-[68ch] text-zinc-300">{confirmation.action === "unpublish" ? "Remove target from YAML; keep hostname." : "Remove target from YAML; release hostname for others to claim."}</p>
337 <div class="flex flex-wrap items-center gap-2"><Button onclick={cancelRemoval} disabled={Boolean(pendingAction)}>Cancel</Button><Button bind:element={confirmButton} variant="danger" onclick={removeTunnel} disabled={actionBusy || refreshing}>{pendingAction ? "Applying…" : confirmation.action === "unpublish" ? "Confirm unpublish" : "Confirm release"}</Button></div>
338 </div>
339 </td></tr>
340 {/if}
341 {/each}
342 </tbody>
343 </table>
344 </ScrollRegion>
345 {/if}
346 </div>
347 <p class="mt-2.5 text-[0.8125rem] text-muted min-[600px]:text-xs">Publication: last acknowledgement; target health unchecked.</p>
348 </section>
349 
350 <div class="mt-6 grid grid-cols-1 gap-7 border-t border-line pt-5 min-[600px]:grid-cols-2 min-[800px]:gap-8">
351 <CustomDomains />
352 <section id="configuration" class="min-w-0 scroll-mt-6 border-t border-line pt-5 min-[600px]:border-0 min-[600px]:pt-0" aria-labelledby="configuration-heading">
353 <h2 id="configuration-heading" class="mb-3.5 text-lg leading-snug font-semibold tracking-tight">Configuration</h2>
354 <dl class="mb-2.5 [&>div]:grid [&>div]:grid-cols-[9.375rem_minmax(0,1fr)] [&>div]:gap-4 [&>div]:py-0.5 [&_dd]:wrap-anywhere [&_dt]:text-muted">
355 <div><dt>Configured tunnels</dt><dd>{status ? configuredCount : "Unavailable"}</dd></div>
356 <div><dt>Disk state</dt><dd class={synchronization?.attemptedAt && !synchronization.saved ? 'text-warning' : ''}>{!synchronization?.attemptedAt ? "Not checked" : synchronization.saved ? "Saved" : "Unsaved"}</dd></div>
357 </dl>
358 <details class="my-2">
359 <summary class="cursor-pointer py-1 font-medium text-zinc-200">Edit tunnels</summary>
360 <pre class="my-3 overflow-auto rounded-md border border-line bg-chrome px-3.5 py-3 font-mono text-[0.8125rem] leading-relaxed whitespace-pre-wrap" aria-label="Example tunnel configuration">{"tunnels:\n - target: http://127.0.0.1:8080\n # hostname: app.example.com"}</pre>
361 <p class="mt-1.5 mb-2.5 max-w-[72ch] text-[0.8125rem] leading-relaxed text-muted">Edit your existing YAML, then synchronize. Keep credentials intact. Omit hostname for automatic assignment; otherwise use a reserved or verified hostname.</p>
362 </details>
363 </section>
364 </div>
365 
366 <DomainTokens
367 hostnames={registered?.map((t) => t.hostname).filter(Boolean) ?? null}
368 loadingHostnames={checkingHostnames}
369 hostnameError={listError}
370 onRefreshHostnames={() => loadHostnames(true)}
371 />
372 
373 <footer class="mt-6 flex justify-end border-t border-line py-3 text-xs text-muted">{statusCheckedAt ? `Checked ${timeLabel(statusCheckedAt)}` : statusError ? "Status unavailable" : "Connecting…"}</footer>
374</Shell>