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
6 changed files with 293 additions and 557 deletions
+69 -97
View File
@@ -1,36 +1,33 @@
import { json } from '@codemirror/lang-json' import Editor, { DiffEditor, Monaco } from '@monaco-editor/react'
import { yaml } from '@codemirror/lang-yaml' import { useEffect, useState } from 'react'
import { useFetcher } from '@remix-run/react' import { ClientOnly } from 'remix-utils/client-only'
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 Button from '~/components/Button' import Fallback from '~/routes/_data.acls._index/fallback'
import Spinner from '~/components/Spinner' import { cn } from '~/utils/cn'
import { toast } from '~/components/Toaster'
import Fallback from './fallback' interface MonacoProps {
variant: 'editor' | 'diff'
interface EditorProperties { language: 'json' | 'yaml'
readonly acl: string value: string
readonly setAcl: (acl: string) => void onChange: (value: string) => void
readonly mode: 'edit' | 'diff' original?: string
readonly data: {
hasAclWrite: boolean
currentAcl: string
aclType: string
}
} }
export default function Editor({ data, acl, setAcl, mode }: EditorProperties) { function monacoCallback(monaco: Monaco) {
const [light, setLight] = useState(false) monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
const [loading, setLoading] = useState(true) validate: true,
allowComments: true,
schemas: [],
enableSchemaRequest: true,
trailingCommas: 'ignore',
})
const fetcher = useFetcher() monaco.languages.register({ id: 'json' })
const aclType = useMemo(() => data.aclType === 'json' ? json() : yaml(), [data.aclType]) monaco.languages.register({ id: 'yaml' })
}
export default function MonacoEditor({ value, onChange, variant, original, language }: MonacoProps) {
const [light, setLight] = useState(false)
useEffect(() => { useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)') 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) => { theme.addEventListener('change', (theme) => {
setLight(theme.matches) setLight(theme.matches)
}) })
// Prevents the FOUC
setLoading(false)
}, []) }, [])
return ( return (
<> <>
<div className={clsx( <div className={cn(
'border border-gray-200 dark:border-gray-700', 'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden', 'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
)} )}
> >
<div className="overflow-y-scroll h-editor text-sm"> <div className="overflow-y-scroll h-editor text-sm">
{loading <ClientOnly fallback={<Fallback acl={value} />}>
? ( {() => variant === 'editor'
<Fallback acl={acl} where="client" /> ? (
) <Editor
: ( height="100%"
mode === 'edit' language={language}
? ( theme={light ? 'light' : 'vs-dark'}
<CodeMirror value={value}
value={acl} onChange={(updated) => {
theme={light ? githubLight : githubDark} if (!updated) {
extensions={[aclType]} return
readOnly={!data.hasAclWrite} }
onChange={(value) => {
setAcl(value) if (updated !== value) {
}} onChange(updated)
/> }
) }}
: ( loading={<Fallback acl={value} />}
<CodeMirrorMerge beforeMount={monacoCallback}
theme={light ? githubLight : githubDark} options={{
orientation="a-b" wordWrap: 'on',
> minimap: { enabled: false },
<CodeMirrorMerge.Original fontSize: 14,
readOnly }}
value={data.currentAcl} />
extensions={[aclType]} )
/> : (
<CodeMirrorMerge.Modified <DiffEditor
readOnly height="100%"
value={acl} language={language}
extensions={[aclType]} theme={light ? 'light' : 'vs-dark'}
/> original={original}
</CodeMirrorMerge> modified={value}
) loading={<Fallback acl={value} />}
)} beforeMount={monacoCallback}
options={{
wordWrap: 'on',
minimap: { enabled: false },
fontSize: 13,
}}
/>
)}
</ClientOnly>
</div> </div>
</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>
</> </>
) )
} }
+18 -37
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' interface FallbackProps {
readonly acl: string
type FallbackProperties = {
readonly acl: string;
readonly where: 'client' | 'server';
} }
export default function Fallback({ acl, where }: FallbackProperties) { export default function Fallback({ acl }: FallbackProps) {
return ( return (
<> <div className="inline-block relative w-full h-editor">
<div className={clsx( <Spinner className="w-4 h-4 absolute p-2" />
where === 'server' ? 'mb-2 overflow-hidden rounded-tr-lg rounded-b-lg' : '', <textarea
where === 'server' ? 'border border-gray-200 dark:border-gray-700' : '' readOnly
)} className={cn(
> 'w-full h-editor font-mono resize-none',
<textarea 'text-sm text-gray-600 dark:text-gray-300',
readOnly 'bg-ui-100 dark:bg-ui-800',
className={clsx( 'pl-16 pr-8 pt-0.5 leading-snug',
'w-full h-editor font-mono resize-none', )}
'text-sm text-gray-600 dark:text-gray-300', value={acl}
'pl-10 pt-1 leading-snug' />
)} </div>
value={acl}
/>
</div>
{where === 'server' ? (
<>
<Button
variant='heavy'
className='mr-2'
>
Save
</Button>
<Button>
Discard Changes
</Button>
</>
) : undefined}
</>
) )
} }
+44 -14
View File
@@ -1,18 +1,19 @@
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react' import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
import { type ActionFunctionArgs, json } from '@remix-run/node' 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 { useState } from 'react'
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components' 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 Link from '~/components/Link'
import Notice from '~/components/Notice' import Notice from '~/components/Notice'
import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
import { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane' import { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane'
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions'
import Editor from './editor' import Monaco from './editor'
import Fallback from './fallback'
export async function loader() { export async function loader() {
const context = await loadContext() const context = await loadContext()
@@ -56,6 +57,7 @@ export async function action({ request }: ActionFunctionArgs) {
export default function Page() { export default function Page() {
const data = useLoaderData<typeof loader>() const data = useLoaderData<typeof loader>()
const [acl, setAcl] = useState(data.currentAcl) const [acl, setAcl] = useState(data.currentAcl)
const fetcher = useFetcher()
return ( return (
<div> <div>
@@ -141,18 +143,21 @@ export default function Page() {
</Tab> </Tab>
</TabList> </TabList>
<TabPanel id="edit"> <TabPanel id="edit">
<ClientOnly fallback={<Fallback acl={acl} where="server" />}> <Monaco
{() => ( variant="editor"
<Editor data={data} acl={acl} setAcl={setAcl} mode="edit" /> language={data.aclType}
)} value={acl}
</ClientOnly> onChange={setAcl}
/>
</TabPanel> </TabPanel>
<TabPanel id="diff"> <TabPanel id="diff">
<ClientOnly fallback={<Fallback acl={acl} where="server" />}> <Monaco
{() => ( variant="editor"
<Editor data={data} acl={acl} setAcl={setAcl} mode="diff" /> language={data.aclType}
)} value={acl}
</ClientOnly> onChange={setAcl}
original={data.currentAcl}
/>
</TabPanel> </TabPanel>
<TabPanel id="preview"> <TabPanel id="preview">
<div <div
@@ -170,6 +175,31 @@ export default function Page() {
</div> </div>
</TabPanel> </TabPanel>
</Tabs> </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> </div>
) )
} }
+1 -1
View File
@@ -83,7 +83,7 @@ export async function loadContext(): Promise<HeadplaneContext> {
return context return context
} }
export async function loadAcl() { export async function loadAcl(): Promise<{ data: string, type: 'json' | 'yaml' }> {
let path = process.env.ACL_FILE let path = process.env.ACL_FILE
if (!path) { if (!path) {
try { try {
+1 -5
View File
@@ -11,26 +11,22 @@
"typecheck": "tsc" "typecheck": "tsc"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.1",
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.6.0",
"@primer/octicons-react": "^19.9.0", "@primer/octicons-react": "^19.9.0",
"@react-aria/toast": "3.0.0-beta.11", "@react-aria/toast": "3.0.0-beta.11",
"@react-stately/toast": "3.0.0-beta.3", "@react-stately/toast": "3.0.0-beta.3",
"@remix-run/node": "^2.9.2", "@remix-run/node": "^2.9.2",
"@remix-run/react": "^2.9.2", "@remix-run/react": "^2.9.2",
"@remix-run/serve": "^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", "clsx": "^2.1.1",
"isbot": "^5.1.6", "isbot": "^5.1.6",
"oauth4webapi": "^2.10.4", "oauth4webapi": "^2.10.4",
"react": "19.0.0-beta-26f2496093-20240514", "react": "19.0.0-beta-26f2496093-20240514",
"react-aria-components": "^1.2.0", "react-aria-components": "^1.2.0",
"react-codemirror-merge": "^4.22.0",
"react-dom": "19.0.0-beta-26f2496093-20240514", "react-dom": "19.0.0-beta-26f2496093-20240514",
"remix-utils": "^7.6.0", "remix-utils": "^7.6.0",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.3.0",
+160 -403
View File
File diff suppressed because it is too large Load Diff