File
Blob: ui/operator/App.svelte
| 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> |