fix: support jsonc comments and switch to monaco for acls

This commit is contained in:
Aarnav Tale 2024-06-02 22:40:23 -04:00
parent dd9d6cd550
commit 3ea5fed8f6
No known key found for this signature in database
6 changed files with 293 additions and 557 deletions

View File

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

View File

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

View File

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

View File

@ -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 {

View File

@ -11,26 +11,22 @@
"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",
"@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",
"oauth4webapi": "^2.10.4",
"react": "19.0.0-beta-26f2496093-20240514",
"react-aria-components": "^1.2.0",
"react-codemirror-merge": "^4.22.0",
"react-dom": "19.0.0-beta-26f2496093-20240514",
"remix-utils": "^7.6.0",
"tailwind-merge": "^2.3.0",

563
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff