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