Compare commits

...

106 Commits

Author SHA1 Message Date
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
Aarnav Tale 41b1d3c847 chore: v0.3.7 2024-11-30 15:34:54 -05:00
Aarnav Tale 712fc28683 feat: allow a public headscale URL separate from the main one 2024-11-30 15:33:58 -05:00
Aarnav Tale 320dab1d4f fix: join children in copyable codeblock 2024-11-30 15:05:07 -05:00
Aarnav Tale da0ee1382b feat: allow setting OIDC_CLIENT_SECRET_METHOD 2024-11-30 15:00:51 -05:00
Aarnav Tale a7d127c7bf feat: add health check route 2024-11-27 11:23:42 -05:00
Aarnav Tale b433e607e2 chore: v0.3.6 2024-11-20 18:15:19 -05:00
Aarnav Tale 8aad883c21 fix: make selects scrollable 2024-11-20 18:13:49 -05:00
Aarnav Tale 3cd28d2136 feat: make the code snippets copyable 2024-11-20 18:01:20 -05:00
Aarnav Tale 9d9cbd8e0e chore: v0.3.5 2024-11-08 12:05:33 -05:00
Aarnav Tale feb8b8bba5 chore: remove logging on expected errors 2024-11-08 12:04:24 -05:00
Aarnav Tale c304effcdf fix: externalize process.env 2024-11-08 12:03:08 -05:00
Aarnav Tale 982d811d53 chore: v0.3.4 2024-11-07 13:09:35 -05:00
Aarnav Tale d369e36fa4 fix: potentially catch undefined bearer errors 2024-11-07 13:08:43 -05:00
Aarnav Tale a2e659f36c feat(TALE-36): redesign machines page to account for exit nodes 2024-11-07 13:04:53 -05:00
Aarnav Tale 1490406784 feat(TALE-36): show exit node status on machine page 2024-11-06 17:01:36 -05:00
Aarnav Tale e1c87412d4 feat(TALE-36): support exit node enabling/disabling 2024-11-06 16:48:14 -05:00
Aarnav Tale 6e55f442fd fix: make the last acl tag removable on machines 2024-11-06 16:10:39 -05:00
Aarnav Tale 09e1b1f261 fix: address a bug that prevented remove split dns records 2024-11-06 15:57:43 -05:00
Aarnav Tale 3e83f8617b fix: use correct vite config in dev 2024-11-06 15:57:27 -05:00
Aarnav Tale 1d6472765a docs: better explain the native integration 2024-11-06 15:18:21 -05:00
Aarnav Tale 2bc85085f5 fix: handle non-prefixed routes on the production server 2024-11-06 14:29:04 -05:00
Aarnav Tale 5c949e2da5 feat: use all native node deps for the server 2024-11-04 22:05:46 -05:00
Aarnav Tale 12754bd0aa fix: resolve type errors across the codebase 2024-11-04 18:13:10 -05:00
Aarnav Tale 10bb4eba97 fix: skip loading env file if context is loaded 2024-11-04 18:00:50 -05:00
Aarnav Tale 13a734923d feat: ditch remix-serve for custom-baked server 2024-11-04 18:00:35 -05:00
Aarnav Tale d5ee8ae0f3 fix: clicking a machine name in users page redirects 2024-11-04 14:59:13 -05:00
Aarnav Tale 331a9c8dcf chore: v0.3.3 2024-10-28 15:10:20 -04:00
Aarnav Tale 808b01c5d7 chore: update react 19 compiler to next beta 2024-10-27 13:13:34 -04:00
Aarnav Tale a8abd37e3a fix: handle errors and fix api logic with acl updating 2024-10-27 13:10:32 -04:00
Aarnav Tale d1fa76971b feat: switch back to codemirror with jsonc support 2024-10-22 00:41:44 -04:00
Aarnav Tale a9e8394dec fix: do not pass the text value as a form field
This fixes an issue where on ephemeral key creation, it would fail due to localized dates
2024-10-20 18:34:43 -04:00
Aarnav Tale 9dafd8e8b0 feat: load from dotenv if specified 2024-10-16 23:07:41 -04:00
Václav Šmejkal 0c1f6969da feat: hide api key on login page (#39) 2024-10-16 13:23:02 -04:00
Aarnav Tale 4214e14ba8 chore: reorganize machines page to prep for routing data 2024-10-13 00:32:21 -04:00
Aarnav Tale 447a31f91e chore: v0.3.2 2024-10-11 03:09:56 -04:00
Aarnav Tale 9e21823163 chore: mention that settings is incomplete 2024-10-11 03:04:45 -04:00
Aarnav Tale aa7e2a3128 feat(TALE-35): implement pre-auth key management 2024-10-11 03:02:33 -04:00
Aarnav Tale ecef45c98a fix(TALE-35): use the correct machine registry endpoint 2024-10-10 11:12:35 -04:00
Aarnav Tale d165264876 fix: handle nullable expiry values from a db 2024-10-10 10:41:41 -04:00
Aarnav Tale dd479d4117 fix: remove older references to magic_dns via usernames 2024-10-10 10:03:43 -04:00
Akira Yamazaki 29d91785fb fix: build oidc callback url in a more proper way (#28) 2024-10-07 23:23:31 -04:00
Aarnav Tale 98d1cb1333 docs: mention 0.3.1 as the latest 2024-10-03 15:36:20 -04:00
Aarnav Tale 2229f547a9 chore: v0.3.1 2024-10-03 15:31:03 -04:00
Aarnav Tale 65cc278a59 docs(TALE-33): document debug 2024-10-03 15:30:24 -04:00
Aarnav Tale 1555846df2 fix(TALE-34): use the http coded URL for socket 2024-10-03 15:15:56 -04:00
Aarnav Tale e8c1cadf54 feat(TALE-35): add initial machine key authorization 2024-10-03 11:58:05 -04:00
Aarnav Tale d867769025 chore: update browserlist 2024-10-02 13:41:45 -04:00
Aarnav Tale 1d6066d3f0 feat(TALE-33): add debug logging with DEBUG env 2024-10-02 13:33:39 -04:00
Aarnav Tale 1d821251a9 fix(TALE-34): setting url.protocol does not work anymore 2024-10-02 12:59:42 -04:00
161 changed files with 11285 additions and 12092 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
node_modules
/.react-router
/.cache
/build
.env
+64
View File
@@ -1,3 +1,67 @@
### 0.4.0 (January 6, 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)
- 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))
- Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60))
- Fixed an issue where the copy machine registration command had a typo.
### 0.3.6 (November 20, 2024)
- Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53))
- Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52))
### 0.3.5 (November 8, 2024)
- Quickfix a bug where environment variables are ignored on the server.
- Remove a nagging error about missing cookie since that happens when signed out.
### 0.3.4 (November 7, 2024)
- Clicking on the machine name in the users page now takes you to the machine overview page.
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
- Improve documentation and support when running Headplane outside of Docker.
- Removing Split DNS records will no longer result in an error (fixes [#40](https://github.com/tale/headplane/issues/40))
- Removing the last ACL tag on a machine no longer results in an error (fixes [#41](https://github.com/tale/headplane/issues/41))
- Added full support for Exit Nodes in the UI and redesigned the machines page (fixes [#36](https://github.com/tale/headplane/issues/36))
- Added a basic check to see if the API keys passed via cookies are invalid.
### 0.3.3 (October 28, 2024)
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
- Improved ACL editor performance by switching back to CodeMirror 6
- Fixed an issue where editing the ACL policy would cause it to revert on the UI (fixes [#34](https://github.com/tale/headplane/issues/34))
- Updated to the next stable beta of the React 19 Compiler ([See More](https://react.dev/learn/react-compiler))
### 0.3.2 (October 11, 2024)
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
- Handle `null` values on machine expiry when using a database like PostgreSQL.
- Use `X-Forwarded-Proto` and `Host` headers for building the OIDC callback URL.
### 0.3.1 (October 3, 2024)
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
- Added the option for debug logs with the `DEBUG=true` environment variable.
### 0.3.0 (September 25, 2024)
- Bumped the minimum supported version of Headscale to 0.23.
- Updated the UI to respect `dns.use_username_in_magic_dns`.
+2 -1
View File
@@ -2,6 +2,7 @@ FROM node:20-alpine AS build
WORKDIR /app
RUN npm install -g pnpm
RUN apk add --no-cache git
COPY package.json pnpm-lock.yaml ./
COPY patches ./patches
RUN pnpm install --frozen-lockfile
@@ -19,4 +20,4 @@ RUN echo '{"type":"module"}' > /app/package.json
EXPOSE 3000
ENV NODE_ENV=production
ENV HOST=0.0.0.0
CMD [ "node_modules/.bin/remix-serve", "./build/server/index.js" ]
CMD [ "node", "./build/headplane/server.js" ]
+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()
}
+31 -32
View File
@@ -1,43 +1,42 @@
import { CopyIcon } from '@primer/octicons-react'
import { toast } from './Toaster'
import { CopyIcon } from '@primer/octicons-react';
import { toast } from './Toaster';
interface Props {
name: string
value: string
isCopyable?: boolean
name: string;
value: string;
isCopyable?: boolean;
link?: string;
}
export default function Attribute({ name, value, isCopyable }: Props) {
const canCopy = isCopyable ?? false
export default function Attribute({ name, value, link, isCopyable }: Props) {
const canCopy = isCopyable ?? false;
return (
<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">
{name}
{link ? (
<a className="hover:underline" href={link}>
{name}
</a>
) : (
name
)}
</dt>
{canCopy
? (
<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"
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onClick={async () => {
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" />
</button>
)
: (
<dd className="min-w-0 truncate px-2 py-1">
{value}
</dd>
)}
{canCopy ? (
<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"
onClick={async () => {
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" />
</button>
) : (
<dd className="min-w-0 truncate px-2 py-1">{value}</dd>
)}
</dl>
)
);
}
+22 -21
View File
@@ -1,37 +1,38 @@
import { type Dispatch, type SetStateAction } from 'react'
import { Button as AriaButton } from 'react-aria-components'
import type { Dispatch, SetStateAction } from 'react';
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] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]
readonly variant?: 'heavy' | 'light'
}
export default function Button(properties: ButtonProperties) {
export default function Button(props: Props) {
return (
<AriaButton
{...properties}
{...props}
className={cn(
'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-200 dark:bg-main-700/30',
properties.variant === 'heavy'
props.variant === 'heavy'
? 'hover:bg-main-800 dark:hover:bg-main-700'
: 'hover:bg-main-300 dark:hover:bg-main-600/30',
properties.variant === 'heavy'
props.variant === 'heavy'
? 'text-white'
: 'text-ui-700 dark:text-ui-300',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className,
props.isDisabled && 'opacity-50 cursor-not-allowed',
props.className,
)}
// If control is passed, set the state value
onPress={properties.control
? () => {
properties.control?.[1](true)
}
: properties.onPress}
onPress={
props.control
? () => {
props.control?.[1](true);
}
: props.onPress
}
/>
)
);
}
+20 -30
View File
@@ -1,53 +1,43 @@
import { type HTMLProps } from 'react'
import { Heading as AriaHeading } from 'react-aria-components'
import type { HTMLProps } from 'react';
import { Heading as AriaHeading } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
function Title(properties: Parameters<typeof AriaHeading>[0]) {
function Title(props: Parameters<typeof AriaHeading>[0]) {
return (
<AriaHeading
{...properties}
slot='title'
className={cn(
'text-lg font-semibold leading-6 mb-5',
properties.className
)}
{...props}
slot="title"
className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
/>
)
);
}
function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
function Text(props: React.HTMLProps<HTMLParagraphElement>) {
return (
<p
{...properties}
className={cn(
'text-base leading-6 my-0',
properties.className
)}
/>
)
<p {...props} className={cn('text-base leading-6 my-0', props.className)} />
);
}
type Properties = HTMLProps<HTMLDivElement> & {
type Props = HTMLProps<HTMLDivElement> & {
variant?: 'raised' | 'flat';
}
};
function Card(properties: Properties) {
function Card(props: Props) {
return (
<div
{...properties}
{...props}
className={cn(
'w-full max-w-md overflow-hidden rounded-xl p-4',
properties.variant === 'flat'
props.variant === 'flat'
? 'bg-transparent shadow-none'
: 'bg-ui-50 dark:bg-ui-900 shadow-sm',
'border border-ui-200 dark:border-ui-700',
properties.className
props.className,
)}
>
{properties.children}
{props.children}
</div>
)
);
}
export default Object.assign(Card, { Title, Text })
export default Object.assign(Card, { Title, Text });
+49 -10
View File
@@ -1,12 +1,51 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
import { useState, HTMLProps } from 'react';
import { CopyIcon, CheckIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
import { toast } from '~/components/Toaster';
type Properties = HTMLProps<HTMLSpanElement>
export default function Code(properties: Properties) {
return (
<code className={clsx('bg-gray-100 dark:bg-zinc-700 p-0.5 rounded-md', properties.className)}>
{properties.children}
</code>
)
interface Props extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean;
}
export default function Code(props: Props) {
const [isCopied, setIsCopied] = useState(false);
return (
<>
<code
className={cn(
'bg-ui-100 dark:bg-ui-800 p-0.5 rounded-md',
props.className,
)}
>
{props.children}
</code>
{props.isCopyable ? (
<button
className={cn(
'ml-1 p-1 rounded-md',
'bg-ui-100 dark:bg-ui-800',
'text-ui-500 dark:text-ui-400',
'inline-flex items-center justify-center',
)}
onClick={() => {
if (!props.children) {
throw new Error('Made copyable without children');
}
navigator.clipboard.writeText(props.children.join(''));
toast('Copied to clipboard');
setIsCopied(true);
setTimeout(() => setIsCopied(false), 1000);
}}
>
{isCopied ? (
<CheckIcon className="h-3 w-3" />
) : (
<CopyIcon className="h-3 w-3" />
)}
</button>
) : undefined}
</>
);
}
+54 -65
View File
@@ -1,106 +1,99 @@
/* eslint-disable unicorn/no-keyword-prefix */
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
import type { Dispatch, ReactNode, SetStateAction } from 'react';
import {
Button as AriaButton,
Dialog as AriaDialog,
DialogTrigger,
Heading as AriaHeading,
Modal,
ModalOverlay
} from 'react-aria-components'
ModalOverlay,
} from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
type ButtonProps = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
}
};
function Button(properties: ButtonProperties) {
function Button(props: ButtonProps) {
return (
<AriaButton
{...properties}
aria-label='Dialog'
{...props}
aria-label="Dialog"
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',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className
props.isDisabled && 'opacity-50 cursor-not-allowed',
props.className,
)}
// If control is passed, set the state value
onPress={properties.control ? () => {
properties.control?.[1](true)
} : undefined}
onPress={
props.control
? () => {
props.control?.[1](true);
}
: undefined
}
/>
)
);
}
type ActionProperties = Parameters<typeof AriaButton>[0] & {
type ActionProps = Parameters<typeof AriaButton>[0] & {
readonly variant: 'cancel' | 'confirm';
}
};
function Action(properties: ActionProperties) {
function Action(props: ActionProps) {
return (
<AriaButton
{...properties}
type={properties.variant === 'confirm' ? 'submit' : 'button'}
{...props}
type={props.variant === 'confirm' ? 'submit' : 'button'}
className={cn(
'px-4 py-2 rounded-lg',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.variant === 'cancel'
props.isDisabled && 'opacity-50 cursor-not-allowed',
props.variant === 'cancel'
? 'text-ui-700 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-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 (
<AriaHeading
{...properties}
slot='title'
className={cn(
'text-lg font-semibold leading-6 mb-5',
properties.className
)}
{...props}
slot="title"
className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
/>
)
);
}
function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
function Text(props: React.HTMLProps<HTMLParagraphElement>) {
return (
<p
{...properties}
className={cn(
'text-base leading-6 my-0',
properties.className
)}
/>
)
<p {...props} className={cn('text-base leading-6 my-0', props.className)} />
);
}
type PanelProperties = {
readonly children: (close: () => void) => ReactNode;
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
readonly className?: string;
interface PanelProps {
children: (close: () => void) => ReactNode;
control?: [boolean, Dispatch<SetStateAction<boolean>>];
className?: string;
}
function Panel({ children, control, className }: PanelProperties) {
function Panel({ children, control, className }: PanelProps) {
return (
<ModalOverlay
aria-hidden='true'
aria-hidden="true"
className={cn(
'fixed inset-0 h-screen w-screen z-50 bg-black/30',
'flex items-center justify-center dark:bg-black/70',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:duration-200 entering:ease-out',
'exiting:fade-out exiting:duration-100 exiting:ease-in',
className
className,
)}
isOpen={control ? control[0] : undefined}
onOpenChange={control ? control[1] : undefined}
@@ -112,32 +105,28 @@ function Panel({ children, control, className }: PanelProperties) {
'entering:animate-in exiting:animate-out',
'dark:border dark:border-ui-700',
'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)}
</AriaDialog>
</Modal>
</ModalOverlay>
)
);
}
type DialogProperties = {
readonly children: ReactNode;
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
interface DialogProps {
children: ReactNode;
control?: [boolean, Dispatch<SetStateAction<boolean>>];
}
function Dialog({ children, control }: DialogProperties) {
function Dialog({ children, control }: DialogProps) {
if (control) {
return children
return children;
}
return (
<DialogTrigger>
{children}
</DialogTrigger>
)
return <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 { isRouteErrorResponse, useRouteError } from '@remix-run/react'
import { AlertIcon } from '@primer/octicons-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'
import Card from './Card'
import Code from './Code'
type Properties = {
readonly type?: 'full' | 'embedded';
interface Props {
type?: 'full' | 'embedded';
}
export function ErrorPopup({ type = 'full' }: Properties) {
const error = useRouteError()
const routing = isRouteErrorResponse(error)
const message = (error instanceof Error ? error.message : 'An unexpected error occurred')
function getMessage(error: Error | unknown) {
if (!(error instanceof Error)) {
return "An unknown 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 (
<div
@@ -21,26 +42,20 @@ export function ErrorPopup({ type = 'full' }: Properties) {
'flex items-center justify-center',
type === 'embedded'
? 'pointer-events-none mt-24'
: 'fixed inset-0 h-screen w-screen z-50'
: 'fixed inset-0 h-screen w-screen z-50',
)}
>
<Card>
<div className='flex items-center justify-between'>
<Card.Title className='text-3xl mb-0'>
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">
{routing ? error.status : 'Error'}
</Card.Title>
<AlertIcon className='w-12 h-12 text-red-500'/>
<AlertIcon className="w-12 h-12 text-red-500" />
</div>
<Card.Text className='mt-4 text-lg'>
{routing ? (
error.statusText
) : (
<Code>
{message}
</Code>
)}
<Card.Text className="mt-4 text-lg">
{routing ? error.statusText : <Code>{message}</Code>}
</Card.Text>
</Card>
</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 { Form } from '@remix-run/react'
import {
GearIcon,
GlobeIcon,
LockIcon,
PaperAirplaneIcon,
PeopleIcon,
PersonIcon,
ServerIcon,
} from '@primer/octicons-react';
import { Form } from 'react-router';
import { cn } from '~/utils/cn'
import { HeadplaneContext } from '~/utils/config/headplane'
import { type SessionData } from '~/utils/sessions'
import { cn } from '~/utils/cn';
import type { HeadplaneContext } from '~/utils/config/headplane';
import type { SessionData } from '~/utils/sessions.server';
import Menu from './Menu'
import TabLink from './TabLink'
import Menu from './Menu';
import TabLink from './TabLink';
interface Properties {
readonly data?: {
config: HeadplaneContext['config']
user?: SessionData['user']
}
interface Props {
config: HeadplaneContext['config'];
user?: SessionData['user'];
}
interface LinkProperties {
readonly href: string
readonly text: string
readonly isMenu?: boolean
interface LinkProps {
href: string;
text: string;
isMenu?: boolean;
}
function Link({ href, text, isMenu }: LinkProperties) {
function Link({ href, text, isMenu }: LinkProps) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
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}
</a>
)
);
}
export default function Header({ data }: Properties) {
export default function Header(data: Props) {
return (
<header className="bg-main-700 dark:bg-main-800 text-ui-50">
<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://github.com/tale/headplane" text="GitHub" />
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
{data?.user
? (
<Menu>
<Menu.Button className={cn(
{data.user ? (
<Menu>
<Menu.Button
className={cn(
'rounded-full h-9 w-9',
'border border-main-600 dark:border-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" />
</Menu.Button>
<Menu.Items>
<Menu.Item className="text-right">
<p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://tailscale.com/download"
text="Download"
/>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://github.com/tale/headplane"
text="GitHub"
/>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://github.com/juanfont/headscale"
text="Headscale"
/>
</Menu.Item>
<Menu.Item className="text-red-500 dark:text-red-400">
<Form method="POST" action="/logout">
<button type="submit" className="w-full text-right">
Logout
</button>
</Form>
</Menu.Item>
</Menu.Items>
</Menu>
)
: undefined}
>
<PersonIcon className="h-5 w-5 mt-0.5" />
</Menu.Button>
<Menu.Items>
<Menu.Item className="text-right">
<p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://tailscale.com/download"
text="Download"
/>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://github.com/tale/headplane"
text="GitHub"
/>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://github.com/juanfont/headscale"
text="Headscale"
/>
</Menu.Item>
<Menu.Item className="text-red-500 dark:text-red-400">
<Form method="POST" action="/logout">
<button type="submit" className="w-full text-right">
Logout
</button>
</Form>
</Menu.Item>
</Menu.Items>
</Menu>
) : undefined}
</div>
</div>
<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 to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} />
<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}
<TabLink
to="/machines"
name="Machines"
icon={<ServerIcon className="w-4 h-4" />}
/>
<TabLink
to="/users"
name="Users"
icon={<PeopleIcon className="w-4 h-4" />}
/>
<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>
</header>
)
);
}
+7 -8
View File
@@ -1,12 +1,11 @@
import { LinkExternalIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
import { LinkExternalIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
interface Props {
to: string
name: string
children: string
className?: string
to: string;
name: string;
children: string;
className?: string;
}
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}
<LinkExternalIcon className="h-3 w-3" />
</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 {
Button as AriaButton,
Menu as AriaMenu,
MenuItem,
MenuTrigger,
Popover
} from 'react-aria-components'
Popover,
} from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
function Button(properties: Parameters<typeof AriaButton>[0]) {
function Button(props: Parameters<typeof AriaButton>[0]) {
return (
<AriaButton
{...properties}
className={cn(
'outline-none',
properties.className
)}
aria-label='Menu'
{...props}
className={cn('outline-none', props.className)}
aria-label="Menu"
/>
)
);
}
function Items(properties: Parameters<typeof AriaMenu>[0]) {
function Items(props: Parameters<typeof AriaMenu>[0]) {
return (
<Popover className={cn(
'mt-2 rounded-md',
'bg-ui-50 dark:bg-ui-800',
'overflow-hidden z-50',
'border border-ui-200 dark:border-ui-600',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:zoom-in-95',
'exiting:fade-out exiting:zoom-out-95',
'fill-mode-forwards origin-left-right'
)}
<Popover
className={cn(
'mt-2 rounded-md',
'bg-ui-50 dark:bg-ui-800',
'overflow-hidden z-50',
'border border-ui-200 dark:border-ui-600',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:zoom-in-95',
'exiting:fade-out exiting:zoom-out-95',
'fill-mode-forwards origin-left-right',
)}
>
<AriaMenu
{...properties}
{...props}
className={cn(
'outline-none',
'divide-y divide-ui-200 dark:divide-ui-600',
properties.className
props.className,
)}
>
{properties.children}
{props.children}
</AriaMenu>
</Popover>
)
);
}
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
type ButtonProps = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
}
};
function ItemButton(properties: ButtonProperties) {
function ItemButton(props: ButtonProps) {
return (
<MenuItem className='outline-none'>
<MenuItem className="outline-none">
<AriaButton
{...properties}
{...props}
className={cn(
'px-4 py-2 w-full outline-none text-left',
'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
onPress={event => {
properties.onPress?.(event)
properties.control?.[1](true)
onPress={(event) => {
props.onPress?.(event);
props.control?.[1](true);
}}
/>
</MenuItem>
)
);
}
function Item(properties: Parameters<typeof MenuItem>[0]) {
function Item(props: Parameters<typeof MenuItem>[0]) {
return (
<MenuItem
{...properties}
{...props}
className={cn(
'px-4 py-2 w-full outline-none',
'hover:bg-ui-200 dark:hover:bg-ui-700',
properties.className
props.className,
)}
/>
)
);
}
function Menu({ children }: { readonly children: ReactNode }) {
return (
<MenuTrigger>
{children}
</MenuTrigger>
)
function Menu({ children }: { children: ReactNode }) {
return <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 type { ReactNode } from 'react'
import { cn } from '~/utils/cn'
import { InfoIcon } from '@primer/octicons-react';
import type { ReactNode } from 'react';
import { cn } from '~/utils/cn';
interface Props {
className?: string
children: ReactNode
className?: string;
children: ReactNode;
}
export default function Notice({ children, className }: Props) {
return (
<div className={cn(
'p-4 rounded-md w-full flex items-center gap-3',
'bg-ui-200 dark:bg-ui-800',
className,
)}
<div
className={cn(
'p-4 rounded-md w-full flex items-center gap-3',
'bg-ui-200 dark:bg-ui-800',
className,
)}
>
<InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" />
{children}
</div>
)
);
}
+46
View File
@@ -0,0 +1,46 @@
import { PlusIcon, DashIcon } from '@primer/octicons-react';
import type { Dispatch, SetStateAction } from 'react';
import {
Button,
Group,
Input,
NumberField as AriaNumberField,
} from 'react-aria-components';
import { cn } from '~/utils/cn';
type NumberFieldProps = Parameters<typeof AriaNumberField>[0] & {
label: string;
state?: [number, Dispatch<SetStateAction<number>>];
};
export default function NumberField(props: NumberFieldProps) {
return (
<AriaNumberField
{...props}
aria-label={props.label}
className="w-full"
value={props.state?.[0]}
onChange={(value) => {
props.state?.[1](value);
}}
>
<Group
className={cn(
'flex px-2.5 py-1.5 w-full rounded-lg my-1',
'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300 gap-2',
'focus-within:ring-2 focus-within:ring-blue-600',
props.className,
)}
>
<Input className="w-full bg-transparent focus:outline-none" />
<Button slot="decrement">
<DashIcon className="w-4 h-4" />
</Button>
<Button slot="increment">
<PlusIcon className="w-4 h-4" />
</Button>
</Group>
</AriaNumberField>
);
}
+20 -22
View File
@@ -1,5 +1,5 @@
import { ChevronDownIcon } from '@primer/octicons-react'
import { Dispatch, ReactNode, SetStateAction } from 'react'
import { ChevronDownIcon } from '@primer/octicons-react';
import type { Dispatch, ReactNode, SetStateAction } from 'react';
import {
Button,
ListBox,
@@ -7,15 +7,14 @@ import {
Popover,
Select as AriaSelect,
SelectValue,
} from 'react-aria-components'
import { cn } from '~/utils/cn'
} from 'react-aria-components';
import { cn } from '~/utils/cn';
type SelectProps = Parameters<typeof AriaSelect>[0] & {
readonly label: string
readonly state?: [string, Dispatch<SetStateAction<string>>]
readonly children: ReactNode
}
readonly label: string;
readonly state?: [string, Dispatch<SetStateAction<string>>];
readonly children: ReactNode;
};
function Select(props: SelectProps) {
return (
@@ -24,7 +23,7 @@ function Select(props: SelectProps) {
aria-label={props.label}
selectedKey={props.state?.[0]}
onSelectionChange={(key) => {
props.state?.[1](key.toString())
props.state?.[1](key.toString());
}}
className={cn(
'block w-full rounded-lg my-1',
@@ -34,10 +33,11 @@ function Select(props: SelectProps) {
props.className,
)}
>
<Button className={cn(
'w-full flex items-center justify-between',
'px-2.5 py-1.5 rounded-lg',
)}
<Button
className={cn(
'w-full flex items-center justify-between',
'px-2.5 py-1.5 rounded-lg',
)}
>
<SelectValue />
<ChevronDownIcon className="w-4 h-4" aria-hidden="true" />
@@ -46,7 +46,7 @@ function Select(props: SelectProps) {
className={cn(
'mt-2 rounded-md w-[var(--trigger-width)]',
'bg-ui-100 dark:bg-ui-800 shadow-sm',
'overflow-hidden z-50',
'z-50 overflow-y-auto',
'border border-ui-200 dark:border-ui-600',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:zoom-in-95',
@@ -54,15 +54,13 @@ function Select(props: SelectProps) {
'fill-mode-forwards origin-left-right',
)}
>
<ListBox>
{props.children}
</ListBox>
<ListBox orientation="vertical">{props.children}</ListBox>
</Popover>
</AriaSelect>
)
);
}
type ItemProps = Parameters<typeof ListBoxItem>[0]
type ItemProps = Parameters<typeof ListBoxItem>[0];
function Item(props: ItemProps) {
return (
@@ -76,7 +74,7 @@ function Item(props: ItemProps) {
>
{props.children}
</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 = {
// eslint-disable-next-line unicorn/no-keyword-prefix
interface Props {
className?: string;
}
export default function Spinner(properties: Properties) {
export default function Spinner({ className }: Props) {
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
className={clsx(
'animate-spin rounded-full w-full h-full',
'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>
)
);
}
+10 -10
View File
@@ -1,24 +1,24 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
import clsx from 'clsx';
import type { HTMLProps } from 'react';
type Properties = HTMLProps<SVGElement> & {
type Props = HTMLProps<SVGElement> & {
readonly isOnline: boolean;
}
};
// eslint-disable-next-line unicorn/no-keyword-prefix
export default function StatusCircle({ isOnline, className }: Properties) {
export default function StatusCircle({ isOnline, className }: Props) {
return (
<svg
className={clsx(
className,
isOnline
? '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'
fill='currentColor'
viewBox="0 0 24 24"
fill="currentColor"
>
<circle cx='12' cy='12' r='8'/>
<circle cx="12" cy="12" r="8" />
</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 SwitchProperties = Parameters<typeof AriaSwitch>[0] & {
type SwitchProps = Parameters<typeof AriaSwitch>[0] & {
readonly label: string;
}
};
export default function Switch(properties: SwitchProperties) {
export default function Switch(props: SwitchProps) {
return (
<AriaSwitch
{...properties}
aria-label={properties.label}
className='group flex gap-2 items-center'
{...props}
aria-label={props.label}
className="group flex gap-2 items-center"
>
<div
className={cn(
'flex h-[26px] w-[44px] p-[4px] shrink-0',
'rounded-full outline-none group-focus-visible:ring-2',
'bg-main-600/50 dark:bg-main-600/20 group-selected:bg-main-700',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className
props.isDisabled && 'opacity-50 cursor-not-allowed',
props.className,
)}
>
<span className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white transition duration-100 ease-in-out',
'translate-x-0 group-selected:translate-x-[100%]'
)}
<span
className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white transition duration-100 ease-in-out',
'translate-x-0 group-selected:translate-x-[100%]',
)}
/>
</div>
</AriaSwitch>
)
);
}
+17 -16
View File
@@ -1,32 +1,33 @@
import { NavLink } from '@remix-run/react'
import type { ReactNode } from 'react'
import { NavLink } from 'react-router';
import type { ReactNode } from 'react';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
type Properties = {
readonly name: string;
readonly to: string;
readonly icon: ReactNode;
interface Props {
name: string;
to: string;
icon: ReactNode;
}
export default function TabLink({ name, to, icon }: Properties) {
export default function TabLink({ name, to, icon }: Props) {
return (
<NavLink
to={to}
prefetch='intent'
className={({ isActive }) => cn(
'border-b-2 py-1.5',
isActive ? 'border-white' : 'border-transparent'
)}
prefetch="intent"
className={({ isActive }) =>
cn(
'border-b-2 py-1.5',
isActive ? 'border-white' : 'border-transparent',
)
}
>
<div
className={cn(
'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}
</div>
</NavLink>
)
);
}
+13 -14
View File
@@ -1,37 +1,36 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
import clsx from 'clsx';
import type { HTMLProps } from 'react';
function TableList(properties: HTMLProps<HTMLDivElement>) {
function TableList(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...properties}
{...props}
className={clsx(
'border border-gray-300 rounded-lg overflow-clip',
'dark:border-zinc-700 dark:text-gray-300',
// 'dark:bg-zinc-800',
properties.className
props.className,
)}
>
{properties.children}
{props.children}
</div>
)
);
}
function Item(properties: HTMLProps<HTMLDivElement>) {
function Item(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...properties}
{...props}
className={clsx(
'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0',
'dark:border-zinc-800',
properties.className
props.className,
)}
>
{properties.children}
{props.children}
</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 {
Input,
TextField as AriaTextField
} from 'react-aria-components'
import type { Dispatch, SetStateAction } from 'react';
import { Input, TextField as AriaTextField } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
type TextFieldProperties = Parameters<typeof AriaTextField>[0] & {
type TextFieldProps = Parameters<typeof AriaTextField>[0] & {
readonly label: string;
readonly placeholder: string;
readonly state?: [string, Dispatch<SetStateAction<string>>];
}
};
export default function TextField(properties: TextFieldProperties) {
export default function TextField(props: TextFieldProps) {
return (
<AriaTextField
{...properties}
aria-label={properties.label}
className='w-full'
>
<AriaTextField {...props} aria-label={props.label} className="w-full">
<Input
placeholder={properties.placeholder}
value={properties.state?.[0]}
name={properties.name}
placeholder={props.placeholder}
value={props.state?.[0]}
name={props.name}
className={cn(
'block px-2.5 py-1.5 w-full rounded-lg my-1',
'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300',
properties.className
props.className,
)}
onChange={event => {
properties.state?.[1](event.target.value)
onChange={(event) => {
props.state?.[1](event.target.value);
}}
/>
</AriaTextField>
)
);
}
+55 -43
View File
@@ -1,20 +1,32 @@
import { XIcon } from '@primer/octicons-react'
import { type AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
import { ToastQueue, type ToastState, useToastQueue } from '@react-stately/toast'
import { type ReactNode, useRef } from 'react'
import { Button } from 'react-aria-components'
import { createPortal } from 'react-dom'
import { ClientOnly } from 'remix-utils/client-only'
import { XIcon } from '@primer/octicons-react';
import {
AriaToastProps,
useToast,
useToastRegion,
} from '@react-aria/toast';
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 ToastProperties = AriaToastProps<ReactNode> & {
type ToastProps = AriaToastProps<ReactNode> & {
readonly state: ToastState<ReactNode>;
}
};
function Toast({ state, ...properties }: ToastProperties) {
const reference = useRef(null)
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference)
function Toast({ state, ...properties }: ToastProps) {
const reference = useRef(null);
const { toastProps, titleProps, closeButtonProps } = useToast(
properties,
state,
reference,
);
return (
<div
@@ -24,7 +36,7 @@ function Toast({ state, ...properties }: ToastProperties) {
'bg-main-700 dark:bg-main-800 rounded-lg',
'text-main-100 dark:text-main-200 z-50',
'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>
@@ -32,48 +44,48 @@ function Toast({ state, ...properties }: ToastProperties) {
{...closeButtonProps}
className={cn(
'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>
</div>
)
);
}
const toasts = new ToastQueue<ReactNode>({
maxVisibleToasts: 5
})
maxVisibleToasts: 5,
});
export function toast(text: string) {
return toasts.add(text, { timeout: 5000 })
return toasts.add(text, { timeout: 5000 });
}
export function Toaster() {
const reference = useRef(null)
const state = useToastQueue(toasts)
const { regionProps } = useToastRegion({}, state, reference)
const reference = useRef(null);
const state = useToastQueue(toasts);
const { regionProps } = useToastRegion({}, state, reference);
return (
<ClientOnly>
{() => createPortal(
state.visibleToasts.length >= 0 ? (
<div
className={cn(
'fixed bottom-4 right-4',
'flex flex-col gap-4'
)}
{...regionProps}
ref={reference}
>
{state.visibleToasts.map(toast => (
<Toast key={toast.key} toast={toast} state={state}/>
))}
</div>
) : undefined,
document.body
)}
{
() =>
createPortal(
state.visibleToasts.length >= 0 ? (
<div
className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
{...regionProps}
ref={reference}
>
{state.visibleToasts.map((toast) => (
<Toast key={toast.key} toast={toast} state={state} />
))}
</div>
) : undefined,
document.body,
)
}
</ClientOnly>
)
);
}
+16 -22
View File
@@ -1,43 +1,37 @@
import { ReactNode } from 'react'
import type { ReactNode } from 'react';
import {
Button as AriaButton,
Tooltip as AriaTooltip,
TooltipTrigger,
} from 'react-aria-components'
import { cn } from '~/utils/cn'
} from 'react-aria-components';
import { cn } from '~/utils/cn';
interface Props {
children: ReactNode
className?: string
children: ReactNode;
className?: string;
}
function Tooltip({ children }: Props) {
return (
<TooltipTrigger delay={0}>
{children}
</TooltipTrigger>
)
return <TooltipTrigger delay={0}>{children}</TooltipTrigger>;
}
function Button(props: Parameters<typeof AriaButton>[0]) {
return (
<AriaButton {...props} />
)
return <AriaButton {...props} />;
}
function Body({ children, className }: Props) {
return (
<AriaTooltip className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-ui-900 drop-shadow-sm',
'border border-gray-200 dark:border-zinc-700',
className,
)}
<AriaTooltip
className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-ui-900 drop-shadow-sm',
'border border-gray-200 dark:border-zinc-700',
className,
)}
>
{children}
</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 { createReadableStreamFromReadable } from '@remix-run/node'
import { RemixServer } from '@remix-run/react'
import { isbot } from 'isbot'
import { renderToPipeableStream } from 'react-dom/server'
import type { AppLoadContext, EntryContext } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
import { ServerRouter } from "react-router";
import { isbot } from "isbot";
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(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_loadContext: AppLoadContext,
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext
) {
const ua = request.headers.get('user-agent')
const isBot = ua ? isbot(ua) : false
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get("user-agent");
return new Promise((resolve, reject) => {
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={5000}
/>,
{
[isBot ? 'onAllReady' : 'onShellReady']() {
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set('Content-Type', 'text/html')
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
? "onAllReady"
: "onShellReady";
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
)
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
pipe(body)
},
onShellError(error: unknown) {
reject(error as Error)
},
onError(error: unknown) {
responseStatusCode = 500
if (shellRendered) {
console.error(error)
}
},
},
)
responseHeaders.set("Content-Type", "text/html");
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);
});
}
+68 -63
View File
@@ -1,17 +1,17 @@
import { access, constants } from 'node:fs/promises'
import { setTimeout } from 'node:timers/promises'
import { access, constants } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises';
import { Client } from 'undici'
import { Client } from 'undici';
import { HeadscaleError, pull } from '~/utils/headscale'
import log from '~/utils/log'
import { HeadscaleError, pull } from '~/utils/headscale';
import log from '~/utils/log';
import { createIntegration } from './integration'
import { createIntegration } from './integration';
interface Context {
client: Client | undefined
container: string | undefined
maxAttempts: number
client: Client | undefined;
container: string | undefined;
maxAttempts: number;
}
export default createIntegration<Context>({
@@ -24,121 +24,126 @@ export default createIntegration<Context>({
isAvailable: async (context) => {
// Check for the HEADSCALE_CONTAINER environment variable first
// to avoid unnecessary fetching of the Docker socket
context.container = process.env.HEADSCALE_CONTAINER
?.trim()
.toLowerCase()
log.debug('INTG', 'Checking Docker integration availability');
context.container = process.env.HEADSCALE_CONTAINER?.trim().toLowerCase();
if (!context.container || context.container.length === 0) {
log.error('INTG', 'Missing HEADSCALE_CONTAINER variable')
return false
log.error('INTG', 'Missing HEADSCALE_CONTAINER variable');
return false;
}
log.info('INTG', 'Using container: %s', context.container)
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'
let url: URL | undefined
log.info('INTG', 'Using container: %s', context.container);
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock';
let url: URL | undefined;
try {
url = new URL(path)
url = new URL(path);
} catch {
log.error('INTG', 'Invalid Docker socket path: %s', path)
return false
log.error('INTG', 'Invalid Docker socket path: %s', path);
return false;
}
if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') {
log.error('INTG', 'Invalid Docker socket protocol: %s',
url.protocol,
)
return false
log.error('INTG', 'Invalid Docker socket protocol: %s', url.protocol);
return false;
}
// The API is available as an HTTP endpoint and this
// will simplify the fetching logic in undici
if (url.protocol === 'tcp:') {
url.protocol = 'http:'
// Apparently setting url.protocol doesn't work anymore?
const fetchU = url.href.replace(url.protocol, 'http:');
try {
log.info('INTG', 'Checking API: %s', url.href)
await fetch(new URL('/v1.30/version', url).href)
} catch {
log.error('INTG', 'Failed to connect to Docker API')
return false
log.info('INTG', 'Checking API: %s', fetchU);
await fetch(new URL('/v1.30/version', fetchU).href);
} catch (error) {
log.debug('INTG', 'Failed to connect to Docker API', error);
log.error('INTG', 'Failed to connect to Docker API');
return false;
}
context.client = new Client(url.href)
context.client = new Client(fetchU);
}
// Check if the socket is accessible
if (url.protocol === 'unix:') {
try {
log.info('INTG', 'Checking socket: %s',
url.pathname,
)
await access(url.pathname, constants.R_OK)
} catch {
log.error('INTG', 'Failed to access Docker socket: %s',
path,
)
return false
log.info('INTG', 'Checking socket: %s', url.pathname);
await access(url.pathname, constants.R_OK);
} catch (error) {
log.debug('INTG', 'Failed to access Docker socket: %s', error);
log.error('INTG', 'Failed to access Docker socket: %s', path);
return false;
}
context.client = new Client('http://localhost', {
socketPath: url.pathname,
})
});
}
return context.client !== undefined
return context.client !== undefined;
},
onConfigChange: async (context) => {
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) {
log.debug(
'INTG',
'Restarting container: %s (attempt %d)',
context.container,
attempts,
);
const response = await context.client.request({
method: 'POST',
path: `/v1.30/containers/${context.container}/restart`,
})
});
if (response.statusCode !== 204) {
if (attempts < context.maxAttempts) {
attempts++
await setTimeout(1000)
continue
attempts++;
await setTimeout(1000);
continue;
}
const stringCode = response.statusCode.toString()
const body = await response.body.text()
throw new Error(`API request failed: ${stringCode} ${body}`)
const stringCode = response.statusCode.toString();
const body = await response.body.text();
throw new Error(`API request failed: ${stringCode} ${body}`);
}
break
break;
}
attempts = 0
attempts = 0;
while (attempts <= context.maxAttempts) {
try {
await pull('v1', '')
return
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts);
await pull('v1', '');
return;
} catch (error) {
if (error instanceof HeadscaleError && error.status === 401) {
break
break;
}
if (error instanceof HeadscaleError && error.status === 404) {
break
break;
}
if (attempts < context.maxAttempts) {
attempts++
await setTimeout(1000)
continue
attempts++;
await setTimeout(1000);
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 { IntegrationFactory } from './integration'
import kubernetesIntegration from './kubernetes'
import procIntegration from './proc'
import dockerIntegration from './docker';
import type { IntegrationFactory } from './integration';
import kubernetesIntegration from './kubernetes';
import procIntegration from './proc';
export * from './integration'
export * from './integration';
export async function loadIntegration() {
let integration = process.env.HEADSCALE_INTEGRATION
?.trim()
.toLowerCase()
let integration = process.env.HEADSCALE_INTEGRATION?.trim().toLowerCase();
// Old HEADSCALE_CONTAINER variable upgrade path
// This ensures that when people upgrade from older versions of Headplane
// they don't explicitly need to define the new HEADSCALE_INTEGRATION
// variable that is needed to configure docker
if (!integration && process.env.HEADSCALE_CONTAINER) {
integration = 'docker'
integration = 'docker';
}
if (!integration) {
log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION')
return
log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION');
return;
}
let integrationFactory: IntegrationFactory | undefined
let integrationFactory: IntegrationFactory | undefined;
switch (integration.toLowerCase().trim()) {
case 'docker': {
integrationFactory = dockerIntegration
break
integrationFactory = dockerIntegration;
break;
}
case 'proc':
case 'native':
case 'linux': {
integrationFactory = procIntegration
break
integrationFactory = procIntegration;
break;
}
case 'kubernetes':
case 'k8s': {
integrationFactory = kubernetesIntegration
break
integrationFactory = kubernetesIntegration;
break;
}
default: {
log.error('INTG', 'Unknown integration: %s', integration)
throw new Error(`Unknown integration: ${integration}`)
log.error('INTG', 'Unknown integration: %s', integration);
throw new Error(`Unknown integration: ${integration}`);
}
}
log.info('INTG', 'Loading integration: %s', integration)
log.info('INTG', 'Loading integration: %s', integration);
try {
const res = await integrationFactory.isAvailable(
integrationFactory.context,
)
);
if (!res) {
log.error('INTG', 'Integration %s is not available',
integration,
)
return
log.error('INTG', 'Integration %s is not available', integration);
return;
}
} catch (error) {
log.error('INTG', 'Failed to load integration %s: %s',
integration,
error,
)
return
log.error('INTG', 'Failed to load integration %s: %s', integration, error);
return;
}
log.info('INTG', 'Loaded integration: %s', integration)
return integrationFactory
log.info('INTG', 'Loaded integration: %s', integration);
return integrationFactory;
}
+6 -8
View File
@@ -1,13 +1,11 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface IntegrationFactory<T = any> {
name: string
context: T
isAvailable: (context: T) => Promise<boolean> | boolean
onConfigChange?: (context: T) => Promise<void> | void
name: string;
context: T;
isAvailable: (context: T) => Promise<boolean> | boolean;
onConfigChange?: (context: T) => Promise<void> | void;
}
export function createIntegration<T>(
options: IntegrationFactory<T>,
) {
return options
export function createIntegration<T>(options: IntegrationFactory<T>) {
return options;
}
+105 -82
View File
@@ -1,16 +1,16 @@
import { readdir, readFile } from 'node:fs/promises'
import { platform } from 'node:os'
import { join, resolve } from 'node:path'
import { kill } from 'node:process'
import { readdir, readFile } from 'node:fs/promises';
import { platform } from 'node:os';
import { join, resolve } from 'node:path';
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 {
pid: number | undefined
pid: number | undefined;
}
export default createIntegration<Context>({
@@ -20,169 +20,192 @@ export default createIntegration<Context>({
},
isAvailable: async (context) => {
if (platform() !== 'linux') {
log.error('INTG', 'Kubernetes is only available on Linux')
return false
log.error('INTG', 'Kubernetes is only available on Linux');
return false;
}
const svcRoot = Config.SERVICEACCOUNT_ROOT
const svcRoot = Config.SERVICEACCOUNT_ROOT;
try {
const files = await readdir(svcRoot)
log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot);
const files = await readdir(svcRoot);
if (files.length === 0) {
log.error('INTG', 'Kubernetes service account not found')
return false
log.error('INTG', 'Kubernetes service account not found');
return false;
}
const mappedFiles = new Set(files.map(file => join(svcRoot, file)))
const mappedFiles = new Set(files.map((file) => join(svcRoot, file)));
const expectedFiles = [
Config.SERVICEACCOUNT_CA_PATH,
Config.SERVICEACCOUNT_TOKEN_PATH,
Config.SERVICEACCOUNT_NAMESPACE_PATH,
]
];
if (!expectedFiles.every(file => mappedFiles.has(file))) {
log.error('INTG', 'Malformed Kubernetes service account')
return false
log.debug('INTG', 'Looking for %s', expectedFiles.join(', '));
if (!expectedFiles.every((file) => mappedFiles.has(file))) {
log.error('INTG', 'Malformed Kubernetes service account');
return false;
}
} catch (error) {
log.error('INTG', 'Failed to access %s: %s', svcRoot, error)
return false
log.error('INTG', 'Failed to access %s: %s', svcRoot, error);
return false;
}
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot);
const namespace = await readFile(
Config.SERVICEACCOUNT_NAMESPACE_PATH,
'utf8',
)
);
// Some very ugly nesting but it's necessary
if (process.env.HEADSCALE_INTEGRATION_UNSTRICT === 'true') {
log.warn('INTG', 'Skipping strict Pod status check')
log.warn('INTG', 'Skipping strict Pod status check');
} else {
const pod = process.env.POD_NAME
const pod = process.env.POD_NAME;
if (!pod) {
log.error('INTG', 'Missing POD_NAME variable')
return false
log.error('INTG', 'Missing POD_NAME variable');
return false;
}
if (pod.trim().length === 0) {
log.error('INTG', 'Pod name is empty')
return false
log.error('INTG', 'Pod name is empty');
return false;
}
try {
const kc = new KubeConfig()
kc.loadFromCluster()
log.debug(
'INTG',
'Checking Kubernetes pod %s in namespace %s',
pod,
namespace,
);
const cluster = kc.getCurrentCluster()
try {
log.debug('INTG', 'Attempgin to get cluster KubeConfig');
const kc = new KubeConfig();
kc.loadFromCluster();
const cluster = kc.getCurrentCluster();
if (!cluster) {
log.error('INTG', 'Malformed kubeconfig')
return false
log.error('INTG', 'Malformed kubeconfig');
return false;
}
log.info('INTG', 'Service account connected to %s (%s)',
log.info(
'INTG',
'Service account connected to %s (%s)',
cluster.name,
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,
namespace,
kCoreV1Api.basePath,
)
);
log.debug('INTG', 'Reading pod info for %s', pod);
const { response, body } = await kCoreV1Api.readNamespacedPod(
pod,
namespace,
)
);
if (response.statusCode !== 200) {
log.error('INTG', 'Failed to read pod info: http %d',
response.statusCode,
)
return false
}
const shared = body.spec?.shareProcessNamespace
if (shared === undefined) {
log.error(
'INTG',
'Pod does not have spec.shareProcessNamespace set',
)
return false
'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('INTG', 'Pod does not have spec.shareProcessNamespace set');
return false;
}
if (!shared) {
log.error(
'INTG',
'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) {
log.error('INTG', 'Failed to read pod info: %s', error)
return false
log.error('INTG', 'Failed to read pod info: %s', error);
return false;
}
}
const dir = resolve('/proc')
log.debug('INTG', 'Looking for namespaced process in /proc');
const dir = resolve('/proc');
try {
const subdirs = await readdir(dir)
const subdirs = await readdir(dir);
const promises = subdirs.map(async (dir) => {
const pid = Number.parseInt(dir, 10)
const pid = Number.parseInt(dir, 10);
if (Number.isNaN(pid)) {
return
return;
}
const path = join('/proc', dir, 'cmdline')
const path = join('/proc', dir, 'cmdline');
try {
const data = await readFile(path, 'utf8')
log.debug('INTG', 'Reading %s', path);
const data = await readFile(path, 'utf8');
if (data.includes('headscale')) {
return pid
return pid;
}
} catch {}
})
} catch (error) {
log.debug('INTG', 'Failed to read %s: %s', path, error);
}
});
const results = await Promise.allSettled(promises)
const pids = []
const results = await Promise.allSettled(promises);
const pids = [];
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
pids.push(result.value)
pids.push(result.value);
}
}
log.debug('INTG', 'Found Headscale processes: %o', pids);
if (pids.length > 1) {
log.error('INTG', 'Found %d Headscale processes: %s',
log.error(
'INTG',
'Found %d Headscale processes: %s',
pids.length,
pids.join(', '),
)
return false
);
return false;
}
if (pids.length === 0) {
log.error('INTG', 'Could not find Headscale process')
return false
log.error('INTG', 'Could not find Headscale process');
return false;
}
context.pid = pids[0]
log.info('INTG', 'Found Headscale process with PID: %d', context.pid)
return true
context.pid = pids[0];
log.info('INTG', 'Found Headscale process with PID: %d', context.pid);
return true;
} catch {
log.error('INTG', 'Failed to read /proc')
return false
log.error('INTG', 'Failed to read /proc');
return false;
}
},
onConfigChange: (context) => {
if (!context.pid) {
return
return;
}
log.info('INTG', 'Sending SIGTERM to Headscale')
kill(context.pid, 'SIGTERM')
log.info('INTG', 'Sending SIGTERM to Headscale');
kill(context.pid, 'SIGTERM');
},
})
});
+40 -33
View File
@@ -1,14 +1,14 @@
import { readdir, readFile } from 'node:fs/promises'
import { platform } from 'node:os'
import { join, resolve } from 'node:path'
import { kill } from 'node:process'
import { readdir, readFile } from 'node:fs/promises';
import { platform } from 'node:os';
import { join, resolve } from 'node:path';
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 {
pid: number | undefined
pid: number | undefined;
}
export default createIntegration<Context>({
@@ -18,57 +18,64 @@ export default createIntegration<Context>({
},
isAvailable: async (context) => {
if (platform() !== 'linux') {
log.error('INTG', '/proc is only available on Linux')
return false
log.error('INTG', '/proc is only available on Linux');
return false;
}
const dir = resolve('/proc')
log.debug('INTG', 'Checking /proc for Headscale process');
const dir = resolve('/proc');
try {
const subdirs = await readdir(dir)
const subdirs = await readdir(dir);
const promises = subdirs.map(async (dir) => {
const pid = Number.parseInt(dir, 10)
const pid = Number.parseInt(dir, 10);
if (Number.isNaN(pid)) {
return
return;
}
const path = join('/proc', dir, 'cmdline')
const path = join('/proc', dir, 'cmdline');
try {
const data = await readFile(path, 'utf8')
log.debug('INTG', 'Reading %s', path);
const data = await readFile(path, 'utf8');
if (data.includes('headscale')) {
return pid
return pid;
}
} catch {}
})
} catch (error) {
log.error('INTG', 'Failed to read %s: %s', path, error);
}
});
const results = await Promise.allSettled(promises)
const pids = []
const results = await Promise.allSettled(promises);
const pids = [];
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
pids.push(result.value)
pids.push(result.value);
}
}
log.debug('INTG', 'Found Headscale processes: %o', pids);
if (pids.length > 1) {
log.error('INTG', 'Found %d Headscale processes: %s',
log.error(
'INTG',
'Found %d Headscale processes: %s',
pids.length,
pids.join(', '),
)
return false
);
return false;
}
if (pids.length === 0) {
log.error('INTG', 'Could not find Headscale process')
return false
log.error('INTG', 'Could not find Headscale process');
return false;
}
context.pid = pids[0]
log.info('INTG', 'Found Headscale process with PID: %d', context.pid)
return true
context.pid = pids[0];
log.info('INTG', 'Found Headscale process with PID: %d', context.pid);
return true;
} catch {
log.error('INTG', 'Failed to read /proc')
return false
log.error('INTG', 'Failed to read /proc');
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 {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from '@remix-run/react'
import type { LoaderFunctionArgs, LinksFunction, MetaFunction } from 'react-router';
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from 'react-router';
import { loadContext } from '~/utils/config/headplane';
import { ErrorPopup } from '~/components/Error'
import { Toaster } from '~/components/Toaster'
import stylesheet from '~/tailwind.css?url'
import { ProgressBar } from 'react-aria-components';
import { ErrorPopup } from '~/components/Error';
// 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 = () => [
{ 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 = () => [
{ rel: 'stylesheet', href: stylesheet },
]
];
export function Layout({ children }: { readonly children: React.ReactNode }) {
return (
@@ -36,13 +37,28 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
<Scripts />
</body>
</html>
)
);
}
export function ErrorBoundary() {
return <ErrorPopup />
return <ErrorPopup />;
}
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'),
]),
]),
]),
];
-99
View File
@@ -1,99 +0,0 @@
import Editor, { DiffEditor, Monaco } from '@monaco-editor/react'
import { useEffect, useState } from 'react'
import { ClientOnly } from 'remix-utils/client-only'
import Fallback from '~/routes/_data.acls._index/fallback'
import { cn } from '~/utils/cn'
interface Props {
variant: 'edit' | 'diff'
language: 'json' | 'yaml'
state: [string, (value: string) => void]
policy?: string
isDisabled?: boolean
}
function monacoCallback(monaco: Monaco) {
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
allowComments: true,
schemas: [],
enableSchemaRequest: true,
trailingCommas: 'ignore',
})
monaco.languages.register({ id: 'json' })
monaco.languages.register({ id: 'yaml' })
}
export default function MonacoEditor({ variant, language, state, policy, isDisabled }: Props) {
const [light, setLight] = useState(false)
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)')
setLight(theme.matches)
theme.addEventListener('change', (theme) => {
setLight(theme.matches)
})
}, [])
return (
<>
<div className={cn(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
)}
>
<div className="overflow-y-scroll h-editor text-sm">
<ClientOnly fallback={<Fallback acl={state[0]} />}>
{() => variant === 'edit'
? (
<Editor
height="100%"
language={language}
theme={light ? 'light' : 'vs-dark'}
value={state[0]}
onChange={(updated) => {
if (!updated) {
return
}
if (updated !== state[0]) {
state[1](updated)
}
}}
loading={<Fallback acl={state[0]} />}
beforeMount={monacoCallback}
options={{
wordWrap: 'on',
minimap: { enabled: false },
fontSize: 14,
readOnly: isDisabled,
}}
/>
)
: (
<DiffEditor
height="100%"
language={language}
theme={light ? 'light' : 'vs-dark'}
original={policy}
modified={state[0]}
loading={<Fallback acl={state[0]} />}
beforeMount={monacoCallback}
options={{
wordWrap: 'on',
minimap: { enabled: false },
fontSize: 13,
readOnly: isDisabled,
}}
/>
)}
</ClientOnly>
</div>
</div>
</>
)
}
-315
View File
@@ -1,315 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
import { type ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
import { useFetcher, useLoaderData } from '@remix-run/react'
import { useEffect, useState } from 'react'
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
import Button from '~/components/Button'
import Code from '~/components/Code'
import Link from '~/components/Link'
import Notice from '~/components/Notice'
import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { HeadscaleError, pull, put } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import Monaco from './editor'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
try {
const { policy } = await pull<{ policy: string }>(
'v1/policy',
session.get('hsApiKey')!,
)
try {
// We have read access, now do we have write access?
// Attempt to set the policy to what we just got
await put('v1/policy', session.get('hsApiKey')!, {
policy,
})
return {
hasAclWrite: true,
currentAcl: policy,
aclType: 'json',
} as const
} catch (error) {
if (!(error instanceof HeadscaleError)) {
throw error
}
if (error.status === 500) {
return {
hasAclWrite: false,
currentAcl: policy,
aclType: 'json',
} as const
}
}
} catch {}
return {
hasAclWrite: true,
currentAcl: '',
aclType: 'json',
} as const
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ success: false }, {
status: 401,
})
}
const { acl } = await request.json() as { acl: string, api: boolean }
try {
await put('v1/policy', session.get('hsApiKey')!, {
policy: acl,
})
return json({ success: true })
} catch (error) {
return json({ success: false }, {
status: error instanceof HeadscaleError ? error.status : 500,
})
}
return json({ success: true })
}
export function ErrorBoundary() {
return (
<div>
<Notice className="mb-4">
An ACL policy is not available or an error occurred while trying to fetch it.
</Notice>
<h1 className="text-2xl font-medium mb-4">
Access Control List (ACL)
</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your network.
You can find more information about the ACL file in the
{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>
{' '}
and the
{' '}
<Link
to="https://headscale.net/acls"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
<div>
<div className="max-w-prose">
<p className="mb-4 text-md">
If you are running Headscale 0.23-beta1 or later, the
ACL configuration is most likely set to
{' '}
<Code>file</Code>
{' '}
mode but the ACL file is not available. In order to
resolve this you will either need to correctly set
{' '}
<Code>policy.path</Code>
{' '}
in your Headscale configuration or set the
{' '}
<Code>policy.mode</Code>
{' '}
to
{' '}
<Code>database</Code>
.
</p>
</div>
</div>
</div>
)
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const fetcher = useFetcher<typeof action>()
const [acl, setAcl] = useState(data.currentAcl)
const [toasted, setToasted] = useState(false)
useEffect(() => {
if (!fetcher.data || toasted) {
return
}
if (fetcher.data.success) {
toast('Updated tailnet ACL policy')
} else {
toast('Failed to update tailnet ACL policy')
}
setToasted(true)
setAcl(data.currentAcl)
}, [fetcher.data, toasted, data.currentAcl])
return (
<div>
{data.hasAclWrite
? undefined
: (
<div className="mb-4">
<Notice className="w-fit">
The ACL policy is read-only. You can view the current policy
but you cannot make changes to it.
<br />
To resolve this, you need to set the ACL policy mode to
database in your Headscale configuration.
</Notice>
</div>
)}
<h1 className="text-2xl font-medium mb-4">
Access Control List (ACL)
</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your network.
You can find more information about the ACL file in the
{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>
{' '}
and the
{' '}
<Link
to="https://headscale.net/acls"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
<Tabs>
<TabList className={cn(
'flex border-t border-gray-200 dark:border-gray-700',
'w-fit rounded-t-lg overflow-hidden',
'text-gray-400 dark:text-gray-500',
)}
>
<Tab
id="edit"
className={({ isSelected }) => cn(
'px-4 py-2 rounded-tl-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)}
>
<PencilIcon className="w-5 h-5" />
<p>Edit file</p>
</Tab>
<Tab
id="diff"
className={({ isSelected }) => cn(
'px-4 py-2',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)}
>
<EyeIcon className="w-5 h-5" />
<p>Preview changes</p>
</Tab>
<Tab
id="preview"
className={({ isSelected }) => cn(
'px-4 py-2 rounded-tr-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)}
>
<BeakerIcon className="w-5 h-5" />
<p>Preview rules</p>
</Tab>
</TabList>
<TabPanel id="edit">
<Monaco
isDisabled={!data.hasAclWrite}
variant="edit"
language={data.aclType}
state={[acl, setAcl]}
/>
</TabPanel>
<TabPanel id="diff">
<Monaco
variant="diff"
language={data.aclType}
state={[acl, setAcl]}
policy={data.currentAcl}
/>
</TabPanel>
<TabPanel id="preview">
<div
className={cn(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
'p-16 flex flex-col items-center justify-center',
)}
>
<IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" />
<p className="w-1/2 text-center mt-4">
The Preview rules is very much still a work in progress.
It is a bit complicated to implement right now but hopefully it will be available soon.
</p>
</div>
</TabPanel>
</Tabs>
<Button
variant="heavy"
className="mr-2"
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
onPress={() => {
setToasted(false)
fetcher.submit({
acl,
}, {
method: 'PATCH',
encType: 'application/json',
})
}}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Save
</Button>
<Button
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl || !data.hasAclWrite}
onPress={() => { setAcl(data.currentAcl) }}
>
Discard Changes
</Button>
</div>
)
}
-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>
)
}
-111
View File
@@ -1,111 +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[key]}
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()
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>
{nameservers.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': nameservers
.filter((_, i) => i !== index),
}, {
method: 'PATCH',
encType: 'application/json',
})
} else {
const key = `dns.nameservers.split."${name}"`
const list = nameservers.filter((_, i) => i !== index)
submit({
[key]: list.length ? list : null,
}, {
method: 'PATCH',
encType: 'application/json',
})
}
}}
>
Remove
</Button>
</TableList.Item>
))}
</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>
)
}
-201
View File
@@ -1,201 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node'
import { Link, useLoaderData } from '@remix-run/react'
import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { type Machine, Route, User } from '~/types'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import { menuAction } from './_data.machines._index/action'
import MenuOptions from './_data.machines._index/menu'
export async function loader({ request, params }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!params.id) {
throw new Error('No machine ID provided')
}
const context = await loadContext()
let magic: string | undefined
if (context.config.read) {
const config = await loadConfig()
if (config.dns.magic_dns) {
magic = config.dns.base_domain
}
}
const [machine, routes, users] = await Promise.all([
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
])
return {
machine: machine.node,
routes: routes.routes.filter(route => route.node.id === params.id),
users: users.users,
magic,
}
}
export async function action({ request }: ActionFunctionArgs) {
return menuAction(request)
}
export default function Page() {
const { machine, magic, routes, users } = useLoaderData<typeof loader>()
useLiveData({ interval: 1000 })
const expired = machine.expiry === '0001-01-01 00:00:00'
|| machine.expiry === '0001-01-01T00:00:00Z'
? false
: new Date(machine.expiry).getTime() < Date.now()
const tags = [
...machine.forcedTags,
...machine.validTags,
]
if (expired) {
tags.unshift('Expired')
}
return (
<div>
<p className="mb-8 text-md">
<Link
to="/machines"
className="font-medium"
>
All Machines
</Link>
<span className="mx-2">
/
</span>
{machine.givenName}
</p>
<div className="flex justify-between items-center">
<span className="flex items-baseline gap-x-4 text-sm mb-4">
<h1 className="text-2xl font-medium">
{machine.givenName}
</h1>
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
</span>
<MenuOptions
machine={machine}
routes={routes}
users={users}
magic={magic}
/>
</div>
<div className="flex gap-1 mt-1 mb-8">
{tags.map(tag => (
<span
key={tag}
className={cn(
'text-xs rounded-md px-1.5 py-0.5',
'bg-ui-200 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300',
)}
>
{tag}
</span>
))}
</div>
<h2 className="text-xl font-medium mb-4">
Machine Details
</h2>
<Card variant="flat" className="w-full max-w-full">
<Attribute name="Creator" value={machine.user.name} />
<Attribute name="Node ID" value={machine.id} />
<Attribute name="Node Name" value={machine.givenName} />
<Attribute name="Hostname" value={machine.name} />
<Attribute
isCopyable
name="Node Key"
value={machine.nodeKey}
/>
<Attribute
name="Created"
value={new Date(machine.createdAt).toLocaleString()}
/>
<Attribute
name="Last Seen"
value={new Date(machine.lastSeen).toLocaleString()}
/>
<Attribute
name="Expiry"
value={new Date(machine.expiry).toLocaleString()}
/>
{magic
? (
<Attribute
isCopyable
name="Domain"
value={`${machine.givenName}.${machine.user.name}.${magic}`}
/>
)
: undefined}
</Card>
<h2 className="text-xl font-medium mb-4 mt-8">
Machine Routes
</h2>
<Card variant="flat" className="w-full max-w-full">
{routes.length === 0
? (
<div
className={cn(
'flex py-4 px-4',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>
No routes are advertised on this machine.
</p>
</div>
)
: routes.map((route, i) => (
<div
key={route.id}
className={cn(
'flex items-center justify-between',
routes.length - 1 === i ? 'border-b pb-3 mb-2' : '',
'border-ui-100 dark:border-ui-800',
)}
>
<div>
<p className="font-mono mb-1">
{route.prefix}
</p>
<p className="text-sm text-ui-600 dark:text-ui-300">
{' '}
(Created:
{' '}
{new Date(route.createdAt).toLocaleString()}
)
</p>
</div>
<div className="text-right">
<p className="mb-1">
{route.enabled ? 'Enabled' : 'Disabled'}
</p>
<p className="text-sm text-ui-600 dark:text-ui-300">
{route.isPrimary ? 'Primary' : 'Secondary'}
</p>
</div>
</div>
))}
</Card>
</div>
)
}
-106
View File
@@ -1,106 +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'
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 '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(',') ?? []
try {
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
tags,
})
return json({ message: 'Tags updated' })
} catch {
return json({ message: 'Failed to update tags' }, {
status: 500,
})
}
}
default: {
return json({ message: 'Invalid method' }, {
status: 400,
})
}
}
}
@@ -1,119 +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.
When MagicDNS is enabled, the username of the owner
will control the hostname of the machine.
</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
? (
owner === machine.user.name
? (
<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}
.
{owner}
.
{magic}
</Code>
.
</p>
)
: (
<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">
{machine.givenName}
.
{owner}
.
{magic}
</Code>
{'. '}
The hostname
{' '}
<Code className="text-sm">
{machine.givenName}
.
{machine.user.name}
.
{magic}
</Code>
{' '}
will no longer point to this machine.
</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,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,97 +0,0 @@
import { useFetcher } from '@remix-run/react'
import { type Dispatch, type SetStateAction } from 'react'
import Dialog from '~/components/Dialog'
import Switch from '~/components/Switch'
import { type Machine, type 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()
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Edit route settings of
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on
by advertising IP ranges as subnet routes.
</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',
)}
>
{routes.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}
{routes.map(route => (
<div
key={route.node.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>
<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,154 +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 { type Machine, type 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'
? 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
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>
)
}
-110
View File
@@ -1,110 +0,0 @@
import { KebabHorizontalIcon } from '@primer/octicons-react'
import { 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
}
export default function Menu({ machine, routes, magic, users }: 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'
? 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>
<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>
</>
)
}
-113
View File
@@ -1,113 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { InfoIcon } from '@primer/octicons-react'
import { type ActionFunctionArgs, type 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 { type Machine, type Route, User } from '~/types'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import { menuAction } from './action'
import MachineRow from './machine'
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,
}
}
export async function action({ request }: ActionFunctionArgs) {
return menuAction(request)
}
export default function Page() {
useLiveData({ interval: 3000 })
const data = useLoaderData<typeof loader>()
return (
<>
<h1 className="text-2xl font-medium mb-4">Machines</h1>
<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>
</>
)
}
-13
View File
@@ -1,13 +0,0 @@
import { IssueDraftIcon } from '@primer/octicons-react'
export default function Page() {
return (
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center my-8'>
<IssueDraftIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
<p className='text-lg mt-8'>
The settings page is currently unavailable.
It will be available in a future release.
</p>
</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>
)
}
-92
View File
@@ -1,92 +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
magic?: string
}
export default function Remove({ username, magic }: 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.
{magic
? (
<p className="text-sm mt-8 text-ui-600 dark:text-ui-300">
{' '}
Since Magic DNS is enabled, machines
currently accessible via
{' '}
<Code>
[machine].
{username}
.
{magic}
</Code>
{' '}
will become orphaned and inaccessible.
</p>
)
: undefined}
</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>
</>
)
}
-106
View File
@@ -1,106 +0,0 @@
import { PencilIcon } 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'
import TextField from '~/components/TextField'
interface Props {
username: string
magic?: string
}
export default function Rename({ username, magic }: Props) {
const submit = useSubmit()
const dialogState = useState(false)
const [newName, setNewName] = useState(username)
return (
<>
<Button
variant="light"
control={dialogState}
className="rounded-full p-0 w-8 h-8"
>
<PencilIcon className="w-4 h-4" />
</Button>
<Dialog control={dialogState}>
<Dialog.Panel control={dialogState}>
{close => (
<>
<Dialog.Title>
Rename
{' '}
{username}
?
</Dialog.Title>
<Dialog.Text className="mb-8">
Enter a new username for
{' '}
{username}
?
{magic
? (
<p className="text-sm mt-8 text-ui-600 dark:text-ui-300">
{' '}
Since Magic DNS is enabled, machines
currently accessible via
{' '}
<Code>
[machine].
{username}
.
{magic}
</Code>
{' '}
will now become accessible via
{' '}
<Code>
[machine].
.
{magic}
</Code>
.
</p>
)
: undefined}
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget)
}}
>
<input type="hidden" name="_method" value="rename" />
<input type="hidden" name="old" value={username} />
<TextField
label="Username"
placeholder="my-new-name"
name="new"
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={close}
>
Rename
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
</>
)
}
-351
View File
@@ -1,351 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { type DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
import { PersonIcon } from '@primer/octicons-react'
import { type ActionFunctionArgs, json, type 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 { type Machine, type 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}
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} magic={magic} />
)
: 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,11 +1,11 @@
import Spinner from '~/components/Spinner'
import { cn } from '~/utils/cn'
import Spinner from '~/components/Spinner';
import { cn } from '~/utils/cn';
interface FallbackProps {
readonly acl: string
interface Props {
readonly acl: string;
}
export default function Fallback({ acl }: FallbackProps) {
export default function Fallback({ acl }: Props) {
return (
<div className="inline-block relative w-full h-editor">
<Spinner className="w-4 h-4 absolute p-2" />
@@ -15,10 +15,10 @@ export default function Fallback({ acl }: FallbackProps) {
'w-full h-editor font-mono resize-none',
'text-sm text-gray-600 dark:text-gray-300',
'bg-ui-100 dark:bg-ui-800',
'pl-16 pr-8 pt-0.5 leading-snug',
'pl-10 pt-1 leading-snug',
)}
value={acl}
/>
</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>
);
}
+352
View File
@@ -0,0 +1,352 @@
import {
BeakerIcon,
EyeIcon,
IssueDraftIcon,
PencilIcon,
} from '@primer/octicons-react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
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 Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Spinner from '~/components/Spinner';
import { toast } from '~/components/Toaster';
import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale';
import { HeadscaleError, pull, put } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { send } from '~/utils/res';
import log from '~/utils/log';
import { Editor, Differ } from './components/cm.client';
import { Unavailable } from './components/unavailable';
import { ErrorView } from './components/error';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
// The way policy is handled in 0.23 of Headscale and later is verbose.
// The 2 ACL policy modes are either the database one or file one
//
// File: The ACL policy is readonly to the API and manually edited
// Database: The ACL policy is read/write to the API
//
// To determine if we first have an ACL policy available we need to check
// if fetching the v1/policy route gives us a 500 status code or a 200.
//
// 500 can mean many different things here unfortunately:
// - In file based that means the file is not accessible
// - In database mode this can mean that we have never set an ACL policy
// - In database mode this can mean that the ACL policy is not available
// - A general server error may have occurred
//
// Unfortunately the server errors are not very descriptive so we have to
// do some silly guesswork here. If we are running in an integration mode
// and have the Headscale configuration available to us, our assumptions
// can be more accurate, otherwise we just HAVE to assume that the ACL
// policy has never been set.
//
// We can do damage control by checking for write access and if we are not
// able to PUT an ACL policy on the v1/policy route, we can already know
// that the policy is at the very-least readonly or not available.
const context = await loadContext();
let modeGuess = 'database'; // Assume database mode
if (context.config.read) {
const config = await loadConfig();
modeGuess = config.policy?.mode ?? 'database';
}
// Attempt to load the policy, for both the frontend and for checking
// if we are able to write to the policy for write access
try {
const { policy } = await pull<{ policy: string }>(
'v1/policy',
session.get('hsApiKey')!,
);
let write = false; // On file mode we already know it's readonly
if (modeGuess === 'database' && policy.length > 0) {
try {
await put('v1/policy', session.get('hsApiKey')!, {
policy: policy,
});
write = true;
} catch (error) {
write = false;
log.debug('APIC', 'Failed to write to ACL policy with error %s', error);
}
}
return {
read: true,
write,
mode: modeGuess,
policy,
};
} catch {
// If we are explicit on file mode then this is the end of the road
if (modeGuess === 'file') {
return {
read: false,
write: false,
mode: modeGuess,
policy: null,
};
}
// Assume that we have write access otherwise?
// This is sort of a brittle assumption to make but we don't want
// to create a default policy if we don't have to.
return {
read: true,
write: true,
mode: modeGuess,
policy: null,
};
}
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return send({ success: false, error: null }, 401);
}
try {
const { acl } = (await request.json()) as { acl: string };
const { policy } = await put<{ policy: string }>(
'v1/policy',
session.get('hsApiKey')!,
{
policy: acl,
},
);
return { success: true, policy, error: null };
} catch (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
const text = JSON.parse(error.message);
return send(
{ success: false, error: text.message },
{
status: error instanceof HeadscaleError ? error.status : 500,
},
);
}
return { success: true, error: null };
}
export default function Page() {
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher<typeof action>();
const revalidator = useRevalidator();
const [acl, setAcl] = useState(data.policy ?? '');
const [toasted, setToasted] = useState(false);
useEffect(() => {
if (!fetcher.data || toasted) {
return;
}
// @ts-ignore: useDebounceFetcher is not typed correctly
if (fetcher.data.success) {
toast('Updated tailnet ACL policy');
} else {
toast('Failed to update tailnet ACL policy');
}
setToasted(true);
if (revalidator.state === 'idle') {
revalidator.revalidate();
}
}, [fetcher.data, toasted, data.policy]);
// The state for if the save and discard buttons should be disabled
// is pretty complicated to calculate and varies on different states.
const disabled = useMemo(() => {
if (!data.read || !data.write) {
return true;
}
// First check our fetcher states
if (fetcher.state === 'loading') {
return true;
}
if (revalidator.state === 'loading') {
return true;
}
// If we have a failed fetcher state allow the user to try again
// @ts-ignore: useDebounceFetcher is not typed correctly
if (fetcher.data?.success === false) {
return false;
}
return data.policy === acl;
}, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl]);
return (
<div>
{data.read && !data.write ? (
<div className="mb-4">
<Notice className="w-fit">
The ACL policy is read-only. You can view the current policy but you
cannot make changes to it.
<br />
To resolve this, you need to set the ACL policy mode to database in
your Headscale configuration.
</Notice>
</div>
) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
{
// @ts-ignore: useDebounceFetcher is not typed correctly
fetcher.data?.success === false ? (
// @ts-ignore: useDebounceFetcher is not typed correctly
<ErrorView message={fetcher.data.error} />
) : undefined
}
{data.read ? (
<>
<Tabs>
<TabList
className={cn(
'flex border-t border-gray-200 dark:border-gray-700',
'w-fit rounded-t-lg overflow-hidden',
'text-gray-400 dark:text-gray-500',
)}
>
<Tab
id="edit"
className={({ isSelected }) =>
cn(
'px-4 py-2 rounded-tl-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)
}
>
<PencilIcon className="w-5 h-5" />
<p>Edit file</p>
</Tab>
<Tab
id="diff"
className={({ isSelected }) =>
cn(
'px-4 py-2',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)
}
>
<EyeIcon className="w-5 h-5" />
<p>Preview changes</p>
</Tab>
<Tab
id="preview"
className={({ isSelected }) =>
cn(
'px-4 py-2 rounded-tr-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)
}
>
<BeakerIcon className="w-5 h-5" />
<p>Preview rules</p>
</Tab>
</TabList>
<TabPanel id="edit">
<Editor isDisabled={!data.write} value={acl} onChange={setAcl} />
</TabPanel>
<TabPanel id="diff">
<Differ left={data?.policy ?? ''} right={acl} />
</TabPanel>
<TabPanel id="preview">
<div
className={cn(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
'p-16 flex flex-col items-center justify-center',
)}
>
<IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" />
<p className="w-1/2 text-center mt-4">
The Preview rules is very much still a work in progress. It is
a bit complicated to implement right now but hopefully it will
be available soon.
</p>
</div>
</TabPanel>
</Tabs>
<Button
variant="heavy"
className="mr-2"
isDisabled={disabled}
onPress={() => {
setToasted(false);
fetcher.submit(
{
acl,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className="w-3 h-3" />
)}
Save
</Button>
<Button
isDisabled={disabled}
onPress={() => {
setAcl(data?.policy ?? '');
}}
>
Discard Changes
</Button>
</>
) : (
<Unavailable mode={data.mode as 'database' | 'file'} />
)}
</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),
},
});
}
+77
View File
@@ -0,0 +1,77 @@
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');
if (!codeVerifier || !state || !nonce) {
return send({ error: 'Missing OIDC state' }, { status: 400 });
}
const flowOptions = {
redirect_uri: request.url,
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',
},
}
);
}
}
+40
View File
@@ -0,0 +1,40 @@
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);
return redirect(data.url, {
status: 302,
headers: {
'Set-Cookie': await commitSession(session),
},
});
}
@@ -1,34 +1,29 @@
import { useSubmit } from '@remix-run/react'
import { Button } from 'react-aria-components'
import { useSubmit } from 'react-router';
import { Button } from 'react-aria-components';
import Code from '~/components/Code'
import Link from '~/components/Link'
import TableList from '~/components/TableList'
import { cn } from '~/utils/cn'
import Code from '~/components/Code';
import Link from '~/components/Link';
import TableList from '~/components/TableList';
import { cn } from '~/utils/cn';
import AddDNS from './dialogs/dns'
import AddDNS from '../dialogs/dns';
interface Props {
records: { name: string, type: 'A', value: string }[]
isDisabled: boolean
records: { name: string; type: 'A'; value: string }[];
isDisabled: boolean;
}
export default function DNS({ records, isDisabled }: Props) {
const submit = useSubmit()
const submit = useSubmit();
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
<p className="text-gray-700 dark:text-gray-300">
Headscale supports adding custom DNS records to your Tailnet.
As of now, only
{' '}
<Code>A</Code>
{' '}
records are supported.
{' '}
Headscale supports adding custom DNS records to your Tailnet. As of now,
only <Code>A</Code> records are supported.{' '}
<Link
to="https://headscale.net/dns-records/"
to="https://headscale.net/stable/ref/dns"
name="Headscale DNS Records documentation"
>
Learn More
@@ -36,15 +31,12 @@ export default function DNS({ records, isDisabled }: Props) {
</p>
<div className="mt-4">
<TableList className="mb-8">
{records.length === 0
? (
<TableList.Item>
<p className="opacity-50 text-sm mx-auto">
No DNS records found
</p>
</TableList.Item>
)
: records.map((record, index) => (
{records.length === 0 ? (
<TableList.Item>
<p className="opacity-50 text-sm mx-auto">No DNS records found</p>
</TableList.Item>
) : (
records.map((record, index) => (
<TableList.Item key={index}>
<div className="flex gap-24">
<div className="flex gap-2">
@@ -62,27 +54,28 @@ export default function DNS({ records, isDisabled }: Props) {
)}
isDisabled={isDisabled}
onPress={() => {
submit({
'dns.extra_records': records
.filter((_, i) => i !== index),
}, {
method: 'PATCH',
encType: 'application/json',
})
submit(
{
'dns.extra_records': records.filter(
(_, i) => i !== index,
),
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}}
>
Remove
</Button>
</TableList.Item>
))}
))
)}
</TableList>
{isDisabled
? undefined
: (
<AddDNS records={records} />
)}
{isDisabled ? undefined : <AddDNS records={records} />}
</div>
</div>
)
);
}
@@ -1,88 +1,88 @@
/* eslint-disable unicorn/no-keyword-prefix */
import {
closestCorners,
DndContext,
DragOverlay
} from '@dnd-kit/core'
import { closestCorners, DndContext, DragOverlay } from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis
} from '@dnd-kit/modifiers'
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react'
import { type FetcherWithComponents, useFetcher } from '@remix-run/react'
import { useEffect, useState } from 'react'
import { Button, Input } from 'react-aria-components'
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react';
import { type FetcherWithComponents, useFetcher } from 'react-router';
import { useEffect, useState } from 'react';
import { Button, Input } from 'react-aria-components';
import Spinner from '~/components/Spinner'
import TableList from '~/components/TableList'
import { cn } from '~/utils/cn'
import Spinner from '~/components/Spinner';
import TableList from '~/components/TableList';
import { cn } from '~/utils/cn';
type Properties = {
readonly baseDomain?: string;
readonly searchDomains: string[];
// eslint-disable-next-line react/boolean-prop-naming
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
const [activeId, setActiveId] = useState<number | string | null>(null)
const [localDomains, setLocalDomains] = useState(searchDomains)
const [newDomain, setNewDomain] = useState('')
const fetcher = useFetcher()
const [activeId, setActiveId] = useState<number | string | null>(null);
const [localDomains, setLocalDomains] = useState(searchDomains);
const [newDomain, setNewDomain] = useState('');
const fetcher = useFetcher();
useEffect(() => {
setLocalDomains(searchDomains)
}, [searchDomains])
setLocalDomains(searchDomains);
}, [searchDomains]);
return (
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Search Domains</h1>
<p className='text-gray-700 dark:text-gray-300 mb-2'>
Set custom DNS search domains for your Tailnet.
When using Magic DNS, your tailnet domain is used as the first search domain.
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
<p className="text-gray-700 dark:text-gray-300 mb-2">
Set custom DNS search domains for your Tailnet. When using Magic DNS,
your tailnet domain is used as the first search domain.
</p>
<DndContext
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
collisionDetection={closestCorners}
onDragStart={event => {
setActiveId(event.active.id)
onDragStart={(event) => {
setActiveId(event.active.id);
}}
onDragEnd={event => {
onDragEnd={(event) => {
// eslint-disable-next-line unicorn/no-null
setActiveId(null)
const { active, over } = event
setActiveId(null);
const { active, over } = event;
if (!over) {
return
return;
}
const activeItem = localDomains[active.id as number - 1]
const overItem = localDomains[over.id as number - 1]
const activeItem = localDomains[(active.id as number) - 1];
const overItem = localDomains[(over.id as number) - 1];
if (!activeItem || !overItem) {
return
return;
}
const oldIndex = localDomains.indexOf(activeItem)
const newIndex = localDomains.indexOf(overItem)
const oldIndex = localDomains.indexOf(activeItem);
const newIndex = localDomains.indexOf(overItem);
if (oldIndex !== newIndex) {
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex))
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
}
}}
>
<TableList>
{baseDomain ? (
<TableList.Item key='magic-dns-sd'>
<p className='font-mono text-sm'>{baseDomain}</p>
<LockIcon className='h-4 w-4'/>
<TableList.Item key="magic-dns-sd">
<p className="font-mono text-sm">{baseDomain}</p>
<LockIcon className="h-4 w-4" />
</TableList.Item>
) : undefined}
<SortableContext
@@ -101,25 +101,27 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
/>
))}
<DragOverlay adjustScale>
{activeId ? <Domain
isDrag
domain={localDomains[activeId as number - 1]}
localDomains={localDomains}
id={activeId as number - 1}
disabled={disabled}
fetcher={fetcher}
/> : undefined}
{activeId ? (
<Domain
isDrag
domain={localDomains[(activeId as number) - 1]}
localDomains={localDomains}
id={(activeId as number) - 1}
disabled={disabled}
fetcher={fetcher}
/>
) : undefined}
</DragOverlay>
</SortableContext>
{disabled ? undefined : (
<TableList.Item key='add-sd'>
<TableList.Item key="add-sd">
<Input
type='text'
className='font-mono text-sm bg-transparent w-full mr-2'
placeholder='Search Domain'
type="text"
className="font-mono text-sm bg-transparent w-full mr-2"
placeholder="Search Domain"
value={newDomain}
onChange={event => {
setNewDomain(event.target.value)
onChange={(event) => {
setNewDomain(event.target.value);
}}
/>
{fetcher.state === 'idle' ? (
@@ -128,32 +130,35 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
'text-sm font-semibold',
'text-blue-600 dark:text-blue-400',
'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}
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns.search_domains': [...localDomains, newDomain]
}, {
method: 'PATCH',
encType: 'application/json'
})
fetcher.submit(
{
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns.search_domains': [...localDomains, newDomain],
},
{
method: 'PATCH',
encType: 'application/json',
},
);
setNewDomain('')
setNewDomain('');
}}
>
Add
</Button>
) : (
<Spinner className='w-3 h-3 mr-0'/>
<Spinner className="w-3 h-3 mr-0" />
)}
</TableList.Item>
)}
</TableList>
</DndContext>
</div>
)
);
}
type DomainProperties = {
@@ -164,17 +169,24 @@ type DomainProperties = {
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
readonly fetcher: FetcherWithComponents<unknown>;
}
};
function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainProperties) {
function Domain({
domain,
id,
localDomains,
isDrag,
disabled,
fetcher,
}: DomainProperties) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id })
isDragging,
} = useSortable({ id });
// TODO: Figure out why TableList.Item breaks dndkit
return (
@@ -184,17 +196,19 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0 dark:border-zinc-800',
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={{
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 : (
<ThreeBarsIcon
className='h-4 w-4 text-gray-400 focus:outline-none'
className="h-4 w-4 text-gray-400 focus:outline-none"
{...attributes}
{...listeners}
/>
@@ -207,21 +221,26 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
'text-sm',
'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300',
disabled && 'opacity-50 cursor-not-allowed'
disabled && 'opacity-50 cursor-not-allowed',
)}
isDisabled={disabled}
onPress={() => {
fetcher.submit({
'dns.search_domains': localDomains.filter((_, index) => index !== id - 1)
}, {
method: 'PATCH',
encType: 'application/json'
})
fetcher.submit(
{
'dns.search_domains': localDomains.filter(
(_, index) => index !== id - 1,
),
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}}
>
Remove
</Button>
)}
</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 { json, useLoaderData } from '@remix-run/react'
import type { ActionFunctionArgs } from 'react-router';
import { data, useLoaderData } from 'react-router';
import Code from '~/components/Code'
import Notice from '~/components/Notice'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig, patchConfig } from '~/utils/config/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import Code from '~/components/Code';
import Notice from '~/components/Notice';
import { loadContext } from '~/utils/config/headplane';
import { loadConfig, patchConfig } from '~/utils/config/headscale';
import { getSession } from '~/utils/sessions.server';
import { useLiveData } from '~/utils/useLiveData';
import DNS from './dns'
import Domains from './domains'
import MagicModal from './magic'
import Nameservers from './nameservers'
import RenameModal from './rename'
import DNS from './components/dns';
import Domains from './components/domains';
import MagicModal from './components/magic';
import Nameservers from './components/nameservers';
import RenameModal from './components/rename';
// We do not want to expose every config value
export async function loader() {
const context = await loadContext()
const context = await loadContext();
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 = {
prefixes: config.prefixes,
magicDns: config.dns.magic_dns,
@@ -32,69 +32,58 @@ export async function loader() {
splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains,
extraRecords: config.dns.extra_records,
}
};
return {
...dns,
...context,
}
};
}
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')) {
return json({ success: false }, {
status: 401,
})
return data({ success: false }, { status: 401 });
}
const context = await loadContext()
const context = await loadContext();
if (!context.config.write) {
return json({ success: false }, {
status: 403,
})
return data({ success: false }, { status: 403 });
}
const data = await request.json() as Record<string, unknown>
await patchConfig(data)
const patch = (await request.json()) as Record<string, unknown>;
await patchConfig(patch);
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() {
useLiveData({ interval: 5000 })
const data = useLoaderData<typeof loader>()
useLiveData({ interval: 5000 });
const data = useLoaderData<typeof loader>();
const allNs: Record<string, string[]> = {}
const allNs: Record<string, string[]> = {};
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 (
<div className="flex flex-col gap-16 max-w-screen-lg">
{data.config.write
? undefined
: (
<Notice>
The Headscale configuration is read-only. You cannot make changes to the configuration
</Notice>
)}
{data.config.write ? undefined : (
<Notice>
The Headscale configuration is read-only. You cannot make changes to
the configuration
</Notice>
)}
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
<Nameservers
nameservers={allNs}
isDisabled={!data.config.write}
/>
<Nameservers nameservers={allNs} isDisabled={!data.config.write} />
<DNS
records={data.extraRecords}
isDisabled={!data.config.write}
/>
<DNS records={data.extraRecords} isDisabled={!data.config.write} />
<Domains
baseDomain={data.magicDns ? data.baseDomain : undefined}
@@ -105,18 +94,16 @@ export default function Page() {
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
<p className="text-gray-700 dark:text-gray-300 mb-4">
Automatically register domain names for each device
on the tailnet. Devices will be accessible at
{' '}
Automatically register domain names for each device on the tailnet.
Devices will be accessible at{' '}
<Code>
[device].
{data.baseDomain}
</Code>
{' '}
</Code>{' '}
when Magic DNS is enabled.
</p>
<MagicModal isEnabled={data.magicDns} disabled={!data.config.write} />
</div>
</div>
)
);
}
-162
View File
@@ -1,162 +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"
/>
<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 { type Dispatch, type SetStateAction } from 'react'
import { Form, useSubmit } from 'react-router';
import type { Dispatch, SetStateAction } from 'react';
import Dialog from '~/components/Dialog'
import { type Machine } from '~/types'
import { cn } from '~/utils/cn'
import Dialog from '~/components/Dialog';
import type { Machine } from '~/types';
import { cn } from '~/utils/cn';
interface DeleteProps {
readonly machine: Machine
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
}
export default function Delete({ machine, state }: DeleteProps) {
const submit = useSubmit()
const submit = useSubmit();
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
{(close) => (
<>
<Dialog.Title>
Remove
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from
your network. To re-add it, you will need to
reauthenticate to your tailnet from the device.
This machine will be permanently removed from your network. To
re-add it, you will need to reauthenticate to your tailnet from
the device.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="id" value={machine.id} />
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
@@ -61,5 +54,5 @@ export default function Delete({ machine, state }: DeleteProps) {
)}
</Dialog.Panel>
</Dialog>
)
);
}
@@ -1,46 +1,38 @@
import { Form, useSubmit } from '@remix-run/react'
import { type Dispatch, type SetStateAction } from 'react'
import { Form, useSubmit } from 'react-router';
import type { Dispatch, SetStateAction } from 'react';
import Dialog from '~/components/Dialog'
import { type Machine } from '~/types'
import { cn } from '~/utils/cn'
import Dialog from '~/components/Dialog';
import type { Machine } from '~/types';
import { cn } from '~/utils/cn';
interface ExpireProps {
readonly machine: Machine
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
}
export default function Expire({ machine, state }: ExpireProps) {
const submit = useSubmit()
const submit = useSubmit();
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
{(close) => (
<>
<Dialog.Title>
Expire
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Title>Expire {machine.givenName}</Dialog.Title>
<Dialog.Text>
This will disconnect the machine from your Tailnet.
In order to reconnect, you will need to re-authenticate
from the device.
This will disconnect the machine from your Tailnet. In order to
reconnect, you will need to re-authenticate from the device.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="expire" />
<input type="hidden" name="id" value={machine.id} />
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
@@ -61,5 +53,5 @@ export default function Expire({ machine, state }: ExpireProps) {
)}
</Dialog.Panel>
</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 { Form, useSubmit } from '@remix-run/react'
import { type Dispatch, type SetStateAction, useState } from 'react'
import { Button, Input } from 'react-aria-components'
import { PlusIcon, XIcon } from '@primer/octicons-react';
import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction, useState } from 'react';
import { Button, Input } from 'react-aria-components';
import Dialog from '~/components/Dialog'
import Link from '~/components/Link'
import { type Machine } from '~/types'
import { cn } from '~/utils/cn'
import Dialog from '~/components/Dialog';
import Link from '~/components/Link';
import type { Machine } from '~/types';
import { cn } from '~/utils/cn';
interface TagsProps {
readonly machine: Machine
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
}
export default function Tags({ machine, state }: TagsProps) {
const [tags, setTags] = useState(machine.forcedTags)
const [tag, setTag] = useState('')
const submit = useSubmit()
const [tags, setTags] = useState(machine.forcedTags);
const [tag, setTag] = useState('');
const submit = useSubmit();
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
{(close) => (
<>
<Dialog.Title>
Edit ACL tags for
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
<Dialog.Text>
ACL tags can be used to reference machines in your ACL policies.
See the
{' '}
See the{' '}
<Link
to="https://tailscale.com/kb/1068/acl-tags"
name="Tailscale documentation"
>
Tailscale documentation
</Link>
{' '}
</Link>{' '}
for more information.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
submit(e.currentTarget);
}}
>
<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">
{tags.length === 0
? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center rounded-t-lg',
'text-ui-600 dark:text-ui-300',
)}
>
<p>
No tags are set on this machine.
</p>
</div>
)
: tags.map(item => (
{tags.length === 0 ? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center rounded-t-lg',
'text-ui-600 dark:text-ui-300',
)}
>
<p>No tags are set on this machine.</p>
</div>
) : (
tags.map((item) => (
<div
key={item}
id={item}
@@ -86,13 +76,14 @@ export default function Tags({ machine, state }: TagsProps) {
<Button
className="rounded-full p-0 w-6 h-6"
onPress={() => {
setTags(tags.filter(tag => tag !== item))
setTags(tags.filter((tag) => tag !== item));
}}
>
<XIcon className="w-4 h-4" />
</Button>
</div>
))}
))
)}
</div>
<div
className={cn(
@@ -101,8 +92,9 @@ export default function Tags({ machine, state }: TagsProps) {
'rounded-b-lg justify-between items-center',
'dark:bg-ui-800 dark:text-ui-300',
'focus-within:ring-2 focus-within:ring-blue-600',
tag.length > 0 && !tag.startsWith('tag:')
&& 'outline outline-red-500',
tag.length > 0 &&
!tag.startsWith('tag:') &&
'outline outline-red-500',
)}
>
<Input
@@ -115,23 +107,23 @@ export default function Tags({ machine, state }: TagsProps) {
)}
value={tag}
onChange={(e) => {
setTag(e.currentTarget.value)
setTag(e.currentTarget.value);
}}
/>
<Button
className={cn(
'rounded-lg p-0 h-6 w-6',
!tag.startsWith('tag:')
&& 'opacity-50 cursor-not-allowed',
!tag.startsWith('tag:') &&
'opacity-50 cursor-not-allowed',
)}
isDisabled={
tag.length === 0
|| !tag.startsWith('tag:')
|| tags.includes(tag)
tag.length === 0 ||
!tag.startsWith('tag:') ||
tags.includes(tag)
}
onPress={() => {
setTags([...tags, tag])
setTag('')
setTags([...tags, tag]);
setTag('');
}}
>
<PlusIcon className="w-4 h-4" />
@@ -139,16 +131,10 @@ export default function Tags({ machine, state }: TagsProps) {
</div>
</div>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
<Dialog.Action variant="confirm" onPress={close}>
Save
</Dialog.Action>
</div>
@@ -157,5 +143,5 @@ export default function Tags({ machine, state }: TagsProps) {
)}
</Dialog.Panel>
</Dialog>
)
);
}
+366
View File
@@ -0,0 +1,366 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { Link as RemixLink, useLoaderData } from 'react-router';
import {
InfoIcon,
GearIcon,
CheckCircleIcon,
SkipIcon,
PersonIcon,
} from '@primer/octicons-react';
import { useMemo, useState } from 'react';
import Attribute from '~/components/Attribute';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Menu from '~/components/Menu';
import Tooltip from '~/components/Tooltip';
import StatusCircle from '~/components/StatusCircle';
import type { Machine, Route, User } from '~/types';
import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale';
import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { useLiveData } from '~/utils/useLiveData';
import Link from '~/components/Link';
import { menuAction } from './action';
import MenuOptions from './components/menu';
import Routes from './dialogs/routes';
export async function loader({ request, params }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (!params.id) {
throw new Error('No machine ID provided');
}
const context = await loadContext();
let magic: string | undefined;
if (context.config.read) {
const config = await loadConfig();
if (config.dns.magic_dns) {
magic = config.dns.base_domain;
}
}
const [machine, routes, users] = await Promise.all([
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
]);
return {
machine: machine.node,
routes: routes.routes.filter((route) => route.node.id === params.id),
users: users.users,
magic,
};
}
export async function action({ request }: ActionFunctionArgs) {
return menuAction(request);
}
export default function Page() {
const { machine, magic, routes, users } = useLoaderData<typeof loader>();
const routesState = useState(false);
useLiveData({ interval: 1000 });
const expired =
machine.expiry === '0001-01-01 00:00:00' ||
machine.expiry === '0001-01-01T00:00:00Z' ||
machine.expiry === null
? false
: new Date(machine.expiry).getTime() < Date.now();
const tags = [...machine.forcedTags, ...machine.validTags];
if (expired) {
tags.unshift('Expired');
}
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce<{
exit: Route[];
subnet: Route[];
subnetApproved: 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 (
<div>
<p className="mb-8 text-md">
<RemixLink to="/machines" className="font-medium">
All Machines
</RemixLink>
<span className="mx-2">/</span>
{machine.givenName}
</p>
<div
className={cn(
'flex justify-between items-center',
'border-b border-ui-100 dark:border-ui-800',
)}
>
<span className="flex items-baseline gap-x-4 text-sm mb-4">
<h1 className="text-2xl font-medium">{machine.givenName}</h1>
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
</span>
<MenuOptions
machine={machine}
routes={routes}
users={users}
magic={magic}
buttonChild={
<Menu.Button
className={cn(
'flex items-center justify-center gap-x-2',
'bg-main-200 dark:bg-main-700/30',
'hover:bg-main-300 dark:hover:bg-main-600/30',
'text-ui-700 dark:text-ui-300 mb-2',
'w-fit text-sm rounded-lg px-3 py-2',
)}
>
<GearIcon className="w-5" />
Machine Settings
</Menu.Button>
}
/>
</div>
<div className="flex gap-1 mb-4">
<div className="border-r border-ui-100 dark:border-ui-800 p-2 pr-4">
<span className="text-sm text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
Managed by
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
By default, a machines permissions match its creators.
</Tooltip.Body>
</Tooltip>
</span>
<div className="flex items-center gap-x-2.5 mt-1">
<div
className={cn(
'rounded-full h-7 w-7 flex items-center justify-center',
'border border-ui-200 dark:border-ui-700',
)}
>
<PersonIcon className="w-4 h-4" />
</div>
{machine.user.name}
</div>
</div>
<div className="p-2 pl-4">
<p className="text-sm text-ui-600 dark:text-ui-300">Status</p>
<div className="flex gap-1 mt-1 mb-8">
{tags.map((tag) => (
<span
key={tag}
className={cn(
'text-xs rounded-md px-1.5 py-0.5',
'bg-ui-200 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300',
)}
>
{tag}
</span>
))}
</div>
</div>
</div>
<h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2>
<Routes machine={machine} routes={routes} state={routesState} />
<div className="flex items-center justify-between mb-4">
<p>
Subnets let you expose physical network routes onto Tailscale.{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation"
>
Learn More
</Link>
</p>
<Button variant="light" control={routesState}>
Review
</Button>
</div>
<Card
variant="flat"
className={cn(
'w-full max-w-full grid sm:grid-cols-2',
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
)}
>
<div>
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
Approved
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
Traffic to these routes are being routed through this machine.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{subnetApproved.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300"></span>
) : (
<ul className="leading-normal">
{subnetApproved.map((route) => (
<li key={route.id}>{route.prefix}</li>
))}
</ul>
)}
</div>
<Button
className={cn(
'p-0 rounded-sm bg-transparent mt-1',
'text-blue-500 dark:text-blue-400',
'hover:bg-transparent',
'hover:text-blue-600 dark:hover:text-blue-500',
)}
control={routesState}
>
Edit
</Button>
</div>
<div>
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
Awaiting Approval
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
This machine is advertising these routes, but they must be
approved before traffic will be routed to them.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{subnet.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300"></span>
) : (
<ul className="leading-normal">
{subnet.map((route) => (
<li key={route.id}>{route.prefix}</li>
))}
</ul>
)}
</div>
<Button
className={cn(
'p-0 rounded-sm bg-transparent mt-1',
'text-blue-500 dark:text-blue-400',
'hover:bg-transparent',
'hover:text-blue-600 dark:hover:text-blue-500',
)}
control={routesState}
>
Edit
</Button>
</div>
<div>
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
Exit Node
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
Whether this machine can act as an exit node for your tailnet.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{exit.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300"></span>
) : exitEnabled ? (
<span className="flex items-center gap-x-1">
<CheckCircleIcon className="w-3.5 h-3.5 text-green-700" />
Allowed
</span>
) : (
<span className="flex items-center gap-x-1">
<SkipIcon className="w-3.5 h-3.5 text-red-700" />
Awaiting Approval
</span>
)}
</div>
<Button
className={cn(
'p-0 rounded-sm bg-transparent mt-1',
'text-blue-500 dark:text-blue-400',
'hover:bg-transparent',
'hover:text-blue-600 dark:hover:text-blue-500',
)}
control={routesState}
>
Edit
</Button>
</div>
</Card>
<h2 className="text-xl font-medium mb-4">Machine Details</h2>
<Card variant="flat" className="w-full max-w-full">
<Attribute name="Creator" value={machine.user.name} />
<Attribute name="Node ID" value={machine.id} />
<Attribute name="Node Name" value={machine.givenName} />
<Attribute name="Hostname" value={machine.name} />
<Attribute isCopyable name="Node Key" value={machine.nodeKey} />
<Attribute
name="Created"
value={new Date(machine.createdAt).toLocaleString()}
/>
<Attribute
name="Last Seen"
value={new Date(machine.lastSeen).toLocaleString()}
/>
<Attribute
name="Expiry"
value={expired ? new Date(machine.expiry).toLocaleString() : 'Never'}
/>
{magic ? (
<Attribute
isCopyable
name="Domain"
value={`${machine.givenName}.${magic}`}
/>
) : undefined}
</Card>
</div>
);
}
+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" />
)
}
-13
View File
@@ -1,13 +0,0 @@
import { type LoaderFunctionArgs } from '@remix-run/node'
import { loadContext } from '~/utils/config/headplane'
import { finishOidc } from '~/utils/oidc'
export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext()
if (!context.oidc) {
throw new Error('An invalid OIDC configuration was provided')
}
return finishOidc(context.oidc, request)
}
+230
View File
@@ -0,0 +1,230 @@
import type { LoaderFunctionArgs, ActionFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import { useLiveData } from '~/utils/useLiveData';
import { getSession } from '~/utils/sessions.server';
import { Link as RemixLink } from 'react-router';
import type { PreAuthKey, User } from '~/types';
import { pull, post } from '~/utils/headscale';
import { loadContext } from '~/utils/config/headplane';
import { useState } from 'react';
import { send } from '~/utils/res';
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 './components/key';
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return send(
{ 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 send(
{ message: 'Missing parameters' },
{
status: 400,
},
);
}
await post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey/expire',
session.get('hsApiKey')!,
{
user: user,
key: key,
},
);
return { 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 send(
{ 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 { 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 || key.reusable);
}
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>
);
}
+38
View File
@@ -0,0 +1,38 @@
import Link from '~/components/Link';
import Button from '~/components/Button';
import { Link as RemixLink } from 'react-router';
import { ArrowRightIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
export default function AgentSection() {
return (
<>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Local Agent</h1>
<p className="text-gray-700 dark:text-gray-300">
Headplane provides a local agent that can be installed on a
server to provide additional features including viewing device
information and SSH access via the web interface (soon).
To learn more about the agent visit the{' '}
<Link
to="https://github.com/tale/headplane/blob/main/docs/Headplane-Agent.md"
name="Headplane Agent Documentation"
>
Headplane documentation
</Link>
</p>
</div>
<RemixLink to="/settings/local-agent">
<div
className={cn(
'text-lg font-medium flex items-center',
'text-gray-700 dark:text-gray-300',
)}
>
Manage Agent
<ArrowRightIcon className="w-5 h-5 ml-2" />
</div>
</RemixLink>
</>
)
}

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