feat: add machine info page

This commit is contained in:
Aarnav Tale 2024-03-26 09:28:52 -04:00
parent b8498a9db3
commit 3b1f0ae6f8
No known key found for this signature in database
5 changed files with 162 additions and 16 deletions

View File

@ -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 (
<dl className='flex gap-1 text-sm'>
<dt className='w-1/4 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300'>
{name}
</dt>
{(canCopy ?? false) ? (
<button
type='button'
className='focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md'
onClick={async () => {
await navigator.clipboard.writeText(value)
toast(`Copied ${name}`)
}}
>
<dd className='min-w-0 truncate px-2 py-1'>
{value}
</dd>
<ClipboardIcon className='text-gray-600 dark:text-gray-200 pr-2 w-max h-4'/>
</button>
) : (
<dd className='min-w-0 truncate px-2 py-1'>
{value}
</dd>
)}
</dl>
)
}

View File

@ -0,0 +1,24 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
type Properties = HTMLProps<SVGElement> & {
readonly isOnline: boolean;
}
// eslint-disable-next-line unicorn/no-keyword-prefix
export default function StatusCircle({ isOnline, className }: Properties) {
return (
<svg
className={clsx(
className,
isOnline
? '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>
)
}

View File

@ -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<typeof loader>()
useLiveData({ interval: 1000 })
return (
<div>
<p className='mb-4 text-gray-500 dark:text-gray-400 text-sm'>
<Link
to='/machines'
className='font-bold text-gray-700 dark:text-gray-300 hover:underline'
>
All Machines
</Link>
{' / '}
{data.givenName}
</p>
<span className='flex items-baseline gap-x-4 text-sm mb-4'>
<h1 className='text-2xl font-bold'>
{data.givenName}
</h1>
<StatusCircle isOnline={data.online} className='w-4 h-4'/>
</span>
<div className='p-4 md:p-6 border dark:border-zinc-700 rounded-lg'>
<Attribute name='Creator' value={data.user.name}/>
<Attribute name='Node ID' value={data.id}/>
<Attribute name='Node Name' value={data.givenName}/>
<Attribute name='Hostname' value={data.name}/>
<Attribute
isCopyable
name='Node Key'
value={data.nodeKey}
/>
<Attribute
name='Created'
value={new Date(data.createdAt).toLocaleString()}
/>
<Attribute
name='Last Seen'
value={new Date(data.lastSeen).toLocaleString()}
/>
<Attribute
name='Expiry'
value={new Date(data.expiry).toLocaleString()}
/>
<Attribute
isCopyable
name='Domain'
value={`${data.givenName}.${data.user.name}.ts.net`}
/>
</div>
</div>
)
}

View File

@ -1,9 +1,11 @@
/* eslint-disable unicorn/filename-case */
import { ClipboardIcon } from '@heroicons/react/24/outline' import { ClipboardIcon } from '@heroicons/react/24/outline'
import { type LoaderFunctionArgs } from '@remix-run/node' 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 clsx from 'clsx'
import { toast } from 'react-hot-toast/headless' import { toast } from 'react-hot-toast/headless'
import StatusCircle from '~/components/StatusCircle'
import { type Machine } from '~/types' import { type Machine } from '~/types'
import { pull } from '~/utils/headscale' import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions'
@ -34,14 +36,14 @@ export default function Page() {
{data.map(machine => ( {data.map(machine => (
<tr key={machine.id} className='hover:bg-zinc-100 dark:hover:bg-zinc-800'> <tr key={machine.id} className='hover:bg-zinc-100 dark:hover:bg-zinc-800'>
<td className='pt-2 pb-4 pl-4'> <td className='pt-2 pb-4 pl-4'>
<a href={`machines/${machine.id}`}> <Link to={`/machines/${machine.id}`}>
<h1>{machine.givenName}</h1> <h1>{machine.givenName}</h1>
<span <span
className='text-sm font-mono text-gray-500 dark:text-gray-400' className='text-sm font-mono text-gray-500 dark:text-gray-400'
>{machine.name} >{machine.name}
</span </span
> >
</a> </Link>
</td> </td>
<td className='pt-2 pb-4 font-mono text-gray-600 dark:text-gray-300'> <td className='pt-2 pb-4 font-mono text-gray-600 dark:text-gray-300'>
{machine.ipAddresses.map((ip, index) => ( {machine.ipAddresses.map((ip, index) => (
@ -65,18 +67,7 @@ export default function Page() {
<span <span
className='flex items-center gap-x-1 text-sm text-gray-500 dark:text-gray-400' className='flex items-center gap-x-1 text-sm text-gray-500 dark:text-gray-400'
> >
<svg <StatusCircle isOnline={machine.online} className='w-4 h-4'/>
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> <p>
{machine.online {machine.online
? 'Connected' ? 'Connected'

View File

@ -1,4 +1,5 @@
import { useRevalidator } from '@remix-run/react' import { useRevalidator } from '@remix-run/react'
import { useEffect } from 'react'
import { useInterval } from 'usehooks-ts' import { useInterval } from 'usehooks-ts'
type Properties = { type Properties = {
@ -7,10 +8,27 @@ type Properties = {
export function useLiveData({ interval }: Properties) { export function useLiveData({ interval }: Properties) {
const revalidator = useRevalidator() const revalidator = useRevalidator()
// Handle normal stale-while-revalidate behavior
useInterval(() => { useInterval(() => {
if (revalidator.state === 'idle') { if (revalidator.state === 'idle') {
revalidator.revalidate() revalidator.revalidate()
} }
}, interval) }, 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])
}