mirror of
https://github.com/tale/headplane.git
synced 2026-07-28 16:48:56 +00:00
Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 156d7c9ee7 | |||
| bd11453593 | |||
| 0f92f09796 | |||
| 9bb20024fa | |||
| 698122068d | |||
| e6a66d5804 | |||
| d524f927a4 | |||
| 539c76dfb3 | |||
| 51fa7c14d0 | |||
| b92c4ce2e8 | |||
| 325e9ba43d | |||
| de9a938da2 | |||
| 1b45b0917f | |||
| e01fd8d50c | |||
| c9603ba38a | |||
| 377641265b | |||
| 5bc313e5fe | |||
| af919b2d34 | |||
| f464c42802 | |||
| e667e112cb | |||
| 39546849ae | |||
| eb922c9318 | |||
| 5569ba4660 | |||
| dfd03e77bb | |||
| e33504016b | |||
| 7d4da73141 | |||
| 1316439786 | |||
| 888c1a5fb6 | |||
| 328302cd81 | |||
| b060700dfe | |||
| 69783f0d05 | |||
| 55c832c536 | |||
| fc5028994a | |||
| 6745ee8529 | |||
| 414b95c293 | |||
| 7217659720 | |||
| a4bb3cce5f | |||
| f3c9d8b54c | |||
| aa9872a45b | |||
| 39504e2487 | |||
| 719d7a5e76 | |||
| 6156d78907 | |||
| 1c2c374ada | |||
| 6cf343d623 | |||
| 50e43bc0c3 | |||
| 9d64a5beac | |||
| 240d8d6197 | |||
| b80bb0cc09 | |||
| 69cc6985b4 | |||
| f9b38939ba | |||
| 4cfa1e5209 | |||
| e713dae91b | |||
| c9bcc1d7c6 | |||
| 401731fd09 | |||
| f623e7bc66 | |||
| 1af292a5b0 | |||
| 21778a43f1 | |||
| 33762e53b5 | |||
| 8867cca494 | |||
| 41b1d3c847 | |||
| 712fc28683 | |||
| 320dab1d4f | |||
| da0ee1382b | |||
| a7d127c7bf | |||
| b433e607e2 | |||
| 8aad883c21 | |||
| 3cd28d2136 | |||
| 9d9cbd8e0e | |||
| feb8b8bba5 | |||
| c304effcdf | |||
| 982d811d53 | |||
| d369e36fa4 | |||
| a2e659f36c | |||
| 1490406784 | |||
| e1c87412d4 | |||
| 6e55f442fd | |||
| 09e1b1f261 | |||
| 3e83f8617b | |||
| 1d6472765a | |||
| 2bc85085f5 | |||
| 5c949e2da5 | |||
| 12754bd0aa | |||
| 10bb4eba97 | |||
| 13a734923d | |||
| d5ee8ae0f3 | |||
| 331a9c8dcf | |||
| 808b01c5d7 | |||
| a8abd37e3a | |||
| d1fa76971b | |||
| a9e8394dec | |||
| 9dafd8e8b0 | |||
| 0c1f6969da | |||
| 4214e14ba8 |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
/.react-router
|
||||||
/.cache
|
/.cache
|
||||||
/build
|
/build
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -1,3 +1,58 @@
|
|||||||
|
### 0.4.1 (January 18, 2025)
|
||||||
|
- Fixed an urgent issue where the OIDC redirect URI would mismatch.
|
||||||
|
|
||||||
|
### 0.4.0 (January 18, 2025)
|
||||||
|
- Switched from Remix.run to React-Router
|
||||||
|
- Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66))
|
||||||
|
- Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
|
||||||
|
- The footer now blurs your Headscale URL as a privacy measure
|
||||||
|
- Updated to the next stable beta of the React Compiler
|
||||||
|
- Changed `/healthz` to use a well-known endpoint instead of trying an invalid API key
|
||||||
|
- Support `OIDC_REDIRECT_URI` to force a specific redirect URI
|
||||||
|
- Redo the OIDC integration for better error handling and configuration
|
||||||
|
- Gracefully handle when Headscale is unreachable instead of crashing the dashboard
|
||||||
|
- Reusable Pre-Auth Keys no longer show expired when used (PR [#88](https://github.com/tale/headplane/pull/88))
|
||||||
|
- Tweaked some CSS issues in the UI
|
||||||
|
|
||||||
|
### 0.3.9 (December 6, 2024)
|
||||||
|
- Fixed a race condition bug in the OIDC validation code
|
||||||
|
|
||||||
|
### 0.3.8 (December 6, 2024)
|
||||||
|
- Added a little HTML footer to show the login page and link to a donation page.
|
||||||
|
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
|
||||||
|
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
|
||||||
|
|
||||||
|
### 0.3.7 (November 30, 2024)
|
||||||
|
- 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)
|
### 0.3.2 (October 11, 2024)
|
||||||
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
|
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
|
||||||
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
|
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
|
||||||
|
|||||||
+2
-1
@@ -2,6 +2,7 @@ FROM node:20-alpine AS build
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN npm install -g pnpm
|
RUN npm install -g pnpm
|
||||||
|
RUN apk add --no-cache git
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
COPY patches ./patches
|
COPY patches ./patches
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
@@ -19,4 +20,4 @@ RUN echo '{"type":"module"}' > /app/package.json
|
|||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
CMD [ "node_modules/.bin/remix-serve", "./build/server/index.js" ]
|
CMD [ "node", "./build/headplane/server.js" ]
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,43 +1,42 @@
|
|||||||
import { CopyIcon } from '@primer/octicons-react'
|
import { CopyIcon } from '@primer/octicons-react';
|
||||||
|
import { toast } from './Toaster';
|
||||||
import { toast } from './Toaster'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: string
|
name: string;
|
||||||
value: string
|
value: string;
|
||||||
isCopyable?: boolean
|
isCopyable?: boolean;
|
||||||
|
link?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Attribute({ name, value, isCopyable }: Props) {
|
export default function Attribute({ name, value, link, isCopyable }: Props) {
|
||||||
const canCopy = isCopyable ?? false
|
const canCopy = isCopyable ?? false;
|
||||||
return (
|
return (
|
||||||
<dl className="flex gap-1 text-sm w-full">
|
<dl className="flex gap-1 text-sm w-full">
|
||||||
<dt className="w-1/2 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1">
|
<dt className="w-1/2 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1">
|
||||||
{name}
|
{link ? (
|
||||||
|
<a className="hover:underline" href={link}>
|
||||||
|
{name}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
name
|
||||||
|
)}
|
||||||
</dt>
|
</dt>
|
||||||
|
|
||||||
{canCopy
|
{canCopy ? (
|
||||||
? (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md"
|
||||||
className="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md"
|
onClick={async () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
await navigator.clipboard.writeText(value);
|
||||||
onClick={async () => {
|
toast(`Copied ${name}`);
|
||||||
await navigator.clipboard.writeText(value)
|
}}
|
||||||
toast(`Copied ${name}`)
|
>
|
||||||
}}
|
<dd className="min-w-0 truncate px-2 py-1">{value}</dd>
|
||||||
>
|
<CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" />
|
||||||
<dd className="min-w-0 truncate px-2 py-1">
|
</button>
|
||||||
{value}
|
) : (
|
||||||
</dd>
|
<dd className="min-w-0 truncate px-2 py-1">{value}</dd>
|
||||||
<CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" />
|
)}
|
||||||
</button>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<dd className="min-w-0 truncate px-2 py-1">
|
|
||||||
{value}
|
|
||||||
</dd>
|
|
||||||
)}
|
|
||||||
</dl>
|
</dl>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-21
@@ -1,37 +1,38 @@
|
|||||||
import { type Dispatch, type SetStateAction } from 'react'
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
import { Button as AriaButton } from 'react-aria-components'
|
import { Button as AriaButton } from 'react-aria-components';
|
||||||
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
import { cn } from '~/utils/cn'
|
type Props = Parameters<typeof AriaButton>[0] & {
|
||||||
|
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||||
|
readonly variant?: 'heavy' | 'light';
|
||||||
|
};
|
||||||
|
|
||||||
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
|
export default function Button(props: Props) {
|
||||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]
|
|
||||||
readonly variant?: 'heavy' | 'light'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Button(properties: ButtonProperties) {
|
|
||||||
return (
|
return (
|
||||||
<AriaButton
|
<AriaButton
|
||||||
{...properties}
|
{...props}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-fit text-sm rounded-lg px-4 py-2',
|
'w-fit text-sm rounded-lg px-4 py-2',
|
||||||
properties.variant === 'heavy'
|
props.variant === 'heavy'
|
||||||
? 'bg-main-700 dark:bg-main-800'
|
? 'bg-main-700 dark:bg-main-800'
|
||||||
: 'bg-main-200 dark:bg-main-700/30',
|
: 'bg-main-200 dark:bg-main-700/30',
|
||||||
properties.variant === 'heavy'
|
props.variant === 'heavy'
|
||||||
? 'hover:bg-main-800 dark:hover:bg-main-700'
|
? 'hover:bg-main-800 dark:hover:bg-main-700'
|
||||||
: 'hover:bg-main-300 dark:hover:bg-main-600/30',
|
: 'hover:bg-main-300 dark:hover:bg-main-600/30',
|
||||||
properties.variant === 'heavy'
|
props.variant === 'heavy'
|
||||||
? 'text-white'
|
? 'text-white'
|
||||||
: 'text-ui-700 dark:text-ui-300',
|
: 'text-ui-700 dark:text-ui-300',
|
||||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
props.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||||
properties.className,
|
props.className,
|
||||||
)}
|
)}
|
||||||
// If control is passed, set the state value
|
// If control is passed, set the state value
|
||||||
onPress={properties.control
|
onPress={
|
||||||
? () => {
|
props.control
|
||||||
properties.control?.[1](true)
|
? () => {
|
||||||
}
|
props.control?.[1](true);
|
||||||
: properties.onPress}
|
}
|
||||||
|
: props.onPress
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-30
@@ -1,53 +1,43 @@
|
|||||||
import { type HTMLProps } from 'react'
|
import type { HTMLProps } from 'react';
|
||||||
import { Heading as AriaHeading } from 'react-aria-components'
|
import { Heading as AriaHeading } from 'react-aria-components';
|
||||||
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
import { cn } from '~/utils/cn'
|
function Title(props: Parameters<typeof AriaHeading>[0]) {
|
||||||
|
|
||||||
function Title(properties: Parameters<typeof AriaHeading>[0]) {
|
|
||||||
return (
|
return (
|
||||||
<AriaHeading
|
<AriaHeading
|
||||||
{...properties}
|
{...props}
|
||||||
slot='title'
|
slot="title"
|
||||||
className={cn(
|
className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
|
||||||
'text-lg font-semibold leading-6 mb-5',
|
|
||||||
properties.className
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
|
function Text(props: React.HTMLProps<HTMLParagraphElement>) {
|
||||||
return (
|
return (
|
||||||
<p
|
<p {...props} className={cn('text-base leading-6 my-0', props.className)} />
|
||||||
{...properties}
|
);
|
||||||
className={cn(
|
|
||||||
'text-base leading-6 my-0',
|
|
||||||
properties.className
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Properties = HTMLProps<HTMLDivElement> & {
|
type Props = HTMLProps<HTMLDivElement> & {
|
||||||
variant?: 'raised' | 'flat';
|
variant?: 'raised' | 'flat';
|
||||||
}
|
};
|
||||||
|
|
||||||
function Card(properties: Properties) {
|
function Card(props: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...properties}
|
{...props}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full max-w-md overflow-hidden rounded-xl p-4',
|
'w-full max-w-md overflow-hidden rounded-xl p-4',
|
||||||
properties.variant === 'flat'
|
props.variant === 'flat'
|
||||||
? 'bg-transparent shadow-none'
|
? 'bg-transparent shadow-none'
|
||||||
: 'bg-ui-50 dark:bg-ui-900 shadow-sm',
|
: 'bg-ui-50 dark:bg-ui-900 shadow-sm',
|
||||||
'border border-ui-200 dark:border-ui-700',
|
'border border-ui-200 dark:border-ui-700',
|
||||||
properties.className
|
props.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{properties.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Object.assign(Card, { Title, Text })
|
export default Object.assign(Card, { Title, Text });
|
||||||
|
|||||||
+49
-10
@@ -1,12 +1,51 @@
|
|||||||
import clsx from 'clsx'
|
import { useState, HTMLProps } from 'react';
|
||||||
import { type HTMLProps } from 'react'
|
import { CopyIcon, CheckIcon } from '@primer/octicons-react';
|
||||||
|
import { cn } from '~/utils/cn';
|
||||||
|
import { toast } from '~/components/Toaster';
|
||||||
|
|
||||||
type Properties = HTMLProps<HTMLSpanElement>
|
interface Props extends HTMLProps<HTMLSpanElement> {
|
||||||
|
isCopyable?: boolean;
|
||||||
export default function Code(properties: Properties) {
|
}
|
||||||
return (
|
|
||||||
<code className={clsx('bg-gray-100 dark:bg-zinc-700 p-0.5 rounded-md', properties.className)}>
|
export default function Code(props: Props) {
|
||||||
{properties.children}
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
</code>
|
|
||||||
)
|
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
@@ -1,106 +1,99 @@
|
|||||||
/* eslint-disable unicorn/no-keyword-prefix */
|
import type { Dispatch, ReactNode, SetStateAction } from 'react';
|
||||||
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
|
|
||||||
import {
|
import {
|
||||||
Button as AriaButton,
|
Button as AriaButton,
|
||||||
Dialog as AriaDialog,
|
Dialog as AriaDialog,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
Heading as AriaHeading,
|
Heading as AriaHeading,
|
||||||
Modal,
|
Modal,
|
||||||
ModalOverlay
|
ModalOverlay,
|
||||||
} from 'react-aria-components'
|
} from 'react-aria-components';
|
||||||
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
import { cn } from '~/utils/cn'
|
type ButtonProps = Parameters<typeof AriaButton>[0] & {
|
||||||
|
|
||||||
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
|
|
||||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||||
}
|
};
|
||||||
|
|
||||||
function Button(properties: ButtonProperties) {
|
function Button(props: ButtonProps) {
|
||||||
return (
|
return (
|
||||||
<AriaButton
|
<AriaButton
|
||||||
{...properties}
|
{...props}
|
||||||
aria-label='Dialog'
|
aria-label="Dialog"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-fit text-sm rounded-lg px-4 py-2',
|
'w-fit text-sm rounded-lg px-4 py-2',
|
||||||
'bg-main-700 dark:bg-main-800 text-white',
|
'bg-main-700 dark:bg-main-800 text-white',
|
||||||
'hover:bg-main-800 dark:hover:bg-main-700',
|
'hover:bg-main-800 dark:hover:bg-main-700',
|
||||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
props.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||||
properties.className
|
props.className,
|
||||||
)}
|
)}
|
||||||
// If control is passed, set the state value
|
// If control is passed, set the state value
|
||||||
onPress={properties.control ? () => {
|
onPress={
|
||||||
properties.control?.[1](true)
|
props.control
|
||||||
} : undefined}
|
? () => {
|
||||||
|
props.control?.[1](true);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionProperties = Parameters<typeof AriaButton>[0] & {
|
type ActionProps = Parameters<typeof AriaButton>[0] & {
|
||||||
readonly variant: 'cancel' | 'confirm';
|
readonly variant: 'cancel' | 'confirm';
|
||||||
}
|
};
|
||||||
|
|
||||||
function Action(properties: ActionProperties) {
|
function Action(props: ActionProps) {
|
||||||
return (
|
return (
|
||||||
<AriaButton
|
<AriaButton
|
||||||
{...properties}
|
{...props}
|
||||||
type={properties.variant === 'confirm' ? 'submit' : 'button'}
|
type={props.variant === 'confirm' ? 'submit' : 'button'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-4 py-2 rounded-lg',
|
'px-4 py-2 rounded-lg',
|
||||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
props.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||||
properties.variant === 'cancel'
|
props.variant === 'cancel'
|
||||||
? 'text-ui-700 dark:text-ui-300'
|
? 'text-ui-700 dark:text-ui-300'
|
||||||
: 'text-ui-300 dark:text-ui-300',
|
: 'text-ui-300 dark:text-ui-300',
|
||||||
properties.variant === 'confirm'
|
props.variant === 'confirm'
|
||||||
? 'bg-main-700 dark:bg-main-700 pressed:bg-main-800 dark:pressed:bg-main-800'
|
? 'bg-main-700 dark:bg-main-700 pressed:bg-main-800 dark:pressed:bg-main-800'
|
||||||
: 'bg-ui-200 dark:bg-ui-800 pressed:bg-ui-300 dark:pressed:bg-ui-700',
|
: 'bg-ui-200 dark:bg-ui-800 pressed:bg-ui-300 dark:pressed:bg-ui-700',
|
||||||
properties.className
|
props.className,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Title(properties: Parameters<typeof AriaHeading>[0]) {
|
function Title(props: Parameters<typeof AriaHeading>[0]) {
|
||||||
return (
|
return (
|
||||||
<AriaHeading
|
<AriaHeading
|
||||||
{...properties}
|
{...props}
|
||||||
slot='title'
|
slot="title"
|
||||||
className={cn(
|
className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
|
||||||
'text-lg font-semibold leading-6 mb-5',
|
|
||||||
properties.className
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
|
function Text(props: React.HTMLProps<HTMLParagraphElement>) {
|
||||||
return (
|
return (
|
||||||
<p
|
<p {...props} className={cn('text-base leading-6 my-0', props.className)} />
|
||||||
{...properties}
|
);
|
||||||
className={cn(
|
|
||||||
'text-base leading-6 my-0',
|
|
||||||
properties.className
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PanelProperties = {
|
interface PanelProps {
|
||||||
readonly children: (close: () => void) => ReactNode;
|
children: (close: () => void) => ReactNode;
|
||||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||||
readonly className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Panel({ children, control, className }: PanelProperties) {
|
function Panel({ children, control, className }: PanelProps) {
|
||||||
return (
|
return (
|
||||||
<ModalOverlay
|
<ModalOverlay
|
||||||
aria-hidden='true'
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-0 h-screen w-screen z-50 bg-black/30',
|
'fixed inset-0 h-screen w-screen z-50 bg-black/30',
|
||||||
'flex items-center justify-center dark:bg-black/70',
|
'flex items-center justify-center dark:bg-black/70',
|
||||||
'entering:animate-in exiting:animate-out',
|
'entering:animate-in exiting:animate-out',
|
||||||
'entering:fade-in entering:duration-200 entering:ease-out',
|
'entering:fade-in entering:duration-200 entering:ease-out',
|
||||||
'exiting:fade-out exiting:duration-100 exiting:ease-in',
|
'exiting:fade-out exiting:duration-100 exiting:ease-in',
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
isOpen={control ? control[0] : undefined}
|
isOpen={control ? control[0] : undefined}
|
||||||
onOpenChange={control ? control[1] : undefined}
|
onOpenChange={control ? control[1] : undefined}
|
||||||
@@ -112,32 +105,28 @@ function Panel({ children, control, className }: PanelProperties) {
|
|||||||
'entering:animate-in exiting:animate-out',
|
'entering:animate-in exiting:animate-out',
|
||||||
'dark:border dark:border-ui-700',
|
'dark:border dark:border-ui-700',
|
||||||
'entering:zoom-in-95 entering:ease-out entering:duration-200',
|
'entering:zoom-in-95 entering:ease-out entering:duration-200',
|
||||||
'exiting:zoom-out-95 exiting:ease-in exiting:duration-100'
|
'exiting:zoom-out-95 exiting:ease-in exiting:duration-100',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AriaDialog role='alertdialog' className='outline-none relative'>
|
<AriaDialog role="alertdialog" className="outline-none relative">
|
||||||
{({ close }) => children(close)}
|
{({ close }) => children(close)}
|
||||||
</AriaDialog>
|
</AriaDialog>
|
||||||
</Modal>
|
</Modal>
|
||||||
</ModalOverlay>
|
</ModalOverlay>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type DialogProperties = {
|
interface DialogProps {
|
||||||
readonly children: ReactNode;
|
children: ReactNode;
|
||||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||||
}
|
}
|
||||||
|
|
||||||
function Dialog({ children, control }: DialogProperties) {
|
function Dialog({ children, control }: DialogProps) {
|
||||||
if (control) {
|
if (control) {
|
||||||
return children
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <DialogTrigger>{children}</DialogTrigger>;
|
||||||
<DialogTrigger>
|
|
||||||
{children}
|
|
||||||
</DialogTrigger>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Object.assign(Dialog, { Button, Title, Text, Panel, Action })
|
export default Object.assign(Dialog, { Button, Title, Text, Panel, Action });
|
||||||
|
|||||||
+41
-26
@@ -1,19 +1,40 @@
|
|||||||
import { AlertIcon } from '@primer/octicons-react'
|
import { AlertIcon } from '@primer/octicons-react';
|
||||||
import { isRouteErrorResponse, useRouteError } from '@remix-run/react'
|
import { isRouteErrorResponse, useRouteError } from 'react-router';
|
||||||
|
import { cn } from '~/utils/cn';
|
||||||
|
import Card from './Card';
|
||||||
|
import Code from './Code';
|
||||||
|
|
||||||
import { cn } from '~/utils/cn'
|
interface Props {
|
||||||
|
type?: 'full' | 'embedded';
|
||||||
import Card from './Card'
|
|
||||||
import Code from './Code'
|
|
||||||
|
|
||||||
type Properties = {
|
|
||||||
readonly type?: 'full' | 'embedded';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorPopup({ type = 'full' }: Properties) {
|
function getMessage(error: Error | unknown) {
|
||||||
const error = useRouteError()
|
if (!(error instanceof Error)) {
|
||||||
const routing = isRouteErrorResponse(error)
|
return "An unknown error occurred";
|
||||||
const message = (error instanceof Error ? error.message : 'An unexpected error occurred')
|
}
|
||||||
|
|
||||||
|
let rootError = error;
|
||||||
|
|
||||||
|
// Traverse the error chain to find the root cause
|
||||||
|
if (error.cause) {
|
||||||
|
rootError = error.cause as Error;
|
||||||
|
while (rootError.cause) {
|
||||||
|
rootError = rootError.cause as Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are aggregate, concat into a single message
|
||||||
|
if (rootError instanceof AggregateError) {
|
||||||
|
return rootError.errors.map((error) => error.message).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootError.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorPopup({ type = 'full' }: Props) {
|
||||||
|
const error = useRouteError();
|
||||||
|
const routing = isRouteErrorResponse(error);
|
||||||
|
const message = getMessage(error);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -21,26 +42,20 @@ export function ErrorPopup({ type = 'full' }: Properties) {
|
|||||||
'flex items-center justify-center',
|
'flex items-center justify-center',
|
||||||
type === 'embedded'
|
type === 'embedded'
|
||||||
? 'pointer-events-none mt-24'
|
? 'pointer-events-none mt-24'
|
||||||
: 'fixed inset-0 h-screen w-screen z-50'
|
: 'fixed inset-0 h-screen w-screen z-50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<div className='flex items-center justify-between'>
|
<div className="flex items-center justify-between">
|
||||||
<Card.Title className='text-3xl mb-0'>
|
<Card.Title className="text-3xl mb-0">
|
||||||
{routing ? error.status : 'Error'}
|
{routing ? error.status : 'Error'}
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
<AlertIcon className='w-12 h-12 text-red-500'/>
|
<AlertIcon className="w-12 h-12 text-red-500" />
|
||||||
</div>
|
</div>
|
||||||
<Card.Text className='mt-4 text-lg'>
|
<Card.Text className="mt-4 text-lg">
|
||||||
{routing ? (
|
{routing ? error.statusText : <Code>{message}</Code>}
|
||||||
error.statusText
|
|
||||||
) : (
|
|
||||||
<Code>
|
|
||||||
{message}
|
|
||||||
</Code>
|
|
||||||
)}
|
|
||||||
</Card.Text>
|
</Card.Text>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,42 +1,49 @@
|
|||||||
import { GearIcon, GlobeIcon, LockIcon, PaperAirplaneIcon, PeopleIcon, PersonIcon, ServerIcon } from '@primer/octicons-react'
|
import {
|
||||||
import { Form } from '@remix-run/react'
|
GearIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
LockIcon,
|
||||||
|
PaperAirplaneIcon,
|
||||||
|
PeopleIcon,
|
||||||
|
PersonIcon,
|
||||||
|
ServerIcon,
|
||||||
|
} from '@primer/octicons-react';
|
||||||
|
import { Form } from 'react-router';
|
||||||
|
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn';
|
||||||
import { HeadplaneContext } from '~/utils/config/headplane'
|
import type { HeadplaneContext } from '~/utils/config/headplane';
|
||||||
import { type SessionData } from '~/utils/sessions'
|
import type { SessionData } from '~/utils/sessions.server';
|
||||||
|
|
||||||
import Menu from './Menu'
|
import Menu from './Menu';
|
||||||
import TabLink from './TabLink'
|
import TabLink from './TabLink';
|
||||||
|
|
||||||
interface Properties {
|
interface Props {
|
||||||
readonly data?: {
|
config: HeadplaneContext['config'];
|
||||||
config: HeadplaneContext['config']
|
user?: SessionData['user'];
|
||||||
user?: SessionData['user']
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LinkProperties {
|
interface LinkProps {
|
||||||
readonly href: string
|
href: string;
|
||||||
readonly text: string
|
text: string;
|
||||||
readonly isMenu?: boolean
|
isMenu?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Link({ href, text, isMenu }: LinkProperties) {
|
function Link({ href, text, isMenu }: LinkProps) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
!isMenu && 'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block',
|
!isMenu &&
|
||||||
|
'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</a>
|
</a>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Header({ data }: Properties) {
|
export default function Header(data: Props) {
|
||||||
return (
|
return (
|
||||||
<header className="bg-main-700 dark:bg-main-800 text-ui-50">
|
<header className="bg-main-700 dark:bg-main-800 text-ui-50">
|
||||||
<div className="container flex items-center justify-between py-4">
|
<div className="container flex items-center justify-between py-4">
|
||||||
@@ -48,69 +55,87 @@ export default function Header({ data }: Properties) {
|
|||||||
<Link href="https://tailscale.com/download" text="Download" />
|
<Link href="https://tailscale.com/download" text="Download" />
|
||||||
<Link href="https://github.com/tale/headplane" text="GitHub" />
|
<Link href="https://github.com/tale/headplane" text="GitHub" />
|
||||||
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
|
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
|
||||||
{data?.user
|
{data.user ? (
|
||||||
? (
|
<Menu>
|
||||||
<Menu>
|
<Menu.Button
|
||||||
<Menu.Button className={cn(
|
className={cn(
|
||||||
'rounded-full h-9 w-9',
|
'rounded-full h-9 w-9',
|
||||||
'border border-main-600 dark:border-main-700',
|
'border border-main-600 dark:border-main-700',
|
||||||
'hover:bg-main-600 dark:hover:bg-main-700',
|
'hover:bg-main-600 dark:hover:bg-main-700',
|
||||||
|
'flex items-center justify-center',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<PersonIcon className="h-5 w-5 mt-0.5" />
|
<PersonIcon className="h-5 w-5 mt-0.5" />
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
<Menu.Items>
|
<Menu.Items>
|
||||||
<Menu.Item className="text-right">
|
<Menu.Item className="text-right">
|
||||||
<p className="font-bold">{data.user.name}</p>
|
<p className="font-bold">{data.user.name}</p>
|
||||||
<p>{data.user.email}</p>
|
<p>{data.user.email}</p>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item className="text-right sm:hidden">
|
<Menu.Item className="text-right sm:hidden">
|
||||||
<Link
|
<Link
|
||||||
isMenu
|
isMenu
|
||||||
href="https://tailscale.com/download"
|
href="https://tailscale.com/download"
|
||||||
text="Download"
|
text="Download"
|
||||||
/>
|
/>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item className="text-right sm:hidden">
|
<Menu.Item className="text-right sm:hidden">
|
||||||
<Link
|
<Link
|
||||||
isMenu
|
isMenu
|
||||||
href="https://github.com/tale/headplane"
|
href="https://github.com/tale/headplane"
|
||||||
text="GitHub"
|
text="GitHub"
|
||||||
/>
|
/>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item className="text-right sm:hidden">
|
<Menu.Item className="text-right sm:hidden">
|
||||||
<Link
|
<Link
|
||||||
isMenu
|
isMenu
|
||||||
href="https://github.com/juanfont/headscale"
|
href="https://github.com/juanfont/headscale"
|
||||||
text="Headscale"
|
text="Headscale"
|
||||||
/>
|
/>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item className="text-red-500 dark:text-red-400">
|
<Menu.Item className="text-red-500 dark:text-red-400">
|
||||||
<Form method="POST" action="/logout">
|
<Form method="POST" action="/logout">
|
||||||
<button type="submit" className="w-full text-right">
|
<button type="submit" className="w-full text-right">
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</Form>
|
</Form>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Items>
|
</Menu.Items>
|
||||||
</Menu>
|
</Menu>
|
||||||
)
|
) : undefined}
|
||||||
: undefined}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="container flex items-center gap-x-4 overflow-x-auto">
|
<nav className="container flex items-center gap-x-4 overflow-x-auto">
|
||||||
<TabLink to="/machines" name="Machines" icon={<ServerIcon className="w-4 h-4" />} />
|
<TabLink
|
||||||
<TabLink to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} />
|
to="/machines"
|
||||||
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
|
name="Machines"
|
||||||
{data?.config.read
|
icon={<ServerIcon className="w-4 h-4" />}
|
||||||
? (
|
/>
|
||||||
<>
|
<TabLink
|
||||||
<TabLink to="/dns" name="DNS" icon={<GlobeIcon className="w-4 h-4" />} />
|
to="/users"
|
||||||
<TabLink to="/settings" name="Settings" icon={<GearIcon className="w-4 h-4" />} />
|
name="Users"
|
||||||
</>
|
icon={<PeopleIcon className="w-4 h-4" />}
|
||||||
)
|
/>
|
||||||
: undefined}
|
<TabLink
|
||||||
|
to="/acls"
|
||||||
|
name="Access Control"
|
||||||
|
icon={<LockIcon className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
{data.config.read ? (
|
||||||
|
<>
|
||||||
|
<TabLink
|
||||||
|
to="/dns"
|
||||||
|
name="DNS"
|
||||||
|
icon={<GlobeIcon className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
<TabLink
|
||||||
|
to="/settings"
|
||||||
|
name="Settings"
|
||||||
|
icon={<GearIcon className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : undefined}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { LinkExternalIcon } from '@primer/octicons-react'
|
import { LinkExternalIcon } from '@primer/octicons-react';
|
||||||
|
import { cn } from '~/utils/cn';
|
||||||
import { cn } from '~/utils/cn'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
to: string
|
to: string;
|
||||||
name: string
|
name: string;
|
||||||
children: string
|
children: string;
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Link({ to, name: alt, children, className }: Props) {
|
export default function Link({ to, name: alt, children, className }: Props) {
|
||||||
@@ -26,5 +25,5 @@ export default function Link({ to, name: alt, children, className }: Props) {
|
|||||||
{children}
|
{children}
|
||||||
<LinkExternalIcon className="h-3 w-3" />
|
<LinkExternalIcon className="h-3 w-3" />
|
||||||
</a>
|
</a>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-50
@@ -1,98 +1,91 @@
|
|||||||
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
|
import type { Dispatch, ReactNode, SetStateAction } from 'react';
|
||||||
import {
|
import {
|
||||||
Button as AriaButton,
|
Button as AriaButton,
|
||||||
Menu as AriaMenu,
|
Menu as AriaMenu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
MenuTrigger,
|
MenuTrigger,
|
||||||
Popover
|
Popover,
|
||||||
} from 'react-aria-components'
|
} from 'react-aria-components';
|
||||||
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
import { cn } from '~/utils/cn'
|
function Button(props: Parameters<typeof AriaButton>[0]) {
|
||||||
|
|
||||||
function Button(properties: Parameters<typeof AriaButton>[0]) {
|
|
||||||
return (
|
return (
|
||||||
<AriaButton
|
<AriaButton
|
||||||
{...properties}
|
{...props}
|
||||||
className={cn(
|
className={cn('outline-none', props.className)}
|
||||||
'outline-none',
|
aria-label="Menu"
|
||||||
properties.className
|
|
||||||
)}
|
|
||||||
aria-label='Menu'
|
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Items(properties: Parameters<typeof AriaMenu>[0]) {
|
function Items(props: Parameters<typeof AriaMenu>[0]) {
|
||||||
return (
|
return (
|
||||||
<Popover className={cn(
|
<Popover
|
||||||
'mt-2 rounded-md',
|
className={cn(
|
||||||
'bg-ui-50 dark:bg-ui-800',
|
'mt-2 rounded-md',
|
||||||
'overflow-hidden z-50',
|
'bg-ui-50 dark:bg-ui-800',
|
||||||
'border border-ui-200 dark:border-ui-600',
|
'overflow-hidden z-50',
|
||||||
'entering:animate-in exiting:animate-out',
|
'border border-ui-200 dark:border-ui-600',
|
||||||
'entering:fade-in entering:zoom-in-95',
|
'entering:animate-in exiting:animate-out',
|
||||||
'exiting:fade-out exiting:zoom-out-95',
|
'entering:fade-in entering:zoom-in-95',
|
||||||
'fill-mode-forwards origin-left-right'
|
'exiting:fade-out exiting:zoom-out-95',
|
||||||
)}
|
'fill-mode-forwards origin-left-right',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<AriaMenu
|
<AriaMenu
|
||||||
{...properties}
|
{...props}
|
||||||
className={cn(
|
className={cn(
|
||||||
'outline-none',
|
'outline-none',
|
||||||
'divide-y divide-ui-200 dark:divide-ui-600',
|
'divide-y divide-ui-200 dark:divide-ui-600',
|
||||||
properties.className
|
props.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{properties.children}
|
{props.children}
|
||||||
</AriaMenu>
|
</AriaMenu>
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
|
type ButtonProps = Parameters<typeof AriaButton>[0] & {
|
||||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||||
}
|
};
|
||||||
|
|
||||||
function ItemButton(properties: ButtonProperties) {
|
function ItemButton(props: ButtonProps) {
|
||||||
return (
|
return (
|
||||||
<MenuItem className='outline-none'>
|
<MenuItem className="outline-none">
|
||||||
<AriaButton
|
<AriaButton
|
||||||
{...properties}
|
{...props}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-4 py-2 w-full outline-none text-left',
|
'px-4 py-2 w-full outline-none text-left',
|
||||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||||
properties.className
|
props.className,
|
||||||
)}
|
)}
|
||||||
aria-label='Menu Dialog'
|
aria-label="Menu Dialog"
|
||||||
// If control is passed, set the state value
|
// If control is passed, set the state value
|
||||||
onPress={event => {
|
onPress={(event) => {
|
||||||
properties.onPress?.(event)
|
props.onPress?.(event);
|
||||||
properties.control?.[1](true)
|
props.control?.[1](true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Item(properties: Parameters<typeof MenuItem>[0]) {
|
function Item(props: Parameters<typeof MenuItem>[0]) {
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
{...properties}
|
{...props}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-4 py-2 w-full outline-none',
|
'px-4 py-2 w-full outline-none',
|
||||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||||
properties.className
|
props.className,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Menu({ children }: { readonly children: ReactNode }) {
|
function Menu({ children }: { children: ReactNode }) {
|
||||||
return (
|
return <MenuTrigger>{children}</MenuTrigger>;
|
||||||
<MenuTrigger>
|
|
||||||
{children}
|
|
||||||
</MenuTrigger>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Object.assign(Menu, { Button, Item, ItemButton, Items })
|
export default Object.assign(Menu, { Button, Item, ItemButton, Items });
|
||||||
|
|||||||
+12
-12
@@ -1,23 +1,23 @@
|
|||||||
import { InfoIcon } from '@primer/octicons-react'
|
import { InfoIcon } from '@primer/octicons-react';
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react';
|
||||||
|
import { cn } from '~/utils/cn';
|
||||||
import { cn } from '~/utils/cn'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string
|
className?: string;
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Notice({ children, className }: Props) {
|
export default function Notice({ children, className }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div
|
||||||
'p-4 rounded-md w-full flex items-center gap-3',
|
className={cn(
|
||||||
'bg-ui-200 dark:bg-ui-800',
|
'p-4 rounded-md w-full flex items-center gap-3',
|
||||||
className,
|
'bg-ui-200 dark:bg-ui-800',
|
||||||
)}
|
className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" />
|
<InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { PlusIcon, DashIcon } from '@primer/octicons-react'
|
import { PlusIcon, DashIcon } from '@primer/octicons-react';
|
||||||
import { Dispatch, SetStateAction } from 'react'
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
Input,
|
Input,
|
||||||
NumberField as AriaNumberField
|
NumberField as AriaNumberField,
|
||||||
} from 'react-aria-components'
|
} from 'react-aria-components';
|
||||||
|
import { cn } from '~/utils/cn';
|
||||||
import { cn } from '~/utils/cn'
|
|
||||||
|
|
||||||
type NumberFieldProps = Parameters<typeof AriaNumberField>[0] & {
|
type NumberFieldProps = Parameters<typeof AriaNumberField>[0] & {
|
||||||
label: string;
|
label: string;
|
||||||
state?: [number, Dispatch<SetStateAction<number>>];
|
state?: [number, Dispatch<SetStateAction<number>>];
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function NumberField(props: NumberFieldProps) {
|
export default function NumberField(props: NumberFieldProps) {
|
||||||
return (
|
return (
|
||||||
@@ -21,21 +20,20 @@ export default function NumberField(props: NumberFieldProps) {
|
|||||||
aria-label={props.label}
|
aria-label={props.label}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
value={props.state?.[0]}
|
value={props.state?.[0]}
|
||||||
onChange={value => {
|
onChange={(value) => {
|
||||||
props.state?.[1](value)
|
props.state?.[1](value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group className={cn(
|
<Group
|
||||||
'flex px-2.5 py-1.5 w-full rounded-lg my-1',
|
className={cn(
|
||||||
'border border-ui-200 dark:border-ui-600',
|
'flex px-2.5 py-1.5 w-full rounded-lg my-1',
|
||||||
'dark:bg-ui-800 dark:text-ui-300 gap-2',
|
'border border-ui-200 dark:border-ui-600',
|
||||||
'focus-within:ring-2 focus-within:ring-blue-600',
|
'dark:bg-ui-800 dark:text-ui-300 gap-2',
|
||||||
props.className
|
'focus-within:ring-2 focus-within:ring-blue-600',
|
||||||
)}>
|
props.className,
|
||||||
<Input
|
)}
|
||||||
className="w-full bg-transparent focus:outline-none"
|
>
|
||||||
name={props.name}
|
<Input className="w-full bg-transparent focus:outline-none" />
|
||||||
/>
|
|
||||||
<Button slot="decrement">
|
<Button slot="decrement">
|
||||||
<DashIcon className="w-4 h-4" />
|
<DashIcon className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -44,5 +42,5 @@ export default function NumberField(props: NumberFieldProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</AriaNumberField>
|
</AriaNumberField>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-22
@@ -1,5 +1,5 @@
|
|||||||
import { ChevronDownIcon } from '@primer/octicons-react'
|
import { ChevronDownIcon } from '@primer/octicons-react';
|
||||||
import { Dispatch, ReactNode, SetStateAction } from 'react'
|
import type { Dispatch, ReactNode, SetStateAction } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ListBox,
|
ListBox,
|
||||||
@@ -7,15 +7,14 @@ import {
|
|||||||
Popover,
|
Popover,
|
||||||
Select as AriaSelect,
|
Select as AriaSelect,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from 'react-aria-components'
|
} from 'react-aria-components';
|
||||||
|
import { cn } from '~/utils/cn';
|
||||||
import { cn } from '~/utils/cn'
|
|
||||||
|
|
||||||
type SelectProps = Parameters<typeof AriaSelect>[0] & {
|
type SelectProps = Parameters<typeof AriaSelect>[0] & {
|
||||||
readonly label: string
|
readonly label: string;
|
||||||
readonly state?: [string, Dispatch<SetStateAction<string>>]
|
readonly state?: [string, Dispatch<SetStateAction<string>>];
|
||||||
readonly children: ReactNode
|
readonly children: ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
function Select(props: SelectProps) {
|
function Select(props: SelectProps) {
|
||||||
return (
|
return (
|
||||||
@@ -24,7 +23,7 @@ function Select(props: SelectProps) {
|
|||||||
aria-label={props.label}
|
aria-label={props.label}
|
||||||
selectedKey={props.state?.[0]}
|
selectedKey={props.state?.[0]}
|
||||||
onSelectionChange={(key) => {
|
onSelectionChange={(key) => {
|
||||||
props.state?.[1](key.toString())
|
props.state?.[1](key.toString());
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'block w-full rounded-lg my-1',
|
'block w-full rounded-lg my-1',
|
||||||
@@ -34,10 +33,11 @@ function Select(props: SelectProps) {
|
|||||||
props.className,
|
props.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Button className={cn(
|
<Button
|
||||||
'w-full flex items-center justify-between',
|
className={cn(
|
||||||
'px-2.5 py-1.5 rounded-lg',
|
'w-full flex items-center justify-between',
|
||||||
)}
|
'px-2.5 py-1.5 rounded-lg',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
<ChevronDownIcon className="w-4 h-4" aria-hidden="true" />
|
<ChevronDownIcon className="w-4 h-4" aria-hidden="true" />
|
||||||
@@ -46,7 +46,7 @@ function Select(props: SelectProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'mt-2 rounded-md w-[var(--trigger-width)]',
|
'mt-2 rounded-md w-[var(--trigger-width)]',
|
||||||
'bg-ui-100 dark:bg-ui-800 shadow-sm',
|
'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',
|
'border border-ui-200 dark:border-ui-600',
|
||||||
'entering:animate-in exiting:animate-out',
|
'entering:animate-in exiting:animate-out',
|
||||||
'entering:fade-in entering:zoom-in-95',
|
'entering:fade-in entering:zoom-in-95',
|
||||||
@@ -54,15 +54,13 @@ function Select(props: SelectProps) {
|
|||||||
'fill-mode-forwards origin-left-right',
|
'fill-mode-forwards origin-left-right',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ListBox>
|
<ListBox orientation="vertical">{props.children}</ListBox>
|
||||||
{props.children}
|
|
||||||
</ListBox>
|
|
||||||
</Popover>
|
</Popover>
|
||||||
</AriaSelect>
|
</AriaSelect>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ItemProps = Parameters<typeof ListBoxItem>[0]
|
type ItemProps = Parameters<typeof ListBoxItem>[0];
|
||||||
|
|
||||||
function Item(props: ItemProps) {
|
function Item(props: ItemProps) {
|
||||||
return (
|
return (
|
||||||
@@ -76,7 +74,7 @@ function Item(props: ItemProps) {
|
|||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</ListBoxItem>
|
</ListBoxItem>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Object.assign(Select, { Item })
|
export default Object.assign(Select, { Item });
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
import clsx from 'clsx'
|
import clsx from 'clsx';
|
||||||
|
|
||||||
type Properties = {
|
interface Props {
|
||||||
// eslint-disable-next-line unicorn/no-keyword-prefix
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Spinner(properties: Properties) {
|
export default function Spinner({ className }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className={clsx('mr-1.5 inline-block align-middle mb-0.5', properties.className)}>
|
<div className={clsx('mr-1.5 inline-block align-middle mb-0.5', className)}>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'animate-spin rounded-full w-full h-full',
|
'animate-spin rounded-full w-full h-full',
|
||||||
'border-2 border-current border-t-transparent',
|
'border-2 border-current border-t-transparent',
|
||||||
properties.className
|
className,
|
||||||
)}
|
)}
|
||||||
role='status'
|
role="status"
|
||||||
>
|
>
|
||||||
<span className='sr-only'>Loading...</span>
|
<span className="sr-only">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import clsx from 'clsx'
|
import clsx from 'clsx';
|
||||||
import { type HTMLProps } from 'react'
|
import type { HTMLProps } from 'react';
|
||||||
|
|
||||||
type Properties = HTMLProps<SVGElement> & {
|
type Props = HTMLProps<SVGElement> & {
|
||||||
readonly isOnline: boolean;
|
readonly isOnline: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line unicorn/no-keyword-prefix
|
// eslint-disable-next-line unicorn/no-keyword-prefix
|
||||||
export default function StatusCircle({ isOnline, className }: Properties) {
|
export default function StatusCircle({ isOnline, className }: Props) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className={clsx(
|
className={clsx(
|
||||||
className,
|
className,
|
||||||
isOnline
|
isOnline
|
||||||
? 'text-green-700 dark:text-green-400'
|
? 'text-green-700 dark:text-green-400'
|
||||||
: 'text-gray-300 dark:text-gray-500'
|
: 'text-gray-300 dark:text-gray-500',
|
||||||
)}
|
)}
|
||||||
viewBox='0 0 24 24'
|
viewBox="0 0 24 24"
|
||||||
fill='currentColor'
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
<circle cx='12' cy='12' r='8'/>
|
<circle cx="12" cy="12" r="8" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-17
@@ -1,34 +1,34 @@
|
|||||||
import { Switch as AriaSwitch } from 'react-aria-components'
|
import { Switch as AriaSwitch } from 'react-aria-components';
|
||||||
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
import { cn } from '~/utils/cn'
|
type SwitchProps = Parameters<typeof AriaSwitch>[0] & {
|
||||||
|
|
||||||
type SwitchProperties = Parameters<typeof AriaSwitch>[0] & {
|
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function Switch(properties: SwitchProperties) {
|
export default function Switch(props: SwitchProps) {
|
||||||
return (
|
return (
|
||||||
<AriaSwitch
|
<AriaSwitch
|
||||||
{...properties}
|
{...props}
|
||||||
aria-label={properties.label}
|
aria-label={props.label}
|
||||||
className='group flex gap-2 items-center'
|
className="group flex gap-2 items-center"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-[26px] w-[44px] p-[4px] shrink-0',
|
'flex h-[26px] w-[44px] p-[4px] shrink-0',
|
||||||
'rounded-full outline-none group-focus-visible:ring-2',
|
'rounded-full outline-none group-focus-visible:ring-2',
|
||||||
'bg-main-600/50 dark:bg-main-600/20 group-selected:bg-main-700',
|
'bg-main-600/50 dark:bg-main-600/20 group-selected:bg-main-700',
|
||||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
props.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||||
properties.className
|
props.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={cn(
|
<span
|
||||||
'h-[18px] w-[18px] transform rounded-full',
|
className={cn(
|
||||||
'bg-white transition duration-100 ease-in-out',
|
'h-[18px] w-[18px] transform rounded-full',
|
||||||
'translate-x-0 group-selected:translate-x-[100%]'
|
'bg-white transition duration-100 ease-in-out',
|
||||||
)}
|
'translate-x-0 group-selected:translate-x-[100%]',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AriaSwitch>
|
</AriaSwitch>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-16
@@ -1,32 +1,33 @@
|
|||||||
import { NavLink } from '@remix-run/react'
|
import { NavLink } from 'react-router';
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react';
|
||||||
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
import { cn } from '~/utils/cn'
|
interface Props {
|
||||||
|
name: string;
|
||||||
type Properties = {
|
to: string;
|
||||||
readonly name: string;
|
icon: ReactNode;
|
||||||
readonly to: string;
|
|
||||||
readonly icon: ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TabLink({ name, to, icon }: Properties) {
|
export default function TabLink({ name, to, icon }: Props) {
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
to={to}
|
to={to}
|
||||||
prefetch='intent'
|
prefetch="intent"
|
||||||
className={({ isActive }) => cn(
|
className={({ isActive }) =>
|
||||||
'border-b-2 py-1.5',
|
cn(
|
||||||
isActive ? 'border-white' : 'border-transparent'
|
'border-b-2 py-1.5',
|
||||||
)}
|
isActive ? 'border-white' : 'border-transparent',
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-x-2 px-2.5 py-1.5 text-md text-nowrap',
|
'flex items-center gap-x-2 px-2.5 py-1.5 text-md text-nowrap',
|
||||||
'hover:bg-ui-100/5 dark:hover:bg-ui-900/40 rounded-md'
|
'hover:bg-ui-100/5 dark:hover:bg-ui-900/40 rounded-md',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon} {name}
|
{icon} {name}
|
||||||
</div>
|
</div>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,36 @@
|
|||||||
import clsx from 'clsx'
|
import clsx from 'clsx';
|
||||||
import { type HTMLProps } from 'react'
|
import type { HTMLProps } from 'react';
|
||||||
|
|
||||||
function TableList(properties: HTMLProps<HTMLDivElement>) {
|
function TableList(props: HTMLProps<HTMLDivElement>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...properties}
|
{...props}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'border border-gray-300 rounded-lg overflow-clip',
|
'border border-gray-300 rounded-lg overflow-clip',
|
||||||
'dark:border-zinc-700 dark:text-gray-300',
|
'dark:border-zinc-700 dark:text-gray-300',
|
||||||
// 'dark:bg-zinc-800',
|
// 'dark:bg-zinc-800',
|
||||||
properties.className
|
props.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{properties.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Item(properties: HTMLProps<HTMLDivElement>) {
|
function Item(props: HTMLProps<HTMLDivElement>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...properties}
|
{...props}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|
||||||
'flex items-center justify-between px-3 py-2',
|
'flex items-center justify-between px-3 py-2',
|
||||||
'border-b border-gray-200 last:border-b-0',
|
'border-b border-gray-200 last:border-b-0',
|
||||||
'dark:border-zinc-800',
|
'dark:border-zinc-800',
|
||||||
properties.className
|
props.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{properties.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Object.assign(TableList, { Item })
|
export default Object.assign(TableList, { Item });
|
||||||
|
|||||||
@@ -1,38 +1,30 @@
|
|||||||
import { type Dispatch, type SetStateAction } from 'react'
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
import {
|
import { Input, TextField as AriaTextField } from 'react-aria-components';
|
||||||
Input,
|
import { cn } from '~/utils/cn';
|
||||||
TextField as AriaTextField
|
|
||||||
} from 'react-aria-components'
|
|
||||||
|
|
||||||
import { cn } from '~/utils/cn'
|
type TextFieldProps = Parameters<typeof AriaTextField>[0] & {
|
||||||
|
|
||||||
type TextFieldProperties = Parameters<typeof AriaTextField>[0] & {
|
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly placeholder: string;
|
readonly placeholder: string;
|
||||||
readonly state?: [string, Dispatch<SetStateAction<string>>];
|
readonly state?: [string, Dispatch<SetStateAction<string>>];
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function TextField(properties: TextFieldProperties) {
|
export default function TextField(props: TextFieldProps) {
|
||||||
return (
|
return (
|
||||||
<AriaTextField
|
<AriaTextField {...props} aria-label={props.label} className="w-full">
|
||||||
{...properties}
|
|
||||||
aria-label={properties.label}
|
|
||||||
className='w-full'
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder={properties.placeholder}
|
placeholder={props.placeholder}
|
||||||
value={properties.state?.[0]}
|
value={props.state?.[0]}
|
||||||
name={properties.name}
|
name={props.name}
|
||||||
className={cn(
|
className={cn(
|
||||||
'block px-2.5 py-1.5 w-full rounded-lg my-1',
|
'block px-2.5 py-1.5 w-full rounded-lg my-1',
|
||||||
'border border-ui-200 dark:border-ui-600',
|
'border border-ui-200 dark:border-ui-600',
|
||||||
'dark:bg-ui-800 dark:text-ui-300',
|
'dark:bg-ui-800 dark:text-ui-300',
|
||||||
properties.className
|
props.className,
|
||||||
)}
|
)}
|
||||||
onChange={event => {
|
onChange={(event) => {
|
||||||
properties.state?.[1](event.target.value)
|
props.state?.[1](event.target.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</AriaTextField>
|
</AriaTextField>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-43
@@ -1,20 +1,32 @@
|
|||||||
import { XIcon } from '@primer/octicons-react'
|
import { XIcon } from '@primer/octicons-react';
|
||||||
import { type AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
|
import {
|
||||||
import { ToastQueue, type ToastState, useToastQueue } from '@react-stately/toast'
|
AriaToastProps,
|
||||||
import { type ReactNode, useRef } from 'react'
|
useToast,
|
||||||
import { Button } from 'react-aria-components'
|
useToastRegion,
|
||||||
import { createPortal } from 'react-dom'
|
} from '@react-aria/toast';
|
||||||
import { ClientOnly } from 'remix-utils/client-only'
|
import {
|
||||||
|
ToastQueue,
|
||||||
|
ToastState,
|
||||||
|
useToastQueue,
|
||||||
|
} from '@react-stately/toast';
|
||||||
|
import { ReactNode, useRef } from 'react';
|
||||||
|
import { Button } from 'react-aria-components';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { ClientOnly } from 'remix-utils/client-only';
|
||||||
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
import { cn } from '~/utils/cn'
|
type ToastProps = AriaToastProps<ReactNode> & {
|
||||||
|
|
||||||
type ToastProperties = AriaToastProps<ReactNode> & {
|
|
||||||
readonly state: ToastState<ReactNode>;
|
readonly state: ToastState<ReactNode>;
|
||||||
}
|
};
|
||||||
|
|
||||||
function Toast({ state, ...properties }: ToastProperties) {
|
function Toast({ state, ...properties }: ToastProps) {
|
||||||
const reference = useRef(null)
|
const reference = useRef(null);
|
||||||
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference)
|
|
||||||
|
const { toastProps, titleProps, closeButtonProps } = useToast(
|
||||||
|
properties,
|
||||||
|
state,
|
||||||
|
reference,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -24,7 +36,7 @@ function Toast({ state, ...properties }: ToastProperties) {
|
|||||||
'bg-main-700 dark:bg-main-800 rounded-lg',
|
'bg-main-700 dark:bg-main-800 rounded-lg',
|
||||||
'text-main-100 dark:text-main-200 z-50',
|
'text-main-100 dark:text-main-200 z-50',
|
||||||
'border border-main-600 dark:border-main-700',
|
'border border-main-600 dark:border-main-700',
|
||||||
'flex items-center justify-between p-3 pl-4 w-80'
|
'flex items-center justify-between p-3 pl-4 w-80',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div {...titleProps}>{properties.toast.content}</div>
|
<div {...titleProps}>{properties.toast.content}</div>
|
||||||
@@ -32,48 +44,48 @@ function Toast({ state, ...properties }: ToastProperties) {
|
|||||||
{...closeButtonProps}
|
{...closeButtonProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
'outline-none rounded-full p-1',
|
'outline-none rounded-full p-1',
|
||||||
'hover:bg-main-600 dark:hover:bg-main-700'
|
'hover:bg-main-600 dark:hover:bg-main-700',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<XIcon className='w-4 h-4'/>
|
<XIcon className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const toasts = new ToastQueue<ReactNode>({
|
const toasts = new ToastQueue<ReactNode>({
|
||||||
maxVisibleToasts: 5
|
maxVisibleToasts: 5,
|
||||||
})
|
});
|
||||||
|
|
||||||
export function toast(text: string) {
|
export function toast(text: string) {
|
||||||
return toasts.add(text, { timeout: 5000 })
|
return toasts.add(text, { timeout: 5000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
const reference = useRef(null)
|
const reference = useRef(null);
|
||||||
const state = useToastQueue(toasts)
|
const state = useToastQueue(toasts);
|
||||||
const { regionProps } = useToastRegion({}, state, reference)
|
|
||||||
|
const { regionProps } = useToastRegion({}, state, reference);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
{() => createPortal(
|
{
|
||||||
state.visibleToasts.length >= 0 ? (
|
() =>
|
||||||
<div
|
createPortal(
|
||||||
className={cn(
|
state.visibleToasts.length >= 0 ? (
|
||||||
'fixed bottom-4 right-4',
|
<div
|
||||||
'flex flex-col gap-4'
|
className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
|
||||||
)}
|
{...regionProps}
|
||||||
{...regionProps}
|
ref={reference}
|
||||||
ref={reference}
|
>
|
||||||
>
|
{state.visibleToasts.map((toast) => (
|
||||||
{state.visibleToasts.map(toast => (
|
<Toast key={toast.key} toast={toast} state={state} />
|
||||||
<Toast key={toast.key} toast={toast} state={state}/>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
) : undefined,
|
||||||
) : undefined,
|
document.body,
|
||||||
document.body
|
)
|
||||||
)}
|
}
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-22
@@ -1,43 +1,37 @@
|
|||||||
import { ReactNode } from 'react'
|
import type { ReactNode } from 'react';
|
||||||
import {
|
import {
|
||||||
Button as AriaButton,
|
Button as AriaButton,
|
||||||
Tooltip as AriaTooltip,
|
Tooltip as AriaTooltip,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from 'react-aria-components'
|
} from 'react-aria-components';
|
||||||
|
import { cn } from '~/utils/cn';
|
||||||
import { cn } from '~/utils/cn'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tooltip({ children }: Props) {
|
function Tooltip({ children }: Props) {
|
||||||
return (
|
return <TooltipTrigger delay={0}>{children}</TooltipTrigger>;
|
||||||
<TooltipTrigger delay={0}>
|
|
||||||
{children}
|
|
||||||
</TooltipTrigger>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Button(props: Parameters<typeof AriaButton>[0]) {
|
function Button(props: Parameters<typeof AriaButton>[0]) {
|
||||||
return (
|
return <AriaButton {...props} />;
|
||||||
<AriaButton {...props} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Body({ children, className }: Props) {
|
function Body({ children, className }: Props) {
|
||||||
return (
|
return (
|
||||||
<AriaTooltip className={cn(
|
<AriaTooltip
|
||||||
'text-sm max-w-xs p-2 rounded-lg mb-2',
|
className={cn(
|
||||||
'bg-white dark:bg-ui-900 drop-shadow-sm',
|
'text-sm max-w-xs p-2 rounded-lg mb-2',
|
||||||
'border border-gray-200 dark:border-zinc-700',
|
'bg-white dark:bg-ui-900 drop-shadow-sm',
|
||||||
className,
|
'border border-gray-200 dark:border-zinc-700',
|
||||||
)}
|
className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</AriaTooltip>
|
</AriaTooltip>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Object.assign(Tooltip, { Button, Body })
|
export default Object.assign(Tooltip, { Button, Body });
|
||||||
|
|||||||
@@ -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
@@ -1,62 +1,71 @@
|
|||||||
import { PassThrough } from 'node:stream'
|
import { PassThrough } from "node:stream";
|
||||||
|
|
||||||
import type { AppLoadContext, EntryContext } from '@remix-run/node'
|
import type { AppLoadContext, EntryContext } from "react-router";
|
||||||
import { createReadableStreamFromReadable } from '@remix-run/node'
|
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||||
import { RemixServer } from '@remix-run/react'
|
import { ServerRouter } from "react-router";
|
||||||
import { isbot } from 'isbot'
|
import { isbot } from "isbot";
|
||||||
import { renderToPipeableStream } from 'react-dom/server'
|
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||||
|
import { renderToPipeableStream } from "react-dom/server";
|
||||||
|
import { loadContext } from "~/utils/config/headplane";
|
||||||
|
|
||||||
import { loadContext } from './utils/config/headplane'
|
loadContext();
|
||||||
|
|
||||||
await loadContext()
|
export const streamTimeout = 5_000;
|
||||||
|
|
||||||
export default function handleRequest(
|
export default function handleRequest(
|
||||||
request: Request,
|
request: Request,
|
||||||
responseStatusCode: number,
|
responseStatusCode: number,
|
||||||
responseHeaders: Headers,
|
responseHeaders: Headers,
|
||||||
remixContext: EntryContext,
|
routerContext: EntryContext,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
loadContext: AppLoadContext
|
||||||
_loadContext: AppLoadContext,
|
|
||||||
) {
|
) {
|
||||||
const ua = request.headers.get('user-agent')
|
return new Promise((resolve, reject) => {
|
||||||
const isBot = ua ? isbot(ua) : false
|
let shellRendered = false;
|
||||||
|
const userAgent = request.headers.get("user-agent");
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
|
||||||
let shellRendered = false
|
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
|
||||||
const { pipe, abort } = renderToPipeableStream(
|
const readyOption: keyof RenderToPipeableStreamOptions =
|
||||||
<RemixServer
|
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
|
||||||
context={remixContext}
|
? "onAllReady"
|
||||||
url={request.url}
|
: "onShellReady";
|
||||||
abortDelay={5000}
|
|
||||||
/>,
|
|
||||||
{
|
|
||||||
[isBot ? 'onAllReady' : 'onShellReady']() {
|
|
||||||
shellRendered = true
|
|
||||||
const body = new PassThrough()
|
|
||||||
const stream = createReadableStreamFromReadable(body)
|
|
||||||
responseHeaders.set('Content-Type', 'text/html')
|
|
||||||
|
|
||||||
resolve(
|
const { pipe, abort } = renderToPipeableStream(
|
||||||
new Response(stream, {
|
<ServerRouter context={routerContext} url={request.url} />,
|
||||||
headers: responseHeaders,
|
{
|
||||||
status: responseStatusCode,
|
[readyOption]() {
|
||||||
}),
|
shellRendered = true;
|
||||||
)
|
const body = new PassThrough();
|
||||||
|
const stream = createReadableStreamFromReadable(body);
|
||||||
|
|
||||||
pipe(body)
|
responseHeaders.set("Content-Type", "text/html");
|
||||||
},
|
|
||||||
onShellError(error: unknown) {
|
|
||||||
reject(error as Error)
|
|
||||||
},
|
|
||||||
onError(error: unknown) {
|
|
||||||
responseStatusCode = 500
|
|
||||||
if (shellRendered) {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
setTimeout(abort, 5000)
|
resolve(
|
||||||
})
|
new Response(stream, {
|
||||||
|
headers: responseHeaders,
|
||||||
|
status: responseStatusCode,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
pipe(body);
|
||||||
|
},
|
||||||
|
onShellError(error: unknown) {
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
onError(error: unknown) {
|
||||||
|
responseStatusCode = 500;
|
||||||
|
// Log streaming rendering errors from inside the shell. Don't log
|
||||||
|
// errors encountered during initial shell rendering since they'll
|
||||||
|
// reject and get logged in handleDocumentRequest.
|
||||||
|
if (shellRendered) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Abort the rendering stream after the `streamTimeout` so it has tine to
|
||||||
|
// flush down the rejected boundaries
|
||||||
|
setTimeout(abort, streamTimeout + 1000);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-67
@@ -1,17 +1,17 @@
|
|||||||
import { access, constants } from 'node:fs/promises'
|
import { access, constants } from 'node:fs/promises';
|
||||||
import { setTimeout } from 'node:timers/promises'
|
import { setTimeout } from 'node:timers/promises';
|
||||||
|
|
||||||
import { Client } from 'undici'
|
import { Client } from 'undici';
|
||||||
|
|
||||||
import { HeadscaleError, pull } from '~/utils/headscale'
|
import { HeadscaleError, pull } from '~/utils/headscale';
|
||||||
import log from '~/utils/log'
|
import log from '~/utils/log';
|
||||||
|
|
||||||
import { createIntegration } from './integration'
|
import { createIntegration } from './integration';
|
||||||
|
|
||||||
interface Context {
|
interface Context {
|
||||||
client: Client | undefined
|
client: Client | undefined;
|
||||||
container: string | undefined
|
container: string | undefined;
|
||||||
maxAttempts: number
|
maxAttempts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default createIntegration<Context>({
|
export default createIntegration<Context>({
|
||||||
@@ -24,133 +24,126 @@ export default createIntegration<Context>({
|
|||||||
isAvailable: async (context) => {
|
isAvailable: async (context) => {
|
||||||
// Check for the HEADSCALE_CONTAINER environment variable first
|
// Check for the HEADSCALE_CONTAINER environment variable first
|
||||||
// to avoid unnecessary fetching of the Docker socket
|
// to avoid unnecessary fetching of the Docker socket
|
||||||
log.debug('INTG', 'Checking Docker integration availability')
|
log.debug('INTG', 'Checking Docker integration availability');
|
||||||
context.container = process.env.HEADSCALE_CONTAINER
|
context.container = process.env.HEADSCALE_CONTAINER?.trim().toLowerCase();
|
||||||
?.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
|
|
||||||
if (!context.container || context.container.length === 0) {
|
if (!context.container || context.container.length === 0) {
|
||||||
log.error('INTG', 'Missing HEADSCALE_CONTAINER variable')
|
log.error('INTG', 'Missing HEADSCALE_CONTAINER variable');
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('INTG', 'Using container: %s', context.container)
|
log.info('INTG', 'Using container: %s', context.container);
|
||||||
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'
|
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock';
|
||||||
let url: URL | undefined
|
let url: URL | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
url = new URL(path)
|
url = new URL(path);
|
||||||
} catch {
|
} catch {
|
||||||
log.error('INTG', 'Invalid Docker socket path: %s', path)
|
log.error('INTG', 'Invalid Docker socket path: %s', path);
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') {
|
if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') {
|
||||||
log.error('INTG', 'Invalid Docker socket protocol: %s',
|
log.error('INTG', 'Invalid Docker socket protocol: %s', url.protocol);
|
||||||
url.protocol,
|
return false;
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The API is available as an HTTP endpoint and this
|
// The API is available as an HTTP endpoint and this
|
||||||
// will simplify the fetching logic in undici
|
// will simplify the fetching logic in undici
|
||||||
if (url.protocol === 'tcp:') {
|
if (url.protocol === 'tcp:') {
|
||||||
// Apparently setting url.protocol doesn't work anymore?
|
// Apparently setting url.protocol doesn't work anymore?
|
||||||
const fetchU = url.href.replace(url.protocol, 'http:')
|
const fetchU = url.href.replace(url.protocol, 'http:');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log.info('INTG', 'Checking API: %s', fetchU)
|
log.info('INTG', 'Checking API: %s', fetchU);
|
||||||
await fetch(new URL('/v1.30/version', fetchU).href)
|
await fetch(new URL('/v1.30/version', fetchU).href);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.debug('INTG', 'Failed to connect to Docker API', error)
|
log.debug('INTG', 'Failed to connect to Docker API', error);
|
||||||
log.error('INTG', 'Failed to connect to Docker API')
|
log.error('INTG', 'Failed to connect to Docker API');
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
context.client = new Client(fetchU)
|
context.client = new Client(fetchU);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the socket is accessible
|
// Check if the socket is accessible
|
||||||
if (url.protocol === 'unix:') {
|
if (url.protocol === 'unix:') {
|
||||||
try {
|
try {
|
||||||
log.info('INTG', 'Checking socket: %s',
|
log.info('INTG', 'Checking socket: %s', url.pathname);
|
||||||
url.pathname,
|
await access(url.pathname, constants.R_OK);
|
||||||
)
|
|
||||||
await access(url.pathname, constants.R_OK)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.debug('INTG', 'Failed to access Docker socket: %s', error)
|
log.debug('INTG', 'Failed to access Docker socket: %s', error);
|
||||||
log.error('INTG', 'Failed to access Docker socket: %s',
|
log.error('INTG', 'Failed to access Docker socket: %s', path);
|
||||||
path,
|
return false;
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
context.client = new Client('http://localhost', {
|
context.client = new Client('http://localhost', {
|
||||||
socketPath: url.pathname,
|
socketPath: url.pathname,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return context.client !== undefined
|
return context.client !== undefined;
|
||||||
},
|
},
|
||||||
|
|
||||||
onConfigChange: async (context) => {
|
onConfigChange: async (context) => {
|
||||||
if (!context.client || !context.container) {
|
if (!context.client || !context.container) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('INTG', 'Restarting Headscale via Docker')
|
log.info('INTG', 'Restarting Headscale via Docker');
|
||||||
|
|
||||||
let attempts = 0
|
let attempts = 0;
|
||||||
while (attempts <= context.maxAttempts) {
|
while (attempts <= context.maxAttempts) {
|
||||||
log.debug(
|
log.debug(
|
||||||
'INTG', 'Restarting container: %s (attempt %d)',
|
'INTG',
|
||||||
|
'Restarting container: %s (attempt %d)',
|
||||||
context.container,
|
context.container,
|
||||||
attempts,
|
attempts,
|
||||||
)
|
);
|
||||||
|
|
||||||
const response = await context.client.request({
|
const response = await context.client.request({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: `/v1.30/containers/${context.container}/restart`,
|
path: `/v1.30/containers/${context.container}/restart`,
|
||||||
})
|
});
|
||||||
|
|
||||||
if (response.statusCode !== 204) {
|
if (response.statusCode !== 204) {
|
||||||
if (attempts < context.maxAttempts) {
|
if (attempts < context.maxAttempts) {
|
||||||
attempts++
|
attempts++;
|
||||||
await setTimeout(1000)
|
await setTimeout(1000);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stringCode = response.statusCode.toString()
|
const stringCode = response.statusCode.toString();
|
||||||
const body = await response.body.text()
|
const body = await response.body.text();
|
||||||
throw new Error(`API request failed: ${stringCode} ${body}`)
|
throw new Error(`API request failed: ${stringCode} ${body}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
attempts = 0
|
attempts = 0;
|
||||||
while (attempts <= context.maxAttempts) {
|
while (attempts <= context.maxAttempts) {
|
||||||
try {
|
try {
|
||||||
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts)
|
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts);
|
||||||
await pull('v1', '')
|
await pull('v1', '');
|
||||||
return
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HeadscaleError && error.status === 401) {
|
if (error instanceof HeadscaleError && error.status === 401) {
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof HeadscaleError && error.status === 404) {
|
if (error instanceof HeadscaleError && error.status === 404) {
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempts < context.maxAttempts) {
|
if (attempts < context.maxAttempts) {
|
||||||
attempts++
|
attempts++;
|
||||||
await setTimeout(1000)
|
await setTimeout(1000);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Missed restart deadline for ${context.container}`)
|
throw new Error(`Missed restart deadline for ${context.container}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
+27
-34
@@ -1,75 +1,68 @@
|
|||||||
import log from '~/utils/log'
|
import log from '~/utils/log';
|
||||||
|
|
||||||
import dockerIntegration from './docker'
|
import dockerIntegration from './docker';
|
||||||
import { IntegrationFactory } from './integration'
|
import type { IntegrationFactory } from './integration';
|
||||||
import kubernetesIntegration from './kubernetes'
|
import kubernetesIntegration from './kubernetes';
|
||||||
import procIntegration from './proc'
|
import procIntegration from './proc';
|
||||||
|
|
||||||
export * from './integration'
|
export * from './integration';
|
||||||
|
|
||||||
export async function loadIntegration() {
|
export async function loadIntegration() {
|
||||||
let integration = process.env.HEADSCALE_INTEGRATION
|
let integration = process.env.HEADSCALE_INTEGRATION?.trim().toLowerCase();
|
||||||
?.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
|
|
||||||
// Old HEADSCALE_CONTAINER variable upgrade path
|
// Old HEADSCALE_CONTAINER variable upgrade path
|
||||||
// This ensures that when people upgrade from older versions of Headplane
|
// This ensures that when people upgrade from older versions of Headplane
|
||||||
// they don't explicitly need to define the new HEADSCALE_INTEGRATION
|
// they don't explicitly need to define the new HEADSCALE_INTEGRATION
|
||||||
// variable that is needed to configure docker
|
// variable that is needed to configure docker
|
||||||
if (!integration && process.env.HEADSCALE_CONTAINER) {
|
if (!integration && process.env.HEADSCALE_CONTAINER) {
|
||||||
integration = 'docker'
|
integration = 'docker';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!integration) {
|
if (!integration) {
|
||||||
log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION')
|
log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let integrationFactory: IntegrationFactory | undefined
|
let integrationFactory: IntegrationFactory | undefined;
|
||||||
switch (integration.toLowerCase().trim()) {
|
switch (integration.toLowerCase().trim()) {
|
||||||
case 'docker': {
|
case 'docker': {
|
||||||
integrationFactory = dockerIntegration
|
integrationFactory = dockerIntegration;
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'proc':
|
case 'proc':
|
||||||
case 'native':
|
case 'native':
|
||||||
case 'linux': {
|
case 'linux': {
|
||||||
integrationFactory = procIntegration
|
integrationFactory = procIntegration;
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'kubernetes':
|
case 'kubernetes':
|
||||||
case 'k8s': {
|
case 'k8s': {
|
||||||
integrationFactory = kubernetesIntegration
|
integrationFactory = kubernetesIntegration;
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
log.error('INTG', 'Unknown integration: %s', integration)
|
log.error('INTG', 'Unknown integration: %s', integration);
|
||||||
throw new Error(`Unknown integration: ${integration}`)
|
throw new Error(`Unknown integration: ${integration}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('INTG', 'Loading integration: %s', integration)
|
log.info('INTG', 'Loading integration: %s', integration);
|
||||||
try {
|
try {
|
||||||
const res = await integrationFactory.isAvailable(
|
const res = await integrationFactory.isAvailable(
|
||||||
integrationFactory.context,
|
integrationFactory.context,
|
||||||
)
|
);
|
||||||
if (!res) {
|
if (!res) {
|
||||||
log.error('INTG', 'Integration %s is not available',
|
log.error('INTG', 'Integration %s is not available', integration);
|
||||||
integration,
|
return;
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('INTG', 'Failed to load integration %s: %s',
|
log.error('INTG', 'Failed to load integration %s: %s', integration, error);
|
||||||
integration,
|
return;
|
||||||
error,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('INTG', 'Loaded integration: %s', integration)
|
log.info('INTG', 'Loaded integration: %s', integration);
|
||||||
return integrationFactory
|
return integrationFactory;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export interface IntegrationFactory<T = any> {
|
export interface IntegrationFactory<T = any> {
|
||||||
name: string
|
name: string;
|
||||||
context: T
|
context: T;
|
||||||
isAvailable: (context: T) => Promise<boolean> | boolean
|
isAvailable: (context: T) => Promise<boolean> | boolean;
|
||||||
onConfigChange?: (context: T) => Promise<void> | void
|
onConfigChange?: (context: T) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createIntegration<T>(
|
export function createIntegration<T>(options: IntegrationFactory<T>) {
|
||||||
options: IntegrationFactory<T>,
|
return options;
|
||||||
) {
|
|
||||||
return options
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { readdir, readFile } from 'node:fs/promises'
|
import { readdir, readFile } from 'node:fs/promises';
|
||||||
import { platform } from 'node:os'
|
import { platform } from 'node:os';
|
||||||
import { join, resolve } from 'node:path'
|
import { join, resolve } from 'node:path';
|
||||||
import { kill } from 'node:process'
|
import { kill } from 'node:process';
|
||||||
|
|
||||||
import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node'
|
import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node';
|
||||||
|
|
||||||
import log from '~/utils/log'
|
import log from '~/utils/log';
|
||||||
|
|
||||||
import { createIntegration } from './integration'
|
import { createIntegration } from './integration';
|
||||||
|
|
||||||
interface Context {
|
interface Context {
|
||||||
pid: number | undefined
|
pid: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default createIntegration<Context>({
|
export default createIntegration<Context>({
|
||||||
@@ -20,185 +20,192 @@ export default createIntegration<Context>({
|
|||||||
},
|
},
|
||||||
isAvailable: async (context) => {
|
isAvailable: async (context) => {
|
||||||
if (platform() !== 'linux') {
|
if (platform() !== 'linux') {
|
||||||
log.error('INTG', 'Kubernetes is only available on Linux')
|
log.error('INTG', 'Kubernetes is only available on Linux');
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const svcRoot = Config.SERVICEACCOUNT_ROOT
|
const svcRoot = Config.SERVICEACCOUNT_ROOT;
|
||||||
try {
|
try {
|
||||||
log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot)
|
log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot);
|
||||||
const files = await readdir(svcRoot)
|
const files = await readdir(svcRoot);
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
log.error('INTG', 'Kubernetes service account not found')
|
log.error('INTG', 'Kubernetes service account not found');
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mappedFiles = new Set(files.map(file => join(svcRoot, file)))
|
const mappedFiles = new Set(files.map((file) => join(svcRoot, file)));
|
||||||
const expectedFiles = [
|
const expectedFiles = [
|
||||||
Config.SERVICEACCOUNT_CA_PATH,
|
Config.SERVICEACCOUNT_CA_PATH,
|
||||||
Config.SERVICEACCOUNT_TOKEN_PATH,
|
Config.SERVICEACCOUNT_TOKEN_PATH,
|
||||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||||
]
|
];
|
||||||
|
|
||||||
log.debug('INTG', 'Looking for %s', expectedFiles.join(', '))
|
log.debug('INTG', 'Looking for %s', expectedFiles.join(', '));
|
||||||
if (!expectedFiles.every(file => mappedFiles.has(file))) {
|
if (!expectedFiles.every((file) => mappedFiles.has(file))) {
|
||||||
log.error('INTG', 'Malformed Kubernetes service account')
|
log.error('INTG', 'Malformed Kubernetes service account');
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('INTG', 'Failed to access %s: %s', svcRoot, error)
|
log.error('INTG', 'Failed to access %s: %s', svcRoot, error);
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot)
|
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot);
|
||||||
const namespace = await readFile(
|
const namespace = await readFile(
|
||||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||||
'utf8',
|
'utf8',
|
||||||
)
|
);
|
||||||
|
|
||||||
// Some very ugly nesting but it's necessary
|
// Some very ugly nesting but it's necessary
|
||||||
if (process.env.HEADSCALE_INTEGRATION_UNSTRICT === 'true') {
|
if (process.env.HEADSCALE_INTEGRATION_UNSTRICT === 'true') {
|
||||||
log.warn('INTG', 'Skipping strict Pod status check')
|
log.warn('INTG', 'Skipping strict Pod status check');
|
||||||
} else {
|
} else {
|
||||||
const pod = process.env.POD_NAME
|
const pod = process.env.POD_NAME;
|
||||||
if (!pod) {
|
if (!pod) {
|
||||||
log.error('INTG', 'Missing POD_NAME variable')
|
log.error('INTG', 'Missing POD_NAME variable');
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pod.trim().length === 0) {
|
if (pod.trim().length === 0) {
|
||||||
log.error('INTG', 'Pod name is empty')
|
log.error('INTG', 'Pod name is empty');
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug('INTG', 'Checking Kubernetes pod %s in namespace %s',
|
log.debug(
|
||||||
|
'INTG',
|
||||||
|
'Checking Kubernetes pod %s in namespace %s',
|
||||||
pod,
|
pod,
|
||||||
namespace,
|
namespace,
|
||||||
)
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log.debug('INTG', 'Attempgin to get cluster KubeConfig')
|
log.debug('INTG', 'Attempgin to get cluster KubeConfig');
|
||||||
const kc = new KubeConfig()
|
const kc = new KubeConfig();
|
||||||
kc.loadFromCluster()
|
kc.loadFromCluster();
|
||||||
|
|
||||||
const cluster = kc.getCurrentCluster()
|
const cluster = kc.getCurrentCluster();
|
||||||
if (!cluster) {
|
if (!cluster) {
|
||||||
log.error('INTG', 'Malformed kubeconfig')
|
log.error('INTG', 'Malformed kubeconfig');
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('INTG', 'Service account connected to %s (%s)',
|
log.info(
|
||||||
|
'INTG',
|
||||||
|
'Service account connected to %s (%s)',
|
||||||
cluster.name,
|
cluster.name,
|
||||||
cluster.server,
|
cluster.server,
|
||||||
)
|
);
|
||||||
|
|
||||||
const kCoreV1Api = kc.makeApiClient(CoreV1Api)
|
const kCoreV1Api = kc.makeApiClient(CoreV1Api);
|
||||||
|
|
||||||
log.info('INTG', 'Checking pod %s in namespace %s (%s)',
|
log.info(
|
||||||
|
'INTG',
|
||||||
|
'Checking pod %s in namespace %s (%s)',
|
||||||
pod,
|
pod,
|
||||||
namespace,
|
namespace,
|
||||||
kCoreV1Api.basePath,
|
kCoreV1Api.basePath,
|
||||||
)
|
);
|
||||||
|
|
||||||
log.debug('INTG', 'Reading pod info for %s', pod)
|
log.debug('INTG', 'Reading pod info for %s', pod);
|
||||||
const { response, body } = await kCoreV1Api.readNamespacedPod(
|
const { response, body } = await kCoreV1Api.readNamespacedPod(
|
||||||
pod,
|
pod,
|
||||||
namespace,
|
namespace,
|
||||||
)
|
);
|
||||||
|
|
||||||
if (response.statusCode !== 200) {
|
if (response.statusCode !== 200) {
|
||||||
log.error('INTG', 'Failed to read pod info: http %d',
|
|
||||||
response.statusCode,
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug('INTG', 'Got pod info: %o', body.spec)
|
|
||||||
const shared = body.spec?.shareProcessNamespace
|
|
||||||
if (shared === undefined) {
|
|
||||||
log.error(
|
log.error(
|
||||||
'INTG',
|
'INTG',
|
||||||
'Pod does not have spec.shareProcessNamespace set',
|
'Failed to read pod info: http %d',
|
||||||
)
|
response.statusCode,
|
||||||
return false
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('INTG', 'Got pod info: %o', body.spec);
|
||||||
|
const shared = body.spec?.shareProcessNamespace;
|
||||||
|
if (shared === undefined) {
|
||||||
|
log.error('INTG', 'Pod does not have spec.shareProcessNamespace set');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shared) {
|
if (!shared) {
|
||||||
log.error(
|
log.error(
|
||||||
'INTG',
|
'INTG',
|
||||||
'Pod has set but disabled spec.shareProcessNamespace',
|
'Pod has set but disabled spec.shareProcessNamespace',
|
||||||
)
|
);
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('INTG', 'Pod %s enabled shared processes', pod)
|
log.info('INTG', 'Pod %s enabled shared processes', pod);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('INTG', 'Failed to read pod info: %s', error)
|
log.error('INTG', 'Failed to read pod info: %s', error);
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug('INTG', 'Looking for namespaced process in /proc')
|
log.debug('INTG', 'Looking for namespaced process in /proc');
|
||||||
const dir = resolve('/proc')
|
const dir = resolve('/proc');
|
||||||
try {
|
try {
|
||||||
const subdirs = await readdir(dir)
|
const subdirs = await readdir(dir);
|
||||||
const promises = subdirs.map(async (dir) => {
|
const promises = subdirs.map(async (dir) => {
|
||||||
const pid = Number.parseInt(dir, 10)
|
const pid = Number.parseInt(dir, 10);
|
||||||
|
|
||||||
if (Number.isNaN(pid)) {
|
if (Number.isNaN(pid)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = join('/proc', dir, 'cmdline')
|
const path = join('/proc', dir, 'cmdline');
|
||||||
try {
|
try {
|
||||||
log.debug('INTG', 'Reading %s', path)
|
log.debug('INTG', 'Reading %s', path);
|
||||||
const data = await readFile(path, 'utf8')
|
const data = await readFile(path, 'utf8');
|
||||||
if (data.includes('headscale')) {
|
if (data.includes('headscale')) {
|
||||||
return pid
|
return pid;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.debug('INTG', 'Failed to read %s: %s', path, error)
|
log.debug('INTG', 'Failed to read %s: %s', path, error);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const results = await Promise.allSettled(promises)
|
const results = await Promise.allSettled(promises);
|
||||||
const pids = []
|
const pids = [];
|
||||||
|
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.status === 'fulfilled' && result.value) {
|
if (result.status === 'fulfilled' && result.value) {
|
||||||
pids.push(result.value)
|
pids.push(result.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug('INTG', 'Found Headscale processes: %o', pids)
|
log.debug('INTG', 'Found Headscale processes: %o', pids);
|
||||||
if (pids.length > 1) {
|
if (pids.length > 1) {
|
||||||
log.error('INTG', 'Found %d Headscale processes: %s',
|
log.error(
|
||||||
|
'INTG',
|
||||||
|
'Found %d Headscale processes: %s',
|
||||||
pids.length,
|
pids.length,
|
||||||
pids.join(', '),
|
pids.join(', '),
|
||||||
)
|
);
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pids.length === 0) {
|
if (pids.length === 0) {
|
||||||
log.error('INTG', 'Could not find Headscale process')
|
log.error('INTG', 'Could not find Headscale process');
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
context.pid = pids[0]
|
context.pid = pids[0];
|
||||||
log.info('INTG', 'Found Headscale process with PID: %d', context.pid)
|
log.info('INTG', 'Found Headscale process with PID: %d', context.pid);
|
||||||
return true
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
log.error('INTG', 'Failed to read /proc')
|
log.error('INTG', 'Failed to read /proc');
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onConfigChange: (context) => {
|
onConfigChange: (context) => {
|
||||||
if (!context.pid) {
|
if (!context.pid) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('INTG', 'Sending SIGTERM to Headscale')
|
log.info('INTG', 'Sending SIGTERM to Headscale');
|
||||||
kill(context.pid, 'SIGTERM')
|
kill(context.pid, 'SIGTERM');
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
+38
-36
@@ -1,14 +1,14 @@
|
|||||||
import { readdir, readFile } from 'node:fs/promises'
|
import { readdir, readFile } from 'node:fs/promises';
|
||||||
import { platform } from 'node:os'
|
import { platform } from 'node:os';
|
||||||
import { join, resolve } from 'node:path'
|
import { join, resolve } from 'node:path';
|
||||||
import { kill } from 'node:process'
|
import { kill } from 'node:process';
|
||||||
|
|
||||||
import log from '~/utils/log'
|
import log from '~/utils/log';
|
||||||
|
|
||||||
import { createIntegration } from './integration'
|
import { createIntegration } from './integration';
|
||||||
|
|
||||||
interface Context {
|
interface Context {
|
||||||
pid: number | undefined
|
pid: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default createIntegration<Context>({
|
export default createIntegration<Context>({
|
||||||
@@ -18,62 +18,64 @@ export default createIntegration<Context>({
|
|||||||
},
|
},
|
||||||
isAvailable: async (context) => {
|
isAvailable: async (context) => {
|
||||||
if (platform() !== 'linux') {
|
if (platform() !== 'linux') {
|
||||||
log.error('INTG', '/proc is only available on Linux')
|
log.error('INTG', '/proc is only available on Linux');
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug('INTG', 'Checking /proc for Headscale process')
|
log.debug('INTG', 'Checking /proc for Headscale process');
|
||||||
const dir = resolve('/proc')
|
const dir = resolve('/proc');
|
||||||
try {
|
try {
|
||||||
const subdirs = await readdir(dir)
|
const subdirs = await readdir(dir);
|
||||||
const promises = subdirs.map(async (dir) => {
|
const promises = subdirs.map(async (dir) => {
|
||||||
const pid = Number.parseInt(dir, 10)
|
const pid = Number.parseInt(dir, 10);
|
||||||
|
|
||||||
if (Number.isNaN(pid)) {
|
if (Number.isNaN(pid)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = join('/proc', dir, 'cmdline')
|
const path = join('/proc', dir, 'cmdline');
|
||||||
try {
|
try {
|
||||||
log.debug('INTG', 'Reading %s', path)
|
log.debug('INTG', 'Reading %s', path);
|
||||||
const data = await readFile(path, 'utf8')
|
const data = await readFile(path, 'utf8');
|
||||||
if (data.includes('headscale')) {
|
if (data.includes('headscale')) {
|
||||||
return pid
|
return pid;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('INTG', 'Failed to read %s: %s', path, error)
|
log.error('INTG', 'Failed to read %s: %s', path, error);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const results = await Promise.allSettled(promises)
|
const results = await Promise.allSettled(promises);
|
||||||
const pids = []
|
const pids = [];
|
||||||
|
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.status === 'fulfilled' && result.value) {
|
if (result.status === 'fulfilled' && result.value) {
|
||||||
pids.push(result.value)
|
pids.push(result.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug('INTG', 'Found Headscale processes: %o', pids)
|
log.debug('INTG', 'Found Headscale processes: %o', pids);
|
||||||
if (pids.length > 1) {
|
if (pids.length > 1) {
|
||||||
log.error('INTG', 'Found %d Headscale processes: %s',
|
log.error(
|
||||||
|
'INTG',
|
||||||
|
'Found %d Headscale processes: %s',
|
||||||
pids.length,
|
pids.length,
|
||||||
pids.join(', '),
|
pids.join(', '),
|
||||||
)
|
);
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pids.length === 0) {
|
if (pids.length === 0) {
|
||||||
log.error('INTG', 'Could not find Headscale process')
|
log.error('INTG', 'Could not find Headscale process');
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
context.pid = pids[0]
|
context.pid = pids[0];
|
||||||
log.info('INTG', 'Found Headscale process with PID: %d', context.pid)
|
log.info('INTG', 'Found Headscale process with PID: %d', context.pid);
|
||||||
return true
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
log.error('INTG', 'Failed to read /proc')
|
log.error('INTG', 'Failed to read /proc');
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -1,24 +1,25 @@
|
|||||||
import type { LinksFunction, MetaFunction } from '@remix-run/node'
|
import type { LoaderFunctionArgs, LinksFunction, MetaFunction } from 'react-router';
|
||||||
import {
|
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from 'react-router';
|
||||||
Links,
|
import { loadContext } from '~/utils/config/headplane';
|
||||||
Meta,
|
|
||||||
Outlet,
|
|
||||||
Scripts,
|
|
||||||
ScrollRestoration,
|
|
||||||
} from '@remix-run/react'
|
|
||||||
|
|
||||||
import { ErrorPopup } from '~/components/Error'
|
import { ProgressBar } from 'react-aria-components';
|
||||||
import { Toaster } from '~/components/Toaster'
|
import { ErrorPopup } from '~/components/Error';
|
||||||
import stylesheet from '~/tailwind.css?url'
|
// TODO: Make this a default export
|
||||||
|
import { Toaster } from '~/components/Toaster';
|
||||||
|
import stylesheet from '~/tailwind.css?url';
|
||||||
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
export const meta: MetaFunction = () => [
|
export const meta: MetaFunction = () => [
|
||||||
{ title: 'Headplane' },
|
{ title: 'Headplane' },
|
||||||
{ name: 'description', content: 'A frontend for the headscale coordination server' },
|
{
|
||||||
]
|
name: 'description',
|
||||||
|
content: 'A frontend for the headscale coordination server',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const links: LinksFunction = () => [
|
export const links: LinksFunction = () => [
|
||||||
{ rel: 'stylesheet', href: stylesheet },
|
{ rel: 'stylesheet', href: stylesheet },
|
||||||
]
|
];
|
||||||
|
|
||||||
export function Layout({ children }: { readonly children: React.ReactNode }) {
|
export function Layout({ children }: { readonly children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -36,13 +37,28 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
|
|||||||
<Scripts />
|
<Scripts />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary() {
|
export function ErrorBoundary() {
|
||||||
return <ErrorPopup />
|
return <ErrorPopup />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return <Outlet />
|
const nav = useNavigation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProgressBar aria-label="Loading...">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed top-0 left-0 z-50 w-1/2 h-1',
|
||||||
|
'bg-blue-500 dark:bg-blue-400 opacity-0',
|
||||||
|
nav.state === 'loading' && 'animate-loading opacity-100',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</ProgressBar>
|
||||||
|
<Outlet />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
];
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
||||||
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
|
|
||||||
import { 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 { 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 { 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,
|
|
||||||
})
|
|
||||||
|
|
||||||
await setTimeout(250)
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
||||||
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
|
|
||||||
import { Link, useLoaderData } from '@remix-run/react'
|
|
||||||
|
|
||||||
import Attribute from '~/components/Attribute'
|
|
||||||
import Card from '~/components/Card'
|
|
||||||
import StatusCircle from '~/components/StatusCircle'
|
|
||||||
import { Machine, Route, User } from '~/types'
|
|
||||||
import { cn } from '~/utils/cn'
|
|
||||||
import { loadContext } from '~/utils/config/headplane'
|
|
||||||
import { loadConfig } from '~/utils/config/headscale'
|
|
||||||
import { pull } from '~/utils/headscale'
|
|
||||||
import { getSession } from '~/utils/sessions'
|
|
||||||
import { useLiveData } from '~/utils/useLiveData'
|
|
||||||
|
|
||||||
import { menuAction } from './_data.machines._index/action'
|
|
||||||
import MenuOptions from './_data.machines._index/menu'
|
|
||||||
|
|
||||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
|
||||||
if (!params.id) {
|
|
||||||
throw new Error('No machine ID provided')
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = await loadContext()
|
|
||||||
let magic: string | undefined
|
|
||||||
|
|
||||||
if (context.config.read) {
|
|
||||||
const config = await loadConfig()
|
|
||||||
if (config.dns.magic_dns) {
|
|
||||||
magic = config.dns.base_domain
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [machine, routes, users] = await Promise.all([
|
|
||||||
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
|
|
||||||
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
|
||||||
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
|
||||||
])
|
|
||||||
|
|
||||||
return {
|
|
||||||
machine: machine.node,
|
|
||||||
routes: routes.routes.filter(route => route.node.id === params.id),
|
|
||||||
users: users.users,
|
|
||||||
magic,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function action({ request }: ActionFunctionArgs) {
|
|
||||||
return menuAction(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const { machine, magic, routes, users } = useLoaderData<typeof loader>()
|
|
||||||
useLiveData({ interval: 1000 })
|
|
||||||
|
|
||||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
|
||||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
|
||||||
|| machine.expiry === null
|
|
||||||
? false
|
|
||||||
: new Date(machine.expiry).getTime() < Date.now()
|
|
||||||
|
|
||||||
const tags = [
|
|
||||||
...machine.forcedTags,
|
|
||||||
...machine.validTags,
|
|
||||||
]
|
|
||||||
|
|
||||||
if (expired) {
|
|
||||||
tags.unshift('Expired')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className="mb-8 text-md">
|
|
||||||
<Link
|
|
||||||
to="/machines"
|
|
||||||
className="font-medium"
|
|
||||||
>
|
|
||||||
All Machines
|
|
||||||
</Link>
|
|
||||||
<span className="mx-2">
|
|
||||||
/
|
|
||||||
</span>
|
|
||||||
{machine.givenName}
|
|
||||||
</p>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="flex items-baseline gap-x-4 text-sm mb-4">
|
|
||||||
<h1 className="text-2xl font-medium">
|
|
||||||
{machine.givenName}
|
|
||||||
</h1>
|
|
||||||
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<MenuOptions
|
|
||||||
machine={machine}
|
|
||||||
routes={routes}
|
|
||||||
users={users}
|
|
||||||
magic={magic}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1 mt-1 mb-8">
|
|
||||||
{tags.map(tag => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className={cn(
|
|
||||||
'text-xs rounded-md px-1.5 py-0.5',
|
|
||||||
'bg-ui-200 dark:bg-ui-800',
|
|
||||||
'text-ui-600 dark:text-ui-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-medium mb-4">
|
|
||||||
Machine Details
|
|
||||||
</h2>
|
|
||||||
<Card variant="flat" className="w-full max-w-full">
|
|
||||||
<Attribute name="Creator" value={machine.user.name} />
|
|
||||||
<Attribute name="Node ID" value={machine.id} />
|
|
||||||
<Attribute name="Node Name" value={machine.givenName} />
|
|
||||||
<Attribute name="Hostname" value={machine.name} />
|
|
||||||
<Attribute
|
|
||||||
isCopyable
|
|
||||||
name="Node Key"
|
|
||||||
value={machine.nodeKey}
|
|
||||||
/>
|
|
||||||
<Attribute
|
|
||||||
name="Created"
|
|
||||||
value={new Date(machine.createdAt).toLocaleString()}
|
|
||||||
/>
|
|
||||||
<Attribute
|
|
||||||
name="Last Seen"
|
|
||||||
value={new Date(machine.lastSeen).toLocaleString()}
|
|
||||||
/>
|
|
||||||
<Attribute
|
|
||||||
name="Expiry"
|
|
||||||
value={expired
|
|
||||||
? new Date(machine.expiry).toLocaleString()
|
|
||||||
: 'Never'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{magic
|
|
||||||
? (
|
|
||||||
<Attribute
|
|
||||||
isCopyable
|
|
||||||
name="Domain"
|
|
||||||
value={`${machine.givenName}.${magic}`}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
: undefined}
|
|
||||||
</Card>
|
|
||||||
<h2 className="text-xl font-medium mb-4 mt-8">
|
|
||||||
Machine Routes
|
|
||||||
</h2>
|
|
||||||
<Card variant="flat" className="w-full max-w-full">
|
|
||||||
{routes.length === 0
|
|
||||||
? (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex py-4 px-4',
|
|
||||||
'items-center justify-center',
|
|
||||||
'text-ui-600 dark:text-ui-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
No routes are advertised on this machine.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: routes.map((route, i) => (
|
|
||||||
<div
|
|
||||||
key={route.id}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center justify-between',
|
|
||||||
routes.length - 1 === i ? 'border-b pb-3 mb-2' : '',
|
|
||||||
'border-ui-100 dark:border-ui-800',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="font-mono mb-1">
|
|
||||||
{route.prefix}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
|
||||||
{' '}
|
|
||||||
(Created:
|
|
||||||
{' '}
|
|
||||||
{new Date(route.createdAt).toLocaleString()}
|
|
||||||
)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="mb-1">
|
|
||||||
{route.enabled ? 'Enabled' : 'Disabled'}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
|
||||||
{route.isPrimary ? 'Primary' : 'Secondary'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,146 +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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'register': {
|
|
||||||
const key = data.get('mkey')?.toString()
|
|
||||||
const user = data.get('user')?.toString()
|
|
||||||
|
|
||||||
if (!key) {
|
|
||||||
return json({ message: 'No machine key provided' }, {
|
|
||||||
status: 400,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return json({ message: 'No user provided' }, {
|
|
||||||
status: 400,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const qp = new URLSearchParams()
|
|
||||||
qp.append('user', user)
|
|
||||||
qp.append('key', key)
|
|
||||||
|
|
||||||
const url = `v1/node/register?${qp.toString()}`
|
|
||||||
await post(url, session.get('hsApiKey')!, {
|
|
||||||
user, key,
|
|
||||||
})
|
|
||||||
|
|
||||||
return json({
|
|
||||||
success: true,
|
|
||||||
message: 'Machine registered'
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return json({
|
|
||||||
success: false,
|
|
||||||
message: 'Failed to register machine'
|
|
||||||
}, {
|
|
||||||
status: 500,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
|
||||||
return json({ message: 'Invalid method' }, {
|
|
||||||
status: 400,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { Form, useSubmit } from '@remix-run/react'
|
|
||||||
import { type Dispatch, type SetStateAction, useState } from 'react'
|
|
||||||
|
|
||||||
import Code from '~/components/Code'
|
|
||||||
import Dialog from '~/components/Dialog'
|
|
||||||
import Select from '~/components/Select'
|
|
||||||
import { type Machine, User } from '~/types'
|
|
||||||
|
|
||||||
interface MoveProps {
|
|
||||||
readonly machine: Machine
|
|
||||||
readonly users: User[]
|
|
||||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
|
||||||
readonly magic?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Move({ machine, state, magic, users }: MoveProps) {
|
|
||||||
const [owner, setOwner] = useState(machine.user.name)
|
|
||||||
const submit = useSubmit()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<Dialog.Panel control={state}>
|
|
||||||
{close => (
|
|
||||||
<>
|
|
||||||
<Dialog.Title>
|
|
||||||
Change the owner of
|
|
||||||
{' '}
|
|
||||||
{machine.givenName}
|
|
||||||
</Dialog.Title>
|
|
||||||
<Dialog.Text>
|
|
||||||
The owner of the machine is the user associated with it.
|
|
||||||
</Dialog.Text>
|
|
||||||
<Form
|
|
||||||
method="POST"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
submit(e.currentTarget)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="_method" value="move" />
|
|
||||||
<input type="hidden" name="id" value={machine.id} />
|
|
||||||
<Select
|
|
||||||
label="Owner"
|
|
||||||
name="to"
|
|
||||||
placeholder="Select a user"
|
|
||||||
state={[owner, setOwner]}
|
|
||||||
>
|
|
||||||
{users.map(user => (
|
|
||||||
<Select.Item key={user.id} id={user.name}>
|
|
||||||
{user.name}
|
|
||||||
</Select.Item>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{magic
|
|
||||||
? (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
|
||||||
This machine is accessible by the hostname
|
|
||||||
{' '}
|
|
||||||
<Code className="text-sm">
|
|
||||||
{machine.givenName}
|
|
||||||
.
|
|
||||||
{magic}
|
|
||||||
</Code>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
: undefined}
|
|
||||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
|
||||||
<Dialog.Action
|
|
||||||
variant="cancel"
|
|
||||||
onPress={close}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Dialog.Action>
|
|
||||||
<Dialog.Action
|
|
||||||
variant="confirm"
|
|
||||||
onPress={close}
|
|
||||||
>
|
|
||||||
Change owner
|
|
||||||
</Dialog.Action>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { Form, useFetcher, Link } from '@remix-run/react'
|
|
||||||
import { Dispatch, SetStateAction, useState, useEffect } from 'react'
|
|
||||||
import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react'
|
|
||||||
import { cn } from '~/utils/cn'
|
|
||||||
|
|
||||||
import Code from '~/components/Code'
|
|
||||||
import Dialog from '~/components/Dialog'
|
|
||||||
import TextField from '~/components/TextField'
|
|
||||||
import Select from '~/components/Select'
|
|
||||||
import Menu from '~/components/Menu'
|
|
||||||
import Spinner from '~/components/Spinner'
|
|
||||||
import { toast } from '~/components/Toaster'
|
|
||||||
import { Machine, User } from '~/types'
|
|
||||||
|
|
||||||
export interface NewProps {
|
|
||||||
server: string
|
|
||||||
users: User[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function New(data: NewProps) {
|
|
||||||
const fetcher = useFetcher()
|
|
||||||
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>
|
|
||||||
tailscale up --login-server=
|
|
||||||
</Code>
|
|
||||||
<Code>
|
|
||||||
{data.server}
|
|
||||||
</Code>
|
|
||||||
{' '}
|
|
||||||
on your device.
|
|
||||||
</Dialog.Text>
|
|
||||||
<fetcher.Form method="POST" onSubmit={e => {
|
|
||||||
fetcher.submit(e.currentTarget)
|
|
||||||
close()
|
|
||||||
}}>
|
|
||||||
<input type="hidden" name="_method" value="register" />
|
|
||||||
<input type="hidden" name="id" value="_" />
|
|
||||||
<TextField
|
|
||||||
label='Machine Key'
|
|
||||||
placeholder='mkey:ff.....'
|
|
||||||
name="mkey"
|
|
||||||
state={[mkey, setMkey]}
|
|
||||||
className='my-2 font-mono'
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="Owner"
|
|
||||||
name="user"
|
|
||||||
placeholder="Select a user"
|
|
||||||
state={[user, setUser]}
|
|
||||||
>
|
|
||||||
{data.users.map(user => (
|
|
||||||
<Select.Item key={user.id} id={user.name}>
|
|
||||||
{user.name}
|
|
||||||
</Select.Item>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
|
||||||
<Dialog.Action
|
|
||||||
variant="cancel"
|
|
||||||
onPress={close}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Dialog.Action>
|
|
||||||
<Dialog.Action
|
|
||||||
variant="confirm"
|
|
||||||
isDisabled={!mkey || !mkey.trim().startsWith('mkey:') || !user}
|
|
||||||
>
|
|
||||||
{fetcher.state === 'idle'
|
|
||||||
? undefined
|
|
||||||
: (
|
|
||||||
<Spinner className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
Register
|
|
||||||
</Dialog.Action>
|
|
||||||
</div>
|
|
||||||
</fetcher.Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Dialog>
|
|
||||||
<Menu>
|
|
||||||
<Menu.Button
|
|
||||||
className={cn(
|
|
||||||
'w-fit text-sm rounded-lg px-4 py-2',
|
|
||||||
'bg-main-700 dark:bg-main-800 text-white',
|
|
||||||
'hover:bg-main-800 dark:hover:bg-main-700',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Add Device
|
|
||||||
</Menu.Button>
|
|
||||||
<Menu.Items>
|
|
||||||
<Menu.ItemButton control={mkeyState}>
|
|
||||||
<ServerIcon className='w-4 h-4 mr-2'/>
|
|
||||||
Register Machine Key
|
|
||||||
</Menu.ItemButton>
|
|
||||||
<Menu.ItemButton>
|
|
||||||
<Link to="/settings/auth-keys">
|
|
||||||
<KeyIcon className='w-4 h-4 mr-2'/>
|
|
||||||
Generate Pre-auth Key
|
|
||||||
</Link>
|
|
||||||
</Menu.ItemButton>
|
|
||||||
</Menu.Items>
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import { Form, useSubmit } from '@remix-run/react'
|
|
||||||
import { type Dispatch, type SetStateAction, useState } from 'react'
|
|
||||||
|
|
||||||
import Code from '~/components/Code'
|
|
||||||
import Dialog from '~/components/Dialog'
|
|
||||||
import TextField from '~/components/TextField'
|
|
||||||
import { type Machine } from '~/types'
|
|
||||||
|
|
||||||
interface RenameProps {
|
|
||||||
readonly machine: Machine
|
|
||||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
|
||||||
readonly magic?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Rename({ machine, state, magic }: RenameProps) {
|
|
||||||
const [name, setName] = useState(machine.givenName)
|
|
||||||
const submit = useSubmit()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<Dialog.Panel control={state}>
|
|
||||||
{close => (
|
|
||||||
<>
|
|
||||||
<Dialog.Title>
|
|
||||||
Edit machine name for
|
|
||||||
{' '}
|
|
||||||
{machine.givenName}
|
|
||||||
</Dialog.Title>
|
|
||||||
<Dialog.Text>
|
|
||||||
This name is shown in the admin panel, in Tailscale clients,
|
|
||||||
and used when generating MagicDNS names.
|
|
||||||
</Dialog.Text>
|
|
||||||
<Form
|
|
||||||
method="POST"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
submit(e.currentTarget)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="_method" value="rename" />
|
|
||||||
<input type="hidden" name="id" value={machine.id} />
|
|
||||||
<TextField
|
|
||||||
label="Machine name"
|
|
||||||
placeholder="Machine name"
|
|
||||||
name="name"
|
|
||||||
state={[name, setName]}
|
|
||||||
className="my-2"
|
|
||||||
/>
|
|
||||||
{magic
|
|
||||||
? (
|
|
||||||
name.length > 0 && name !== machine.givenName
|
|
||||||
? (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
|
||||||
This machine will be accessible by the hostname
|
|
||||||
{' '}
|
|
||||||
<Code className="text-sm">
|
|
||||||
{name.toLowerCase().replaceAll(/\s+/g, '-')}
|
|
||||||
</Code>
|
|
||||||
{'. '}
|
|
||||||
The hostname
|
|
||||||
{' '}
|
|
||||||
<Code className="text-sm">
|
|
||||||
{machine.givenName}
|
|
||||||
</Code>
|
|
||||||
{' '}
|
|
||||||
will no longer point to this machine.
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
|
||||||
This machine is accessible by the hostname
|
|
||||||
{' '}
|
|
||||||
<Code className="text-sm">
|
|
||||||
{machine.givenName}
|
|
||||||
</Code>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: undefined}
|
|
||||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
|
||||||
<Dialog.Action
|
|
||||||
variant="cancel"
|
|
||||||
onPress={close}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Dialog.Action>
|
|
||||||
<Dialog.Action
|
|
||||||
variant="confirm"
|
|
||||||
onPress={close}
|
|
||||||
>
|
|
||||||
Rename
|
|
||||||
</Dialog.Action>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,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'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,155 +0,0 @@
|
|||||||
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react'
|
|
||||||
import { Link } from '@remix-run/react'
|
|
||||||
|
|
||||||
import Menu from '~/components/Menu'
|
|
||||||
import StatusCircle from '~/components/StatusCircle'
|
|
||||||
import { toast } from '~/components/Toaster'
|
|
||||||
import { Machine, Route, User } from '~/types'
|
|
||||||
import { cn } from '~/utils/cn'
|
|
||||||
|
|
||||||
import MenuOptions from './menu'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
readonly machine: Machine
|
|
||||||
readonly routes: Route[]
|
|
||||||
readonly users: User[]
|
|
||||||
readonly magic?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MachineRow({ machine, routes, magic, users }: Props) {
|
|
||||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
|
||||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
|
||||||
|| machine.expiry === null
|
|
||||||
? false
|
|
||||||
: new Date(machine.expiry).getTime() < Date.now()
|
|
||||||
|
|
||||||
const tags = [
|
|
||||||
...machine.forcedTags,
|
|
||||||
...machine.validTags,
|
|
||||||
]
|
|
||||||
|
|
||||||
if (expired) {
|
|
||||||
tags.unshift('Expired')
|
|
||||||
}
|
|
||||||
|
|
||||||
let prefix = magic?.startsWith('[user]')
|
|
||||||
? magic.replace('[user]', machine.user.name)
|
|
||||||
: magic
|
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,111 +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'
|
|
||||||
|| 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>
|
|
||||||
<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,118 +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'
|
|
||||||
import NewMachine from './dialogs/new'
|
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
|
||||||
const [machines, routes, users] = await Promise.all([
|
|
||||||
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
|
|
||||||
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
|
||||||
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
|
||||||
])
|
|
||||||
|
|
||||||
const context = await loadContext()
|
|
||||||
let magic: string | undefined
|
|
||||||
|
|
||||||
if (context.config.read) {
|
|
||||||
const config = await loadConfig()
|
|
||||||
if (config.dns.magic_dns) {
|
|
||||||
magic = config.dns.base_domain
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.dns.use_username_in_magic_dns) {
|
|
||||||
magic = `[user].${magic}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
nodes: machines.nodes,
|
|
||||||
routes: routes.routes,
|
|
||||||
users: users.users,
|
|
||||||
magic,
|
|
||||||
server: context.headscaleUrl,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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">
|
|
||||||
<h1 className="text-2xl font-medium mb-4">Machines</h1>
|
|
||||||
<NewMachine server={data.server} users={data.users} />
|
|
||||||
</div>
|
|
||||||
<table className="table-auto w-full rounded-lg">
|
|
||||||
<thead className="text-gray-500 dark:text-gray-400">
|
|
||||||
<tr className="text-left uppercase text-xs font-bold px-0.5">
|
|
||||||
<th className="pb-2">Name</th>
|
|
||||||
<th className="pb-2">
|
|
||||||
<div className="flex items-center gap-x-1">
|
|
||||||
Addresses
|
|
||||||
{data.magic
|
|
||||||
? (
|
|
||||||
<TooltipTrigger delay={0}>
|
|
||||||
<Button>
|
|
||||||
<InfoIcon className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Tooltip className={cn(
|
|
||||||
'text-sm max-w-xs p-2 rounded-lg mb-2',
|
|
||||||
'bg-white dark:bg-zinc-800',
|
|
||||||
'border border-gray-200 dark:border-zinc-700',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Since MagicDNS is enabled, you can access devices
|
|
||||||
based on their name and also at
|
|
||||||
{' '}
|
|
||||||
<Code>
|
|
||||||
[name].
|
|
||||||
{data.magic}
|
|
||||||
</Code>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipTrigger>
|
|
||||||
)
|
|
||||||
: undefined}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th className="pb-2">Last Seen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className={cn(
|
|
||||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
|
||||||
'border-t border-zinc-200 dark:border-zinc-700',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{data.nodes.map(machine => (
|
|
||||||
<MachineRow
|
|
||||||
key={machine.id}
|
|
||||||
machine={machine}
|
|
||||||
routes={data.routes.filter(route => route.node.id === machine.id)}
|
|
||||||
users={data.users}
|
|
||||||
magic={data.magic}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import Link from '~/components/Link'
|
|
||||||
import Button from '~/components/Button'
|
|
||||||
import { Link as RemixLink } from '@remix-run/react'
|
|
||||||
import { ArrowRightIcon } from '@primer/octicons-react'
|
|
||||||
import { cn } from '~/utils/cn'
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-8 max-w-screen-lg">
|
|
||||||
<div className='flex flex-col w-2/3'>
|
|
||||||
<h1 className='text-2xl font-medium mb-4'>Settings</h1>
|
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
|
||||||
The settings page is still under construction.
|
|
||||||
As I'm able to add more features, I'll be adding them here.
|
|
||||||
If you require any features, feel free to open an issue on
|
|
||||||
the GitHub repository.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-col w-2/3'>
|
|
||||||
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
|
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
|
||||||
Headscale fully supports pre-authentication keys in order to
|
|
||||||
easily add devices to your Tailnet.
|
|
||||||
To learn more about using pre-authentication keys, visit the
|
|
||||||
{' '}
|
|
||||||
<Link
|
|
||||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
|
||||||
name="Tailscale Auth Keys documentation"
|
|
||||||
>
|
|
||||||
Tailscale documentation
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<RemixLink
|
|
||||||
to="/settings/auth-keys"
|
|
||||||
name="Auth Keys"
|
|
||||||
>
|
|
||||||
<span className={cn(
|
|
||||||
'text-lg font-medium',
|
|
||||||
'text-gray-700 dark:text-gray-300',
|
|
||||||
)}>
|
|
||||||
Manage Auth Keys
|
|
||||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
|
||||||
</span>
|
|
||||||
</RemixLink>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { useFetcher } from '@remix-run/react'
|
|
||||||
import type { PreAuthKey } from '~/types'
|
|
||||||
import { cn } from '~/utils/cn'
|
|
||||||
|
|
||||||
import Dialog from '~/components/Dialog'
|
|
||||||
import Spinner from '~/components/Spinner'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
authKey: PreAuthKey
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ExpireKey({ authKey }: Props) {
|
|
||||||
const fetcher = useFetcher()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<Dialog.Button className="my-4">
|
|
||||||
Expire Key
|
|
||||||
</Dialog.Button>
|
|
||||||
<Dialog.Panel>
|
|
||||||
{close => (
|
|
||||||
<>
|
|
||||||
<Dialog.Title>
|
|
||||||
Expire auth key?
|
|
||||||
</Dialog.Title>
|
|
||||||
<fetcher.Form method="DELETE" onSubmit={e => {
|
|
||||||
fetcher.submit(e.currentTarget)
|
|
||||||
close()
|
|
||||||
}}>
|
|
||||||
<input type="hidden" name="user" value={authKey.user} />
|
|
||||||
<input type="hidden" name="key" value={authKey.key} />
|
|
||||||
<Dialog.Text>
|
|
||||||
Expiring this authentication key will immediately
|
|
||||||
prevent it from being used to authenticate new devices.
|
|
||||||
{' '}
|
|
||||||
This action cannot be undone.
|
|
||||||
</Dialog.Text>
|
|
||||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
|
||||||
<Dialog.Action
|
|
||||||
variant="cancel"
|
|
||||||
onPress={close}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Dialog.Action>
|
|
||||||
<Dialog.Action
|
|
||||||
variant="confirm"
|
|
||||||
className={cn(
|
|
||||||
'bg-red-500 hover:border-red-700',
|
|
||||||
'dark:bg-red-600 dark:hover:border-red-700',
|
|
||||||
'pressed:bg-red-600 hover:bg-red-600',
|
|
||||||
'text-white dark:text-white',
|
|
||||||
)}
|
|
||||||
onPress={close}
|
|
||||||
>
|
|
||||||
{fetcher.state === 'idle'
|
|
||||||
? undefined
|
|
||||||
: (
|
|
||||||
<Spinner className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
Expire
|
|
||||||
</Dialog.Action>
|
|
||||||
</div>
|
|
||||||
</fetcher.Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
import { LoaderFunctionArgs, ActionFunctionArgs, json } from '@remix-run/node'
|
|
||||||
import { useLoaderData } from '@remix-run/react'
|
|
||||||
import { useLiveData } from '~/utils/useLiveData'
|
|
||||||
import { getSession } from '~/utils/sessions'
|
|
||||||
import { Link as RemixLink } from '@remix-run/react'
|
|
||||||
import { PreAuthKey, User } from '~/types'
|
|
||||||
import { pull, post } from '~/utils/headscale'
|
|
||||||
import { loadContext } from '~/utils/config/headplane'
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
import Link from '~/components/Link'
|
|
||||||
import TableList from '~/components/TableList'
|
|
||||||
import Select from '~/components/Select'
|
|
||||||
import Switch from '~/components/Switch'
|
|
||||||
|
|
||||||
import AddPreAuthKey from './dialogs/new'
|
|
||||||
import AuthKeyRow from './key'
|
|
||||||
|
|
||||||
export async function action({ request }: ActionFunctionArgs) {
|
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
|
||||||
if (!session.has('hsApiKey')) {
|
|
||||||
return json({ message: 'Unauthorized' }, {
|
|
||||||
status: 401,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await request.formData()
|
|
||||||
|
|
||||||
// Expiring a pre-auth key
|
|
||||||
if (request.method === 'DELETE') {
|
|
||||||
const key = data.get('key')
|
|
||||||
const user = data.get('user')
|
|
||||||
|
|
||||||
if (!key || !user) {
|
|
||||||
return json({ message: 'Missing parameters' }, {
|
|
||||||
status: 400,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await post<{ preAuthKey: PreAuthKey }>(
|
|
||||||
'v1/preauthkey/expire',
|
|
||||||
session.get('hsApiKey')!,
|
|
||||||
{
|
|
||||||
user: user,
|
|
||||||
key: key,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return json({ message: 'Pre-auth key expired' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Creating a new pre-auth key
|
|
||||||
if (request.method === 'POST') {
|
|
||||||
const user = data.get('user')
|
|
||||||
const expiry = data.get('expiry')
|
|
||||||
const reusable = data.get('reusable')
|
|
||||||
const ephemeral = data.get('ephemeral')
|
|
||||||
|
|
||||||
if (!user || !expiry || !reusable || !ephemeral) {
|
|
||||||
return json({ message: 'Missing parameters' }, {
|
|
||||||
status: 400,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the first "word" from expiry which is the day number
|
|
||||||
// Calculate the date X days from now using the day number
|
|
||||||
const day = Number(expiry.toString().split(' ')[0])
|
|
||||||
const date = new Date()
|
|
||||||
date.setDate(date.getDate() + day)
|
|
||||||
|
|
||||||
const key = await post<{ preAuthKey: PreAuthKey }>(
|
|
||||||
'v1/preauthkey',
|
|
||||||
session.get('hsApiKey')!,
|
|
||||||
{
|
|
||||||
user: user,
|
|
||||||
ephemeral: ephemeral === 'on',
|
|
||||||
reusable: reusable === 'on',
|
|
||||||
expiration: date.toISOString(),
|
|
||||||
aclTags: [], // TODO
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return json({ message: 'Pre-auth key created', key })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
|
||||||
const context = await loadContext()
|
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
|
||||||
const users = await pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!)
|
|
||||||
|
|
||||||
const preAuthKeys = await Promise.all(users.users.map(user => {
|
|
||||||
const qp = new URLSearchParams()
|
|
||||||
qp.set('user', user.name)
|
|
||||||
|
|
||||||
return pull<{ preAuthKeys: PreAuthKey[] }>(
|
|
||||||
`v1/preauthkey?${qp.toString()}`,
|
|
||||||
session.get('hsApiKey')!
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
|
|
||||||
return {
|
|
||||||
keys: preAuthKeys.flatMap(keys => keys.preAuthKeys),
|
|
||||||
users: users.users,
|
|
||||||
server: context.headscaleUrl,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const { keys, users, server } = useLoaderData<typeof loader>()
|
|
||||||
const [user, setUser] = useState('All')
|
|
||||||
const [status, setStatus] = useState('Active')
|
|
||||||
|
|
||||||
const filteredKeys = keys.filter(key => {
|
|
||||||
if (user !== 'All' && key.user !== user) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status !== 'All') {
|
|
||||||
const now = new Date()
|
|
||||||
const expiry = new Date(key.expiration)
|
|
||||||
|
|
||||||
if (status === 'Active') {
|
|
||||||
return !(expiry < now) && !key.used
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'Used/Expired') {
|
|
||||||
return key.used || expiry < now
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'Reusable') {
|
|
||||||
return key.reusable
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'Ephemeral') {
|
|
||||||
return key.ephemeral
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col w-2/3'>
|
|
||||||
<p className="mb-8 text-md">
|
|
||||||
<RemixLink
|
|
||||||
to="/settings"
|
|
||||||
className="font-medium"
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</RemixLink>
|
|
||||||
<span className="mx-2">
|
|
||||||
/
|
|
||||||
</span>
|
|
||||||
{' '}
|
|
||||||
Pre-Auth Keys
|
|
||||||
</p>
|
|
||||||
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
|
|
||||||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
|
||||||
Headscale fully supports pre-authentication keys in order to
|
|
||||||
easily add devices to your Tailnet.
|
|
||||||
To learn more about using pre-authentication keys, visit the
|
|
||||||
{' '}
|
|
||||||
<Link
|
|
||||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
|
||||||
name="Tailscale Auth Keys documentation"
|
|
||||||
>
|
|
||||||
Tailscale documentation
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
<AddPreAuthKey users={users} />
|
|
||||||
<div className="flex justify-between gap-4 mt-4">
|
|
||||||
<div className="w-full">
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
|
||||||
Filter by user
|
|
||||||
</p>
|
|
||||||
<Select
|
|
||||||
label="Filter by User"
|
|
||||||
placeholder="Select a user"
|
|
||||||
state={[user, setUser]}
|
|
||||||
>
|
|
||||||
<Select.Item id="All">All</Select.Item>
|
|
||||||
{users.map(user => (
|
|
||||||
<Select.Item key={user.id} id={user.name}>
|
|
||||||
{user.name}
|
|
||||||
</Select.Item>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="w-full">
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
|
||||||
Filter by status
|
|
||||||
</p>
|
|
||||||
<Select
|
|
||||||
label="Filter by status"
|
|
||||||
placeholder="Select a status"
|
|
||||||
state={[status, setStatus]}
|
|
||||||
>
|
|
||||||
<Select.Item id="All">All</Select.Item>
|
|
||||||
<Select.Item id="Active">Active</Select.Item>
|
|
||||||
<Select.Item id="Used/Expired">Used/Expired</Select.Item>
|
|
||||||
<Select.Item id="Reusable">Reusable</Select.Item>
|
|
||||||
<Select.Item id="Ephemeral">Ephemeral</Select.Item>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<TableList className="mt-4">
|
|
||||||
{filteredKeys.length === 0 ? (
|
|
||||||
<TableList.Item>
|
|
||||||
<p className="opacity-50 text-sm mx-auto">
|
|
||||||
No pre-auth keys
|
|
||||||
</p>
|
|
||||||
</TableList.Item>
|
|
||||||
) : filteredKeys.map(key => (
|
|
||||||
<TableList.Item key={key.id}>
|
|
||||||
<AuthKeyRow authKey={key} server={server} />
|
|
||||||
</TableList.Item>
|
|
||||||
))}
|
|
||||||
</TableList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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" />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { XIcon } from '@primer/octicons-react'
|
|
||||||
import { Form, useSubmit } from '@remix-run/react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
import Button from '~/components/Button'
|
|
||||||
import Code from '~/components/Code'
|
|
||||||
import Dialog from '~/components/Dialog'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
username: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Remove({ username }: Props) {
|
|
||||||
const submit = useSubmit()
|
|
||||||
const dialogState = useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
control={dialogState}
|
|
||||||
className="rounded-full p-0 w-8 h-8"
|
|
||||||
>
|
|
||||||
<XIcon className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Dialog control={dialogState}>
|
|
||||||
<Dialog.Panel control={dialogState}>
|
|
||||||
{close => (
|
|
||||||
<>
|
|
||||||
<Dialog.Title>
|
|
||||||
Delete
|
|
||||||
{' '}
|
|
||||||
{username}
|
|
||||||
?
|
|
||||||
</Dialog.Title>
|
|
||||||
<Dialog.Text className="mb-8">
|
|
||||||
Are you sure you want to delete
|
|
||||||
{' '}
|
|
||||||
{username}
|
|
||||||
?
|
|
||||||
{' '}
|
|
||||||
A deleted user cannot be recovered.
|
|
||||||
</Dialog.Text>
|
|
||||||
<Form
|
|
||||||
method="POST"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
submit(event.currentTarget)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="_method" value="delete" />
|
|
||||||
<input type="hidden" name="username" value={username} />
|
|
||||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
|
||||||
<Dialog.Action
|
|
||||||
variant="cancel"
|
|
||||||
onPress={close}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Dialog.Action>
|
|
||||||
<Dialog.Action
|
|
||||||
variant="confirm"
|
|
||||||
onPress={close}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Dialog.Action>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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} />
|
|
||||||
)
|
|
||||||
: undefined}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
{user.machines.map(machine => (
|
|
||||||
<MachineChip key={machine.id} machine={machine} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { redirect } from '@remix-run/node'
|
|
||||||
|
|
||||||
export function loader() {
|
|
||||||
return redirect('/machines')
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 Spinner from '~/components/Spinner';
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
interface FallbackProps {
|
interface Props {
|
||||||
readonly acl: string
|
readonly acl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Fallback({ acl }: FallbackProps) {
|
export default function Fallback({ acl }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="inline-block relative w-full h-editor">
|
<div className="inline-block relative w-full h-editor">
|
||||||
<Spinner className="w-4 h-4 absolute p-2" />
|
<Spinner className="w-4 h-4 absolute p-2" />
|
||||||
@@ -15,10 +15,10 @@ export default function Fallback({ acl }: FallbackProps) {
|
|||||||
'w-full h-editor font-mono resize-none',
|
'w-full h-editor font-mono resize-none',
|
||||||
'text-sm text-gray-600 dark:text-gray-300',
|
'text-sm text-gray-600 dark:text-gray-300',
|
||||||
'bg-ui-100 dark:bg-ui-800',
|
'bg-ui-100 dark:bg-ui-800',
|
||||||
'pl-16 pr-8 pt-0.5 leading-snug',
|
'pl-10 pt-1 leading-snug',
|
||||||
)}
|
)}
|
||||||
value={acl}
|
value={acl}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { cn } from '~/utils/cn';
|
||||||
|
import { AlertIcon } from '@primer/octicons-react';
|
||||||
|
|
||||||
|
import Code from '~/components/Code';
|
||||||
|
import Card from '~/components/Card';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: 'file' | 'database';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Unavailable({ mode }: Props) {
|
||||||
|
return (
|
||||||
|
<Card variant="flat" className="max-w-prose mt-12">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Card.Title className="text-xl mb-0">ACL Policy Unavailable</Card.Title>
|
||||||
|
<AlertIcon className="w-8 h-8 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<Card.Text className="mt-4">
|
||||||
|
Unable to load a valid ACL policy configuration. This is most likely due
|
||||||
|
to a misconfiguration in your Headscale configuration file.
|
||||||
|
</Card.Text>
|
||||||
|
|
||||||
|
{mode !== 'file' ? (
|
||||||
|
<p className="mt-4 text-sm">
|
||||||
|
According to your configuration, the ACL policy mode is set to{' '}
|
||||||
|
<Code>file</Code> but the ACL file is not available. Ensure that the{' '}
|
||||||
|
<Code>policy.path</Code> is set to a valid path in your Headscale
|
||||||
|
configuration.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-4 text-sm">
|
||||||
|
In order to fully utilize the ACL management features of Headplane,
|
||||||
|
please set <Code>policy.mode</Code> to either <Code>file</Code> or{' '}
|
||||||
|
<Code>database</Code> in your Headscale configuration.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { type LoaderFunctionArgs, redirect } from 'react-router';
|
||||||
|
import { loadContext } from '~/utils/config/headplane';
|
||||||
|
import { getSession, commitSession } from '~/utils/sessions.server';
|
||||||
|
import { finishAuthFlow, getRedirectUri, formatError } from '~/utils/oidc';
|
||||||
|
import { send } from '~/utils/res';
|
||||||
|
|
||||||
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
|
// Check if we have 0 query parameters
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.searchParams.toString().length === 0) {
|
||||||
|
return redirect('/machines');
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getSession(request.headers.get('Cookie'));
|
||||||
|
if (session.has('hsApiKey')) {
|
||||||
|
return redirect('/machines')
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a hold-over from the old code
|
||||||
|
// TODO: Rewrite checkOIDC in the context loader
|
||||||
|
const { oidc } = await loadContext();
|
||||||
|
if (!oidc) {
|
||||||
|
throw new Error('An invalid OIDC configuration was provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const oidcConfig = {
|
||||||
|
issuer: oidc.issuer,
|
||||||
|
clientId: oidc.client,
|
||||||
|
clientSecret: oidc.secret,
|
||||||
|
redirectUri: oidc.redirectUri,
|
||||||
|
tokenEndpointAuthMethod: oidc.method,
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeVerifier = session.get('oidc_code_verif');
|
||||||
|
const state = session.get('oidc_state');
|
||||||
|
const nonce = session.get('oidc_nonce');
|
||||||
|
const redirectUri = session.get('oidc_redirect_uri');
|
||||||
|
|
||||||
|
if (!codeVerifier || !state || !nonce) {
|
||||||
|
return send({ error: 'Missing OIDC state' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct the redirect URI using the query parameters
|
||||||
|
// and the one we saved in the session
|
||||||
|
const flowRedirectUri = new URL(redirectUri);
|
||||||
|
flowRedirectUri.search = url.search;
|
||||||
|
|
||||||
|
const flowOptions = {
|
||||||
|
redirect_uri: flowRedirectUri.toString(),
|
||||||
|
codeVerifier,
|
||||||
|
state,
|
||||||
|
nonce: nonce === '<none>' ? undefined : nonce,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await finishAuthFlow(oidcConfig, flowOptions);
|
||||||
|
session.set('user', user);
|
||||||
|
session.unset('oidc_code_verif');
|
||||||
|
session.unset('oidc_state');
|
||||||
|
session.unset('oidc_nonce');
|
||||||
|
|
||||||
|
// TODO: This is breaking, to stop the "over-generation" of API
|
||||||
|
// keys because they are currently non-deletable in the headscale
|
||||||
|
// database. Look at this in the future once we have a solution
|
||||||
|
// or we have permissioned API keys.
|
||||||
|
session.set('hsApiKey', oidc.rootKey);
|
||||||
|
return redirect('/machines', {
|
||||||
|
headers: {
|
||||||
|
'Set-Cookie': await commitSession(session),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify(formatError(error)),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { type LoaderFunctionArgs, data, redirect } from 'react-router';
|
||||||
|
import { commitSession, getSession } from '~/utils/sessions.server';
|
||||||
|
import { send } from '~/utils/res';
|
||||||
|
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
|
||||||
|
import { loadContext } from '~/utils/config/headplane';
|
||||||
|
|
||||||
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
|
const session = await getSession(request.headers.get('Cookie'));
|
||||||
|
if (session.has('hsApiKey')) {
|
||||||
|
return redirect('/machines')
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a hold-over from the old code
|
||||||
|
// TODO: Rewrite checkOIDC in the context loader
|
||||||
|
const { oidc } = await loadContext();
|
||||||
|
if (!oidc) {
|
||||||
|
throw new Error('An invalid OIDC configuration was provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const oidcConfig = {
|
||||||
|
issuer: oidc.issuer,
|
||||||
|
clientId: oidc.client,
|
||||||
|
clientSecret: oidc.secret,
|
||||||
|
redirectUri: oidc.redirectUri,
|
||||||
|
tokenEndpointAuthMethod: oidc.method,
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = oidcConfig.redirectUri ?? getRedirectUri(request);
|
||||||
|
const data = await beginAuthFlow(oidcConfig, redirectUri);
|
||||||
|
session.set('oidc_code_verif', data.codeVerifier);
|
||||||
|
session.set('oidc_state', data.state);
|
||||||
|
session.set('oidc_nonce', data.nonce);
|
||||||
|
session.set('oidc_redirect_uri', redirectUri)
|
||||||
|
|
||||||
|
return redirect(data.url, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
'Set-Cookie': await commitSession(session),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,34 +1,29 @@
|
|||||||
import { useSubmit } from '@remix-run/react'
|
import { useSubmit } from 'react-router';
|
||||||
import { Button } from 'react-aria-components'
|
import { Button } from 'react-aria-components';
|
||||||
|
|
||||||
import Code from '~/components/Code'
|
import Code from '~/components/Code';
|
||||||
import Link from '~/components/Link'
|
import Link from '~/components/Link';
|
||||||
import TableList from '~/components/TableList'
|
import TableList from '~/components/TableList';
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
import AddDNS from './dialogs/dns'
|
import AddDNS from '../dialogs/dns';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
records: { name: string, type: 'A', value: string }[]
|
records: { name: string; type: 'A'; value: string }[];
|
||||||
isDisabled: boolean
|
isDisabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DNS({ records, isDisabled }: Props) {
|
export default function DNS({ records, isDisabled }: Props) {
|
||||||
const submit = useSubmit()
|
const submit = useSubmit();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
|
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
<p className="text-gray-700 dark:text-gray-300">
|
||||||
Headscale supports adding custom DNS records to your Tailnet.
|
Headscale supports adding custom DNS records to your Tailnet. As of now,
|
||||||
As of now, only
|
only <Code>A</Code> records are supported.{' '}
|
||||||
{' '}
|
|
||||||
<Code>A</Code>
|
|
||||||
{' '}
|
|
||||||
records are supported.
|
|
||||||
{' '}
|
|
||||||
<Link
|
<Link
|
||||||
to="https://headscale.net/dns-records/"
|
to="https://headscale.net/stable/ref/dns"
|
||||||
name="Headscale DNS Records documentation"
|
name="Headscale DNS Records documentation"
|
||||||
>
|
>
|
||||||
Learn More
|
Learn More
|
||||||
@@ -36,15 +31,12 @@ export default function DNS({ records, isDisabled }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<TableList className="mb-8">
|
<TableList className="mb-8">
|
||||||
{records.length === 0
|
{records.length === 0 ? (
|
||||||
? (
|
<TableList.Item>
|
||||||
<TableList.Item>
|
<p className="opacity-50 text-sm mx-auto">No DNS records found</p>
|
||||||
<p className="opacity-50 text-sm mx-auto">
|
</TableList.Item>
|
||||||
No DNS records found
|
) : (
|
||||||
</p>
|
records.map((record, index) => (
|
||||||
</TableList.Item>
|
|
||||||
)
|
|
||||||
: records.map((record, index) => (
|
|
||||||
<TableList.Item key={index}>
|
<TableList.Item key={index}>
|
||||||
<div className="flex gap-24">
|
<div className="flex gap-24">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -62,27 +54,28 @@ export default function DNS({ records, isDisabled }: Props) {
|
|||||||
)}
|
)}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
submit({
|
submit(
|
||||||
'dns.extra_records': records
|
{
|
||||||
.filter((_, i) => i !== index),
|
'dns.extra_records': records.filter(
|
||||||
}, {
|
(_, i) => i !== index,
|
||||||
method: 'PATCH',
|
),
|
||||||
encType: 'application/json',
|
},
|
||||||
})
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
},
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
</TableList.Item>
|
</TableList.Item>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</TableList>
|
</TableList>
|
||||||
|
|
||||||
{isDisabled
|
{isDisabled ? undefined : <AddDNS records={records} />}
|
||||||
? undefined
|
|
||||||
: (
|
|
||||||
<AddDNS records={records} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,88 +1,88 @@
|
|||||||
/* eslint-disable unicorn/no-keyword-prefix */
|
/* eslint-disable unicorn/no-keyword-prefix */
|
||||||
import {
|
import { closestCorners, DndContext, DragOverlay } from '@dnd-kit/core';
|
||||||
closestCorners,
|
|
||||||
DndContext,
|
|
||||||
DragOverlay
|
|
||||||
} from '@dnd-kit/core'
|
|
||||||
import {
|
import {
|
||||||
restrictToParentElement,
|
restrictToParentElement,
|
||||||
restrictToVerticalAxis
|
restrictToVerticalAxis,
|
||||||
} from '@dnd-kit/modifiers'
|
} from '@dnd-kit/modifiers';
|
||||||
import {
|
import {
|
||||||
arrayMove,
|
arrayMove,
|
||||||
SortableContext,
|
SortableContext,
|
||||||
useSortable,
|
useSortable,
|
||||||
verticalListSortingStrategy
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react'
|
import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react';
|
||||||
import { type FetcherWithComponents, useFetcher } from '@remix-run/react'
|
import { type FetcherWithComponents, useFetcher } from 'react-router';
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react';
|
||||||
import { Button, Input } from 'react-aria-components'
|
import { Button, Input } from 'react-aria-components';
|
||||||
|
|
||||||
import Spinner from '~/components/Spinner'
|
import Spinner from '~/components/Spinner';
|
||||||
import TableList from '~/components/TableList'
|
import TableList from '~/components/TableList';
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
type Properties = {
|
type Properties = {
|
||||||
readonly baseDomain?: string;
|
readonly baseDomain?: string;
|
||||||
readonly searchDomains: string[];
|
readonly searchDomains: string[];
|
||||||
// eslint-disable-next-line react/boolean-prop-naming
|
// eslint-disable-next-line react/boolean-prop-naming
|
||||||
readonly disabled?: boolean;
|
readonly disabled?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function Domains({ baseDomain, searchDomains, disabled }: Properties) {
|
export default function Domains({
|
||||||
|
baseDomain,
|
||||||
|
searchDomains,
|
||||||
|
disabled,
|
||||||
|
}: Properties) {
|
||||||
// eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types
|
// eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types
|
||||||
const [activeId, setActiveId] = useState<number | string | null>(null)
|
const [activeId, setActiveId] = useState<number | string | null>(null);
|
||||||
const [localDomains, setLocalDomains] = useState(searchDomains)
|
const [localDomains, setLocalDomains] = useState(searchDomains);
|
||||||
const [newDomain, setNewDomain] = useState('')
|
const [newDomain, setNewDomain] = useState('');
|
||||||
const fetcher = useFetcher()
|
const fetcher = useFetcher();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalDomains(searchDomains)
|
setLocalDomains(searchDomains);
|
||||||
}, [searchDomains])
|
}, [searchDomains]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col w-2/3'>
|
<div className="flex flex-col w-2/3">
|
||||||
<h1 className='text-2xl font-medium mb-4'>Search Domains</h1>
|
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
|
||||||
<p className='text-gray-700 dark:text-gray-300 mb-2'>
|
<p className="text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Set custom DNS search domains for your Tailnet.
|
Set custom DNS search domains for your Tailnet. When using Magic DNS,
|
||||||
When using Magic DNS, your tailnet domain is used as the first search domain.
|
your tailnet domain is used as the first search domain.
|
||||||
</p>
|
</p>
|
||||||
<DndContext
|
<DndContext
|
||||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
collisionDetection={closestCorners}
|
collisionDetection={closestCorners}
|
||||||
onDragStart={event => {
|
onDragStart={(event) => {
|
||||||
setActiveId(event.active.id)
|
setActiveId(event.active.id);
|
||||||
}}
|
}}
|
||||||
onDragEnd={event => {
|
onDragEnd={(event) => {
|
||||||
// eslint-disable-next-line unicorn/no-null
|
// eslint-disable-next-line unicorn/no-null
|
||||||
setActiveId(null)
|
setActiveId(null);
|
||||||
const { active, over } = event
|
const { active, over } = event;
|
||||||
if (!over) {
|
if (!over) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeItem = localDomains[active.id as number - 1]
|
const activeItem = localDomains[(active.id as number) - 1];
|
||||||
const overItem = localDomains[over.id as number - 1]
|
const overItem = localDomains[(over.id as number) - 1];
|
||||||
|
|
||||||
if (!activeItem || !overItem) {
|
if (!activeItem || !overItem) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldIndex = localDomains.indexOf(activeItem)
|
const oldIndex = localDomains.indexOf(activeItem);
|
||||||
const newIndex = localDomains.indexOf(overItem)
|
const newIndex = localDomains.indexOf(overItem);
|
||||||
|
|
||||||
if (oldIndex !== newIndex) {
|
if (oldIndex !== newIndex) {
|
||||||
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex))
|
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TableList>
|
<TableList>
|
||||||
{baseDomain ? (
|
{baseDomain ? (
|
||||||
<TableList.Item key='magic-dns-sd'>
|
<TableList.Item key="magic-dns-sd">
|
||||||
<p className='font-mono text-sm'>{baseDomain}</p>
|
<p className="font-mono text-sm">{baseDomain}</p>
|
||||||
<LockIcon className='h-4 w-4'/>
|
<LockIcon className="h-4 w-4" />
|
||||||
</TableList.Item>
|
</TableList.Item>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<SortableContext
|
<SortableContext
|
||||||
@@ -101,25 +101,27 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<DragOverlay adjustScale>
|
<DragOverlay adjustScale>
|
||||||
{activeId ? <Domain
|
{activeId ? (
|
||||||
isDrag
|
<Domain
|
||||||
domain={localDomains[activeId as number - 1]}
|
isDrag
|
||||||
localDomains={localDomains}
|
domain={localDomains[(activeId as number) - 1]}
|
||||||
id={activeId as number - 1}
|
localDomains={localDomains}
|
||||||
disabled={disabled}
|
id={(activeId as number) - 1}
|
||||||
fetcher={fetcher}
|
disabled={disabled}
|
||||||
/> : undefined}
|
fetcher={fetcher}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
{disabled ? undefined : (
|
{disabled ? undefined : (
|
||||||
<TableList.Item key='add-sd'>
|
<TableList.Item key="add-sd">
|
||||||
<Input
|
<Input
|
||||||
type='text'
|
type="text"
|
||||||
className='font-mono text-sm bg-transparent w-full mr-2'
|
className="font-mono text-sm bg-transparent w-full mr-2"
|
||||||
placeholder='Search Domain'
|
placeholder="Search Domain"
|
||||||
value={newDomain}
|
value={newDomain}
|
||||||
onChange={event => {
|
onChange={(event) => {
|
||||||
setNewDomain(event.target.value)
|
setNewDomain(event.target.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{fetcher.state === 'idle' ? (
|
{fetcher.state === 'idle' ? (
|
||||||
@@ -128,32 +130,35 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
|
|||||||
'text-sm font-semibold',
|
'text-sm font-semibold',
|
||||||
'text-blue-600 dark:text-blue-400',
|
'text-blue-600 dark:text-blue-400',
|
||||||
'hover:text-blue-700 dark:hover:text-blue-300',
|
'hover:text-blue-700 dark:hover:text-blue-300',
|
||||||
newDomain.length === 0 && 'opacity-50 cursor-not-allowed'
|
newDomain.length === 0 && 'opacity-50 cursor-not-allowed',
|
||||||
)}
|
)}
|
||||||
isDisabled={newDomain.length === 0}
|
isDisabled={newDomain.length === 0}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
fetcher.submit({
|
fetcher.submit(
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
{
|
||||||
'dns.search_domains': [...localDomains, newDomain]
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
}, {
|
'dns.search_domains': [...localDomains, newDomain],
|
||||||
method: 'PATCH',
|
},
|
||||||
encType: 'application/json'
|
{
|
||||||
})
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
setNewDomain('')
|
setNewDomain('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Spinner className='w-3 h-3 mr-0'/>
|
<Spinner className="w-3 h-3 mr-0" />
|
||||||
)}
|
)}
|
||||||
</TableList.Item>
|
</TableList.Item>
|
||||||
)}
|
)}
|
||||||
</TableList>
|
</TableList>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type DomainProperties = {
|
type DomainProperties = {
|
||||||
@@ -164,17 +169,24 @@ type DomainProperties = {
|
|||||||
// eslint-disable-next-line react/boolean-prop-naming
|
// eslint-disable-next-line react/boolean-prop-naming
|
||||||
readonly disabled?: boolean;
|
readonly disabled?: boolean;
|
||||||
readonly fetcher: FetcherWithComponents<unknown>;
|
readonly fetcher: FetcherWithComponents<unknown>;
|
||||||
}
|
};
|
||||||
|
|
||||||
function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainProperties) {
|
function Domain({
|
||||||
|
domain,
|
||||||
|
id,
|
||||||
|
localDomains,
|
||||||
|
isDrag,
|
||||||
|
disabled,
|
||||||
|
fetcher,
|
||||||
|
}: DomainProperties) {
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
setNodeRef,
|
setNodeRef,
|
||||||
transform,
|
transform,
|
||||||
transition,
|
transition,
|
||||||
isDragging
|
isDragging,
|
||||||
} = useSortable({ id })
|
} = useSortable({ id });
|
||||||
|
|
||||||
// TODO: Figure out why TableList.Item breaks dndkit
|
// TODO: Figure out why TableList.Item breaks dndkit
|
||||||
return (
|
return (
|
||||||
@@ -184,17 +196,19 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
|
|||||||
'flex items-center justify-between px-3 py-2',
|
'flex items-center justify-between px-3 py-2',
|
||||||
'border-b border-gray-200 last:border-b-0 dark:border-zinc-800',
|
'border-b border-gray-200 last:border-b-0 dark:border-zinc-800',
|
||||||
isDragging ? 'text-gray-400' : '',
|
isDragging ? 'text-gray-400' : '',
|
||||||
isDrag ? 'outline outline-1 outline-gray-500 bg-gray-200 dark:bg-zinc-800' : ''
|
isDrag
|
||||||
|
? 'outline outline-1 outline-gray-500 bg-gray-200 dark:bg-zinc-800'
|
||||||
|
: '',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition
|
transition,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className='font-mono text-sm flex items-center gap-4'>
|
<p className="font-mono text-sm flex items-center gap-4">
|
||||||
{disabled ? undefined : (
|
{disabled ? undefined : (
|
||||||
<ThreeBarsIcon
|
<ThreeBarsIcon
|
||||||
className='h-4 w-4 text-gray-400 focus:outline-none'
|
className="h-4 w-4 text-gray-400 focus:outline-none"
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
/>
|
/>
|
||||||
@@ -207,21 +221,26 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
|
|||||||
'text-sm',
|
'text-sm',
|
||||||
'text-red-600 dark:text-red-400',
|
'text-red-600 dark:text-red-400',
|
||||||
'hover:text-red-700 dark:hover:text-red-300',
|
'hover:text-red-700 dark:hover:text-red-300',
|
||||||
disabled && 'opacity-50 cursor-not-allowed'
|
disabled && 'opacity-50 cursor-not-allowed',
|
||||||
)}
|
)}
|
||||||
isDisabled={disabled}
|
isDisabled={disabled}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
fetcher.submit({
|
fetcher.submit(
|
||||||
'dns.search_domains': localDomains.filter((_, index) => index !== id - 1)
|
{
|
||||||
}, {
|
'dns.search_domains': localDomains.filter(
|
||||||
method: 'PATCH',
|
(_, index) => index !== id - 1,
|
||||||
encType: 'application/json'
|
),
|
||||||
})
|
},
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
},
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { RepoForkedIcon } from '@primer/octicons-react';
|
||||||
|
import { Form, useSubmit } from 'react-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import Dialog from '~/components/Dialog';
|
||||||
|
import Switch from '~/components/Switch';
|
||||||
|
import TextField from '~/components/TextField';
|
||||||
|
import Tooltip from '~/components/Tooltip';
|
||||||
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nameservers: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddNameserver({ nameservers }: Props) {
|
||||||
|
const submit = useSubmit();
|
||||||
|
const [split, setSplit] = useState(false);
|
||||||
|
const [ns, setNs] = useState('');
|
||||||
|
const [domain, setDomain] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<Dialog.Button>Add nameserver</Dialog.Button>
|
||||||
|
<Dialog.Panel>
|
||||||
|
{(close) => (
|
||||||
|
<>
|
||||||
|
<Dialog.Title>Add nameserver</Dialog.Title>
|
||||||
|
<Dialog.Text className="font-semibold">Nameserver</Dialog.Text>
|
||||||
|
<Dialog.Text className="text-sm">
|
||||||
|
Use this IPv4 or IPv6 address to resolve names.
|
||||||
|
</Dialog.Text>
|
||||||
|
<Form
|
||||||
|
method="POST"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!ns) return;
|
||||||
|
|
||||||
|
if (split) {
|
||||||
|
const splitNs: Record<string, string[]> = {};
|
||||||
|
for (const [key, value] of Object.entries(nameservers)) {
|
||||||
|
if (key === 'global') continue;
|
||||||
|
splitNs[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(splitNs).includes(domain)) {
|
||||||
|
splitNs[domain].push(ns);
|
||||||
|
} else {
|
||||||
|
splitNs[domain] = [ns];
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(
|
||||||
|
{
|
||||||
|
'dns.nameservers.split': splitNs,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const globalNs = nameservers.global;
|
||||||
|
globalNs.push(ns);
|
||||||
|
|
||||||
|
submit(
|
||||||
|
{
|
||||||
|
'dns.nameservers.global': globalNs,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setNs('');
|
||||||
|
setDomain('');
|
||||||
|
setSplit(false);
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="DNS Server"
|
||||||
|
placeholder="1.2.3.4"
|
||||||
|
name="ns"
|
||||||
|
state={[ns, setNs]}
|
||||||
|
className="mt-2 mb-8"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="block">
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<Dialog.Text className="font-semibold">
|
||||||
|
Restrict to domain
|
||||||
|
</Dialog.Text>
|
||||||
|
<Tooltip>
|
||||||
|
<Tooltip.Button
|
||||||
|
className={cn(
|
||||||
|
'text-xs rounded-md px-1.5 py-0.5',
|
||||||
|
'bg-ui-200 dark:bg-ui-800',
|
||||||
|
'text-ui-600 dark:text-ui-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
|
||||||
|
Split DNS
|
||||||
|
</Tooltip.Button>
|
||||||
|
<Tooltip.Body>
|
||||||
|
Only clients that support split DNS (Tailscale v1.8 or
|
||||||
|
later for most platforms) will use this nameserver.
|
||||||
|
Older clients will ignore it.
|
||||||
|
</Tooltip.Body>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Dialog.Text className="text-sm">
|
||||||
|
This nameserver will only be used for some domains.
|
||||||
|
</Dialog.Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
label="Split DNS"
|
||||||
|
defaultSelected={split}
|
||||||
|
onChange={() => {
|
||||||
|
setSplit(!split);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{split ? (
|
||||||
|
<>
|
||||||
|
<Dialog.Text className="font-semibold mt-8">
|
||||||
|
Domain
|
||||||
|
</Dialog.Text>
|
||||||
|
<TextField
|
||||||
|
label="Domain"
|
||||||
|
placeholder="example.com"
|
||||||
|
name="domain"
|
||||||
|
state={[domain, setDomain]}
|
||||||
|
className="my-2"
|
||||||
|
/>
|
||||||
|
<Dialog.Text className="text-sm">
|
||||||
|
Only single-label or fully-qualified queries matching this
|
||||||
|
suffix should use the nameserver.
|
||||||
|
</Dialog.Text>
|
||||||
|
</>
|
||||||
|
) : undefined}
|
||||||
|
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||||
|
<Dialog.Action variant="cancel" onPress={close}>
|
||||||
|
Cancel
|
||||||
|
</Dialog.Action>
|
||||||
|
<Dialog.Action variant="confirm" onPress={close}>
|
||||||
|
Add
|
||||||
|
</Dialog.Action>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,27 +1,27 @@
|
|||||||
import { type ActionFunctionArgs } from '@remix-run/node'
|
import type { ActionFunctionArgs } from 'react-router';
|
||||||
import { json, useLoaderData } from '@remix-run/react'
|
import { data, useLoaderData } from 'react-router';
|
||||||
|
|
||||||
import Code from '~/components/Code'
|
import Code from '~/components/Code';
|
||||||
import Notice from '~/components/Notice'
|
import Notice from '~/components/Notice';
|
||||||
import { loadContext } from '~/utils/config/headplane'
|
import { loadContext } from '~/utils/config/headplane';
|
||||||
import { loadConfig, patchConfig } from '~/utils/config/headscale'
|
import { loadConfig, patchConfig } from '~/utils/config/headscale';
|
||||||
import { getSession } from '~/utils/sessions'
|
import { getSession } from '~/utils/sessions.server';
|
||||||
import { useLiveData } from '~/utils/useLiveData'
|
import { useLiveData } from '~/utils/useLiveData';
|
||||||
|
|
||||||
import DNS from './dns'
|
import DNS from './components/dns';
|
||||||
import Domains from './domains'
|
import Domains from './components/domains';
|
||||||
import MagicModal from './magic'
|
import MagicModal from './components/magic';
|
||||||
import Nameservers from './nameservers'
|
import Nameservers from './components/nameservers';
|
||||||
import RenameModal from './rename'
|
import RenameModal from './components/rename';
|
||||||
|
|
||||||
// We do not want to expose every config value
|
// We do not want to expose every config value
|
||||||
export async function loader() {
|
export async function loader() {
|
||||||
const context = await loadContext()
|
const context = await loadContext();
|
||||||
if (!context.config.read) {
|
if (!context.config.read) {
|
||||||
throw new Error('No configuration is available')
|
throw new Error('No configuration is available');
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await loadConfig()
|
const config = await loadConfig();
|
||||||
const dns = {
|
const dns = {
|
||||||
prefixes: config.prefixes,
|
prefixes: config.prefixes,
|
||||||
magicDns: config.dns.magic_dns,
|
magicDns: config.dns.magic_dns,
|
||||||
@@ -32,69 +32,58 @@ export async function loader() {
|
|||||||
splitDns: config.dns.nameservers.split,
|
splitDns: config.dns.nameservers.split,
|
||||||
searchDomains: config.dns.search_domains,
|
searchDomains: config.dns.search_domains,
|
||||||
extraRecords: config.dns.extra_records,
|
extraRecords: config.dns.extra_records,
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...dns,
|
...dns,
|
||||||
...context,
|
...context,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request }: ActionFunctionArgs) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
const session = await getSession(request.headers.get('Cookie'));
|
||||||
if (!session.has('hsApiKey')) {
|
if (!session.has('hsApiKey')) {
|
||||||
return json({ success: false }, {
|
return data({ success: false }, { status: 401 });
|
||||||
status: 401,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await loadContext()
|
const context = await loadContext();
|
||||||
if (!context.config.write) {
|
if (!context.config.write) {
|
||||||
return json({ success: false }, {
|
return data({ success: false }, { status: 403 });
|
||||||
status: 403,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await request.json() as Record<string, unknown>
|
const patch = (await request.json()) as Record<string, unknown>;
|
||||||
await patchConfig(data)
|
await patchConfig(patch);
|
||||||
|
|
||||||
if (context.integration?.onConfigChange) {
|
if (context.integration?.onConfigChange) {
|
||||||
await context.integration.onConfigChange(context.integration.context)
|
await context.integration.onConfigChange(context.integration.context);
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ success: true })
|
return data({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
useLiveData({ interval: 5000 })
|
useLiveData({ interval: 5000 });
|
||||||
const data = useLoaderData<typeof loader>()
|
const data = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
const allNs: Record<string, string[]> = {}
|
const allNs: Record<string, string[]> = {};
|
||||||
for (const key of Object.keys(data.splitDns)) {
|
for (const key of Object.keys(data.splitDns)) {
|
||||||
allNs[key] = data.splitDns[key]
|
allNs[key] = data.splitDns[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
allNs.global = data.nameservers
|
allNs.global = data.nameservers;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-16 max-w-screen-lg">
|
<div className="flex flex-col gap-16 max-w-screen-lg">
|
||||||
{data.config.write
|
{data.config.write ? undefined : (
|
||||||
? undefined
|
<Notice>
|
||||||
: (
|
The Headscale configuration is read-only. You cannot make changes to
|
||||||
<Notice>
|
the configuration
|
||||||
The Headscale configuration is read-only. You cannot make changes to the configuration
|
</Notice>
|
||||||
</Notice>
|
)}
|
||||||
)}
|
|
||||||
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
|
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
|
||||||
<Nameservers
|
<Nameservers nameservers={allNs} isDisabled={!data.config.write} />
|
||||||
nameservers={allNs}
|
|
||||||
isDisabled={!data.config.write}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DNS
|
<DNS records={data.extraRecords} isDisabled={!data.config.write} />
|
||||||
records={data.extraRecords}
|
|
||||||
isDisabled={!data.config.write}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Domains
|
<Domains
|
||||||
baseDomain={data.magicDns ? data.baseDomain : undefined}
|
baseDomain={data.magicDns ? data.baseDomain : undefined}
|
||||||
@@ -105,18 +94,16 @@ export default function Page() {
|
|||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
|
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
|
||||||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
||||||
Automatically register domain names for each device
|
Automatically register domain names for each device on the tailnet.
|
||||||
on the tailnet. Devices will be accessible at
|
Devices will be accessible at{' '}
|
||||||
{' '}
|
|
||||||
<Code>
|
<Code>
|
||||||
[device].
|
[device].
|
||||||
{data.baseDomain}
|
{data.baseDomain}
|
||||||
</Code>
|
</Code>{' '}
|
||||||
{' '}
|
|
||||||
when Magic DNS is enabled.
|
when Magic DNS is enabled.
|
||||||
</p>
|
</p>
|
||||||
<MagicModal isEnabled={data.magicDns} disabled={!data.config.write} />
|
<MagicModal isEnabled={data.magicDns} disabled={!data.config.write} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+16
-23
@@ -1,46 +1,39 @@
|
|||||||
import { Form, useSubmit } from '@remix-run/react'
|
import { Form, useSubmit } from 'react-router';
|
||||||
import { type Dispatch, type SetStateAction } from 'react'
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
import Dialog from '~/components/Dialog'
|
import Dialog from '~/components/Dialog';
|
||||||
import { type Machine } from '~/types'
|
import type { Machine } from '~/types';
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
interface DeleteProps {
|
interface DeleteProps {
|
||||||
readonly machine: Machine
|
readonly machine: Machine;
|
||||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Delete({ machine, state }: DeleteProps) {
|
export default function Delete({ machine, state }: DeleteProps) {
|
||||||
const submit = useSubmit()
|
const submit = useSubmit();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Dialog.Panel control={state}>
|
<Dialog.Panel control={state}>
|
||||||
{close => (
|
{(close) => (
|
||||||
<>
|
<>
|
||||||
<Dialog.Title>
|
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
|
||||||
Remove
|
|
||||||
{' '}
|
|
||||||
{machine.givenName}
|
|
||||||
</Dialog.Title>
|
|
||||||
<Dialog.Text>
|
<Dialog.Text>
|
||||||
This machine will be permanently removed from
|
This machine will be permanently removed from your network. To
|
||||||
your network. To re-add it, you will need to
|
re-add it, you will need to reauthenticate to your tailnet from
|
||||||
reauthenticate to your tailnet from the device.
|
the device.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<Form
|
<Form
|
||||||
method="POST"
|
method="POST"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
submit(e.currentTarget)
|
submit(e.currentTarget);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="_method" value="delete" />
|
<input type="hidden" name="_method" value="delete" />
|
||||||
<input type="hidden" name="id" value={machine.id} />
|
<input type="hidden" name="id" value={machine.id} />
|
||||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||||
<Dialog.Action
|
<Dialog.Action variant="cancel" onPress={close}>
|
||||||
variant="cancel"
|
|
||||||
onPress={close}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Dialog.Action>
|
</Dialog.Action>
|
||||||
<Dialog.Action
|
<Dialog.Action
|
||||||
@@ -61,5 +54,5 @@ export default function Delete({ machine, state }: DeleteProps) {
|
|||||||
)}
|
)}
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
+15
-23
@@ -1,46 +1,38 @@
|
|||||||
import { Form, useSubmit } from '@remix-run/react'
|
import { Form, useSubmit } from 'react-router';
|
||||||
import { type Dispatch, type SetStateAction } from 'react'
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
import Dialog from '~/components/Dialog'
|
import Dialog from '~/components/Dialog';
|
||||||
import { type Machine } from '~/types'
|
import type { Machine } from '~/types';
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
interface ExpireProps {
|
interface ExpireProps {
|
||||||
readonly machine: Machine
|
readonly machine: Machine;
|
||||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Expire({ machine, state }: ExpireProps) {
|
export default function Expire({ machine, state }: ExpireProps) {
|
||||||
const submit = useSubmit()
|
const submit = useSubmit();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Dialog.Panel control={state}>
|
<Dialog.Panel control={state}>
|
||||||
{close => (
|
{(close) => (
|
||||||
<>
|
<>
|
||||||
<Dialog.Title>
|
<Dialog.Title>Expire {machine.givenName}</Dialog.Title>
|
||||||
Expire
|
|
||||||
{' '}
|
|
||||||
{machine.givenName}
|
|
||||||
</Dialog.Title>
|
|
||||||
<Dialog.Text>
|
<Dialog.Text>
|
||||||
This will disconnect the machine from your Tailnet.
|
This will disconnect the machine from your Tailnet. In order to
|
||||||
In order to reconnect, you will need to re-authenticate
|
reconnect, you will need to re-authenticate from the device.
|
||||||
from the device.
|
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<Form
|
<Form
|
||||||
method="POST"
|
method="POST"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
submit(e.currentTarget)
|
submit(e.currentTarget);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="_method" value="expire" />
|
<input type="hidden" name="_method" value="expire" />
|
||||||
<input type="hidden" name="id" value={machine.id} />
|
<input type="hidden" name="id" value={machine.id} />
|
||||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||||
<Dialog.Action
|
<Dialog.Action variant="cancel" onPress={close}>
|
||||||
variant="cancel"
|
|
||||||
onPress={close}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Dialog.Action>
|
</Dialog.Action>
|
||||||
<Dialog.Action
|
<Dialog.Action
|
||||||
@@ -61,5 +53,5 @@ export default function Expire({ machine, state }: ExpireProps) {
|
|||||||
)}
|
)}
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+47
-61
@@ -1,51 +1,44 @@
|
|||||||
import { PlusIcon, XIcon } from '@primer/octicons-react'
|
import { PlusIcon, XIcon } from '@primer/octicons-react';
|
||||||
import { Form, useSubmit } from '@remix-run/react'
|
import { Form, useSubmit } from 'react-router';
|
||||||
import { type Dispatch, type SetStateAction, useState } from 'react'
|
import { type Dispatch, type SetStateAction, useState } from 'react';
|
||||||
import { Button, Input } from 'react-aria-components'
|
import { Button, Input } from 'react-aria-components';
|
||||||
|
|
||||||
import Dialog from '~/components/Dialog'
|
import Dialog from '~/components/Dialog';
|
||||||
import Link from '~/components/Link'
|
import Link from '~/components/Link';
|
||||||
import { type Machine } from '~/types'
|
import type { Machine } from '~/types';
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
interface TagsProps {
|
interface TagsProps {
|
||||||
readonly machine: Machine
|
readonly machine: Machine;
|
||||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Tags({ machine, state }: TagsProps) {
|
export default function Tags({ machine, state }: TagsProps) {
|
||||||
const [tags, setTags] = useState(machine.forcedTags)
|
const [tags, setTags] = useState(machine.forcedTags);
|
||||||
const [tag, setTag] = useState('')
|
const [tag, setTag] = useState('');
|
||||||
const submit = useSubmit()
|
const submit = useSubmit();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Dialog.Panel control={state}>
|
<Dialog.Panel control={state}>
|
||||||
{close => (
|
{(close) => (
|
||||||
<>
|
<>
|
||||||
<Dialog.Title>
|
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
|
||||||
Edit ACL tags for
|
|
||||||
{' '}
|
|
||||||
{machine.givenName}
|
|
||||||
</Dialog.Title>
|
|
||||||
<Dialog.Text>
|
<Dialog.Text>
|
||||||
ACL tags can be used to reference machines in your ACL policies.
|
ACL tags can be used to reference machines in your ACL policies.
|
||||||
See the
|
See the{' '}
|
||||||
{' '}
|
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
to="https://tailscale.com/kb/1068/acl-tags"
|
to="https://tailscale.com/kb/1068/acl-tags"
|
||||||
name="Tailscale documentation"
|
name="Tailscale documentation"
|
||||||
>
|
>
|
||||||
Tailscale documentation
|
Tailscale documentation
|
||||||
</Link>
|
</Link>{' '}
|
||||||
{' '}
|
|
||||||
for more information.
|
for more information.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<Form
|
<Form
|
||||||
method="POST"
|
method="POST"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
submit(e.currentTarget)
|
submit(e.currentTarget);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="_method" value="tags" />
|
<input type="hidden" name="_method" value="tags" />
|
||||||
@@ -58,21 +51,18 @@ export default function Tags({ machine, state }: TagsProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="divide-y divide-ui-200 dark:divide-ui-600">
|
<div className="divide-y divide-ui-200 dark:divide-ui-600">
|
||||||
{tags.length === 0
|
{tags.length === 0 ? (
|
||||||
? (
|
<div
|
||||||
<div
|
className={cn(
|
||||||
className={cn(
|
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
'items-center justify-center rounded-t-lg',
|
||||||
'items-center justify-center rounded-t-lg',
|
'text-ui-600 dark:text-ui-300',
|
||||||
'text-ui-600 dark:text-ui-300',
|
)}
|
||||||
)}
|
>
|
||||||
>
|
<p>No tags are set on this machine.</p>
|
||||||
<p>
|
</div>
|
||||||
No tags are set on this machine.
|
) : (
|
||||||
</p>
|
tags.map((item) => (
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: tags.map(item => (
|
|
||||||
<div
|
<div
|
||||||
key={item}
|
key={item}
|
||||||
id={item}
|
id={item}
|
||||||
@@ -86,13 +76,14 @@ export default function Tags({ machine, state }: TagsProps) {
|
|||||||
<Button
|
<Button
|
||||||
className="rounded-full p-0 w-6 h-6"
|
className="rounded-full p-0 w-6 h-6"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setTags(tags.filter(tag => tag !== item))
|
setTags(tags.filter((tag) => tag !== item));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<XIcon className="w-4 h-4" />
|
<XIcon className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -101,8 +92,9 @@ export default function Tags({ machine, state }: TagsProps) {
|
|||||||
'rounded-b-lg justify-between items-center',
|
'rounded-b-lg justify-between items-center',
|
||||||
'dark:bg-ui-800 dark:text-ui-300',
|
'dark:bg-ui-800 dark:text-ui-300',
|
||||||
'focus-within:ring-2 focus-within:ring-blue-600',
|
'focus-within:ring-2 focus-within:ring-blue-600',
|
||||||
tag.length > 0 && !tag.startsWith('tag:')
|
tag.length > 0 &&
|
||||||
&& 'outline outline-red-500',
|
!tag.startsWith('tag:') &&
|
||||||
|
'outline outline-red-500',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@@ -115,23 +107,23 @@ export default function Tags({ machine, state }: TagsProps) {
|
|||||||
)}
|
)}
|
||||||
value={tag}
|
value={tag}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setTag(e.currentTarget.value)
|
setTag(e.currentTarget.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg p-0 h-6 w-6',
|
'rounded-lg p-0 h-6 w-6',
|
||||||
!tag.startsWith('tag:')
|
!tag.startsWith('tag:') &&
|
||||||
&& 'opacity-50 cursor-not-allowed',
|
'opacity-50 cursor-not-allowed',
|
||||||
)}
|
)}
|
||||||
isDisabled={
|
isDisabled={
|
||||||
tag.length === 0
|
tag.length === 0 ||
|
||||||
|| !tag.startsWith('tag:')
|
!tag.startsWith('tag:') ||
|
||||||
|| tags.includes(tag)
|
tags.includes(tag)
|
||||||
}
|
}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setTags([...tags, tag])
|
setTags([...tags, tag]);
|
||||||
setTag('')
|
setTag('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlusIcon className="w-4 h-4" />
|
<PlusIcon className="w-4 h-4" />
|
||||||
@@ -139,16 +131,10 @@ export default function Tags({ machine, state }: TagsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||||
<Dialog.Action
|
<Dialog.Action variant="cancel" onPress={close}>
|
||||||
variant="cancel"
|
|
||||||
onPress={close}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Dialog.Action>
|
</Dialog.Action>
|
||||||
<Dialog.Action
|
<Dialog.Action variant="confirm" onPress={close}>
|
||||||
variant="confirm"
|
|
||||||
onPress={close}
|
|
||||||
>
|
|
||||||
Save
|
Save
|
||||||
</Dialog.Action>
|
</Dialog.Action>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,5 +143,5 @@ export default function Tags({ machine, state }: TagsProps) {
|
|||||||
)}
|
)}
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -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 machine’s permissions match its creator’s.
|
||||||
|
</Tooltip.Body>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-x-2.5 mt-1">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-full h-7 w-7 flex items-center justify-center',
|
||||||
|
'border border-ui-200 dark:border-ui-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PersonIcon className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
{machine.user.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 pl-4">
|
||||||
|
<p className="text-sm text-ui-600 dark:text-ui-300">Status</p>
|
||||||
|
<div className="flex gap-1 mt-1 mb-8">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className={cn(
|
||||||
|
'text-xs rounded-md px-1.5 py-0.5',
|
||||||
|
'bg-ui-200 dark:bg-ui-800',
|
||||||
|
'text-ui-600 dark:text-ui-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2>
|
||||||
|
<Routes machine={machine} routes={routes} state={routesState} />
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<p>
|
||||||
|
Subnets let you expose physical network routes onto Tailscale.{' '}
|
||||||
|
<Link
|
||||||
|
to="https://tailscale.com/kb/1019/subnets"
|
||||||
|
name="Tailscale Subnets Documentation"
|
||||||
|
>
|
||||||
|
Learn More
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<Button variant="light" control={routesState}>
|
||||||
|
Review
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Card
|
||||||
|
variant="flat"
|
||||||
|
className={cn(
|
||||||
|
'w-full max-w-full grid sm:grid-cols-2',
|
||||||
|
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
||||||
|
Approved
|
||||||
|
<Tooltip>
|
||||||
|
<Tooltip.Button>
|
||||||
|
<InfoIcon className="w-3.5 h-3.5" />
|
||||||
|
</Tooltip.Button>
|
||||||
|
<Tooltip.Body>
|
||||||
|
Traffic to these routes are being routed through this machine.
|
||||||
|
</Tooltip.Body>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
<div className="mt-1">
|
||||||
|
{subnetApproved.length === 0 ? (
|
||||||
|
<span className="text-ui-400 dark:text-ui-300">—</span>
|
||||||
|
) : (
|
||||||
|
<ul className="leading-normal">
|
||||||
|
{subnetApproved.map((route) => (
|
||||||
|
<li key={route.id}>{route.prefix}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'p-0 rounded-sm bg-transparent mt-1',
|
||||||
|
'text-blue-500 dark:text-blue-400',
|
||||||
|
'hover:bg-transparent',
|
||||||
|
'hover:text-blue-600 dark:hover:text-blue-500',
|
||||||
|
)}
|
||||||
|
control={routesState}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
||||||
|
Awaiting Approval
|
||||||
|
<Tooltip>
|
||||||
|
<Tooltip.Button>
|
||||||
|
<InfoIcon className="w-3.5 h-3.5" />
|
||||||
|
</Tooltip.Button>
|
||||||
|
<Tooltip.Body>
|
||||||
|
This machine is advertising these routes, but they must be
|
||||||
|
approved before traffic will be routed to them.
|
||||||
|
</Tooltip.Body>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
<div className="mt-1">
|
||||||
|
{subnet.length === 0 ? (
|
||||||
|
<span className="text-ui-400 dark:text-ui-300">—</span>
|
||||||
|
) : (
|
||||||
|
<ul className="leading-normal">
|
||||||
|
{subnet.map((route) => (
|
||||||
|
<li key={route.id}>{route.prefix}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'p-0 rounded-sm bg-transparent mt-1',
|
||||||
|
'text-blue-500 dark:text-blue-400',
|
||||||
|
'hover:bg-transparent',
|
||||||
|
'hover:text-blue-600 dark:hover:text-blue-500',
|
||||||
|
)}
|
||||||
|
control={routesState}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
||||||
|
Exit Node
|
||||||
|
<Tooltip>
|
||||||
|
<Tooltip.Button>
|
||||||
|
<InfoIcon className="w-3.5 h-3.5" />
|
||||||
|
</Tooltip.Button>
|
||||||
|
<Tooltip.Body>
|
||||||
|
Whether this machine can act as an exit node for your tailnet.
|
||||||
|
</Tooltip.Body>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
<div className="mt-1">
|
||||||
|
{exit.length === 0 ? (
|
||||||
|
<span className="text-ui-400 dark:text-ui-300">—</span>
|
||||||
|
) : exitEnabled ? (
|
||||||
|
<span className="flex items-center gap-x-1">
|
||||||
|
<CheckCircleIcon className="w-3.5 h-3.5 text-green-700" />
|
||||||
|
Allowed
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-x-1">
|
||||||
|
<SkipIcon className="w-3.5 h-3.5 text-red-700" />
|
||||||
|
Awaiting Approval
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'p-0 rounded-sm bg-transparent mt-1',
|
||||||
|
'text-blue-500 dark:text-blue-400',
|
||||||
|
'hover:bg-transparent',
|
||||||
|
'hover:text-blue-600 dark:hover:text-blue-500',
|
||||||
|
)}
|
||||||
|
control={routesState}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<h2 className="text-xl font-medium mb-4">Machine Details</h2>
|
||||||
|
<Card variant="flat" className="w-full max-w-full">
|
||||||
|
<Attribute name="Creator" value={machine.user.name} />
|
||||||
|
<Attribute name="Node ID" value={machine.id} />
|
||||||
|
<Attribute name="Node Name" value={machine.givenName} />
|
||||||
|
<Attribute name="Hostname" value={machine.name} />
|
||||||
|
<Attribute isCopyable name="Node Key" value={machine.nodeKey} />
|
||||||
|
<Attribute
|
||||||
|
name="Created"
|
||||||
|
value={new Date(machine.createdAt).toLocaleString()}
|
||||||
|
/>
|
||||||
|
<Attribute
|
||||||
|
name="Last Seen"
|
||||||
|
value={new Date(machine.lastSeen).toLocaleString()}
|
||||||
|
/>
|
||||||
|
<Attribute
|
||||||
|
name="Expiry"
|
||||||
|
value={expired ? new Date(machine.expiry).toLocaleString() : 'Never'}
|
||||||
|
/>
|
||||||
|
{magic ? (
|
||||||
|
<Attribute
|
||||||
|
isCopyable
|
||||||
|
name="Domain"
|
||||||
|
value={`${machine.givenName}.${magic}`}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user