diff --git a/app/components/Toaster.tsx b/app/components/Toaster.tsx new file mode 100644 index 0000000..b858340 --- /dev/null +++ b/app/components/Toaster.tsx @@ -0,0 +1,47 @@ +import { useToaster } from 'react-hot-toast/headless' + +export default function Toaster() { + const { toasts, handlers } = useToaster() + const { startPause, endPause, calculateOffset, updateHeight } = handlers + + return ( +
+ {toasts.slice(0, 6).map(toast => { + const offset = calculateOffset(toast, { + reverseOrder: false, + gutter: -8 + }) + + // eslint-disable-next-line @typescript-eslint/ban-types + const reference = (element: HTMLDivElement | null) => { + if (element && typeof toast.height !== 'number') { + const { height } = element.getBoundingClientRect() + updateHeight(toast.id, -height) + } + } + + return ( +
+ {typeof toast.message === 'function' ? ( + toast.message(toast) + ) : ( + toast.message + )} +
+ ) + })} +
+ ) +} diff --git a/app/root.tsx b/app/root.tsx index e56a86b..bd5ce9c 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -11,6 +11,7 @@ import { } from '@remix-run/react' import clsx from 'clsx' +import Toaster from '~/components/Toaster' import stylesheet from '~/tailwind.css?url' export const meta: MetaFunction = () => [ @@ -50,6 +51,7 @@ export function Layout({ children }: { readonly children: React.ReactNode }) { {children} + diff --git a/app/routes/_data.machines.tsx b/app/routes/_data.machines.tsx index 0360e7b..987245e 100644 --- a/app/routes/_data.machines.tsx +++ b/app/routes/_data.machines.tsx @@ -1,32 +1,94 @@ -export default function Index() { +import { ClipboardIcon } from '@heroicons/react/24/outline' +import { type LoaderFunctionArgs } from '@remix-run/node' +import { useLoaderData } from '@remix-run/react' +import clsx from 'clsx' +import { toast } from 'react-hot-toast/headless' + +import { type Machine } from '~/types' +import { pull } from '~/utils/headscale' +import { getSession } from '~/utils/sessions' +import { useLiveData } from '~/utils/useLiveData' + +export async function loader({ request }: LoaderFunctionArgs) { + const session = await getSession(request.headers.get('Cookie')) + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!) + return data.nodes +} + +export default function Page() { + const data = useLoaderData() + useLiveData({ interval: 3000 }) + return ( -
-

Welcome to Remix

- -
+ + + + + + + + + + {data.map(machine => ( + + + + + + ))} + +
NameIP AddressesLast Seen
+ +

{machine.givenName}

+ {machine.name} + +
+
+ {machine.ipAddresses.map((ip, index) => ( + + ))} + + + + + +

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

+
+
) } diff --git a/app/types/Machine.ts b/app/types/Machine.ts new file mode 100644 index 0000000..d0a954e --- /dev/null +++ b/app/types/Machine.ts @@ -0,0 +1,28 @@ +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/app/types/Route.ts b/app/types/Route.ts new file mode 100644 index 0000000..3d0a039 --- /dev/null +++ b/app/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/app/types/User.ts b/app/types/User.ts new file mode 100644 index 0000000..2160627 --- /dev/null +++ b/app/types/User.ts @@ -0,0 +1,5 @@ +export type User = { + id: string; + name: string; + createdAt: Date; +} diff --git a/app/types/index.ts b/app/types/index.ts new file mode 100644 index 0000000..64063d4 --- /dev/null +++ b/app/types/index.ts @@ -0,0 +1,3 @@ +export * from './Machine' +export * from './Route' +export * from './User' diff --git a/app/utils/useLiveData.ts b/app/utils/useLiveData.ts new file mode 100644 index 0000000..d0d0824 --- /dev/null +++ b/app/utils/useLiveData.ts @@ -0,0 +1,16 @@ +import { useRevalidator } from '@remix-run/react' +import { useInterval } from 'usehooks-ts' + +type Properties = { + interval: number; +} + +export function useLiveData({ interval }: Properties) { + const revalidator = useRevalidator() + useInterval(() => { + if (revalidator.state === 'idle') { + revalidator.revalidate() + } + }, interval) +} + diff --git a/package.json b/package.json index e7b424c..8d55179 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "isbot": "^4.1.0", "oauth4webapi": "^2.10.3", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-hot-toast": "^2.4.1", + "usehooks-ts": "^3.0.2" }, "devDependencies": { "@remix-run/dev": "^2.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7c3465..0e95f3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,12 @@ dependencies: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-hot-toast: + specifier: ^2.4.1 + version: 2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0) + usehooks-ts: + specifier: ^3.0.2 + version: 3.0.2(react@18.2.0) devDependencies: '@remix-run/dev': @@ -2227,7 +2233,6 @@ packages: /csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - dev: true /data-uri-to-buffer@3.0.1: resolution: {integrity: sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==} @@ -3280,6 +3285,14 @@ packages: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} dev: true + /goober@2.1.14(csstype@3.1.3): + resolution: {integrity: sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==} + peerDependencies: + csstype: ^3.0.10 + dependencies: + csstype: 3.1.3 + dev: false + /gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: @@ -3905,7 +3918,6 @@ packages: /lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} - dev: true /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -5201,6 +5213,20 @@ packages: scheduler: 0.23.0 dev: false + /react-hot-toast@2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==} + engines: {node: '>=10'} + peerDependencies: + react: '>=16' + react-dom: '>=16' + dependencies: + goober: 2.1.14(csstype@3.1.3) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + transitivePeerDependencies: + - csstype + dev: false + /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: true @@ -6197,6 +6223,16 @@ packages: punycode: 2.3.1 dev: true + /usehooks-ts@3.0.2(react@18.2.0): + resolution: {integrity: sha512-qJScCj8YOxa8RV3Iz2T+2IsydLG0EID5FouTGE7aNFEpFlCXmRrnJiPCESDArKr1FLTaUQSfDQ43UDn7yMLExw==} + engines: {node: '>=16.15.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + dependencies: + lodash.debounce: 4.0.8 + react: 18.2.0 + dev: false + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true