feat: facelift overall machines page and refactor
This commit is contained in:
parent
c6930732ee
commit
252e78d618
@ -26,6 +26,8 @@ type Properties = {
|
|||||||
readonly parameters?: HookParameters;
|
readonly parameters?: HookParameters;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OpenFunction = (overrides?: Overrides) => void
|
||||||
|
|
||||||
export default function useModal(properties?: HookParameters) {
|
export default function useModal(properties?: HookParameters) {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [liveProperties, setLiveProperties] = useState(properties)
|
const [liveProperties, setLiveProperties] = useState(properties)
|
||||||
|
|||||||
@ -59,7 +59,7 @@ export default function Page() {
|
|||||||
const [acl, setAcl] = useState(data.currentAcl)
|
const [acl, setAcl] = useState(data.currentAcl)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mx-16'>
|
<div>
|
||||||
{data.hasAclWrite ? undefined : (
|
{data.hasAclWrite ? undefined : (
|
||||||
<div className='mb-4'>
|
<div className='mb-4'>
|
||||||
<Notice>
|
<Notice>
|
||||||
|
|||||||
@ -1,208 +0,0 @@
|
|||||||
/* eslint-disable unicorn/filename-case */
|
|
||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
||||||
|
|
||||||
import { ClipboardIcon, EllipsisHorizontalIcon } from '@heroicons/react/24/outline'
|
|
||||||
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
|
|
||||||
import { Link, useFetcher, useLoaderData } from '@remix-run/react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { toast } from 'react-hot-toast/headless'
|
|
||||||
|
|
||||||
import Dropdown from '~/components/Dropdown'
|
|
||||||
import useModal from '~/components/Modal'
|
|
||||||
import StatusCircle from '~/components/StatusCircle'
|
|
||||||
import { type Machine } from '~/types'
|
|
||||||
import { del, 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'))
|
|
||||||
|
|
||||||
const data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
|
|
||||||
return data.nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function action({ request }: ActionFunctionArgs) {
|
|
||||||
const data = await request.json() as { id?: string }
|
|
||||||
if (!data.id) {
|
|
||||||
return json({ message: 'No ID provided' }, {
|
|
||||||
status: 400
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
|
||||||
if (!session.has('hsApiKey')) {
|
|
||||||
return json({ message: 'Unauthorized' }, {
|
|
||||||
status: 401
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await del(`v1/node/${data.id}`, session.get('hsApiKey')!)
|
|
||||||
return json({ message: 'Machine removed' })
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
useLiveData({ interval: 3000 })
|
|
||||||
const data = useLoaderData<typeof loader>()
|
|
||||||
const fetcher = useFetcher()
|
|
||||||
|
|
||||||
const { Modal, open } = useModal()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{Modal}
|
|
||||||
<table className='table-auto w-full rounded-lg'>
|
|
||||||
<thead className='text-gray-500 dark:text-gray-400'>
|
|
||||||
<tr className='text-left uppercase text-sm font-bold'>
|
|
||||||
<th className='pl-4'>Name</th>
|
|
||||||
<th>IP Addresses</th>
|
|
||||||
<th>Last Seen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className={clsx(
|
|
||||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
|
||||||
'border-t border-zinc-200 dark:border-zinc-700'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{data.map(machine => {
|
|
||||||
const tags = [...machine.forcedTags, ...machine.validTags]
|
|
||||||
return (
|
|
||||||
<tr key={machine.id} className='hover:bg-zinc-100 dark:hover:bg-zinc-800 group'>
|
|
||||||
<td className='py-2 pl-4'>
|
|
||||||
<Link to={`/machines/${machine.id}`}>
|
|
||||||
<h1>{machine.givenName}</h1>
|
|
||||||
<span className='text-sm font-mono text-gray-500 dark:text-gray-400'>
|
|
||||||
{machine.name}
|
|
||||||
</span>
|
|
||||||
<div className='flex gap-1 mt-1'>
|
|
||||||
{tags.map(tag => (
|
|
||||||
<span key={tag} className='text-xs bg-gray-200 text-gray-600 rounded-sm px-1 py-0.5'>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</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 className='py-2'>
|
|
||||||
<span
|
|
||||||
className='flex items-center gap-x-1 text-sm text-gray-500 dark:text-gray-400'
|
|
||||||
>
|
|
||||||
<StatusCircle isOnline={machine.online} className='w-4 h-4'/>
|
|
||||||
<p>
|
|
||||||
{machine.online
|
|
||||||
? 'Connected'
|
|
||||||
: new Date(
|
|
||||||
machine.lastSeen
|
|
||||||
).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className='py-2 pr-4'>
|
|
||||||
<div className={clsx(
|
|
||||||
'border border-transparent rounded-lg py-0.5 w-10',
|
|
||||||
'group-hover:border-gray-200 dark:group-hover:border-zinc-700'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Dropdown
|
|
||||||
className='left-1/4'
|
|
||||||
width='w-48'
|
|
||||||
button={(
|
|
||||||
<EllipsisHorizontalIcon className='w-5 h-5'/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Dropdown.Item variant='static'>
|
|
||||||
<button
|
|
||||||
disabled
|
|
||||||
type='button'
|
|
||||||
className='text-left w-full opacity-50'
|
|
||||||
onClick={() => {
|
|
||||||
open()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Edit machine name
|
|
||||||
</button>
|
|
||||||
</Dropdown.Item>
|
|
||||||
<Dropdown.Item variant='static'>
|
|
||||||
<button
|
|
||||||
disabled
|
|
||||||
type='button'
|
|
||||||
className='text-left w-full opacity-50'
|
|
||||||
onClick={() => {
|
|
||||||
open()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Edit route settings
|
|
||||||
</button>
|
|
||||||
</Dropdown.Item>
|
|
||||||
<Dropdown.Item variant='static'>
|
|
||||||
<button
|
|
||||||
disabled
|
|
||||||
type='button'
|
|
||||||
className='text-left w-full opacity-50'
|
|
||||||
onClick={() => {
|
|
||||||
open()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Edit ACL tags
|
|
||||||
</button>
|
|
||||||
</Dropdown.Item>
|
|
||||||
<Dropdown.Item>
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
className='text-left text-red-700 w-full'
|
|
||||||
onClick={() => {
|
|
||||||
open({
|
|
||||||
title: 'Remove Machine',
|
|
||||||
description: [
|
|
||||||
'This action is irreversible and will disconnect the machine from the Headscale server.',
|
|
||||||
'All data associated with this machine including ACLs and tags will be lost.'
|
|
||||||
].join('\n'),
|
|
||||||
variant: 'danger',
|
|
||||||
buttonText: 'Remove',
|
|
||||||
onConfirm: () => {
|
|
||||||
fetcher.submit(
|
|
||||||
{
|
|
||||||
id: machine.id
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'DELETE',
|
|
||||||
encType: 'application/json'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</Dropdown.Item>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
204
app/routes/_data.machines._index/machine.tsx
Normal file
204
app/routes/_data.machines._index/machine.tsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import { ChevronDownIcon, ClipboardIcon, EllipsisHorizontalIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { type FetcherWithComponents, Link } from '@remix-run/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import toast from 'react-hot-toast/headless'
|
||||||
|
|
||||||
|
import Dropdown from '~/components/Dropdown'
|
||||||
|
import type { OpenFunction } from '~/components/Modal'
|
||||||
|
import StatusCircle from '~/components/StatusCircle'
|
||||||
|
import { type Machine } from '~/types'
|
||||||
|
|
||||||
|
type MachineProperties = {
|
||||||
|
readonly machine: Machine;
|
||||||
|
readonly open: OpenFunction;
|
||||||
|
readonly fetcher: FetcherWithComponents<unknown>;
|
||||||
|
readonly magic?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MachineRow({ machine, open, fetcher, magic }: MachineProperties) {
|
||||||
|
const tags = [...machine.forcedTags, ...machine.validTags]
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={machine.id}
|
||||||
|
className='hover:bg-zinc-100 dark:hover:bg-zinc-800 group'
|
||||||
|
>
|
||||||
|
<td className='pl-0.5 py-2'>
|
||||||
|
<Link
|
||||||
|
to={`/machines/${machine.id}`}
|
||||||
|
className='group/link h-full'
|
||||||
|
>
|
||||||
|
<p className={clsx(
|
||||||
|
'font-semibold leading-snug',
|
||||||
|
'group-hover/link:text-blue-600',
|
||||||
|
'group-hover/link:dark:text-blue-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{machine.givenName}
|
||||||
|
</p>
|
||||||
|
<p className='text-sm text-gray-400 font-mono'>
|
||||||
|
{machine.name}
|
||||||
|
</p>
|
||||||
|
<div className='flex gap-1 mt-1'>
|
||||||
|
{tags.map(tag => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className={clsx(
|
||||||
|
'text-xs rounded-sm px-1 py-0.5',
|
||||||
|
'bg-gray-100 dark:bg-zinc-700',
|
||||||
|
'text-gray-600 dark:text-gray-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className='py-2'>
|
||||||
|
<div className='flex items-center gap-x-1'>
|
||||||
|
{machine.ipAddresses[0]}
|
||||||
|
<Dropdown
|
||||||
|
width='w-max'
|
||||||
|
button={(
|
||||||
|
<ChevronDownIcon className='w-4 h-4'/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{machine.ipAddresses.map(ip => (
|
||||||
|
<Dropdown.Item key={ip}>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-x-1.5 text-sm',
|
||||||
|
'justify-between w-full'
|
||||||
|
)}
|
||||||
|
onClick={async () => {
|
||||||
|
await navigator.clipboard.writeText(ip)
|
||||||
|
toast('Copied IP address to clipboard')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ip}
|
||||||
|
<ClipboardIcon className='w-3 h-3'/>
|
||||||
|
</button>
|
||||||
|
</Dropdown.Item>
|
||||||
|
))}
|
||||||
|
{magic ? (
|
||||||
|
<Dropdown.Item>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-x-1.5 text-sm',
|
||||||
|
'justify-between w-full break-keep'
|
||||||
|
)}
|
||||||
|
onClick={async () => {
|
||||||
|
const ip = `${machine.givenName}.${machine.user.name}.${magic}`
|
||||||
|
await navigator.clipboard.writeText(ip)
|
||||||
|
toast('Copied hostname to clipboard')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{machine.givenName}.{machine.user.name}.{magic}
|
||||||
|
<ClipboardIcon className='w-3 h-3'/>
|
||||||
|
</button>
|
||||||
|
</Dropdown.Item>
|
||||||
|
) : undefined}
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className='py-2'>
|
||||||
|
<span
|
||||||
|
className='flex items-center gap-x-1 text-sm text-gray-500 dark:text-gray-400'
|
||||||
|
>
|
||||||
|
<StatusCircle isOnline={machine.online} className='w-4 h-4'/>
|
||||||
|
<p>
|
||||||
|
{machine.online
|
||||||
|
? 'Connected'
|
||||||
|
: new Date(
|
||||||
|
machine.lastSeen
|
||||||
|
).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className='py-2 pr-0.5'>
|
||||||
|
<div className={clsx(
|
||||||
|
'border border-transparent rounded-lg py-0.5 w-10',
|
||||||
|
'group-hover:border-gray-200 dark:group-hover:border-zinc-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
className='left-1/4'
|
||||||
|
width='w-48'
|
||||||
|
button={(
|
||||||
|
<EllipsisHorizontalIcon className='w-5 h-5'/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Dropdown.Item variant='static'>
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
type='button'
|
||||||
|
className='text-left w-full opacity-50'
|
||||||
|
onClick={() => {
|
||||||
|
open()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit machine name
|
||||||
|
</button>
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item variant='static'>
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
type='button'
|
||||||
|
className='text-left w-full opacity-50'
|
||||||
|
onClick={() => {
|
||||||
|
open()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit route settings
|
||||||
|
</button>
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item variant='static'>
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
type='button'
|
||||||
|
className='text-left w-full opacity-50'
|
||||||
|
onClick={() => {
|
||||||
|
open()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit ACL tags
|
||||||
|
</button>
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='text-left text-red-700 w-full'
|
||||||
|
onClick={() => {
|
||||||
|
open({
|
||||||
|
title: 'Remove Machine',
|
||||||
|
description: [
|
||||||
|
'This action is irreversible and will disconnect the machine from the Headscale server.',
|
||||||
|
'All data associated with this machine including ACLs and tags will be lost.'
|
||||||
|
].join('\n'),
|
||||||
|
variant: 'danger',
|
||||||
|
buttonText: 'Remove',
|
||||||
|
onConfirm: () => {
|
||||||
|
fetcher.submit(
|
||||||
|
{
|
||||||
|
id: machine.id
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
encType: 'application/json'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
app/routes/_data.machines._index/route.tsx
Normal file
121
app/routes/_data.machines._index/route.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
|
||||||
|
import { InformationCircleIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
|
||||||
|
import { useFetcher, useLoaderData } from '@remix-run/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
|
||||||
|
|
||||||
|
import Code from '~/components/Code'
|
||||||
|
import useModal from '~/components/Modal'
|
||||||
|
import { type Machine } from '~/types'
|
||||||
|
import { getConfig, getContext } from '~/utils/config'
|
||||||
|
import { del, pull } from '~/utils/headscale'
|
||||||
|
import { getSession } from '~/utils/sessions'
|
||||||
|
import { useLiveData } from '~/utils/useLiveData'
|
||||||
|
|
||||||
|
import MachineRow from './machine'
|
||||||
|
|
||||||
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
|
const session = await getSession(request.headers.get('Cookie'))
|
||||||
|
|
||||||
|
const data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
|
||||||
|
const context = await getContext()
|
||||||
|
|
||||||
|
let magic: string | undefined
|
||||||
|
if (context.hasConfig) {
|
||||||
|
const config = await getConfig()
|
||||||
|
if (config.dns_config.magic_dns) {
|
||||||
|
magic = config.dns_config.base_domain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: data.nodes,
|
||||||
|
magic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
|
const data = await request.json() as { id?: string }
|
||||||
|
if (!data.id) {
|
||||||
|
return json({ message: 'No ID provided' }, {
|
||||||
|
status: 400
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getSession(request.headers.get('Cookie'))
|
||||||
|
if (!session.has('hsApiKey')) {
|
||||||
|
return json({ message: 'Unauthorized' }, {
|
||||||
|
status: 401
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await del(`v1/node/${data.id}`, session.get('hsApiKey')!)
|
||||||
|
return json({ message: 'Machine removed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
useLiveData({ interval: 3000 })
|
||||||
|
const data = useLoaderData<typeof loader>()
|
||||||
|
const fetcher = useFetcher()
|
||||||
|
|
||||||
|
const { Modal, open } = useModal()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Modal}
|
||||||
|
<h1 className='text-2xl font-medium mb-4'>Machines</h1>
|
||||||
|
<table className='table-auto w-full rounded-lg'>
|
||||||
|
<thead className='text-gray-500 dark:text-gray-400'>
|
||||||
|
<tr className='text-left uppercase text-xs font-bold px-0.5'>
|
||||||
|
<th className='pb-2'>Name</th>
|
||||||
|
<th className='pb-2'>
|
||||||
|
<div className='flex items-center gap-x-1'>
|
||||||
|
Addresses
|
||||||
|
{data.magic ? (
|
||||||
|
<TooltipTrigger delay={0}>
|
||||||
|
<Button>
|
||||||
|
<InformationCircleIcon className='w-4 h-4 text-gray-400'/>
|
||||||
|
</Button>
|
||||||
|
<Tooltip className={clsx(
|
||||||
|
'text-sm max-w-xs p-2 rounded-lg mb-2',
|
||||||
|
'bg-white dark:bg-zinc-800',
|
||||||
|
'border border-gray-200 dark:border-zinc-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Since MagicDNS is enabled, you can access devices
|
||||||
|
based on their name and also at
|
||||||
|
{' '}
|
||||||
|
<Code>
|
||||||
|
[name].[user].{data.magic}
|
||||||
|
</Code>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipTrigger>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className='pb-2'>Last Seen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className={clsx(
|
||||||
|
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
||||||
|
'border-t border-zinc-200 dark:border-zinc-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{data.nodes.map(machine => (
|
||||||
|
<MachineRow
|
||||||
|
key={machine.id}
|
||||||
|
// Typescript isn't smart enough yet
|
||||||
|
machine={machine as unknown as Machine}
|
||||||
|
fetcher={fetcher}
|
||||||
|
open={open}
|
||||||
|
magic={data.magic}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -45,7 +45,7 @@ export default function Layout() {
|
|||||||
const data = useLoaderData<typeof loader>()
|
const data = useLoaderData<typeof loader>()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className='mb-16 bg-gray-800 text-white dark:bg-gray-700'>
|
<header className='mb-6 bg-gray-800 text-white dark:bg-gray-700'>
|
||||||
<nav className='container mx-auto'>
|
<nav className='container mx-auto'>
|
||||||
<div className='flex items-center justify-between mb-8 pt-4'>
|
<div className='flex items-center justify-between mb-8 pt-4'>
|
||||||
<div className='flex items-center gap-x-2'>
|
<div className='flex items-center gap-x-2'>
|
||||||
|
|||||||
@ -28,6 +28,7 @@
|
|||||||
"isbot": "^4.1.0",
|
"isbot": "^4.1.0",
|
||||||
"oauth4webapi": "^2.10.3",
|
"oauth4webapi": "^2.10.3",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
"react-aria-components": "^1.1.1",
|
||||||
"react-codemirror-merge": "^4.21.25",
|
"react-codemirror-merge": "^4.21.25",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
|
|||||||
1446
pnpm-lock.yaml
1446
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,19 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
import type { Config } from 'tailwindcss'
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
content: ['./app/**/*.{js,jsx,ts,tsx}'],
|
content: ['./app/**/*.{js,jsx,ts,tsx}'],
|
||||||
theme: {
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: {
|
||||||
|
DEFAULT: '1rem',
|
||||||
|
sm: '2rem',
|
||||||
|
lg: '4rem',
|
||||||
|
xl: '5rem',
|
||||||
|
'2xl': '6rem'
|
||||||
|
}
|
||||||
|
},
|
||||||
extend: {
|
extend: {
|
||||||
height: {
|
height: {
|
||||||
editor: 'calc(100vh - 20rem)'
|
editor: 'calc(100vh - 20rem)'
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user