mirror of
https://github.com/tale/headplane.git
synced 2026-09-01 09:48:24 +00:00
feat: refactor all dashboard routes
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { useSubmit } from '@remix-run/react'
|
||||
import { Button } from 'react-aria-components'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Link from '~/components/Link'
|
||||
import TableList from '~/components/TableList'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import AddDNS from '../dialogs/dns'
|
||||
|
||||
interface Props {
|
||||
records: { name: string, type: 'A', value: string }[]
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
export default function DNS({ records, isDisabled }: Props) {
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Headscale supports adding custom DNS records to your Tailnet.
|
||||
As of now, only
|
||||
{' '}
|
||||
<Code>A</Code>
|
||||
{' '}
|
||||
records are supported.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://headscale.net/dns-records/"
|
||||
name="Headscale DNS Records documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<TableList className="mb-8">
|
||||
{records.length === 0
|
||||
? (
|
||||
<TableList.Item>
|
||||
<p className="opacity-50 text-sm mx-auto">
|
||||
No DNS records found
|
||||
</p>
|
||||
</TableList.Item>
|
||||
)
|
||||
: records.map((record, index) => (
|
||||
<TableList.Item key={index}>
|
||||
<div className="flex gap-24">
|
||||
<div className="flex gap-2">
|
||||
<p className="font-mono text-sm font-bold">{record.type}</p>
|
||||
<p className="font-mono text-sm">{record.name}</p>
|
||||
</div>
|
||||
<p className="font-mono text-sm">{record.value}</p>
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm',
|
||||
'text-red-600 dark:text-red-400',
|
||||
'hover:text-red-700 dark:hover:text-red-300',
|
||||
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => {
|
||||
submit({
|
||||
'dns.extra_records': records
|
||||
.filter((_, i) => i !== index),
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
))}
|
||||
</TableList>
|
||||
|
||||
{isDisabled
|
||||
? undefined
|
||||
: (
|
||||
<AddDNS records={records} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/* eslint-disable unicorn/no-keyword-prefix */
|
||||
import {
|
||||
closestCorners,
|
||||
DndContext,
|
||||
DragOverlay
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
restrictToParentElement,
|
||||
restrictToVerticalAxis
|
||||
} from '@dnd-kit/modifiers'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react'
|
||||
import { FetcherWithComponents, useFetcher } from '@remix-run/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Input } from 'react-aria-components'
|
||||
|
||||
import Spinner from '~/components/Spinner'
|
||||
import TableList from '~/components/TableList'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type Properties = {
|
||||
readonly baseDomain?: string;
|
||||
readonly searchDomains: string[];
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function Domains({ baseDomain, searchDomains, disabled }: Properties) {
|
||||
// eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types
|
||||
const [activeId, setActiveId] = useState<number | string | null>(null)
|
||||
const [localDomains, setLocalDomains] = useState(searchDomains)
|
||||
const [newDomain, setNewDomain] = useState('')
|
||||
const fetcher = useFetcher()
|
||||
|
||||
useEffect(() => {
|
||||
setLocalDomains(searchDomains)
|
||||
}, [searchDomains])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col w-2/3'>
|
||||
<h1 className='text-2xl font-medium mb-4'>Search Domains</h1>
|
||||
<p className='text-gray-700 dark:text-gray-300 mb-2'>
|
||||
Set custom DNS search domains for your Tailnet.
|
||||
When using Magic DNS, your tailnet domain is used as the first search domain.
|
||||
</p>
|
||||
<DndContext
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={event => {
|
||||
setActiveId(event.active.id)
|
||||
}}
|
||||
onDragEnd={event => {
|
||||
// eslint-disable-next-line unicorn/no-null
|
||||
setActiveId(null)
|
||||
const { active, over } = event
|
||||
if (!over) {
|
||||
return
|
||||
}
|
||||
|
||||
const activeItem = localDomains[active.id as number - 1]
|
||||
const overItem = localDomains[over.id as number - 1]
|
||||
|
||||
if (!activeItem || !overItem) {
|
||||
return
|
||||
}
|
||||
|
||||
const oldIndex = localDomains.indexOf(activeItem)
|
||||
const newIndex = localDomains.indexOf(overItem)
|
||||
|
||||
if (oldIndex !== newIndex) {
|
||||
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableList>
|
||||
{baseDomain ? (
|
||||
<TableList.Item key='magic-dns-sd'>
|
||||
<p className='font-mono text-sm'>{baseDomain}</p>
|
||||
<LockIcon className='h-4 w-4'/>
|
||||
</TableList.Item>
|
||||
) : undefined}
|
||||
<SortableContext
|
||||
items={localDomains}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{localDomains.map((sd, index) => (
|
||||
<Domain
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
domain={sd}
|
||||
id={index + 1}
|
||||
localDomains={localDomains}
|
||||
disabled={disabled}
|
||||
fetcher={fetcher}
|
||||
/>
|
||||
))}
|
||||
<DragOverlay adjustScale>
|
||||
{activeId ? <Domain
|
||||
isDrag
|
||||
domain={localDomains[activeId as number - 1]}
|
||||
localDomains={localDomains}
|
||||
id={activeId as number - 1}
|
||||
disabled={disabled}
|
||||
fetcher={fetcher}
|
||||
/> : undefined}
|
||||
</DragOverlay>
|
||||
</SortableContext>
|
||||
{disabled ? undefined : (
|
||||
<TableList.Item key='add-sd'>
|
||||
<Input
|
||||
type='text'
|
||||
className='font-mono text-sm bg-transparent w-full mr-2'
|
||||
placeholder='Search Domain'
|
||||
value={newDomain}
|
||||
onChange={event => {
|
||||
setNewDomain(event.target.value)
|
||||
}}
|
||||
/>
|
||||
{fetcher.state === 'idle' ? (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm font-semibold',
|
||||
'text-blue-600 dark:text-blue-400',
|
||||
'hover:text-blue-700 dark:hover:text-blue-300',
|
||||
newDomain.length === 0 && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
isDisabled={newDomain.length === 0}
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns.search_domains': [...localDomains, newDomain]
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
})
|
||||
|
||||
setNewDomain('')
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
) : (
|
||||
<Spinner className='w-3 h-3 mr-0'/>
|
||||
)}
|
||||
</TableList.Item>
|
||||
)}
|
||||
</TableList>
|
||||
</DndContext>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type DomainProperties = {
|
||||
readonly domain: string;
|
||||
readonly id: number;
|
||||
readonly isDrag?: boolean;
|
||||
readonly localDomains: string[];
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
readonly disabled?: boolean;
|
||||
readonly fetcher: FetcherWithComponents<unknown>;
|
||||
}
|
||||
|
||||
function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainProperties) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging
|
||||
} = useSortable({ id })
|
||||
|
||||
// TODO: Figure out why TableList.Item breaks dndkit
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'flex items-center justify-between px-3 py-2',
|
||||
'border-b border-gray-200 last:border-b-0 dark:border-zinc-800',
|
||||
isDragging ? 'text-gray-400' : '',
|
||||
isDrag ? 'outline outline-1 outline-gray-500 bg-gray-200 dark:bg-zinc-800' : ''
|
||||
)}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition
|
||||
}}
|
||||
>
|
||||
<p className='font-mono text-sm flex items-center gap-4'>
|
||||
{disabled ? undefined : (
|
||||
<ThreeBarsIcon
|
||||
className='h-4 w-4 text-gray-400 focus:outline-none'
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
)}
|
||||
{domain}
|
||||
</p>
|
||||
{isDrag ? undefined : (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm',
|
||||
'text-red-600 dark:text-red-400',
|
||||
'hover:text-red-700 dark:hover:text-red-300',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
isDisabled={disabled}
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
'dns.search_domains': localDomains.filter((_, index) => index !== id - 1)
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
})
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Spinner from '~/components/Spinner'
|
||||
|
||||
type Properties = {
|
||||
readonly isEnabled: boolean;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function Modal({ isEnabled, disabled }: Properties) {
|
||||
const fetcher = useFetcher()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={disabled}>
|
||||
{fetcher.state === 'idle' ? undefined : (
|
||||
<Spinner className='w-3 h-3'/>
|
||||
)}
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Devices will no longer be accessible via your tailnet domain.
|
||||
The search domain will also be disabled.
|
||||
</Dialog.Text>
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
<Dialog.Action
|
||||
variant='cancel'
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant='confirm'
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns.magic_dns': !isEnabled
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
})
|
||||
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useSubmit } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from 'react-aria-components'
|
||||
|
||||
import Link from '~/components/Link'
|
||||
import Switch from '~/components/Switch'
|
||||
import TableList from '~/components/TableList'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import AddNameserver from '../dialogs/nameserver'
|
||||
|
||||
interface Props {
|
||||
nameservers: Record<string, string[]>
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
export default function Nameservers({ nameservers, isDisabled }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Set the nameservers used by devices on the Tailnet
|
||||
to resolve DNS queries.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1054/dns"
|
||||
name="Tailscale DNS Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
{Object.keys(nameservers).map(key => (
|
||||
<NameserverList
|
||||
key={key}
|
||||
isGlobal={key === 'global'}
|
||||
isDisabled={isDisabled}
|
||||
nameservers={nameservers}
|
||||
name={key}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isDisabled
|
||||
? undefined
|
||||
: (
|
||||
<AddNameserver nameservers={nameservers} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ListProps {
|
||||
isGlobal: boolean
|
||||
isDisabled: boolean
|
||||
nameservers: string[]
|
||||
name: string
|
||||
}
|
||||
|
||||
function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps) {
|
||||
const submit = useSubmit()
|
||||
const list = isGlobal ? nameservers['global'] : nameservers[name]
|
||||
if (list.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-md font-medium opacity-80">
|
||||
{isGlobal ? 'Global Nameservers' : name}
|
||||
</h2>
|
||||
</div>
|
||||
<TableList>
|
||||
{list.length > 0 ? list.map((ns, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<TableList.Item key={index}>
|
||||
<p className="font-mono text-sm">{ns}</p>
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm',
|
||||
'text-red-600 dark:text-red-400',
|
||||
'hover:text-red-700 dark:hover:text-red-300',
|
||||
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => {
|
||||
if (isGlobal) {
|
||||
submit({
|
||||
'dns.nameservers.global': list
|
||||
.filter((_, i) => i !== index),
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
} else {
|
||||
submit({
|
||||
'dns.nameservers.split': {
|
||||
...nameservers,
|
||||
[name]: list.filter((_, i) => i !== index),
|
||||
}
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
)) : undefined}
|
||||
</TableList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
import { Input } from 'react-aria-components'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import TextField from '~/components/TextField'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type Properties = {
|
||||
readonly name: string;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function Modal({ name, disabled }: Properties) {
|
||||
const [newName, setNewName] = useState(name)
|
||||
const fetcher = useFetcher()
|
||||
|
||||
return (
|
||||
<div className='flex flex-col w-2/3'>
|
||||
<h1 className='text-2xl font-medium mb-4'>Tailnet Name</h1>
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
This is the base domain name of your Tailnet.
|
||||
Devices are accessible at
|
||||
{' '}
|
||||
<Code>
|
||||
[device].{name}
|
||||
</Code>
|
||||
{' '}
|
||||
when Magic DNS is enabled.
|
||||
</p>
|
||||
<Input
|
||||
readOnly
|
||||
className={cn(
|
||||
'block px-2.5 py-1.5 w-1/2 rounded-lg my-4',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'dark:bg-ui-800 dark:text-ui-300 text-sm',
|
||||
'outline-none'
|
||||
)}
|
||||
type='text'
|
||||
value={name}
|
||||
onFocus={event => {
|
||||
event.target.select()
|
||||
}}
|
||||
/>
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={disabled}>
|
||||
{fetcher.state === 'idle' ? undefined : (
|
||||
<Spinner className='w-3 h-3'/>
|
||||
)}
|
||||
Rename Tailnet
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Rename Tailnet
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Keep in mind that changing this can lead to all sorts of unexpected behavior and may break existing devices in your tailnet.
|
||||
</Dialog.Text>
|
||||
<TextField
|
||||
label='Tailnet name'
|
||||
placeholder='ts.net'
|
||||
state={[newName, setNewName]}
|
||||
className='my-2'
|
||||
/>
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
<Dialog.Action
|
||||
variant='cancel'
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant='confirm'
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
'dns.base_domain': newName
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
})
|
||||
|
||||
close()
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
records: { name: string, type: 'A', value: string }[]
|
||||
}
|
||||
|
||||
export default function AddDNS({ records }: Props) {
|
||||
const submit = useSubmit()
|
||||
const [name, setName] = useState('')
|
||||
const [ip, setIp] = useState('')
|
||||
|
||||
const isDuplicate = useMemo(() => {
|
||||
if (name.length === 0 || ip.length === 0) return false
|
||||
const lookup = records.find(record => record.name === name)
|
||||
if (!lookup) return false
|
||||
|
||||
return lookup.value === ip
|
||||
}, [records, name, ip])
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>
|
||||
Add DNS record
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Add DNS record
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Enter the domain and IP address for the new DNS record.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!name || !ip) return
|
||||
|
||||
setName('')
|
||||
setIp('')
|
||||
|
||||
submit({
|
||||
'dns.extra_records': [
|
||||
...records,
|
||||
{
|
||||
name,
|
||||
type: 'A',
|
||||
value: ip,
|
||||
},
|
||||
],
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Domain"
|
||||
placeholder="test.example.com"
|
||||
name="domain"
|
||||
state={[name, setName]}
|
||||
className={cn(
|
||||
'mt-2',
|
||||
isDuplicate && 'outline outline-red-500',
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
label="IP Address"
|
||||
placeholder="101.101.101.101"
|
||||
name="ip"
|
||||
state={[ip, setIp]}
|
||||
className={cn(
|
||||
isDuplicate && 'outline outline-red-500',
|
||||
)}
|
||||
/>
|
||||
{isDuplicate
|
||||
? (
|
||||
<p className="text-sm opacity-50">
|
||||
A record with the domain name
|
||||
{' '}
|
||||
<Code>{name}</Code>
|
||||
{' '}
|
||||
and IP address
|
||||
{' '}
|
||||
<Code>{ip}</Code>
|
||||
{' '}
|
||||
already exists.
|
||||
</p>
|
||||
)
|
||||
: undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-8">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
isDisabled={isDuplicate}
|
||||
>
|
||||
Add
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { RepoForkedIcon } from '@primer/octicons-react'
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Switch from '~/components/Switch'
|
||||
import TextField from '~/components/TextField'
|
||||
import Tooltip from '~/components/Tooltip'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
nameservers: Record<string, string[]>
|
||||
}
|
||||
|
||||
export default function AddNameserver({ nameservers }: Props) {
|
||||
const submit = useSubmit()
|
||||
const [split, setSplit] = useState(false)
|
||||
const [ns, setNs] = useState('')
|
||||
const [domain, setDomain] = useState('')
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>
|
||||
Add nameserver
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Add nameserver
|
||||
</Dialog.Title>
|
||||
<Dialog.Text className="font-semibold">
|
||||
Nameserver
|
||||
</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Use this IPv4 or IPv6 address to resolve names.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!ns) return
|
||||
|
||||
if (split) {
|
||||
const splitNs: Record<string, string[]> = {}
|
||||
for (const [key, value] of Object.entries(nameservers)) {
|
||||
if (key === 'global') continue
|
||||
splitNs[key] = value
|
||||
}
|
||||
|
||||
if (Object.keys(splitNs).includes(domain)) {
|
||||
splitNs[domain].push(ns)
|
||||
} else {
|
||||
splitNs[domain] = [ns]
|
||||
}
|
||||
|
||||
submit({
|
||||
'dns.nameservers.split': splitNs,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
} else {
|
||||
const globalNs = nameservers.global
|
||||
globalNs.push(ns)
|
||||
|
||||
submit({
|
||||
'dns.nameservers.global': globalNs,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}
|
||||
|
||||
setNs('')
|
||||
setDomain('')
|
||||
setSplit(false)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="DNS Server"
|
||||
placeholder="1.2.3.4"
|
||||
name="ns"
|
||||
state={[ns, setNs]}
|
||||
className="mt-2 mb-8"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="block">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Dialog.Text className="font-semibold">
|
||||
Restrict to domain
|
||||
</Dialog.Text>
|
||||
<Tooltip>
|
||||
<Tooltip.Button className={cn(
|
||||
'text-xs rounded-md px-1.5 py-0.5',
|
||||
'bg-ui-200 dark:bg-ui-800',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
|
||||
Split DNS
|
||||
</Tooltip.Button>
|
||||
<Tooltip.Body>
|
||||
Only clients that support split DNS
|
||||
(Tailscale v1.8 or later for most platforms)
|
||||
will use this nameserver. Older clients
|
||||
will ignore it.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Dialog.Text className="text-sm">
|
||||
This nameserver will only be used for some domains.
|
||||
</Dialog.Text>
|
||||
</div>
|
||||
<Switch
|
||||
label="Split DNS"
|
||||
defaultSelected={split}
|
||||
onChange={() => { setSplit(!split) }}
|
||||
/>
|
||||
</div>
|
||||
{split
|
||||
? (
|
||||
<>
|
||||
<Dialog.Text className="font-semibold mt-8">
|
||||
Domain
|
||||
</Dialog.Text>
|
||||
<TextField
|
||||
label="Domain"
|
||||
placeholder="example.com"
|
||||
name="domain"
|
||||
state={[domain, setDomain]}
|
||||
className="my-2"
|
||||
/>
|
||||
<Dialog.Text className="text-sm">
|
||||
Only single-label or fully-qualified queries
|
||||
matching this suffix should use the nameserver.
|
||||
</Dialog.Text>
|
||||
</>
|
||||
)
|
||||
: undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
Add
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { ActionFunctionArgs } from '@remix-run/node'
|
||||
import { json, useLoaderData } from '@remix-run/react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Notice from '~/components/Notice'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig, patchConfig } from '~/utils/config/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
|
||||
import DNS from './components/dns'
|
||||
import Domains from './components/domains'
|
||||
import MagicModal from './components/magic'
|
||||
import Nameservers from './components/nameservers'
|
||||
import RenameModal from './components/rename'
|
||||
|
||||
// We do not want to expose every config value
|
||||
export async function loader() {
|
||||
const context = await loadContext()
|
||||
if (!context.config.read) {
|
||||
throw new Error('No configuration is available')
|
||||
}
|
||||
|
||||
const config = await loadConfig()
|
||||
const dns = {
|
||||
prefixes: config.prefixes,
|
||||
magicDns: config.dns.magic_dns,
|
||||
baseDomain: config.dns.use_username_in_magic_dns
|
||||
? `[user].${config.dns.base_domain}`
|
||||
: config.dns.base_domain,
|
||||
nameservers: config.dns.nameservers.global,
|
||||
splitDns: config.dns.nameservers.split,
|
||||
searchDomains: config.dns.search_domains,
|
||||
extraRecords: config.dns.extra_records,
|
||||
}
|
||||
|
||||
return {
|
||||
...dns,
|
||||
...context,
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return send({ success: false }, 401)
|
||||
}
|
||||
|
||||
const context = await loadContext()
|
||||
if (!context.config.write) {
|
||||
return send({ success: false }, 403)
|
||||
}
|
||||
|
||||
const data = await request.json() as Record<string, unknown>
|
||||
await patchConfig(data)
|
||||
|
||||
if (context.integration?.onConfigChange) {
|
||||
await context.integration.onConfigChange(context.integration.context)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
useLiveData({ interval: 5000 })
|
||||
const data = useLoaderData<typeof loader>()
|
||||
|
||||
const allNs: Record<string, string[]> = {}
|
||||
for (const key of Object.keys(data.splitDns)) {
|
||||
allNs[key] = data.splitDns[key]
|
||||
}
|
||||
|
||||
allNs.global = data.nameservers
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-16 max-w-screen-lg">
|
||||
{data.config.write
|
||||
? undefined
|
||||
: (
|
||||
<Notice>
|
||||
The Headscale configuration is read-only. You cannot make changes to the configuration
|
||||
</Notice>
|
||||
)}
|
||||
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
|
||||
<Nameservers
|
||||
nameservers={allNs}
|
||||
isDisabled={!data.config.write}
|
||||
/>
|
||||
|
||||
<DNS
|
||||
records={data.extraRecords}
|
||||
isDisabled={!data.config.write}
|
||||
/>
|
||||
|
||||
<Domains
|
||||
baseDomain={data.magicDns ? data.baseDomain : undefined}
|
||||
searchDomains={data.searchDomains}
|
||||
disabled={!data.config.write}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
||||
Automatically register domain names for each device
|
||||
on the tailnet. Devices will be accessible at
|
||||
{' '}
|
||||
<Code>
|
||||
[device].
|
||||
{data.baseDomain}
|
||||
</Code>
|
||||
{' '}
|
||||
when Magic DNS is enabled.
|
||||
</p>
|
||||
<MagicModal isEnabled={data.magicDns} disabled={!data.config.write} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user