mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 16:18:57 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 982d811d53 | |||
| d369e36fa4 | |||
| a2e659f36c | |||
| 1490406784 | |||
| e1c87412d4 | |||
| 6e55f442fd | |||
| 09e1b1f261 | |||
| 3e83f8617b | |||
| 1d6472765a | |||
| 2bc85085f5 | |||
| 5c949e2da5 | |||
| 12754bd0aa | |||
| 10bb4eba97 | |||
| 13a734923d | |||
| d5ee8ae0f3 | |||
| 331a9c8dcf | |||
| 808b01c5d7 | |||
| a8abd37e3a | |||
| d1fa76971b | |||
| a9e8394dec | |||
| 9dafd8e8b0 | |||
| 0c1f6969da | |||
| 4214e14ba8 |
@@ -1,3 +1,20 @@
|
|||||||
|
### 0.3.4 (November 7, 2024)
|
||||||
|
- Clicking on the machine name in the users page now takes you to the machine overview page.
|
||||||
|
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
|
||||||
|
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
|
||||||
|
- Improve documentation and support when running Headplane outside of Docker.
|
||||||
|
- Removing Split DNS records will no longer result in an error (fixes [#40](https://github.com/tale/headplane/issues/40))
|
||||||
|
- Removing the last ACL tag on a machine no longer results in an error (fixes [#41](https://github.com/tale/headplane/issues/41))
|
||||||
|
- Added full support for Exit Nodes in the UI and redesigned the machines page (fixes [#36](https://github.com/tale/headplane/issues/36))
|
||||||
|
- Added a basic check to see if the API keys passed via cookies are invalid.
|
||||||
|
|
||||||
|
### 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)
|
### 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))
|
- 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))
|
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
|
||||||
|
|||||||
+1
-1
@@ -19,4 +19,4 @@ RUN echo '{"type":"module"}' > /app/package.json
|
|||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
CMD [ "node_modules/.bin/remix-serve", "./build/server/index.js" ]
|
CMD [ "node", "./build/headplane/server.js" ]
|
||||||
|
|||||||
@@ -6,14 +6,19 @@ interface Props {
|
|||||||
name: string
|
name: string
|
||||||
value: string
|
value: string
|
||||||
isCopyable?: boolean
|
isCopyable?: boolean
|
||||||
|
link?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Attribute({ name, value, isCopyable }: Props) {
|
export default function Attribute({ name, value, link, isCopyable }: Props) {
|
||||||
const canCopy = isCopyable ?? false
|
const canCopy = isCopyable ?? false
|
||||||
return (
|
return (
|
||||||
<dl className="flex gap-1 text-sm w-full">
|
<dl className="flex gap-1 text-sm w-full">
|
||||||
<dt className="w-1/2 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1">
|
<dt className="w-1/2 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1">
|
||||||
{name}
|
{link ? (
|
||||||
|
<a className="hover:underline" href={link}>
|
||||||
|
{name}
|
||||||
|
</a>
|
||||||
|
) : name}
|
||||||
</dt>
|
</dt>
|
||||||
|
|
||||||
{canCopy
|
{canCopy
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export default function NumberField(props: NumberFieldProps) {
|
|||||||
)}>
|
)}>
|
||||||
<Input
|
<Input
|
||||||
className="w-full bg-transparent focus:outline-none"
|
className="w-full bg-transparent focus:outline-none"
|
||||||
name={props.name}
|
|
||||||
/>
|
/>
|
||||||
<Button slot="decrement">
|
<Button slot="decrement">
|
||||||
<DashIcon className="w-4 h-4" />
|
<DashIcon className="w-4 h-4" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { XIcon } from '@primer/octicons-react'
|
import { XIcon } from '@primer/octicons-react'
|
||||||
import { type AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
|
import { AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
|
||||||
import { ToastQueue, type ToastState, useToastQueue } from '@react-stately/toast'
|
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast'
|
||||||
import { type ReactNode, useRef } from 'react'
|
import { ReactNode, useRef } from 'react'
|
||||||
import { Button } from 'react-aria-components'
|
import { Button } from 'react-aria-components'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { ClientOnly } from 'remix-utils/client-only'
|
import { ClientOnly } from 'remix-utils/client-only'
|
||||||
@@ -14,6 +14,8 @@ type ToastProperties = AriaToastProps<ReactNode> & {
|
|||||||
|
|
||||||
function Toast({ state, ...properties }: ToastProperties) {
|
function Toast({ state, ...properties }: ToastProperties) {
|
||||||
const reference = useRef(null)
|
const reference = useRef(null)
|
||||||
|
|
||||||
|
// @ts-expect-error: RefObject doesn't map to FocusableElement?
|
||||||
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference)
|
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -52,11 +54,15 @@ export function toast(text: string) {
|
|||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
const reference = useRef(null)
|
const reference = useRef(null)
|
||||||
const state = useToastQueue(toasts)
|
const state = useToastQueue(toasts)
|
||||||
|
|
||||||
|
// @ts-expect-error: React 19 has weird types for Portal vs Node
|
||||||
const { regionProps } = useToastRegion({}, state, reference)
|
const { regionProps } = useToastRegion({}, state, reference)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
{() => createPortal(
|
{
|
||||||
|
// @ts-expect-error: Portal doesn't match Node in React 19 yet
|
||||||
|
() => createPortal(
|
||||||
state.visibleToasts.length >= 0 ? (
|
state.visibleToasts.length >= 0 ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import React, { useEffect } from 'react'
|
||||||
|
import Merge from 'react-codemirror-merge'
|
||||||
|
import CodeMirror from '@uiw/react-codemirror'
|
||||||
|
import * as shopify from '@shopify/lang-jsonc'
|
||||||
|
import { ClientOnly } from 'remix-utils/client-only'
|
||||||
|
import { ErrorBoundary } from 'react-error-boundary'
|
||||||
|
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={[shopify.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={[shopify.jsonc()]}
|
||||||
|
/>
|
||||||
|
<Merge.Modified
|
||||||
|
readOnly
|
||||||
|
value={props.right}
|
||||||
|
extensions={[shopify.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 Spinner from '~/components/Spinner'
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
interface FallbackProps {
|
interface Props {
|
||||||
readonly acl: string
|
readonly acl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Fallback({ acl }: FallbackProps) {
|
export default function Fallback({ acl }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="inline-block relative w-full h-editor">
|
<div className="inline-block relative w-full h-editor">
|
||||||
<Spinner className="w-4 h-4 absolute p-2" />
|
<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',
|
'w-full h-editor font-mono resize-none',
|
||||||
'text-sm text-gray-600 dark:text-gray-300',
|
'text-sm text-gray-600 dark:text-gray-300',
|
||||||
'bg-ui-100 dark:bg-ui-800',
|
'bg-ui-100 dark:bg-ui-800',
|
||||||
'pl-16 pr-8 pt-0.5 leading-snug',
|
'pl-10 pt-1 leading-snug',
|
||||||
)}
|
)}
|
||||||
value={acl}
|
value={acl}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
|
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
|
||||||
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
|
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
|
||||||
import { useFetcher, useLoaderData } from '@remix-run/react'
|
import { useLoaderData, useRevalidator } from '@remix-run/react'
|
||||||
import { useEffect, useState } from '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 { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
|
||||||
import { setTimeout } from 'node:timers/promises'
|
import { setTimeout } from 'node:timers/promises'
|
||||||
|
|
||||||
@@ -14,142 +15,142 @@ import Spinner from '~/components/Spinner'
|
|||||||
import { toast } from '~/components/Toaster'
|
import { toast } from '~/components/Toaster'
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
import { loadContext } from '~/utils/config/headplane'
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
|
import { loadConfig } from '~/utils/config/headscale'
|
||||||
import { HeadscaleError, pull, put } from '~/utils/headscale'
|
import { HeadscaleError, pull, put } from '~/utils/headscale'
|
||||||
import { getSession } from '~/utils/sessions'
|
import { getSession } from '~/utils/sessions'
|
||||||
|
import log from '~/utils/log'
|
||||||
|
|
||||||
import Monaco from './editor'
|
import { Editor, Differ } from './cm.client'
|
||||||
|
import { Unavailable } from './unavailable'
|
||||||
|
import { ErrorView } from './error'
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
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 ?? 'database'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
try {
|
||||||
const { policy } = await pull<{ policy: string }>(
|
const { policy } = await pull<{ policy: string }>(
|
||||||
'v1/policy',
|
'v1/policy',
|
||||||
session.get('hsApiKey')!,
|
session.get('hsApiKey')!,
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
let write = false // On file mode we already know it's readonly
|
||||||
// We have read access, now do we have write access?
|
if (modeGuess === 'database' && policy.length > 0) {
|
||||||
// Attempt to set the policy to what we just got
|
try {
|
||||||
await put('v1/policy', session.get('hsApiKey')!, {
|
await put('v1/policy', session.get('hsApiKey')!, {
|
||||||
policy,
|
policy: policy,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
write = true
|
||||||
hasAclWrite: true,
|
} catch (error) {
|
||||||
currentAcl: policy,
|
write = false
|
||||||
aclType: 'json',
|
log.debug(
|
||||||
} as const
|
'APIC',
|
||||||
} catch (error) {
|
'Failed to write to ACL policy with error %s',
|
||||||
if (!(error instanceof HeadscaleError)) {
|
error
|
||||||
throw error
|
)
|
||||||
}
|
|
||||||
|
|
||||||
if (error.status === 500) {
|
|
||||||
return {
|
|
||||||
hasAclWrite: false,
|
|
||||||
currentAcl: policy,
|
|
||||||
aclType: 'json',
|
|
||||||
} as const
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasAclWrite: true,
|
read: true,
|
||||||
currentAcl: '',
|
write,
|
||||||
aclType: 'json',
|
mode: modeGuess,
|
||||||
} as const
|
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,
|
||||||
|
policy: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
policy: null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request }: ActionFunctionArgs) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
const session = await getSession(request.headers.get('Cookie'))
|
||||||
if (!session.has('hsApiKey')) {
|
if (!session.has('hsApiKey')) {
|
||||||
return json({ success: false }, {
|
return json({ success: false, error: null }, {
|
||||||
status: 401,
|
status: 401,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const { acl } = await request.json() as { acl: string, api: boolean }
|
|
||||||
try {
|
try {
|
||||||
await put('v1/policy', session.get('hsApiKey')!, {
|
const { acl } = await request.json() as { acl: string }
|
||||||
policy: acl,
|
const { policy } = await put<{ policy: string }>(
|
||||||
})
|
'v1/policy',
|
||||||
|
session.get('hsApiKey')!,
|
||||||
|
{
|
||||||
|
policy: acl,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
await setTimeout(250)
|
return json({ success: true, policy, error: null })
|
||||||
return json({ success: true })
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return json({ success: false }, {
|
log.debug('APIC', 'Failed to update ACL policy with error %s', error)
|
||||||
|
|
||||||
|
// @ts-ignore: Shut UP we know it's a string most of the time
|
||||||
|
const text = JSON.parse(error.message)
|
||||||
|
return json({ success: false, error: text.message }, {
|
||||||
status: error instanceof HeadscaleError ? error.status : 500,
|
status: error instanceof HeadscaleError ? error.status : 500,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ success: true })
|
return json({ success: true, error: null })
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
export default function Page() {
|
||||||
const data = useLoaderData<typeof loader>()
|
const data = useLoaderData<typeof loader>()
|
||||||
const fetcher = useFetcher<typeof action>()
|
const fetcher = useDebounceFetcher<typeof action>()
|
||||||
const [acl, setAcl] = useState(data.currentAcl)
|
const revalidator = useRevalidator()
|
||||||
|
|
||||||
|
const [acl, setAcl] = useState(data.policy ?? '')
|
||||||
const [toasted, setToasted] = useState(false)
|
const [toasted, setToasted] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -157,6 +158,7 @@ export default function Page() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ts-ignore: useDebounceFetcher is not typed correctly
|
||||||
if (fetcher.data.success) {
|
if (fetcher.data.success) {
|
||||||
toast('Updated tailnet ACL policy')
|
toast('Updated tailnet ACL policy')
|
||||||
} else {
|
} else {
|
||||||
@@ -164,14 +166,40 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setToasted(true)
|
setToasted(true)
|
||||||
setAcl(data.currentAcl)
|
if (revalidator.state === 'idle') {
|
||||||
}, [fetcher.data, toasted, data.currentAcl])
|
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
|
||||||
|
// @ts-ignore: useDebounceFetcher is not typed correctly
|
||||||
|
if (fetcher.data?.success === false) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.policy === acl
|
||||||
|
}, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{data.hasAclWrite
|
{data.read && !data.write
|
||||||
? undefined
|
? (
|
||||||
: (
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Notice className="w-fit">
|
<Notice className="w-fit">
|
||||||
The ACL policy is read-only. You can view the current policy
|
The ACL policy is read-only. You can view the current policy
|
||||||
@@ -181,7 +209,7 @@ export default function Page() {
|
|||||||
database in your Headscale configuration.
|
database in your Headscale configuration.
|
||||||
</Notice>
|
</Notice>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : undefined}
|
||||||
|
|
||||||
<h1 className="text-2xl font-medium mb-4">
|
<h1 className="text-2xl font-medium mb-4">
|
||||||
Access Control List (ACL)
|
Access Control List (ACL)
|
||||||
@@ -209,109 +237,120 @@ export default function Page() {
|
|||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Tabs>
|
{
|
||||||
<TabList className={cn(
|
// @ts-ignore: useDebounceFetcher is not typed correctly
|
||||||
'flex border-t border-gray-200 dark:border-gray-700',
|
fetcher.data?.success === false
|
||||||
'w-fit rounded-t-lg overflow-hidden',
|
? (
|
||||||
'text-gray-400 dark:text-gray-500',
|
// @ts-ignore: useDebounceFetcher is not typed correctly
|
||||||
)}
|
<ErrorView message={fetcher.data.error} />
|
||||||
>
|
) : undefined}
|
||||||
<Tab
|
|
||||||
id="edit"
|
{data.read ? (
|
||||||
className={({ isSelected }) => cn(
|
<>
|
||||||
'px-4 py-2 rounded-tl-lg',
|
<Tabs>
|
||||||
'focus:outline-none flex items-center gap-2',
|
<TabList className={cn(
|
||||||
'border-x border-gray-200 dark:border-gray-700',
|
'flex border-t border-gray-200 dark:border-gray-700',
|
||||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
'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" />
|
{fetcher.state === 'idle'
|
||||||
<p>Edit file</p>
|
? undefined
|
||||||
</Tab>
|
: (
|
||||||
<Tab
|
<Spinner className="w-3 h-3" />
|
||||||
id="diff"
|
)}
|
||||||
className={({ isSelected }) => cn(
|
Save
|
||||||
'px-4 py-2',
|
</Button>
|
||||||
'focus:outline-none flex items-center gap-2',
|
<Button
|
||||||
'border-x border-gray-200 dark:border-gray-700',
|
isDisabled={disabled}
|
||||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
onPress={() => {
|
||||||
)}
|
setAcl(data?.policy ?? '')
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<EyeIcon className="w-5 h-5" />
|
Discard Changes
|
||||||
<p>Preview changes</p>
|
</Button>
|
||||||
</Tab>
|
</>
|
||||||
<Tab
|
) : <Unavailable mode={data.mode as "database" | "file"} />}
|
||||||
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>
|
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ export default function Nameservers({ nameservers, isDisabled }: Props) {
|
|||||||
key={key}
|
key={key}
|
||||||
isGlobal={key === 'global'}
|
isGlobal={key === 'global'}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
nameservers={nameservers[key]}
|
nameservers={nameservers}
|
||||||
name={key}
|
name={key}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -59,6 +59,10 @@ interface ListProps {
|
|||||||
|
|
||||||
function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps) {
|
function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps) {
|
||||||
const submit = useSubmit()
|
const submit = useSubmit()
|
||||||
|
const list = isGlobal ? nameservers['global'] : nameservers[name]
|
||||||
|
if (list.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -68,7 +72,7 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps)
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<TableList>
|
<TableList>
|
||||||
{nameservers.map((ns, index) => (
|
{list.length > 0 ? list.map((ns, index) => (
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
<TableList.Item key={index}>
|
<TableList.Item key={index}>
|
||||||
<p className="font-mono text-sm">{ns}</p>
|
<p className="font-mono text-sm">{ns}</p>
|
||||||
@@ -83,17 +87,18 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps)
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (isGlobal) {
|
if (isGlobal) {
|
||||||
submit({
|
submit({
|
||||||
'dns.nameservers.global': nameservers
|
'dns.nameservers.global': list
|
||||||
.filter((_, i) => i !== index),
|
.filter((_, i) => i !== index),
|
||||||
}, {
|
}, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
encType: 'application/json',
|
encType: 'application/json',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const key = `dns.nameservers.split."${name}"`
|
|
||||||
const list = nameservers.filter((_, i) => i !== index)
|
|
||||||
submit({
|
submit({
|
||||||
[key]: list.length ? list : null,
|
'dns.nameservers.split': {
|
||||||
|
...nameservers,
|
||||||
|
[name]: list.filter((_, i) => i !== index),
|
||||||
|
}
|
||||||
}, {
|
}, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
encType: 'application/json',
|
encType: 'application/json',
|
||||||
@@ -104,7 +109,7 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps)
|
|||||||
Remove
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
</TableList.Item>
|
</TableList.Item>
|
||||||
))}
|
)) : undefined}
|
||||||
</TableList>
|
</TableList>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
||||||
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
|
|
||||||
import { Link, useLoaderData } from '@remix-run/react'
|
|
||||||
|
|
||||||
import Attribute from '~/components/Attribute'
|
|
||||||
import Card from '~/components/Card'
|
|
||||||
import StatusCircle from '~/components/StatusCircle'
|
|
||||||
import { Machine, Route, User } 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 { 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'))
|
|
||||||
if (!params.id) {
|
|
||||||
throw new Error('No machine ID provided')
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = await loadContext()
|
|
||||||
let magic: string | undefined
|
|
||||||
|
|
||||||
if (context.config.read) {
|
|
||||||
const config = await loadConfig()
|
|
||||||
if (config.dns.magic_dns) {
|
|
||||||
magic = config.dns.base_domain
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [machine, routes, users] = await Promise.all([
|
|
||||||
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
|
|
||||||
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
|
||||||
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
|
||||||
])
|
|
||||||
|
|
||||||
return {
|
|
||||||
machine: machine.node,
|
|
||||||
routes: routes.routes.filter(route => route.node.id === params.id),
|
|
||||||
users: users.users,
|
|
||||||
magic,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function action({ request }: ActionFunctionArgs) {
|
|
||||||
return menuAction(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const { machine, magic, routes, users } = useLoaderData<typeof loader>()
|
|
||||||
useLiveData({ interval: 1000 })
|
|
||||||
|
|
||||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
|
||||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
|
||||||
|| machine.expiry === null
|
|
||||||
? false
|
|
||||||
: new Date(machine.expiry).getTime() < Date.now()
|
|
||||||
|
|
||||||
const tags = [
|
|
||||||
...machine.forcedTags,
|
|
||||||
...machine.validTags,
|
|
||||||
]
|
|
||||||
|
|
||||||
if (expired) {
|
|
||||||
tags.unshift('Expired')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className="mb-8 text-md">
|
|
||||||
<Link
|
|
||||||
to="/machines"
|
|
||||||
className="font-medium"
|
|
||||||
>
|
|
||||||
All Machines
|
|
||||||
</Link>
|
|
||||||
<span className="mx-2">
|
|
||||||
/
|
|
||||||
</span>
|
|
||||||
{machine.givenName}
|
|
||||||
</p>
|
|
||||||
<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}
|
|
||||||
users={users}
|
|
||||||
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={machine.nodeKey}
|
|
||||||
/>
|
|
||||||
<Attribute
|
|
||||||
name="Created"
|
|
||||||
value={new Date(machine.createdAt).toLocaleString()}
|
|
||||||
/>
|
|
||||||
<Attribute
|
|
||||||
name="Last Seen"
|
|
||||||
value={new Date(machine.lastSeen).toLocaleString()}
|
|
||||||
/>
|
|
||||||
<Attribute
|
|
||||||
name="Expiry"
|
|
||||||
value={expired
|
|
||||||
? new Date(machine.expiry).toLocaleString()
|
|
||||||
: 'Never'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{magic
|
|
||||||
? (
|
|
||||||
<Attribute
|
|
||||||
isCopyable
|
|
||||||
name="Domain"
|
|
||||||
value={`${machine.givenName}.${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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
|
||||||
|
import { Link as RemixLink, useLoaderData } from '@remix-run/react'
|
||||||
|
import { InfoIcon, GearIcon, CheckCircleIcon, SkipIcon, PersonIcon } from '@primer/octicons-react'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import Attribute from '~/components/Attribute'
|
||||||
|
import Button from '~/components/Button'
|
||||||
|
import Card from '~/components/Card'
|
||||||
|
import Menu from '~/components/Menu'
|
||||||
|
import Tooltip from '~/components/Tooltip'
|
||||||
|
import StatusCircle from '~/components/StatusCircle'
|
||||||
|
import { Machine, Route, User } 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 Link from '~/components/Link'
|
||||||
|
|
||||||
|
import { menuAction } from '../_data.machines._index/action'
|
||||||
|
import MenuOptions from '../_data.machines._index/menu'
|
||||||
|
import Routes from '../_data.machines._index/dialogs/routes'
|
||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await loadContext()
|
||||||
|
let magic: string | undefined
|
||||||
|
|
||||||
|
if (context.config.read) {
|
||||||
|
const config = await loadConfig()
|
||||||
|
if (config.dns.magic_dns) {
|
||||||
|
magic = config.dns.base_domain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [machine, routes, users] = await Promise.all([
|
||||||
|
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
|
||||||
|
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
||||||
|
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
machine: machine.node,
|
||||||
|
routes: routes.routes.filter(route => route.node.id === params.id),
|
||||||
|
users: users.users,
|
||||||
|
magic,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
|
return menuAction(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const { machine, magic, routes, users } = useLoaderData<typeof loader>()
|
||||||
|
const routesState = useState(false)
|
||||||
|
useLiveData({ interval: 1000 })
|
||||||
|
|
||||||
|
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||||
|
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||||
|
|| machine.expiry === null
|
||||||
|
? false
|
||||||
|
: new Date(machine.expiry).getTime() < Date.now()
|
||||||
|
|
||||||
|
const tags = [
|
||||||
|
...machine.forcedTags,
|
||||||
|
...machine.validTags,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (expired) {
|
||||||
|
tags.unshift('Expired')
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is much easier with Object.groupBy but it's too new for us
|
||||||
|
const { exit, subnet, subnetApproved } = routes.reduce((acc, route) => {
|
||||||
|
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
|
||||||
|
acc.exit.push(route)
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.enabled) {
|
||||||
|
acc.subnetApproved.push(route)
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.subnet.push(route)
|
||||||
|
return acc
|
||||||
|
}, { exit: [], subnetApproved: [], subnet: [] })
|
||||||
|
|
||||||
|
const exitEnabled = useMemo(() => {
|
||||||
|
if (exit.length !== 2) return false
|
||||||
|
return exit[0].enabled && exit[1].enabled
|
||||||
|
}, [exit])
|
||||||
|
|
||||||
|
if (exitEnabled) {
|
||||||
|
tags.unshift('Exit Node')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subnetApproved.length > 0) {
|
||||||
|
tags.unshift('Subnets')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="mb-8 text-md">
|
||||||
|
<RemixLink
|
||||||
|
to="/machines"
|
||||||
|
className="font-medium"
|
||||||
|
>
|
||||||
|
All Machines
|
||||||
|
</RemixLink>
|
||||||
|
<span className="mx-2">
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
{machine.givenName}
|
||||||
|
</p>
|
||||||
|
<div className={cn(
|
||||||
|
'flex justify-between items-center',
|
||||||
|
'border-b border-ui-100 dark:border-ui-800',
|
||||||
|
)}>
|
||||||
|
<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
|
||||||
|
className={cn(
|
||||||
|
'bg-ui-100 dark:bg-ui-800',
|
||||||
|
)}
|
||||||
|
machine={machine}
|
||||||
|
routes={routes}
|
||||||
|
users={users}
|
||||||
|
magic={magic}
|
||||||
|
buttonChild={
|
||||||
|
<Menu.Button className={cn(
|
||||||
|
'flex items-center justify-center gap-x-2',
|
||||||
|
'bg-main-200 dark:bg-main-700/30',
|
||||||
|
'hover:bg-main-300 dark:hover:bg-main-600/30',
|
||||||
|
'text-ui-700 dark:text-ui-300 mb-2',
|
||||||
|
'w-fit text-sm rounded-lg px-3 py-2'
|
||||||
|
)}>
|
||||||
|
<GearIcon className="w-5" />
|
||||||
|
Machine Settings
|
||||||
|
</Menu.Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 mb-4">
|
||||||
|
<div className="border-r border-ui-100 dark:border-ui-800 p-2 pr-4">
|
||||||
|
<span className="text-sm text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
||||||
|
Managed by
|
||||||
|
<Tooltip>
|
||||||
|
<Tooltip.Button>
|
||||||
|
<InfoIcon className="w-3.5 h-3.5" />
|
||||||
|
</Tooltip.Button>
|
||||||
|
<Tooltip.Body>
|
||||||
|
By default, a machine’s permissions match its creator’s.
|
||||||
|
</Tooltip.Body>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-x-2.5 mt-1">
|
||||||
|
<div className={cn(
|
||||||
|
'rounded-full h-7 w-7 flex items-center justify-center',
|
||||||
|
'border border-ui-200 dark:border-ui-700',
|
||||||
|
)}>
|
||||||
|
<PersonIcon className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
{machine.user.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 pl-4">
|
||||||
|
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||||
|
Status
|
||||||
|
</p>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-medium mb-4 mt-8">
|
||||||
|
Subnets & Routing
|
||||||
|
</h2>
|
||||||
|
<Routes
|
||||||
|
machine={machine}
|
||||||
|
routes={routes}
|
||||||
|
state={routesState}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<p>
|
||||||
|
Subnets let you expose physical network routes onto Tailscale.
|
||||||
|
{' '}
|
||||||
|
<Link
|
||||||
|
to="https://tailscale.com/kb/1019/subnets"
|
||||||
|
name="Tailscale Subnets Documentation"
|
||||||
|
>
|
||||||
|
Learn More
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
control={routesState}
|
||||||
|
>
|
||||||
|
Review
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Card
|
||||||
|
variant="flat"
|
||||||
|
className={cn(
|
||||||
|
'w-full max-w-full grid sm:grid-cols-2',
|
||||||
|
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
||||||
|
Approved
|
||||||
|
<Tooltip>
|
||||||
|
<Tooltip.Button>
|
||||||
|
<InfoIcon className="w-3.5 h-3.5" />
|
||||||
|
</Tooltip.Button>
|
||||||
|
<Tooltip.Body>
|
||||||
|
Traffic to these routes are being
|
||||||
|
routed through this machine.
|
||||||
|
</Tooltip.Body>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
<div className="mt-1">
|
||||||
|
{subnetApproved.length === 0 ? (
|
||||||
|
<span className="text-ui-400 dark:text-ui-300">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<ul className="leading-normal">
|
||||||
|
{subnetApproved.map(route => (
|
||||||
|
<li key={route.id}>
|
||||||
|
{route.prefix}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'p-0 rounded-sm bg-transparent mt-1',
|
||||||
|
'text-blue-500 dark:text-blue-400',
|
||||||
|
'hover:bg-transparent',
|
||||||
|
'hover:text-blue-600 dark:hover:text-blue-500',
|
||||||
|
)}
|
||||||
|
control={routesState}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
||||||
|
Awaiting Approval
|
||||||
|
<Tooltip>
|
||||||
|
<Tooltip.Button>
|
||||||
|
<InfoIcon className="w-3.5 h-3.5" />
|
||||||
|
</Tooltip.Button>
|
||||||
|
<Tooltip.Body>
|
||||||
|
This machine is advertising these routes,
|
||||||
|
but they must be approved before traffic
|
||||||
|
will be routed to them.
|
||||||
|
</Tooltip.Body>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
<div className="mt-1">
|
||||||
|
{subnet.length === 0 ? (
|
||||||
|
<span className="text-ui-400 dark:text-ui-300">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<ul className="leading-normal">
|
||||||
|
{subnet.map(route => (
|
||||||
|
<li key={route.id}>
|
||||||
|
{route.prefix}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'p-0 rounded-sm bg-transparent mt-1',
|
||||||
|
'text-blue-500 dark:text-blue-400',
|
||||||
|
'hover:bg-transparent',
|
||||||
|
'hover:text-blue-600 dark:hover:text-blue-500',
|
||||||
|
)}
|
||||||
|
control={routesState}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
||||||
|
Exit Node
|
||||||
|
<Tooltip>
|
||||||
|
<Tooltip.Button>
|
||||||
|
<InfoIcon className="w-3.5 h-3.5" />
|
||||||
|
</Tooltip.Button>
|
||||||
|
<Tooltip.Body>
|
||||||
|
Whether this machine can act as an
|
||||||
|
exit node for your tailnet.
|
||||||
|
</Tooltip.Body>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
<div className="mt-1">
|
||||||
|
{exit.length === 0 ? (
|
||||||
|
<span className="text-ui-400 dark:text-ui-300">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
) : exitEnabled ? (
|
||||||
|
<span className="flex items-center gap-x-1">
|
||||||
|
<CheckCircleIcon className="w-3.5 h-3.5 text-green-700" />
|
||||||
|
Allowed
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-x-1">
|
||||||
|
<SkipIcon className="w-3.5 h-3.5 text-red-700" />
|
||||||
|
Awaiting Approval
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'p-0 rounded-sm bg-transparent mt-1',
|
||||||
|
'text-blue-500 dark:text-blue-400',
|
||||||
|
'hover:bg-transparent',
|
||||||
|
'hover:text-blue-600 dark:hover:text-blue-500',
|
||||||
|
)}
|
||||||
|
control={routesState}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<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={machine.nodeKey}
|
||||||
|
/>
|
||||||
|
<Attribute
|
||||||
|
name="Created"
|
||||||
|
value={new Date(machine.createdAt).toLocaleString()}
|
||||||
|
/>
|
||||||
|
<Attribute
|
||||||
|
name="Last Seen"
|
||||||
|
value={new Date(machine.lastSeen).toLocaleString()}
|
||||||
|
/>
|
||||||
|
<Attribute
|
||||||
|
name="Expiry"
|
||||||
|
value={expired
|
||||||
|
? new Date(machine.expiry).toLocaleString()
|
||||||
|
: 'Never'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{magic
|
||||||
|
? (
|
||||||
|
<Attribute
|
||||||
|
isCopyable
|
||||||
|
name="Domain"
|
||||||
|
value={`${machine.givenName}.${magic}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { ActionFunctionArgs, json } from '@remix-run/node'
|
|||||||
|
|
||||||
import { del, post } from '~/utils/headscale'
|
import { del, post } from '~/utils/headscale'
|
||||||
import { getSession } from '~/utils/sessions'
|
import { getSession } from '~/utils/sessions'
|
||||||
|
import log from '~/utils/log'
|
||||||
|
|
||||||
export async function menuAction(request: ActionFunctionArgs['request']) {
|
export async function menuAction(request: ActionFunctionArgs['request']) {
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
const session = await getSession(request.headers.get('Cookie'))
|
||||||
@@ -61,6 +62,24 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
|
|||||||
return json({ message: 'Route updated' })
|
return json({ message: 'Route updated' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'exit-node': {
|
||||||
|
if (!data.has('routes') || !data.has('enabled')) {
|
||||||
|
return json({ message: 'No route or enabled provided' }, {
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes = data.get('routes')?.toString().split(',') ?? []
|
||||||
|
const enabled = data.get('enabled') === 'true'
|
||||||
|
const postfix = enabled ? 'enable' : 'disable'
|
||||||
|
|
||||||
|
await Promise.all(routes.map(async (route) => {
|
||||||
|
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
|
||||||
|
}))
|
||||||
|
|
||||||
|
return json({ message: 'Exit node updated' })
|
||||||
|
}
|
||||||
|
|
||||||
case 'move': {
|
case 'move': {
|
||||||
if (!data.has('to')) {
|
if (!data.has('to')) {
|
||||||
return json({ message: 'No destination provided' }, {
|
return json({ message: 'No destination provided' }, {
|
||||||
@@ -82,7 +101,9 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
|
|||||||
|
|
||||||
case 'tags': {
|
case 'tags': {
|
||||||
const tags = data.get('tags')?.toString()
|
const tags = data.get('tags')?.toString()
|
||||||
.split(',') ?? []
|
.split(',')
|
||||||
|
.filter((tag) => tag.trim() !== '')
|
||||||
|
?? []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
|
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
|
||||||
@@ -90,7 +111,8 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return json({ message: 'Tags updated' })
|
return json({ message: 'Tags updated' })
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
log.debug('APIC', 'Failed to update tags: %s', error)
|
||||||
return json({ message: 'Failed to update tags' }, {
|
return json({ message: 'Failed to update tags' }, {
|
||||||
status: 500,
|
status: 500,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface NewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function New(data: NewProps) {
|
export default function New(data: NewProps) {
|
||||||
const fetcher = useFetcher()
|
const fetcher = useFetcher<{ success?: boolean }>()
|
||||||
const mkeyState = useState(false)
|
const mkeyState = useState(false)
|
||||||
const [mkey, setMkey] = useState('')
|
const [mkey, setMkey] = useState('')
|
||||||
const [user, setUser] = useState('')
|
const [user, setUser] = useState('')
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useFetcher } from '@remix-run/react'
|
import { useFetcher } from '@remix-run/react'
|
||||||
import { type Dispatch, type SetStateAction } from 'react'
|
import { Dispatch, SetStateAction, useMemo } from 'react'
|
||||||
|
|
||||||
import Dialog from '~/components/Dialog'
|
import Dialog from '~/components/Dialog'
|
||||||
import Switch from '~/components/Switch'
|
import Switch from '~/components/Switch'
|
||||||
import { type Machine, type Route } from '~/types'
|
import Link from '~/components/Link'
|
||||||
|
import { Machine, Route } from '~/types'
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
interface RoutesProps {
|
interface RoutesProps {
|
||||||
@@ -16,6 +17,22 @@ interface RoutesProps {
|
|||||||
export default function Routes({ machine, routes, state }: RoutesProps) {
|
export default function Routes({ machine, routes, state }: RoutesProps) {
|
||||||
const fetcher = useFetcher()
|
const fetcher = useFetcher()
|
||||||
|
|
||||||
|
// This is much easier with Object.groupBy but it's too new for us
|
||||||
|
const { exit, subnet } = routes.reduce((acc, route) => {
|
||||||
|
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
|
||||||
|
acc.exit.push(route)
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.subnet.push(route)
|
||||||
|
return acc
|
||||||
|
}, { exit: [], subnet: [] })
|
||||||
|
|
||||||
|
const exitEnabled = useMemo(() => {
|
||||||
|
if (exit.length !== 2) return false
|
||||||
|
return exit[0].enabled && exit[1].enabled
|
||||||
|
}, [exit])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Dialog.Panel control={state}>
|
<Dialog.Panel control={state}>
|
||||||
@@ -26,9 +43,19 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
|
|||||||
{' '}
|
{' '}
|
||||||
{machine.givenName}
|
{machine.givenName}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
|
<Dialog.Text className="font-bold">
|
||||||
|
Subnet routes
|
||||||
|
</Dialog.Text>
|
||||||
<Dialog.Text>
|
<Dialog.Text>
|
||||||
Connect to devices you can't install Tailscale on
|
Connect to devices you can't install Tailscale on
|
||||||
by advertising IP ranges as subnet routes.
|
by advertising IP ranges as subnet routes.
|
||||||
|
{' '}
|
||||||
|
<Link
|
||||||
|
to="https://tailscale.com/kb/1019/subnets"
|
||||||
|
name="Tailscale Subnets Documentation"
|
||||||
|
>
|
||||||
|
Learn More
|
||||||
|
</Link>
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'rounded-lg overflow-y-auto my-2',
|
'rounded-lg overflow-y-auto my-2',
|
||||||
@@ -36,7 +63,7 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
|
|||||||
'border border-zinc-200 dark:border-zinc-700',
|
'border border-zinc-200 dark:border-zinc-700',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{routes.length === 0
|
{subnet.length === 0
|
||||||
? (
|
? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -51,9 +78,9 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
: undefined}
|
: undefined}
|
||||||
{routes.map(route => (
|
{subnet.map(route => (
|
||||||
<div
|
<div
|
||||||
key={route.node.id}
|
key={route.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
||||||
'items-center justify-between',
|
'items-center justify-between',
|
||||||
@@ -80,6 +107,66 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<Dialog.Text className="font-bold mt-8">
|
||||||
|
Exit nodes
|
||||||
|
</Dialog.Text>
|
||||||
|
<Dialog.Text>
|
||||||
|
Allow your network to route internet traffic through this machine.
|
||||||
|
{' '}
|
||||||
|
<Link
|
||||||
|
to="https://tailscale.com/kb/1103/exit-nodes"
|
||||||
|
name="Tailscale Exit-node Documentation"
|
||||||
|
>
|
||||||
|
Learn More
|
||||||
|
</Link>
|
||||||
|
</Dialog.Text>
|
||||||
|
<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',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{exit.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>
|
||||||
|
This machine is not an exit node.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
||||||
|
'items-center justify-between',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Use as exit node
|
||||||
|
</p>
|
||||||
|
<Switch
|
||||||
|
defaultSelected={exitEnabled}
|
||||||
|
label="Enabled"
|
||||||
|
onChange={(checked) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.set('id', machine.id)
|
||||||
|
form.set('_method', 'exit-node')
|
||||||
|
form.set('routes', exit.map(route => route.id).join(','))
|
||||||
|
|
||||||
|
form.set('enabled', String(checked))
|
||||||
|
fetcher.submit(form, {
|
||||||
|
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
|
<Dialog.Action
|
||||||
variant="cancel"
|
variant="cancel"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { PlusIcon, XIcon } from '@primer/octicons-react'
|
import { PlusIcon, XIcon } from '@primer/octicons-react'
|
||||||
import { Form, useSubmit } from '@remix-run/react'
|
import { Form, useSubmit } from '@remix-run/react'
|
||||||
import { type Dispatch, type SetStateAction, useState } from 'react'
|
import { Dispatch, SetStateAction, useState } from 'react'
|
||||||
import { Button, Input } from 'react-aria-components'
|
import { Button, Input } from 'react-aria-components'
|
||||||
|
|
||||||
import Dialog from '~/components/Dialog'
|
import Dialog from '~/components/Dialog'
|
||||||
import Link from '~/components/Link'
|
import Link from '~/components/Link'
|
||||||
import { type Machine } from '~/types'
|
import { Machine } from '~/types'
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
interface TagsProps {
|
interface TagsProps {
|
||||||
|
|||||||
@@ -36,6 +36,35 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
|
|||||||
? magic.replace('[user]', machine.user.name)
|
? magic.replace('[user]', machine.user.name)
|
||||||
: magic
|
: magic
|
||||||
|
|
||||||
|
// This is much easier with Object.groupBy but it's too new for us
|
||||||
|
const { exit, subnet, subnetApproved } = routes.reduce((acc, route) => {
|
||||||
|
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
|
||||||
|
acc.exit.push(route)
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.enabled) {
|
||||||
|
acc.subnetApproved.push(route)
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.subnet.push(route)
|
||||||
|
return acc
|
||||||
|
}, { exit: [], subnetApproved: [], subnet: [] })
|
||||||
|
|
||||||
|
const exitEnabled = useMemo(() => {
|
||||||
|
if (exit.length !== 2) return false
|
||||||
|
return exit[0].enabled && exit[1].enabled
|
||||||
|
}, [exit])
|
||||||
|
|
||||||
|
if (exitEnabled) {
|
||||||
|
tags.unshift('Exit Node')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subnetApproved.length > 0) {
|
||||||
|
tags.unshift('Subnets')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={machine.id}
|
key={machine.id}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { KebabHorizontalIcon } from '@primer/octicons-react'
|
import { KebabHorizontalIcon } from '@primer/octicons-react'
|
||||||
import { useState } from 'react'
|
import { ReactNode, useState } from 'react'
|
||||||
|
|
||||||
import MenuComponent from '~/components/Menu'
|
import MenuComponent from '~/components/Menu'
|
||||||
import { Machine, Route, User } from '~/types'
|
import { Machine, Route, User } from '~/types'
|
||||||
@@ -17,9 +17,10 @@ interface MenuProps {
|
|||||||
routes: Route[]
|
routes: Route[]
|
||||||
users: User[]
|
users: User[]
|
||||||
magic?: string
|
magic?: string
|
||||||
|
buttonChild?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Menu({ machine, routes, magic, users }: MenuProps) {
|
export default function Menu({ machine, routes, magic, users, buttonChild }: MenuProps) {
|
||||||
const renameState = useState(false)
|
const renameState = useState(false)
|
||||||
const expireState = useState(false)
|
const expireState = useState(false)
|
||||||
const removeState = useState(false)
|
const removeState = useState(false)
|
||||||
@@ -69,15 +70,17 @@ export default function Menu({ machine, routes, magic, users }: MenuProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<MenuComponent>
|
<MenuComponent>
|
||||||
<MenuComponent.Button
|
{buttonChild ?? (
|
||||||
className={cn(
|
<MenuComponent.Button
|
||||||
'flex items-center justify-center',
|
className={cn(
|
||||||
'border border-transparent rounded-lg py-0.5 w-10',
|
'flex items-center justify-center',
|
||||||
'group-hover:border-gray-200 dark:group-hover:border-zinc-700',
|
'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>
|
<KebabHorizontalIcon className="w-5" />
|
||||||
|
</MenuComponent.Button>
|
||||||
|
)}
|
||||||
<MenuComponent.Items>
|
<MenuComponent.Items>
|
||||||
<MenuComponent.ItemButton control={renameState}>
|
<MenuComponent.ItemButton control={renameState}>
|
||||||
Edit machine name
|
Edit machine name
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import { InfoIcon } from '@primer/octicons-react'
|
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 { useLoaderData } from '@remix-run/react'
|
||||||
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
|
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
|
||||||
|
|
||||||
import Code from '~/components/Code'
|
import Code from '~/components/Code'
|
||||||
import { type Machine, type Route, User } from '~/types'
|
import Link from '~/components/Link'
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
import { loadContext } from '~/utils/config/headplane'
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
import { loadConfig } from '~/utils/config/headscale'
|
import { loadConfig } from '~/utils/config/headscale'
|
||||||
import { pull } from '~/utils/headscale'
|
import { pull } from '~/utils/headscale'
|
||||||
import { getSession } from '~/utils/sessions'
|
import { getSession } from '~/utils/sessions'
|
||||||
import { useLiveData } from '~/utils/useLiveData'
|
import { useLiveData } from '~/utils/useLiveData'
|
||||||
|
import type { Machine, Route, User } from '~/types'
|
||||||
|
|
||||||
import { menuAction } from './action'
|
import { menuAction } from './action'
|
||||||
import MachineRow from './machine'
|
import MachineRow from './machine'
|
||||||
@@ -58,8 +59,20 @@ export default function Page() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<h1 className="text-2xl font-medium mb-4">Machines</h1>
|
<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} />
|
<NewMachine server={data.server} users={data.users} />
|
||||||
</div>
|
</div>
|
||||||
<table className="table-auto w-full rounded-lg">
|
<table className="table-auto w-full rounded-lg">
|
||||||
|
|||||||
@@ -31,10 +31,7 @@ export default function Page() {
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<RemixLink
|
<RemixLink to="/settings/auth-keys">
|
||||||
to="/settings/auth-keys"
|
|
||||||
name="Auth Keys"
|
|
||||||
>
|
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'text-lg font-medium',
|
'text-lg font-medium',
|
||||||
'text-gray-700 dark:text-gray-300',
|
'text-gray-700 dark:text-gray-300',
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default function AddPreAuthKey(data: Props) {
|
|||||||
onChange={() => { setReusable(!reusable) }}
|
onChange={() => { setReusable(!reusable) }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="reusable" value={reusable} />
|
<input type="hidden" name="reusable" value={reusable.toString()} />
|
||||||
<div className="flex justify-between items-center mt-6">
|
<div className="flex justify-between items-center mt-6">
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Text className="font-semibold">
|
<Dialog.Text className="font-semibold">
|
||||||
@@ -121,7 +121,7 @@ export default function AddPreAuthKey(data: Props) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="ephemeral" value={ephemeral} />
|
<input type="hidden" name="ephemeral" value={ephemeral.toString()} />
|
||||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||||
<Dialog.Action
|
<Dialog.Action
|
||||||
variant="cancel"
|
variant="cancel"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function AuthKeyRow({ authKey, server }: Props) {
|
|||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
className="my-4"
|
className="my-4"
|
||||||
onClick={async () => {
|
onPress={async () => {
|
||||||
await navigator.clipboard.writeText(
|
await navigator.clipboard.writeText(
|
||||||
`tailscale up --login-server ${server} --authkey ${authKey.key}`
|
`tailscale up --login-server ${server} --authkey ${authKey.key}`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export default function Page() {
|
|||||||
const { keys, users, server } = useLoaderData<typeof loader>()
|
const { keys, users, server } = useLoaderData<typeof loader>()
|
||||||
const [user, setUser] = useState('All')
|
const [user, setUser] = useState('All')
|
||||||
const [status, setStatus] = useState('Active')
|
const [status, setStatus] = useState('Active')
|
||||||
|
useLiveData({ interval: 3000 })
|
||||||
|
|
||||||
const filteredKeys = keys.filter(key => {
|
const filteredKeys = keys.filter(key => {
|
||||||
if (user !== 'All' && key.user !== user) {
|
if (user !== 'All' && key.user !== user) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import { type DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
|
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
|
||||||
import { PersonIcon } from '@primer/octicons-react'
|
import { PersonIcon } from '@primer/octicons-react'
|
||||||
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
|
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
|
||||||
import { useActionData, useLoaderData, useSubmit } from '@remix-run/react'
|
import { useActionData, useLoaderData, useSubmit } from '@remix-run/react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { ClientOnly } from 'remix-utils/client-only'
|
import { ClientOnly } from 'remix-utils/client-only'
|
||||||
@@ -10,7 +10,7 @@ import Attribute from '~/components/Attribute'
|
|||||||
import Card from '~/components/Card'
|
import Card from '~/components/Card'
|
||||||
import StatusCircle from '~/components/StatusCircle'
|
import StatusCircle from '~/components/StatusCircle'
|
||||||
import { toast } from '~/components/Toaster'
|
import { toast } from '~/components/Toaster'
|
||||||
import { type Machine, type User } from '~/types'
|
import { Machine, User } from '~/types'
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
import { loadContext } from '~/utils/config/headplane'
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
import { loadConfig } from '~/utils/config/headscale'
|
import { loadConfig } from '~/utils/config/headscale'
|
||||||
@@ -299,6 +299,7 @@ function MachineChip({ machine }: { readonly machine: Machine }) {
|
|||||||
<StatusCircle isOnline={machine.online} className="w-4 h-4 px-1 w-fit" />
|
<StatusCircle isOnline={machine.online} className="w-4 h-4 px-1 w-fit" />
|
||||||
<Attribute
|
<Attribute
|
||||||
name={machine.givenName}
|
name={machine.givenName}
|
||||||
|
link={`machines/${machine.id}`}
|
||||||
value={machine.ipAddresses[0]}
|
value={machine.ipAddresses[0]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export default function Page() {
|
|||||||
label="API Key"
|
label="API Key"
|
||||||
name="api-key"
|
name="api-key"
|
||||||
placeholder="API Key"
|
placeholder="API Key"
|
||||||
|
type="password"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
className="w-full mt-2.5"
|
className="w-full mt-2.5"
|
||||||
|
|||||||
@@ -7,3 +7,24 @@
|
|||||||
scrollbar-gutter: stable
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ export async function loadContext(): Promise<HeadplaneContext> {
|
|||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const envFile = process.env.LOAD_ENV_FILE === 'true'
|
||||||
|
if (envFile) {
|
||||||
|
log.info('CTXT', 'Loading environment variables from .env')
|
||||||
|
await import('dotenv/config')
|
||||||
|
}
|
||||||
|
|
||||||
const debug = process.env.DEBUG === 'true'
|
const debug = process.env.DEBUG === 'true'
|
||||||
if (debug) {
|
if (debug) {
|
||||||
log.info('CTXT', 'Debug mode is enabled! Logs will spam a lot.')
|
log.info('CTXT', 'Debug mode is enabled! Logs will spam a lot.')
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ export class FatalError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function pull<T>(url: string, key: string) {
|
export async function pull<T>(url: string, key: string) {
|
||||||
|
if (!key || key === 'undefined' || key.length === 0) {
|
||||||
|
log.error('APIC', 'Missing API key, could this be a cookie setting issue?')
|
||||||
|
log.error('APIC', 'Check that the hp_sess cookie is being set correctly')
|
||||||
|
log.error('APIC', 'If you are running without HTTPs, make sure the Secure flag is false')
|
||||||
|
throw new Error('Missing API key, could this be a cookie setting issue?')
|
||||||
|
}
|
||||||
|
|
||||||
const context = await loadContext()
|
const context = await loadContext()
|
||||||
const prefix = context.headscaleUrl
|
const prefix = context.headscaleUrl
|
||||||
|
|
||||||
@@ -38,6 +45,13 @@ export async function pull<T>(url: string, key: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function post<T>(url: string, key: string, body?: unknown) {
|
export async function post<T>(url: string, key: string, body?: unknown) {
|
||||||
|
if (!key || key === 'undefined' || key.length === 0) {
|
||||||
|
log.error('APIC', 'Missing API key, could this be a cookie setting issue?')
|
||||||
|
log.error('APIC', 'Check that the hp_sess cookie is being set correctly')
|
||||||
|
log.error('APIC', 'If you are running without HTTPs, make sure the Secure flag is false')
|
||||||
|
throw new Error('Missing API key, could this be a cookie setting issue?')
|
||||||
|
}
|
||||||
|
|
||||||
const context = await loadContext()
|
const context = await loadContext()
|
||||||
const prefix = context.headscaleUrl
|
const prefix = context.headscaleUrl
|
||||||
|
|
||||||
@@ -59,6 +73,13 @@ export async function post<T>(url: string, key: string, body?: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function put<T>(url: string, key: string, body?: unknown) {
|
export async function put<T>(url: string, key: string, body?: unknown) {
|
||||||
|
if (!key || key === 'undefined' || key.length === 0) {
|
||||||
|
log.error('APIC', 'Missing API key, could this be a cookie setting issue?')
|
||||||
|
log.error('APIC', 'Check that the hp_sess cookie is being set correctly')
|
||||||
|
log.error('APIC', 'If you are running without HTTPs, make sure the Secure flag is false')
|
||||||
|
throw new Error('Missing API key, could this be a cookie setting issue?')
|
||||||
|
}
|
||||||
|
|
||||||
const context = await loadContext()
|
const context = await loadContext()
|
||||||
const prefix = context.headscaleUrl
|
const prefix = context.headscaleUrl
|
||||||
|
|
||||||
@@ -80,6 +101,13 @@ export async function put<T>(url: string, key: string, body?: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function del<T>(url: string, key: string) {
|
export async function del<T>(url: string, key: string) {
|
||||||
|
if (!key || key === 'undefined' || key.length === 0) {
|
||||||
|
log.error('APIC', 'Missing API key, could this be a cookie setting issue?')
|
||||||
|
log.error('APIC', 'Check that the hp_sess cookie is being set correctly')
|
||||||
|
log.error('APIC', 'If you are running without HTTPs, make sure the Secure flag is false')
|
||||||
|
throw new Error('Missing API key, could this be a cookie setting issue?')
|
||||||
|
}
|
||||||
|
|
||||||
const context = await loadContext()
|
const context = await loadContext()
|
||||||
const prefix = context.headscaleUrl
|
const prefix = context.headscaleUrl
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ You can configure Headplane using environment variables.
|
|||||||
- **`CONFIG_FILE`**: The path to the Headscale `config.yaml` (default: `/etc/headscale/config.yaml`).
|
- **`CONFIG_FILE`**: The path to the Headscale `config.yaml` (default: `/etc/headscale/config.yaml`).
|
||||||
- **`HEADSCALE_CONFIG_UNSTRICT`**: This will disable the strict configuration loader (default: `false`).
|
- **`HEADSCALE_CONFIG_UNSTRICT`**: This will disable the strict configuration loader (default: `false`).
|
||||||
- **`COOKIE_SECURE`**: This option enables the `Secure` flag for cookies, ensuring they are sent only over HTTPS, which helps prevent interception and enhances data security. It should be disabled when using HTTP instead of HTTPS (default: `true`).
|
- **`COOKIE_SECURE`**: This option enables the `Secure` flag for cookies, ensuring they are sent only over HTTPS, which helps prevent interception and enhances data security. It should be disabled when using HTTP instead of HTTPS (default: `true`).
|
||||||
|
- **`LOAD_ENV_FILE`**: Tell Headplane to read the `.env` file and load it into the environment (default: `false`).
|
||||||
|
|
||||||
#### Docker Integration
|
#### Docker Integration
|
||||||
The Docker integration allows Headplane to manage the Headscale docker container.
|
The Docker integration allows Headplane to manage the Headscale docker container.
|
||||||
|
|||||||
+52
-11
@@ -10,19 +10,60 @@ to undefined behavior or the service not restarting.
|
|||||||
|
|
||||||
### Deployment
|
### Deployment
|
||||||
|
|
||||||
Follow the instructions to install Headscale from the
|
1. Follow the instructions to install Headscale from the
|
||||||
[Linux Installation Guide](https://headscale.net/running-headscale-linux/). As
|
[Linux Installation Guide](https://headscale.net/setup/install/official).
|
||||||
of now, Headplane requires Node.js 20 to be installed on the system. Once you
|
|
||||||
are ready, clone the repository (`git clone https://github.com/tale/headplane`),
|
2. Install [Node.js](https://nodejs.org/en/download/package-manager)
|
||||||
install dependencies (`npm install`), build the project (`npm run build`), and
|
version 20 or higher (your package manager most likely already has this).
|
||||||
start the server (`npm start`).
|
|
||||||
|
3. Install [PNPM](https://pnpm.io/installation). This is required
|
||||||
|
as Headplane has issues running correctly when installed and built via NPM or Yarn.
|
||||||
|
|
||||||
|
4. Clone the Headplane repository, install dependencies, and build the project:
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/tale/headplane # (or clone via SSH)
|
||||||
|
cd headplane
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Headplane
|
||||||
|
Start Headplane with `node build/headplane/server.js`.
|
||||||
|
|
||||||
|
Headplane does need various environment variables to run correctly. The required
|
||||||
|
variables can be found in the [Configuration](/docs/Configuration.md) guide.
|
||||||
|
If you choose to do this with a `.env` file, you can use the `LOAD_ENV_FILE`
|
||||||
|
variable to tell Headplane to load the file.
|
||||||
|
|
||||||
|
Finally, make sure to set `HEADSCALE_INTEGRATION=proc` to take advantage
|
||||||
|
of controlling Headscale natively on Linux.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
LOAD_ENV_FILE=true node ./build/headplane/server.js
|
||||||
|
```
|
||||||
|
|
||||||
> If you'd like, you can turn this into a `systemd` unit to manage the service.
|
> If you'd like, you can turn this into a `systemd` unit to manage the service.
|
||||||
> I plan to provide packages and unit files to make this easier in the future.
|
> I plan to provide packages and unit files to make this easier in the future.
|
||||||
|
|
||||||
When running Headplane, you'll need to set environment variables to configure
|
### Cannot find ./build directory?
|
||||||
the application. The `HEADSCALE_INTEGRATION` variable should be set to `proc`.
|
Headplane expects the `build` directory to be present when running the server.
|
||||||
|
The structure of this folder is very important and should not be tampered with.
|
||||||
|
If you would like to keep the build directory in a different location, you can
|
||||||
|
set the `BUILD_PATH` environment variable to the path of the build directory
|
||||||
|
at runtime.
|
||||||
|
|
||||||
> For a breakdown of each configuration variable, please refer to the
|
```sh
|
||||||
[Configuration](/docs/Configuration.md) guide.
|
LOAD_ENV_FILE=true BUILD_PATH=/path/to/build node ./build/headplane/server.js
|
||||||
> It explains what each variable does, how to configure them, and what the default values are.
|
```
|
||||||
|
|
||||||
|
### Changing Headplane's Path from `/admin`
|
||||||
|
Additionally, because you are building Headplane from source, you're able to
|
||||||
|
change the default path that Headplane is served from. This can be done by
|
||||||
|
specifying the `__INTERNAL_PREFIX` environment variable, when building.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
__INTERNAL_PREFIX=/my-admin-path pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
> Keep in mind that this is very much an experimental feature. Things can easily
|
||||||
|
> break and until it's more stable, it's not recommended to use in production.
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import tale from 'eslint-config-tale'
|
|
||||||
|
|
||||||
export default [
|
|
||||||
...tale
|
|
||||||
]
|
|
||||||
+37
-29
@@ -4,10 +4,8 @@
|
|||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "remix vite:build",
|
"build": "remix vite:build && vite build",
|
||||||
"dev": "remix vite:dev",
|
"dev": "remix vite:dev",
|
||||||
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
|
|
||||||
"start": "remix-serve ./build/server/index.js",
|
|
||||||
"typecheck": "tsc"
|
"typecheck": "tsc"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -15,43 +13,46 @@
|
|||||||
"@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",
|
||||||
"@kubernetes/client-node": "^0.21.0",
|
"@kubernetes/client-node": "^0.22.2",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@primer/octicons-react": "^19.12.0",
|
||||||
"@primer/octicons-react": "^19.10.0",
|
|
||||||
"@react-aria/toast": "3.0.0-beta.12",
|
"@react-aria/toast": "3.0.0-beta.12",
|
||||||
"@react-stately/toast": "3.0.0-beta.4",
|
"@react-stately/toast": "3.0.0-beta.4",
|
||||||
"@remix-run/node": "^2.10.2",
|
"@remix-run/node": "^2.13.1",
|
||||||
"@remix-run/react": "^2.10.2",
|
"@remix-run/react": "^2.13.1",
|
||||||
"@remix-run/serve": "^2.10.2",
|
"@shopify/lang-jsonc": "^1.0.0",
|
||||||
|
"@uiw/codemirror-theme-github": "^4.23.6",
|
||||||
|
"@uiw/react-codemirror": "^4.23.6",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"isbot": "^5.1.11",
|
"dotenv": "^16.4.5",
|
||||||
"oauth4webapi": "^2.11.1",
|
"isbot": "^5.1.17",
|
||||||
"react": "19.0.0-rc-f38c22b244-20240704",
|
"mime": "^4.0.4",
|
||||||
|
"oauth4webapi": "^2.17.0",
|
||||||
|
"react": "19.0.0-beta-26f2496093-20240514",
|
||||||
"react-aria-components": "^1.2.1",
|
"react-aria-components": "^1.2.1",
|
||||||
"react-dom": "19.0.0-rc-f38c22b244-20240704",
|
"react-codemirror-merge": "^4.23.6",
|
||||||
"remix-utils": "^7.6.0",
|
"react-dom": "19.0.0-beta-26f2496093-20240514",
|
||||||
"tailwind-merge": "^2.3.0",
|
"react-error-boundary": "^4.1.2",
|
||||||
"tailwindcss-react-aria-components": "^1.1.3",
|
"remix-utils": "^7.7.0",
|
||||||
"undici": "^6.19.2",
|
"tailwind-merge": "^2.5.4",
|
||||||
|
"tailwindcss-react-aria-components": "^1.1.6",
|
||||||
|
"undici": "^6.20.1",
|
||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "^3.1.0",
|
||||||
"yaml": "^2.4.5",
|
"yaml": "^2.6.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@remix-run/dev": "^2.10.2",
|
"@remix-run/dev": "^2.13.1",
|
||||||
"@types/react": "npm:types-react@beta",
|
"@types/react": "npm:types-react@beta",
|
||||||
"@types/react-dom": "npm:types-react-dom@beta",
|
"@types/react-dom": "npm:types-react-dom@beta",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.20",
|
||||||
"babel-plugin-react-compiler": "0.0.0-experimental-c23de8d-20240515",
|
"babel-plugin-react-compiler": "19.0.0-beta-8a03594-20241020",
|
||||||
"eslint": "^8.57.0",
|
"postcss": "^8.4.47",
|
||||||
"eslint-config-tale": "^2.0.4",
|
"tailwindcss": "^3.4.14",
|
||||||
"postcss": "^8.4.39",
|
|
||||||
"tailwindcss": "^3.4.4",
|
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^5.3.3",
|
"vite": "^5.4.10",
|
||||||
"vite-plugin-babel": "^1.2.0",
|
"vite-plugin-babel": "^1.2.0",
|
||||||
"vite-tsconfig-paths": "^4.3.2"
|
"vite-tsconfig-paths": "^5.1.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/react": "npm:types-react@beta",
|
"@types/react": "npm:types-react@beta",
|
||||||
@@ -63,7 +64,14 @@
|
|||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"patchedDependencies": {
|
"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"
|
||||||
|
},
|
||||||
|
"peerDependencyRules": {
|
||||||
|
"allowAny": [
|
||||||
|
"react",
|
||||||
|
"react-dom"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
+1922
-3735
File diff suppressed because it is too large
Load Diff
Executable
+158
@@ -0,0 +1,158 @@
|
|||||||
|
// This is a polyglot entrypoint for Headplane when running in production
|
||||||
|
// It doesn't use any dependencies aside from @remix-run/node and mime
|
||||||
|
// During build we bundle the used dependencies into the file so that
|
||||||
|
// we can only need this file and a Node.js installation to run the server.
|
||||||
|
// PREFIX is defined globally, see vite.config.ts
|
||||||
|
|
||||||
|
import { access, constants } from 'node:fs/promises'
|
||||||
|
import { createReadStream, existsSync, statSync } from 'node:fs'
|
||||||
|
import { createServer } from 'node:http'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
|
||||||
|
function log(level, message) {
|
||||||
|
const date = new Date().toISOString()
|
||||||
|
console.log(`${date} (${level}) [SRVX] ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
log('INFO', `Running with Node.js ${process.versions.node}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await access('./node_modules/@remix-run', constants.F_OK | constants.R_OK)
|
||||||
|
log('INFO', 'Found node_modules dependencies')
|
||||||
|
} catch (error) {
|
||||||
|
log('ERROR', 'No node_modules found. Please run `pnpm install` first')
|
||||||
|
log('ERROR', error)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await access('./build/server', constants.F_OK | constants.R_OK)
|
||||||
|
log('INFO', 'Found build directory')
|
||||||
|
} catch (error) {
|
||||||
|
const date = new Date().toISOString()
|
||||||
|
log('ERROR', 'No build directory found. Please run `pnpm build` first')
|
||||||
|
log('ERROR', error)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
createRequestHandler: remixRequestHandler,
|
||||||
|
createReadableStreamFromReadable,
|
||||||
|
writeReadableStreamToWritable
|
||||||
|
} = await import('@remix-run/node')
|
||||||
|
const { default: mime } = await import('mime')
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3000
|
||||||
|
const host = process.env.HOST || '0.0.0.0'
|
||||||
|
const buildPath = process.env.BUILD_PATH || './build'
|
||||||
|
|
||||||
|
// Because this is a dynamic import without an easily discernable path
|
||||||
|
// we gain the "deoptimization" we want so that Vite doesn't bundle this
|
||||||
|
const build = await import(resolve(join(buildPath, 'server', 'index.js')))
|
||||||
|
const baseDir = resolve(join(buildPath, 'client'))
|
||||||
|
|
||||||
|
const handler = remixRequestHandler(build, 'production')
|
||||||
|
const http = createServer(async (req, res) => {
|
||||||
|
const url = new URL(`http://${req.headers.host}${req.url}`)
|
||||||
|
|
||||||
|
if (!url.pathname.startsWith(PREFIX)) {
|
||||||
|
res.writeHead(404)
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to handle an issue where say we are navigating to $PREFIX
|
||||||
|
// but Remix does not handle it without the trailing slash. This is
|
||||||
|
// because Remix uses the URL constructor to parse the URL and it
|
||||||
|
// will remove the trailing slash. We need to redirect to the correct
|
||||||
|
// URL so that Remix can handle it correctly.
|
||||||
|
if (url.pathname === PREFIX) {
|
||||||
|
res.writeHead(302, {
|
||||||
|
Location: `${PREFIX}/`
|
||||||
|
})
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before we pass any requests to our Remix handler we need to check
|
||||||
|
// if we can handle a raw file request. This is important for the
|
||||||
|
// Remix loader to work correctly.
|
||||||
|
//
|
||||||
|
// To optimize this, we send them as readable streams in the node
|
||||||
|
// response and we also set headers for aggressive caching.
|
||||||
|
if (url.pathname.startsWith(`${PREFIX}/assets/`)) {
|
||||||
|
const filePath = join(baseDir, url.pathname.replace(PREFIX, ''))
|
||||||
|
const exists = existsSync(filePath)
|
||||||
|
const stats = statSync(filePath)
|
||||||
|
|
||||||
|
if (exists && stats.isFile()) {
|
||||||
|
// Build assets are cache-bust friendly so we can cache them heavily
|
||||||
|
if (req.url.startsWith('/build')) {
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the file as a readable stream
|
||||||
|
const fileStream = createReadStream(filePath)
|
||||||
|
const type = mime.getType(filePath)
|
||||||
|
|
||||||
|
res.setHeader('Content-Length', stats.size)
|
||||||
|
res.setHeader('Content-Type', type)
|
||||||
|
fileStream.pipe(res)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handling the request
|
||||||
|
const controller = new AbortController()
|
||||||
|
res.on('close', () => controller.abort())
|
||||||
|
|
||||||
|
const headers = new Headers()
|
||||||
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
|
if (!value) continue
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const v of value) {
|
||||||
|
headers.append(key, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.append(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const remixReq = new Request(url.href, {
|
||||||
|
headers,
|
||||||
|
method: req.method,
|
||||||
|
signal: controller.signal,
|
||||||
|
|
||||||
|
// If we have a body we set a duplex and we load the body
|
||||||
|
...(req.method !== 'GET' && req.method !== 'HEAD' ? {
|
||||||
|
body: createReadableStreamFromReadable(req),
|
||||||
|
duplex: 'half'
|
||||||
|
} : {}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pass our request to the Remix handler and get a response
|
||||||
|
const response = await handler(remixReq, {}) // No context
|
||||||
|
|
||||||
|
// Handle our response and reply
|
||||||
|
res.statusCode = response.status
|
||||||
|
res.statusMessage = response.statusText
|
||||||
|
|
||||||
|
for (const [key, value] of response.headers.entries()) {
|
||||||
|
res.appendHeader(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.body) {
|
||||||
|
await writeReadableStreamToWritable(response.body, res)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
http.listen(port, host, () => {
|
||||||
|
log('INFO', `Running on ${host}:${port}`)
|
||||||
|
})
|
||||||
+55
-18
@@ -6,22 +6,59 @@ import tsconfigPaths from 'vite-tsconfig-paths'
|
|||||||
|
|
||||||
installGlobals()
|
installGlobals()
|
||||||
|
|
||||||
export default defineConfig(({ isSsrBuild }) => ({
|
const prefix = process.env.__INTERNAL_PREFIX || '/admin'
|
||||||
base: '/admin/',
|
if (prefix.endsWith('/')) {
|
||||||
build: isSsrBuild ? { target: 'ES2022' } : {},
|
throw new Error('Prefix must not end with a slash')
|
||||||
plugins: [
|
}
|
||||||
remix({
|
|
||||||
basename: '/admin/',
|
export default defineConfig(({ isSsrBuild }) => {
|
||||||
}),
|
// If we have the Headplane entry we build it as a single
|
||||||
tsconfigPaths(),
|
// server.mjs file that is built for production server bundle
|
||||||
babel({
|
// We know the remix invoked command is vite:build
|
||||||
filter: /\.[jt]sx?$/,
|
if (!process.argv.includes('vite:build') && !process.argv.includes('vite:dev')) {
|
||||||
babelConfig: {
|
return {
|
||||||
presets: ['@babel/preset-typescript'],
|
build: {
|
||||||
plugins: [
|
minify: false,
|
||||||
['babel-plugin-react-compiler', {}],
|
target: 'esnext',
|
||||||
],
|
rollupOptions: {
|
||||||
|
input: './server.mjs',
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'server.js',
|
||||||
|
dir: 'build/headplane',
|
||||||
|
banner: '#!/usr/bin/env node\n',
|
||||||
|
},
|
||||||
|
external: (id) => id.startsWith('node:'),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}),
|
define: {
|
||||||
],
|
PREFIX: JSON.stringify(prefix),
|
||||||
}))
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
stream: 'node:stream',
|
||||||
|
crypto: 'node:crypto',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ({
|
||||||
|
base: `${prefix}/`,
|
||||||
|
build: isSsrBuild ? { target: 'ES2022' } : {},
|
||||||
|
plugins: [
|
||||||
|
remix({
|
||||||
|
basename: `${prefix}/`,
|
||||||
|
}),
|
||||||
|
tsconfigPaths(),
|
||||||
|
babel({
|
||||||
|
filter: /\.[jt]sx?$/,
|
||||||
|
babelConfig: {
|
||||||
|
presets: ['@babel/preset-typescript'],
|
||||||
|
plugins: [
|
||||||
|
['babel-plugin-react-compiler', {}],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user