diff --git a/app/components/Attribute.tsx b/app/components/Attribute.tsx new file mode 100644 index 0000000..604a6c9 --- /dev/null +++ b/app/components/Attribute.tsx @@ -0,0 +1,39 @@ +import { ClipboardIcon } from '@heroicons/react/24/outline' +import toast from 'react-hot-toast/headless' + +type Properties = { + readonly name: string; + readonly value: string; + readonly isCopyable?: boolean; +} + +export default function Attribute({ name, value, isCopyable }: Properties) { + const canCopy = isCopyable ?? false + return ( +
+
+ {name} +
+ + {(canCopy ?? false) ? ( + + ) : ( +
+ {value} +
+ )} +
+ ) +} diff --git a/app/components/StatusCircle.tsx b/app/components/StatusCircle.tsx new file mode 100644 index 0000000..571334d --- /dev/null +++ b/app/components/StatusCircle.tsx @@ -0,0 +1,24 @@ +import clsx from 'clsx' +import { type HTMLProps } from 'react' + +type Properties = HTMLProps & { + readonly isOnline: boolean; +} + +// eslint-disable-next-line unicorn/no-keyword-prefix +export default function StatusCircle({ isOnline, className }: Properties) { + return ( + + + + ) +} diff --git a/app/routes/_data.machines.$id.tsx b/app/routes/_data.machines.$id.tsx new file mode 100644 index 0000000..13dc219 --- /dev/null +++ b/app/routes/_data.machines.$id.tsx @@ -0,0 +1,74 @@ +import { type LoaderFunctionArgs } from '@remix-run/node' +import { Link, useLoaderData } from '@remix-run/react' + +import Attribute from '~/components/Attribute' +import StatusCircle from '~/components/StatusCircle' +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, params }: LoaderFunctionArgs) { + const session = await getSession(request.headers.get('Cookie')) + if (!params.id) { + throw new Error('No machine ID provided') + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const data = await pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!) + return data.node +} + +export default function Page() { + const data = useLoaderData() + useLiveData({ interval: 1000 }) + + return ( +
+

+ + All Machines + + {' / '} + {data.givenName} +

+ +

+ {data.givenName} +

+ +
+
+ + + + + + + + + +
+
+ ) +} diff --git a/app/routes/_data.machines.tsx b/app/routes/_data.machines._index.tsx similarity index 85% rename from app/routes/_data.machines.tsx rename to app/routes/_data.machines._index.tsx index 987245e..3c49bea 100644 --- a/app/routes/_data.machines.tsx +++ b/app/routes/_data.machines._index.tsx @@ -1,9 +1,11 @@ +/* eslint-disable unicorn/filename-case */ import { ClipboardIcon } from '@heroicons/react/24/outline' import { type LoaderFunctionArgs } from '@remix-run/node' -import { useLoaderData } from '@remix-run/react' +import { Link, useLoaderData } from '@remix-run/react' import clsx from 'clsx' import { toast } from 'react-hot-toast/headless' +import StatusCircle from '~/components/StatusCircle' import { type Machine } from '~/types' import { pull } from '~/utils/headscale' import { getSession } from '~/utils/sessions' @@ -34,14 +36,14 @@ export default function Page() { {data.map(machine => ( - +

{machine.givenName}

{machine.name} -
+ {machine.ipAddresses.map((ip, index) => ( @@ -65,18 +67,7 @@ export default function Page() { - - - +

{machine.online ? 'Connected' diff --git a/app/utils/useLiveData.ts b/app/utils/useLiveData.ts index d0d0824..e3e1793 100644 --- a/app/utils/useLiveData.ts +++ b/app/utils/useLiveData.ts @@ -1,4 +1,5 @@ import { useRevalidator } from '@remix-run/react' +import { useEffect } from 'react' import { useInterval } from 'usehooks-ts' type Properties = { @@ -7,10 +8,27 @@ type Properties = { export function useLiveData({ interval }: Properties) { const revalidator = useRevalidator() + + // Handle normal stale-while-revalidate behavior useInterval(() => { if (revalidator.state === 'idle') { revalidator.revalidate() } }, interval) -} + useEffect(() => { + const handler = () => { + if (revalidator.state === 'idle') { + revalidator.revalidate() + } + } + + window.addEventListener('online', handler) + document.addEventListener('focus', handler) + + return () => { + window.removeEventListener('online', handler) + document.removeEventListener('focus', handler) + } + }, [revalidator]) +}