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 (
-
+
+
+
+ | Name |
+ IP Addresses |
+ Last Seen |
+
+
+
+ {data.map(machine => (
+
+
+
+ {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