feat: add machines page and the boilerplate query
This commit is contained in:
parent
bb493f8e1b
commit
c6c164315b
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
PUBLIC_HEADSCALE_URL=https://tailscale.example.com
|
||||||
|
PUBLIC_API_KEY=abcdefghijklmnopqrstuvwxyz
|
||||||
22
src/app.css
Normal file
22
src/app.css
Normal 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
13
src/app.d.ts
vendored
Normal 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
12
src/app.html
Normal 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
18
src/lib/api/index.ts
Normal 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>
|
||||||
|
}
|
||||||
22
src/lib/components/Link.svelte
Normal file
22
src/lib/components/Link.svelte
Normal 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
1
src/lib/index.ts
Normal 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
29
src/lib/types/Machine.ts
Normal 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
13
src/lib/types/Route.ts
Normal 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
5
src/lib/types/User.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export type User = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
3
src/lib/types/index.ts
Normal file
3
src/lib/types/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './Machine'
|
||||||
|
export * from './Route'
|
||||||
|
export * from './User'
|
||||||
62
src/lib/utils.ts
Normal file
62
src/lib/utils.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
9
src/routes/+layout.server.ts
Normal file
9
src/routes/+layout.server.ts
Normal 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
50
src/routes/+layout.svelte
Normal 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
15
src/routes/+layout.ts
Normal 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 }
|
||||||
|
}
|
||||||
96
src/routes/machines/+page.svelte
Normal file
96
src/routes/machines/+page.svelte
Normal 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}
|
||||||
15
src/routes/machines/+page.ts
Normal file
15
src/routes/machines/+page.ts
Normal 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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user