feat: add machine info page
This commit is contained in:
parent
b8498a9db3
commit
3b1f0ae6f8
39
app/components/Attribute.tsx
Normal file
39
app/components/Attribute.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
app/components/StatusCircle.tsx
Normal file
24
app/components/StatusCircle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
app/routes/_data.machines.$id.tsx
Normal file
74
app/routes/_data.machines.$id.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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'
|
||||||
@ -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])
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user