feat: add machines page and the boilerplate query

This commit is contained in:
Aarnav Tale 2024-03-17 19:35:06 -04:00
parent bb493f8e1b
commit c6c164315b
No known key found for this signature in database
GPG Key ID: 1CA889B6ACCAF2F2
17 changed files with 387 additions and 0 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
PUBLIC_HEADSCALE_URL=https://tailscale.example.com
PUBLIC_API_KEY=abcdefghijklmnopqrstuvwxyz

22
src/app.css Normal file
View File

@ -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;
}

13
src/app.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
import 'unplugin-icons/types/svelte'
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export { };

12
src/app.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

18
src/lib/api/index.ts Normal file
View File

@ -0,0 +1,18 @@
import { env } from '$env/dynamic/public'
export async function pull<T>(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<T>
}

View File

@ -0,0 +1,22 @@
<script lang="ts">
import { page } from "$app/stores";
import { base } from "$app/paths";
import clsx from "clsx";
export let to: string;
export let name: string;
export let icon: any;
</script>
<a
href={`${base}${to}`}
class={clsx(
"flex items-center gap-x-2 p-4 border-b-2",
$page.url.pathname === `${base}${to}`
? "border-white"
: "border-transparent",
)}
>
<svelte:component this={icon} stroke={1} size={24} />
{name}
</a>

1
src/lib/index.ts Normal file
View File

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

29
src/lib/types/Machine.ts Normal file
View File

@ -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
}

13
src/lib/types/Route.ts Normal file
View File

@ -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;
}

5
src/lib/types/User.ts Normal file
View File

@ -0,0 +1,5 @@
export type User = {
id: string;
name: string;
createdAt: Date;
}

3
src/lib/types/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from './Machine'
export * from './Route'
export * from './User'

62
src/lib/utils.ts Normal file
View File

@ -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, number | string | undefined>
): 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
};
};

View File

@ -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<LayoutServerLoad>[0]) {
if (url.pathname === base) {
redirect(307, `${base}/machines`);
}
}

50
src/routes/+layout.svelte Normal file
View File

@ -0,0 +1,50 @@
<script lang="ts">
import "../app.css";
import Link from "$lib/components/Link.svelte";
import { SvelteToast } from "@zerodevx/svelte-toast";
import {
type QueryClient,
QueryClientProvider,
} from "@tanstack/svelte-query";
import {
IconBrandFlightradar24,
IconServer,
IconUsers,
} from "@tabler/icons-svelte";
type Data = {
queryClient: QueryClient;
};
export let data: Data;
</script>
<header class="bg-gray-800 text-white mb-16">
<nav class="flex items-end gap-x-8 container mx-auto">
<div class="flex items-center gap-x-2 p-4">
<IconBrandFlightradar24 stroke={1} size={24} />
<h1 class="text-2xl">Headplane</h1>
</div>
<div class="flex items-center gap-x-4">
<Link to="/machines" name="Machines" icon={IconServer} />
<Link to="/users" name="Users" icon={IconUsers} />
</div>
</nav>
</header>
<QueryClientProvider client={data.queryClient}>
<main class="container mx-auto">
<slot />
</main>
</QueryClientProvider>
<SvelteToast
options={{
duration: 2000,
initial: 2000,
dismissable: false,
reversed: true,
intro: { y: "100%" },
}}
/>

15
src/routes/+layout.ts Normal file
View File

@ -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 }
}

View File

@ -0,0 +1,96 @@
<script lang="ts">
import { IconCircleFilled, IconCopy } from "@tabler/icons-svelte";
import { toast } from "@zerodevx/svelte-toast";
import type { Machine } from "$lib/types";
import clsx from "clsx";
import { createQuery } from "@tanstack/svelte-query";
import { pull } from "$lib/api";
const query = createQuery({
queryKey: ["machines"],
queryFn: async () => {
const data = await pull<{ nodes: Machine[] }>("v1/node");
return data.nodes;
},
});
</script>
<svelte:head>
<title>Machines</title>
</svelte:head>
{#if $query.isLoading}
<p>Loading...</p>
{:else if $query.isError}
<p>Error: {$query.error.message}</p>
{:else if $query.isSuccess}
<table class="table-auto w-full rounded-lg">
<thead>
<tr class="text-left">
<th class="pl-4">Name</th>
<th>IP Addresses</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each $query.data as machine}
<tr class="hover:bg-gray-100">
<td class="pt-2 pb-4 pl-4">
<h1>{machine.givenName}</h1>
<span class="text-sm font-mono text-gray-500"
>{machine.name}</span
>
</td>
<td class="pt-2 pb-4 font-mono text-gray-600">
{#each machine.ipAddresses as ip, i}
<span
class={clsx(
"flex items-center gap-x-1",
i > 0 && "text-sm text-gray-400",
)}
>
{ip}
<button
class="focus:outline-none"
on:click={() => {
navigator.clipboard.writeText(ip);
toast.push("Copied IP address");
}}
>
<IconCopy
stroke={1}
size={16}
class="text-gray-400"
/>
</button>
</span>
{/each}
</td>
<td>
<span
class="flex items-center gap-x-1 text-sm text-gray-500"
>
<IconCircleFilled
stroke={1}
size={24}
class={clsx(
"w-4 h-4",
machine.online
? "text-green-700"
: "text-gray-300",
)}
/>
<p>
{machine.online
? "Connected"
: new Date(
machine.lastSeen,
).toLocaleString()}
</p>
</span>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}

View File

@ -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<PageLoad>[0]) {
const { queryClient } = await parent();
await queryClient.prefetchQuery({
queryKey: ['machines'],
queryFn: async () => {
const data = await pull<{ nodes: Machine[] }>('v1/node');
return data.nodes;
},
});
}