Compare commits

...

258 Commits

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

* Update Native.md
2025-02-19 13:06:08 -05:00
Aarnav Tale 774fdb7be2 fix: use new headscale user api routes 2025-02-19 13:01:20 -05:00
Aarnav Tale e6580eed2c fix: no top level await 2025-02-19 13:00:21 -05:00
Aarnav Tale 56b660b30c chore: switch to 0.25.0 in dev 2025-02-19 10:37:10 -05:00
Aarnav Tale 85c31ca5bf feat: use new config env 2025-02-14 00:04:01 -05:00
Aarnav Tale 44d08169a9 fix: use ring instead of outline on components 2025-02-14 00:03:46 -05:00
Aarnav Tale 76d263b7e6 feat: switch to config file system 2025-02-13 12:35:12 -05:00
Aarnav Tale 5be3cb345e feat: switch to normal form actions for dns 2025-02-13 12:29:16 -05:00
Aarnav Tale 2a1c795d46 feat: update to node 22, pnpm 10 2025-02-04 17:25:39 -05:00
Aarnav Tale 316e5b501f fix: pin to pnpm 9 2025-02-04 17:21:57 -05:00
Aarnav Tale 287ac2dff0 feat: unify all colors 2025-02-04 17:21:03 -05:00
Aarnav Tale d1f6c450c0 feat: remove react-aria-components 2025-02-04 11:42:57 -05:00
Aarnav Tale 0a14533756 fix: copyable code cannot be a button 2025-02-04 11:39:17 -05:00
Aarnav Tale 347c6698ee chore: fix aria label warnings 2025-02-04 11:27:29 -05:00
Aarnav Tale 771b87ae41 chore: only invoke useLiveData in root 2025-02-02 00:54:42 -05:00
Aarnav Tale 31ac25d510 feat: redesign code and attribute 2025-02-01 18:11:33 -05:00
Aarnav Tale 68babd7fe9 feat: redesign tooltips 2025-02-01 15:28:04 -05:00
Aarnav Tale c9d8052e39 chore: remove cn non-default exports 2025-01-28 17:46:16 -05:00
Aarnav Tale 843cfc4d4f chore: fix build issues 2025-01-28 16:18:51 -05:00
Aarnav Tale 6922548317 chore: switch to headscale 24 in dev 2025-01-28 16:07:44 -05:00
Aarnav Tale 07cee17501 style: temporarily disable some naggy lints 2025-01-28 16:07:15 -05:00
Aarnav Tale 2c8880c84d feat: switch to new toast provider 2025-01-28 16:06:41 -05:00
Aarnav Tale a19eb6bcda feat: add new menu 2025-01-28 16:04:42 -05:00
Aarnav Tale 28e40eecbf fix: switch to new aria components 2025-01-28 16:02:47 -05:00
Aarnav Tale 741f9aa6b5 feat: switch to new dialog across all code 2025-01-26 15:04:13 -05:00
Aarnav Tale 0f75636342 feat: add react-scan in dev 2025-01-25 12:51:32 -05:00
Aarnav Tale 665509e710 chore: extract tags to shared chip component 2025-01-23 11:28:42 -05:00
Aarnav Tale ac937f9014 fix: dedupe machine tags 2025-01-23 11:16:56 -05:00
Aarnav Tale 8d3f31e7f9 chore: add lefthook for lint staged 2025-01-20 08:26:00 +00:00
Aarnav Tale 2d47f1b952 chore: switch tailwind fonts 2025-01-20 08:15:14 +00:00
Aarnav Tale c24cd34925 feat: switch to new dialog gutter 2025-01-20 08:14:42 +00:00
Aarnav Tale af248df648 feat: redesign buttons and share them in dialogs 2025-01-19 17:31:46 +00:00
Aarnav Tale b9a708b2e9 chore: update deps and add react-aria 2025-01-19 15:24:14 +00:00
Aarnav Tale ec36876b9f feat: begin redesign and component unification 2025-01-19 15:24:03 +00:00
Aarnav Tale ed0cdbdf4d chore: add lucide-react icons 2025-01-18 18:42:57 +00:00
Aarnav Tale fd506ba474 feat: redesign header and footer 2025-01-18 18:42:49 +00:00
Aarnav Tale 9734f0c704 chore: remove old username in dns option 2025-01-18 13:22:43 +00:00
Aarnav Tale 156d7c9ee7 chore: v0.4.1 2025-01-18 08:18:28 +00:00
Aarnav Tale bd11453593 fix: store oidc redirect_uri in session for parity 2025-01-18 08:18:19 +00:00
Aarnav Tale 0f92f09796 chore: v0.4.0 2025-01-18 07:52:04 +00:00
kevinf100 9bb20024fa fix: Reusable Pre-Auth Keys no longer show expired when used (#88) 2025-01-18 07:48:50 +00:00
Aarnav Tale 698122068d fix: center user icon in profile 2025-01-18 07:48:05 +00:00
Aarnav Tale e6a66d5804 fix: don't propagate errors on user page to root 2025-01-18 07:41:08 +00:00
Aarnav Tale d524f927a4 fix: don't throw when session manager isn't available 2025-01-18 07:39:12 +00:00
Aarnav Tale 539c76dfb3 chore: switch to shared healtcheck func 2025-01-18 07:38:55 +00:00
Aarnav Tale 51fa7c14d0 fix: handle prod config for server correctly 2025-01-18 07:37:51 +00:00
Aarnav Tale b92c4ce2e8 chore: remove broken vite plugin import 2025-01-18 07:37:26 +00:00
Aarnav Tale 325e9ba43d chore: push disabled local-agent code 2025-01-18 07:37:03 +00:00
Aarnav Tale de9a938da2 chore: use healthcheck method in healthz 2025-01-17 11:46:36 +00:00
Aarnav Tale 1b45b0917f fix: handle headscale unavailability gracefully 2025-01-17 11:45:36 +00:00
Aarnav Tale e01fd8d50c fix: handle undefined websocket in context 2025-01-17 08:34:19 +00:00
Aarnav Tale c9603ba38a chore: switch to vite for server dev 2025-01-17 08:33:22 +00:00
Aarnav Tale 377641265b fix: require auth for agent ws 2025-01-15 15:14:37 +05:30
Aarnav Tale 5bc313e5fe feat: containerize agent 2025-01-15 10:17:30 +05:30
Aarnav Tale af919b2d34 fix: support the agent querying itself 2025-01-10 14:49:13 +05:30
Aarnav Tale f464c42802 fix: format hostinfo in overview correctly 2025-01-10 14:41:05 +05:30
Aarnav Tale e667e112cb chore: add @types/ws 2025-01-10 14:24:23 +05:30
Aarnav Tale 39546849ae fix: mix cached and uncached data on the ws-agent 2025-01-10 14:24:15 +05:30
Aarnav Tale eb922c9318 fix: implement write-lock for agent cache 2025-01-10 14:06:22 +05:30
Aarnav Tale 5569ba4660 feat: rework oidc to be more resilient
This includes setting a custom redirect URI, handling errors, and using a better library.
As an API decision I've also disabled per session API keys as it clutters up too much.
2025-01-10 13:55:24 +05:30
Aarnav Tale dfd03e77bb fix: use headscale health route for healthz 2025-01-10 10:55:35 +05:30
Aarnav Tale e33504016b feat: integrate hostinfo into ui 2025-01-08 14:34:53 +05:30
Aarnav Tale 7d4da73141 fix: relax config requirement for paths by using nullish 2025-01-06 08:49:26 +05:30
Aarnav Tale 1316439786 fix: read aggregate and error cause bubbles 2025-01-06 08:47:24 +05:30
Aarnav Tale 888c1a5fb6 fix: resolve css issues with button icons 2025-01-06 08:39:15 +05:30
Aarnav Tale 328302cd81 fix: show toasts above the footer 2025-01-06 08:35:25 +05:30
Aarnav Tale b060700dfe fix: blur footer URL for screenshots 2025-01-06 08:31:49 +05:30
Aarnav Tale 69783f0d05 chore: re-stage entry files 2025-01-06 08:19:41 +05:30
Aarnav Tale 55c832c536 feat: do partial client method validation 2025-01-06 08:19:41 +05:30
Aarnav Tale fc5028994a chore: remove old import for fs-routes 2025-01-06 08:19:41 +05:30
Aarnav Tale 6745ee8529 fix: resolve type errors and lint components/routes 2025-01-06 08:19:41 +05:30
Aarnav Tale 414b95c293 fix: separate vite builds for server and framework 2025-01-06 08:19:41 +05:30
Aarnav Tale 7217659720 fix: prevent cookie manager from racing env init
this fixes a bug where using LOAD_ENV_FILE did not work with COOKIE_SECRET
2025-01-06 08:19:40 +05:30
Aarnav Tale a4bb3cce5f feat: fix build scripts and server 2025-01-06 08:19:40 +05:30
Aarnav Tale f3c9d8b54c style: run biome lint 2025-01-06 08:19:40 +05:30
Aarnav Tale aa9872a45b chore: switch to react-router v7 2025-01-06 08:19:40 +05:30
Artur Motyka 39504e2487 chore: update outdated docs links (#74) 2025-01-03 10:31:12 +05:30
Aarnav Tale 719d7a5e76 feat: create initial agent for localapi 2024-12-30 13:48:49 +05:30
Aarnav Tale 6156d78907 feat: add ws server for agent 2024-12-30 13:48:18 +05:30
Aarnav Tale 1c2c374ada feat: switch to server in dev & prod 2024-12-23 10:27:05 -05:00
Aarnav Tale 6cf343d623 feat: refactor all dashboard routes 2024-12-08 13:52:02 -05:00
Aarnav Tale 50e43bc0c3 feat: refactor/reorganize auth routes 2024-12-08 13:27:51 -05:00
Aarnav Tale 9d64a5beac chore: switch to react-router routes.ts config 2024-12-08 13:11:26 -05:00
Aarnav Tale 240d8d6197 chore: v0.3.9 2024-12-06 19:30:12 -05:00
Aarnav Tale b80bb0cc09 fix: await testOidc 2024-12-06 19:29:44 -05:00
Aarnav Tale 69cc6985b4 chore: v0.3.8 2024-12-06 19:07:36 -05:00
Aarnav Tale f9b38939ba chore: add border for footer 2024-12-06 19:06:27 -05:00
Aarnav Tale 4cfa1e5209 chore: remove unused patch 2024-12-06 19:05:15 -05:00
Aarnav Tale e713dae91b fix: validate and respect oidc validation settings 2024-12-06 19:05:07 -05:00
Aarnav Tale c9bcc1d7c6 chore: update deps 2024-12-06 18:37:36 -05:00
Aarnav Tale 401731fd09 fix: allow generating higher expiring preauthkeys 2024-12-06 18:37:17 -05:00
Aarnav Tale f623e7bc66 feat: enable all remix future flags 2024-12-06 11:58:17 -05:00
Aarnav Tale 1af292a5b0 fix: we need git in docker 2024-12-05 11:35:41 -05:00
Aarnav Tale 21778a43f1 feat: add footer with version and donate link 2024-12-05 02:33:17 -05:00
Aarnav Tale 33762e53b5 chore: document the /admin endpoint 2024-12-05 02:07:28 -05:00
Aarnav Tale 8867cca494 chore: use 0.3.7 in examples 2024-11-30 15:42:33 -05:00
Aarnav Tale 41b1d3c847 chore: v0.3.7 2024-11-30 15:34:54 -05:00
Aarnav Tale 712fc28683 feat: allow a public headscale URL separate from the main one 2024-11-30 15:33:58 -05:00
Aarnav Tale 320dab1d4f fix: join children in copyable codeblock 2024-11-30 15:05:07 -05:00
Aarnav Tale da0ee1382b feat: allow setting OIDC_CLIENT_SECRET_METHOD 2024-11-30 15:00:51 -05:00
Aarnav Tale a7d127c7bf feat: add health check route 2024-11-27 11:23:42 -05:00
Aarnav Tale b433e607e2 chore: v0.3.6 2024-11-20 18:15:19 -05:00
Aarnav Tale 8aad883c21 fix: make selects scrollable 2024-11-20 18:13:49 -05:00
Aarnav Tale 3cd28d2136 feat: make the code snippets copyable 2024-11-20 18:01:20 -05:00
Aarnav Tale 9d9cbd8e0e chore: v0.3.5 2024-11-08 12:05:33 -05:00
Aarnav Tale feb8b8bba5 chore: remove logging on expected errors 2024-11-08 12:04:24 -05:00
Aarnav Tale c304effcdf fix: externalize process.env 2024-11-08 12:03:08 -05:00
Aarnav Tale 982d811d53 chore: v0.3.4 2024-11-07 13:09:35 -05:00
Aarnav Tale d369e36fa4 fix: potentially catch undefined bearer errors 2024-11-07 13:08:43 -05:00
Aarnav Tale a2e659f36c feat(TALE-36): redesign machines page to account for exit nodes 2024-11-07 13:04:53 -05:00
Aarnav Tale 1490406784 feat(TALE-36): show exit node status on machine page 2024-11-06 17:01:36 -05:00
Aarnav Tale e1c87412d4 feat(TALE-36): support exit node enabling/disabling 2024-11-06 16:48:14 -05:00
Aarnav Tale 6e55f442fd fix: make the last acl tag removable on machines 2024-11-06 16:10:39 -05:00
Aarnav Tale 09e1b1f261 fix: address a bug that prevented remove split dns records 2024-11-06 15:57:43 -05:00
Aarnav Tale 3e83f8617b fix: use correct vite config in dev 2024-11-06 15:57:27 -05:00
Aarnav Tale 1d6472765a docs: better explain the native integration 2024-11-06 15:18:21 -05:00
Aarnav Tale 2bc85085f5 fix: handle non-prefixed routes on the production server 2024-11-06 14:29:04 -05:00
Aarnav Tale 5c949e2da5 feat: use all native node deps for the server 2024-11-04 22:05:46 -05:00
Aarnav Tale 12754bd0aa fix: resolve type errors across the codebase 2024-11-04 18:13:10 -05:00
Aarnav Tale 10bb4eba97 fix: skip loading env file if context is loaded 2024-11-04 18:00:50 -05:00
Aarnav Tale 13a734923d feat: ditch remix-serve for custom-baked server 2024-11-04 18:00:35 -05:00
Aarnav Tale d5ee8ae0f3 fix: clicking a machine name in users page redirects 2024-11-04 14:59:13 -05:00
Aarnav Tale 331a9c8dcf chore: v0.3.3 2024-10-28 15:10:20 -04:00
Aarnav Tale 808b01c5d7 chore: update react 19 compiler to next beta 2024-10-27 13:13:34 -04:00
Aarnav Tale a8abd37e3a fix: handle errors and fix api logic with acl updating 2024-10-27 13:10:32 -04:00
Aarnav Tale d1fa76971b feat: switch back to codemirror with jsonc support 2024-10-22 00:41:44 -04:00
Aarnav Tale a9e8394dec fix: do not pass the text value as a form field
This fixes an issue where on ephemeral key creation, it would fail due to localized dates
2024-10-20 18:34:43 -04:00
Aarnav Tale 9dafd8e8b0 feat: load from dotenv if specified 2024-10-16 23:07:41 -04:00
Václav Šmejkal 0c1f6969da feat: hide api key on login page (#39) 2024-10-16 13:23:02 -04:00
Aarnav Tale 4214e14ba8 chore: reorganize machines page to prep for routing data 2024-10-13 00:32:21 -04:00
Aarnav Tale 447a31f91e chore: v0.3.2 2024-10-11 03:09:56 -04:00
Aarnav Tale 9e21823163 chore: mention that settings is incomplete 2024-10-11 03:04:45 -04:00
Aarnav Tale aa7e2a3128 feat(TALE-35): implement pre-auth key management 2024-10-11 03:02:33 -04:00
Aarnav Tale ecef45c98a fix(TALE-35): use the correct machine registry endpoint 2024-10-10 11:12:35 -04:00
Aarnav Tale d165264876 fix: handle nullable expiry values from a db 2024-10-10 10:41:41 -04:00
Aarnav Tale dd479d4117 fix: remove older references to magic_dns via usernames 2024-10-10 10:03:43 -04:00
Akira Yamazaki 29d91785fb fix: build oidc callback url in a more proper way (#28) 2024-10-07 23:23:31 -04:00
Aarnav Tale 98d1cb1333 docs: mention 0.3.1 as the latest 2024-10-03 15:36:20 -04:00
Aarnav Tale 2229f547a9 chore: v0.3.1 2024-10-03 15:31:03 -04:00
Aarnav Tale 65cc278a59 docs(TALE-33): document debug 2024-10-03 15:30:24 -04:00
Aarnav Tale 1555846df2 fix(TALE-34): use the http coded URL for socket 2024-10-03 15:15:56 -04:00
Aarnav Tale e8c1cadf54 feat(TALE-35): add initial machine key authorization 2024-10-03 11:58:05 -04:00
Aarnav Tale d867769025 chore: update browserlist 2024-10-02 13:41:45 -04:00
Aarnav Tale 1d6066d3f0 feat(TALE-33): add debug logging with DEBUG env 2024-10-02 13:33:39 -04:00
Aarnav Tale 1d821251a9 fix(TALE-34): setting url.protocol does not work anymore 2024-10-02 12:59:42 -04:00
Aarnav Tale a0d6905123 chore: v0.3.0 2024-09-25 16:32:58 -04:00
Aarnav Tale 4095ed2a68 docs: version the docker images 2024-09-25 16:26:19 -04:00
Aarnav Tale 58e98278d1 chore: support dns use_username_in_magic_dns config 2024-09-25 16:22:00 -04:00
Aarnav Tale 90f0bf2555 chore: v0.2.4 2024-08-24 10:35:05 -04:00
Aarnav Tale ea2ffdf0c1 feat: support removing config values via null 2024-08-24 10:33:30 -04:00
Aarnav Tale 9aedd9baad chore: use beta2 on the dev env 2024-08-24 10:19:07 -04:00
Aarnav Tale 690b52d8c6 chore(TALE-29): remove acl from integration/context 2024-08-24 10:19:07 -04:00
Aarnav Tale a72a3d6e5f chore(TALE-29): remove references to ACL_FILE 2024-08-24 10:19:06 -04:00
Akira Yamazaki c4c1fd8aab feat: make secure flag of cookie configurable (#26) 2024-08-24 10:18:38 -04:00
Aarnav Tale 9801ef453d fix(TALE-29): remove all old ACL_FILE handling
No longer required if the minimum is beta2
2024-08-23 16:12:46 -04:00
Aarnav Tale d041a62fcd chore: 0.2.3 2024-08-23 15:20:27 -04:00
Aarnav Tale 0e6b5ea6d0 chore: update docs for minimum beta requirement 2024-08-23 15:18:32 -04:00
Aarnav Tale a2054786f8 fix(TALE-31): use oidc variables first over config 2024-08-23 15:12:53 -04:00
Aarnav Tale 5a46fd0a97 feat(TALE-30): add support for new DNS configs
This is a breaking change to support 0.23-beta2
2024-08-22 16:55:05 -04:00
Aarnav Tale b8999161a2 feat(TALE-29): handle no ACL configurations being available 2024-08-04 17:30:41 -04:00
Aarnav Tale 224cbbdcaf chore: update to headscale beta for compose dev 2024-08-04 11:33:11 -04:00
Aarnav Tale 75ba3a3dc7 feat(TALE-29): support the headscale policy api changes 2024-08-04 11:32:29 -04:00
Aarnav Tale 4f57fdb43b chore: v0.2.2 2024-08-02 16:15:08 -04:00
Aarnav Tale b170e11dd6 fix(TALE-7): don't destructure context otherwise it won't set properly 2024-08-02 16:14:05 -04:00
Aarnav Tale 6b278309ed fix(TALE-7): use the new integration methods in the code 2024-07-10 19:38:44 -04:00
Aarnav Tale ea8ecfb28f chore: use new logger 2024-07-10 19:36:13 -04:00
Aarnav Tale 099bd3bcb8 feat(TALE-7): add proper integration logging 2024-07-10 19:26:59 -04:00
Aarnav Tale 0aa0406ea6 feat(TALE-7): reimplement integration system 2024-07-09 22:53:00 -04:00
Aarnav Tale 3cc726320a fix(TALE-5): remove deployment check and only use pod spec 2024-07-08 14:40:38 -04:00
Aarnav Tale bda151f4e8 chore: simplify documentation 2024-07-08 14:31:53 -04:00
Aarnav Tale 6d411853d5 feat(TALE-5): implement k8s integration 2024-07-08 13:23:53 -04:00
Aarnav Tale dc4d05a2d9 fix: filter out undefined proc pids 2024-07-08 01:17:28 -04:00
Aarnav Tale 361859f374 chore: v0.2.1 2024-07-07 14:57:33 -04:00
Aarnav Tale c60652e39f fix(TALE-4): prevent duplicate ACL tags from being added 2024-07-07 14:57:16 -04:00
Aarnav Tale 1db385e716 chore(TALE-11): remove unnecessary component in dns 2024-07-07 14:53:16 -04:00
Aarnav Tale ab0cb7b782 feat(TALE-11): support custom DNS records 2024-07-07 14:52:47 -04:00
Aarnav Tale fd73832879 fix: use acl diff editor and make discard button work 2024-07-07 14:28:28 -04:00
Aarnav Tale 7804d83181 feat(TALE-4): make acl tags editable from the menu 2024-07-07 14:20:39 -04:00
Aarnav Tale fe40a5734e chore: update deps 2024-07-07 14:19:53 -04:00
Aarnav Tale c0bdf91f6b chore: v0.2.0 2024-06-23 18:21:37 -04:00
Aarnav Tale bd46f97121 chore: update deps 2024-06-23 18:18:01 -04:00
Aarnav Tale d71e55d7af feat(TALE-10): edit machine owner via dropdown 2024-06-23 17:25:33 -04:00
Aarnav Tale ddd20fe027 feat: create select component 2024-06-23 17:25:22 -04:00
Aarnav Tale dca5f9d149 fix(TALE-6): make menu dropdown actions available on machines page 2024-06-23 01:05:03 -04:00
Aarnav Tale be8ce7a2fe fix: set defaults with HEADSCALE_UNSTRICT 2024-06-18 23:59:06 -04:00
Aarnav Tale 6fa0e265fe feat: update images 2024-06-03 23:33:38 -04:00
Aarnav Tale 74e87bd80e chore: v0.1.9 2024-06-03 22:52:26 -04:00
Aarnav Tale d01699009d chore: update packages 2024-06-03 22:51:45 -04:00
Aarnav Tale 08db192db1 fix: use magic domain for machine domain attribute 2024-06-03 22:43:38 -04:00
Aarnav Tale 3ea5fed8f6 fix: support jsonc comments and switch to monaco for acls 2024-06-02 22:40:23 -04:00
Aarnav Tale dd9d6cd550 fix: set edge tag to main 2024-06-02 16:52:29 -04:00
Aarnav Tale f82244f85a fix: github actions work this time? 2024-06-02 14:33:43 -04:00
Aarnav Tale 896aaad61e fix: push action had syntax error 2024-06-02 14:29:32 -04:00
Aarnav Tale e5dece5b7b feat: add build and nightly 2024-06-02 14:28:42 -04:00
Aarnav Tale 2797525969 fix: expired check 2.0? 2024-06-02 14:03:25 -04:00
Aarnav Tale f0f8a6f01b chore: v0.1.8 2024-06-02 01:40:05 -04:00
Aarnav Tale 3bd5cc99aa fix: handle 0001-01-01 00:00:00 as the no-expiry timestamp 2024-06-02 01:37:31 -04:00
Aarnav Tale 937fa6f6fa fix: use proper route ids for key mapping 2024-06-02 01:34:58 -04:00
Aarnav Tale 3ffbabd7fc feat: add functional machine overview page and fix types 2024-06-02 01:33:40 -04:00
Aarnav Tale c08203cc76 fix: prevent user deletion if they have machines owned 2024-06-02 00:54:20 -04:00
Aarnav Tale 57f045eaf6 fix: support top level await on the server 2024-05-30 11:41:36 -04:00
Aarnav Tale faa583bb38 chore: v0.1.7 2024-05-30 10:41:55 -04:00
Aarnav Tale 98ea2cb06f feat: add a way to escape hatch from strict config checking 2024-05-30 10:41:40 -04:00
Aarnav Tale 868d85bbeb chore: log about context loading 2024-05-30 10:28:37 -04:00
Aarnav Tale dbd9d39da9 style: cleanup vite.config.ts 2024-05-30 10:20:53 -04:00
Aarnav Tale 72cc9d8974 fix: flexible duration and go boolean handling 2024-05-30 10:20:41 -04:00
Aarnav Tale ed50c48965 fix: handle configs better and propagate errors 2024-05-30 10:15:02 -04:00
Aarnav Tale e19dbda5ed fix: headscale expiry can be a literal 0 2024-05-26 19:41:56 -04:00
Aarnav Tale a72cc1bb1c chore: document the new integration providers 2024-05-26 19:40:46 -04:00
Aarnav Tale f0e4868252 feat: configure support for generic integrations
Co-authored-by: Gage Orsburn <gageorsburn@live.com>
2024-05-26 19:23:24 -04:00
Aarnav Tale 1a30185047 chore: v0.1.6 2024-05-22 14:15:05 -04:00
Aarnav Tale c7e59b137c docs: solidify DOCKER_SOCK and ROOT_API_KEY usage 2024-05-22 12:43:42 -04:00
Aarnav Tale 694b22f205 style: cleanup oidc spaghetti 2024-05-21 23:57:03 -04:00
Aarnav Tale 84855d9d51 chore: update pnpm and enforce engines 2024-05-21 23:50:43 -04:00
Aarnav Tale 6bea3b2a55 fix: user grid on mobile should be full width 2024-05-21 17:23:33 -04:00
Aarnav Tale 2ee1adf142 chore: delete entry files because they aren't modified 2024-05-21 17:22:11 -04:00
Aarnav Tale 06d7d1ccad feat: add support for split dns 2024-05-21 17:21:14 -04:00
Aarnav Tale 2aae6bfc06 chore: v0.1.5 2024-05-20 14:08:10 -04:00
Aarnav Tale 6794b1b2dd fix: don't log the context 2024-05-20 14:06:38 -04:00
Aarnav Tale a6582ef835 fix: button responders on acl page never worked 2024-05-20 14:05:27 -04:00
Aarnav Tale 3e51e4861d feat: use strictly typed configs and context 2024-05-20 14:05:09 -04:00
Aarnav Tale 0a12cdb3d6 fix: call button onPress even without state handler 2024-05-20 14:02:04 -04:00
Aarnav Tale 78698dcbba fix: DISABLE_API_KEY_LOGIN should only work when set to true 2024-05-19 19:15:29 -04:00
Aarnav Tale 260fb870e1 fix: when renaming users start with old names 2024-05-15 23:46:15 -04:00
Aarnav Tale 2b1e7be193 chore: v0.1.4 (attempt 2) 2024-05-15 22:27:03 -04:00
Aarnav Tale 65a76a7fa2 fix: docker needs the patches dir 2024-05-15 22:26:36 -04:00
Aarnav Tale 3d554a2434 chore: v0.1.4 2024-05-15 22:23:19 -04:00
Aarnav Tale d57ee2677c feat: react 19 to take advantage of compiler 2024-05-15 22:22:38 -04:00
Aarnav Tale 2f02ccc362 style: eslint changes 2024-05-15 21:54:40 -04:00
Aarnav Tale 37a31e30c7 chore: update eslint package 2024-05-15 21:54:34 -04:00
Aarnav Tale e7c6271322 chore: cleanup login page 2024-05-15 21:54:02 -04:00
Aarnav Tale 3deec75715 fix: Promise.all on concurrent promises 2024-05-15 21:53:39 -04:00
Aarnav Tale 18999357a8 fix: return the revalidator in useLiveData 2024-05-15 21:53:12 -04:00
Aarnav Tale e6eba645c4 feat: implement complete user control 2024-05-15 21:52:19 -04:00
Aarnav Tale f563335fab feat: add loading indicator 2024-05-11 23:07:24 -04:00
Aarnav Tale 6102fabfcb fix: add fallback values from the config 2024-05-11 23:07:06 -04:00
Aarnav Tale 87430ccf7b feat: continue moving older components to aria 2024-05-05 23:38:41 -04:00
Aarnav Tale ee42016a67 fix: patch @react-aria/overlays to support scroll gutter 2024-05-04 16:21:35 -04:00
Aarnav Tale 9f5ac6a9ef chore: v0.1.3 2024-05-04 15:07:27 -04:00
Aarnav Tale 7f55ad826c chore: add link to headscale acl faq on acl page 2024-05-04 15:04:55 -04:00
Aarnav Tale 2cc65e8783 chore: do a feature check for scrollbar-gutter-stable 2024-05-04 15:04:40 -04:00
Aarnav Tale 5ff09e44d9 feat: cleanup header and extract to component 2024-05-04 15:04:22 -04:00
Aarnav Tale c6cdcf35eb chore: switch from heroicons to primer icons 2024-05-04 15:03:21 -04:00
196 changed files with 16120 additions and 11069 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
API_KEY=abcdefghijklmnopqrstuvwxyz ROOT_API_KEY=abcdefghijklmnopqrstuvwxyz
COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz
DISABLE_API_KEY_LOGIN=true DISABLE_API_KEY_LOGIN=true
HEADSCALE_CONTAINER=headscale HEADSCALE_CONTAINER=headscale
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "tale"
}
+44
View File
@@ -0,0 +1,44 @@
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
+37 -33
View File
@@ -1,42 +1,46 @@
name: Publish Docker Image name: Publish Docker Image
on: on:
push: push:
tags: tags:
- '*' - "*"
jobs: jobs:
publish: publish:
name: Build and Publish name: Build and Publish
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to ghcr.io - name: Log in to ghcr.io
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Metadata - name: Docker Metadata
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ghcr.io/${{ github.repository }} images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=false
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64 platforms: linux/amd64, linux/arm64
+44
View File
@@ -0,0 +1,44 @@
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: 22
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10
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
+1 -1
View File
@@ -1,5 +1,5 @@
node_modules node_modules
/.react-router
/.cache /.cache
/build /build
.env .env
+1
View File
@@ -0,0 +1 @@
side-effects-cache = false
+165
View File
@@ -1,3 +1,168 @@
### 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)
- 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)
- Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57))
- Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59))
- Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60))
- Fixed an issue where the copy machine registration command had a typo.
### 0.3.6 (November 20, 2024)
- Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53))
- Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52))
### 0.3.5 (November 8, 2024)
- Quickfix a bug where environment variables are ignored on the server.
- Remove a nagging error about missing cookie since that happens when signed out.
### 0.3.4 (November 7, 2024)
- Clicking on the machine name in the users page now takes you to the machine overview page.
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
- Improve documentation and support when running Headplane outside of Docker.
- Removing Split DNS records will no longer result in an error (fixes [#40](https://github.com/tale/headplane/issues/40))
- Removing the last ACL tag on a machine no longer results in an error (fixes [#41](https://github.com/tale/headplane/issues/41))
- Added full support for Exit Nodes in the UI and redesigned the machines page (fixes [#36](https://github.com/tale/headplane/issues/36))
- Added a basic check to see if the API keys passed via cookies are invalid.
### 0.3.3 (October 28, 2024)
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
- Improved ACL editor performance by switching back to CodeMirror 6
- Fixed an issue where editing the ACL policy would cause it to revert on the UI (fixes [#34](https://github.com/tale/headplane/issues/34))
- Updated to the next stable beta of the React 19 Compiler ([See More](https://react.dev/learn/react-compiler))
### 0.3.2 (October 11, 2024)
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
- Handle `null` values on machine expiry when using a database like PostgreSQL.
- Use `X-Forwarded-Proto` and `Host` headers for building the OIDC callback URL.
### 0.3.1 (October 3, 2024)
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
- Added the option for debug logs with the `DEBUG=true` environment variable.
### 0.3.0 (September 25, 2024)
- Bumped the minimum supported version of Headscale to 0.23.
- Updated the UI to respect `dns.use_username_in_magic_dns`.
### 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)
- 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)
- 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)
- 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)
- 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)
- 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)
- 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)
- 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.
- Added `HEADSCALE_CONFIG_UNSTRICT`=true to revert back to a weaker configuration loader.
- 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)
- 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)
- 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)
- Users can now be created, renamed, and deleted on the users page.
- Machines can be dragged between users to change their ownership.
- The login page actually respects the `DISABLE_API_KEY_LOGIN` variable.
- Implemented some fixes that should stop dialogs from hanging a webpage.
- 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)
- 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. - Added support for renaming, expiring, removing, and managing the routes of a machine.
+6 -4
View File
@@ -1,15 +1,17 @@
FROM node:20-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
RUN npm install -g pnpm RUN npm install -g pnpm@10
RUN apk add --no-cache git
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
COPY patches ./patches
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
COPY . . COPY . .
RUN pnpm run build RUN pnpm run build
RUN pnpm prune --prod RUN pnpm prune --prod
FROM node:20-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY --from=build /app/build /app/build COPY --from=build /app/build /app/build
COPY --from=build /app/node_modules /app/node_modules COPY --from=build /app/node_modules /app/node_modules
@@ -18,4 +20,4 @@ RUN echo '{"type":"module"}' > /app/package.json
EXPOSE 3000 EXPOSE 3000
ENV NODE_ENV=production ENV NODE_ENV=production
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
CMD [ "node_modules/.bin/remix-serve", "./build/server/index.js" ] CMD [ "node", "./build/headplane/server.js" ]
+68 -15
View File
@@ -1,24 +1,77 @@
# Headplane # Headplane
> An advanced UI for [juanfont/headscale](https://github.com/juanfont/headscale) > A feature-complete web UI for [Headscale](https://headscale.net)
![Preview](/assets/main-preview.png) <picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./assets/preview-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./assets/preview-light.png"
>
<img
alt="Preview"
src="./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. Headscale is the de-facto self-hosted version of Tailscale, a popular Wireguard
Headplane aims to solve this issue by providing a GUI that can deeply integrate with the Headscale server. based VPN service. By default, it does not ship with a web UI, which is where
It's able to replicate nearly all of the functions of the official Tailscale SaaS UI, including: 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 Headplane aims to replicate the functionality offered by the official Tailscale
- Access Control List (ACL) and tagging configuration 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 - 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 ## Deployment
- If you run Headscale in a Docker container, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide. Headplane runs as a server-based web-application, meaning you'll need a server to run it.
- If you run Headscale natively, see the [Basic Deployment](/docs/Basic-Integration.md) guide. It's available as a Docker image (recommended) or through a manual installation.
- For more configuration options, refer to the [Configuration](/docs/Configuration.md) guide. There are 2 ways to deploy Headplane:
## Contributing - ### [Integrated Mode (Recommended)](/docs/Integrated-Mode.md)
If you would like to contribute, please install a relatively modern version of Node.js and PNPM. Integrated mode unlocks all the features of Headplane and is the most
Clone this repository, run `pnpm install`, and then run `pnpm dev` to start the development server. feature-complete deployment method. It communicates with Headscale directly.
> Copyright (c) 2024 Aarnav Tale - ### [Simple Mode](/docs/Simple-Mode.md)
Simple mode does not include the automatic management of DNS and Headplane
settings, requiring manual editing and reloading when making changes.
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./assets/acls-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./assets/acls-light.png"
>
<img
alt="ACLs"
src="./assets/acls-dark.png"
>
</picture>
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./assets/machine-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./assets/machine-light.png"
>
<img
alt="Machine Management"
src="./assets/machine-dark.png"
>
</picture>
> Copyright (c) 2025 Aarnav Tale
+15
View File
@@ -0,0 +1,15 @@
FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY agent/ ./agent
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags "-s -w" \
-o /app/hp_agent ./agent/cmd/hp_agent
FROM scratch
COPY --from=builder /app/hp_agent /hp_agent
ENTRYPOINT ["/hp_agent"]
+40
View File
@@ -0,0 +1,40 @@
package main
import (
_ "github.com/joho/godotenv/autoload"
"github.com/tale/headplane/agent/config"
"github.com/tale/headplane/agent/tsnet"
"github.com/tale/headplane/agent/hpagent"
"log"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %s", err)
}
agent := tsnet.NewAgent(
cfg.Hostname,
cfg.TSControlURL,
cfg.TSAuthKey,
cfg.Debug,
)
agent.StartAndFetchID()
defer agent.Shutdown()
ws, err := hpagent.NewSocket(
agent,
cfg.HPControlURL,
cfg.HPAuthKey,
cfg.Debug,
)
if err != nil {
log.Fatalf("Failed to create websocket: %s", err)
}
defer ws.StopListening()
ws.StartListening()
}
+55
View File
@@ -0,0 +1,55 @@
package config
import (
_ "github.com/joho/godotenv/autoload"
"os"
)
// Config represents the configuration for the agent.
type Config struct {
Debug bool
Hostname string
TSControlURL string
TSAuthKey string
HPControlURL string
HPAuthKey string
}
const (
DebugEnv = "HP_AGENT_DEBUG"
HostnameEnv = "HP_AGENT_HOSTNAME"
TSControlURLEnv = "HP_AGENT_TS_SERVER"
TSAuthKeyEnv = "HP_AGENT_TS_AUTHKEY"
HPControlURLEnv = "HP_AGENT_HP_SERVER"
HPAuthKeyEnv = "HP_AGENT_HP_AUTHKEY"
)
// Load reads the agent configuration from environment variables.
func Load() (*Config, error) {
c := &Config{
Debug: false,
Hostname: os.Getenv(HostnameEnv),
TSControlURL: os.Getenv(TSControlURLEnv),
TSAuthKey: os.Getenv(TSAuthKeyEnv),
HPControlURL: os.Getenv(HPControlURLEnv),
HPAuthKey: os.Getenv(HPAuthKeyEnv),
}
if os.Getenv(DebugEnv) == "true" {
c.Debug = true
}
if err := validateRequired(c); err != nil {
return nil, err
}
if err := validateTSReady(c); err != nil {
return nil, err
}
if err := validateHPReady(c); err != nil {
return nil, err
}
return c, nil
}
+73
View File
@@ -0,0 +1,73 @@
package config
import (
"fmt"
"net/http"
"strings"
)
// Checks to make sure all required environment variables are set
func validateRequired(config *Config) error {
if config.Hostname == "" {
return fmt.Errorf("%s is required", HostnameEnv)
}
if config.TSControlURL == "" {
return fmt.Errorf("%s is required", TSControlURLEnv)
}
if config.HPControlURL == "" {
return fmt.Errorf("%s is required", HPControlURLEnv)
}
if config.TSAuthKey == "" {
return fmt.Errorf("%s is required", TSAuthKeyEnv)
}
if config.HPAuthKey == "" {
return fmt.Errorf("%s is required", HPAuthKeyEnv)
}
return nil
}
// Pings the Tailscale control server to make sure it's up and running
func validateTSReady(config *Config) error {
testURL := config.TSControlURL
if strings.HasSuffix(testURL, "/") {
testURL = testURL[:len(testURL)-1]
}
// TODO: Consequences of switching to /health (headscale only)
testURL = fmt.Sprintf("%s/key?v=109", testURL)
resp, err := http.Get(testURL)
if err != nil {
return fmt.Errorf("Failed to connect to TS control server: %s", err)
}
if resp.StatusCode != 200 {
return fmt.Errorf("Failed to connect to TS control server: %s", resp.Status)
}
return nil
}
// Pings the Headplane server to make sure it's up and running
func validateHPReady(config *Config) error {
testURL := config.HPControlURL
if strings.HasSuffix(testURL, "/") {
testURL = testURL[:len(testURL)-1]
}
testURL = fmt.Sprintf("%s/healthz", testURL)
resp, err := http.Get(testURL)
if err != nil {
return fmt.Errorf("Failed to connect to HP control server: %s", err)
}
if resp.StatusCode != 200 {
return fmt.Errorf("Failed to connect to HP control server: %s", resp.Status)
}
return nil
}
+83
View File
@@ -0,0 +1,83 @@
package hpagent
import (
"encoding/json"
"log"
"sync"
"tailscale.com/tailcfg"
)
// Represents messages from the Headplane master
type RecvMessage struct {
NodeIDs []string `json:omitempty`
}
// Starts listening for messages from the Headplane master
func (s *Socket) StartListening() {
for {
_, message, err := s.ReadMessage()
if err != nil {
log.Printf("error reading message: %v", err)
return
}
var msg RecvMessage
err = json.Unmarshal(message, &msg)
if err != nil {
log.Printf("error unmarshalling message: %v", err)
continue
}
if s.Debug {
log.Printf("got message: %s", message)
}
if len(msg.NodeIDs) == 0 {
log.Printf("got a message with no node IDs? %s", message)
continue
}
// Accumulate the results since we invoke via gofunc
results := make(map[string]*tailcfg.HostinfoView)
mu := sync.Mutex{}
wg := sync.WaitGroup{}
for _, nodeID := range msg.NodeIDs {
wg.Add(1)
go func(nodeID string) {
defer wg.Done()
result, err := s.Agent.GetStatusForPeer(nodeID)
if err != nil {
log.Printf("error getting status: %v", err)
return
}
if result == nil {
return
}
mu.Lock()
results[nodeID] = result
mu.Unlock()
}(nodeID)
}
wg.Wait()
// Send the results back to the Headplane master
err = s.SendStatus(results)
if err != nil {
log.Printf("error sending status: %v", err)
return
}
if s.Debug {
log.Printf("sent status: %s", results)
}
}
}
// Stops listening for messages from the Headplane master
func (s *Socket) StopListening() {
s.Close()
}
+11
View File
@@ -0,0 +1,11 @@
package hpagent
import (
"tailscale.com/tailcfg"
)
// Sends the status to the Headplane master
func (s *Socket) SendStatus(status map[string]*tailcfg.HostinfoView) error {
err := s.WriteJSON(status)
return err
}
+56
View File
@@ -0,0 +1,56 @@
package hpagent
import (
"fmt"
"github.com/gorilla/websocket"
"github.com/tale/headplane/agent/tsnet"
"log"
"net/http"
"net/url"
)
type Socket struct {
*websocket.Conn
Debug bool
Agent *tsnet.TSAgent
}
// Creates a new websocket connection to the Headplane server.
func NewSocket(agent *tsnet.TSAgent, controlURL, authKey string, debug bool) (*Socket, error) {
wsURL, err := httpToWs(controlURL)
if err != nil {
return nil, err
}
headers := http.Header{}
headers.Add("X-Headplane-TS-Node-ID", agent.ID)
auth := fmt.Sprintf("Bearer %s", authKey)
headers.Add("Authorization", auth)
log.Printf("dialing websocket at %s", wsURL)
ws, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err != nil {
return nil, err
}
return &Socket{ws, debug, agent}, nil
}
// We need to convert the control URL to a websocket URL
func httpToWs(controlURL string) (string, error) {
u, err := url.Parse(controlURL)
if err != nil {
return "", err
}
if u.Scheme == "http" {
u.Scheme = "ws"
} else if u.Scheme == "https" {
u.Scheme = "wss"
} else {
return "", fmt.Errorf("unsupported scheme: %s", u.Scheme)
}
return u.String(), nil
}
+47
View File
@@ -0,0 +1,47 @@
package tsnet
import (
"context"
"fmt"
"log"
"strings"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"go4.org/mem"
)
// Returns the raw hostinfo for a peer based on node ID.
func (s *TSAgent) GetStatusForPeer(id string) (*tailcfg.HostinfoView, error) {
if !strings.HasPrefix(id, "nodekey:") {
return nil, fmt.Errorf("invalid node ID: %s", id)
}
if s.Debug {
log.Printf("querying peer state for %s", id)
}
status, err := s.Lc.Status(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get status: %w", err)
}
nodeKey, err := key.ParseNodePublicUntyped(mem.S(id[8:]))
peer := status.Peer[nodeKey]
if peer == nil {
// Check if we are on Self.
if status.Self.PublicKey == nodeKey {
peer = status.Self
} else {
return nil, nil
}
}
ip := peer.TailscaleIPs[0].String()
whois, err := s.Lc.WhoIs(context.Background(), ip)
if err != nil {
return nil, fmt.Errorf("failed to get whois: %w", err)
}
return &whois.Node.Hostinfo, nil
}
+61
View File
@@ -0,0 +1,61 @@
package tsnet
import (
"context"
"fmt"
"log"
"os"
"tailscale.com/client/tailscale"
"tailscale.com/tsnet"
)
// Wrapper type so we can add methods to the server.
type TSAgent struct {
*tsnet.Server
Lc *tailscale.LocalClient
ID string
Debug bool
}
// Creates a new tsnet agent and returns an instance of the server.
func NewAgent(hostname, controlURL, authKey string, debug bool) *TSAgent {
s := &tsnet.Server{
Hostname: hostname,
ControlURL: controlURL,
AuthKey: authKey,
Logf: func(string, ...interface{}) {}, // Disabled by default
}
if debug {
s.Logf = log.New(
os.Stderr,
fmt.Sprintf("[DBG:%s] ", hostname),
log.LstdFlags,
).Printf
}
return &TSAgent{s, nil, "", debug}
}
// Starts the tsnet agent and sets the node ID.
func (s *TSAgent) StartAndFetchID() {
// Waits until the agent is up and running.
status, err := s.Up(context.Background())
if err != nil {
log.Fatalf("Failed to start agent: %v", err)
}
s.Lc, err = s.LocalClient()
if err != nil {
log.Fatalf("Failed to create local client: %v", err)
}
log.Printf("Agent running with ID: %s", status.Self.PublicKey)
s.ID = string(status.Self.ID)
}
// Shuts down the tsnet agent.
func (s *TSAgent) Shutdown() {
s.Close()
}
+59 -32
View File
@@ -1,40 +1,67 @@
import { ClipboardIcon } from '@heroicons/react/24/outline' import { Check, Copy } from 'lucide-react';
import cn from '~/utils/cn';
import toast from '~/utils/toast';
import { toast } from './Toaster' export interface AttributeProps {
name: string;
type Properties = { value: string;
readonly name: string; isCopyable?: boolean;
readonly value: string; link?: string;
readonly isCopyable?: boolean;
} }
export default function Attribute({ name, value, isCopyable }: Properties) { export default function Attribute({
const canCopy = isCopyable ?? false name,
value,
link,
isCopyable,
}: AttributeProps) {
return ( return (
<dl className='flex gap-1 text-sm w-full'> <dl className="flex items-center w-full gap-x-1">
<dt className='w-1/4 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1'> <dt className="font-semibold w-1/4 shrink-0 text-sm">
{name} {link ? (
<a className="hover:underline" href={link}>
{name}
</a>
) : (
name
)}
</dt> </dt>
<dd
className={cn(
'rounded-lg truncate w-full px-2.5 py-1 text-sm',
'flex items-center gap-x-1',
'focus-within:outline-none focus-within:ring-2',
isCopyable && 'hover:bg-headplane-100 dark:hover:bg-headplane-800',
)}
>
{isCopyable ? (
<button
type="button"
className="w-full flex items-center gap-x-1 outline-none"
onClick={async (event) => {
const svgs = event.currentTarget.querySelectorAll('svg');
for (const svg of svgs) {
svg.toggleAttribute('data-copied', true);
}
{(canCopy ?? false) ? ( await navigator.clipboard.writeText(value);
<button toast('Copied to clipboard');
type='button'
className='focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md' setTimeout(() => {
onClick={async () => { for (const svg of svgs) {
await navigator.clipboard.writeText(value) svg.toggleAttribute('data-copied', false);
toast(`Copied ${name}`) }
}} }, 1000);
> }}
<dd className='min-w-0 truncate px-2 py-1'> >
{value} <p className="truncate">{value}</p>
</dd> <Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
<ClipboardIcon className='text-gray-600 dark:text-gray-200 pr-2 w-max h-4'/> <Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
</button> </button>
) : ( ) : (
<dd className='min-w-0 truncate px-2 py-1'> value
{value} )}
</dd> </dd>
)}
</dl> </dl>
) );
} }
+35 -18
View File
@@ -1,26 +1,43 @@
import clsx from 'clsx' import React, { useRef } from 'react';
import { type ButtonHTMLAttributes, type DetailedHTMLProps } from 'react' import { type AriaButtonOptions, useButton } from 'react-aria';
import cn from '~/utils/cn';
type Properties = { export interface ButtonProps extends AriaButtonOptions<'button'> {
readonly variant?: 'emphasized' | 'normal' | 'destructive'; variant?: 'heavy' | 'light' | 'danger';
} & DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> className?: string;
children?: React.ReactNode;
ref?: React.RefObject<HTMLButtonElement | null>;
}
export default function Button({ variant = 'light', ...props }: ButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
export default function Action(properties: Properties) {
return ( return (
<button <button
type='button' ref={ref}
{...properties} {...buttonProps}
className={clsx( className={cn(
'focus:outline-none focus:ring focus:ring-1', 'w-fit text-sm rounded-xl px-3 py-2',
'focus:ring-blue-500 dark:focus:ring-blue-300', 'focus:outline-none focus:ring',
properties.className, props.isDisabled && 'opacity-60 cursor-not-allowed',
properties.disabled && 'opacity-50 cursor-not-allowed', ...(variant === 'heavy'
properties.variant === 'destructive' ? 'text-red-700 dark:text-red-500' : '', ? [
properties.variant === 'emphasized' ? 'rounded-lg px-4 py-2 bg-gray-800 dark:bg-gray-700 text-white' : '', 'bg-headplane-900 dark:bg-headplane-50 font-semibold',
!properties.variant || properties.variant === 'normal' ? 'text-blue-700 dark:text-blue-400' : '' '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,
)} )}
> >
{properties.children} {props.children}
</button> </button>
) );
} }
+20 -10
View File
@@ -1,18 +1,28 @@
import clsx from 'clsx' import React from 'react';
import { type HTMLProps } from 'react' import Text from '~/components/Text';
import Title from '~/components/Title';
import cn from '~/utils/cn';
type Properties = HTMLProps<HTMLDivElement> interface Props extends React.HTMLProps<HTMLDivElement> {
variant?: 'raised' | 'flat';
}
export default function Card(properties: Properties) { function Card({ variant = 'raised', ...props }: Props) {
return ( return (
<div <div
{...properties} {...props}
className={clsx( className={cn(
'p-4 md:p-6 border dark:border-zinc-700 rounded-lg', 'w-full max-w-md rounded-3xl p-5',
properties.className variant === 'flat'
? 'bg-transparent shadow-none'
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-sm',
'border border-headplane-100 dark:border-headplane-800',
props.className,
)} )}
> >
{properties.children} {props.children}
</div> </div>
) );
} }
export default Object.assign(Card, { Title, Text });
+32
View File
@@ -0,0 +1,32 @@
import React from 'react';
import cn from '~/utils/cn';
export interface ChipProps {
text: string;
className?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
}
export default function Chip({
text,
className,
leftIcon,
rightIcon,
}: ChipProps) {
return (
<span
className={cn(
'text-xs px-2 py-0.5 rounded-full',
'text-headplane-700 dark:text-headplane-100',
'bg-headplane-100 dark:bg-headplane-700',
leftIcon || rightIcon ? 'inline-flex items-center gap-x-1' : '',
className,
)}
>
{leftIcon}
{text}
{rightIcon}
</span>
);
}
+50 -10
View File
@@ -1,12 +1,52 @@
import clsx from 'clsx' import { Check, Copy } from 'lucide-react';
import { type HTMLProps } from 'react' import { HTMLProps } from 'react';
import cn from '~/utils/cn';
import toast from '~/utils/toast';
type Properties = HTMLProps<HTMLSpanElement> export interface CodeProps extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean;
export default function Code(properties: Properties) { children: string | string[];
return ( }
<code className={clsx('bg-gray-100 dark:bg-zinc-700 p-0.5 rounded-md', properties.className)}>
{properties.children} export default function Code({ isCopyable, children, className }: CodeProps) {
</code> return (
) <code
className={cn(
'bg-headplane-100 dark:bg-headplane-800 px-1 py-0.5 font-mono',
'rounded-lg focus-within:outline-none focus-within:ring-2',
isCopyable && 'relative pr-7',
className,
)}
>
{children}
{isCopyable && (
<button
type="button"
className="bottom-0 right-0 absolute"
onClick={async (event) => {
const text = Array.isArray(children)
? children.join(' ')
: children;
const svgs = event.currentTarget.querySelectorAll('svg');
for (const svg of svgs) {
svg.toggleAttribute('data-copied', true);
}
await navigator.clipboard.writeText(text);
toast('Copied to clipboard');
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute('data-copied', false);
}
}, 1000);
}}
>
<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>
);
} }
+175 -131
View File
@@ -1,142 +1,186 @@
/* eslint-disable unicorn/no-keyword-prefix */ import React, { cloneElement, useRef } from 'react';
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
import { import {
Button as AriaButton, type AriaDialogProps,
Dialog as AriaDialog, type AriaModalOverlayProps,
DialogTrigger, Overlay,
Heading as AriaHeading, useDialog,
Modal, useModalOverlay,
ModalOverlay useOverlayTrigger,
} from 'react-aria-components' } 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 { cn } from '~/utils/cn' export interface DialogProps extends OverlayTriggerProps {
children:
type ButtonProperties = Parameters<typeof AriaButton>[0] & { | [
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]; React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<DialogPanelProps>,
]
| React.ReactElement<DialogPanelProps>;
} }
function Button(properties: ButtonProperties) { function Dialog(props: DialogProps) {
return ( const state = useOverlayTriggerState(props);
<AriaButton const { triggerProps, overlayProps } = useOverlayTrigger(
{...properties} {
aria-label='Dialog' type: 'dialog',
className={cn( },
'w-fit text-sm rounded-lg px-4 py-2', state,
'bg-main-700 dark:bg-main-800 text-white', );
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}
/>
)
}
type ActionProperties = Parameters<typeof AriaButton>[0] & { if (Array.isArray(props.children)) {
readonly variant: 'cancel' | 'confirm'; const [button, panel] = props.children;
} return (
<>
function Action(properties: ActionProperties) { {cloneElement(button, triggerProps)}
return ( {state.isOpen && (
<AriaButton <DModal state={state}>
{...properties} {cloneElement(panel, {
type={properties.variant === 'confirm' ? 'submit' : 'button'} ...overlayProps,
className={cn( close: () => state.close(),
'px-4 py-2 rounded-lg', })}
properties.isDisabled && 'opacity-50 cursor-not-allowed', </DModal>
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'
)} )}
> </>
<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 ( return (
<DialogTrigger> <DModal state={state}>
{children} {cloneElement(props.children, {
</DialogTrigger> ...overlayProps,
) close: () => state.close(),
})}
</DModal>
);
} }
export default Object.assign(Dialog, { Button, Title, Text, Panel, Action }) export interface DialogPanelProps extends AriaDialogProps {
children: React.ReactNode;
variant?: 'normal' | 'destructive' | 'unactionable';
onSubmit?: React.FormEventHandler<HTMLFormElement>;
method?: HTMLFormMethod;
isDisabled?: boolean;
// Anonymous (passed by parent)
close?: () => void;
}
function Panel(props: DialogPanelProps) {
const {
children,
onSubmit,
isDisabled,
close,
variant,
method = 'POST',
} = props;
const ref = useRef<HTMLFormElement | null>(null);
const { dialogProps } = useDialog(
{
...props,
role: 'alertdialog',
},
ref,
);
return (
<Form
{...dialogProps}
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
}
close?.();
}}
method={method ?? 'POST'}
ref={ref}
className={cn(
'outline-none rounded-3xl w-full max-w-lg',
'bg-white dark:bg-headplane-900',
)}
>
<Card className="w-full max-w-lg" variant="flat">
{children}
<div className="mt-6 flex justify-end gap-4">
{variant === 'unactionable' ? (
<Button onPress={close}>Close</Button>
) : (
<>
<Button onPress={close}>Cancel</Button>
<Button
type="submit"
variant={variant === 'destructive' ? 'danger' : 'heavy'}
isDisabled={
isDisabled || !(ref.current?.checkValidity() ?? true)
}
>
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,
});
+54 -45
View File
@@ -1,52 +1,61 @@
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline' import { AlertIcon } from '@primer/octicons-react';
import { isRouteErrorResponse, useRouteError } from '@remix-run/react' import { isRouteErrorResponse, useRouteError } from 'react-router';
import { useState } from 'react' import cn from '~/utils/cn';
import Card from './Card';
import Code from './Code';
import { cn } from '~/utils/cn' interface Props {
type?: 'full' | 'embedded';
import Code from './Code'
import Dialog from './Dialog'
type Properties = {
readonly type?: 'full' | 'embedded';
} }
export function ErrorPopup({ type = 'full' }: Properties) { function getMessage(error: Error | unknown) {
// eslint-disable-next-line react/hook-use-state if (!(error instanceof Error)) {
const open = useState(true) return 'An unknown error occurred';
}
const error = useRouteError() let rootError = error;
const routing = isRouteErrorResponse(error)
const message = (error instanceof Error ? error.message : 'An unexpected error occurred') // Traverse the error chain to find the root cause
if (error.cause) {
rootError = error.cause as Error;
while (rootError.cause) {
rootError = rootError.cause as Error;
}
}
// If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
return rootError.errors.map((error) => error.message).join('\n');
}
return rootError.message;
}
export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError();
const routing = isRouteErrorResponse(error);
const message = getMessage(error);
return ( return (
<Dialog> <div
<Dialog.Panel className={cn(
className={cn( 'flex items-center justify-center',
type === 'embedded' ? 'pointer-events-none bg-transparent dark:bg-transparent' : '', type === 'embedded'
)} ? 'pointer-events-none mt-24'
control={open} : 'fixed inset-0 h-screen w-screen z-50',
> )}
{() => ( >
<> <Card>
<div className='flex items-center justify-between'> <div className="flex items-center justify-between">
<Dialog.Title className='text-3xl mb-0'> <Card.Title className="text-3xl mb-0">
{routing ? error.status : 'Error'} {routing ? error.status : 'Error'}
</Dialog.Title> </Card.Title>
<ExclamationTriangleIcon className='w-12 h-12 text-red-500'/> <AlertIcon className="w-12 h-12 text-red-500" />
</div> </div>
<Dialog.Text className='mt-4 text-lg'> <Card.Text className="mt-4 text-lg">
{routing ? ( {routing ? error.statusText : <Code>{message}</Code>}
error.statusText </Card.Text>
) : ( </Card>
<Code> </div>
{message} );
</Code>
)}
</Dialog.Text>
</>
)}
</Dialog.Panel>
</Dialog>
)
} }
+42
View File
@@ -0,0 +1,42 @@
import Link from '~/components/Link';
import cn from '~/utils/cn';
declare global {
const __VERSION__: string;
}
interface FooterProps {
url: string;
debug: boolean;
}
export default function Footer({ url, debug }: FooterProps) {
return (
<footer
className={cn(
'fixed bottom-0 left-0 z-40 w-full h-14',
'flex flex-col justify-center gap-1 shadow-inner',
'bg-headplane-100 dark:bg-headplane-950',
'text-headplane-800 dark:text-headplane-200',
'dark:border-t dark:border-headplane-800',
)}
>
<p className="container text-xs">
Headplane is entirely free to use. If you find it useful, consider{' '}
<Link
to="https://github.com/sponsors/tale"
name="Aarnav's GitHub Sponsors"
>
donating
</Link>{' '}
to support development.{' '}
</p>
<p className="container text-xs opacity-75">
Version: {__VERSION__}
{' — '}
Connecting to <strong className="blur-xs hover:blur-none">{url}</strong>
{debug && ' (Debug mode enabled)'}
</p>
</footer>
);
}
+152
View File
@@ -0,0 +1,152 @@
import {
CircleUser,
Globe2,
Lock,
PlaneTakeoff,
Server,
Settings,
Users,
} from 'lucide-react';
import type { ReactNode } from 'react';
import { NavLink, useSubmit } from 'react-router';
import Menu from '~/components/Menu';
import cn from '~/utils/cn';
import type { SessionData } from '~/utils/sessions.server';
interface Props {
configAvailable: boolean;
user?: SessionData['user'];
}
interface LinkProps {
href: string;
text: string;
}
interface TabLinkProps {
name: string;
to: string;
icon: ReactNode;
}
function TabLink({ name, to, icon }: TabLinkProps) {
return (
<div className="relative py-2">
<NavLink
to={to}
prefetch="intent"
className={({ isActive }) =>
cn(
'px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5',
'after:absolute after:bottom-0 after:left-3 after:right-3',
'after:h-0.5 after:bg-headplane-900 dark:after:bg-headplane-200',
'hover:bg-headplane-200 dark:hover:bg-headplane-900',
'focus:outline-none focus:ring',
isActive ? 'after:visible' : 'after:invisible',
)
}
>
{icon} {name}
</NavLink>
</div>
);
}
function Link({ href, text }: LinkProps) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className={cn(
'hidden sm:block hover:underline text-sm',
'focus:outline-none focus:ring rounded-md',
)}
>
{text}
</a>
);
}
export default function Header(data: Props) {
const submit = useSubmit();
return (
<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">
<PlaneTakeoff />
<h1 className="text-2xl font-semibold">headplane</h1>
</div>
<div className="flex items-center gap-x-4">
<Link href="https://tailscale.com/download" text="Download" />
<Link href="https://github.com/tale/headplane" text="GitHub" />
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
{data.user ? (
<Menu>
<Menu.IconButton label="User">
<CircleUser />
</Menu.IconButton>
<Menu.Panel
onAction={(key) => {
if (key === 'logout') {
submit(
{},
{
method: 'POST',
action: '/logout',
},
);
}
}}
disabledKeys={['profile']}
>
<Menu.Section>
<Menu.Item key="profile" textValue="Profile">
<div className="text-black dark:text-headplane-50">
<p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p>
</div>
</Menu.Item>
<Menu.Item key="logout" textValue="Logout">
<p className="text-red-500 dark:text-red-400">Logout</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
) : undefined}
</div>
</div>
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
<TabLink
to="/machines"
name="Machines"
icon={<Server className="w-5" />}
/>
<TabLink to="/users" name="Users" icon={<Users className="w-5" />} />
<TabLink
to="/acls"
name="Access Control"
icon={<Lock className="w-5" />}
/>
{data.configAvailable ? (
<>
<TabLink to="/dns" name="DNS" icon={<Globe2 className="w-5" />} />
<TabLink
to="/settings"
name="Settings"
icon={<Settings className="w-5" />}
/>
</>
) : undefined}
</nav>
</header>
);
}
+46
View File
@@ -0,0 +1,46 @@
import React, { useRef } from 'react';
import { type AriaButtonOptions, useButton } from 'react-aria';
import cn from '~/utils/cn';
export interface IconButtonProps extends AriaButtonOptions<'button'> {
variant?: 'heavy' | 'light';
className?: string;
children: React.ReactNode;
label: string;
ref?: React.RefObject<HTMLButtonElement | null>;
}
export default function IconButton({
variant = 'light',
...props
}: IconButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
return (
<button
ref={ref}
{...buttonProps}
aria-label={props.label}
className={cn(
'rounded-full flex items-center justify-center p-1',
'focus:outline-none focus:ring',
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
'text-headplane-200 dark:text-headplane-800',
]
: [
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
]),
props.className,
)}
>
{props.children}
</button>
);
}
+77 -24
View File
@@ -1,26 +1,79 @@
import clsx from 'clsx' import { useRef } from 'react';
import { type DetailedHTMLProps, type InputHTMLAttributes } from 'react' import { type AriaTextFieldProps, useId, useTextField } from 'react-aria';
import cn from '~/utils/cn';
type Properties = { export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
readonly variant?: 'embedded' | 'normal'; label: string;
} & DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> labelHidden?: boolean;
isRequired?: boolean;
export default function Input(properties: Properties) { className?: string;
return ( }
<input
{...properties} export default function Input(props: InputProps) {
className={clsx( const { label, labelHidden, className } = props;
'block w-full dark:text-gray-300', const ref = useRef<HTMLInputElement | null>(null);
'border-gray-300 dark:border-zinc-700', const id = useId(props.id);
'focus:outline-none focus:ring',
'focus:ring-blue-500 dark:focus:ring-blue-300', const {
properties.variant === 'embedded' ? 'bg-transparent' : 'dark:bg-zinc-800', labelProps,
properties.variant === 'embedded' ? 'p-0' : 'px-2.5 py-1.5', inputProps,
properties.variant === 'embedded' ? 'border-none' : 'border', descriptionProps,
properties.variant === 'embedded' ? 'focus:ring-0' : 'focus:ring-1', errorMessageProps,
properties.variant === 'embedded' ? 'rounded-none' : 'rounded-lg', isInvalid,
properties.className 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}
</label>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
className={cn(
'rounded-xl px-3 py-2',
'focus:outline-none focus:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
className,
)}
/>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{isInvalid && (
<div
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{validationErrors.join(' ')}
</div>
)}
</div>
);
} }
+35
View File
@@ -0,0 +1,35 @@
import { ExternalLink } from 'lucide-react';
import cn from '~/utils/cn';
export interface LinkProps {
to: string;
name: string;
children: string;
className?: string;
}
export default function Link({
to,
name: alt,
children,
className,
}: LinkProps) {
return (
<a
href={to}
aria-label={alt}
target="_blank"
rel="noreferrer"
className={cn(
'inline-flex items-center gap-x-0.5',
'text-blue-500 hover:text-blue-700',
'dark:text-blue-400 dark:hover:text-blue-300',
'focus:outline-none focus:ring rounded-md',
className,
)}
>
{children}
<ExternalLink className="w-3.5" />
</a>
);
}
+135 -80
View File
@@ -1,98 +1,153 @@
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 { import {
Button as AriaButton, type MenuTriggerProps,
Menu as AriaMenu, Node,
MenuItem, TreeState,
MenuTrigger, useMenuTriggerState,
Popover useTreeState,
} from 'react-aria-components' } 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' interface MenuProps extends MenuTriggerProps {
placement?: Placement;
function Button(properties: Parameters<typeof AriaButton>[0]) { children: [
return ( React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
<AriaButton React.ReactElement<MenuPanelProps>,
{...properties} ];
className={cn(
'outline-none',
properties.className
)}
aria-label='Menu'
/>
)
} }
function Items(properties: Parameters<typeof AriaMenu>[0]) { // TODO: onAction is called twice for some reason?
function Menu(props: MenuProps) {
const { placement = 'bottom' } = props;
const state = useMenuTriggerState(props);
const ref = useRef<HTMLButtonElement | null>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
{},
state,
ref,
);
// cloneElement is necessary because the button is a union type
// of multiple things and we need to join props from our hooks
const [button, panel] = props.children;
return ( return (
<Popover className={cn( <div>
'mt-2 rounded-md', {cloneElement(button, {
'bg-ui-50 dark:bg-ui-800', ...menuTriggerProps,
'overflow-hidden z-50', ref,
'border border-ui-200 dark:border-ui-600', })}
'entering:animate-in exiting:animate-out', {state.isOpen && (
'entering:fade-in entering:zoom-in-95', <Popover state={state} triggerRef={ref} placement={placement}>
'exiting:fade-out exiting:zoom-out-95', {cloneElement(panel, {
'fill-mode-forwards origin-left-right' ...menuProps,
)} autoFocus: state.focusStrategy ?? true,
onClose: () => state.close(),
})}
</Popover>
)}
</div>
);
}
interface MenuPanelProps extends AriaMenuProps<object> {
onClose?: () => void;
}
function Panel(props: MenuPanelProps) {
const state = useTreeState(props);
const ref = useRef(null);
const { menuProps } = useMenu(props, state, ref);
return (
<ul
{...menuProps}
ref={ref}
className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none"
> >
<AriaMenu {[...state.collection].map((item) => (
{...properties} <MenuSection key={item.key} section={item} state={state} />
className={cn( ))}
'outline-none', </ul>
'divide-y divide-ui-200 dark:divide-ui-600', );
properties.className
)}
>
{properties.children}
</AriaMenu>
</Popover>
)
} }
type ButtonProperties = Parameters<typeof AriaButton>[0] & { interface MenuSectionProps<T> {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]; section: Node<T>;
state: TreeState<T>;
} }
function ItemButton(properties: ButtonProperties) { function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
const { itemProps, groupProps } = useMenuSection({
heading: section.rendered,
'aria-label': section['aria-label'],
});
const { separatorProps } = useSeparator({
elementType: 'li',
});
return ( return (
<MenuItem className='outline-none'> <>
<AriaButton {section.key !== state.collection.getFirstKey() ? (
{...properties} <li
className={cn( {...separatorProps}
'px-4 py-2 w-full outline-none text-left', className={cn(
'hover:bg-ui-200 dark:hover:bg-ui-700', 'mx-2 mt-1 mb-1 border-t',
properties.className 'border-headplane-200 dark:border-headplane-800',
)} )}
aria-label='Menu Dialog' />
// If control is passed, set the state value ) : undefined}
onPress={event => { <li {...itemProps}>
properties.onPress?.(event) <ul {...groupProps}>
properties.control?.[1](true) {[...section.childNodes].map((item) => (
}} <MenuItem key={item.key} item={item} state={state} />
/> ))}
</MenuItem> </ul>
) </li>
</>
);
} }
function Item(properties: Parameters<typeof MenuItem>[0]) { interface MenuItemProps<T> {
item: Node<T>;
state: TreeState<T>;
}
function MenuItem<T>({ item, state }: MenuItemProps<T>) {
const ref = useRef<HTMLLIElement | null>(null);
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
const isFocused = state.selectionManager.focusedKey === item.key;
const isDisabled = state.selectionManager.isDisabled(item.key);
return ( return (
<MenuItem <li
{...properties} {...menuItemProps}
ref={ref}
className={cn( className={cn(
'px-4 py-2 w-full outline-none', 'py-2 px-3 mx-1 rounded-lg',
'hover:bg-ui-200 dark:hover:bg-ui-700', 'focus:outline-none select-none',
properties.className 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 }) { export default Object.assign(Menu, {
return ( Button,
<MenuTrigger> IconButton,
{children} Panel,
</MenuTrigger> Section,
) Item,
} });
export default Object.assign(Menu, { Button, Item, ItemButton, Items })
+14 -14
View File
@@ -1,16 +1,16 @@
import { InformationCircleIcon } from '@heroicons/react/24/outline' import { CircleSlash2 } from 'lucide-react';
import clsx from 'clsx' import React from 'react';
import { type ReactNode } from 'react' import Card from '~/components/Card';
export default function Notice({ children }: { readonly children: ReactNode }) { export interface NoticeProps {
return ( children: React.ReactNode;
<div className={clsx( }
'p-4 rounded-md w-fit flex items-center gap-3',
'bg-slate-400 dark:bg-slate-700' export default function Notice({ children }: NoticeProps) {
)} return (
> <Card className="flex w-full max-w-full gap-4 font-semibold">
<InformationCircleIcon className='h-6 w-6 text-white'/> <CircleSlash2 />
{children} {children}
</div> </Card>
) );
} }
+102
View File
@@ -0,0 +1,102 @@
import { Minus, Plus } from 'lucide-react';
import { useRef } from 'react';
import {
type AriaNumberFieldProps,
useId,
useLocale,
useNumberField,
} from 'react-aria';
import { useNumberFieldState } from 'react-stately';
import IconButton from '~/components/IconButton';
import cn from '~/utils/cn';
export interface InputProps extends AriaNumberFieldProps {
isRequired?: boolean;
name?: string;
}
export default function NumberInput(props: InputProps) {
const { label, name } = props;
const { locale } = useLocale();
const state = useNumberFieldState({ ...props, locale });
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const {
labelProps,
inputProps,
groupProps,
incrementButtonProps,
decrementButtonProps,
descriptionProps,
errorMessageProps,
isInvalid,
validationErrors,
} = useNumberField(props, state, ref);
return (
<div className="flex flex-col">
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
>
{label}
</label>
<div
{...groupProps}
className={cn(
'flex items-center gap-1 rounded-xl pr-1',
'focus-within:outline-none focus-within:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
)}
>
<input
{...inputProps}
id={id}
required={props.isRequired}
name={name}
ref={ref}
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-none"
/>
<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>
);
}
+49
View File
@@ -0,0 +1,49 @@
import React, { useRef } from 'react';
import {
type AriaPopoverProps,
DismissButton,
Overlay,
usePopover,
} from 'react-aria';
import type { OverlayTriggerState } from 'react-stately';
import cn from '~/utils/cn';
export interface PopoverProps extends Omit<AriaPopoverProps, 'popoverRef'> {
children: React.ReactNode;
state: OverlayTriggerState;
popoverRef?: React.RefObject<HTMLDivElement | null>;
className?: string;
}
export default function Popover(props: PopoverProps) {
const ref = props.popoverRef ?? useRef<HTMLDivElement | null>(null);
const { state, children, className } = props;
const { popoverProps, underlayProps } = usePopover(
{
...props,
popoverRef: ref,
offset: 8,
},
state,
);
return (
<Overlay>
<div {...underlayProps} className="fixed inset-0" />
<div
{...popoverProps}
ref={ref}
className={cn(
'z-10 shadow-sm rounded-xl',
'bg-white dark:bg-headplane-900',
'border border-headplane-200 dark:border-headplane-800',
className,
)}
>
<DismissButton onDismiss={state.close} />
{children}
<DismissButton onDismiss={state.close} />
</div>
</Overlay>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { useProgressBar } from 'react-aria';
import cn from '~/utils/cn';
export interface ProgressBarProps {
isVisible: boolean;
}
export default function ProgressBar(props: ProgressBarProps) {
const { isVisible } = props;
const { progressBarProps } = useProgressBar({
label: 'Loading...',
isIndeterminate: true,
});
return (
<div
{...progressBarProps}
aria-hidden={!isVisible}
className={cn(
'fixed top-0 left-0 z-50 w-1/2 h-1 opacity-0',
'bg-headplane-950 dark:bg-headplane-50',
isVisible && 'animate-loading opacity-100',
)}
/>
);
}
+172
View File
@@ -0,0 +1,172 @@
import { Check, ChevronDown } from 'lucide-react';
import { useRef } from 'react';
import {
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';
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 (
<div className={cn('flex flex-col', props.className)}>
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
>
{props.label}
</label>
<div
className={cn(
'flex rounded-xl focus:outline-none focus-within:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
)}
>
<input
{...inputProps}
ref={inputRef}
id={id}
className="outline-none px-3 py-2 rounded-l-xl w-full bg-transparent"
data-1p-ignore
/>
<button
{...buttonProps}
ref={buttonRef}
className={cn(
'flex items-center justify-center p-1 rounded-lg m-1',
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
)}
>
<ChevronDown className="p-0.5" />
</button>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{state.isOpen && (
<Popover
popoverRef={popoverRef}
triggerRef={inputRef}
state={state}
isNonModal
placement="bottom start"
className="w-full max-w-xs"
>
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover>
)}
</div>
);
}
interface ListBoxProps extends AriaListBoxOptions<object> {
listBoxRef?: React.RefObject<HTMLUListElement | null>;
state: ListState<object>;
}
function ListBox(props: ListBoxProps) {
const { listBoxRef, state } = props;
const ref = listBoxRef ?? useRef<HTMLUListElement | null>(null);
const { listBoxProps } = useListBox(props, state, ref);
return (
<ul
{...listBoxProps}
ref={listBoxRef}
className="w-full max-h-72 overflow-auto outline-none pt-1"
>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</ul>
);
}
interface OptionProps {
item: Node<unknown>;
state: ListState<unknown>;
}
function Option({ item, state }: OptionProps) {
const ref = useRef<HTMLLIElement | null>(null);
const { optionProps, isDisabled, isSelected, isFocused } = useOption(
{
key: item.key,
},
state,
ref,
);
return (
<li
{...optionProps}
ref={ref}
className={cn(
'flex items-center justify-between',
'py-2 px-3 mx-1 rounded-lg mb-1',
'focus:outline-none select-none',
isFocused || isSelected
? 'bg-headplane-100/50 dark:bg-headplane-800'
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
isDisabled && 'text-headplane-300 dark:text-headplane-600',
)}
>
{item.rendered}
{isSelected && <Check className="p-0.5" />}
</li>
);
}
export default Object.assign(Select, { Item });
+7 -9
View File
@@ -1,23 +1,21 @@
import clsx from 'clsx' import clsx from 'clsx';
type Properties = { interface Props {
// eslint-disable-next-line unicorn/no-keyword-prefix
className?: string; className?: string;
} }
export default function Spinner(properties: Properties) { export default function Spinner({ className }: Props) {
return ( return (
<div className={clsx('mr-1.5 inline-block align-middle mb-0.5', properties.className)}> <div className={clsx('inline-block align-middle mb-0.5', className)}>
<div <div
className={clsx( className={clsx(
'animate-spin rounded-full w-full h-full', 'animate-spin rounded-full w-full h-full',
'border-2 border-current border-t-transparent', 'border-2 border-current border-t-transparent',
properties.className className,
)} )}
role='status'
> >
<span className='sr-only'>Loading...</span> <span className="sr-only">Loading...</span>
</div> </div>
</div> </div>
) );
} }
+17 -14
View File
@@ -1,24 +1,27 @@
import clsx from 'clsx' import cn from '~/utils/cn';
import { type HTMLProps } from 'react'
type Properties = HTMLProps<SVGElement> & { export interface StatusCircleProps {
readonly isOnline: boolean; isOnline: boolean;
className?: string;
} }
// eslint-disable-next-line unicorn/no-keyword-prefix export default function StatusCircle({
export default function StatusCircle({ isOnline, className }: Properties) { isOnline,
className,
}: StatusCircleProps) {
return ( return (
<svg <svg
className={clsx( className={cn(
className,
isOnline isOnline
? 'text-green-700 dark:text-green-400' ? 'text-green-600 dark:text-green-500'
: 'text-gray-300 dark:text-gray-500' : 'text-headplane-200 dark:text-headplane-800',
className,
)} )}
viewBox='0 0 24 24' viewBox="0 0 24 24"
fill='currentColor' fill="currentColor"
> >
<circle cx='12' cy='12' r='8'/> <title>{isOnline ? 'Online' : 'Offline'}</title>
<circle cx="12" cy="12" r="8" />
</svg> </svg>
) );
} }
+52 -31
View File
@@ -1,40 +1,61 @@
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' export interface SwitchProps extends AriaSwitchProps {
label: string;
type SwitchProperties = Parameters<typeof AriaSwitch>[0] & { className?: string;
readonly label: string;
} }
export default function Switch(properties: SwitchProperties) { export default function Switch(props: SwitchProps) {
return ( const state = useToggleState(props);
<AriaSwitch const ref = useRef<HTMLInputElement | null>(null);
{...properties} const { focusProps, isFocusVisible } = useFocusRing();
aria-label={properties.label} const { inputProps } = useSwitch(
className='group flex gap-2 items-center' {
> ...props,
<div 'aria-label': props.label,
className={cn( },
'flex h-[26px] w-[44px] shrink-0 cursor-default', state,
'rounded-full shadow-inner bg-clip-padding', ref,
'border border-solid border-white/30 p-[3px]', );
'box-border transition duration-100 ease-in-out',
'outline-none group-focus-visible:ring-2 ring-black',
'bg-main-700 dark:bg-main-800', return (
'group-pressed:bg-main-800 dark:group-pressed:bg-main-900', <label className="flex items-center gap-x-2">
'group-selected:bg-main-900 group-selected:group-pressed:bg-main-900', <VisuallyHidden elementType="span">
properties.isDisabled && 'opacity-50 cursor-not-allowed', <input
properties.className {...inputProps}
{...focusProps}
aria-label={props.label}
ref={ref}
/>
</VisuallyHidden>
<div
aria-hidden
className={cn(
'flex h-[28px] w-[46px] p-[4px] shrink-0 rounded-full',
'bg-headplane-300 dark:bg-headplane-700',
'border border-transparent dark:border-headplane-800',
state.isSelected && 'bg-headplane-900 dark:bg-headplane-950',
isFocusVisible && 'ring-2',
props.isDisabled && 'opacity-50',
)} )}
> >
<span className={cn( <span
'h-[18px] w-[18px] transform rounded-full', className={cn(
'bg-white shadow transition duration-100', 'h-[18px] w-[18px] transform rounded-full',
'ease-in-out translate-x-0 group-selected:translate-x-[100%]' 'bg-white transition duration-50 ease-in-out',
)} 'translate-x-0 group-selected:translate-x-[100%]',
state.isSelected && 'translate-x-[100%]',
)}
/> />
</div> </div>
</AriaSwitch> </label>
) );
} }
-23
View File
@@ -1,23 +0,0 @@
import { NavLink } from '@remix-run/react'
import clsx from 'clsx'
import type { ReactNode } from 'react'
type Properties = {
readonly name: string;
readonly to: string;
readonly icon: ReactNode;
}
export default function TabLink({ name, to, icon }: Properties) {
return (
<NavLink
to={to}
className={({ isActive, isPending }) => clsx(
'flex items-center gap-x-2 p-2 border-b-2 text-md',
isActive ? 'border-white' : 'border-transparent'
)}
>
{icon} {name}
</NavLink>
)
}
+19 -22
View File
@@ -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 ( return (
<div <div
{...properties} {...props}
className={clsx( className={cn(
'border border-gray-300 rounded-lg overflow-clip', 'rounded-xl',
'dark:border-zinc-700 dark:text-gray-300', 'border border-headplane-100 dark:border-headplane-800',
// 'dark:bg-zinc-800', props.className,
properties.className
)} )}
> >
{properties.children} {props.children}
</div> </div>
) );
} }
function Item(properties: HTMLProps<HTMLDivElement>) { function Item(props: HTMLProps<HTMLDivElement>) {
return ( return (
<div <div
{...properties} {...props}
className={clsx( className={cn(
'flex items-center justify-between p-2 last:border-b-0',
'flex items-center justify-between px-3 py-2', 'border-b border-headplane-100 dark:border-headplane-800',
'border-b border-gray-200 last:border-b-0', props.className,
'dark:border-zinc-800',
properties.className
)} )}
> >
{properties.children} {props.children}
</div> </div>
) );
} }
export default Object.assign(TableList, { Item }) export default Object.assign(TableList, { Item });
+90
View File
@@ -0,0 +1,90 @@
import { useRef } from 'react';
import {
AriaTabListProps,
AriaTabPanelProps,
useTab,
useTabList,
useTabPanel,
} from 'react-aria';
import { Item, Node, TabListState, useTabListState } from 'react-stately';
import cn from '~/utils/cn';
export interface TabsProps extends AriaTabListProps<object> {
label: string;
className?: string;
}
function Tabs({ label, className, ...props }: TabsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
ref={ref}
className={cn(
'flex items-center rounded-t-xl w-fit',
'border-headplane-100 dark:border-headplane-800',
'border-t border-x',
)}
>
{[...state.collection].map((item) => (
<Tab key={item.key} item={item} state={state} />
))}
</div>
<TabsPanel key={state.selectedItem?.key} state={state} />
</div>
);
}
export interface TabsTabProps {
item: Node<object>;
state: TabListState<object>;
}
function Tab({ item, state }: TabsTabProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
ref={ref}
className={cn(
'pl-2 pr-3 py-2.5',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-none focus:ring z-10',
'border-r border-headplane-100 dark:border-headplane-800',
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
)}
>
{rendered}
</div>
);
}
export interface TabsPanelProps extends AriaTabPanelProps {
state: TabListState<object>;
}
function TabsPanel({ state, ...props }: TabsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div
{...tabPanelProps}
ref={ref}
className={cn(
'w-full overflow-clip rounded-b-xl rounded-r-xl',
'border border-headplane-100 dark:border-headplane-800',
)}
>
{state.selectedItem?.props.children}
</div>
);
}
export default Object.assign(Tabs, { Item });
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import cn from '~/utils/cn';
export interface TextProps {
children: React.ReactNode;
className?: string;
}
export default function Text({ children, className }: TextProps) {
return <p className={cn('text-md my-0', className)}>{children}</p>;
}
-38
View File
@@ -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>
)
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import cn from '~/utils/cn';
export interface TitleProps {
children: React.ReactNode;
className?: string;
}
export default function Title({ children, className }: TitleProps) {
return (
<h3 className={cn('text-2xl font-bold mb-2', className)}>{children}</h3>
);
}
+87
View File
@@ -0,0 +1,87 @@
import {
AriaToastProps,
AriaToastRegionProps,
useToast,
useToastRegion,
} from '@react-aria/toast';
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast';
import { X } from 'lucide-react';
import React, { useRef } from 'react';
import IconButton from '~/components/IconButton';
import cn from '~/utils/cn';
interface ToastProps extends AriaToastProps<React.ReactNode> {
state: ToastState<React.ReactNode>;
}
function Toast({ state, ...props }: ToastProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
props,
state,
ref,
);
return (
<div
{...toastProps}
ref={ref}
className={cn(
'flex items-center justify-between gap-x-3 pl-4 pr-3',
'text-white shadow-lg dark:shadow-md rounded-xl py-3',
'bg-headplane-900 dark:bg-headplane-950',
)}
>
<div {...contentProps} className="flex flex-col gap-2">
<div {...titleProps}>{props.toast.content}</div>
</div>
<IconButton
{...closeButtonProps}
label="Close"
className={cn(
'bg-transparent hover:bg-headplane-700',
'dark:bg-transparent dark:hover:bg-headplane-800',
)}
>
<X className="p-1" />
</IconButton>
</div>
);
}
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<React.ReactNode>;
}
function ToastRegion({ state, ...props }: ToastRegionProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { regionProps } = useToastRegion(props, state, ref);
return (
<div
{...regionProps}
ref={ref}
className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
>
{state.visibleToasts.map((toast) => (
<Toast key={toast.key} toast={toast} state={state} />
))}
</div>
);
}
export interface ToastProviderProps extends AriaToastRegionProps {
queue: ToastQueue<React.ReactNode>;
}
export default function ToastProvider({ queue, ...props }: ToastProviderProps) {
const state = useToastQueue(queue);
return (
<>
{state.visibleToasts.length > 0 && (
<ToastRegion {...props} state={state} />
)}
</>
);
}
-79
View File
@@ -1,79 +0,0 @@
import { XMarkIcon } from '@heroicons/react/24/outline'
import { type AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
import { ToastQueue, type ToastState, useToastQueue } from '@react-stately/toast'
import { type ReactNode, useRef } from 'react'
import { Button } from 'react-aria-components'
import { createPortal } from 'react-dom'
import { ClientOnly } from 'remix-utils/client-only'
import { cn } from '~/utils/cn'
type ToastProperties = AriaToastProps<ReactNode> & {
readonly state: ToastState<ReactNode>;
}
function Toast({ state, ...properties }: ToastProperties) {
const reference = useRef(null)
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference)
return (
<div
{...toastProps}
ref={reference}
className={cn(
'bg-main-700 dark:bg-main-800 rounded-lg',
'text-main-100 dark:text-main-200 z-50',
'border border-main-600 dark:border-main-700',
'flex items-center justify-between p-3 pl-4 w-80'
)}
>
<div {...titleProps}>{properties.toast.content}</div>
<Button
{...closeButtonProps}
className={cn(
'outline-none rounded-full p-1',
'hover:bg-main-600 dark:hover:bg-main-700'
)}
>
<XMarkIcon className='w-4 h-4'/>
</Button>
</div>
)
}
const toasts = new ToastQueue<ReactNode>({
maxVisibleToasts: 5
})
export function toast(text: string) {
return toasts.add(text, { timeout: 5000 })
}
export function Toaster() {
const reference = useRef(null)
const state = useToastQueue(toasts)
const { regionProps } = useToastRegion({}, state, reference)
return (
<ClientOnly>
{() => createPortal(
state.visibleToasts.length >= 0 ? (
<div
className={cn(
'fixed bottom-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>
)
}
+83
View File
@@ -0,0 +1,83 @@
import React, { cloneElement, useRef } from 'react';
import {
AriaTooltipProps,
mergeProps,
useTooltip,
useTooltipTrigger,
} from 'react-aria';
import { TooltipTriggerState, useTooltipTriggerState } from 'react-stately';
import cn from '~/utils/cn';
export interface TooltipProps extends AriaTooltipProps {
children: [React.ReactElement, React.ReactElement<TooltipBodyProps>];
}
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 (
<span className="relative">
<button
ref={ref}
{...triggerProps}
className={cn(
'flex items-center justify-center',
'focus:outline-none focus:ring rounded-xl',
)}
>
{component}
</button>
{state.isOpen &&
cloneElement(body, {
...tooltipProps,
state,
})}
</span>
);
}
interface TooltipBodyProps extends AriaTooltipProps {
children: React.ReactNode;
state?: TooltipTriggerState;
className?: string;
}
function Body({ state, className, ...props }: TooltipBodyProps) {
const { tooltipProps } = useTooltip(props, state);
return (
<span
{...mergeProps(props, tooltipProps)}
className={cn(
'absolute z-50 p-3 top-full mt-1',
'outline-none rounded-3xl text-sm w-48',
'bg-white dark:bg-headplane-950',
'text-black dark:text-white',
'shadow-lg dark:shadow-md rounded-xl',
'border border-headplane-100 dark:border-headplane-800',
className,
)}
>
{props.children}
</span>
);
}
export default Object.assign(Tooltip, {
Body,
});
+12 -12
View File
@@ -1,18 +1,18 @@
/** import { StrictMode, startTransition } from 'react';
* By default, Remix will handle hydrating your app on the client for you. import { hydrateRoot } from 'react-dom/client';
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ import { HydratedRouter } from 'react-router/dom';
* For more information, see https://remix.run/file-conventions/entry.client
*/
import { RemixBrowser } from '@remix-run/react' if (import.meta.env.DEV) {
import { startTransition, StrictMode } from 'react' import('react-scan').then(({ scan }) => {
import { hydrateRoot } from 'react-dom/client' scan({ enabled: true });
});
}
startTransition(() => { startTransition(() => {
hydrateRoot( hydrateRoot(
document, document,
<StrictMode> <StrictMode>
<RemixBrowser/> <HydratedRouter />
</StrictMode> </StrictMode>,
) );
}) });
+49 -113
View File
@@ -1,137 +1,73 @@
/** import { PassThrough } from 'node:stream';
* By default, Remix will handle generating the HTTP Response for you. import { createReadableStreamFromReadable } from '@react-router/node';
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ import { isbot } from 'isbot';
* For more information, see https://remix.run/file-conventions/entry.server import type { RenderToPipeableStreamOptions } from 'react-dom/server';
*/ import { renderToPipeableStream } from 'react-dom/server';
import { EntryContext, ServerRouter } from 'react-router';
import { hp_loadLogger } from '~/utils/log';
import type { AppContext } from '~server/context/app';
import { PassThrough } from 'node:stream' export const streamTimeout = 5_000;
export default function handleRequest(
import type { 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'
// eslint-disable-next-line @typescript-eslint/naming-convention
const ABORT_DELAY = 5000
export default async function handleRequest(
request: Request, request: Request,
responseStatusCode: number, responseStatusCode: number,
responseHeaders: Headers, responseHeaders: Headers,
remixContext: EntryContext routerContext: EntryContext,
) { loadContext: AppContext,
return isbot(request.headers.get('user-agent') ?? '')
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
}
async function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) { ) {
const { context } = loadContext;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let shellRendered = false // This is a promise but we don't need to wait for it to finish
const { pipe, abort } = renderToPipeableStream( // before we start rendering the shell since it only loads once.
<RemixServer hp_loadLogger(context.debug);
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onAllReady() {
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set('Content-Type', 'text/html') 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(
<ServerRouter context={routerContext} url={request.url} />,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set('Content-Type', 'text/html');
resolve( resolve(
new Response(stream, { new Response(stream, {
headers: responseHeaders, headers: responseHeaders,
status: responseStatusCode status: responseStatusCode,
}) }),
) );
pipe(body) pipe(body);
}, },
onShellError(error: unknown) { onShellError(error: unknown) {
reject(error) reject(error);
}, },
onError(error: unknown) { onError(error: unknown) {
responseStatusCode = 500 // biome-ignore lint/style/noParameterAssign: Lazy
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log // Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll // errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest. // reject and get logged in handleDocumentRequest.
if (shellRendered) { if (shellRendered) {
console.error(error) console.error(error);
} }
}
}
)
setTimeout(abort, ABORT_DELAY)
})
}
async function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onShellReady() {
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)
}, },
onShellError(error: unknown) { },
reject(error) );
},
onError(error: unknown) {
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error)
}
}
}
)
setTimeout(abort, ABORT_DELAY) // Abort the rendering stream after the `streamTimeout` so it has tine to
}) // flush down the rejected boundaries
setTimeout(abort, streamTimeout + 1000);
});
} }
+73
View File
@@ -0,0 +1,73 @@
import { XCircleFillIcon } from '@primer/octicons-react';
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { Outlet, useLoaderData } from 'react-router';
import cn from '~/utils/cn';
import { HeadscaleError, healthcheck, pull } from '~/utils/headscale';
import log from '~/utils/log';
import { destroySession, getSession } from '~/utils/sessions.server';
import { useLiveData } from '~/utils/useLiveData';
export async function loader({ request }: LoaderFunctionArgs) {
let healthy = false;
try {
healthy = await healthcheck();
} catch (error) {
log.debug('APIC', 'Healthcheck failed %o', error);
}
// We shouldn't session invalidate if Headscale is down
if (healthy) {
// We can assert because shell ensures this is set
const session = await getSession(request.headers.get('Cookie'));
const apiKey = session.get('hsApiKey')!;
try {
await pull('v1/apikey', apiKey);
} catch (error) {
if (error instanceof HeadscaleError) {
log.debug('APIC', 'API Key validation failed %o', error);
return redirect('/login', {
headers: {
'Set-Cookie': await destroySession(session),
},
});
}
}
}
return {
healthy,
};
}
export default function Layout() {
useLiveData({ interval: 3000 });
const { healthy } = useLoaderData<typeof loader>();
return (
<>
{!healthy ? (
<div
className={cn(
'fixed bottom-0 right-0 z-50 w-fit h-14',
'flex flex-col justify-center gap-1',
)}
>
<div
className={cn(
'flex items-center gap-1.5 mr-1.5 py-2 px-1.5',
'border rounded-lg text-white bg-red-500',
'border-red-600 dark:border-red-400 shadow-sm',
)}
>
<XCircleFillIcon className="w-4 h-4 text-white" />
Headscale is unreachable
</div>
</div>
) : undefined}
<main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet />
</main>
</>
);
}
+50
View File
@@ -0,0 +1,50 @@
import {
LoaderFunctionArgs,
Outlet,
redirect,
useLoaderData,
} from 'react-router';
import Footer from '~/components/Footer';
import Header from '~/components/Header';
import { hs_getConfig } from '~/utils/config/loader';
import { noContext } from '~/utils/log';
import { getSession } from '~/utils/sessions.server';
import type { AppContext } from '~server/context/app';
// 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,
}: LoaderFunctionArgs<AppContext>) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return redirect('/login');
}
if (!context) {
throw noContext();
}
const ctx = context.context;
const { mode, config } = hs_getConfig();
return {
config,
url: ctx.headscale.public_url ?? ctx.headscale.url,
configAvailable: mode !== 'no',
debug: ctx.debug,
user: session.get('user'),
};
}
export default function Shell() {
const data = useLoaderData<typeof loader>();
return (
<>
<Header {...data} />
<Outlet />
<Footer {...data} />
</>
);
}
+38 -40
View File
@@ -1,65 +1,63 @@
import type { LinksFunction, MetaFunction } from '@remix-run/node' import type { LinksFunction, MetaFunction } from 'react-router';
import { import {
Links, Links,
Meta, Meta,
Outlet, Outlet,
Scripts, Scripts,
ScrollRestoration ScrollRestoration,
} from '@remix-run/react' useNavigation,
} from 'react-router';
import { ErrorPopup } from '~/components/Error' import '@fontsource-variable/inter';
import { Toaster } from '~/components/Toaster' import { ErrorPopup } from '~/components/Error';
import stylesheet from '~/tailwind.css?url' import ProgressBar from '~/components/ProgressBar';
import { getContext, registerConfigWatcher } from '~/utils/config' import ToastProvider from '~/components/ToastProvider';
import stylesheet from '~/tailwind.css?url';
import { useToastQueue } from '~/utils/toast';
export const meta: MetaFunction = () => [ export const meta: MetaFunction = () => [
{ title: 'Headplane' }, { title: 'Headplane' },
{ name: 'description', content: 'A frontend for the headscale coordination server' } {
] name: 'description',
content: 'A frontend for the headscale coordination server',
},
];
export const links: LinksFunction = () => [ export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: stylesheet } { rel: 'stylesheet', href: stylesheet },
] ];
export async function loader() {
const context = await getContext()
registerConfigWatcher()
if (context.headscaleUrl.length === 0) {
throw new Error('No headscale URL was provided either by the HEADSCALE_URL environment variable or the config file')
}
if (!process.env.COOKIE_SECRET) {
throw new Error('The COOKIE_SECRET environment variable is required')
}
// eslint-disable-next-line unicorn/no-null
return null
}
export function Layout({ children }: { readonly children: React.ReactNode }) { export function Layout({ children }: { readonly children: React.ReactNode }) {
const toastQueue = useToastQueue();
return ( return (
<html lang='en'> <html lang="en">
<head> <head>
<meta charSet='utf-8'/> <meta charSet="utf-8" />
<meta name='viewport' content='width=device-width, initial-scale=1'/> <meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta/> <Meta />
<Links/> <Links />
</head> </head>
<body className='overscroll-none dark:bg-zinc-900 dark:text-white'> <body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
{children} {children}
<Toaster/> <ToastProvider queue={toastQueue} />
<ScrollRestoration/> <ScrollRestoration />
<Scripts/> <Scripts />
</body> </body>
</html> </html>
) );
} }
export function ErrorBoundary() { export function ErrorBoundary() {
return <ErrorPopup/> return <ErrorPopup />;
} }
export default function App() { export default function App() {
return <Outlet/> const nav = useNavigation();
return (
<>
<ProgressBar isVisible={nav.state === 'loading'} />
<Outlet />
</>
);
} }
+34
View File
@@ -0,0 +1,34 @@
import { index, layout, prefix, route } from '@react-router/dev/routes';
export default [
// Utility Routes
index('routes/util/redirect.ts'),
route('/healthz', 'routes/util/healthz.ts'),
// Authentication Routes
route('/login', 'routes/auth/login.tsx'),
route('/logout', 'routes/auth/logout.ts'),
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
route('/oidc/start', 'routes/auth/oidc-start.ts'),
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout('layouts/shell.tsx', [
layout('layouts/dashboard.tsx', [
...prefix('/machines', [
index('routes/machines/overview.tsx'),
route('/:id', 'routes/machines/machine.tsx'),
]),
route('/users', 'routes/users/overview.tsx'),
route('/acls', 'routes/acls/editor.tsx'),
route('/dns', 'routes/dns/overview.tsx'),
...prefix('/settings', [
index('routes/settings/overview.tsx'),
route('/auth-keys', 'routes/settings/auth-keys.tsx'),
// route('/local-agent', 'routes/settings/local-agent.tsx'),
]),
]),
]),
];
-122
View File
@@ -1,122 +0,0 @@
import { json } from '@codemirror/lang-json'
import { yaml } from '@codemirror/lang-yaml'
import { useFetcher } from '@remix-run/react'
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
import CodeMirror from '@uiw/react-codemirror'
import clsx from 'clsx'
import { useEffect, useMemo, useState } from 'react'
import CodeMirrorMerge from 'react-codemirror-merge'
import Button from '~/components/Button'
import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster'
import Fallback from './fallback'
type EditorProperties = {
readonly acl: string;
readonly setAcl: (acl: string) => void;
readonly mode: 'edit' | 'diff';
readonly data: {
hasAclWrite: boolean;
currentAcl: string;
aclType: string;
};
}
export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
const [light, setLight] = useState(false)
const [loading, setLoading] = useState(true)
const fetcher = useFetcher()
const aclType = useMemo(() => data.aclType === 'json' ? json() : yaml(), [data.aclType])
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)')
setLight(theme.matches)
theme.addEventListener('change', theme => {
setLight(theme.matches)
})
// Prevents the FOUC
setLoading(false)
}, [])
return (
<>
<div className={clsx(
'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'>
{loading ? (
<Fallback acl={acl} where='client'/>
) : (
mode === 'edit' ? (
<CodeMirror
value={acl}
theme={light ? githubLight : githubDark}
extensions={[aclType]}
readOnly={!data.hasAclWrite}
onChange={value => {
setAcl(value)
}}
/>
) : (
<CodeMirrorMerge
theme={light ? githubLight : githubDark}
orientation='a-b'
>
<CodeMirrorMerge.Original
readOnly
value={data.currentAcl}
extensions={[aclType]}
/>
<CodeMirrorMerge.Modified
readOnly
value={acl}
extensions={[aclType]}
/>
</CodeMirrorMerge>
)
)}
</div>
</div>
<Button
variant='emphasized'
className='text-sm w-fit mr-2'
onClick={() => {
fetcher.submit({
acl
}, {
method: 'PATCH',
encType: 'application/json'
})
toast('Updated tailnet ACL policy')
}}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
)}
Save
</Button>
<Button
variant='emphasized'
className={clsx(
'text-sm w-fit bg-gray-100 dark:bg-transparent',
'border border-gray-200 dark:border-gray-700'
)}
onClick={() => {
setAcl(data.currentAcl)
}}
>
Discard Changes
</Button>
</>
)
}
-51
View File
@@ -1,51 +0,0 @@
import clsx from 'clsx'
import Button from '~/components/Button'
type FallbackProperties = {
readonly acl: string;
readonly where: 'client' | 'server';
}
export default function Fallback({ acl, where }: FallbackProperties) {
return (
<>
<div className={clsx(
where === 'server' ? 'mb-2 overflow-hidden rounded-tr-lg rounded-b-lg' : '',
where === 'server' ? 'border border-gray-200 dark:border-gray-700' : ''
)}
>
<textarea
readOnly
className={clsx(
'w-full h-editor font-mono resize-none',
'text-sm text-gray-600 dark:text-gray-300',
'pl-10 pt-1 leading-snug'
)}
value={acl}
/>
</div>
{where === 'server' ? (
<>
<Button
disabled
variant='emphasized'
className='text-sm w-fit mr-2'
>
Save
</Button>
<Button
disabled
variant='emphasized'
className={clsx(
'text-sm w-fit bg-gray-100 dark:bg-transparent',
'border border-gray-200 dark:border-gray-700'
)}
>
Discard Changes
</Button>
</>
) : undefined}
</>
)
}
-165
View File
@@ -1,165 +0,0 @@
import { BeakerIcon, CubeTransparentIcon, EyeIcon, PencilSquareIcon } from '@heroicons/react/24/outline'
import { type ActionFunctionArgs, json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { useState } from 'react'
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
import { ClientOnly } from 'remix-utils/client-only'
import Notice from '~/components/Notice'
import { cn } from '~/utils/cn'
import { getAcl, getContext, patchAcl } from '~/utils/config'
import { sighupHeadscale } from '~/utils/docker'
import { getSession } from '~/utils/sessions'
import Editor from './editor'
import Fallback from './fallback'
export async function loader() {
const context = await getContext()
if (!context.hasAcl) {
throw new Error('No ACL configuration is available')
}
const { data, type } = await getAcl()
return {
hasAclWrite: context.hasAclWrite,
currentAcl: data,
aclType: type
}
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ success: false }, {
status: 401
})
}
const context = await getContext()
if (!context.hasAclWrite) {
return json({ success: false }, {
status: 403
})
}
const data = await request.json() as { acl: string }
await patchAcl(data.acl)
if (context.hasDockerSock) {
await sighupHeadscale()
}
return json({ success: true })
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const [acl, setAcl] = useState(data.currentAcl)
return (
<div>
{data.hasAclWrite ? undefined : (
<div className='mb-4'>
<Notice>
The ACL policy file is readonly to Headplane.
You will not be able to make changes here.
</Notice>
</div>
)}
<h1 className='text-2xl font-medium mb-4'>
Access Control List (ACL)
</h1>
<p className='mb-4 max-w-prose'>
The ACL file is used to define the access control rules for your network.
You can find more information about the ACL file in the Tailscale documentation.
{' '}
<a
target='_blank'
rel='noreferrer'
href='https://tailscale.com/kb/1018/acls'
className='text-blue-500 dark:text-blue-400 hover:underline'
>
More information
</a>
</p>
<Tabs>
<TabList className={cn(
'flex border-t border-gray-200 dark:border-gray-700',
'w-fit rounded-t-lg overflow-hidden',
'text-gray-400 dark:text-gray-500'
)}
>
<Tab
id='edit'
className={({ isSelected }) => cn(
'px-4 py-2 rounded-tl-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
)}
>
<PencilSquareIcon 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'>
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
{() => (
<Editor data={data} acl={acl} setAcl={setAcl} mode='edit'/>
)}
</ClientOnly>
</TabPanel>
<TabPanel id='diff'>
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
{() => (
<Editor data={data} acl={acl} setAcl={setAcl} mode='diff'/>
)}
</ClientOnly>
</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'
)}
>
<CubeTransparentIcon 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>
</div>
)
}
-221
View File
@@ -1,221 +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 { Bars3Icon, LockClosedIcon } from '@heroicons/react/24/outline'
import { type FetcherWithComponents, useFetcher } from '@remix-run/react'
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import Button from '~/components/Button'
import Input from '~/components/Input'
import Spinner from '~/components/Spinner'
import TableList from '~/components/TableList'
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>
<LockClosedIcon 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
variant='embedded'
type='text'
className='font-mono text-sm'
placeholder='Search Domain'
value={newDomain}
onChange={event => {
setNewDomain(event.target.value)
}}
/>
{fetcher.state === 'idle' ? (
<Button
className='text-sm'
disabled={newDomain.length === 0}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.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={clsx(
'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 : (
<Bars3Icon
className='h-4 w-4 text-gray-400 focus:outline-none'
{...attributes}
{...listeners}
/>
)}
{domain}
</p>
{isDrag ? undefined : (
<Button
variant='destructive'
className='text-sm'
disabled={disabled}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.domains': localDomains.filter((_, index) => index !== id - 1)
}, {
method: 'PATCH',
encType: 'application/json'
})
}}
>
Remove
</Button>
)}
</div>
)
}
-70
View File
@@ -1,70 +0,0 @@
import { useFetcher } from '@remix-run/react'
import Dialog from '~/components/Dialog'
import Spinner from '~/components/Spinner'
import { cn } from '~/utils/cn'
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}
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
{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_config.magic_dns': !isEnabled
}, {
method: 'PATCH',
encType: 'application/json'
})
close()
}}
>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
-103
View File
@@ -1,103 +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 Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import Input from '~/components/Input'
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].[user].{name}
</Code>
{' '}
when Magic DNS is enabled.
</p>
<Input
readOnly
className='font-mono text-sm my-4 w-1/2'
type='text'
value={name}
onFocus={event => {
event.target.select()
}}
/>
<Dialog>
<Dialog.Button
isDisabled={disabled}
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
{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_config.base_domain': newName
}, {
method: 'PATCH',
encType: 'application/json'
})
close()
}}
>
Rename
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
</div>
)
}
-201
View File
@@ -1,201 +0,0 @@
import { type ActionFunctionArgs } from '@remix-run/node'
import { json, useFetcher, useLoaderData } from '@remix-run/react'
import { useState } from 'react'
import Button from '~/components/Button'
import Code from '~/components/Code'
import Input from '~/components/Input'
import Notice from '~/components/Notice'
import Spinner from '~/components/Spinner'
import Switch from '~/components/Switch'
import TableList from '~/components/TableList'
import { getConfig, getContext, patchConfig } from '~/utils/config'
import { restartHeadscale } from '~/utils/docker'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import Domains from './domains'
import MagicModal from './magic'
import RenameModal from './rename'
// We do not want to expose every config value
export async function loader() {
const context = await getContext()
if (!context.hasConfig) {
throw new Error('No configuration is available')
}
const config = await getConfig()
const dns = {
prefixes: config.prefixes,
magicDns: config.dns_config.magic_dns,
baseDomain: config.dns_config.base_domain,
overrideLocal: config.dns_config.override_local_dns,
nameservers: config.dns_config.nameservers,
splitDns: config.dns_config.restricted_nameservers,
searchDomains: config.dns_config.domains,
extraRecords: config.dns_config.extra_records
}
return {
...dns,
...context
}
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ success: false }, {
status: 401
})
}
const context = await getContext()
if (!context.hasConfigWrite) {
return json({ success: false }, {
status: 403
})
}
const data = await request.json() as Record<string, unknown>
await patchConfig(data)
await restartHeadscale()
return json({ success: true })
}
export default function Page() {
useLiveData({ interval: 5000 })
const data = useLoaderData<typeof loader>()
const fetcher = useFetcher()
const [localOverride, setLocalOverride] = useState(data.overrideLocal)
const [ns, setNs] = useState('')
return (
<div className='flex flex-col gap-16 max-w-screen-lg'>
{data.hasConfigWrite ? undefined : (
<Notice>
The Headscale configuration is read-only. You cannot make changes to the configuration
</Notice>
)}
<RenameModal name={data.baseDomain} disabled={!data.hasConfigWrite}/>
<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.
</p>
<div className='mt-4'>
<div className='flex items-center justify-between mb-2'>
<h2 className='text-md font-medium opacity-80'>
Global Nameservers
</h2>
<div className='flex gap-2 items-center'>
<span className='text-sm opacity-50'>
Override local DNS
</span>
<Switch
label='Override local DNS'
defaultSelected={localOverride}
isDisabled={!data.hasConfigWrite}
onChange={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.override_local_dns': !localOverride
}, {
method: 'PATCH',
encType: 'application/json'
})
setLocalOverride(!localOverride)
}}
/>
</div>
</div>
<TableList>
{data.nameservers.map((ns, index) => (
// eslint-disable-next-line react/no-array-index-key
<TableList.Item key={index}>
<p className='font-mono text-sm'>{ns}</p>
<Button
variant='destructive'
className='text-sm'
disabled={!data.hasConfigWrite}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.nameservers': data.nameservers.filter((_, index_) => index_ !== index)
}, {
method: 'PATCH',
encType: 'application/json'
})
}}
>
Remove
</Button>
</TableList.Item>
))}
{data.hasConfigWrite ? (
<TableList.Item>
<Input
variant='embedded'
type='text'
className='font-mono text-sm'
placeholder='Nameserver'
value={ns}
onChange={event => {
setNs(event.target.value)
}}
/>
{fetcher.state === 'idle' ? (
<Button
className='text-sm'
disabled={ns.length === 0}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.nameservers': [...data.nameservers, ns]
}, {
method: 'PATCH',
encType: 'application/json'
})
setNs('')
}}
>
Add
</Button>
) : (
<Spinner className='w-3 h-3 mr-0'/>
)}
</TableList.Item>
) : undefined}
</TableList>
{/* TODO: Split DNS and Custom A Records */}
</div>
</div>
<Domains
baseDomain={data.magicDns ? data.baseDomain : undefined}
searchDomains={data.searchDomains}
disabled={!data.hasConfigWrite}
/>
<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'>
Automaticall register domain names for each device
on the tailnet. Devices will be accessible at
{' '}
<Code>
[device].[user].{data.baseDomain}
</Code>
{' '}
when Magic DNS is enabled.
</p>
<MagicModal isEnabled={data.magicDns} disabled={!data.hasConfigWrite}/>
</div>
</div>
)
}
-75
View File
@@ -1,75 +0,0 @@
import { type LoaderFunctionArgs } from '@remix-run/node'
import { Link, useLoaderData } from '@remix-run/react'
import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { type Machine } from '~/types'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
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')
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const data = await pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!)
return data.node
}
export default function Page() {
const data = useLoaderData<typeof loader>()
useLiveData({ interval: 1000 })
return (
<div>
<p className='mb-4 text-gray-500 dark:text-gray-400 text-sm'>
<Link
to='/machines'
className='font-bold text-gray-700 dark:text-gray-300 hover:underline'
>
All Machines
</Link>
{' / '}
{data.givenName}
</p>
<span className='flex items-baseline gap-x-4 text-sm mb-4'>
<h1 className='text-2xl font-bold'>
{data.givenName}
</h1>
<StatusCircle isOnline={data.online} className='w-4 h-4'/>
</span>
<Card>
<Attribute name='Creator' value={data.user.name}/>
<Attribute name='Node ID' value={data.id}/>
<Attribute name='Node Name' value={data.givenName}/>
<Attribute name='Hostname' value={data.name}/>
<Attribute
isCopyable
name='Node Key'
value={data.nodeKey}
/>
<Attribute
name='Created'
value={new Date(data.createdAt).toLocaleString()}
/>
<Attribute
name='Last Seen'
value={new Date(data.lastSeen).toLocaleString()}
/>
<Attribute
name='Expiry'
value={new Date(data.expiry).toLocaleString()}
/>
<Attribute
isCopyable
name='Domain'
value={`${data.givenName}.${data.user.name}.ts.net`}
/>
</Card>
</div>
)
}
@@ -1,57 +0,0 @@
import { type FetcherWithComponents } 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'
type DeleteProperties = {
readonly machine: Machine;
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
}
export default function Delete({ machine, fetcher, state }: DeleteProperties) {
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>
<fetcher.Form method='POST'>
<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>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -1,57 +0,0 @@
import { type FetcherWithComponents } 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'
type DeleteProperties = {
readonly machine: Machine;
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
}
export default function Expire({ machine, fetcher, state }: DeleteProperties) {
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>
<fetcher.Form method='POST'>
<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>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -1,89 +0,0 @@
import { type FetcherWithComponents } 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'
type RenameProperties = {
readonly machine: Machine;
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
readonly magic?: string;
}
export default function Rename({ machine, fetcher, state, magic }: RenameProperties) {
const [name, setName] = useState(machine.givenName)
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>
<fetcher.Form method='POST'>
<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>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -1,92 +0,0 @@
import { type FetcherWithComponents } from '@remix-run/react'
import { type Dispatch, type SetStateAction } from 'react'
import Dialog from '~/components/Dialog'
import Switch from '~/components/Switch'
import { type Machine, type Route } from '~/types'
import { cn } from '~/utils/cn'
type RoutesProperties = {
readonly machine: Machine;
readonly routes: Route[];
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
}
// TODO: Support deleting routes
export default function Routes({ machine, routes, fetcher, state }: RoutesProperties) {
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Edit route settings of {machine.givenName}
</Dialog.Title>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on
by advertising IP ranges as subnet routes.
</Dialog.Text>
<div className={cn(
'rounded-lg overflow-y-auto my-2',
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border border-zinc-200 dark:border-zinc-700'
)}
>
{routes.length === 0 ? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300'
)}
>
<p>
No routes are advertised on this machine.
</p>
</div>
) : undefined}
{routes.map(route => (
<div
key={route.node.id}
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between'
)}
>
<p>
{route.prefix}
</p>
<Switch
defaultSelected={route.enabled}
label='Enabled'
onChange={checked => {
const form = new FormData()
form.set('id', machine.id)
form.set('_method', 'routes')
form.set('route', route.id)
form.set('enabled', String(checked))
fetcher.submit(form, {
method: 'POST'
})
}}
/>
</div>
))}
</div>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant='cancel'
isDisabled={fetcher.state === 'submitting'}
onPress={close}
>
Close
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -1,204 +0,0 @@
/* eslint-disable react/hook-use-state */
import { ChevronDownIcon, ClipboardIcon, EllipsisHorizontalIcon } from '@heroicons/react/24/outline'
import { type FetcherWithComponents, Link } from '@remix-run/react'
import { useState } from 'react'
import Menu from '~/components/Menu'
import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster'
import { type Machine, type Route } from '~/types'
import { cn } from '~/utils/cn'
import Delete from './dialogs/delete'
import Expire from './dialogs/expire'
import Rename from './dialogs/rename'
import Routes from './dialogs/routes'
type MachineProperties = {
readonly machine: Machine;
readonly routes: Route[];
readonly fetcher: FetcherWithComponents<unknown>;
readonly magic?: string;
}
export default function MachineRow({ machine, routes, fetcher, magic }: MachineProperties) {
const renameState = useState(false)
const expireState = useState(false)
const removeState = useState(false)
const routesState = useState(false)
const expired = new Date(machine.expiry).getTime() < Date.now()
const tags = [
...machine.forcedTags,
...machine.validTags
]
if (expired) {
tags.unshift('Expired')
}
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-sm px-1 py-0.5',
'bg-gray-100 dark:bg-zinc-700',
'text-gray-600 dark:text-gray-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}
<ClipboardIcon 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}.${machine.user.name}.${magic}`
await navigator.clipboard.writeText(ip)
toast('Copied hostname to clipboard')
}}
>
{machine.givenName}.{machine.user.name}.{magic}
<ClipboardIcon 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'>
<Rename
machine={machine}
fetcher={fetcher}
state={renameState}
magic={magic}
/>
<Delete
machine={machine}
fetcher={fetcher}
state={removeState}
/>
{expired ? undefined : (
<Expire
machine={machine}
fetcher={fetcher}
state={expireState}
/>
)}
<Routes
machine={machine}
routes={routes}
fetcher={fetcher}
state={routesState}
/>
<Menu>
<Menu.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'
)}
>
<EllipsisHorizontalIcon className='w-5'/>
</Menu.Button>
<Menu.Items>
<Menu.ItemButton control={renameState}>
Edit machine name
</Menu.ItemButton>
<Menu.ItemButton control={routesState}>
Edit route settings
</Menu.ItemButton>
<Menu.Item className='opacity-50 hover:bg-transparent'>
Edit ACL tags
</Menu.Item>
{expired ? undefined : (
<Menu.ItemButton control={expireState}>
Expire
</Menu.ItemButton>
)}
<Menu.ItemButton
className='text-red-500 dark:text-red-400'
control={removeState}
>
Remove
</Menu.ItemButton>
</Menu.Items>
</Menu>
</td>
</tr>
)
}
-165
View File
@@ -1,165 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { InformationCircleIcon } from '@heroicons/react/24/outline'
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
import { useFetcher, useLoaderData } from '@remix-run/react'
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
import Code from '~/components/Code'
import { type Machine, type Route } from '~/types'
import { cn } from '~/utils/cn'
import { getConfig, getContext } from '~/utils/config'
import { del, post, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import MachineRow from './machine'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const machines = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
const routes = await pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!)
const context = await getContext()
let magic: string | undefined
if (context.hasConfig) {
const config = await getConfig()
if (config.dns_config.magic_dns) {
magic = config.dns_config.base_domain
}
}
return {
nodes: machines.nodes,
routes: routes.routes,
magic
}
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401
})
}
const data = await request.formData()
if (!data.has('_method') || !data.has('id')) {
return json({ message: 'No method or ID provided' }, {
status: 400
})
}
const id = String(data.get('id'))
const method = String(data.get('_method'))
switch (method) {
case 'delete': {
await del(`v1/node/${id}`, session.get('hsApiKey')!)
return json({ message: 'Machine removed' })
}
case 'expire': {
console.log('expire')
const data = await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
console.log(data)
return json({ message: 'Machine expired' })
}
case 'rename': {
if (!data.has('name')) {
return json({ message: 'No name provided' }, {
status: 400
})
}
const name = String(data.get('name'))
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
return json({ message: 'Machine renamed' })
}
case 'routes': {
if (!data.has('route') || !data.has('enabled')) {
return json({ message: 'No route or enabled provided' }, {
status: 400
})
}
const route = String(data.get('route'))
const enabled = data.get('enabled') === 'true'
const postfix = enabled ? 'enable' : 'disable'
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
return json({ message: 'Route updated' })
}
default: {
return json({ message: 'Invalid method' }, {
status: 400
})
}
}
}
export default function Page() {
useLiveData({ interval: 3000 })
const data = useLoaderData<typeof loader>()
const fetcher = useFetcher()
return (
<>
<h1 className='text-2xl font-medium mb-4'>Machines</h1>
<table className='table-auto w-full rounded-lg'>
<thead className='text-gray-500 dark:text-gray-400'>
<tr className='text-left uppercase text-xs font-bold px-0.5'>
<th className='pb-2'>Name</th>
<th className='pb-2'>
<div className='flex items-center gap-x-1'>
Addresses
{data.magic ? (
<TooltipTrigger delay={0}>
<Button>
<InformationCircleIcon className='w-4 h-4 text-gray-400'/>
</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].[user].{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}
// Typescript isn't smart enough yet
machine={machine as unknown as Machine}
routes={data.routes.filter(route => route.node.id === machine.id) as unknown as Route[]}
fetcher={fetcher}
magic={data.magic}
/>
))}
</tbody>
</table>
</>
)
}
-13
View File
@@ -1,13 +0,0 @@
import { CubeTransparentIcon } from '@heroicons/react/24/outline'
export default function Page() {
return (
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center'>
<CubeTransparentIcon className='w-32 h-32 text-gray-500'/>
<p className='text-lg mt-8'>
The settings page is currently unavailable.
It will be available in a future release.
</p>
</div>
)
}
-128
View File
@@ -1,128 +0,0 @@
import { Cog8ToothIcon, CpuChipIcon, GlobeAltIcon, LockClosedIcon, ServerStackIcon, UserCircleIcon, UsersIcon } from '@heroicons/react/24/outline'
import { type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Form, Outlet, useLoaderData } from '@remix-run/react'
import { ErrorPopup } from '~/components/Error'
import Menu from '~/components/Menu'
import TabLink from '~/components/TabLink'
import { getContext } from '~/utils/config'
import { HeadscaleError, pull } from '~/utils/headscale'
import { destroySession, getSession } from '~/utils/sessions'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return redirect('/login')
}
try {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await pull('v1/apikey', session.get('hsApiKey')!)
} catch (error) {
if (error instanceof HeadscaleError) {
console.error(error)
// Safest to just redirect to login if we can't pull
return redirect('/login', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await destroySession(session)
}
})
}
// Otherwise propagate to boundary
throw error
}
const context = await getContext()
return {
...context,
user: session.get('user')
}
}
export default function Layout() {
const data = useLoaderData<typeof loader>()
return (
<>
<header className='mb-6 bg-main-700 dark:bg-main-800 text-white'>
<nav className='container mx-auto'>
<div className='flex items-center justify-between mb-8 pt-4'>
<div className='flex items-center gap-x-2'>
<CpuChipIcon className='w-8 h-8'/>
<h1 className='text-2xl'>Headplane</h1>
</div>
<div className='flex items-center gap-x-4'>
<a href='https://tailscale.com/download' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
Download
</a>
<a href='https://github.com/tale/headplane' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
GitHub
</a>
<a href='https://github.com/juanfont/headscale' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
Headscale
</a>
<Menu>
<Menu.Button>
<UserCircleIcon className='w-8 h-8'/>
</Menu.Button>
<Menu.Items>
<Menu.Item className='text-right'>
<p className='font-bold'>{data.user?.name}</p>
<p>{data.user?.email}</p>
</Menu.Item>
<Menu.Item className='text-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>
</div>
</div>
<div className='flex items-center gap-x-4'>
<TabLink to='/machines' name='Machines' icon={<ServerStackIcon className='w-5 h-5'/>}/>
<TabLink to='/users' name='Users' icon={<UsersIcon className='w-5 h-5'/>}/>
{data.hasAcl ? <TabLink to='/acls' name='Access Control' icon={<LockClosedIcon className='w-5 h-5'/>}/> : undefined}
{data.hasConfig ? (
<>
<TabLink to='/dns' name='DNS' icon={<GlobeAltIcon className='w-5 h-5'/>}/>
<TabLink to='/settings' name='Settings' icon={<Cog8ToothIcon className='w-5 h-5'/>}/>
</>
) : undefined}
</div>
</nav>
</header>
<main className='container mx-auto overscroll-contain mb-24'>
<Outlet/>
</main>
</>
)
}
export function ErrorBoundary() {
return (
<>
<header className='mb-16 bg-main-700 dark:bg-main-800 text-white'>
<nav className='container mx-auto'>
<div className='flex items-center gap-x-2 mb-8 pt-4'>
<CpuChipIcon className='w-8 h-8'/>
<h1 className='text-2xl'>Headplane</h1>
</div>
<div className='flex items-center gap-x-4'>
<TabLink to='/machines' name='Machines' icon={<ServerStackIcon className='w-5 h-5'/>}/>
<TabLink to='/users' name='Users' icon={<UsersIcon className='w-5 h-5'/>}/>
<TabLink to='/acls' name='Access Control' icon={<LockClosedIcon className='w-5 h-5'/>}/>
<TabLink to='/dns' name='DNS' icon={<GlobeAltIcon className='w-5 h-5'/>}/>
<TabLink to='/settings' name='Settings' icon={<Cog8ToothIcon className='w-5 h-5'/>}/>
</div>
</nav>
</header>
<ErrorPopup type='embedded'/>
</>
)
}
-67
View File
@@ -1,67 +0,0 @@
/* eslint-disable unicorn/filename-case */
import { ClipboardIcon, UserIcon } from '@heroicons/react/24/outline'
import { type LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { toast } from 'react-hot-toast/headless'
import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { type Machine, type User } from '~/types'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
const users = new Map<string, Machine[]>()
for (const machine of data.nodes) {
const { user } = machine
if (!users.has(user.id)) {
users.set(user.id, [])
}
users.get(user.id)?.push(machine)
}
return [...users.values()].map(machines => {
const { user } = machines[0]
return {
...user,
machines
}
})
}
export default function Page() {
const data = useLoaderData<typeof loader>()
useLiveData({ interval: 3000 })
return (
<div className='grid grid-cols-2 gap-4 auto-rows-min'>
{data.map(user => (
<Card key={user.id}>
<div className='flex items-center gap-4'>
<UserIcon className='w-6 h-6'/>
<span className='text-lg font-mono'>
{user.name}
</span>
</div>
<div className='py-4'>
{user.machines.map(machine => (
<div key={machine.id} className='flex items-center w-full gap-4'>
<StatusCircle isOnline={machine.online} className='w-4 h-4 px-1 w-fit'/>
<Attribute name={`Node ${machine.id}`} value={machine.givenName}/>
</div>
))}
</div>
</Card>
))}
</div>
)
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from '@remix-run/node'
export function loader() {
return redirect('/machines')
}
+109
View File
@@ -0,0 +1,109 @@
import * as shopify from '@shopify/lang-jsonc';
import { xcodeDark, xcodeLight } from '@uiw/codemirror-theme-xcode';
import CodeMirror from '@uiw/react-codemirror';
import { BookCopy, CircleX } from 'lucide-react';
import { useEffect, useState } from 'react';
import Merge from 'react-codemirror-merge';
import { ErrorBoundary } from 'react-error-boundary';
import { ClientOnly } from 'remix-utils/client-only';
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="overflow-y-scroll h-editor text-sm">
<ErrorBoundary
fallback={
<div className="flex flex-col items-center gap-2.5 py-8">
<CircleX />
<p className="text-lg font-semibold">Failed to load the editor.</p>
</div>
}
>
<ClientOnly fallback={<Fallback acl={props.value} />}>
{() => (
<CodeMirror
value={props.value}
height="100%"
extensions={[shopify.jsonc()]}
style={{ height: '100%' }}
theme={light ? xcodeLight : xcodeDark}
onChange={(value) => props.onChange(value)}
/>
)}
</ClientOnly>
</ErrorBoundary>
</div>
);
}
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="text-sm">
{props.left === props.right ? (
<div className="flex flex-col items-center gap-2.5 py-8">
<BookCopy />
<p className="text-lg font-semibold">No changes</p>
</div>
) : (
<div className="h-editor overflow-y-scroll">
<ErrorBoundary
fallback={
<div className="flex flex-col items-center gap-2.5 py-8">
<CircleX />
<p className="text-lg font-semibold">
Failed to load the editor.
</p>
</div>
}
>
<ClientOnly fallback={<Fallback acl={props.right} />}>
{() => (
<Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}>
<Merge.Original
readOnly
value={props.left}
extensions={[shopify.jsonc()]}
/>
<Merge.Modified
readOnly
value={props.right}
extensions={[shopify.jsonc()]}
/>
</Merge>
)}
</ClientOnly>
</ErrorBoundary>
</div>
)}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { AlertIcon } from '@primer/octicons-react';
import cn from '~/utils/cn';
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>
);
}
+37
View File
@@ -0,0 +1,37 @@
import Spinner from '~/components/Spinner';
import cn from '~/utils/cn';
interface Props {
readonly acl: string;
}
export default function Fallback({ acl }: Props) {
return (
<div className="relative w-full h-editor flex">
<div
className={cn(
'h-full w-8 flex justify-center p-1',
'border-r border-headscale-400 dark:border-headscale-800',
)}
>
<div
aria-hidden
className={cn(
'h-5 w-5 animate-spin rounded-full',
'border-headplane-900 dark:border-headplane-100',
'border-2 border-t-transparent dark:border-t-transparent',
)}
/>
</div>
<textarea
readOnly
className={cn(
'w-full h-editor font-mono resize-none text-sm',
'bg-headplane-50 dark:bg-headplane-950 opacity-60',
'pl-1 pt-1 leading-snug',
)}
value={acl}
/>
</div>
);
}
@@ -0,0 +1,39 @@
import { AlertIcon } from '@primer/octicons-react';
import cn from '~/utils/cn';
import Card from '~/components/Card';
import Code from '~/components/Code';
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>
);
}
+300
View File
@@ -0,0 +1,300 @@
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useFetcher, useLoaderData, useRevalidator } from 'react-router';
import Button from '~/components/Button';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Spinner from '~/components/Spinner';
import Tabs from '~/components/Tabs';
import { hs_getConfig } from '~/utils/config/loader';
import { HeadscaleError, pull, put } from '~/utils/headscale';
import log from '~/utils/log';
import { send } from '~/utils/res';
import { getSession } from '~/utils/sessions.server';
import toast from '~/utils/toast';
import type { AppContext } from '~server/context/app';
import { Differ, Editor } from './components/cm.client';
import { ErrorView } from './components/error';
import { Unavailable } from './components/unavailable';
export async function loader({ request }: LoaderFunctionArgs<AppContext>) {
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 { mode, config } = hs_getConfig();
let modeGuess = 'database'; // Assume database mode
if (mode !== 'no') {
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: TODO: Shut UP we know it's a string most of the time
const text = JSON.parse(error.message);
return send(
{ success: false, error: text.message },
{
status: error instanceof HeadscaleError ? error.status : 500,
},
);
}
}
export default function Page() {
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher<typeof action>();
const revalidator = useRevalidator();
const [acl, setAcl] = useState(data.policy ?? '');
const [toasted, setToasted] = useState(false);
useEffect(() => {
if (!fetcher.data || toasted) {
return;
}
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
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>
The ACL policy is read-only. You can view the current policy but you
cannot make changes to it.
<br />
To resolve this, you need to set the ACL policy mode to database in
your Headscale configuration.
</Notice>
</div>
) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
{fetcher.data?.success === false ? (
<ErrorView message={fetcher.data.error} />
) : undefined}
{data.read ? (
<>
<Tabs label="ACL Editor" className="mb-4">
<Tabs.Item
key="edit"
title={
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
}
>
<Editor isDisabled={!data.write} value={acl} onChange={setAcl} />
</Tabs.Item>
<Tabs.Item
key="diff"
title={
<div className="flex items-center gap-2">
<Eye className="p-1" />
<span>Preview changes</span>
</div>
}
>
<Differ left={data?.policy ?? ''} right={acl} />
</Tabs.Item>
<Tabs.Item
key="preview"
title={
<div className="flex items-center gap-2">
<FlaskConical className="p-1" />
<span>Preview rules</span>
</div>
}
>
<div className="flex flex-col items-center py-8">
<Construction />
<p className="w-1/2 text-center mt-4">
Previewing rules is not available yet. This feature is still
in development and is pretty complicated to implement.
Hopefully I will be able to get to it soon.
</p>
</div>
</Tabs.Item>
</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>
);
}
+157
View File
@@ -0,0 +1,157 @@
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
redirect,
} from 'react-router';
import { Form, useActionData, useLoaderData } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
import type { Key } from '~/types';
import { pull } from '~/utils/headscale';
import { noContext } from '~/utils/log';
import { commitSession, getSession } from '~/utils/sessions.server';
import type { AppContext } from '~server/context/app';
export async function loader({
request,
context,
}: LoaderFunctionArgs<AppContext>) {
const session = await getSession(request.headers.get('Cookie'));
if (session.has('hsApiKey')) {
return redirect('/machines', {
headers: {
'Set-Cookie': await commitSession(session),
},
});
}
if (!context) {
throw noContext();
}
// Only set if OIDC is properly enabled anyways
const ctx = context.context;
if (ctx.oidc?.disable_api_key_login) {
return redirect('/oidc/start');
}
return {
oidc: ctx.oidc?.issuer,
apiKey: !ctx.oidc?.disable_api_key_login,
};
}
export async function action({
request,
context,
}: ActionFunctionArgs<AppContext>) {
const formData = await request.formData();
const oidcStart = formData.get('oidc-start');
const session = await getSession(request.headers.get('Cookie'));
if (oidcStart) {
if (!context) {
throw noContext();
}
const ctx = context.context;
if (!ctx.oidc) {
throw new Error('An invalid OIDC configuration was provided');
}
return redirect('/oidc/start');
}
const apiKey = String(formData.get('api-key'));
// Test the API key
try {
const apiKeys = await pull<{ apiKeys: Key[] }>('v1/apikey', apiKey);
const key = apiKeys.apiKeys.find((k) => apiKey.startsWith(k.prefix));
if (!key) {
throw new Error('Invalid API key');
}
const expiry = new Date(key.expiration);
const expiresIn = expiry.getTime() - Date.now();
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24);
session.set('hsApiKey', apiKey);
session.set('user', {
subject: 'unknown-non-oauth',
name: key.prefix,
email: `${expiresDays.toString()} days`,
});
return redirect('/machines', {
headers: {
'Set-Cookie': await commitSession(session, {
maxAge: expiresIn,
}),
},
});
} catch {
return {
error: 'Invalid API key',
};
}
}
export default function Page() {
const data = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>Welcome to Headplane</Card.Title>
{data.apiKey ? (
<Form method="post">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate
one by running <Code>headscale apikeys create</Code> in your
terminal.
</Card.Text>
{actionData?.error ? (
<p className="text-red-500 text-sm mb-2">{actionData.error}</p>
) : undefined}
<Input
isRequired
labelHidden
label="API Key"
name="api-key"
placeholder="API Key"
type="password"
className="mt-4 mb-2"
/>
<Button className="w-full" variant="heavy" type="submit">
Sign In
</Button>
</Form>
) : undefined}
{data.oidc ? (
<Form method="POST">
{!data.apiKey ? (
<Card.Text className="mb-6">
Sign in with your authentication provider to continue. Your
administrator has disabled API key login.
</Card.Text>
) : undefined}
<input type="hidden" name="oidc-start" value="true" />
<Button
className="w-full mt-2"
variant={data.apiKey ? 'light' : 'heavy'}
type="submit"
>
Single Sign-On
</Button>
</Form>
) : undefined}
</Card>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { type ActionFunctionArgs, redirect } from 'react-router';
import { destroySession, getSession } from '~/utils/sessions.server';
export async function loader() {
return redirect('/machines');
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
return redirect('/login', {
headers: {
'Set-Cookie': await destroySession(session),
},
});
}
+78
View File
@@ -0,0 +1,78 @@
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { noContext } from '~/utils/log';
import { finishAuthFlow, formatError } from '~/utils/oidc';
import { send } from '~/utils/res';
import { commitSession, getSession } from '~/utils/sessions.server';
import type { AppContext } from '~server/context/app';
export async function loader({
request,
context,
}: LoaderFunctionArgs<AppContext>) {
// Check if we have 0 query parameters
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect('/machines');
}
const session = await getSession(request.headers.get('Cookie'));
if (session.has('hsApiKey')) {
return redirect('/machines');
}
if (!context) {
throw noContext();
}
const { oidc } = context.context;
if (!oidc) {
throw new Error('An invalid OIDC configuration was provided');
}
const codeVerifier = session.get('oidc_code_verif');
const state = session.get('oidc_state');
const nonce = session.get('oidc_nonce');
const redirectUri = session.get('oidc_redirect_uri');
if (!codeVerifier || !state || !nonce || !redirectUri) {
return send({ error: 'Missing OIDC state' }, { status: 400 });
}
// Reconstruct the redirect URI using the query parameters
// and the one we saved in the session
const flowRedirectUri = new URL(redirectUri);
flowRedirectUri.search = url.search;
const flowOptions = {
redirect_uri: flowRedirectUri.toString(),
codeVerifier,
state,
nonce: nonce === '<none>' ? undefined : nonce,
};
try {
const user = await finishAuthFlow(oidc, flowOptions);
session.set('user', user);
session.unset('oidc_code_verif');
session.unset('oidc_state');
session.unset('oidc_nonce');
// TODO: This is breaking, to stop the "over-generation" of API
// keys because they are currently non-deletable in the headscale
// database. Look at this in the future once we have a solution
// or we have permissioned API keys.
session.set('hsApiKey', oidc.headscale_api_key);
return redirect('/machines', {
headers: {
'Set-Cookie': await commitSession(session),
},
});
} catch (error) {
return new Response(JSON.stringify(formatError(error)), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
}
+38
View File
@@ -0,0 +1,38 @@
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { noContext } from '~/utils/log';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
import { commitSession, getSession } from '~/utils/sessions.server';
import type { AppContext } from '~server/context/app';
export async function loader({
request,
context,
}: LoaderFunctionArgs<AppContext>) {
const session = await getSession(request.headers.get('Cookie'));
if (session.has('hsApiKey')) {
return redirect('/machines');
}
if (!context) {
throw noContext();
}
const { oidc } = context.context;
if (!oidc) {
throw new Error('An invalid OIDC configuration was provided');
}
const redirectUri = oidc.redirect_uri ?? getRedirectUri(request);
const data = await beginAuthFlow(oidc, redirectUri);
session.set('oidc_code_verif', data.codeVerifier);
session.set('oidc_state', data.state);
session.set('oidc_nonce', data.nonce);
session.set('oidc_redirect_uri', redirectUri);
return redirect(data.url, {
status: 302,
headers: {
'Set-Cookie': await commitSession(session),
},
});
}
@@ -0,0 +1,205 @@
import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Lock } from 'lucide-react';
import { useEffect, useState } from 'react';
import { type FetcherWithComponents, Form, useFetcher } from 'react-router';
import Button from '~/components/Button';
import Input from '~/components/Input';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
interface Props {
searchDomains: string[];
isDisabled: boolean;
magic?: string;
}
export default function ManageDomains({
searchDomains,
isDisabled,
magic,
}: Props) {
const [activeId, setActiveId] = useState<number | string | null>(null);
const [localDomains, setLocalDomains] = useState(searchDomains);
useEffect(() => {
setLocalDomains(searchDomains);
}, [searchDomains]);
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
<p className="mb-4">
Set custom DNS search domains for your Tailnet. When using Magic DNS,
your tailnet domain is used as the first search domain.
</p>
<DndContext
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
collisionDetection={closestCorners}
onDragStart={(event) => {
setActiveId(event.active.id);
}}
onDragEnd={(event) => {
setActiveId(null);
const { active, over } = event;
if (!over) {
return;
}
const activeItem = localDomains[(active.id as number) - 1];
const overItem = localDomains[(over.id as number) - 1];
if (!activeItem || !overItem) {
return;
}
const oldIndex = localDomains.indexOf(activeItem);
const newIndex = localDomains.indexOf(overItem);
if (oldIndex !== newIndex) {
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
}
}}
>
<TableList>
{magic ? (
<TableList.Item key="magic-dns-sd">
<div
className={cn(
'flex items-center gap-4',
isDisabled ? 'flex-row-reverse justify-between w-full' : '',
)}
>
<Lock className="p-0.5" />
<p className="font-mono text-sm py-0.5">{magic}</p>
</div>
</TableList.Item>
) : undefined}
<SortableContext
items={localDomains}
strategy={verticalListSortingStrategy}
>
{localDomains.map((sd, index) => (
<Domain
key={sd}
domain={sd}
id={index + 1}
isDisabled={isDisabled}
/>
))}
<DragOverlay adjustScale>
{activeId ? (
<Domain
isDragging
domain={localDomains[(activeId as number) - 1]}
id={(activeId as number) - 1}
isDisabled={isDisabled}
/>
) : undefined}
</DragOverlay>
</SortableContext>
{isDisabled ? undefined : (
<TableList.Item key="add-sd">
<Form
method="POST"
className="flex items-center justify-between w-full"
>
<input type="hidden" name="action_id" value="add_domain" />
<Input
type="text"
className={cn(
'border-none font-mono p-0 text-sm',
'rounded-none focus:ring-0 w-full ml-1',
)}
placeholder="Search Domain"
label="Search Domain"
name="domain"
labelHidden
isRequired
/>
<Button
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-blue-500 dark:text-blue-400',
)}
>
Add
</Button>
</Form>
</TableList.Item>
)}
</TableList>
</DndContext>
</div>
);
}
interface DomainProps {
domain: string;
id: number;
isDragging?: boolean;
isDisabled: boolean;
}
function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging: isSortableDragging,
} = useSortable({ id });
return (
<TableList.Item
ref={setNodeRef}
className={cn(
isSortableDragging ? 'opacity-50' : '',
isDragging ? 'ring bg-white dark:bg-headplane-900' : '',
)}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
>
<p className="font-mono text-sm flex items-center gap-4">
{isDisabled ? undefined : (
<GripVertical
{...attributes}
{...listeners}
className="p-0.5 focus:ring outline-none rounded-md"
/>
)}
{domain}
</p>
{isDragging ? undefined : (
<Form method="POST">
<input type="hidden" name="action_id" value="remove_domain" />
<input type="hidden" name="domain" value={domain} />
<Button
type="submit"
isDisabled={isDisabled}
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
>
Remove
</Button>
</Form>
)}
</TableList.Item>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { Form } from 'react-router';
import Button from '~/components/Button';
import Link from '~/components/Link';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
import AddNS from '../dialogs/add-ns';
interface Props {
nameservers: Record<string, string[]>;
isDisabled: boolean;
}
export default function ManageNS({ nameservers, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p>
Set the nameservers used by devices on the Tailnet to resolve DNS
queries.{' '}
<Link
to="https://tailscale.com/kb/1054/dns"
name="Tailscale DNS Documentation"
>
Learn more
</Link>
</p>
<div className="mt-4">
{Object.keys(nameservers).map((key) => (
<NameserverList
key={key}
isGlobal={key === 'global'}
isDisabled={isDisabled}
nameservers={nameservers}
name={key}
/>
))}
{isDisabled ? undefined : <AddNS nameservers={nameservers} />}
</div>
</div>
);
}
interface ListProps {
isGlobal: boolean;
isDisabled: boolean;
nameservers: Record<string, string[]>;
name: string;
}
function NameserverList({
isGlobal,
isDisabled,
nameservers,
name,
}: ListProps) {
const list = isGlobal ? nameservers.global : nameservers[name];
if (list.length === 0) {
return null;
}
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h2 className="text-md font-medium opacity-80">
{isGlobal ? 'Global Nameservers' : name}
</h2>
</div>
<TableList>
{list.length > 0
? list.map((ns) => (
<TableList.Item key={ns}>
<p className="font-mono text-sm">{ns}</p>
<Form method="POST">
<input type="hidden" name="action_id" value="remove_ns" />
<input type="hidden" name="ns" value={ns} />
<input
type="hidden"
name="split_name"
value={isGlobal ? 'global' : name}
/>
<Button
isDisabled={isDisabled}
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
>
Remove
</Button>
</Form>
</TableList.Item>
))
: undefined}
</TableList>
</div>
);
}
@@ -0,0 +1,75 @@
import { Form } from 'react-router';
import Button from '~/components/Button';
import Code from '~/components/Code';
import Link from '~/components/Link';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
import AddRecord from '../dialogs/add-record';
interface Props {
records: { name: string; type: 'A' | string; value: string }[];
isDisabled: boolean;
}
export default function ManageRecords({ records, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
<p>
Headscale supports adding custom DNS records to your Tailnet. As of now,
only <Code>A</Code> records are supported.{' '}
<Link
to="https://headscale.net/stable/ref/dns"
name="Headscale DNS Records documentation"
>
Learn More
</Link>
</p>
<div className="mt-4">
<TableList className="mb-8">
{records.length === 0 ? (
<TableList.Item>
<p className="opacity-50 mx-auto">No DNS records found</p>
</TableList.Item>
) : (
records.map((record, index) => (
<TableList.Item key={`${record.name}-${record.value}`}>
<div className="flex gap-24 items-center">
<div className="flex gap-4 items-center">
<p
className={cn(
'font-mono text-sm font-bold py-1 px-2 rounded-md',
'bg-headplane-100 dark:bg-headplane-700/30',
)}
>
{record.type}
</p>
<p className="font-mono text-sm">{record.name}</p>
</div>
<p className="font-mono text-sm">{record.value}</p>
</div>
<Form method="POST">
<input type="hidden" name="action_id" value="remove_record" />
<input type="hidden" name="record_name" value={record.name} />
<input type="hidden" name="record_type" value={record.type} />
<Button
type="submit"
isDisabled={isDisabled}
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
>
Remove
</Button>
</Form>
</TableList.Item>
))
)}
</TableList>
{isDisabled ? undefined : <AddRecord records={records} />}
</div>
</div>
);
}
@@ -0,0 +1,48 @@
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
interface Props {
name: string;
isDisabled: boolean;
}
export default function RenameTailnet({ name, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3 gap-y-4">
<h1 className="text-2xl font-medium mb-2">Tailnet Name</h1>
<p>
This is the base domain name of your Tailnet. Devices are accessible at{' '}
<Code>[device].{name}</Code> when Magic DNS is enabled.
</p>
<Input
isReadOnly
labelHidden
className="w-3/5 font-medium text-sm"
label="Tailnet name"
value={name}
onFocus={(event) => {
event.target.select();
}}
/>
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Rename Tailnet</Dialog.Button>
<Dialog.Panel isDisabled={isDisabled}>
<Dialog.Title>Rename Tailnet</Dialog.Title>
<Dialog.Text className="mb-8">
Keep in mind that changing this can lead to all sorts of unexpected
behavior and may break existing devices in your tailnet.
</Dialog.Text>
<input type="hidden" name="action_id" value="rename_tailnet" />
<Input
isRequired
label="Tailnet name"
placeholder="ts.net"
defaultValue={name}
name="new_name"
/>
</Dialog.Panel>
</Dialog>
</div>
);
}
@@ -0,0 +1,31 @@
import Dialog from '~/components/Dialog';
interface Props {
isEnabled: boolean;
isDisabled: boolean;
}
export default function Modal({ isEnabled, isDisabled }: Props) {
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Button>
<Dialog.Panel isDisabled={isDisabled}>
<Dialog.Title>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Title>
<Dialog.Text>
Devices will no longer be accessible via your tailnet domain. The
search domain will also be disabled.
</Dialog.Text>
<input type="hidden" name="action_id" value="toggle_magic" />
<input
type="hidden"
name="new_state"
value={isEnabled ? 'disabled' : 'enabled'}
/>
</Dialog.Panel>
</Dialog>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { RepoForkedIcon } from '@primer/octicons-react';
import { useMemo, useState } from 'react';
import Chip from '~/components/Chip';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Switch from '~/components/Switch';
import Tooltip from '~/components/Tooltip';
import cn from '~/utils/cn';
interface Props {
nameservers: Record<string, string[]>;
}
export default function AddNameserver({ nameservers }: Props) {
const [split, setSplit] = useState(false);
const [ns, setNs] = useState('');
const [domain, setDomain] = useState('');
const isInvalid = useMemo(() => {
if (ns === '') return false;
// Test if it's a valid IPv4 or IPv6 address
const ipv4 = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
const ipv6 = /^([0-9a-fA-F:]+:+)+[0-9a-fA-F]+$/;
if (!ipv4.test(ns) && !ipv6.test(ns)) return true;
if (split) {
return nameservers[domain]?.includes(ns);
}
return Object.values(nameservers).some((nsList) => nsList.includes(ns));
}, [nameservers, ns]);
return (
<Dialog>
<Dialog.Button>Add nameserver</Dialog.Button>
<Dialog.Panel>
<Dialog.Title className="mb-4">Add nameserver</Dialog.Title>
<input type="hidden" name="action_id" value="add_ns" />
<Input
isRequired
label="Nameserver"
description="Use this IPv4 or IPv6 address to resolve names."
placeholder="1.2.3.4"
name="ns"
onChange={setNs}
isInvalid={isInvalid}
/>
<div className="flex items-center justify-between mt-8">
<div className="block">
<div className="inline-flex items-center gap-2">
<Dialog.Text className="font-semibold">
Restrict to domain
</Dialog.Text>
<Tooltip>
<Chip
text="Split DNS"
leftIcon={<RepoForkedIcon className="w-4 h-4 mr-0.5" />}
className={cn('inline-flex items-center')}
/>
<Tooltip.Body>
Only clients that support split DNS (Tailscale v1.8 or later
for most platforms) will use this nameserver. Older clients
will ignore it.
</Tooltip.Body>
</Tooltip>
</div>
<Dialog.Text className="text-sm">
This nameserver will only be used for some domains.
</Dialog.Text>
</div>
<Switch label="Split DNS" onChange={setSplit} />
</div>
{split ? (
<>
<Dialog.Text className="font-semibold mt-8">Domain</Dialog.Text>
<Input
isRequired={split === true}
label="Domain"
placeholder="example.com"
name="split_name"
onChange={setDomain}
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries matching this suffix
should use the nameserver.
</Dialog.Text>
</>
) : (
<input type="hidden" name="split_name" value="global" />
)}
</Dialog.Panel>
</Dialog>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useMemo, useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
interface Props {
records: { name: string; type: 'A' | string; value: string }[];
}
export default function AddRecord({ records }: Props) {
const [name, setName] = useState('');
const [ip, setIp] = useState('');
const isDuplicate = useMemo(() => {
if (name.length === 0 || ip.length === 0) return false;
const lookup = records.find((record) => record.name === name);
if (!lookup) return false;
return lookup.value === ip;
}, [records, name, ip]);
return (
<Dialog>
<Dialog.Button>Add DNS record</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Add DNS record</Dialog.Title>
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<div className="flex flex-col gap-2 mt-4">
<input type="hidden" name="action_id" value="add_record" />
<input type="hidden" name="record_type" value="A" />
<Input
isRequired
label="Domain"
placeholder="test.example.com"
name="record_name"
onChange={setName}
isInvalid={isDuplicate}
/>
<Input
isRequired
label="IP Address"
placeholder="101.101.101.101"
name="record_value"
onChange={setIp}
isInvalid={isDuplicate}
/>
{isDuplicate ? (
<p className="text-sm opacity-50">
A record with the domain name <Code>{name}</Code> and IP address{' '}
<Code>{ip}</Code> already exists.
</p>
) : undefined}
</div>
</Dialog.Panel>
</Dialog>
);
}
+252
View File
@@ -0,0 +1,252 @@
import { ActionFunctionArgs, data } from 'react-router';
import { hs_getConfig, hs_patchConfig } from '~/utils/config/loader';
import { hp_getIntegration } from '~/utils/integration/loader';
import { auth } from '~/utils/sessions.server';
export async function dnsAction({ request }: ActionFunctionArgs) {
const session = await auth(request);
if (!session) {
return data({ success: false }, 401);
}
const { mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
return data({ success: false }, 400);
}
switch (action) {
case 'rename_tailnet':
return renameTailnet(formData);
case 'toggle_magic':
return toggleMagic(formData);
case 'remove_ns':
return removeNs(formData);
case 'add_ns':
return addNs(formData);
case 'remove_domain':
return removeDomain(formData);
case 'add_domain':
return addDomain(formData);
case 'remove_record':
return removeRecord(formData);
case 'add_record':
return addRecord(formData);
default:
return data({ success: false }, 400);
}
}
async function renameTailnet(formData: FormData) {
const newName = formData.get('new_name')?.toString();
if (!newName) {
return data({ success: false }, 400);
}
await hs_patchConfig([
{
path: 'dns.base_domain',
value: newName,
},
]);
await hp_getIntegration()?.onConfigChange();
}
async function toggleMagic(formData: FormData) {
const newState = formData.get('new_state')?.toString();
if (!newState) {
return data({ success: false }, 400);
}
await hs_patchConfig([
{
path: 'dns.magic_dns',
value: newState === 'enabled',
},
]);
await hp_getIntegration()?.onConfigChange();
}
async function removeNs(formData: FormData) {
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
await hs_patchConfig([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns);
await hs_patchConfig([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await hp_getIntegration()?.onConfigChange();
}
async function addNs(formData: FormData) {
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global;
servers.push(ns);
await hs_patchConfig([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName] ?? [];
servers.push(ns);
await hs_patchConfig([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await hp_getIntegration()?.onConfigChange();
}
async function removeDomain(formData: FormData) {
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
const domains = config.dns.search_domains.filter((i) => i !== domain);
await hs_patchConfig([
{
path: 'dns.search_domains',
value: domains,
},
]);
await hp_getIntegration()?.onConfigChange();
}
async function addDomain(formData: FormData) {
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
const domains = config.dns.search_domains;
domains.push(domain);
await hs_patchConfig([
{
path: 'dns.search_domains',
value: domains,
},
]);
await hp_getIntegration()?.onConfigChange();
}
async function removeRecord(formData: FormData) {
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
if (!recordName || !recordType) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
const records = config.dns.extra_records.filter(
(i) => i.name !== recordName || i.type !== recordType,
);
await hs_patchConfig([
{
path: 'dns.extra_records',
value: records,
},
]);
await hp_getIntegration()?.onConfigChange();
}
async function addRecord(formData: FormData) {
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
const recordValue = formData.get('record_value')?.toString();
if (!recordName || !recordType || !recordValue) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
const records = config.dns.extra_records;
records.push({ name: recordName, type: recordType, value: recordValue });
await hs_patchConfig([
{
path: 'dns.extra_records',
value: records,
},
]);
await hp_getIntegration()?.onConfigChange();
}
+83
View File
@@ -0,0 +1,83 @@
import type { ActionFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import Code from '~/components/Code';
import Notice from '~/components/Notice';
import { hs_getConfig } from '~/utils/config/loader';
import ManageDomains from './components/manage-domains';
import ManageNS from './components/manage-ns';
import ManageRecords from './components/manage-records';
import RenameTailnet from './components/rename-tailnet';
import ToggleMagic from './components/toggle-magic';
import { dnsAction } from './dns-actions';
// We do not want to expose every config value
export async function loader() {
const { config, mode } = hs_getConfig();
if (mode === 'no') {
throw new Error('No configuration is available');
}
const dns = {
prefixes: config.prefixes,
magicDns: config.dns.magic_dns,
baseDomain: 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,
mode,
};
}
export async function action(data: ActionFunctionArgs) {
return dnsAction(data);
}
export default function Page() {
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;
const isDisabled = data.mode !== 'rw';
return (
<div className="flex flex-col gap-16 max-w-screen-lg">
{data.mode === 'rw' ? undefined : (
<Notice>
The Headscale configuration is read-only. You cannot make changes to
the configuration
</Notice>
)}
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
<ManageNS nameservers={allNs} isDisabled={isDisabled} />
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
<ManageDomains
searchDomains={data.searchDomains}
isDisabled={isDisabled}
magic={data.magicDns ? data.baseDomain : undefined}
/>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
<p className="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>
<ToggleMagic isEnabled={data.magicDns} isDisabled={isDisabled} />
</div>
</div>
);
}
-169
View File
@@ -1,169 +0,0 @@
import { type ActionFunctionArgs, json, type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Form, useActionData, useLoaderData } from '@remix-run/react'
import { useMemo } from 'react'
import Button from '~/components/Button'
import Card from '~/components/Card'
import Code from '~/components/Code'
import Input from '~/components/Input'
import { type Key } from '~/types'
import { getContext } from '~/utils/config'
import { pull } from '~/utils/headscale'
import { startOidc } from '~/utils/oidc'
import { commitSession, getSession } from '~/utils/sessions'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (session.has('hsApiKey')) {
return redirect('/machines', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session)
}
})
}
const context = await getContext()
const issuer = context.oidcConfig?.issuer
const id = context.oidcConfig?.client
const secret = context.oidcConfig?.secret
const normal = process.env.DISABLE_API_KEY_LOGIN
if (issuer && (!id || !secret)) {
throw new Error('An invalid OIDC configuration was provided')
}
const data = {
oidc: issuer,
apiKey: normal === undefined
}
if (!data.oidc && !data.apiKey) {
throw new Error('No authentication method is enabled')
}
if (data.oidc && !data.apiKey) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return startOidc(data.oidc, id!, request)
}
return data
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const oidcStart = formData.get('oidc-start')
if (oidcStart) {
const context = await getContext()
const issuer = context.oidcConfig?.issuer
const id = context.oidcConfig?.client
// We know it exists here because this action only happens on OIDC
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return startOidc(issuer!, id!, request)
}
const apiKey = String(formData.get('api-key'))
const session = await getSession(request.headers.get('Cookie'))
// Test the API key
try {
const apiKeys = await pull<{ apiKeys: Key[] }>('v1/apikey', apiKey)
const key = apiKeys.apiKeys.find(k => apiKey.startsWith(k.prefix))
if (!key) {
throw new Error('Invalid API key')
}
const expiry = new Date(key.expiration)
const expiresIn = expiry.getTime() - Date.now()
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24)
session.set('hsApiKey', apiKey)
session.set('user', {
name: key.prefix,
email: `${expiresDays} days`
})
return redirect('/machines', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session, {
maxAge: expiresIn
})
}
})
} catch (error) {
console.error(error)
return json({
error: 'Invalid API key'
})
}
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const actionData = useActionData<typeof action>()
const showOr = useMemo(() => data.oidc && data.apiKey, [data])
return (
<div className='flex min-h-screen items-center justify-center'>
<Card className='w-96'>
<h1 className='text-2xl mb-8'>Login</h1>
{data.apiKey ? (
<Form method='post'>
<p className='text-sm text-gray-500 mb-4'>
Enter an API key to authenticate with Headplane. You can generate
one by running
{' '}
<Code>
headscale apikeys create
</Code>
{' '}
in your terminal.
</p>
{actionData?.error ? (
<p className='text-red-500 text-sm mb-2'>{actionData.error}</p>
) : undefined}
<Input
required
type='text'
name='api-key'
id='api-key'
className='border rounded-md p-2 w-full'
placeholder='API Key'
/>
<Button
variant='emphasized'
type='submit'
className='bg-gray-800 text-white rounded-md p-2 w-full mt-4'
>
Login
</Button>
</Form>
) : undefined}
{showOr ? (
<div className='flex items-center gap-x-2 py-2'>
<hr className='flex-1 dark:border-zinc-700'/>
<span className='text-gray-500'>or</span>
<hr className='flex-1 dark:border-zinc-700'/>
</div>
) : undefined}
{data.oidc ? (
<Form method='POST'>
<input type='hidden' name='oidc-start' value='true'/>
<Button
variant='emphasized'
type='submit'
className='bg-gray-800 text-white rounded-md p-2 w-full'
>
Login with SSO
</Button>
</Form>
) : undefined}
</Card>
</div>
)
}
-15
View File
@@ -1,15 +0,0 @@
import { type ActionFunctionArgs, redirect } from '@remix-run/node'
import { destroySession, getSession } from '~/utils/sessions'
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const returnTo = new URL(request.url).pathname
return redirect(`/login?returnTo=${returnTo}`, {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await destroySession(session)
}
})
}
+212
View File
@@ -0,0 +1,212 @@
import type { ActionFunctionArgs } from 'react-router';
import { del, post } from '~/utils/headscale';
import log from '~/utils/log';
import { send } from '~/utils/res';
import { getSession } from '~/utils/sessions.server';
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`, session.get('hsApiKey')!, {
user: to,
});
return { message: `Moved node ${id} to ${to}` };
} catch (error) {
console.error(error);
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,
},
);
}
}
}
+195
View File
@@ -0,0 +1,195 @@
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react';
import { useMemo } from 'react';
import { Link } from 'react-router';
import Chip from '~/components/Chip';
import Menu from '~/components/Menu';
import StatusCircle from '~/components/StatusCircle';
import type { HostInfo, Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import * as hinfo from '~/utils/host-info';
import toast from '~/utils/toast';
import MenuOptions from './menu';
interface Props {
machine: Machine;
routes: Route[];
users: User[];
magic?: string;
stats?: HostInfo;
}
export default function MachineRow({
machine,
routes,
magic,
users,
stats,
}: Props) {
const expired =
machine.expiry === '0001-01-01 00:00:00' ||
machine.expiry === '0001-01-01T00:00:00Z' ||
machine.expiry === null
? false
: new Date(machine.expiry).getTime() < Date.now();
const tags = [...new Set([...machine.forcedTags, ...machine.validTags])];
if (expired) {
tags.unshift('Expired');
}
const prefix = magic?.startsWith('[user]')
? magic.replace('[user]', machine.user.name)
: magic;
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce<{
exit: Route[];
subnetApproved: Route[];
subnet: Route[];
}>(
(acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route);
return acc;
}
if (route.enabled) {
acc.subnetApproved.push(route);
return acc;
}
acc.subnet.push(route);
return acc;
},
{ exit: [], subnetApproved: [], subnet: [] },
);
const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false;
return exit[0].enabled && exit[1].enabled;
}, [exit]);
if (exitEnabled) {
tags.unshift('Exit Node');
}
if (subnetApproved.length > 0) {
tags.unshift('Subnets');
}
const ipOptions = useMemo(() => {
if (magic) {
return [...machine.ipAddresses, `${machine.givenName}.${prefix}`];
}
return machine.ipAddresses;
}, [magic, machine.ipAddresses]);
return (
<tr
key={machine.id}
className="'group hover:bg-headplane-50 dark:hover:bg-headplane-950"
>
<td className="pl-0.5 py-2 focus-within:ring">
<Link
to={`/machines/${machine.id}`}
className={cn('group/link h-full focus:outline-none')}
>
<p
className={cn(
'font-semibold leading-snug',
'group-hover/link:text-blue-600',
'group-hover/link:dark:text-blue-400',
)}
>
{machine.givenName}
</p>
<p className="text-sm font-mono opacity-50">{machine.name}</p>
<div className="flex gap-1 mt-1">
{tags.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
</Link>
</td>
<td className="py-2">
<div className="flex items-center gap-x-1">
{machine.ipAddresses[0]}
<Menu placement="bottom end">
<Menu.IconButton className="bg-transparent" label="IP Addresses">
<ChevronDownIcon className="w-4 h-4" />
</Menu.IconButton>
<Menu.Panel
onAction={async (key) => {
await navigator.clipboard.writeText(key.toString());
toast('Copied IP address to clipboard');
}}
>
<Menu.Section>
{ipOptions.map((ip) => (
<Menu.Item key={ip} textValue={ip}>
<div
className={cn(
'flex items-center justify-between',
'text-sm w-full gap-x-6',
)}
>
{ip}
<CopyIcon className="w-3 h-3" />
</div>
</Menu.Item>
))}
</Menu.Section>
</Menu.Panel>
</Menu>
</div>
</td>
{/**
<td className="py-2">
{stats !== undefined ? (
<>
<p className="leading-snug">
{hinfo.getTSVersion(stats)}
</p>
<p className="text-sm opacity-50 max-w-48 truncate">
{hinfo.getOSInfo(stats)}
</p>
</>
) : (
<p className="text-sm opacity-50">
Unknown
</p>
)}
</td>
**/}
<td className="py-2">
<span
className={cn(
'flex items-center gap-x-1 text-sm',
'text-headplane-600 dark:text-headplane-300',
)}
>
<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>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { Cog, Ellipsis } from 'lucide-react';
import { useState } from 'react';
import Menu from '~/components/Menu';
import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import 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;
isFullButton?: boolean;
}
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
export default function MachineMenu({
machine,
routes,
magic,
users,
isFullButton,
}: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
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 (
<>
{modal === 'remove' && (
<Delete
machine={machine}
isOpen={modal === 'remove'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'move' && (
<Move
machine={machine}
users={users}
isOpen={modal === 'move'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'rename' && (
<Rename
machine={machine}
magic={magic}
isOpen={modal === 'rename'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'routes' && (
<Routes
machine={machine}
routes={routes}
isOpen={modal === 'routes'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'tags' && (
<Tags
machine={machine}
isOpen={modal === 'tags'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{expired && modal === 'expire' ? undefined : (
<Expire
machine={machine}
isOpen={modal === 'expire'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
<Menu>
{isFullButton ? (
<Menu.Button className="flex items-center gap-x-2">
<Cog className="h-5" />
<p>Machine Settings</p>
</Menu.Button>
) : (
<Menu.IconButton
label="Machine Options"
className={cn(
'py-0.5 w-10 bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
)}
>
<Ellipsis className="h-5" />
</Menu.IconButton>
)}
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
<Menu.Section>
<Menu.Item key="rename">Edit machine name</Menu.Item>
<Menu.Item key="routes">Edit route settings</Menu.Item>
<Menu.Item key="tags">Edit ACL tags</Menu.Item>
<Menu.Item key="move">Change owner</Menu.Item>
</Menu.Section>
<Menu.Section>
{expired ? (
<></>
) : (
<Menu.Item key="expire" textValue="Expire">
<p className="text-red-500 dark:text-red-400">Expire</p>
</Menu.Item>
)}
<Menu.Item key="remove" textValue="Remove">
<p className="text-red-500 dark:text-red-400">Remove</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
</>
);
}
+24
View File
@@ -0,0 +1,24 @@
import Dialog from '~/components/Dialog';
import type { Machine } from '~/types';
interface DeleteProps {
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="destructive">
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from your network. To re-add
it, you will need to reauthenticate to your tailnet from the device.
</Dialog.Text>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="id" value={machine.id} />
</Dialog.Panel>
</Dialog>
);
}
+24
View File
@@ -0,0 +1,24 @@
import Dialog from '~/components/Dialog';
import type { Machine } from '~/types';
interface ExpireProps {
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Expire({ machine, isOpen, setIsOpen }: ExpireProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="destructive">
<Dialog.Title>Expire {machine.givenName}</Dialog.Title>
<Dialog.Text>
This will disconnect the machine from your Tailnet. In order to
reconnect, you will need to re-authenticate from the device.
</Dialog.Text>
<input type="hidden" name="_method" value="expire" />
<input type="hidden" name="id" value={machine.id} />
</Dialog.Panel>
</Dialog>
);
}
+35
View File
@@ -0,0 +1,35 @@
import Dialog from '~/components/Dialog';
import Select from '~/components/Select';
import type { Machine, User } from '~/types';
interface MoveProps {
machine: Machine;
users: User[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
<Dialog.Text>
The owner of the machine is the user associated with it.
</Dialog.Text>
<input type="hidden" name="_method" value="move" />
<input type="hidden" name="id" value={machine.id} />
<Select
label="Owner"
name="to"
placeholder="Select a user"
defaultSelectedKey={machine.user.id}
>
{users.map((user) => (
<Select.Item key={user.id}>{user.name}</Select.Item>
))}
</Select>
</Dialog.Panel>
</Dialog>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { Computer, KeySquare } from 'lucide-react';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Menu from '~/components/Menu';
import Select from '~/components/Select';
import type { User } from '~/types';
export interface NewProps {
server: string;
users: User[];
}
export default function New(data: NewProps) {
const [pushDialog, setPushDialog] = useState(false);
const [mkey, setMkey] = useState('');
const navigate = useNavigate();
return (
<>
<Dialog isOpen={pushDialog} onOpenChange={setPushDialog}>
<Dialog.Panel isDisabled={!mkey.trim().startsWith('mkey:')}>
<Dialog.Title>Register Machine Key</Dialog.Title>
<Dialog.Text className="mb-4">
The machine key is given when you run{' '}
<Code isCopyable>
tailscale up --login-server=
{data.server}
</Code>{' '}
on your device.
</Dialog.Text>
<input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" />
<Input
isRequired
label="Machine Key"
placeholder="mkey:ff....."
validationBehavior="native"
name="mkey"
onChange={setMkey}
/>
<Select
isRequired
label="Owner"
name="user"
placeholder="Select a user"
>
{data.users.map((user) => (
<Select.Item key={user.id}>{user.name}</Select.Item>
))}
</Select>
</Dialog.Panel>
</Dialog>
<Menu>
<Menu.Button variant="heavy">Add Device</Menu.Button>
<Menu.Panel
onAction={(key) => {
if (key === 'register') {
setPushDialog(true);
return;
}
if (key === 'pre-auth') {
navigate('/settings/auth-keys');
}
}}
>
<Menu.Section>
<Menu.Item key="register" textValue="Register Machine Key">
<div className="flex items-center gap-x-3">
<Computer className="w-4" />
Register Machine Key
</div>
</Menu.Item>
<Menu.Item key="pre-auth" textValue="Generate Pre-auth Key">
<div className="flex items-center gap-x-3">
<KeySquare className="w-4" />
Generate Pre-auth Key
</div>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
</>
);
}

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