Compare commits

...

59 Commits

Author SHA1 Message Date
Aarnav Tale 156d7c9ee7 chore: v0.4.1 2025-01-18 08:18:28 +00:00
Aarnav Tale bd11453593 fix: store oidc redirect_uri in session for parity 2025-01-18 08:18:19 +00:00
Aarnav Tale 0f92f09796 chore: v0.4.0 2025-01-18 07:52:04 +00:00
kevinf100 9bb20024fa fix: Reusable Pre-Auth Keys no longer show expired when used (#88) 2025-01-18 07:48:50 +00:00
Aarnav Tale 698122068d fix: center user icon in profile 2025-01-18 07:48:05 +00:00
Aarnav Tale e6a66d5804 fix: don't propagate errors on user page to root 2025-01-18 07:41:08 +00:00
Aarnav Tale d524f927a4 fix: don't throw when session manager isn't available 2025-01-18 07:39:12 +00:00
Aarnav Tale 539c76dfb3 chore: switch to shared healtcheck func 2025-01-18 07:38:55 +00:00
Aarnav Tale 51fa7c14d0 fix: handle prod config for server correctly 2025-01-18 07:37:51 +00:00
Aarnav Tale b92c4ce2e8 chore: remove broken vite plugin import 2025-01-18 07:37:26 +00:00
Aarnav Tale 325e9ba43d chore: push disabled local-agent code 2025-01-18 07:37:03 +00:00
Aarnav Tale de9a938da2 chore: use healthcheck method in healthz 2025-01-17 11:46:36 +00:00
Aarnav Tale 1b45b0917f fix: handle headscale unavailability gracefully 2025-01-17 11:45:36 +00:00
Aarnav Tale e01fd8d50c fix: handle undefined websocket in context 2025-01-17 08:34:19 +00:00
Aarnav Tale c9603ba38a chore: switch to vite for server dev 2025-01-17 08:33:22 +00:00
Aarnav Tale 377641265b fix: require auth for agent ws 2025-01-15 15:14:37 +05:30
Aarnav Tale 5bc313e5fe feat: containerize agent 2025-01-15 10:17:30 +05:30
Aarnav Tale af919b2d34 fix: support the agent querying itself 2025-01-10 14:49:13 +05:30
Aarnav Tale f464c42802 fix: format hostinfo in overview correctly 2025-01-10 14:41:05 +05:30
Aarnav Tale e667e112cb chore: add @types/ws 2025-01-10 14:24:23 +05:30
Aarnav Tale 39546849ae fix: mix cached and uncached data on the ws-agent 2025-01-10 14:24:15 +05:30
Aarnav Tale eb922c9318 fix: implement write-lock for agent cache 2025-01-10 14:06:22 +05:30
Aarnav Tale 5569ba4660 feat: rework oidc to be more resilient
This includes setting a custom redirect URI, handling errors, and using a better library.
As an API decision I've also disabled per session API keys as it clutters up too much.
2025-01-10 13:55:24 +05:30
Aarnav Tale dfd03e77bb fix: use headscale health route for healthz 2025-01-10 10:55:35 +05:30
Aarnav Tale e33504016b feat: integrate hostinfo into ui 2025-01-08 14:34:53 +05:30
Aarnav Tale 7d4da73141 fix: relax config requirement for paths by using nullish 2025-01-06 08:49:26 +05:30
Aarnav Tale 1316439786 fix: read aggregate and error cause bubbles 2025-01-06 08:47:24 +05:30
Aarnav Tale 888c1a5fb6 fix: resolve css issues with button icons 2025-01-06 08:39:15 +05:30
Aarnav Tale 328302cd81 fix: show toasts above the footer 2025-01-06 08:35:25 +05:30
Aarnav Tale b060700dfe fix: blur footer URL for screenshots 2025-01-06 08:31:49 +05:30
Aarnav Tale 69783f0d05 chore: re-stage entry files 2025-01-06 08:19:41 +05:30
Aarnav Tale 55c832c536 feat: do partial client method validation 2025-01-06 08:19:41 +05:30
Aarnav Tale fc5028994a chore: remove old import for fs-routes 2025-01-06 08:19:41 +05:30
Aarnav Tale 6745ee8529 fix: resolve type errors and lint components/routes 2025-01-06 08:19:41 +05:30
Aarnav Tale 414b95c293 fix: separate vite builds for server and framework 2025-01-06 08:19:41 +05:30
Aarnav Tale 7217659720 fix: prevent cookie manager from racing env init
this fixes a bug where using LOAD_ENV_FILE did not work with COOKIE_SECRET
2025-01-06 08:19:40 +05:30
Aarnav Tale a4bb3cce5f feat: fix build scripts and server 2025-01-06 08:19:40 +05:30
Aarnav Tale f3c9d8b54c style: run biome lint 2025-01-06 08:19:40 +05:30
Aarnav Tale aa9872a45b chore: switch to react-router v7 2025-01-06 08:19:40 +05:30
Artur Motyka 39504e2487 chore: update outdated docs links (#74) 2025-01-03 10:31:12 +05:30
Aarnav Tale 719d7a5e76 feat: create initial agent for localapi 2024-12-30 13:48:49 +05:30
Aarnav Tale 6156d78907 feat: add ws server for agent 2024-12-30 13:48:18 +05:30
Aarnav Tale 1c2c374ada feat: switch to server in dev & prod 2024-12-23 10:27:05 -05:00
Aarnav Tale 6cf343d623 feat: refactor all dashboard routes 2024-12-08 13:52:02 -05:00
Aarnav Tale 50e43bc0c3 feat: refactor/reorganize auth routes 2024-12-08 13:27:51 -05:00
Aarnav Tale 9d64a5beac chore: switch to react-router routes.ts config 2024-12-08 13:11:26 -05:00
Aarnav Tale 240d8d6197 chore: v0.3.9 2024-12-06 19:30:12 -05:00
Aarnav Tale b80bb0cc09 fix: await testOidc 2024-12-06 19:29:44 -05:00
Aarnav Tale 69cc6985b4 chore: v0.3.8 2024-12-06 19:07:36 -05:00
Aarnav Tale f9b38939ba chore: add border for footer 2024-12-06 19:06:27 -05:00
Aarnav Tale 4cfa1e5209 chore: remove unused patch 2024-12-06 19:05:15 -05:00
Aarnav Tale e713dae91b fix: validate and respect oidc validation settings 2024-12-06 19:05:07 -05:00
Aarnav Tale c9bcc1d7c6 chore: update deps 2024-12-06 18:37:36 -05:00
Aarnav Tale 401731fd09 fix: allow generating higher expiring preauthkeys 2024-12-06 18:37:17 -05:00
Aarnav Tale f623e7bc66 feat: enable all remix future flags 2024-12-06 11:58:17 -05:00
Aarnav Tale 1af292a5b0 fix: we need git in docker 2024-12-05 11:35:41 -05:00
Aarnav Tale 21778a43f1 feat: add footer with version and donate link 2024-12-05 02:33:17 -05:00
Aarnav Tale 33762e53b5 chore: document the /admin endpoint 2024-12-05 02:07:28 -05:00
Aarnav Tale 8867cca494 chore: use 0.3.7 in examples 2024-11-30 15:42:33 -05:00
162 changed files with 9928 additions and 10643 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
node_modules node_modules
/.react-router
/.cache /.cache
/build /build
.env .env
+24
View File
@@ -1,3 +1,27 @@
### 0.4.1 (January 18, 2025)
- Fixed an urgent issue where the OIDC redirect URI would mismatch.
### 0.4.0 (January 18, 2025)
- Switched from Remix.run to React-Router
- Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66))
- Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
- The footer now blurs your Headscale URL as a privacy measure
- Updated to the next stable beta of the React Compiler
- Changed `/healthz` to use a well-known endpoint instead of trying an invalid API key
- Support `OIDC_REDIRECT_URI` to force a specific redirect URI
- Redo the OIDC integration for better error handling and configuration
- Gracefully handle when Headscale is unreachable instead of crashing the dashboard
- Reusable Pre-Auth Keys no longer show expired when used (PR [#88](https://github.com/tale/headplane/pull/88))
- Tweaked some CSS issues in the UI
### 0.3.9 (December 6, 2024)
- Fixed a race condition bug in the OIDC validation code
### 0.3.8 (December 6, 2024)
- Added a little HTML footer to show the login page and link to a donation page.
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
### 0.3.7 (November 30, 2024) ### 0.3.7 (November 30, 2024)
- Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57)) - Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57))
- Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59)) - Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59))
+1
View File
@@ -2,6 +2,7 @@ FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
RUN npm install -g pnpm RUN npm install -g pnpm
RUN apk add --no-cache git
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
COPY patches ./patches COPY patches ./patches
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
+15
View File
@@ -0,0 +1,15 @@
FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY agent/ ./agent
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags "-s -w" \
-o /app/hp_agent ./agent/cmd/hp_agent
FROM scratch
COPY --from=builder /app/hp_agent /hp_agent
ENTRYPOINT ["/hp_agent"]
+40
View File
@@ -0,0 +1,40 @@
package main
import (
_ "github.com/joho/godotenv/autoload"
"github.com/tale/headplane/agent/config"
"github.com/tale/headplane/agent/tsnet"
"github.com/tale/headplane/agent/hpagent"
"log"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %s", err)
}
agent := tsnet.NewAgent(
cfg.Hostname,
cfg.TSControlURL,
cfg.TSAuthKey,
cfg.Debug,
)
agent.StartAndFetchID()
defer agent.Shutdown()
ws, err := hpagent.NewSocket(
agent,
cfg.HPControlURL,
cfg.HPAuthKey,
cfg.Debug,
)
if err != nil {
log.Fatalf("Failed to create websocket: %s", err)
}
defer ws.StopListening()
ws.StartListening()
}
+55
View File
@@ -0,0 +1,55 @@
package config
import (
_ "github.com/joho/godotenv/autoload"
"os"
)
// Config represents the configuration for the agent.
type Config struct {
Debug bool
Hostname string
TSControlURL string
TSAuthKey string
HPControlURL string
HPAuthKey string
}
const (
DebugEnv = "HP_AGENT_DEBUG"
HostnameEnv = "HP_AGENT_HOSTNAME"
TSControlURLEnv = "HP_AGENT_TS_SERVER"
TSAuthKeyEnv = "HP_AGENT_TS_AUTHKEY"
HPControlURLEnv = "HP_AGENT_HP_SERVER"
HPAuthKeyEnv = "HP_AGENT_HP_AUTHKEY"
)
// Load reads the agent configuration from environment variables.
func Load() (*Config, error) {
c := &Config{
Debug: false,
Hostname: os.Getenv(HostnameEnv),
TSControlURL: os.Getenv(TSControlURLEnv),
TSAuthKey: os.Getenv(TSAuthKeyEnv),
HPControlURL: os.Getenv(HPControlURLEnv),
HPAuthKey: os.Getenv(HPAuthKeyEnv),
}
if os.Getenv(DebugEnv) == "true" {
c.Debug = true
}
if err := validateRequired(c); err != nil {
return nil, err
}
if err := validateTSReady(c); err != nil {
return nil, err
}
if err := validateHPReady(c); err != nil {
return nil, err
}
return c, nil
}
+73
View File
@@ -0,0 +1,73 @@
package config
import (
"fmt"
"net/http"
"strings"
)
// Checks to make sure all required environment variables are set
func validateRequired(config *Config) error {
if config.Hostname == "" {
return fmt.Errorf("%s is required", HostnameEnv)
}
if config.TSControlURL == "" {
return fmt.Errorf("%s is required", TSControlURLEnv)
}
if config.HPControlURL == "" {
return fmt.Errorf("%s is required", HPControlURLEnv)
}
if config.TSAuthKey == "" {
return fmt.Errorf("%s is required", TSAuthKeyEnv)
}
if config.HPAuthKey == "" {
return fmt.Errorf("%s is required", HPAuthKeyEnv)
}
return nil
}
// Pings the Tailscale control server to make sure it's up and running
func validateTSReady(config *Config) error {
testURL := config.TSControlURL
if strings.HasSuffix(testURL, "/") {
testURL = testURL[:len(testURL)-1]
}
// TODO: Consequences of switching to /health (headscale only)
testURL = fmt.Sprintf("%s/key?v=109", testURL)
resp, err := http.Get(testURL)
if err != nil {
return fmt.Errorf("Failed to connect to TS control server: %s", err)
}
if resp.StatusCode != 200 {
return fmt.Errorf("Failed to connect to TS control server: %s", resp.Status)
}
return nil
}
// Pings the Headplane server to make sure it's up and running
func validateHPReady(config *Config) error {
testURL := config.HPControlURL
if strings.HasSuffix(testURL, "/") {
testURL = testURL[:len(testURL)-1]
}
testURL = fmt.Sprintf("%s/healthz", testURL)
resp, err := http.Get(testURL)
if err != nil {
return fmt.Errorf("Failed to connect to HP control server: %s", err)
}
if resp.StatusCode != 200 {
return fmt.Errorf("Failed to connect to HP control server: %s", resp.Status)
}
return nil
}
+83
View File
@@ -0,0 +1,83 @@
package hpagent
import (
"encoding/json"
"log"
"sync"
"tailscale.com/tailcfg"
)
// Represents messages from the Headplane master
type RecvMessage struct {
NodeIDs []string `json:omitempty`
}
// Starts listening for messages from the Headplane master
func (s *Socket) StartListening() {
for {
_, message, err := s.ReadMessage()
if err != nil {
log.Printf("error reading message: %v", err)
return
}
var msg RecvMessage
err = json.Unmarshal(message, &msg)
if err != nil {
log.Printf("error unmarshalling message: %v", err)
continue
}
if s.Debug {
log.Printf("got message: %s", message)
}
if len(msg.NodeIDs) == 0 {
log.Printf("got a message with no node IDs? %s", message)
continue
}
// Accumulate the results since we invoke via gofunc
results := make(map[string]*tailcfg.HostinfoView)
mu := sync.Mutex{}
wg := sync.WaitGroup{}
for _, nodeID := range msg.NodeIDs {
wg.Add(1)
go func(nodeID string) {
defer wg.Done()
result, err := s.Agent.GetStatusForPeer(nodeID)
if err != nil {
log.Printf("error getting status: %v", err)
return
}
if result == nil {
return
}
mu.Lock()
results[nodeID] = result
mu.Unlock()
}(nodeID)
}
wg.Wait()
// Send the results back to the Headplane master
err = s.SendStatus(results)
if err != nil {
log.Printf("error sending status: %v", err)
return
}
if s.Debug {
log.Printf("sent status: %s", results)
}
}
}
// Stops listening for messages from the Headplane master
func (s *Socket) StopListening() {
s.Close()
}
+11
View File
@@ -0,0 +1,11 @@
package hpagent
import (
"tailscale.com/tailcfg"
)
// Sends the status to the Headplane master
func (s *Socket) SendStatus(status map[string]*tailcfg.HostinfoView) error {
err := s.WriteJSON(status)
return err
}
+56
View File
@@ -0,0 +1,56 @@
package hpagent
import (
"fmt"
"github.com/gorilla/websocket"
"github.com/tale/headplane/agent/tsnet"
"log"
"net/http"
"net/url"
)
type Socket struct {
*websocket.Conn
Debug bool
Agent *tsnet.TSAgent
}
// Creates a new websocket connection to the Headplane server.
func NewSocket(agent *tsnet.TSAgent, controlURL, authKey string, debug bool) (*Socket, error) {
wsURL, err := httpToWs(controlURL)
if err != nil {
return nil, err
}
headers := http.Header{}
headers.Add("X-Headplane-TS-Node-ID", agent.ID)
auth := fmt.Sprintf("Bearer %s", authKey)
headers.Add("Authorization", auth)
log.Printf("dialing websocket at %s", wsURL)
ws, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err != nil {
return nil, err
}
return &Socket{ws, debug, agent}, nil
}
// We need to convert the control URL to a websocket URL
func httpToWs(controlURL string) (string, error) {
u, err := url.Parse(controlURL)
if err != nil {
return "", err
}
if u.Scheme == "http" {
u.Scheme = "ws"
} else if u.Scheme == "https" {
u.Scheme = "wss"
} else {
return "", fmt.Errorf("unsupported scheme: %s", u.Scheme)
}
return u.String(), nil
}
+47
View File
@@ -0,0 +1,47 @@
package tsnet
import (
"context"
"fmt"
"log"
"strings"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"go4.org/mem"
)
// Returns the raw hostinfo for a peer based on node ID.
func (s *TSAgent) GetStatusForPeer(id string) (*tailcfg.HostinfoView, error) {
if !strings.HasPrefix(id, "nodekey:") {
return nil, fmt.Errorf("invalid node ID: %s", id)
}
if s.Debug {
log.Printf("querying peer state for %s", id)
}
status, err := s.Lc.Status(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get status: %w", err)
}
nodeKey, err := key.ParseNodePublicUntyped(mem.S(id[8:]))
peer := status.Peer[nodeKey]
if peer == nil {
// Check if we are on Self.
if status.Self.PublicKey == nodeKey {
peer = status.Self
} else {
return nil, nil
}
}
ip := peer.TailscaleIPs[0].String()
whois, err := s.Lc.WhoIs(context.Background(), ip)
if err != nil {
return nil, fmt.Errorf("failed to get whois: %w", err)
}
return &whois.Node.Hostinfo, nil
}
+61
View File
@@ -0,0 +1,61 @@
package tsnet
import (
"context"
"fmt"
"log"
"os"
"tailscale.com/client/tailscale"
"tailscale.com/tsnet"
)
// Wrapper type so we can add methods to the server.
type TSAgent struct {
*tsnet.Server
Lc *tailscale.LocalClient
ID string
Debug bool
}
// Creates a new tsnet agent and returns an instance of the server.
func NewAgent(hostname, controlURL, authKey string, debug bool) *TSAgent {
s := &tsnet.Server{
Hostname: hostname,
ControlURL: controlURL,
AuthKey: authKey,
Logf: func(string, ...interface{}) {}, // Disabled by default
}
if debug {
s.Logf = log.New(
os.Stderr,
fmt.Sprintf("[DBG:%s] ", hostname),
log.LstdFlags,
).Printf
}
return &TSAgent{s, nil, "", debug}
}
// Starts the tsnet agent and sets the node ID.
func (s *TSAgent) StartAndFetchID() {
// Waits until the agent is up and running.
status, err := s.Up(context.Background())
if err != nil {
log.Fatalf("Failed to start agent: %v", err)
}
s.Lc, err = s.LocalClient()
if err != nil {
log.Fatalf("Failed to create local client: %v", err)
}
log.Printf("Agent running with ID: %s", status.Self.PublicKey)
s.ID = string(status.Self.ID)
}
// Shuts down the tsnet agent.
func (s *TSAgent) Shutdown() {
s.Close()
}
+26 -32
View File
@@ -1,16 +1,15 @@
import { CopyIcon } from '@primer/octicons-react' import { CopyIcon } from '@primer/octicons-react';
import { toast } from './Toaster';
import { toast } from './Toaster'
interface Props { interface Props {
name: string name: string;
value: string value: string;
isCopyable?: boolean isCopyable?: boolean;
link?: string link?: string;
} }
export default function Attribute({ name, value, link, 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">
@@ -18,31 +17,26 @@ export default function Attribute({ name, value, link, isCopyable }: Props) {
<a className="hover:underline" href={link}> <a className="hover:underline" href={link}>
{name} {name}
</a> </a>
) : name} ) : (
name
)}
</dt> </dt>
{canCopy {canCopy ? (
? ( <button
<button type="button"
type="button" className="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md"
className="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md" onClick={async () => {
// eslint-disable-next-line @typescript-eslint/no-misused-promises await navigator.clipboard.writeText(value);
onClick={async () => { toast(`Copied ${name}`);
await navigator.clipboard.writeText(value) }}
toast(`Copied ${name}`) >
}} <dd className="min-w-0 truncate px-2 py-1">{value}</dd>
> <CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" />
<dd className="min-w-0 truncate px-2 py-1"> </button>
{value} ) : (
</dd> <dd className="min-w-0 truncate px-2 py-1">{value}</dd>
<CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" /> )}
</button>
)
: (
<dd className="min-w-0 truncate px-2 py-1">
{value}
</dd>
)}
</dl> </dl>
) );
} }
+22 -21
View File
@@ -1,37 +1,38 @@
import { type Dispatch, type SetStateAction } from 'react' import type { Dispatch, SetStateAction } from 'react';
import { Button as AriaButton } from 'react-aria-components' import { Button as AriaButton } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' type Props = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
readonly variant?: 'heavy' | 'light';
};
type ButtonProperties = Parameters<typeof AriaButton>[0] & { export default function Button(props: Props) {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]
readonly variant?: 'heavy' | 'light'
}
export default function Button(properties: ButtonProperties) {
return ( return (
<AriaButton <AriaButton
{...properties} {...props}
className={cn( className={cn(
'w-fit text-sm rounded-lg px-4 py-2', 'w-fit text-sm rounded-lg px-4 py-2',
properties.variant === 'heavy' props.variant === 'heavy'
? 'bg-main-700 dark:bg-main-800' ? 'bg-main-700 dark:bg-main-800'
: 'bg-main-200 dark:bg-main-700/30', : 'bg-main-200 dark:bg-main-700/30',
properties.variant === 'heavy' props.variant === 'heavy'
? 'hover:bg-main-800 dark:hover:bg-main-700' ? 'hover:bg-main-800 dark:hover:bg-main-700'
: 'hover:bg-main-300 dark:hover:bg-main-600/30', : 'hover:bg-main-300 dark:hover:bg-main-600/30',
properties.variant === 'heavy' props.variant === 'heavy'
? 'text-white' ? 'text-white'
: 'text-ui-700 dark:text-ui-300', : 'text-ui-700 dark:text-ui-300',
properties.isDisabled && 'opacity-50 cursor-not-allowed', props.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className, props.className,
)} )}
// If control is passed, set the state value // If control is passed, set the state value
onPress={properties.control onPress={
? () => { props.control
properties.control?.[1](true) ? () => {
} props.control?.[1](true);
: properties.onPress} }
: props.onPress
}
/> />
) );
} }
+20 -30
View File
@@ -1,53 +1,43 @@
import { type HTMLProps } from 'react' import type { HTMLProps } from 'react';
import { Heading as AriaHeading } from 'react-aria-components' import { Heading as AriaHeading } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' function Title(props: Parameters<typeof AriaHeading>[0]) {
function Title(properties: Parameters<typeof AriaHeading>[0]) {
return ( return (
<AriaHeading <AriaHeading
{...properties} {...props}
slot='title' slot="title"
className={cn( className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
'text-lg font-semibold leading-6 mb-5',
properties.className
)}
/> />
) );
} }
function Text(properties: React.HTMLProps<HTMLParagraphElement>) { function Text(props: React.HTMLProps<HTMLParagraphElement>) {
return ( return (
<p <p {...props} className={cn('text-base leading-6 my-0', props.className)} />
{...properties} );
className={cn(
'text-base leading-6 my-0',
properties.className
)}
/>
)
} }
type Properties = HTMLProps<HTMLDivElement> & { type Props = HTMLProps<HTMLDivElement> & {
variant?: 'raised' | 'flat'; variant?: 'raised' | 'flat';
} };
function Card(properties: Properties) { function Card(props: Props) {
return ( return (
<div <div
{...properties} {...props}
className={cn( className={cn(
'w-full max-w-md overflow-hidden rounded-xl p-4', 'w-full max-w-md overflow-hidden rounded-xl p-4',
properties.variant === 'flat' props.variant === 'flat'
? 'bg-transparent shadow-none' ? 'bg-transparent shadow-none'
: 'bg-ui-50 dark:bg-ui-900 shadow-sm', : 'bg-ui-50 dark:bg-ui-900 shadow-sm',
'border border-ui-200 dark:border-ui-700', 'border border-ui-200 dark:border-ui-700',
properties.className props.className,
)} )}
> >
{properties.children} {props.children}
</div> </div>
) );
} }
export default Object.assign(Card, { Title, Text }) export default Object.assign(Card, { Title, Text });
+28 -22
View File
@@ -1,45 +1,51 @@
import { useState, HTMLProps } from 'react' import { useState, HTMLProps } from 'react';
import { CopyIcon, CheckIcon } from '@primer/octicons-react' import { CopyIcon, CheckIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' import { toast } from '~/components/Toaster';
import { toast } from '~/components/Toaster'
interface Props extends HTMLProps<HTMLSpanElement> { interface Props extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean isCopyable?: boolean;
} }
export default function Code(props: Props) { export default function Code(props: Props) {
const [isCopied, setIsCopied] = useState(false) const [isCopied, setIsCopied] = useState(false);
return ( return (
<> <>
<code className={cn( <code
'bg-ui-100 dark:bg-ui-800 p-0.5 rounded-md', className={cn(
props.className 'bg-ui-100 dark:bg-ui-800 p-0.5 rounded-md',
)}> props.className,
)}
>
{props.children} {props.children}
</code> </code>
{props.isCopyable && ( {props.isCopyable ? (
<button <button
className={cn( className={cn(
'ml-1 p-1 rounded-md', 'ml-1 p-1 rounded-md',
'bg-ui-100 dark:bg-ui-800', 'bg-ui-100 dark:bg-ui-800',
'text-ui-500 dark:text-ui-400', 'text-ui-500 dark:text-ui-400',
'inline-flex items-center justify-center' 'inline-flex items-center justify-center',
)} )}
onClick={() => { onClick={() => {
navigator.clipboard.writeText(props.children.join('')) if (!props.children) {
toast('Copied to clipboard') throw new Error('Made copyable without children');
setIsCopied(true) }
setTimeout(() => setIsCopied(false), 1000)
navigator.clipboard.writeText(props.children.join(''));
toast('Copied to clipboard');
setIsCopied(true);
setTimeout(() => setIsCopied(false), 1000);
}} }}
> >
{isCopied ? {isCopied ? (
<CheckIcon className="h-3 w-3" /> : <CheckIcon className="h-3 w-3" />
) : (
<CopyIcon className="h-3 w-3" /> <CopyIcon className="h-3 w-3" />
} )}
</button> </button>
)} ) : undefined}
</> </>
) );
} }
+54 -65
View File
@@ -1,106 +1,99 @@
/* eslint-disable unicorn/no-keyword-prefix */ import type { Dispatch, ReactNode, SetStateAction } from 'react';
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
import { import {
Button as AriaButton, Button as AriaButton,
Dialog as AriaDialog, Dialog as AriaDialog,
DialogTrigger, DialogTrigger,
Heading as AriaHeading, Heading as AriaHeading,
Modal, Modal,
ModalOverlay ModalOverlay,
} from 'react-aria-components' } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' type ButtonProps = Parameters<typeof AriaButton>[0] & {
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]; readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
} };
function Button(properties: ButtonProperties) { function Button(props: ButtonProps) {
return ( return (
<AriaButton <AriaButton
{...properties} {...props}
aria-label='Dialog' aria-label="Dialog"
className={cn( className={cn(
'w-fit text-sm rounded-lg px-4 py-2', 'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white', 'bg-main-700 dark:bg-main-800 text-white',
'hover:bg-main-800 dark:hover:bg-main-700', 'hover:bg-main-800 dark:hover:bg-main-700',
properties.isDisabled && 'opacity-50 cursor-not-allowed', props.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className props.className,
)} )}
// If control is passed, set the state value // If control is passed, set the state value
onPress={properties.control ? () => { onPress={
properties.control?.[1](true) props.control
} : undefined} ? () => {
props.control?.[1](true);
}
: undefined
}
/> />
) );
} }
type ActionProperties = Parameters<typeof AriaButton>[0] & { type ActionProps = Parameters<typeof AriaButton>[0] & {
readonly variant: 'cancel' | 'confirm'; readonly variant: 'cancel' | 'confirm';
} };
function Action(properties: ActionProperties) { function Action(props: ActionProps) {
return ( return (
<AriaButton <AriaButton
{...properties} {...props}
type={properties.variant === 'confirm' ? 'submit' : 'button'} type={props.variant === 'confirm' ? 'submit' : 'button'}
className={cn( className={cn(
'px-4 py-2 rounded-lg', 'px-4 py-2 rounded-lg',
properties.isDisabled && 'opacity-50 cursor-not-allowed', props.isDisabled && 'opacity-50 cursor-not-allowed',
properties.variant === 'cancel' props.variant === 'cancel'
? 'text-ui-700 dark:text-ui-300' ? 'text-ui-700 dark:text-ui-300'
: 'text-ui-300 dark:text-ui-300', : 'text-ui-300 dark:text-ui-300',
properties.variant === 'confirm' props.variant === 'confirm'
? 'bg-main-700 dark:bg-main-700 pressed:bg-main-800 dark:pressed:bg-main-800' ? 'bg-main-700 dark:bg-main-700 pressed:bg-main-800 dark:pressed:bg-main-800'
: 'bg-ui-200 dark:bg-ui-800 pressed:bg-ui-300 dark:pressed:bg-ui-700', : 'bg-ui-200 dark:bg-ui-800 pressed:bg-ui-300 dark:pressed:bg-ui-700',
properties.className props.className,
)} )}
/> />
) );
} }
function Title(properties: Parameters<typeof AriaHeading>[0]) { function Title(props: Parameters<typeof AriaHeading>[0]) {
return ( return (
<AriaHeading <AriaHeading
{...properties} {...props}
slot='title' slot="title"
className={cn( className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
'text-lg font-semibold leading-6 mb-5',
properties.className
)}
/> />
) );
} }
function Text(properties: React.HTMLProps<HTMLParagraphElement>) { function Text(props: React.HTMLProps<HTMLParagraphElement>) {
return ( return (
<p <p {...props} className={cn('text-base leading-6 my-0', props.className)} />
{...properties} );
className={cn(
'text-base leading-6 my-0',
properties.className
)}
/>
)
} }
type PanelProperties = { interface PanelProps {
readonly children: (close: () => void) => ReactNode; children: (close: () => void) => ReactNode;
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]; control?: [boolean, Dispatch<SetStateAction<boolean>>];
readonly className?: string; className?: string;
} }
function Panel({ children, control, className }: PanelProperties) { function Panel({ children, control, className }: PanelProps) {
return ( return (
<ModalOverlay <ModalOverlay
aria-hidden='true' aria-hidden="true"
className={cn( className={cn(
'fixed inset-0 h-screen w-screen z-50 bg-black/30', 'fixed inset-0 h-screen w-screen z-50 bg-black/30',
'flex items-center justify-center dark:bg-black/70', 'flex items-center justify-center dark:bg-black/70',
'entering:animate-in exiting:animate-out', 'entering:animate-in exiting:animate-out',
'entering:fade-in entering:duration-200 entering:ease-out', 'entering:fade-in entering:duration-200 entering:ease-out',
'exiting:fade-out exiting:duration-100 exiting:ease-in', 'exiting:fade-out exiting:duration-100 exiting:ease-in',
className className,
)} )}
isOpen={control ? control[0] : undefined} isOpen={control ? control[0] : undefined}
onOpenChange={control ? control[1] : undefined} onOpenChange={control ? control[1] : undefined}
@@ -112,32 +105,28 @@ function Panel({ children, control, className }: PanelProperties) {
'entering:animate-in exiting:animate-out', 'entering:animate-in exiting:animate-out',
'dark:border dark:border-ui-700', 'dark:border dark:border-ui-700',
'entering:zoom-in-95 entering:ease-out entering:duration-200', 'entering:zoom-in-95 entering:ease-out entering:duration-200',
'exiting:zoom-out-95 exiting:ease-in exiting:duration-100' 'exiting:zoom-out-95 exiting:ease-in exiting:duration-100',
)} )}
> >
<AriaDialog role='alertdialog' className='outline-none relative'> <AriaDialog role="alertdialog" className="outline-none relative">
{({ close }) => children(close)} {({ close }) => children(close)}
</AriaDialog> </AriaDialog>
</Modal> </Modal>
</ModalOverlay> </ModalOverlay>
) );
} }
type DialogProperties = { interface DialogProps {
readonly children: ReactNode; children: ReactNode;
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]; control?: [boolean, Dispatch<SetStateAction<boolean>>];
} }
function Dialog({ children, control }: DialogProperties) { function Dialog({ children, control }: DialogProps) {
if (control) { if (control) {
return children return children;
} }
return ( return <DialogTrigger>{children}</DialogTrigger>;
<DialogTrigger>
{children}
</DialogTrigger>
)
} }
export default Object.assign(Dialog, { Button, Title, Text, Panel, Action }) export default Object.assign(Dialog, { Button, Title, Text, Panel, Action });
+41 -26
View File
@@ -1,19 +1,40 @@
import { AlertIcon } from '@primer/octicons-react' import { AlertIcon } from '@primer/octicons-react';
import { isRouteErrorResponse, useRouteError } from '@remix-run/react' import { isRouteErrorResponse, useRouteError } from 'react-router';
import { cn } from '~/utils/cn';
import Card from './Card';
import Code from './Code';
import { cn } from '~/utils/cn' interface Props {
type?: 'full' | 'embedded';
import Card from './Card'
import Code from './Code'
type Properties = {
readonly type?: 'full' | 'embedded';
} }
export function ErrorPopup({ type = 'full' }: Properties) { function getMessage(error: Error | unknown) {
const error = useRouteError() if (!(error instanceof Error)) {
const routing = isRouteErrorResponse(error) return "An unknown error occurred";
const message = (error instanceof Error ? error.message : 'An unexpected error occurred') }
let rootError = error;
// Traverse the error chain to find the root cause
if (error.cause) {
rootError = error.cause as Error;
while (rootError.cause) {
rootError = rootError.cause as Error;
}
}
// If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
return rootError.errors.map((error) => error.message).join('\n');
}
return rootError.message;
}
export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError();
const routing = isRouteErrorResponse(error);
const message = getMessage(error);
return ( return (
<div <div
@@ -21,26 +42,20 @@ export function ErrorPopup({ type = 'full' }: Properties) {
'flex items-center justify-center', 'flex items-center justify-center',
type === 'embedded' type === 'embedded'
? 'pointer-events-none mt-24' ? 'pointer-events-none mt-24'
: 'fixed inset-0 h-screen w-screen z-50' : 'fixed inset-0 h-screen w-screen z-50',
)} )}
> >
<Card> <Card>
<div className='flex items-center justify-between'> <div className="flex items-center justify-between">
<Card.Title className='text-3xl mb-0'> <Card.Title className="text-3xl mb-0">
{routing ? error.status : 'Error'} {routing ? error.status : 'Error'}
</Card.Title> </Card.Title>
<AlertIcon className='w-12 h-12 text-red-500'/> <AlertIcon className="w-12 h-12 text-red-500" />
</div> </div>
<Card.Text className='mt-4 text-lg'> <Card.Text className="mt-4 text-lg">
{routing ? ( {routing ? error.statusText : <Code>{message}</Code>}
error.statusText
) : (
<Code>
{message}
</Code>
)}
</Card.Text> </Card.Text>
</Card> </Card>
</div> </div>
) );
} }
+46
View File
@@ -0,0 +1,46 @@
import { cn } from '~/utils/cn';
import Link from '~/components/Link';
import Tooltip from '~/components/Tooltip';
declare global {
const __VERSION__: string;
}
interface FooterProps {
url: string;
debug: boolean;
}
export default function Footer({ url, debug }: FooterProps) {
return (
<footer
className={cn(
'fixed bottom-0 left-0 z-40 w-full h-14',
'bg-ui-100 dark:bg-ui-900 text-ui-500',
'flex flex-col justify-center gap-1',
'border-t border-ui-200 dark:border-ui-800',
)}
>
<p className="container text-xs">
Headplane is entirely free to use. If you find it useful, consider{' '}
<Link
to="https://github.com/sponsors/tale"
name="Aarnav's GitHub Sponsors"
>
donating
</Link>{' '}
to support development.{' '}
</p>
<p className="container text-xs opacity-75">
Version: {__VERSION__}
{' | '}
Connecting to
{' '}
<strong className="blur-xs hover:blur-none">
{url}
</strong>
{debug && ' (Debug mode enabled)'}
</p>
</footer>
);
}
+101 -76
View File
@@ -1,42 +1,49 @@
import { GearIcon, GlobeIcon, LockIcon, PaperAirplaneIcon, PeopleIcon, PersonIcon, ServerIcon } from '@primer/octicons-react' import {
import { Form } from '@remix-run/react' GearIcon,
GlobeIcon,
LockIcon,
PaperAirplaneIcon,
PeopleIcon,
PersonIcon,
ServerIcon,
} from '@primer/octicons-react';
import { Form } from 'react-router';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import { HeadplaneContext } from '~/utils/config/headplane' import type { HeadplaneContext } from '~/utils/config/headplane';
import { type SessionData } from '~/utils/sessions' import type { SessionData } from '~/utils/sessions.server';
import Menu from './Menu' import Menu from './Menu';
import TabLink from './TabLink' import TabLink from './TabLink';
interface Properties { interface Props {
readonly data?: { config: HeadplaneContext['config'];
config: HeadplaneContext['config'] user?: SessionData['user'];
user?: SessionData['user']
}
} }
interface LinkProperties { interface LinkProps {
readonly href: string href: string;
readonly text: string text: string;
readonly isMenu?: boolean isMenu?: boolean;
} }
function Link({ href, text, isMenu }: LinkProperties) { function Link({ href, text, isMenu }: LinkProps) {
return ( return (
<a <a
href={href} href={href}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className={cn( className={cn(
!isMenu && 'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block', !isMenu &&
'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block',
)} )}
> >
{text} {text}
</a> </a>
) );
} }
export default function Header({ data }: Properties) { export default function Header(data: Props) {
return ( return (
<header className="bg-main-700 dark:bg-main-800 text-ui-50"> <header className="bg-main-700 dark:bg-main-800 text-ui-50">
<div className="container flex items-center justify-between py-4"> <div className="container flex items-center justify-between py-4">
@@ -48,69 +55,87 @@ export default function Header({ data }: Properties) {
<Link href="https://tailscale.com/download" text="Download" /> <Link href="https://tailscale.com/download" text="Download" />
<Link href="https://github.com/tale/headplane" text="GitHub" /> <Link href="https://github.com/tale/headplane" text="GitHub" />
<Link href="https://github.com/juanfont/headscale" text="Headscale" /> <Link href="https://github.com/juanfont/headscale" text="Headscale" />
{data?.user {data.user ? (
? ( <Menu>
<Menu> <Menu.Button
<Menu.Button className={cn( className={cn(
'rounded-full h-9 w-9', 'rounded-full h-9 w-9',
'border border-main-600 dark:border-main-700', 'border border-main-600 dark:border-main-700',
'hover:bg-main-600 dark:hover:bg-main-700', 'hover:bg-main-600 dark:hover:bg-main-700',
'flex items-center justify-center',
)} )}
> >
<PersonIcon className="h-5 w-5 mt-0.5" /> <PersonIcon className="h-5 w-5 mt-0.5" />
</Menu.Button> </Menu.Button>
<Menu.Items> <Menu.Items>
<Menu.Item className="text-right"> <Menu.Item className="text-right">
<p className="font-bold">{data.user.name}</p> <p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p> <p>{data.user.email}</p>
</Menu.Item> </Menu.Item>
<Menu.Item className="text-right sm:hidden"> <Menu.Item className="text-right sm:hidden">
<Link <Link
isMenu isMenu
href="https://tailscale.com/download" href="https://tailscale.com/download"
text="Download" text="Download"
/> />
</Menu.Item> </Menu.Item>
<Menu.Item className="text-right sm:hidden"> <Menu.Item className="text-right sm:hidden">
<Link <Link
isMenu isMenu
href="https://github.com/tale/headplane" href="https://github.com/tale/headplane"
text="GitHub" text="GitHub"
/> />
</Menu.Item> </Menu.Item>
<Menu.Item className="text-right sm:hidden"> <Menu.Item className="text-right sm:hidden">
<Link <Link
isMenu isMenu
href="https://github.com/juanfont/headscale" href="https://github.com/juanfont/headscale"
text="Headscale" text="Headscale"
/> />
</Menu.Item> </Menu.Item>
<Menu.Item className="text-red-500 dark:text-red-400"> <Menu.Item className="text-red-500 dark:text-red-400">
<Form method="POST" action="/logout"> <Form method="POST" action="/logout">
<button type="submit" className="w-full text-right"> <button type="submit" className="w-full text-right">
Logout Logout
</button> </button>
</Form> </Form>
</Menu.Item> </Menu.Item>
</Menu.Items> </Menu.Items>
</Menu> </Menu>
) ) : undefined}
: undefined}
</div> </div>
</div> </div>
<nav className="container flex items-center gap-x-4 overflow-x-auto"> <nav className="container flex items-center gap-x-4 overflow-x-auto">
<TabLink to="/machines" name="Machines" icon={<ServerIcon className="w-4 h-4" />} /> <TabLink
<TabLink to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} /> to="/machines"
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} /> name="Machines"
{data?.config.read icon={<ServerIcon className="w-4 h-4" />}
? ( />
<> <TabLink
<TabLink to="/dns" name="DNS" icon={<GlobeIcon className="w-4 h-4" />} /> to="/users"
<TabLink to="/settings" name="Settings" icon={<GearIcon className="w-4 h-4" />} /> name="Users"
</> icon={<PeopleIcon className="w-4 h-4" />}
) />
: undefined} <TabLink
to="/acls"
name="Access Control"
icon={<LockIcon className="w-4 h-4" />}
/>
{data.config.read ? (
<>
<TabLink
to="/dns"
name="DNS"
icon={<GlobeIcon className="w-4 h-4" />}
/>
<TabLink
to="/settings"
name="Settings"
icon={<GearIcon className="w-4 h-4" />}
/>
</>
) : undefined}
</nav> </nav>
</header> </header>
) );
} }
+7 -8
View File
@@ -1,12 +1,11 @@
import { LinkExternalIcon } from '@primer/octicons-react' import { LinkExternalIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
interface Props { interface Props {
to: string to: string;
name: string name: string;
children: string children: string;
className?: string className?: string;
} }
export default function Link({ to, name: alt, children, className }: Props) { export default function Link({ to, name: alt, children, className }: Props) {
@@ -26,5 +25,5 @@ export default function Link({ to, name: alt, children, className }: Props) {
{children} {children}
<LinkExternalIcon className="h-3 w-3" /> <LinkExternalIcon className="h-3 w-3" />
</a> </a>
) );
} }
+43 -50
View File
@@ -1,98 +1,91 @@
import { type Dispatch, type ReactNode, type SetStateAction } from 'react' import type { Dispatch, ReactNode, SetStateAction } from 'react';
import { import {
Button as AriaButton, Button as AriaButton,
Menu as AriaMenu, Menu as AriaMenu,
MenuItem, MenuItem,
MenuTrigger, MenuTrigger,
Popover Popover,
} from 'react-aria-components' } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' function Button(props: Parameters<typeof AriaButton>[0]) {
function Button(properties: Parameters<typeof AriaButton>[0]) {
return ( return (
<AriaButton <AriaButton
{...properties} {...props}
className={cn( className={cn('outline-none', props.className)}
'outline-none', aria-label="Menu"
properties.className
)}
aria-label='Menu'
/> />
) );
} }
function Items(properties: Parameters<typeof AriaMenu>[0]) { function Items(props: Parameters<typeof AriaMenu>[0]) {
return ( return (
<Popover className={cn( <Popover
'mt-2 rounded-md', className={cn(
'bg-ui-50 dark:bg-ui-800', 'mt-2 rounded-md',
'overflow-hidden z-50', 'bg-ui-50 dark:bg-ui-800',
'border border-ui-200 dark:border-ui-600', 'overflow-hidden z-50',
'entering:animate-in exiting:animate-out', 'border border-ui-200 dark:border-ui-600',
'entering:fade-in entering:zoom-in-95', 'entering:animate-in exiting:animate-out',
'exiting:fade-out exiting:zoom-out-95', 'entering:fade-in entering:zoom-in-95',
'fill-mode-forwards origin-left-right' 'exiting:fade-out exiting:zoom-out-95',
)} 'fill-mode-forwards origin-left-right',
)}
> >
<AriaMenu <AriaMenu
{...properties} {...props}
className={cn( className={cn(
'outline-none', 'outline-none',
'divide-y divide-ui-200 dark:divide-ui-600', 'divide-y divide-ui-200 dark:divide-ui-600',
properties.className props.className,
)} )}
> >
{properties.children} {props.children}
</AriaMenu> </AriaMenu>
</Popover> </Popover>
) );
} }
type ButtonProperties = Parameters<typeof AriaButton>[0] & { type ButtonProps = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]; readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
} };
function ItemButton(properties: ButtonProperties) { function ItemButton(props: ButtonProps) {
return ( return (
<MenuItem className='outline-none'> <MenuItem className="outline-none">
<AriaButton <AriaButton
{...properties} {...props}
className={cn( className={cn(
'px-4 py-2 w-full outline-none text-left', 'px-4 py-2 w-full outline-none text-left',
'hover:bg-ui-200 dark:hover:bg-ui-700', 'hover:bg-ui-200 dark:hover:bg-ui-700',
properties.className props.className,
)} )}
aria-label='Menu Dialog' aria-label="Menu Dialog"
// If control is passed, set the state value // If control is passed, set the state value
onPress={event => { onPress={(event) => {
properties.onPress?.(event) props.onPress?.(event);
properties.control?.[1](true) props.control?.[1](true);
}} }}
/> />
</MenuItem> </MenuItem>
) );
} }
function Item(properties: Parameters<typeof MenuItem>[0]) { function Item(props: Parameters<typeof MenuItem>[0]) {
return ( return (
<MenuItem <MenuItem
{...properties} {...props}
className={cn( className={cn(
'px-4 py-2 w-full outline-none', 'px-4 py-2 w-full outline-none',
'hover:bg-ui-200 dark:hover:bg-ui-700', 'hover:bg-ui-200 dark:hover:bg-ui-700',
properties.className props.className,
)} )}
/> />
) );
} }
function Menu({ children }: { readonly children: ReactNode }) { function Menu({ children }: { children: ReactNode }) {
return ( return <MenuTrigger>{children}</MenuTrigger>;
<MenuTrigger>
{children}
</MenuTrigger>
)
} }
export default Object.assign(Menu, { Button, Item, ItemButton, Items }) export default Object.assign(Menu, { Button, Item, ItemButton, Items });
+12 -12
View File
@@ -1,23 +1,23 @@
import { InfoIcon } from '@primer/octicons-react' import { InfoIcon } from '@primer/octicons-react';
import type { ReactNode } from 'react' import type { ReactNode } from 'react';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
interface Props { interface Props {
className?: string className?: string;
children: ReactNode children: ReactNode;
} }
export default function Notice({ children, className }: Props) { export default function Notice({ children, className }: Props) {
return ( return (
<div className={cn( <div
'p-4 rounded-md w-full flex items-center gap-3', className={cn(
'bg-ui-200 dark:bg-ui-800', 'p-4 rounded-md w-full flex items-center gap-3',
className, 'bg-ui-200 dark:bg-ui-800',
)} className,
)}
> >
<InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" /> <InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" />
{children} {children}
</div> </div>
) );
} }
+19 -20
View File
@@ -1,18 +1,17 @@
import { PlusIcon, DashIcon } from '@primer/octicons-react' import { PlusIcon, DashIcon } from '@primer/octicons-react';
import { Dispatch, SetStateAction } from 'react' import type { Dispatch, SetStateAction } from 'react';
import { import {
Button, Button,
Group, Group,
Input, Input,
NumberField as AriaNumberField NumberField as AriaNumberField,
} from 'react-aria-components' } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
type NumberFieldProps = Parameters<typeof AriaNumberField>[0] & { type NumberFieldProps = Parameters<typeof AriaNumberField>[0] & {
label: string; label: string;
state?: [number, Dispatch<SetStateAction<number>>]; state?: [number, Dispatch<SetStateAction<number>>];
} };
export default function NumberField(props: NumberFieldProps) { export default function NumberField(props: NumberFieldProps) {
return ( return (
@@ -21,20 +20,20 @@ export default function NumberField(props: NumberFieldProps) {
aria-label={props.label} aria-label={props.label}
className="w-full" className="w-full"
value={props.state?.[0]} value={props.state?.[0]}
onChange={value => { onChange={(value) => {
props.state?.[1](value) props.state?.[1](value);
}} }}
> >
<Group className={cn( <Group
'flex px-2.5 py-1.5 w-full rounded-lg my-1', className={cn(
'border border-ui-200 dark:border-ui-600', 'flex px-2.5 py-1.5 w-full rounded-lg my-1',
'dark:bg-ui-800 dark:text-ui-300 gap-2', 'border border-ui-200 dark:border-ui-600',
'focus-within:ring-2 focus-within:ring-blue-600', 'dark:bg-ui-800 dark:text-ui-300 gap-2',
props.className 'focus-within:ring-2 focus-within:ring-blue-600',
)}> props.className,
<Input )}
className="w-full bg-transparent focus:outline-none" >
/> <Input className="w-full bg-transparent focus:outline-none" />
<Button slot="decrement"> <Button slot="decrement">
<DashIcon className="w-4 h-4" /> <DashIcon className="w-4 h-4" />
</Button> </Button>
@@ -43,5 +42,5 @@ export default function NumberField(props: NumberFieldProps) {
</Button> </Button>
</Group> </Group>
</AriaNumberField> </AriaNumberField>
) );
} }
+19 -21
View File
@@ -1,5 +1,5 @@
import { ChevronDownIcon } from '@primer/octicons-react' import { ChevronDownIcon } from '@primer/octicons-react';
import { Dispatch, ReactNode, SetStateAction } from 'react' import type { Dispatch, ReactNode, SetStateAction } from 'react';
import { import {
Button, Button,
ListBox, ListBox,
@@ -7,15 +7,14 @@ import {
Popover, Popover,
Select as AriaSelect, Select as AriaSelect,
SelectValue, SelectValue,
} from 'react-aria-components' } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
type SelectProps = Parameters<typeof AriaSelect>[0] & { type SelectProps = Parameters<typeof AriaSelect>[0] & {
readonly label: string readonly label: string;
readonly state?: [string, Dispatch<SetStateAction<string>>] readonly state?: [string, Dispatch<SetStateAction<string>>];
readonly children: ReactNode readonly children: ReactNode;
} };
function Select(props: SelectProps) { function Select(props: SelectProps) {
return ( return (
@@ -24,7 +23,7 @@ function Select(props: SelectProps) {
aria-label={props.label} aria-label={props.label}
selectedKey={props.state?.[0]} selectedKey={props.state?.[0]}
onSelectionChange={(key) => { onSelectionChange={(key) => {
props.state?.[1](key.toString()) props.state?.[1](key.toString());
}} }}
className={cn( className={cn(
'block w-full rounded-lg my-1', 'block w-full rounded-lg my-1',
@@ -34,10 +33,11 @@ function Select(props: SelectProps) {
props.className, props.className,
)} )}
> >
<Button className={cn( <Button
'w-full flex items-center justify-between', className={cn(
'px-2.5 py-1.5 rounded-lg', 'w-full flex items-center justify-between',
)} 'px-2.5 py-1.5 rounded-lg',
)}
> >
<SelectValue /> <SelectValue />
<ChevronDownIcon className="w-4 h-4" aria-hidden="true" /> <ChevronDownIcon className="w-4 h-4" aria-hidden="true" />
@@ -54,15 +54,13 @@ function Select(props: SelectProps) {
'fill-mode-forwards origin-left-right', 'fill-mode-forwards origin-left-right',
)} )}
> >
<ListBox orientation="vertical"> <ListBox orientation="vertical">{props.children}</ListBox>
{props.children}
</ListBox>
</Popover> </Popover>
</AriaSelect> </AriaSelect>
) );
} }
type ItemProps = Parameters<typeof ListBoxItem>[0] type ItemProps = Parameters<typeof ListBoxItem>[0];
function Item(props: ItemProps) { function Item(props: ItemProps) {
return ( return (
@@ -76,7 +74,7 @@ function Item(props: ItemProps) {
> >
{props.children} {props.children}
</ListBoxItem> </ListBoxItem>
) );
} }
export default Object.assign(Select, { Item }) export default Object.assign(Select, { Item });
+8 -9
View File
@@ -1,23 +1,22 @@
import clsx from 'clsx' import clsx from 'clsx';
type Properties = { interface Props {
// eslint-disable-next-line unicorn/no-keyword-prefix
className?: string; className?: string;
} }
export default function Spinner(properties: Properties) { export default function Spinner({ className }: Props) {
return ( return (
<div className={clsx('mr-1.5 inline-block align-middle mb-0.5', properties.className)}> <div className={clsx('mr-1.5 inline-block align-middle mb-0.5', className)}>
<div <div
className={clsx( className={clsx(
'animate-spin rounded-full w-full h-full', 'animate-spin rounded-full w-full h-full',
'border-2 border-current border-t-transparent', 'border-2 border-current border-t-transparent',
properties.className className,
)} )}
role='status' role="status"
> >
<span className='sr-only'>Loading...</span> <span className="sr-only">Loading...</span>
</div> </div>
</div> </div>
) );
} }
+10 -10
View File
@@ -1,24 +1,24 @@
import clsx from 'clsx' import clsx from 'clsx';
import { type HTMLProps } from 'react' import type { HTMLProps } from 'react';
type Properties = HTMLProps<SVGElement> & { type Props = HTMLProps<SVGElement> & {
readonly isOnline: boolean; readonly isOnline: boolean;
} };
// eslint-disable-next-line unicorn/no-keyword-prefix // eslint-disable-next-line unicorn/no-keyword-prefix
export default function StatusCircle({ isOnline, className }: Properties) { export default function StatusCircle({ isOnline, className }: Props) {
return ( return (
<svg <svg
className={clsx( className={clsx(
className, className,
isOnline isOnline
? 'text-green-700 dark:text-green-400' ? 'text-green-700 dark:text-green-400'
: 'text-gray-300 dark:text-gray-500' : 'text-gray-300 dark:text-gray-500',
)} )}
viewBox='0 0 24 24' viewBox="0 0 24 24"
fill='currentColor' fill="currentColor"
> >
<circle cx='12' cy='12' r='8'/> <circle cx="12" cy="12" r="8" />
</svg> </svg>
) );
} }
+17 -17
View File
@@ -1,34 +1,34 @@
import { Switch as AriaSwitch } from 'react-aria-components' import { Switch as AriaSwitch } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' type SwitchProps = Parameters<typeof AriaSwitch>[0] & {
type SwitchProperties = Parameters<typeof AriaSwitch>[0] & {
readonly label: string; readonly label: string;
} };
export default function Switch(properties: SwitchProperties) { export default function Switch(props: SwitchProps) {
return ( return (
<AriaSwitch <AriaSwitch
{...properties} {...props}
aria-label={properties.label} aria-label={props.label}
className='group flex gap-2 items-center' className="group flex gap-2 items-center"
> >
<div <div
className={cn( className={cn(
'flex h-[26px] w-[44px] p-[4px] shrink-0', 'flex h-[26px] w-[44px] p-[4px] shrink-0',
'rounded-full outline-none group-focus-visible:ring-2', 'rounded-full outline-none group-focus-visible:ring-2',
'bg-main-600/50 dark:bg-main-600/20 group-selected:bg-main-700', 'bg-main-600/50 dark:bg-main-600/20 group-selected:bg-main-700',
properties.isDisabled && 'opacity-50 cursor-not-allowed', props.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className props.className,
)} )}
> >
<span className={cn( <span
'h-[18px] w-[18px] transform rounded-full', className={cn(
'bg-white transition duration-100 ease-in-out', 'h-[18px] w-[18px] transform rounded-full',
'translate-x-0 group-selected:translate-x-[100%]' 'bg-white transition duration-100 ease-in-out',
)} 'translate-x-0 group-selected:translate-x-[100%]',
)}
/> />
</div> </div>
</AriaSwitch> </AriaSwitch>
) );
} }
+17 -16
View File
@@ -1,32 +1,33 @@
import { NavLink } from '@remix-run/react' import { NavLink } from 'react-router';
import type { ReactNode } from 'react' import type { ReactNode } from 'react';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' interface Props {
name: string;
type Properties = { to: string;
readonly name: string; icon: ReactNode;
readonly to: string;
readonly icon: ReactNode;
} }
export default function TabLink({ name, to, icon }: Properties) { export default function TabLink({ name, to, icon }: Props) {
return ( return (
<NavLink <NavLink
to={to} to={to}
prefetch='intent' prefetch="intent"
className={({ isActive }) => cn( className={({ isActive }) =>
'border-b-2 py-1.5', cn(
isActive ? 'border-white' : 'border-transparent' 'border-b-2 py-1.5',
)} isActive ? 'border-white' : 'border-transparent',
)
}
> >
<div <div
className={cn( className={cn(
'flex items-center gap-x-2 px-2.5 py-1.5 text-md text-nowrap', 'flex items-center gap-x-2 px-2.5 py-1.5 text-md text-nowrap',
'hover:bg-ui-100/5 dark:hover:bg-ui-900/40 rounded-md' 'hover:bg-ui-100/5 dark:hover:bg-ui-900/40 rounded-md',
)} )}
> >
{icon} {name} {icon} {name}
</div> </div>
</NavLink> </NavLink>
) );
} }
+13 -14
View File
@@ -1,37 +1,36 @@
import clsx from 'clsx' import clsx from 'clsx';
import { type HTMLProps } from 'react' import type { HTMLProps } from 'react';
function TableList(properties: HTMLProps<HTMLDivElement>) { function TableList(props: HTMLProps<HTMLDivElement>) {
return ( return (
<div <div
{...properties} {...props}
className={clsx( className={clsx(
'border border-gray-300 rounded-lg overflow-clip', 'border border-gray-300 rounded-lg overflow-clip',
'dark:border-zinc-700 dark:text-gray-300', 'dark:border-zinc-700 dark:text-gray-300',
// 'dark:bg-zinc-800', // 'dark:bg-zinc-800',
properties.className props.className,
)} )}
> >
{properties.children} {props.children}
</div> </div>
) );
} }
function Item(properties: HTMLProps<HTMLDivElement>) { function Item(props: HTMLProps<HTMLDivElement>) {
return ( return (
<div <div
{...properties} {...props}
className={clsx( className={clsx(
'flex items-center justify-between px-3 py-2', 'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0', 'border-b border-gray-200 last:border-b-0',
'dark:border-zinc-800', 'dark:border-zinc-800',
properties.className props.className,
)} )}
> >
{properties.children} {props.children}
</div> </div>
) );
} }
export default Object.assign(TableList, { Item }) export default Object.assign(TableList, { Item });
+14 -22
View File
@@ -1,38 +1,30 @@
import { type Dispatch, type SetStateAction } from 'react' import type { Dispatch, SetStateAction } from 'react';
import { import { Input, TextField as AriaTextField } from 'react-aria-components';
Input, import { cn } from '~/utils/cn';
TextField as AriaTextField
} from 'react-aria-components'
import { cn } from '~/utils/cn' type TextFieldProps = Parameters<typeof AriaTextField>[0] & {
type TextFieldProperties = Parameters<typeof AriaTextField>[0] & {
readonly label: string; readonly label: string;
readonly placeholder: string; readonly placeholder: string;
readonly state?: [string, Dispatch<SetStateAction<string>>]; readonly state?: [string, Dispatch<SetStateAction<string>>];
} };
export default function TextField(properties: TextFieldProperties) { export default function TextField(props: TextFieldProps) {
return ( return (
<AriaTextField <AriaTextField {...props} aria-label={props.label} className="w-full">
{...properties}
aria-label={properties.label}
className='w-full'
>
<Input <Input
placeholder={properties.placeholder} placeholder={props.placeholder}
value={properties.state?.[0]} value={props.state?.[0]}
name={properties.name} name={props.name}
className={cn( className={cn(
'block px-2.5 py-1.5 w-full rounded-lg my-1', 'block px-2.5 py-1.5 w-full rounded-lg my-1',
'border border-ui-200 dark:border-ui-600', 'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300', 'dark:bg-ui-800 dark:text-ui-300',
properties.className props.className,
)} )}
onChange={event => { onChange={(event) => {
properties.state?.[1](event.target.value) props.state?.[1](event.target.value);
}} }}
/> />
</AriaTextField> </AriaTextField>
) );
} }
+52 -46
View File
@@ -1,22 +1,32 @@
import { XIcon } from '@primer/octicons-react' import { XIcon } from '@primer/octicons-react';
import { AriaToastProps, useToast, useToastRegion } from '@react-aria/toast' import {
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast' AriaToastProps,
import { ReactNode, useRef } from 'react' useToast,
import { Button } from 'react-aria-components' useToastRegion,
import { createPortal } from 'react-dom' } from '@react-aria/toast';
import { ClientOnly } from 'remix-utils/client-only' import {
ToastQueue,
ToastState,
useToastQueue,
} from '@react-stately/toast';
import { ReactNode, useRef } from 'react';
import { Button } from 'react-aria-components';
import { createPortal } from 'react-dom';
import { ClientOnly } from 'remix-utils/client-only';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' type ToastProps = AriaToastProps<ReactNode> & {
type ToastProperties = AriaToastProps<ReactNode> & {
readonly state: ToastState<ReactNode>; readonly state: ToastState<ReactNode>;
} };
function Toast({ state, ...properties }: ToastProperties) { function Toast({ state, ...properties }: ToastProps) {
const reference = useRef(null) const reference = useRef(null);
// @ts-expect-error: RefObject doesn't map to FocusableElement? const { toastProps, titleProps, closeButtonProps } = useToast(
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference) properties,
state,
reference,
);
return ( return (
<div <div
@@ -26,7 +36,7 @@ function Toast({ state, ...properties }: ToastProperties) {
'bg-main-700 dark:bg-main-800 rounded-lg', 'bg-main-700 dark:bg-main-800 rounded-lg',
'text-main-100 dark:text-main-200 z-50', 'text-main-100 dark:text-main-200 z-50',
'border border-main-600 dark:border-main-700', 'border border-main-600 dark:border-main-700',
'flex items-center justify-between p-3 pl-4 w-80' 'flex items-center justify-between p-3 pl-4 w-80',
)} )}
> >
<div {...titleProps}>{properties.toast.content}</div> <div {...titleProps}>{properties.toast.content}</div>
@@ -34,52 +44,48 @@ function Toast({ state, ...properties }: ToastProperties) {
{...closeButtonProps} {...closeButtonProps}
className={cn( className={cn(
'outline-none rounded-full p-1', 'outline-none rounded-full p-1',
'hover:bg-main-600 dark:hover:bg-main-700' 'hover:bg-main-600 dark:hover:bg-main-700',
)} )}
> >
<XIcon className='w-4 h-4'/> <XIcon className="w-4 h-4" />
</Button> </Button>
</div> </div>
) );
} }
const toasts = new ToastQueue<ReactNode>({ const toasts = new ToastQueue<ReactNode>({
maxVisibleToasts: 5 maxVisibleToasts: 5,
}) });
export function toast(text: string) { export function toast(text: string) {
return toasts.add(text, { timeout: 5000 }) return toasts.add(text, { timeout: 5000 });
} }
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>
{ {
// @ts-expect-error: Portal doesn't match Node in React 19 yet () =>
() => createPortal( createPortal(
state.visibleToasts.length >= 0 ? ( state.visibleToasts.length >= 0 ? (
<div <div
className={cn( className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
'fixed bottom-4 right-4', {...regionProps}
'flex flex-col gap-4' ref={reference}
)} >
{...regionProps} {state.visibleToasts.map((toast) => (
ref={reference} <Toast key={toast.key} toast={toast} state={state} />
> ))}
{state.visibleToasts.map(toast => ( </div>
<Toast key={toast.key} toast={toast} state={state}/> ) : undefined,
))} document.body,
</div> )
) : undefined, }
document.body
)}
</ClientOnly> </ClientOnly>
) );
} }
+16 -22
View File
@@ -1,43 +1,37 @@
import { ReactNode } from 'react' import type { ReactNode } from 'react';
import { import {
Button as AriaButton, Button as AriaButton,
Tooltip as AriaTooltip, Tooltip as AriaTooltip,
TooltipTrigger, TooltipTrigger,
} from 'react-aria-components' } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
interface Props { interface Props {
children: ReactNode children: ReactNode;
className?: string className?: string;
} }
function Tooltip({ children }: Props) { function Tooltip({ children }: Props) {
return ( return <TooltipTrigger delay={0}>{children}</TooltipTrigger>;
<TooltipTrigger delay={0}>
{children}
</TooltipTrigger>
)
} }
function Button(props: Parameters<typeof AriaButton>[0]) { function Button(props: Parameters<typeof AriaButton>[0]) {
return ( return <AriaButton {...props} />;
<AriaButton {...props} />
)
} }
function Body({ children, className }: Props) { function Body({ children, className }: Props) {
return ( return (
<AriaTooltip className={cn( <AriaTooltip
'text-sm max-w-xs p-2 rounded-lg mb-2', className={cn(
'bg-white dark:bg-ui-900 drop-shadow-sm', 'text-sm max-w-xs p-2 rounded-lg mb-2',
'border border-gray-200 dark:border-zinc-700', 'bg-white dark:bg-ui-900 drop-shadow-sm',
className, 'border border-gray-200 dark:border-zinc-700',
)} className,
)}
> >
{children} {children}
</AriaTooltip> </AriaTooltip>
) );
} }
export default Object.assign(Tooltip, { Button, Body }) export default Object.assign(Tooltip, { Button, Body });
+12
View File
@@ -0,0 +1,12 @@
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>
);
});
+60 -51
View File
@@ -1,62 +1,71 @@
import { PassThrough } from 'node:stream' import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from '@remix-run/node' import type { AppLoadContext, EntryContext } from "react-router";
import { createReadableStreamFromReadable } from '@remix-run/node' import { createReadableStreamFromReadable } from "@react-router/node";
import { RemixServer } from '@remix-run/react' import { ServerRouter } from "react-router";
import { isbot } from 'isbot' import { isbot } from "isbot";
import { renderToPipeableStream } from 'react-dom/server' import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
import { loadContext } from "~/utils/config/headplane";
import { loadContext } from './utils/config/headplane' loadContext();
await loadContext() export const streamTimeout = 5_000;
export default function handleRequest( export default function handleRequest(
request: Request, request: Request,
responseStatusCode: number, responseStatusCode: number,
responseHeaders: Headers, responseHeaders: Headers,
remixContext: EntryContext, routerContext: EntryContext,
// eslint-disable-next-line @typescript-eslint/no-unused-vars loadContext: AppLoadContext
_loadContext: AppLoadContext,
) { ) {
const ua = request.headers.get('user-agent') return new Promise((resolve, reject) => {
const isBot = ua ? isbot(ua) : false let shellRendered = false;
const userAgent = request.headers.get("user-agent");
return new Promise((resolve, reject) => { // Ensure requests from bots and SPA Mode renders wait for all content to load before responding
let shellRendered = false // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
const { pipe, abort } = renderToPipeableStream( const readyOption: keyof RenderToPipeableStreamOptions =
<RemixServer (userAgent && isbot(userAgent)) || routerContext.isSpaMode
context={remixContext} ? "onAllReady"
url={request.url} : "onShellReady";
abortDelay={5000}
/>,
{
[isBot ? 'onAllReady' : 'onShellReady']() {
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set('Content-Type', 'text/html')
resolve( const { pipe, abort } = renderToPipeableStream(
new Response(stream, { <ServerRouter context={routerContext} url={request.url} />,
headers: responseHeaders, {
status: responseStatusCode, [readyOption]() {
}), shellRendered = true;
) const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
pipe(body) responseHeaders.set("Content-Type", "text/html");
},
onShellError(error: unknown) {
reject(error as Error)
},
onError(error: unknown) {
responseStatusCode = 500
if (shellRendered) {
console.error(error)
}
},
},
)
setTimeout(abort, 5000) resolve(
}) new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
// Abort the rendering stream after the `streamTimeout` so it has tine to
// flush down the rejected boundaries
setTimeout(abort, streamTimeout + 1000);
});
} }
+60 -67
View File
@@ -1,17 +1,17 @@
import { access, constants } from 'node:fs/promises' import { access, constants } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises' import { setTimeout } from 'node:timers/promises';
import { Client } from 'undici' import { Client } from 'undici';
import { HeadscaleError, pull } from '~/utils/headscale' import { HeadscaleError, pull } from '~/utils/headscale';
import log from '~/utils/log' import log from '~/utils/log';
import { createIntegration } from './integration' import { createIntegration } from './integration';
interface Context { interface Context {
client: Client | undefined client: Client | undefined;
container: string | undefined container: string | undefined;
maxAttempts: number maxAttempts: number;
} }
export default createIntegration<Context>({ export default createIntegration<Context>({
@@ -24,133 +24,126 @@ export default createIntegration<Context>({
isAvailable: async (context) => { isAvailable: async (context) => {
// Check for the HEADSCALE_CONTAINER environment variable first // Check for the HEADSCALE_CONTAINER environment variable first
// to avoid unnecessary fetching of the Docker socket // to avoid unnecessary fetching of the Docker socket
log.debug('INTG', 'Checking Docker integration availability') log.debug('INTG', 'Checking Docker integration availability');
context.container = process.env.HEADSCALE_CONTAINER context.container = process.env.HEADSCALE_CONTAINER?.trim().toLowerCase();
?.trim()
.toLowerCase()
if (!context.container || context.container.length === 0) { if (!context.container || context.container.length === 0) {
log.error('INTG', 'Missing HEADSCALE_CONTAINER variable') log.error('INTG', 'Missing HEADSCALE_CONTAINER variable');
return false return false;
} }
log.info('INTG', 'Using container: %s', context.container) log.info('INTG', 'Using container: %s', context.container);
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock' const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock';
let url: URL | undefined let url: URL | undefined;
try { try {
url = new URL(path) url = new URL(path);
} catch { } catch {
log.error('INTG', 'Invalid Docker socket path: %s', path) log.error('INTG', 'Invalid Docker socket path: %s', path);
return false return false;
} }
if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') { if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') {
log.error('INTG', 'Invalid Docker socket protocol: %s', log.error('INTG', 'Invalid Docker socket protocol: %s', url.protocol);
url.protocol, return false;
)
return false
} }
// The API is available as an HTTP endpoint and this // The API is available as an HTTP endpoint and this
// will simplify the fetching logic in undici // will simplify the fetching logic in undici
if (url.protocol === 'tcp:') { if (url.protocol === 'tcp:') {
// Apparently setting url.protocol doesn't work anymore? // Apparently setting url.protocol doesn't work anymore?
const fetchU = url.href.replace(url.protocol, 'http:') const fetchU = url.href.replace(url.protocol, 'http:');
try { try {
log.info('INTG', 'Checking API: %s', fetchU) log.info('INTG', 'Checking API: %s', fetchU);
await fetch(new URL('/v1.30/version', fetchU).href) await fetch(new URL('/v1.30/version', fetchU).href);
} catch (error) { } catch (error) {
log.debug('INTG', 'Failed to connect to Docker API', error) log.debug('INTG', 'Failed to connect to Docker API', error);
log.error('INTG', 'Failed to connect to Docker API') log.error('INTG', 'Failed to connect to Docker API');
return false return false;
} }
context.client = new Client(fetchU) context.client = new Client(fetchU);
} }
// Check if the socket is accessible // Check if the socket is accessible
if (url.protocol === 'unix:') { if (url.protocol === 'unix:') {
try { try {
log.info('INTG', 'Checking socket: %s', log.info('INTG', 'Checking socket: %s', url.pathname);
url.pathname, await access(url.pathname, constants.R_OK);
)
await access(url.pathname, constants.R_OK)
} catch (error) { } catch (error) {
log.debug('INTG', 'Failed to access Docker socket: %s', error) log.debug('INTG', 'Failed to access Docker socket: %s', error);
log.error('INTG', 'Failed to access Docker socket: %s', log.error('INTG', 'Failed to access Docker socket: %s', path);
path, return false;
)
return false
} }
context.client = new Client('http://localhost', { context.client = new Client('http://localhost', {
socketPath: url.pathname, socketPath: url.pathname,
}) });
} }
return context.client !== undefined return context.client !== undefined;
}, },
onConfigChange: async (context) => { onConfigChange: async (context) => {
if (!context.client || !context.container) { if (!context.client || !context.container) {
return return;
} }
log.info('INTG', 'Restarting Headscale via Docker') log.info('INTG', 'Restarting Headscale via Docker');
let attempts = 0 let attempts = 0;
while (attempts <= context.maxAttempts) { while (attempts <= context.maxAttempts) {
log.debug( log.debug(
'INTG', 'Restarting container: %s (attempt %d)', 'INTG',
'Restarting container: %s (attempt %d)',
context.container, context.container,
attempts, attempts,
) );
const response = await context.client.request({ const response = await context.client.request({
method: 'POST', method: 'POST',
path: `/v1.30/containers/${context.container}/restart`, path: `/v1.30/containers/${context.container}/restart`,
}) });
if (response.statusCode !== 204) { if (response.statusCode !== 204) {
if (attempts < context.maxAttempts) { if (attempts < context.maxAttempts) {
attempts++ attempts++;
await setTimeout(1000) await setTimeout(1000);
continue continue;
} }
const stringCode = response.statusCode.toString() const stringCode = response.statusCode.toString();
const body = await response.body.text() const body = await response.body.text();
throw new Error(`API request failed: ${stringCode} ${body}`) throw new Error(`API request failed: ${stringCode} ${body}`);
} }
break break;
} }
attempts = 0 attempts = 0;
while (attempts <= context.maxAttempts) { while (attempts <= context.maxAttempts) {
try { try {
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts) log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts);
await pull('v1', '') await pull('v1', '');
return return;
} catch (error) { } catch (error) {
if (error instanceof HeadscaleError && error.status === 401) { if (error instanceof HeadscaleError && error.status === 401) {
break break;
} }
if (error instanceof HeadscaleError && error.status === 404) { if (error instanceof HeadscaleError && error.status === 404) {
break break;
} }
if (attempts < context.maxAttempts) { if (attempts < context.maxAttempts) {
attempts++ attempts++;
await setTimeout(1000) await setTimeout(1000);
continue continue;
} }
throw new Error(`Missed restart deadline for ${context.container}`) throw new Error(`Missed restart deadline for ${context.container}`);
} }
} }
}, },
}) });
+27 -34
View File
@@ -1,75 +1,68 @@
import log from '~/utils/log' import log from '~/utils/log';
import dockerIntegration from './docker' import dockerIntegration from './docker';
import { IntegrationFactory } from './integration' import type { IntegrationFactory } from './integration';
import kubernetesIntegration from './kubernetes' import kubernetesIntegration from './kubernetes';
import procIntegration from './proc' import procIntegration from './proc';
export * from './integration' export * from './integration';
export async function loadIntegration() { export async function loadIntegration() {
let integration = process.env.HEADSCALE_INTEGRATION let integration = process.env.HEADSCALE_INTEGRATION?.trim().toLowerCase();
?.trim()
.toLowerCase()
// Old HEADSCALE_CONTAINER variable upgrade path // Old HEADSCALE_CONTAINER variable upgrade path
// This ensures that when people upgrade from older versions of Headplane // This ensures that when people upgrade from older versions of Headplane
// they don't explicitly need to define the new HEADSCALE_INTEGRATION // they don't explicitly need to define the new HEADSCALE_INTEGRATION
// variable that is needed to configure docker // variable that is needed to configure docker
if (!integration && process.env.HEADSCALE_CONTAINER) { if (!integration && process.env.HEADSCALE_CONTAINER) {
integration = 'docker' integration = 'docker';
} }
if (!integration) { if (!integration) {
log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION') log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION');
return return;
} }
let integrationFactory: IntegrationFactory | undefined let integrationFactory: IntegrationFactory | undefined;
switch (integration.toLowerCase().trim()) { switch (integration.toLowerCase().trim()) {
case 'docker': { case 'docker': {
integrationFactory = dockerIntegration integrationFactory = dockerIntegration;
break break;
} }
case 'proc': case 'proc':
case 'native': case 'native':
case 'linux': { case 'linux': {
integrationFactory = procIntegration integrationFactory = procIntegration;
break break;
} }
case 'kubernetes': case 'kubernetes':
case 'k8s': { case 'k8s': {
integrationFactory = kubernetesIntegration integrationFactory = kubernetesIntegration;
break break;
} }
default: { default: {
log.error('INTG', 'Unknown integration: %s', integration) log.error('INTG', 'Unknown integration: %s', integration);
throw new Error(`Unknown integration: ${integration}`) throw new Error(`Unknown integration: ${integration}`);
} }
} }
log.info('INTG', 'Loading integration: %s', integration) log.info('INTG', 'Loading integration: %s', integration);
try { try {
const res = await integrationFactory.isAvailable( const res = await integrationFactory.isAvailable(
integrationFactory.context, integrationFactory.context,
) );
if (!res) { if (!res) {
log.error('INTG', 'Integration %s is not available', log.error('INTG', 'Integration %s is not available', integration);
integration, return;
)
return
} }
} catch (error) { } catch (error) {
log.error('INTG', 'Failed to load integration %s: %s', log.error('INTG', 'Failed to load integration %s: %s', integration, error);
integration, return;
error,
)
return
} }
log.info('INTG', 'Loaded integration: %s', integration) log.info('INTG', 'Loaded integration: %s', integration);
return integrationFactory return integrationFactory;
} }
+6 -8
View File
@@ -1,13 +1,11 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface IntegrationFactory<T = any> { export interface IntegrationFactory<T = any> {
name: string name: string;
context: T context: T;
isAvailable: (context: T) => Promise<boolean> | boolean isAvailable: (context: T) => Promise<boolean> | boolean;
onConfigChange?: (context: T) => Promise<void> | void onConfigChange?: (context: T) => Promise<void> | void;
} }
export function createIntegration<T>( export function createIntegration<T>(options: IntegrationFactory<T>) {
options: IntegrationFactory<T>, return options;
) {
return options
} }
+99 -92
View File
@@ -1,16 +1,16 @@
import { readdir, readFile } from 'node:fs/promises' import { readdir, readFile } from 'node:fs/promises';
import { platform } from 'node:os' import { platform } from 'node:os';
import { join, resolve } from 'node:path' import { join, resolve } from 'node:path';
import { kill } from 'node:process' import { kill } from 'node:process';
import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node' import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node';
import log from '~/utils/log' import log from '~/utils/log';
import { createIntegration } from './integration' import { createIntegration } from './integration';
interface Context { interface Context {
pid: number | undefined pid: number | undefined;
} }
export default createIntegration<Context>({ export default createIntegration<Context>({
@@ -20,185 +20,192 @@ export default createIntegration<Context>({
}, },
isAvailable: async (context) => { isAvailable: async (context) => {
if (platform() !== 'linux') { if (platform() !== 'linux') {
log.error('INTG', 'Kubernetes is only available on Linux') log.error('INTG', 'Kubernetes is only available on Linux');
return false return false;
} }
const svcRoot = Config.SERVICEACCOUNT_ROOT const svcRoot = Config.SERVICEACCOUNT_ROOT;
try { try {
log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot) log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot);
const files = await readdir(svcRoot) const files = await readdir(svcRoot);
if (files.length === 0) { if (files.length === 0) {
log.error('INTG', 'Kubernetes service account not found') log.error('INTG', 'Kubernetes service account not found');
return false return false;
} }
const mappedFiles = new Set(files.map(file => join(svcRoot, file))) const mappedFiles = new Set(files.map((file) => join(svcRoot, file)));
const expectedFiles = [ const expectedFiles = [
Config.SERVICEACCOUNT_CA_PATH, Config.SERVICEACCOUNT_CA_PATH,
Config.SERVICEACCOUNT_TOKEN_PATH, Config.SERVICEACCOUNT_TOKEN_PATH,
Config.SERVICEACCOUNT_NAMESPACE_PATH, Config.SERVICEACCOUNT_NAMESPACE_PATH,
] ];
log.debug('INTG', 'Looking for %s', expectedFiles.join(', ')) log.debug('INTG', 'Looking for %s', expectedFiles.join(', '));
if (!expectedFiles.every(file => mappedFiles.has(file))) { if (!expectedFiles.every((file) => mappedFiles.has(file))) {
log.error('INTG', 'Malformed Kubernetes service account') log.error('INTG', 'Malformed Kubernetes service account');
return false return false;
} }
} catch (error) { } catch (error) {
log.error('INTG', 'Failed to access %s: %s', svcRoot, error) log.error('INTG', 'Failed to access %s: %s', svcRoot, error);
return false return false;
} }
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot) log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot);
const namespace = await readFile( const namespace = await readFile(
Config.SERVICEACCOUNT_NAMESPACE_PATH, Config.SERVICEACCOUNT_NAMESPACE_PATH,
'utf8', 'utf8',
) );
// Some very ugly nesting but it's necessary // Some very ugly nesting but it's necessary
if (process.env.HEADSCALE_INTEGRATION_UNSTRICT === 'true') { if (process.env.HEADSCALE_INTEGRATION_UNSTRICT === 'true') {
log.warn('INTG', 'Skipping strict Pod status check') log.warn('INTG', 'Skipping strict Pod status check');
} else { } else {
const pod = process.env.POD_NAME const pod = process.env.POD_NAME;
if (!pod) { if (!pod) {
log.error('INTG', 'Missing POD_NAME variable') log.error('INTG', 'Missing POD_NAME variable');
return false return false;
} }
if (pod.trim().length === 0) { if (pod.trim().length === 0) {
log.error('INTG', 'Pod name is empty') log.error('INTG', 'Pod name is empty');
return false return false;
} }
log.debug('INTG', 'Checking Kubernetes pod %s in namespace %s', log.debug(
'INTG',
'Checking Kubernetes pod %s in namespace %s',
pod, pod,
namespace, namespace,
) );
try { try {
log.debug('INTG', 'Attempgin to get cluster KubeConfig') log.debug('INTG', 'Attempgin to get cluster KubeConfig');
const kc = new KubeConfig() const kc = new KubeConfig();
kc.loadFromCluster() kc.loadFromCluster();
const cluster = kc.getCurrentCluster() const cluster = kc.getCurrentCluster();
if (!cluster) { if (!cluster) {
log.error('INTG', 'Malformed kubeconfig') log.error('INTG', 'Malformed kubeconfig');
return false return false;
} }
log.info('INTG', 'Service account connected to %s (%s)', log.info(
'INTG',
'Service account connected to %s (%s)',
cluster.name, cluster.name,
cluster.server, cluster.server,
) );
const kCoreV1Api = kc.makeApiClient(CoreV1Api) const kCoreV1Api = kc.makeApiClient(CoreV1Api);
log.info('INTG', 'Checking pod %s in namespace %s (%s)', log.info(
'INTG',
'Checking pod %s in namespace %s (%s)',
pod, pod,
namespace, namespace,
kCoreV1Api.basePath, kCoreV1Api.basePath,
) );
log.debug('INTG', 'Reading pod info for %s', pod) log.debug('INTG', 'Reading pod info for %s', pod);
const { response, body } = await kCoreV1Api.readNamespacedPod( const { response, body } = await kCoreV1Api.readNamespacedPod(
pod, pod,
namespace, namespace,
) );
if (response.statusCode !== 200) { if (response.statusCode !== 200) {
log.error('INTG', 'Failed to read pod info: http %d',
response.statusCode,
)
return false
}
log.debug('INTG', 'Got pod info: %o', body.spec)
const shared = body.spec?.shareProcessNamespace
if (shared === undefined) {
log.error( log.error(
'INTG', 'INTG',
'Pod does not have spec.shareProcessNamespace set', 'Failed to read pod info: http %d',
) response.statusCode,
return false );
return false;
}
log.debug('INTG', 'Got pod info: %o', body.spec);
const shared = body.spec?.shareProcessNamespace;
if (shared === undefined) {
log.error('INTG', 'Pod does not have spec.shareProcessNamespace set');
return false;
} }
if (!shared) { if (!shared) {
log.error( log.error(
'INTG', 'INTG',
'Pod has set but disabled spec.shareProcessNamespace', 'Pod has set but disabled spec.shareProcessNamespace',
) );
return false return false;
} }
log.info('INTG', 'Pod %s enabled shared processes', pod) log.info('INTG', 'Pod %s enabled shared processes', pod);
} catch (error) { } catch (error) {
log.error('INTG', 'Failed to read pod info: %s', error) log.error('INTG', 'Failed to read pod info: %s', error);
return false return false;
} }
} }
log.debug('INTG', 'Looking for namespaced process in /proc') log.debug('INTG', 'Looking for namespaced process in /proc');
const dir = resolve('/proc') const dir = resolve('/proc');
try { try {
const subdirs = await readdir(dir) const subdirs = await readdir(dir);
const promises = subdirs.map(async (dir) => { const promises = subdirs.map(async (dir) => {
const pid = Number.parseInt(dir, 10) const pid = Number.parseInt(dir, 10);
if (Number.isNaN(pid)) { if (Number.isNaN(pid)) {
return return;
} }
const path = join('/proc', dir, 'cmdline') const path = join('/proc', dir, 'cmdline');
try { try {
log.debug('INTG', 'Reading %s', path) log.debug('INTG', 'Reading %s', path);
const data = await readFile(path, 'utf8') const data = await readFile(path, 'utf8');
if (data.includes('headscale')) { if (data.includes('headscale')) {
return pid return pid;
} }
} catch (error) { } catch (error) {
log.debug('INTG', 'Failed to read %s: %s', path, error) log.debug('INTG', 'Failed to read %s: %s', path, error);
} }
}) });
const results = await Promise.allSettled(promises) const results = await Promise.allSettled(promises);
const pids = [] const pids = [];
for (const result of results) { for (const result of results) {
if (result.status === 'fulfilled' && result.value) { if (result.status === 'fulfilled' && result.value) {
pids.push(result.value) pids.push(result.value);
} }
} }
log.debug('INTG', 'Found Headscale processes: %o', pids) log.debug('INTG', 'Found Headscale processes: %o', pids);
if (pids.length > 1) { if (pids.length > 1) {
log.error('INTG', 'Found %d Headscale processes: %s', log.error(
'INTG',
'Found %d Headscale processes: %s',
pids.length, pids.length,
pids.join(', '), pids.join(', '),
) );
return false return false;
} }
if (pids.length === 0) { if (pids.length === 0) {
log.error('INTG', 'Could not find Headscale process') log.error('INTG', 'Could not find Headscale process');
return false return false;
} }
context.pid = pids[0] context.pid = pids[0];
log.info('INTG', 'Found Headscale process with PID: %d', context.pid) log.info('INTG', 'Found Headscale process with PID: %d', context.pid);
return true return true;
} catch { } catch {
log.error('INTG', 'Failed to read /proc') log.error('INTG', 'Failed to read /proc');
return false return false;
} }
}, },
onConfigChange: (context) => { onConfigChange: (context) => {
if (!context.pid) { if (!context.pid) {
return return;
} }
log.info('INTG', 'Sending SIGTERM to Headscale') log.info('INTG', 'Sending SIGTERM to Headscale');
kill(context.pid, 'SIGTERM') kill(context.pid, 'SIGTERM');
}, },
}) });
+38 -36
View File
@@ -1,14 +1,14 @@
import { readdir, readFile } from 'node:fs/promises' import { readdir, readFile } from 'node:fs/promises';
import { platform } from 'node:os' import { platform } from 'node:os';
import { join, resolve } from 'node:path' import { join, resolve } from 'node:path';
import { kill } from 'node:process' import { kill } from 'node:process';
import log from '~/utils/log' import log from '~/utils/log';
import { createIntegration } from './integration' import { createIntegration } from './integration';
interface Context { interface Context {
pid: number | undefined pid: number | undefined;
} }
export default createIntegration<Context>({ export default createIntegration<Context>({
@@ -18,62 +18,64 @@ export default createIntegration<Context>({
}, },
isAvailable: async (context) => { isAvailable: async (context) => {
if (platform() !== 'linux') { if (platform() !== 'linux') {
log.error('INTG', '/proc is only available on Linux') log.error('INTG', '/proc is only available on Linux');
return false return false;
} }
log.debug('INTG', 'Checking /proc for Headscale process') log.debug('INTG', 'Checking /proc for Headscale process');
const dir = resolve('/proc') const dir = resolve('/proc');
try { try {
const subdirs = await readdir(dir) const subdirs = await readdir(dir);
const promises = subdirs.map(async (dir) => { const promises = subdirs.map(async (dir) => {
const pid = Number.parseInt(dir, 10) const pid = Number.parseInt(dir, 10);
if (Number.isNaN(pid)) { if (Number.isNaN(pid)) {
return return;
} }
const path = join('/proc', dir, 'cmdline') const path = join('/proc', dir, 'cmdline');
try { try {
log.debug('INTG', 'Reading %s', path) log.debug('INTG', 'Reading %s', path);
const data = await readFile(path, 'utf8') const data = await readFile(path, 'utf8');
if (data.includes('headscale')) { if (data.includes('headscale')) {
return pid return pid;
} }
} catch (error) { } catch (error) {
log.error('INTG', 'Failed to read %s: %s', path, error) log.error('INTG', 'Failed to read %s: %s', path, error);
} }
}) });
const results = await Promise.allSettled(promises) const results = await Promise.allSettled(promises);
const pids = [] const pids = [];
for (const result of results) { for (const result of results) {
if (result.status === 'fulfilled' && result.value) { if (result.status === 'fulfilled' && result.value) {
pids.push(result.value) pids.push(result.value);
} }
} }
log.debug('INTG', 'Found Headscale processes: %o', pids) log.debug('INTG', 'Found Headscale processes: %o', pids);
if (pids.length > 1) { if (pids.length > 1) {
log.error('INTG', 'Found %d Headscale processes: %s', log.error(
'INTG',
'Found %d Headscale processes: %s',
pids.length, pids.length,
pids.join(', '), pids.join(', '),
) );
return false return false;
} }
if (pids.length === 0) { if (pids.length === 0) {
log.error('INTG', 'Could not find Headscale process') log.error('INTG', 'Could not find Headscale process');
return false return false;
} }
context.pid = pids[0] context.pid = pids[0];
log.info('INTG', 'Found Headscale process with PID: %d', context.pid) log.info('INTG', 'Found Headscale process with PID: %d', context.pid);
return true return true;
} catch { } catch {
log.error('INTG', 'Failed to read /proc') log.error('INTG', 'Failed to read /proc');
return false return false;
} }
} },
}) });
+77
View File
@@ -0,0 +1,77 @@
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { Outlet, useLoaderData } from 'react-router';
import { useEffect } from 'react'
import { ErrorPopup } from '~/components/Error';
import Header from '~/components/Header';
import { toast } from '~/components/Toaster';
import Footer from '~/components/Footer';
import Link from '~/components/Link';
import { useLiveData } from '~/utils/useLiveData'
import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane';
import { HeadscaleError, pull, healthcheck } from '~/utils/headscale';
import { destroySession, getSession } from '~/utils/sessions.server';
import { XCircleFillIcon } from '@primer/octicons-react';
import log from '~/utils/log';
export async function loader({ request }: LoaderFunctionArgs) {
let healthy = false;
try {
healthy = await healthcheck();
} catch (error) {
log.debug('APIC', 'Healthcheck failed %o', error);
}
// We shouldn't session invalidate if Headscale is down
if (healthy) {
// We can assert because shell ensures this is set
const session = await getSession(request.headers.get('Cookie'));
const apiKey = session.get('hsApiKey')!;
try {
await pull('v1/apikey', apiKey);
} catch (error) {
if (error instanceof HeadscaleError) {
log.debug('APIC', 'API Key validation failed %o', error);
return redirect('/login', {
headers: {
'Set-Cookie': await destroySession(session),
},
});
}
}
}
return {
healthy,
}
}
export default function Layout() {
useLiveData({ interval: 3000 });
const { healthy } = useLoaderData<typeof loader>()
return (
<>
{!healthy ? (
<div className={cn(
'fixed bottom-0 right-0 z-50 w-fit h-14',
'flex flex-col justify-center gap-1',
)}>
<div className={cn(
'flex items-center gap-1.5 mr-1.5 py-2 px-1.5',
'border rounded-lg text-white bg-red-500',
'border-red-600 dark:border-red-400 shadow-sm',
)}>
<XCircleFillIcon className="w-4 h-4 text-white" />
Headscale is unreachable
</div>
</div>
) : undefined}
<main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet />
</main>
</>
);
}
+33
View File
@@ -0,0 +1,33 @@
import Header from '~/components/Header';
import Footer from '~/components/Footer';
import { getSession } from '~/utils/sessions.server';
import { loadContext } from '~/utils/config/headplane';
import { useLoaderData, LoaderFunctionArgs, Outlet, redirect } from 'react-router';
// This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops?
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return redirect('/login');
}
const context = await loadContext();
return {
config: context.config,
url: context.headscalePublicUrl ?? context.headscaleUrl,
debug: context.debug,
user: session.get('user'),
};
}
export default function Shell() {
const data = useLoaderData<typeof loader>();
return (
<>
<Header {...data} />
<Outlet />
<Footer {...data} />
</>
)
}
+33 -17
View File
@@ -1,24 +1,25 @@
import type { LinksFunction, MetaFunction } from '@remix-run/node' import type { LoaderFunctionArgs, LinksFunction, MetaFunction } from 'react-router';
import { import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from 'react-router';
Links, import { loadContext } from '~/utils/config/headplane';
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from '@remix-run/react'
import { ErrorPopup } from '~/components/Error' import { ProgressBar } from 'react-aria-components';
import { Toaster } from '~/components/Toaster' import { ErrorPopup } from '~/components/Error';
import stylesheet from '~/tailwind.css?url' // TODO: Make this a default export
import { Toaster } from '~/components/Toaster';
import stylesheet from '~/tailwind.css?url';
import { cn } from '~/utils/cn';
export const meta: MetaFunction = () => [ export const meta: MetaFunction = () => [
{ title: 'Headplane' }, { title: 'Headplane' },
{ name: 'description', content: 'A frontend for the headscale coordination server' }, {
] name: 'description',
content: 'A frontend for the headscale coordination server',
},
];
export const links: LinksFunction = () => [ export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: stylesheet }, { rel: 'stylesheet', href: stylesheet },
] ];
export function Layout({ children }: { readonly children: React.ReactNode }) { export function Layout({ children }: { readonly children: React.ReactNode }) {
return ( return (
@@ -36,13 +37,28 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
<Scripts /> <Scripts />
</body> </body>
</html> </html>
) );
} }
export function ErrorBoundary() { export function ErrorBoundary() {
return <ErrorPopup /> return <ErrorPopup />;
} }
export default function App() { export default function App() {
return <Outlet /> const nav = useNavigation();
return (
<>
<ProgressBar aria-label="Loading...">
<div
className={cn(
'fixed top-0 left-0 z-50 w-1/2 h-1',
'bg-blue-500 dark:bg-blue-400 opacity-0',
nav.state === 'loading' && 'animate-loading opacity-100',
)}
/>
</ProgressBar>
<Outlet />
</>
)
} }
+34
View File
@@ -0,0 +1,34 @@
import { index, layout, prefix, route } from '@react-router/dev/routes';
export default [
// Utility Routes
index('routes/util/redirect.ts'),
route('/healthz', 'routes/util/healthz.ts'),
// Authentication Routes
route('/login', 'routes/auth/login.tsx'),
route('/logout', 'routes/auth/logout.ts'),
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
route('/oidc/start', 'routes/auth/oidc-start.ts'),
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout('layouts/shell.tsx', [
layout('layouts/dashboard.tsx', [
...prefix('/machines', [
index('routes/machines/overview.tsx'),
route('/:id', 'routes/machines/machine.tsx'),
]),
route('/users', 'routes/users/overview.tsx'),
route('/acls', 'routes/acls/editor.tsx'),
route('/dns', 'routes/dns/overview.tsx'),
...prefix('/settings', [
index('routes/settings/overview.tsx'),
route('/auth-keys', 'routes/settings/auth-keys.tsx'),
// route('/local-agent', 'routes/settings/local-agent.tsx'),
]),
]),
]),
];
-122
View File
@@ -1,122 +0,0 @@
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>
)
}
-30
View File
@@ -1,30 +0,0 @@
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,43 +0,0 @@
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>
)
}
-121
View File
@@ -1,121 +0,0 @@
import { Form, useSubmit } from '@remix-run/react'
import { useMemo, useState } from 'react'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import { cn } from '~/utils/cn'
interface Props {
records: { name: string, type: 'A', value: string }[]
}
export default function AddDNS({ records }: Props) {
const submit = useSubmit()
const [name, setName] = useState('')
const [ip, setIp] = useState('')
const isDuplicate = useMemo(() => {
if (name.length === 0 || ip.length === 0) return false
const lookup = records.find(record => record.name === name)
if (!lookup) return false
return lookup.value === ip
}, [records, name, ip])
return (
<Dialog>
<Dialog.Button>
Add DNS record
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Add DNS record
</Dialog.Title>
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
event.preventDefault()
if (!name || !ip) return
setName('')
setIp('')
submit({
'dns.extra_records': [
...records,
{
name,
type: 'A',
value: ip,
},
],
}, {
method: 'PATCH',
encType: 'application/json',
})
close()
}}
>
<TextField
label="Domain"
placeholder="test.example.com"
name="domain"
state={[name, setName]}
className={cn(
'mt-2',
isDuplicate && 'outline outline-red-500',
)}
/>
<TextField
label="IP Address"
placeholder="101.101.101.101"
name="ip"
state={[ip, setIp]}
className={cn(
isDuplicate && 'outline outline-red-500',
)}
/>
{isDuplicate
? (
<p className="text-sm opacity-50">
A record with the domain name
{' '}
<Code>{name}</Code>
{' '}
and IP address
{' '}
<Code>{ip}</Code>
{' '}
already exists.
</p>
)
: undefined}
<div className="mt-6 flex justify-end gap-2 mt-8">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
isDisabled={isDuplicate}
>
Add
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -1,162 +0,0 @@
import { RepoForkedIcon } from '@primer/octicons-react'
import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react'
import Dialog from '~/components/Dialog'
import Switch from '~/components/Switch'
import TextField from '~/components/TextField'
import Tooltip from '~/components/Tooltip'
import { cn } from '~/utils/cn'
interface Props {
nameservers: Record<string, string[]>
}
export default function AddNameserver({ nameservers }: Props) {
const submit = useSubmit()
const [split, setSplit] = useState(false)
const [ns, setNs] = useState('')
const [domain, setDomain] = useState('')
return (
<Dialog>
<Dialog.Button>
Add nameserver
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Add nameserver
</Dialog.Title>
<Dialog.Text className="font-semibold">
Nameserver
</Dialog.Text>
<Dialog.Text className="text-sm">
Use this IPv4 or IPv6 address to resolve names.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
event.preventDefault()
if (!ns) return
if (split) {
const splitNs: Record<string, string[]> = {}
for (const [key, value] of Object.entries(nameservers)) {
if (key === 'global') continue
splitNs[key] = value
}
if (Object.keys(splitNs).includes(domain)) {
splitNs[domain].push(ns)
} else {
splitNs[domain] = [ns]
}
submit({
'dns.nameservers.split': splitNs,
}, {
method: 'PATCH',
encType: 'application/json',
})
} else {
const globalNs = nameservers.global
globalNs.push(ns)
submit({
'dns.nameservers.global': globalNs,
}, {
method: 'PATCH',
encType: 'application/json',
})
}
setNs('')
setDomain('')
setSplit(false)
close()
}}
>
<TextField
label="DNS Server"
placeholder="1.2.3.4"
name="ns"
state={[ns, setNs]}
className="mt-2 mb-8"
/>
<div className="flex items-center justify-between">
<div className="block">
<div className="inline-flex items-center gap-2">
<Dialog.Text className="font-semibold">
Restrict to domain
</Dialog.Text>
<Tooltip>
<Tooltip.Button 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',
)}
>
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
Split DNS
</Tooltip.Button>
<Tooltip.Body>
Only clients that support split DNS
(Tailscale v1.8 or later for most platforms)
will use this nameserver. Older clients
will ignore it.
</Tooltip.Body>
</Tooltip>
</div>
<Dialog.Text className="text-sm">
This nameserver will only be used for some domains.
</Dialog.Text>
</div>
<Switch
label="Split DNS"
defaultSelected={split}
onChange={() => { setSplit(!split) }}
/>
</div>
{split
? (
<>
<Dialog.Text className="font-semibold mt-8">
Domain
</Dialog.Text>
<TextField
label="Domain"
placeholder="example.com"
name="domain"
state={[domain, setDomain]}
className="my-2"
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries
matching this suffix should use the nameserver.
</Dialog.Text>
</>
)
: undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
Add
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
-62
View File
@@ -1,62 +0,0 @@
import { useFetcher } from '@remix-run/react'
import Dialog from '~/components/Dialog'
import Spinner from '~/components/Spinner'
type Properties = {
readonly isEnabled: boolean;
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
}
export default function Modal({ isEnabled, disabled }: Properties) {
const fetcher = useFetcher()
return (
<Dialog>
<Dialog.Button isDisabled={disabled}>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
)}
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Title>
<Dialog.Text>
Devices will no longer be accessible via your tailnet domain.
The search domain will also be disabled.
</Dialog.Text>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant='cancel'
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns.magic_dns': !isEnabled
}, {
method: 'PATCH',
encType: 'application/json'
})
close()
}}
>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
-116
View File
@@ -1,116 +0,0 @@
import { useSubmit } from '@remix-run/react'
import { useState } from 'react'
import { Button } from 'react-aria-components'
import Link from '~/components/Link'
import Switch from '~/components/Switch'
import TableList from '~/components/TableList'
import { cn } from '~/utils/cn'
import AddNameserver from './dialogs/nameserver'
interface Props {
nameservers: Record<string, string[]>
isDisabled: boolean
}
export default function Nameservers({ nameservers, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p className="text-gray-700 dark:text-gray-300">
Set the nameservers used by devices on the Tailnet
to resolve DNS queries.
{' '}
<Link
to="https://tailscale.com/kb/1054/dns"
name="Tailscale DNS Documentation"
>
Learn more
</Link>
</p>
<div className="mt-4">
{Object.keys(nameservers).map(key => (
<NameserverList
key={key}
isGlobal={key === 'global'}
isDisabled={isDisabled}
nameservers={nameservers}
name={key}
/>
))}
{isDisabled
? undefined
: (
<AddNameserver nameservers={nameservers} />
)}
</div>
</div>
)
}
interface ListProps {
isGlobal: boolean
isDisabled: boolean
nameservers: string[]
name: string
}
function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps) {
const submit = useSubmit()
const list = isGlobal ? nameservers['global'] : nameservers[name]
if (list.length === 0) {
return null
}
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h2 className="text-md font-medium opacity-80">
{isGlobal ? 'Global Nameservers' : name}
</h2>
</div>
<TableList>
{list.length > 0 ? list.map((ns, index) => (
// eslint-disable-next-line react/no-array-index-key
<TableList.Item key={index}>
<p className="font-mono text-sm">{ns}</p>
<Button
className={cn(
'text-sm',
'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300',
isDisabled && 'opacity-50 cursor-not-allowed',
)}
isDisabled={isDisabled}
onPress={() => {
if (isGlobal) {
submit({
'dns.nameservers.global': list
.filter((_, i) => i !== index),
}, {
method: 'PATCH',
encType: 'application/json',
})
} else {
submit({
'dns.nameservers.split': {
...nameservers,
[name]: list.filter((_, i) => i !== index),
}
}, {
method: 'PATCH',
encType: 'application/json',
})
}
}}
>
Remove
</Button>
</TableList.Item>
)) : undefined}
</TableList>
</div>
)
}
-101
View File
@@ -1,101 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable unicorn/no-keyword-prefix */
import { useFetcher } from '@remix-run/react'
import { useState } from 'react'
import { Input } from 'react-aria-components'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import Spinner from '~/components/Spinner'
import TextField from '~/components/TextField'
import { cn } from '~/utils/cn'
type Properties = {
readonly name: string;
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
}
export default function Modal({ name, disabled }: Properties) {
const [newName, setNewName] = useState(name)
const fetcher = useFetcher()
return (
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Tailnet Name</h1>
<p className='text-gray-700 dark:text-gray-300'>
This is the base domain name of your Tailnet.
Devices are accessible at
{' '}
<Code>
[device].{name}
</Code>
{' '}
when Magic DNS is enabled.
</p>
<Input
readOnly
className={cn(
'block px-2.5 py-1.5 w-1/2 rounded-lg my-4',
'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300 text-sm',
'outline-none'
)}
type='text'
value={name}
onFocus={event => {
event.target.select()
}}
/>
<Dialog>
<Dialog.Button isDisabled={disabled}>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
)}
Rename Tailnet
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Rename Tailnet
</Dialog.Title>
<Dialog.Text>
Keep in mind that changing this can lead to all sorts of unexpected behavior and may break existing devices in your tailnet.
</Dialog.Text>
<TextField
label='Tailnet name'
placeholder='ts.net'
state={[newName, setNewName]}
className='my-2'
/>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant='cancel'
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
onPress={() => {
fetcher.submit({
'dns.base_domain': newName
}, {
method: 'PATCH',
encType: 'application/json'
})
close()
}}
>
Rename
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
</div>
)
}
-168
View File
@@ -1,168 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ActionFunctionArgs, json } from '@remix-run/node'
import { del, post } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import log from '~/utils/log'
export async function menuAction(request: ActionFunctionArgs['request']) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401,
})
}
const data = await request.formData()
if (!data.has('_method') || !data.has('id')) {
return json({ message: 'No method or ID provided' }, {
status: 400,
})
}
const id = String(data.get('id'))
const method = String(data.get('_method'))
switch (method) {
case 'delete': {
await del(`v1/node/${id}`, session.get('hsApiKey')!)
return json({ message: 'Machine removed' })
}
case 'expire': {
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
return json({ message: 'Machine expired' })
}
case 'rename': {
if (!data.has('name')) {
return json({ message: 'No name provided' }, {
status: 400,
})
}
const name = String(data.get('name'))
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
return json({ message: 'Machine renamed' })
}
case 'routes': {
if (!data.has('route') || !data.has('enabled')) {
return json({ message: 'No route or enabled provided' }, {
status: 400,
})
}
const route = String(data.get('route'))
const enabled = data.get('enabled') === 'true'
const postfix = enabled ? 'enable' : 'disable'
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
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': {
if (!data.has('to')) {
return json({ message: 'No destination provided' }, {
status: 400,
})
}
const to = String(data.get('to'))
try {
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
return json({ message: `Moved node ${id} to ${to}` })
} catch {
return json({ message: `Failed to move node ${id} to ${to}` }, {
status: 500,
})
}
}
case 'tags': {
const tags = data.get('tags')?.toString()
.split(',')
.filter((tag) => tag.trim() !== '')
?? []
try {
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
tags,
})
return json({ message: 'Tags updated' })
} catch (error) {
log.debug('APIC', 'Failed to update tags: %s', error)
return json({ message: 'Failed to update tags' }, {
status: 500,
})
}
}
case 'register': {
const key = data.get('mkey')?.toString()
const user = data.get('user')?.toString()
if (!key) {
return json({ message: 'No machine key provided' }, {
status: 400,
})
}
if (!user) {
return json({ message: 'No user provided' }, {
status: 400,
})
}
try {
const qp = new URLSearchParams()
qp.append('user', user)
qp.append('key', key)
const url = `v1/node/register?${qp.toString()}`
await post(url, session.get('hsApiKey')!, {
user, key,
})
return json({
success: true,
message: 'Machine registered'
})
} catch {
return json({
success: false,
message: 'Failed to register machine'
}, {
status: 500,
})
}
}
default: {
return json({ message: 'Invalid method' }, {
status: 400,
})
}
}
}
@@ -1,87 +0,0 @@
import { Form, useSubmit } from '@remix-run/react'
import { type Dispatch, type SetStateAction, useState } from 'react'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import Select from '~/components/Select'
import { type Machine, User } from '~/types'
interface MoveProps {
readonly machine: Machine
readonly users: User[]
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly magic?: string
}
export default function Move({ machine, state, magic, users }: MoveProps) {
const [owner, setOwner] = useState(machine.user.name)
const submit = useSubmit()
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Change the owner of
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
The owner of the machine is the user associated with it.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
}}
>
<input type="hidden" name="_method" value="move" />
<input type="hidden" name="id" value={machine.id} />
<Select
label="Owner"
name="to"
placeholder="Select a user"
state={[owner, setOwner]}
>
{users.map(user => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
{magic
? (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine is accessible by the hostname
{' '}
<Code className="text-sm">
{machine.givenName}
.
{magic}
</Code>
.
</p>
)
: undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
Change owner
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -1,134 +0,0 @@
import { Form, useFetcher, Link } from '@remix-run/react'
import { Dispatch, SetStateAction, useState, useEffect } from 'react'
import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import Select from '~/components/Select'
import Menu from '~/components/Menu'
import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster'
import { Machine, User } from '~/types'
export interface NewProps {
server: string
users: User[]
}
export default function New(data: NewProps) {
const fetcher = useFetcher<{ success?: boolean }>()
const mkeyState = useState(false)
const [mkey, setMkey] = useState('')
const [user, setUser] = useState('')
const [toasted, setToasted] = useState(false)
useEffect(() => {
if (!fetcher.data || toasted) {
return
}
if (fetcher.data.success) {
toast('Registered new machine')
} else {
toast('Failed to register machine due to an invalid key')
}
setToasted(true)
}, [fetcher.data, toasted, mkey])
return (
<>
<Dialog>
<Dialog.Panel control={mkeyState}>
{close => (
<>
<Dialog.Title>
Register Machine Key
</Dialog.Title>
<Dialog.Text className='mb-4'>
The machine key is given when you run
{' '}
<Code isCopyable>
tailscale up --login-server=
{data.server}
</Code>
{' '}
on your device.
</Dialog.Text>
<fetcher.Form method="POST" onSubmit={e => {
fetcher.submit(e.currentTarget)
close()
}}>
<input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" />
<TextField
label='Machine Key'
placeholder='mkey:ff.....'
name="mkey"
state={[mkey, setMkey]}
className='my-2 font-mono'
/>
<Select
label="Owner"
name="user"
placeholder="Select a user"
state={[user, setUser]}
>
{data.users.map(user => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
isDisabled={!mkey || !mkey.trim().startsWith('mkey:') || !user}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Register
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
<Menu>
<Menu.Button
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
'hover:bg-main-800 dark:hover:bg-main-700',
)}
>
Add Device
</Menu.Button>
<Menu.Items>
<Menu.ItemButton control={mkeyState}>
<ServerIcon className='w-4 h-4 mr-2'/>
Register Machine Key
</Menu.ItemButton>
<Menu.ItemButton>
<Link to="/settings/auth-keys">
<KeyIcon className='w-4 h-4 mr-2'/>
Generate Pre-auth Key
</Link>
</Menu.ItemButton>
</Menu.Items>
</Menu>
</>
)
}
@@ -1,100 +0,0 @@
import { Form, useSubmit } from '@remix-run/react'
import { type Dispatch, type SetStateAction, useState } from 'react'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import { type Machine } from '~/types'
interface RenameProps {
readonly machine: Machine
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly magic?: string
}
export default function Rename({ machine, state, magic }: RenameProps) {
const [name, setName] = useState(machine.givenName)
const submit = useSubmit()
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Edit machine name for
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
This name is shown in the admin panel, in Tailscale clients,
and used when generating MagicDNS names.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
}}
>
<input type="hidden" name="_method" value="rename" />
<input type="hidden" name="id" value={machine.id} />
<TextField
label="Machine name"
placeholder="Machine name"
name="name"
state={[name, setName]}
className="my-2"
/>
{magic
? (
name.length > 0 && name !== machine.givenName
? (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine will be accessible by the hostname
{' '}
<Code className="text-sm">
{name.toLowerCase().replaceAll(/\s+/g, '-')}
</Code>
{'. '}
The hostname
{' '}
<Code className="text-sm">
{machine.givenName}
</Code>
{' '}
will no longer point to this machine.
</p>
)
: (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine is accessible by the hostname
{' '}
<Code className="text-sm">
{machine.givenName}
</Code>
.
</p>
)
)
: undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
Rename
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -1,184 +0,0 @@
import { useFetcher } from '@remix-run/react'
import { Dispatch, SetStateAction, useMemo } from 'react'
import Dialog from '~/components/Dialog'
import Switch from '~/components/Switch'
import Link from '~/components/Link'
import { Machine, Route } from '~/types'
import { cn } from '~/utils/cn'
interface RoutesProps {
readonly machine: Machine
readonly routes: Route[]
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
}
// TODO: Support deleting routes
export default function Routes({ machine, routes, state }: RoutesProps) {
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 (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Edit route settings of
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text className="font-bold">
Subnet routes
</Dialog.Text>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on
by advertising IP ranges as subnet routes.
{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets 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',
)}
>
{subnet.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>
No routes are advertised on this machine.
</p>
</div>
)
: undefined}
{subnet.map(route => (
<div
key={route.id}
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between',
)}
>
<p>
{route.prefix}
</p>
<Switch
defaultSelected={route.enabled}
label="Enabled"
onChange={(checked) => {
const form = new FormData()
form.set('id', machine.id)
form.set('_method', 'routes')
form.set('route', route.id)
form.set('enabled', String(checked))
fetcher.submit(form, {
method: 'POST',
})
}}
/>
</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">
<Dialog.Action
variant="cancel"
isDisabled={fetcher.state === 'submitting'}
onPress={close}
>
Close
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -1,184 +0,0 @@
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react'
import { Link } from '@remix-run/react'
import Menu from '~/components/Menu'
import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster'
import { Machine, Route, User } from '~/types'
import { cn } from '~/utils/cn'
import MenuOptions from './menu'
interface Props {
readonly machine: Machine
readonly routes: Route[]
readonly users: User[]
readonly magic?: string
}
export default function MachineRow({ machine, routes, magic, users }: Props) {
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')
}
let prefix = magic?.startsWith('[user]')
? magic.replace('[user]', machine.user.name)
: 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 (
<tr
key={machine.id}
className="hover:bg-zinc-100 dark:hover:bg-zinc-800 group"
>
<td className="pl-0.5 py-2">
<Link
to={`/machines/${machine.id}`}
className="group/link h-full"
>
<p className={cn(
'font-semibold leading-snug',
'group-hover/link:text-blue-600',
'group-hover/link:dark:text-blue-400',
)}
>
{machine.givenName}
</p>
<p className="text-sm text-gray-500 dark:text-gray-300 font-mono">
{machine.name}
</p>
<div className="flex gap-1 mt-1">
{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>
</Link>
</td>
<td className="py-2">
<div className="flex items-center gap-x-1">
{machine.ipAddresses[0]}
<Menu>
<Menu.Button>
<ChevronDownIcon className="w-4 h-4" />
</Menu.Button>
<Menu.Items>
{machine.ipAddresses.map(ip => (
<Menu.ItemButton
key={ip}
type="button"
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full',
)}
onPress={async () => {
await navigator.clipboard.writeText(ip)
toast('Copied IP address to clipboard')
}}
>
{ip}
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
))}
{magic
? (
<Menu.ItemButton
type="button"
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full break-keep',
)}
onPress={async () => {
const ip = `${machine.givenName}.${prefix}`
await navigator.clipboard.writeText(ip)
toast('Copied hostname to clipboard')
}}
>
{machine.givenName}
.
{prefix}
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
)
: undefined}
</Menu.Items>
</Menu>
</div>
</td>
<td className="py-2">
<span className={cn(
'flex items-center gap-x-1 text-sm',
'text-gray-500 dark:text-gray-400',
)}
>
<StatusCircle
isOnline={machine.online && !expired}
className="w-4 h-4"
/>
<p>
{machine.online && !expired
? 'Connected'
: new Date(
machine.lastSeen,
).toLocaleString()}
</p>
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions
machine={machine}
routes={routes}
users={users}
magic={magic}
/>
</td>
</tr>
)
}
-114
View File
@@ -1,114 +0,0 @@
import { KebabHorizontalIcon } from '@primer/octicons-react'
import { ReactNode, useState } from 'react'
import MenuComponent from '~/components/Menu'
import { Machine, Route, User } from '~/types'
import { cn } from '~/utils/cn'
import Delete from './dialogs/delete'
import Expire from './dialogs/expire'
import Move from './dialogs/move'
import Rename from './dialogs/rename'
import Routes from './dialogs/routes'
import Tags from './dialogs/tags'
interface MenuProps {
machine: Machine
routes: Route[]
users: User[]
magic?: string
buttonChild?: ReactNode
}
export default function Menu({ machine, routes, magic, users, buttonChild }: MenuProps) {
const renameState = useState(false)
const expireState = useState(false)
const removeState = useState(false)
const routesState = useState(false)
const moveState = useState(false)
const tagsState = useState(false)
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()
return (
<>
<Rename
machine={machine}
state={renameState}
magic={magic}
/>
<Delete
machine={machine}
state={removeState}
/>
{expired
? undefined
: (
<Expire
machine={machine}
state={expireState}
/>
)}
<Routes
machine={machine}
routes={routes}
state={routesState}
/>
<Tags
machine={machine}
state={tagsState}
/>
<Move
machine={machine}
state={moveState}
users={users}
magic={magic}
/>
<MenuComponent>
{buttonChild ?? (
<MenuComponent.Button
className={cn(
'flex items-center justify-center',
'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>
)}
<MenuComponent.Items>
<MenuComponent.ItemButton control={renameState}>
Edit machine name
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={routesState}>
Edit route settings
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={tagsState}>
Edit ACL tags
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={moveState}>
Change owner
</MenuComponent.ItemButton>
{expired
? undefined
: (
<MenuComponent.ItemButton control={expireState}>
Expire
</MenuComponent.ItemButton>
)}
<MenuComponent.ItemButton
className="text-red-500 dark:text-red-400"
control={removeState}
>
Remove
</MenuComponent.ItemButton>
</MenuComponent.Items>
</MenuComponent>
</>
)
}
-135
View File
@@ -1,135 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { InfoIcon } from '@primer/octicons-react'
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
import Code from '~/components/Code'
import Link from '~/components/Link'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import type { Machine, Route, User } from '~/types'
import { menuAction } from './action'
import MachineRow from './machine'
import NewMachine from './dialogs/new'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const [machines, routes, users] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
])
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
}
if (config.dns.use_username_in_magic_dns) {
magic = `[user].${magic}`
}
}
return {
nodes: machines.nodes,
routes: routes.routes,
users: users.users,
magic,
server: context.headscaleUrl,
publicServer: context.headscalePublicUrl,
}
}
export async function action({ request }: ActionFunctionArgs) {
return menuAction(request)
}
export default function Page() {
useLiveData({ interval: 3000 })
const data = useLoaderData<typeof loader>()
return (
<>
<div className="flex justify-between items-center mb-8">
<div className="flex flex-col w-2/3">
<h1 className='text-2xl font-medium mb-4'>Machines</h1>
<p className='text-gray-700 dark:text-gray-300'>
Manage the devices connected to your Tailnet.
{' '}
<Link
to="https://tailscale.com/kb/1372/manage-devices"
name="Tailscale Manage Devices Documentation"
>
Learn more
</Link>
</p>
</div>
<NewMachine
server={data.publicServer ?? data.server}
users={data.users}
/>
</div>
<table className="table-auto w-full rounded-lg">
<thead className="text-gray-500 dark:text-gray-400">
<tr className="text-left uppercase text-xs font-bold px-0.5">
<th className="pb-2">Name</th>
<th className="pb-2">
<div className="flex items-center gap-x-1">
Addresses
{data.magic
? (
<TooltipTrigger delay={0}>
<Button>
<InfoIcon className="w-4 h-4" />
</Button>
<Tooltip className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-zinc-800',
'border border-gray-200 dark:border-zinc-700',
)}
>
Since MagicDNS is enabled, you can access devices
based on their name and also at
{' '}
<Code>
[name].
{data.magic}
</Code>
</Tooltip>
</TooltipTrigger>
)
: undefined}
</div>
</th>
<th className="pb-2">Last Seen</th>
</tr>
</thead>
<tbody className={cn(
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border-t border-zinc-200 dark:border-zinc-700',
)}
>
{data.nodes.map(machine => (
<MachineRow
key={machine.id}
machine={machine}
routes={data.routes.filter(route => route.node.id === machine.id)}
users={data.users}
magic={data.magic}
/>
))}
</tbody>
</table>
</>
)
}
@@ -1,45 +0,0 @@
import Link from '~/components/Link'
import Button from '~/components/Button'
import { Link as RemixLink } from '@remix-run/react'
import { ArrowRightIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
export default function Page() {
return (
<div className="flex flex-col gap-8 max-w-screen-lg">
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Settings</h1>
<p className="text-gray-700 dark:text-gray-300">
The settings page is still under construction.
As I'm able to add more features, I'll be adding them here.
If you require any features, feel free to open an issue on
the GitHub repository.
</p>
</div>
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
<p className="text-gray-700 dark:text-gray-300">
Headscale fully supports pre-authentication keys in order to
easily add devices to your Tailnet.
To learn more about using pre-authentication keys, visit the
{' '}
<Link
to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation"
>
Tailscale documentation
</Link>
</p>
</div>
<RemixLink to="/settings/auth-keys">
<span className={cn(
'text-lg font-medium',
'text-gray-700 dark:text-gray-300',
)}>
Manage Auth Keys
<ArrowRightIcon className="w-5 h-5 ml-2" />
</span>
</RemixLink>
</div>
)
}
@@ -1,69 +0,0 @@
import { useFetcher } from '@remix-run/react'
import type { PreAuthKey } from '~/types'
import { cn } from '~/utils/cn'
import Dialog from '~/components/Dialog'
import Spinner from '~/components/Spinner'
interface Props {
authKey: PreAuthKey
}
export default function ExpireKey({ authKey }: Props) {
const fetcher = useFetcher()
return (
<Dialog>
<Dialog.Button className="my-4">
Expire Key
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Expire auth key?
</Dialog.Title>
<fetcher.Form method="DELETE" onSubmit={e => {
fetcher.submit(e.currentTarget)
close()
}}>
<input type="hidden" name="user" value={authKey.user} />
<input type="hidden" name="key" value={authKey.key} />
<Dialog.Text>
Expiring this authentication key will immediately
prevent it from being used to authenticate new devices.
{' '}
This action cannot be undone.
</Dialog.Text>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
className={cn(
'bg-red-500 hover:border-red-700',
'dark:bg-red-600 dark:hover:border-red-700',
'pressed:bg-red-600 hover:bg-red-600',
'text-white dark:text-white',
)}
onPress={close}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Expire
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -1,223 +0,0 @@
import { LoaderFunctionArgs, ActionFunctionArgs, json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { useLiveData } from '~/utils/useLiveData'
import { getSession } from '~/utils/sessions'
import { Link as RemixLink } from '@remix-run/react'
import { PreAuthKey, User } from '~/types'
import { pull, post } from '~/utils/headscale'
import { loadContext } from '~/utils/config/headplane'
import { useState } from 'react'
import Link from '~/components/Link'
import TableList from '~/components/TableList'
import Select from '~/components/Select'
import Switch from '~/components/Switch'
import AddPreAuthKey from './dialogs/new'
import AuthKeyRow from './key'
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401,
})
}
const data = await request.formData()
// Expiring a pre-auth key
if (request.method === 'DELETE') {
const key = data.get('key')
const user = data.get('user')
if (!key || !user) {
return json({ message: 'Missing parameters' }, {
status: 400,
})
}
await post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey/expire',
session.get('hsApiKey')!,
{
user: user,
key: key,
}
)
return json({ message: 'Pre-auth key expired' })
}
// Creating a new pre-auth key
if (request.method === 'POST') {
const user = data.get('user')
const expiry = data.get('expiry')
const reusable = data.get('reusable')
const ephemeral = data.get('ephemeral')
if (!user || !expiry || !reusable || !ephemeral) {
return json({ message: 'Missing parameters' }, {
status: 400,
})
}
// Extract the first "word" from expiry which is the day number
// Calculate the date X days from now using the day number
const day = Number(expiry.toString().split(' ')[0])
const date = new Date()
date.setDate(date.getDate() + day)
const key = await post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
session.get('hsApiKey')!,
{
user: user,
ephemeral: ephemeral === 'on',
reusable: reusable === 'on',
expiration: date.toISOString(),
aclTags: [], // TODO
}
)
return json({ message: 'Pre-auth key created', key })
}
}
export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext()
const session = await getSession(request.headers.get('Cookie'))
const users = await pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!)
const preAuthKeys = await Promise.all(users.users.map(user => {
const qp = new URLSearchParams()
qp.set('user', user.name)
return pull<{ preAuthKeys: PreAuthKey[] }>(
`v1/preauthkey?${qp.toString()}`,
session.get('hsApiKey')!
)
}))
return {
keys: preAuthKeys.flatMap(keys => keys.preAuthKeys),
users: users.users,
server: context.headscalePublicUrl ?? context.headscaleUrl,
}
}
export default function Page() {
const { keys, users, server } = useLoaderData<typeof loader>()
const [user, setUser] = useState('All')
const [status, setStatus] = useState('Active')
useLiveData({ interval: 3000 })
const filteredKeys = keys.filter(key => {
if (user !== 'All' && key.user !== user) {
return false
}
if (status !== 'All') {
const now = new Date()
const expiry = new Date(key.expiration)
if (status === 'Active') {
return !(expiry < now) && !key.used
}
if (status === 'Used/Expired') {
return key.used || expiry < now
}
if (status === 'Reusable') {
return key.reusable
}
if (status === 'Ephemeral') {
return key.ephemeral
}
}
return true
})
return (
<div className='flex flex-col w-2/3'>
<p className="mb-8 text-md">
<RemixLink
to="/settings"
className="font-medium"
>
Settings
</RemixLink>
<span className="mx-2">
/
</span>
{' '}
Pre-Auth Keys
</p>
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
<p className="text-gray-700 dark:text-gray-300 mb-4">
Headscale fully supports pre-authentication keys in order to
easily add devices to your Tailnet.
To learn more about using pre-authentication keys, visit the
{' '}
<Link
to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation"
>
Tailscale documentation
</Link>
</p>
<AddPreAuthKey users={users} />
<div className="flex justify-between gap-4 mt-4">
<div className="w-full">
<p className="text-sm text-gray-500 dark:text-gray-300">
Filter by user
</p>
<Select
label="Filter by User"
placeholder="Select a user"
state={[user, setUser]}
>
<Select.Item id="All">All</Select.Item>
{users.map(user => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
</div>
<div className="w-full">
<p className="text-sm text-gray-500 dark:text-gray-300">
Filter by status
</p>
<Select
label="Filter by status"
placeholder="Select a status"
state={[status, setStatus]}
>
<Select.Item id="All">All</Select.Item>
<Select.Item id="Active">Active</Select.Item>
<Select.Item id="Used/Expired">Used/Expired</Select.Item>
<Select.Item id="Reusable">Reusable</Select.Item>
<Select.Item id="Ephemeral">Ephemeral</Select.Item>
</Select>
</div>
</div>
<TableList className="mt-4">
{filteredKeys.length === 0 ? (
<TableList.Item>
<p className="opacity-50 text-sm mx-auto">
No pre-auth keys
</p>
</TableList.Item>
) : filteredKeys.map(key => (
<TableList.Item key={key.id}>
<AuthKeyRow authKey={key} server={server} />
</TableList.Item>
))}
</TableList>
</div>
)
}
-75
View File
@@ -1,75 +0,0 @@
import { type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Outlet, useLoaderData, useNavigation } from '@remix-run/react'
import { ProgressBar } from 'react-aria-components'
import { ErrorPopup } from '~/components/Error'
import Header from '~/components/Header'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { HeadscaleError, pull } from '~/utils/headscale'
import { destroySession, getSession } from '~/utils/sessions'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return redirect('/login')
}
try {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await pull('v1/apikey', session.get('hsApiKey')!)
} catch (error) {
if (error instanceof HeadscaleError) {
// Safest to just redirect to login if we can't pull
return redirect('/login', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await destroySession(session),
},
})
}
// Otherwise propagate to boundary
throw error
}
const context = await loadContext()
return {
config: context.config,
user: session.get('user'),
}
}
export default function Layout() {
const data = useLoaderData<typeof loader>()
const nav = useNavigation()
return (
<>
<ProgressBar
aria-label="Loading..."
>
<div
className={cn(
'fixed top-0 left-0 z-50 w-1/2 h-1',
'bg-blue-500 dark:bg-blue-400 opacity-0',
nav.state === 'loading' && 'animate-loading opacity-100',
)}
/>
</ProgressBar>
<Header data={data} />
<main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet />
</main>
</>
)
}
export function ErrorBoundary() {
return (
<>
<Header />
<ErrorPopup type="embedded" />
</>
)
}
-81
View File
@@ -1,81 +0,0 @@
import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
interface Props {
magic?: string
}
export default function Add({ magic }: Props) {
const [username, setUsername] = useState('')
const submit = useSubmit()
return (
<Dialog>
<Dialog.Button>
Add a new user
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Add a new user
</Dialog.Title>
<Dialog.Text className="mb-8">
Enter a username to create a new user.
{' '}
{magic
? (
<>
Since Magic DNS is enabled, machines will be
accessible via
{' '}
<Code>
[machine].
.
{magic}
</Code>
.
</>
)
: undefined}
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget)
}}
>
<input type="hidden" name="_method" value="create" />
<TextField
label="Username"
placeholder="my-new-user"
name="username"
state={[username, setUsername]}
className="my-2"
/>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
Create
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
-73
View File
@@ -1,73 +0,0 @@
import { XIcon } from '@primer/octicons-react'
import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react'
import Button from '~/components/Button'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
interface Props {
username: string
}
export default function Remove({ username }: Props) {
const submit = useSubmit()
const dialogState = useState(false)
return (
<>
<Button
variant="light"
control={dialogState}
className="rounded-full p-0 w-8 h-8"
>
<XIcon className="w-4 h-4" />
</Button>
<Dialog control={dialogState}>
<Dialog.Panel control={dialogState}>
{close => (
<>
<Dialog.Title>
Delete
{' '}
{username}
?
</Dialog.Title>
<Dialog.Text className="mb-8">
Are you sure you want to delete
{' '}
{username}
?
{' '}
A deleted user cannot be recovered.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget)
}}
>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="username" value={username} />
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
Delete
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
</>
)
}
-352
View File
@@ -1,352 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
import { PersonIcon } from '@primer/octicons-react'
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
import { useActionData, useLoaderData, useSubmit } from '@remix-run/react'
import { useEffect, useState } from 'react'
import { ClientOnly } from 'remix-utils/client-only'
import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster'
import { Machine, User } from '~/types'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { del, post, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import Auth from './auth'
import Oidc from './oidc'
import Remove from './remove'
import Rename from './rename'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const [machines, apiUsers] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
])
const users = apiUsers.users.map(user => ({
...user,
machines: machines.nodes.filter(machine => machine.user.id === user.id),
}))
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
}
}
return {
oidc: context.oidc,
magic,
users,
}
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401,
})
}
const data = await request.formData()
if (!data.has('_method')) {
return json({ message: 'No method provided' }, {
status: 400,
})
}
const method = String(data.get('_method'))
switch (method) {
case 'create': {
if (!data.has('username')) {
return json({ message: 'No name provided' }, {
status: 400,
})
}
const username = String(data.get('username'))
await post('v1/user', session.get('hsApiKey')!, {
name: username,
})
return json({ message: `User ${username} created` })
}
case 'delete': {
if (!data.has('username')) {
return json({ message: 'No name provided' }, {
status: 400,
})
}
const username = String(data.get('username'))
await del(`v1/user/${username}`, session.get('hsApiKey')!)
return json({ message: `User ${username} deleted` })
}
case 'rename': {
if (!data.has('old') || !data.has('new')) {
return json({ message: 'No old or new name provided' }, {
status: 400,
})
}
const old = String(data.get('old'))
const newName = String(data.get('new'))
await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!)
return json({ message: `User ${old} renamed to ${newName}` })
}
case 'move': {
if (!data.has('id') || !data.has('to') || !data.has('name')) {
return json({ message: 'No ID or destination provided' }, {
status: 400,
})
}
const id = String(data.get('id'))
const to = String(data.get('to'))
const name = String(data.get('name'))
try {
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
return json({ message: `Moved ${name} to ${to}` })
} catch {
return json({ message: `Failed to move ${name} to ${to}` }, {
status: 500,
})
}
}
default: {
return json({ message: 'Invalid method' }, {
status: 400,
})
}
}
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const [users, setUsers] = useState(data.users)
const actionData = useActionData<typeof action>()
useLiveData({ interval: 3000 })
useEffect(() => {
if (!actionData) {
return
}
toast(actionData.message)
if (actionData.message.startsWith('Failed')) {
setUsers(data.users)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionData])
useEffect(() => {
setUsers(data.users)
}, [data.users])
return (
<>
<h1 className="text-2xl font-medium mb-1.5">
Users
</h1>
<p className="mb-8 text-md">
Manage the users in your network and their permissions.
Tip: You can drag machines between users to change ownership.
</p>
{data.oidc
? (
<Oidc
oidc={data.oidc}
magic={data.magic}
/>
)
: (
<Auth magic={data.magic} />
)}
<ClientOnly fallback={
<Users users={users} />
}
>
{() => (
<InteractiveUsers
users={users}
setUsers={setUsers}
magic={data.magic}
/>
)}
</ClientOnly>
</>
)
}
type UserMachine = User & { machines: Machine[] }
interface UserProps {
users: UserMachine[]
setUsers?: (users: UserMachine[]) => void
magic?: string
}
function Users({ users, magic }: UserProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map(user => (
<UserCard
key={user.id}
user={user}
magic={magic}
/>
))}
</div>
)
}
function InteractiveUsers({ users, setUsers, magic }: UserProps) {
const submit = useSubmit()
return (
<DndContext onDragEnd={(event) => {
const { over, active } = event
if (!over) {
return
}
// Update the UI optimistically
const newUsers = new Array<UserMachine>()
const reference = active.data as DataRef<Machine>
if (!reference.current) {
return
}
// Ignore if the user is unchanged
if (reference.current.user.name === over.id) {
return
}
for (const user of users) {
newUsers.push({
...user,
machines: over.id === user.name
? [...user.machines, reference.current]
: user.machines.filter(m => m.id !== active.id),
})
}
setUsers?.(newUsers)
const data = new FormData()
data.append('_method', 'move')
data.append('id', active.id.toString())
data.append('to', over.id.toString())
data.append('name', reference.current.givenName)
submit(data, {
method: 'POST',
})
}}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map(user => (
<UserCard
key={user.id}
user={user}
magic={magic}
/>
))}
</div>
</DndContext>
)
}
function MachineChip({ machine }: { readonly machine: Machine }) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: machine.id,
data: machine,
})
return (
<div
ref={setNodeRef}
className={cn(
'flex items-center w-full gap-2 py-1',
'hover:bg-ui-100 dark:hover:bg-ui-800 rounded-lg',
)}
style={{
transform: transform
? `translate3d(${transform.x.toString()}px, ${transform.y.toString()}px, 0)`
: undefined,
}}
{...listeners}
{...attributes}
>
<StatusCircle isOnline={machine.online} className="w-4 h-4 px-1 w-fit" />
<Attribute
name={machine.givenName}
link={`machines/${machine.id}`}
value={machine.ipAddresses[0]}
/>
</div>
)
}
interface CardProps {
user: UserMachine
magic?: string
}
function UserCard({ user, magic }: CardProps) {
const { isOver, setNodeRef } = useDroppable({
id: user.name,
})
return (
<div ref={setNodeRef}>
<Card
variant="flat"
className={cn(
'max-w-full w-full overflow-visible h-full',
isOver ? 'bg-ui-100 dark:bg-ui-800' : '',
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<PersonIcon className="w-6 h-6" />
<span className="text-lg font-mono">
{user.name}
</span>
</div>
<div className="flex items-center gap-2">
<Rename username={user.name} magic={magic} />
{user.machines.length === 0
? (
<Remove username={user.name} />
)
: undefined}
</div>
</div>
<div className="mt-4">
{user.machines.map(machine => (
<MachineChip key={machine.id} machine={machine} />
))}
</div>
</Card>
</div>
)
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from '@remix-run/node'
export function loader() {
return redirect('/machines')
}
+136
View File
@@ -0,0 +1,136 @@
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>
);
}
+25
View File
@@ -0,0 +1,25 @@
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,8 +1,8 @@
import Spinner from '~/components/Spinner' import Spinner from '~/components/Spinner';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
interface Props { interface Props {
readonly acl: string readonly acl: string;
} }
export default function Fallback({ acl }: Props) { export default function Fallback({ acl }: Props) {
@@ -20,5 +20,5 @@ export default function Fallback({ acl }: Props) {
value={acl} value={acl}
/> />
</div> </div>
) );
} }
@@ -0,0 +1,39 @@
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>
);
}
@@ -1,32 +1,37 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ import {
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react' BeakerIcon,
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node' EyeIcon,
import { useLoaderData, useRevalidator } from '@remix-run/react' IssueDraftIcon,
import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher' PencilIcon,
import { useEffect, useState, useMemo } from 'react' } from '@primer/octicons-react';
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components' import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { setTimeout } from 'node:timers/promises' import { useLoaderData, useRevalidator, useFetcher } from 'react-router';
//import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher';
import { useEffect, useState, useMemo } from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
import { setTimeout } from 'node:timers/promises';
import Button from '~/components/Button' import Button from '~/components/Button';
import Code from '~/components/Code' import Code from '~/components/Code';
import Link from '~/components/Link' import Link from '~/components/Link';
import Notice from '~/components/Notice' import Notice from '~/components/Notice';
import Spinner from '~/components/Spinner' import 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 { 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.server';
import log from '~/utils/log' import { send } from '~/utils/res';
import log from '~/utils/log';
import { Editor, Differ } from './cm.client' import { Editor, Differ } from './components/cm.client';
import { Unavailable } from './unavailable' import { Unavailable } from './components/unavailable';
import { ErrorView } from './error' import { ErrorView } from './components/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 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 // The 2 ACL policy modes are either the database one or file one
// //
@@ -51,11 +56,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
// We can do damage control by checking for write access and if we are not // 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 // 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. // that the policy is at the very-least readonly or not available.
const context = await loadContext() const context = await loadContext();
let modeGuess = 'database' // Assume database mode let modeGuess = 'database'; // Assume database mode
if (context.config.read) { if (context.config.read) {
const config = await loadConfig() const config = await loadConfig();
modeGuess = config.policy?.mode ?? 'database' modeGuess = config.policy?.mode ?? 'database';
} }
// Attempt to load the policy, for both the frontend and for checking // Attempt to load the policy, for both the frontend and for checking
@@ -64,23 +69,19 @@ export async function loader({ request }: LoaderFunctionArgs) {
const { policy } = await pull<{ policy: string }>( const { policy } = await pull<{ policy: string }>(
'v1/policy', 'v1/policy',
session.get('hsApiKey')!, session.get('hsApiKey')!,
) );
let write = false // On file mode we already know it's readonly let write = false; // On file mode we already know it's readonly
if (modeGuess === 'database' && policy.length > 0) { if (modeGuess === 'database' && policy.length > 0) {
try { try {
await put('v1/policy', session.get('hsApiKey')!, { await put('v1/policy', session.get('hsApiKey')!, {
policy: policy, policy: policy,
}) });
write = true write = true;
} catch (error) { } catch (error) {
write = false write = false;
log.debug( log.debug('APIC', 'Failed to write to ACL policy with error %s', error);
'APIC',
'Failed to write to ACL policy with error %s',
error
)
} }
} }
@@ -88,8 +89,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
read: true, read: true,
write, write,
mode: modeGuess, mode: modeGuess,
policy policy,
} };
} catch { } catch {
// If we are explicit on file mode then this is the end of the road // If we are explicit on file mode then this is the end of the road
if (modeGuess === 'file') { if (modeGuess === 'file') {
@@ -97,8 +98,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
read: false, read: false,
write: false, write: false,
mode: modeGuess, mode: modeGuess,
policy: null policy: null,
} };
} }
// Assume that we have write access otherwise? // Assume that we have write access otherwise?
@@ -108,201 +109,192 @@ export async function loader({ request }: LoaderFunctionArgs) {
read: true, read: true,
write: true, write: true,
mode: modeGuess, mode: modeGuess,
policy: null 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, error: null }, { return send({ success: false, error: null }, 401);
status: 401,
})
} }
try { try {
const { acl } = await request.json() as { acl: string } const { acl } = (await request.json()) as { acl: string };
const { policy } = await put<{ policy: string }>( const { policy } = await put<{ policy: string }>(
'v1/policy', 'v1/policy',
session.get('hsApiKey')!, session.get('hsApiKey')!,
{ {
policy: acl, policy: acl,
} },
) );
return json({ success: true, policy, error: null }) return { success: true, policy, error: null };
} catch (error) { } catch (error) {
log.debug('APIC', 'Failed to update ACL policy with error %s', error) 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 // @ts-ignore: Shut UP we know it's a string most of the time
const text = JSON.parse(error.message) const text = JSON.parse(error.message);
return json({ success: false, error: text.message }, { return send(
status: error instanceof HeadscaleError ? error.status : 500, { success: false, error: text.message },
}) {
status: error instanceof HeadscaleError ? error.status : 500,
},
);
} }
return json({ success: true, error: null }) return { success: true, error: null };
} }
export default function Page() { export default function Page() {
const data = useLoaderData<typeof loader>() const data = useLoaderData<typeof loader>();
const fetcher = useDebounceFetcher<typeof action>() const fetcher = useFetcher<typeof action>();
const revalidator = useRevalidator() const revalidator = useRevalidator();
const [acl, setAcl] = useState(data.policy ?? '') const [acl, setAcl] = useState(data.policy ?? '');
const [toasted, setToasted] = useState(false) const [toasted, setToasted] = useState(false);
useEffect(() => { useEffect(() => {
if (!fetcher.data || toasted) { if (!fetcher.data || toasted) {
return return;
} }
// @ts-ignore: useDebounceFetcher is not typed correctly // @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 {
toast('Failed to update tailnet ACL policy') toast('Failed to update tailnet ACL policy');
} }
setToasted(true) setToasted(true);
if (revalidator.state === 'idle') { if (revalidator.state === 'idle') {
revalidator.revalidate() revalidator.revalidate();
} }
}, [fetcher.data, toasted, data.policy]) }, [fetcher.data, toasted, data.policy]);
// The state for if the save and discard buttons should be disabled // The state for if the save and discard buttons should be disabled
// is pretty complicated to calculate and varies on different states. // is pretty complicated to calculate and varies on different states.
const disabled = useMemo(() => { const disabled = useMemo(() => {
if (!data.read || !data.write) { if (!data.read || !data.write) {
return true return true;
} }
// First check our fetcher states // First check our fetcher states
if (fetcher.state === 'loading') { if (fetcher.state === 'loading') {
return true return true;
} }
if (revalidator.state === 'loading') { if (revalidator.state === 'loading') {
return true return true;
} }
// If we have a failed fetcher state allow the user to try again // If we have a failed fetcher state allow the user to try again
// @ts-ignore: useDebounceFetcher is not typed correctly // @ts-ignore: useDebounceFetcher is not typed correctly
if (fetcher.data?.success === false) { if (fetcher.data?.success === false) {
return false return false;
} }
return data.policy === acl return data.policy === acl;
}, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl]) }, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl]);
return ( return (
<div> <div>
{data.read && !data.write {data.read && !data.write ? (
? ( <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 but you
The ACL policy is read-only. You can view the current policy cannot make changes to it.
but you cannot make changes to it. <br />
<br /> To resolve this, you need to set the ACL policy mode to database in
To resolve this, you need to set the ACL policy mode to your Headscale configuration.
database in your Headscale configuration. </Notice>
</Notice> </div>
</div> ) : undefined}
) : undefined} <h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<h1 className="text-2xl font-medium mb-4">
Access Control List (ACL)
</h1>
<p className="mb-4 max-w-prose"> <p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your network. The ACL file is used to define the access control rules for your
You can find more information about the ACL file in the network. You can find more information about the ACL file in the{' '}
{' '}
<Link <Link
to="https://tailscale.com/kb/1018/acls" to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation" name="Tailscale ACL documentation"
> >
Tailscale ACL guide Tailscale ACL guide
</Link> </Link>{' '}
{' '} and the{' '}
and the
{' '}
<Link <Link
to="https://headscale.net/acls" to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation" name="Headscale ACL documentation"
> >
Headscale docs Headscale docs
</Link> </Link>
. .
</p> </p>
{ {
// @ts-ignore: useDebounceFetcher is not typed correctly // @ts-ignore: useDebounceFetcher is not typed correctly
fetcher.data?.success === false fetcher.data?.success === false ? (
? (
// @ts-ignore: useDebounceFetcher is not typed correctly // @ts-ignore: useDebounceFetcher is not typed correctly
<ErrorView message={fetcher.data.error} /> <ErrorView message={fetcher.data.error} />
) : undefined} ) : undefined
}
{data.read ? ( {data.read ? (
<> <>
<Tabs> <Tabs>
<TabList className={cn( <TabList
'flex border-t border-gray-200 dark:border-gray-700', className={cn(
'w-fit rounded-t-lg overflow-hidden', 'flex border-t border-gray-200 dark:border-gray-700',
'text-gray-400 dark:text-gray-500', 'w-fit rounded-t-lg overflow-hidden',
)} 'text-gray-400 dark:text-gray-500',
)}
> >
<Tab <Tab
id="edit" id="edit"
className={({ isSelected }) => cn( className={({ isSelected }) =>
'px-4 py-2 rounded-tl-lg', cn(
'focus:outline-none flex items-center gap-2', 'px-4 py-2 rounded-tl-lg',
'border-x border-gray-200 dark:border-gray-700', 'focus:outline-none flex items-center gap-2',
isSelected ? 'text-gray-900 dark:text-gray-100' : '', 'border-x border-gray-200 dark:border-gray-700',
)} isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)
}
> >
<PencilIcon className="w-5 h-5" /> <PencilIcon className="w-5 h-5" />
<p>Edit file</p> <p>Edit file</p>
</Tab> </Tab>
<Tab <Tab
id="diff" id="diff"
className={({ isSelected }) => cn( className={({ isSelected }) =>
'px-4 py-2', cn(
'focus:outline-none flex items-center gap-2', 'px-4 py-2',
'border-x border-gray-200 dark:border-gray-700', 'focus:outline-none flex items-center gap-2',
isSelected ? 'text-gray-900 dark:text-gray-100' : '', 'border-x border-gray-200 dark:border-gray-700',
)} isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)
}
> >
<EyeIcon className="w-5 h-5" /> <EyeIcon className="w-5 h-5" />
<p>Preview changes</p> <p>Preview changes</p>
</Tab> </Tab>
<Tab <Tab
id="preview" id="preview"
className={({ isSelected }) => cn( className={({ isSelected }) =>
'px-4 py-2 rounded-tr-lg', cn(
'focus:outline-none flex items-center gap-2', 'px-4 py-2 rounded-tr-lg',
'border-x border-gray-200 dark:border-gray-700', 'focus:outline-none flex items-center gap-2',
isSelected ? 'text-gray-900 dark:text-gray-100' : '', 'border-x border-gray-200 dark:border-gray-700',
)} isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)
}
> >
<BeakerIcon className="w-5 h-5" /> <BeakerIcon className="w-5 h-5" />
<p>Preview rules</p> <p>Preview rules</p>
</Tab> </Tab>
</TabList> </TabList>
<TabPanel id="edit"> <TabPanel id="edit">
<Editor <Editor isDisabled={!data.write} value={acl} onChange={setAcl} />
isDisabled={!data.write}
value={acl}
onChange={setAcl}
/>
</TabPanel> </TabPanel>
<TabPanel id="diff"> <TabPanel id="diff">
<Differ <Differ left={data?.policy ?? ''} right={acl} />
left={data?.policy ?? ''}
right={acl}
/>
</TabPanel> </TabPanel>
<TabPanel id="preview"> <TabPanel id="preview">
<div <div
@@ -314,8 +306,9 @@ export default function Page() {
> >
<IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" /> <IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" />
<p className="w-1/2 text-center mt-4"> <p className="w-1/2 text-center mt-4">
The Preview rules is very much still a work in progress. The Preview rules is very much still a work in progress. It is
It is a bit complicated to implement right now but hopefully it will be available soon. a bit complicated to implement right now but hopefully it will
be available soon.
</p> </p>
</div> </div>
</TabPanel> </TabPanel>
@@ -325,32 +318,35 @@ export default function Page() {
className="mr-2" className="mr-2"
isDisabled={disabled} isDisabled={disabled}
onPress={() => { onPress={() => {
setToasted(false) setToasted(false);
fetcher.submit({ fetcher.submit(
acl, {
}, { acl,
method: 'PATCH', },
encType: 'application/json', {
}) method: 'PATCH',
encType: 'application/json',
},
);
}} }}
> >
{fetcher.state === 'idle' {fetcher.state === 'idle' ? undefined : (
? undefined <Spinner className="w-3 h-3" />
: ( )}
<Spinner className="w-3 h-3" />
)}
Save Save
</Button> </Button>
<Button <Button
isDisabled={disabled} isDisabled={disabled}
onPress={() => { onPress={() => {
setAcl(data?.policy ?? '') setAcl(data?.policy ?? '');
}} }}
> >
Discard Changes Discard Changes
</Button> </Button>
</> </>
) : <Unavailable mode={data.mode as "database" | "file"} />} ) : (
<Unavailable mode={data.mode as 'database' | 'file'} />
)}
</div> </div>
) );
} }
+142
View File
@@ -0,0 +1,142 @@
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
redirect,
} from 'react-router';
import { Form, useActionData, useLoaderData } from 'react-router';
import { useMemo } from 'react';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import TextField from '~/components/TextField';
import type { Key } from '~/types';
import { loadContext } from '~/utils/config/headplane';
import { pull } from '~/utils/headscale';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
import { commitSession, getSession } from '~/utils/sessions.server';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (session.has('hsApiKey')) {
return redirect('/machines', {
headers: {
'Set-Cookie': await commitSession(session),
},
});
}
const context = await loadContext();
// Only set if OIDC is properly enabled anyways
if (context.oidc?.disableKeyLogin) {
return redirect('/oidc/start');
}
return {
oidc: context.oidc?.issuer,
apiKey: !context.oidc?.disableKeyLogin,
};
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const oidcStart = formData.get('oidc-start');
const session = await getSession(request.headers.get('Cookie'));
if (oidcStart) {
const context = await loadContext();
if (!context.oidc) {
throw new Error('An invalid OIDC configuration was provided');
}
return redirect('/oidc/start');
}
const apiKey = String(formData.get('api-key'));
// Test the API key
try {
const apiKeys = await pull<{ apiKeys: Key[] }>('v1/apikey', apiKey);
const key = apiKeys.apiKeys.find((k) => apiKey.startsWith(k.prefix));
if (!key) {
throw new Error('Invalid API key');
}
const expiry = new Date(key.expiration);
const expiresIn = expiry.getTime() - Date.now();
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24);
session.set('hsApiKey', apiKey);
session.set('user', {
subject: 'unknown-non-oauth',
name: key.prefix,
email: `${expiresDays.toString()} days`,
});
return redirect('/machines', {
headers: {
'Set-Cookie': await commitSession(session, {
maxAge: expiresIn,
}),
},
});
} catch {
return {
error: 'Invalid API key',
};
}
}
export default function Page() {
const data = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const showOr = useMemo(() => data.oidc && data.apiKey, [data]);
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0 rounded-2xl">
<Card.Title>Welcome to Headplane</Card.Title>
{data.apiKey ? (
<Form method="post">
<Card.Text className="mb-8 text-sm">
Enter an API key to authenticate with Headplane. You can generate
one by running <Code>headscale apikeys create</Code> in your
terminal.
</Card.Text>
{actionData?.error ? (
<p className="text-red-500 text-sm mb-2">{actionData.error}</p>
) : undefined}
<TextField
isRequired
label="API Key"
name="api-key"
placeholder="API Key"
type="password"
/>
<Button className="w-full mt-2.5" variant="heavy" type="submit">
Login
</Button>
</Form>
) : undefined}
{showOr ? (
<div className="flex items-center gap-x-1.5 py-1">
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
<span className="text-gray-500 text-sm">or</span>
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
</div>
) : undefined}
{data.oidc ? (
<Form method="POST">
<input type="hidden" name="oidc-start" value="true" />
<Button className="w-full" variant="heavy" type="submit">
Login with SSO
</Button>
</Form>
) : undefined}
</Card>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { type ActionFunctionArgs, redirect } from 'react-router';
import { destroySession, getSession } from '~/utils/sessions.server';
export async function loader() {
return redirect('/machines');
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
return redirect('/login', {
headers: {
'Set-Cookie': await destroySession(session),
},
});
}
+83
View File
@@ -0,0 +1,83 @@
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { loadContext } from '~/utils/config/headplane';
import { getSession, commitSession } from '~/utils/sessions.server';
import { finishAuthFlow, getRedirectUri, formatError } from '~/utils/oidc';
import { send } from '~/utils/res';
export async function loader({ request }: LoaderFunctionArgs) {
// Check if we have 0 query parameters
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect('/machines');
}
const session = await getSession(request.headers.get('Cookie'));
if (session.has('hsApiKey')) {
return redirect('/machines')
}
// This is a hold-over from the old code
// TODO: Rewrite checkOIDC in the context loader
const { oidc } = await loadContext();
if (!oidc) {
throw new Error('An invalid OIDC configuration was provided');
}
const oidcConfig = {
issuer: oidc.issuer,
clientId: oidc.client,
clientSecret: oidc.secret,
redirectUri: oidc.redirectUri,
tokenEndpointAuthMethod: oidc.method,
}
const codeVerifier = session.get('oidc_code_verif');
const state = session.get('oidc_state');
const nonce = session.get('oidc_nonce');
const redirectUri = session.get('oidc_redirect_uri');
if (!codeVerifier || !state || !nonce) {
return send({ error: 'Missing OIDC state' }, { status: 400 });
}
// Reconstruct the redirect URI using the query parameters
// and the one we saved in the session
const flowRedirectUri = new URL(redirectUri);
flowRedirectUri.search = url.search;
const flowOptions = {
redirect_uri: flowRedirectUri.toString(),
codeVerifier,
state,
nonce: nonce === '<none>' ? undefined : nonce,
}
try {
const user = await finishAuthFlow(oidcConfig, flowOptions);
session.set('user', user);
session.unset('oidc_code_verif');
session.unset('oidc_state');
session.unset('oidc_nonce');
// TODO: This is breaking, to stop the "over-generation" of API
// keys because they are currently non-deletable in the headscale
// database. Look at this in the future once we have a solution
// or we have permissioned API keys.
session.set('hsApiKey', oidc.rootKey);
return redirect('/machines', {
headers: {
'Set-Cookie': await commitSession(session),
},
});
} catch (error) {
return new Response(
JSON.stringify(formatError(error)),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { type LoaderFunctionArgs, data, redirect } from 'react-router';
import { commitSession, getSession } from '~/utils/sessions.server';
import { send } from '~/utils/res';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
import { loadContext } from '~/utils/config/headplane';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (session.has('hsApiKey')) {
return redirect('/machines')
}
// This is a hold-over from the old code
// TODO: Rewrite checkOIDC in the context loader
const { oidc } = await loadContext();
if (!oidc) {
throw new Error('An invalid OIDC configuration was provided');
}
const oidcConfig = {
issuer: oidc.issuer,
clientId: oidc.client,
clientSecret: oidc.secret,
redirectUri: oidc.redirectUri,
tokenEndpointAuthMethod: oidc.method,
}
const redirectUri = oidcConfig.redirectUri ?? getRedirectUri(request);
const data = await beginAuthFlow(oidcConfig, redirectUri);
session.set('oidc_code_verif', data.codeVerifier);
session.set('oidc_state', data.state);
session.set('oidc_nonce', data.nonce);
session.set('oidc_redirect_uri', redirectUri)
return redirect(data.url, {
status: 302,
headers: {
'Set-Cookie': await commitSession(session),
},
});
}
@@ -1,34 +1,29 @@
import { useSubmit } from '@remix-run/react' import { useSubmit } from 'react-router';
import { Button } from 'react-aria-components' import { Button } from 'react-aria-components';
import Code from '~/components/Code' import Code from '~/components/Code';
import Link from '~/components/Link' import Link from '~/components/Link';
import TableList from '~/components/TableList' import TableList from '~/components/TableList';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import AddDNS from './dialogs/dns' import AddDNS from '../dialogs/dns';
interface Props { interface Props {
records: { name: string, type: 'A', value: string }[] records: { name: string; type: 'A'; value: string }[];
isDisabled: boolean isDisabled: boolean;
} }
export default function DNS({ records, isDisabled }: Props) { export default function DNS({ records, isDisabled }: Props) {
const submit = useSubmit() const submit = useSubmit();
return ( return (
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">DNS Records</h1> <h1 className="text-2xl font-medium mb-4">DNS Records</h1>
<p className="text-gray-700 dark:text-gray-300"> <p className="text-gray-700 dark:text-gray-300">
Headscale supports adding custom DNS records to your Tailnet. Headscale supports adding custom DNS records to your Tailnet. As of now,
As of now, only only <Code>A</Code> records are supported.{' '}
{' '}
<Code>A</Code>
{' '}
records are supported.
{' '}
<Link <Link
to="https://headscale.net/dns-records/" to="https://headscale.net/stable/ref/dns"
name="Headscale DNS Records documentation" name="Headscale DNS Records documentation"
> >
Learn More Learn More
@@ -36,15 +31,12 @@ export default function DNS({ records, isDisabled }: Props) {
</p> </p>
<div className="mt-4"> <div className="mt-4">
<TableList className="mb-8"> <TableList className="mb-8">
{records.length === 0 {records.length === 0 ? (
? ( <TableList.Item>
<TableList.Item> <p className="opacity-50 text-sm mx-auto">No DNS records found</p>
<p className="opacity-50 text-sm mx-auto"> </TableList.Item>
No DNS records found ) : (
</p> records.map((record, index) => (
</TableList.Item>
)
: records.map((record, index) => (
<TableList.Item key={index}> <TableList.Item key={index}>
<div className="flex gap-24"> <div className="flex gap-24">
<div className="flex gap-2"> <div className="flex gap-2">
@@ -62,27 +54,28 @@ export default function DNS({ records, isDisabled }: Props) {
)} )}
isDisabled={isDisabled} isDisabled={isDisabled}
onPress={() => { onPress={() => {
submit({ submit(
'dns.extra_records': records {
.filter((_, i) => i !== index), 'dns.extra_records': records.filter(
}, { (_, i) => i !== index,
method: 'PATCH', ),
encType: 'application/json', },
}) {
method: 'PATCH',
encType: 'application/json',
},
);
}} }}
> >
Remove Remove
</Button> </Button>
</TableList.Item> </TableList.Item>
))} ))
)}
</TableList> </TableList>
{isDisabled {isDisabled ? undefined : <AddDNS records={records} />}
? undefined
: (
<AddDNS records={records} />
)}
</div> </div>
</div> </div>
) );
} }
@@ -1,88 +1,88 @@
/* eslint-disable unicorn/no-keyword-prefix */ /* eslint-disable unicorn/no-keyword-prefix */
import { import { closestCorners, DndContext, DragOverlay } from '@dnd-kit/core';
closestCorners,
DndContext,
DragOverlay
} from '@dnd-kit/core'
import { import {
restrictToParentElement, restrictToParentElement,
restrictToVerticalAxis restrictToVerticalAxis,
} from '@dnd-kit/modifiers' } from '@dnd-kit/modifiers';
import { import {
arrayMove, arrayMove,
SortableContext, SortableContext,
useSortable, useSortable,
verticalListSortingStrategy verticalListSortingStrategy,
} from '@dnd-kit/sortable' } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities';
import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react' import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react';
import { type FetcherWithComponents, useFetcher } from '@remix-run/react' import { type FetcherWithComponents, useFetcher } from 'react-router';
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react';
import { Button, Input } from 'react-aria-components' import { Button, Input } from 'react-aria-components';
import Spinner from '~/components/Spinner' import Spinner from '~/components/Spinner';
import TableList from '~/components/TableList' import TableList from '~/components/TableList';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
type Properties = { type Properties = {
readonly baseDomain?: string; readonly baseDomain?: string;
readonly searchDomains: string[]; readonly searchDomains: string[];
// eslint-disable-next-line react/boolean-prop-naming // eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean; readonly disabled?: boolean;
} };
export default function Domains({ baseDomain, searchDomains, disabled }: Properties) { export default function Domains({
baseDomain,
searchDomains,
disabled,
}: Properties) {
// eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types // eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types
const [activeId, setActiveId] = useState<number | string | null>(null) const [activeId, setActiveId] = useState<number | string | null>(null);
const [localDomains, setLocalDomains] = useState(searchDomains) const [localDomains, setLocalDomains] = useState(searchDomains);
const [newDomain, setNewDomain] = useState('') const [newDomain, setNewDomain] = useState('');
const fetcher = useFetcher() const fetcher = useFetcher();
useEffect(() => { useEffect(() => {
setLocalDomains(searchDomains) setLocalDomains(searchDomains);
}, [searchDomains]) }, [searchDomains]);
return ( return (
<div className='flex flex-col w-2/3'> <div className="flex flex-col w-2/3">
<h1 className='text-2xl font-medium mb-4'>Search Domains</h1> <h1 className="text-2xl font-medium mb-4">Search Domains</h1>
<p className='text-gray-700 dark:text-gray-300 mb-2'> <p className="text-gray-700 dark:text-gray-300 mb-2">
Set custom DNS search domains for your Tailnet. Set custom DNS search domains for your Tailnet. When using Magic DNS,
When using Magic DNS, your tailnet domain is used as the first search domain. your tailnet domain is used as the first search domain.
</p> </p>
<DndContext <DndContext
modifiers={[restrictToVerticalAxis, restrictToParentElement]} modifiers={[restrictToVerticalAxis, restrictToParentElement]}
collisionDetection={closestCorners} collisionDetection={closestCorners}
onDragStart={event => { onDragStart={(event) => {
setActiveId(event.active.id) setActiveId(event.active.id);
}} }}
onDragEnd={event => { onDragEnd={(event) => {
// eslint-disable-next-line unicorn/no-null // eslint-disable-next-line unicorn/no-null
setActiveId(null) setActiveId(null);
const { active, over } = event const { active, over } = event;
if (!over) { if (!over) {
return return;
} }
const activeItem = localDomains[active.id as number - 1] const activeItem = localDomains[(active.id as number) - 1];
const overItem = localDomains[over.id as number - 1] const overItem = localDomains[(over.id as number) - 1];
if (!activeItem || !overItem) { if (!activeItem || !overItem) {
return return;
} }
const oldIndex = localDomains.indexOf(activeItem) const oldIndex = localDomains.indexOf(activeItem);
const newIndex = localDomains.indexOf(overItem) const newIndex = localDomains.indexOf(overItem);
if (oldIndex !== newIndex) { if (oldIndex !== newIndex) {
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex)) setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
} }
}} }}
> >
<TableList> <TableList>
{baseDomain ? ( {baseDomain ? (
<TableList.Item key='magic-dns-sd'> <TableList.Item key="magic-dns-sd">
<p className='font-mono text-sm'>{baseDomain}</p> <p className="font-mono text-sm">{baseDomain}</p>
<LockIcon className='h-4 w-4'/> <LockIcon className="h-4 w-4" />
</TableList.Item> </TableList.Item>
) : undefined} ) : undefined}
<SortableContext <SortableContext
@@ -101,25 +101,27 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
/> />
))} ))}
<DragOverlay adjustScale> <DragOverlay adjustScale>
{activeId ? <Domain {activeId ? (
isDrag <Domain
domain={localDomains[activeId as number - 1]} isDrag
localDomains={localDomains} domain={localDomains[(activeId as number) - 1]}
id={activeId as number - 1} localDomains={localDomains}
disabled={disabled} id={(activeId as number) - 1}
fetcher={fetcher} disabled={disabled}
/> : undefined} fetcher={fetcher}
/>
) : undefined}
</DragOverlay> </DragOverlay>
</SortableContext> </SortableContext>
{disabled ? undefined : ( {disabled ? undefined : (
<TableList.Item key='add-sd'> <TableList.Item key="add-sd">
<Input <Input
type='text' type="text"
className='font-mono text-sm bg-transparent w-full mr-2' className="font-mono text-sm bg-transparent w-full mr-2"
placeholder='Search Domain' placeholder="Search Domain"
value={newDomain} value={newDomain}
onChange={event => { onChange={(event) => {
setNewDomain(event.target.value) setNewDomain(event.target.value);
}} }}
/> />
{fetcher.state === 'idle' ? ( {fetcher.state === 'idle' ? (
@@ -128,32 +130,35 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
'text-sm font-semibold', 'text-sm font-semibold',
'text-blue-600 dark:text-blue-400', 'text-blue-600 dark:text-blue-400',
'hover:text-blue-700 dark:hover:text-blue-300', 'hover:text-blue-700 dark:hover:text-blue-300',
newDomain.length === 0 && 'opacity-50 cursor-not-allowed' newDomain.length === 0 && 'opacity-50 cursor-not-allowed',
)} )}
isDisabled={newDomain.length === 0} isDisabled={newDomain.length === 0}
onPress={() => { onPress={() => {
fetcher.submit({ fetcher.submit(
// eslint-disable-next-line @typescript-eslint/naming-convention {
'dns.search_domains': [...localDomains, newDomain] // eslint-disable-next-line @typescript-eslint/naming-convention
}, { 'dns.search_domains': [...localDomains, newDomain],
method: 'PATCH', },
encType: 'application/json' {
}) method: 'PATCH',
encType: 'application/json',
},
);
setNewDomain('') setNewDomain('');
}} }}
> >
Add Add
</Button> </Button>
) : ( ) : (
<Spinner className='w-3 h-3 mr-0'/> <Spinner className="w-3 h-3 mr-0" />
)} )}
</TableList.Item> </TableList.Item>
)} )}
</TableList> </TableList>
</DndContext> </DndContext>
</div> </div>
) );
} }
type DomainProperties = { type DomainProperties = {
@@ -164,17 +169,24 @@ type DomainProperties = {
// eslint-disable-next-line react/boolean-prop-naming // eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean; readonly disabled?: boolean;
readonly fetcher: FetcherWithComponents<unknown>; readonly fetcher: FetcherWithComponents<unknown>;
} };
function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainProperties) { function Domain({
domain,
id,
localDomains,
isDrag,
disabled,
fetcher,
}: DomainProperties) {
const { const {
attributes, attributes,
listeners, listeners,
setNodeRef, setNodeRef,
transform, transform,
transition, transition,
isDragging isDragging,
} = useSortable({ id }) } = useSortable({ id });
// TODO: Figure out why TableList.Item breaks dndkit // TODO: Figure out why TableList.Item breaks dndkit
return ( return (
@@ -184,17 +196,19 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
'flex items-center justify-between px-3 py-2', 'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0 dark:border-zinc-800', 'border-b border-gray-200 last:border-b-0 dark:border-zinc-800',
isDragging ? 'text-gray-400' : '', isDragging ? 'text-gray-400' : '',
isDrag ? 'outline outline-1 outline-gray-500 bg-gray-200 dark:bg-zinc-800' : '' isDrag
? 'outline outline-1 outline-gray-500 bg-gray-200 dark:bg-zinc-800'
: '',
)} )}
style={{ style={{
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition transition,
}} }}
> >
<p className='font-mono text-sm flex items-center gap-4'> <p className="font-mono text-sm flex items-center gap-4">
{disabled ? undefined : ( {disabled ? undefined : (
<ThreeBarsIcon <ThreeBarsIcon
className='h-4 w-4 text-gray-400 focus:outline-none' className="h-4 w-4 text-gray-400 focus:outline-none"
{...attributes} {...attributes}
{...listeners} {...listeners}
/> />
@@ -207,21 +221,26 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
'text-sm', 'text-sm',
'text-red-600 dark:text-red-400', 'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300', 'hover:text-red-700 dark:hover:text-red-300',
disabled && 'opacity-50 cursor-not-allowed' disabled && 'opacity-50 cursor-not-allowed',
)} )}
isDisabled={disabled} isDisabled={disabled}
onPress={() => { onPress={() => {
fetcher.submit({ fetcher.submit(
'dns.search_domains': localDomains.filter((_, index) => index !== id - 1) {
}, { 'dns.search_domains': localDomains.filter(
method: 'PATCH', (_, index) => index !== id - 1,
encType: 'application/json' ),
}) },
{
method: 'PATCH',
encType: 'application/json',
},
);
}} }}
> >
Remove Remove
</Button> </Button>
)} )}
</div> </div>
) );
} }
+59
View File
@@ -0,0 +1,59 @@
import { useFetcher } from 'react-router';
import Dialog from '~/components/Dialog';
import Spinner from '~/components/Spinner';
type Properties = {
readonly isEnabled: boolean;
readonly disabled?: boolean;
};
export default function Modal({ isEnabled, disabled }: Properties) {
const fetcher = useFetcher();
return (
<Dialog>
<Dialog.Button isDisabled={disabled}>
{fetcher.state === 'idle' ? undefined : <Spinner className="w-3 h-3" />}
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Button>
<Dialog.Panel>
{(close) => (
<>
<Dialog.Title>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Title>
<Dialog.Text>
Devices will no longer be accessible via your tailnet domain. The
search domain will also be disabled.
</Dialog.Text>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={() => {
fetcher.submit(
{
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns.magic_dns': !isEnabled,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
close();
}}
>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { useSubmit } from 'react-router';
import { useState } from 'react';
import { Button } from 'react-aria-components';
import Link from '~/components/Link';
import Switch from '~/components/Switch';
import TableList from '~/components/TableList';
import { cn } from '~/utils/cn';
import AddNameserver from '../dialogs/nameserver';
interface Props {
nameservers: Record<string, string[]>;
isDisabled: boolean;
}
export default function Nameservers({ nameservers, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p className="text-gray-700 dark:text-gray-300">
Set the nameservers used by devices on the Tailnet to resolve DNS
queries.{' '}
<Link
to="https://tailscale.com/kb/1054/dns"
name="Tailscale DNS Documentation"
>
Learn more
</Link>
</p>
<div className="mt-4">
{Object.keys(nameservers).map((key) => (
<NameserverList
key={key}
isGlobal={key === 'global'}
isDisabled={isDisabled}
nameservers={nameservers}
name={key}
/>
))}
{isDisabled ? undefined : <AddNameserver nameservers={nameservers} />}
</div>
</div>
);
}
interface ListProps {
isGlobal: boolean;
isDisabled: boolean;
nameservers: Record<string, string[]>;
name: string;
}
function NameserverList({
isGlobal,
isDisabled,
nameservers,
name,
}: ListProps) {
const submit = useSubmit();
const list = isGlobal ? nameservers['global'] : nameservers[name];
if (list.length === 0) {
return null;
}
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h2 className="text-md font-medium opacity-80">
{isGlobal ? 'Global Nameservers' : name}
</h2>
</div>
<TableList>
{list.length > 0
? list.map((ns, index) => (
// eslint-disable-next-line react/no-array-index-key
<TableList.Item key={index}>
<p className="font-mono text-sm">{ns}</p>
<Button
className={cn(
'text-sm',
'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300',
isDisabled && 'opacity-50 cursor-not-allowed',
)}
isDisabled={isDisabled}
onPress={() => {
if (isGlobal) {
submit(
{
'dns.nameservers.global': list.filter(
(_, i) => i !== index,
),
},
{
method: 'PATCH',
encType: 'application/json',
},
);
} else {
submit(
{
'dns.nameservers.split': {
...nameservers,
[name]: list.filter((_, i) => i !== index),
},
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}
}}
>
Remove
</Button>
</TableList.Item>
))
: undefined}
</TableList>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useFetcher } from 'react-router';
import { useState } from 'react';
import { Input } from 'react-aria-components';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Spinner from '~/components/Spinner';
import TextField from '~/components/TextField';
import { cn } from '~/utils/cn';
type Properties = {
readonly name: string;
readonly disabled?: boolean;
};
export default function Modal({ name, disabled }: Properties) {
const [newName, setNewName] = useState(name);
const fetcher = useFetcher();
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Tailnet Name</h1>
<p className="text-gray-700 dark:text-gray-300">
This is the base domain name of your Tailnet. Devices are accessible at{' '}
<Code>[device].{name}</Code> when Magic DNS is enabled.
</p>
<Input
readOnly
className={cn(
'block px-2.5 py-1.5 w-1/2 rounded-lg my-4',
'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300 text-sm',
'outline-none',
)}
type="text"
value={name}
onFocus={(event) => {
event.target.select();
}}
/>
<Dialog>
<Dialog.Button isDisabled={disabled}>
{fetcher.state === 'idle' ? undefined : (
<Spinner className="w-3 h-3" />
)}
Rename Tailnet
</Dialog.Button>
<Dialog.Panel>
{(close) => (
<>
<Dialog.Title>Rename Tailnet</Dialog.Title>
<Dialog.Text>
Keep in mind that changing this can lead to all sorts of
unexpected behavior and may break existing devices in your
tailnet.
</Dialog.Text>
<TextField
label="Tailnet name"
placeholder="ts.net"
state={[newName, setNewName]}
className="my-2"
/>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={() => {
fetcher.submit(
{
'dns.base_domain': newName,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
close();
}}
>
Rename
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { Form, useSubmit } from 'react-router';
import { useMemo, useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
import { cn } from '~/utils/cn';
interface Props {
records: { name: string; type: 'A'; value: string }[];
}
export default function AddDNS({ records }: Props) {
const submit = useSubmit();
const [name, setName] = useState('');
const [ip, setIp] = useState('');
const isDuplicate = useMemo(() => {
if (name.length === 0 || ip.length === 0) return false;
const lookup = records.find((record) => record.name === name);
if (!lookup) return false;
return lookup.value === ip;
}, [records, name, ip]);
return (
<Dialog>
<Dialog.Button>Add DNS record</Dialog.Button>
<Dialog.Panel>
{(close) => (
<>
<Dialog.Title>Add DNS record</Dialog.Title>
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
event.preventDefault();
if (!name || !ip) return;
setName('');
setIp('');
submit(
{
'dns.extra_records': [
...records,
{
name,
type: 'A',
value: ip,
},
],
},
{
method: 'PATCH',
encType: 'application/json',
},
);
close();
}}
>
<TextField
label="Domain"
placeholder="test.example.com"
name="domain"
state={[name, setName]}
className={cn('mt-2', isDuplicate && 'outline outline-red-500')}
/>
<TextField
label="IP Address"
placeholder="101.101.101.101"
name="ip"
state={[ip, setIp]}
className={cn(isDuplicate && 'outline outline-red-500')}
/>
{isDuplicate ? (
<p className="text-sm opacity-50">
A record with the domain name <Code>{name}</Code> and IP
address <Code>{ip}</Code> already exists.
</p>
) : undefined}
<div className="mt-6 flex justify-end gap-2 mt-8">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
isDisabled={isDuplicate}
>
Add
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { RepoForkedIcon } from '@primer/octicons-react';
import { Form, useSubmit } from 'react-router';
import { useState } from 'react';
import Dialog from '~/components/Dialog';
import Switch from '~/components/Switch';
import TextField from '~/components/TextField';
import Tooltip from '~/components/Tooltip';
import { cn } from '~/utils/cn';
interface Props {
nameservers: Record<string, string[]>;
}
export default function AddNameserver({ nameservers }: Props) {
const submit = useSubmit();
const [split, setSplit] = useState(false);
const [ns, setNs] = useState('');
const [domain, setDomain] = useState('');
return (
<Dialog>
<Dialog.Button>Add nameserver</Dialog.Button>
<Dialog.Panel>
{(close) => (
<>
<Dialog.Title>Add nameserver</Dialog.Title>
<Dialog.Text className="font-semibold">Nameserver</Dialog.Text>
<Dialog.Text className="text-sm">
Use this IPv4 or IPv6 address to resolve names.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
event.preventDefault();
if (!ns) return;
if (split) {
const splitNs: Record<string, string[]> = {};
for (const [key, value] of Object.entries(nameservers)) {
if (key === 'global') continue;
splitNs[key] = value;
}
if (Object.keys(splitNs).includes(domain)) {
splitNs[domain].push(ns);
} else {
splitNs[domain] = [ns];
}
submit(
{
'dns.nameservers.split': splitNs,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
} else {
const globalNs = nameservers.global;
globalNs.push(ns);
submit(
{
'dns.nameservers.global': globalNs,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}
setNs('');
setDomain('');
setSplit(false);
close();
}}
>
<TextField
label="DNS Server"
placeholder="1.2.3.4"
name="ns"
state={[ns, setNs]}
className="mt-2 mb-8"
/>
<div className="flex items-center justify-between">
<div className="block">
<div className="inline-flex items-center gap-2">
<Dialog.Text className="font-semibold">
Restrict to domain
</Dialog.Text>
<Tooltip>
<Tooltip.Button
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',
)}
>
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
Split DNS
</Tooltip.Button>
<Tooltip.Body>
Only clients that support split DNS (Tailscale v1.8 or
later for most platforms) will use this nameserver.
Older clients will ignore it.
</Tooltip.Body>
</Tooltip>
</div>
<Dialog.Text className="text-sm">
This nameserver will only be used for some domains.
</Dialog.Text>
</div>
<Switch
label="Split DNS"
defaultSelected={split}
onChange={() => {
setSplit(!split);
}}
/>
</div>
{split ? (
<>
<Dialog.Text className="font-semibold mt-8">
Domain
</Dialog.Text>
<TextField
label="Domain"
placeholder="example.com"
name="domain"
state={[domain, setDomain]}
className="my-2"
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries matching this
suffix should use the nameserver.
</Dialog.Text>
</>
) : undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action variant="confirm" onPress={close}>
Add
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
@@ -1,27 +1,27 @@
import { type ActionFunctionArgs } from '@remix-run/node' import type { ActionFunctionArgs } from 'react-router';
import { json, useLoaderData } from '@remix-run/react' import { data, useLoaderData } from 'react-router';
import Code from '~/components/Code' import Code from '~/components/Code';
import Notice from '~/components/Notice' import Notice from '~/components/Notice';
import { loadContext } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane';
import { loadConfig, patchConfig } from '~/utils/config/headscale' import { loadConfig, patchConfig } from '~/utils/config/headscale';
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions.server';
import { useLiveData } from '~/utils/useLiveData' import { useLiveData } from '~/utils/useLiveData';
import DNS from './dns' import DNS from './components/dns';
import Domains from './domains' import Domains from './components/domains';
import MagicModal from './magic' import MagicModal from './components/magic';
import Nameservers from './nameservers' import Nameservers from './components/nameservers';
import RenameModal from './rename' import RenameModal from './components/rename';
// We do not want to expose every config value // We do not want to expose every config value
export async function loader() { export async function loader() {
const context = await loadContext() const context = await loadContext();
if (!context.config.read) { if (!context.config.read) {
throw new Error('No configuration is available') throw new Error('No configuration is available');
} }
const config = await loadConfig() const config = await loadConfig();
const dns = { const dns = {
prefixes: config.prefixes, prefixes: config.prefixes,
magicDns: config.dns.magic_dns, magicDns: config.dns.magic_dns,
@@ -32,69 +32,58 @@ export async function loader() {
splitDns: config.dns.nameservers.split, splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains, searchDomains: config.dns.search_domains,
extraRecords: config.dns.extra_records, extraRecords: config.dns.extra_records,
} };
return { return {
...dns, ...dns,
...context, ...context,
} };
} }
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 data({ success: false }, { status: 401 });
status: 401,
})
} }
const context = await loadContext() const context = await loadContext();
if (!context.config.write) { if (!context.config.write) {
return json({ success: false }, { return data({ success: false }, { status: 403 });
status: 403,
})
} }
const data = await request.json() as Record<string, unknown> const patch = (await request.json()) as Record<string, unknown>;
await patchConfig(data) await patchConfig(patch);
if (context.integration?.onConfigChange) { if (context.integration?.onConfigChange) {
await context.integration.onConfigChange(context.integration.context) await context.integration.onConfigChange(context.integration.context);
} }
return json({ success: true }) return data({ success: true });
} }
export default function Page() { export default function Page() {
useLiveData({ interval: 5000 }) useLiveData({ interval: 5000 });
const data = useLoaderData<typeof loader>() const data = useLoaderData<typeof loader>();
const allNs: Record<string, string[]> = {} const allNs: Record<string, string[]> = {};
for (const key of Object.keys(data.splitDns)) { for (const key of Object.keys(data.splitDns)) {
allNs[key] = data.splitDns[key] allNs[key] = data.splitDns[key];
} }
allNs.global = data.nameservers allNs.global = data.nameservers;
return ( return (
<div className="flex flex-col gap-16 max-w-screen-lg"> <div className="flex flex-col gap-16 max-w-screen-lg">
{data.config.write {data.config.write ? undefined : (
? undefined <Notice>
: ( The Headscale configuration is read-only. You cannot make changes to
<Notice> the configuration
The Headscale configuration is read-only. You cannot make changes to the configuration </Notice>
</Notice> )}
)}
<RenameModal name={data.baseDomain} disabled={!data.config.write} /> <RenameModal name={data.baseDomain} disabled={!data.config.write} />
<Nameservers <Nameservers nameservers={allNs} isDisabled={!data.config.write} />
nameservers={allNs}
isDisabled={!data.config.write}
/>
<DNS <DNS records={data.extraRecords} isDisabled={!data.config.write} />
records={data.extraRecords}
isDisabled={!data.config.write}
/>
<Domains <Domains
baseDomain={data.magicDns ? data.baseDomain : undefined} baseDomain={data.magicDns ? data.baseDomain : undefined}
@@ -105,18 +94,16 @@ export default function Page() {
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1> <h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
<p className="text-gray-700 dark:text-gray-300 mb-4"> <p className="text-gray-700 dark:text-gray-300 mb-4">
Automatically register domain names for each device Automatically register domain names for each device on the tailnet.
on the tailnet. Devices will be accessible at Devices will be accessible at{' '}
{' '}
<Code> <Code>
[device]. [device].
{data.baseDomain} {data.baseDomain}
</Code> </Code>{' '}
{' '}
when Magic DNS is enabled. when Magic DNS is enabled.
</p> </p>
<MagicModal isEnabled={data.magicDns} disabled={!data.config.write} /> <MagicModal isEnabled={data.magicDns} disabled={!data.config.write} />
</div> </div>
</div> </div>
) );
} }
-29
View File
@@ -1,29 +0,0 @@
import { loadContext } from '~/utils/config/headplane'
import { HeadscaleError, pull } from '~/utils/headscale'
import log from '~/utils/log'
export async function loader() {
const context = await loadContext()
try {
// Doesn't matter, we just need a 401
await pull('v1/', 'wrongkey')
} catch (e) {
if (!(e instanceof HeadscaleError)) {
log.debug('Healthz', 'Headscale is not reachable')
return new Response('Headscale is not reachable', {
status: 500,
headers: {
'Content-Type': 'text/plain',
},
})
}
}
return new Response('OK', {
status: 200,
headers: {
'Content-Type': 'text/plain',
},
})
}
-163
View File
@@ -1,163 +0,0 @@
import { type ActionFunctionArgs, json, type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Form, useActionData, useLoaderData } from '@remix-run/react'
import { useMemo } from 'react'
import Button from '~/components/Button'
import Card from '~/components/Card'
import Code from '~/components/Code'
import TextField from '~/components/TextField'
import { type Key } from '~/types'
import { loadContext } from '~/utils/config/headplane'
import { pull } from '~/utils/headscale'
import { startOidc } from '~/utils/oidc'
import { commitSession, getSession } from '~/utils/sessions'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (session.has('hsApiKey')) {
return redirect('/machines', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session),
},
})
}
const context = await loadContext()
// Only set if OIDC is properly enabled anyways
if (context.oidc?.disableKeyLogin) {
return startOidc(context.oidc, request)
}
return {
oidc: context.oidc?.issuer,
apiKey: !context.oidc?.disableKeyLogin,
}
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const oidcStart = formData.get('oidc-start')
if (oidcStart) {
const context = await loadContext()
if (!context.oidc) {
throw new Error('An invalid OIDC configuration was provided')
}
// We know it exists here because this action only happens on OIDC
return startOidc(context.oidc, request)
}
const apiKey = String(formData.get('api-key'))
const session = await getSession(request.headers.get('Cookie'))
// Test the API key
try {
const apiKeys = await pull<{ apiKeys: Key[] }>('v1/apikey', apiKey)
const key = apiKeys.apiKeys.find(k => apiKey.startsWith(k.prefix))
if (!key) {
throw new Error('Invalid API key')
}
const expiry = new Date(key.expiration)
const expiresIn = expiry.getTime() - Date.now()
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24)
session.set('hsApiKey', apiKey)
session.set('user', {
name: key.prefix,
email: `${expiresDays.toString()} days`,
})
return redirect('/machines', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session, {
maxAge: expiresIn,
}),
},
})
} catch {
return json({
error: 'Invalid API key',
})
}
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const actionData = useActionData<typeof action>()
const showOr = useMemo(() => data.oidc && data.apiKey, [data])
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0 rounded-2xl">
<Card.Title>
Welcome to Headplane
</Card.Title>
{data.apiKey
? (
<Form method="post">
<Card.Text className="mb-8 text-sm">
Enter an API key to authenticate with Headplane. You can generate
one by running
{' '}
<Code>
headscale apikeys create
</Code>
{' '}
in your terminal.
</Card.Text>
{actionData?.error
? (
<p className="text-red-500 text-sm mb-2">{actionData.error}</p>
)
: undefined}
<TextField
isRequired
label="API Key"
name="api-key"
placeholder="API Key"
type="password"
/>
<Button
className="w-full mt-2.5"
variant="heavy"
type="submit"
>
Login
</Button>
</Form>
)
: undefined}
{showOr
? (
<div className="flex items-center gap-x-1.5 py-1">
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
<span className="text-gray-500 text-sm">or</span>
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
</div>
)
: undefined}
{data.oidc
? (
<Form method="POST">
<input type="hidden" name="oidc-start" value="true" />
<Button
className="w-full"
variant="heavy"
type="submit"
>
Login with SSO
</Button>
</Form>
)
: undefined}
</Card>
</div>
)
}
-15
View File
@@ -1,15 +0,0 @@
import { type ActionFunctionArgs, redirect } from '@remix-run/node'
import { destroySession, getSession } from '~/utils/sessions'
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const returnTo = new URL(request.url).pathname
return redirect(`/login?returnTo=${returnTo}`, {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await destroySession(session)
}
})
}
+208
View File
@@ -0,0 +1,208 @@
import type { ActionFunctionArgs } from 'react-router';
import { del, post } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { send } from '~/utils/res';
import log from '~/utils/log';
export async function menuAction(request: ActionFunctionArgs['request']) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return send(
{ message: 'Unauthorized' },
{
status: 401,
},
);
}
const data = await request.formData();
if (!data.has('_method') || !data.has('id')) {
return send(
{ message: 'No method or ID provided' },
{
status: 400,
},
);
}
const id = String(data.get('id'));
const method = String(data.get('_method'));
switch (method) {
case 'delete': {
await del(`v1/node/${id}`, session.get('hsApiKey')!);
return { message: 'Machine removed' };
}
case 'expire': {
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!);
return { message: 'Machine expired' };
}
case 'rename': {
if (!data.has('name')) {
return send(
{ message: 'No name provided' },
{
status: 400,
},
);
}
const name = String(data.get('name'));
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!);
return { message: 'Machine renamed' };
}
case 'routes': {
if (!data.has('route') || !data.has('enabled')) {
return send(
{ message: 'No route or enabled provided' },
{
status: 400,
},
);
}
const route = String(data.get('route'));
const enabled = data.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!);
return { message: 'Route updated' };
}
case 'exit-node': {
if (!data.has('routes') || !data.has('enabled')) {
return send(
{ 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 { message: 'Exit node updated' };
}
case 'move': {
if (!data.has('to')) {
return send(
{ message: 'No destination provided' },
{
status: 400,
},
);
}
const to = String(data.get('to'));
try {
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!);
return { message: `Moved node ${id} to ${to}` };
} catch {
return send(
{ message: `Failed to move node ${id} to ${to}` },
{
status: 500,
},
);
}
}
case 'tags': {
const tags =
data
.get('tags')
?.toString()
.split(',')
.filter((tag) => tag.trim() !== '') ?? [];
try {
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
tags,
});
return { message: 'Tags updated' };
} catch (error) {
log.debug('APIC', 'Failed to update tags: %s', error);
return send(
{ message: 'Failed to update tags' },
{
status: 500,
},
);
}
}
case 'register': {
const key = data.get('mkey')?.toString();
const user = data.get('user')?.toString();
if (!key) {
return send(
{ message: 'No machine key provided' },
{
status: 400,
},
);
}
if (!user) {
return send(
{ message: 'No user provided' },
{
status: 400,
},
);
}
try {
const qp = new URLSearchParams();
qp.append('user', user);
qp.append('key', key);
const url = `v1/node/register?${qp.toString()}`;
await post(url, session.get('hsApiKey')!, {
user,
key,
});
return {
success: true,
message: 'Machine registered',
};
} catch {
return send(
{
success: false,
message: 'Failed to register machine',
},
{
status: 500,
},
);
}
}
default: {
return send(
{ message: 'Invalid method' },
{
status: 400,
},
);
}
}
}
+202
View File
@@ -0,0 +1,202 @@
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react';
import { Link } from 'react-router';
import { useMemo } from 'react';
import Menu from '~/components/Menu';
import StatusCircle from '~/components/StatusCircle';
import { toast } from '~/components/Toaster';
import type { Machine, Route, User, HostInfo } from '~/types';
import { cn } from '~/utils/cn';
import * as hinfo from '~/utils/host-info';
import MenuOptions from './menu';
interface Props {
machine: Machine;
routes: Route[];
users: User[];
magic?: string;
stats?: HostInfo;
}
export default function MachineRow({ machine, routes, magic, users, stats }: Props) {
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');
}
const prefix = magic?.startsWith('[user]')
? magic.replace('[user]', machine.user.name)
: magic;
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce<{
exit: Route[];
subnetApproved: Route[];
subnet: Route[];
}>(
(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 (
<tr
key={machine.id}
className="hover:bg-zinc-100 dark:hover:bg-zinc-800 group"
>
<td className="pl-0.5 py-2">
<Link to={`/machines/${machine.id}`} className="group/link h-full">
<p
className={cn(
'font-semibold leading-snug',
'group-hover/link:text-blue-600',
'group-hover/link:dark:text-blue-400',
)}
>
{machine.givenName}
</p>
<p className="text-sm text-gray-500 dark:text-gray-300 font-mono">
{machine.name}
</p>
<div className="flex gap-1 mt-1">
{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>
</Link>
</td>
<td className="py-2">
<div className="flex items-center gap-x-1">
{machine.ipAddresses[0]}
<Menu>
<Menu.Button>
<ChevronDownIcon className="w-4 h-4" />
</Menu.Button>
<Menu.Items>
{machine.ipAddresses.map((ip) => (
<Menu.ItemButton
key={ip}
type="button"
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full',
)}
onPress={async () => {
await navigator.clipboard.writeText(ip);
toast('Copied IP address to clipboard');
}}
>
{ip}
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
))}
{magic ? (
<Menu.ItemButton
type="button"
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full break-keep',
)}
onPress={async () => {
const ip = `${machine.givenName}.${prefix}`;
await navigator.clipboard.writeText(ip);
toast('Copied hostname to clipboard');
}}
>
{machine.givenName}.{prefix}
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
) : undefined}
</Menu.Items>
</Menu>
</div>
</td>
{/**
<td className="py-2">
{stats !== undefined ? (
<>
<p className="leading-snug">
{hinfo.getTSVersion(stats)}
</p>
<p className="text-sm text-gray-500 dark:text-gray-300 max-w-48 truncate">
{hinfo.getOSInfo(stats)}
</p>
</>
) : (
<p className="text-sm text-gray-500 dark:text-gray-300">
Unknown
</p>
)}
</td>
**/}
<td className="py-2">
<span
className={cn(
'flex items-center gap-x-1 text-sm',
'text-gray-500 dark:text-gray-400',
)}
>
<StatusCircle
isOnline={machine.online && !expired}
className="w-4 h-4"
/>
<p>
{machine.online && !expired
? 'Connected'
: new Date(machine.lastSeen).toLocaleString()}
</p>
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions
machine={machine}
routes={routes}
users={users}
magic={magic}
/>
</td>
</tr>
);
}
+93
View File
@@ -0,0 +1,93 @@
import { KebabHorizontalIcon } from '@primer/octicons-react';
import { type ReactNode, useState } from 'react';
import MenuComponent from '~/components/Menu';
import type { Machine, Route, User } from '~/types';
import { cn } from '~/utils/cn';
import Delete from '../dialogs/delete';
import Expire from '../dialogs/expire';
import Move from '../dialogs/move';
import Rename from '../dialogs/rename';
import Routes from '../dialogs/routes';
import Tags from '../dialogs/tags';
interface MenuProps {
machine: Machine;
routes: Route[];
users: User[];
magic?: string;
buttonChild?: ReactNode;
}
export default function Menu({
machine,
routes,
magic,
users,
buttonChild,
}: MenuProps) {
const renameState = useState(false);
const expireState = useState(false);
const removeState = useState(false);
const routesState = useState(false);
const moveState = useState(false);
const tagsState = useState(false);
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();
return (
<>
<Rename machine={machine} state={renameState} magic={magic} />
<Delete machine={machine} state={removeState} />
{expired ? undefined : <Expire machine={machine} state={expireState} />}
<Routes machine={machine} routes={routes} state={routesState} />
<Tags machine={machine} state={tagsState} />
<Move machine={machine} state={moveState} users={users} magic={magic} />
<MenuComponent>
{buttonChild ?? (
<MenuComponent.Button
className={cn(
'flex items-center justify-center',
'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>
)}
<MenuComponent.Items>
<MenuComponent.ItemButton control={renameState}>
Edit machine name
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={routesState}>
Edit route settings
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={tagsState}>
Edit ACL tags
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={moveState}>
Change owner
</MenuComponent.ItemButton>
{expired ? undefined : (
<MenuComponent.ItemButton control={expireState}>
Expire
</MenuComponent.ItemButton>
)}
<MenuComponent.ItemButton
className="text-red-500 dark:text-red-400"
control={removeState}
>
Remove
</MenuComponent.ItemButton>
</MenuComponent.Items>
</MenuComponent>
</>
);
}
@@ -1,46 +1,39 @@
import { Form, useSubmit } from '@remix-run/react' import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction } from 'react' import type { Dispatch, SetStateAction } from 'react';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import { type Machine } from '~/types' import type { Machine } from '~/types';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
interface DeleteProps { interface DeleteProps {
readonly machine: Machine readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>] readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
} }
export default function Delete({ machine, state }: DeleteProps) { export default function Delete({ machine, state }: DeleteProps) {
const submit = useSubmit() const submit = useSubmit();
return ( return (
<Dialog> <Dialog>
<Dialog.Panel control={state}> <Dialog.Panel control={state}>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Remove {machine.givenName}</Dialog.Title>
Remove
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text> <Dialog.Text>
This machine will be permanently removed from This machine will be permanently removed from your network. To
your network. To re-add it, you will need to re-add it, you will need to reauthenticate to your tailnet from
reauthenticate to your tailnet from the device. the device.
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
onSubmit={(e) => { onSubmit={(e) => {
submit(e.currentTarget) submit(e.currentTarget);
}} }}
> >
<input type="hidden" name="_method" value="delete" /> <input type="hidden" name="_method" value="delete" />
<input type="hidden" name="id" value={machine.id} /> <input type="hidden" name="id" value={machine.id} />
<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" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action
@@ -61,5 +54,5 @@ export default function Delete({ machine, state }: DeleteProps) {
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) );
} }
@@ -1,46 +1,38 @@
import { Form, useSubmit } from '@remix-run/react' import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction } from 'react' import type { Dispatch, SetStateAction } from 'react';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import { type Machine } from '~/types' import type { Machine } from '~/types';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
interface ExpireProps { interface ExpireProps {
readonly machine: Machine readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>] readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
} }
export default function Expire({ machine, state }: ExpireProps) { export default function Expire({ machine, state }: ExpireProps) {
const submit = useSubmit() const submit = useSubmit();
return ( return (
<Dialog> <Dialog>
<Dialog.Panel control={state}> <Dialog.Panel control={state}>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Expire {machine.givenName}</Dialog.Title>
Expire
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text> <Dialog.Text>
This will disconnect the machine from your Tailnet. This will disconnect the machine from your Tailnet. In order to
In order to reconnect, you will need to re-authenticate reconnect, you will need to re-authenticate from the device.
from the device.
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
onSubmit={(e) => { onSubmit={(e) => {
submit(e.currentTarget) submit(e.currentTarget);
}} }}
> >
<input type="hidden" name="_method" value="expire" /> <input type="hidden" name="_method" value="expire" />
<input type="hidden" name="id" value={machine.id} /> <input type="hidden" name="id" value={machine.id} />
<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" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action
@@ -61,5 +53,5 @@ export default function Expire({ machine, state }: ExpireProps) {
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) );
} }
+72
View File
@@ -0,0 +1,72 @@
import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction, useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Select from '~/components/Select';
import type { Machine, User } from '~/types';
interface MoveProps {
readonly machine: Machine;
readonly users: User[];
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
readonly magic?: string;
}
export default function Move({ machine, state, magic, users }: MoveProps) {
const [owner, setOwner] = useState(machine.user.name);
const submit = useSubmit();
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
<Dialog.Text>
The owner of the machine is the user associated with it.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="move" />
<input type="hidden" name="id" value={machine.id} />
<Select
label="Owner"
name="to"
placeholder="Select a user"
state={[owner, setOwner]}
>
{users.map((user) => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
{magic ? (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine is accessible by the hostname{' '}
<Code className="text-sm">
{machine.givenName}.{magic}
</Code>
.
</p>
) : undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action variant="confirm" onPress={close}>
Change owner
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { Form, useFetcher, Link } from 'react-router';
import { Dispatch, SetStateAction, useState, useEffect } from 'react';
import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
import Select from '~/components/Select';
import Menu from '~/components/Menu';
import Spinner from '~/components/Spinner';
import { toast } from '~/components/Toaster';
import { Machine, type User } from '~/types';
export interface NewProps {
server: string;
users: User[];
}
export default function New(data: NewProps) {
const fetcher = useFetcher<{ success?: boolean }>();
const mkeyState = useState(false);
const [mkey, setMkey] = useState('');
const [user, setUser] = useState('');
const [toasted, setToasted] = useState(false);
useEffect(() => {
if (!fetcher.data || toasted) {
return;
}
if (fetcher.data.success) {
toast('Registered new machine');
} else {
toast('Failed to register machine due to an invalid key');
}
setToasted(true);
}, [fetcher.data, toasted, mkey]);
return (
<>
<Dialog>
<Dialog.Panel control={mkeyState}>
{(close) => (
<>
<Dialog.Title>Register Machine Key</Dialog.Title>
<Dialog.Text className="mb-4">
The machine key is given when you run{' '}
<Code isCopyable>
tailscale up --login-server=
{data.server}
</Code>{' '}
on your device.
</Dialog.Text>
<fetcher.Form
method="POST"
onSubmit={(e) => {
fetcher.submit(e.currentTarget);
close();
}}
>
<input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" />
<TextField
label="Machine Key"
placeholder="mkey:ff....."
name="mkey"
state={[mkey, setMkey]}
className="my-2 font-mono"
/>
<Select
label="Owner"
name="user"
placeholder="Select a user"
state={[user, setUser]}
>
{data.users.map((user) => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
isDisabled={
!mkey || !mkey.trim().startsWith('mkey:') || !user
}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className="w-3 h-3" />
)}
Register
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
<Menu>
<Menu.Button
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
'hover:bg-main-800 dark:hover:bg-main-700',
)}
>
Add Device
</Menu.Button>
<Menu.Items>
<Menu.ItemButton control={mkeyState}>
<ServerIcon className="w-4 h-4 mr-2" />
Register Machine Key
</Menu.ItemButton>
<Menu.ItemButton>
<Link to="/settings/auth-keys">
<KeyIcon className="w-4 h-4 mr-2" />
Generate Pre-auth Key
</Link>
</Menu.ItemButton>
</Menu.Items>
</Menu>
</>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction, useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
import type { Machine } from '~/types';
interface RenameProps {
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
readonly magic?: string;
}
export default function Rename({ machine, state, magic }: RenameProps) {
const [name, setName] = useState(machine.givenName);
const submit = useSubmit();
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog.Title>
Edit machine name for {machine.givenName}
</Dialog.Title>
<Dialog.Text>
This name is shown in the admin panel, in Tailscale clients, and
used when generating MagicDNS names.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="rename" />
<input type="hidden" name="id" value={machine.id} />
<TextField
label="Machine name"
placeholder="Machine name"
name="name"
state={[name, setName]}
className="my-2"
/>
{magic ? (
name.length > 0 && name !== machine.givenName ? (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine will be accessible by the hostname{' '}
<Code className="text-sm">
{name.toLowerCase().replaceAll(/\s+/g, '-')}
</Code>
{'. '}
The hostname{' '}
<Code className="text-sm">{machine.givenName}</Code> will no
longer point to this machine.
</p>
) : (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine is accessible by the hostname{' '}
<Code className="text-sm">{machine.givenName}</Code>.
</p>
)
) : undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action variant="confirm" onPress={close}>
Rename
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { useFetcher } from 'react-router';
import { Dispatch, SetStateAction, useMemo } from 'react';
import Dialog from '~/components/Dialog';
import Switch from '~/components/Switch';
import Link from '~/components/Link';
import type { Machine, Route } from '~/types';
import { cn } from '~/utils/cn';
interface RoutesProps {
readonly machine: Machine;
readonly routes: Route[];
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
}
// TODO: Support deleting routes
export default function Routes({ machine, routes, state }: RoutesProps) {
const fetcher = useFetcher();
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet } = routes.reduce<{
exit: Route[];
subnet: Route[];
}>(
(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 (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog.Title>
Edit route settings of {machine.givenName}
</Dialog.Title>
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on by
advertising IP ranges as subnet routes.{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets 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',
)}
>
{subnet.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>No routes are advertised on this machine.</p>
</div>
) : undefined}
{subnet.map((route) => (
<div
key={route.id}
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between',
)}
>
<p>{route.prefix}</p>
<Switch
defaultSelected={route.enabled}
label="Enabled"
onChange={(checked) => {
const form = new FormData();
form.set('id', machine.id);
form.set('_method', 'routes');
form.set('route', route.id);
form.set('enabled', String(checked));
fetcher.submit(form, {
method: 'POST',
});
}}
/>
</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">
<Dialog.Action
variant="cancel"
isDisabled={fetcher.state === 'submitting'}
onPress={close}
>
Close
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
@@ -1,51 +1,44 @@
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 'react-router';
import { Dispatch, SetStateAction, useState } from 'react' import { type Dispatch, type 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 { Machine } from '~/types' import type { Machine } from '~/types';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
interface TagsProps { interface TagsProps {
readonly machine: Machine readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>] readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
} }
export default function Tags({ machine, state }: TagsProps) { export default function Tags({ machine, state }: TagsProps) {
const [tags, setTags] = useState(machine.forcedTags) const [tags, setTags] = useState(machine.forcedTags);
const [tag, setTag] = useState('') const [tag, setTag] = useState('');
const submit = useSubmit() const submit = useSubmit();
return ( return (
<Dialog> <Dialog>
<Dialog.Panel control={state}> <Dialog.Panel control={state}>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
Edit ACL tags for
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text> <Dialog.Text>
ACL tags can be used to reference machines in your ACL policies. ACL tags can be used to reference machines in your ACL policies.
See the See the{' '}
{' '}
<Link <Link
to="https://tailscale.com/kb/1068/acl-tags" to="https://tailscale.com/kb/1068/acl-tags"
name="Tailscale documentation" name="Tailscale documentation"
> >
Tailscale documentation Tailscale documentation
</Link> </Link>{' '}
{' '}
for more information. for more information.
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
onSubmit={(e) => { onSubmit={(e) => {
submit(e.currentTarget) submit(e.currentTarget);
}} }}
> >
<input type="hidden" name="_method" value="tags" /> <input type="hidden" name="_method" value="tags" />
@@ -58,21 +51,18 @@ export default function Tags({ machine, state }: TagsProps) {
)} )}
> >
<div className="divide-y divide-ui-200 dark:divide-ui-600"> <div className="divide-y divide-ui-200 dark:divide-ui-600">
{tags.length === 0 {tags.length === 0 ? (
? ( <div
<div className={cn(
className={cn( 'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800', 'items-center justify-center rounded-t-lg',
'items-center justify-center rounded-t-lg', 'text-ui-600 dark:text-ui-300',
'text-ui-600 dark:text-ui-300', )}
)} >
> <p>No tags are set on this machine.</p>
<p> </div>
No tags are set on this machine. ) : (
</p> tags.map((item) => (
</div>
)
: tags.map(item => (
<div <div
key={item} key={item}
id={item} id={item}
@@ -86,13 +76,14 @@ export default function Tags({ machine, state }: TagsProps) {
<Button <Button
className="rounded-full p-0 w-6 h-6" className="rounded-full p-0 w-6 h-6"
onPress={() => { onPress={() => {
setTags(tags.filter(tag => tag !== item)) setTags(tags.filter((tag) => tag !== item));
}} }}
> >
<XIcon className="w-4 h-4" /> <XIcon className="w-4 h-4" />
</Button> </Button>
</div> </div>
))} ))
)}
</div> </div>
<div <div
className={cn( className={cn(
@@ -101,8 +92,9 @@ export default function Tags({ machine, state }: TagsProps) {
'rounded-b-lg justify-between items-center', 'rounded-b-lg justify-between items-center',
'dark:bg-ui-800 dark:text-ui-300', 'dark:bg-ui-800 dark:text-ui-300',
'focus-within:ring-2 focus-within:ring-blue-600', 'focus-within:ring-2 focus-within:ring-blue-600',
tag.length > 0 && !tag.startsWith('tag:') tag.length > 0 &&
&& 'outline outline-red-500', !tag.startsWith('tag:') &&
'outline outline-red-500',
)} )}
> >
<Input <Input
@@ -115,23 +107,23 @@ export default function Tags({ machine, state }: TagsProps) {
)} )}
value={tag} value={tag}
onChange={(e) => { onChange={(e) => {
setTag(e.currentTarget.value) setTag(e.currentTarget.value);
}} }}
/> />
<Button <Button
className={cn( className={cn(
'rounded-lg p-0 h-6 w-6', 'rounded-lg p-0 h-6 w-6',
!tag.startsWith('tag:') !tag.startsWith('tag:') &&
&& 'opacity-50 cursor-not-allowed', 'opacity-50 cursor-not-allowed',
)} )}
isDisabled={ isDisabled={
tag.length === 0 tag.length === 0 ||
|| !tag.startsWith('tag:') !tag.startsWith('tag:') ||
|| tags.includes(tag) tags.includes(tag)
} }
onPress={() => { onPress={() => {
setTags([...tags, tag]) setTags([...tags, tag]);
setTag('') setTag('');
}} }}
> >
<PlusIcon className="w-4 h-4" /> <PlusIcon className="w-4 h-4" />
@@ -139,16 +131,10 @@ export default function Tags({ machine, state }: TagsProps) {
</div> </div>
</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" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action variant="confirm" onPress={close}>
variant="confirm"
onPress={close}
>
Save Save
</Dialog.Action> </Dialog.Action>
</div> </div>
@@ -157,5 +143,5 @@ export default function Tags({ machine, state }: TagsProps) {
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) );
} }
@@ -1,41 +1,46 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node' import { Link as RemixLink, useLoaderData } from 'react-router';
import { Link as RemixLink, useLoaderData } from '@remix-run/react' import {
import { InfoIcon, GearIcon, CheckCircleIcon, SkipIcon, PersonIcon } from '@primer/octicons-react' InfoIcon,
import { useMemo, useState } from 'react' GearIcon,
CheckCircleIcon,
SkipIcon,
PersonIcon,
} from '@primer/octicons-react';
import { useMemo, useState } from 'react';
import Attribute from '~/components/Attribute' import Attribute from '~/components/Attribute';
import Button from '~/components/Button' import Button from '~/components/Button';
import Card from '~/components/Card' import Card from '~/components/Card';
import Menu from '~/components/Menu' import Menu from '~/components/Menu';
import Tooltip from '~/components/Tooltip' import Tooltip from '~/components/Tooltip';
import StatusCircle from '~/components/StatusCircle' import StatusCircle from '~/components/StatusCircle';
import { Machine, Route, User } from '~/types' import type { Machine, Route, 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';
import { pull } from '~/utils/headscale' import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions.server';
import { useLiveData } from '~/utils/useLiveData' import { useLiveData } from '~/utils/useLiveData';
import Link from '~/components/Link' import Link from '~/components/Link';
import { menuAction } from '../_data.machines._index/action' import { menuAction } from './action';
import MenuOptions from '../_data.machines._index/menu' import MenuOptions from './components/menu';
import Routes from '../_data.machines._index/dialogs/routes' import Routes from './dialogs/routes';
export async function loader({ request, params }: LoaderFunctionArgs) { export async function loader({ request, params }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'));
if (!params.id) { if (!params.id) {
throw new Error('No machine ID provided') throw new Error('No machine ID provided');
} }
const context = await loadContext() const context = await loadContext();
let magic: string | undefined let magic: string | undefined;
if (context.config.read) { if (context.config.read) {
const config = await loadConfig() const config = await loadConfig();
if (config.dns.magic_dns) { if (config.dns.magic_dns) {
magic = config.dns.base_domain magic = config.dns.base_domain;
} }
} }
@@ -43,110 +48,109 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!), pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!), pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!), pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
]) ]);
return { return {
machine: machine.node, machine: machine.node,
routes: routes.routes.filter(route => route.node.id === params.id), routes: routes.routes.filter((route) => route.node.id === params.id),
users: users.users, users: users.users,
magic, magic,
} };
} }
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
return menuAction(request) return menuAction(request);
} }
export default function Page() { export default function Page() {
const { machine, magic, routes, users } = useLoaderData<typeof loader>() const { machine, magic, routes, users } = useLoaderData<typeof loader>();
const routesState = useState(false) const routesState = useState(false);
useLiveData({ interval: 1000 }) useLiveData({ interval: 1000 });
const expired = machine.expiry === '0001-01-01 00:00:00' const expired =
|| machine.expiry === '0001-01-01T00:00:00Z' machine.expiry === '0001-01-01 00:00:00' ||
|| machine.expiry === null machine.expiry === '0001-01-01T00:00:00Z' ||
? false machine.expiry === null
: new Date(machine.expiry).getTime() < Date.now() ? false
: new Date(machine.expiry).getTime() < Date.now();
const tags = [ const tags = [...machine.forcedTags, ...machine.validTags];
...machine.forcedTags,
...machine.validTags,
]
if (expired) { if (expired) {
tags.unshift('Expired') tags.unshift('Expired');
} }
// This is much easier with Object.groupBy but it's too new for us // This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce((acc, route) => { const { exit, subnet, subnetApproved } = routes.reduce<{
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') { exit: Route[];
acc.exit.push(route) subnet: Route[];
return acc subnetApproved: Route[];
} }>(
(acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route);
return acc;
}
if (route.enabled) { if (route.enabled) {
acc.subnetApproved.push(route) acc.subnetApproved.push(route);
return acc return acc;
} }
acc.subnet.push(route) acc.subnet.push(route);
return acc return acc;
}, { exit: [], subnetApproved: [], subnet: [] }) },
{ exit: [], subnetApproved: [], subnet: [] },
);
const exitEnabled = useMemo(() => { const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false if (exit.length !== 2) return false;
return exit[0].enabled && exit[1].enabled return exit[0].enabled && exit[1].enabled;
}, [exit]) }, [exit]);
if (exitEnabled) { if (exitEnabled) {
tags.unshift('Exit Node') tags.unshift('Exit Node');
} }
if (subnetApproved.length > 0) { if (subnetApproved.length > 0) {
tags.unshift('Subnets') tags.unshift('Subnets');
} }
return ( return (
<div> <div>
<p className="mb-8 text-md"> <p className="mb-8 text-md">
<RemixLink <RemixLink to="/machines" className="font-medium">
to="/machines"
className="font-medium"
>
All Machines All Machines
</RemixLink> </RemixLink>
<span className="mx-2"> <span className="mx-2">/</span>
/
</span>
{machine.givenName} {machine.givenName}
</p> </p>
<div className={cn( <div
'flex justify-between items-center', className={cn(
'border-b border-ui-100 dark:border-ui-800', '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"> <span className="flex items-baseline gap-x-4 text-sm mb-4">
<h1 className="text-2xl font-medium"> <h1 className="text-2xl font-medium">{machine.givenName}</h1>
{machine.givenName}
</h1>
<StatusCircle isOnline={machine.online} className="w-4 h-4" /> <StatusCircle isOnline={machine.online} className="w-4 h-4" />
</span> </span>
<MenuOptions <MenuOptions
className={cn(
'bg-ui-100 dark:bg-ui-800',
)}
machine={machine} machine={machine}
routes={routes} routes={routes}
users={users} users={users}
magic={magic} magic={magic}
buttonChild={ buttonChild={
<Menu.Button className={cn( <Menu.Button
'flex items-center justify-center gap-x-2', className={cn(
'bg-main-200 dark:bg-main-700/30', 'flex items-center justify-center gap-x-2',
'hover:bg-main-300 dark:hover:bg-main-600/30', 'bg-main-200 dark:bg-main-700/30',
'text-ui-700 dark:text-ui-300 mb-2', 'hover:bg-main-300 dark:hover:bg-main-600/30',
'w-fit text-sm rounded-lg px-3 py-2' 'text-ui-700 dark:text-ui-300 mb-2',
)}> 'w-fit text-sm rounded-lg px-3 py-2',
)}
>
<GearIcon className="w-5" /> <GearIcon className="w-5" />
Machine Settings Machine Settings
</Menu.Button> </Menu.Button>
@@ -167,21 +171,21 @@ export default function Page() {
</Tooltip> </Tooltip>
</span> </span>
<div className="flex items-center gap-x-2.5 mt-1"> <div className="flex items-center gap-x-2.5 mt-1">
<div className={cn( <div
'rounded-full h-7 w-7 flex items-center justify-center', className={cn(
'border border-ui-200 dark:border-ui-700', '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" /> <PersonIcon className="w-4 h-4" />
</div> </div>
{machine.user.name} {machine.user.name}
</div> </div>
</div> </div>
<div className="p-2 pl-4"> <div className="p-2 pl-4">
<p className="text-sm text-ui-600 dark:text-ui-300"> <p className="text-sm text-ui-600 dark:text-ui-300">Status</p>
Status
</p>
<div className="flex gap-1 mt-1 mb-8"> <div className="flex gap-1 mt-1 mb-8">
{tags.map(tag => ( {tags.map((tag) => (
<span <span
key={tag} key={tag}
className={cn( className={cn(
@@ -196,18 +200,11 @@ export default function Page() {
</div> </div>
</div> </div>
</div> </div>
<h2 className="text-xl font-medium mb-4 mt-8"> <h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2>
Subnets & Routing <Routes machine={machine} routes={routes} state={routesState} />
</h2>
<Routes
machine={machine}
routes={routes}
state={routesState}
/>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<p> <p>
Subnets let you expose physical network routes onto Tailscale. Subnets let you expose physical network routes onto Tailscale.{' '}
{' '}
<Link <Link
to="https://tailscale.com/kb/1019/subnets" to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation" name="Tailscale Subnets Documentation"
@@ -215,10 +212,7 @@ export default function Page() {
Learn More Learn More
</Link> </Link>
</p> </p>
<Button <Button variant="light" control={routesState}>
variant="light"
control={routesState}
>
Review Review
</Button> </Button>
</div> </div>
@@ -237,22 +231,17 @@ export default function Page() {
<InfoIcon className="w-3.5 h-3.5" /> <InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button> </Tooltip.Button>
<Tooltip.Body> <Tooltip.Body>
Traffic to these routes are being Traffic to these routes are being routed through this machine.
routed through this machine.
</Tooltip.Body> </Tooltip.Body>
</Tooltip> </Tooltip>
</span> </span>
<div className="mt-1"> <div className="mt-1">
{subnetApproved.length === 0 ? ( {subnetApproved.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300"> <span className="text-ui-400 dark:text-ui-300"></span>
</span>
) : ( ) : (
<ul className="leading-normal"> <ul className="leading-normal">
{subnetApproved.map(route => ( {subnetApproved.map((route) => (
<li key={route.id}> <li key={route.id}>{route.prefix}</li>
{route.prefix}
</li>
))} ))}
</ul> </ul>
)} )}
@@ -277,23 +266,18 @@ export default function Page() {
<InfoIcon className="w-3.5 h-3.5" /> <InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button> </Tooltip.Button>
<Tooltip.Body> <Tooltip.Body>
This machine is advertising these routes, This machine is advertising these routes, but they must be
but they must be approved before traffic approved before traffic will be routed to them.
will be routed to them.
</Tooltip.Body> </Tooltip.Body>
</Tooltip> </Tooltip>
</span> </span>
<div className="mt-1"> <div className="mt-1">
{subnet.length === 0 ? ( {subnet.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300"> <span className="text-ui-400 dark:text-ui-300"></span>
</span>
) : ( ) : (
<ul className="leading-normal"> <ul className="leading-normal">
{subnet.map(route => ( {subnet.map((route) => (
<li key={route.id}> <li key={route.id}>{route.prefix}</li>
{route.prefix}
</li>
))} ))}
</ul> </ul>
)} )}
@@ -318,16 +302,13 @@ export default function Page() {
<InfoIcon className="w-3.5 h-3.5" /> <InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button> </Tooltip.Button>
<Tooltip.Body> <Tooltip.Body>
Whether this machine can act as an Whether this machine can act as an exit node for your tailnet.
exit node for your tailnet.
</Tooltip.Body> </Tooltip.Body>
</Tooltip> </Tooltip>
</span> </span>
<div className="mt-1"> <div className="mt-1">
{exit.length === 0 ? ( {exit.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300"> <span className="text-ui-400 dark:text-ui-300"></span>
</span>
) : exitEnabled ? ( ) : exitEnabled ? (
<span className="flex items-center gap-x-1"> <span className="flex items-center gap-x-1">
<CheckCircleIcon className="w-3.5 h-3.5 text-green-700" /> <CheckCircleIcon className="w-3.5 h-3.5 text-green-700" />
@@ -353,19 +334,13 @@ export default function Page() {
</Button> </Button>
</div> </div>
</Card> </Card>
<h2 className="text-xl font-medium mb-4"> <h2 className="text-xl font-medium mb-4">Machine Details</h2>
Machine Details
</h2>
<Card variant="flat" className="w-full max-w-full"> <Card variant="flat" className="w-full max-w-full">
<Attribute name="Creator" value={machine.user.name} /> <Attribute name="Creator" value={machine.user.name} />
<Attribute name="Node ID" value={machine.id} /> <Attribute name="Node ID" value={machine.id} />
<Attribute name="Node Name" value={machine.givenName} /> <Attribute name="Node Name" value={machine.givenName} />
<Attribute name="Hostname" value={machine.name} /> <Attribute name="Hostname" value={machine.name} />
<Attribute <Attribute isCopyable name="Node Key" value={machine.nodeKey} />
isCopyable
name="Node Key"
value={machine.nodeKey}
/>
<Attribute <Attribute
name="Created" name="Created"
value={new Date(machine.createdAt).toLocaleString()} value={new Date(machine.createdAt).toLocaleString()}
@@ -376,21 +351,16 @@ export default function Page() {
/> />
<Attribute <Attribute
name="Expiry" name="Expiry"
value={expired value={expired ? new Date(machine.expiry).toLocaleString() : 'Never'}
? new Date(machine.expiry).toLocaleString()
: 'Never'
}
/> />
{magic {magic ? (
? ( <Attribute
<Attribute isCopyable
isCopyable name="Domain"
name="Domain" value={`${machine.givenName}.${magic}`}
value={`${machine.givenName}.${magic}`} />
/> ) : undefined}
)
: undefined}
</Card> </Card>
</div> </div>
) );
} }
+148
View File
@@ -0,0 +1,148 @@
import { InfoIcon } from '@primer/octicons-react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components';
import Code from '~/components/Code';
import Link from '~/components/Link';
import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale';
import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { useLiveData } from '~/utils/useLiveData';
import type { Machine, Route, User } from '~/types';
import { queryAgent, initAgentSocket } from '~/utils/ws-agent';
import { ErrorPopup } from '~/components/Error'
import { menuAction } from './action';
import MachineRow from './components/machine';
import NewMachine from './dialogs/new';
export async function loader({ request, context: lC }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
const [machines, routes, users] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
]);
initAgentSocket(lC);
const stats = await queryAgent(machines.nodes.map((node) => node.nodeKey));
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;
}
if (config.dns.use_username_in_magic_dns) {
magic = `[user].${magic}`;
}
}
return {
nodes: machines.nodes,
routes: routes.routes,
users: users.users,
magic,
stats,
server: context.headscaleUrl,
publicServer: context.headscalePublicUrl,
};
}
export async function action({ request }: ActionFunctionArgs) {
return menuAction(request);
}
export default function Page() {
useLiveData({ interval: 3000 });
const data = useLoaderData<typeof loader>();
return (
<>
<div className="flex justify-between items-center mb-8">
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Machines</h1>
<p className="text-gray-700 dark:text-gray-300">
Manage the devices connected to your Tailnet.{' '}
<Link
to="https://tailscale.com/kb/1372/manage-devices"
name="Tailscale Manage Devices Documentation"
>
Learn more
</Link>
</p>
</div>
<NewMachine
server={data.publicServer ?? data.server}
users={data.users}
/>
</div>
<table className="table-auto w-full rounded-lg">
<thead className="text-gray-500 dark:text-gray-400">
<tr className="text-left uppercase text-xs font-bold px-0.5">
<th className="pb-2">Name</th>
<th className="pb-2 w-1/4">
<div className="flex items-center gap-x-1">
Addresses
{data.magic ? (
<TooltipTrigger delay={0}>
<Button>
<InfoIcon className="w-4 h-4" />
</Button>
<Tooltip
className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-zinc-800',
'border border-gray-200 dark:border-zinc-700',
)}
>
Since MagicDNS is enabled, you can access devices based on
their name and also at{' '}
<Code>
[name].
{data.magic}
</Code>
</Tooltip>
</TooltipTrigger>
) : undefined}
</div>
</th>
{/**<th className="pb-2">Version</th>**/}
<th className="pb-2">Last Seen</th>
</tr>
</thead>
<tbody
className={cn(
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border-t border-zinc-200 dark:border-zinc-700',
)}
>
{data.nodes.map((machine) => (
<MachineRow
key={machine.id}
machine={machine}
routes={data.routes.filter(
(route) => route.node.id === machine.id,
)}
users={data.users}
magic={data.magic}
stats={data.stats?.[machine.nodeKey]}
/>
))}
</tbody>
</table>
</>
);
}
export function ErrorBoundary() {
return (
<ErrorPopup type="embedded" />
)
}

Some files were not shown because too many files have changed in this diff Show More