Skip to content
File

Blob: ui/operator/App.svelte

4.9 KB
1<script lang="ts">
2 import { onMount } from 'svelte';
3 import Button from '../shared/Button.svelte';
4 import Shell from '../shared/Shell.svelte';
5 import Overview from './Overview.svelte';
6 import Clients from './Clients.svelte';
7 import ClientTunnels from './ClientTunnels.svelte';
8 import type { ClientList, Overview as OverviewData, Snapshot, TunnelDetail } from './types';
9 
10 const path = window.location.pathname;
11 const kind = /^\/_internal\/tun\/.+/.test(path)
12 ? 'tunnels'
13 : /^\/_internal\/tun\/?$/.test(path)
14 ? 'clients'
15 : 'overview';
16 const endpoint = kind === 'overview'
17 ? '/_internal/overview.json'
18 : path.replace(/^\/_internal\/tun/, '/_internal/api/tun');
19 const title = kind === 'overview' ? 'Server overview' : kind === 'clients' ? 'Connected clients' : 'Client tunnels';
20 const navigation = [
21 { label: 'Overview', href: '/_internal/', active: kind === 'overview' },
22 { label: 'Clients', href: '/_internal/tun/', active: kind !== 'overview' },
23 { label: 'Endpoint reference', href: '/_internal/endpoints' },
24 ];
25 
26 let snapshot = $state<Snapshot | null>(null);
27 let loading = $state(true);
28 let error = $state('');
29 let pending: AbortController | undefined;
30 
31 async function refresh() {
32 pending?.abort();
33 const controller = new AbortController();
34 pending = controller;
35 let timedOut = false;
36 const timeout = window.setTimeout(() => {
37 timedOut = true;
38 controller.abort();
39 }, 12_000);
40 loading = true;
41 error = '';
42 
43 try {
44 const response = await fetch(endpoint, {
45 signal: controller.signal,
46 credentials: 'same-origin',
47 cache: 'no-store',
48 headers: { Accept: 'application/json' },
49 });
50 if (!response.ok) {
51 throw new Error(response.status === 401
52 ? 'Authentication required (401).'
53 : response.status === 403
54 ? 'Access denied (403).'
55 : `Server returned ${response.status}.`);
56 }
57 const data = await response.json();
58 if (!data || typeof data !== 'object' || !Array.isArray(data[kind === 'overview' ? 'vnodes' : kind === 'clients' ? 'clients' : 'tunnels'])) {
59 throw new Error('Unexpected server response.');
60 }
61 if (controller.signal.aborted) return;
62 snapshot = kind === 'overview'
63 ? { kind, data: data as OverviewData }
64 : kind === 'clients'
65 ? { kind, data: data as ClientList }
66 : { kind, data: data as TunnelDetail };
67 } catch (cause) {
68 if (controller.signal.aborted && !timedOut) return;
69 error = timedOut ? 'Request timed out.' : cause instanceof Error ? cause.message : 'Request failed.';
70 } finally {
71 window.clearTimeout(timeout);
72 if (pending === controller) {
73 pending = undefined;
74 loading = false;
75 }
76 }
77 }
78 
79 onMount(() => {
80 void refresh();
81 return () => pending?.abort();
82 });
83</script>
84 
85<svelte:head><title>{title} · Specter</title></svelte:head>
86 
87<Shell label="Operator" home="/_internal/" {navigation}>
88 {#if kind === 'tunnels'}
89 <a class="mb-2 inline-flex min-h-10 items-center text-accent-300 underline underline-offset-4 hover:text-accent-100" href="/_internal/tun/">← Back to clients</a>
90 {/if}
91 <div class="mb-2 flex flex-wrap items-center justify-between gap-3">
92 <h1 class="text-[22px] leading-tight font-semibold tracking-tight sm:text-2xl">{title}</h1>
93 <div class="flex flex-wrap items-center gap-2">
94 {#if kind === 'overview'}
95 <a class="inline-flex min-h-10 items-center rounded-md border border-zinc-500 bg-surface px-3 py-1.5 text-sm leading-5 font-semibold text-foreground hover:border-zinc-400 hover:bg-zinc-700" href={endpoint}>View JSON</a>
96 {/if}
97 <Button variant="secondary" onclick={refresh} disabled={loading}>{loading ? 'Refreshing…' : 'Refresh'}</Button>
98 </div>
99 </div>
100 {#if error}
101 <div class="my-4 rounded-md border border-danger/40 bg-surface px-4 py-3" role="alert">
102 <p class="font-medium text-danger">{snapshot ? 'Refresh failed. Showing previous snapshot.' : 'Could not load this view.'}</p>
103 <details class="mt-1">
104 <summary class="min-h-8 cursor-pointer py-1 text-muted">Request error</summary>
105 <p class="mt-1 max-w-[72ch] font-mono text-xs wrap-anywhere">{error}</p>
106 </details>
107 </div>
108 {/if}
109 <div aria-busy={loading}>
110 {#if snapshot?.kind === 'overview'}
111 <Overview data={snapshot.data} />
112 {:else if snapshot?.kind === 'clients'}
113 <Clients data={snapshot.data} />
114 {:else if snapshot?.kind === 'tunnels'}
115 <ClientTunnels data={snapshot.data} />
116 {:else if loading}
117 <div class="mt-5 space-y-3" role="status">
118 <span class="sr-only">Loading {title.toLowerCase()}…</span>
119 <div class="h-4 w-48 max-w-full rounded bg-surface" aria-hidden="true"></div>
120 <div class="h-36 rounded-md border border-line bg-chrome" aria-hidden="true"></div>
121 </div>
122 {/if}
123 </div>
124</Shell>