fix: handle errors and fix api logic with acl updating

This commit is contained in:
Aarnav Tale 2024-10-26 23:06:29 -04:00
parent d1fa76971b
commit a8abd37e3a
No known key found for this signature in database
7 changed files with 394 additions and 242 deletions

View File

@ -2,6 +2,7 @@ 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'
@ -11,12 +12,11 @@ import Fallback from './fallback'
interface EditorProps {
isDisabled?: boolean
value: string
onChange: (value: string) => void
defaultValue?: string
}
export function Editor(props: EditorProps) {
const [value, setValue] = useState(props.defaultValue ?? '')
const [light, setLight] = useState(false)
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)')
@ -32,17 +32,27 @@ export function Editor(props: EditorProps) {
'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={value} />}>
<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={value}
value={props.value}
height="100%"
extensions={[jsonc()]}
style={{ height: "100%" }}
theme={light ? githubLight : githubDark}
onChange={(value) => props.onChange(value)}
/>
)}
</ClientOnly>
</ErrorBoundary>
</div>
</div>
)
@ -77,6 +87,14 @@ export function Differ(props: DifferProps) {
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
@ -96,6 +114,7 @@ export function Differ(props: DifferProps) {
</Merge>
)}
</ClientOnly>
</ErrorBoundary>
)}
</div>
</div>

View File

@ -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>
)
}

View File

@ -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 { 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')!,
)
let write = false // On file mode we already know it's readonly
if (modeGuess === 'database' && policy.length > 0) {
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,
policy: policy,
})
return {
hasAclWrite: true,
currentAcl: policy,
aclType: 'json',
} as const
write = true
} catch (error) {
if (!(error instanceof HeadscaleError)) {
throw error
write = false
log.debug(
'APIC',
'Failed to write to ACL policy with error %s',
error
)
}
}
if (error.status === 500) {
return {
hasAclWrite: false,
currentAcl: policy,
aclType: 'json',
} as const
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
}
}
} catch {}
// 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 {
hasAclWrite: true,
currentAcl: '',
aclType: 'json',
} as const
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')!, {
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,6 +231,13 @@ export default function Page() {
.
</p>
{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',
@ -255,14 +284,14 @@ export default function Page() {
</TabList>
<TabPanel id="edit">
<Editor
isDisabled={!data.hasAclWrite}
defaultValue={data.currentAcl}
isDisabled={!data.write}
value={acl}
onChange={setAcl}
/>
</TabPanel>
<TabPanel id="diff">
<Differ
left={data.currentAcl}
left={data.policy}
right={acl}
/>
</TabPanel>
@ -285,7 +314,7 @@ export default function Page() {
<Button
variant="heavy"
className="mr-2"
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
isDisabled={disabled}
onPress={() => {
setToasted(false)
fetcher.submit({
@ -304,11 +333,15 @@ export default function Page() {
Save
</Button>
<Button
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl || !data.hasAclWrite}
onPress={() => { setAcl(data.currentAcl) }}
isDisabled={disabled}
onPress={() => {
setAcl(data.policy)
}}
>
Discard Changes
</Button>
</>
) : <Unavailable mode={data.mode} />}
</div>
)
}

View File

@ -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>
)
}

View File

@ -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;
}

View File

@ -33,6 +33,7 @@
"react-aria-components": "^1.2.1",
"react-codemirror-merge": "^4.23.5",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.1.2",
"remix-utils": "^7.6.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-react-aria-components": "^1.1.3",

View File

@ -82,6 +82,9 @@ importers:
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
react-error-boundary:
specifier: ^4.1.2
version: 4.1.2(react@18.3.1)
remix-utils:
specifier: ^7.6.0
version: 7.6.0(@remix-run/node@2.10.2(typescript@5.5.3))(@remix-run/react@2.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.3))(@remix-run/router@1.20.0)(react@18.3.1)(zod@3.23.8)
@ -331,10 +334,6 @@ packages:
resolution: {integrity: sha512-+BIznRzyqBf+2wCTxcKE3wDjfGeCoVE61KSHGpkzqrLi8qxqFwBeUFyId2cxkTmm55fzDGnm0+yCxaxygrLUnQ==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.24.7':
resolution: {integrity: sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.25.7':
resolution: {integrity: sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==}
engines: {node: '>=6.9.0'}
@ -3915,6 +3914,11 @@ packages:
peerDependencies:
react: ^18.3.1
react-error-boundary@4.1.2:
resolution: {integrity: sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==}
peerDependencies:
react: '>=16.13.1'
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@ -5019,10 +5023,6 @@ snapshots:
dependencies:
regenerator-runtime: 0.14.1
'@babel/runtime@7.24.7':
dependencies:
regenerator-runtime: 0.14.1
'@babel/runtime@7.25.7':
dependencies:
regenerator-runtime: 0.14.1
@ -8739,7 +8739,7 @@ snapshots:
media-query-parser@2.0.2:
dependencies:
'@babel/runtime': 7.24.7
'@babel/runtime': 7.25.7
media-typer@0.3.0: {}
@ -9579,6 +9579,11 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
react-error-boundary@4.1.2(react@18.3.1):
dependencies:
'@babel/runtime': 7.25.7
react: 18.3.1
react-is@16.13.1: {}
react-refresh@0.14.2: {}