diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d3c3a0a --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +PUBLIC_HEADSCALE_URL=https://tailscale.example.com +PUBLIC_API_KEY=abcdefghijklmnopqrstuvwxyz diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..b89e7ad --- /dev/null +++ b/src/app.css @@ -0,0 +1,22 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --toastContainerBottom: 1.5rem; + --toastContainerRight: 2rem; + --toastContainerLeft: auto; + --toastContainerTop: auto; + + --toastBorderRadius: 0.5rem; + --toastBackground: #1f2937; + --toastMsgPadding: 0.5rem 0.75rem; +} + +._toastBtn { + display: none !important; +} + +._toastBar { + display: none !important; +} diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..1c6eb6c --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,13 @@ +import 'unplugin-icons/types/svelte' + +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export { }; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..77a5ff5 --- /dev/null +++ b/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts new file mode 100644 index 0000000..8905310 --- /dev/null +++ b/src/lib/api/index.ts @@ -0,0 +1,18 @@ +import { env } from '$env/dynamic/public' + +export async function pull(url: string) { + const prefix = env.PUBLIC_HEADSCALE_URL + const key = env.PUBLIC_API_KEY + + const res = await fetch(`${prefix}/api/${url}`, { + headers: { + 'Authorization': `Bearer ${key}` + } + }) + + if (!res.ok) { + throw new Error(res.statusText) + } + + return res.json() as Promise +} diff --git a/src/lib/components/Link.svelte b/src/lib/components/Link.svelte new file mode 100644 index 0000000..eea3378 --- /dev/null +++ b/src/lib/components/Link.svelte @@ -0,0 +1,22 @@ + + + + + {name} + diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/src/lib/types/Machine.ts b/src/lib/types/Machine.ts new file mode 100644 index 0000000..4eb273a --- /dev/null +++ b/src/lib/types/Machine.ts @@ -0,0 +1,29 @@ +import type { User } from "./User"; + +export type Machine = { + id: string; + machineKey: string; + nodeKey: string; + discoKey: string; + ipAddresses: string[]; + name: string; + + user: User; + lastSeen: Date; + expiry: Date; + + + preAuthKey?: unknown; // TODO + + createdAt: Date; + registerMethod: 'REGISTER_METHOD_UNSPECIFIED' + | 'REGISTER_METHOD_AUTH_KEY' + | 'REGISTER_METHOD_CLI' + | 'REGISTER_METHOD_OIDC' + + forcedTags: string[]; + invalidTags: string[]; + validTags: string[]; + givenName: string; + online: boolean +} diff --git a/src/lib/types/Route.ts b/src/lib/types/Route.ts new file mode 100644 index 0000000..4fde833 --- /dev/null +++ b/src/lib/types/Route.ts @@ -0,0 +1,13 @@ +import type { Machine } from "./Machine"; + +export type Route = { + id: string; + node: Machine; + prefix: string; + advertised: boolean; + enabled: boolean; + isPrimary: boolean; + createdAt: Date; + updatedAt: Date; + deletedAt: Date; +} diff --git a/src/lib/types/User.ts b/src/lib/types/User.ts new file mode 100644 index 0000000..2160627 --- /dev/null +++ b/src/lib/types/User.ts @@ -0,0 +1,5 @@ +export type User = { + id: string; + name: string; + createdAt: Date; +} diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts new file mode 100644 index 0000000..64063d4 --- /dev/null +++ b/src/lib/types/index.ts @@ -0,0 +1,3 @@ +export * from './Machine' +export * from './Route' +export * from './User' diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..230a1fb --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,62 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +import { cubicOut } from "svelte/easing"; +import type { TransitionConfig } from "svelte/transition"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +type FlyAndScaleParams = { + y?: number; + x?: number; + start?: number; + duration?: number; +}; + +export const flyAndScale = ( + node: Element, + params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 } +): TransitionConfig => { + const style = getComputedStyle(node); + const transform = style.transform === "none" ? "" : style.transform; + + const scaleConversion = ( + valueA: number, + scaleA: [number, number], + scaleB: [number, number] + ) => { + const [minA, maxA] = scaleA; + const [minB, maxB] = scaleB; + + const percentage = (valueA - minA) / (maxA - minA); + const valueB = percentage * (maxB - minB) + minB; + + return valueB; + }; + + const styleToString = ( + style: Record + ): string => { + return Object.keys(style).reduce((str, key) => { + if (style[key] === undefined) return str; + return str + `${key}:${style[key]};`; + }, ""); + }; + + return { + duration: params.duration ?? 200, + delay: 0, + css: (t) => { + const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]); + const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]); + const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]); + + return styleToString({ + transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`, + opacity: t + }); + }, + easing: cubicOut + }; +}; \ No newline at end of file diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..c34606c --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,9 @@ +import { redirect } from "@sveltejs/kit"; +import type { LayoutServerLoad } from './$types'; +import { base } from "$app/paths"; + +export async function load({ url }: Parameters[0]) { + if (url.pathname === base) { + redirect(307, `${base}/machines`); + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..520b05a --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,50 @@ + + +
+ +
+ + +
+ +
+
+ + diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts new file mode 100644 index 0000000..4fc3be6 --- /dev/null +++ b/src/routes/+layout.ts @@ -0,0 +1,15 @@ +import { browser } from '$app/environment' +import { QueryClient } from '@tanstack/svelte-query' + +export async function load() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + enabled: browser, + refetchInterval: 1000 + }, + }, + }) + + return { queryClient } +} diff --git a/src/routes/machines/+page.svelte b/src/routes/machines/+page.svelte new file mode 100644 index 0000000..0022180 --- /dev/null +++ b/src/routes/machines/+page.svelte @@ -0,0 +1,96 @@ + + + + Machines + + +{#if $query.isLoading} +

Loading...

+{:else if $query.isError} +

Error: {$query.error.message}

+{:else if $query.isSuccess} + + + + + + + + + + {#each $query.data as machine} + + + + + + {/each} + +
NameIP AddressesLast Seen
+

{machine.givenName}

+ {machine.name} +
+ {#each machine.ipAddresses as ip, i} + 0 && "text-sm text-gray-400", + )} + > + {ip} + + + {/each} + + + +

+ {machine.online + ? "Connected" + : new Date( + machine.lastSeen, + ).toLocaleString()} +

+
+
+{/if} diff --git a/src/routes/machines/+page.ts b/src/routes/machines/+page.ts new file mode 100644 index 0000000..52c331d --- /dev/null +++ b/src/routes/machines/+page.ts @@ -0,0 +1,15 @@ +import { pull } from "$lib/api"; +import type { Machine } from "$lib/types"; +import type { PageLoad } from './$types'; + +export async function load({ parent }: Parameters[0]) { + const { queryClient } = await parent(); + + await queryClient.prefetchQuery({ + queryKey: ['machines'], + queryFn: async () => { + const data = await pull<{ nodes: Machine[] }>('v1/node'); + return data.nodes; + }, + }); +}