mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
Compare commits
481 Commits
0.3.8
...
v0.6.2-beta.1
| Author | SHA1 | Date | |
|---|---|---|---|
| e09c5760af | |||
| a824ea3dcf | |||
| bf3fdd47cd | |||
| f6cdad535a | |||
| ba61656fb0 | |||
| d363ed5486 | |||
| 45de5f6833 | |||
| afc5d5451a | |||
| ef6563742c | |||
| 86184e3420 | |||
| d3d7c7cc0e | |||
| c0c4ecf631 | |||
| 5ea96db816 | |||
| cc533f1b72 | |||
| 3d9b6370bf | |||
| afd512138b | |||
| 08a251cc8c | |||
| 63d1e84ebe | |||
| 20f5342ea3 | |||
| db850942ef | |||
| c9986ba466 | |||
| d331a22951 | |||
| 6be801e9bb | |||
| 299859e279 | |||
| 5090733013 | |||
| 7d77f2cb81 | |||
| c3e07e4e10 | |||
| 1aa921aebe | |||
| 444b2325fb | |||
| c84e9ca4a8 | |||
| d68737e410 | |||
| a68aedc297 | |||
| 1c0561edb8 | |||
| e883a7cd63 | |||
| cc2112a805 | |||
| afde3399e1 | |||
| b1c93512df | |||
| ce4be73faf | |||
| 192cce23b0 | |||
| 7d7e08ee54 | |||
| 06a667b561 | |||
| 75c688fd00 | |||
| 28ad1d439f | |||
| 135d2940f1 | |||
| 497a782f17 | |||
| 6d5e44f585 | |||
| 94e5cb95c6 | |||
| 800d73248d | |||
| 66b6dea068 | |||
| 489b12460f | |||
| ef8dcec9c8 | |||
| f66f778398 | |||
| 6eabfb7610 | |||
| a68df033a2 | |||
| 53b8f76ac6 | |||
| df4d864838 | |||
| c9bae42622 | |||
| a2a28e747b | |||
| d175171efc | |||
| ac9ce72078 | |||
| f44881b399 | |||
| 3b93f2fb61 | |||
| 1b2d7e305a | |||
| 639e7e9c6b | |||
| 5d94667186 | |||
| 225dfceddb | |||
| cd379cd10d | |||
| 8f260b79f1 | |||
| eaffcd7b62 | |||
| 4ccc73d7e4 | |||
| 7a222c47ee | |||
| cb9d0d5317 | |||
| bb0d24b708 | |||
| ebe487daf0 | |||
| 4fd8062476 | |||
| 9967ef9e4c | |||
| 43cbc177d6 | |||
| 0eef9aea9b | |||
| 9b53711673 | |||
| 341a0e8f81 | |||
| cee28fba2f | |||
| 65f56842de | |||
| 4803b2aa4a | |||
| 1dfb2fb777 | |||
| 07c5c6d496 | |||
| 4df15fd4b2 | |||
| 5b138c191a | |||
| 957157de5f | |||
| 9f0564943b | |||
| 2fcd19e2b4 | |||
| eb4669498a | |||
| bda9dedbfd | |||
| a9d5e10be9 | |||
| bd8a7a56d4 | |||
| d7b1e19985 | |||
| 9bc8483a8f | |||
| 4351e1fcb3 | |||
| 9183f805a6 | |||
| cd4e8f8399 | |||
| 6c5656334d | |||
| ff3bdc1898 | |||
| 356abab90a | |||
| 82f6294499 | |||
| 8fc657f86a | |||
| bcd87453bd | |||
| 7adacae940 | |||
| 797ed56c07 | |||
| ae2bd35cbf | |||
| d2c4f5eb2b | |||
| 8cb91cd45b | |||
| a4a037ed68 | |||
| 17712cbb1c | |||
| 616b63bb71 | |||
| 361bc493c0 | |||
| bc3fc5ec5d | |||
| c98dc5ed1d | |||
| 9192013bfe | |||
| fdbfe584c5 | |||
| 97e2557710 | |||
| 82a3eb626e | |||
| cdcb38f11e | |||
| f20b2c869f | |||
| 1355e1535d | |||
| ebd219b622 | |||
| 1ce0dc375e | |||
| 1eaf3abe21 | |||
| 8a9235836a | |||
| 344901d38c | |||
| ea004df2bb | |||
| c59632a0b7 | |||
| 89e38e1356 | |||
| d257b22f91 | |||
| 441e31690b | |||
| e0fde7d0f4 | |||
| 943e5d2a15 | |||
| 7f952bcb3b | |||
| c3112507b5 | |||
| 8b3d0e9bb6 | |||
| 7a34a01dd0 | |||
| 51a85e1bfe | |||
| 5ab9686460 | |||
| 5cfd9e411b | |||
| 65a14aefcf | |||
| f0c1f617c7 | |||
| 44577aff81 | |||
| 27b2831f00 | |||
| ad469129bc | |||
| 7691f74d43 | |||
| f7547a4c62 | |||
| ce4617d0e1 | |||
| 6d99aca058 | |||
| d809eea564 | |||
| eacde3deb8 | |||
| fc7bedc8b1 | |||
| ecc547601f | |||
| eed12d348e | |||
| 5a254b746b | |||
| 2cc4339499 | |||
| 84c820efb1 | |||
| 779af42601 | |||
| f990670e86 | |||
| 0344f17887 | |||
| f28bfd5f86 | |||
| 8f1b577b5e | |||
| 6ea9fe169d | |||
| 9a81b76398 | |||
| 144a8b87cd | |||
| 2bf088b4a9 | |||
| 87b8d64bcc | |||
| 8819af270d | |||
| 1150d1616e | |||
| dd287c0444 | |||
| cf55621b57 | |||
| 73f0a0d273 | |||
| 71f130ede5 | |||
| c0af2aa9c8 | |||
| b18147fa82 | |||
| bf1d75a27a | |||
| 1e86b0e95b | |||
| b34a3f0ca1 | |||
| 799d7b5728 | |||
| 4961e83a9c | |||
| ab7587e7a9 | |||
| 6d15c411f1 | |||
| 7f376c3f70 | |||
| 6a0e097412 | |||
| 721439868f | |||
| f4af5b920d | |||
| 0f9bf73b82 | |||
| 7a6ad8d2d5 | |||
| f9751f5ab2 | |||
| 46f1576b8f | |||
| ccde3513dc | |||
| a0a80852eb | |||
| cb32637938 | |||
| 55eacb59e9 | |||
| 7dfcbef774 | |||
| f6d8ad25e1 | |||
| e36193a8c0 | |||
| 0c87f3e6ec | |||
| 090f5c1ba9 | |||
| 9b9d5432c6 | |||
| 2a26a74b65 | |||
| a298686d6b | |||
| 2f316176c8 | |||
| 59525b7b63 | |||
| b7009e387b | |||
| e173376319 | |||
| 59d7f15694 | |||
| 0866ced767 | |||
| 40a2eb4186 | |||
| cc0c7fa61d | |||
| d25e5fd883 | |||
| 769674ab07 | |||
| 0276a500c7 | |||
| d6fbc3b5ad | |||
| 662c38ea44 | |||
| c827f56b6b | |||
| 753a403aed | |||
| ffce20e88a | |||
| eda8ed0b3e | |||
| 59b32c0842 | |||
| 68bd6200fc | |||
| f56d4e9d9f | |||
| 602e55a94d | |||
| 5e60ac1208 | |||
| b1da29b2fb | |||
| 0808299369 | |||
| 39d6602263 | |||
| df4f250e0f | |||
| 129e4f648b | |||
| 2a4d23d70f | |||
| f4da4c6428 | |||
| 7f5e0fe42d | |||
| 021ecabd3c | |||
| 81feb2f19a | |||
| abedf40690 | |||
| c9874966d2 | |||
| 887834efd9 | |||
| 6f2a0773ba | |||
| 8c01fcfe89 | |||
| f93acd8ef1 | |||
| a510796e38 | |||
| 69d97ea585 | |||
| 000e9326fb | |||
| 8299aac94f | |||
| 08ee0eff42 | |||
| d1f395acfd | |||
| 6bc20bd0e6 | |||
| 130b603d58 | |||
| ae0aadd83f | |||
| 6be55ec686 | |||
| fb3bc35b40 | |||
| dedec8f317 | |||
| 7eda09bae8 | |||
| ed4041073c | |||
| c5e6a6c407 | |||
| 608fcacdb1 | |||
| 3dca401837 | |||
| 785d5a663f | |||
| ece304638f | |||
| 1a2b1ad3f2 | |||
| 169a7b14d0 | |||
| 3a5ab68432 | |||
| 62863ad649 | |||
| 91fcf5233e | |||
| 8ea8c7195f | |||
| c3a50ea4f4 | |||
| 6716e5f0b0 | |||
| 346b44ec69 | |||
| faa61b0f1d | |||
| 6b63fe209f | |||
| c8507cff7c | |||
| b86f4461c0 | |||
| 77b510c927 | |||
| 5adcb8c582 | |||
| 524c5eb639 | |||
| 2894c664d3 | |||
| 5c2d08decd | |||
| f2e8c6ae4c | |||
| c3e727842a | |||
| 96345ab0a6 | |||
| fe2d7cb57a | |||
| 66c7d9a327 | |||
| f2747ada94 | |||
| 0ad578e651 | |||
| 8d1132606a | |||
| 5e332c4a5c | |||
| 69c6fc4847 | |||
| 9b09b13b5f | |||
| cf90f3dd32 | |||
| 72c1174cb3 | |||
| 63bfad77ce | |||
| 41223b89b3 | |||
| 93d5c2ed29 | |||
| 6a94e815f2 | |||
| 234020eec5 | |||
| 58cc7b742c | |||
| 259d150fc4 | |||
| 5d3fada266 | |||
| 9d046a0cf6 | |||
| 1fb084451d | |||
| d5fb8a2966 | |||
| 7b1340c93e | |||
| 16a8122f85 | |||
| 7d61ad50c4 | |||
| 103a826178 | |||
| 090dec1ca6 | |||
| 8596a56375 | |||
| 000ec620b4 | |||
| 83a69792ea | |||
| fee5f423a6 | |||
| d698cf5478 | |||
| 80c987f383 | |||
| 17d477bf0f | |||
| 5e5c7c4c7a | |||
| 2e383ddabe | |||
| 2299907932 | |||
| 3771890f98 | |||
| bf02015dc7 | |||
| 8429b19c4a | |||
| 9a5952adcb | |||
| 222ac7a279 | |||
| 6f40f9cfac | |||
| 457cbc45e6 | |||
| aac8a9ef12 | |||
| b8d22beb17 | |||
| cac64a6fbe | |||
| 5918d0e501 | |||
| 03acebb23e | |||
| 73ea35980d | |||
| 9a1051b9af | |||
| c066b3064d | |||
| 98d02bb595 | |||
| 2964ff295e | |||
| b0a3f9d5fd | |||
| 34cfee7cff | |||
| 8db323b63f | |||
| 08c25caca3 | |||
| cbbd64e91a | |||
| 48fc0f7ef3 | |||
| 23fd2bbda2 | |||
| 05837963c4 | |||
| 2a16115e69 | |||
| 5675ecdeac | |||
| 29424366a8 | |||
| c47346df52 | |||
| 92dedf51aa | |||
| 8f7a85f47d | |||
| ed1cf188dc | |||
| 03a7e51384 | |||
| 483f86c457 | |||
| a8a4d2a6f8 | |||
| eef5bd0648 | |||
| 6108de52e7 | |||
| 43e06987ad | |||
| 7741ab88bf | |||
| 3bdbfdc033 | |||
| e701e16112 | |||
| 79d83537d5 | |||
| 251c16ca48 | |||
| 134b38ceda | |||
| b8fb1fa40f | |||
| 951a96fad6 | |||
| 5b9dc7cfc6 | |||
| e36352b7f3 | |||
| 296e4d489c | |||
| c3ddac42a0 | |||
| 01f432cedc | |||
| 9c8a2c0120 | |||
| 5f6460f42f | |||
| d9314887fb | |||
| 5dd4c41291 | |||
| 45537620a6 | |||
| 21af5c4a4d | |||
| 983356611e | |||
| 41ca4c40ad | |||
| f7c9a14e92 | |||
| 6e514a9064 | |||
| 631ea15895 | |||
| 762bc6a793 | |||
| 6c83e96341 | |||
| 03e6a26b3a | |||
| 5b716ab5ce | |||
| a7e4f3e4d2 | |||
| 40ddb69bc9 | |||
| 6aa6e77611 | |||
| c5c2f8a93b | |||
| acd042b4de | |||
| a563d4c0b3 | |||
| 7117060863 | |||
| 30a1153fa5 | |||
| 870680b6c5 | |||
| 25e6410c65 | |||
| f982217dd0 | |||
| f5436f5ee3 | |||
| 06049169a2 | |||
| 774fdb7be2 | |||
| e6580eed2c | |||
| 56b660b30c | |||
| 85c31ca5bf | |||
| 44d08169a9 | |||
| 76d263b7e6 | |||
| 5be3cb345e | |||
| 2a1c795d46 | |||
| 316e5b501f | |||
| 287ac2dff0 | |||
| d1f6c450c0 | |||
| 0a14533756 | |||
| 347c6698ee | |||
| 771b87ae41 | |||
| 31ac25d510 | |||
| 68babd7fe9 | |||
| c9d8052e39 | |||
| 843cfc4d4f | |||
| 6922548317 | |||
| 07cee17501 | |||
| 2c8880c84d | |||
| a19eb6bcda | |||
| 28e40eecbf | |||
| 741f9aa6b5 | |||
| 0f75636342 | |||
| 665509e710 | |||
| ac937f9014 | |||
| 8d3f31e7f9 | |||
| 2d47f1b952 | |||
| c24cd34925 | |||
| af248df648 | |||
| b9a708b2e9 | |||
| ec36876b9f | |||
| ed0cdbdf4d | |||
| fd506ba474 | |||
| 9734f0c704 | |||
| 156d7c9ee7 | |||
| bd11453593 | |||
| 0f92f09796 | |||
| 9bb20024fa | |||
| 698122068d | |||
| e6a66d5804 | |||
| d524f927a4 | |||
| 539c76dfb3 | |||
| 51fa7c14d0 | |||
| b92c4ce2e8 | |||
| 325e9ba43d | |||
| de9a938da2 | |||
| 1b45b0917f | |||
| e01fd8d50c | |||
| c9603ba38a | |||
| 377641265b | |||
| 5bc313e5fe | |||
| af919b2d34 | |||
| f464c42802 | |||
| e667e112cb | |||
| 39546849ae | |||
| eb922c9318 | |||
| 5569ba4660 | |||
| dfd03e77bb | |||
| e33504016b | |||
| 7d4da73141 | |||
| 1316439786 | |||
| 888c1a5fb6 | |||
| 328302cd81 | |||
| b060700dfe | |||
| 69783f0d05 | |||
| 55c832c536 | |||
| fc5028994a | |||
| 6745ee8529 | |||
| 414b95c293 | |||
| 7217659720 | |||
| a4bb3cce5f | |||
| f3c9d8b54c | |||
| aa9872a45b | |||
| 39504e2487 | |||
| 719d7a5e76 | |||
| 6156d78907 | |||
| 1c2c374ada | |||
| 6cf343d623 | |||
| 50e43bc0c3 | |||
| 9d64a5beac | |||
| 240d8d6197 | |||
| b80bb0cc09 |
+8
-4
@@ -1,7 +1,11 @@
|
||||
node_modules
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
|
||||
/.cache
|
||||
/.github
|
||||
/.react-router
|
||||
/node_modules
|
||||
/build
|
||||
/docs
|
||||
/nix
|
||||
|
||||
.dockerignore
|
||||
.env
|
||||
Dockerfile
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
ROOT_API_KEY=abcdefghijklmnopqrstuvwxyz
|
||||
COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz
|
||||
DISABLE_API_KEY_LOGIN=true
|
||||
HEADSCALE_CONTAINER=headscale
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
CONFIG_FILE=/etc/headscale/config.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
* @tale
|
||||
|
||||
/nix @tale @StealthBadger747
|
||||
@@ -0,0 +1,2 @@
|
||||
github: tale
|
||||
ko_fi: atale
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: write # Read/write 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
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Build
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- "assets/**"
|
||||
- "docs/**"
|
||||
- "CHANGELOG.md"
|
||||
- "README.md"
|
||||
branches:
|
||||
- "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:
|
||||
ci:
|
||||
name: Build and Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: CI pipeline
|
||||
run: ./build.sh --skip-pnpm-prune
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm run test:unit
|
||||
|
||||
- name: Run integration tests
|
||||
run: pnpm run test:integration
|
||||
|
||||
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
|
||||
@@ -0,0 +1,81 @@
|
||||
name: Pre-release (next)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
concurrency:
|
||||
group: pre-release-${{ 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
|
||||
id-token: write # For the attest action to push
|
||||
attestations: write # For the attest action to push
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# Ensure the action only runs if manually dispatched or a PR on the `next` branch in the *main* repository is opened or synchronized.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'next') }}
|
||||
name: Docker Pre-release
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: final
|
||||
tag: 'next'
|
||||
- target: debug-shell
|
||||
tag: 'next-shell'
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=${{ matrix.tag }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and publish ghcr.io/${{ github.repository }}:${{ matrix.tag }}
|
||||
uses: docker/build-push-action@v6
|
||||
id: push
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: ${{ matrix.target }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ matrix.tag }}
|
||||
secrets: |
|
||||
gh_token=${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Attestation Provenance for ghcr.io/${{ github.repository }}:${{ matrix.tag }}
|
||||
uses: actions/attest-build-provenance@v2
|
||||
id: attest
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Publish Nightly Docker Image
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 8 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and Publish Nightly
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=edge,branch=main
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
@@ -1,42 +0,0 @@
|
||||
name: Publish Docker Image
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and Publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
@@ -1,44 +0,0 @@
|
||||
name: 'Build'
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
@@ -0,0 +1,79 @@
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
concurrency:
|
||||
group: release-${{ 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
|
||||
id-token: write # For the attest action to push
|
||||
attestations: write # For the attest action to push
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
name: Docker Release
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: final
|
||||
tag_suffix: ''
|
||||
- target: debug-shell
|
||||
tag_suffix: '-shell'
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
flavor: |
|
||||
latest=auto
|
||||
suffix=${{ matrix.tag_suffix }},onlatest=true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
|
||||
uses: docker/build-push-action@v6
|
||||
id: push
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: ${{ matrix.target }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
- name: Attestation Provenance for ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
|
||||
uses: actions/attest-build-provenance@v2
|
||||
id: attest
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
+11
-1
@@ -1,5 +1,15 @@
|
||||
node_modules
|
||||
|
||||
/.react-router
|
||||
/.cache
|
||||
/build
|
||||
/test
|
||||
.env
|
||||
/app/hp_ssh.wasm
|
||||
/app/wasm_exec.js
|
||||
|
||||
/public/hp_ssh.wasm
|
||||
/public/wasm_exec.js
|
||||
|
||||
/docs/.vitepress/dist/
|
||||
/docs/.vitepress/cache/
|
||||
/.direnv
|
||||
|
||||
+271
-22
@@ -1,23 +1,238 @@
|
||||
### 0.3.8 (December 6, 2024)
|
||||
# 0.6.2 (Next)
|
||||
- Added support for Headscale 0.27.0 and 0.27.1
|
||||
- Bundle all `node_modules` aside from native ones to reduce bundle and container size (closes [#331](https://github.com/tale/headplane/issues/331)).
|
||||
- Allow conditionally compiling the SSH WASM integration when building (closes [#337](https://github.com/tale/headplane/issues/337)).
|
||||
- Implemented the ability to customize the build with a custom script (see `./build.sh --help` for more information).
|
||||
- Attempt to warn against misconfigured cookie settings on the login page.
|
||||
- Made `server.cookie_max_age` and `server.cookie_domain` configurable (closes [#348](https://github.com/tale/headplane/issues/348)).
|
||||
- Re-worked the configuration loading system with several enhancements:
|
||||
- It is now possible to skip a configuration file and only use environment variables (closes [#150](https://github.com/tale/headplane/issues/150)).
|
||||
- Secret path loading has been reworked from the ground up to be more reliable (closes [#334](https://github.com/tale/headplane/issues/334)).
|
||||
- Added better testing and validation for configuration loading
|
||||
- Re-worked the OIDC integration to adhere to the correct standards and surface more errors to the user.
|
||||
- Removed several unnecessarily verbose or spammy log messages.
|
||||
- Updated the minimum Docker API used to support the latest Docker versions (via [#370](https://github.com/tale/headplane/pull/370)).
|
||||
- Enhanced the node tag dialog to show a dropdown of assignable tags (via [#362](https://github.com/tale/headplane/pull/362)).
|
||||
- Fixed an issue where the website favicon would not load correctly (closes [#323](https://github.com/tale/headplane/issues/323)).
|
||||
---
|
||||
|
||||
# 0.6.1 (October 12, 2025)
|
||||
- **Headplane now supports connecting to machines via SSH in the web browser.**
|
||||
- This is an experimental feature and requires the `integration.agent` section to be set up in the config file.
|
||||
- This is built on top of a Go binary that runs in WebAssembly, using Xterm.js for the terminal interface.
|
||||
- Begin using a new SQLite database file in `/var/lib/headplane/hp_persist.db`.
|
||||
- The database is created automatically if it does not exist.
|
||||
- It currently stores SSH connection details and HostInfo for the agent.
|
||||
- User information is automatically migrated from the previous database.
|
||||
- The docker container now runs in a distroless image (closes [#255](https://github.com/tale/headplane/issues/255)).
|
||||
- A debug version of the container that runs as root and has a shell is available as `ghcr.io/tale/headplane:<version>-shell`.
|
||||
- Removing a Split DNS record will no longer make the split domain unresolvable by clients (closes [#231](https://github.com/tale/headplane/issues/231)).
|
||||
- Reintroduce the toggle for overriding local DNS settings in the Headscale config (closes [#236](https://github.com/tale/headplane/issues/236)).
|
||||
- Prefer cross-compiling in the Dockerfile to speed up builds while still supporting multiple architectures.
|
||||
- Add a build attestation to validate SLSA provenance for the Docker image.
|
||||
- Implement more accurate guessing on the PID with the `/proc` integration (via [#219](https://github.com/tale/headplane/pull/219)).
|
||||
- Usernames will now correctly fall back to emails if not provided (via [#257](https://github.com/tale/headplane/pull/257)).
|
||||
- Configuration loading via paths is now supported for sensitive values (via [#283](https://github.com/tale/headplane/pulls/283))
|
||||
- Options like `server.cookie_secret_path` can override `server.cookie_secret`
|
||||
- Environment variables are interpolatable into these paths
|
||||
- See the full reference in the [docs](https://github.com/tale/headplane/blob/main/docs/Configuration.md#sensitive-values)
|
||||
- The nix overlay build is fixed for the SSH module (via [#282](https://github.com/tale/headplane/pull/282))
|
||||
- Switch our build processes to use TypeScript Go and Rolldown Vite for better build and type-check performance.
|
||||
- Cookies are now encrypted JWTs, preserving API key secrets (*GHSA-wrqq-v7qw-r5w7*)
|
||||
- OIDC profile pictures are now available from Gravatar by setting `oidc.profile_picture_source` to `gravatar` (closes [#232](https://github.com/tale/headplane/issues/232)).
|
||||
- OIDC now allows passing many custom parameters:
|
||||
- `oidc.authorization_endpoint`, `oidc.token_endpoint`, and `oidc.userinfo_endpoint` can be overridden to support non-standard providers or scenarios without discovery (closes [#117](https://github.com/tale/headplane/issues/117)).
|
||||
- `oidc.scope` can be set to specify custom scopes (defaults to `openid email profile`).
|
||||
- `oidc.extra_params` can be set to pass arbitrary query parameters to the authorization endpoint (closes [#197](https://github.com/tale/headplane/issues/197)).
|
||||
|
||||
---
|
||||
|
||||
# 0.6.0 (May 25, 2025)
|
||||
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.
|
||||
- Breaking API changes with routes and pre auth keys are now supported (closes [#204](https://github.com/tale/headplane/issues/204)).
|
||||
- Older versions of Headscale will not work with Headplane.
|
||||
|
||||
- OIDC authorization restrictions can now be controlled from the settings UI. (closes [#102](https://github.com/tale/headplane/issues/102))
|
||||
- The required permission role for this is **IT Admin** or **Admin/Owner** and require the Headscale configuration.
|
||||
- Changes made will modify the `oidc.allowed_{domains,groups,users}` fields in the Headscale config file.
|
||||
- The Pre-Auth keys page has been fully reworked (closes [#179](https://github.com/tale/headplane/issues/179), [#143](https://github.com/tale/headplane/issues/143)).
|
||||
- The Headplane agent is now available as an integration (closes [#65](https://github.com/tale/headplane/issues/65)).
|
||||
- The agent runs as an embedded process alongside the Headplane server and reports host information and system metrics.
|
||||
- Refer to the `integrations.agent` section of the config file for more information and how to enable it.
|
||||
- Requests to `/admin` will now be redirected to `/admin/` to prevent issues with the React Router (works with custom prefixes, closes [#173](https://github.com/tale/headplane/issues/173)).
|
||||
- The Login page has been simplified and separately reports errors versus incorrect API keys (closes [#186](https://github.com/tale/headplane/issues/186)).
|
||||
- The machine actions backend has been reworked to better handle errors and provide more information to the user (closes [#185](https://github.com/tale/headplane/issues/185)).
|
||||
- Machine tags now show states when waiting for subnet or exit node approval and when expiry is disabled.
|
||||
- Expiry status on the UI was incorrectly showing as never due to changes in the Headscale API.
|
||||
- Added validation for machine renaming to prevent invalid submissions (closes [#192](https://github.com/tale/headplane/issues/192)).
|
||||
- Unmanaged (non-OIDC) users cannot have a role assigned to them so the menu option was disabled.
|
||||
- Support Docker container discovery through labels (via [#194](https://github.com/tale/headplane/pull/194)).
|
||||
- AAAA records are now supported on the DNS page (closes [#189](https://github.com/tale/headplane/issues/189)).
|
||||
- Add support for `dns.extra_records_path` in the Headscale config (closes [#144](https://github.com/tale/headplane/issues/144)).
|
||||
- Tighten `proc` integration logic by checking for the `headscale serve` command (via #[195](https://github.com/tale/headplane/pull/195)).
|
||||
- Strip newlines in the OIDC `client_secret_path` file if provided (closes [#199](https://github.com/tale/headplane/issues/199)).
|
||||
|
||||
---
|
||||
|
||||
# 0.5.10 (April 4, 2025)
|
||||
- Fix an issue where other preferences to skip onboarding affected every user.
|
||||
|
||||
---
|
||||
|
||||
# 0.5.9 (April 3, 2025)
|
||||
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.
|
||||
- OIDC users cannot be renamed, so that functionality has been disabled in the menu options.
|
||||
- Suppress hydration errors for any fields with a date in it.
|
||||
|
||||
---
|
||||
|
||||
# 0.5.8 (April 3, 2025)
|
||||
- You can now skip the onboarding page if desired.
|
||||
- Added the UI to change user roles in the dashboard.
|
||||
- Fixed an issue where integrations would throw instead of loading properly.
|
||||
- Loading the ACL page no longer spams blank updates to the Headscale database (fixes [#151](https://github.com/tale/headplane/issues/151))
|
||||
- Automatically create `/var/lib/headplane` in the Docker container (fixes [#166](https://github.com/tale/headplane/issues/166))
|
||||
- OIDC logout with `disable_api_key_login` set to true will not automatically login again (fixes [#149](https://github.com/tale/headplane/issues/149))
|
||||
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
# 0.4.0 (January 18, 2025)
|
||||
- Switched from Remix.run to React-Router
|
||||
- Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66))
|
||||
- Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
|
||||
- The footer now blurs your Headscale URL as a privacy measure
|
||||
- Updated to the next stable beta of the React Compiler
|
||||
- Changed `/healthz` to use a well-known endpoint instead of trying an invalid API key
|
||||
- Support `OIDC_REDIRECT_URI` to force a specific redirect URI
|
||||
- Redo the OIDC integration for better error handling and configuration
|
||||
- Gracefully handle when Headscale is unreachable instead of crashing the dashboard
|
||||
- Reusable Pre-Auth Keys no longer show expired when used (PR [#88](https://github.com/tale/headplane/pull/88))
|
||||
- Tweaked some CSS issues in the UI
|
||||
|
||||
---
|
||||
|
||||
# 0.3.9 (December 6, 2024)
|
||||
- Fixed a race condition bug in the OIDC validation code
|
||||
|
||||
---
|
||||
|
||||
# 0.3.8 (December 6, 2024)
|
||||
- Added a little HTML footer to show the login page and link to a donation page.
|
||||
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
|
||||
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
|
||||
|
||||
### 0.3.7 (November 30, 2024)
|
||||
---
|
||||
|
||||
# 0.3.7 (November 30, 2024)
|
||||
- Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57))
|
||||
- Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59))
|
||||
- Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60))
|
||||
- Fixed an issue where the copy machine registration command had a typo.
|
||||
|
||||
### 0.3.6 (November 20, 2024)
|
||||
---
|
||||
|
||||
# 0.3.6 (November 20, 2024)
|
||||
- Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53))
|
||||
- Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52))
|
||||
|
||||
### 0.3.5 (November 8, 2024)
|
||||
---
|
||||
|
||||
# 0.3.5 (November 8, 2024)
|
||||
- Quickfix a bug where environment variables are ignored on the server.
|
||||
- Remove a nagging error about missing cookie since that happens when signed out.
|
||||
|
||||
### 0.3.4 (November 7, 2024)
|
||||
---
|
||||
|
||||
# 0.3.4 (November 7, 2024)
|
||||
- Clicking on the machine name in the users page now takes you to the machine overview page.
|
||||
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
|
||||
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
|
||||
@@ -27,70 +242,94 @@
|
||||
- Added full support for Exit Nodes in the UI and redesigned the machines page (fixes [#36](https://github.com/tale/headplane/issues/36))
|
||||
- Added a basic check to see if the API keys passed via cookies are invalid.
|
||||
|
||||
### 0.3.3 (October 28, 2024)
|
||||
---
|
||||
|
||||
# 0.3.3 (October 28, 2024)
|
||||
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
|
||||
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
|
||||
- Improved ACL editor performance by switching back to CodeMirror 6
|
||||
- Fixed an issue where editing the ACL policy would cause it to revert on the UI (fixes [#34](https://github.com/tale/headplane/issues/34))
|
||||
- Updated to the next stable beta of the React 19 Compiler ([See More](https://react.dev/learn/react-compiler))
|
||||
|
||||
### 0.3.2 (October 11, 2024)
|
||||
---
|
||||
|
||||
# 0.3.2 (October 11, 2024)
|
||||
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
|
||||
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
|
||||
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
|
||||
- Handle `null` values on machine expiry when using a database like PostgreSQL.
|
||||
- Use `X-Forwarded-Proto` and `Host` headers for building the OIDC callback URL.
|
||||
|
||||
### 0.3.1 (October 3, 2024)
|
||||
---
|
||||
|
||||
# 0.3.1 (October 3, 2024)
|
||||
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
|
||||
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
|
||||
- Added the option for debug logs with the `DEBUG=true` environment variable.
|
||||
|
||||
### 0.3.0 (September 25, 2024)
|
||||
---
|
||||
|
||||
# 0.3.0 (September 25, 2024)
|
||||
- Bumped the minimum supported version of Headscale to 0.23.
|
||||
- Updated the UI to respect `dns.use_username_in_magic_dns`.
|
||||
|
||||
### 0.2.4 (August 24, 2024)
|
||||
---
|
||||
|
||||
# 0.2.4 (August 24, 2024)
|
||||
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
|
||||
- Removed the `ACL_FILE` environment variable since it's no longer needed.
|
||||
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
|
||||
- Fixed a bug where removing Split DNS configurations would crash the UI.
|
||||
|
||||
### 0.2.3 (August 23, 2024)
|
||||
---
|
||||
|
||||
# 0.2.3 (August 23, 2024)
|
||||
- Change the minimum required version of Headscale to 0.23-beta2
|
||||
- Support the new API policy mode for Headscale 0.23-beta1
|
||||
- Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29))
|
||||
- If OIDC environment variables are defined, don't use configuration file values (fixes [#24](https://github.com/tale/headplane/issues/24))
|
||||
|
||||
### 0.2.2 (August 2, 2024)
|
||||
---
|
||||
|
||||
# 0.2.2 (August 2, 2024)
|
||||
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
|
||||
- Added a new logger utility that shows categories, levels, and timestamps.
|
||||
- Reimplemented the integration system to be more resilient and log more information.
|
||||
- Fixed an issue where the /proc integration found `undefined` PIDs.
|
||||
|
||||
### 0.2.1 (July 7, 2024)
|
||||
---
|
||||
|
||||
# 0.2.1 (July 7, 2024)
|
||||
- Added the ability to manage custom DNS records on your Tailnet.
|
||||
- ACL tags for machines are now able to be changed via the machine menu.
|
||||
- Fixed a bug where the ACL editor did not show the diffs correctly.
|
||||
- Fixed an issue that stopped the "Discard changes" button in the ACL editor from working.
|
||||
|
||||
### 0.2.0 (June 23, 2024)
|
||||
---
|
||||
|
||||
# 0.2.0 (June 23, 2024)
|
||||
- Fix the dropdown options for machines not working on the machines page.
|
||||
- Add an option to change the machine owner in the dropdown (aside from the users page).
|
||||
|
||||
### 0.1.9 (June 2, 2024)
|
||||
---
|
||||
|
||||
# 0.1.9 (June 2, 2024)
|
||||
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
|
||||
- Utilize magic DNS hostnames for the machine overview page.
|
||||
- Fixed the expiry issue once and for all.
|
||||
- Add a nightly build with the `ghcr.io/tale/headplane:edge` tag
|
||||
|
||||
### 0.1.8 (June 2, 2024)
|
||||
---
|
||||
|
||||
# 0.1.8 (June 2, 2024)
|
||||
- Built basic functionality for the machine overview page (by machine ID).
|
||||
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
|
||||
- Prevent users from being deleted if they still have ownership of machines.
|
||||
- Fixed some type issues where `Date` was being used instead of `string` for timestamps.
|
||||
|
||||
### 0.1.7 (May 30, 2024)
|
||||
---
|
||||
|
||||
# 0.1.7 (May 30, 2024)
|
||||
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
|
||||
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
|
||||
- Made the strict configuration loader more lenient to allow for more flexibility.
|
||||
@@ -98,19 +337,25 @@
|
||||
- Headplane's context now only loads once at start instead of being lazy-loaded.
|
||||
- Improved logging and error propagation so that it's easier to debug issues.
|
||||
|
||||
### 0.1.6 (May 22, 2024)
|
||||
---
|
||||
|
||||
# 0.1.6 (May 22, 2024)
|
||||
- Added experimental support for advanced integration without Docker.
|
||||
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
|
||||
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
|
||||
|
||||
### 0.1.5 (May 20, 2024)
|
||||
---
|
||||
|
||||
# 0.1.5 (May 20, 2024)
|
||||
- Robust configuration handling with fallbacks based on the headscale source.
|
||||
- Support for `client_secret_path` on configuration file based OIDC.
|
||||
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
|
||||
- `API_KEY` is renamed to `ROOT_API_KEY` for better clarity (old variable still works).
|
||||
- Fixed button responders not actually being invoked (should fix the ACL page).
|
||||
|
||||
### 0.1.4 (May 15, 2024)
|
||||
---
|
||||
|
||||
# 0.1.4 (May 15, 2024)
|
||||
|
||||
- Users can now be created, renamed, and deleted on the users page.
|
||||
- Machines can be dragged between users to change their ownership.
|
||||
@@ -119,13 +364,17 @@
|
||||
- Upgrade to React 19 beta to take advantage of the compiler (may revert if it causes issues).
|
||||
- Upgrade other dependencies
|
||||
|
||||
### 0.1.3 (May 4, 2024)
|
||||
---
|
||||
|
||||
# 0.1.3 (May 4, 2024)
|
||||
|
||||
- Switched to a better icon set for the UI.
|
||||
- Support stable scrollbar gutter if supported by the browser.
|
||||
- Cleaned up the header which fixed a bug that could crash the entire application on fetch errors.
|
||||
|
||||
### 0.1.2 (May 1, 2024)
|
||||
---
|
||||
|
||||
# 0.1.2 (May 1, 2024)
|
||||
|
||||
- Added support for renaming, expiring, removing, and managing the routes of a machine.
|
||||
- Implemented an expiry check for machines which now reflect on the machine table.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
https://localhost {
|
||||
reverse_proxy headscale:8080
|
||||
tls /certs/localhost.pem /certs/localhost-key.pem
|
||||
|
||||
header {
|
||||
Access-Control-Allow-Origin *
|
||||
Access-Control-Allow-Headers *
|
||||
Access-Control-Allow-Methods GET, POST, OPTIONS
|
||||
}
|
||||
}
|
||||
+59
-16
@@ -1,23 +1,66 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.1 AS go-base
|
||||
WORKDIR /run
|
||||
|
||||
RUN npm install -g pnpm
|
||||
RUN apk add --no-cache git
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY go.mod go.sum build.sh ./
|
||||
RUN go mod download
|
||||
|
||||
COPY cmd/ ./cmd/
|
||||
COPY internal/ ./internal/
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG IMAGE_TAG
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 IMAGE_TAG=$IMAGE_TAG \
|
||||
./build.sh --wasm --agent --fake-shell \
|
||||
--wasm-output /bin/hp_ssh.wasm \
|
||||
--agent-output /bin/hp_agent \
|
||||
--fake-shell-output /bin/fake-sh
|
||||
|
||||
RUN chmod +x /bin/hp_ssh.wasm
|
||||
RUN chmod +x /bin/hp_agent
|
||||
RUN chmod +x /bin/fake-sh
|
||||
|
||||
# Folder needs to exist for later stages
|
||||
RUN mkdir -p /var/lib/headplane/agent
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:22.16-slim AS js-base
|
||||
WORKDIR /run
|
||||
|
||||
RUN corepack enable
|
||||
COPY patches ./patches
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY package.json pnpm-lock.yaml build.sh ./
|
||||
|
||||
COPY --from=go-base /bin/hp_ssh.wasm /run/app/hp_ssh.wasm
|
||||
COPY --from=go-base /bin/wasm_exec.js /run/app/wasm_exec.js
|
||||
RUN ./build.sh --app --app-install-only
|
||||
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
RUN pnpm prune --prod
|
||||
RUN ./build.sh --app
|
||||
|
||||
FROM gcr.io/distroless/nodejs22-debian12:latest AS final
|
||||
COPY --from=js-base /run/build /app/build
|
||||
COPY --from=js-base /run/drizzle /app/drizzle
|
||||
COPY --from=js-base /run/node_modules /app/node_modules
|
||||
|
||||
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
|
||||
COPY --from=go-base /var/lib/headplane /var/lib/headplane
|
||||
|
||||
# Fake shell to inform the user that they should use the debug image
|
||||
COPY --from=go-base /bin/fake-sh /bin/sh
|
||||
COPY --from=go-base /bin/fake-sh /bin/bash
|
||||
|
||||
FROM node:20-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
|
||||
CMD [ "/app/build/server/index.js" ]
|
||||
|
||||
EXPOSE 3000
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
CMD [ "node", "./build/headplane/server.js" ]
|
||||
FROM node:22-alpine AS debug-shell
|
||||
RUN apk add --no-cache bash curl
|
||||
|
||||
COPY --from=js-base /run/build /app/build
|
||||
COPY --from=js-base /run/drizzle /app/drizzle
|
||||
COPY --from=js-base /run/node_modules /app/node_modules
|
||||
|
||||
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
|
||||
COPY --from=go-base /var/lib/headplane /var/lib/headplane
|
||||
|
||||
WORKDIR /app
|
||||
CMD [ "node", "/app/build/server/index.js" ]
|
||||
|
||||
@@ -1,45 +1,79 @@
|
||||
# Headplane
|
||||
> An advanced UI for [juanfont/headscale](https://github.com/juanfont/headscale)
|
||||
> A feature-complete web UI for [Headscale](https://headscale.net)
|
||||
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="./assets/preview-dark.png"
|
||||
srcset="./docs/assets/preview-dark.png"
|
||||
>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="./assets/preview-light.png"
|
||||
srcset="./docs/assets/preview-light.png"
|
||||
>
|
||||
<img
|
||||
alt="Preview"
|
||||
src="./assets/preview-dark.png"
|
||||
src="./docs/assets/preview-dark.png"
|
||||
>
|
||||
</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.
|
||||
Refer to the [website](https://headplane.net) for detailed installation instructions.
|
||||
|
||||
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.*
|
||||
|
||||
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.
|
||||
## Versioning
|
||||
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0).
|
||||
Pre-release builds are available under the `next` tag and get updated when a new release
|
||||
PR is opened and actively in testing.
|
||||
|
||||
## 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="./docs/assets/acls-dark.png"
|
||||
>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="./docs/assets/acls-light.png"
|
||||
>
|
||||
<img
|
||||
alt="ACLs"
|
||||
src="./docs/assets/acls-dark.png"
|
||||
>
|
||||
</picture>
|
||||
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="./docs/assets/machine-dark.png"
|
||||
>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="./docs/assets/machine-light.png"
|
||||
>
|
||||
<img
|
||||
alt="Machine Management"
|
||||
src="./docs/assets/machine-dark.png"
|
||||
>
|
||||
</picture>
|
||||
|
||||
> Copyright (c) 2025 Aarnav Tale
|
||||
|
||||
@@ -1,48 +1,86 @@
|
||||
import { CopyIcon } from '@primer/octicons-react'
|
||||
import { Check, Copy, Info } from 'lucide-react';
|
||||
import cn from '~/utils/cn';
|
||||
import toast from '~/utils/toast';
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
import { toast } from './Toaster'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
value: string
|
||||
isCopyable?: boolean
|
||||
link?: string
|
||||
export interface AttributeProps {
|
||||
name: string;
|
||||
value: string;
|
||||
tooltip?: string;
|
||||
isCopyable?: boolean;
|
||||
}
|
||||
|
||||
export default function Attribute({ name, value, link, isCopyable }: Props) {
|
||||
const canCopy = isCopyable ?? false
|
||||
export default function Attribute({
|
||||
name,
|
||||
value,
|
||||
tooltip,
|
||||
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">
|
||||
{link ? (
|
||||
<a className="hover:underline" href={link}>
|
||||
{name}
|
||||
</a>
|
||||
) : name}
|
||||
<dl className="flex gap-1 items-center text-sm">
|
||||
<dt
|
||||
className={cn(
|
||||
'w-1/3 sm:w-1/4 lg:w-1/3 shrink-0 min-w-0',
|
||||
'text-headplane-500 dark:text-headplane-400',
|
||||
tooltip ? 'flex items-center gap-1' : undefined,
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
{tooltip ? (
|
||||
<Tooltip>
|
||||
<Info className="size-4" />
|
||||
<Tooltip.Body>{tooltip}</Tooltip.Body>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
</dt>
|
||||
|
||||
{canCopy
|
||||
? (
|
||||
<dd
|
||||
className={cn(
|
||||
'min-w-0 px-1.5 py-1 rounded-lg border border-transparent',
|
||||
...(isCopyable
|
||||
? [
|
||||
'cursor-pointer hover:shadow-xs',
|
||||
'hover:bg-headplane-50 dark:hover:bg-headplane-800',
|
||||
'hover:border-headplane-100 dark:hover:border-headplane-700',
|
||||
]
|
||||
: []),
|
||||
)}
|
||||
>
|
||||
{isCopyable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md"
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(value)
|
||||
toast(`Copied ${name}`)
|
||||
className="flex items-center gap-1.5 relative min-w-0 w-full"
|
||||
onClick={async (event) => {
|
||||
const svgs = event.currentTarget.querySelectorAll('svg');
|
||||
for (const svg of svgs) {
|
||||
svg.toggleAttribute('data-copied', true);
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast(`Copied ${name} to clipboard`);
|
||||
|
||||
setTimeout(() => {
|
||||
for (const svg of svgs) {
|
||||
svg.toggleAttribute('data-copied', false);
|
||||
}
|
||||
}, 1000);
|
||||
}}
|
||||
>
|
||||
<dd className="min-w-0 truncate px-2 py-1">
|
||||
<div suppressHydrationWarning className="truncate">
|
||||
{value}
|
||||
</dd>
|
||||
<CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" />
|
||||
</div>
|
||||
{isCopyable ? (
|
||||
<div>
|
||||
<Check className="size-4 hidden data-copied:block" />
|
||||
<Copy className="size-4 block data-copied:hidden" />
|
||||
</div>
|
||||
) : undefined}
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<dd className="min-w-0 truncate px-2 py-1">
|
||||
) : (
|
||||
<div className="relative min-w-0 truncate" suppressHydrationWarning>
|
||||
{value}
|
||||
</dd>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+36
-30
@@ -1,37 +1,43 @@
|
||||
import { type Dispatch, type SetStateAction } from 'react'
|
||||
import { Button as AriaButton } from 'react-aria-components'
|
||||
import React, { useRef } from 'react';
|
||||
import { type AriaButtonOptions, useButton } from 'react-aria';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type ButtonProperties = 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(properties: ButtonProperties) {
|
||||
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);
|
||||
|
||||
return (
|
||||
<AriaButton
|
||||
{...properties}
|
||||
<button
|
||||
ref={ref}
|
||||
{...buttonProps}
|
||||
className={cn(
|
||||
'w-fit text-sm rounded-lg px-4 py-2',
|
||||
properties.variant === 'heavy'
|
||||
? 'bg-main-700 dark:bg-main-800'
|
||||
: 'bg-main-200 dark:bg-main-700/30',
|
||||
properties.variant === 'heavy'
|
||||
? 'hover:bg-main-800 dark:hover:bg-main-700'
|
||||
: 'hover:bg-main-300 dark:hover:bg-main-600/30',
|
||||
properties.variant === 'heavy'
|
||||
? 'text-white'
|
||||
: 'text-ui-700 dark:text-ui-300',
|
||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
properties.className,
|
||||
'w-fit text-sm rounded-xl px-3 py-2',
|
||||
'focus:outline-hidden focus:ring-3',
|
||||
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={properties.control
|
||||
? () => {
|
||||
properties.control?.[1](true)
|
||||
}
|
||||
: properties.onPress}
|
||||
/>
|
||||
)
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
+15
-40
@@ -1,53 +1,28 @@
|
||||
import { type HTMLProps } from 'react'
|
||||
import { Heading as AriaHeading } from 'react-aria-components'
|
||||
import React from 'react';
|
||||
import Text from '~/components/Text';
|
||||
import Title from '~/components/Title';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
function Title(properties: Parameters<typeof AriaHeading>[0]) {
|
||||
return (
|
||||
<AriaHeading
|
||||
{...properties}
|
||||
slot='title'
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-6 mb-5',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
{...properties}
|
||||
className={cn(
|
||||
'text-base leading-6 my-0',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type Properties = HTMLProps<HTMLDivElement> & {
|
||||
interface Props extends React.HTMLProps<HTMLDivElement> {
|
||||
variant?: 'raised' | 'flat';
|
||||
}
|
||||
|
||||
function Card(properties: Properties) {
|
||||
function Card({ variant = 'raised', ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
{...properties}
|
||||
{...props}
|
||||
className={cn(
|
||||
'w-full max-w-md overflow-hidden rounded-xl p-4',
|
||||
properties.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',
|
||||
properties.className
|
||||
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-xs',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Card, { Title, Text })
|
||||
export default Object.assign(Card, { Title, Text });
|
||||
|
||||
@@ -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(
|
||||
'h-5 text-xs py-0.5 px-1 rounded-md text-nowrap',
|
||||
'text-headplane-700 dark:text-headplane-100',
|
||||
'bg-headplane-100 dark:bg-headplane-700',
|
||||
'inline-flex items-center gap-x-1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{leftIcon}
|
||||
{text}
|
||||
{rightIcon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+40
-35
@@ -1,45 +1,50 @@
|
||||
import { useState, HTMLProps } from 'react'
|
||||
import { CopyIcon, CheckIcon } from '@primer/octicons-react'
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { HTMLProps } from 'react';
|
||||
import cn from '~/utils/cn';
|
||||
import toast from '~/utils/toast';
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
import { toast } from '~/components/Toaster'
|
||||
|
||||
interface Props extends HTMLProps<HTMLSpanElement> {
|
||||
isCopyable?: boolean
|
||||
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
|
||||
isCopyable?: boolean;
|
||||
children: string | string[] | number;
|
||||
}
|
||||
|
||||
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-hidden 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={() => {
|
||||
navigator.clipboard.writeText(props.children.join(''))
|
||||
toast('Copied to clipboard')
|
||||
setIsCopied(true)
|
||||
setTimeout(() => setIsCopied(false), 1000)
|
||||
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);
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast('Copied to clipboard');
|
||||
|
||||
setTimeout(() => {
|
||||
for (const svg of svgs) {
|
||||
svg.toggleAttribute('data-copied', false);
|
||||
}
|
||||
}, 1000);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
+182
-131
@@ -1,143 +1,194 @@
|
||||
/* eslint-disable unicorn/no-keyword-prefix */
|
||||
import { type Dispatch, type ReactNode, type 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'
|
||||
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';
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type ButtonProperties = 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(properties: ButtonProperties) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...properties}
|
||||
aria-label='Dialog'
|
||||
className={cn(
|
||||
'w-fit text-sm rounded-lg px-4 py-2',
|
||||
'bg-main-700 dark:bg-main-800 text-white',
|
||||
'hover:bg-main-800 dark:hover:bg-main-700',
|
||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
properties.className
|
||||
)}
|
||||
// If control is passed, set the state value
|
||||
onPress={properties.control ? () => {
|
||||
properties.control?.[1](true)
|
||||
} : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
function Dialog(props: DialogProps) {
|
||||
const { pause, resume } = useLiveData();
|
||||
const state = useOverlayTriggerState(props);
|
||||
const { triggerProps, overlayProps } = useOverlayTrigger(
|
||||
{
|
||||
type: 'dialog',
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
type ActionProperties = Parameters<typeof AriaButton>[0] & {
|
||||
readonly variant: 'cancel' | 'confirm';
|
||||
}
|
||||
useEffect(() => {
|
||||
if (state.isOpen) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}, [state.isOpen]);
|
||||
|
||||
function Action(properties: ActionProperties) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...properties}
|
||||
type={properties.variant === 'confirm' ? 'submit' : 'button'}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg',
|
||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
properties.variant === 'cancel'
|
||||
? 'text-ui-700 dark:text-ui-300'
|
||||
: 'text-ui-300 dark:text-ui-300',
|
||||
properties.variant === 'confirm'
|
||||
? 'bg-main-700 dark:bg-main-700 pressed:bg-main-800 dark:pressed:bg-main-800'
|
||||
: 'bg-ui-200 dark:bg-ui-800 pressed:bg-ui-300 dark:pressed:bg-ui-700',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Title(properties: Parameters<typeof AriaHeading>[0]) {
|
||||
return (
|
||||
<AriaHeading
|
||||
{...properties}
|
||||
slot='title'
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-6 mb-5',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
{...properties}
|
||||
className={cn(
|
||||
'text-base leading-6 my-0',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type PanelProperties = {
|
||||
readonly children: (close: () => void) => ReactNode;
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
function Panel({ children, control, className }: PanelProperties) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
type DialogProperties = {
|
||||
readonly children: ReactNode;
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
}
|
||||
|
||||
function Dialog({ children, control }: DialogProperties) {
|
||||
if (control) {
|
||||
return children
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
)
|
||||
<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-hidden 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,
|
||||
});
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { AlertIcon } from '@primer/octicons-react'
|
||||
import { isRouteErrorResponse, useRouteError } from '@remix-run/react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Card from './Card'
|
||||
import Code from './Code'
|
||||
|
||||
type Properties = {
|
||||
readonly type?: 'full' | 'embedded';
|
||||
}
|
||||
|
||||
export function ErrorPopup({ type = 'full' }: Properties) {
|
||||
const error = useRouteError()
|
||||
const routing = isRouteErrorResponse(error)
|
||||
const message = (error instanceof Error ? error.message : 'An unexpected error occurred')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
type === 'embedded'
|
||||
? 'pointer-events-none mt-24'
|
||||
: 'fixed inset-0 h-screen w-screen z-50'
|
||||
)}
|
||||
>
|
||||
<Card>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Card.Title className='text-3xl mb-0'>
|
||||
{routing ? error.status : 'Error'}
|
||||
</Card.Title>
|
||||
<AlertIcon className='w-12 h-12 text-red-500'/>
|
||||
</div>
|
||||
<Card.Text className='mt-4 text-lg'>
|
||||
{routing ? (
|
||||
error.statusText
|
||||
) : (
|
||||
<Code>
|
||||
{message}
|
||||
</Code>
|
||||
)}
|
||||
</Card.Text>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { CircleX } from 'lucide-react';
|
||||
import Link from '~/components/Link';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
interface FooterProps {
|
||||
url: string;
|
||||
debug: boolean;
|
||||
healthy: boolean;
|
||||
}
|
||||
|
||||
export default function Footer({ url, debug, healthy }: FooterProps) {
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
'fixed w-full bottom-0 left-0 z-40 h-12',
|
||||
'flex items-center justify-center',
|
||||
'bg-headplane-50 dark:bg-headplane-950',
|
||||
'dark:border-t dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-rows-1 items-center container mx-auto',
|
||||
!healthy && 'md:grid-cols-[1fr_auto] grid-cols-1',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('text-xs leading-none', !healthy && 'hidden md:block')}
|
||||
>
|
||||
<p>
|
||||
Headplane is free. Please consider{' '}
|
||||
<Link
|
||||
to="https://github.com/sponsors/tale"
|
||||
name="Aarnav's GitHub Sponsors"
|
||||
>
|
||||
donating
|
||||
</Link>{' '}
|
||||
to support development.{' '}
|
||||
</p>
|
||||
<p className="opacity-75">
|
||||
Version: {__VERSION__}
|
||||
{' — '}
|
||||
Connecting to{' '}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0} // Allows keyboard focus
|
||||
className={cn(
|
||||
'blur-sm hover:blur-none focus:blur-none transition',
|
||||
'focus:outline-hidden focus:ring-2 rounded-xs',
|
||||
)}
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
{debug && ' (Debug mode enabled)'}
|
||||
</p>
|
||||
</div>
|
||||
{!healthy ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-1.5 items-center p-2 rounded-xl text-sm',
|
||||
'bg-red-500 text-white font-semibold',
|
||||
)}
|
||||
>
|
||||
<CircleX size={16} strokeWidth={3} />
|
||||
<p className="text-nowrap">Headscale is unreachable</p>
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
+161
-85
@@ -1,116 +1,192 @@
|
||||
import { GearIcon, GlobeIcon, LockIcon, PaperAirplaneIcon, PeopleIcon, PersonIcon, ServerIcon } from '@primer/octicons-react'
|
||||
import { Form } from '@remix-run/react'
|
||||
import {
|
||||
CircleUser,
|
||||
Globe2,
|
||||
Lock,
|
||||
Server,
|
||||
Settings,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { NavLink, useSubmit } from 'react-router';
|
||||
import Logo from '~/components/Logo';
|
||||
import Menu from '~/components/Menu';
|
||||
import { AuthSession } from '~/server/web/sessions';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
import { HeadplaneContext } from '~/utils/config/headplane'
|
||||
import { type SessionData } from '~/utils/sessions'
|
||||
|
||||
import Menu from './Menu'
|
||||
import TabLink from './TabLink'
|
||||
|
||||
interface Properties {
|
||||
readonly data?: {
|
||||
config: HeadplaneContext['config']
|
||||
user?: SessionData['user']
|
||||
}
|
||||
interface Props {
|
||||
configAvailable: boolean;
|
||||
onboarding: boolean;
|
||||
user?: AuthSession['user'];
|
||||
access: {
|
||||
ui: boolean;
|
||||
machines: boolean;
|
||||
dns: boolean;
|
||||
users: boolean;
|
||||
policy: boolean;
|
||||
settings: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface LinkProperties {
|
||||
readonly href: string
|
||||
readonly text: string
|
||||
readonly isMenu?: boolean
|
||||
interface LinkProps {
|
||||
href: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function Link({ href, text, isMenu }: LinkProperties) {
|
||||
interface TabLinkProps {
|
||||
name: string;
|
||||
to: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
function TabLink({ name, to, icon }: TabLinkProps) {
|
||||
return (
|
||||
<div className="relative py-2">
|
||||
<NavLink
|
||||
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-hidden focus:ring-3',
|
||||
isActive ? 'after:visible' : 'after:invisible',
|
||||
)
|
||||
}
|
||||
prefetch="intent"
|
||||
to={to}
|
||||
>
|
||||
{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-hidden focus:ring-3 rounded-md',
|
||||
)}
|
||||
href={href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function Header({ data }: Properties) {
|
||||
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>
|
||||
<Logo />
|
||||
<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" />
|
||||
<Link href="https://github.com/tale/headplane" text="GitHub" />
|
||||
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
|
||||
{data?.user
|
||||
? (
|
||||
<Menu>
|
||||
<Menu.Button className={cn(
|
||||
'rounded-full h-9 w-9',
|
||||
'border border-main-600 dark:border-main-700',
|
||||
'hover:bg-main-600 dark:hover:bg-main-700',
|
||||
{data.user ? (
|
||||
<Menu>
|
||||
<Menu.IconButton
|
||||
className={cn(data.user.picture ? 'p-0' : '')}
|
||||
label="User"
|
||||
>
|
||||
{data.user.picture ? (
|
||||
<img
|
||||
alt={data.user.name}
|
||||
className="w-8 h-8 rounded-full"
|
||||
src={data.user.picture}
|
||||
/>
|
||||
) : (
|
||||
<CircleUser />
|
||||
)}
|
||||
>
|
||||
<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.IconButton>
|
||||
<Menu.Panel
|
||||
disabledKeys={['profile']}
|
||||
onAction={(key) => {
|
||||
if (key === 'logout') {
|
||||
submit(
|
||||
{},
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/logout',
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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 className="text-right sm:hidden">
|
||||
<Link
|
||||
isMenu
|
||||
href="https://tailscale.com/download"
|
||||
text="Download"
|
||||
/>
|
||||
<Menu.Item key="logout" textValue="Logout">
|
||||
<p className="text-red-500 dark:text-red-400">Logout</p>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="text-right sm:hidden">
|
||||
<Link
|
||||
isMenu
|
||||
href="https://github.com/tale/headplane"
|
||||
text="GitHub"
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="text-right sm:hidden">
|
||||
<Link
|
||||
isMenu
|
||||
href="https://github.com/juanfont/headscale"
|
||||
text="Headscale"
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="text-red-500 dark:text-red-400">
|
||||
<Form method="POST" action="/logout">
|
||||
<button type="submit" className="w-full text-right">
|
||||
Logout
|
||||
</button>
|
||||
</Form>
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
)
|
||||
: undefined}
|
||||
</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
|
||||
? (
|
||||
{data.access.ui && !data.onboarding ? (
|
||||
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
|
||||
{data.access.machines ? (
|
||||
<TabLink
|
||||
icon={<Server className="w-5" />}
|
||||
name="Machines"
|
||||
to="/machines"
|
||||
/>
|
||||
) : undefined}
|
||||
{data.access.users ? (
|
||||
<TabLink
|
||||
icon={<Users className="w-5" />}
|
||||
name="Users"
|
||||
to="/users"
|
||||
/>
|
||||
) : undefined}
|
||||
{data.access.policy ? (
|
||||
<TabLink
|
||||
icon={<Lock className="w-5" />}
|
||||
name="Access Control"
|
||||
to="/acls"
|
||||
/>
|
||||
) : undefined}
|
||||
{data.configAvailable ? (
|
||||
<>
|
||||
<TabLink to="/dns" name="DNS" icon={<GlobeIcon className="w-4 h-4" />} />
|
||||
<TabLink to="/settings" name="Settings" icon={<GearIcon className="w-4 h-4" />} />
|
||||
{data.access.dns ? (
|
||||
<TabLink
|
||||
icon={<Globe2 className="w-5" />}
|
||||
name="DNS"
|
||||
to="/dns"
|
||||
/>
|
||||
) : undefined}
|
||||
{data.access.settings ? (
|
||||
<TabLink
|
||||
icon={<Settings className="w-5" />}
|
||||
name="Settings"
|
||||
to="/settings"
|
||||
/>
|
||||
) : undefined}
|
||||
</>
|
||||
)
|
||||
: undefined}
|
||||
</nav>
|
||||
) : undefined}
|
||||
</nav>
|
||||
) : undefined}
|
||||
</header>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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-hidden focus:ring-3',
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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-hidden focus:ring-3',
|
||||
'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>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+17
-12
@@ -1,15 +1,19 @@
|
||||
import { LinkExternalIcon } from '@primer/octicons-react'
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
to: string
|
||||
name: string
|
||||
children: string
|
||||
className?: string
|
||||
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}
|
||||
@@ -17,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-hidden focus:ring-3 rounded-md',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<LinkExternalIcon className="h-3 w-3" />
|
||||
<ExternalLink className="w-3.5" />
|
||||
</a>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import cn from '~/utils/cn';
|
||||
import LogoSvg from '../../public/logo-light.svg';
|
||||
|
||||
export interface LogoProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Logo({ className }: LogoProps) {
|
||||
return <img alt="Logo" className={cn(className)} src={LogoSvg} />;
|
||||
}
|
||||
+152
-80
@@ -1,98 +1,170 @@
|
||||
import { type Dispatch, type ReactNode, type 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'
|
||||
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';
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
function Button(properties: Parameters<typeof AriaButton>[0]) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...properties}
|
||||
className={cn(
|
||||
'outline-none',
|
||||
properties.className
|
||||
)}
|
||||
aria-label='Menu'
|
||||
/>
|
||||
)
|
||||
interface MenuProps extends MenuTriggerProps {
|
||||
placement?: Placement;
|
||||
isDisabled?: boolean;
|
||||
disabledKeys?: Key[];
|
||||
children: [
|
||||
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
||||
React.ReactElement<MenuPanelProps>,
|
||||
];
|
||||
}
|
||||
|
||||
function Items(properties: Parameters<typeof AriaMenu>[0]) {
|
||||
// TODO: onAction is called twice for some reason?
|
||||
// TODO: isDisabled per-prop
|
||||
function Menu(props: MenuProps) {
|
||||
const { placement = 'bottom', isDisabled, disabledKeys = [] } = 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 (
|
||||
<Popover className={cn(
|
||||
'mt-2 rounded-md',
|
||||
'bg-ui-50 dark:bg-ui-800',
|
||||
'overflow-hidden z-50',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'entering:fade-in entering:zoom-in-95',
|
||||
'exiting:fade-out exiting:zoom-out-95',
|
||||
'fill-mode-forwards origin-left-right'
|
||||
)}
|
||||
<div>
|
||||
{cloneElement(button, {
|
||||
...menuTriggerProps,
|
||||
isDisabled: isDisabled,
|
||||
ref,
|
||||
})}
|
||||
{state.isOpen && (
|
||||
<Popover state={state} triggerRef={ref} placement={placement}>
|
||||
{cloneElement(panel, {
|
||||
...menuProps,
|
||||
autoFocus: state.focusStrategy ?? true,
|
||||
onClose: () => state.close(),
|
||||
disabledKeys,
|
||||
})}
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuPanelProps extends AriaMenuProps<object> {
|
||||
onClose?: () => void;
|
||||
disabledKeys?: Key[];
|
||||
}
|
||||
|
||||
function Panel(props: MenuPanelProps) {
|
||||
const state = useTreeState(props);
|
||||
const ref = useRef(null);
|
||||
|
||||
const { menuProps } = useMenu(props, state, ref);
|
||||
return (
|
||||
<ul
|
||||
{...menuProps}
|
||||
ref={ref}
|
||||
className="pt-1 pb-1 shadow-2xs rounded-md min-w-[200px] focus:outline-hidden"
|
||||
>
|
||||
<AriaMenu
|
||||
{...properties}
|
||||
className={cn(
|
||||
'outline-none',
|
||||
'divide-y divide-ui-200 dark:divide-ui-600',
|
||||
properties.className
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
</AriaMenu>
|
||||
</Popover>
|
||||
)
|
||||
{[...state.collection].map((item) => (
|
||||
<MenuSection
|
||||
key={item.key}
|
||||
section={item}
|
||||
state={state}
|
||||
disabledKeys={props.disabledKeys}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
interface MenuSectionProps<T> {
|
||||
section: Node<T>;
|
||||
state: TreeState<T>;
|
||||
disabledKeys?: Key[];
|
||||
}
|
||||
|
||||
function ItemButton(properties: ButtonProperties) {
|
||||
function MenuSection<T>({ section, state, disabledKeys }: MenuSectionProps<T>) {
|
||||
const { itemProps, groupProps } = useMenuSection({
|
||||
heading: section.rendered,
|
||||
'aria-label': section['aria-label'],
|
||||
});
|
||||
|
||||
const { separatorProps } = useSeparator({
|
||||
elementType: 'li',
|
||||
});
|
||||
|
||||
return (
|
||||
<MenuItem className='outline-none'>
|
||||
<AriaButton
|
||||
{...properties}
|
||||
className={cn(
|
||||
'px-4 py-2 w-full outline-none text-left',
|
||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||
properties.className
|
||||
)}
|
||||
aria-label='Menu Dialog'
|
||||
// If control is passed, set the state value
|
||||
onPress={event => {
|
||||
properties.onPress?.(event)
|
||||
properties.control?.[1](true)
|
||||
}}
|
||||
/>
|
||||
</MenuItem>
|
||||
)
|
||||
<>
|
||||
{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}
|
||||
isDisabled={disabledKeys?.includes(item.key)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Item(properties: Parameters<typeof MenuItem>[0]) {
|
||||
interface MenuItemProps<T> {
|
||||
item: Node<T>;
|
||||
state: TreeState<T>;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
|
||||
const ref = useRef<HTMLLIElement | null>(null);
|
||||
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
|
||||
|
||||
const isFocused = state.selectionManager.focusedKey === item.key;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
{...properties}
|
||||
<li
|
||||
{...menuItemProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-4 py-2 w-full outline-none',
|
||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||
properties.className
|
||||
'py-2 px-3 mx-1 rounded-lg',
|
||||
'focus:outline-hidden 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',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
>
|
||||
{item.rendered}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Menu({ children }: { readonly 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,
|
||||
});
|
||||
|
||||
+40
-18
@@ -1,23 +1,45 @@
|
||||
import { InfoIcon } from '@primer/octicons-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
CircleAlert,
|
||||
CircleSlash2,
|
||||
LucideProps,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import Card from '~/components/Card';
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
export interface NoticeProps {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
variant?: 'default' | 'error' | 'warning';
|
||||
icon?: React.ReactElement<LucideProps>;
|
||||
}
|
||||
|
||||
export default function Notice({ children, className }: Props) {
|
||||
export default function Notice({
|
||||
children,
|
||||
title,
|
||||
variant,
|
||||
icon,
|
||||
}: 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" />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
<Card variant="flat" className="max-w-2xl my-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{title ? (
|
||||
<Card.Title className="text-xl mb-0">{title}</Card.Title>
|
||||
) : undefined}
|
||||
{!variant && icon ? icon : iconForVariant(variant)}
|
||||
</div>
|
||||
<Card.Text className="mt-4">{children}</Card.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function iconForVariant(variant?: 'default' | 'error' | 'warning') {
|
||||
switch (variant) {
|
||||
case 'error':
|
||||
return <TriangleAlert className="text-red-500" />;
|
||||
case 'warning':
|
||||
return <CircleAlert className="text-yellow-500" />;
|
||||
default:
|
||||
return <CircleSlash2 />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { PlusIcon, DashIcon } from '@primer/octicons-react'
|
||||
import { 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>
|
||||
)
|
||||
}
|
||||
@@ -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-hidden focus-within:ring-3',
|
||||
'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-hidden"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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-hidden focus:ring-3 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 });
|
||||
@@ -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-xs 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { createContext, useContext, useRef } from 'react';
|
||||
import {
|
||||
AriaRadioGroupProps,
|
||||
AriaRadioProps,
|
||||
VisuallyHidden,
|
||||
useFocusRing,
|
||||
} from 'react-aria';
|
||||
import { RadioGroupState } from 'react-stately';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
import { useRadio, useRadioGroup } from 'react-aria';
|
||||
import { useRadioGroupState } from 'react-stately';
|
||||
|
||||
interface RadioGroupProps extends AriaRadioGroupProps {
|
||||
children: React.ReactElement<RadioProps>[];
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const RadioContext = createContext<RadioGroupState | null>(null);
|
||||
|
||||
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
|
||||
const state = useRadioGroupState(props);
|
||||
const { radioGroupProps, labelProps } = useRadioGroup(
|
||||
{
|
||||
...props,
|
||||
'aria-label': label,
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
return (
|
||||
<div {...radioGroupProps} className={cn('flex flex-col gap-2', className)}>
|
||||
<VisuallyHidden>
|
||||
<span {...labelProps}>{label}</span>
|
||||
</VisuallyHidden>
|
||||
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RadioProps extends AriaRadioProps {
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Radio({ children, label, className, ...props }: RadioProps) {
|
||||
const state = useContext(RadioContext);
|
||||
const ref = useRef(null);
|
||||
const { inputProps, isSelected, isDisabled } = useRadio(
|
||||
{
|
||||
...props,
|
||||
'aria-label': label,
|
||||
},
|
||||
state!,
|
||||
ref,
|
||||
);
|
||||
const { isFocusVisible, focusProps } = useFocusRing();
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<VisuallyHidden>
|
||||
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
|
||||
</VisuallyHidden>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'w-5 h-5 aspect-square rounded-full p-1 border-2',
|
||||
'border border-headplane-600 dark:border-headplane-300',
|
||||
isFocusVisible ? 'ring-4' : '',
|
||||
isDisabled ? 'opacity-50 cursor-not-allowed' : '',
|
||||
isSelected
|
||||
? 'border-[6px] border-headplane-900 dark:border-headplane-100'
|
||||
: '',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(RadioGroup, { Radio });
|
||||
+162
-70
@@ -1,82 +1,174 @@
|
||||
import { ChevronDownIcon } from '@primer/octicons-react'
|
||||
import { 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'
|
||||
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';
|
||||
|
||||
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,
|
||||
)}
|
||||
>
|
||||
<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
|
||||
<div className={cn('flex flex-col', props.className)}>
|
||||
<label
|
||||
{...labelProps}
|
||||
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',
|
||||
'text-xs font-medium px-3 mb-0.5',
|
||||
'text-headplane-700 dark:text-headplane-100',
|
||||
)}
|
||||
htmlFor={id}
|
||||
>
|
||||
{props.label}
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
'flex rounded-xl focus:outline-hidden focus-within:ring-3',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
props.isInvalid && 'ring-red-400',
|
||||
)}
|
||||
>
|
||||
<ListBox orientation="vertical">
|
||||
{props.children}
|
||||
</ListBox>
|
||||
</Popover>
|
||||
</AriaSelect>
|
||||
)
|
||||
}
|
||||
|
||||
type ItemProps = Parameters<typeof ListBoxItem>[0]
|
||||
|
||||
function Item(props: ItemProps) {
|
||||
return (
|
||||
<ListBoxItem
|
||||
{...props}
|
||||
className={cn(
|
||||
'px-4 py-2 w-full outline-none w-full',
|
||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||
props.className,
|
||||
<input
|
||||
{...inputProps}
|
||||
className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent"
|
||||
data-1p-ignore
|
||||
id={id}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<button
|
||||
{...buttonProps}
|
||||
className={cn(
|
||||
'flex items-center justify-center p-1 rounded-lg m-1',
|
||||
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
||||
props.isDisabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
||||
)}
|
||||
ref={buttonRef}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</ListBoxItem>
|
||||
)
|
||||
{state.isOpen && (
|
||||
<Popover
|
||||
className="w-full max-w-xs"
|
||||
isNonModal
|
||||
placement="bottom start"
|
||||
popoverRef={popoverRef}
|
||||
state={state}
|
||||
triggerRef={inputRef}
|
||||
>
|
||||
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Select, { Item })
|
||||
interface ListBoxProps extends AriaListBoxOptions<object> {
|
||||
listBoxRef: React.RefObject<HTMLUListElement | null>;
|
||||
state: ListState<object>;
|
||||
}
|
||||
|
||||
function ListBox(props: ListBoxProps) {
|
||||
const { listBoxRef, state } = props;
|
||||
const { listBoxProps } = useListBox(props, state, listBoxRef);
|
||||
|
||||
return (
|
||||
<ul
|
||||
{...listBoxProps}
|
||||
className="w-full max-h-72 overflow-auto outline-hidden pt-1"
|
||||
ref={listBoxRef}
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<Option item={item} key={item.key} 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}
|
||||
className={cn(
|
||||
'flex items-center justify-between',
|
||||
'py-2 px-3 mx-1 rounded-lg mb-1',
|
||||
'focus:outline-hidden 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',
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{item.rendered}
|
||||
{isSelected && <Check className="p-0.5" />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Select, { Item });
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import clsx from 'clsx'
|
||||
import clsx from 'clsx';
|
||||
|
||||
type Properties = {
|
||||
// eslint-disable-next-line unicorn/no-keyword-prefix
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Spinner(properties: Properties) {
|
||||
export default function Spinner({ className }: Props) {
|
||||
return (
|
||||
<div className={clsx('mr-1.5 inline-block align-middle mb-0.5', properties.className)}>
|
||||
<div className={clsx('inline-block align-middle mb-0.5', className)}>
|
||||
<div
|
||||
className={clsx(
|
||||
'animate-spin rounded-full w-full h-full',
|
||||
'border-2 border-current border-t-transparent',
|
||||
properties.className
|
||||
className,
|
||||
)}
|
||||
role='status'
|
||||
>
|
||||
<span className='sr-only'>Loading...</span>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import clsx from 'clsx'
|
||||
import { type HTMLProps } from 'react'
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
type Properties = 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 }: Properties) {
|
||||
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'
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<circle cx='12' cy='12' r='8'/>
|
||||
<title>{isOnline ? 'Online' : 'Offline'}</title>
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+53
-23
@@ -1,34 +1,64 @@
|
||||
import { Switch as AriaSwitch } from 'react-aria-components'
|
||||
import { useRef } from 'react';
|
||||
import {
|
||||
AriaSwitchProps,
|
||||
VisuallyHidden,
|
||||
useFocusRing,
|
||||
useSwitch,
|
||||
} from 'react-aria';
|
||||
import { useToggleState } from 'react-stately';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type SwitchProperties = Parameters<typeof AriaSwitch>[0] & {
|
||||
readonly label: string;
|
||||
export interface SwitchProps extends AriaSwitchProps {
|
||||
label: string;
|
||||
className?: string;
|
||||
switchClassName?: string;
|
||||
}
|
||||
|
||||
export default function Switch(properties: SwitchProperties) {
|
||||
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
|
||||
{...properties}
|
||||
aria-label={properties.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',
|
||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
properties.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',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'h-[18px] w-[18px] transform rounded-full',
|
||||
'bg-white transition duration-100 ease-in-out',
|
||||
'translate-x-0 group-selected:translate-x-[100%]'
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'h-[18px] w-[18px] transform rounded-full',
|
||||
'bg-white transition duration-50 ease-in-out',
|
||||
'translate-x-0 group-selected:translate-x-full',
|
||||
state.isSelected && 'translate-x-full',
|
||||
props.switchClassName,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AriaSwitch>
|
||||
)
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { NavLink } from '@remix-run/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type Properties = {
|
||||
readonly name: string;
|
||||
readonly to: string;
|
||||
readonly icon: ReactNode;
|
||||
}
|
||||
|
||||
export default function TabLink({ name, to, icon }: Properties) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +1,34 @@
|
||||
import clsx from 'clsx'
|
||||
import { type HTMLProps } from 'react'
|
||||
import type { HTMLProps } from 'react';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
function TableList(properties: HTMLProps<HTMLDivElement>) {
|
||||
function TableList(props: HTMLProps<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...properties}
|
||||
className={clsx(
|
||||
'border border-gray-300 rounded-lg overflow-clip',
|
||||
'dark:border-zinc-700 dark:text-gray-300',
|
||||
// 'dark:bg-zinc-800',
|
||||
properties.className
|
||||
{...props}
|
||||
className={cn(
|
||||
'rounded-xl',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Item(properties: HTMLProps<HTMLDivElement>) {
|
||||
function Item(props: HTMLProps<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...properties}
|
||||
className={clsx(
|
||||
|
||||
'flex items-center justify-between px-3 py-2',
|
||||
'border-b border-gray-200 last:border-b-0',
|
||||
'dark:border-zinc-800',
|
||||
properties.className
|
||||
{...props}
|
||||
className={cn(
|
||||
'flex items-center justify-between p-2 last:border-b-0',
|
||||
'border-b border-headplane-100 dark:border-headplane-800',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(TableList, { Item })
|
||||
export default Object.assign(TableList, { Item });
|
||||
|
||||
@@ -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-hidden focus:ring-3 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 });
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { type Dispatch, type SetStateAction } from 'react'
|
||||
import {
|
||||
Input,
|
||||
TextField as AriaTextField
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type TextFieldProperties = Parameters<typeof AriaTextField>[0] & {
|
||||
readonly label: string;
|
||||
readonly placeholder: string;
|
||||
readonly state?: [string, Dispatch<SetStateAction<string>>];
|
||||
}
|
||||
|
||||
export default function TextField(properties: TextFieldProperties) {
|
||||
return (
|
||||
<AriaTextField
|
||||
{...properties}
|
||||
aria-label={properties.label}
|
||||
className='w-full'
|
||||
>
|
||||
<Input
|
||||
placeholder={properties.placeholder}
|
||||
value={properties.state?.[0]}
|
||||
name={properties.name}
|
||||
className={cn(
|
||||
'block px-2.5 py-1.5 w-full rounded-lg my-1',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'dark:bg-ui-800 dark:text-ui-300',
|
||||
properties.className
|
||||
)}
|
||||
onChange={event => {
|
||||
properties.state?.[1](event.target.value)
|
||||
}}
|
||||
/>
|
||||
</AriaTextField>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +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 ToastProperties = AriaToastProps<ReactNode> & {
|
||||
readonly state: ToastState<ReactNode>;
|
||||
}
|
||||
|
||||
function Toast({ state, ...properties }: ToastProperties) {
|
||||
const reference = useRef(null)
|
||||
|
||||
// @ts-expect-error: RefObject doesn't map to FocusableElement?
|
||||
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)
|
||||
|
||||
// @ts-expect-error: React 19 has weird types for Portal vs Node
|
||||
const { regionProps } = useToastRegion({}, state, reference)
|
||||
|
||||
return (
|
||||
<ClientOnly>
|
||||
{
|
||||
// @ts-expect-error: Portal doesn't match Node in React 19 yet
|
||||
() => createPortal(
|
||||
state.visibleToasts.length >= 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-4 right-4',
|
||||
'flex flex-col gap-4'
|
||||
)}
|
||||
{...regionProps}
|
||||
ref={reference}
|
||||
>
|
||||
{state.visibleToasts.map(toast => (
|
||||
<Toast key={toast.key} toast={toast} state={state}/>
|
||||
))}
|
||||
</div>
|
||||
) : undefined,
|
||||
document.body
|
||||
)}
|
||||
</ClientOnly>
|
||||
)
|
||||
}
|
||||
|
||||
+70
-30
@@ -1,43 +1,83 @@
|
||||
import { ReactNode } from 'react'
|
||||
import React, { cloneElement, useRef } from 'react';
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Tooltip as AriaTooltip,
|
||||
TooltipTrigger,
|
||||
} from 'react-aria-components'
|
||||
AriaTooltipProps,
|
||||
mergeProps,
|
||||
useTooltip,
|
||||
useTooltipTrigger,
|
||||
} from 'react-aria';
|
||||
import { TooltipTriggerState, useTooltipTriggerState } from 'react-stately';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
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) {
|
||||
function Tooltip(props: TooltipProps) {
|
||||
const state = useTooltipTriggerState({
|
||||
...props,
|
||||
delay: 0,
|
||||
closeDelay: 0,
|
||||
});
|
||||
|
||||
const ref = useRef<HTMLButtonElement | null>(null);
|
||||
const { triggerProps, tooltipProps } = useTooltipTrigger(
|
||||
{
|
||||
...props,
|
||||
delay: 0,
|
||||
closeDelay: 0,
|
||||
},
|
||||
state,
|
||||
ref,
|
||||
);
|
||||
|
||||
const [component, body] = props.children;
|
||||
return (
|
||||
<TooltipTrigger delay={0}>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
)
|
||||
<span className="relative">
|
||||
<button
|
||||
ref={ref}
|
||||
{...triggerProps}
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'focus:outline-hidden focus:ring-3 rounded-xl',
|
||||
)}
|
||||
>
|
||||
{component}
|
||||
</button>
|
||||
{state.isOpen &&
|
||||
cloneElement(body, {
|
||||
...tooltipProps,
|
||||
state,
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Button(props: Parameters<typeof AriaButton>[0]) {
|
||||
return (
|
||||
<AriaButton {...props} />
|
||||
)
|
||||
interface TooltipBodyProps extends AriaTooltipProps {
|
||||
children: React.ReactNode;
|
||||
state?: TooltipTriggerState;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Body({ children, className }: Props) {
|
||||
function Body({ state, className, ...props }: TooltipBodyProps) {
|
||||
const { tooltipProps } = useTooltip(props, state);
|
||||
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,
|
||||
)}
|
||||
<span
|
||||
{...mergeProps(props, tooltipProps)}
|
||||
className={cn(
|
||||
'absolute z-50 p-3 top-full mt-1',
|
||||
'outline-hidden 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,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</AriaTooltip>
|
||||
)
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Tooltip, { Button, Body })
|
||||
export default Object.assign(Tooltip, {
|
||||
Body,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { isRouteErrorResponse } from 'react-router';
|
||||
import {
|
||||
isApiError,
|
||||
isConnectionError,
|
||||
} from '~/server/headscale/api/error-client';
|
||||
import cn from '~/utils/cn';
|
||||
import Card from './Card';
|
||||
import Code from './Code';
|
||||
import Link from './Link';
|
||||
|
||||
export function getErrorMessage(error: Error | unknown): {
|
||||
title: string;
|
||||
jsxMessage: React.ReactNode;
|
||||
} {
|
||||
if (isRouteErrorResponse(error)) {
|
||||
if (isApiError(error.data)) {
|
||||
const { statusCode, rawData, data, requestUrl } = error.data;
|
||||
if (statusCode >= 500) {
|
||||
return {
|
||||
title: 'Cannot connect to Headscale API',
|
||||
jsxMessage: (
|
||||
<Card.Text>
|
||||
There was an error communicating with the Headscale API.
|
||||
<br />
|
||||
The server responded with a status code of{' '}
|
||||
<strong>{statusCode}</strong>, indicating a server-side issue.
|
||||
Please check the Headscale server status and try again later.
|
||||
</Card.Text>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const authError =
|
||||
error.data.statusCode === 401 || error.data.statusCode === 403;
|
||||
|
||||
return {
|
||||
title: 'Invalid response from Headscale API',
|
||||
jsxMessage: (
|
||||
<>
|
||||
<Card.Text className="leading-snug">
|
||||
The Headscale API returned an unexpected response.
|
||||
{authError ? (
|
||||
<>
|
||||
{' '}
|
||||
The status code indicates an authentication error. Please
|
||||
verify your API key and Headplane configuration.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{' '}
|
||||
You may be using an unsupported version of Headscale or this
|
||||
may be a bug.
|
||||
</>
|
||||
)}
|
||||
</Card.Text>
|
||||
<ul className="list-disc list-inside mt-2">
|
||||
<li>
|
||||
Request URL: <Code>{requestUrl}</Code>
|
||||
</li>
|
||||
<li>
|
||||
Status Code:{' '}
|
||||
<Code>
|
||||
{/* @ts-expect-error */}
|
||||
{data === null ? (
|
||||
<>
|
||||
{statusCode} {rawData}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{statusCode} {error.statusText}
|
||||
</>
|
||||
)}
|
||||
</Code>
|
||||
</li>
|
||||
</ul>
|
||||
<Card.Text className="text-lg font-semibold mt-4">
|
||||
Error Details
|
||||
</Card.Text>
|
||||
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
||||
<code>{JSON.stringify(error.data, null, 2)}</code>
|
||||
</pre>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (isConnectionError(error.data)) {
|
||||
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
|
||||
return {
|
||||
title: 'Cannot connect to Headscale API',
|
||||
jsxMessage: (
|
||||
<>
|
||||
<Card.Text className="leading-snug">
|
||||
Headplane was unable to reach the Headscale API. Please check your
|
||||
network setup and configuration to ensure Headplane is able to
|
||||
connect.
|
||||
</Card.Text>
|
||||
<Card.Text className="text-lg font-semibold mt-4">
|
||||
Error Details
|
||||
</Card.Text>
|
||||
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
||||
{requestUrl}
|
||||
<br />
|
||||
{errorCode}: {errorMessage}
|
||||
{extraData != null && (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
<code>{JSON.stringify(extraData, null, 2)}</code>
|
||||
</>
|
||||
)}
|
||||
</pre>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `Error ${error.status}`,
|
||||
jsxMessage: (
|
||||
<>
|
||||
There was an error processing your request.
|
||||
<br />
|
||||
Status Code: <strong>{error.status}</strong>
|
||||
<br />
|
||||
Status Text: <strong>{error.statusText}</strong>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!(error instanceof Error)) {
|
||||
return {
|
||||
title: 'Unexpected Error',
|
||||
jsxMessage: (
|
||||
<>
|
||||
<Card.Text>
|
||||
An unexpected error occurred which is most likely a bug. Please
|
||||
consider reporting filing an issue on the{' '}
|
||||
<Link
|
||||
name="Headplane GitHub"
|
||||
to="https://github.com/tale/headplane/issues"
|
||||
>
|
||||
Headplane GitHub
|
||||
</Link>{' '}
|
||||
repository with the details below.
|
||||
</Card.Text>
|
||||
<Card.Text className="text-lg font-semibold mt-4">
|
||||
Error Details
|
||||
</Card.Text>
|
||||
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
||||
<code>{JSON.stringify(error, null, 2)}</code>
|
||||
</pre>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Traverse the error chain to find the root cause
|
||||
let rootError = error;
|
||||
console.log('error', error.cause != null);
|
||||
if (error.cause != null) {
|
||||
rootError = error.cause as Error;
|
||||
while (rootError.cause != null) {
|
||||
rootError = rootError.cause as Error;
|
||||
console.log('setting rootError', rootError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: If we are aggregate, concat into a single message
|
||||
if (rootError instanceof AggregateError) {
|
||||
throw new Error('AggregateError handling not implemented yet');
|
||||
}
|
||||
|
||||
return {
|
||||
title:
|
||||
rootError.name.length > 0 && rootError.name !== 'Error'
|
||||
? `Error: ${rootError.name}`
|
||||
: 'Error',
|
||||
jsxMessage: rootError.message,
|
||||
};
|
||||
}
|
||||
|
||||
interface ErrorBannerProps {
|
||||
error: unknown;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ErrorBanner({ error, className }: ErrorBannerProps) {
|
||||
const { title, jsxMessage } = getErrorMessage(error);
|
||||
|
||||
return (
|
||||
<Card className={cn('w-screen', className)} variant="flat">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title>{title}</Card.Title>
|
||||
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
|
||||
</div>
|
||||
{jsxMessage}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import cn from '~/utils/cn';
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
|
||||
export interface ExitNodeTagProps {
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function ExitNodeTag({ isEnabled }: ExitNodeTagProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Exit Node"
|
||||
className={cn(
|
||||
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
|
||||
)}
|
||||
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
{isEnabled ? (
|
||||
<>This machine is acting as an exit node.</>
|
||||
) : (
|
||||
<>
|
||||
This machine is requesting to be used as an exit node. Review this
|
||||
from the "Edit route settings..." option in the machine's menu.
|
||||
</>
|
||||
)}
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
|
||||
export interface ExpiryTagProps {
|
||||
variant: 'expired' | 'no-expiry';
|
||||
expiry?: string;
|
||||
}
|
||||
|
||||
export function ExpiryTag({ variant, expiry }: ExpiryTagProps) {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text={
|
||||
variant === 'expired'
|
||||
? `Expired ${formatter.format(new Date(expiry!))}`
|
||||
: 'No expiry'
|
||||
}
|
||||
className="bg-headplane-200 text-headplane-800 dark:bg-headplane-800 dark:text-headplane-200"
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
{variant === 'expired' ? (
|
||||
<>
|
||||
This machine is expired and will not be able to connect to the
|
||||
network. Re-authenticate with Tailscale on the machine to re-enable
|
||||
it.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This machine has key expiry disabled and will never need to
|
||||
re-authenticate.
|
||||
</>
|
||||
)}
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import cn from '~/utils/cn';
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
|
||||
export function HeadplaneAgentTag() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Headplane Agent"
|
||||
className={cn(
|
||||
'bg-purple-300 text-purple-900 dark:bg-purple-900 dark:text-purple-300',
|
||||
)}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
This machine is running the Headplane agent, which allows it to provide
|
||||
host information in the web UI.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import cn from '~/utils/cn';
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
|
||||
export interface SubnetTagProps {
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function SubnetTag({ isEnabled }: SubnetTagProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Subnets"
|
||||
className={cn(
|
||||
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
|
||||
)}
|
||||
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
{isEnabled ? (
|
||||
<>This machine advertises subnet routes.</>
|
||||
) : (
|
||||
<>
|
||||
This machine has unadvertised subnet routes. Review this from the
|
||||
"Edit route settings..." option in the machine's menu.
|
||||
</>
|
||||
)}
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import cn from '~/utils/cn';
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
|
||||
export function TailscaleSSHTag() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Tailscale SSH"
|
||||
className={cn(
|
||||
'bg-lime-500 text-lime-900 dark:bg-lime-900 dark:text-lime-500',
|
||||
)}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
This machine advertises Tailscale SSH, which allows you to authenticate
|
||||
SSH credentials using your Tailscale account and via the Headplane web
|
||||
UI.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StrictMode, startTransition } from 'react';
|
||||
import { hydrateRoot } from 'react-dom/client';
|
||||
import { HydratedRouter } from 'react-router/dom';
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<HydratedRouter />
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
+41
-36
@@ -1,62 +1,67 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
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 type { AppLoadContext, EntryContext } from 'react-router';
|
||||
import { ServerRouter } from 'react-router';
|
||||
|
||||
import type { AppLoadContext, EntryContext } from '@remix-run/node'
|
||||
import { createReadableStreamFromReadable } from '@remix-run/node'
|
||||
import { RemixServer } from '@remix-run/react'
|
||||
import { isbot } from 'isbot'
|
||||
import { renderToPipeableStream } from 'react-dom/server'
|
||||
|
||||
import { loadContext } from './utils/config/headplane'
|
||||
|
||||
await loadContext()
|
||||
|
||||
export const streamTimeout = 5000
|
||||
export const streamTimeout = 5_000;
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_loadContext: AppLoadContext,
|
||||
routerContext: EntryContext,
|
||||
loadContext: AppLoadContext,
|
||||
) {
|
||||
const ua = request.headers.get('user-agent')
|
||||
const isBot = ua ? isbot(ua) : false
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false
|
||||
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';
|
||||
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer
|
||||
context={remixContext}
|
||||
url={request.url}
|
||||
/>,
|
||||
<ServerRouter context={routerContext} url={request.url} />,
|
||||
{
|
||||
[isBot ? 'onAllReady' : 'onShellReady']() {
|
||||
shellRendered = true
|
||||
const body = new PassThrough()
|
||||
const stream = createReadableStreamFromReadable(body)
|
||||
responseHeaders.set('Content-Type', 'text/html')
|
||||
[readyOption]() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set('Content-Type', 'text/html');
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
}),
|
||||
)
|
||||
);
|
||||
|
||||
pipe(body)
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error as Error)
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500
|
||||
// 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)
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,156 +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}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,75 +0,0 @@
|
||||
import log from '~/utils/log'
|
||||
|
||||
import dockerIntegration from './docker'
|
||||
import { 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
|
||||
}
|
||||
@@ -1,13 +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
|
||||
}
|
||||
@@ -1,204 +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 { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node'
|
||||
|
||||
import log from '~/utils/log'
|
||||
|
||||
import { createIntegration } from './integration'
|
||||
|
||||
interface Context {
|
||||
pid: number | undefined
|
||||
}
|
||||
|
||||
export default createIntegration<Context>({
|
||||
name: 'Kubernetes (k8s)',
|
||||
context: {
|
||||
pid: undefined,
|
||||
},
|
||||
isAvailable: async (context) => {
|
||||
if (platform() !== 'linux') {
|
||||
log.error('INTG', 'Kubernetes is only available on Linux')
|
||||
return false
|
||||
}
|
||||
|
||||
const svcRoot = Config.SERVICEACCOUNT_ROOT
|
||||
try {
|
||||
log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot)
|
||||
const files = await readdir(svcRoot)
|
||||
if (files.length === 0) {
|
||||
log.error('INTG', 'Kubernetes service account not found')
|
||||
return false
|
||||
}
|
||||
|
||||
const mappedFiles = new Set(files.map(file => join(svcRoot, file)))
|
||||
const expectedFiles = [
|
||||
Config.SERVICEACCOUNT_CA_PATH,
|
||||
Config.SERVICEACCOUNT_TOKEN_PATH,
|
||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||
]
|
||||
|
||||
log.debug('INTG', 'Looking for %s', expectedFiles.join(', '))
|
||||
if (!expectedFiles.every(file => mappedFiles.has(file))) {
|
||||
log.error('INTG', 'Malformed Kubernetes service account')
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to access %s: %s', svcRoot, error)
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot)
|
||||
const namespace = await readFile(
|
||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
// Some very ugly nesting but it's necessary
|
||||
if (process.env.HEADSCALE_INTEGRATION_UNSTRICT === 'true') {
|
||||
log.warn('INTG', 'Skipping strict Pod status check')
|
||||
} else {
|
||||
const pod = process.env.POD_NAME
|
||||
if (!pod) {
|
||||
log.error('INTG', 'Missing POD_NAME variable')
|
||||
return false
|
||||
}
|
||||
|
||||
if (pod.trim().length === 0) {
|
||||
log.error('INTG', 'Pod name is empty')
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Checking Kubernetes pod %s in namespace %s',
|
||||
pod,
|
||||
namespace,
|
||||
)
|
||||
|
||||
try {
|
||||
log.debug('INTG', 'Attempgin to get cluster KubeConfig')
|
||||
const kc = new KubeConfig()
|
||||
kc.loadFromCluster()
|
||||
|
||||
const cluster = kc.getCurrentCluster()
|
||||
if (!cluster) {
|
||||
log.error('INTG', 'Malformed kubeconfig')
|
||||
return false
|
||||
}
|
||||
|
||||
log.info('INTG', 'Service account connected to %s (%s)',
|
||||
cluster.name,
|
||||
cluster.server,
|
||||
)
|
||||
|
||||
const kCoreV1Api = kc.makeApiClient(CoreV1Api)
|
||||
|
||||
log.info('INTG', 'Checking pod %s in namespace %s (%s)',
|
||||
pod,
|
||||
namespace,
|
||||
kCoreV1Api.basePath,
|
||||
)
|
||||
|
||||
log.debug('INTG', 'Reading pod info for %s', pod)
|
||||
const { response, body } = await kCoreV1Api.readNamespacedPod(
|
||||
pod,
|
||||
namespace,
|
||||
)
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
log.error('INTG', 'Failed to read pod info: http %d',
|
||||
response.statusCode,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Got pod info: %o', body.spec)
|
||||
const shared = body.spec?.shareProcessNamespace
|
||||
if (shared === undefined) {
|
||||
log.error(
|
||||
'INTG',
|
||||
'Pod does not have spec.shareProcessNamespace set',
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!shared) {
|
||||
log.error(
|
||||
'INTG',
|
||||
'Pod has set but disabled spec.shareProcessNamespace',
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
log.info('INTG', 'Pod %s enabled shared processes', pod)
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to read pod info: %s', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Looking for namespaced process in /proc')
|
||||
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.debug('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
|
||||
}
|
||||
},
|
||||
|
||||
onConfigChange: (context) => {
|
||||
if (!context.pid) {
|
||||
return
|
||||
}
|
||||
|
||||
log.info('INTG', 'Sending SIGTERM to Headscale')
|
||||
kill(context.pid, 'SIGTERM')
|
||||
},
|
||||
})
|
||||
@@ -1,79 +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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Outlet, redirect } from 'react-router';
|
||||
import { ErrorBanner } from '~/components/error-banner';
|
||||
import { pruneEphemeralNodes } from '~/server/db/pruner';
|
||||
import { isDataUnauthorizedError } from '~/server/headscale/api/error-client';
|
||||
import log from '~/utils/log';
|
||||
import type { Route } from './+types/dashboard';
|
||||
|
||||
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
|
||||
// MARK: The session should stay valid if Headscale isn't healthy
|
||||
const healthy = await api.isHealthy();
|
||||
if (healthy) {
|
||||
try {
|
||||
await api.getApiKeys();
|
||||
await pruneEphemeralNodes({ context, request, ...rest });
|
||||
} catch (error) {
|
||||
if (isDataUnauthorizedError(error)) {
|
||||
log.warn(
|
||||
'auth',
|
||||
'Logging out %s due to expired API key',
|
||||
session.user.name,
|
||||
);
|
||||
return redirect('/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.destroySession(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
healthy,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
return (
|
||||
<div className="w-fit mx-auto overscroll-contain my-24">
|
||||
<ErrorBanner className="max-w-2xl" error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { CircleCheckIcon } from 'lucide-react';
|
||||
import { Outlet, redirect } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Footer from '~/components/Footer';
|
||||
import Header from '~/components/Header';
|
||||
import { users } from '~/server/db/schema';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import toast from '~/utils/toast';
|
||||
import { Route } from './+types/shell';
|
||||
|
||||
// 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, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (
|
||||
typeof context.oidc === 'object' &&
|
||||
session.user.subject !== 'unknown-non-oauth' &&
|
||||
!request.url.endsWith('/onboarding')
|
||||
) {
|
||||
const [user] = await context.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, session.user.subject))
|
||||
.limit(1);
|
||||
|
||||
if (!user?.onboarded) {
|
||||
return redirect('/onboarding');
|
||||
}
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
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.user,
|
||||
uiAccess: check,
|
||||
access: {
|
||||
ui: await context.sessions.check(request, Capabilities.ui_access),
|
||||
dns: await context.sessions.check(request, Capabilities.read_network),
|
||||
users: await context.sessions.check(request, Capabilities.read_users),
|
||||
policy: await context.sessions.check(request, Capabilities.read_policy),
|
||||
machines: await context.sessions.check(
|
||||
request,
|
||||
Capabilities.read_machines,
|
||||
),
|
||||
settings: await context.sessions.check(
|
||||
request,
|
||||
Capabilities.read_feature,
|
||||
),
|
||||
},
|
||||
onboarding: request.url.endsWith('/onboarding'),
|
||||
healthy: await api.isHealthy(),
|
||||
};
|
||||
} catch {
|
||||
return redirect('/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.destroySession(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Shell({ loaderData }: Route.ComponentProps) {
|
||||
return (
|
||||
<>
|
||||
<Header {...loaderData} />
|
||||
{/* Always show the outlet if we are onboarding */}
|
||||
{(loaderData.onboarding ? true : loaderData.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="flex text-md font-mono"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
`tailscale up --login-server=${loaderData.url}`,
|
||||
);
|
||||
|
||||
toast('Copied to clipboard');
|
||||
}}
|
||||
>
|
||||
tailscale up --login-server={loaderData.url}
|
||||
</Button>
|
||||
<p className="text-xs mt-1 opacity-50 text-center">
|
||||
Click this button to copy the command.
|
||||
</p>
|
||||
<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 {...loaderData} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"0.26.1": ["0.26.0", "0.26.1"],
|
||||
"0.27.0": ["0.27.0"],
|
||||
"0.27.1": ["0.27.1"]
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"0.26.0": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.26.1": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.27.0": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.27.1": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
}
|
||||
}
|
||||
+56
-27
@@ -1,48 +1,77 @@
|
||||
import type { LinksFunction, MetaFunction } from '@remix-run/node'
|
||||
import type { LinksFunction, MetaFunction } from 'react-router';
|
||||
import {
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from '@remix-run/react'
|
||||
|
||||
import { ErrorPopup } from '~/components/Error'
|
||||
import { Toaster } from '~/components/Toaster'
|
||||
import stylesheet from '~/tailwind.css?url'
|
||||
useNavigation,
|
||||
} from 'react-router';
|
||||
import '@fontsource-variable/inter';
|
||||
import { ExternalScripts } from 'remix-utils/external-scripts';
|
||||
import ProgressBar from '~/components/ProgressBar';
|
||||
import ToastProvider from '~/components/ToastProvider';
|
||||
import stylesheet from '~/tailwind.css?url';
|
||||
import { LiveDataProvider } from '~/utils/live-data';
|
||||
import { useToastQueue } from '~/utils/toast';
|
||||
import type { Route } from './+types/root';
|
||||
import { ErrorBanner } from './components/error-banner';
|
||||
|
||||
export const meta: MetaFunction = () => [
|
||||
{ title: 'Headplane' },
|
||||
{ name: 'description', content: 'A frontend for the headscale coordination server' },
|
||||
]
|
||||
{
|
||||
name: 'description',
|
||||
content: 'A frontend for the headscale coordination server',
|
||||
},
|
||||
];
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: 'stylesheet', href: stylesheet },
|
||||
]
|
||||
];
|
||||
|
||||
export function Layout({ children }: { readonly children: React.ReactNode }) {
|
||||
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 content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<Meta />
|
||||
<Links />
|
||||
<link href="favicon.ico" rel="icon" />
|
||||
</head>
|
||||
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
|
||||
{children}
|
||||
<ToastProvider queue={toastQueue} />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
<ExternalScripts />
|
||||
</body>
|
||||
</html>
|
||||
</LiveDataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <ErrorPopup />
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
return (
|
||||
<div className="w-screen h-screen flex items-center justify-center p-4">
|
||||
<ErrorBanner className="max-w-2xl" error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Outlet />
|
||||
const nav = useNavigation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProgressBar isVisible={nav.state === 'loading'} />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+37
-2
@@ -1,3 +1,38 @@
|
||||
import { flatRoutes } from '@remix-run/fs-routes'
|
||||
import { index, layout, prefix, route } from '@react-router/dev/routes';
|
||||
|
||||
export default flatRoutes()
|
||||
export default [
|
||||
// Utility Routes
|
||||
index('routes/util/redirect.ts'),
|
||||
route('/healthz', 'routes/util/healthz.ts'),
|
||||
|
||||
// Authentication Routes
|
||||
route('/login', 'routes/auth/login/page.tsx'),
|
||||
route('/logout', 'routes/auth/logout.ts'),
|
||||
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
|
||||
route('/oidc/start', 'routes/auth/oidc-start.ts'),
|
||||
route('/ssh', 'routes/ssh/console.tsx'),
|
||||
|
||||
// All the main logged-in dashboard routes
|
||||
// Double nested to separate error propagations
|
||||
layout('layouts/shell.tsx', [
|
||||
route('/onboarding', 'routes/users/onboarding.tsx'),
|
||||
route('/onboarding/skip', 'routes/users/onboarding-skip.tsx'),
|
||||
layout('layouts/dashboard.tsx', [
|
||||
...prefix('/machines', [
|
||||
index('routes/machines/overview.tsx'),
|
||||
route('/:id', 'routes/machines/machine.tsx'),
|
||||
]),
|
||||
|
||||
route('/users', 'routes/users/overview.tsx'),
|
||||
route('/acls', 'routes/acls/overview.tsx'),
|
||||
route('/dns', 'routes/dns/overview.tsx'),
|
||||
|
||||
...prefix('/settings', [
|
||||
index('routes/settings/overview.tsx'),
|
||||
route('/auth-keys', 'routes/settings/auth-keys/overview.tsx'),
|
||||
route('/restrictions', 'routes/settings/restrictions/overview.tsx'),
|
||||
// route('/local-agent', 'routes/settings/local-agent.tsx'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import Merge from 'react-codemirror-merge'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import * as shopify from '@shopify/lang-jsonc'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
|
||||
import { useState } from 'react'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Fallback from './fallback'
|
||||
|
||||
interface EditorProps {
|
||||
isDisabled?: boolean
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function Editor(props: EditorProps) {
|
||||
const [light, setLight] = useState(false)
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)')
|
||||
setLight(theme.matches)
|
||||
theme.addEventListener('change', (theme) => {
|
||||
setLight(theme.matches)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
|
||||
)}>
|
||||
<div className="overflow-y-scroll h-editor text-sm">
|
||||
<ErrorBoundary fallback={
|
||||
<p className={cn(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
'text-gray-400 dark:text-gray-500 text-xl',
|
||||
)}>
|
||||
Failed to load the editor.
|
||||
</p>
|
||||
}>
|
||||
<ClientOnly fallback={<Fallback acl={props.value} />}>
|
||||
{() => (
|
||||
<CodeMirror
|
||||
value={props.value}
|
||||
height="100%"
|
||||
extensions={[shopify.jsonc()]}
|
||||
style={{ height: "100%" }}
|
||||
theme={light ? githubLight : githubDark}
|
||||
onChange={(value) => props.onChange(value)}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DifferProps {
|
||||
left: string
|
||||
right: string
|
||||
}
|
||||
|
||||
export function Differ(props: DifferProps) {
|
||||
const [light, setLight] = useState(false)
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)')
|
||||
setLight(theme.matches)
|
||||
theme.addEventListener('change', (theme) => {
|
||||
setLight(theme.matches)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
|
||||
)}>
|
||||
<div className="overflow-y-scroll h-editor text-sm">
|
||||
{props.left === props.right ? (
|
||||
<p className={cn(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
'text-gray-400 dark:text-gray-500 text-xl',
|
||||
)}>
|
||||
No changes
|
||||
</p>
|
||||
) : (
|
||||
<ErrorBoundary fallback={
|
||||
<p className={cn(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
'text-gray-400 dark:text-gray-500 text-xl',
|
||||
)}>
|
||||
Failed to load the editor.
|
||||
</p>
|
||||
}>
|
||||
<ClientOnly fallback={<Fallback acl={props.right} />}>
|
||||
{() => (
|
||||
<Merge
|
||||
orientation="a-b"
|
||||
theme={light ? githubLight : githubDark}
|
||||
>
|
||||
<Merge.Original
|
||||
readOnly
|
||||
value={props.left}
|
||||
extensions={[shopify.jsonc()]}
|
||||
/>
|
||||
<Merge.Modified
|
||||
readOnly
|
||||
value={props.right}
|
||||
extensions={[shopify.jsonc()]}
|
||||
/>
|
||||
</Merge>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { cn } from '~/utils/cn'
|
||||
import { AlertIcon } from '@primer/octicons-react'
|
||||
|
||||
import Card from '~/components/Card'
|
||||
import Code from '~/components/Code'
|
||||
|
||||
interface Props {
|
||||
message: string
|
||||
}
|
||||
|
||||
export function ErrorView({ message }: Props) {
|
||||
return (
|
||||
<Card variant="flat" className="max-w-full mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-xl mb-0">
|
||||
Error
|
||||
</Card.Title>
|
||||
<AlertIcon className="w-8 h-8 text-red-500"/>
|
||||
</div>
|
||||
<Card.Text className="mt-4">
|
||||
Could not apply changes to your ACL policy
|
||||
due to the following error:
|
||||
<br />
|
||||
<Code>
|
||||
{message}
|
||||
</Code>
|
||||
</Card.Text>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import Spinner from '~/components/Spinner'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
readonly acl: string
|
||||
}
|
||||
|
||||
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" />
|
||||
<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',
|
||||
)}
|
||||
value={acl}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { useLoaderData, useRevalidator } from '@remix-run/react'
|
||||
import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import Code from '~/components/Code'
|
||||
import Link from '~/components/Link'
|
||||
import Notice from '~/components/Notice'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig } from '~/utils/config/headscale'
|
||||
import { HeadscaleError, pull, put } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { send } from '~/utils/res'
|
||||
import log from '~/utils/log'
|
||||
|
||||
import { Editor, Differ } from './cm.client'
|
||||
import { Unavailable } from './unavailable'
|
||||
import { ErrorView } from './error'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
|
||||
// The way policy is handled in 0.23 of Headscale and later is verbose.
|
||||
// The 2 ACL policy modes are either the database one or file one
|
||||
//
|
||||
// File: The ACL policy is readonly to the API and manually edited
|
||||
// Database: The ACL policy is read/write to the API
|
||||
//
|
||||
// To determine if we first have an ACL policy available we need to check
|
||||
// if fetching the v1/policy route gives us a 500 status code or a 200.
|
||||
//
|
||||
// 500 can mean many different things here unfortunately:
|
||||
// - In file based that means the file is not accessible
|
||||
// - In database mode this can mean that we have never set an ACL policy
|
||||
// - In database mode this can mean that the ACL policy is not available
|
||||
// - A general server error may have occurred
|
||||
//
|
||||
// Unfortunately the server errors are not very descriptive so we have to
|
||||
// do some silly guesswork here. If we are running in an integration mode
|
||||
// and have the Headscale configuration available to us, our assumptions
|
||||
// can be more accurate, otherwise we just HAVE to assume that the ACL
|
||||
// policy has never been set.
|
||||
//
|
||||
// We can do damage control by checking for write access and if we are not
|
||||
// able to PUT an ACL policy on the v1/policy route, we can already know
|
||||
// that the policy is at the very-least readonly or not available.
|
||||
const context = await loadContext()
|
||||
let modeGuess = 'database' // Assume database mode
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig()
|
||||
modeGuess = config.policy?.mode ?? 'database'
|
||||
}
|
||||
|
||||
// Attempt to load the policy, for both the frontend and for checking
|
||||
// if we are able to write to the policy for write access
|
||||
try {
|
||||
const { policy } = await pull<{ policy: string }>(
|
||||
'v1/policy',
|
||||
session.get('hsApiKey')!,
|
||||
)
|
||||
|
||||
let write = false // On file mode we already know it's readonly
|
||||
if (modeGuess === 'database' && policy.length > 0) {
|
||||
try {
|
||||
await put('v1/policy', session.get('hsApiKey')!, {
|
||||
policy: policy,
|
||||
})
|
||||
|
||||
write = true
|
||||
} catch (error) {
|
||||
write = false
|
||||
log.debug(
|
||||
'APIC',
|
||||
'Failed to write to ACL policy with error %s',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
read: true,
|
||||
write,
|
||||
mode: modeGuess,
|
||||
policy
|
||||
}
|
||||
} catch {
|
||||
// If we are explicit on file mode then this is the end of the road
|
||||
if (modeGuess === 'file') {
|
||||
return {
|
||||
read: false,
|
||||
write: false,
|
||||
mode: modeGuess,
|
||||
policy: null
|
||||
}
|
||||
}
|
||||
|
||||
// Assume that we have write access otherwise?
|
||||
// This is sort of a brittle assumption to make but we don't want
|
||||
// to create a default policy if we don't have to.
|
||||
return {
|
||||
read: true,
|
||||
write: true,
|
||||
mode: modeGuess,
|
||||
policy: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return send({ success: false, error: null }, 401)
|
||||
}
|
||||
|
||||
try {
|
||||
const { acl } = await request.json() as { acl: string }
|
||||
const { policy } = await put<{ policy: string }>(
|
||||
'v1/policy',
|
||||
session.get('hsApiKey')!,
|
||||
{
|
||||
policy: acl,
|
||||
}
|
||||
)
|
||||
|
||||
return { success: true, policy, error: null }
|
||||
} catch (error) {
|
||||
log.debug('APIC', 'Failed to update ACL policy with error %s', error)
|
||||
|
||||
// @ts-ignore: Shut UP we know it's a string most of the time
|
||||
const text = JSON.parse(error.message)
|
||||
return send({ success: false, error: text.message }, {
|
||||
status: error instanceof HeadscaleError ? error.status : 500,
|
||||
})
|
||||
}
|
||||
|
||||
return { success: true, error: null }
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const fetcher = useDebounceFetcher<typeof action>()
|
||||
const revalidator = useRevalidator()
|
||||
|
||||
const [acl, setAcl] = useState(data.policy ?? '')
|
||||
const [toasted, setToasted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetcher.data || toasted) {
|
||||
return
|
||||
}
|
||||
|
||||
// @ts-ignore: useDebounceFetcher is not typed correctly
|
||||
if (fetcher.data.success) {
|
||||
toast('Updated tailnet ACL policy')
|
||||
} else {
|
||||
toast('Failed to update tailnet ACL policy')
|
||||
}
|
||||
|
||||
setToasted(true)
|
||||
if (revalidator.state === 'idle') {
|
||||
revalidator.revalidate()
|
||||
}
|
||||
}, [fetcher.data, toasted, data.policy])
|
||||
|
||||
// The state for if the save and discard buttons should be disabled
|
||||
// is pretty complicated to calculate and varies on different states.
|
||||
const disabled = useMemo(() => {
|
||||
if (!data.read || !data.write) {
|
||||
return true
|
||||
}
|
||||
|
||||
// First check our fetcher states
|
||||
if (fetcher.state === 'loading') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (revalidator.state === 'loading') {
|
||||
return true
|
||||
}
|
||||
|
||||
// If we have a failed fetcher state allow the user to try again
|
||||
// @ts-ignore: useDebounceFetcher is not typed correctly
|
||||
if (fetcher.data?.success === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
return data.policy === acl
|
||||
}, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data.read && !data.write
|
||||
? (
|
||||
<div className="mb-4">
|
||||
<Notice className="w-fit">
|
||||
The ACL policy is read-only. You can view the current policy
|
||||
but you cannot make changes to it.
|
||||
<br />
|
||||
To resolve this, you need to set the ACL policy mode to
|
||||
database in your Headscale configuration.
|
||||
</Notice>
|
||||
</div>
|
||||
) : undefined}
|
||||
|
||||
<h1 className="text-2xl font-medium mb-4">
|
||||
Access Control List (ACL)
|
||||
</h1>
|
||||
|
||||
<p className="mb-4 max-w-prose">
|
||||
The ACL file is used to define the access control rules for your network.
|
||||
You can find more information about the ACL file in the
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1018/acls"
|
||||
name="Tailscale ACL documentation"
|
||||
>
|
||||
Tailscale ACL guide
|
||||
</Link>
|
||||
{' '}
|
||||
and the
|
||||
{' '}
|
||||
<Link
|
||||
to="https://headscale.net/acls"
|
||||
name="Headscale ACL documentation"
|
||||
>
|
||||
Headscale docs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
{
|
||||
// @ts-ignore: useDebounceFetcher is not typed correctly
|
||||
fetcher.data?.success === false
|
||||
? (
|
||||
// @ts-ignore: useDebounceFetcher is not typed correctly
|
||||
<ErrorView message={fetcher.data.error} />
|
||||
) : undefined}
|
||||
|
||||
{data.read ? (
|
||||
<>
|
||||
<Tabs>
|
||||
<TabList className={cn(
|
||||
'flex border-t border-gray-200 dark:border-gray-700',
|
||||
'w-fit rounded-t-lg overflow-hidden',
|
||||
'text-gray-400 dark:text-gray-500',
|
||||
)}
|
||||
>
|
||||
<Tab
|
||||
id="edit"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2 rounded-tl-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
>
|
||||
<PencilIcon className="w-5 h-5" />
|
||||
<p>Edit file</p>
|
||||
</Tab>
|
||||
<Tab
|
||||
id="diff"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
>
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
<p>Preview changes</p>
|
||||
</Tab>
|
||||
<Tab
|
||||
id="preview"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2 rounded-tr-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
>
|
||||
<BeakerIcon className="w-5 h-5" />
|
||||
<p>Preview rules</p>
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="edit">
|
||||
<Editor
|
||||
isDisabled={!data.write}
|
||||
value={acl}
|
||||
onChange={setAcl}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="diff">
|
||||
<Differ
|
||||
left={data?.policy ?? ''}
|
||||
right={acl}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="preview">
|
||||
<div
|
||||
className={cn(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
|
||||
'p-16 flex flex-col items-center justify-center',
|
||||
)}
|
||||
>
|
||||
<IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" />
|
||||
<p className="w-1/2 text-center mt-4">
|
||||
The Preview rules is very much still a work in progress.
|
||||
It is a bit complicated to implement right now but hopefully it will be available soon.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="mr-2"
|
||||
isDisabled={disabled}
|
||||
onPress={() => {
|
||||
setToasted(false)
|
||||
fetcher.submit({
|
||||
acl,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{fetcher.state === 'idle'
|
||||
? undefined
|
||||
: (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={disabled}
|
||||
onPress={() => {
|
||||
setAcl(data?.policy ?? '')
|
||||
}}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</>
|
||||
) : <Unavailable mode={data.mode as "database" | "file"} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { cn } from '~/utils/cn'
|
||||
import { AlertIcon } from '@primer/octicons-react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Card from '~/components/Card'
|
||||
|
||||
interface Props {
|
||||
mode: 'file' | 'database'
|
||||
}
|
||||
|
||||
export function Unavailable({ mode }: Props) {
|
||||
return (
|
||||
<Card variant="flat" className="max-w-prose mt-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-xl mb-0">
|
||||
ACL Policy Unavailable
|
||||
</Card.Title>
|
||||
<AlertIcon className="w-8 h-8 text-red-500"/>
|
||||
</div>
|
||||
<Card.Text className="mt-4">
|
||||
Unable to load a valid ACL policy configuration.
|
||||
This is most likely due to a misconfiguration in your
|
||||
Headscale configuration file.
|
||||
</Card.Text>
|
||||
|
||||
{mode !== 'file' ? (
|
||||
<p className="mt-4 text-sm">
|
||||
According to your configuration, the ACL policy mode
|
||||
is set to <Code>file</Code> but the ACL file is not
|
||||
available. Ensure that the <Code>policy.path</Code> is
|
||||
set to a valid path in your Headscale configuration.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-4 text-sm">
|
||||
In order to fully utilize the ACL management features of
|
||||
Headplane, please set <Code>policy.mode</Code> to either
|
||||
{' '}<Code>file</Code> or <Code>database</Code> in your
|
||||
Headscale configuration.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
records: { name: string, type: 'A', value: string }[]
|
||||
}
|
||||
|
||||
export default function AddDNS({ records }: Props) {
|
||||
const submit = useSubmit()
|
||||
const [name, setName] = useState('')
|
||||
const [ip, setIp] = useState('')
|
||||
|
||||
const isDuplicate = useMemo(() => {
|
||||
if (name.length === 0 || ip.length === 0) return false
|
||||
const lookup = records.find(record => record.name === name)
|
||||
if (!lookup) return false
|
||||
|
||||
return lookup.value === ip
|
||||
}, [records, name, ip])
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>
|
||||
Add DNS record
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Add DNS record
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Enter the domain and IP address for the new DNS record.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!name || !ip) return
|
||||
|
||||
setName('')
|
||||
setIp('')
|
||||
|
||||
submit({
|
||||
'dns.extra_records': [
|
||||
...records,
|
||||
{
|
||||
name,
|
||||
type: 'A',
|
||||
value: ip,
|
||||
},
|
||||
],
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Domain"
|
||||
placeholder="test.example.com"
|
||||
name="domain"
|
||||
state={[name, setName]}
|
||||
className={cn(
|
||||
'mt-2',
|
||||
isDuplicate && 'outline outline-red-500',
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
label="IP Address"
|
||||
placeholder="101.101.101.101"
|
||||
name="ip"
|
||||
state={[ip, setIp]}
|
||||
className={cn(
|
||||
isDuplicate && 'outline outline-red-500',
|
||||
)}
|
||||
/>
|
||||
{isDuplicate
|
||||
? (
|
||||
<p className="text-sm opacity-50">
|
||||
A record with the domain name
|
||||
{' '}
|
||||
<Code>{name}</Code>
|
||||
{' '}
|
||||
and IP address
|
||||
{' '}
|
||||
<Code>{ip}</Code>
|
||||
{' '}
|
||||
already exists.
|
||||
</p>
|
||||
)
|
||||
: undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-8">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
isDisabled={isDuplicate}
|
||||
>
|
||||
Add
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { RepoForkedIcon } from '@primer/octicons-react'
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Switch from '~/components/Switch'
|
||||
import TextField from '~/components/TextField'
|
||||
import Tooltip from '~/components/Tooltip'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
nameservers: Record<string, string[]>
|
||||
}
|
||||
|
||||
export default function AddNameserver({ nameservers }: Props) {
|
||||
const submit = useSubmit()
|
||||
const [split, setSplit] = useState(false)
|
||||
const [ns, setNs] = useState('')
|
||||
const [domain, setDomain] = useState('')
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>
|
||||
Add nameserver
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Add nameserver
|
||||
</Dialog.Title>
|
||||
<Dialog.Text className="font-semibold">
|
||||
Nameserver
|
||||
</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Use this IPv4 or IPv6 address to resolve names.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!ns) return
|
||||
|
||||
if (split) {
|
||||
const splitNs: Record<string, string[]> = {}
|
||||
for (const [key, value] of Object.entries(nameservers)) {
|
||||
if (key === 'global') continue
|
||||
splitNs[key] = value
|
||||
}
|
||||
|
||||
if (Object.keys(splitNs).includes(domain)) {
|
||||
splitNs[domain].push(ns)
|
||||
} else {
|
||||
splitNs[domain] = [ns]
|
||||
}
|
||||
|
||||
submit({
|
||||
'dns.nameservers.split': splitNs,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
} else {
|
||||
const globalNs = nameservers.global
|
||||
globalNs.push(ns)
|
||||
|
||||
submit({
|
||||
'dns.nameservers.global': globalNs,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}
|
||||
|
||||
setNs('')
|
||||
setDomain('')
|
||||
setSplit(false)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="DNS Server"
|
||||
placeholder="1.2.3.4"
|
||||
name="ns"
|
||||
state={[ns, setNs]}
|
||||
className="mt-2 mb-8"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="block">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Dialog.Text className="font-semibold">
|
||||
Restrict to domain
|
||||
</Dialog.Text>
|
||||
<Tooltip>
|
||||
<Tooltip.Button className={cn(
|
||||
'text-xs rounded-md px-1.5 py-0.5',
|
||||
'bg-ui-200 dark:bg-ui-800',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
|
||||
Split DNS
|
||||
</Tooltip.Button>
|
||||
<Tooltip.Body>
|
||||
Only clients that support split DNS
|
||||
(Tailscale v1.8 or later for most platforms)
|
||||
will use this nameserver. Older clients
|
||||
will ignore it.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Dialog.Text className="text-sm">
|
||||
This nameserver will only be used for some domains.
|
||||
</Dialog.Text>
|
||||
</div>
|
||||
<Switch
|
||||
label="Split DNS"
|
||||
defaultSelected={split}
|
||||
onChange={() => { setSplit(!split) }}
|
||||
/>
|
||||
</div>
|
||||
{split
|
||||
? (
|
||||
<>
|
||||
<Dialog.Text className="font-semibold mt-8">
|
||||
Domain
|
||||
</Dialog.Text>
|
||||
<TextField
|
||||
label="Domain"
|
||||
placeholder="example.com"
|
||||
name="domain"
|
||||
state={[domain, setDomain]}
|
||||
className="my-2"
|
||||
/>
|
||||
<Dialog.Text className="text-sm">
|
||||
Only single-label or fully-qualified queries
|
||||
matching this suffix should use the nameserver.
|
||||
</Dialog.Text>
|
||||
</>
|
||||
)
|
||||
: undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
Add
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useSubmit } from '@remix-run/react'
|
||||
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/dns-records/"
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,227 +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 '@remix-run/react'
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Spinner from '~/components/Spinner'
|
||||
|
||||
type Properties = {
|
||||
readonly isEnabled: boolean;
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function Modal({ isEnabled, disabled }: Properties) {
|
||||
const fetcher = useFetcher()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={disabled}>
|
||||
{fetcher.state === 'idle' ? undefined : (
|
||||
<Spinner className='w-3 h-3'/>
|
||||
)}
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Devices will no longer be accessible via your tailnet domain.
|
||||
The search domain will also be disabled.
|
||||
</Dialog.Text>
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
<Dialog.Action
|
||||
variant='cancel'
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant='confirm'
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns.magic_dns': !isEnabled
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
})
|
||||
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useSubmit } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from 'react-aria-components'
|
||||
|
||||
import Link from '~/components/Link'
|
||||
import Switch from '~/components/Switch'
|
||||
import TableList from '~/components/TableList'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import AddNameserver from './dialogs/nameserver'
|
||||
|
||||
interface Props {
|
||||
nameservers: Record<string, string[]>
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
export default function Nameservers({ nameservers, isDisabled }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Set the nameservers used by devices on the Tailnet
|
||||
to resolve DNS queries.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1054/dns"
|
||||
name="Tailscale DNS Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
{Object.keys(nameservers).map(key => (
|
||||
<NameserverList
|
||||
key={key}
|
||||
isGlobal={key === 'global'}
|
||||
isDisabled={isDisabled}
|
||||
nameservers={nameservers}
|
||||
name={key}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isDisabled
|
||||
? undefined
|
||||
: (
|
||||
<AddNameserver nameservers={nameservers} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ListProps {
|
||||
isGlobal: boolean
|
||||
isDisabled: boolean
|
||||
nameservers: string[]
|
||||
name: string
|
||||
}
|
||||
|
||||
function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps) {
|
||||
const submit = useSubmit()
|
||||
const list = isGlobal ? nameservers['global'] : nameservers[name]
|
||||
if (list.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-md font-medium opacity-80">
|
||||
{isGlobal ? 'Global Nameservers' : name}
|
||||
</h2>
|
||||
</div>
|
||||
<TableList>
|
||||
{list.length > 0 ? list.map((ns, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<TableList.Item key={index}>
|
||||
<p className="font-mono text-sm">{ns}</p>
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm',
|
||||
'text-red-600 dark:text-red-400',
|
||||
'hover:text-red-700 dark:hover:text-red-300',
|
||||
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => {
|
||||
if (isGlobal) {
|
||||
submit({
|
||||
'dns.nameservers.global': list
|
||||
.filter((_, i) => i !== index),
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
} else {
|
||||
submit({
|
||||
'dns.nameservers.split': {
|
||||
...nameservers,
|
||||
[name]: list.filter((_, i) => i !== index),
|
||||
}
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
)) : undefined}
|
||||
</TableList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
/* eslint-disable unicorn/no-keyword-prefix */
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
import { Input } from 'react-aria-components'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import TextField from '~/components/TextField'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type Properties = {
|
||||
readonly name: string;
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function Modal({ name, disabled }: Properties) {
|
||||
const [newName, setNewName] = useState(name)
|
||||
const fetcher = useFetcher()
|
||||
|
||||
return (
|
||||
<div className='flex flex-col w-2/3'>
|
||||
<h1 className='text-2xl font-medium mb-4'>Tailnet Name</h1>
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
This is the base domain name of your Tailnet.
|
||||
Devices are accessible at
|
||||
{' '}
|
||||
<Code>
|
||||
[device].{name}
|
||||
</Code>
|
||||
{' '}
|
||||
when Magic DNS is enabled.
|
||||
</p>
|
||||
<Input
|
||||
readOnly
|
||||
className={cn(
|
||||
'block px-2.5 py-1.5 w-1/2 rounded-lg my-4',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'dark:bg-ui-800 dark:text-ui-300 text-sm',
|
||||
'outline-none'
|
||||
)}
|
||||
type='text'
|
||||
value={name}
|
||||
onFocus={event => {
|
||||
event.target.select()
|
||||
}}
|
||||
/>
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={disabled}>
|
||||
{fetcher.state === 'idle' ? undefined : (
|
||||
<Spinner className='w-3 h-3'/>
|
||||
)}
|
||||
Rename Tailnet
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Rename Tailnet
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Keep in mind that changing this can lead to all sorts of unexpected behavior and may break existing devices in your tailnet.
|
||||
</Dialog.Text>
|
||||
<TextField
|
||||
label='Tailnet name'
|
||||
placeholder='ts.net'
|
||||
state={[newName, setNewName]}
|
||||
className='my-2'
|
||||
/>
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
<Dialog.Action
|
||||
variant='cancel'
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant='confirm'
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
'dns.base_domain': newName
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
})
|
||||
|
||||
close()
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { type ActionFunctionArgs } from '@remix-run/node'
|
||||
import { json, useLoaderData } from '@remix-run/react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Notice from '~/components/Notice'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig, patchConfig } from '~/utils/config/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
|
||||
import DNS from './dns'
|
||||
import Domains from './domains'
|
||||
import MagicModal from './magic'
|
||||
import Nameservers from './nameservers'
|
||||
import RenameModal from './rename'
|
||||
|
||||
// We do not want to expose every config value
|
||||
export async function loader() {
|
||||
const context = await loadContext()
|
||||
if (!context.config.read) {
|
||||
throw new Error('No configuration is available')
|
||||
}
|
||||
|
||||
const config = await loadConfig()
|
||||
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,
|
||||
nameservers: config.dns.nameservers.global,
|
||||
splitDns: config.dns.nameservers.split,
|
||||
searchDomains: config.dns.search_domains,
|
||||
extraRecords: config.dns.extra_records,
|
||||
}
|
||||
|
||||
return {
|
||||
...dns,
|
||||
...context,
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return send({ success: false }, 401)
|
||||
}
|
||||
|
||||
const context = await loadContext()
|
||||
if (!context.config.write) {
|
||||
return send({ success: false }, 403)
|
||||
}
|
||||
|
||||
const data = await request.json() as Record<string, unknown>
|
||||
await patchConfig(data)
|
||||
|
||||
if (context.integration?.onConfigChange) {
|
||||
await context.integration.onConfigChange(context.integration.context)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
useLiveData({ interval: 5000 })
|
||||
const data = useLoaderData<typeof loader>()
|
||||
|
||||
const allNs: Record<string, string[]> = {}
|
||||
for (const key of Object.keys(data.splitDns)) {
|
||||
allNs[key] = data.splitDns[key]
|
||||
}
|
||||
|
||||
allNs.global = data.nameservers
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-16 max-w-screen-lg">
|
||||
{data.config.write
|
||||
? undefined
|
||||
: (
|
||||
<Notice>
|
||||
The Headscale configuration is read-only. You cannot make changes to the configuration
|
||||
</Notice>
|
||||
)}
|
||||
<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}
|
||||
searchDomains={data.searchDomains}
|
||||
disabled={!data.config.write}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
||||
Automatically register domain names for each device
|
||||
on the tailnet. Devices will be accessible at
|
||||
{' '}
|
||||
<Code>
|
||||
[device].
|
||||
{data.baseDomain}
|
||||
</Code>
|
||||
{' '}
|
||||
when Magic DNS is enabled.
|
||||
</p>
|
||||
<MagicModal isEnabled={data.magicDns} disabled={!data.config.write} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { Link as RemixLink, useLoaderData } from '@remix-run/react'
|
||||
import { InfoIcon, GearIcon, CheckCircleIcon, SkipIcon, PersonIcon } from '@primer/octicons-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import Attribute from '~/components/Attribute'
|
||||
import Button from '~/components/Button'
|
||||
import Card from '~/components/Card'
|
||||
import Menu from '~/components/Menu'
|
||||
import Tooltip from '~/components/Tooltip'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
import { Machine, Route, User } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig } from '~/utils/config/headscale'
|
||||
import { pull } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
import Link from '~/components/Link'
|
||||
|
||||
import { menuAction } from '../_data.machines._index/action'
|
||||
import MenuOptions from '../_data.machines._index/menu'
|
||||
import Routes from '../_data.machines._index/dialogs/routes'
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!params.id) {
|
||||
throw new Error('No machine ID provided')
|
||||
}
|
||||
|
||||
const context = await loadContext()
|
||||
let magic: string | undefined
|
||||
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig()
|
||||
if (config.dns.magic_dns) {
|
||||
magic = config.dns.base_domain
|
||||
}
|
||||
}
|
||||
|
||||
const [machine, routes, users] = await Promise.all([
|
||||
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
|
||||
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
||||
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
||||
])
|
||||
|
||||
return {
|
||||
machine: machine.node,
|
||||
routes: routes.routes.filter(route => route.node.id === params.id),
|
||||
users: users.users,
|
||||
magic,
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
return menuAction(request)
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { machine, magic, routes, users } = useLoaderData<typeof loader>()
|
||||
const routesState = useState(false)
|
||||
useLiveData({ interval: 1000 })
|
||||
|
||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||
|| machine.expiry === null
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now()
|
||||
|
||||
const tags = [
|
||||
...machine.forcedTags,
|
||||
...machine.validTags,
|
||||
]
|
||||
|
||||
if (expired) {
|
||||
tags.unshift('Expired')
|
||||
}
|
||||
|
||||
// This is much easier with Object.groupBy but it's too new for us
|
||||
const { exit, subnet, subnetApproved } = routes.reduce((acc, route) => {
|
||||
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
|
||||
acc.exit.push(route)
|
||||
return acc
|
||||
}
|
||||
|
||||
if (route.enabled) {
|
||||
acc.subnetApproved.push(route)
|
||||
return acc
|
||||
}
|
||||
|
||||
acc.subnet.push(route)
|
||||
return acc
|
||||
}, { exit: [], subnetApproved: [], subnet: [] })
|
||||
|
||||
const exitEnabled = useMemo(() => {
|
||||
if (exit.length !== 2) return false
|
||||
return exit[0].enabled && exit[1].enabled
|
||||
}, [exit])
|
||||
|
||||
if (exitEnabled) {
|
||||
tags.unshift('Exit Node')
|
||||
}
|
||||
|
||||
if (subnetApproved.length > 0) {
|
||||
tags.unshift('Subnets')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-8 text-md">
|
||||
<RemixLink
|
||||
to="/machines"
|
||||
className="font-medium"
|
||||
>
|
||||
All Machines
|
||||
</RemixLink>
|
||||
<span className="mx-2">
|
||||
/
|
||||
</span>
|
||||
{machine.givenName}
|
||||
</p>
|
||||
<div className={cn(
|
||||
'flex justify-between items-center',
|
||||
'border-b border-ui-100 dark:border-ui-800',
|
||||
)}>
|
||||
<span className="flex items-baseline gap-x-4 text-sm mb-4">
|
||||
<h1 className="text-2xl font-medium">
|
||||
{machine.givenName}
|
||||
</h1>
|
||||
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
|
||||
</span>
|
||||
|
||||
<MenuOptions
|
||||
className={cn(
|
||||
'bg-ui-100 dark:bg-ui-800',
|
||||
)}
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
users={users}
|
||||
magic={magic}
|
||||
buttonChild={
|
||||
<Menu.Button className={cn(
|
||||
'flex items-center justify-center gap-x-2',
|
||||
'bg-main-200 dark:bg-main-700/30',
|
||||
'hover:bg-main-300 dark:hover:bg-main-600/30',
|
||||
'text-ui-700 dark:text-ui-300 mb-2',
|
||||
'w-fit text-sm rounded-lg px-3 py-2'
|
||||
)}>
|
||||
<GearIcon className="w-5" />
|
||||
Machine Settings
|
||||
</Menu.Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 mb-4">
|
||||
<div className="border-r border-ui-100 dark:border-ui-800 p-2 pr-4">
|
||||
<span className="text-sm text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
||||
Managed by
|
||||
<Tooltip>
|
||||
<Tooltip.Button>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</Tooltip.Button>
|
||||
<Tooltip.Body>
|
||||
By default, a machine’s permissions match its creator’s.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="flex items-center gap-x-2.5 mt-1">
|
||||
<div className={cn(
|
||||
'rounded-full h-7 w-7 flex items-center justify-center',
|
||||
'border border-ui-200 dark:border-ui-700',
|
||||
)}>
|
||||
<PersonIcon className="w-4 h-4" />
|
||||
</div>
|
||||
{machine.user.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 pl-4">
|
||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||
Status
|
||||
</p>
|
||||
<div className="flex gap-1 mt-1 mb-8">
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className={cn(
|
||||
'text-xs rounded-md px-1.5 py-0.5',
|
||||
'bg-ui-200 dark:bg-ui-800',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-medium mb-4 mt-8">
|
||||
Subnets & Routing
|
||||
</h2>
|
||||
<Routes
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
state={routesState}
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p>
|
||||
Subnets let you expose physical network routes onto Tailscale.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1019/subnets"
|
||||
name="Tailscale Subnets Documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
<Button
|
||||
variant="light"
|
||||
control={routesState}
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
</div>
|
||||
<Card
|
||||
variant="flat"
|
||||
className={cn(
|
||||
'w-full max-w-full grid sm:grid-cols-2',
|
||||
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
||||
Approved
|
||||
<Tooltip>
|
||||
<Tooltip.Button>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</Tooltip.Button>
|
||||
<Tooltip.Body>
|
||||
Traffic to these routes are being
|
||||
routed through this machine.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{subnetApproved.length === 0 ? (
|
||||
<span className="text-ui-400 dark:text-ui-300">
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{subnetApproved.map(route => (
|
||||
<li key={route.id}>
|
||||
{route.prefix}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
'p-0 rounded-sm bg-transparent mt-1',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
'hover:bg-transparent',
|
||||
'hover:text-blue-600 dark:hover:text-blue-500',
|
||||
)}
|
||||
control={routesState}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
||||
Awaiting Approval
|
||||
<Tooltip>
|
||||
<Tooltip.Button>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</Tooltip.Button>
|
||||
<Tooltip.Body>
|
||||
This machine is advertising these routes,
|
||||
but they must be approved before traffic
|
||||
will be routed to them.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{subnet.length === 0 ? (
|
||||
<span className="text-ui-400 dark:text-ui-300">
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{subnet.map(route => (
|
||||
<li key={route.id}>
|
||||
{route.prefix}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
'p-0 rounded-sm bg-transparent mt-1',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
'hover:bg-transparent',
|
||||
'hover:text-blue-600 dark:hover:text-blue-500',
|
||||
)}
|
||||
control={routesState}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
||||
Exit Node
|
||||
<Tooltip>
|
||||
<Tooltip.Button>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</Tooltip.Button>
|
||||
<Tooltip.Body>
|
||||
Whether this machine can act as an
|
||||
exit node for your tailnet.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{exit.length === 0 ? (
|
||||
<span className="text-ui-400 dark:text-ui-300">
|
||||
—
|
||||
</span>
|
||||
) : exitEnabled ? (
|
||||
<span className="flex items-center gap-x-1">
|
||||
<CheckCircleIcon className="w-3.5 h-3.5 text-green-700" />
|
||||
Allowed
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-x-1">
|
||||
<SkipIcon className="w-3.5 h-3.5 text-red-700" />
|
||||
Awaiting Approval
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
'p-0 rounded-sm bg-transparent mt-1',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
'hover:bg-transparent',
|
||||
'hover:text-blue-600 dark:hover:text-blue-500',
|
||||
)}
|
||||
control={routesState}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<h2 className="text-xl font-medium mb-4">
|
||||
Machine Details
|
||||
</h2>
|
||||
<Card variant="flat" className="w-full max-w-full">
|
||||
<Attribute name="Creator" value={machine.user.name} />
|
||||
<Attribute name="Node ID" value={machine.id} />
|
||||
<Attribute name="Node Name" value={machine.givenName} />
|
||||
<Attribute name="Hostname" value={machine.name} />
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Node Key"
|
||||
value={machine.nodeKey}
|
||||
/>
|
||||
<Attribute
|
||||
name="Created"
|
||||
value={new Date(machine.createdAt).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name="Last Seen"
|
||||
value={new Date(machine.lastSeen).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name="Expiry"
|
||||
value={expired
|
||||
? new Date(machine.expiry).toLocaleString()
|
||||
: 'Never'
|
||||
}
|
||||
/>
|
||||
{magic
|
||||
? (
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Domain"
|
||||
value={`${machine.givenName}.${magic}`}
|
||||
/>
|
||||
)
|
||||
: undefined}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { ActionFunctionArgs } from '@remix-run/node'
|
||||
import { del, post } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { send } from '~/utils/res'
|
||||
import log from '~/utils/log'
|
||||
|
||||
export async function menuAction(request: ActionFunctionArgs['request']) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return send({ message: 'Unauthorized' }, {
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
|
||||
const data = await request.formData()
|
||||
if (!data.has('_method') || !data.has('id')) {
|
||||
return send({ message: 'No method or ID provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const id = String(data.get('id'))
|
||||
const method = String(data.get('_method'))
|
||||
|
||||
switch (method) {
|
||||
case 'delete': {
|
||||
await del(`v1/node/${id}`, session.get('hsApiKey')!)
|
||||
return { message: 'Machine removed' }
|
||||
}
|
||||
|
||||
case 'expire': {
|
||||
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
|
||||
return { message: 'Machine expired' }
|
||||
}
|
||||
|
||||
case 'rename': {
|
||||
if (!data.has('name')) {
|
||||
return send({ message: 'No name provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const name = String(data.get('name'))
|
||||
|
||||
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
|
||||
return { message: 'Machine renamed' }
|
||||
}
|
||||
|
||||
case 'routes': {
|
||||
if (!data.has('route') || !data.has('enabled')) {
|
||||
return send({ message: 'No route or enabled provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const route = String(data.get('route'))
|
||||
const enabled = data.get('enabled') === 'true'
|
||||
const postfix = enabled ? 'enable' : 'disable'
|
||||
|
||||
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
|
||||
return { message: 'Route updated' }
|
||||
}
|
||||
|
||||
case 'exit-node': {
|
||||
if (!data.has('routes') || !data.has('enabled')) {
|
||||
return send({ message: 'No route or enabled provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const routes = data.get('routes')?.toString().split(',') ?? []
|
||||
const enabled = data.get('enabled') === 'true'
|
||||
const postfix = enabled ? 'enable' : 'disable'
|
||||
|
||||
await Promise.all(routes.map(async (route) => {
|
||||
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
|
||||
}))
|
||||
|
||||
return { message: 'Exit node updated' }
|
||||
}
|
||||
|
||||
case 'move': {
|
||||
if (!data.has('to')) {
|
||||
return send({ message: 'No destination provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const to = String(data.get('to'))
|
||||
|
||||
try {
|
||||
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
|
||||
return { message: `Moved node ${id} to ${to}` }
|
||||
} catch {
|
||||
return send({ message: `Failed to move node ${id} to ${to}` }, {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
case 'tags': {
|
||||
const tags = data.get('tags')?.toString()
|
||||
.split(',')
|
||||
.filter((tag) => tag.trim() !== '')
|
||||
?? []
|
||||
|
||||
try {
|
||||
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
|
||||
tags,
|
||||
})
|
||||
|
||||
return { message: 'Tags updated' }
|
||||
} catch (error) {
|
||||
log.debug('APIC', 'Failed to update tags: %s', error)
|
||||
return send({ message: 'Failed to update tags' }, {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
case 'register': {
|
||||
const key = data.get('mkey')?.toString()
|
||||
const user = data.get('user')?.toString()
|
||||
|
||||
if (!key) {
|
||||
return send({ message: 'No machine key provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return send({ message: 'No user provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const qp = new URLSearchParams()
|
||||
qp.append('user', user)
|
||||
qp.append('key', key)
|
||||
|
||||
const url = `v1/node/register?${qp.toString()}`
|
||||
await post(url, session.get('hsApiKey')!, {
|
||||
user, key,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Machine registered'
|
||||
}
|
||||
} catch {
|
||||
return send({
|
||||
success: false,
|
||||
message: 'Failed to register machine'
|
||||
}, {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
return send({ message: 'Invalid method' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { type Dispatch, type SetStateAction } from 'react'
|
||||
|
||||
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>>]
|
||||
}
|
||||
|
||||
export default function Delete({ machine, state }: DeleteProps) {
|
||||
const submit = useSubmit()
|
||||
|
||||
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.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { type Dispatch, type 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>>]
|
||||
}
|
||||
|
||||
export default function Expire({ machine, state }: ExpireProps) {
|
||||
const submit = useSubmit()
|
||||
|
||||
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.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Select from '~/components/Select'
|
||||
import { type Machine, User } from '~/types'
|
||||
|
||||
interface MoveProps {
|
||||
readonly machine: Machine
|
||||
readonly users: User[]
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
readonly magic?: string
|
||||
}
|
||||
|
||||
export default function Move({ machine, state, magic, users }: MoveProps) {
|
||||
const [owner, setOwner] = useState(machine.user.name)
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Change the owner of
|
||||
{' '}
|
||||
{machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
The owner of the machine is the user associated with it.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="move" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<Select
|
||||
label="Owner"
|
||||
name="to"
|
||||
placeholder="Select a user"
|
||||
state={[owner, setOwner]}
|
||||
>
|
||||
{users.map(user => (
|
||||
<Select.Item key={user.id} id={user.name}>
|
||||
{user.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
{magic
|
||||
? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||
This machine is accessible by the hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{machine.givenName}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
: undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
Change owner
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { Form, useFetcher, Link } from '@remix-run/react'
|
||||
import { Dispatch, SetStateAction, useState, useEffect } from 'react'
|
||||
import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
import Select from '~/components/Select'
|
||||
import Menu from '~/components/Menu'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import { Machine, User } from '~/types'
|
||||
|
||||
export interface NewProps {
|
||||
server: string
|
||||
users: User[]
|
||||
}
|
||||
|
||||
export default function New(data: NewProps) {
|
||||
const fetcher = useFetcher<{ success?: boolean }>()
|
||||
const mkeyState = useState(false)
|
||||
const [mkey, setMkey] = useState('')
|
||||
const [user, setUser] = useState('')
|
||||
const [toasted, setToasted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetcher.data || toasted) {
|
||||
return
|
||||
}
|
||||
|
||||
if (fetcher.data.success) {
|
||||
toast('Registered new machine')
|
||||
} else {
|
||||
toast('Failed to register machine due to an invalid key')
|
||||
}
|
||||
|
||||
setToasted(true)
|
||||
}, [fetcher.data, toasted, mkey])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog>
|
||||
<Dialog.Panel control={mkeyState}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Register Machine Key
|
||||
</Dialog.Title>
|
||||
<Dialog.Text className='mb-4'>
|
||||
The machine key is given when you run
|
||||
{' '}
|
||||
<Code isCopyable>
|
||||
tailscale up --login-server=
|
||||
{data.server}
|
||||
</Code>
|
||||
{' '}
|
||||
on your device.
|
||||
</Dialog.Text>
|
||||
<fetcher.Form method="POST" onSubmit={e => {
|
||||
fetcher.submit(e.currentTarget)
|
||||
close()
|
||||
}}>
|
||||
<input type="hidden" name="_method" value="register" />
|
||||
<input type="hidden" name="id" value="_" />
|
||||
<TextField
|
||||
label='Machine Key'
|
||||
placeholder='mkey:ff.....'
|
||||
name="mkey"
|
||||
state={[mkey, setMkey]}
|
||||
className='my-2 font-mono'
|
||||
/>
|
||||
<Select
|
||||
label="Owner"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
state={[user, setUser]}
|
||||
>
|
||||
{data.users.map(user => (
|
||||
<Select.Item key={user.id} id={user.name}>
|
||||
{user.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
isDisabled={!mkey || !mkey.trim().startsWith('mkey:') || !user}
|
||||
>
|
||||
{fetcher.state === 'idle'
|
||||
? undefined
|
||||
: (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Register
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
<Menu>
|
||||
<Menu.Button
|
||||
className={cn(
|
||||
'w-fit text-sm rounded-lg px-4 py-2',
|
||||
'bg-main-700 dark:bg-main-800 text-white',
|
||||
'hover:bg-main-800 dark:hover:bg-main-700',
|
||||
)}
|
||||
>
|
||||
Add Device
|
||||
</Menu.Button>
|
||||
<Menu.Items>
|
||||
<Menu.ItemButton control={mkeyState}>
|
||||
<ServerIcon className='w-4 h-4 mr-2'/>
|
||||
Register Machine Key
|
||||
</Menu.ItemButton>
|
||||
<Menu.ItemButton>
|
||||
<Link to="/settings/auth-keys">
|
||||
<KeyIcon className='w-4 h-4 mr-2'/>
|
||||
Generate Pre-auth Key
|
||||
</Link>
|
||||
</Menu.ItemButton>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
import { type Machine } from '~/types'
|
||||
|
||||
interface RenameProps {
|
||||
readonly machine: Machine
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
readonly magic?: string
|
||||
}
|
||||
|
||||
export default function Rename({ machine, state, magic }: RenameProps) {
|
||||
const [name, setName] = useState(machine.givenName)
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Edit machine name for
|
||||
{' '}
|
||||
{machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
This name is shown in the admin panel, in Tailscale clients,
|
||||
and used when generating MagicDNS names.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="rename" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<TextField
|
||||
label="Machine name"
|
||||
placeholder="Machine name"
|
||||
name="name"
|
||||
state={[name, setName]}
|
||||
className="my-2"
|
||||
/>
|
||||
{magic
|
||||
? (
|
||||
name.length > 0 && name !== machine.givenName
|
||||
? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||
This machine will be accessible by the hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{name.toLowerCase().replaceAll(/\s+/g, '-')}
|
||||
</Code>
|
||||
{'. '}
|
||||
The hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{machine.givenName}
|
||||
</Code>
|
||||
{' '}
|
||||
will no longer point to this machine.
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||
This machine is accessible by the hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{machine.givenName}
|
||||
</Code>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
)
|
||||
: undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
Rename
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import { Dispatch, SetStateAction, useMemo } from 'react'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Switch from '~/components/Switch'
|
||||
import Link from '~/components/Link'
|
||||
import { Machine, Route } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface RoutesProps {
|
||||
readonly machine: Machine
|
||||
readonly routes: Route[]
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
}
|
||||
|
||||
// TODO: Support deleting routes
|
||||
export default function Routes({ machine, routes, state }: RoutesProps) {
|
||||
const fetcher = useFetcher()
|
||||
|
||||
// This is much easier with Object.groupBy but it's too new for us
|
||||
const { exit, subnet } = routes.reduce((acc, route) => {
|
||||
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
|
||||
acc.exit.push(route)
|
||||
return acc
|
||||
}
|
||||
|
||||
acc.subnet.push(route)
|
||||
return acc
|
||||
}, { exit: [], subnet: [] })
|
||||
|
||||
const exitEnabled = useMemo(() => {
|
||||
if (exit.length !== 2) return false
|
||||
return exit[0].enabled && exit[1].enabled
|
||||
}, [exit])
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Edit route settings of
|
||||
{' '}
|
||||
{machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text className="font-bold">
|
||||
Subnet routes
|
||||
</Dialog.Text>
|
||||
<Dialog.Text>
|
||||
Connect to devices you can't install Tailscale on
|
||||
by advertising IP ranges as subnet routes.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1019/subnets"
|
||||
name="Tailscale Subnets Documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
<div className={cn(
|
||||
'rounded-lg overflow-y-auto my-2',
|
||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
||||
'border border-zinc-200 dark:border-zinc-700',
|
||||
)}
|
||||
>
|
||||
{subnet.length === 0
|
||||
? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-center',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
No routes are advertised on this machine.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: undefined}
|
||||
{subnet.map(route => (
|
||||
<div
|
||||
key={route.id}
|
||||
className={cn(
|
||||
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-between',
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
{route.prefix}
|
||||
</p>
|
||||
<Switch
|
||||
defaultSelected={route.enabled}
|
||||
label="Enabled"
|
||||
onChange={(checked) => {
|
||||
const form = new FormData()
|
||||
form.set('id', machine.id)
|
||||
form.set('_method', 'routes')
|
||||
form.set('route', route.id)
|
||||
|
||||
form.set('enabled', String(checked))
|
||||
fetcher.submit(form, {
|
||||
method: 'POST',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Dialog.Text className="font-bold mt-8">
|
||||
Exit nodes
|
||||
</Dialog.Text>
|
||||
<Dialog.Text>
|
||||
Allow your network to route internet traffic through this machine.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1103/exit-nodes"
|
||||
name="Tailscale Exit-node Documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
<div className={cn(
|
||||
'rounded-lg overflow-y-auto my-2',
|
||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
||||
'border border-zinc-200 dark:border-zinc-700',
|
||||
)}
|
||||
>
|
||||
{exit.length === 0
|
||||
? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-center',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
This machine is not an exit node.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-between',
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
Use as exit node
|
||||
</p>
|
||||
<Switch
|
||||
defaultSelected={exitEnabled}
|
||||
label="Enabled"
|
||||
onChange={(checked) => {
|
||||
const form = new FormData()
|
||||
form.set('id', machine.id)
|
||||
form.set('_method', 'exit-node')
|
||||
form.set('routes', exit.map(route => route.id).join(','))
|
||||
|
||||
form.set('enabled', String(checked))
|
||||
fetcher.submit(form, {
|
||||
method: 'POST',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
isDisabled={fetcher.state === 'submitting'}
|
||||
onPress={close}
|
||||
>
|
||||
Close
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { PlusIcon, XIcon } from '@primer/octicons-react'
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { Dispatch, SetStateAction, useState } from 'react'
|
||||
import { Button, Input } from 'react-aria-components'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Link from '~/components/Link'
|
||||
import { Machine } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface TagsProps {
|
||||
readonly machine: Machine
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
}
|
||||
|
||||
export default function Tags({ machine, state }: TagsProps) {
|
||||
const [tags, setTags] = useState(machine.forcedTags)
|
||||
const [tag, setTag] = useState('')
|
||||
const submit = useSubmit()
|
||||
|
||||
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)
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react'
|
||||
import { Link } from '@remix-run/react'
|
||||
|
||||
import Menu from '~/components/Menu'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import { Machine, Route, User } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import MenuOptions from './menu'
|
||||
|
||||
interface Props {
|
||||
readonly machine: Machine
|
||||
readonly routes: Route[]
|
||||
readonly users: User[]
|
||||
readonly magic?: string
|
||||
}
|
||||
|
||||
export default function MachineRow({ machine, routes, magic, users }: Props) {
|
||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||
|| machine.expiry === null
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now()
|
||||
|
||||
const tags = [
|
||||
...machine.forcedTags,
|
||||
...machine.validTags,
|
||||
]
|
||||
|
||||
if (expired) {
|
||||
tags.unshift('Expired')
|
||||
}
|
||||
|
||||
let prefix = magic?.startsWith('[user]')
|
||||
? magic.replace('[user]', machine.user.name)
|
||||
: magic
|
||||
|
||||
// This is much easier with Object.groupBy but it's too new for us
|
||||
const { exit, subnet, subnetApproved } = routes.reduce((acc, route) => {
|
||||
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
|
||||
acc.exit.push(route)
|
||||
return acc
|
||||
}
|
||||
|
||||
if (route.enabled) {
|
||||
acc.subnetApproved.push(route)
|
||||
return acc
|
||||
}
|
||||
|
||||
acc.subnet.push(route)
|
||||
return acc
|
||||
}, { exit: [], subnetApproved: [], subnet: [] })
|
||||
|
||||
const exitEnabled = useMemo(() => {
|
||||
if (exit.length !== 2) return false
|
||||
return exit[0].enabled && exit[1].enabled
|
||||
}, [exit])
|
||||
|
||||
if (exitEnabled) {
|
||||
tags.unshift('Exit Node')
|
||||
}
|
||||
|
||||
if (subnetApproved.length > 0) {
|
||||
tags.unshift('Subnets')
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={machine.id}
|
||||
className="hover:bg-zinc-100 dark:hover:bg-zinc-800 group"
|
||||
>
|
||||
<td className="pl-0.5 py-2">
|
||||
<Link
|
||||
to={`/machines/${machine.id}`}
|
||||
className="group/link h-full"
|
||||
>
|
||||
<p className={cn(
|
||||
'font-semibold leading-snug',
|
||||
'group-hover/link:text-blue-600',
|
||||
'group-hover/link:dark:text-blue-400',
|
||||
)}
|
||||
>
|
||||
{machine.givenName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 font-mono">
|
||||
{machine.name}
|
||||
</p>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className={cn(
|
||||
'text-xs rounded-md px-1.5 py-0.5',
|
||||
'bg-ui-200 dark:bg-ui-800',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
{machine.ipAddresses[0]}
|
||||
<Menu>
|
||||
<Menu.Button>
|
||||
<ChevronDownIcon className="w-4 h-4" />
|
||||
</Menu.Button>
|
||||
<Menu.Items>
|
||||
{machine.ipAddresses.map(ip => (
|
||||
<Menu.ItemButton
|
||||
key={ip}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1.5 text-sm',
|
||||
'justify-between w-full',
|
||||
)}
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(ip)
|
||||
toast('Copied IP address to clipboard')
|
||||
}}
|
||||
>
|
||||
{ip}
|
||||
<CopyIcon className="w-3 h-3" />
|
||||
</Menu.ItemButton>
|
||||
))}
|
||||
{magic
|
||||
? (
|
||||
<Menu.ItemButton
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1.5 text-sm',
|
||||
'justify-between w-full break-keep',
|
||||
)}
|
||||
onPress={async () => {
|
||||
const ip = `${machine.givenName}.${prefix}`
|
||||
await navigator.clipboard.writeText(ip)
|
||||
toast('Copied hostname to clipboard')
|
||||
}}
|
||||
>
|
||||
{machine.givenName}
|
||||
.
|
||||
{prefix}
|
||||
<CopyIcon className="w-3 h-3" />
|
||||
</Menu.ItemButton>
|
||||
)
|
||||
: undefined}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<span className={cn(
|
||||
'flex items-center gap-x-1 text-sm',
|
||||
'text-gray-500 dark:text-gray-400',
|
||||
)}
|
||||
>
|
||||
<StatusCircle
|
||||
isOnline={machine.online && !expired}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<p>
|
||||
{machine.online && !expired
|
||||
? 'Connected'
|
||||
: new Date(
|
||||
machine.lastSeen,
|
||||
).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
users={users}
|
||||
magic={magic}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { KebabHorizontalIcon } from '@primer/octicons-react'
|
||||
import { ReactNode, useState } from 'react'
|
||||
|
||||
import MenuComponent from '~/components/Menu'
|
||||
import { Machine, Route, User } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Delete from './dialogs/delete'
|
||||
import Expire from './dialogs/expire'
|
||||
import Move from './dialogs/move'
|
||||
import Rename from './dialogs/rename'
|
||||
import Routes from './dialogs/routes'
|
||||
import Tags from './dialogs/tags'
|
||||
|
||||
interface MenuProps {
|
||||
machine: Machine
|
||||
routes: Route[]
|
||||
users: User[]
|
||||
magic?: string
|
||||
buttonChild?: ReactNode
|
||||
}
|
||||
|
||||
export default function Menu({ machine, routes, magic, users, buttonChild }: MenuProps) {
|
||||
const renameState = useState(false)
|
||||
const expireState = useState(false)
|
||||
const removeState = useState(false)
|
||||
const routesState = useState(false)
|
||||
const moveState = useState(false)
|
||||
const tagsState = useState(false)
|
||||
|
||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||
|| machine.expiry === null
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Rename
|
||||
machine={machine}
|
||||
state={renameState}
|
||||
magic={magic}
|
||||
/>
|
||||
<Delete
|
||||
machine={machine}
|
||||
state={removeState}
|
||||
/>
|
||||
{expired
|
||||
? undefined
|
||||
: (
|
||||
<Expire
|
||||
machine={machine}
|
||||
state={expireState}
|
||||
/>
|
||||
)}
|
||||
<Routes
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
state={routesState}
|
||||
/>
|
||||
<Tags
|
||||
machine={machine}
|
||||
state={tagsState}
|
||||
/>
|
||||
<Move
|
||||
machine={machine}
|
||||
state={moveState}
|
||||
users={users}
|
||||
magic={magic}
|
||||
/>
|
||||
|
||||
<MenuComponent>
|
||||
{buttonChild ?? (
|
||||
<MenuComponent.Button
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'border border-transparent rounded-lg py-0.5 w-10',
|
||||
'group-hover:border-gray-200 dark:group-hover:border-zinc-700',
|
||||
)}
|
||||
>
|
||||
<KebabHorizontalIcon className="w-5" />
|
||||
</MenuComponent.Button>
|
||||
)}
|
||||
<MenuComponent.Items>
|
||||
<MenuComponent.ItemButton control={renameState}>
|
||||
Edit machine name
|
||||
</MenuComponent.ItemButton>
|
||||
<MenuComponent.ItemButton control={routesState}>
|
||||
Edit route settings
|
||||
</MenuComponent.ItemButton>
|
||||
<MenuComponent.ItemButton control={tagsState}>
|
||||
Edit ACL tags
|
||||
</MenuComponent.ItemButton>
|
||||
<MenuComponent.ItemButton control={moveState}>
|
||||
Change owner
|
||||
</MenuComponent.ItemButton>
|
||||
{expired
|
||||
? undefined
|
||||
: (
|
||||
<MenuComponent.ItemButton control={expireState}>
|
||||
Expire
|
||||
</MenuComponent.ItemButton>
|
||||
)}
|
||||
<MenuComponent.ItemButton
|
||||
className="text-red-500 dark:text-red-400"
|
||||
control={removeState}
|
||||
>
|
||||
Remove
|
||||
</MenuComponent.ItemButton>
|
||||
</MenuComponent.Items>
|
||||
</MenuComponent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { InfoIcon } from '@primer/octicons-react'
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { useLoaderData } from '@remix-run/react'
|
||||
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Link from '~/components/Link'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig } from '~/utils/config/headscale'
|
||||
import { pull } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
import type { Machine, Route, User } from '~/types'
|
||||
|
||||
import { menuAction } from './action'
|
||||
import MachineRow from './machine'
|
||||
import NewMachine from './dialogs/new'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
const [machines, routes, users] = await Promise.all([
|
||||
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
|
||||
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
||||
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
||||
])
|
||||
|
||||
const context = await loadContext()
|
||||
let magic: string | undefined
|
||||
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig()
|
||||
if (config.dns.magic_dns) {
|
||||
magic = config.dns.base_domain
|
||||
}
|
||||
|
||||
if (config.dns.use_username_in_magic_dns) {
|
||||
magic = `[user].${magic}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: machines.nodes,
|
||||
routes: routes.routes,
|
||||
users: users.users,
|
||||
magic,
|
||||
server: context.headscaleUrl,
|
||||
publicServer: context.headscalePublicUrl,
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
return menuAction(request)
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
useLiveData({ interval: 3000 })
|
||||
const data = useLoaderData<typeof loader>()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className='text-2xl font-medium mb-4'>Machines</h1>
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
Manage the devices connected to your Tailnet.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1372/manage-devices"
|
||||
name="Tailscale Manage Devices Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<NewMachine
|
||||
server={data.publicServer ?? data.server}
|
||||
users={data.users}
|
||||
/>
|
||||
</div>
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
<thead className="text-gray-500 dark:text-gray-400">
|
||||
<tr className="text-left uppercase text-xs font-bold px-0.5">
|
||||
<th className="pb-2">Name</th>
|
||||
<th className="pb-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
Addresses
|
||||
{data.magic
|
||||
? (
|
||||
<TooltipTrigger delay={0}>
|
||||
<Button>
|
||||
<InfoIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Tooltip className={cn(
|
||||
'text-sm max-w-xs p-2 rounded-lg mb-2',
|
||||
'bg-white dark:bg-zinc-800',
|
||||
'border border-gray-200 dark:border-zinc-700',
|
||||
)}
|
||||
>
|
||||
Since MagicDNS is enabled, you can access devices
|
||||
based on their name and also at
|
||||
{' '}
|
||||
<Code>
|
||||
[name].
|
||||
{data.magic}
|
||||
</Code>
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
)
|
||||
: undefined}
|
||||
</div>
|
||||
</th>
|
||||
<th className="pb-2">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={cn(
|
||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
||||
'border-t border-zinc-200 dark:border-zinc-700',
|
||||
)}
|
||||
>
|
||||
{data.nodes.map(machine => (
|
||||
<MachineRow
|
||||
key={machine.id}
|
||||
machine={machine}
|
||||
routes={data.routes.filter(route => route.node.id === machine.id)}
|
||||
users={data.users}
|
||||
magic={data.magic}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import Link from '~/components/Link'
|
||||
import Button from '~/components/Button'
|
||||
import { Link as RemixLink } from '@remix-run/react'
|
||||
import { ArrowRightIcon } from '@primer/octicons-react'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex flex-col gap-8 max-w-screen-lg">
|
||||
<div className='flex flex-col w-2/3'>
|
||||
<h1 className='text-2xl font-medium mb-4'>Settings</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
The settings page is still under construction.
|
||||
As I'm able to add more features, I'll be adding them here.
|
||||
If you require any features, feel free to open an issue on
|
||||
the GitHub repository.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-col w-2/3'>
|
||||
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Headscale fully supports pre-authentication keys in order to
|
||||
easily add devices to your Tailnet.
|
||||
To learn more about using pre-authentication keys, visit the
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||
name="Tailscale Auth Keys documentation"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<RemixLink to="/settings/auth-keys">
|
||||
<span className={cn(
|
||||
'text-lg font-medium',
|
||||
'text-gray-700 dark:text-gray-300',
|
||||
)}>
|
||||
Manage Auth Keys
|
||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
||||
</span>
|
||||
</RemixLink>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import type { PreAuthKey } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Spinner from '~/components/Spinner'
|
||||
|
||||
interface Props {
|
||||
authKey: PreAuthKey
|
||||
}
|
||||
|
||||
export default function ExpireKey({ authKey }: Props) {
|
||||
const fetcher = useFetcher()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button className="my-4">
|
||||
Expire Key
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Expire auth key?
|
||||
</Dialog.Title>
|
||||
<fetcher.Form method="DELETE" onSubmit={e => {
|
||||
fetcher.submit(e.currentTarget)
|
||||
close()
|
||||
}}>
|
||||
<input type="hidden" name="user" value={authKey.user} />
|
||||
<input type="hidden" name="key" value={authKey.key} />
|
||||
<Dialog.Text>
|
||||
Expiring this authentication key will immediately
|
||||
prevent it from being used to authenticate new devices.
|
||||
{' '}
|
||||
This action cannot be undone.
|
||||
</Dialog.Text>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
className={cn(
|
||||
'bg-red-500 hover:border-red-700',
|
||||
'dark:bg-red-600 dark:hover:border-red-700',
|
||||
'pressed:bg-red-600 hover:bg-red-600',
|
||||
'text-white dark:text-white',
|
||||
)}
|
||||
onPress={close}
|
||||
>
|
||||
{fetcher.state === 'idle'
|
||||
? undefined
|
||||
: (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Expire
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user