Compare commits

...

125 Commits

Author SHA1 Message Date
Aarnav Tale 8596a56375 chore: v0.5.7 2025-04-02 14:47:50 -04:00
Aarnav Tale 000ec620b4 fix: serve css and js assets correctly 2025-04-02 14:27:23 -04:00
Aarnav Tale 83a69792ea chore: v0.5.6 2025-04-02 13:47:45 -04:00
Aarnav Tale fee5f423a6 chore: update nix hash 2025-04-02 13:40:50 -04:00
Aarnav Tale d698cf5478 fix: open file in a+ to avoid read issues 2025-04-02 13:40:01 -04:00
Aarnav Tale 80c987f383 feat: implement onboarding for non-registered users 2025-04-02 13:26:58 -04:00
Aarnav Tale 17d477bf0f fix: join copied commands without a space 2025-04-02 13:25:33 -04:00
Aarnav Tale 5e5c7c4c7a fix: remove unreleased feature and ignore invalid keys in config 2025-04-02 10:16:34 -04:00
Aarnav Tale 2e383ddabe feat: reimplement user actions 2025-04-01 12:27:44 -04:00
Aarnav Tale 2299907932 chore: document user login state 2025-03-29 14:28:11 -04:00
Aarnav Tale 3771890f98 fix: disable live data fetching when a dialog is open 2025-03-29 14:27:56 -04:00
Aarnav Tale bf02015dc7 feat: begin working on user auth 2025-03-29 14:12:15 -04:00
Aarnav Tale 8429b19c4a docs: use git tags for bare metal 2025-03-29 14:10:46 -04:00
Aarnav Tale 9a5952adcb fix: split email for username if preferred_username is unavailable 2025-03-29 12:16:38 -04:00
Aarnav Tale 222ac7a279 chore: make prefixes.v4/6 optional 2025-03-27 12:20:47 -04:00
Aarnav Tale 6f40f9cfac feat: rework github actions 2025-03-24 16:49:54 -04:00
Aarnav Tale 457cbc45e6 fix: do not bundle ssr deps in dev 2025-03-24 16:26:47 -04:00
Aarnav Tale aac8a9ef12 fix: update nix hash 2025-03-24 16:15:38 -04:00
Aarnav Tale b8d22beb17 feat: bundle node_modules into the server 2025-03-24 16:15:38 -04:00
Aarnav Tale cac64a6fbe chore: remove old server code 2025-03-24 16:15:38 -04:00
Aarnav Tale 5918d0e501 fix: allow hostname passthrough for hono node 2025-03-24 16:15:38 -04:00
Aarnav Tale 03acebb23e fix: env variables did not resolve in prod 2025-03-24 16:15:38 -04:00
Aarnav Tale 73ea35980d feat: switch agent fetching to the server side
this brings the benefit of fitting in the revalidator lifecycle we have
created via the useLiveData hook.
2025-03-24 16:15:38 -04:00
Aarnav Tale 9a1051b9af feat: reimplement websocket to use hono 2025-03-24 16:15:38 -04:00
Aarnav Tale c066b3064d fix: make useLiveData a context that is pausable 2025-03-24 16:15:38 -04:00
Aarnav Tale 98d02bb595 chore: migrate patching to HeadscaleConfig 2025-03-24 16:15:38 -04:00
Aarnav Tale 2964ff295e fix: set config after loading 2025-03-24 16:15:38 -04:00
Aarnav Tale b0a3f9d5fd chore: update deps 2025-03-24 16:15:38 -04:00
Aarnav Tale 34cfee7cff feat: reach an initial working stage 2025-03-24 16:15:38 -04:00
Aarnav Tale 8db323b63f style: use biome in zed settings 2025-03-24 16:15:38 -04:00
Aarnav Tale 08c25caca3 chore: add deps and include 2025-03-24 16:15:38 -04:00
Aarnav Tale cbbd64e91a feat: initial server side systems 2025-03-24 16:15:38 -04:00
Aarnav Tale 48fc0f7ef3 fix: remove unnecessary title in templates 2025-03-18 12:25:19 -04:00
Aarnav Tale 23fd2bbda2 chore: update nix sha 2025-03-18 01:59:16 -04:00
Aarnav Tale 05837963c4 chore: FUNDING 2025-03-18 01:56:17 -04:00
Aarnav Tale 2a16115e69 feat: add issue templates 2025-03-18 01:54:36 -04:00
Aarnav Tale 5675ecdeac chore: add 'community' things 2025-03-18 01:32:05 -04:00
Aarnav Tale 29424366a8 chore: v0.5.5 2025-03-18 00:46:54 -04:00
Aarnav Tale c47346df52 fix: do not require authkey 2025-03-18 00:46:35 -04:00
Aarnav Tale 92dedf51aa fix: ignore ws_agents if there are no agents connected 2025-03-18 00:43:52 -04:00
Aarnav Tale 8f7a85f47d chore: v0.5.4 2025-03-18 00:34:49 -04:00
Aarnav Tale ed1cf188dc style: format github actions 2025-03-18 00:33:14 -04:00
Igor Ramazanov 03a7e51384 feat: add a nix flake (#132)
* feat: wip `nix`

Add initial code to be used when working with `nix` and `NixOS`.

* a Nix flake
* building a package
* a NixOS module

* feat: build `hp_agent` with `nix`

* feat: add `hp_agent` as a flake output

* feat: nix: start `headplane` after `headscale`

* feat: do not rely on `git` for versioning

It causes lots of pain when building with `nix` for a local `flake.nix`.

Not sure if it's a good general solution:
* now it requires a manual step of updating `./version` on each release.
* we're losing commit hash abbreviation, like `0.5.3-5-gbe5a291`

I guess, this can be fixed by installing git-pre-commit-hook, but even
then it'd be wrong, because the output of `git describe --tags --always`
won't be the same before and after commit.

* feat: include `hp_agent` to the NixOS module

* fix: version error message

* fix: use relative path imports in `nix`

* fix: NixOS module: generate `/etc/headplane/config.yaml` from `services.headplane.settings`

* fix: NixOS module: allow passing `bool` in `services.headplane.settings.*`

* fix: NixOS module: fix `/etc/headplane/config.yaml` generation

* docs: add Nix/NixOS docs

* feat: nix: read version from `package.json`

* fix: nix: fix `agent` env vars configuration

* feat: nix: add `services.headplane.agent.debug` option

* fix: delete unnecessary `version` file

* fix: nix: remove unnecessary `sed` substitutions

A left over from previous versions.
See: https://github.com/tale/headplane/issues/95#issue-2807487849

* feat: nix: do not hardcode `headplane-agent` configuration environment variables

To make the module more flexible and to reduce the dependency on the
config API.

* docs: improve `Nix` documentation

* Reflect recent changes.
* Link `Nix` in README

* feat: nix: setup Nix CI

* feat: nix: CI without depending on flakehub.com

* chore: begin bundling all deps into the server

* fix: loosen headscale config validation

* fix: navigate back to machines page on node deletion

* fix: slice off trailing slash if present on headscale url

* feat: switch to a central singleton handler

This also adds support for Headscale TLS installations

* fix: shamefully-hoist dependencies

* fix: handle localized number fields

* fix: revert dependency bundling for now

* chore: cleanup and remove from readme for now

---------

Co-authored-by: Aarnav Tale <aarnavtale@icloud.com>
2025-03-18 00:31:37 -04:00
Aarnav Tale 483f86c457 fix: revert dependency bundling for now 2025-03-18 00:24:25 -04:00
Aarnav Tale a8a4d2a6f8 fix: handle localized number fields 2025-03-17 22:45:31 -04:00
Aarnav Tale eef5bd0648 fix: shamefully-hoist dependencies 2025-03-17 22:25:41 -04:00
Aarnav Tale 6108de52e7 feat: switch to a central singleton handler
This also adds support for Headscale TLS installations
2025-03-17 22:21:16 -04:00
Aarnav Tale 43e06987ad fix: slice off trailing slash if present on headscale url 2025-03-17 16:47:27 -04:00
Aarnav Tale 7741ab88bf fix: navigate back to machines page on node deletion 2025-03-17 10:46:31 -04:00
Aarnav Tale 3bdbfdc033 fix: loosen headscale config validation 2025-03-17 10:44:13 -04:00
Aarnav Tale e701e16112 chore: begin bundling all deps into the server 2025-03-17 10:39:38 -04:00
Aarnav Tale 79d83537d5 chore: track semver in package.json 2025-03-11 22:44:23 -04:00
Aarnav Tale 251c16ca48 fix: handle ws upgrade smoothly 2025-03-11 22:39:49 -04:00
Aarnav Tale 134b38ceda chore: update agent var scheme 2025-03-11 22:39:05 -04:00
Aarnav Tale b8fb1fa40f chore: switch to x-headplane-tailnet-id 2025-03-11 22:35:52 -04:00
Aarnav Tale 951a96fad6 fix: set id ref to none to fetch agent data on first change 2025-03-11 22:35:02 -04:00
Aarnav Tale 5b9dc7cfc6 feat: ingest data from new agent system 2025-03-11 22:34:30 -04:00
Aarnav Tale e36352b7f3 fix: define globals for context 2025-03-11 21:59:26 -04:00
Aarnav Tale 296e4d489c fix: set a default for global dns nameservers 2025-03-11 21:57:40 -04:00
Aarnav Tale c3ddac42a0 fix: memoize agent query params to prevent infinite refetching 2025-03-11 18:16:47 -04:00
Aarnav Tale 01f432cedc feat: support oidc client_secret_path with env interpolation 2025-03-11 18:15:56 -04:00
Aarnav Tale 9c8a2c0120 feat: support oidc picture if available 2025-03-11 15:35:11 -04:00
Aarnav Tale 5f6460f42f fix: use the userinfo endpoint for oidc 2025-03-11 15:29:45 -04:00
Aarnav Tale d9314887fb feat: keep track of agent ids 2025-03-11 15:29:45 -04:00
Aarnav Tale 5dd4c41291 feat: use a new ws implementation thats encapsulated 2025-03-11 15:29:45 -04:00
bitbronze 45537620a6 fix: stop checking for mkey prefix on registration (#131)
String "mkey:" was being appended to the node registration http request causing it to fail. Removed mkey presence check and changed default hint. Form validation is based on length instead.
2025-03-11 15:24:31 -04:00
Gage Orsburn 21af5c4a4d fix: handle missing split dns config (#129)
Closes #127
2025-03-11 15:12:49 -04:00
Steve Wills 983356611e docs: fix mistake in env docs (#119) 2025-03-03 14:32:05 -05:00
Aarnav Tale 41ca4c40ad chore: v0.5.3 2025-03-01 10:37:18 -05:00
Aarnav Tale f7c9a14e92 chore: temporarily halt full form validation 2025-03-01 10:36:58 -05:00
Aarnav Tale 6e514a9064 fix: handle oidc scope types correctly 2025-03-01 10:36:58 -05:00
Aarnav Tale 631ea15895 docs: warn on breaking change 2025-03-01 10:36:58 -05:00
nerdlich 762bc6a793 fix: cosmetic docs/config fixes (#112)
* fix config path

* fix typo
2025-03-01 10:30:15 -05:00
Aarnav Tale 6c83e96341 chore: v0.5.2 2025-02-28 13:07:14 -05:00
Aarnav Tale 03e6a26b3a fix: don't reload the prod bundle on refresh 2025-02-28 13:06:38 -05:00
Aarnav Tale 5b716ab5ce chore: update docs to reflect 0.5.1 2025-02-28 12:22:20 -05:00
Aarnav Tale a7e4f3e4d2 chore: v0.5.1 2025-02-28 12:21:00 -05:00
Aarnav Tale 40ddb69bc9 fix: resolve latest tags being set 2025-02-28 12:19:14 -05:00
Aarnav Tale 6aa6e77611 chore: fix globalThis typings 2025-02-28 12:17:58 -05:00
Aarnav Tale c5c2f8a93b fix: use context for k8s config 2025-02-28 11:29:31 -05:00
Aarnav Tale acd042b4de fix: define globals correctly 2025-02-28 11:29:11 -05:00
Aarnav Tale a563d4c0b3 chore: v0.5.0 2025-02-27 21:52:42 -05:00
Aarnav Tale 7117060863 chore: stop publishing latest tag 2025-02-27 21:52:22 -05:00
Aarnav Tale 30a1153fa5 docs: cleanup integrated docker formatting 2025-02-27 15:33:06 -05:00
Aarnav Tale 870680b6c5 docs: redo docs 2025-02-27 15:31:16 -05:00
Aarnav Tale 25e6410c65 feat: reimplement integrations 2025-02-27 13:42:36 -05:00
Aarnav Tale f982217dd0 fix: use globals to avoid loader race conditions 2025-02-20 10:45:52 -05:00
Aarnav Tale f5436f5ee3 feat: respect context in server 2025-02-19 18:09:42 -05:00
Leo Wilkin 06049169a2 chore(docs): Fix broken link to Linux setup on Headscale docs! (#107)
* Update Headscale install link

* Update Native.md
2025-02-19 13:06:08 -05:00
Aarnav Tale 774fdb7be2 fix: use new headscale user api routes 2025-02-19 13:01:20 -05:00
Aarnav Tale e6580eed2c fix: no top level await 2025-02-19 13:00:21 -05:00
Aarnav Tale 56b660b30c chore: switch to 0.25.0 in dev 2025-02-19 10:37:10 -05:00
Aarnav Tale 85c31ca5bf feat: use new config env 2025-02-14 00:04:01 -05:00
Aarnav Tale 44d08169a9 fix: use ring instead of outline on components 2025-02-14 00:03:46 -05:00
Aarnav Tale 76d263b7e6 feat: switch to config file system 2025-02-13 12:35:12 -05:00
Aarnav Tale 5be3cb345e feat: switch to normal form actions for dns 2025-02-13 12:29:16 -05:00
Aarnav Tale 2a1c795d46 feat: update to node 22, pnpm 10 2025-02-04 17:25:39 -05:00
Aarnav Tale 316e5b501f fix: pin to pnpm 9 2025-02-04 17:21:57 -05:00
Aarnav Tale 287ac2dff0 feat: unify all colors 2025-02-04 17:21:03 -05:00
Aarnav Tale d1f6c450c0 feat: remove react-aria-components 2025-02-04 11:42:57 -05:00
Aarnav Tale 0a14533756 fix: copyable code cannot be a button 2025-02-04 11:39:17 -05:00
Aarnav Tale 347c6698ee chore: fix aria label warnings 2025-02-04 11:27:29 -05:00
Aarnav Tale 771b87ae41 chore: only invoke useLiveData in root 2025-02-02 00:54:42 -05:00
Aarnav Tale 31ac25d510 feat: redesign code and attribute 2025-02-01 18:11:33 -05:00
Aarnav Tale 68babd7fe9 feat: redesign tooltips 2025-02-01 15:28:04 -05:00
Aarnav Tale c9d8052e39 chore: remove cn non-default exports 2025-01-28 17:46:16 -05:00
Aarnav Tale 843cfc4d4f chore: fix build issues 2025-01-28 16:18:51 -05:00
Aarnav Tale 6922548317 chore: switch to headscale 24 in dev 2025-01-28 16:07:44 -05:00
Aarnav Tale 07cee17501 style: temporarily disable some naggy lints 2025-01-28 16:07:15 -05:00
Aarnav Tale 2c8880c84d feat: switch to new toast provider 2025-01-28 16:06:41 -05:00
Aarnav Tale a19eb6bcda feat: add new menu 2025-01-28 16:04:42 -05:00
Aarnav Tale 28e40eecbf fix: switch to new aria components 2025-01-28 16:02:47 -05:00
Aarnav Tale 741f9aa6b5 feat: switch to new dialog across all code 2025-01-26 15:04:13 -05:00
Aarnav Tale 0f75636342 feat: add react-scan in dev 2025-01-25 12:51:32 -05:00
Aarnav Tale 665509e710 chore: extract tags to shared chip component 2025-01-23 11:28:42 -05:00
Aarnav Tale ac937f9014 fix: dedupe machine tags 2025-01-23 11:16:56 -05:00
Aarnav Tale 8d3f31e7f9 chore: add lefthook for lint staged 2025-01-20 08:26:00 +00:00
Aarnav Tale 2d47f1b952 chore: switch tailwind fonts 2025-01-20 08:15:14 +00:00
Aarnav Tale c24cd34925 feat: switch to new dialog gutter 2025-01-20 08:14:42 +00:00
Aarnav Tale af248df648 feat: redesign buttons and share them in dialogs 2025-01-19 17:31:46 +00:00
Aarnav Tale b9a708b2e9 chore: update deps and add react-aria 2025-01-19 15:24:14 +00:00
Aarnav Tale ec36876b9f feat: begin redesign and component unification 2025-01-19 15:24:03 +00:00
Aarnav Tale ed0cdbdf4d chore: add lucide-react icons 2025-01-18 18:42:57 +00:00
Aarnav Tale fd506ba474 feat: redesign header and footer 2025-01-18 18:42:49 +00:00
Aarnav Tale 9734f0c704 chore: remove old username in dns option 2025-01-18 13:22:43 +00:00
195 changed files with 10870 additions and 7740 deletions
+1
View File
@@ -0,0 +1 @@
use_flake
+2
View File
@@ -0,0 +1,2 @@
github: tale
ko_fi: atale
+31
View File
@@ -0,0 +1,31 @@
name: Bug Report
description: Report an issue with Headplane
assignees: [tale]
labels: [bug, triage]
body:
- type: textarea
attributes:
label: Description
description: |
A detailed description of the issue and steps to reproduce it.
If applicable, include any error messages or screenshots.
If this is not an issue with Headplane, but an issue with your
environment, please consider opening a discussion instead.
placeholder: e.g. "When I try to upload a file, I get an error message."
validations:
required: true
- type: input
attributes:
label: Headplane Version
description: What version of Headplane are you using?
placeholder: e.g. "v0.5.5"
validations:
required: true
- type: input
attributes:
label: Headscale Version
description: What version of Headscale are you using?
placeholder: e.g. "v0.25.1"
validations:
required: true
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
@@ -0,0 +1,15 @@
name: Feature Request
description: Request a new feature or enhancement for Headplane
assignees: [tale]
labels: [enhancement, triage]
body:
- type: textarea
attributes:
label: Description
description: |
A detailed description of the feature you would like to see added.
Please include any relevant context, such as why this feature is
important and how it would benefit other users beyond yourself.
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
validations:
required: true
+33
View File
@@ -0,0 +1,33 @@
name: Automated
on:
schedule:
- cron: "0 8 * * 0"
workflow_dispatch:
concurrency:
group: automation-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
pull-requests: write # Allow creating pull requests
jobs:
flake-inputs:
name: flake-inputs
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: DeterminateSystems/update-flake-lock@main
with:
pr-title: "chore: update flake.lock"
pr-labels: |
automated
@@ -1,13 +1,21 @@
name: 'Build'
name: Build
on:
push:
branches:
- 'main'
- "main"
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
jobs:
build:
name: Build
native:
name: native
runs-on: ubuntu-latest
steps:
- name: Check out the repo
@@ -16,12 +24,12 @@ jobs:
- name: Install node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 9
version: 10
run_install: false
- name: Get pnpm store directory
@@ -42,3 +50,21 @@ jobs:
- name: Build
run: pnpm build
nix:
name: nix
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- name: Check flake inputs
uses: DeterminateSystems/flake-checker-action@main
- name: Check flake outputs
run: nix flake check --all-systems
+12 -3
View File
@@ -1,12 +1,21 @@
name: Publish Nightly Docker Image
name: Nightly
on:
schedule:
- cron: '0 8 * * *'
- cron: "0 8 * * *"
workflow_dispatch:
concurrency:
group: publish-nightly-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
packages: write # Write access to the container registry
jobs:
publish:
name: Build and Publish Nightly
name: docker-publish
runs-on: ubuntu-latest
steps:
- name: Check out the repo
@@ -1,12 +1,21 @@
name: Publish Docker Image
name: Release
on:
push:
tags:
- '*'
- "*"
concurrency:
group: publish-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
packages: write # Write access to the container registry
jobs:
publish:
name: Build and Publish
name: docker-publish
runs-on: ubuntu-latest
steps:
- name: Check out the repo
@@ -30,6 +39,10 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=false
- name: Build and push Docker image
uses: docker/build-push-action@v5
+2
View File
@@ -0,0 +1,2 @@
side-effects-cache = false
public-hoist-pattern[]=*hono*
+17
View File
@@ -0,0 +1,17 @@
{
"formatter": {
"language_server": {
"name": "biome"
}
},
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true
},
"languages": {
"YAML": {
"tab_size": 2,
"hard_tabs": false
}
}
}
+68
View File
@@ -1,3 +1,71 @@
### 0.5.7 (April 2, 2025)
- Hotfix an issue where assets aren't served under `/admin` or the prefix.
### 0.5.6 (April 2, 2025)
### IMPORTANT
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously *ANY* accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
Implemented *proper* authentication methods for OIDC.
This is a large update and copies the permission system from Tailscale.
Permissions are not automatically derived from OIDC, but they can be configured via the UI.
Additionally, certain roles give certain capabilities, limiting access to parts of the dashboard.
By default, new users will have a `member` role which forbids access to the UI.
If there are no users, the first user will be given an `owner` role which cannot be removed.
**Changes**:
- Switched the internal server to use `hono` for better performance.
- Fixed an issue that caused dialogs to randomly refocus every 3 seconds.
- Headplane will not send API requests when the tab is not focused.
- Continue loosening the configuration requirements for Headscale (part of an ongoing effort).
- Unknown values in the Headplane config no longer cause a crash.
- Fixed an issue that caused copied commands to have a random space (fixes [#161](https://github.com/tale/headplane/issues/161))
### 0.5.5 (March 18, 2025)
- Hotfix an issue that caused Headplane to crash if no agents are available
### 0.5.4 (March 18, 2025)
- Fixed a typo in the Kubernetes documentation
- Handle split and global DNS records not being set in the Headscale config (via [#129](https://github.com/tale/headplane/pull/129))
- Stop checking for the `mkey:` prefix on machine registration (via [#131](https://github.com/tale/headplane/pull/131))
- OIDC auth was not using information from the `user_info` endpoint.
- Support the picture of the user who is logged in via OIDC if available.
- Rewrote the Agent implementation to better utilize disk space and perform better (coming soon).
- Loosened checking for the Headscale configuration as it was too strict and required certain optional fields.
- Deleting a node will now correctly redirect back to the nodes page (fixes [#137](https://github.com/tale/headplane/issues/137))
- Supports connecting to Headscale via TLS and can accept a certificate file (partially fixes [#82](https://github.com/tale/headplane/issues/82))
- Add support for running Headplane through Nix, though currently unsupported (via [#132](https://github.com/tale/headplane/pull/132))
- You can now pass in an OIDC client secret through `oidc.client_secret_path` in the config (fixes [#126](https://github.com/tale/headplane/issues/126))
- Correctly handle differently localized number inputs (fixes [#125](https://github.com/tale/headplane/issues/125))
### 0.5.3 (March 1, 2025)
- Fixed an issue where Headplane expected the incorrect config value for OIDC scope (fixes [#111](https://github.com/tale/headplane/issues/111))
- Added an ARIA indicator for when an input is required and fixed the confirm buttons (fixed [#116](https://github.com/tale/headplane/issues/116))
- Fixed a typo in the docs that defaulted to `/var/run/docker.dock` for the Docker socket (via [#112](https://github.com/tale/headplane/pull/112))
### 0.5.2 (February 28, 2025)
- Hotfixed an issue where the server bundle got reloaded on each request
### 0.5.1 (February 28, 2025)
- Fixed an issue that caused the entire server to crash on start
- Fixed the published semver tags from Docker
- Fixed the Kubernetes integration not reading the config
### 0.5 (February 27, 2025)
> This release is a major overhaul and contains a significant breaking change.
> We now use a config file for all settings instead of environment variables.
> Please see [config.example.yaml](/config.example.yaml) for the new format.
- Completely redesigned the UI from the ground up for accessibility and performance.
- Switched to a config-file setup (this introduces breaking changes, see [config.example.yaml](/config.example.yaml) for the new format).
- If the config is read-only, the options are still visible, just disabled (fixes [#48](https://github.com/tale/headplane/issues/48))
- Added support for Headscale 0.25.0 (this drops support for any older versions).
- Fixed issues where renaming, deleting, and changing node owners via users was not possible (fixes [#91](https://github.com/tale/headplane/issues/91))
- Operations now have significantly less moving parts and better error handling.
- Updated to `pnpm` 10 and Node.js 22.
- Settings that were previously shared like `public_url` or `oidc` are now separate within Headplane/Headscale. This is a rather large breaking change but fixes cases where a user may choose to utilize Headscale OIDC for Tailscale but not for the Headplane UI.
- Deprecate the `latest` tag in Docker for explicit versioning and `edge` for nightly builds.
### 0.4.1 (January 18, 2025)
- Fixed an urgent issue where the OIDC redirect URI would mismatch.
+4 -11
View File
@@ -1,7 +1,7 @@
FROM node:20-alpine AS build
FROM node:22-alpine AS build
WORKDIR /app
RUN npm install -g pnpm
RUN npm install -g pnpm@10
RUN apk add --no-cache git
COPY package.json pnpm-lock.yaml ./
COPY patches ./patches
@@ -9,15 +9,8 @@ RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
RUN pnpm prune --prod
FROM node:20-alpine
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/build /app/build
COPY --from=build /app/node_modules /app/node_modules
RUN echo '{"type":"module"}' > /app/package.json
EXPOSE 3000
ENV NODE_ENV=production
ENV HOST=0.0.0.0
CMD [ "node", "./build/headplane/server.js" ]
CMD [ "node", "./build/server/index.js" ]
+56 -19
View File
@@ -1,5 +1,5 @@
# Headplane
> An advanced UI for [juanfont/headscale](https://github.com/juanfont/headscale)
> A feature-complete web UI for [Headscale](https://headscale.net)
<picture>
<source
@@ -16,30 +16,67 @@
>
</picture>
Headscale is a self-hosted version of the Tailscale control server, however, it currently lacks a first-party web UI.
Headplane aims to solve this issue by providing a GUI that can deeply integrate with the Headscale server.
It's able to replicate nearly all of the functions of the official Tailscale SaaS UI, including:
Headscale is the de-facto self-hosted version of Tailscale, a popular Wireguard
based VPN service. By default, it does not ship with a web UI, which is where
Headplane comes in. Headplane is a feature-complete web UI for Headscale, allowing
you to manage your nodes, networks, and ACLs with ease.
- Machine/Node expiry, network routing, name, and owner management
- Access Control List (ACL) and tagging configuration
Headplane aims to replicate the functionality offered by the official Tailscale
product and dashboard, being one of the most feature complete Headscale UIs available.
These are some of the features that Headplane offers:
- Machine management, including expiry, network routing, name, and owner management
- Access Control List (ACL) and tagging configuration for ACL enforcement
- Support for OpenID Connect (OIDC) as a login provider
- DNS and *safe* Headscale configuration management
- The ability to edit DNS settings and automatically provision Headscale
- Configurability for Headscale's settings
## Deployment
> For more configuration options, refer to the [Configuration](/docs/Configuration.md) guide.
Headplane runs as a server-based web-application, meaning you'll need a server to run it.
It's available as a Docker image (recommended) or through a manual installation.
There are 2 ways to deploy Headplane:
For fully-featured deployments, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
This includes automatic management of ACLs, DNS settings, and Headscale configuration.
*This is the closest experience to the Tailscale UI that can be achieved with Headscale and Headplane.*
*If you aren't sure which one to pick, we recommend this.*
- ### [Integrated Mode (Recommended)](/docs/Integrated-Mode.md)
Integrated mode unlocks all the features of Headplane and is the most
feature-complete deployment method. It communicates with Headscale directly.
If your environment is not able to support the advanced deployment, you can still use the basic deployment.
For basic deployments, see the [Basic Deployment](/docs/Basic-Integration.md) guide.
It does not include automatic management of ACLs, DNS settings, or the Headscale configuration,
instead requiring manual editing and reloading when making changes.
- ### [Simple Mode](/docs/Simple-Mode.md)
Simple mode does not include the automatic management of DNS and Headplane
settings, requiring manual editing and reloading when making changes.
## Contributing
If you would like to contribute, please install a relatively modern version of Node.js and PNPM.
Clone this repository, run `pnpm install`, and then run `pnpm dev` to start the development server.
Headplane is an open-source project and contributions are welcome! If you have
any suggestions, bug reports, or feature requests, please open an issue. Also
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
> Copyright (c) 2024 Aarnav Tale
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./assets/acls-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./assets/acls-light.png"
>
<img
alt="ACLs"
src="./assets/acls-dark.png"
>
</picture>
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./assets/machine-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./assets/machine-light.png"
>
<img
alt="Machine Management"
src="./assets/machine-dark.png"
>
</picture>
> Copyright (c) 2025 Aarnav Tale
+8 -7
View File
@@ -1,8 +1,9 @@
package config
import (
_ "github.com/joho/godotenv/autoload"
"os"
_ "github.com/joho/godotenv/autoload"
)
// Config represents the configuration for the agent.
@@ -16,12 +17,12 @@ type Config struct {
}
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"
DebugEnv = "HEADPLANE_AGENT_DEBUG"
HostnameEnv = "HEADPLANE_AGENT_HOSTNAME"
TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER"
TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY"
HPControlURLEnv = "HEADPLANE_AGENT_HP_SERVER"
HPAuthKeyEnv = "HEADPLANE_AGENT_HP_AUTHKEY"
)
// Load reads the agent configuration from environment variables.
+10 -3
View File
@@ -2,11 +2,12 @@ package hpagent
import (
"fmt"
"github.com/gorilla/websocket"
"github.com/tale/headplane/agent/tsnet"
"log"
"net/http"
"net/url"
"github.com/gorilla/websocket"
"github.com/tale/headplane/agent/tsnet"
)
type Socket struct {
@@ -23,7 +24,7 @@ func NewSocket(agent *tsnet.TSAgent, controlURL, authKey string, debug bool) (*S
}
headers := http.Header{}
headers.Add("X-Headplane-TS-Node-ID", agent.ID)
headers.Add("X-Headplane-Tailnet-ID", agent.ID)
auth := fmt.Sprintf("Bearer %s", authKey)
headers.Add("Authorization", auth)
@@ -52,5 +53,11 @@ func httpToWs(controlURL string) (string, error) {
return "", fmt.Errorf("unsupported scheme: %s", u.Scheme)
}
// We also need to append /_dial to the path
if u.Path[len(u.Path)-1] != '/' {
u.Path += "/"
}
u.Path += "_dial"
return u.String(), nil
}
+47 -22
View File
@@ -1,18 +1,23 @@
import { CopyIcon } from '@primer/octicons-react';
import { toast } from './Toaster';
import { Check, Copy } from 'lucide-react';
import cn from '~/utils/cn';
import toast from '~/utils/toast';
interface Props {
export interface AttributeProps {
name: string;
value: string;
isCopyable?: boolean;
link?: string;
}
export default function Attribute({ name, value, link, isCopyable }: Props) {
const canCopy = isCopyable ?? false;
export default function Attribute({
name,
value,
link,
isCopyable,
}: AttributeProps) {
return (
<dl className="flex gap-1 text-sm w-full">
<dt className="w-1/2 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1">
<dl className="flex items-center w-full gap-x-1">
<dt className="font-semibold w-1/4 shrink-0 text-sm">
{link ? (
<a className="hover:underline" href={link}>
{name}
@@ -21,22 +26,42 @@ export default function Attribute({ name, value, link, isCopyable }: Props) {
name
)}
</dt>
<dd
className={cn(
'rounded-lg truncate w-full px-2.5 py-1 text-sm',
'flex items-center gap-x-1',
'focus-within:outline-none focus-within:ring-2',
isCopyable && 'hover:bg-headplane-100 dark:hover:bg-headplane-800',
)}
>
{isCopyable ? (
<button
type="button"
className="w-full flex items-center gap-x-1 outline-none"
onClick={async (event) => {
const svgs = event.currentTarget.querySelectorAll('svg');
for (const svg of svgs) {
svg.toggleAttribute('data-copied', true);
}
{canCopy ? (
<button
type="button"
className="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md"
onClick={async () => {
await navigator.clipboard.writeText(value);
toast(`Copied ${name}`);
}}
>
<dd className="min-w-0 truncate px-2 py-1">{value}</dd>
<CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" />
</button>
) : (
<dd className="min-w-0 truncate px-2 py-1">{value}</dd>
)}
await navigator.clipboard.writeText(value);
toast('Copied to clipboard');
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute('data-copied', false);
}
}, 1000);
}}
>
<p className="truncate">{value}</p>
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
</button>
) : (
value
)}
</dd>
</dl>
);
}
+35 -30
View File
@@ -1,38 +1,43 @@
import type { Dispatch, SetStateAction } from 'react';
import { Button as AriaButton } from 'react-aria-components';
import { cn } from '~/utils/cn';
import React, { useRef } from 'react';
import { type AriaButtonOptions, useButton } from 'react-aria';
import cn from '~/utils/cn';
type Props = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
readonly variant?: 'heavy' | 'light';
};
export interface ButtonProps extends AriaButtonOptions<'button'> {
variant?: 'heavy' | 'light' | 'danger';
className?: string;
children?: React.ReactNode;
ref?: React.RefObject<HTMLButtonElement | null>;
}
export default function Button({ variant = 'light', ...props }: ButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
export default function Button(props: Props) {
return (
<AriaButton
{...props}
<button
ref={ref}
{...buttonProps}
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
props.variant === 'heavy'
? 'bg-main-700 dark:bg-main-800'
: 'bg-main-200 dark:bg-main-700/30',
props.variant === 'heavy'
? 'hover:bg-main-800 dark:hover:bg-main-700'
: 'hover:bg-main-300 dark:hover:bg-main-600/30',
props.variant === 'heavy'
? 'text-white'
: 'text-ui-700 dark:text-ui-300',
props.isDisabled && 'opacity-50 cursor-not-allowed',
'w-fit text-sm rounded-xl px-3 py-2',
'focus:outline-none focus:ring',
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
'text-headplane-200 dark:text-headplane-800',
]
: variant === 'danger'
? ['bg-red-500 text-white font-semibold', 'hover:bg-red-500/90']
: [
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
]),
props.className,
)}
// If control is passed, set the state value
onPress={
props.control
? () => {
props.control?.[1](true);
}
: props.onPress
}
/>
>
{props.children}
</button>
);
}
+11 -26
View File
@@ -1,37 +1,22 @@
import type { HTMLProps } from 'react';
import { Heading as AriaHeading } from 'react-aria-components';
import { cn } from '~/utils/cn';
import React from 'react';
import Text from '~/components/Text';
import Title from '~/components/Title';
import cn from '~/utils/cn';
function Title(props: Parameters<typeof AriaHeading>[0]) {
return (
<AriaHeading
{...props}
slot="title"
className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
/>
);
}
function Text(props: React.HTMLProps<HTMLParagraphElement>) {
return (
<p {...props} className={cn('text-base leading-6 my-0', props.className)} />
);
}
type Props = HTMLProps<HTMLDivElement> & {
interface Props extends React.HTMLProps<HTMLDivElement> {
variant?: 'raised' | 'flat';
};
}
function Card(props: Props) {
function Card({ variant = 'raised', ...props }: Props) {
return (
<div
{...props}
className={cn(
'w-full max-w-md overflow-hidden rounded-xl p-4',
props.variant === 'flat'
'w-full max-w-md rounded-3xl p-5',
variant === 'flat'
? 'bg-transparent shadow-none'
: 'bg-ui-50 dark:bg-ui-900 shadow-sm',
'border border-ui-200 dark:border-ui-700',
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-sm',
'border border-headplane-100 dark:border-headplane-800',
props.className,
)}
>
+32
View File
@@ -0,0 +1,32 @@
import React from 'react';
import cn from '~/utils/cn';
export interface ChipProps {
text: string;
className?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
}
export default function Chip({
text,
className,
leftIcon,
rightIcon,
}: ChipProps) {
return (
<span
className={cn(
'text-xs px-2 py-0.5 rounded-full',
'text-headplane-700 dark:text-headplane-100',
'bg-headplane-100 dark:bg-headplane-700',
leftIcon || rightIcon ? 'inline-flex items-center gap-x-1' : '',
className,
)}
>
{leftIcon}
{text}
{rightIcon}
</span>
);
}
+36 -37
View File
@@ -1,51 +1,50 @@
import { useState, HTMLProps } from 'react';
import { CopyIcon, CheckIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
import { toast } from '~/components/Toaster';
import { Check, Copy } from 'lucide-react';
import { HTMLProps } from 'react';
import cn from '~/utils/cn';
import toast from '~/utils/toast';
interface Props extends HTMLProps<HTMLSpanElement> {
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean;
children: string | string[];
}
export default function Code(props: Props) {
const [isCopied, setIsCopied] = useState(false);
export default function Code({ isCopyable, children, className }: CodeProps) {
return (
<>
<code
className={cn(
'bg-ui-100 dark:bg-ui-800 p-0.5 rounded-md',
props.className,
)}
>
{props.children}
</code>
{props.isCopyable ? (
<code
className={cn(
'bg-headplane-100 dark:bg-headplane-800 px-1 py-0.5 font-mono',
'rounded-lg focus-within:outline-none focus-within:ring-2',
isCopyable && 'relative pr-7',
className,
)}
>
{children}
{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');
type="button"
className="bottom-0 right-0 absolute"
onClick={async (event) => {
const text = Array.isArray(children) ? children.join('') : children;
const svgs = event.currentTarget.querySelectorAll('svg');
for (const svg of svgs) {
svg.toggleAttribute('data-copied', true);
}
navigator.clipboard.writeText(props.children.join(''));
await navigator.clipboard.writeText(text);
toast('Copied to clipboard');
setIsCopied(true);
setTimeout(() => setIsCopied(false), 1000);
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute('data-copied', false);
}
}, 1000);
}}
>
{isCopied ? (
<CheckIcon className="h-3 w-3" />
) : (
<CopyIcon className="h-3 w-3" />
)}
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
</button>
) : undefined}
</>
)}
</code>
);
}
+183 -121
View File
@@ -1,132 +1,194 @@
import type { Dispatch, ReactNode, SetStateAction } from 'react';
import React, { cloneElement, useEffect, useRef } from 'react';
import {
Button as AriaButton,
Dialog as AriaDialog,
DialogTrigger,
Heading as AriaHeading,
Modal,
ModalOverlay,
} from 'react-aria-components';
import { cn } from '~/utils/cn';
type AriaDialogProps,
type AriaModalOverlayProps,
Overlay,
useDialog,
useModalOverlay,
useOverlayTrigger,
} from 'react-aria';
import { Form, type HTMLFormMethod } from 'react-router';
import {
type OverlayTriggerProps,
type OverlayTriggerState,
useOverlayTriggerState,
} from 'react-stately';
import Button, { ButtonProps } from '~/components/Button';
import Card from '~/components/Card';
import IconButton, { IconButtonProps } from '~/components/IconButton';
import Text from '~/components/Text';
import Title from '~/components/Title';
import cn from '~/utils/cn';
import { useLiveData } from '~/utils/live-data';
type ButtonProps = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
};
export interface DialogProps extends OverlayTriggerProps {
children:
| [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<DialogPanelProps>,
]
| React.ReactElement<DialogPanelProps>;
}
function Button(props: ButtonProps) {
return (
<AriaButton
{...props}
aria-label="Dialog"
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
'hover:bg-main-800 dark:hover:bg-main-700',
props.isDisabled && 'opacity-50 cursor-not-allowed',
props.className,
)}
// If control is passed, set the state value
onPress={
props.control
? () => {
props.control?.[1](true);
}
: undefined
}
/>
function Dialog(props: DialogProps) {
const { pause, resume } = useLiveData();
const state = useOverlayTriggerState(props);
const { triggerProps, overlayProps } = useOverlayTrigger(
{
type: 'dialog',
},
state,
);
}
type ActionProps = Parameters<typeof AriaButton>[0] & {
readonly variant: 'cancel' | 'confirm';
};
useEffect(() => {
if (state.isOpen) {
pause();
} else {
resume();
}
}, [state.isOpen]);
function Action(props: ActionProps) {
return (
<AriaButton
{...props}
type={props.variant === 'confirm' ? 'submit' : 'button'}
className={cn(
'px-4 py-2 rounded-lg',
props.isDisabled && 'opacity-50 cursor-not-allowed',
props.variant === 'cancel'
? 'text-ui-700 dark:text-ui-300'
: 'text-ui-300 dark:text-ui-300',
props.variant === 'confirm'
? 'bg-main-700 dark:bg-main-700 pressed:bg-main-800 dark:pressed:bg-main-800'
: 'bg-ui-200 dark:bg-ui-800 pressed:bg-ui-300 dark:pressed:bg-ui-700',
props.className,
)}
/>
);
}
function Title(props: Parameters<typeof AriaHeading>[0]) {
return (
<AriaHeading
{...props}
slot="title"
className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
/>
);
}
function Text(props: React.HTMLProps<HTMLParagraphElement>) {
return (
<p {...props} className={cn('text-base leading-6 my-0', props.className)} />
);
}
interface PanelProps {
children: (close: () => void) => ReactNode;
control?: [boolean, Dispatch<SetStateAction<boolean>>];
className?: string;
}
function Panel({ children, control, className }: PanelProps) {
return (
<ModalOverlay
aria-hidden="true"
className={cn(
'fixed inset-0 h-screen w-screen z-50 bg-black/30',
'flex items-center justify-center dark:bg-black/70',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:duration-200 entering:ease-out',
'exiting:fade-out exiting:duration-100 exiting:ease-in',
className,
)}
isOpen={control ? control[0] : undefined}
onOpenChange={control ? control[1] : undefined}
>
<Modal
className={cn(
'w-full max-w-md overflow-hidden rounded-xl p-4',
'bg-ui-50 dark:bg-ui-900 shadow-lg',
'entering:animate-in exiting:animate-out',
'dark:border dark:border-ui-700',
'entering:zoom-in-95 entering:ease-out entering:duration-200',
'exiting:zoom-out-95 exiting:ease-in exiting:duration-100',
if (Array.isArray(props.children)) {
const [button, panel] = props.children;
return (
<>
{cloneElement(button, triggerProps)}
{state.isOpen && (
<DModal state={state}>
{cloneElement(panel, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
)}
>
<AriaDialog role="alertdialog" className="outline-none relative">
{({ close }) => children(close)}
</AriaDialog>
</Modal>
</ModalOverlay>
);
}
interface DialogProps {
children: ReactNode;
control?: [boolean, Dispatch<SetStateAction<boolean>>];
}
function Dialog({ children, control }: DialogProps) {
if (control) {
return children;
</>
);
}
return <DialogTrigger>{children}</DialogTrigger>;
return (
<DModal state={state}>
{cloneElement(props.children, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
);
}
export default Object.assign(Dialog, { Button, Title, Text, Panel, Action });
export interface DialogPanelProps extends AriaDialogProps {
children: React.ReactNode;
variant?: 'normal' | 'destructive' | 'unactionable';
onSubmit?: React.FormEventHandler<HTMLFormElement>;
method?: HTMLFormMethod;
isDisabled?: boolean;
// Anonymous (passed by parent)
close?: () => void;
}
function Panel(props: DialogPanelProps) {
const {
children,
onSubmit,
isDisabled,
close,
variant,
method = 'POST',
} = props;
const ref = useRef<HTMLFormElement | null>(null);
const { dialogProps } = useDialog(
{
...props,
role: 'alertdialog',
},
ref,
);
return (
<Form
{...dialogProps}
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
}
close?.();
}}
method={method ?? 'POST'}
ref={ref}
className={cn(
'outline-none rounded-3xl w-full max-w-lg',
'bg-white dark:bg-headplane-900',
)}
>
<Card className="w-full max-w-lg" variant="flat">
{children}
<div className="mt-6 flex justify-end gap-4">
{variant === 'unactionable' ? (
<Button onPress={close}>Close</Button>
) : (
<>
<Button onPress={close}>Cancel</Button>
<Button
type="submit"
variant={variant === 'destructive' ? 'danger' : 'heavy'}
isDisabled={isDisabled}
>
Confirm
</Button>
</>
)}
</div>
</Card>
</Form>
);
}
interface DModalProps extends AriaModalOverlayProps {
children: React.ReactNode;
state: OverlayTriggerState;
}
function DModal(props: DModalProps) {
const { children, state } = props;
const ref = useRef<HTMLDivElement>(null);
const { modalProps, underlayProps } = useModalOverlay(props, state, ref);
if (!state.isOpen) {
return null;
}
return (
<Overlay>
<div
{...underlayProps}
aria-hidden="true"
className={cn(
'fixed inset-0 h-screen w-screen z-20',
'flex items-center justify-center',
'bg-headplane-900/15 dark:bg-headplane-900/30',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:duration-100 entering:ease-out',
'exiting:fade-out exiting:duration-50 exiting:ease-in',
)}
/>
<div
{...modalProps}
className={cn(
'fixed inset-0 h-screen w-screen z-20',
'flex items-center justify-center',
)}
>
{children}
</div>
</Overlay>
);
}
export default Object.assign(Dialog, {
Button,
IconButton,
Panel,
Title,
Text,
});
+2 -2
View File
@@ -1,6 +1,6 @@
import { AlertIcon } from '@primer/octicons-react';
import { isRouteErrorResponse, useRouteError } from 'react-router';
import { cn } from '~/utils/cn';
import cn from '~/utils/cn';
import Card from './Card';
import Code from './Code';
@@ -10,7 +10,7 @@ interface Props {
function getMessage(error: Error | unknown) {
if (!(error instanceof Error)) {
return "An unknown error occurred";
return 'An unknown error occurred';
}
let rootError = error;
+7 -15
View File
@@ -1,10 +1,5 @@
import { cn } from '~/utils/cn';
import Link from '~/components/Link';
import Tooltip from '~/components/Tooltip';
declare global {
const __VERSION__: string;
}
import cn from '~/utils/cn';
interface FooterProps {
url: string;
@@ -16,9 +11,10 @@ export default function Footer({ url, debug }: FooterProps) {
<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',
'flex flex-col justify-center gap-1 shadow-inner',
'bg-headplane-100 dark:bg-headplane-950',
'text-headplane-800 dark:text-headplane-200',
'dark:border-t dark:border-headplane-800',
)}
>
<p className="container text-xs">
@@ -33,12 +29,8 @@ export default function Footer({ url, debug }: FooterProps) {
</p>
<p className="container text-xs opacity-75">
Version: {__VERSION__}
{' | '}
Connecting to
{' '}
<strong className="blur-xs hover:blur-none">
{url}
</strong>
{' '}
Connecting to <strong className="blur-xs hover:blur-none">{url}</strong>
{debug && ' (Debug mode enabled)'}
</p>
</footer>
+124 -98
View File
@@ -1,41 +1,68 @@
import {
GearIcon,
GlobeIcon,
LockIcon,
PaperAirplaneIcon,
PeopleIcon,
PersonIcon,
ServerIcon,
} from '@primer/octicons-react';
import { Form } from 'react-router';
import { cn } from '~/utils/cn';
import type { HeadplaneContext } from '~/utils/config/headplane';
import type { SessionData } from '~/utils/sessions.server';
import Menu from './Menu';
import TabLink from './TabLink';
CircleUser,
Globe2,
Lock,
PlaneTakeoff,
Server,
Settings,
Users,
} from 'lucide-react';
import type { ReactNode } from 'react';
import { NavLink, useSubmit } from 'react-router';
import Menu from '~/components/Menu';
import { AuthSession } from '~/server/web/sessions';
import cn from '~/utils/cn';
interface Props {
config: HeadplaneContext['config'];
user?: SessionData['user'];
configAvailable: boolean;
uiAccess: boolean;
onboarding: boolean;
user?: AuthSession['user'];
}
interface LinkProps {
href: string;
text: string;
isMenu?: boolean;
}
function Link({ href, text, isMenu }: LinkProps) {
interface TabLinkProps {
name: string;
to: string;
icon: ReactNode;
}
function TabLink({ name, to, icon }: TabLinkProps) {
return (
<div className="relative py-2">
<NavLink
to={to}
prefetch="intent"
className={({ isActive }) =>
cn(
'px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5',
'after:absolute after:bottom-0 after:left-3 after:right-3',
'after:h-0.5 after:bg-headplane-900 dark:after:bg-headplane-200',
'hover:bg-headplane-200 dark:hover:bg-headplane-900',
'focus:outline-none focus:ring',
isActive ? 'after:visible' : 'after:invisible',
)
}
>
{icon} {name}
</NavLink>
</div>
);
}
function Link({ href, text }: LinkProps) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className={cn(
!isMenu &&
'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block',
'hidden sm:block hover:underline text-sm',
'focus:outline-none focus:ring rounded-md',
)}
>
{text}
@@ -44,12 +71,21 @@ function Link({ href, text, isMenu }: LinkProps) {
}
export default function Header(data: Props) {
const submit = useSubmit();
return (
<header className="bg-main-700 dark:bg-main-800 text-ui-50">
<header
className={cn(
'bg-headplane-100 dark:bg-headplane-950',
'text-headplane-800 dark:text-headplane-200',
'dark:border-b dark:border-headplane-800',
'shadow-inner',
)}
>
<div className="container flex items-center justify-between py-4">
<div className="flex items-center gap-x-2">
<PaperAirplaneIcon className="w-6 h-6" />
<h1 className="text-2xl">Headplane</h1>
<PlaneTakeoff />
<h1 className="text-2xl font-semibold">headplane</h1>
</div>
<div className="flex items-center gap-x-4">
<Link href="https://tailscale.com/download" text="Download" />
@@ -57,85 +93,75 @@ export default function Header(data: Props) {
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
{data.user ? (
<Menu>
<Menu.Button
className={cn(
'rounded-full h-9 w-9',
'border border-main-600 dark:border-main-700',
'hover:bg-main-600 dark:hover:bg-main-700',
'flex items-center justify-center',
)}
<Menu.IconButton
label="User"
className={cn(data.user.picture ? 'p-0' : '')}
>
<PersonIcon className="h-5 w-5 mt-0.5" />
</Menu.Button>
<Menu.Items>
<Menu.Item className="text-right">
<p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://tailscale.com/download"
text="Download"
{data.user.picture ? (
<img
src={data.user.picture}
alt={data.user.name}
className="w-8 h-8 rounded-full"
/>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://github.com/tale/headplane"
text="GitHub"
/>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://github.com/juanfont/headscale"
text="Headscale"
/>
</Menu.Item>
<Menu.Item className="text-red-500 dark:text-red-400">
<Form method="POST" action="/logout">
<button type="submit" className="w-full text-right">
Logout
</button>
</Form>
</Menu.Item>
</Menu.Items>
) : (
<CircleUser />
)}
</Menu.IconButton>
<Menu.Panel
onAction={(key) => {
if (key === 'logout') {
submit(
{},
{
method: 'POST',
action: '/logout',
},
);
}
}}
disabledKeys={['profile']}
>
<Menu.Section>
<Menu.Item key="profile" textValue="Profile">
<div className="text-black dark:text-headplane-50">
<p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p>
</div>
</Menu.Item>
<Menu.Item key="logout" textValue="Logout">
<p className="text-red-500 dark:text-red-400">Logout</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
) : undefined}
</div>
</div>
<nav className="container flex items-center gap-x-4 overflow-x-auto">
<TabLink
to="/machines"
name="Machines"
icon={<ServerIcon className="w-4 h-4" />}
/>
<TabLink
to="/users"
name="Users"
icon={<PeopleIcon className="w-4 h-4" />}
/>
<TabLink
to="/acls"
name="Access Control"
icon={<LockIcon className="w-4 h-4" />}
/>
{data.config.read ? (
<>
<TabLink
to="/dns"
name="DNS"
icon={<GlobeIcon className="w-4 h-4" />}
/>
<TabLink
to="/settings"
name="Settings"
icon={<GearIcon className="w-4 h-4" />}
/>
</>
) : undefined}
</nav>
{data.uiAccess && !data.onboarding ? (
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
<TabLink
to="/machines"
name="Machines"
icon={<Server className="w-5" />}
/>
<TabLink to="/users" name="Users" icon={<Users className="w-5" />} />
<TabLink
to="/acls"
name="Access Control"
icon={<Lock className="w-5" />}
/>
{data.configAvailable ? (
<>
<TabLink to="/dns" name="DNS" icon={<Globe2 className="w-5" />} />
<TabLink
to="/settings"
name="Settings"
icon={<Settings className="w-5" />}
/>
</>
) : undefined}
</nav>
) : undefined}
</header>
);
}
+46
View File
@@ -0,0 +1,46 @@
import React, { useRef } from 'react';
import { type AriaButtonOptions, useButton } from 'react-aria';
import cn from '~/utils/cn';
export interface IconButtonProps extends AriaButtonOptions<'button'> {
variant?: 'heavy' | 'light';
className?: string;
children: React.ReactNode;
label: string;
ref?: React.RefObject<HTMLButtonElement | null>;
}
export default function IconButton({
variant = 'light',
...props
}: IconButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
return (
<button
ref={ref}
{...buttonProps}
aria-label={props.label}
className={cn(
'rounded-full flex items-center justify-center p-1',
'focus:outline-none focus:ring',
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
'text-headplane-200 dark:text-headplane-800',
]
: [
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
]),
props.className,
)}
>
{props.children}
</button>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { Asterisk } from 'lucide-react';
import { useRef } from 'react';
import { type AriaTextFieldProps, useId, useTextField } from 'react-aria';
import cn from '~/utils/cn';
export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
label: string;
labelHidden?: boolean;
isRequired?: boolean;
className?: string;
}
// TODO: Custom isInvalid logic for custom error messages
export default function Input(props: InputProps) {
const { label, labelHidden, className } = props;
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const {
labelProps,
inputProps,
descriptionProps,
errorMessageProps,
isInvalid,
validationErrors,
} = useTextField(
{
...props,
label,
'aria-label': label,
},
ref,
);
return (
<div className="flex flex-col w-full" aria-label={label}>
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
labelHidden && 'sr-only',
)}
>
{label}
{props.isRequired && (
<Asterisk className="inline w-3.5 text-red-500 pb-1 ml-0.5" />
)}
</label>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
className={cn(
'rounded-xl px-3 py-2',
'focus:outline-none focus:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
className,
)}
/>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{isInvalid && (
<div
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{validationErrors.join(' ')}
</div>
)}
</div>
);
}
+12 -6
View File
@@ -1,14 +1,19 @@
import { LinkExternalIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
import { ExternalLink } from 'lucide-react';
import cn from '~/utils/cn';
interface Props {
export interface LinkProps {
to: string;
name: string;
children: string;
className?: string;
}
export default function Link({ to, name: alt, children, className }: Props) {
export default function Link({
to,
name: alt,
children,
className,
}: LinkProps) {
return (
<a
href={to}
@@ -16,14 +21,15 @@ export default function Link({ to, name: alt, children, className }: Props) {
target="_blank"
rel="noreferrer"
className={cn(
'inline-flex items-center gap-x-1',
'inline-flex items-center gap-x-0.5',
'text-blue-500 hover:text-blue-700',
'dark:text-blue-400 dark:hover:text-blue-300',
'focus:outline-none focus:ring rounded-md',
className,
)}
>
{children}
<LinkExternalIcon className="h-3 w-3" />
<ExternalLink className="w-3.5" />
</a>
);
}
+140 -78
View File
@@ -1,91 +1,153 @@
import type { Dispatch, ReactNode, SetStateAction } from 'react';
import React, { useRef, cloneElement } from 'react';
import { type AriaMenuProps, Key, Placement, useMenuTrigger } from 'react-aria';
import { useMenu, useMenuItem, useMenuSection, useSeparator } from 'react-aria';
import { Item, Section } from 'react-stately';
import {
Button as AriaButton,
Menu as AriaMenu,
MenuItem,
MenuTrigger,
Popover,
} from 'react-aria-components';
import { cn } from '~/utils/cn';
type MenuTriggerProps,
Node,
TreeState,
useMenuTriggerState,
useTreeState,
} from 'react-stately';
import Button, { ButtonProps } from '~/components/Button';
import IconButton, { IconButtonProps } from '~/components/IconButton';
import Popover from '~/components/Popover';
import cn from '~/utils/cn';
function Button(props: Parameters<typeof AriaButton>[0]) {
interface MenuProps extends MenuTriggerProps {
placement?: Placement;
children: [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<MenuPanelProps>,
];
}
// TODO: onAction is called twice for some reason?
function Menu(props: MenuProps) {
const { placement = 'bottom' } = props;
const state = useMenuTriggerState(props);
const ref = useRef<HTMLButtonElement | null>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
{},
state,
ref,
);
// cloneElement is necessary because the button is a union type
// of multiple things and we need to join props from our hooks
const [button, panel] = props.children;
return (
<AriaButton
{...props}
className={cn('outline-none', props.className)}
aria-label="Menu"
/>
<div>
{cloneElement(button, {
...menuTriggerProps,
ref,
})}
{state.isOpen && (
<Popover state={state} triggerRef={ref} placement={placement}>
{cloneElement(panel, {
...menuProps,
autoFocus: state.focusStrategy ?? true,
onClose: () => state.close(),
})}
</Popover>
)}
</div>
);
}
function Items(props: Parameters<typeof AriaMenu>[0]) {
interface MenuPanelProps extends AriaMenuProps<object> {
onClose?: () => void;
}
function Panel(props: MenuPanelProps) {
const state = useTreeState(props);
const ref = useRef(null);
const { menuProps } = useMenu(props, state, ref);
return (
<Popover
<ul
{...menuProps}
ref={ref}
className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none"
>
{[...state.collection].map((item) => (
<MenuSection key={item.key} section={item} state={state} />
))}
</ul>
);
}
interface MenuSectionProps<T> {
section: Node<T>;
state: TreeState<T>;
}
function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
const { itemProps, groupProps } = useMenuSection({
heading: section.rendered,
'aria-label': section['aria-label'],
});
const { separatorProps } = useSeparator({
elementType: 'li',
});
return (
<>
{section.key !== state.collection.getFirstKey() ? (
<li
{...separatorProps}
className={cn(
'mx-2 mt-1 mb-1 border-t',
'border-headplane-200 dark:border-headplane-800',
)}
/>
) : undefined}
<li {...itemProps}>
<ul {...groupProps}>
{[...section.childNodes].map((item) => (
<MenuItem key={item.key} item={item} state={state} />
))}
</ul>
</li>
</>
);
}
interface MenuItemProps<T> {
item: Node<T>;
state: TreeState<T>;
}
function MenuItem<T>({ item, state }: MenuItemProps<T>) {
const ref = useRef<HTMLLIElement | null>(null);
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
const isFocused = state.selectionManager.focusedKey === item.key;
const isDisabled = state.selectionManager.isDisabled(item.key);
return (
<li
{...menuItemProps}
ref={ref}
className={cn(
'mt-2 rounded-md',
'bg-ui-50 dark:bg-ui-800',
'overflow-hidden z-50',
'border border-ui-200 dark:border-ui-600',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:zoom-in-95',
'exiting:fade-out exiting:zoom-out-95',
'fill-mode-forwards origin-left-right',
'py-2 px-3 mx-1 rounded-lg',
'focus:outline-none select-none',
isFocused && 'bg-headplane-100/50 dark:bg-headplane-800',
isDisabled
? 'text-headplane-400 dark:text-headplane-600'
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800 cursor-pointer',
)}
>
<AriaMenu
{...props}
className={cn(
'outline-none',
'divide-y divide-ui-200 dark:divide-ui-600',
props.className,
)}
>
{props.children}
</AriaMenu>
</Popover>
{item.rendered}
</li>
);
}
type ButtonProps = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
};
function ItemButton(props: ButtonProps) {
return (
<MenuItem className="outline-none">
<AriaButton
{...props}
className={cn(
'px-4 py-2 w-full outline-none text-left',
'hover:bg-ui-200 dark:hover:bg-ui-700',
props.className,
)}
aria-label="Menu Dialog"
// If control is passed, set the state value
onPress={(event) => {
props.onPress?.(event);
props.control?.[1](true);
}}
/>
</MenuItem>
);
}
function Item(props: Parameters<typeof MenuItem>[0]) {
return (
<MenuItem
{...props}
className={cn(
'px-4 py-2 w-full outline-none',
'hover:bg-ui-200 dark:hover:bg-ui-700',
props.className,
)}
/>
);
}
function Menu({ children }: { children: ReactNode }) {
return <MenuTrigger>{children}</MenuTrigger>;
}
export default Object.assign(Menu, { Button, Item, ItemButton, Items });
export default Object.assign(Menu, {
Button,
IconButton,
Panel,
Section,
Item,
});
+9 -16
View File
@@ -1,23 +1,16 @@
import { InfoIcon } from '@primer/octicons-react';
import type { ReactNode } from 'react';
import { cn } from '~/utils/cn';
import { CircleSlash2 } from 'lucide-react';
import React from 'react';
import Card from '~/components/Card';
interface Props {
className?: string;
children: ReactNode;
export interface NoticeProps {
children: React.ReactNode;
}
export default function Notice({ children, className }: Props) {
export default function Notice({ children }: NoticeProps) {
return (
<div
className={cn(
'p-4 rounded-md w-full flex items-center gap-3',
'bg-ui-200 dark:bg-ui-800',
className,
)}
>
<InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" />
<Card className="flex w-full max-w-full gap-4 font-semibold">
<CircleSlash2 />
{children}
</div>
</Card>
);
}
-46
View File
@@ -1,46 +0,0 @@
import { PlusIcon, DashIcon } from '@primer/octicons-react';
import type { Dispatch, SetStateAction } from 'react';
import {
Button,
Group,
Input,
NumberField as AriaNumberField,
} from 'react-aria-components';
import { cn } from '~/utils/cn';
type NumberFieldProps = Parameters<typeof AriaNumberField>[0] & {
label: string;
state?: [number, Dispatch<SetStateAction<number>>];
};
export default function NumberField(props: NumberFieldProps) {
return (
<AriaNumberField
{...props}
aria-label={props.label}
className="w-full"
value={props.state?.[0]}
onChange={(value) => {
props.state?.[1](value);
}}
>
<Group
className={cn(
'flex px-2.5 py-1.5 w-full rounded-lg my-1',
'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300 gap-2',
'focus-within:ring-2 focus-within:ring-blue-600',
props.className,
)}
>
<Input className="w-full bg-transparent focus:outline-none" />
<Button slot="decrement">
<DashIcon className="w-4 h-4" />
</Button>
<Button slot="increment">
<PlusIcon className="w-4 h-4" />
</Button>
</Group>
</AriaNumberField>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { Minus, Plus } from 'lucide-react';
import { useRef } from 'react';
import {
type AriaNumberFieldProps,
useId,
useLocale,
useNumberField,
} from 'react-aria';
import { useNumberFieldState } from 'react-stately';
import IconButton from '~/components/IconButton';
import cn from '~/utils/cn';
export interface InputProps extends AriaNumberFieldProps {
isRequired?: boolean;
name?: string;
}
export default function NumberInput(props: InputProps) {
const { label, name } = props;
const { locale } = useLocale();
const state = useNumberFieldState({ ...props, locale });
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const {
labelProps,
inputProps,
groupProps,
incrementButtonProps,
decrementButtonProps,
descriptionProps,
errorMessageProps,
isInvalid,
validationErrors,
} = useNumberField(props, state, ref);
return (
<div className="flex flex-col">
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
>
{label}
</label>
<div
{...groupProps}
className={cn(
'flex items-center gap-1 rounded-xl pr-1',
'focus-within:outline-none focus-within:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
)}
>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
id={id}
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-none"
/>
<input type="hidden" name={name} value={state.numberValue} />
<IconButton
{...decrementButtonProps}
label="Decrement"
className="w-7.5 h-7.5 rounded-lg"
>
<Minus className="p-1" />
</IconButton>
<IconButton
{...incrementButtonProps}
label="Increment"
className="w-7.5 h-7.5 rounded-lg"
>
<Plus className="p-1" />
</IconButton>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{isInvalid && (
<div
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{validationErrors.join(' ')}
</div>
)}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { useRef } from 'react';
import {
AriaTabListProps,
AriaTabPanelProps,
useTab,
useTabList,
useTabPanel,
} from 'react-aria';
import { Item, Node, TabListState, useTabListState } from 'react-stately';
import cn from '~/utils/cn';
export interface OptionsProps extends AriaTabListProps<object> {
label: string;
className?: string;
}
function Options({ label, className, ...props }: OptionsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
ref={ref}
className="flex items-center gap-2 overflow-x-scroll"
>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</div>
<OptionsPanel key={state.selectedItem?.key} state={state} />
</div>
);
}
export interface OptionsOptionProps {
item: Node<object>;
state: TabListState<object>;
}
function Option({ item, state }: OptionsOptionProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
ref={ref}
className={cn(
'pl-0.5 pr-2 py-0.5 rounded-lg cursor-pointer',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-none focus:ring z-10',
'border border-headplane-100 dark:border-headplane-800',
)}
>
{rendered}
</div>
);
}
export interface OptionsPanelProps extends AriaTabPanelProps {
state: TabListState<object>;
}
function OptionsPanel({ state, ...props }: OptionsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div {...tabPanelProps} ref={ref} className="w-full mt-2">
{state.selectedItem?.props.children}
</div>
);
}
export default Object.assign(Options, { Item });
+49
View File
@@ -0,0 +1,49 @@
import React, { useRef } from 'react';
import {
type AriaPopoverProps,
DismissButton,
Overlay,
usePopover,
} from 'react-aria';
import type { OverlayTriggerState } from 'react-stately';
import cn from '~/utils/cn';
export interface PopoverProps extends Omit<AriaPopoverProps, 'popoverRef'> {
children: React.ReactNode;
state: OverlayTriggerState;
popoverRef?: React.RefObject<HTMLDivElement | null>;
className?: string;
}
export default function Popover(props: PopoverProps) {
const ref = props.popoverRef ?? useRef<HTMLDivElement | null>(null);
const { state, children, className } = props;
const { popoverProps, underlayProps } = usePopover(
{
...props,
popoverRef: ref,
offset: 8,
},
state,
);
return (
<Overlay>
<div {...underlayProps} className="fixed inset-0" />
<div
{...popoverProps}
ref={ref}
className={cn(
'z-10 shadow-sm rounded-xl',
'bg-white dark:bg-headplane-900',
'border border-headplane-200 dark:border-headplane-800',
className,
)}
>
<DismissButton onDismiss={state.close} />
{children}
<DismissButton onDismiss={state.close} />
</div>
</Overlay>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { useProgressBar } from 'react-aria';
import cn from '~/utils/cn';
export interface ProgressBarProps {
isVisible: boolean;
}
export default function ProgressBar(props: ProgressBarProps) {
const { isVisible } = props;
const { progressBarProps } = useProgressBar({
label: 'Loading...',
isIndeterminate: true,
});
return (
<div
{...progressBarProps}
aria-hidden={!isVisible}
className={cn(
'fixed top-0 left-0 z-50 w-1/2 h-1 opacity-0',
'bg-headplane-950 dark:bg-headplane-50',
isVisible && 'animate-loading opacity-100',
)}
/>
);
}
+154 -62
View File
@@ -1,79 +1,171 @@
import { ChevronDownIcon } from '@primer/octicons-react';
import type { Dispatch, ReactNode, SetStateAction } from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { useRef } from 'react';
import {
Button,
ListBox,
ListBoxItem,
Popover,
Select as AriaSelect,
SelectValue,
} from 'react-aria-components';
import { cn } from '~/utils/cn';
AriaComboBoxProps,
AriaListBoxOptions,
useButton,
useComboBox,
useFilter,
useId,
useListBox,
useOption,
} from 'react-aria';
import { Item, ListState, Node, useComboBoxState } from 'react-stately';
import Popover from '~/components/Popover';
import cn from '~/utils/cn';
type SelectProps = Parameters<typeof AriaSelect>[0] & {
readonly label: string;
readonly state?: [string, Dispatch<SetStateAction<string>>];
readonly children: ReactNode;
};
export interface SelectProps extends AriaComboBoxProps<object> {
className?: string;
}
function Select(props: SelectProps) {
const { contains } = useFilter({ sensitivity: 'base' });
const state = useComboBoxState({ ...props, defaultFilter: contains });
const id = useId(props.id);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const listBoxRef = useRef<HTMLUListElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const {
buttonProps: triggerProps,
inputProps,
listBoxProps,
labelProps,
descriptionProps,
} = useComboBox(
{
...props,
inputRef,
buttonRef,
listBoxRef,
popoverRef,
},
state,
);
const { buttonProps } = useButton(triggerProps, buttonRef);
return (
<AriaSelect
{...props}
aria-label={props.label}
selectedKey={props.state?.[0]}
onSelectionChange={(key) => {
props.state?.[1](key.toString());
}}
className={cn(
'block w-full rounded-lg my-1',
'border border-ui-200 dark:border-ui-600',
'bg-white dark:bg-ui-800 dark:text-ui-300',
'focus-within:outline-6',
props.className,
<div className={cn('flex flex-col', props.className)}>
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
>
{props.label}
</label>
<div
className={cn(
'flex rounded-xl focus:outline-none focus-within:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
)}
>
<input
{...inputProps}
ref={inputRef}
id={id}
className="outline-none px-3 py-2 rounded-l-xl w-full bg-transparent"
data-1p-ignore
/>
<button
{...buttonProps}
ref={buttonRef}
className={cn(
'flex items-center justify-center p-1 rounded-lg m-1',
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
)}
>
<ChevronDown className="p-0.5" />
</button>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
>
<Button
className={cn(
'w-full flex items-center justify-between',
'px-2.5 py-1.5 rounded-lg',
)}
>
<SelectValue />
<ChevronDownIcon className="w-4 h-4" aria-hidden="true" />
</Button>
<Popover
className={cn(
'mt-2 rounded-md w-[var(--trigger-width)]',
'bg-ui-100 dark:bg-ui-800 shadow-sm',
'z-50 overflow-y-auto',
'border border-ui-200 dark:border-ui-600',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:zoom-in-95',
'exiting:fade-out exiting:zoom-out-95',
'fill-mode-forwards origin-left-right',
)}
>
<ListBox orientation="vertical">{props.children}</ListBox>
</Popover>
</AriaSelect>
{state.isOpen && (
<Popover
popoverRef={popoverRef}
triggerRef={inputRef}
state={state}
isNonModal
placement="bottom start"
className="w-full max-w-xs"
>
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover>
)}
</div>
);
}
type ItemProps = Parameters<typeof ListBoxItem>[0];
interface ListBoxProps extends AriaListBoxOptions<object> {
listBoxRef?: React.RefObject<HTMLUListElement | null>;
state: ListState<object>;
}
function ListBox(props: ListBoxProps) {
const { listBoxRef, state } = props;
const ref = listBoxRef ?? useRef<HTMLUListElement | null>(null);
const { listBoxProps } = useListBox(props, state, ref);
function Item(props: ItemProps) {
return (
<ListBoxItem
{...props}
<ul
{...listBoxProps}
ref={listBoxRef}
className="w-full max-h-72 overflow-auto outline-none pt-1"
>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</ul>
);
}
interface OptionProps {
item: Node<unknown>;
state: ListState<unknown>;
}
function Option({ item, state }: OptionProps) {
const ref = useRef<HTMLLIElement | null>(null);
const { optionProps, isDisabled, isSelected, isFocused } = useOption(
{
key: item.key,
},
state,
ref,
);
return (
<li
{...optionProps}
ref={ref}
className={cn(
'px-4 py-2 w-full outline-none w-full',
'hover:bg-ui-200 dark:hover:bg-ui-700',
props.className,
'flex items-center justify-between',
'py-2 px-3 mx-1 rounded-lg mb-1',
'focus:outline-none select-none',
isFocused || isSelected
? 'bg-headplane-100/50 dark:bg-headplane-800'
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
isDisabled && 'text-headplane-300 dark:text-headplane-600',
)}
>
{props.children}
</ListBoxItem>
{item.rendered}
{isSelected && <Check className="p-0.5" />}
</li>
);
}
+1 -2
View File
@@ -6,14 +6,13 @@ interface Props {
export default function Spinner({ className }: Props) {
return (
<div className={clsx('mr-1.5 inline-block align-middle mb-0.5', className)}>
<div className={clsx('inline-block align-middle mb-0.5', className)}>
<div
className={clsx(
'animate-spin rounded-full w-full h-full',
'border-2 border-current border-t-transparent',
className,
)}
role="status"
>
<span className="sr-only">Loading...</span>
</div>
+14 -11
View File
@@ -1,23 +1,26 @@
import clsx from 'clsx';
import type { HTMLProps } from 'react';
import cn from '~/utils/cn';
type Props = HTMLProps<SVGElement> & {
readonly isOnline: boolean;
};
export interface StatusCircleProps {
isOnline: boolean;
className?: string;
}
// eslint-disable-next-line unicorn/no-keyword-prefix
export default function StatusCircle({ isOnline, className }: Props) {
export default function StatusCircle({
isOnline,
className,
}: StatusCircleProps) {
return (
<svg
className={clsx(
className,
className={cn(
isOnline
? 'text-green-700 dark:text-green-400'
: 'text-gray-300 dark:text-gray-500',
? 'text-green-600 dark:text-green-500'
: 'text-headplane-200 dark:text-headplane-800',
className,
)}
viewBox="0 0 24 24"
fill="currentColor"
>
<title>{isOnline ? 'Online' : 'Offline'}</title>
<circle cx="12" cy="12" r="8" />
</svg>
);
+44 -17
View File
@@ -1,34 +1,61 @@
import { Switch as AriaSwitch } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { useRef } from 'react';
import {
AriaSwitchProps,
VisuallyHidden,
useFocusRing,
useSwitch,
} from 'react-aria';
import { useToggleState } from 'react-stately';
import cn from '~/utils/cn';
type SwitchProps = Parameters<typeof AriaSwitch>[0] & {
readonly label: string;
};
export interface SwitchProps extends AriaSwitchProps {
label: string;
className?: string;
}
export default function Switch(props: SwitchProps) {
const state = useToggleState(props);
const ref = useRef<HTMLInputElement | null>(null);
const { focusProps, isFocusVisible } = useFocusRing();
const { inputProps } = useSwitch(
{
...props,
'aria-label': props.label,
},
state,
ref,
);
return (
<AriaSwitch
{...props}
aria-label={props.label}
className="group flex gap-2 items-center"
>
<label className="flex items-center gap-x-2">
<VisuallyHidden elementType="span">
<input
{...inputProps}
{...focusProps}
aria-label={props.label}
ref={ref}
/>
</VisuallyHidden>
<div
aria-hidden
className={cn(
'flex h-[26px] w-[44px] p-[4px] shrink-0',
'rounded-full outline-none group-focus-visible:ring-2',
'bg-main-600/50 dark:bg-main-600/20 group-selected:bg-main-700',
props.isDisabled && 'opacity-50 cursor-not-allowed',
props.className,
'flex h-[28px] w-[46px] p-[4px] shrink-0 rounded-full',
'bg-headplane-300 dark:bg-headplane-700',
'border border-transparent dark:border-headplane-800',
state.isSelected && 'bg-headplane-900 dark:bg-headplane-950',
isFocusVisible && 'ring-2',
props.isDisabled && 'opacity-50',
)}
>
<span
className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white transition duration-100 ease-in-out',
'bg-white transition duration-50 ease-in-out',
'translate-x-0 group-selected:translate-x-[100%]',
state.isSelected && 'translate-x-[100%]',
)}
/>
</div>
</AriaSwitch>
</label>
);
}
-33
View File
@@ -1,33 +0,0 @@
import { NavLink } from 'react-router';
import type { ReactNode } from 'react';
import { cn } from '~/utils/cn';
interface Props {
name: string;
to: string;
icon: ReactNode;
}
export default function TabLink({ name, to, icon }: Props) {
return (
<NavLink
to={to}
prefetch="intent"
className={({ isActive }) =>
cn(
'border-b-2 py-1.5',
isActive ? 'border-white' : 'border-transparent',
)
}
>
<div
className={cn(
'flex items-center gap-x-2 px-2.5 py-1.5 text-md text-nowrap',
'hover:bg-ui-100/5 dark:hover:bg-ui-900/40 rounded-md',
)}
>
{icon} {name}
</div>
</NavLink>
);
}
+7 -9
View File
@@ -1,14 +1,13 @@
import clsx from 'clsx';
import type { HTMLProps } from 'react';
import cn from '~/utils/cn';
function TableList(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...props}
className={clsx(
'border border-gray-300 rounded-lg overflow-clip',
'dark:border-zinc-700 dark:text-gray-300',
// 'dark:bg-zinc-800',
className={cn(
'rounded-xl',
'border border-headplane-100 dark:border-headplane-800',
props.className,
)}
>
@@ -21,10 +20,9 @@ function Item(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...props}
className={clsx(
'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0',
'dark:border-zinc-800',
className={cn(
'flex items-center justify-between p-2 last:border-b-0',
'border-b border-headplane-100 dark:border-headplane-800',
props.className,
)}
>
+90
View File
@@ -0,0 +1,90 @@
import { useRef } from 'react';
import {
AriaTabListProps,
AriaTabPanelProps,
useTab,
useTabList,
useTabPanel,
} from 'react-aria';
import { Item, Node, TabListState, useTabListState } from 'react-stately';
import cn from '~/utils/cn';
export interface TabsProps extends AriaTabListProps<object> {
label: string;
className?: string;
}
function Tabs({ label, className, ...props }: TabsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
ref={ref}
className={cn(
'flex items-center rounded-t-xl w-fit',
'border-headplane-100 dark:border-headplane-800',
'border-t border-x',
)}
>
{[...state.collection].map((item) => (
<Tab key={item.key} item={item} state={state} />
))}
</div>
<TabsPanel key={state.selectedItem?.key} state={state} />
</div>
);
}
export interface TabsTabProps {
item: Node<object>;
state: TabListState<object>;
}
function Tab({ item, state }: TabsTabProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
ref={ref}
className={cn(
'pl-2 pr-3 py-2.5',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-none focus:ring z-10',
'border-r border-headplane-100 dark:border-headplane-800',
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
)}
>
{rendered}
</div>
);
}
export interface TabsPanelProps extends AriaTabPanelProps {
state: TabListState<object>;
}
function TabsPanel({ state, ...props }: TabsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div
{...tabPanelProps}
ref={ref}
className={cn(
'w-full overflow-clip rounded-b-xl rounded-r-xl',
'border border-headplane-100 dark:border-headplane-800',
)}
>
{state.selectedItem?.props.children}
</div>
);
}
export default Object.assign(Tabs, { Item });
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import cn from '~/utils/cn';
export interface TextProps {
children: React.ReactNode;
className?: string;
}
export default function Text({ children, className }: TextProps) {
return <p className={cn('text-md my-0', className)}>{children}</p>;
}
-30
View File
@@ -1,30 +0,0 @@
import type { Dispatch, SetStateAction } from 'react';
import { Input, TextField as AriaTextField } from 'react-aria-components';
import { cn } from '~/utils/cn';
type TextFieldProps = Parameters<typeof AriaTextField>[0] & {
readonly label: string;
readonly placeholder: string;
readonly state?: [string, Dispatch<SetStateAction<string>>];
};
export default function TextField(props: TextFieldProps) {
return (
<AriaTextField {...props} aria-label={props.label} className="w-full">
<Input
placeholder={props.placeholder}
value={props.state?.[0]}
name={props.name}
className={cn(
'block px-2.5 py-1.5 w-full rounded-lg my-1',
'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300',
props.className,
)}
onChange={(event) => {
props.state?.[1](event.target.value);
}}
/>
</AriaTextField>
);
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import cn from '~/utils/cn';
export interface TitleProps {
children: React.ReactNode;
className?: string;
}
export default function Title({ children, className }: TitleProps) {
return (
<h3 className={cn('text-2xl font-bold mb-2', className)}>{children}</h3>
);
}
+87
View File
@@ -0,0 +1,87 @@
import {
AriaToastProps,
AriaToastRegionProps,
useToast,
useToastRegion,
} from '@react-aria/toast';
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast';
import { X } from 'lucide-react';
import React, { useRef } from 'react';
import IconButton from '~/components/IconButton';
import cn from '~/utils/cn';
interface ToastProps extends AriaToastProps<React.ReactNode> {
state: ToastState<React.ReactNode>;
}
function Toast({ state, ...props }: ToastProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
props,
state,
ref,
);
return (
<div
{...toastProps}
ref={ref}
className={cn(
'flex items-center justify-between gap-x-3 pl-4 pr-3',
'text-white shadow-lg dark:shadow-md rounded-xl py-3',
'bg-headplane-900 dark:bg-headplane-950',
)}
>
<div {...contentProps} className="flex flex-col gap-2">
<div {...titleProps}>{props.toast.content}</div>
</div>
<IconButton
{...closeButtonProps}
label="Close"
className={cn(
'bg-transparent hover:bg-headplane-700',
'dark:bg-transparent dark:hover:bg-headplane-800',
)}
>
<X className="p-1" />
</IconButton>
</div>
);
}
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<React.ReactNode>;
}
function ToastRegion({ state, ...props }: ToastRegionProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { regionProps } = useToastRegion(props, state, ref);
return (
<div
{...regionProps}
ref={ref}
className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
>
{state.visibleToasts.map((toast) => (
<Toast key={toast.key} toast={toast} state={state} />
))}
</div>
);
}
export interface ToastProviderProps extends AriaToastRegionProps {
queue: ToastQueue<React.ReactNode>;
}
export default function ToastProvider({ queue, ...props }: ToastProviderProps) {
const state = useToastQueue(queue);
return (
<>
{state.visibleToasts.length > 0 && (
<ToastRegion {...props} state={state} />
)}
</>
);
}
-91
View File
@@ -1,91 +0,0 @@
import { XIcon } from '@primer/octicons-react';
import {
AriaToastProps,
useToast,
useToastRegion,
} from '@react-aria/toast';
import {
ToastQueue,
ToastState,
useToastQueue,
} from '@react-stately/toast';
import { ReactNode, useRef } from 'react';
import { Button } from 'react-aria-components';
import { createPortal } from 'react-dom';
import { ClientOnly } from 'remix-utils/client-only';
import { cn } from '~/utils/cn';
type ToastProps = AriaToastProps<ReactNode> & {
readonly state: ToastState<ReactNode>;
};
function Toast({ state, ...properties }: ToastProps) {
const reference = useRef(null);
const { toastProps, titleProps, closeButtonProps } = useToast(
properties,
state,
reference,
);
return (
<div
{...toastProps}
ref={reference}
className={cn(
'bg-main-700 dark:bg-main-800 rounded-lg',
'text-main-100 dark:text-main-200 z-50',
'border border-main-600 dark:border-main-700',
'flex items-center justify-between p-3 pl-4 w-80',
)}
>
<div {...titleProps}>{properties.toast.content}</div>
<Button
{...closeButtonProps}
className={cn(
'outline-none rounded-full p-1',
'hover:bg-main-600 dark:hover:bg-main-700',
)}
>
<XIcon className="w-4 h-4" />
</Button>
</div>
);
}
const toasts = new ToastQueue<ReactNode>({
maxVisibleToasts: 5,
});
export function toast(text: string) {
return toasts.add(text, { timeout: 5000 });
}
export function Toaster() {
const reference = useRef(null);
const state = useToastQueue(toasts);
const { regionProps } = useToastRegion({}, state, reference);
return (
<ClientOnly>
{
() =>
createPortal(
state.visibleToasts.length >= 0 ? (
<div
className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
{...regionProps}
ref={reference}
>
{state.visibleToasts.map((toast) => (
<Toast key={toast.key} toast={toast} state={state} />
))}
</div>
) : undefined,
document.body,
)
}
</ClientOnly>
);
}
+73 -27
View File
@@ -1,37 +1,83 @@
import type { ReactNode } from 'react';
import React, { cloneElement, useRef } from 'react';
import {
Button as AriaButton,
Tooltip as AriaTooltip,
TooltipTrigger,
} from 'react-aria-components';
import { cn } from '~/utils/cn';
AriaTooltipProps,
mergeProps,
useTooltip,
useTooltipTrigger,
} from 'react-aria';
import { TooltipTriggerState, useTooltipTriggerState } from 'react-stately';
import cn from '~/utils/cn';
interface Props {
children: ReactNode;
className?: string;
export interface TooltipProps extends AriaTooltipProps {
children: [React.ReactElement, React.ReactElement<TooltipBodyProps>];
}
function Tooltip({ children }: Props) {
return <TooltipTrigger delay={0}>{children}</TooltipTrigger>;
}
function Tooltip(props: TooltipProps) {
const state = useTooltipTriggerState({
...props,
delay: 0,
closeDelay: 0,
});
function Button(props: Parameters<typeof AriaButton>[0]) {
return <AriaButton {...props} />;
}
const ref = useRef<HTMLButtonElement | null>(null);
const { triggerProps, tooltipProps } = useTooltipTrigger(
{
...props,
delay: 0,
closeDelay: 0,
},
state,
ref,
);
function Body({ children, className }: Props) {
const [component, body] = props.children;
return (
<AriaTooltip
className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-ui-900 drop-shadow-sm',
'border border-gray-200 dark:border-zinc-700',
className,
)}
>
{children}
</AriaTooltip>
<span className="relative">
<button
ref={ref}
{...triggerProps}
className={cn(
'flex items-center justify-center',
'focus:outline-none focus:ring rounded-xl',
)}
>
{component}
</button>
{state.isOpen &&
cloneElement(body, {
...tooltipProps,
state,
})}
</span>
);
}
export default Object.assign(Tooltip, { Button, Body });
interface TooltipBodyProps extends AriaTooltipProps {
children: React.ReactNode;
state?: TooltipTriggerState;
className?: string;
}
function Body({ state, className, ...props }: TooltipBodyProps) {
const { tooltipProps } = useTooltip(props, state);
return (
<span
{...mergeProps(props, tooltipProps)}
className={cn(
'absolute z-50 p-3 top-full mt-1',
'outline-none rounded-3xl text-sm w-48',
'bg-white dark:bg-headplane-950',
'text-black dark:text-white',
'shadow-lg dark:shadow-md rounded-xl',
'border border-headplane-100 dark:border-headplane-800',
className,
)}
>
{props.children}
</span>
);
}
export default Object.assign(Tooltip, {
Body,
});
+15 -9
View File
@@ -1,12 +1,18 @@
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
import { StrictMode, startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
if (import.meta.env.DEV) {
import('react-scan').then(({ scan }) => {
scan({ enabled: true });
});
}
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>
);
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>,
);
});
+55 -60
View File
@@ -1,71 +1,66 @@
import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
import { ServerRouter } from "react-router";
import { isbot } from "isbot";
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
import { loadContext } from "~/utils/config/headplane";
loadContext();
import { PassThrough } from 'node:stream';
import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import { AppLoadContext, EntryContext, ServerRouter } from 'react-router';
export const streamTimeout = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get("user-agent");
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get('user-agent');
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
? "onAllReady"
: "onShellReady";
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
? 'onAllReady'
: 'onShellReady';
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
responseHeaders.set('Content-Type', 'text/html');
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
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);
}
},
}
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
// biome-ignore lint/style/noParameterAssign: Lazy
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);
});
// Abort the rendering stream after the `streamTimeout` so it has tine to
// flush down the rejected boundaries
setTimeout(abort, streamTimeout + 1000);
});
}
-149
View File
@@ -1,149 +0,0 @@
import { access, constants } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises';
import { Client } from 'undici';
import { HeadscaleError, pull } from '~/utils/headscale';
import log from '~/utils/log';
import { createIntegration } from './integration';
interface Context {
client: Client | undefined;
container: string | undefined;
maxAttempts: number;
}
export default createIntegration<Context>({
name: 'Docker',
context: {
client: undefined,
container: undefined,
maxAttempts: 10,
},
isAvailable: async (context) => {
// Check for the HEADSCALE_CONTAINER environment variable first
// to avoid unnecessary fetching of the Docker socket
log.debug('INTG', 'Checking Docker integration availability');
context.container = process.env.HEADSCALE_CONTAINER?.trim().toLowerCase();
if (!context.container || context.container.length === 0) {
log.error('INTG', 'Missing HEADSCALE_CONTAINER variable');
return false;
}
log.info('INTG', 'Using container: %s', context.container);
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock';
let url: URL | undefined;
try {
url = new URL(path);
} catch {
log.error('INTG', 'Invalid Docker socket path: %s', path);
return false;
}
if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') {
log.error('INTG', 'Invalid Docker socket protocol: %s', url.protocol);
return false;
}
// The API is available as an HTTP endpoint and this
// will simplify the fetching logic in undici
if (url.protocol === 'tcp:') {
// Apparently setting url.protocol doesn't work anymore?
const fetchU = url.href.replace(url.protocol, 'http:');
try {
log.info('INTG', 'Checking API: %s', fetchU);
await fetch(new URL('/v1.30/version', fetchU).href);
} catch (error) {
log.debug('INTG', 'Failed to connect to Docker API', error);
log.error('INTG', 'Failed to connect to Docker API');
return false;
}
context.client = new Client(fetchU);
}
// Check if the socket is accessible
if (url.protocol === 'unix:') {
try {
log.info('INTG', 'Checking socket: %s', url.pathname);
await access(url.pathname, constants.R_OK);
} catch (error) {
log.debug('INTG', 'Failed to access Docker socket: %s', error);
log.error('INTG', 'Failed to access Docker socket: %s', path);
return false;
}
context.client = new Client('http://localhost', {
socketPath: url.pathname,
});
}
return context.client !== undefined;
},
onConfigChange: async (context) => {
if (!context.client || !context.container) {
return;
}
log.info('INTG', 'Restarting Headscale via Docker');
let attempts = 0;
while (attempts <= context.maxAttempts) {
log.debug(
'INTG',
'Restarting container: %s (attempt %d)',
context.container,
attempts,
);
const response = await context.client.request({
method: 'POST',
path: `/v1.30/containers/${context.container}/restart`,
});
if (response.statusCode !== 204) {
if (attempts < context.maxAttempts) {
attempts++;
await setTimeout(1000);
continue;
}
const stringCode = response.statusCode.toString();
const body = await response.body.text();
throw new Error(`API request failed: ${stringCode} ${body}`);
}
break;
}
attempts = 0;
while (attempts <= context.maxAttempts) {
try {
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts);
await pull('v1', '');
return;
} catch (error) {
if (error instanceof HeadscaleError && error.status === 401) {
break;
}
if (error instanceof HeadscaleError && error.status === 404) {
break;
}
if (attempts < context.maxAttempts) {
attempts++;
await setTimeout(1000);
continue;
}
throw new Error(`Missed restart deadline for ${context.container}`);
}
}
},
});
-68
View File
@@ -1,68 +0,0 @@
import log from '~/utils/log';
import dockerIntegration from './docker';
import type { IntegrationFactory } from './integration';
import kubernetesIntegration from './kubernetes';
import procIntegration from './proc';
export * from './integration';
export async function loadIntegration() {
let integration = process.env.HEADSCALE_INTEGRATION?.trim().toLowerCase();
// Old HEADSCALE_CONTAINER variable upgrade path
// This ensures that when people upgrade from older versions of Headplane
// they don't explicitly need to define the new HEADSCALE_INTEGRATION
// variable that is needed to configure docker
if (!integration && process.env.HEADSCALE_CONTAINER) {
integration = 'docker';
}
if (!integration) {
log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION');
return;
}
let integrationFactory: IntegrationFactory | undefined;
switch (integration.toLowerCase().trim()) {
case 'docker': {
integrationFactory = dockerIntegration;
break;
}
case 'proc':
case 'native':
case 'linux': {
integrationFactory = procIntegration;
break;
}
case 'kubernetes':
case 'k8s': {
integrationFactory = kubernetesIntegration;
break;
}
default: {
log.error('INTG', 'Unknown integration: %s', integration);
throw new Error(`Unknown integration: ${integration}`);
}
}
log.info('INTG', 'Loading integration: %s', integration);
try {
const res = await integrationFactory.isAvailable(
integrationFactory.context,
);
if (!res) {
log.error('INTG', 'Integration %s is not available', integration);
return;
}
} catch (error) {
log.error('INTG', 'Failed to load integration %s: %s', integration, error);
return;
}
log.info('INTG', 'Loaded integration: %s', integration);
return integrationFactory;
}
-11
View File
@@ -1,11 +0,0 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface IntegrationFactory<T = any> {
name: string;
context: T;
isAvailable: (context: T) => Promise<boolean> | boolean;
onConfigChange?: (context: T) => Promise<void> | void;
}
export function createIntegration<T>(options: IntegrationFactory<T>) {
return options;
}
-81
View File
@@ -1,81 +0,0 @@
import { readdir, readFile } from 'node:fs/promises';
import { platform } from 'node:os';
import { join, resolve } from 'node:path';
import { kill } from 'node:process';
import log from '~/utils/log';
import { createIntegration } from './integration';
interface Context {
pid: number | undefined;
}
export default createIntegration<Context>({
name: 'Native Linux (/proc)',
context: {
pid: undefined,
},
isAvailable: async (context) => {
if (platform() !== 'linux') {
log.error('INTG', '/proc is only available on Linux');
return false;
}
log.debug('INTG', 'Checking /proc for Headscale process');
const dir = resolve('/proc');
try {
const subdirs = await readdir(dir);
const promises = subdirs.map(async (dir) => {
const pid = Number.parseInt(dir, 10);
if (Number.isNaN(pid)) {
return;
}
const path = join('/proc', dir, 'cmdline');
try {
log.debug('INTG', 'Reading %s', path);
const data = await readFile(path, 'utf8');
if (data.includes('headscale')) {
return pid;
}
} catch (error) {
log.error('INTG', 'Failed to read %s: %s', path, error);
}
});
const results = await Promise.allSettled(promises);
const pids = [];
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
pids.push(result.value);
}
}
log.debug('INTG', 'Found Headscale processes: %o', pids);
if (pids.length > 1) {
log.error(
'INTG',
'Found %d Headscale processes: %s',
pids.length,
pids.join(', '),
);
return false;
}
if (pids.length === 0) {
log.error('INTG', 'Could not find Headscale process');
return false;
}
context.pid = pids[0];
log.info('INTG', 'Found Headscale process with PID: %d', context.pid);
return true;
} catch {
log.error('INTG', 'Failed to read /proc');
return false;
}
},
});
+30 -40
View File
@@ -1,42 +1,29 @@
import { XCircleFillIcon } from '@primer/octicons-react';
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 type { LoadContext } from '~/server';
import { ResponseError } from '~/server/headscale/api-client';
import cn from '~/utils/cn';
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);
}
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const healthy = await context.client.healthcheck();
const session = await context.sessions.auth(request);
// We shouldn't session invalidate if Headscale is down
// TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled
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);
await context.client.get('v1/apikey', session.get('api_key')!);
} catch (error) {
if (error instanceof HeadscaleError) {
log.debug('APIC', 'API Key validation failed %o', error);
if (error instanceof ResponseError) {
log.debug('api', 'API Key validation failed %o', error);
return redirect('/login', {
headers: {
'Set-Cookie': await destroySession(session),
'Set-Cookie': await context.sessions.destroy(session),
},
});
}
@@ -45,25 +32,28 @@ export async function loader({ request }: LoaderFunctionArgs) {
return {
healthy,
}
};
}
export default function Layout() {
useLiveData({ interval: 3000 });
const { healthy } = useLoaderData<typeof loader>()
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',
)}>
<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>
+115 -17
View File
@@ -1,33 +1,131 @@
import Header from '~/components/Header';
import { BanIcon, CircleCheckIcon } from 'lucide-react';
import {
LoaderFunctionArgs,
Outlet,
redirect,
useLoaderData,
} from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
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';
import Header from '~/components/Header';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { User } from '~/types';
import log from '~/utils/log';
// 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')) {
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
try {
const session = await context.sessions.auth(request);
if (!session.has('api_key')) {
// There is a session, but it's not valid
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
},
});
}
// Onboarding is only a feature of the OIDC flow
if (context.oidc && !request.url.endsWith('/onboarding')) {
let onboarded = false;
try {
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
if (users.length === 0) {
onboarded = false;
}
const user = users.find((u) => {
if (u.provider !== 'oidc') {
return false;
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = u.providerId?.split('/').pop();
if (!subject) {
return false;
}
const sessionUser = session.get('user');
if (!sessionUser) {
return false;
}
return subject === sessionUser.subject;
});
if (user) {
onboarded = true;
}
} catch (e) {
// If we cannot lookup users, just assume our user is onboarded
log.debug('api', 'Failed to lookup users %o', e);
onboarded = true;
}
if (!onboarded) {
return redirect('/onboarding');
}
}
const check = await context.sessions.check(request, Capabilities.ui_access);
return {
config: context.hs.c,
url: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(),
debug: context.config.debug,
user: session.get('user'),
uiAccess: check,
onboarding: request.url.endsWith('/onboarding'),
};
} catch {
// No session, so we can just return
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 />
{data.uiAccess ? (
<Outlet />
) : (
<Card className="mx-auto w-fit mt-24">
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">Connected</Card.Title>
<CircleCheckIcon className="w-10 h-10" />
</div>
<Card.Text className="my-4 text-lg">
Connect to Tailscale with your devices to access this Tailnet. Use
this command to help you get started:
</Card.Text>
<Button className="pointer-events-none text-md hover:bg-initial focus:ring-0">
<Code className="pointer-events-auto bg-transparent" isCopyable>
tailscale up --login-server={data.url}
</Code>
</Button>
<p className="mt-4 text-sm opacity-50">
Your account does not have access to the UI. Please contact your
administrator if you believe this is a mistake.
</p>
</Card>
)}
<Footer {...data} />
</>
)
);
}
+43 -32
View File
@@ -1,13 +1,25 @@
import type { LoaderFunctionArgs, LinksFunction, MetaFunction } from 'react-router';
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from 'react-router';
import { loadContext } from '~/utils/config/headplane';
import { ProgressBar } from 'react-aria-components';
import type {
LinksFunction,
LoaderFunctionArgs,
MetaFunction,
} from 'react-router';
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useNavigation,
} from 'react-router';
import '@fontsource-variable/inter';
import { ErrorPopup } from '~/components/Error';
// TODO: Make this a default export
import { Toaster } from '~/components/Toaster';
import ProgressBar from '~/components/ProgressBar';
import ToastProvider from '~/components/ToastProvider';
import stylesheet from '~/tailwind.css?url';
import { cn } from '~/utils/cn';
import { LiveDataProvider } from '~/utils/live-data';
import { useToastQueue } from '~/utils/toast';
import { LoadContext } from './server';
import log from './utils/log';
export const meta: MetaFunction = () => [
{ title: 'Headplane' },
@@ -22,21 +34,28 @@ export const links: LinksFunction = () => [
];
export function Layout({ children }: { readonly children: React.ReactNode }) {
const toastQueue = useToastQueue();
// LiveDataProvider is wrapped at the top level since dialogs and things
// that control its state are usually open in portal containers which
// are not a part of the normal React tree.
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className="overscroll-none dark:bg-ui-950 dark:text-ui-50">
{children}
<Toaster />
<ScrollRestoration />
<Scripts />
</body>
</html>
<LiveDataProvider>
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
{children}
<ToastProvider queue={toastQueue} />
<ScrollRestoration />
<Scripts />
</body>
</html>
</LiveDataProvider>
);
}
@@ -49,16 +68,8 @@ export default function App() {
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>
<ProgressBar isVisible={nav.state === 'loading'} />
<Outlet />
</>
)
);
}
+1
View File
@@ -14,6 +14,7 @@ export default [
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout('layouts/shell.tsx', [
route('/onboarding', 'routes/users/onboarding.tsx'),
layout('layouts/dashboard.tsx', [
...prefix('/machines', [
index('routes/machines/overview.tsx'),
+45 -72
View File
@@ -1,13 +1,11 @@
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 { xcodeDark, xcodeLight } from '@uiw/codemirror-theme-xcode';
import CodeMirror from '@uiw/react-codemirror';
import { BookCopy, CircleX } from 'lucide-react';
import { useEffect, useState } from 'react';
import Merge from 'react-codemirror-merge';
import { ErrorBoundary } from 'react-error-boundary';
import { githubDark, githubLight } from '@uiw/codemirror-theme-github';
import { useState } from 'react';
import { cn } from '~/utils/cn';
import { ClientOnly } from 'remix-utils/client-only';
import Fallback from './fallback';
interface EditorProps {
@@ -27,39 +25,28 @@ export function Editor(props: EditorProps) {
});
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 className="overflow-y-scroll h-editor text-sm">
<ErrorBoundary
fallback={
<div className="flex flex-col items-center gap-2.5 py-8">
<CircleX />
<p className="text-lg font-semibold">Failed to load the editor.</p>
</div>
}
>
<ClientOnly fallback={<Fallback acl={props.value} />}>
{() => (
<CodeMirror
value={props.value}
height="100%"
extensions={[shopify.jsonc()]}
style={{ height: '100%' }}
theme={light ? xcodeLight : xcodeDark}
onChange={(value) => props.onChange(value)}
/>
)}
</ClientOnly>
</ErrorBoundary>
</div>
);
}
@@ -80,41 +67,27 @@ export function Differ(props: DifferProps) {
});
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>
) : (
<div className="text-sm">
{props.left === props.right ? (
<div className="flex flex-col items-center gap-2.5 py-8">
<BookCopy />
<p className="text-lg font-semibold">No changes</p>
</div>
) : (
<div className="h-editor overflow-y-scroll">
<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>
<div className="flex flex-col items-center gap-2.5 py-8">
<CircleX />
<p className="text-lg font-semibold">
Failed to load the editor.
</p>
</div>
}
>
<ClientOnly fallback={<Fallback acl={props.right} />}>
{() => (
<Merge
orientation="a-b"
theme={light ? githubLight : githubDark}
>
<Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}>
<Merge.Original
readOnly
value={props.left}
@@ -129,8 +102,8 @@ export function Differ(props: DifferProps) {
)}
</ClientOnly>
</ErrorBoundary>
)}
</div>
</div>
)}
</div>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { cn } from '~/utils/cn';
import { AlertIcon } from '@primer/octicons-react';
import cn from '~/utils/cn';
import Card from '~/components/Card';
import Code from '~/components/Code';
+20 -7
View File
@@ -1,5 +1,5 @@
import Spinner from '~/components/Spinner';
import { cn } from '~/utils/cn';
import cn from '~/utils/cn';
interface Props {
readonly acl: string;
@@ -7,15 +7,28 @@ interface Props {
export default function Fallback({ acl }: Props) {
return (
<div className="inline-block relative w-full h-editor">
<Spinner className="w-4 h-4 absolute p-2" />
<div className="relative w-full h-editor flex">
<div
className={cn(
'h-full w-8 flex justify-center p-1',
'border-r border-headscale-400 dark:border-headscale-800',
)}
>
<div
aria-hidden
className={cn(
'h-5 w-5 animate-spin rounded-full',
'border-headplane-900 dark:border-headplane-100',
'border-2 border-t-transparent dark:border-t-transparent',
)}
/>
</div>
<textarea
readOnly
className={cn(
'w-full h-editor font-mono resize-none',
'text-sm text-gray-600 dark:text-gray-300',
'bg-ui-100 dark:bg-ui-800',
'pl-10 pt-1 leading-snug',
'w-full h-editor font-mono resize-none text-sm',
'bg-headplane-50 dark:bg-headplane-950 opacity-60',
'pl-1 pt-1 leading-snug',
)}
value={acl}
/>
+2 -2
View File
@@ -1,8 +1,8 @@
import { cn } from '~/utils/cn';
import { AlertIcon } from '@primer/octicons-react';
import cn from '~/utils/cn';
import Code from '~/components/Code';
import Card from '~/components/Card';
import Code from '~/components/Code';
interface Props {
mode: 'file' | 'database';
+75 -122
View File
@@ -1,36 +1,31 @@
import {
BeakerIcon,
EyeIcon,
IssueDraftIcon,
PencilIcon,
} from '@primer/octicons-react';
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
import { useEffect, useMemo, useState } from '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 {
redirect,
useFetcher,
useLoaderData,
useRevalidator,
} from 'react-router';
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 Tabs from '~/components/Tabs';
import type { LoadContext } from '~/server';
import { ResponseError } from '~/server/headscale/api-client';
import log from '~/utils/log';
import { Editor, Differ } from './components/cm.client';
import { Unavailable } from './components/unavailable';
import { send } from '~/utils/res';
import toast from '~/utils/toast';
import { Differ, Editor } from './components/cm.client';
import { ErrorView } from './components/error';
import { Unavailable } from './components/unavailable';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
// 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
@@ -56,32 +51,30 @@ export async function loader({ request }: LoaderFunctionArgs) {
// 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';
if (!context.hs.readable()) {
modeGuess = context.hs.c!.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 }>(
const { policy } = await context.client.get<{ policy: string }>(
'v1/policy',
session.get('hsApiKey')!,
session.get('api_key')!,
);
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')!, {
await context.client.put('v1/policy', session.get('api_key')!, {
policy: policy,
});
write = true;
} catch (error) {
write = false;
log.debug('APIC', 'Failed to write to ACL policy with error %s', error);
log.debug('api', 'Failed to write to ACL policy with error %s', error);
}
}
@@ -114,17 +107,17 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
}
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);
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
try {
const { acl } = (await request.json()) as { acl: string };
const { policy } = await put<{ policy: string }>(
const { policy } = await context.client.put<{ policy: string }>(
'v1/policy',
session.get('hsApiKey')!,
session.get('api_key')!,
{
policy: acl,
},
@@ -132,19 +125,17 @@ export async function action({ request }: ActionFunctionArgs) {
return { success: true, policy, error: null };
} catch (error) {
log.debug('APIC', 'Failed to update ACL policy with error %s', error);
log.debug('api', 'Failed to update ACL policy with error %s', error);
// @ts-ignore: Shut UP we know it's a string most of the time
// @ts-ignore: TODO: 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,
status: error instanceof ResponseError ? error.status : 500,
},
);
}
return { success: true, error: null };
}
export default function Page() {
@@ -160,7 +151,6 @@ export default function Page() {
return;
}
// @ts-ignore: useDebounceFetcher is not typed correctly
if (fetcher.data.success) {
toast('Updated tailnet ACL policy');
} else {
@@ -190,7 +180,6 @@ export default function Page() {
}
// 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;
}
@@ -202,7 +191,7 @@ export default function Page() {
<div>
{data.read && !data.write ? (
<div className="mb-4">
<Notice className="w-fit">
<Notice>
The ACL policy is read-only. You can view the current policy but you
cannot make changes to it.
<br />
@@ -230,88 +219,52 @@ export default function Page() {
</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
}
{fetcher.data?.success === false ? (
<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',
)}
<Tabs label="ACL Editor" className="mb-4">
<Tabs.Item
key="edit"
title={
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
}
>
<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">
</Tabs.Item>
<Tabs.Item
key="diff"
title={
<div className="flex items-center gap-2">
<Eye className="p-1" />
<span>Preview changes</span>
</div>
}
>
<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" />
</Tabs.Item>
<Tabs.Item
key="preview"
title={
<div className="flex items-center gap-2">
<FlaskConical className="p-1" />
<span>Preview rules</span>
</div>
}
>
<div className="flex flex-col items-center py-8">
<Construction />
<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.
Previewing rules is not available yet. This feature is still
in development and is pretty complicated to implement.
Hopefully I will be able to get to it soon.
</p>
</div>
</TabPanel>
</Tabs.Item>
</Tabs>
<Button
variant="heavy"
+54 -47
View File
@@ -4,51 +4,46 @@ import {
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 Input from '~/components/Input';
import type { LoadContext } from '~/server';
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),
},
});
}
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
try {
const session = await context.sessions.auth(request);
if (session.has('api_key')) {
return redirect('/machines');
}
} catch {}
const context = await loadContext();
// Only set if OIDC is properly enabled anyways
if (context.oidc?.disableKeyLogin) {
const disableApiKeyLogin = context.config.oidc?.disable_api_key_login;
if (context.oidc && disableApiKeyLogin) {
return redirect('/oidc/start');
}
return {
oidc: context.oidc?.issuer,
apiKey: !context.oidc?.disableKeyLogin,
oidc: context.oidc,
disableApiKeyLogin,
};
}
export async function action({ request }: ActionFunctionArgs) {
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const formData = await request.formData();
const oidcStart = formData.get('oidc-start');
const session = await getSession(request.headers.get('Cookie'));
const session = await context.sessions.getOrCreate(request);
if (oidcStart) {
const context = await loadContext();
if (!context.oidc) {
throw new Error('An invalid OIDC configuration was provided');
throw new Error('OIDC is not enabled');
}
return redirect('/oidc/start');
@@ -58,17 +53,24 @@ export async function action({ request }: ActionFunctionArgs) {
// Test the API key
try {
const apiKeys = await pull<{ apiKeys: Key[] }>('v1/apikey', apiKey);
const apiKeys = await context.client.get<{ apiKeys: Key[] }>(
'v1/apikey',
apiKey,
);
const key = apiKeys.apiKeys.find((k) => apiKey.startsWith(k.prefix));
if (!key) {
throw new Error('Invalid API key');
return {
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('state', 'auth');
session.set('api_key', apiKey);
session.set('user', {
subject: 'unknown-non-oauth',
name: key.prefix,
@@ -77,7 +79,7 @@ export async function action({ request }: ActionFunctionArgs) {
return redirect('/machines', {
headers: {
'Set-Cookie': await commitSession(session, {
'Set-Cookie': await context.sessions.commit(session, {
maxAge: expiresIn,
}),
},
@@ -92,15 +94,14 @@ export async function action({ request }: ActionFunctionArgs) {
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 className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>Welcome to Headplane</Card.Title>
{data.apiKey ? (
{!data.disableApiKeyLogin ? (
<Form method="post">
<Card.Text className="mb-8 text-sm">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate
one by running <Code>headscale apikeys create</Code> in your
terminal.
@@ -109,30 +110,36 @@ export default function Page() {
{actionData?.error ? (
<p className="text-red-500 text-sm mb-2">{actionData.error}</p>
) : undefined}
<TextField
<Input
isRequired
labelHidden
label="API Key"
name="api-key"
placeholder="API Key"
type="password"
className="mt-4 mb-2"
/>
<Button className="w-full mt-2.5" variant="heavy" type="submit">
Login
<Button className="w-full" variant="heavy" type="submit">
Sign In
</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">
{data.disableApiKeyLogin ? (
<Card.Text className="mb-6">
Sign in with your authentication provider to continue. Your
administrator has disabled API key login.
</Card.Text>
) : undefined}
<input type="hidden" name="oidc-start" value="true" />
<Button className="w-full" variant="heavy" type="submit">
Login with SSO
<Button
className="w-full mt-2"
variant={data.disableApiKeyLogin ? 'heavy' : 'light'}
type="submit"
>
Single Sign-On
</Button>
</Form>
) : undefined}
+11 -4
View File
@@ -1,15 +1,22 @@
import { type ActionFunctionArgs, redirect } from 'react-router';
import { destroySession, getSession } from '~/utils/sessions.server';
import type { LoadContext } from '~/server';
export async function loader() {
return redirect('/machines');
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
if (!session.has('api_key')) {
return redirect('/login');
}
return redirect('/login', {
headers: {
'Set-Cookie': await destroySession(session),
'Set-Cookie': await context.sessions.destroy(session),
},
});
}
+35 -49
View File
@@ -1,83 +1,69 @@
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 { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import type { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { finishAuthFlow, formatError } from '~/utils/oidc';
import { send } from '~/utils/res';
export async function loader({ request }: LoaderFunctionArgs) {
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
// Check if we have 0 query parameters
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect('/machines');
return redirect('/login');
}
const session = await getSession(request.headers.get('Cookie'));
if (session.has('hsApiKey')) {
return redirect('/machines')
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if (session.get('state') !== 'flow') {
return redirect('/login'); // Haven't started an OIDC flow
}
// 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) {
const payload = session.get('oidc')!;
const { code_verifier, state, nonce, redirect_uri } = payload;
if (!code_verifier || !state || !nonce || !redirect_uri) {
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);
const flowRedirectUri = new URL(redirect_uri);
flowRedirectUri.search = url.search;
const flowOptions = {
redirect_uri: flowRedirectUri.toString(),
codeVerifier,
code_verifier,
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');
const user = await finishAuthFlow(context.oidc, flowOptions);
session.unset('oidc');
const userSession = session as Session<AuthSession>;
// 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);
userSession.set('user', user);
userSession.set('api_key', context.config.oidc?.headscale_api_key!);
userSession.set('state', 'auth');
return redirect('/machines', {
headers: {
'Set-Cookie': await commitSession(session),
'Set-Cookie': await context.sessions.commit(userSession),
},
});
} catch (error) {
return new Response(
JSON.stringify(formatError(error)),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
);
return new Response(JSON.stringify(formatError(error)), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
}
+28 -27
View File
@@ -1,41 +1,42 @@
import { type LoaderFunctionArgs, data, redirect } from 'react-router';
import { commitSession, getSession } from '~/utils/sessions.server';
import { send } from '~/utils/res';
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import { AuthSession, OidcFlowSession } from '~/server/web/sessions';
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')
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if ((session as Session<AuthSession>).has('api_key')) {
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');
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
const oidcConfig = {
issuer: oidc.issuer,
clientId: oidc.client,
clientSecret: oidc.secret,
redirectUri: oidc.redirectUri,
tokenEndpointAuthMethod: oidc.method,
}
const redirectUri =
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
const data = await beginAuthFlow(
context.oidc,
redirectUri,
// We can't get here without the OIDC config being defined
context.config.oidc!.token_endpoint_auth_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)
session.set('state', 'flow');
session.set('oidc', {
state: data.state,
nonce: data.nonce,
code_verifier: data.codeVerifier,
redirect_uri: redirectUri,
});
return redirect(data.url, {
status: 302,
headers: {
'Set-Cookie': await commitSession(session),
'Set-Cookie': await context.sessions.commit(session),
},
});
}
-81
View File
@@ -1,81 +0,0 @@
import { useSubmit } from 'react-router';
import { Button } from 'react-aria-components';
import Code from '~/components/Code';
import Link from '~/components/Link';
import TableList from '~/components/TableList';
import { cn } from '~/utils/cn';
import AddDNS from '../dialogs/dns';
interface Props {
records: { name: string; type: 'A'; value: string }[];
isDisabled: boolean;
}
export default function DNS({ records, isDisabled }: Props) {
const submit = useSubmit();
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
<p className="text-gray-700 dark:text-gray-300">
Headscale supports adding custom DNS records to your Tailnet. As of now,
only <Code>A</Code> records are supported.{' '}
<Link
to="https://headscale.net/stable/ref/dns"
name="Headscale DNS Records documentation"
>
Learn More
</Link>
</p>
<div className="mt-4">
<TableList className="mb-8">
{records.length === 0 ? (
<TableList.Item>
<p className="opacity-50 text-sm mx-auto">No DNS records found</p>
</TableList.Item>
) : (
records.map((record, index) => (
<TableList.Item key={index}>
<div className="flex gap-24">
<div className="flex gap-2">
<p className="font-mono text-sm font-bold">{record.type}</p>
<p className="font-mono text-sm">{record.name}</p>
</div>
<p className="font-mono text-sm">{record.value}</p>
</div>
<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={() => {
submit(
{
'dns.extra_records': records.filter(
(_, i) => i !== index,
),
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}}
>
Remove
</Button>
</TableList.Item>
))
)}
</TableList>
{isDisabled ? undefined : <AddDNS records={records} />}
</div>
</div>
);
}
-246
View File
@@ -1,246 +0,0 @@
/* eslint-disable unicorn/no-keyword-prefix */
import { closestCorners, DndContext, DragOverlay } from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react';
import { type FetcherWithComponents, useFetcher } from 'react-router';
import { useEffect, useState } from 'react';
import { Button, Input } from 'react-aria-components';
import Spinner from '~/components/Spinner';
import TableList from '~/components/TableList';
import { cn } from '~/utils/cn';
type Properties = {
readonly baseDomain?: string;
readonly searchDomains: string[];
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
};
export default function Domains({
baseDomain,
searchDomains,
disabled,
}: Properties) {
// eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types
const [activeId, setActiveId] = useState<number | string | null>(null);
const [localDomains, setLocalDomains] = useState(searchDomains);
const [newDomain, setNewDomain] = useState('');
const fetcher = useFetcher();
useEffect(() => {
setLocalDomains(searchDomains);
}, [searchDomains]);
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
<p className="text-gray-700 dark:text-gray-300 mb-2">
Set custom DNS search domains for your Tailnet. When using Magic DNS,
your tailnet domain is used as the first search domain.
</p>
<DndContext
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
collisionDetection={closestCorners}
onDragStart={(event) => {
setActiveId(event.active.id);
}}
onDragEnd={(event) => {
// eslint-disable-next-line unicorn/no-null
setActiveId(null);
const { active, over } = event;
if (!over) {
return;
}
const activeItem = localDomains[(active.id as number) - 1];
const overItem = localDomains[(over.id as number) - 1];
if (!activeItem || !overItem) {
return;
}
const oldIndex = localDomains.indexOf(activeItem);
const newIndex = localDomains.indexOf(overItem);
if (oldIndex !== newIndex) {
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
}
}}
>
<TableList>
{baseDomain ? (
<TableList.Item key="magic-dns-sd">
<p className="font-mono text-sm">{baseDomain}</p>
<LockIcon className="h-4 w-4" />
</TableList.Item>
) : undefined}
<SortableContext
items={localDomains}
strategy={verticalListSortingStrategy}
>
{localDomains.map((sd, index) => (
<Domain
// eslint-disable-next-line react/no-array-index-key
key={index}
domain={sd}
id={index + 1}
localDomains={localDomains}
disabled={disabled}
fetcher={fetcher}
/>
))}
<DragOverlay adjustScale>
{activeId ? (
<Domain
isDrag
domain={localDomains[(activeId as number) - 1]}
localDomains={localDomains}
id={(activeId as number) - 1}
disabled={disabled}
fetcher={fetcher}
/>
) : undefined}
</DragOverlay>
</SortableContext>
{disabled ? undefined : (
<TableList.Item key="add-sd">
<Input
type="text"
className="font-mono text-sm bg-transparent w-full mr-2"
placeholder="Search Domain"
value={newDomain}
onChange={(event) => {
setNewDomain(event.target.value);
}}
/>
{fetcher.state === 'idle' ? (
<Button
className={cn(
'text-sm font-semibold',
'text-blue-600 dark:text-blue-400',
'hover:text-blue-700 dark:hover:text-blue-300',
newDomain.length === 0 && 'opacity-50 cursor-not-allowed',
)}
isDisabled={newDomain.length === 0}
onPress={() => {
fetcher.submit(
{
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns.search_domains': [...localDomains, newDomain],
},
{
method: 'PATCH',
encType: 'application/json',
},
);
setNewDomain('');
}}
>
Add
</Button>
) : (
<Spinner className="w-3 h-3 mr-0" />
)}
</TableList.Item>
)}
</TableList>
</DndContext>
</div>
);
}
type DomainProperties = {
readonly domain: string;
readonly id: number;
readonly isDrag?: boolean;
readonly localDomains: string[];
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
readonly fetcher: FetcherWithComponents<unknown>;
};
function Domain({
domain,
id,
localDomains,
isDrag,
disabled,
fetcher,
}: DomainProperties) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
// TODO: Figure out why TableList.Item breaks dndkit
return (
<div
ref={setNodeRef}
className={cn(
'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0 dark:border-zinc-800',
isDragging ? 'text-gray-400' : '',
isDrag
? 'outline outline-1 outline-gray-500 bg-gray-200 dark:bg-zinc-800'
: '',
)}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
>
<p className="font-mono text-sm flex items-center gap-4">
{disabled ? undefined : (
<ThreeBarsIcon
className="h-4 w-4 text-gray-400 focus:outline-none"
{...attributes}
{...listeners}
/>
)}
{domain}
</p>
{isDrag ? undefined : (
<Button
className={cn(
'text-sm',
'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300',
disabled && 'opacity-50 cursor-not-allowed',
)}
isDisabled={disabled}
onPress={() => {
fetcher.submit(
{
'dns.search_domains': localDomains.filter(
(_, index) => index !== id - 1,
),
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}}
>
Remove
</Button>
)}
</div>
);
}
-59
View File
@@ -1,59 +0,0 @@
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,205 @@
import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Lock } from 'lucide-react';
import { useEffect, useState } from 'react';
import { type FetcherWithComponents, Form, useFetcher } from 'react-router';
import Button from '~/components/Button';
import Input from '~/components/Input';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
interface Props {
searchDomains: string[];
isDisabled: boolean;
magic?: string;
}
export default function ManageDomains({
searchDomains,
isDisabled,
magic,
}: Props) {
const [activeId, setActiveId] = useState<number | string | null>(null);
const [localDomains, setLocalDomains] = useState(searchDomains);
useEffect(() => {
setLocalDomains(searchDomains);
}, [searchDomains]);
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
<p className="mb-4">
Set custom DNS search domains for your Tailnet. When using Magic DNS,
your tailnet domain is used as the first search domain.
</p>
<DndContext
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
collisionDetection={closestCorners}
onDragStart={(event) => {
setActiveId(event.active.id);
}}
onDragEnd={(event) => {
setActiveId(null);
const { active, over } = event;
if (!over) {
return;
}
const activeItem = localDomains[(active.id as number) - 1];
const overItem = localDomains[(over.id as number) - 1];
if (!activeItem || !overItem) {
return;
}
const oldIndex = localDomains.indexOf(activeItem);
const newIndex = localDomains.indexOf(overItem);
if (oldIndex !== newIndex) {
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
}
}}
>
<TableList>
{magic ? (
<TableList.Item key="magic-dns-sd">
<div
className={cn(
'flex items-center gap-4',
isDisabled ? 'flex-row-reverse justify-between w-full' : '',
)}
>
<Lock className="p-0.5" />
<p className="font-mono text-sm py-0.5">{magic}</p>
</div>
</TableList.Item>
) : undefined}
<SortableContext
items={localDomains}
strategy={verticalListSortingStrategy}
>
{localDomains.map((sd, index) => (
<Domain
key={sd}
domain={sd}
id={index + 1}
isDisabled={isDisabled}
/>
))}
<DragOverlay adjustScale>
{activeId ? (
<Domain
isDragging
domain={localDomains[(activeId as number) - 1]}
id={(activeId as number) - 1}
isDisabled={isDisabled}
/>
) : undefined}
</DragOverlay>
</SortableContext>
{isDisabled ? undefined : (
<TableList.Item key="add-sd">
<Form
method="POST"
className="flex items-center justify-between w-full"
>
<input type="hidden" name="action_id" value="add_domain" />
<Input
type="text"
className={cn(
'border-none font-mono p-0 text-sm',
'rounded-none focus:ring-0 w-full ml-1',
)}
placeholder="Search Domain"
label="Search Domain"
name="domain"
labelHidden
isRequired
/>
<Button
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-blue-500 dark:text-blue-400',
)}
>
Add
</Button>
</Form>
</TableList.Item>
)}
</TableList>
</DndContext>
</div>
);
}
interface DomainProps {
domain: string;
id: number;
isDragging?: boolean;
isDisabled: boolean;
}
function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging: isSortableDragging,
} = useSortable({ id });
return (
<TableList.Item
ref={setNodeRef}
className={cn(
isSortableDragging ? 'opacity-50' : '',
isDragging ? 'ring bg-white dark:bg-headplane-900' : '',
)}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
>
<p className="font-mono text-sm flex items-center gap-4">
{isDisabled ? undefined : (
<GripVertical
{...attributes}
{...listeners}
className="p-0.5 focus:ring outline-none rounded-md"
/>
)}
{domain}
</p>
{isDragging ? undefined : (
<Form method="POST">
<input type="hidden" name="action_id" value="remove_domain" />
<input type="hidden" name="domain" value={domain} />
<Button
type="submit"
isDisabled={isDisabled}
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
>
Remove
</Button>
</Form>
)}
</TableList.Item>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { Form } from 'react-router';
import Button from '~/components/Button';
import Link from '~/components/Link';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
import AddNS from '../dialogs/add-ns';
interface Props {
nameservers: Record<string, string[]>;
isDisabled: boolean;
}
export default function ManageNS({ nameservers, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p>
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 : <AddNS nameservers={nameservers} />}
</div>
</div>
);
}
interface ListProps {
isGlobal: boolean;
isDisabled: boolean;
nameservers: Record<string, string[]>;
name: string;
}
function NameserverList({
isGlobal,
isDisabled,
nameservers,
name,
}: ListProps) {
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) => (
<TableList.Item key={ns}>
<p className="font-mono text-sm">{ns}</p>
<Form method="POST">
<input type="hidden" name="action_id" value="remove_ns" />
<input type="hidden" name="ns" value={ns} />
<input
type="hidden"
name="split_name"
value={isGlobal ? 'global' : name}
/>
<Button
isDisabled={isDisabled}
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
>
Remove
</Button>
</Form>
</TableList.Item>
))
: undefined}
</TableList>
</div>
);
}
@@ -0,0 +1,75 @@
import { Form } from 'react-router';
import Button from '~/components/Button';
import Code from '~/components/Code';
import Link from '~/components/Link';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
import AddRecord from '../dialogs/add-record';
interface Props {
records: { name: string; type: 'A' | string; value: string }[];
isDisabled: boolean;
}
export default function ManageRecords({ records, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
<p>
Headscale supports adding custom DNS records to your Tailnet. As of now,
only <Code>A</Code> records are supported.{' '}
<Link
to="https://headscale.net/stable/ref/dns"
name="Headscale DNS Records documentation"
>
Learn More
</Link>
</p>
<div className="mt-4">
<TableList className="mb-8">
{records.length === 0 ? (
<TableList.Item>
<p className="opacity-50 mx-auto">No DNS records found</p>
</TableList.Item>
) : (
records.map((record, index) => (
<TableList.Item key={`${record.name}-${record.value}`}>
<div className="flex gap-24 items-center">
<div className="flex gap-4 items-center">
<p
className={cn(
'font-mono text-sm font-bold py-1 px-2 rounded-md',
'bg-headplane-100 dark:bg-headplane-700/30',
)}
>
{record.type}
</p>
<p className="font-mono text-sm">{record.name}</p>
</div>
<p className="font-mono text-sm">{record.value}</p>
</div>
<Form method="POST">
<input type="hidden" name="action_id" value="remove_record" />
<input type="hidden" name="record_name" value={record.name} />
<input type="hidden" name="record_type" value={record.type} />
<Button
type="submit"
isDisabled={isDisabled}
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
>
Remove
</Button>
</Form>
</TableList.Item>
))
)}
</TableList>
{isDisabled ? undefined : <AddRecord records={records} />}
</div>
</div>
);
}
-125
View File
@@ -1,125 +0,0 @@
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,48 @@
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
interface Props {
name: string;
isDisabled: boolean;
}
export default function RenameTailnet({ name, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3 gap-y-4">
<h1 className="text-2xl font-medium mb-2">Tailnet Name</h1>
<p>
This is the base domain name of your Tailnet. Devices are accessible at{' '}
<Code>[device].{name}</Code> when Magic DNS is enabled.
</p>
<Input
isReadOnly
labelHidden
className="w-3/5 font-medium text-sm"
label="Tailnet name"
value={name}
onFocus={(event) => {
event.target.select();
}}
/>
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Rename Tailnet</Dialog.Button>
<Dialog.Panel isDisabled={isDisabled}>
<Dialog.Title>Rename Tailnet</Dialog.Title>
<Dialog.Text className="mb-8">
Keep in mind that changing this can lead to all sorts of unexpected
behavior and may break existing devices in your tailnet.
</Dialog.Text>
<input type="hidden" name="action_id" value="rename_tailnet" />
<Input
isRequired
label="Tailnet name"
placeholder="ts.net"
defaultValue={name}
name="new_name"
/>
</Dialog.Panel>
</Dialog>
</div>
);
}
-92
View File
@@ -1,92 +0,0 @@
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,31 @@
import Dialog from '~/components/Dialog';
interface Props {
isEnabled: boolean;
isDisabled: boolean;
}
export default function Modal({ isEnabled, isDisabled }: Props) {
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Button>
<Dialog.Panel isDisabled={isDisabled}>
<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>
<input type="hidden" name="action_id" value="toggle_magic" />
<input
type="hidden"
name="new_state"
value={isEnabled ? 'disabled' : 'enabled'}
/>
</Dialog.Panel>
</Dialog>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { RepoForkedIcon } from '@primer/octicons-react';
import { useMemo, useState } from 'react';
import Chip from '~/components/Chip';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Switch from '~/components/Switch';
import Tooltip from '~/components/Tooltip';
import cn from '~/utils/cn';
interface Props {
nameservers: Record<string, string[]>;
}
export default function AddNameserver({ nameservers }: Props) {
const [split, setSplit] = useState(false);
const [ns, setNs] = useState('');
const [domain, setDomain] = useState('');
const isInvalid = useMemo(() => {
if (ns === '') return false;
// Test if it's a valid IPv4 or IPv6 address
const ipv4 = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
const ipv6 = /^([0-9a-fA-F:]+:+)+[0-9a-fA-F]+$/;
if (!ipv4.test(ns) && !ipv6.test(ns)) return true;
if (split) {
return nameservers[domain]?.includes(ns);
}
return Object.values(nameservers).some((nsList) => nsList.includes(ns));
}, [nameservers, ns]);
return (
<Dialog>
<Dialog.Button>Add nameserver</Dialog.Button>
<Dialog.Panel>
<Dialog.Title className="mb-4">Add nameserver</Dialog.Title>
<input type="hidden" name="action_id" value="add_ns" />
<Input
isRequired
label="Nameserver"
description="Use this IPv4 or IPv6 address to resolve names."
placeholder="1.2.3.4"
name="ns"
onChange={setNs}
isInvalid={isInvalid}
/>
<div className="flex items-center justify-between mt-8">
<div className="block">
<div className="inline-flex items-center gap-2">
<Dialog.Text className="font-semibold">
Restrict to domain
</Dialog.Text>
<Tooltip>
<Chip
text="Split DNS"
leftIcon={<RepoForkedIcon className="w-4 h-4 mr-0.5" />}
className={cn('inline-flex items-center')}
/>
<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" onChange={setSplit} />
</div>
{split ? (
<>
<Dialog.Text className="font-semibold mt-8">Domain</Dialog.Text>
<Input
isRequired={split === true}
label="Domain"
placeholder="example.com"
name="split_name"
onChange={setDomain}
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries matching this suffix
should use the nameserver.
</Dialog.Text>
</>
) : (
<input type="hidden" name="split_name" value="global" />
)}
</Dialog.Panel>
</Dialog>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useMemo, useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
interface Props {
records: { name: string; type: 'A' | string; value: string }[];
}
export default function AddRecord({ records }: Props) {
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>
<Dialog.Title>Add DNS record</Dialog.Title>
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<div className="flex flex-col gap-2 mt-4">
<input type="hidden" name="action_id" value="add_record" />
<input type="hidden" name="record_type" value="A" />
<Input
isRequired
label="Domain"
placeholder="test.example.com"
name="record_name"
onChange={setName}
isInvalid={isDuplicate}
/>
<Input
isRequired
label="IP Address"
placeholder="101.101.101.101"
name="record_value"
onChange={setIp}
isInvalid={isDuplicate}
/>
{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>
</Dialog.Panel>
</Dialog>
);
}
-103
View File
@@ -1,103 +0,0 @@
import { Form, useSubmit } from 'react-router';
import { useMemo, useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
import { cn } from '~/utils/cn';
interface Props {
records: { name: string; type: 'A'; value: string }[];
}
export default function AddDNS({ records }: Props) {
const submit = useSubmit();
const [name, setName] = useState('');
const [ip, setIp] = useState('');
const isDuplicate = useMemo(() => {
if (name.length === 0 || ip.length === 0) return false;
const lookup = records.find((record) => record.name === name);
if (!lookup) return false;
return lookup.value === ip;
}, [records, name, ip]);
return (
<Dialog>
<Dialog.Button>Add DNS record</Dialog.Button>
<Dialog.Panel>
{(close) => (
<>
<Dialog.Title>Add DNS record</Dialog.Title>
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
event.preventDefault();
if (!name || !ip) return;
setName('');
setIp('');
submit(
{
'dns.extra_records': [
...records,
{
name,
type: 'A',
value: ip,
},
],
},
{
method: 'PATCH',
encType: 'application/json',
},
);
close();
}}
>
<TextField
label="Domain"
placeholder="test.example.com"
name="domain"
state={[name, setName]}
className={cn('mt-2', isDuplicate && 'outline outline-red-500')}
/>
<TextField
label="IP Address"
placeholder="101.101.101.101"
name="ip"
state={[ip, setIp]}
className={cn(isDuplicate && 'outline outline-red-500')}
/>
{isDuplicate ? (
<p className="text-sm opacity-50">
A record with the domain name <Code>{name}</Code> and IP
address <Code>{ip}</Code> already exists.
</p>
) : undefined}
<div className="mt-6 flex justify-end gap-2 mt-8">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
isDisabled={isDuplicate}
>
Add
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
-156
View File
@@ -1,156 +0,0 @@
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>
);
}
+223
View File
@@ -0,0 +1,223 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { hp_getIntegration } from '~/utils/integration/loader';
export async function dnsAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
if (!context.hs.writable()) {
return data({ success: false }, 403);
}
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
return data({ success: false }, 400);
}
switch (action) {
case 'rename_tailnet':
return renameTailnet(formData, context);
case 'toggle_magic':
return toggleMagic(formData, context);
case 'remove_ns':
return removeNs(formData, context);
case 'add_ns':
return addNs(formData, context);
case 'remove_domain':
return removeDomain(formData, context);
case 'add_domain':
return addDomain(formData, context);
case 'remove_record':
return removeRecord(formData, context);
case 'add_record':
return addRecord(formData, context);
default:
return data({ success: false }, 400);
}
}
async function renameTailnet(formData: FormData, context: LoadContext) {
const newName = formData.get('new_name')?.toString();
if (!newName) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.base_domain',
value: newName,
},
]);
await hp_getIntegration()?.onConfigChange();
}
async function toggleMagic(formData: FormData, context: LoadContext) {
const newState = formData.get('new_state')?.toString();
if (!newState) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.magic_dns',
value: newState === 'enabled',
},
]);
await hp_getIntegration()?.onConfigChange();
}
async function removeNs(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await hp_getIntegration()?.onConfigChange();
}
async function addNs(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global;
servers.push(ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName] ?? [];
servers.push(ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await hp_getIntegration()?.onConfigChange();
}
async function removeDomain(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains.filter((i) => i !== domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await hp_getIntegration()?.onConfigChange();
}
async function addDomain(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains;
domains.push(domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await hp_getIntegration()?.onConfigChange();
}
async function removeRecord(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
if (!recordName || !recordType) {
return data({ success: false }, 400);
}
const records = config.dns.extra_records.filter(
(i) => i.name !== recordName || i.type !== recordType,
);
await context.hs.patch([
{
path: 'dns.extra_records',
value: records,
},
]);
await hp_getIntegration()?.onConfigChange();
}
async function addRecord(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
const recordValue = formData.get('record_value')?.toString();
if (!recordName || !recordType || !recordValue) {
return data({ success: false }, 400);
}
const records = config.dns.extra_records;
records.push({ name: recordName, type: recordType, value: recordValue });
await context.hs.patch([
{
path: 'dns.extra_records',
value: records,
},
]);
await hp_getIntegration()?.onConfigChange();
}
+26 -52
View File
@@ -1,33 +1,26 @@
import type { ActionFunctionArgs } from 'react-router';
import { data, useLoaderData } from 'react-router';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import Code from '~/components/Code';
import Notice from '~/components/Notice';
import { loadContext } from '~/utils/config/headplane';
import { loadConfig, patchConfig } from '~/utils/config/headscale';
import { getSession } from '~/utils/sessions.server';
import { useLiveData } from '~/utils/useLiveData';
import DNS from './components/dns';
import Domains from './components/domains';
import MagicModal from './components/magic';
import Nameservers from './components/nameservers';
import RenameModal from './components/rename';
import type { LoadContext } from '~/server';
import ManageDomains from './components/manage-domains';
import ManageNS from './components/manage-ns';
import ManageRecords from './components/manage-records';
import RenameTailnet from './components/rename-tailnet';
import ToggleMagic from './components/toggle-magic';
import { dnsAction } from './dns-actions';
// We do not want to expose every config value
export async function loader() {
const context = await loadContext();
if (!context.config.read) {
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
if (!context.hs.readable()) {
throw new Error('No configuration is available');
}
const config = await loadConfig();
const config = context.hs.c!;
const dns = {
prefixes: config.prefixes,
magicDns: config.dns.magic_dns,
baseDomain: config.dns.use_username_in_magic_dns
? `[user].${config.dns.base_domain}`
: config.dns.base_domain,
baseDomain: config.dns.base_domain,
nameservers: config.dns.nameservers.global,
splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains,
@@ -36,33 +29,15 @@ export async function loader() {
return {
...dns,
...context,
writable: context.hs.writable(),
};
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return data({ success: false }, { status: 401 });
}
const context = await loadContext();
if (!context.config.write) {
return data({ success: false }, { status: 403 });
}
const patch = (await request.json()) as Record<string, unknown>;
await patchConfig(patch);
if (context.integration?.onConfigChange) {
await context.integration.onConfigChange(context.integration.context);
}
return data({ success: true });
export async function action(data: ActionFunctionArgs) {
return dnsAction(data);
}
export default function Page() {
useLiveData({ interval: 5000 });
const data = useLoaderData<typeof loader>();
const allNs: Record<string, string[]> = {};
@@ -71,29 +46,28 @@ export default function Page() {
}
allNs.global = data.nameservers;
const isDisabled = data.writable === false;
return (
<div className="flex flex-col gap-16 max-w-screen-lg">
{data.config.write ? undefined : (
{data.writable ? undefined : (
<Notice>
The Headscale configuration is read-only. You cannot make changes to
the configuration
</Notice>
)}
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
<Nameservers nameservers={allNs} isDisabled={!data.config.write} />
<DNS records={data.extraRecords} isDisabled={!data.config.write} />
<Domains
baseDomain={data.magicDns ? data.baseDomain : undefined}
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
<ManageNS nameservers={allNs} isDisabled={isDisabled} />
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
<ManageDomains
searchDomains={data.searchDomains}
disabled={!data.config.write}
isDisabled={isDisabled}
magic={data.magicDns ? data.baseDomain : undefined}
/>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
<p className="text-gray-700 dark:text-gray-300 mb-4">
<p className="mb-4">
Automatically register domain names for each device on the tailnet.
Devices will be accessible at{' '}
<Code>
@@ -102,7 +76,7 @@ export default function Page() {
</Code>{' '}
when Magic DNS is enabled.
</p>
<MagicModal isEnabled={data.magicDns} disabled={!data.config.write} />
<ToggleMagic isEnabled={data.magicDns} isDisabled={isDisabled} />
</div>
</div>
);
+44 -27
View File
@@ -1,20 +1,14 @@
import type { ActionFunctionArgs } from 'react-router';
import { del, post } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { send } from '~/utils/res';
import type { LoadContext } from '~/server';
import log from '~/utils/log';
import { send } from '~/utils/res';
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,
},
);
}
// TODO: Turn this into the same thing as dns-actions like machine-actions!!!
export async function menuAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const data = await request.formData();
if (!data.has('_method') || !data.has('id')) {
return send(
@@ -30,12 +24,15 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
switch (method) {
case 'delete': {
await del(`v1/node/${id}`, session.get('hsApiKey')!);
await context.client.delete(`v1/node/${id}`, session.get('api_key')!);
return { message: 'Machine removed' };
}
case 'expire': {
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!);
await context.client.post(
`v1/node/${id}/expire`,
session.get('api_key')!,
);
return { message: 'Machine expired' };
}
@@ -50,8 +47,10 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
}
const name = String(data.get('name'));
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!);
await context.client.post(
`v1/node/${id}/rename/${name}`,
session.get('api_key')!,
);
return { message: 'Machine renamed' };
}
@@ -69,7 +68,10 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
const enabled = data.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!);
await context.client.post(
`v1/routes/${route}/${postfix}`,
session.get('api_key')!,
);
return { message: 'Route updated' };
}
@@ -89,7 +91,10 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
await Promise.all(
routes.map(async (route) => {
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!);
await context.client.post(
`v1/routes/${route}/${postfix}`,
session.get('api_key')!,
);
}),
);
@@ -109,9 +114,17 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
const to = String(data.get('to'));
try {
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!);
await context.client.post(
`v1/node/${id}/user`,
session.get('api_key')!,
{
user: to,
},
);
return { message: `Moved node ${id} to ${to}` };
} catch {
} catch (error) {
console.error(error);
return send(
{ message: `Failed to move node ${id} to ${to}` },
{
@@ -130,13 +143,17 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
.filter((tag) => tag.trim() !== '') ?? [];
try {
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
tags,
});
await context.client.post(
`v1/node/${id}/tags`,
session.get('api_key')!,
{
tags,
},
);
return { message: 'Tags updated' };
} catch (error) {
log.debug('APIC', 'Failed to update tags: %s', error);
log.debug('api', 'Failed to update tags: %s', error);
return send(
{ message: 'Failed to update tags' },
{
@@ -174,7 +191,7 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
qp.append('key', key);
const url = `v1/node/register?${qp.toString()}`;
await post(url, session.get('hsApiKey')!, {
await context.client.post(url, session.get('api_key')!, {
user,
key,
});
+71 -75
View File
@@ -1,24 +1,33 @@
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react';
import { Link } from 'react-router';
import { useMemo } from 'react';
import { Link } from 'react-router';
import Chip from '~/components/Chip';
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 type { HostInfo, Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import * as hinfo from '~/utils/host-info';
import toast from '~/utils/toast';
import MenuOptions from './menu';
interface Props {
machine: Machine;
routes: Route[];
users: User[];
isAgent?: boolean;
magic?: string;
stats?: HostInfo;
}
export default function MachineRow({ machine, routes, magic, users, stats }: Props) {
export default function MachineRow({
machine,
routes,
users,
isAgent,
magic,
stats,
}: Props) {
const expired =
machine.expiry === '0001-01-01 00:00:00' ||
machine.expiry === '0001-01-01T00:00:00Z' ||
@@ -26,7 +35,7 @@ export default function MachineRow({ machine, routes, magic, users, stats }: Pro
? false
: new Date(machine.expiry).getTime() < Date.now();
const tags = [...machine.forcedTags, ...machine.validTags];
const tags = [...new Set([...machine.forcedTags, ...machine.validTags])];
if (expired) {
tags.unshift('Expired');
@@ -72,13 +81,28 @@ export default function MachineRow({ machine, routes, magic, users, stats }: Pro
tags.unshift('Subnets');
}
if (isAgent) {
tags.unshift('Headplane Agent');
}
const ipOptions = useMemo(() => {
if (magic) {
return [...machine.ipAddresses, `${machine.givenName}.${prefix}`];
}
return machine.ipAddresses;
}, [magic, machine.ipAddresses]);
return (
<tr
key={machine.id}
className="hover:bg-zinc-100 dark:hover:bg-zinc-800 group"
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
>
<td className="pl-0.5 py-2">
<Link to={`/machines/${machine.id}`} className="group/link h-full">
<td className="pl-0.5 py-2 focus-within:ring">
<Link
to={`/machines/${machine.id}`}
className={cn('group/link h-full focus:outline-none')}
>
<p
className={cn(
'font-semibold leading-snug',
@@ -88,21 +112,10 @@ export default function MachineRow({ machine, routes, magic, users, stats }: Pro
>
{machine.givenName}
</p>
<p className="text-sm text-gray-500 dark:text-gray-300 font-mono">
{machine.name}
</p>
<p className="text-sm font-mono opacity-50">{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>
<Chip key={tag} text={tag} />
))}
</div>
</Link>
@@ -110,72 +123,55 @@ export default function MachineRow({ machine, routes, magic, users, stats }: Pro
<td className="py-2">
<div className="flex items-center gap-x-1">
{machine.ipAddresses[0]}
<Menu>
<Menu.Button>
<Menu placement="bottom end">
<Menu.IconButton className="bg-transparent" label="IP Addresses">
<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.IconButton>
<Menu.Panel
onAction={async (key) => {
await navigator.clipboard.writeText(key.toString());
toast('Copied IP address to clipboard');
}}
>
<Menu.Section>
{ipOptions.map((ip) => (
<Menu.Item key={ip} textValue={ip}>
<div
className={cn(
'flex items-center justify-between',
'text-sm w-full gap-x-6',
)}
>
{ip}
<CopyIcon className="w-3 h-3" />
</div>
</Menu.Item>
))}
</Menu.Section>
</Menu.Panel>
</Menu>
</div>
</td>
{/**
<td className="py-2">
{/* We pass undefined when agents are not enabled */}
{isAgent !== undefined ? (
<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="leading-snug">{hinfo.getTSVersion(stats)}</p>
<p className="text-sm opacity-50 max-w-48 truncate">
{hinfo.getOSInfo(stats)}
</p>
</>
) : (
<p className="text-sm text-gray-500 dark:text-gray-300">
Unknown
</p>
<p className="text-sm opacity-50">Unknown</p>
)}
</td>
**/}
</td>
) : undefined}
<td className="py-2">
<span
className={cn(
'flex items-center gap-x-1 text-sm',
'text-gray-500 dark:text-gray-400',
'text-headplane-600 dark:text-headplane-300',
)}
>
<StatusCircle
+102 -55
View File
@@ -1,10 +1,8 @@
import { KebabHorizontalIcon } from '@primer/octicons-react';
import { type ReactNode, useState } from 'react';
import MenuComponent from '~/components/Menu';
import { Cog, Ellipsis } from 'lucide-react';
import { useState } from 'react';
import Menu from '~/components/Menu';
import type { Machine, Route, User } from '~/types';
import { cn } from '~/utils/cn';
import cn from '~/utils/cn';
import Delete from '../dialogs/delete';
import Expire from '../dialogs/expire';
import Move from '../dialogs/move';
@@ -17,22 +15,19 @@ interface MenuProps {
routes: Route[];
users: User[];
magic?: string;
buttonChild?: ReactNode;
isFullButton?: boolean;
}
export default function Menu({
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
export default function MachineMenu({
machine,
routes,
magic,
users,
buttonChild,
isFullButton,
}: 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 [modal, setModal] = useState<Modal>(null);
const expired =
machine.expiry === '0001-01-01 00:00:00' ||
@@ -43,51 +38,103 @@ export default function Menu({
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} />
{modal === 'remove' && (
<Delete
machine={machine}
isOpen={modal === 'remove'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'move' && (
<Move
machine={machine}
users={users}
isOpen={modal === 'move'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'rename' && (
<Rename
machine={machine}
magic={magic}
isOpen={modal === 'rename'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'routes' && (
<Routes
machine={machine}
routes={routes}
isOpen={modal === 'routes'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'tags' && (
<Tags
machine={machine}
isOpen={modal === 'tags'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{expired && modal === 'expire' ? undefined : (
<Expire
machine={machine}
isOpen={modal === 'expire'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
<MenuComponent>
{buttonChild ?? (
<MenuComponent.Button
<Menu>
{isFullButton ? (
<Menu.Button className="flex items-center gap-x-2">
<Cog className="h-5" />
<p>Machine Settings</p>
</Menu.Button>
) : (
<Menu.IconButton
label="Machine Options"
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',
'py-0.5 w-10 bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
)}
>
<KebabHorizontalIcon className="w-5" />
</MenuComponent.Button>
<Ellipsis className="h-5" />
</Menu.IconButton>
)}
<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>
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
<Menu.Section>
<Menu.Item key="rename">Edit machine name</Menu.Item>
<Menu.Item key="routes">Edit route settings</Menu.Item>
<Menu.Item key="tags">Edit ACL tags</Menu.Item>
<Menu.Item key="move">Change owner</Menu.Item>
</Menu.Section>
<Menu.Section>
{expired ? (
<></>
) : (
<Menu.Item key="expire" textValue="Expire">
<p className="text-red-500 dark:text-red-400">Expire</p>
</Menu.Item>
)}
<Menu.Item key="remove" textValue="Remove">
<p className="text-red-500 dark:text-red-400">Remove</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
</>
);
}
+18 -46
View File
@@ -1,57 +1,29 @@
import { Form, useSubmit } from 'react-router';
import type { Dispatch, SetStateAction } from 'react';
import { useNavigate } from 'react-router';
import Dialog from '~/components/Dialog';
import type { Machine } from '~/types';
import { cn } from '~/utils/cn';
interface DeleteProps {
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Delete({ machine, state }: DeleteProps) {
const submit = useSubmit();
export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
const navigate = useNavigate();
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from your network. To
re-add it, you will need to reauthenticate to your tailnet from
the device.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="id" value={machine.id} />
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
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}
>
Remove
</Dialog.Action>
</div>
</Form>
</>
)}
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel
variant="destructive"
onSubmit={() => navigate('/machines')}
>
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from your network. To re-add
it, you will need to reauthenticate to your tailnet from the device.
</Dialog.Text>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="id" value={machine.id} />
</Dialog.Panel>
</Dialog>
);
+13 -46
View File
@@ -1,56 +1,23 @@
import { Form, useSubmit } from 'react-router';
import type { Dispatch, SetStateAction } from 'react';
import Dialog from '~/components/Dialog';
import type { Machine } from '~/types';
import { cn } from '~/utils/cn';
interface ExpireProps {
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Expire({ machine, state }: ExpireProps) {
const submit = useSubmit();
export default function Expire({ machine, isOpen, setIsOpen }: ExpireProps) {
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog.Title>Expire {machine.givenName}</Dialog.Title>
<Dialog.Text>
This will disconnect the machine from your Tailnet. In order to
reconnect, you will need to re-authenticate from the device.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="expire" />
<input type="hidden" name="id" value={machine.id} />
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
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}
>
Expire
</Dialog.Action>
</div>
</Form>
</>
)}
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="destructive">
<Dialog.Title>Expire {machine.givenName}</Dialog.Title>
<Dialog.Text>
This will disconnect the machine from your Tailnet. In order to
reconnect, you will need to re-authenticate from the device.
</Dialog.Text>
<input type="hidden" name="_method" value="expire" />
<input type="hidden" name="id" value={machine.id} />
</Dialog.Panel>
</Dialog>
);
+23 -60
View File
@@ -1,71 +1,34 @@
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;
machine: Machine;
users: User[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Move({ machine, state, magic, users }: MoveProps) {
const [owner, setOwner] = useState(machine.user.name);
const submit = useSubmit();
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
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 isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<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>
<input type="hidden" name="_method" value="move" />
<input type="hidden" name="id" value={machine.id} />
<Select
label="Owner"
name="to"
placeholder="Select a user"
defaultSelectedKey={machine.user.id}
>
{users.map((user) => (
<Select.Item key={user.id}>{user.name}</Select.Item>
))}
</Select>
</Dialog.Panel>
</Dialog>
);
+66 -108
View File
@@ -1,16 +1,12 @@
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 { Computer, KeySquare } from 'lucide-react';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
import Select from '~/components/Select';
import Input from '~/components/Input';
import Menu from '~/components/Menu';
import Spinner from '~/components/Spinner';
import { toast } from '~/components/Toaster';
import { Machine, type User } from '~/types';
import Select from '~/components/Select';
import type { User } from '~/types';
export interface NewProps {
server: string;
@@ -18,112 +14,74 @@ export interface NewProps {
}
export default function New(data: NewProps) {
const fetcher = useFetcher<{ success?: boolean }>();
const mkeyState = useState(false);
const [pushDialog, setPushDialog] = 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]);
const navigate = useNavigate();
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 isOpen={pushDialog} onOpenChange={setPushDialog}>
<Dialog.Panel isDisabled={mkey.length < 1}>
<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>
<input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" />
<Input
isRequired
label="Machine Key"
placeholder="AbCd..."
validationBehavior="native"
name="mkey"
onChange={setMkey}
/>
<Select
isRequired
label="Owner"
name="user"
placeholder="Select a user"
>
{data.users.map((user) => (
<Select.Item key={user.id}>{user.name}</Select.Item>
))}
</Select>
</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',
)}
<Menu.Button variant="heavy">Add Device</Menu.Button>
<Menu.Panel
onAction={(key) => {
if (key === 'register') {
setPushDialog(true);
return;
}
if (key === 'pre-auth') {
navigate('/settings/auth-keys');
}
}}
>
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.Section>
<Menu.Item key="register" textValue="Register Machine Key">
<div className="flex items-center gap-x-3">
<Computer className="w-4" />
Register Machine Key
</div>
</Menu.Item>
<Menu.Item key="pre-auth" textValue="Generate Pre-auth Key">
<div className="flex items-center gap-x-3">
<KeySquare className="w-4" />
Generate Pre-auth Key
</div>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
</>
);
+46 -65
View File
@@ -1,78 +1,59 @@
import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction, useState } from 'react';
import { useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
import Input from '~/components/Input';
import type { Machine } from '~/types';
interface RenameProps {
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
readonly magic?: string;
machine: Machine;
isOpen: boolean;
magic?: string;
setIsOpen: (isOpen: boolean) => void;
}
export default function Rename({ machine, state, magic }: RenameProps) {
export default function Rename({
machine,
magic,
isOpen,
setIsOpen,
}: 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 isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Edit machine name for {machine.givenName}</Dialog.Title>
<Dialog.Text className="mb-6">
This name is shown in the admin panel, in Tailscale clients, and used
when generating MagicDNS names.
</Dialog.Text>
<input type="hidden" name="_method" value="rename" />
<input type="hidden" name="id" value={machine.id} />
<Input
label="Machine name"
placeholder="Machine name"
name="name"
defaultValue={machine.givenName}
onChange={setName}
/>
{magic ? (
name.length > 0 && name !== machine.givenName ? (
<p className="text-sm text-headplane-600 dark:text-headplane-300 leading-tight mt-2">
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-headplane-600 dark:text-headplane-300 leading-tight mt-2">
This machine is accessible by the hostname{' '}
<Code className="text-sm">{machine.givenName}</Code>.
</p>
)
) : undefined}
</Dialog.Panel>
</Dialog>
);
+94 -136
View File
@@ -1,20 +1,27 @@
import { GlobeLock, RouteOff } from 'lucide-react';
import { useMemo } from 'react';
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 Switch from '~/components/Switch';
import TableList from '~/components/TableList';
import type { Machine, Route } from '~/types';
import { cn } from '~/utils/cn';
import cn from '~/utils/cn';
interface RoutesProps {
readonly machine: Machine;
readonly routes: Route[];
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
machine: Machine;
routes: Route[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
// TODO: Support deleting routes
export default function Routes({ machine, routes, state }: RoutesProps) {
export default function Routes({
machine,
routes,
isOpen,
setIsOpen,
}: RoutesProps) {
const fetcher = useFetcher();
// This is much easier with Object.groupBy but it's too new for us
@@ -40,136 +47,87 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
}, [exit]);
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog.Title>
Edit route settings of {machine.givenName}
</Dialog.Title>
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on by
advertising IP ranges as subnet routes.{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation"
>
Learn More
</Link>
</Dialog.Text>
<div
className={cn(
'rounded-lg overflow-y-auto my-2',
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border border-zinc-200 dark:border-zinc-700',
)}
>
{subnet.length === 0 ? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>No routes are advertised on this machine.</p>
</div>
) : undefined}
{subnet.map((route) => (
<div
key={route.id}
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between',
)}
>
<p>{route.prefix}</p>
<Switch
defaultSelected={route.enabled}
label="Enabled"
onChange={(checked) => {
const form = new FormData();
form.set('id', machine.id);
form.set('_method', 'routes');
form.set('route', route.id);
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="unactionable">
<Dialog.Title>Edit route settings of {machine.givenName}</Dialog.Title>
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on by advertising
IP ranges as subnet routes.{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation"
>
Learn More
</Link>
</Dialog.Text>
<TableList className="mt-4">
{subnet.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<RouteOff />
<p className="font-semibold">
No routes are advertised by this machine
</p>
</TableList.Item>
) : undefined}
{subnet.map((route) => (
<TableList.Item key={route.id}>
<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',
});
}}
/>
</TableList.Item>
))}
</TableList>
<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>
<TableList className="mt-4">
{exit.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<GlobeLock />
<p className="font-semibold">This machine is not an exit node</p>
</TableList.Item>
) : (
<TableList.Item>
<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>
</>
)}
form.set('enabled', String(checked));
fetcher.submit(form, {
method: 'POST',
});
}}
/>
</TableList.Item>
)}
</TableList>
</Dialog.Panel>
</Dialog>
);
+84 -130
View File
@@ -1,146 +1,100 @@
import { PlusIcon, XIcon } from '@primer/octicons-react';
import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction, useState } from 'react';
import { Button, Input } from 'react-aria-components';
import { Plus, TagsIcon, X } from 'lucide-react';
import { useState } from 'react';
import Button from '~/components/Button';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Link from '~/components/Link';
import TableList from '~/components/TableList';
import type { Machine } from '~/types';
import { cn } from '~/utils/cn';
import cn from '~/utils/cn';
interface TagsProps {
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Tags({ machine, state }: TagsProps) {
export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
const [tags, setTags] = useState(machine.forcedTags);
const [tag, setTag] = useState('');
const submit = useSubmit();
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
<Dialog.Text>
ACL tags can be used to reference machines in your ACL policies.
See the{' '}
<Link
to="https://tailscale.com/kb/1068/acl-tags"
name="Tailscale documentation"
>
Tailscale documentation
</Link>{' '}
for more information.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget);
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
<Dialog.Text>
ACL tags can be used to reference machines in your ACL policies. See
the{' '}
<Link
to="https://tailscale.com/kb/1068/acl-tags"
name="Tailscale documentation"
>
Tailscale documentation
</Link>{' '}
for more information.
</Dialog.Text>
<input type="hidden" name="_method" value="tags" />
<input type="hidden" name="id" value={machine.id} />
<input type="hidden" name="tags" value={tags.join(',')} />
<TableList className="mt-4">
{tags.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<TagsIcon />
<p className="font-semibold">No tags are set on this machine</p>
</TableList.Item>
) : (
tags.map((item) => (
<TableList.Item className="font-mono" key={item} id={item}>
{item}
<Button
className="rounded-md p-0.5"
onPress={() => {
setTags(tags.filter((tag) => tag !== item));
}}
>
<X className="p-1" />
</Button>
</TableList.Item>
))
)}
<TableList.Item
className={cn(
'rounded-b-xl focus-within:ring',
tag.length > 0 &&
(!tag.startsWith('tag:') || tags.includes(tag)) &&
'ring ring-red-500 ring-opacity-50',
)}
>
<Input
labelHidden
label="Add a tag"
placeholder="tag:example"
onChange={setTag}
className={cn(
'border-none font-mono p-0',
'rounded-none focus:ring-0 w-full',
)}
/>
<Button
className={cn(
'rounded-md p-0.5',
(!tag.startsWith('tag:') || tags.includes(tag)) &&
'opacity-50 cursor-not-allowed',
)}
isDisabled={
tag.length === 0 ||
!tag.startsWith('tag:') ||
tags.includes(tag)
}
onPress={() => {
setTags([...tags, tag]);
setTag('');
}}
>
<input type="hidden" name="_method" value="tags" />
<input type="hidden" name="id" value={machine.id} />
<input type="hidden" name="tags" value={tags.join(',')} />
<div
className={cn(
'border border-ui-300 rounded-lg overflow-visible',
'dark:border-ui-700 dark:text-ui-300 mt-4',
)}
>
<div className="divide-y divide-ui-200 dark:divide-ui-600">
{tags.length === 0 ? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center rounded-t-lg',
'text-ui-600 dark:text-ui-300',
)}
>
<p>No tags are set on this machine.</p>
</div>
) : (
tags.map((item) => (
<div
key={item}
id={item}
className={cn(
'px-2.5 py-1.5 flex',
'items-center justify-between',
'font-mono text-sm',
)}
>
{item}
<Button
className="rounded-full p-0 w-6 h-6"
onPress={() => {
setTags(tags.filter((tag) => tag !== item));
}}
>
<XIcon className="w-4 h-4" />
</Button>
</div>
))
)}
</div>
<div
className={cn(
'flex px-2.5 py-1.5 w-full',
'border-t border-ui-300 dark:border-ui-700',
'rounded-b-lg justify-between items-center',
'dark:bg-ui-800 dark:text-ui-300',
'focus-within:ring-2 focus-within:ring-blue-600',
tag.length > 0 &&
!tag.startsWith('tag:') &&
'outline outline-red-500',
)}
>
<Input
placeholder="tag:example"
className={cn(
'bg-transparent w-full',
'border-none focus:ring-0',
'focus:outline-none font-mono text-sm',
'dark:bg-transparent dark:text-ui-300',
)}
value={tag}
onChange={(e) => {
setTag(e.currentTarget.value);
}}
/>
<Button
className={cn(
'rounded-lg p-0 h-6 w-6',
!tag.startsWith('tag:') &&
'opacity-50 cursor-not-allowed',
)}
isDisabled={
tag.length === 0 ||
!tag.startsWith('tag:') ||
tags.includes(tag)
}
onPress={() => {
setTags([...tags, tag]);
setTag('');
}}
>
<PlusIcon className="w-4 h-4" />
</Button>
</div>
</div>
<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}>
Save
</Dialog.Action>
</div>
</Form>
</>
)}
<Plus className="p-1" />
</Button>
</TableList.Item>
</TableList>
</Dialog.Panel>
</Dialog>
);
+83 -115
View File
@@ -1,53 +1,48 @@
import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
import { useMemo, useState } from 'react';
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 Chip from '~/components/Chip';
import Link from '~/components/Link';
import StatusCircle from '~/components/StatusCircle';
import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server';
import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
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'));
export async function loader({
request,
params,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
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;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.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')!),
context.client.get<{ node: Machine }>(
`v1/node/${params.id}`,
session.get('api_key')!,
),
context.client.get<{ routes: Route[] }>(
'v1/routes',
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
]);
return {
@@ -55,17 +50,22 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
routes: routes.routes.filter((route) => route.node.id === params.id),
users: users.users,
magic,
// TODO: Fix agent
agent: false,
// agent: [...(hp_getSingletonUnsafe('ws_agents') ?? []).keys()].includes(
// machine.node.id,
// ),
};
}
export async function action({ request }: ActionFunctionArgs) {
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 { machine, magic, routes, users, agent } =
useLoaderData<typeof loader>();
const [showRouting, setShowRouting] = useState(false);
const expired =
machine.expiry === '0001-01-01 00:00:00' ||
@@ -74,12 +74,16 @@ export default function Page() {
? false
: new Date(machine.expiry).getTime() < Date.now();
const tags = [...machine.forcedTags, ...machine.validTags];
const tags = [...new Set([...machine.forcedTags, ...machine.validTags])];
if (expired) {
tags.unshift('Expired');
}
if (agent) {
tags.unshift('Headplane Agent');
}
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce<{
exit: Route[];
@@ -127,81 +131,59 @@ export default function Page() {
</p>
<div
className={cn(
'flex justify-between items-center',
'border-b border-ui-100 dark:border-ui-800',
'flex justify-between items-center pb-2',
'border-b border-headplane-100 dark:border-headplane-800',
)}
>
<span className="flex items-baseline gap-x-4 text-sm mb-4">
<span className="flex items-baseline gap-x-4 text-sm">
<h1 className="text-2xl font-medium">{machine.givenName}</h1>
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
</span>
<MenuOptions
isFullButton
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">
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
<span className="text-sm text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Managed by
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Info className="p-1" />
<Tooltip.Body>
By default, a machines permissions match its creators.
</Tooltip.Body>
</Tooltip>
</span>
<div className="flex items-center gap-x-2.5 mt-1">
<div
className={cn(
'rounded-full h-7 w-7 flex items-center justify-center',
'border border-ui-200 dark:border-ui-700',
)}
>
<PersonIcon className="w-4 h-4" />
</div>
<UserCircle />
{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>
))}
{tags.length > 0 ? (
<div className="p-2 pl-4">
<p className="text-sm text-headplane-600 dark:text-headplane-300">
Status
</p>
<div className="flex gap-1 mt-1 mb-8">
{tags.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
</div>
</div>
) : undefined}
</div>
<h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2>
<Routes machine={machine} routes={routes} state={routesState} />
<Routes
machine={machine}
routes={routes}
isOpen={showRouting}
setIsOpen={setShowRouting}
/>
<div className="flex items-center justify-between mb-4">
<p>
Subnets let you expose physical network routes onto Tailscale.{' '}
@@ -212,9 +194,7 @@ export default function Page() {
Learn More
</Link>
</p>
<Button variant="light" control={routesState}>
Review
</Button>
<Button onPress={() => setShowRouting(true)}>Review</Button>
</div>
<Card
variant="flat"
@@ -224,12 +204,10 @@ export default function Page() {
)}
>
<div>
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Approved
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Info className="w-3.5 h-3.5" />
<Tooltip.Body>
Traffic to these routes are being routed through this machine.
</Tooltip.Body>
@@ -237,7 +215,7 @@ export default function Page() {
</span>
<div className="mt-1">
{subnetApproved.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300"></span>
<span className="opacity-50"></span>
) : (
<ul className="leading-normal">
{subnetApproved.map((route) => (
@@ -247,24 +225,20 @@ export default function Page() {
)}
</div>
<Button
onPress={() => setShowRouting(true)}
className={cn(
'p-0 rounded-sm bg-transparent mt-1',
'px-1.5 py-0.5 rounded-md mt-1.5',
'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">
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Awaiting Approval
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Info className="w-3.5 h-3.5" />
<Tooltip.Body>
This machine is advertising these routes, but they must be
approved before traffic will be routed to them.
@@ -273,7 +247,7 @@ export default function Page() {
</span>
<div className="mt-1">
{subnet.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300"></span>
<span className="opacity-50"></span>
) : (
<ul className="leading-normal">
{subnet.map((route) => (
@@ -283,24 +257,20 @@ export default function Page() {
)}
</div>
<Button
onPress={() => setShowRouting(true)}
className={cn(
'p-0 rounded-sm bg-transparent mt-1',
'px-1.5 py-0.5 rounded-md mt-1.5',
'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">
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Exit Node
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Info className="w-3.5 h-3.5" />
<Tooltip.Body>
Whether this machine can act as an exit node for your tailnet.
</Tooltip.Body>
@@ -308,27 +278,25 @@ export default function Page() {
</span>
<div className="mt-1">
{exit.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300"></span>
<span className="opacity-50"></span>
) : exitEnabled ? (
<span className="flex items-center gap-x-1">
<CheckCircleIcon className="w-3.5 h-3.5 text-green-700" />
<CheckCircle 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" />
<CircleSlash className="w-3.5 h-3.5 text-red-700" />
Awaiting Approval
</span>
)}
</div>
<Button
onPress={() => setShowRouting(true)}
className={cn(
'p-0 rounded-sm bg-transparent mt-1',
'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400',
'hover:bg-transparent',
'hover:text-blue-600 dark:hover:text-blue-500',
)}
control={routesState}
>
Edit
</Button>
+49 -61
View File
@@ -1,46 +1,38 @@
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 { ErrorPopup } from '~/components/Error';
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 Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server';
import type { Machine, Route, User } from '~/types';
import { queryAgent, initAgentSocket } from '~/utils/ws-agent';
import { ErrorPopup } from '~/components/Error'
import cn from '~/utils/cn';
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'));
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
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')!),
context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.get('api_key')!,
),
context.client.get<{ routes: Route[] }>(
'v1/routes',
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
]);
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}`;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
@@ -49,26 +41,26 @@ export async function loader({ request, context: lC }: LoaderFunctionArgs) {
routes: routes.routes,
users: users.users,
magic,
stats,
server: context.headscaleUrl,
publicServer: context.headscalePublicUrl,
server: context.config.headscale.url,
publicServer: context.config.headscale.public_url,
agents: context.agents?.tailnetIDs(),
stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)),
};
}
export async function action({ request }: ActionFunctionArgs) {
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 justify-between items-center mb-6">
<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">
<h1 className="text-2xl font-medium mb-2">Machines</h1>
<p>
Manage the devices connected to your Tailnet.{' '}
<Link
to="https://tailscale.com/kb/1372/manage-devices"
@@ -84,43 +76,38 @@ export default function Page() {
/>
</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>
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
<th className="uppercase text-xs font-bold pb-2">Name</th>
<th className="pb-2 w-1/4">
<div className="flex items-center gap-x-1">
Addresses
<p className="uppercase text-xs font-bold">Addresses</p>
{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',
)}
>
<Tooltip>
<InfoIcon className="w-4 h-4" />
<Tooltip.Body className="font-normal">
Since MagicDNS is enabled, you can access devices based on
their name and also at{' '}
<Code>
[name].
{data.magic}
</Code>
</Tooltip>
</TooltipTrigger>
</Tooltip.Body>
</Tooltip>
) : undefined}
</div>
</th>
{/**<th className="pb-2">Version</th>**/}
<th className="pb-2">Last Seen</th>
{/* We only want to show the version column if there are agents */}
{data.agents !== undefined ? (
<th className="uppercase text-xs font-bold pb-2">Version</th>
) : undefined}
<th className="uppercase text-xs font-bold 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',
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
'border-t border-headplane-100 dark:border-headplane-800',
)}
>
{data.nodes.map((machine) => (
@@ -132,6 +119,9 @@ export default function Page() {
)}
users={data.users}
magic={data.magic}
// If we pass undefined, the column will not be rendered
// This is useful for when there are no agents configured
isAgent={data.agents?.includes(machine.id)}
stats={data.stats?.[machine.nodeKey]}
/>
))}
@@ -142,7 +132,5 @@ export default function Page() {
}
export function ErrorBoundary() {
return (
<ErrorPopup type="embedded" />
)
return <ErrorPopup type="embedded" />;
}
+84 -100
View File
@@ -1,33 +1,50 @@
import type { LoaderFunctionArgs, ActionFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import { useLiveData } from '~/utils/useLiveData';
import { getSession } from '~/utils/sessions.server';
import { Link as RemixLink } from 'react-router';
import type { PreAuthKey, User } from '~/types';
import { pull, post } from '~/utils/headscale';
import { loadContext } from '~/utils/config/headplane';
import { useState } from 'react';
import { send } from '~/utils/res';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import { Link as RemixLink } from 'react-router';
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 TableList from '~/components/TableList';
import type { LoadContext } from '~/server';
import type { PreAuthKey, User } from '~/types';
import { send } from '~/utils/res';
import AuthKeyRow from './components/key';
import AddPreAuthKey from './dialogs/new';
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return send(
{ message: 'Unauthorized' },
{
status: 401,
},
);
}
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const users = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
const preAuthKeys = await Promise.all(
users.users.map((user) => {
const qp = new URLSearchParams();
qp.set('user', user.name);
return context.client.get<{ preAuthKeys: PreAuthKey[] }>(
`v1/preauthkey?${qp.toString()}`,
session.get('api_key')!,
);
}),
);
return {
keys: preAuthKeys.flatMap((keys) => keys.preAuthKeys),
users: users.users,
server: context.config.headscale.public_url ?? context.config.headscale.url,
};
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const data = await request.formData();
// Expiring a pre-auth key
@@ -44,9 +61,9 @@ export async function action({ request }: ActionFunctionArgs) {
);
}
await post<{ preAuthKey: PreAuthKey }>(
await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey/expire',
session.get('hsApiKey')!,
session.get('api_key')!,
{
user: user,
key: key,
@@ -78,9 +95,9 @@ export async function action({ request }: ActionFunctionArgs) {
const date = new Date();
date.setDate(date.getDate() + day);
const key = await post<{ preAuthKey: PreAuthKey }>(
const key = await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
session.get('hsApiKey')!,
session.get('api_key')!,
{
user: user,
ephemeral: ephemeral === 'on',
@@ -94,61 +111,33 @@ export async function action({ request }: ActionFunctionArgs) {
}
}
export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext();
const session = await getSession(request.headers.get('Cookie'));
const users = await pull<{ users: User[] }>(
'v1/user',
session.get('hsApiKey')!,
);
const preAuthKeys = await Promise.all(
users.users.map((user) => {
const qp = new URLSearchParams();
qp.set('user', user.name);
return pull<{ preAuthKeys: PreAuthKey[] }>(
`v1/preauthkey?${qp.toString()}`,
session.get('hsApiKey')!,
);
}),
);
return {
keys: preAuthKeys.flatMap((keys) => keys.preAuthKeys),
users: users.users,
server: context.headscalePublicUrl ?? context.headscaleUrl,
};
}
export default function Page() {
const { keys, users, server } = useLoaderData<typeof loader>();
const [user, setUser] = useState('All');
const [status, setStatus] = useState('Active');
useLiveData({ interval: 3000 });
const [user, setUser] = useState('__headplane_all');
const [status, setStatus] = useState('active');
const filteredKeys = keys.filter((key) => {
if (user !== 'All' && key.user !== user) {
if (user !== '__headplane_all' && key.user !== user) {
return false;
}
if (status !== 'All') {
if (status !== 'all') {
const now = new Date();
const expiry = new Date(key.expiration);
if (status === 'Active') {
if (status === 'active') {
return !(expiry < now) && (!key.used || key.reusable);
}
if (status === 'Used/Expired') {
if (status === 'expired') {
return key.used || expiry < now;
}
if (status === 'Reusable') {
if (status === 'reusable') {
return key.reusable;
}
if (status === 'Ephemeral') {
if (status === 'ephemeral') {
return key.ephemeral;
}
}
@@ -156,6 +145,7 @@ export default function Page() {
return true;
});
// TODO: Fix the selects
return (
<div className="flex flex-col w-2/3">
<p className="mb-8 text-md">
@@ -164,8 +154,8 @@ export default function Page() {
</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">
<h1 className="text-2xl font-medium mb-2">Pre-Auth Keys</h1>
<p className="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{' '}
@@ -177,40 +167,34 @@ export default function Page() {
</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 className="flex items-center gap-4 mt-4">
<Select
label="Filter by User"
placeholder="Select a user"
className="w-full"
defaultSelectedKey="__headplane_all"
onSelectionChange={(value) => setUser(value?.toString() ?? '')}
>
{[
<Select.Item key="__headplane_all">All</Select.Item>,
...users.map((user) => (
<Select.Item key={user.name}>{user.name}</Select.Item>
)),
]}
</Select>
<Select
label="Filter by status"
placeholder="Select a status"
className="w-full"
defaultSelectedKey="active"
onSelectionChange={(value) => setStatus(value?.toString() ?? '')}
>
<Select.Item key="all">All</Select.Item>
<Select.Item key="active">Active</Select.Item>
<Select.Item key="expired">Used/Expired</Select.Item>
<Select.Item key="reusable">Reusable</Select.Item>
<Select.Item key="ephemeral">Ephemeral</Select.Item>
</Select>
</div>
<TableList className="mt-4">
{filteredKeys.length === 0 ? (
+11 -16
View File
@@ -1,19 +1,19 @@
import Link from '~/components/Link';
import Button from '~/components/Button';
import { Link as RemixLink } from 'react-router';
import { ArrowRightIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
import { Link as RemixLink } from 'react-router';
import Button from '~/components/Button';
import Link from '~/components/Link';
import cn from '~/utils/cn';
export default function AgentSection() {
return (
<>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Local Agent</h1>
<p className="text-gray-700 dark:text-gray-300">
Headplane provides a local agent that can be installed on a
server to provide additional features including viewing device
information and SSH access via the web interface (soon).
To learn more about the agent visit the{' '}
<p>
Headplane provides a local agent that can be installed on a server to
provide additional features including viewing device information and
SSH access via the web interface (soon). To learn more about the agent
visit the{' '}
<Link
to="https://github.com/tale/headplane/blob/main/docs/Headplane-Agent.md"
name="Headplane Agent Documentation"
@@ -23,16 +23,11 @@ export default function AgentSection() {
</p>
</div>
<RemixLink to="/settings/local-agent">
<div
className={cn(
'text-lg font-medium flex items-center',
'text-gray-700 dark:text-gray-300',
)}
>
<div className={cn('text-lg font-medium flex items-center')}>
Manage Agent
<ArrowRightIcon className="w-5 h-5 ml-2" />
</div>
</RemixLink>
</>
)
);
}
@@ -1,5 +1,5 @@
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import Card from '~/components/Card';
import StatusCircle from '~/components/StatusCircle';
import type { HostInfo } from '~/types';
import * as hinfo from '~/utils/host-info';
@@ -12,26 +12,21 @@ export default function AgentManagement({ reachable, hostInfo }: Props) {
console.log('hostInfo:', hostInfo);
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">
Local Agent Configuration
</h1>
<p className="text-gray-700 dark:text-gray-300 mb-8">
A local agent has already been configured for this
Headplane instance. You can manage the agent settings here.
<h1 className="text-2xl font-medium mb-4">Local Agent Configuration</h1>
<p className="mb-8">
A local agent has already been configured for this Headplane instance.
You can manage the agent settings here.
</p>
<Card>
<div className="flex items-center gap-2">
<StatusCircle
isOnline={reachable}
className="w-4 h-4 px-1 w-fit"
/>
<StatusCircle isOnline={reachable} className="w-4 h-4 px-1" />
<div>
<p className="text-lg font-bold">
{hostInfo.Hostname ?? 'Unknown'}
</p>
<p className="leading-snug">
{hinfo.getTSVersion(hostInfo)}
<span className="ml-2 text-sm text-gray-500 dark:text-gray-300">
<span className="ml-2 text-sm text-headplane-600 dark:text-headplane-300">
{hinfo.getOSInfo(hostInfo)}
</span>
</p>
@@ -40,5 +35,5 @@ export default function AgentManagement({ reachable, hostInfo }: Props) {
{JSON.stringify(hostInfo)}
</Card>
</div>
)
);
}
+4 -4
View File
@@ -1,9 +1,9 @@
import type { PreAuthKey } from '~/types';
import { toast } from '~/components/Toaster';
import Code from '~/components/Code';
import Button from '~/components/Button';
import Attribute from '~/components/Attribute';
import Button from '~/components/Button';
import Code from '~/components/Code';
import toast from '~/utils/toast';
import ExpireKey from '../dialogs/expire';
interface Props {
@@ -31,7 +31,7 @@ export default function AuthKeyRow({ authKey, server }: Props) {
tailscale up --login-server {server} --authkey {authKey.key}
</Code>
<div className="flex gap-4 items-center">
{authKey.used && !authKey.reusable ||
{(authKey.used && !authKey.reusable) ||
new Date(authKey.expiration) < new Date() ? undefined : (
<ExpireKey authKey={authKey} />
)}
+10 -49
View File
@@ -1,61 +1,22 @@
import { useFetcher } from 'react-router';
import type { PreAuthKey } from '~/types';
import { cn } from '~/utils/cn';
import Dialog from '~/components/Dialog';
import Spinner from '~/components/Spinner';
import type { PreAuthKey } from '~/types';
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.Button>Expire Key</Dialog.Button>
<Dialog.Panel method="DELETE" variant="destructive">
<Dialog.Title>Expire auth key?</Dialog.Title>
<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>
</Dialog.Panel>
</Dialog>
);

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