feat: add machine data
This commit is contained in:
parent
b38eb0885f
commit
abaf71636e
47
app/components/Toaster.tsx
Normal file
47
app/components/Toaster.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
className='fixed bottom-0 right-0 p-4 w-80 h-1/2 overflow-hidden'
|
||||
onMouseEnter={startPause}
|
||||
onMouseLeave={endPause}
|
||||
>
|
||||
{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 (
|
||||
<div
|
||||
key={toast.id}
|
||||
ref={reference}
|
||||
className='fixed bottom-4 right-4 p-4 bg-gray-800 rounded-lg text-white transition-all duration-300'
|
||||
{...toast.ariaProps}
|
||||
style={{
|
||||
transform: `translateY(${offset}px) translateX(${toast.visible ? 0 : 200}%)`
|
||||
}}
|
||||
>
|
||||
{typeof toast.message === 'function' ? (
|
||||
toast.message(toast)
|
||||
) : (
|
||||
toast.message
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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 }) {
|
||||
</head>
|
||||
<body className='overscroll-none'>
|
||||
{children}
|
||||
<Toaster/>
|
||||
<ScrollRestoration/>
|
||||
<Scripts/>
|
||||
</body>
|
||||
|
||||
@ -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<typeof loader>()
|
||||
useLiveData({ interval: 3000 })
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.8' }}>
|
||||
<h1>Welcome to Remix</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
target='_blank'
|
||||
href='https://remix.run/tutorials/blog'
|
||||
rel='noreferrer'
|
||||
>
|
||||
15m Quickstart Blog Tutorial
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
target='_blank'
|
||||
href='https://remix.run/tutorials/jokes'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Deep Dive Jokes App Tutorial
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a target='_blank' href='https://remix.run/docs' rel='noreferrer'>
|
||||
Remix Docs
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<table className='table-auto w-full rounded-lg'>
|
||||
<thead>
|
||||
<tr className='text-left'>
|
||||
<th className='pl-4'>Name</th>
|
||||
<th>IP Addresses</th>
|
||||
<th>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='divide-y divide-zinc-200 dark:divide-zinc-700'>
|
||||
{data.map(machine => (
|
||||
<tr key={machine.id} className='hover:bg-zinc-100 dark:hover:bg-zinc-800'>
|
||||
<td className='pt-2 pb-4 pl-4'>
|
||||
<a href={`machines/${machine.id}`}>
|
||||
<h1>{machine.givenName}</h1>
|
||||
<span
|
||||
className='text-sm font-mono text-gray-500 dark:text-gray-400'
|
||||
>{machine.name}
|
||||
</span
|
||||
>
|
||||
</a>
|
||||
</td>
|
||||
<td className='pt-2 pb-4 font-mono text-gray-600 dark:text-gray-300'>
|
||||
{machine.ipAddresses.map((ip, index) => (
|
||||
<button
|
||||
key={ip}
|
||||
type='button'
|
||||
className='flex items-center gap-x-1 w-full'
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(ip)
|
||||
toast('Copied IP address to clipboard')
|
||||
}}
|
||||
>
|
||||
<span className={clsx(index === 0 ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500')}>
|
||||
{ip}
|
||||
</span>
|
||||
<ClipboardIcon className='text-gray-400 dark:text-gray-500 w-4 h-4'/>
|
||||
</button>
|
||||
))}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className='flex items-center gap-x-1 text-sm text-gray-500 dark:text-gray-400'
|
||||
>
|
||||
<svg
|
||||
className={clsx(
|
||||
'w-4 h-4',
|
||||
machine.online
|
||||
? 'text-green-700 dark:text-green-400'
|
||||
: 'text-gray-300 dark:text-gray-500'
|
||||
)}
|
||||
viewBox='0 0 24 24'
|
||||
fill='currentColor'
|
||||
>
|
||||
<circle cx='12' cy='12' r='8'/>
|
||||
</svg>
|
||||
<p>
|
||||
{machine.online
|
||||
? 'Connected'
|
||||
: new Date(
|
||||
machine.lastSeen
|
||||
).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
28
app/types/Machine.ts
Normal file
28
app/types/Machine.ts
Normal file
@ -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;
|
||||
}
|
||||
13
app/types/Route.ts
Normal file
13
app/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
app/types/User.ts
Normal file
5
app/types/User.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
3
app/types/index.ts
Normal file
3
app/types/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './Machine'
|
||||
export * from './Route'
|
||||
export * from './User'
|
||||
16
app/utils/useLiveData.ts
Normal file
16
app/utils/useLiveData.ts
Normal file
@ -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)
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user