mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 08:08:56 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74e87bd80e | |||
| d01699009d | |||
| 08db192db1 | |||
| 3ea5fed8f6 | |||
| dd9d6cd550 | |||
| f82244f85a | |||
| 896aaad61e | |||
| e5dece5b7b | |||
| 2797525969 | |||
| f0f8a6f01b | |||
| 3bd5cc99aa | |||
| 937fa6f6fa | |||
| 3ffbabd7fc | |||
| c08203cc76 |
@@ -0,0 +1,44 @@
|
||||
name: Publish Nightly Docker Image
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 8 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and Publish Nightly
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=edge,branch=main
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
@@ -0,0 +1,44 @@
|
||||
name: 'Build'
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
@@ -1,3 +1,15 @@
|
||||
### 0.1.9 (June 2, 2024)
|
||||
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
|
||||
- Utilize magic DNS hostnames for the machine overview page.
|
||||
- Fixed the expiry issue once and for all.
|
||||
- Add a nightly build with the `ghcr.io/tale/headplane:edge` tag
|
||||
|
||||
### 0.1.8 (June 2, 2024)
|
||||
- Built basic functionality for the machine overview page (by machine ID).
|
||||
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
|
||||
- Prevent users from being deleted if they still have ownership of machines.
|
||||
- Fixed some type issues where `Date` was being used instead of `string` for timestamps.
|
||||
|
||||
### 0.1.7 (May 30, 2024)
|
||||
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
|
||||
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
|
||||
|
||||
@@ -1,36 +1,33 @@
|
||||
import { json } from '@codemirror/lang-json'
|
||||
import { yaml } from '@codemirror/lang-yaml'
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import CodeMirrorMerge from 'react-codemirror-merge'
|
||||
import Editor, { DiffEditor, Monaco } from '@monaco-editor/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import Fallback from '~/routes/_data.acls._index/fallback'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Fallback from './fallback'
|
||||
|
||||
interface EditorProperties {
|
||||
readonly acl: string
|
||||
readonly setAcl: (acl: string) => void
|
||||
readonly mode: 'edit' | 'diff'
|
||||
|
||||
readonly data: {
|
||||
hasAclWrite: boolean
|
||||
currentAcl: string
|
||||
aclType: string
|
||||
}
|
||||
interface MonacoProps {
|
||||
variant: 'editor' | 'diff'
|
||||
language: 'json' | 'yaml'
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
original?: string
|
||||
}
|
||||
|
||||
export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
|
||||
const [light, setLight] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
function monacoCallback(monaco: Monaco) {
|
||||
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
|
||||
validate: true,
|
||||
allowComments: true,
|
||||
schemas: [],
|
||||
enableSchemaRequest: true,
|
||||
trailingCommas: 'ignore',
|
||||
})
|
||||
|
||||
const fetcher = useFetcher()
|
||||
const aclType = useMemo(() => data.aclType === 'json' ? json() : yaml(), [data.aclType])
|
||||
monaco.languages.register({ id: 'json' })
|
||||
monaco.languages.register({ id: 'yaml' })
|
||||
}
|
||||
|
||||
export default function MonacoEditor({ value, onChange, variant, original, language }: MonacoProps) {
|
||||
const [light, setLight] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)')
|
||||
@@ -39,87 +36,62 @@ export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
|
||||
theme.addEventListener('change', (theme) => {
|
||||
setLight(theme.matches)
|
||||
})
|
||||
|
||||
// Prevents the FOUC
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={clsx(
|
||||
<div className={cn(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-y-scroll h-editor text-sm">
|
||||
{loading
|
||||
? (
|
||||
<Fallback acl={acl} where="client" />
|
||||
)
|
||||
: (
|
||||
mode === 'edit'
|
||||
? (
|
||||
<CodeMirror
|
||||
value={acl}
|
||||
theme={light ? githubLight : githubDark}
|
||||
extensions={[aclType]}
|
||||
readOnly={!data.hasAclWrite}
|
||||
onChange={(value) => {
|
||||
setAcl(value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<CodeMirrorMerge
|
||||
theme={light ? githubLight : githubDark}
|
||||
orientation="a-b"
|
||||
>
|
||||
<CodeMirrorMerge.Original
|
||||
readOnly
|
||||
value={data.currentAcl}
|
||||
extensions={[aclType]}
|
||||
/>
|
||||
<CodeMirrorMerge.Modified
|
||||
readOnly
|
||||
value={acl}
|
||||
extensions={[aclType]}
|
||||
/>
|
||||
</CodeMirrorMerge>
|
||||
)
|
||||
)}
|
||||
<ClientOnly fallback={<Fallback acl={value} />}>
|
||||
{() => variant === 'editor'
|
||||
? (
|
||||
<Editor
|
||||
height="100%"
|
||||
language={language}
|
||||
theme={light ? 'light' : 'vs-dark'}
|
||||
value={value}
|
||||
onChange={(updated) => {
|
||||
if (!updated) {
|
||||
return
|
||||
}
|
||||
|
||||
if (updated !== value) {
|
||||
onChange(updated)
|
||||
}
|
||||
}}
|
||||
loading={<Fallback acl={value} />}
|
||||
beforeMount={monacoCallback}
|
||||
options={{
|
||||
wordWrap: 'on',
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<DiffEditor
|
||||
height="100%"
|
||||
language={language}
|
||||
theme={light ? 'light' : 'vs-dark'}
|
||||
original={original}
|
||||
modified={value}
|
||||
loading={<Fallback acl={value} />}
|
||||
beforeMount={monacoCallback}
|
||||
options={{
|
||||
wordWrap: 'on',
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="mr-2"
|
||||
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
acl,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
|
||||
toast('Updated tailnet ACL policy')
|
||||
}}
|
||||
>
|
||||
{fetcher.state === 'idle'
|
||||
? undefined
|
||||
: (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl}
|
||||
onPress={() => {
|
||||
setAcl(data.currentAcl)
|
||||
}}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,43 +1,24 @@
|
||||
import clsx from 'clsx'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
|
||||
type FallbackProperties = {
|
||||
readonly acl: string;
|
||||
readonly where: 'client' | 'server';
|
||||
interface FallbackProps {
|
||||
readonly acl: string
|
||||
}
|
||||
|
||||
export default function Fallback({ acl, where }: FallbackProperties) {
|
||||
export default function Fallback({ acl }: FallbackProps) {
|
||||
return (
|
||||
<>
|
||||
<div className={clsx(
|
||||
where === 'server' ? 'mb-2 overflow-hidden rounded-tr-lg rounded-b-lg' : '',
|
||||
where === 'server' ? 'border border-gray-200 dark:border-gray-700' : ''
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
readOnly
|
||||
className={clsx(
|
||||
'w-full h-editor font-mono resize-none',
|
||||
'text-sm text-gray-600 dark:text-gray-300',
|
||||
'pl-10 pt-1 leading-snug'
|
||||
)}
|
||||
value={acl}
|
||||
/>
|
||||
</div>
|
||||
{where === 'server' ? (
|
||||
<>
|
||||
<Button
|
||||
variant='heavy'
|
||||
className='mr-2'
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</>
|
||||
) : undefined}
|
||||
</>
|
||||
<div className="inline-block relative w-full h-editor">
|
||||
<Spinner className="w-4 h-4 absolute p-2" />
|
||||
<textarea
|
||||
readOnly
|
||||
className={cn(
|
||||
'w-full h-editor font-mono resize-none',
|
||||
'text-sm text-gray-600 dark:text-gray-300',
|
||||
'bg-ui-100 dark:bg-ui-800',
|
||||
'pl-16 pr-8 pt-0.5 leading-snug',
|
||||
)}
|
||||
value={acl}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
|
||||
import { type ActionFunctionArgs, json } from '@remix-run/node'
|
||||
import { useLoaderData } from '@remix-run/react'
|
||||
import { useFetcher, useLoaderData } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import Link from '~/components/Link'
|
||||
import Notice from '~/components/Notice'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
|
||||
import Editor from './editor'
|
||||
import Fallback from './fallback'
|
||||
import Monaco from './editor'
|
||||
|
||||
export async function loader() {
|
||||
const context = await loadContext()
|
||||
@@ -56,6 +57,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const [acl, setAcl] = useState(data.currentAcl)
|
||||
const fetcher = useFetcher()
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -141,18 +143,21 @@ export default function Page() {
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="edit">
|
||||
<ClientOnly fallback={<Fallback acl={acl} where="server" />}>
|
||||
{() => (
|
||||
<Editor data={data} acl={acl} setAcl={setAcl} mode="edit" />
|
||||
)}
|
||||
</ClientOnly>
|
||||
<Monaco
|
||||
variant="editor"
|
||||
language={data.aclType}
|
||||
value={acl}
|
||||
onChange={setAcl}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="diff">
|
||||
<ClientOnly fallback={<Fallback acl={acl} where="server" />}>
|
||||
{() => (
|
||||
<Editor data={data} acl={acl} setAcl={setAcl} mode="diff" />
|
||||
)}
|
||||
</ClientOnly>
|
||||
<Monaco
|
||||
variant="editor"
|
||||
language={data.aclType}
|
||||
value={acl}
|
||||
onChange={setAcl}
|
||||
original={data.currentAcl}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="preview">
|
||||
<div
|
||||
@@ -170,6 +175,31 @@ export default function Page() {
|
||||
</div>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="mr-2"
|
||||
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
acl,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
|
||||
toast('Updated tailnet ACL policy')
|
||||
}}
|
||||
>
|
||||
{fetcher.state === 'idle'
|
||||
? undefined
|
||||
: (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,71 +4,190 @@ import { Link, useLoaderData } from '@remix-run/react'
|
||||
import Attribute from '~/components/Attribute'
|
||||
import Card from '~/components/Card'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
import { type Machine } from '~/types'
|
||||
import { type Machine, Route } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig } from '~/utils/config/headscale'
|
||||
import { pull } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
|
||||
import MenuOptions from './_data.machines._index/menu'
|
||||
|
||||
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
|
||||
const context = await loadContext()
|
||||
let magic: string | undefined
|
||||
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig()
|
||||
if (config.dns_config.magic_dns) {
|
||||
magic = config.dns_config.base_domain
|
||||
}
|
||||
}
|
||||
|
||||
const [machine, routes] = await Promise.all([
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
||||
])
|
||||
|
||||
return {
|
||||
machine: machine.node,
|
||||
routes: routes.routes.filter(route => route.node.id === params.id),
|
||||
magic,
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const { machine, magic, routes } = useLoaderData<typeof loader>()
|
||||
useLiveData({ interval: 1000 })
|
||||
|
||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now()
|
||||
|
||||
const tags = [
|
||||
...machine.forcedTags,
|
||||
...machine.validTags,
|
||||
]
|
||||
|
||||
if (expired) {
|
||||
tags.unshift('Expired')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className='mb-4 text-gray-500 dark:text-gray-400 text-sm'>
|
||||
<p className="mb-8 text-md">
|
||||
<Link
|
||||
to='/machines'
|
||||
className='font-bold text-gray-700 dark:text-gray-300 hover:underline'
|
||||
to="/machines"
|
||||
className="font-medium"
|
||||
>
|
||||
All Machines
|
||||
</Link>
|
||||
{' / '}
|
||||
{data.givenName}
|
||||
<span className="mx-2">
|
||||
/
|
||||
</span>
|
||||
{machine.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>
|
||||
<Card variant='flat'>
|
||||
<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}/>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="flex items-baseline gap-x-4 text-sm mb-4">
|
||||
<h1 className="text-2xl font-medium">
|
||||
{machine.givenName}
|
||||
</h1>
|
||||
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
|
||||
</span>
|
||||
|
||||
<MenuOptions
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
magic={magic}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-1 mb-8">
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
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',
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h2 className="text-xl font-medium mb-4">
|
||||
Machine Details
|
||||
</h2>
|
||||
<Card variant="flat" className="w-full max-w-full">
|
||||
<Attribute name="Creator" value={machine.user.name} />
|
||||
<Attribute name="Node ID" value={machine.id} />
|
||||
<Attribute name="Node Name" value={machine.givenName} />
|
||||
<Attribute name="Hostname" value={machine.name} />
|
||||
<Attribute
|
||||
isCopyable
|
||||
name='Node Key'
|
||||
value={data.nodeKey}
|
||||
name="Node Key"
|
||||
value={machine.nodeKey}
|
||||
/>
|
||||
<Attribute
|
||||
name='Created'
|
||||
value={new Date(data.createdAt).toLocaleString()}
|
||||
name="Created"
|
||||
value={new Date(machine.createdAt).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name='Last Seen'
|
||||
value={new Date(data.lastSeen).toLocaleString()}
|
||||
name="Last Seen"
|
||||
value={new Date(machine.lastSeen).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name='Expiry'
|
||||
value={new Date(data.expiry).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name='Domain'
|
||||
value={`${data.givenName}.${data.user.name}.ts.net`}
|
||||
name="Expiry"
|
||||
value={new Date(machine.expiry).toLocaleString()}
|
||||
/>
|
||||
{magic
|
||||
? (
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Domain"
|
||||
value={`${machine.givenName}.${machine.user.name}.${magic}`}
|
||||
/>
|
||||
)
|
||||
: undefined}
|
||||
</Card>
|
||||
<h2 className="text-xl font-medium mb-4 mt-8">
|
||||
Machine Routes
|
||||
</h2>
|
||||
<Card variant="flat" className="w-full max-w-full">
|
||||
{routes.length === 0
|
||||
? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-4 px-4',
|
||||
'items-center justify-center',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
No routes are advertised on this machine.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: routes.map((route, i) => (
|
||||
<div
|
||||
key={route.id}
|
||||
className={cn(
|
||||
'flex items-center justify-between',
|
||||
routes.length - 1 === i ? 'border-b pb-3 mb-2' : '',
|
||||
'border-ui-100 dark:border-ui-800',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<p className="font-mono mb-1">
|
||||
{route.prefix}
|
||||
</p>
|
||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||
{' '}
|
||||
(Created:
|
||||
{' '}
|
||||
{new Date(route.createdAt).toLocaleString()}
|
||||
)
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="mb-1">
|
||||
{route.enabled ? 'Enabled' : 'Disabled'}
|
||||
</p>
|
||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||
{route.isPrimary ? 'Primary' : 'Secondary'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,54 +1,62 @@
|
||||
import { type FetcherWithComponents } from '@remix-run/react'
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { type Dispatch, type SetStateAction } from 'react'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import { type Machine } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type DeleteProperties = {
|
||||
readonly machine: Machine;
|
||||
readonly fetcher: FetcherWithComponents<unknown>;
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
interface DeleteProps {
|
||||
readonly machine: Machine
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
}
|
||||
|
||||
export default function Delete({ machine, fetcher, state }: DeleteProperties) {
|
||||
export default function Delete({ machine, state }: DeleteProps) {
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Remove {machine.givenName}
|
||||
Remove
|
||||
{' '}
|
||||
{machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
This machine will be permanently removed from
|
||||
your network. To re-add it, you will need to
|
||||
reauthenticate to your tailnet from the device.
|
||||
</Dialog.Text>
|
||||
<fetcher.Form method='POST'>
|
||||
<input type='hidden' name='_method' value='delete'/>
|
||||
<input type='hidden' name='id' value={machine.id}/>
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="delete" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant='cancel'
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant='confirm'
|
||||
variant="confirm"
|
||||
className={cn(
|
||||
'bg-red-500 hover:border-red-700',
|
||||
'dark:bg-red-600 dark:hover:border-red-700',
|
||||
'pressed:bg-red-600 hover:bg-red-600',
|
||||
'text-white dark:text-white'
|
||||
'text-white dark:text-white',
|
||||
)}
|
||||
onPress={close}
|
||||
>
|
||||
Remove
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
|
||||
@@ -1,54 +1,62 @@
|
||||
import { type FetcherWithComponents } from '@remix-run/react'
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { type Dispatch, type SetStateAction } from 'react'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import { type Machine } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type DeleteProperties = {
|
||||
readonly machine: Machine;
|
||||
readonly fetcher: FetcherWithComponents<unknown>;
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
interface ExpireProps {
|
||||
readonly machine: Machine
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
}
|
||||
|
||||
export default function Expire({ machine, fetcher, state }: DeleteProperties) {
|
||||
export default function Expire({ machine, state }: ExpireProps) {
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Expire {machine.givenName}
|
||||
Expire
|
||||
{' '}
|
||||
{machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
This will disconnect the machine from your Tailnet.
|
||||
In order to reconnect, you will need to re-authenticate
|
||||
from the device.
|
||||
</Dialog.Text>
|
||||
<fetcher.Form method='POST'>
|
||||
<input type='hidden' name='_method' value='expire'/>
|
||||
<input type='hidden' name='id' value={machine.id}/>
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="expire" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant='cancel'
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant='confirm'
|
||||
variant="confirm"
|
||||
className={cn(
|
||||
'bg-red-500 hover:border-red-700',
|
||||
'dark:bg-red-600 dark:hover:border-red-700',
|
||||
'pressed:bg-red-600 hover:bg-red-600',
|
||||
'text-white dark:text-white'
|
||||
'text-white dark:text-white',
|
||||
)}
|
||||
onPress={close}
|
||||
>
|
||||
Expire
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FetcherWithComponents } from '@remix-run/react'
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
@@ -6,15 +6,15 @@ import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
import { type Machine } from '~/types'
|
||||
|
||||
type RenameProperties = {
|
||||
readonly machine: Machine;
|
||||
readonly fetcher: FetcherWithComponents<unknown>;
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
readonly magic?: string;
|
||||
interface RenameProps {
|
||||
readonly machine: Machine
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
readonly magic?: string
|
||||
}
|
||||
|
||||
export default function Rename({ machine, fetcher, state, magic }: RenameProperties) {
|
||||
export default function Rename({ machine, state, magic }: RenameProps) {
|
||||
const [name, setName] = useState(machine.givenName)
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
@@ -22,65 +22,76 @@ export default function Rename({ machine, fetcher, state, magic }: RenamePropert
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Edit machine name for {machine.givenName}
|
||||
Edit machine name for
|
||||
{' '}
|
||||
{machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
This name is shown in the admin panel, in Tailscale clients,
|
||||
and used when generating MagicDNS names.
|
||||
</Dialog.Text>
|
||||
<fetcher.Form method='POST'>
|
||||
<input type='hidden' name='_method' value='rename'/>
|
||||
<input type='hidden' name='id' value={machine.id}/>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="rename" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<TextField
|
||||
label='Machine name'
|
||||
placeholder='Machine name'
|
||||
name='name'
|
||||
label="Machine name"
|
||||
placeholder="Machine name"
|
||||
name="name"
|
||||
state={[name, setName]}
|
||||
className='my-2'
|
||||
className="my-2"
|
||||
/>
|
||||
{magic ? (
|
||||
name.length > 0 && name !== machine.givenName ? (
|
||||
<p className='text-sm text-gray-500 dark:text-gray-300 leading-tight'>
|
||||
This machine will be accessible by the hostname
|
||||
{' '}
|
||||
<Code className='text-sm'>
|
||||
{name.toLowerCase().replaceAll(/\s+/g, '-')}
|
||||
</Code>
|
||||
{'. '}
|
||||
The hostname
|
||||
{' '}
|
||||
<Code className='text-sm'>
|
||||
{machine.givenName}
|
||||
</Code>
|
||||
{' '}
|
||||
will no longer point to this machine.
|
||||
</p>
|
||||
) : (
|
||||
<p className='text-sm text-gray-500 dark:text-gray-300 leading-tight'>
|
||||
This machine is accessible by the hostname
|
||||
{' '}
|
||||
<Code className='text-sm'>
|
||||
{machine.givenName}
|
||||
</Code>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
) : undefined}
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
{magic
|
||||
? (
|
||||
name.length > 0 && name !== machine.givenName
|
||||
? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||
This machine will be accessible by the hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{name.toLowerCase().replaceAll(/\s+/g, '-')}
|
||||
</Code>
|
||||
{'. '}
|
||||
The hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{machine.givenName}
|
||||
</Code>
|
||||
{' '}
|
||||
will no longer point to this machine.
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||
This machine is accessible by the hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{machine.givenName}
|
||||
</Code>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
)
|
||||
: undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant='cancel'
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant='confirm'
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
Rename
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FetcherWithComponents } from '@remix-run/react'
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import { type Dispatch, type SetStateAction } from 'react'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
@@ -6,22 +6,25 @@ import Switch from '~/components/Switch'
|
||||
import { type Machine, type Route } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type RoutesProperties = {
|
||||
readonly machine: Machine;
|
||||
readonly routes: Route[];
|
||||
readonly fetcher: FetcherWithComponents<unknown>;
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
interface RoutesProps {
|
||||
readonly machine: Machine
|
||||
readonly routes: Route[]
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
}
|
||||
|
||||
// TODO: Support deleting routes
|
||||
export default function Routes({ machine, routes, fetcher, state }: RoutesProperties) {
|
||||
export default function Routes({ machine, routes, state }: RoutesProps) {
|
||||
const fetcher = useFetcher()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Edit route settings of {machine.givenName}
|
||||
Edit route settings of
|
||||
{' '}
|
||||
{machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Connect to devices you can't install Tailscale on
|
||||
@@ -30,28 +33,30 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper
|
||||
<div className={cn(
|
||||
'rounded-lg overflow-y-auto my-2',
|
||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
||||
'border border-zinc-200 dark:border-zinc-700'
|
||||
'border border-zinc-200 dark:border-zinc-700',
|
||||
)}
|
||||
>
|
||||
{routes.length === 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-center',
|
||||
'text-ui-600 dark:text-ui-300'
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
No routes are advertised on this machine.
|
||||
</p>
|
||||
</div>
|
||||
) : undefined}
|
||||
{routes.length === 0
|
||||
? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-center',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
No routes are advertised on this machine.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: undefined}
|
||||
{routes.map(route => (
|
||||
<div
|
||||
key={route.node.id}
|
||||
className={cn(
|
||||
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-between'
|
||||
'items-center justify-between',
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
@@ -59,8 +64,8 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper
|
||||
</p>
|
||||
<Switch
|
||||
defaultSelected={route.enabled}
|
||||
label='Enabled'
|
||||
onChange={checked => {
|
||||
label="Enabled"
|
||||
onChange={(checked) => {
|
||||
const form = new FormData()
|
||||
form.set('id', machine.id)
|
||||
form.set('_method', 'routes')
|
||||
@@ -68,16 +73,16 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper
|
||||
|
||||
form.set('enabled', String(checked))
|
||||
fetcher.submit(form, {
|
||||
method: 'POST'
|
||||
method: 'POST',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant='cancel'
|
||||
variant="cancel"
|
||||
isDisabled={fetcher.state === 'submitting'}
|
||||
onPress={close}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/* eslint-disable react/hook-use-state */
|
||||
import { ChevronDownIcon, CopyIcon, KebabHorizontalIcon } from '@primer/octicons-react'
|
||||
import { type FetcherWithComponents, Link } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react'
|
||||
import { Link } from '@remix-run/react'
|
||||
|
||||
import Menu from '~/components/Menu'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
@@ -9,28 +7,23 @@ import { toast } from '~/components/Toaster'
|
||||
import { type Machine, type Route } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Delete from './dialogs/delete'
|
||||
import Expire from './dialogs/expire'
|
||||
import Rename from './dialogs/rename'
|
||||
import Routes from './dialogs/routes'
|
||||
import MenuOptions from './menu'
|
||||
|
||||
type MachineProperties = {
|
||||
readonly machine: Machine;
|
||||
readonly routes: Route[];
|
||||
readonly fetcher: FetcherWithComponents<unknown>;
|
||||
readonly magic?: string;
|
||||
interface Props {
|
||||
readonly machine: Machine
|
||||
readonly routes: Route[]
|
||||
readonly magic?: string
|
||||
}
|
||||
|
||||
export default function MachineRow({ machine, routes, fetcher, magic }: MachineProperties) {
|
||||
const renameState = useState(false)
|
||||
const expireState = useState(false)
|
||||
const removeState = useState(false)
|
||||
const routesState = useState(false)
|
||||
export default function MachineRow({ machine, routes, magic }: Props) {
|
||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now()
|
||||
|
||||
const expired = new Date(machine.expiry).getTime() < Date.now()
|
||||
const tags = [
|
||||
...machine.forcedTags,
|
||||
...machine.validTags
|
||||
...machine.validTags,
|
||||
]
|
||||
|
||||
if (expired) {
|
||||
@@ -40,32 +33,32 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
|
||||
return (
|
||||
<tr
|
||||
key={machine.id}
|
||||
className='hover:bg-zinc-100 dark:hover:bg-zinc-800 group'
|
||||
className="hover:bg-zinc-100 dark:hover:bg-zinc-800 group"
|
||||
>
|
||||
<td className='pl-0.5 py-2'>
|
||||
<td className="pl-0.5 py-2">
|
||||
<Link
|
||||
to={`/machines/${machine.id}`}
|
||||
className='group/link h-full'
|
||||
className="group/link h-full"
|
||||
>
|
||||
<p className={cn(
|
||||
'font-semibold leading-snug',
|
||||
'group-hover/link:text-blue-600',
|
||||
'group-hover/link:dark:text-blue-400'
|
||||
'group-hover/link:dark:text-blue-400',
|
||||
)}
|
||||
>
|
||||
{machine.givenName}
|
||||
</p>
|
||||
<p className='text-sm text-gray-500 dark:text-gray-300 font-mono'>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 font-mono">
|
||||
{machine.name}
|
||||
</p>
|
||||
<div className='flex gap-1 mt-1'>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className={cn(
|
||||
'text-xs rounded-sm px-1 py-0.5',
|
||||
'bg-gray-100 dark:bg-zinc-700',
|
||||
'text-gray-600 dark:text-gray-300'
|
||||
'text-xs rounded-md px-1.5 py-0.5',
|
||||
'bg-ui-200 dark:bg-ui-800',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
@@ -74,21 +67,21 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className='py-2'>
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
{machine.ipAddresses[0]}
|
||||
<Menu>
|
||||
<Menu.Button>
|
||||
<ChevronDownIcon className='w-4 h-4'/>
|
||||
<ChevronDownIcon className="w-4 h-4" />
|
||||
</Menu.Button>
|
||||
<Menu.Items>
|
||||
{machine.ipAddresses.map(ip => (
|
||||
<Menu.ItemButton
|
||||
key={ip}
|
||||
type='button'
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1.5 text-sm',
|
||||
'justify-between w-full'
|
||||
'justify-between w-full',
|
||||
)}
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(ip)
|
||||
@@ -96,108 +89,61 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
|
||||
}}
|
||||
>
|
||||
{ip}
|
||||
<CopyIcon className='w-3 h-3'/>
|
||||
<CopyIcon className="w-3 h-3" />
|
||||
</Menu.ItemButton>
|
||||
))}
|
||||
{magic ? (
|
||||
<Menu.ItemButton
|
||||
type='button'
|
||||
className={cn(
|
||||
'flex items-center gap-x-1.5 text-sm',
|
||||
'justify-between w-full break-keep'
|
||||
)}
|
||||
onPress={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}
|
||||
<CopyIcon className='w-3 h-3'/>
|
||||
</Menu.ItemButton>
|
||||
) : undefined}
|
||||
{magic
|
||||
? (
|
||||
<Menu.ItemButton
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1.5 text-sm',
|
||||
'justify-between w-full break-keep',
|
||||
)}
|
||||
onPress={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}
|
||||
<CopyIcon className="w-3 h-3" />
|
||||
</Menu.ItemButton>
|
||||
)
|
||||
: undefined}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
</td>
|
||||
<td className='py-2'>
|
||||
<td className="py-2">
|
||||
<span className={cn(
|
||||
'flex items-center gap-x-1 text-sm',
|
||||
'text-gray-500 dark:text-gray-400'
|
||||
'text-gray-500 dark:text-gray-400',
|
||||
)}
|
||||
>
|
||||
<StatusCircle
|
||||
isOnline={machine.online && !expired}
|
||||
className='w-4 h-4'
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<p>
|
||||
{machine.online && !expired
|
||||
? 'Connected'
|
||||
: new Date(
|
||||
machine.lastSeen
|
||||
machine.lastSeen,
|
||||
).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className='py-2 pr-0.5'>
|
||||
<Rename
|
||||
machine={machine}
|
||||
fetcher={fetcher}
|
||||
state={renameState}
|
||||
magic={magic}
|
||||
/>
|
||||
<Delete
|
||||
machine={machine}
|
||||
fetcher={fetcher}
|
||||
state={removeState}
|
||||
/>
|
||||
{expired ? undefined : (
|
||||
<Expire
|
||||
machine={machine}
|
||||
fetcher={fetcher}
|
||||
state={expireState}
|
||||
/>
|
||||
)}
|
||||
<Routes
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
fetcher={fetcher}
|
||||
state={routesState}
|
||||
magic={magic}
|
||||
/>
|
||||
|
||||
<Menu>
|
||||
<Menu.Button
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'border border-transparent rounded-lg py-0.5 w-10',
|
||||
'group-hover:border-gray-200 dark:group-hover:border-zinc-700'
|
||||
)}
|
||||
>
|
||||
<KebabHorizontalIcon className='w-5'/>
|
||||
</Menu.Button>
|
||||
<Menu.Items>
|
||||
<Menu.ItemButton control={renameState}>
|
||||
Edit machine name
|
||||
</Menu.ItemButton>
|
||||
<Menu.ItemButton control={routesState}>
|
||||
Edit route settings
|
||||
</Menu.ItemButton>
|
||||
<Menu.Item className='opacity-50 hover:bg-transparent'>
|
||||
Edit ACL tags
|
||||
</Menu.Item>
|
||||
{expired ? undefined : (
|
||||
<Menu.ItemButton control={expireState}>
|
||||
Expire
|
||||
</Menu.ItemButton>
|
||||
)}
|
||||
<Menu.ItemButton
|
||||
className='text-red-500 dark:text-red-400'
|
||||
control={removeState}
|
||||
>
|
||||
Remove
|
||||
</Menu.ItemButton>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { KebabHorizontalIcon } from '@primer/octicons-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import MenuComponent from '~/components/Menu'
|
||||
import { Machine, Route } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Delete from './dialogs/delete'
|
||||
import Expire from './dialogs/expire'
|
||||
import Rename from './dialogs/rename'
|
||||
import Routes from './dialogs/routes'
|
||||
|
||||
interface MenuProps {
|
||||
machine: Machine
|
||||
routes: Route[]
|
||||
magic?: string
|
||||
}
|
||||
|
||||
export default function Menu({ machine, routes, magic }: MenuProps) {
|
||||
const renameState = useState(false)
|
||||
const expireState = useState(false)
|
||||
const removeState = useState(false)
|
||||
const routesState = useState(false)
|
||||
|
||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Rename
|
||||
machine={machine}
|
||||
state={renameState}
|
||||
magic={magic}
|
||||
/>
|
||||
<Delete
|
||||
machine={machine}
|
||||
state={removeState}
|
||||
/>
|
||||
{expired
|
||||
? undefined
|
||||
: (
|
||||
<Expire
|
||||
machine={machine}
|
||||
state={expireState}
|
||||
/>
|
||||
)}
|
||||
<Routes
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
state={routesState}
|
||||
/>
|
||||
|
||||
<MenuComponent>
|
||||
<MenuComponent.Button
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'border border-transparent rounded-lg py-0.5 w-10',
|
||||
'group-hover:border-gray-200 dark:group-hover:border-zinc-700',
|
||||
)}
|
||||
>
|
||||
<KebabHorizontalIcon className="w-5" />
|
||||
</MenuComponent.Button>
|
||||
<MenuComponent.Items>
|
||||
<MenuComponent.ItemButton control={renameState}>
|
||||
Edit machine name
|
||||
</MenuComponent.ItemButton>
|
||||
<MenuComponent.ItemButton control={routesState}>
|
||||
Edit route settings
|
||||
</MenuComponent.ItemButton>
|
||||
<MenuComponent.Item className="opacity-50 hover:bg-transparent">
|
||||
Edit ACL tags
|
||||
</MenuComponent.Item>
|
||||
{expired
|
||||
? undefined
|
||||
: (
|
||||
<MenuComponent.ItemButton control={expireState}>
|
||||
Expire
|
||||
</MenuComponent.ItemButton>
|
||||
)}
|
||||
<MenuComponent.ItemButton
|
||||
className="text-red-500 dark:text-red-400"
|
||||
control={removeState}
|
||||
>
|
||||
Remove
|
||||
</MenuComponent.ItemButton>
|
||||
</MenuComponent.Items>
|
||||
</MenuComponent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { InfoIcon } from '@primer/octicons-react'
|
||||
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { useFetcher, useLoaderData } from '@remix-run/react'
|
||||
import { useLoaderData } from '@remix-run/react'
|
||||
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
@@ -107,7 +107,6 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
export default function Page() {
|
||||
useLiveData({ interval: 3000 })
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const fetcher = useFetcher()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -155,10 +154,8 @@ export default function Page() {
|
||||
{data.nodes.map(machine => (
|
||||
<MachineRow
|
||||
key={machine.id}
|
||||
// Typescript isn't smart enough yet
|
||||
machine={machine as unknown as Machine}
|
||||
routes={data.routes.filter(route => route.node.id === machine.id) as unknown as Route[]}
|
||||
fetcher={fetcher}
|
||||
machine={machine}
|
||||
routes={data.routes.filter(route => route.node.id === machine.id)}
|
||||
magic={data.magic}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -182,15 +182,12 @@ export default function Page() {
|
||||
<Auth magic={data.magic} />
|
||||
)}
|
||||
<ClientOnly fallback={
|
||||
// @ts-expect-error: JsonifyObject is weird
|
||||
<Users users={users} />
|
||||
}
|
||||
>
|
||||
{() => (
|
||||
<InteractiveUsers
|
||||
// @ts-expect-error: JsonifyObject is weird
|
||||
users={users}
|
||||
// @ts-expect-error: JsonifyObject is weird
|
||||
setUsers={setUsers}
|
||||
magic={data.magic}
|
||||
/>
|
||||
@@ -211,11 +208,10 @@ interface UserProps {
|
||||
function Users({ users, magic }: UserProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
|
||||
{users.map((user, i) => (
|
||||
{users.map(user => (
|
||||
<UserCard
|
||||
key={user.id}
|
||||
user={user}
|
||||
isFirst={i === 0}
|
||||
magic={magic}
|
||||
/>
|
||||
))}
|
||||
@@ -267,11 +263,10 @@ function InteractiveUsers({ users, setUsers, magic }: UserProps) {
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
|
||||
{users.map((user, i) => (
|
||||
{users.map(user => (
|
||||
<UserCard
|
||||
key={user.id}
|
||||
user={user}
|
||||
isFirst={i === 0}
|
||||
magic={magic}
|
||||
/>
|
||||
))}
|
||||
@@ -312,11 +307,10 @@ function MachineChip({ machine }: { readonly machine: Machine }) {
|
||||
|
||||
interface CardProps {
|
||||
user: UserMachine
|
||||
isFirst: boolean
|
||||
magic?: string
|
||||
}
|
||||
|
||||
function UserCard({ user, isFirst, magic }: CardProps) {
|
||||
function UserCard({ user, magic }: CardProps) {
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: user.name,
|
||||
})
|
||||
@@ -339,11 +333,11 @@ function UserCard({ user, isFirst, magic }: CardProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Rename username={user.name} magic={magic} />
|
||||
{isFirst
|
||||
? undefined
|
||||
: (
|
||||
{user.machines.length === 0
|
||||
? (
|
||||
<Remove username={user.name} magic={magic} />
|
||||
)}
|
||||
)
|
||||
: undefined}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
|
||||
+18
-18
@@ -1,28 +1,28 @@
|
||||
import type { User } from './User'
|
||||
|
||||
export type Machine = {
|
||||
id: string;
|
||||
machineKey: string;
|
||||
nodeKey: string;
|
||||
discoKey: string;
|
||||
ipAddresses: string[];
|
||||
name: string;
|
||||
export interface Machine {
|
||||
id: string
|
||||
machineKey: string
|
||||
nodeKey: string
|
||||
discoKey: string
|
||||
ipAddresses: string[]
|
||||
name: string
|
||||
|
||||
user: User;
|
||||
lastSeen: Date;
|
||||
expiry: Date;
|
||||
user: User
|
||||
lastSeen: string
|
||||
expiry: string
|
||||
|
||||
preAuthKey?: unknown; // TODO
|
||||
preAuthKey?: unknown // TODO
|
||||
|
||||
createdAt: Date;
|
||||
createdAt: string
|
||||
registerMethod: 'REGISTER_METHOD_UNSPECIFIED'
|
||||
| 'REGISTER_METHOD_AUTH_KEY'
|
||||
| 'REGISTER_METHOD_CLI'
|
||||
| 'REGISTER_METHOD_OIDC';
|
||||
| 'REGISTER_METHOD_OIDC'
|
||||
|
||||
forcedTags: string[];
|
||||
invalidTags: string[];
|
||||
validTags: string[];
|
||||
givenName: string;
|
||||
online: boolean;
|
||||
forcedTags: string[]
|
||||
invalidTags: string[]
|
||||
validTags: string[]
|
||||
givenName: string
|
||||
online: boolean
|
||||
}
|
||||
|
||||
+10
-10
@@ -1,13 +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;
|
||||
export interface Route {
|
||||
id: string
|
||||
node: Machine
|
||||
prefix: string
|
||||
advertised: boolean
|
||||
enabled: boolean
|
||||
isPrimary: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt: string
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
export type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export async function loadContext(): Promise<HeadplaneContext> {
|
||||
return context
|
||||
}
|
||||
|
||||
export async function loadAcl() {
|
||||
export async function loadAcl(): Promise<{ data: string, type: 'json' | 'yaml' }> {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path) {
|
||||
try {
|
||||
|
||||
+10
-14
@@ -11,33 +11,29 @@
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-yaml": "^6.1.1",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@primer/octicons-react": "^19.9.0",
|
||||
"@react-aria/toast": "3.0.0-beta.11",
|
||||
"@react-stately/toast": "3.0.0-beta.3",
|
||||
"@react-aria/toast": "3.0.0-beta.12",
|
||||
"@react-stately/toast": "3.0.0-beta.4",
|
||||
"@remix-run/node": "^2.9.2",
|
||||
"@remix-run/react": "^2.9.2",
|
||||
"@remix-run/serve": "^2.9.2",
|
||||
"@uiw/codemirror-theme-github": "^4.22.0",
|
||||
"@uiw/react-codemirror": "^4.22.0",
|
||||
"clsx": "^2.1.1",
|
||||
"isbot": "^5.1.6",
|
||||
"isbot": "^5.1.8",
|
||||
"oauth4webapi": "^2.10.4",
|
||||
"react": "19.0.0-beta-26f2496093-20240514",
|
||||
"react-aria-components": "^1.2.0",
|
||||
"react-codemirror-merge": "^4.22.0",
|
||||
"react-aria-components": "^1.2.1",
|
||||
"react-dom": "19.0.0-beta-26f2496093-20240514",
|
||||
"remix-utils": "^7.6.0",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss-react-aria-components": "^1.1.2",
|
||||
"undici": "^6.16.1",
|
||||
"tailwindcss-react-aria-components": "^1.1.3",
|
||||
"undici": "^6.18.2",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"yaml": "^2.4.2",
|
||||
"yaml": "^2.4.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -52,7 +48,7 @@
|
||||
"tailwindcss": "^3.4.3",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11",
|
||||
"vite": "^5.2.12",
|
||||
"vite-plugin-babel": "^1.2.0",
|
||||
"vite-tsconfig-paths": "^4.2.1"
|
||||
},
|
||||
@@ -66,7 +62,7 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@react-aria/overlays@3.22.0": "patches/@react-aria__overlays@3.22.0.patch"
|
||||
"@react-aria/overlays@3.22.1": "patches/@react-aria__overlays@3.22.1.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -1,13 +1,13 @@
|
||||
diff --git a/dist/usePreventScroll.mjs b/dist/usePreventScroll.mjs
|
||||
index 532a72f9e9ba985d036b7eba0f950ff6552898d9..7c9263667c2463c8e84fd07064076132fe53284c 100644
|
||||
index 69b84ce2aec5b637a9f0ba8158b6a1ba4173c266..5e546a6e4fc3b7a0468c1c89d887a4392c010624 100644
|
||||
--- a/dist/usePreventScroll.mjs
|
||||
+++ b/dist/usePreventScroll.mjs
|
||||
@@ -48,7 +48,7 @@ function $49c51c25361d4cd2$export$ee0f7cc6afcd1c18(options = {}) {
|
||||
// For most browsers, all we need to do is set `overflow: hidden` on the root element, and
|
||||
// add some padding to prevent the page from shifting when the scrollbar is hidden.
|
||||
function $49c51c25361d4cd2$var$preventScrollStandard() {
|
||||
- return (0, $7mMvr$chain)($49c51c25361d4cd2$var$setStyle(document.documentElement, "paddingRight", `${window.innerWidth - document.documentElement.clientWidth}px`), $49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"));
|
||||
+ return (0, $7mMvr$chain)($49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"));
|
||||
- return (0, $7mMvr$chain)($49c51c25361d4cd2$var$setStyle(document.documentElement, 'paddingRight', `${window.innerWidth - document.documentElement.clientWidth}px`), $49c51c25361d4cd2$var$setStyle(document.documentElement, 'overflow', 'hidden'));
|
||||
+ return (0, $7mMvr$chain)($49c51c25361d4cd2$var$setStyle(document.documentElement, 'overflow', 'hidden'));
|
||||
}
|
||||
// Mobile Safari is a whole different beast. Even with overflow: hidden,
|
||||
// it still scrolls the page in many situations:
|
||||
@@ -15,8 +15,8 @@ index 532a72f9e9ba985d036b7eba0f950ff6552898d9..7c9263667c2463c8e84fd07064076132
|
||||
// enable us to scroll the window to the top, which is required for the rest of this to work.
|
||||
let scrollX = window.pageXOffset;
|
||||
let scrollY = window.pageYOffset;
|
||||
- restoreStyles = (0, $7mMvr$chain)($49c51c25361d4cd2$var$addEvent(window, "scroll", onWindowScroll), $49c51c25361d4cd2$var$setStyle(document.documentElement, "paddingRight", `${window.innerWidth - document.documentElement.clientWidth}px`), $49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"), $49c51c25361d4cd2$var$setStyle(document.body, "marginTop", `-${scrollY}px`), ()=>{
|
||||
+ restoreStyles = (0, $7mMvr$chain)($49c51c25361d4cd2$var$addEvent(window, "scroll", onWindowScroll), $49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"), $49c51c25361d4cd2$var$setStyle(document.body, "marginTop", `-${scrollY}px`), ()=>{
|
||||
- restoreStyles = (0, $7mMvr$chain)($49c51c25361d4cd2$var$addEvent(window, 'scroll', onWindowScroll), $49c51c25361d4cd2$var$setStyle(document.documentElement, 'paddingRight', `${window.innerWidth - document.documentElement.clientWidth}px`), $49c51c25361d4cd2$var$setStyle(document.documentElement, 'overflow', 'hidden'), $49c51c25361d4cd2$var$setStyle(document.body, 'marginTop', `-${scrollY}px`), ()=>{
|
||||
+ restoreStyles = (0, $7mMvr$chain)($49c51c25361d4cd2$var$addEvent(window, 'scroll', onWindowScroll), $49c51c25361d4cd2$var$setStyle(document.documentElement, 'overflow', 'hidden'), $49c51c25361d4cd2$var$setStyle(document.body, 'marginTop', `-${scrollY}px`), ()=>{
|
||||
window.scrollTo(scrollX, scrollY);
|
||||
});
|
||||
// Scroll to the top. The negative margin on the body will make this appear the same.
|
||||
Generated
+1062
-1312
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user