mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 08:08:56 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 331a9c8dcf | |||
| 808b01c5d7 | |||
| a8abd37e3a | |||
| d1fa76971b | |||
| a9e8394dec | |||
| 9dafd8e8b0 | |||
| 0c1f6969da | |||
| 4214e14ba8 |
@@ -1,3 +1,10 @@
|
||||
### 0.3.3 (October 28, 2024)
|
||||
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
|
||||
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
|
||||
- Improved ACL editor performance by switching back to CodeMirror 6
|
||||
- Fixed an issue where editing the ACL policy would cause it to revert on the UI (fixes [#34](https://github.com/tale/headplane/issues/34))
|
||||
- Updated to the next stable beta of the React 19 Compiler ([See More](https://react.dev/learn/react-compiler))
|
||||
|
||||
### 0.3.2 (October 11, 2024)
|
||||
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
|
||||
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
|
||||
|
||||
@@ -34,7 +34,6 @@ export default function NumberField(props: NumberFieldProps) {
|
||||
)}>
|
||||
<Input
|
||||
className="w-full bg-transparent focus:outline-none"
|
||||
name={props.name}
|
||||
/>
|
||||
<Button slot="decrement">
|
||||
<DashIcon className="w-4 h-4" />
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import Merge from 'react-codemirror-merge'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
import { jsonc } from '@shopify/lang-jsonc'
|
||||
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
|
||||
import { useState } from 'react'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Fallback from './fallback'
|
||||
|
||||
interface EditorProps {
|
||||
isDisabled?: boolean
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function Editor(props: EditorProps) {
|
||||
const [light, setLight] = useState(false)
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)')
|
||||
setLight(theme.matches)
|
||||
theme.addEventListener('change', (theme) => {
|
||||
setLight(theme.matches)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<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">
|
||||
<ErrorBoundary fallback={
|
||||
<p className={cn(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
'text-gray-400 dark:text-gray-500 text-xl',
|
||||
)}>
|
||||
Failed to load the editor.
|
||||
</p>
|
||||
}>
|
||||
<ClientOnly fallback={<Fallback acl={props.value} />}>
|
||||
{() => (
|
||||
<CodeMirror
|
||||
value={props.value}
|
||||
height="100%"
|
||||
extensions={[jsonc()]}
|
||||
style={{ height: "100%" }}
|
||||
theme={light ? githubLight : githubDark}
|
||||
onChange={(value) => props.onChange(value)}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DifferProps {
|
||||
left: string
|
||||
right: string
|
||||
}
|
||||
|
||||
export function Differ(props: DifferProps) {
|
||||
const [light, setLight] = useState(false)
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)')
|
||||
setLight(theme.matches)
|
||||
theme.addEventListener('change', (theme) => {
|
||||
setLight(theme.matches)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<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">
|
||||
{props.left === props.right ? (
|
||||
<p className={cn(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
'text-gray-400 dark:text-gray-500 text-xl',
|
||||
)}>
|
||||
No changes
|
||||
</p>
|
||||
) : (
|
||||
<ErrorBoundary fallback={
|
||||
<p className={cn(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
'text-gray-400 dark:text-gray-500 text-xl',
|
||||
)}>
|
||||
Failed to load the editor.
|
||||
</p>
|
||||
}>
|
||||
<ClientOnly fallback={<Fallback acl={props.right} />}>
|
||||
{() => (
|
||||
<Merge
|
||||
orientation="a-b"
|
||||
theme={light ? githubLight : githubDark}
|
||||
>
|
||||
<Merge.Original
|
||||
readOnly
|
||||
value={props.left}
|
||||
extensions={[jsonc()]}
|
||||
/>
|
||||
<Merge.Modified
|
||||
readOnly
|
||||
value={props.right}
|
||||
extensions={[jsonc()]}
|
||||
/>
|
||||
</Merge>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import Editor, { DiffEditor, Monaco } from '@monaco-editor/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
|
||||
import Fallback from '~/routes/_data.acls._index/fallback'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
variant: 'edit' | 'diff'
|
||||
language: 'json' | 'yaml'
|
||||
state: [string, (value: string) => void]
|
||||
policy?: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
function monacoCallback(monaco: Monaco) {
|
||||
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
|
||||
validate: true,
|
||||
allowComments: true,
|
||||
schemas: [],
|
||||
enableSchemaRequest: true,
|
||||
trailingCommas: 'ignore',
|
||||
})
|
||||
|
||||
monaco.languages.register({ id: 'json' })
|
||||
monaco.languages.register({ id: 'yaml' })
|
||||
}
|
||||
|
||||
export default function MonacoEditor({ variant, language, state, policy, isDisabled }: Props) {
|
||||
const [light, setLight] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)')
|
||||
setLight(theme.matches)
|
||||
|
||||
theme.addEventListener('change', (theme) => {
|
||||
setLight(theme.matches)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<ClientOnly fallback={<Fallback acl={state[0]} />}>
|
||||
{() => variant === 'edit'
|
||||
? (
|
||||
<Editor
|
||||
height="100%"
|
||||
language={language}
|
||||
theme={light ? 'light' : 'vs-dark'}
|
||||
value={state[0]}
|
||||
onChange={(updated) => {
|
||||
if (!updated) {
|
||||
return
|
||||
}
|
||||
|
||||
if (updated !== state[0]) {
|
||||
state[1](updated)
|
||||
}
|
||||
}}
|
||||
loading={<Fallback acl={state[0]} />}
|
||||
beforeMount={monacoCallback}
|
||||
options={{
|
||||
wordWrap: 'on',
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
readOnly: isDisabled,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<DiffEditor
|
||||
height="100%"
|
||||
language={language}
|
||||
theme={light ? 'light' : 'vs-dark'}
|
||||
original={policy}
|
||||
modified={state[0]}
|
||||
loading={<Fallback acl={state[0]} />}
|
||||
beforeMount={monacoCallback}
|
||||
options={{
|
||||
wordWrap: 'on',
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
readOnly: isDisabled,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cn } from '~/utils/cn'
|
||||
import { AlertIcon } from '@primer/octicons-react'
|
||||
|
||||
import Card from '~/components/Card'
|
||||
import Code from '~/components/Code'
|
||||
|
||||
interface Props {
|
||||
message: string
|
||||
}
|
||||
|
||||
export function ErrorView({ message }: Props) {
|
||||
return (
|
||||
<Card variant="flat" className="max-w-full mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-xl mb-0">
|
||||
Error
|
||||
</Card.Title>
|
||||
<AlertIcon className="w-8 h-8 text-red-500"/>
|
||||
</div>
|
||||
<Card.Text className="mt-4">
|
||||
Could not apply changes to your ACL policy
|
||||
due to the following error:
|
||||
<br />
|
||||
<Code>
|
||||
{message}
|
||||
</Code>
|
||||
</Card.Text>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import Spinner from '~/components/Spinner'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface FallbackProps {
|
||||
interface Props {
|
||||
readonly acl: string
|
||||
}
|
||||
|
||||
export default function Fallback({ acl }: FallbackProps) {
|
||||
export default function Fallback({ acl }: Props) {
|
||||
return (
|
||||
<div className="inline-block relative w-full h-editor">
|
||||
<Spinner className="w-4 h-4 absolute p-2" />
|
||||
@@ -15,7 +15,7 @@ export default function Fallback({ acl }: FallbackProps) {
|
||||
'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',
|
||||
'pl-10 pt-1 leading-snug',
|
||||
)}
|
||||
value={acl}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
|
||||
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { useFetcher, useLoaderData } from '@remix-run/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLoaderData, useRevalidator } from '@remix-run/react'
|
||||
import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
|
||||
@@ -14,52 +15,100 @@ import Spinner from '~/components/Spinner'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig } from '~/utils/config/headscale'
|
||||
import { HeadscaleError, pull, put } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import log from '~/utils/log'
|
||||
|
||||
import Monaco from './editor'
|
||||
import { Editor, Differ } from './cm'
|
||||
import { Unavailable } from './unavailable'
|
||||
import { ErrorView } from './error'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
|
||||
// The way policy is handled in 0.23 of Headscale and later is verbose.
|
||||
// The 2 ACL policy modes are either the database one or file one
|
||||
//
|
||||
// File: The ACL policy is readonly to the API and manually edited
|
||||
// Database: The ACL policy is read/write to the API
|
||||
//
|
||||
// To determine if we first have an ACL policy available we need to check
|
||||
// if fetching the v1/policy route gives us a 500 status code or a 200.
|
||||
//
|
||||
// 500 can mean many different things here unfortunately:
|
||||
// - In file based that means the file is not accessible
|
||||
// - In database mode this can mean that we have never set an ACL policy
|
||||
// - In database mode this can mean that the ACL policy is not available
|
||||
// - A general server error may have occurred
|
||||
//
|
||||
// Unfortunately the server errors are not very descriptive so we have to
|
||||
// do some silly guesswork here. If we are running in an integration mode
|
||||
// and have the Headscale configuration available to us, our assumptions
|
||||
// can be more accurate, otherwise we just HAVE to assume that the ACL
|
||||
// policy has never been set.
|
||||
//
|
||||
// We can do damage control by checking for write access and if we are not
|
||||
// able to PUT an ACL policy on the v1/policy route, we can already know
|
||||
// that the policy is at the very-least readonly or not available.
|
||||
const context = await loadContext()
|
||||
let modeGuess = 'database' // Assume database mode
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig()
|
||||
modeGuess = config.policy.mode
|
||||
}
|
||||
|
||||
// Attempt to load the policy, for both the frontend and for checking
|
||||
// if we are able to write to the policy for write access
|
||||
try {
|
||||
const { policy } = await pull<{ policy: string }>(
|
||||
'v1/policy',
|
||||
session.get('hsApiKey')!,
|
||||
)
|
||||
|
||||
try {
|
||||
// We have read access, now do we have write access?
|
||||
// Attempt to set the policy to what we just got
|
||||
await put('v1/policy', session.get('hsApiKey')!, {
|
||||
policy,
|
||||
})
|
||||
let write = false // On file mode we already know it's readonly
|
||||
if (modeGuess === 'database' && policy.length > 0) {
|
||||
try {
|
||||
await put('v1/policy', session.get('hsApiKey')!, {
|
||||
policy: policy,
|
||||
})
|
||||
|
||||
return {
|
||||
hasAclWrite: true,
|
||||
currentAcl: policy,
|
||||
aclType: 'json',
|
||||
} as const
|
||||
} catch (error) {
|
||||
if (!(error instanceof HeadscaleError)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (error.status === 500) {
|
||||
return {
|
||||
hasAclWrite: false,
|
||||
currentAcl: policy,
|
||||
aclType: 'json',
|
||||
} as const
|
||||
write = true
|
||||
} catch (error) {
|
||||
write = false
|
||||
log.debug(
|
||||
'APIC',
|
||||
'Failed to write to ACL policy with error %s',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
hasAclWrite: true,
|
||||
currentAcl: '',
|
||||
aclType: 'json',
|
||||
} as const
|
||||
return {
|
||||
read: true,
|
||||
write,
|
||||
mode: modeGuess,
|
||||
policy
|
||||
}
|
||||
} catch {
|
||||
// If we are explicit on file mode then this is the end of the road
|
||||
if (modeGuess === 'file') {
|
||||
return {
|
||||
read: false,
|
||||
write: false,
|
||||
mode: modeGuess
|
||||
}
|
||||
}
|
||||
|
||||
// Assume that we have write access otherwise?
|
||||
// This is sort of a brittle assumption to make but we don't want
|
||||
// to create a default policy if we don't have to.
|
||||
return {
|
||||
read: true,
|
||||
write: true,
|
||||
mode: modeGuess
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
@@ -70,16 +119,21 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
})
|
||||
}
|
||||
|
||||
const { acl } = await request.json() as { acl: string, api: boolean }
|
||||
try {
|
||||
await put('v1/policy', session.get('hsApiKey')!, {
|
||||
policy: acl,
|
||||
})
|
||||
const { acl } = await request.json() as { acl: string }
|
||||
const { policy } = await put<{ policy: string }>(
|
||||
'v1/policy',
|
||||
session.get('hsApiKey')!,
|
||||
{
|
||||
policy: acl,
|
||||
}
|
||||
)
|
||||
|
||||
await setTimeout(250)
|
||||
return json({ success: true })
|
||||
return json({ success: true, policy })
|
||||
} catch (error) {
|
||||
return json({ success: false }, {
|
||||
log.debug('APIC', 'Failed to update ACL policy with error %s', error)
|
||||
const text = JSON.parse(error.message)
|
||||
return json({ success: false, error: text.message }, {
|
||||
status: error instanceof HeadscaleError ? error.status : 500,
|
||||
})
|
||||
}
|
||||
@@ -87,69 +141,12 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
return json({ success: true })
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<div>
|
||||
<Notice className="mb-4">
|
||||
An ACL policy is not available or an error occurred while trying to fetch it.
|
||||
</Notice>
|
||||
<h1 className="text-2xl font-medium mb-4">
|
||||
Access Control List (ACL)
|
||||
</h1>
|
||||
|
||||
<p className="mb-4 max-w-prose">
|
||||
The ACL file is used to define the access control rules for your network.
|
||||
You can find more information about the ACL file in the
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1018/acls"
|
||||
name="Tailscale ACL documentation"
|
||||
>
|
||||
Tailscale ACL guide
|
||||
</Link>
|
||||
{' '}
|
||||
and the
|
||||
{' '}
|
||||
<Link
|
||||
to="https://headscale.net/acls"
|
||||
name="Headscale ACL documentation"
|
||||
>
|
||||
Headscale docs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<div>
|
||||
<div className="max-w-prose">
|
||||
<p className="mb-4 text-md">
|
||||
If you are running Headscale 0.23-beta1 or later, the
|
||||
ACL configuration is most likely set to
|
||||
{' '}
|
||||
<Code>file</Code>
|
||||
{' '}
|
||||
mode but the ACL file is not available. In order to
|
||||
resolve this you will either need to correctly set
|
||||
{' '}
|
||||
<Code>policy.path</Code>
|
||||
{' '}
|
||||
in your Headscale configuration or set the
|
||||
{' '}
|
||||
<Code>policy.mode</Code>
|
||||
{' '}
|
||||
to
|
||||
{' '}
|
||||
<Code>database</Code>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const fetcher = useFetcher<typeof action>()
|
||||
const [acl, setAcl] = useState(data.currentAcl)
|
||||
const fetcher = useDebounceFetcher<typeof action>()
|
||||
const revalidator = useRevalidator()
|
||||
|
||||
const [acl, setAcl] = useState(data.policy ?? '')
|
||||
const [toasted, setToasted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -164,14 +161,39 @@ export default function Page() {
|
||||
}
|
||||
|
||||
setToasted(true)
|
||||
setAcl(data.currentAcl)
|
||||
}, [fetcher.data, toasted, data.currentAcl])
|
||||
if (revalidator.state === 'idle') {
|
||||
revalidator.revalidate()
|
||||
}
|
||||
}, [fetcher.data, toasted, data.policy])
|
||||
|
||||
// The state for if the save and discard buttons should be disabled
|
||||
// is pretty complicated to calculate and varies on different states.
|
||||
const disabled = useMemo(() => {
|
||||
if (!data.read || !data.write) {
|
||||
return true
|
||||
}
|
||||
|
||||
// First check our fetcher states
|
||||
if (fetcher.state === 'loading') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (revalidator.state === 'loading') {
|
||||
return true
|
||||
}
|
||||
|
||||
// If we have a failed fetcher state allow the user to try again
|
||||
if (fetcher.data?.success === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
return data.policy === acl
|
||||
}, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data.hasAclWrite
|
||||
? undefined
|
||||
: (
|
||||
{data.read && !data.write
|
||||
? (
|
||||
<div className="mb-4">
|
||||
<Notice className="w-fit">
|
||||
The ACL policy is read-only. You can view the current policy
|
||||
@@ -181,7 +203,7 @@ export default function Page() {
|
||||
database in your Headscale configuration.
|
||||
</Notice>
|
||||
</div>
|
||||
)}
|
||||
) : undefined}
|
||||
|
||||
<h1 className="text-2xl font-medium mb-4">
|
||||
Access Control List (ACL)
|
||||
@@ -209,109 +231,117 @@ export default function Page() {
|
||||
.
|
||||
</p>
|
||||
|
||||
<Tabs>
|
||||
<TabList className={cn(
|
||||
'flex border-t border-gray-200 dark:border-gray-700',
|
||||
'w-fit rounded-t-lg overflow-hidden',
|
||||
'text-gray-400 dark:text-gray-500',
|
||||
)}
|
||||
>
|
||||
<Tab
|
||||
id="edit"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2 rounded-tl-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
{fetcher.data?.success === false
|
||||
? (
|
||||
<ErrorView message={fetcher.data.error} />
|
||||
) : undefined}
|
||||
|
||||
{data.read ? (
|
||||
<>
|
||||
<Tabs>
|
||||
<TabList className={cn(
|
||||
'flex border-t border-gray-200 dark:border-gray-700',
|
||||
'w-fit rounded-t-lg overflow-hidden',
|
||||
'text-gray-400 dark:text-gray-500',
|
||||
)}
|
||||
>
|
||||
<Tab
|
||||
id="edit"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2 rounded-tl-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
>
|
||||
<PencilIcon className="w-5 h-5" />
|
||||
<p>Edit file</p>
|
||||
</Tab>
|
||||
<Tab
|
||||
id="diff"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
>
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
<p>Preview changes</p>
|
||||
</Tab>
|
||||
<Tab
|
||||
id="preview"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2 rounded-tr-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
>
|
||||
<BeakerIcon className="w-5 h-5" />
|
||||
<p>Preview rules</p>
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="edit">
|
||||
<Editor
|
||||
isDisabled={!data.write}
|
||||
value={acl}
|
||||
onChange={setAcl}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="diff">
|
||||
<Differ
|
||||
left={data.policy}
|
||||
right={acl}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="preview">
|
||||
<div
|
||||
className={cn(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
|
||||
'p-16 flex flex-col items-center justify-center',
|
||||
)}
|
||||
>
|
||||
<IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" />
|
||||
<p className="w-1/2 text-center mt-4">
|
||||
The Preview rules is very much still a work in progress.
|
||||
It is a bit complicated to implement right now but hopefully it will be available soon.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="mr-2"
|
||||
isDisabled={disabled}
|
||||
onPress={() => {
|
||||
setToasted(false)
|
||||
fetcher.submit({
|
||||
acl,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="w-5 h-5" />
|
||||
<p>Edit file</p>
|
||||
</Tab>
|
||||
<Tab
|
||||
id="diff"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
{fetcher.state === 'idle'
|
||||
? undefined
|
||||
: (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={disabled}
|
||||
onPress={() => {
|
||||
setAcl(data.policy)
|
||||
}}
|
||||
>
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
<p>Preview changes</p>
|
||||
</Tab>
|
||||
<Tab
|
||||
id="preview"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2 rounded-tr-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
>
|
||||
<BeakerIcon className="w-5 h-5" />
|
||||
<p>Preview rules</p>
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="edit">
|
||||
<Monaco
|
||||
isDisabled={!data.hasAclWrite}
|
||||
variant="edit"
|
||||
language={data.aclType}
|
||||
state={[acl, setAcl]}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="diff">
|
||||
<Monaco
|
||||
variant="diff"
|
||||
language={data.aclType}
|
||||
state={[acl, setAcl]}
|
||||
policy={data.currentAcl}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="preview">
|
||||
<div
|
||||
className={cn(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
|
||||
'p-16 flex flex-col items-center justify-center',
|
||||
)}
|
||||
>
|
||||
<IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" />
|
||||
<p className="w-1/2 text-center mt-4">
|
||||
The Preview rules is very much still a work in progress.
|
||||
It is a bit complicated to implement right now but hopefully it will be available soon.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="mr-2"
|
||||
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
|
||||
onPress={() => {
|
||||
setToasted(false)
|
||||
fetcher.submit({
|
||||
acl,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{fetcher.state === 'idle'
|
||||
? undefined
|
||||
: (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl || !data.hasAclWrite}
|
||||
onPress={() => { setAcl(data.currentAcl) }}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</>
|
||||
) : <Unavailable mode={data.mode} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { cn } from '~/utils/cn'
|
||||
import { AlertIcon } from '@primer/octicons-react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Card from '~/components/Card'
|
||||
|
||||
interface Props {
|
||||
mode: 'file' | 'database'
|
||||
}
|
||||
|
||||
export function Unavailable({ mode }: Props) {
|
||||
return (
|
||||
<Card variant="flat" className="max-w-prose mt-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-xl mb-0">
|
||||
ACL Policy Unavailable
|
||||
</Card.Title>
|
||||
<AlertIcon className="w-8 h-8 text-red-500"/>
|
||||
</div>
|
||||
<Card.Text className="mt-4">
|
||||
Unable to load a valid ACL policy configuration.
|
||||
This is most likely due to a misconfiguration in your
|
||||
Headscale configuration file.
|
||||
</Card.Text>
|
||||
|
||||
{mode !== 'file' ? (
|
||||
<p className="mt-4 text-sm">
|
||||
According to your configuration, the ACL policy mode
|
||||
is set to <Code>file</Code> but the ACL file is not
|
||||
available. Ensure that the <Code>policy.path</Code> is
|
||||
set to a valid path in your Headscale configuration.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-4 text-sm">
|
||||
In order to fully utilize the ACL management features of
|
||||
Headplane, please set <Code>policy.mode</Code> to either
|
||||
{' '}<Code>file</Code> or <Code>database</Code> in your
|
||||
Headscale configuration.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import { pull } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
|
||||
import { menuAction } from './_data.machines._index/action'
|
||||
import MenuOptions from './_data.machines._index/menu'
|
||||
import { menuAction } from '../_data.machines._index/action'
|
||||
import MenuOptions from '../_data.machines._index/menu'
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
@@ -1,17 +1,18 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { InfoIcon } from '@primer/octicons-react'
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { useLoaderData } from '@remix-run/react'
|
||||
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import { type Machine, type Route, User } from '~/types'
|
||||
import Link from '~/components/Link'
|
||||
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 type { Machine, Route, User } from '~/types'
|
||||
|
||||
import { menuAction } from './action'
|
||||
import MachineRow from './machine'
|
||||
@@ -58,8 +59,20 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-medium mb-4">Machines</h1>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className='text-2xl font-medium mb-4'>Machines</h1>
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
Manage the devices connected to your Tailnet.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1372/manage-devices"
|
||||
name="Tailscale Manage Devices Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<NewMachine server={data.server} users={data.users} />
|
||||
</div>
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
|
||||
@@ -110,6 +110,7 @@ export default function Page() {
|
||||
const { keys, users, server } = useLoaderData<typeof loader>()
|
||||
const [user, setUser] = useState('All')
|
||||
const [status, setStatus] = useState('Active')
|
||||
useLiveData({ interval: 3000 })
|
||||
|
||||
const filteredKeys = keys.filter(key => {
|
||||
if (user !== 'All' && key.user !== user) {
|
||||
|
||||
@@ -122,6 +122,7 @@ export default function Page() {
|
||||
label="API Key"
|
||||
name="api-key"
|
||||
placeholder="API Key"
|
||||
type="password"
|
||||
/>
|
||||
<Button
|
||||
className="w-full mt-2.5"
|
||||
|
||||
@@ -7,3 +7,24 @@
|
||||
scrollbar-gutter: stable
|
||||
}
|
||||
}
|
||||
|
||||
.cm-merge-theme {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.cm-mergeView {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.cm-mergeViewEditors {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cm-mergeViewEditor {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Weirdest class name characters but ok */
|
||||
.cm-mergeView .ͼ1 .cm-scroller, .cm-mergeView .ͼ1 {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@ export interface HeadplaneContext {
|
||||
let context: HeadplaneContext | undefined
|
||||
|
||||
export async function loadContext(): Promise<HeadplaneContext> {
|
||||
const envFile = process.env.LOAD_ENV_FILE === 'true'
|
||||
if (envFile) {
|
||||
log.info('CTXT', 'Loading environment variables from .env')
|
||||
await import('dotenv/config')
|
||||
}
|
||||
|
||||
if (context) {
|
||||
return context
|
||||
}
|
||||
|
||||
+12
-6
@@ -16,19 +16,24 @@
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@kubernetes/client-node": "^0.21.0",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@primer/octicons-react": "^19.10.0",
|
||||
"@react-aria/toast": "3.0.0-beta.12",
|
||||
"@react-stately/toast": "3.0.0-beta.4",
|
||||
"@remix-run/node": "^2.10.2",
|
||||
"@remix-run/react": "^2.10.2",
|
||||
"@remix-run/serve": "^2.10.2",
|
||||
"@shopify/lang-jsonc": "^1.0.0",
|
||||
"@uiw/codemirror-theme-github": "^4.23.5",
|
||||
"@uiw/react-codemirror": "^4.23.5",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"isbot": "^5.1.11",
|
||||
"oauth4webapi": "^2.11.1",
|
||||
"react": "19.0.0-rc-f38c22b244-20240704",
|
||||
"react": "19.0.0-beta-26f2496093-20240514",
|
||||
"react-aria-components": "^1.2.1",
|
||||
"react-dom": "19.0.0-rc-f38c22b244-20240704",
|
||||
"react-codemirror-merge": "^4.23.5",
|
||||
"react-dom": "19.0.0-beta-26f2496093-20240514",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"remix-utils": "^7.6.0",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss-react-aria-components": "^1.1.3",
|
||||
@@ -42,8 +47,8 @@
|
||||
"@types/react": "npm:types-react@beta",
|
||||
"@types/react-dom": "npm:types-react-dom@beta",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"babel-plugin-react-compiler": "0.0.0-experimental-c23de8d-20240515",
|
||||
"eslint": "^8.57.0",
|
||||
"babel-plugin-react-compiler": "19.0.0-beta-8a03594-20241020",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-tale": "^2.0.4",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.4",
|
||||
@@ -63,7 +68,8 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@react-aria/overlays@3.22.1": "patches/@react-aria__overlays@3.22.1.patch"
|
||||
"@react-aria/overlays@3.22.1": "patches/@react-aria__overlays@3.22.1.patch",
|
||||
"@shopify/lang-jsonc@1.0.0": "patches/@shopify__lang-jsonc@1.0.0.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
diff --git a/dist/esm/index.js b/dist/esm/index.js
|
||||
index 8b5b71f8eee6606ca8de47c15eef0ce73c01d93f..2bfa4e7c7ff650b35b1bdee75c28bd6fbf6c6704 100644
|
||||
--- a/dist/esm/index.js
|
||||
+++ b/dist/esm/index.js
|
||||
@@ -1,3 +1,3 @@
|
||||
-export { jsonc, jsoncLanguage } from './jsonc';
|
||||
-export { parser } from './parser';
|
||||
+export { jsonc, jsoncLanguage } from './jsonc.js';
|
||||
+export { parser } from './parser.js';
|
||||
//# sourceMappingURL=index.js.map
|
||||
\ No newline at end of file
|
||||
diff --git a/dist/esm/jsonc.js b/dist/esm/jsonc.js
|
||||
index 5f968887d47696122c6bbfc4b98f94af6477f37a..b144021c4b5d4504c73f6c2172e8d1c2ddbfe3ac 100644
|
||||
--- a/dist/esm/jsonc.js
|
||||
+++ b/dist/esm/jsonc.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { parser } from './parser';
|
||||
+import { parser } from './parser.js';
|
||||
import { continuedIndent, indentNodeProp, foldNodeProp, foldInside, LRLanguage, LanguageSupport, } from '@codemirror/language';
|
||||
/// A language provider that provides JSON parsing.
|
||||
export const jsoncLanguage = LRLanguage.define({
|
||||
diff --git a/dist/esm/parser.js b/dist/esm/parser.js
|
||||
index 3d966fcf7c55003b1ba6c2b3f531b7c9b8045cb8..f6e8f56c3506f76f31571a49af8cd44364a864a3 100644
|
||||
--- a/dist/esm/parser.js
|
||||
+++ b/dist/esm/parser.js
|
||||
@@ -1,6 +1,6 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
-import {jsonHighlighting} from "./highlight"
|
||||
+import {jsonHighlighting} from "./highlight.js"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "$zO]QPOOOOQO'#Cd'#CdOtQPO'#CgO|QPO'#ClOOQO'#Cr'#CrQOQPOOOOQO'#Ci'#CiO!TQPO'#ChO!YQPO'#CtOOQO,59R,59RO!bQPO,59RO!gQPO'#CwOOQO,59W,59WO!oQPO,59WO]QPO,59SO!tQPO,59`O!|QPO,59`OOQO1G.m1G.mO#UQPO,59cO#]QPO,59cOOQO1G.r1G.rOOQO1G.n1G.nOOQO,59X,59XO#eQPO1G.zOOQO-E6k-E6kOOQO,59Y,59YO#mQPO1G.}OOQO-E6l-E6lPwQPO'#CmP]QPO'#Cn",
|
||||
diff --git a/package.json b/package.json
|
||||
index 9c3a56db25535b7eb0b1f951abe486d530a2714f..5e858500f155c16ecb068854552a71e1da5f04fc 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "@shopify/lang-jsonc",
|
||||
+ "type": "module",
|
||||
"version": "1.0.0",
|
||||
"description": "JSONC language support for CodeMirror",
|
||||
"publishConfig": {
|
||||
Generated
+1306
-984
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user