Compare commits
686 Commits
0.3.4
...
v0.7.0-beta.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 73b5d5514e | |||
| 838a2cd732 | |||
| 0191da4ce2 | |||
| ad1d47b223 | |||
| a5e2af9b30 | |||
| 9ac3ab1861 | |||
| 44f45c396c | |||
| ee59a2d06d | |||
| 2c57187628 | |||
| 57c15f7c6b | |||
| 131c54f501 | |||
| 333220a153 | |||
| 61ca6693c4 | |||
| 30bbcbab2a | |||
| a1eb6d35db | |||
| 98d8dfe235 | |||
| 54f236a332 | |||
| 873e6005d9 | |||
| 3213385c0f | |||
| 2e2d5d1dff | |||
| 925d78a175 | |||
| 4e94e82a01 | |||
| 3ec00e17d8 | |||
| dfda629b12 | |||
| 04debdcadf | |||
| 718cbf3c40 | |||
| 246bb90ef0 | |||
| 7403ebea06 | |||
| 941971b8c7 | |||
| 5aa17e448d | |||
| fc9df0c536 | |||
| b6b773fa6d | |||
| cc5aa99046 | |||
| 5f81822366 | |||
| 4fb1cf059e | |||
| b1361e9062 | |||
| dd0587dad1 | |||
| 4997687a58 | |||
| 8f6fe05c83 | |||
| 25dc09e025 | |||
| 27f8fa0b42 | |||
| eda5713700 | |||
| dc2e2dcc98 | |||
| f0df663c90 | |||
| 1025a657b0 | |||
| 0741567754 | |||
| a26faab139 | |||
| 55ddbe0987 | |||
| e255407115 | |||
| 225a52ded6 | |||
| 38cf93e5ae | |||
| b7a85684a9 | |||
| e1808bee1e | |||
| cf4c163c92 | |||
| 6470f5a821 | |||
| a53fd31d27 | |||
| 37d0080cba | |||
| 0ce411e92b | |||
| df5e04f239 | |||
| e6d90c3124 | |||
| a967731461 | |||
| a1efe36ff1 | |||
| 270b99f063 | |||
| 434f886034 | |||
| 684a95b5e8 | |||
| 45984ec639 | |||
| c7f3b60b62 | |||
| 0f8e192b5c | |||
| 6d70497758 | |||
| f4a2811afb | |||
| 5f2cbebbcd | |||
| da3132323c | |||
| cb97b73abd | |||
| 432cd29cd0 | |||
| 1feb109a36 | |||
| f6613f1faf | |||
| 210d473bc2 | |||
| 67b1611d13 | |||
| df11aefd0c | |||
| f9dc9d454b | |||
| 64edd2041b | |||
| 1462016a39 | |||
| 779103147d | |||
| be79413178 | |||
| 787f8decbc | |||
| 0cff389051 | |||
| 3c5a653942 | |||
| 2e5bb791e8 | |||
| ab3c4f7819 | |||
| b2db6efd63 | |||
| 937bc4a667 | |||
| 9f4a7dc6e0 | |||
| 2afc11f652 | |||
| 624b9ab479 | |||
| dde72ae4ed | |||
| 20c2a71713 | |||
| 2fdbf4abea | |||
| 5e0ac88189 | |||
| 4cb231deb1 | |||
| 2a66e02bd4 | |||
| a385c72d90 | |||
| 4f59f4c77b | |||
| 20297c6800 | |||
| 7c39f8f3a8 | |||
| 2e6b387d67 | |||
| 5e45295523 | |||
| a6b25b3ba3 | |||
| badefc7f85 | |||
| 30dd718d68 | |||
| c96249f41e | |||
| c60a47b968 | |||
| 026b683c72 | |||
| 23df48bb2f | |||
| 9183ec2942 | |||
| e70319ae1a | |||
| 0b2ea70f33 | |||
| 931a7f8d14 | |||
| 0080cddb55 | |||
| 0b8ae8fa2d | |||
| 9f0bc4c46a | |||
| 8f445ce347 | |||
| b68722eff0 | |||
| b38b733e9f | |||
| 0d0b5f88a1 | |||
| fad0c99fc9 | |||
| d2c16d2380 | |||
| 2b0f337cb5 | |||
| 5fbb4358ad | |||
| 573dd7e52e | |||
| b5c94f9bc9 | |||
| 696e08dbcc | |||
| 5777a5f509 | |||
| c61a9119a7 | |||
| a710995f97 | |||
| c8861ec978 | |||
| 1a1d7dbe6a | |||
| ec1d34e449 | |||
| 97499ed0c2 | |||
| 403f9c9b0b | |||
| 2618cdd9f6 | |||
| 15d145bf01 | |||
| 40361175e5 | |||
| 034ba25ce3 | |||
| 0df4130217 | |||
| 62325f7c86 | |||
| 42ffe69486 | |||
| 0fb02d0d8b | |||
| d189d75d5d | |||
| 8105b8f94d | |||
| 5953740769 | |||
| 7a345d118c | |||
| 0965e16401 | |||
| e57821df14 | |||
| dfdf5d3ff9 | |||
| 4046c6edb1 | |||
| 7817addbc6 | |||
| e727c48406 | |||
| 85a0f932a7 | |||
| 985d7d9dc6 | |||
| 91254902fa | |||
| 55ffd5e841 | |||
| 82cb74b20b | |||
| e373c4a65e | |||
| 7b4966be02 | |||
| 3d07b941b4 | |||
| 862180df91 | |||
| 11a7c335f7 | |||
| 3d71049afe | |||
| 8948d440fc | |||
| d5dffbaa1d | |||
| d1e2773179 | |||
| 295dd43059 | |||
| b3791385b9 | |||
| 70d535fe8d | |||
| 2ff5dd290f | |||
| 1463a8cc42 | |||
| 87485a81f3 | |||
| 3a3e5ca65e | |||
| 3eff763436 | |||
| 6ad0847653 | |||
| 199ef46ee1 | |||
| e09c5760af | |||
| a824ea3dcf | |||
| bf3fdd47cd | |||
| f6cdad535a | |||
| ba61656fb0 | |||
| d363ed5486 | |||
| b938642da7 | |||
| 45de5f6833 | |||
| afc5d5451a | |||
| ef6563742c | |||
| 86184e3420 | |||
| e868f177aa | |||
| d3d7c7cc0e | |||
| c0c4ecf631 | |||
| 5ea96db816 | |||
| cc533f1b72 | |||
| 3d9b6370bf | |||
| afd512138b | |||
| 08a251cc8c | |||
| 63d1e84ebe | |||
| 20f5342ea3 | |||
| db850942ef | |||
| c9986ba466 | |||
| d331a22951 | |||
| 6be801e9bb | |||
| 299859e279 | |||
| 5090733013 | |||
| 7d77f2cb81 | |||
| c3e07e4e10 | |||
| 1aa921aebe | |||
| 444b2325fb | |||
| c84e9ca4a8 | |||
| d68737e410 | |||
| a68aedc297 | |||
| 1c0561edb8 | |||
| e883a7cd63 | |||
| cc2112a805 | |||
| afde3399e1 | |||
| b1c93512df | |||
| ce4be73faf | |||
| 192cce23b0 | |||
| 7d7e08ee54 | |||
| 06a667b561 | |||
| 75c688fd00 | |||
| 28ad1d439f | |||
| 135d2940f1 | |||
| 497a782f17 | |||
| 6d5e44f585 | |||
| 94e5cb95c6 | |||
| 800d73248d | |||
| 66b6dea068 | |||
| 489b12460f | |||
| ef8dcec9c8 | |||
| f66f778398 | |||
| 6eabfb7610 | |||
| a68df033a2 | |||
| 53b8f76ac6 | |||
| df4d864838 | |||
| c9bae42622 | |||
| a2a28e747b | |||
| d175171efc | |||
| ac9ce72078 | |||
| f44881b399 | |||
| 3b93f2fb61 | |||
| 1b2d7e305a | |||
| 639e7e9c6b | |||
| 5d94667186 | |||
| 225dfceddb | |||
| cd379cd10d | |||
| 8f260b79f1 | |||
| eaffcd7b62 | |||
| 4ccc73d7e4 | |||
| 7a222c47ee | |||
| cb9d0d5317 | |||
| bb0d24b708 | |||
| ebe487daf0 | |||
| 4fd8062476 | |||
| 9967ef9e4c | |||
| 43cbc177d6 | |||
| 0eef9aea9b | |||
| 9b53711673 | |||
| 341a0e8f81 | |||
| cee28fba2f | |||
| 65f56842de | |||
| 4803b2aa4a | |||
| 1dfb2fb777 | |||
| 07c5c6d496 | |||
| 4df15fd4b2 | |||
| 5b138c191a | |||
| 957157de5f | |||
| 9f0564943b | |||
| 2fcd19e2b4 | |||
| eb4669498a | |||
| bda9dedbfd | |||
| a9d5e10be9 | |||
| bd8a7a56d4 | |||
| d7b1e19985 | |||
| 9bc8483a8f | |||
| 4351e1fcb3 | |||
| 9183f805a6 | |||
| cd4e8f8399 | |||
| 6c5656334d | |||
| ff3bdc1898 | |||
| 356abab90a | |||
| 82f6294499 | |||
| 8fc657f86a | |||
| bcd87453bd | |||
| 7adacae940 | |||
| 797ed56c07 | |||
| ae2bd35cbf | |||
| d2c4f5eb2b | |||
| 8cb91cd45b | |||
| a4a037ed68 | |||
| 17712cbb1c | |||
| 616b63bb71 | |||
| 361bc493c0 | |||
| bc3fc5ec5d | |||
| c98dc5ed1d | |||
| 9192013bfe | |||
| fdbfe584c5 | |||
| 97e2557710 | |||
| 82a3eb626e | |||
| cdcb38f11e | |||
| f20b2c869f | |||
| 1355e1535d | |||
| ebd219b622 | |||
| 1ce0dc375e | |||
| 1eaf3abe21 | |||
| 8a9235836a | |||
| 344901d38c | |||
| ea004df2bb | |||
| c59632a0b7 | |||
| 89e38e1356 | |||
| d257b22f91 | |||
| 441e31690b | |||
| e0fde7d0f4 | |||
| 943e5d2a15 | |||
| 7f952bcb3b | |||
| c3112507b5 | |||
| 8b3d0e9bb6 | |||
| 7a34a01dd0 | |||
| 51a85e1bfe | |||
| 5ab9686460 | |||
| 5cfd9e411b | |||
| 65a14aefcf | |||
| f0c1f617c7 | |||
| 44577aff81 | |||
| 27b2831f00 | |||
| ad469129bc | |||
| 7691f74d43 | |||
| f7547a4c62 | |||
| ce4617d0e1 | |||
| 6d99aca058 | |||
| d809eea564 | |||
| eacde3deb8 | |||
| fc7bedc8b1 | |||
| ecc547601f | |||
| eed12d348e | |||
| 5a254b746b | |||
| 2cc4339499 | |||
| 84c820efb1 | |||
| 779af42601 | |||
| f990670e86 | |||
| 0344f17887 | |||
| f28bfd5f86 | |||
| 8f1b577b5e | |||
| 6ea9fe169d | |||
| 9a81b76398 | |||
| 144a8b87cd | |||
| 2bf088b4a9 | |||
| 87b8d64bcc | |||
| 8819af270d | |||
| 1150d1616e | |||
| dd287c0444 | |||
| cf55621b57 | |||
| 73f0a0d273 | |||
| 71f130ede5 | |||
| c0af2aa9c8 | |||
| b18147fa82 | |||
| bf1d75a27a | |||
| 1e86b0e95b | |||
| b34a3f0ca1 | |||
| 799d7b5728 | |||
| 4961e83a9c | |||
| ab7587e7a9 | |||
| 6d15c411f1 | |||
| 7f376c3f70 | |||
| 6a0e097412 | |||
| 721439868f | |||
| f4af5b920d | |||
| 0f9bf73b82 | |||
| 7a6ad8d2d5 | |||
| f9751f5ab2 | |||
| 46f1576b8f | |||
| ccde3513dc | |||
| a0a80852eb | |||
| cb32637938 | |||
| 55eacb59e9 | |||
| 7dfcbef774 | |||
| f6d8ad25e1 | |||
| e36193a8c0 | |||
| 0c87f3e6ec | |||
| 090f5c1ba9 | |||
| 9b9d5432c6 | |||
| 2a26a74b65 | |||
| a298686d6b | |||
| 2f316176c8 | |||
| 59525b7b63 | |||
| b7009e387b | |||
| e173376319 | |||
| 59d7f15694 | |||
| 0866ced767 | |||
| 40a2eb4186 | |||
| cc0c7fa61d | |||
| d25e5fd883 | |||
| 769674ab07 | |||
| 0276a500c7 | |||
| d6fbc3b5ad | |||
| 662c38ea44 | |||
| c827f56b6b | |||
| 753a403aed | |||
| ffce20e88a | |||
| eda8ed0b3e | |||
| 59b32c0842 | |||
| 68bd6200fc | |||
| f56d4e9d9f | |||
| 602e55a94d | |||
| 5e60ac1208 | |||
| b1da29b2fb | |||
| 0808299369 | |||
| 39d6602263 | |||
| df4f250e0f | |||
| 129e4f648b | |||
| 2a4d23d70f | |||
| f4da4c6428 | |||
| 7f5e0fe42d | |||
| 021ecabd3c | |||
| 81feb2f19a | |||
| abedf40690 | |||
| c9874966d2 | |||
| 887834efd9 | |||
| 6f2a0773ba | |||
| 8c01fcfe89 | |||
| f93acd8ef1 | |||
| a510796e38 | |||
| 69d97ea585 | |||
| 000e9326fb | |||
| 8299aac94f | |||
| 08ee0eff42 | |||
| d1f395acfd | |||
| 6bc20bd0e6 | |||
| 130b603d58 | |||
| ae0aadd83f | |||
| 6be55ec686 | |||
| fb3bc35b40 | |||
| dedec8f317 | |||
| 7eda09bae8 | |||
| ed4041073c | |||
| c5e6a6c407 | |||
| 608fcacdb1 | |||
| 3dca401837 | |||
| 785d5a663f | |||
| ece304638f | |||
| 1a2b1ad3f2 | |||
| 169a7b14d0 | |||
| 3a5ab68432 | |||
| 62863ad649 | |||
| 91fcf5233e | |||
| 8ea8c7195f | |||
| c3a50ea4f4 | |||
| 6716e5f0b0 | |||
| 346b44ec69 | |||
| faa61b0f1d | |||
| 6b63fe209f | |||
| c8507cff7c | |||
| b86f4461c0 | |||
| 77b510c927 | |||
| 5adcb8c582 | |||
| 524c5eb639 | |||
| 2894c664d3 | |||
| 5c2d08decd | |||
| f2e8c6ae4c | |||
| c3e727842a | |||
| 96345ab0a6 | |||
| fe2d7cb57a | |||
| 66c7d9a327 | |||
| f2747ada94 | |||
| 0ad578e651 | |||
| 8d1132606a | |||
| 5e332c4a5c | |||
| 69c6fc4847 | |||
| 9b09b13b5f | |||
| cf90f3dd32 | |||
| 72c1174cb3 | |||
| 63bfad77ce | |||
| 41223b89b3 | |||
| 93d5c2ed29 | |||
| 6a94e815f2 | |||
| 234020eec5 | |||
| 58cc7b742c | |||
| 259d150fc4 | |||
| 5d3fada266 | |||
| 9d046a0cf6 | |||
| 1fb084451d | |||
| d5fb8a2966 | |||
| 7b1340c93e | |||
| 16a8122f85 | |||
| 7d61ad50c4 | |||
| 103a826178 | |||
| 090dec1ca6 | |||
| 8596a56375 | |||
| 000ec620b4 | |||
| 83a69792ea | |||
| fee5f423a6 | |||
| d698cf5478 | |||
| 80c987f383 | |||
| 17d477bf0f | |||
| 5e5c7c4c7a | |||
| 2e383ddabe | |||
| 2299907932 | |||
| 3771890f98 | |||
| bf02015dc7 | |||
| 8429b19c4a | |||
| 9a5952adcb | |||
| 222ac7a279 | |||
| 6f40f9cfac | |||
| 457cbc45e6 | |||
| aac8a9ef12 | |||
| b8d22beb17 | |||
| cac64a6fbe | |||
| 5918d0e501 | |||
| 03acebb23e | |||
| 73ea35980d | |||
| 9a1051b9af | |||
| c066b3064d | |||
| 98d02bb595 | |||
| 2964ff295e | |||
| b0a3f9d5fd | |||
| 34cfee7cff | |||
| 8db323b63f | |||
| 08c25caca3 | |||
| cbbd64e91a | |||
| 48fc0f7ef3 | |||
| 23fd2bbda2 | |||
| 05837963c4 | |||
| 2a16115e69 | |||
| 5675ecdeac | |||
| 29424366a8 | |||
| c47346df52 | |||
| 92dedf51aa | |||
| 8f7a85f47d | |||
| ed1cf188dc | |||
| 03a7e51384 | |||
| 483f86c457 | |||
| a8a4d2a6f8 | |||
| eef5bd0648 | |||
| 6108de52e7 | |||
| 43e06987ad | |||
| 7741ab88bf | |||
| 3bdbfdc033 | |||
| e701e16112 | |||
| 79d83537d5 | |||
| 251c16ca48 | |||
| 134b38ceda | |||
| b8fb1fa40f | |||
| 951a96fad6 | |||
| 5b9dc7cfc6 | |||
| e36352b7f3 | |||
| 296e4d489c | |||
| c3ddac42a0 | |||
| 01f432cedc | |||
| 9c8a2c0120 | |||
| 5f6460f42f | |||
| d9314887fb | |||
| 5dd4c41291 | |||
| 45537620a6 | |||
| 21af5c4a4d | |||
| 983356611e | |||
| 41ca4c40ad | |||
| f7c9a14e92 | |||
| 6e514a9064 | |||
| 631ea15895 | |||
| 762bc6a793 | |||
| 6c83e96341 | |||
| 03e6a26b3a | |||
| 5b716ab5ce | |||
| a7e4f3e4d2 | |||
| 40ddb69bc9 | |||
| 6aa6e77611 | |||
| c5c2f8a93b | |||
| acd042b4de | |||
| a563d4c0b3 | |||
| 7117060863 | |||
| 30a1153fa5 | |||
| 870680b6c5 | |||
| 25e6410c65 | |||
| f982217dd0 | |||
| f5436f5ee3 | |||
| 06049169a2 | |||
| 774fdb7be2 | |||
| e6580eed2c | |||
| 56b660b30c | |||
| 85c31ca5bf | |||
| 44d08169a9 | |||
| 76d263b7e6 | |||
| 5be3cb345e | |||
| 2a1c795d46 | |||
| 316e5b501f | |||
| 287ac2dff0 | |||
| d1f6c450c0 | |||
| 0a14533756 | |||
| 347c6698ee | |||
| 771b87ae41 | |||
| 31ac25d510 | |||
| 68babd7fe9 | |||
| c9d8052e39 | |||
| 843cfc4d4f | |||
| 6922548317 | |||
| 07cee17501 | |||
| 2c8880c84d | |||
| a19eb6bcda | |||
| 28e40eecbf | |||
| 741f9aa6b5 | |||
| 0f75636342 | |||
| 665509e710 | |||
| ac937f9014 | |||
| 8d3f31e7f9 | |||
| 2d47f1b952 | |||
| c24cd34925 | |||
| af248df648 | |||
| b9a708b2e9 | |||
| ec36876b9f | |||
| ed0cdbdf4d | |||
| fd506ba474 | |||
| 9734f0c704 | |||
| 156d7c9ee7 | |||
| bd11453593 | |||
| 0f92f09796 | |||
| 9bb20024fa | |||
| 698122068d | |||
| e6a66d5804 | |||
| d524f927a4 | |||
| 539c76dfb3 | |||
| 51fa7c14d0 | |||
| b92c4ce2e8 | |||
| 325e9ba43d | |||
| de9a938da2 | |||
| 1b45b0917f | |||
| e01fd8d50c | |||
| c9603ba38a | |||
| 377641265b | |||
| 5bc313e5fe | |||
| af919b2d34 | |||
| f464c42802 | |||
| e667e112cb | |||
| 39546849ae | |||
| eb922c9318 | |||
| 5569ba4660 | |||
| dfd03e77bb | |||
| e33504016b | |||
| 7d4da73141 | |||
| 1316439786 | |||
| 888c1a5fb6 | |||
| 328302cd81 | |||
| b060700dfe | |||
| 69783f0d05 | |||
| 55c832c536 | |||
| fc5028994a | |||
| 6745ee8529 | |||
| 414b95c293 | |||
| 7217659720 | |||
| a4bb3cce5f | |||
| f3c9d8b54c | |||
| aa9872a45b | |||
| 39504e2487 | |||
| 719d7a5e76 | |||
| 6156d78907 | |||
| 1c2c374ada | |||
| 6cf343d623 | |||
| 50e43bc0c3 | |||
| 9d64a5beac | |||
| 240d8d6197 | |||
| b80bb0cc09 | |||
| 69cc6985b4 | |||
| f9b38939ba | |||
| 4cfa1e5209 | |||
| e713dae91b | |||
| c9bcc1d7c6 | |||
| 401731fd09 | |||
| f623e7bc66 | |||
| 1af292a5b0 | |||
| 21778a43f1 | |||
| 33762e53b5 | |||
| 8867cca494 | |||
| 41b1d3c847 | |||
| 712fc28683 | |||
| 320dab1d4f | |||
| da0ee1382b | |||
| a7d127c7bf | |||
| b433e607e2 | |||
| 8aad883c21 | |||
| 3cd28d2136 | |||
| 9d9cbd8e0e | |||
| feb8b8bba5 | |||
| c304effcdf |
@@ -0,0 +1,7 @@
|
||||
FROM mcr.microsoft.com/devcontainers/base:bookworm
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends build-essential libnss3-tools mkcert pkg-config && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "headplane",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile"
|
||||
},
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/common-utils:2": {
|
||||
"upgradePackages": true,
|
||||
"username": "vscode"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"nodeGypDependencies": true,
|
||||
"version": "24"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/go:1": {
|
||||
"version": "1.25.1"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/nix:1": {
|
||||
"extraNixConfig": "experimental-features = nix-command flakes,accept-flake-config = true"
|
||||
}
|
||||
},
|
||||
"containerEnv": {
|
||||
"COREPACK_ENABLE_DOWNLOAD_PROMPT": "0"
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"golang.go",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"ms-vscode.vscode-typescript-next"
|
||||
],
|
||||
"settings": {
|
||||
"editor.formatOnSave": true,
|
||||
"go.useLanguageServer": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"forwardPorts": [3000, 5173],
|
||||
"postCreateCommand": "pnpm config set store-dir $HOME/.pnpm-store --global && pnpm install",
|
||||
"remoteUser": "vscode",
|
||||
"updateRemoteUserUID": true
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
node_modules
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
|
||||
/.cache
|
||||
/.github
|
||||
/.react-router
|
||||
/node_modules
|
||||
/build
|
||||
/docs
|
||||
/nix
|
||||
|
||||
.dockerignore
|
||||
.env
|
||||
Dockerfile
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
ROOT_API_KEY=abcdefghijklmnopqrstuvwxyz
|
||||
COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz
|
||||
DISABLE_API_KEY_LOGIN=true
|
||||
HEADSCALE_CONTAINER=headscale
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
CONFIG_FILE=/etc/headscale/config.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
* @tale
|
||||
|
||||
/nix @tale @StealthBadger747
|
||||
@@ -0,0 +1,2 @@
|
||||
github: tale
|
||||
ko_fi: atale
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Bug Report
|
||||
description: Report an issue with Headplane
|
||||
assignees: [tale]
|
||||
labels: ["Bug", "Needs Triage"]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
A detailed description of the issue and steps to reproduce it.
|
||||
If applicable, include any error messages or screenshots.
|
||||
|
||||
If this is not an issue with Headplane, but an issue with your
|
||||
environment, please consider opening a discussion instead.
|
||||
placeholder: e.g. "When I try to upload a file, I get an error message."
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Headplane Version
|
||||
description: What version of Headplane are you using?
|
||||
placeholder: e.g. "v0.6.2"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Headscale Version
|
||||
description: What version of Headscale are you using?
|
||||
placeholder: e.g. "v0.27.1"
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Question or Support
|
||||
url: https://github.com/tale/headplane/discussions/new?category=q-a
|
||||
about: Ask questions or get help with your setup
|
||||
@@ -0,0 +1,15 @@
|
||||
name: Feature Request
|
||||
description: Request a new feature or enhancement for Headplane
|
||||
assignees: [tale]
|
||||
labels: ["Feature", "Needs Triage"]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
A detailed description of the feature you would like to see added.
|
||||
Please include any relevant context, such as why this feature is
|
||||
important and how it would benefit other users beyond yourself.
|
||||
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,45 @@
|
||||
Agent:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- cmd/hp_agent/**
|
||||
- internal/**
|
||||
- go.mod
|
||||
- go.sum
|
||||
|
||||
UI/UX:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- app/components/**
|
||||
- app/layouts/**
|
||||
- app/root.tsx
|
||||
- app/tailwind.css
|
||||
|
||||
Authentication:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- app/server/web/**
|
||||
- app/routes/auth*/**
|
||||
|
||||
Config:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- app/server/config/**
|
||||
- internal/config/**
|
||||
- config.example.yaml
|
||||
|
||||
Integrations:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- app/server/headscale/**
|
||||
- app/openapi-*.json
|
||||
|
||||
Web SSH:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- cmd/hp_ssh/**
|
||||
|
||||
Docs:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- docs/**
|
||||
- README.md
|
||||
@@ -0,0 +1,85 @@
|
||||
name: Automated
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "pnpm-lock.yaml"
|
||||
- "package.json"
|
||||
branches:
|
||||
- "main"
|
||||
schedule:
|
||||
- cron: "0 8 * * 0"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: automation-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
nix-pnpm-hash:
|
||||
name: Update Nix PNPM Hash
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
|
||||
- name: Cache Nix store
|
||||
uses: DeterminateSystems/magic-nix-cache-action@main
|
||||
|
||||
- name: Compute correct PNPM dependencies hash
|
||||
run: |
|
||||
CURRENT_HASH=$(grep -oP "hash = \"\K[^\"]*" nix/package.nix)
|
||||
sed -i "s|hash = \"$CURRENT_HASH\"|hash = \"\"|" nix/package.nix
|
||||
|
||||
NEW_HASH=$(nix build .#headplane.pnpmDeps --no-link 2>&1 \
|
||||
| grep 'got:' \
|
||||
| awk '{print $2}')
|
||||
|
||||
if [ -n "$NEW_HASH" ]; then
|
||||
sed -i "s|hash = \"\"|hash = \"$NEW_HASH\"|" nix/package.nix
|
||||
else
|
||||
git checkout nix/package.nix
|
||||
fi
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
if git diff --quiet nix/package.nix; then
|
||||
echo "Hash is already up to date, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add nix/package.nix
|
||||
git commit -m "chore: update nix pnpm deps hash"
|
||||
git push
|
||||
|
||||
flake-inputs:
|
||||
name: Update Flake Inputs
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
|
||||
- name: Update flake.lock
|
||||
uses: DeterminateSystems/update-flake-lock@main
|
||||
with:
|
||||
pr-title: "chore: update flake.lock"
|
||||
pr-labels: |
|
||||
automated
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Build
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- "assets/**"
|
||||
- "docs/**"
|
||||
- "CHANGELOG.md"
|
||||
- "README.md"
|
||||
branches:
|
||||
- "main"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: Build and Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Build
|
||||
run: ./build.sh --skip-pnpm-prune
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm run test:unit
|
||||
|
||||
- name: Run integration tests
|
||||
run: pnpm run test:integration
|
||||
|
||||
nix:
|
||||
name: Nix Flake Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
|
||||
- name: Cache Nix store
|
||||
uses: DeterminateSystems/magic-nix-cache-action@main
|
||||
|
||||
- name: Check flake inputs
|
||||
uses: DeterminateSystems/flake-checker-action@main
|
||||
|
||||
- name: Check flake outputs
|
||||
run: nix flake check
|
||||
@@ -0,0 +1,18 @@
|
||||
name: Labeler
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Label Pull Request
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels based on changed files
|
||||
uses: actions/labeler@v6
|
||||
with:
|
||||
sync-labels: true
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Release Lifecycle
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
beta:
|
||||
name: Beta Release Lifecycle
|
||||
if: contains(github.ref_name, '-')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Label and comment on referenced issues
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "No previous tag found, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Processing issues referenced between ${PREV_TAG} and ${TAG}"
|
||||
ISSUES=$(git log --format="%B" "${PREV_TAG}..HEAD" | grep -oE '#[0-9]+' | sort -u | tr -d '#')
|
||||
|
||||
for ISSUE in $ISSUES; do
|
||||
[ -z "$ISSUE" ] && continue
|
||||
|
||||
STATE=$(gh issue view "$ISSUE" --json state --jq '.state' \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || echo "")
|
||||
[ "$STATE" != "OPEN" ] && continue
|
||||
|
||||
echo "Labeling and commenting on #${ISSUE}"
|
||||
gh issue edit "$ISSUE" --add-label "In Beta" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
gh issue comment "$ISSUE" \
|
||||
--body "🧪 Available in [\`${TAG}\`](https://github.com/${{ github.repository }}/releases/tag/${TAG})" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
done
|
||||
|
||||
stable:
|
||||
name: Stable Release Lifecycle
|
||||
if: ${{ !contains(github.ref_name, '-') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close milestone issues
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
VERSION="${TAG#v}"
|
||||
|
||||
MILESTONE_NUMBER=$(gh api "repos/${{ github.repository }}/milestones" \
|
||||
--jq ".[] | select(.title == \"${VERSION}\") | .number" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$MILESTONE_NUMBER" ]; then
|
||||
echo "No milestone found for ${VERSION}, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Processing milestone ${VERSION} (#${MILESTONE_NUMBER})"
|
||||
gh api "repos/${{ github.repository }}/issues?milestone=${MILESTONE_NUMBER}&state=open&per_page=100" \
|
||||
--paginate --jq '.[].number' 2>/dev/null | while IFS= read -r ISSUE; do
|
||||
[ -z "$ISSUE" ] && continue
|
||||
|
||||
echo "Closing #${ISSUE}"
|
||||
gh issue edit "$ISSUE" --remove-label "In Beta" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
gh issue comment "$ISSUE" \
|
||||
--body "✅ Released in [\`${TAG}\`](https://github.com/${{ github.repository }}/releases/tag/${TAG})" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
gh issue close "$ISSUE" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
done
|
||||
|
||||
echo "Closing milestone ${VERSION}"
|
||||
gh api -X PATCH "repos/${{ github.repository }}/milestones/${MILESTONE_NUMBER}" \
|
||||
-f state=closed 2>/dev/null || true
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Publish Nightly Docker Image
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 8 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and Publish Nightly
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=edge,branch=main
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
@@ -1,42 +0,0 @@
|
||||
name: Publish Docker Image
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and Publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
@@ -1,44 +0,0 @@
|
||||
name: 'Build'
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
@@ -0,0 +1,79 @@
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
name: Docker Release (${{ matrix.target }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: final
|
||||
tag_suffix: ""
|
||||
- target: debug-shell
|
||||
tag_suffix: "-shell"
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
flavor: |
|
||||
latest=auto
|
||||
suffix=${{ matrix.tag_suffix }},onlatest=true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: ${{ matrix.target }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
HEADPLANE_VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
|
||||
- name: Attest build provenance
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Stale
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
name: Close Stale Issues
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mark and close stale issues
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
stale-issue-label: Stale
|
||||
only-labels: "Needs Info"
|
||||
days-before-stale: 30
|
||||
stale-issue-message: >
|
||||
This issue has been waiting for information for 30 days.
|
||||
It will be closed in 7 days if there is no further activity.
|
||||
Feel free to reopen if you can provide the requested details.
|
||||
close-issue-message: >
|
||||
Closed due to inactivity. Feel free to reopen with the
|
||||
requested information.
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Triage
|
||||
on:
|
||||
issues:
|
||||
types: [milestoned]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
remove-triage:
|
||||
name: Remove Needs Triage Label
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Remove Needs Triage label
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh issue edit ${{ github.event.issue.number }} \
|
||||
--remove-label "Needs Triage" \
|
||||
--repo ${{ github.repository }} 2>/dev/null || true
|
||||
@@ -1,5 +1,15 @@
|
||||
node_modules
|
||||
|
||||
/.react-router
|
||||
/.cache
|
||||
/build
|
||||
/test
|
||||
.env
|
||||
/app/hp_ssh.wasm
|
||||
/app/wasm_exec.js
|
||||
|
||||
/public/hp_ssh.wasm
|
||||
/public/wasm_exec.js
|
||||
|
||||
/docs/.vitepress/dist/
|
||||
/docs/.vitepress/cache/
|
||||
/.direnv
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"endOfLine": "lf",
|
||||
"experimentalSortImports": {},
|
||||
"experimentalTailwindcss": {},
|
||||
"ignorePatterns": [],
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["unicorn", "typescript", "oxc"],
|
||||
"categories": {},
|
||||
"rules": {
|
||||
"constructor-super": "warn",
|
||||
"for-direction": "warn",
|
||||
"no-async-promise-executor": "warn",
|
||||
"no-caller": "warn",
|
||||
"no-class-assign": "warn",
|
||||
"no-compare-neg-zero": "warn",
|
||||
"no-cond-assign": "warn",
|
||||
"no-const-assign": "warn",
|
||||
"no-constant-binary-expression": "warn",
|
||||
"no-constant-condition": "warn",
|
||||
"no-control-regex": "warn",
|
||||
"no-debugger": "warn",
|
||||
"no-delete-var": "warn",
|
||||
"no-dupe-class-members": "warn",
|
||||
"no-dupe-else-if": "warn",
|
||||
"no-dupe-keys": "warn",
|
||||
"no-duplicate-case": "warn",
|
||||
"no-empty-character-class": "warn",
|
||||
"no-empty-pattern": "warn",
|
||||
"no-empty-static-block": "warn",
|
||||
"no-eval": "warn",
|
||||
"no-ex-assign": "warn",
|
||||
"no-extra-boolean-cast": "warn",
|
||||
"no-func-assign": "warn",
|
||||
"no-global-assign": "warn",
|
||||
"no-import-assign": "warn",
|
||||
"no-invalid-regexp": "warn",
|
||||
"no-irregular-whitespace": "warn",
|
||||
"no-loss-of-precision": "warn",
|
||||
"no-new-native-nonconstructor": "warn",
|
||||
"no-nonoctal-decimal-escape": "warn",
|
||||
"no-obj-calls": "warn",
|
||||
"no-self-assign": "warn",
|
||||
"no-setter-return": "warn",
|
||||
"no-shadow-restricted-names": "warn",
|
||||
"no-sparse-arrays": "warn",
|
||||
"no-this-before-super": "warn",
|
||||
"no-unassigned-vars": "warn",
|
||||
"no-unsafe-finally": "warn",
|
||||
"no-unsafe-negation": "warn",
|
||||
"no-unsafe-optional-chaining": "warn",
|
||||
"no-unused-expressions": "warn",
|
||||
"no-unused-labels": "warn",
|
||||
"no-unused-private-class-members": "warn",
|
||||
"no-unused-vars": "warn",
|
||||
"no-useless-backreference": "warn",
|
||||
"no-useless-catch": "warn",
|
||||
"no-useless-escape": "warn",
|
||||
"no-useless-rename": "warn",
|
||||
"no-with": "warn",
|
||||
"require-yield": "warn",
|
||||
"use-isnan": "warn",
|
||||
"valid-typeof": "warn",
|
||||
"oxc/bad-array-method-on-arguments": "warn",
|
||||
"oxc/bad-char-at-comparison": "warn",
|
||||
"oxc/bad-comparison-sequence": "warn",
|
||||
"oxc/bad-min-max-func": "warn",
|
||||
"oxc/bad-object-literal-comparison": "warn",
|
||||
"oxc/bad-replace-all-arg": "warn",
|
||||
"oxc/const-comparisons": "warn",
|
||||
"oxc/double-comparisons": "warn",
|
||||
"oxc/erasing-op": "warn",
|
||||
"oxc/missing-throw": "warn",
|
||||
"oxc/number-arg-out-of-range": "warn",
|
||||
"oxc/only-used-in-recursion": "warn",
|
||||
"oxc/uninvoked-array-callback": "warn",
|
||||
"typescript/await-thenable": "warn",
|
||||
"typescript/no-array-delete": "warn",
|
||||
"typescript/no-base-to-string": "warn",
|
||||
"typescript/no-duplicate-enum-values": "warn",
|
||||
"typescript/no-duplicate-type-constituents": "warn",
|
||||
"typescript/no-extra-non-null-assertion": "warn",
|
||||
"typescript/no-floating-promises": "warn",
|
||||
"typescript/no-for-in-array": "warn",
|
||||
"typescript/no-implied-eval": "warn",
|
||||
"typescript/no-meaningless-void-operator": "warn",
|
||||
"typescript/no-misused-new": "warn",
|
||||
"typescript/no-misused-spread": "warn",
|
||||
"typescript/no-non-null-asserted-optional-chain": "warn",
|
||||
"typescript/no-redundant-type-constituents": "warn",
|
||||
"typescript/no-this-alias": "warn",
|
||||
"typescript/no-unnecessary-parameter-property-assignment": "warn",
|
||||
"typescript/no-unsafe-declaration-merging": "warn",
|
||||
"typescript/no-unsafe-unary-minus": "warn",
|
||||
"typescript/no-useless-empty-export": "warn",
|
||||
"typescript/no-wrapper-object-types": "warn",
|
||||
"typescript/prefer-as-const": "warn",
|
||||
"typescript/require-array-sort-compare": "warn",
|
||||
"typescript/restrict-template-expressions": "warn",
|
||||
"typescript/triple-slash-reference": "warn",
|
||||
"typescript/unbound-method": "warn",
|
||||
"unicorn/no-await-in-promise-methods": "warn",
|
||||
"unicorn/no-empty-file": "warn",
|
||||
"unicorn/no-invalid-fetch-options": "warn",
|
||||
"unicorn/no-invalid-remove-event-listener": "warn",
|
||||
"unicorn/no-new-array": "warn",
|
||||
"unicorn/no-single-promise-in-promise-methods": "warn",
|
||||
"unicorn/no-thenable": "warn",
|
||||
"unicorn/no-unnecessary-await": "warn",
|
||||
"unicorn/no-useless-fallback-in-spread": "warn",
|
||||
"unicorn/no-useless-length-check": "warn",
|
||||
"unicorn/no-useless-spread": "warn",
|
||||
"unicorn/prefer-set-size": "warn",
|
||||
"unicorn/prefer-string-starts-ends-with": "warn"
|
||||
},
|
||||
"settings": {
|
||||
"jsx-a11y": {
|
||||
"polymorphicPropName": null,
|
||||
"components": {},
|
||||
"attributes": {}
|
||||
},
|
||||
"next": {
|
||||
"rootDir": []
|
||||
},
|
||||
"react": {
|
||||
"formComponents": [],
|
||||
"linkComponents": [],
|
||||
"version": null
|
||||
},
|
||||
"jsdoc": {
|
||||
"ignorePrivate": false,
|
||||
"ignoreInternal": false,
|
||||
"ignoreReplacesDocs": true,
|
||||
"overrideReplacesDocs": true,
|
||||
"augmentsExtendsReplacesDocs": false,
|
||||
"implementsReplacesDocs": false,
|
||||
"exemptDestructuredRootsFromChecks": false,
|
||||
"tagNamePreference": {}
|
||||
},
|
||||
"vitest": {
|
||||
"typecheck": false
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"builtin": true
|
||||
},
|
||||
"globals": {},
|
||||
"ignorePatterns": []
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# Core Concepts
|
||||
|
||||
Headplane is a web application to manage Headscale, a self-hosted implementation
|
||||
of the Tailscale control server. There are a few tenets that guide the entire
|
||||
development of the project:
|
||||
|
||||
- **Simple starts**: We want to make it as easy as possible to set up and use
|
||||
Headplane, while still providing powerful features for advanced users. This
|
||||
means that we prioritize a clean and intuitive user interface, as well as
|
||||
straightforward installation and configuration processes.
|
||||
|
||||
- **No breaking changes**: We want to avoid making breaking changes to the
|
||||
project as much as possible. This means that we will strive to maintain
|
||||
backward compatibility and provide clear migration paths when necessary.
|
||||
|
||||
- **Documentation**: This is the most important part of the project, without it
|
||||
the entire project falls apart and is hard to use.
|
||||
|
||||
## Project Management
|
||||
|
||||
It's hard to manage this project easily, use the `gh` CLI when responding to
|
||||
prompts to get context. Some common issue tags to keep track of include a
|
||||
"Needs Triage", "Needs Info", "Bug", "Enhancement", and several other tags based
|
||||
on what parts of the project are affected.
|
||||
|
||||
## Headplane Agent
|
||||
|
||||
The Headplane Agent is a lightweight component that runs on the same server as
|
||||
Headplane and connects directly to the Tailnet in order to pull in details about
|
||||
nodes that aren't available through the Headscale API such as versions, etc.
|
||||
|
||||
## WebSSH
|
||||
|
||||
This is an ephemeral WASM shim that runs in the browser and connects directly
|
||||
to the Tailnet using Tailscale's go packages. It allows anyone to open up an
|
||||
ephemeral machine in the Tailnet that directly SSHes into a target node.
|
||||
|
||||
## Build/Tooling
|
||||
|
||||
Headplane is a React Router 7 (framework mode) project built with Vite. Take
|
||||
care to use our preferred PNPM version and Node version as defined in the
|
||||
`engines` field of `package.json`. We also use TypeScript Go and Oxfmt for
|
||||
type-checking and formatting respectively.
|
||||
|
||||
When typechecking, use `pnpm run typecheck`, when linting and formatting, use
|
||||
the respective `lint` and `format` scripts, you can pass flags to them. You can
|
||||
also run Headscale CLI commands with `docker exec headscale headscale <command>`
|
||||
when the dev environment is running.
|
||||
|
||||
## Docs
|
||||
|
||||
The project has a documentation site available at the `docs/` directory built
|
||||
with VitePress. The documentation is written in Markdown and can be easily
|
||||
edited and extended. If making changes to staple features, please take care to
|
||||
also update the documentation to reflect any changes in functionality or usage.
|
||||
@@ -1,4 +1,320 @@
|
||||
### 0.3.4 (November 7, 2024)
|
||||
# 0.7.0-beta.1 (March 27, 2026)
|
||||
|
||||
> This is a beta release. Please report any issues you encounter.
|
||||
|
||||
- **Migrated all UI components from react-aria/react-stately to @base-ui-components/react.**
|
||||
- Removed `react-aria`, `react-stately`, and `tailwindcss-react-aria-components` as dependencies.
|
||||
- **Rearchitected the Headplane Agent** from a long-running stdin/stdout daemon to a one-shot sync model (closes [#350](https://github.com/tale/headplane/issues/350), closes [#455](https://github.com/tale/headplane/issues/455)).
|
||||
- The Go binary connects to the Tailnet, fetches all peer hostinfo as JSON, and exits.
|
||||
- The Node.js manager auto-generates ephemeral tag-only pre-auth keys (requires Headscale 0.28+).
|
||||
- Deprecated `integration.agent.pre_authkey` and `integration.agent.cache_path` config fields.
|
||||
- Added `integration.agent.executable_path` config field.
|
||||
- **Consolidated the Headscale API key** under `headscale.api_key` (and `headscale.api_key_path`).
|
||||
- Deprecated `oidc.headscale_api_key` — it is still read as a fallback but will be removed in a future release.
|
||||
- Both the agent and OIDC now use the same key from `headscale.api_key`.
|
||||
- **Reworked the authentication system** with a new `AuthService` that consolidates session management and role enforcement (via [#489](https://github.com/tale/headplane/pull/489)).
|
||||
- Added an agent status page at `/settings/agent` showing sync status, node count, errors, and a "Sync Now" button.
|
||||
- Added additional machine list filters for user, tag, status, and route (via [#507](https://github.com/tale/headplane/pull/507), closes [#506](https://github.com/tale/headplane/issues/506)).
|
||||
- Added self-service pre-auth key creation for auditor role users (via [#478](https://github.com/tale/headplane/pull/478), closes [#453](https://github.com/tale/headplane/issues/453)).
|
||||
- Fetch OIDC profile pictures server-side when the URL requires authentication (via [#510](https://github.com/tale/headplane/pull/510), closes [#326](https://github.com/tale/headplane/issues/326)).
|
||||
- Fixed first user not being assigned the owner role on OIDC login (via [#480](https://github.com/tale/headplane/pull/480), closes [#266](https://github.com/tale/headplane/issues/266)).
|
||||
- Fixed login errors throwing an unexpected server error instead of showing form validation (via [#475](https://github.com/tale/headplane/pull/475), closes [#474](https://github.com/tale/headplane/issues/474)).
|
||||
- Fixed agent HostInfo not refreshing periodically using `cache_ttl` (via [#477](https://github.com/tale/headplane/pull/477), closes [#427](https://github.com/tale/headplane/issues/427)).
|
||||
- Fixed a race condition where the SSE controller could be used after being closed.
|
||||
- Fixed WebSSH WASM prefix paths for correct asset loading (closes [#386](https://github.com/tale/headplane/issues/386)).
|
||||
- Fixed Dockerfile WASM copy paths.
|
||||
- Fixed CodeMirror version mismatch override in the ACL editor.
|
||||
- Fixed cookie secret generation using incorrect byte length (via [#501](https://github.com/tale/headplane/pull/501)).
|
||||
- Detect unsupported Docker API versions early with a clear error message (via [#497](https://github.com/tale/headplane/pull/497)).
|
||||
- Updated NixOS module options: removed deprecated agent fields, added `headscale.api_key_path` and `integration.agent.executable_path`.
|
||||
|
||||
---
|
||||
|
||||
# 0.6.2 (February 26, 2026)
|
||||
|
||||
- **Added support for Headscale 0.28.0** including all API and data model changes.
|
||||
- Added search and sortable columns to the machines list page (closes [#351](https://github.com/tale/headplane/issues/351)).
|
||||
- Added support for Headscale 0.27.0 and 0.27.1
|
||||
- Bundle all `node_modules` aside from native ones to reduce bundle and container size (closes [#331](https://github.com/tale/headplane/issues/331)).
|
||||
- Allow conditionally compiling the SSH WASM integration when building (closes [#337](https://github.com/tale/headplane/issues/337)).
|
||||
- Implemented the ability to customize the build with a custom script (see `./build.sh --help` for more information).
|
||||
- Attempt to warn against misconfigured cookie settings on the login page.
|
||||
- Made `server.cookie_max_age` and `server.cookie_domain` configurable (closes [#348](https://github.com/tale/headplane/issues/348)).
|
||||
- Re-worked the configuration loading system with several enhancements:
|
||||
- It is now possible to skip a configuration file and only use environment variables (closes [#150](https://github.com/tale/headplane/issues/150)).
|
||||
- Secret path loading has been reworked from the ground up to be more reliable (closes [#334](https://github.com/tale/headplane/issues/334)).
|
||||
- Added better testing and validation for configuration loading
|
||||
- Re-worked the OIDC integration to adhere to the correct standards and surface more errors to the user.
|
||||
- Deprecated `oidc.redirect_uri` and automated callback URL detection in favor of setting `server.base_url` correctly.
|
||||
- Explicitly added `oidc.use_pkce` to correctly determine PKCE configuration.
|
||||
- `oidc.token_endpoint_auth_method` is now optional and will attempt to be auto-detected, defaulting to `client_secret_basic` if unavailable (closes [#410](https://github.com/tale/headplane/issues/410)).
|
||||
- Added `oidc.enabled` config option to explicitly control OIDC availability (via [#463](https://github.com/tale/headplane/pull/463)).
|
||||
- Removed several unnecessarily verbose or spammy log messages.
|
||||
- Updated the minimum Docker API used to support the latest Docker versions (via [#370](https://github.com/tale/headplane/pull/370)).
|
||||
- Enhanced the node tag dialog to show a dropdown of assignable tags (via [#362](https://github.com/tale/headplane/pull/362)).
|
||||
- Fixed an issue where the website favicon would not load correctly (closes [#323](https://github.com/tale/headplane/issues/323)).
|
||||
- Correctly handle invalid ACL policy inserts on Headscale 0.27+ (closes [#383](https://github.com/tale/headplane/issues/383)).
|
||||
- Prevent a machine from changing its owner to itself (closes [#373](https://github.com/tale/headplane/issues/373)).
|
||||
- Added an `/admin/api/info` route that can expose sensitive information if `server.info_secret` is set in the configuration (closes [#324](https://github.com/tale/headplane/issues/324)).
|
||||
- Correctly apply Gravatar profile pictures on the user page if applicable (closes [#405](https://github.com/tale/headplane/issues/405)).
|
||||
- Machine key registration no longer works if the key isn't 24 characters long (closes [#415](https://github.com/tale/headplane/issues/415)).
|
||||
- Fixed some mobile CSS issues across the application (closes [#401](https://github.com/tale/headplane/issues/401)).
|
||||
- Added a Docker healthcheck to the container (closes [#411](https://github.com/tale/headplane/issues/411)).
|
||||
- Strengthened the validation for the `/proc` integration to correctly discover the Headscale PID.
|
||||
- Added lazy retry logic for OIDC providers if they initially fail to respond (closes [#423](https://github.com/tale/headplane/issues/423)).
|
||||
- Fixed API key login on Headscale 0.28.0-beta.1+ (closes [#429](https://github.com/tale/headplane/issues/429)).
|
||||
- Fixed an issue that prevented the pre-auth-key UI from being usable on Headscale 0.28 and later.
|
||||
- Added support for creating tag-only pre-auth keys on Headscale 0.28+ (via [#465](https://github.com/tale/headplane/pull/465)).
|
||||
- Pre-auth keys are now listed without a user filter on Headscale 0.28+, with a fallback to per-user fetching on older versions (via [#466](https://github.com/tale/headplane/pull/466)).
|
||||
- Fixed handling of tag-only nodes that have no user on Headscale 0.28+ (via [#467](https://github.com/tale/headplane/pull/467)).
|
||||
- Adapted to the removal of Node Ownership Change in Headscale 0.28 (via [#436](https://github.com/tale/headplane/pull/436)).
|
||||
- Fixed pre-auth keys not showing for OIDC users without a username (via [#470](https://github.com/tale/headplane/pull/470)).
|
||||
- Fixed truncated pre-auth key display with longer Headscale 0.28 bcrypt tokens (closes [#435](https://github.com/tale/headplane/issues/435)).
|
||||
- Fixed Nix systemd service to use user-specified package (via [#454](https://github.com/tale/headplane/pull/454)).
|
||||
- Version displayed in the UI is now derived from git tags and build args instead of `package.json`, fixing incorrect versions shown on beta and nightly builds.
|
||||
- Improved the no-access user page on the UI (via [#469](https://github.com/tale/headplane/pull/469)).
|
||||
|
||||
---
|
||||
|
||||
# 0.6.1 (October 12, 2025)
|
||||
|
||||
- **Headplane now supports connecting to machines via SSH in the web browser.**
|
||||
- This is an experimental feature and requires the `integration.agent` section to be set up in the config file.
|
||||
- This is built on top of a Go binary that runs in WebAssembly, using Xterm.js for the terminal interface.
|
||||
- Begin using a new SQLite database file in `/var/lib/headplane/hp_persist.db`.
|
||||
- The database is created automatically if it does not exist.
|
||||
- It currently stores SSH connection details and HostInfo for the agent.
|
||||
- User information is automatically migrated from the previous database.
|
||||
- The docker container now runs in a distroless image (closes [#255](https://github.com/tale/headplane/issues/255)).
|
||||
- A debug version of the container that runs as root and has a shell is available as `ghcr.io/tale/headplane:<version>-shell`.
|
||||
- Removing a Split DNS record will no longer make the split domain unresolvable by clients (closes [#231](https://github.com/tale/headplane/issues/231)).
|
||||
- Reintroduce the toggle for overriding local DNS settings in the Headscale config (closes [#236](https://github.com/tale/headplane/issues/236)).
|
||||
- Prefer cross-compiling in the Dockerfile to speed up builds while still supporting multiple architectures.
|
||||
- Add a build attestation to validate SLSA provenance for the Docker image.
|
||||
- Implement more accurate guessing on the PID with the `/proc` integration (via [#219](https://github.com/tale/headplane/pull/219)).
|
||||
- Usernames will now correctly fall back to emails if not provided (via [#257](https://github.com/tale/headplane/pull/257)).
|
||||
- Configuration loading via paths is now supported for sensitive values (via [#283](https://github.com/tale/headplane/pulls/283))
|
||||
- Options like `server.cookie_secret_path` can override `server.cookie_secret`
|
||||
- Environment variables are interpolatable into these paths
|
||||
- See the full reference in the [docs](https://github.com/tale/headplane/blob/main/docs/Configuration.md#sensitive-values)
|
||||
- The nix overlay build is fixed for the SSH module (via [#282](https://github.com/tale/headplane/pull/282))
|
||||
- Switch our build processes to use TypeScript Go and Rolldown Vite for better build and type-check performance.
|
||||
- Cookies are now encrypted JWTs, preserving API key secrets (_GHSA-wrqq-v7qw-r5w7_)
|
||||
- OIDC profile pictures are now available from Gravatar by setting `oidc.profile_picture_source` to `gravatar` (closes [#232](https://github.com/tale/headplane/issues/232)).
|
||||
- OIDC now allows passing many custom parameters:
|
||||
- `oidc.authorization_endpoint`, `oidc.token_endpoint`, and `oidc.userinfo_endpoint` can be overridden to support non-standard providers or scenarios without discovery (closes [#117](https://github.com/tale/headplane/issues/117)).
|
||||
- `oidc.scope` can be set to specify custom scopes (defaults to `openid email profile`).
|
||||
- `oidc.extra_params` can be set to pass arbitrary query parameters to the authorization endpoint (closes [#197](https://github.com/tale/headplane/issues/197)).
|
||||
|
||||
---
|
||||
|
||||
# 0.6.0 (May 25, 2025)
|
||||
|
||||
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.
|
||||
- Breaking API changes with routes and pre auth keys are now supported (closes [#204](https://github.com/tale/headplane/issues/204)).
|
||||
- Older versions of Headscale will not work with Headplane.
|
||||
|
||||
- OIDC authorization restrictions can now be controlled from the settings UI. (closes [#102](https://github.com/tale/headplane/issues/102))
|
||||
- The required permission role for this is **IT Admin** or **Admin/Owner** and require the Headscale configuration.
|
||||
- Changes made will modify the `oidc.allowed_{domains,groups,users}` fields in the Headscale config file.
|
||||
- The Pre-Auth keys page has been fully reworked (closes [#179](https://github.com/tale/headplane/issues/179), [#143](https://github.com/tale/headplane/issues/143)).
|
||||
- The Headplane agent is now available as an integration (closes [#65](https://github.com/tale/headplane/issues/65)).
|
||||
- The agent runs as an embedded process alongside the Headplane server and reports host information and system metrics.
|
||||
- Refer to the `integrations.agent` section of the config file for more information and how to enable it.
|
||||
- Requests to `/admin` will now be redirected to `/admin/` to prevent issues with the React Router (works with custom prefixes, closes [#173](https://github.com/tale/headplane/issues/173)).
|
||||
- The Login page has been simplified and separately reports errors versus incorrect API keys (closes [#186](https://github.com/tale/headplane/issues/186)).
|
||||
- The machine actions backend has been reworked to better handle errors and provide more information to the user (closes [#185](https://github.com/tale/headplane/issues/185)).
|
||||
- Machine tags now show states when waiting for subnet or exit node approval and when expiry is disabled.
|
||||
- Expiry status on the UI was incorrectly showing as never due to changes in the Headscale API.
|
||||
- Added validation for machine renaming to prevent invalid submissions (closes [#192](https://github.com/tale/headplane/issues/192)).
|
||||
- Unmanaged (non-OIDC) users cannot have a role assigned to them so the menu option was disabled.
|
||||
- Support Docker container discovery through labels (via [#194](https://github.com/tale/headplane/pull/194)).
|
||||
- AAAA records are now supported on the DNS page (closes [#189](https://github.com/tale/headplane/issues/189)).
|
||||
- Add support for `dns.extra_records_path` in the Headscale config (closes [#144](https://github.com/tale/headplane/issues/144)).
|
||||
- Tighten `proc` integration logic by checking for the `headscale serve` command (via #[195](https://github.com/tale/headplane/pull/195)).
|
||||
- Strip newlines in the OIDC `client_secret_path` file if provided (closes [#199](https://github.com/tale/headplane/issues/199)).
|
||||
|
||||
---
|
||||
|
||||
# 0.5.10 (April 4, 2025)
|
||||
|
||||
- Fix an issue where other preferences to skip onboarding affected every user.
|
||||
|
||||
---
|
||||
|
||||
# 0.5.9 (April 3, 2025)
|
||||
|
||||
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.
|
||||
- OIDC users cannot be renamed, so that functionality has been disabled in the menu options.
|
||||
- Suppress hydration errors for any fields with a date in it.
|
||||
|
||||
---
|
||||
|
||||
# 0.5.8 (April 3, 2025)
|
||||
|
||||
- You can now skip the onboarding page if desired.
|
||||
- Added the UI to change user roles in the dashboard.
|
||||
- Fixed an issue where integrations would throw instead of loading properly.
|
||||
- Loading the ACL page no longer spams blank updates to the Headscale database (fixes [#151](https://github.com/tale/headplane/issues/151))
|
||||
- Automatically create `/var/lib/headplane` in the Docker container (fixes [#166](https://github.com/tale/headplane/issues/166))
|
||||
- OIDC logout with `disable_api_key_login` set to true will not automatically login again (fixes [#149](https://github.com/tale/headplane/issues/149))
|
||||
|
||||
---
|
||||
|
||||
# 0.5.7 (April 2, 2025)
|
||||
|
||||
- Hotfix an issue where assets aren't served under `/admin` or the prefix.
|
||||
|
||||
---
|
||||
|
||||
# 0.5.6 (April 2, 2025)
|
||||
|
||||
### IMPORTANT
|
||||
|
||||
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously _ANY_ accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
|
||||
|
||||
Implemented _proper_ authentication methods for OIDC.
|
||||
This is a large update and copies the permission system from Tailscale.
|
||||
Permissions are not automatically derived from OIDC, but they can be configured via the UI.
|
||||
Additionally, certain roles give certain capabilities, limiting access to parts of the dashboard.
|
||||
By default, new users will have a `member` role which forbids access to the UI.
|
||||
If there are no users, the first user will be given an `owner` role which cannot be removed.
|
||||
|
||||
**Changes**:
|
||||
|
||||
- Switched the internal server to use `hono` for better performance.
|
||||
- Fixed an issue that caused dialogs to randomly refocus every 3 seconds.
|
||||
- Headplane will not send API requests when the tab is not focused.
|
||||
- Continue loosening the configuration requirements for Headscale (part of an ongoing effort).
|
||||
- Unknown values in the Headplane config no longer cause a crash.
|
||||
- Fixed an issue that caused copied commands to have a random space (fixes [#161](https://github.com/tale/headplane/issues/161))
|
||||
|
||||
---
|
||||
|
||||
# 0.5.5 (March 18, 2025)
|
||||
|
||||
- Hotfix an issue that caused Headplane to crash if no agents are available
|
||||
|
||||
---
|
||||
|
||||
# 0.5.4 (March 18, 2025)
|
||||
|
||||
- Fixed a typo in the Kubernetes documentation
|
||||
- Handle split and global DNS records not being set in the Headscale config (via [#129](https://github.com/tale/headplane/pull/129))
|
||||
- Stop checking for the `mkey:` prefix on machine registration (via [#131](https://github.com/tale/headplane/pull/131))
|
||||
- OIDC auth was not using information from the `user_info` endpoint.
|
||||
- Support the picture of the user who is logged in via OIDC if available.
|
||||
- Rewrote the Agent implementation to better utilize disk space and perform better (coming soon).
|
||||
- Loosened checking for the Headscale configuration as it was too strict and required certain optional fields.
|
||||
- Deleting a node will now correctly redirect back to the nodes page (fixes [#137](https://github.com/tale/headplane/issues/137))
|
||||
- Supports connecting to Headscale via TLS and can accept a certificate file (partially fixes [#82](https://github.com/tale/headplane/issues/82))
|
||||
- Add support for running Headplane through Nix, though currently unsupported (via [#132](https://github.com/tale/headplane/pull/132))
|
||||
- You can now pass in an OIDC client secret through `oidc.client_secret_path` in the config (fixes [#126](https://github.com/tale/headplane/issues/126))
|
||||
- Correctly handle differently localized number inputs (fixes [#125](https://github.com/tale/headplane/issues/125))
|
||||
|
||||
---
|
||||
|
||||
# 0.5.3 (March 1, 2025)
|
||||
|
||||
- Fixed an issue where Headplane expected the incorrect config value for OIDC scope (fixes [#111](https://github.com/tale/headplane/issues/111))
|
||||
- Added an ARIA indicator for when an input is required and fixed the confirm buttons (fixed [#116](https://github.com/tale/headplane/issues/116))
|
||||
- Fixed a typo in the docs that defaulted to `/var/run/docker.dock` for the Docker socket (via [#112](https://github.com/tale/headplane/pull/112))
|
||||
|
||||
---
|
||||
|
||||
# 0.5.2 (February 28, 2025)
|
||||
|
||||
- Hotfixed an issue where the server bundle got reloaded on each request
|
||||
|
||||
---
|
||||
|
||||
# 0.5.1 (February 28, 2025)
|
||||
|
||||
- Fixed an issue that caused the entire server to crash on start
|
||||
- Fixed the published semver tags from Docker
|
||||
- Fixed the Kubernetes integration not reading the config
|
||||
|
||||
---
|
||||
|
||||
# 0.5 (February 27, 2025)
|
||||
|
||||
> This release is a major overhaul and contains a significant breaking change.
|
||||
> We now use a config file for all settings instead of environment variables.
|
||||
> Please see [config.example.yaml](/config.example.yaml) for the new format.
|
||||
|
||||
- Completely redesigned the UI from the ground up for accessibility and performance.
|
||||
- Switched to a config-file setup (this introduces breaking changes, see [config.example.yaml](/config.example.yaml) for the new format).
|
||||
- If the config is read-only, the options are still visible, just disabled (fixes [#48](https://github.com/tale/headplane/issues/48))
|
||||
- Added support for Headscale 0.25.0 (this drops support for any older versions).
|
||||
- Fixed issues where renaming, deleting, and changing node owners via users was not possible (fixes [#91](https://github.com/tale/headplane/issues/91))
|
||||
- Operations now have significantly less moving parts and better error handling.
|
||||
- Updated to `pnpm` 10 and Node.js 22.
|
||||
- Settings that were previously shared like `public_url` or `oidc` are now separate within Headplane/Headscale. This is a rather large breaking change but fixes cases where a user may choose to utilize Headscale OIDC for Tailscale but not for the Headplane UI.
|
||||
- Deprecate the `latest` tag in Docker for explicit versioning and `edge` for nightly builds.
|
||||
|
||||
---
|
||||
|
||||
# 0.4.1 (January 18, 2025)
|
||||
|
||||
- Fixed an urgent issue where the OIDC redirect URI would mismatch.
|
||||
|
||||
---
|
||||
|
||||
# 0.4.0 (January 18, 2025)
|
||||
|
||||
- Switched from Remix.run to React-Router
|
||||
- Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66))
|
||||
- Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
|
||||
- The footer now blurs your Headscale URL as a privacy measure
|
||||
- Updated to the next stable beta of the React Compiler
|
||||
- Changed `/healthz` to use a well-known endpoint instead of trying an invalid API key
|
||||
- Support `OIDC_REDIRECT_URI` to force a specific redirect URI
|
||||
- Redo the OIDC integration for better error handling and configuration
|
||||
- Gracefully handle when Headscale is unreachable instead of crashing the dashboard
|
||||
- Reusable Pre-Auth Keys no longer show expired when used (PR [#88](https://github.com/tale/headplane/pull/88))
|
||||
- Tweaked some CSS issues in the UI
|
||||
|
||||
---
|
||||
|
||||
# 0.3.9 (December 6, 2024)
|
||||
|
||||
- Fixed a race condition bug in the OIDC validation code
|
||||
|
||||
---
|
||||
|
||||
# 0.3.8 (December 6, 2024)
|
||||
|
||||
- Added a little HTML footer to show the login page and link to a donation page.
|
||||
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
|
||||
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
|
||||
|
||||
---
|
||||
|
||||
# 0.3.7 (November 30, 2024)
|
||||
|
||||
- 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.
|
||||
@@ -8,70 +324,106 @@
|
||||
- Added full support for Exit Nodes in the UI and redesigned the machines page (fixes [#36](https://github.com/tale/headplane/issues/36))
|
||||
- Added a basic check to see if the API keys passed via cookies are invalid.
|
||||
|
||||
### 0.3.3 (October 28, 2024)
|
||||
---
|
||||
|
||||
# 0.3.3 (October 28, 2024)
|
||||
|
||||
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
|
||||
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
|
||||
- Improved ACL editor performance by switching back to CodeMirror 6
|
||||
- Fixed an issue where editing the ACL policy would cause it to revert on the UI (fixes [#34](https://github.com/tale/headplane/issues/34))
|
||||
- Updated to the next stable beta of the React 19 Compiler ([See More](https://react.dev/learn/react-compiler))
|
||||
|
||||
### 0.3.2 (October 11, 2024)
|
||||
---
|
||||
|
||||
# 0.3.2 (October 11, 2024)
|
||||
|
||||
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
|
||||
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
|
||||
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
|
||||
- Handle `null` values on machine expiry when using a database like PostgreSQL.
|
||||
- Use `X-Forwarded-Proto` and `Host` headers for building the OIDC callback URL.
|
||||
|
||||
### 0.3.1 (October 3, 2024)
|
||||
---
|
||||
|
||||
# 0.3.1 (October 3, 2024)
|
||||
|
||||
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
|
||||
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
|
||||
- Added the option for debug logs with the `DEBUG=true` environment variable.
|
||||
|
||||
### 0.3.0 (September 25, 2024)
|
||||
---
|
||||
|
||||
# 0.3.0 (September 25, 2024)
|
||||
|
||||
- Bumped the minimum supported version of Headscale to 0.23.
|
||||
- Updated the UI to respect `dns.use_username_in_magic_dns`.
|
||||
|
||||
### 0.2.4 (August 24, 2024)
|
||||
---
|
||||
|
||||
# 0.2.4 (August 24, 2024)
|
||||
|
||||
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
|
||||
- Removed the `ACL_FILE` environment variable since it's no longer needed.
|
||||
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
|
||||
- Fixed a bug where removing Split DNS configurations would crash the UI.
|
||||
|
||||
### 0.2.3 (August 23, 2024)
|
||||
---
|
||||
|
||||
# 0.2.3 (August 23, 2024)
|
||||
|
||||
- Change the minimum required version of Headscale to 0.23-beta2
|
||||
- Support the new API policy mode for Headscale 0.23-beta1
|
||||
- Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29))
|
||||
- If OIDC environment variables are defined, don't use configuration file values (fixes [#24](https://github.com/tale/headplane/issues/24))
|
||||
|
||||
### 0.2.2 (August 2, 2024)
|
||||
---
|
||||
|
||||
# 0.2.2 (August 2, 2024)
|
||||
|
||||
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
|
||||
- Added a new logger utility that shows categories, levels, and timestamps.
|
||||
- Reimplemented the integration system to be more resilient and log more information.
|
||||
- Fixed an issue where the /proc integration found `undefined` PIDs.
|
||||
|
||||
### 0.2.1 (July 7, 2024)
|
||||
---
|
||||
|
||||
# 0.2.1 (July 7, 2024)
|
||||
|
||||
- Added the ability to manage custom DNS records on your Tailnet.
|
||||
- ACL tags for machines are now able to be changed via the machine menu.
|
||||
- Fixed a bug where the ACL editor did not show the diffs correctly.
|
||||
- Fixed an issue that stopped the "Discard changes" button in the ACL editor from working.
|
||||
|
||||
### 0.2.0 (June 23, 2024)
|
||||
---
|
||||
|
||||
# 0.2.0 (June 23, 2024)
|
||||
|
||||
- Fix the dropdown options for machines not working on the machines page.
|
||||
- Add an option to change the machine owner in the dropdown (aside from the users page).
|
||||
|
||||
### 0.1.9 (June 2, 2024)
|
||||
---
|
||||
|
||||
# 0.1.9 (June 2, 2024)
|
||||
|
||||
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
|
||||
- Utilize magic DNS hostnames for the machine overview page.
|
||||
- Fixed the expiry issue once and for all.
|
||||
- Add a nightly build with the `ghcr.io/tale/headplane:edge` tag
|
||||
|
||||
### 0.1.8 (June 2, 2024)
|
||||
---
|
||||
|
||||
# 0.1.8 (June 2, 2024)
|
||||
|
||||
- Built basic functionality for the machine overview page (by machine ID).
|
||||
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
|
||||
- Prevent users from being deleted if they still have ownership of machines.
|
||||
- Fixed some type issues where `Date` was being used instead of `string` for timestamps.
|
||||
|
||||
### 0.1.7 (May 30, 2024)
|
||||
---
|
||||
|
||||
# 0.1.7 (May 30, 2024)
|
||||
|
||||
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
|
||||
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
|
||||
- Made the strict configuration loader more lenient to allow for more flexibility.
|
||||
@@ -79,19 +431,27 @@
|
||||
- Headplane's context now only loads once at start instead of being lazy-loaded.
|
||||
- Improved logging and error propagation so that it's easier to debug issues.
|
||||
|
||||
### 0.1.6 (May 22, 2024)
|
||||
---
|
||||
|
||||
# 0.1.6 (May 22, 2024)
|
||||
|
||||
- Added experimental support for advanced integration without Docker.
|
||||
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
|
||||
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
|
||||
|
||||
### 0.1.5 (May 20, 2024)
|
||||
---
|
||||
|
||||
# 0.1.5 (May 20, 2024)
|
||||
|
||||
- Robust configuration handling with fallbacks based on the headscale source.
|
||||
- Support for `client_secret_path` on configuration file based OIDC.
|
||||
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
|
||||
- `API_KEY` is renamed to `ROOT_API_KEY` for better clarity (old variable still works).
|
||||
- Fixed button responders not actually being invoked (should fix the ACL page).
|
||||
|
||||
### 0.1.4 (May 15, 2024)
|
||||
---
|
||||
|
||||
# 0.1.4 (May 15, 2024)
|
||||
|
||||
- Users can now be created, renamed, and deleted on the users page.
|
||||
- Machines can be dragged between users to change their ownership.
|
||||
@@ -100,13 +460,17 @@
|
||||
- Upgrade to React 19 beta to take advantage of the compiler (may revert if it causes issues).
|
||||
- Upgrade other dependencies
|
||||
|
||||
### 0.1.3 (May 4, 2024)
|
||||
---
|
||||
|
||||
# 0.1.3 (May 4, 2024)
|
||||
|
||||
- Switched to a better icon set for the UI.
|
||||
- Support stable scrollbar gutter if supported by the browser.
|
||||
- Cleaned up the header which fixed a bug that could crash the entire application on fetch errors.
|
||||
|
||||
### 0.1.2 (May 1, 2024)
|
||||
---
|
||||
|
||||
# 0.1.2 (May 1, 2024)
|
||||
|
||||
- Added support for renaming, expiring, removing, and managing the routes of a machine.
|
||||
- Implemented an expiry check for machines which now reflect on the machine table.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
https://localhost {
|
||||
reverse_proxy headscale:8080
|
||||
tls /certs/localhost.pem /certs/localhost-key.pem
|
||||
|
||||
header {
|
||||
Access-Control-Allow-Origin *
|
||||
Access-Control-Allow-Headers *
|
||||
Access-Control-Allow-Methods GET, POST, OPTIONS
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,75 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.1 AS go-base
|
||||
WORKDIR /run
|
||||
|
||||
RUN npm install -g pnpm
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY go.mod go.sum build.sh ./
|
||||
RUN go mod download
|
||||
|
||||
COPY cmd/ ./cmd/
|
||||
COPY internal/ ./internal/
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG IMAGE_TAG
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 IMAGE_TAG=$IMAGE_TAG \
|
||||
./build.sh --wasm --agent --fake-shell --healthcheck \
|
||||
--wasm-output /bin/hp_ssh.wasm \
|
||||
--agent-output /bin/hp_agent \
|
||||
--fake-shell-output /bin/fake-sh \
|
||||
--healthcheck-output /bin/hp_healthcheck
|
||||
|
||||
RUN chmod +x /bin/hp_ssh.wasm
|
||||
RUN chmod +x /bin/hp_agent
|
||||
RUN chmod +x /bin/fake-sh
|
||||
RUN chmod +x /bin/hp_healthcheck
|
||||
|
||||
# Folder needs to exist for later stages
|
||||
RUN mkdir -p /var/lib/headplane/agent
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:24-slim AS js-base
|
||||
WORKDIR /run
|
||||
|
||||
RUN corepack enable
|
||||
COPY patches ./patches
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY package.json pnpm-lock.yaml build.sh ./
|
||||
|
||||
COPY --from=go-base /bin/hp_ssh.wasm /run/public/hp_ssh.wasm
|
||||
COPY --from=go-base /bin/wasm_exec.js /run/public/wasm_exec.js
|
||||
RUN ./build.sh --app --app-install-only
|
||||
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
RUN pnpm prune --prod
|
||||
ARG HEADPLANE_VERSION
|
||||
RUN HEADPLANE_VERSION=$HEADPLANE_VERSION ./build.sh --app
|
||||
|
||||
FROM gcr.io/distroless/nodejs24-debian13:latest AS final
|
||||
COPY --from=js-base /run/build /app/build
|
||||
COPY --from=js-base /run/drizzle /app/drizzle
|
||||
|
||||
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
|
||||
COPY --from=go-base /var/lib/headplane /var/lib/headplane
|
||||
|
||||
# Fake shell to inform the user that they should use the debug image
|
||||
COPY --from=go-base /bin/fake-sh /bin/sh
|
||||
COPY --from=go-base /bin/fake-sh /bin/bash
|
||||
|
||||
COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD ["/bin/hp_healthcheck"]
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/build /app/build
|
||||
COPY --from=build /app/node_modules /app/node_modules
|
||||
RUN echo '{"type":"module"}' > /app/package.json
|
||||
CMD [ "/app/build/server/index.js" ]
|
||||
|
||||
EXPOSE 3000
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
CMD [ "node", "./build/headplane/server.js" ]
|
||||
FROM node:24-alpine AS debug-shell
|
||||
RUN apk add --no-cache bash curl
|
||||
|
||||
COPY --from=js-base /run/build /app/build
|
||||
COPY --from=js-base /run/drizzle /app/drizzle
|
||||
|
||||
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
|
||||
COPY --from=go-base /var/lib/headplane /var/lib/headplane
|
||||
COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD ["/bin/hp_healthcheck"]
|
||||
|
||||
WORKDIR /app
|
||||
CMD [ "node", "/app/build/server/index.js" ]
|
||||
|
||||
@@ -1,45 +1,79 @@
|
||||
# Headplane
|
||||
> An advanced UI for [juanfont/headscale](https://github.com/juanfont/headscale)
|
||||
> A feature-complete web UI for [Headscale](https://headscale.net)
|
||||
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="./assets/preview-dark.png"
|
||||
srcset="./docs/assets/preview-dark.png"
|
||||
>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="./assets/preview-light.png"
|
||||
srcset="./docs/assets/preview-light.png"
|
||||
>
|
||||
<img
|
||||
alt="Preview"
|
||||
src="./assets/preview-dark.png"
|
||||
src="./docs/assets/preview-dark.png"
|
||||
>
|
||||
</picture>
|
||||
|
||||
Headscale is a self-hosted version of the Tailscale control server, however, it currently lacks a first-party web UI.
|
||||
Headplane aims to solve this issue by providing a GUI that can deeply integrate with the Headscale server.
|
||||
It's able to replicate nearly all of the functions of the official Tailscale SaaS UI, including:
|
||||
Headscale is the de-facto self-hosted version of Tailscale, a popular Wireguard
|
||||
based VPN service. By default, it does not ship with a web UI, which is where
|
||||
Headplane comes in. Headplane is a feature-complete web UI for Headscale, allowing
|
||||
you to manage your nodes, networks, and ACLs with ease.
|
||||
|
||||
- Machine/Node expiry, network routing, name, and owner management
|
||||
- Access Control List (ACL) and tagging configuration
|
||||
Headplane aims to replicate the functionality offered by the official Tailscale
|
||||
product and dashboard, being one of the most feature complete Headscale UIs available.
|
||||
These are some of the features that Headplane offers:
|
||||
|
||||
- Machine management, including expiry, network routing, name, and owner management
|
||||
- Access Control List (ACL) and tagging configuration for ACL enforcement
|
||||
- Support for OpenID Connect (OIDC) as a login provider
|
||||
- DNS and *safe* Headscale configuration management
|
||||
- The ability to edit DNS settings and automatically provision Headscale
|
||||
- Configurability for Headscale's settings
|
||||
|
||||
## Deployment
|
||||
> For more configuration options, refer to the [Configuration](/docs/Configuration.md) guide.
|
||||
Refer to the [website](https://headplane.net) for detailed installation instructions.
|
||||
|
||||
For fully-featured deployments, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
|
||||
This includes automatic management of ACLs, DNS settings, and Headscale configuration.
|
||||
*This is the closest experience to the Tailscale UI that can be achieved with Headscale and Headplane.*
|
||||
*If you aren't sure which one to pick, we recommend this.*
|
||||
|
||||
If your environment is not able to support the advanced deployment, you can still use the basic deployment.
|
||||
For basic deployments, see the [Basic Deployment](/docs/Basic-Integration.md) guide.
|
||||
It does not include automatic management of ACLs, DNS settings, or the Headscale configuration,
|
||||
instead requiring manual editing and reloading when making changes.
|
||||
## Versioning
|
||||
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0).
|
||||
Pre-release builds are available under the `next` tag and get updated when a new release
|
||||
PR is opened and actively in testing.
|
||||
|
||||
## Contributing
|
||||
If you would like to contribute, please install a relatively modern version of Node.js and PNPM.
|
||||
Clone this repository, run `pnpm install`, and then run `pnpm dev` to start the development server.
|
||||
Headplane is an open-source project and contributions are welcome! If you have
|
||||
any suggestions, bug reports, or feature requests, please open an issue. Also
|
||||
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
|
||||
|
||||
> Copyright (c) 2024 Aarnav Tale
|
||||
---
|
||||
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="./docs/assets/acls-dark.png"
|
||||
>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="./docs/assets/acls-light.png"
|
||||
>
|
||||
<img
|
||||
alt="ACLs"
|
||||
src="./docs/assets/acls-dark.png"
|
||||
>
|
||||
</picture>
|
||||
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="./docs/assets/machine-dark.png"
|
||||
>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="./docs/assets/machine-light.png"
|
||||
>
|
||||
<img
|
||||
alt="Machine Management"
|
||||
src="./docs/assets/machine-dark.png"
|
||||
>
|
||||
</picture>
|
||||
|
||||
> Copyright (c) 2025 Aarnav Tale
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 63 36" height="36" width="63" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M47.0239 10.6522L52.1669 1.64895C52.4571 1.14423 52.2816 0.503129 51.7889 0.209825C51.2962 -0.0834073 50.655 0.0871143 50.3716 0.59178L45.161 9.71096C41.1856 7.87622 36.7108 6.8531 31.8985 6.8531C27.0861 6.8531 22.6113 7.87622 18.6359 9.71096L13.4253 0.59178C13.1351 0.0870609 12.5007 -0.0834429 12.0012 0.209825C11.5018 0.50304 11.333 1.14423 11.6232 1.64895L16.7663 10.6522C7.89757 15.5085 1.89061 24.5799 0.898438 35.1997H62.8984C61.9063 24.5799 55.8993 15.5085 47.0238 10.6522H47.0239ZM17.664 26.4011C16.2264 26.4011 15.0655 25.2211 15.0655 23.7751C15.0655 22.3223 16.2331 21.1492 17.664 21.1492C19.1016 21.1492 20.2625 22.3292 20.2625 23.7751C20.2695 25.2211 19.1016 26.4011 17.664 26.4011ZM46.1262 26.4011C44.6886 26.4011 43.5277 25.2211 43.5277 23.7751C43.5277 22.3223 44.6953 21.1492 46.1262 21.1492C47.5638 21.1492 48.7247 22.3292 48.7247 23.7751C48.7318 25.2211 47.5638 26.4011 46.1262 26.4011Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 39 46" height="46" width="39" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M21.6443 4.03452C24.6748 0.0855621 28.8877 0.0663452 28.8877 0.0663452C28.8877 0.0663452 29.5143 3.779 26.5038 7.35551C23.2893 11.1745 19.6355 10.5496 19.6355 10.5496C19.6355 10.5496 18.9495 7.54621 21.6443 4.03452V4.03452ZM20.021 13.1503C21.58 13.1503 24.4733 11.0315 28.2395 11.0315C34.7224 11.0315 37.2728 15.5923 37.2728 15.5923C37.2728 15.5923 32.2847 18.1137 32.2847 24.2317C32.2847 31.1335 38.4984 33.5121 38.4984 33.5121C38.4984 33.5121 34.1548 45.5994 28.2878 45.5994C25.5931 45.5994 23.4982 43.8041 20.6589 43.8041C17.7656 43.8041 14.8944 45.6663 13.0241 45.6663C7.66679 45.6665 0.898438 34.2004 0.898438 24.9837C0.898438 15.9156 6.6274 11.1585 12.0009 11.1585C15.4941 11.1585 18.2049 13.1503 20.021 13.1503V13.1503Z" clip-rule="evenodd" fill-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 903 B |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 55 40" height="40" width="55" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M0.613281 0.225403H54.3397V39.0278H0.613281V0.225403ZM8.36288 8.47665C8.75143 8.08811 9.38138 8.08811 9.76993 8.47665L14.7446 13.4513C15.1331 13.8399 15.1331 14.4698 14.7446 14.8584L9.76993 19.833C9.38138 20.2216 8.75143 20.2216 8.36288 19.833C7.97434 19.4445 7.97434 18.8145 8.36288 18.426L12.634 14.1548L8.36288 9.8837C7.97434 9.49515 7.97434 8.8652 8.36288 8.47665ZM16.534 22.1143C15.9845 22.1143 15.5391 22.5597 15.5391 23.1092C15.5391 23.6587 15.9845 24.1041 16.534 24.1041H25.4884C26.0379 24.1041 26.4833 23.6587 26.4833 23.1092C26.4833 22.5597 26.0379 22.1143 25.4884 22.1143H16.534Z" clip-rule="evenodd" fill-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 767 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="44" height="44" viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.7588 27.4022C22.0336 22.6094 24.079 14.802 28.5236 6H6.00006C3.79092 6 2.00006 7.79086 2.00006 10V42C2.00006 44.2092 3.79092 46 6.00006 46H28.735C28.3672 43.6252 28.2408 41.2548 28.2502 39.0346C27.353 39.1516 26.4424 39.2148 25.5294 39.2148C22.3036 39.2148 19.106 38.427 16.4322 37.2544C13.7899 36.0954 11.5029 34.4902 10.2351 32.7296C9.76718 32.0798 9.91469 31.1736 10.5647 30.7056C11.2145 30.2378 12.1207 30.3852 12.5885 31.0352C13.4385 32.2156 15.2105 33.5518 17.5972 34.5986C19.9528 35.6318 22.7552 36.3148 25.5294 36.3148C26.4688 36.3148 27.4114 36.2364 28.339 36.0926C28.4724 33.65 28.7306 31.5148 28.9364 29.9336H24.1764C22.8458 29.9336 21.6766 28.8396 21.7588 27.4022ZM31.156 38.4622C32.3768 38.1404 33.5446 37.729 34.6266 37.2544C37.2692 36.0954 39.5562 34.4902 40.8238 32.7296C41.2918 32.0798 41.1442 31.1736 40.4944 30.7056C39.8444 30.2378 38.9382 30.3852 38.4704 31.0352C37.6204 32.2156 35.8486 33.5518 33.4618 34.5986C32.7714 34.9014 32.0426 35.174 31.2876 35.4082C31.4398 33.1888 31.688 31.2466 31.8794 29.7964C32.073 28.328 30.9296 27.0336 29.4554 27.0336H24.692C25.0766 22.4392 27.179 14.7405 31.7868 6H46C48.2092 6 50 7.79086 50 10V42C50 44.2092 48.2092 46 46 46H31.6716C31.2398 43.446 31.1176 40.8638 31.156 38.4622ZM15.0001 14.55C15.8009 14.55 16.4501 15.1992 16.4501 16V20.7059C16.4501 21.5067 15.8009 22.1558 15.0001 22.1558C14.1993 22.1558 13.5501 21.5067 13.5501 20.7059V16C13.5501 15.1992 14.1993 14.55 15.0001 14.55ZM38.45 16C38.45 15.1992 37.8008 14.55 37 14.55C36.1992 14.55 35.55 15.1992 35.55 16V20.7059C35.55 21.5067 36.1992 22.1558 37 22.1558C37.8008 22.1558 38.45 21.5067 38.45 20.7059V16Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 46 46" height="46" width="46" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M0.296875 6.56799L18.9443 4.0173V21.9762H0.296875V6.56799ZM21.1903 3.66333L45.8969 0.0663452V21.8252H21.1903V3.66333ZM0.296875 23.8762H18.9443V41.8872L0.296875 39.2844V23.8762ZM21.1903 24.1157H45.8969V45.6663L21.1903 42.1787" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 361 B |
@@ -1,48 +0,0 @@
|
||||
import { CopyIcon } from '@primer/octicons-react'
|
||||
|
||||
import { toast } from './Toaster'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
value: string
|
||||
isCopyable?: boolean
|
||||
link?: string
|
||||
}
|
||||
|
||||
export default function Attribute({ name, value, link, isCopyable }: Props) {
|
||||
const canCopy = isCopyable ?? false
|
||||
return (
|
||||
<dl className="flex gap-1 text-sm w-full">
|
||||
<dt className="w-1/2 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1">
|
||||
{link ? (
|
||||
<a className="hover:underline" href={link}>
|
||||
{name}
|
||||
</a>
|
||||
) : name}
|
||||
</dt>
|
||||
|
||||
{canCopy
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md"
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(value)
|
||||
toast(`Copied ${name}`)
|
||||
}}
|
||||
>
|
||||
<dd className="min-w-0 truncate px-2 py-1">
|
||||
{value}
|
||||
</dd>
|
||||
<CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" />
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<dd className="min-w-0 truncate px-2 py-1">
|
||||
{value}
|
||||
</dd>
|
||||
)}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { type Dispatch, type SetStateAction } from 'react'
|
||||
import { Button as AriaButton } from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
readonly variant?: 'heavy' | 'light'
|
||||
}
|
||||
|
||||
export default function Button(properties: ButtonProperties) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...properties}
|
||||
className={cn(
|
||||
'w-fit text-sm rounded-lg px-4 py-2',
|
||||
properties.variant === 'heavy'
|
||||
? 'bg-main-700 dark:bg-main-800'
|
||||
: 'bg-main-200 dark:bg-main-700/30',
|
||||
properties.variant === 'heavy'
|
||||
? 'hover:bg-main-800 dark:hover:bg-main-700'
|
||||
: 'hover:bg-main-300 dark:hover:bg-main-600/30',
|
||||
properties.variant === 'heavy'
|
||||
? 'text-white'
|
||||
: 'text-ui-700 dark:text-ui-300',
|
||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
properties.className,
|
||||
)}
|
||||
// If control is passed, set the state value
|
||||
onPress={properties.control
|
||||
? () => {
|
||||
properties.control?.[1](true)
|
||||
}
|
||||
: properties.onPress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { type HTMLProps } from 'react'
|
||||
import { Heading as AriaHeading } from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
function Title(properties: Parameters<typeof AriaHeading>[0]) {
|
||||
return (
|
||||
<AriaHeading
|
||||
{...properties}
|
||||
slot='title'
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-6 mb-5',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
{...properties}
|
||||
className={cn(
|
||||
'text-base leading-6 my-0',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type Properties = HTMLProps<HTMLDivElement> & {
|
||||
variant?: 'raised' | 'flat';
|
||||
}
|
||||
|
||||
function Card(properties: Properties) {
|
||||
return (
|
||||
<div
|
||||
{...properties}
|
||||
className={cn(
|
||||
'w-full max-w-md overflow-hidden rounded-xl p-4',
|
||||
properties.variant === 'flat'
|
||||
? 'bg-transparent shadow-none'
|
||||
: 'bg-ui-50 dark:bg-ui-900 shadow-sm',
|
||||
'border border-ui-200 dark:border-ui-700',
|
||||
properties.className
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(Card, { Title, Text })
|
||||
@@ -1,12 +0,0 @@
|
||||
import clsx from 'clsx'
|
||||
import { type HTMLProps } from 'react'
|
||||
|
||||
type Properties = HTMLProps<HTMLSpanElement>
|
||||
|
||||
export default function Code(properties: Properties) {
|
||||
return (
|
||||
<code className={clsx('bg-gray-100 dark:bg-zinc-700 p-0.5 rounded-md', properties.className)}>
|
||||
{properties.children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/* eslint-disable unicorn/no-keyword-prefix */
|
||||
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Dialog as AriaDialog,
|
||||
DialogTrigger,
|
||||
Heading as AriaHeading,
|
||||
Modal,
|
||||
ModalOverlay
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
}
|
||||
|
||||
function Button(properties: ButtonProperties) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...properties}
|
||||
aria-label='Dialog'
|
||||
className={cn(
|
||||
'w-fit text-sm rounded-lg px-4 py-2',
|
||||
'bg-main-700 dark:bg-main-800 text-white',
|
||||
'hover:bg-main-800 dark:hover:bg-main-700',
|
||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
properties.className
|
||||
)}
|
||||
// If control is passed, set the state value
|
||||
onPress={properties.control ? () => {
|
||||
properties.control?.[1](true)
|
||||
} : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ActionProperties = Parameters<typeof AriaButton>[0] & {
|
||||
readonly variant: 'cancel' | 'confirm';
|
||||
}
|
||||
|
||||
function Action(properties: ActionProperties) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...properties}
|
||||
type={properties.variant === 'confirm' ? 'submit' : 'button'}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg',
|
||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
properties.variant === 'cancel'
|
||||
? 'text-ui-700 dark:text-ui-300'
|
||||
: 'text-ui-300 dark:text-ui-300',
|
||||
properties.variant === 'confirm'
|
||||
? 'bg-main-700 dark:bg-main-700 pressed:bg-main-800 dark:pressed:bg-main-800'
|
||||
: 'bg-ui-200 dark:bg-ui-800 pressed:bg-ui-300 dark:pressed:bg-ui-700',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Title(properties: Parameters<typeof AriaHeading>[0]) {
|
||||
return (
|
||||
<AriaHeading
|
||||
{...properties}
|
||||
slot='title'
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-6 mb-5',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
{...properties}
|
||||
className={cn(
|
||||
'text-base leading-6 my-0',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type PanelProperties = {
|
||||
readonly children: (close: () => void) => ReactNode;
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
function Panel({ children, control, className }: PanelProperties) {
|
||||
return (
|
||||
<ModalOverlay
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
'fixed inset-0 h-screen w-screen z-50 bg-black/30',
|
||||
'flex items-center justify-center dark:bg-black/70',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'entering:fade-in entering:duration-200 entering:ease-out',
|
||||
'exiting:fade-out exiting:duration-100 exiting:ease-in',
|
||||
className
|
||||
)}
|
||||
isOpen={control ? control[0] : undefined}
|
||||
onOpenChange={control ? control[1] : undefined}
|
||||
>
|
||||
<Modal
|
||||
className={cn(
|
||||
'w-full max-w-md overflow-hidden rounded-xl p-4',
|
||||
'bg-ui-50 dark:bg-ui-900 shadow-lg',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'dark:border dark:border-ui-700',
|
||||
'entering:zoom-in-95 entering:ease-out entering:duration-200',
|
||||
'exiting:zoom-out-95 exiting:ease-in exiting:duration-100'
|
||||
)}
|
||||
>
|
||||
<AriaDialog role='alertdialog' className='outline-none relative'>
|
||||
{({ close }) => children(close)}
|
||||
</AriaDialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
)
|
||||
}
|
||||
|
||||
type DialogProperties = {
|
||||
readonly children: ReactNode;
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
}
|
||||
|
||||
function Dialog({ children, control }: DialogProperties) {
|
||||
if (control) {
|
||||
return children
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(Dialog, { Button, Title, Text, Panel, Action })
|
||||
@@ -1,46 +0,0 @@
|
||||
import { AlertIcon } from '@primer/octicons-react'
|
||||
import { isRouteErrorResponse, useRouteError } from '@remix-run/react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Card from './Card'
|
||||
import Code from './Code'
|
||||
|
||||
type Properties = {
|
||||
readonly type?: 'full' | 'embedded';
|
||||
}
|
||||
|
||||
export function ErrorPopup({ type = 'full' }: Properties) {
|
||||
const error = useRouteError()
|
||||
const routing = isRouteErrorResponse(error)
|
||||
const message = (error instanceof Error ? error.message : 'An unexpected error occurred')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
type === 'embedded'
|
||||
? 'pointer-events-none mt-24'
|
||||
: 'fixed inset-0 h-screen w-screen z-50'
|
||||
)}
|
||||
>
|
||||
<Card>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Card.Title className='text-3xl mb-0'>
|
||||
{routing ? error.status : 'Error'}
|
||||
</Card.Title>
|
||||
<AlertIcon className='w-12 h-12 text-red-500'/>
|
||||
</div>
|
||||
<Card.Text className='mt-4 text-lg'>
|
||||
{routing ? (
|
||||
error.statusText
|
||||
) : (
|
||||
<Code>
|
||||
{message}
|
||||
</Code>
|
||||
)}
|
||||
</Card.Text>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { GearIcon, GlobeIcon, LockIcon, PaperAirplaneIcon, PeopleIcon, PersonIcon, ServerIcon } from '@primer/octicons-react'
|
||||
import { Form } from '@remix-run/react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
import { HeadplaneContext } from '~/utils/config/headplane'
|
||||
import { type SessionData } from '~/utils/sessions'
|
||||
|
||||
import Menu from './Menu'
|
||||
import TabLink from './TabLink'
|
||||
|
||||
interface Properties {
|
||||
readonly data?: {
|
||||
config: HeadplaneContext['config']
|
||||
user?: SessionData['user']
|
||||
}
|
||||
}
|
||||
|
||||
interface LinkProperties {
|
||||
readonly href: string
|
||||
readonly text: string
|
||||
readonly isMenu?: boolean
|
||||
}
|
||||
|
||||
function Link({ href, text, isMenu }: LinkProperties) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
!isMenu && 'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block',
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Header({ data }: Properties) {
|
||||
return (
|
||||
<header className="bg-main-700 dark:bg-main-800 text-ui-50">
|
||||
<div className="container flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<PaperAirplaneIcon className="w-6 h-6" />
|
||||
<h1 className="text-2xl">Headplane</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Link href="https://tailscale.com/download" text="Download" />
|
||||
<Link href="https://github.com/tale/headplane" text="GitHub" />
|
||||
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
|
||||
{data?.user
|
||||
? (
|
||||
<Menu>
|
||||
<Menu.Button className={cn(
|
||||
'rounded-full h-9 w-9',
|
||||
'border border-main-600 dark:border-main-700',
|
||||
'hover:bg-main-600 dark:hover:bg-main-700',
|
||||
)}
|
||||
>
|
||||
<PersonIcon className="h-5 w-5 mt-0.5" />
|
||||
</Menu.Button>
|
||||
<Menu.Items>
|
||||
<Menu.Item className="text-right">
|
||||
<p className="font-bold">{data.user.name}</p>
|
||||
<p>{data.user.email}</p>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="text-right sm:hidden">
|
||||
<Link
|
||||
isMenu
|
||||
href="https://tailscale.com/download"
|
||||
text="Download"
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="text-right sm:hidden">
|
||||
<Link
|
||||
isMenu
|
||||
href="https://github.com/tale/headplane"
|
||||
text="GitHub"
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="text-right sm:hidden">
|
||||
<Link
|
||||
isMenu
|
||||
href="https://github.com/juanfont/headscale"
|
||||
text="Headscale"
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="text-red-500 dark:text-red-400">
|
||||
<Form method="POST" action="/logout">
|
||||
<button type="submit" className="w-full text-right">
|
||||
Logout
|
||||
</button>
|
||||
</Form>
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
)
|
||||
: undefined}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="container flex items-center gap-x-4 overflow-x-auto">
|
||||
<TabLink to="/machines" name="Machines" icon={<ServerIcon className="w-4 h-4" />} />
|
||||
<TabLink to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} />
|
||||
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
|
||||
{data?.config.read
|
||||
? (
|
||||
<>
|
||||
<TabLink to="/dns" name="DNS" icon={<GlobeIcon className="w-4 h-4" />} />
|
||||
<TabLink to="/settings" name="Settings" icon={<GearIcon className="w-4 h-4" />} />
|
||||
</>
|
||||
)
|
||||
: undefined}
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { LinkExternalIcon } from '@primer/octicons-react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
to: string
|
||||
name: string
|
||||
children: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function Link({ to, name: alt, children, className }: Props) {
|
||||
return (
|
||||
<a
|
||||
href={to}
|
||||
aria-label={alt}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-x-1',
|
||||
'text-blue-500 hover:text-blue-700',
|
||||
'dark:text-blue-400 dark:hover:text-blue-300',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<LinkExternalIcon className="h-3 w-3" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Menu as AriaMenu,
|
||||
MenuItem,
|
||||
MenuTrigger,
|
||||
Popover
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
function Button(properties: Parameters<typeof AriaButton>[0]) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...properties}
|
||||
className={cn(
|
||||
'outline-none',
|
||||
properties.className
|
||||
)}
|
||||
aria-label='Menu'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Items(properties: Parameters<typeof AriaMenu>[0]) {
|
||||
return (
|
||||
<Popover className={cn(
|
||||
'mt-2 rounded-md',
|
||||
'bg-ui-50 dark:bg-ui-800',
|
||||
'overflow-hidden z-50',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'entering:fade-in entering:zoom-in-95',
|
||||
'exiting:fade-out exiting:zoom-out-95',
|
||||
'fill-mode-forwards origin-left-right'
|
||||
)}
|
||||
>
|
||||
<AriaMenu
|
||||
{...properties}
|
||||
className={cn(
|
||||
'outline-none',
|
||||
'divide-y divide-ui-200 dark:divide-ui-600',
|
||||
properties.className
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
</AriaMenu>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
}
|
||||
|
||||
function ItemButton(properties: ButtonProperties) {
|
||||
return (
|
||||
<MenuItem className='outline-none'>
|
||||
<AriaButton
|
||||
{...properties}
|
||||
className={cn(
|
||||
'px-4 py-2 w-full outline-none text-left',
|
||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||
properties.className
|
||||
)}
|
||||
aria-label='Menu Dialog'
|
||||
// If control is passed, set the state value
|
||||
onPress={event => {
|
||||
properties.onPress?.(event)
|
||||
properties.control?.[1](true)
|
||||
}}
|
||||
/>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function Item(properties: Parameters<typeof MenuItem>[0]) {
|
||||
return (
|
||||
<MenuItem
|
||||
{...properties}
|
||||
className={cn(
|
||||
'px-4 py-2 w-full outline-none',
|
||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Menu({ children }: { readonly children: ReactNode }) {
|
||||
return (
|
||||
<MenuTrigger>
|
||||
{children}
|
||||
</MenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(Menu, { Button, Item, ItemButton, Items })
|
||||
@@ -1,23 +0,0 @@
|
||||
import { InfoIcon } from '@primer/octicons-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function Notice({ children, className }: Props) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'p-4 rounded-md w-full flex items-center gap-3',
|
||||
'bg-ui-200 dark:bg-ui-800',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { PlusIcon, DashIcon } from '@primer/octicons-react'
|
||||
import { Dispatch, SetStateAction } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Input,
|
||||
NumberField as AriaNumberField
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type NumberFieldProps = Parameters<typeof AriaNumberField>[0] & {
|
||||
label: string;
|
||||
state?: [number, Dispatch<SetStateAction<number>>];
|
||||
}
|
||||
|
||||
export default function NumberField(props: NumberFieldProps) {
|
||||
return (
|
||||
<AriaNumberField
|
||||
{...props}
|
||||
aria-label={props.label}
|
||||
className="w-full"
|
||||
value={props.state?.[0]}
|
||||
onChange={value => {
|
||||
props.state?.[1](value)
|
||||
}}
|
||||
>
|
||||
<Group className={cn(
|
||||
'flex px-2.5 py-1.5 w-full rounded-lg my-1',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'dark:bg-ui-800 dark:text-ui-300 gap-2',
|
||||
'focus-within:ring-2 focus-within:ring-blue-600',
|
||||
props.className
|
||||
)}>
|
||||
<Input
|
||||
className="w-full bg-transparent focus:outline-none"
|
||||
/>
|
||||
<Button slot="decrement">
|
||||
<DashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button slot="increment">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Group>
|
||||
</AriaNumberField>
|
||||
)
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { ChevronDownIcon } from '@primer/octicons-react'
|
||||
import { Dispatch, ReactNode, SetStateAction } from 'react'
|
||||
import {
|
||||
Button,
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
Popover,
|
||||
Select as AriaSelect,
|
||||
SelectValue,
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type SelectProps = Parameters<typeof AriaSelect>[0] & {
|
||||
readonly label: string
|
||||
readonly state?: [string, Dispatch<SetStateAction<string>>]
|
||||
readonly children: ReactNode
|
||||
}
|
||||
|
||||
function Select(props: SelectProps) {
|
||||
return (
|
||||
<AriaSelect
|
||||
{...props}
|
||||
aria-label={props.label}
|
||||
selectedKey={props.state?.[0]}
|
||||
onSelectionChange={(key) => {
|
||||
props.state?.[1](key.toString())
|
||||
}}
|
||||
className={cn(
|
||||
'block w-full rounded-lg my-1',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'bg-white dark:bg-ui-800 dark:text-ui-300',
|
||||
'focus-within:outline-6',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<Button className={cn(
|
||||
'w-full flex items-center justify-between',
|
||||
'px-2.5 py-1.5 rounded-lg',
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
<ChevronDownIcon className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Popover
|
||||
className={cn(
|
||||
'mt-2 rounded-md w-[var(--trigger-width)]',
|
||||
'bg-ui-100 dark:bg-ui-800 shadow-sm',
|
||||
'overflow-hidden z-50',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'entering:fade-in entering:zoom-in-95',
|
||||
'exiting:fade-out exiting:zoom-out-95',
|
||||
'fill-mode-forwards origin-left-right',
|
||||
)}
|
||||
>
|
||||
<ListBox>
|
||||
{props.children}
|
||||
</ListBox>
|
||||
</Popover>
|
||||
</AriaSelect>
|
||||
)
|
||||
}
|
||||
|
||||
type ItemProps = Parameters<typeof ListBoxItem>[0]
|
||||
|
||||
function Item(props: ItemProps) {
|
||||
return (
|
||||
<ListBoxItem
|
||||
{...props}
|
||||
className={cn(
|
||||
'px-4 py-2 w-full outline-none w-full',
|
||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</ListBoxItem>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(Select, { Item })
|
||||
@@ -1,23 +0,0 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
type Properties = {
|
||||
// eslint-disable-next-line unicorn/no-keyword-prefix
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Spinner(properties: Properties) {
|
||||
return (
|
||||
<div className={clsx('mr-1.5 inline-block align-middle mb-0.5', properties.className)}>
|
||||
<div
|
||||
className={clsx(
|
||||
'animate-spin rounded-full w-full h-full',
|
||||
'border-2 border-current border-t-transparent',
|
||||
properties.className
|
||||
)}
|
||||
role='status'
|
||||
>
|
||||
<span className='sr-only'>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import clsx from 'clsx'
|
||||
import { type HTMLProps } from 'react'
|
||||
|
||||
type Properties = HTMLProps<SVGElement> & {
|
||||
readonly isOnline: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unicorn/no-keyword-prefix
|
||||
export default function StatusCircle({ isOnline, className }: Properties) {
|
||||
return (
|
||||
<svg
|
||||
className={clsx(
|
||||
className,
|
||||
isOnline
|
||||
? 'text-green-700 dark:text-green-400'
|
||||
: 'text-gray-300 dark:text-gray-500'
|
||||
)}
|
||||
viewBox='0 0 24 24'
|
||||
fill='currentColor'
|
||||
>
|
||||
<circle cx='12' cy='12' r='8'/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Switch as AriaSwitch } from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type SwitchProperties = Parameters<typeof AriaSwitch>[0] & {
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export default function Switch(properties: SwitchProperties) {
|
||||
return (
|
||||
<AriaSwitch
|
||||
{...properties}
|
||||
aria-label={properties.label}
|
||||
className='group flex gap-2 items-center'
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[26px] w-[44px] p-[4px] shrink-0',
|
||||
'rounded-full outline-none group-focus-visible:ring-2',
|
||||
'bg-main-600/50 dark:bg-main-600/20 group-selected:bg-main-700',
|
||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
properties.className
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'h-[18px] w-[18px] transform rounded-full',
|
||||
'bg-white transition duration-100 ease-in-out',
|
||||
'translate-x-0 group-selected:translate-x-[100%]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AriaSwitch>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { NavLink } from '@remix-run/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type Properties = {
|
||||
readonly name: string;
|
||||
readonly to: string;
|
||||
readonly icon: ReactNode;
|
||||
}
|
||||
|
||||
export default function TabLink({ name, to, icon }: Properties) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
prefetch='intent'
|
||||
className={({ isActive }) => cn(
|
||||
'border-b-2 py-1.5',
|
||||
isActive ? 'border-white' : 'border-transparent'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-x-2 px-2.5 py-1.5 text-md text-nowrap',
|
||||
'hover:bg-ui-100/5 dark:hover:bg-ui-900/40 rounded-md'
|
||||
)}
|
||||
>
|
||||
{icon} {name}
|
||||
</div>
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import clsx from 'clsx'
|
||||
import { type HTMLProps } from 'react'
|
||||
|
||||
function TableList(properties: HTMLProps<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...properties}
|
||||
className={clsx(
|
||||
'border border-gray-300 rounded-lg overflow-clip',
|
||||
'dark:border-zinc-700 dark:text-gray-300',
|
||||
// 'dark:bg-zinc-800',
|
||||
properties.className
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Item(properties: HTMLProps<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...properties}
|
||||
className={clsx(
|
||||
|
||||
'flex items-center justify-between px-3 py-2',
|
||||
'border-b border-gray-200 last:border-b-0',
|
||||
'dark:border-zinc-800',
|
||||
properties.className
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(TableList, { Item })
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { XIcon } from '@primer/octicons-react'
|
||||
import { AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
|
||||
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast'
|
||||
import { ReactNode, useRef } from 'react'
|
||||
import { Button } from 'react-aria-components'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type ToastProperties = AriaToastProps<ReactNode> & {
|
||||
readonly state: ToastState<ReactNode>;
|
||||
}
|
||||
|
||||
function Toast({ state, ...properties }: ToastProperties) {
|
||||
const reference = useRef(null)
|
||||
|
||||
// @ts-expect-error: RefObject doesn't map to FocusableElement?
|
||||
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference)
|
||||
|
||||
return (
|
||||
<div
|
||||
{...toastProps}
|
||||
ref={reference}
|
||||
className={cn(
|
||||
'bg-main-700 dark:bg-main-800 rounded-lg',
|
||||
'text-main-100 dark:text-main-200 z-50',
|
||||
'border border-main-600 dark:border-main-700',
|
||||
'flex items-center justify-between p-3 pl-4 w-80'
|
||||
)}
|
||||
>
|
||||
<div {...titleProps}>{properties.toast.content}</div>
|
||||
<Button
|
||||
{...closeButtonProps}
|
||||
className={cn(
|
||||
'outline-none rounded-full p-1',
|
||||
'hover:bg-main-600 dark:hover:bg-main-700'
|
||||
)}
|
||||
>
|
||||
<XIcon className='w-4 h-4'/>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const toasts = new ToastQueue<ReactNode>({
|
||||
maxVisibleToasts: 5
|
||||
})
|
||||
|
||||
export function toast(text: string) {
|
||||
return toasts.add(text, { timeout: 5000 })
|
||||
}
|
||||
|
||||
export function Toaster() {
|
||||
const reference = useRef(null)
|
||||
const state = useToastQueue(toasts)
|
||||
|
||||
// @ts-expect-error: React 19 has weird types for Portal vs Node
|
||||
const { regionProps } = useToastRegion({}, state, reference)
|
||||
|
||||
return (
|
||||
<ClientOnly>
|
||||
{
|
||||
// @ts-expect-error: Portal doesn't match Node in React 19 yet
|
||||
() => createPortal(
|
||||
state.visibleToasts.length >= 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-4 right-4',
|
||||
'flex flex-col gap-4'
|
||||
)}
|
||||
{...regionProps}
|
||||
ref={reference}
|
||||
>
|
||||
{state.visibleToasts.map(toast => (
|
||||
<Toast key={toast.key} toast={toast} state={state}/>
|
||||
))}
|
||||
</div>
|
||||
) : undefined,
|
||||
document.body
|
||||
)}
|
||||
</ClientOnly>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Tooltip as AriaTooltip,
|
||||
TooltipTrigger,
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function Tooltip({ children }: Props) {
|
||||
return (
|
||||
<TooltipTrigger delay={0}>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function Button(props: Parameters<typeof AriaButton>[0]) {
|
||||
return (
|
||||
<AriaButton {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function Body({ children, className }: Props) {
|
||||
return (
|
||||
<AriaTooltip className={cn(
|
||||
'text-sm max-w-xs p-2 rounded-lg mb-2',
|
||||
'bg-white dark:bg-ui-900 drop-shadow-sm',
|
||||
'border border-gray-200 dark:border-zinc-700',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</AriaTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(Tooltip, { Button, Body })
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Check, Copy, Info } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
import toast from "~/utils/toast";
|
||||
|
||||
import Tooltip from "./tooltip";
|
||||
|
||||
export interface AttributeProps {
|
||||
name: string;
|
||||
value: string;
|
||||
tooltip?: string;
|
||||
isCopyable?: boolean;
|
||||
}
|
||||
|
||||
export default function Attribute({ name, value, tooltip, isCopyable }: AttributeProps) {
|
||||
return (
|
||||
<dl className="flex items-center gap-1 text-sm">
|
||||
<dt
|
||||
className={cn(
|
||||
"w-1/3 sm:w-1/4 lg:w-1/3 shrink-0 min-w-0",
|
||||
"text-mist-500 dark:text-mist-400",
|
||||
tooltip ? "flex items-center gap-1" : undefined,
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
{tooltip ? (
|
||||
<Tooltip content={tooltip}>
|
||||
<Info className="size-4" />
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
"min-w-0 px-1.5 py-1 rounded-lg border border-transparent",
|
||||
...(isCopyable
|
||||
? [
|
||||
"cursor-pointer hover:shadow-xs",
|
||||
"hover:bg-mist-50 dark:hover:bg-mist-800",
|
||||
"hover:border-mist-100 dark:hover:border-mist-700",
|
||||
]
|
||||
: []),
|
||||
)}
|
||||
>
|
||||
{isCopyable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex w-full min-w-0 items-center gap-1.5"
|
||||
onClick={async (event) => {
|
||||
const svgs = event.currentTarget.querySelectorAll("svg");
|
||||
for (const svg of svgs) {
|
||||
svg.toggleAttribute("data-copied", true);
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast(`Copied ${name} to clipboard`);
|
||||
|
||||
setTimeout(() => {
|
||||
for (const svg of svgs) {
|
||||
svg.toggleAttribute("data-copied", false);
|
||||
}
|
||||
}, 1000);
|
||||
}}
|
||||
>
|
||||
<div suppressHydrationWarning className="truncate">
|
||||
{value}
|
||||
</div>
|
||||
{isCopyable ? (
|
||||
<div>
|
||||
<Check className="hidden size-4 data-copied:block" />
|
||||
<Copy className="block size-4 data-copied:hidden" />
|
||||
</div>
|
||||
) : undefined}
|
||||
</button>
|
||||
) : (
|
||||
<div className="relative min-w-0 truncate" suppressHydrationWarning>
|
||||
{value}
|
||||
</div>
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Button as BaseButton } from "@base-ui/react/button";
|
||||
import React from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface ButtonProps extends React.ComponentProps<typeof BaseButton> {
|
||||
variant?: "heavy" | "light" | "danger" | "ghost";
|
||||
}
|
||||
|
||||
export default function Button({ variant = "light", className, ...props }: ButtonProps) {
|
||||
return (
|
||||
<BaseButton
|
||||
{...props}
|
||||
className={cn(
|
||||
"inline-flex w-fit items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm",
|
||||
"transition-colors duration-100",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
props.disabled && "pointer-events-none opacity-50",
|
||||
...(variant === "heavy"
|
||||
? [
|
||||
"bg-indigo-500 font-semibold text-white",
|
||||
"hover:bg-indigo-500/90",
|
||||
"dark:bg-indigo-500/90 dark:hover:bg-indigo-500/80",
|
||||
]
|
||||
: variant === "danger"
|
||||
? [
|
||||
"bg-red-600 font-semibold text-white",
|
||||
"hover:bg-red-600/90",
|
||||
"dark:bg-red-500 dark:hover:bg-red-500/90",
|
||||
]
|
||||
: variant === "ghost"
|
||||
? [
|
||||
"font-medium text-indigo-600 dark:text-indigo-400",
|
||||
"hover:bg-indigo-50 dark:hover:bg-indigo-500/10",
|
||||
]
|
||||
: [
|
||||
"border border-mist-200 bg-white font-medium",
|
||||
"hover:bg-mist-50",
|
||||
"dark:border-mist-700 dark:bg-mist-800/50",
|
||||
"dark:hover:bg-mist-700/50",
|
||||
]),
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface Props extends React.HTMLProps<HTMLDivElement> {
|
||||
variant?: "raised" | "flat";
|
||||
}
|
||||
|
||||
function Card({ variant = "raised", ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
"w-full max-w-md rounded-lg p-5",
|
||||
"bg-mist-50/50 dark:bg-mist-950/50",
|
||||
variant === "flat" ? "shadow-none" : "shadow",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Card, { Title, Text });
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface ChipProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Chip({ text, className, leftIcon, rightIcon }: ChipProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"h-5 text-xs py-0.5 px-1 rounded-md text-nowrap",
|
||||
"text-mist-700 dark:text-mist-100",
|
||||
"bg-mist-100 dark:bg-mist-700",
|
||||
"inline-flex items-center gap-x-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{leftIcon}
|
||||
{text}
|
||||
{rightIcon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Copy } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
import toast from "~/utils/toast";
|
||||
|
||||
export interface CodeBlockProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function CodeBlock({ children, className }: CodeBlockProps) {
|
||||
const text = children.trim();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full cursor-pointer rounded-md bg-mist-100 text-left dark:bg-mist-800",
|
||||
"hover:bg-mist-200 dark:hover:bg-mist-700 transition-colors",
|
||||
className,
|
||||
)}
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<code className="block px-3 pt-2 pb-1 text-sm break-all">{text}</code>
|
||||
<span className="mt-0.5 flex items-center gap-1 px-3 pb-2 text-xs text-mist-500 dark:text-mist-400">
|
||||
<Copy className="size-3" />
|
||||
Click to copy
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface CodeProps {
|
||||
children: string | string[] | number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Code({ children, className }: CodeProps) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"bg-mist-100 dark:bg-mist-800 px-1.5 py-0.5 font-mono rounded-sm text-[0.875em]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { AlertDialog } from "@base-ui/react/alert-dialog";
|
||||
import React, { cloneElement, useEffect, useRef } from "react";
|
||||
import { Form, type HTMLFormMethod } from "react-router";
|
||||
|
||||
import Button, { type ButtonProps } from "~/components/button";
|
||||
import cn from "~/utils/cn";
|
||||
import { useLiveData } from "~/utils/live-data";
|
||||
|
||||
export interface DialogProps {
|
||||
children:
|
||||
| [React.ReactElement<ButtonProps>, React.ReactElement<DialogPanelProps>]
|
||||
| React.ReactElement<DialogPanelProps>;
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
function Dialog(props: DialogProps) {
|
||||
const { pause, resume } = useLiveData();
|
||||
const { isOpen, onOpenChange } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (Array.isArray(props.children)) {
|
||||
const [button, panel] = props.children;
|
||||
return (
|
||||
<AlertDialog.Root open={isOpen} onOpenChange={(open) => onOpenChange?.(open)}>
|
||||
<AlertDialog.Trigger render={cloneElement(button)} />
|
||||
<DialogOverlay>{panel}</DialogOverlay>
|
||||
</AlertDialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog.Root open={isOpen} onOpenChange={(open) => onOpenChange?.(open)}>
|
||||
<DialogOverlay>{props.children}</DialogOverlay>
|
||||
</AlertDialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DialogPanelProps {
|
||||
children: React.ReactNode;
|
||||
variant?: "normal" | "destructive" | "unactionable";
|
||||
onSubmit?: React.FormEventHandler<HTMLFormElement>;
|
||||
method?: HTMLFormMethod;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
function Panel(props: DialogPanelProps) {
|
||||
const { children, onSubmit, isDisabled, variant, method = "POST" } = props;
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<AlertDialog.Popup
|
||||
className={cn(
|
||||
"w-full max-w-lg rounded-xl p-4",
|
||||
"outline-hidden",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
"shadow-overlay",
|
||||
)}
|
||||
>
|
||||
<Form
|
||||
method={method ?? "POST"}
|
||||
onSubmit={(event) => {
|
||||
if (onSubmit) {
|
||||
onSubmit(event);
|
||||
}
|
||||
|
||||
if (!event.defaultPrevented) {
|
||||
closeRef.current?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">{children}</div>
|
||||
<div className="mt-5 flex justify-end gap-3">
|
||||
{variant === "unactionable" ? (
|
||||
<AlertDialog.Close render={<Button>Close</Button>} />
|
||||
) : (
|
||||
<>
|
||||
<AlertDialog.Close render={<Button>Cancel</Button>} />
|
||||
<AlertDialog.Close ref={closeRef} className="hidden" aria-hidden tabIndex={-1} />
|
||||
<Button
|
||||
disabled={isDisabled}
|
||||
type="submit"
|
||||
variant={variant === "destructive" ? "danger" : "heavy"}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</AlertDialog.Popup>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogOverlay({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Backdrop
|
||||
className={cn(
|
||||
"fixed inset-0 z-20 h-screen w-screen",
|
||||
"bg-mist-900/30 dark:bg-mist-950/60",
|
||||
"transition-opacity duration-100",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-20 h-screen w-screen",
|
||||
"flex items-center justify-center p-4",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AlertDialog.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Panel as DialogPanel };
|
||||
export default Dialog;
|
||||
@@ -0,0 +1,194 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { isRouteErrorResponse } from "react-router";
|
||||
|
||||
import { isApiError, isConnectionError } from "~/server/headscale/api/error-client";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Card from "./card";
|
||||
import Code from "./code";
|
||||
import Link from "./link";
|
||||
|
||||
export function getErrorMessage(error: Error | unknown): {
|
||||
title: string;
|
||||
jsxMessage: React.ReactNode;
|
||||
} {
|
||||
if (isRouteErrorResponse(error)) {
|
||||
if (isApiError(error.data)) {
|
||||
const { statusCode, rawData, data, requestUrl } = error.data;
|
||||
if (statusCode >= 500) {
|
||||
return {
|
||||
jsxMessage: (
|
||||
<>
|
||||
<Card.Text>
|
||||
There was an error communicating with the Headscale API.
|
||||
<br />
|
||||
The server responded with a status code of <strong>{statusCode}</strong>, indicating
|
||||
a server-side issue. Please check the Headscale server status and try again later.
|
||||
</Card.Text>
|
||||
{(error.data.data != null || error.data.rawData != null) && (
|
||||
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
|
||||
{error.data.data != null ? (
|
||||
<code>{JSON.stringify(error.data.data, null, 2)}</code>
|
||||
) : (
|
||||
<code>{error.data.rawData}</code>
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
title: "Headscale API Error",
|
||||
};
|
||||
}
|
||||
|
||||
const authError = error.data.statusCode === 401 || error.data.statusCode === 403;
|
||||
|
||||
return {
|
||||
jsxMessage: (
|
||||
<>
|
||||
<Card.Text className="leading-snug">
|
||||
The Headscale API returned an unexpected response.
|
||||
{authError ? (
|
||||
<>
|
||||
{" "}
|
||||
The status code indicates an authentication error. Please verify your API key and
|
||||
Headplane configuration.
|
||||
</>
|
||||
) : (
|
||||
<> You may be using an unsupported version of Headscale or this may be a bug.</>
|
||||
)}
|
||||
</Card.Text>
|
||||
<ul className="mt-2 list-inside list-disc">
|
||||
<li>
|
||||
Request URL: <Code>{requestUrl}</Code>
|
||||
</li>
|
||||
<li>
|
||||
Status Code:{" "}
|
||||
<Code>
|
||||
{/* @ts-expect-error */}
|
||||
{data === null ? (
|
||||
<>
|
||||
{statusCode} {rawData}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{statusCode} {error.statusText}
|
||||
</>
|
||||
)}
|
||||
</Code>
|
||||
</li>
|
||||
</ul>
|
||||
<Card.Text className="mt-4 text-lg font-semibold">Error Details</Card.Text>
|
||||
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
|
||||
<code>{JSON.stringify(error.data, null, 2)}</code>
|
||||
</pre>
|
||||
</>
|
||||
),
|
||||
title: "Invalid response from Headscale API",
|
||||
};
|
||||
}
|
||||
|
||||
if (isConnectionError(error.data)) {
|
||||
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
|
||||
return {
|
||||
jsxMessage: (
|
||||
<>
|
||||
<Card.Text className="leading-snug">
|
||||
Headplane was unable to reach the Headscale API. Please check your network setup and
|
||||
configuration to ensure Headplane is able to connect.
|
||||
</Card.Text>
|
||||
<Card.Text className="mt-4 text-lg font-semibold">Error Details</Card.Text>
|
||||
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
|
||||
{requestUrl}
|
||||
<br />
|
||||
{errorCode}: {errorMessage}
|
||||
{extraData != null && (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
<code>{JSON.stringify(extraData, null, 2)}</code>
|
||||
</>
|
||||
)}
|
||||
</pre>
|
||||
</>
|
||||
),
|
||||
title: "Cannot connect to Headscale API",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
jsxMessage: (
|
||||
<>
|
||||
There was an error processing your request.
|
||||
<br />
|
||||
Status Code: <strong>{error.status}</strong>
|
||||
<br />
|
||||
Status Text: <strong>{error.data}</strong>
|
||||
</>
|
||||
),
|
||||
title: `Error ${error.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!(error instanceof Error)) {
|
||||
return {
|
||||
jsxMessage: (
|
||||
<>
|
||||
<Card.Text>
|
||||
An unexpected error occurred which is most likely a bug. Please consider reporting
|
||||
filing an issue on the{" "}
|
||||
<Link external styled to="https://github.com/tale/headplane/issues">
|
||||
Headplane GitHub
|
||||
</Link>{" "}
|
||||
repository with the details below.
|
||||
</Card.Text>
|
||||
<Card.Text className="mt-4 text-lg font-semibold">Error Details</Card.Text>
|
||||
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
|
||||
<code>{JSON.stringify(error, null, 2)}</code>
|
||||
</pre>
|
||||
</>
|
||||
),
|
||||
title: "Unexpected Error",
|
||||
};
|
||||
}
|
||||
|
||||
// Traverse the error chain to find the root cause
|
||||
let rootError = error;
|
||||
if (error.cause != null) {
|
||||
rootError = error.cause as Error;
|
||||
while (rootError.cause != null) {
|
||||
rootError = rootError.cause as Error;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: If we are aggregate, concat into a single message
|
||||
if (rootError instanceof AggregateError) {
|
||||
throw new Error("AggregateError handling not implemented yet");
|
||||
}
|
||||
|
||||
return {
|
||||
jsxMessage: rootError.message,
|
||||
title:
|
||||
rootError.name.length > 0 && rootError.name !== "Error"
|
||||
? `Error: ${rootError.name}`
|
||||
: "Error",
|
||||
};
|
||||
}
|
||||
|
||||
interface ErrorBannerProps {
|
||||
error: unknown;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ErrorBanner({ error, className }: ErrorBannerProps) {
|
||||
const { title, jsxMessage } = getErrorMessage(error);
|
||||
|
||||
return (
|
||||
<Card className={cn("w-screen", className)} variant="flat">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title>{title}</Card.Title>
|
||||
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
{jsxMessage}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Field } from "@base-ui/react/field";
|
||||
import { Input as BaseInput } from "@base-ui/react/input";
|
||||
import { Asterisk } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface InputProps extends Omit<ComponentProps<typeof BaseInput>, "onChange"> {
|
||||
label: string;
|
||||
labelHidden?: boolean;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
invalid?: boolean;
|
||||
errorMessage?: string;
|
||||
description?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function Input(props: InputProps) {
|
||||
const {
|
||||
label,
|
||||
labelHidden,
|
||||
className,
|
||||
invalid,
|
||||
errorMessage,
|
||||
description,
|
||||
required,
|
||||
onChange,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Field.Root className={cn("flex w-full flex-col gap-1", className)} invalid={invalid}>
|
||||
<Field.Label
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
"text-mist-700 dark:text-mist-200",
|
||||
labelHidden && "sr-only",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{required && <Asterisk className="ml-0.5 inline w-3.5 pb-1 text-red-500" />}
|
||||
</Field.Label>
|
||||
<BaseInput
|
||||
{...rest}
|
||||
required={required}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-2 text-sm",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
)}
|
||||
onChange={
|
||||
onChange
|
||||
? (e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{description && (
|
||||
<Field.Description className={cn("text-xs", "text-mist-500 dark:text-mist-400")}>
|
||||
{description}
|
||||
</Field.Description>
|
||||
)}
|
||||
{invalid && errorMessage ? (
|
||||
<Field.Error className={cn("text-xs", "text-red-500 dark:text-red-400")}>
|
||||
{errorMessage}
|
||||
</Field.Error>
|
||||
) : null}
|
||||
</Field.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import type { JSX, ReactNode } from "react";
|
||||
import { Link as RouterLink } from "react-router";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export type LinkProps =
|
||||
| {
|
||||
external: true;
|
||||
to: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
styled?: boolean;
|
||||
}
|
||||
| {
|
||||
external?: false;
|
||||
to: string;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Link(props: LinkProps): JSX.Element {
|
||||
if (props.external) {
|
||||
return (
|
||||
<a
|
||||
href={props.to}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
props.styled && [
|
||||
"inline-flex items-center gap-x-0.5",
|
||||
"text-blue-500 hover:text-blue-700",
|
||||
"dark:text-blue-400 dark:hover:text-blue-300",
|
||||
],
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
{props.styled && <ExternalLink className="w-3.5" />}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RouterLink to={props.to} prefetch="intent" className={props.className}>
|
||||
{props.children}
|
||||
</RouterLink>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Menu as BaseMenu } from "@base-ui/react/menu";
|
||||
import type { ComponentProps, JSX, ReactNode } from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
const SIDE_OFFSET = 8;
|
||||
|
||||
export const Menu = ({
|
||||
children,
|
||||
disabled,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
}): JSX.Element => <BaseMenu.Root disabled={disabled}>{children}</BaseMenu.Root>;
|
||||
|
||||
export const MenuTrigger = ({
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
}: Pick<
|
||||
ComponentProps<typeof BaseMenu.Trigger>,
|
||||
"className" | "children" | "disabled" | "onClick"
|
||||
>): JSX.Element => (
|
||||
<BaseMenu.Trigger
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</BaseMenu.Trigger>
|
||||
);
|
||||
|
||||
export const MenuContent = ({
|
||||
children,
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = SIDE_OFFSET,
|
||||
align = "start",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
side?: ComponentProps<typeof BaseMenu.Positioner>["side"];
|
||||
sideOffset?: number;
|
||||
align?: ComponentProps<typeof BaseMenu.Positioner>["align"];
|
||||
}): JSX.Element => (
|
||||
<BaseMenu.Portal>
|
||||
<BaseMenu.Positioner side={side} sideOffset={sideOffset} align={align}>
|
||||
<BaseMenu.Popup
|
||||
className={cn(
|
||||
"min-w-50 rounded-lg py-1",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"shadow-lg dark:shadow-none",
|
||||
"border border-mist-200 dark:border-mist-700",
|
||||
"focus:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</BaseMenu.Popup>
|
||||
</BaseMenu.Positioner>
|
||||
</BaseMenu.Portal>
|
||||
);
|
||||
|
||||
export const MenuItem = ({
|
||||
className,
|
||||
variant,
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
}: Pick<ComponentProps<typeof BaseMenu.Item>, "className" | "children" | "disabled" | "onClick"> & {
|
||||
variant?: "danger";
|
||||
}): JSX.Element => (
|
||||
<BaseMenu.Item
|
||||
className={cn(
|
||||
"py-2 px-3 mx-1 rounded-md",
|
||||
"select-none cursor-pointer",
|
||||
"focus:outline-hidden",
|
||||
"text-mist-700 dark:text-mist-300",
|
||||
"data-highlighted:bg-mist-100/50 dark:data-highlighted:bg-mist-800",
|
||||
"data-disabled:text-mist-400 dark:data-disabled:text-mist-600 data-disabled:cursor-default",
|
||||
variant === "danger" && "text-red-500 dark:text-red-400",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</BaseMenu.Item>
|
||||
);
|
||||
|
||||
export const MenuSeparator = ({ className }: { className?: string }): JSX.Element => (
|
||||
<BaseMenu.Separator
|
||||
className={cn("mx-2 my-1 border-t border-mist-200 dark:border-mist-800", className)}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CircleAlert, CircleSlash2, LucideProps, TriangleAlert } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
import Card from "~/components/card";
|
||||
|
||||
export interface NoticeProps {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
variant?: "default" | "error" | "warning";
|
||||
icon?: React.ReactElement<LucideProps>;
|
||||
}
|
||||
|
||||
export default function Notice({ children, title, variant, icon }: NoticeProps) {
|
||||
return (
|
||||
<Card variant="flat" className="my-6 max-w-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
{title ? <Card.Title className="mb-0 text-xl">{title}</Card.Title> : undefined}
|
||||
{!variant && icon ? icon : iconForVariant(variant)}
|
||||
</div>
|
||||
<Card.Text className="mt-4">{children}</Card.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function iconForVariant(variant?: "default" | "error" | "warning") {
|
||||
switch (variant) {
|
||||
case "error":
|
||||
return <TriangleAlert className="text-red-500" />;
|
||||
case "warning":
|
||||
return <CircleAlert className="text-yellow-500" />;
|
||||
default:
|
||||
return <CircleSlash2 />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NumberField } from "@base-ui/react/number-field";
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface NumberInputProps {
|
||||
label?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
defaultValue?: number;
|
||||
value?: number;
|
||||
onValueChange?: (value: number | null) => void;
|
||||
}
|
||||
|
||||
export default function NumberInput(props: NumberInputProps) {
|
||||
const { label, name, description } = props;
|
||||
|
||||
return (
|
||||
<NumberField.Root
|
||||
className="flex flex-col gap-1"
|
||||
defaultValue={props.defaultValue}
|
||||
value={props.value}
|
||||
onValueChange={props.onValueChange}
|
||||
min={props.min}
|
||||
max={props.max}
|
||||
step={props.step}
|
||||
disabled={props.disabled}
|
||||
required={props.required}
|
||||
>
|
||||
{label && (
|
||||
<NumberField.ScrubArea>
|
||||
<label className={cn("text-sm font-medium", "text-mist-700 dark:text-mist-200")}>
|
||||
<NumberField.ScrubAreaCursor />
|
||||
{label}
|
||||
</label>
|
||||
</NumberField.ScrubArea>
|
||||
)}
|
||||
<NumberField.Group
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md pr-1",
|
||||
"focus-within:outline-hidden focus-within:ring-2 focus-within:ring-indigo-500/40 focus-within:ring-offset-1",
|
||||
"dark:focus-within:ring-indigo-400/40 dark:focus-within:ring-offset-mist-900",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
)}
|
||||
>
|
||||
<NumberField.Input
|
||||
name={name}
|
||||
className="w-full rounded-l-md bg-transparent py-2 pl-3 text-sm focus:outline-hidden"
|
||||
/>
|
||||
<NumberField.Decrement aria-label="Decrement" className="h-7.5 w-7.5 rounded-lg p-1">
|
||||
<Minus className="h-4 w-4" />
|
||||
</NumberField.Decrement>
|
||||
<NumberField.Increment aria-label="Increment" className="h-7.5 w-7.5 rounded-lg p-1">
|
||||
<Plus className="h-4 w-4" />
|
||||
</NumberField.Increment>
|
||||
</NumberField.Group>
|
||||
{description && (
|
||||
<div className={cn("text-xs", "text-mist-500 dark:text-mist-400")}>{description}</div>
|
||||
)}
|
||||
</NumberField.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { RefreshCw, ServerOff } from "lucide-react";
|
||||
import { isRouteErrorResponse, useRevalidator } from "react-router";
|
||||
|
||||
import { isConnectionError } from "~/server/headscale/api/error-client";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Button from "./button";
|
||||
import { ErrorBanner } from "./error-banner";
|
||||
|
||||
interface PageErrorProps {
|
||||
error: unknown;
|
||||
page: string;
|
||||
}
|
||||
|
||||
export default function PageError({ error, page }: PageErrorProps) {
|
||||
const { revalidate, state } = useRevalidator();
|
||||
|
||||
if (isRouteErrorResponse(error) && isConnectionError(error.data)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<ServerOff className={cn("h-12 w-12", "text-mist-400 dark:text-mist-500")} />
|
||||
<h2 className="mt-4 text-lg font-semibold">{page} Unavailable</h2>
|
||||
<p className="mt-1 max-w-sm text-sm text-mist-500 dark:text-mist-400">
|
||||
This page could not be loaded because the Headscale server is unreachable. It will be
|
||||
available once the connection is restored.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-6"
|
||||
variant="light"
|
||||
onClick={() => revalidate()}
|
||||
disabled={state === "loading"}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("mr-2 inline-block h-4 w-4", state === "loading" && "animate-spin")}
|
||||
/>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ErrorBanner className="max-w-2xl" error={error} />;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Radio } from "@base-ui/react/radio";
|
||||
import { RadioGroup as BaseRadioGroup } from "@base-ui/react/radio-group";
|
||||
import type React from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface RadioGroupProps {
|
||||
children: React.ReactNode;
|
||||
label: string;
|
||||
className?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onValueChange?: (value: string, eventDetails: BaseRadioGroup.ChangeEventDetails) => void;
|
||||
}
|
||||
|
||||
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
|
||||
return (
|
||||
<BaseRadioGroup
|
||||
{...props}
|
||||
aria-label={label}
|
||||
className={cn("flex flex-col gap-2.5", className)}
|
||||
>
|
||||
{children}
|
||||
</BaseRadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface RadioItemProps {
|
||||
value: string;
|
||||
label: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function RadioItem({ children, label, className, value, disabled }: RadioItemProps) {
|
||||
return (
|
||||
<label className="flex items-center gap-2.5 text-sm">
|
||||
<Radio.Root
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"w-5 h-5 aspect-square rounded-full border-2",
|
||||
"border-mist-400 dark:border-mist-500",
|
||||
"focus-visible:ring-2 focus-visible:ring-indigo-500/40 focus-visible:ring-offset-1 dark:focus-visible:ring-indigo-400/40 dark:focus-visible:ring-offset-mist-900",
|
||||
"data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed",
|
||||
"data-[checked]:border-[6px] data-[checked]:border-mist-900 dark:data-[checked]:border-mist-100",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Radio.Indicator />
|
||||
</Radio.Root>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(RadioGroup, { Radio: RadioItem });
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Combobox } from "@base-ui/react/combobox";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface SelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SelectProps {
|
||||
items: SelectItem[];
|
||||
label?: string;
|
||||
"aria-label"?: string;
|
||||
name?: string;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
invalid?: boolean;
|
||||
value?: string | null;
|
||||
defaultValue?: string | null;
|
||||
onValueChange?: (value: string | null) => void;
|
||||
}
|
||||
|
||||
export default function Select({
|
||||
items,
|
||||
label,
|
||||
className,
|
||||
placeholder,
|
||||
description,
|
||||
required,
|
||||
disabled,
|
||||
invalid,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
name,
|
||||
...props
|
||||
}: SelectProps) {
|
||||
const selectedItem =
|
||||
value !== undefined ? (items.find((i) => i.value === value) ?? null) : undefined;
|
||||
const defaultSelectedItem =
|
||||
defaultValue !== undefined ? (items.find((i) => i.value === defaultValue) ?? null) : undefined;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", className)}>
|
||||
{label && (
|
||||
<label className={cn("text-sm font-medium", "text-mist-700 dark:text-mist-200")}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<Combobox.Root
|
||||
items={items}
|
||||
value={selectedItem}
|
||||
defaultValue={defaultSelectedItem}
|
||||
onValueChange={(item) => onValueChange?.(item?.value ?? null)}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
aria-label={props["aria-label"]}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-md",
|
||||
"focus-within:ring-2 focus-within:ring-indigo-500/40 focus-within:ring-offset-1",
|
||||
"dark:focus-within:ring-indigo-400/40 dark:focus-within:ring-offset-mist-900",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
invalid && "ring-red-400",
|
||||
)}
|
||||
>
|
||||
<Combobox.Input
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
className="w-full rounded-md bg-transparent px-3 py-2 pr-9 text-sm outline-hidden"
|
||||
data-1p-ignore
|
||||
/>
|
||||
<Combobox.Trigger
|
||||
className={cn(
|
||||
"absolute inset-y-0 right-0 flex items-center pr-3",
|
||||
"text-mist-400 dark:text-mist-500",
|
||||
disabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "hover:text-mist-600 dark:hover:text-mist-300",
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Combobox.Trigger>
|
||||
</div>
|
||||
<Combobox.Portal>
|
||||
<Combobox.Positioner
|
||||
className="z-50"
|
||||
sideOffset={8}
|
||||
style={{ width: "var(--anchor-width)" }}
|
||||
>
|
||||
<Combobox.Popup
|
||||
className={cn(
|
||||
"max-h-72 w-full overflow-auto rounded-lg p-1",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
"shadow-overlay",
|
||||
)}
|
||||
>
|
||||
<Combobox.Empty className="px-3 py-2 text-sm text-mist-500 empty:hidden">
|
||||
No results found.
|
||||
</Combobox.Empty>
|
||||
<Combobox.List>
|
||||
{(item: SelectItem) => (
|
||||
<Combobox.Item
|
||||
key={item.value}
|
||||
value={item}
|
||||
className={cn(
|
||||
"flex items-center justify-between text-sm",
|
||||
"py-1.5 px-2.5 rounded-md",
|
||||
"outline-hidden select-none cursor-default",
|
||||
"data-[highlighted]:bg-mist-100 dark:data-[highlighted]:bg-mist-800",
|
||||
"data-[selected]:font-medium",
|
||||
"data-[disabled]:text-mist-300 dark:data-[disabled]:text-mist-600",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
<Combobox.ItemIndicator>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Combobox.ItemIndicator>
|
||||
</Combobox.Item>
|
||||
)}
|
||||
</Combobox.List>
|
||||
</Combobox.Popup>
|
||||
</Combobox.Positioner>
|
||||
</Combobox.Portal>
|
||||
</Combobox.Root>
|
||||
{description && (
|
||||
<div className={cn("text-xs", "text-mist-500 dark:text-mist-400")}>{description}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { AlertCircle, CloudOff, Info, TriangleAlert, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
type Variant = "info" | "warning" | "error" | "critical";
|
||||
|
||||
interface StatusBannerProps {
|
||||
variant: Variant;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
dismissable?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const icons: Record<Variant, React.ReactNode> = {
|
||||
info: <Info className="h-5 w-5 shrink-0" />,
|
||||
warning: <TriangleAlert className="h-5 w-5 shrink-0" />,
|
||||
error: <AlertCircle className="h-5 w-5 shrink-0" />,
|
||||
critical: <CloudOff className="h-5 w-5 shrink-0" />,
|
||||
};
|
||||
|
||||
export default function StatusBanner({
|
||||
variant,
|
||||
title,
|
||||
children,
|
||||
dismissable = true,
|
||||
className,
|
||||
}: StatusBannerProps) {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-lg border px-4 py-3 text-sm",
|
||||
variant === "info" && [
|
||||
"border-indigo-200 bg-indigo-50 text-indigo-900",
|
||||
"dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-200",
|
||||
],
|
||||
variant === "warning" && [
|
||||
"border-yellow-300 bg-yellow-50 text-yellow-900",
|
||||
"dark:border-yellow-500/30 dark:bg-yellow-500/10 dark:text-yellow-200",
|
||||
],
|
||||
variant === "error" && [
|
||||
"border-red-300 bg-red-50 text-red-900",
|
||||
"dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200",
|
||||
],
|
||||
variant === "critical" && [
|
||||
"border-red-400 bg-red-100 text-red-900",
|
||||
"dark:border-red-500/40 dark:bg-red-500/20 dark:text-red-100",
|
||||
],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icons[variant]}
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{title}</p>
|
||||
<div className="mt-0.5 text-current/80">{children}</div>
|
||||
</div>
|
||||
{dismissable && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
onClick={() => setDismissed(true)}
|
||||
className={cn(
|
||||
"shrink-0 rounded p-0.5 transition-colors",
|
||||
"hover:bg-black/10 dark:hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface StatusCircleProps {
|
||||
isOnline: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function StatusCircle({ isOnline, className }: StatusCircleProps) {
|
||||
return (
|
||||
<svg
|
||||
className={cn(
|
||||
isOnline ? "text-green-600 dark:text-green-500" : "text-mist-200 dark:text-mist-800",
|
||||
className,
|
||||
)}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<title>{isOnline ? "Online" : "Offline"}</title>
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Switch as BaseSwitch } from "@base-ui/react/switch";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface SwitchProps {
|
||||
label: string;
|
||||
className?: string;
|
||||
switchClassName?: string;
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Switch(props: SwitchProps) {
|
||||
return (
|
||||
<BaseSwitch.Root
|
||||
aria-label={props.label}
|
||||
checked={props.checked}
|
||||
className={cn(
|
||||
"flex h-[22px] w-[38px] p-[3px] shrink-0 rounded-full",
|
||||
"bg-mist-300 dark:bg-mist-700",
|
||||
"border border-transparent dark:border-mist-800",
|
||||
"data-[checked]:bg-mist-900 dark:data-[checked]:bg-mist-950",
|
||||
"focus-visible:ring-2 focus-visible:ring-indigo-500/40 focus-visible:ring-offset-1 dark:focus-visible:ring-indigo-400/40 dark:focus-visible:ring-offset-mist-900",
|
||||
props.disabled && "opacity-50",
|
||||
props.className,
|
||||
)}
|
||||
defaultChecked={props.defaultChecked}
|
||||
disabled={props.disabled}
|
||||
name={props.name}
|
||||
onCheckedChange={props.onCheckedChange}
|
||||
>
|
||||
<BaseSwitch.Thumb
|
||||
className={cn(
|
||||
"h-[14px] w-[14px] transform rounded-full",
|
||||
"bg-white transition duration-50 ease-in-out",
|
||||
"translate-x-0 data-[checked]:translate-x-full",
|
||||
props.switchClassName,
|
||||
)}
|
||||
/>
|
||||
</BaseSwitch.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { HTMLProps } from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
function TableList(props: HTMLProps<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn("rounded-lg", "border border-mist-200 dark:border-mist-800", props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Item(props: HTMLProps<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
"flex items-center justify-between p-2 last:border-b-0",
|
||||
"border-b border-mist-200 dark:border-mist-800",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(TableList, { Item });
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Tabs as BaseTabs } from "@base-ui/react/tabs";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface TabsProps {
|
||||
label: string;
|
||||
className?: string;
|
||||
defaultValue?: string | number;
|
||||
value?: string | number;
|
||||
onValueChange?: (value: string | number) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function Tabs({ label, className, children, ...props }: TabsProps) {
|
||||
return (
|
||||
<BaseTabs.Root
|
||||
{...props}
|
||||
defaultValue={props.defaultValue ?? 0}
|
||||
aria-label={label}
|
||||
className={cn("flex flex-col", className)}
|
||||
>
|
||||
{children}
|
||||
</BaseTabs.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function TabList({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<BaseTabs.List
|
||||
className={cn(
|
||||
"flex items-center rounded-t-lg w-fit max-w-full",
|
||||
"border-mist-200 dark:border-mist-800",
|
||||
"border-t border-x",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</BaseTabs.List>
|
||||
);
|
||||
}
|
||||
|
||||
function Tab({
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof BaseTabs.Tab> & { className?: string }) {
|
||||
return (
|
||||
<BaseTabs.Tab
|
||||
value={value}
|
||||
{...props}
|
||||
className={cn(
|
||||
"pl-2 pr-3 py-2.5",
|
||||
"data-[selected]:bg-mist-50 dark:data-[selected]:bg-mist-950",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1 z-10",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
"border-r border-mist-200 dark:border-mist-800",
|
||||
"first:rounded-tl-lg last:rounded-tr-lg last:border-r-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</BaseTabs.Tab>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof BaseTabs.Panel> & { className?: string }) {
|
||||
return (
|
||||
<BaseTabs.Panel
|
||||
value={value}
|
||||
{...props}
|
||||
className={cn(
|
||||
"w-full overflow-clip rounded-b-lg rounded-r-lg",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</BaseTabs.Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabList as TabsList, Tab as TabsTab, Panel as TabsPanel };
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export interface ExitNodeTagProps {
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function ExitNodeTag({ isEnabled }: ExitNodeTagProps) {
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
isEnabled ? (
|
||||
<>This machine is acting as an exit node.</>
|
||||
) : (
|
||||
<>
|
||||
This machine is requesting to be used as an exit node. Review this from the "Edit route
|
||||
settings..." option in the machine's menu.
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
text="Exit Node"
|
||||
className={cn("bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300")}
|
||||
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export interface ExpiryTagProps {
|
||||
variant: "expired" | "no-expiry";
|
||||
expiry?: string;
|
||||
}
|
||||
|
||||
export function ExpiryTag({ variant, expiry }: ExpiryTagProps) {
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
variant === "expired" ? (
|
||||
<>
|
||||
This machine is expired and will not be able to connect to the network. Re-authenticate
|
||||
with Tailscale on the machine to re-enable it.
|
||||
</>
|
||||
) : (
|
||||
<>This machine has key expiry disabled and will never need to re-authenticate.</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
text={
|
||||
variant === "expired" ? `Expired ${formatter.format(new Date(expiry!))}` : "No expiry"
|
||||
}
|
||||
className="bg-mist-200 text-mist-800 dark:bg-mist-800 dark:text-mist-200"
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export function HeadplaneAgentTag() {
|
||||
return (
|
||||
<Tooltip content="This machine is running the Headplane agent, which allows it to provide host information in the web UI.">
|
||||
<Chip
|
||||
text="Headplane Agent"
|
||||
className={cn("bg-purple-300 text-purple-900 dark:bg-purple-900 dark:text-purple-300")}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export interface SubnetTagProps {
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function SubnetTag({ isEnabled }: SubnetTagProps) {
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
isEnabled ? (
|
||||
<>This machine advertises subnet routes.</>
|
||||
) : (
|
||||
<>
|
||||
This machine has unadvertised subnet routes. Review this from the "Edit route
|
||||
settings..." option in the machine's menu.
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
text="Subnets"
|
||||
className={cn("bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300")}
|
||||
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export function TailscaleSSHTag() {
|
||||
return (
|
||||
<Tooltip content="This machine advertises Tailscale SSH, which allows you to authenticate SSH credentials using your Tailscale account and via the Headplane web UI.">
|
||||
<Chip
|
||||
text="Tailscale SSH"
|
||||
className={cn("bg-lime-500 text-lime-900 dark:bg-lime-900 dark:text-lime-500")}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface TitleProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Title({ children, className }: TitleProps) {
|
||||
return <h3 className={cn("text-2xl font-bold mb-2", className)}>{children}</h3>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface TooltipProps {
|
||||
children: ReactNode;
|
||||
content: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Tooltip({ children, content, className }: TooltipProps) {
|
||||
return (
|
||||
<BaseTooltip.Root>
|
||||
<BaseTooltip.Trigger
|
||||
delay={0}
|
||||
closeDelay={0}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</BaseTooltip.Trigger>
|
||||
<BaseTooltip.Portal>
|
||||
<BaseTooltip.Positioner sideOffset={4}>
|
||||
<BaseTooltip.Popup
|
||||
className={cn(
|
||||
"z-50 rounded-lg p-3 text-sm w-48",
|
||||
"outline-hidden",
|
||||
"bg-white dark:bg-mist-950",
|
||||
"text-black dark:text-white",
|
||||
"shadow-overlay",
|
||||
"border border-mist-100 dark:border-mist-800",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</BaseTooltip.Popup>
|
||||
</BaseTooltip.Positioner>
|
||||
</BaseTooltip.Portal>
|
||||
</BaseTooltip.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StrictMode, startTransition } from 'react';
|
||||
import { hydrateRoot } from 'react-dom/client';
|
||||
import { HydratedRouter } from 'react-router/dom';
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<HydratedRouter />
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
@@ -1,62 +1,65 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||
import type { AppLoadContext, EntryContext } from "react-router";
|
||||
|
||||
import type { AppLoadContext, EntryContext } from '@remix-run/node'
|
||||
import { createReadableStreamFromReadable } from '@remix-run/node'
|
||||
import { RemixServer } from '@remix-run/react'
|
||||
import { isbot } from 'isbot'
|
||||
import { renderToPipeableStream } from 'react-dom/server'
|
||||
|
||||
import { loadContext } from './utils/config/headplane'
|
||||
|
||||
await loadContext()
|
||||
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||
import { isbot } from "isbot";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
import { ServerRouter } from "react-router";
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_loadContext: AppLoadContext,
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
_loadContext: AppLoadContext,
|
||||
) {
|
||||
const ua = request.headers.get('user-agent')
|
||||
const isBot = ua ? isbot(ua) : false
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const userAgent = request.headers.get("user-agent");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer
|
||||
context={remixContext}
|
||||
url={request.url}
|
||||
abortDelay={5000}
|
||||
/>,
|
||||
{
|
||||
[isBot ? 'onAllReady' : 'onShellReady']() {
|
||||
shellRendered = true
|
||||
const body = new PassThrough()
|
||||
const stream = createReadableStreamFromReadable(body)
|
||||
responseHeaders.set('Content-Type', 'text/html')
|
||||
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
|
||||
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
|
||||
const readyOption: keyof RenderToPipeableStreamOptions =
|
||||
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? "onAllReady" : "onShellReady";
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
}),
|
||||
)
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<ServerRouter context={routerContext} url={request.url} />,
|
||||
{
|
||||
[readyOption]() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
pipe(body)
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error as Error)
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500
|
||||
if (shellRendered) {
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
|
||||
setTimeout(abort, 5000)
|
||||
})
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
}),
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Abort the rendering stream after the `streamTimeout` so it has tine to
|
||||
// flush down the rejected boundaries
|
||||
setTimeout(abort, streamTimeout + 1000);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { type, type Type } from "arktype";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type FormValue = string | number | boolean | null;
|
||||
|
||||
interface FormState {
|
||||
values: Record<string, FormValue>;
|
||||
errors: Record<string, string>;
|
||||
modified: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface FormFieldProps {
|
||||
name: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur: () => void;
|
||||
invalid: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface UseFormOptions<T extends Record<string, unknown>> {
|
||||
schema: Type<T>;
|
||||
defaultValues?: Partial<Record<keyof T & string, FormValue>>;
|
||||
actionData?: unknown;
|
||||
validate?: (values: Record<string, FormValue>) => Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
// arktype's Type<T> doesn't expose `.props` in the type system,
|
||||
// but it exists at runtime. This extracts schema keys safely.
|
||||
function schemaKeys(schema: Type): string[] {
|
||||
const props = (schema as unknown as { props?: { key: string }[] }).props;
|
||||
return props?.map((p) => p.key) ?? [];
|
||||
}
|
||||
|
||||
export function useForm<T extends Record<string, unknown>>(options: UseFormOptions<T>) {
|
||||
const { schema, actionData, validate } = options;
|
||||
const keys = schemaKeys(schema);
|
||||
const validateRef = useRef(validate);
|
||||
validateRef.current = validate;
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
const values: Record<string, FormValue> = {};
|
||||
for (const key of keys) {
|
||||
values[key] =
|
||||
options.defaultValues && key in options.defaultValues
|
||||
? (options.defaultValues[key as keyof T & string] as FormValue)
|
||||
: "";
|
||||
}
|
||||
return values;
|
||||
}, []);
|
||||
|
||||
const [state, setState] = useState<FormState>({
|
||||
values: initialValues,
|
||||
errors: {},
|
||||
modified: {},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData && typeof actionData === "object" && "errors" in actionData) {
|
||||
const errors = actionData.errors as Record<string, string>;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
errors: { ...prev.errors, ...errors },
|
||||
}));
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
const setValue = useCallback((name: keyof T & string, raw: FormValue) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
values: { ...prev.values, [name]: raw },
|
||||
modified: { ...prev.modified, [name]: true },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
function validateField(name: string) {
|
||||
if (!state.modified[name]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
// Schema validation
|
||||
const result = schema({ ...state.values } as never);
|
||||
if (result instanceof type.errors) {
|
||||
const fieldProblems = result.flatProblemsByPath[name];
|
||||
if (fieldProblems) {
|
||||
errors[name] = fieldProblems[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-field validation
|
||||
if (!errors[name]) {
|
||||
const customErrors = validateRef.current?.(state.values);
|
||||
if (customErrors?.[name]) {
|
||||
errors[name] = customErrors[name];
|
||||
}
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
errors: {
|
||||
...prev.errors,
|
||||
[name]: errors[name] ?? "",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function field(name: keyof T & string): FormFieldProps {
|
||||
const error = state.errors[name];
|
||||
return {
|
||||
name,
|
||||
value: String(state.values[name] ?? ""),
|
||||
onChange: (value: string) => setValue(name, value),
|
||||
onBlur: () => validateField(name),
|
||||
invalid: !!error,
|
||||
errorMessage: error || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function computeCanSubmit(): boolean {
|
||||
const result = schema({ ...state.values } as never);
|
||||
if (result instanceof type.errors) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const customErrors = validateRef.current?.(state.values);
|
||||
if (customErrors && Object.values(customErrors).some(Boolean)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({
|
||||
values: { ...initialValues },
|
||||
errors: {},
|
||||
modified: {},
|
||||
});
|
||||
}, [initialValues]);
|
||||
|
||||
return {
|
||||
values: state.values,
|
||||
errors: state.errors,
|
||||
field,
|
||||
canSubmit: computeCanSubmit(),
|
||||
setValue,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
type ServerValidationResult<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; errors: Record<string, string> };
|
||||
|
||||
export async function validateFormData<T extends Record<string, unknown>>(
|
||||
request: Request,
|
||||
schema: Type<T>,
|
||||
): Promise<ServerValidationResult<T>> {
|
||||
const formData = await request.formData();
|
||||
const parsed: Record<string, unknown> = {};
|
||||
|
||||
for (const key of schemaKeys(schema)) {
|
||||
const raw = formData.get(key);
|
||||
if (raw !== null) {
|
||||
parsed[key] = raw.toString();
|
||||
}
|
||||
}
|
||||
|
||||
const result = schema(parsed as never);
|
||||
if (result instanceof type.errors) {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const [path, problems] of Object.entries(result.flatProblemsByPath)) {
|
||||
errors[path] = problems[0];
|
||||
}
|
||||
return { success: false, errors };
|
||||
}
|
||||
|
||||
return { success: true, data: result as T };
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { access, constants } from 'node:fs/promises'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
|
||||
import { Client } from 'undici'
|
||||
|
||||
import { HeadscaleError, pull } from '~/utils/headscale'
|
||||
import log from '~/utils/log'
|
||||
|
||||
import { createIntegration } from './integration'
|
||||
|
||||
interface Context {
|
||||
client: Client | undefined
|
||||
container: string | undefined
|
||||
maxAttempts: number
|
||||
}
|
||||
|
||||
export default createIntegration<Context>({
|
||||
name: 'Docker',
|
||||
context: {
|
||||
client: undefined,
|
||||
container: undefined,
|
||||
maxAttempts: 10,
|
||||
},
|
||||
isAvailable: async (context) => {
|
||||
// Check for the HEADSCALE_CONTAINER environment variable first
|
||||
// to avoid unnecessary fetching of the Docker socket
|
||||
log.debug('INTG', 'Checking Docker integration availability')
|
||||
context.container = process.env.HEADSCALE_CONTAINER
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
|
||||
if (!context.container || context.container.length === 0) {
|
||||
log.error('INTG', 'Missing HEADSCALE_CONTAINER variable')
|
||||
return false
|
||||
}
|
||||
|
||||
log.info('INTG', 'Using container: %s', context.container)
|
||||
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'
|
||||
let url: URL | undefined
|
||||
|
||||
try {
|
||||
url = new URL(path)
|
||||
} catch {
|
||||
log.error('INTG', 'Invalid Docker socket path: %s', path)
|
||||
return false
|
||||
}
|
||||
|
||||
if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') {
|
||||
log.error('INTG', 'Invalid Docker socket protocol: %s',
|
||||
url.protocol,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// The API is available as an HTTP endpoint and this
|
||||
// will simplify the fetching logic in undici
|
||||
if (url.protocol === 'tcp:') {
|
||||
// Apparently setting url.protocol doesn't work anymore?
|
||||
const fetchU = url.href.replace(url.protocol, 'http:')
|
||||
|
||||
try {
|
||||
log.info('INTG', 'Checking API: %s', fetchU)
|
||||
await fetch(new URL('/v1.30/version', fetchU).href)
|
||||
} catch (error) {
|
||||
log.debug('INTG', 'Failed to connect to Docker API', error)
|
||||
log.error('INTG', 'Failed to connect to Docker API')
|
||||
return false
|
||||
}
|
||||
|
||||
context.client = new Client(fetchU)
|
||||
}
|
||||
|
||||
// Check if the socket is accessible
|
||||
if (url.protocol === 'unix:') {
|
||||
try {
|
||||
log.info('INTG', 'Checking socket: %s',
|
||||
url.pathname,
|
||||
)
|
||||
await access(url.pathname, constants.R_OK)
|
||||
} catch (error) {
|
||||
log.debug('INTG', 'Failed to access Docker socket: %s', error)
|
||||
log.error('INTG', 'Failed to access Docker socket: %s',
|
||||
path,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
context.client = new Client('http://localhost', {
|
||||
socketPath: url.pathname,
|
||||
})
|
||||
}
|
||||
|
||||
return context.client !== undefined
|
||||
},
|
||||
|
||||
onConfigChange: async (context) => {
|
||||
if (!context.client || !context.container) {
|
||||
return
|
||||
}
|
||||
|
||||
log.info('INTG', 'Restarting Headscale via Docker')
|
||||
|
||||
let attempts = 0
|
||||
while (attempts <= context.maxAttempts) {
|
||||
log.debug(
|
||||
'INTG', 'Restarting container: %s (attempt %d)',
|
||||
context.container,
|
||||
attempts,
|
||||
)
|
||||
|
||||
const response = await context.client.request({
|
||||
method: 'POST',
|
||||
path: `/v1.30/containers/${context.container}/restart`,
|
||||
})
|
||||
|
||||
if (response.statusCode !== 204) {
|
||||
if (attempts < context.maxAttempts) {
|
||||
attempts++
|
||||
await setTimeout(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
const stringCode = response.statusCode.toString()
|
||||
const body = await response.body.text()
|
||||
throw new Error(`API request failed: ${stringCode} ${body}`)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
attempts = 0
|
||||
while (attempts <= context.maxAttempts) {
|
||||
try {
|
||||
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts)
|
||||
await pull('v1', '')
|
||||
return
|
||||
} catch (error) {
|
||||
if (error instanceof HeadscaleError && error.status === 401) {
|
||||
break
|
||||
}
|
||||
|
||||
if (error instanceof HeadscaleError && error.status === 404) {
|
||||
break
|
||||
}
|
||||
|
||||
if (attempts < context.maxAttempts) {
|
||||
attempts++
|
||||
await setTimeout(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
throw new Error(`Missed restart deadline for ${context.container}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,75 +0,0 @@
|
||||
import log from '~/utils/log'
|
||||
|
||||
import dockerIntegration from './docker'
|
||||
import { IntegrationFactory } from './integration'
|
||||
import kubernetesIntegration from './kubernetes'
|
||||
import procIntegration from './proc'
|
||||
|
||||
export * from './integration'
|
||||
|
||||
export async function loadIntegration() {
|
||||
let integration = process.env.HEADSCALE_INTEGRATION
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
|
||||
// Old HEADSCALE_CONTAINER variable upgrade path
|
||||
// This ensures that when people upgrade from older versions of Headplane
|
||||
// they don't explicitly need to define the new HEADSCALE_INTEGRATION
|
||||
// variable that is needed to configure docker
|
||||
if (!integration && process.env.HEADSCALE_CONTAINER) {
|
||||
integration = 'docker'
|
||||
}
|
||||
|
||||
if (!integration) {
|
||||
log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION')
|
||||
return
|
||||
}
|
||||
|
||||
let integrationFactory: IntegrationFactory | undefined
|
||||
switch (integration.toLowerCase().trim()) {
|
||||
case 'docker': {
|
||||
integrationFactory = dockerIntegration
|
||||
break
|
||||
}
|
||||
|
||||
case 'proc':
|
||||
case 'native':
|
||||
case 'linux': {
|
||||
integrationFactory = procIntegration
|
||||
break
|
||||
}
|
||||
|
||||
case 'kubernetes':
|
||||
case 'k8s': {
|
||||
integrationFactory = kubernetesIntegration
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
log.error('INTG', 'Unknown integration: %s', integration)
|
||||
throw new Error(`Unknown integration: ${integration}`)
|
||||
}
|
||||
}
|
||||
|
||||
log.info('INTG', 'Loading integration: %s', integration)
|
||||
try {
|
||||
const res = await integrationFactory.isAvailable(
|
||||
integrationFactory.context,
|
||||
)
|
||||
if (!res) {
|
||||
log.error('INTG', 'Integration %s is not available',
|
||||
integration,
|
||||
)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to load integration %s: %s',
|
||||
integration,
|
||||
error,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
log.info('INTG', 'Loaded integration: %s', integration)
|
||||
return integrationFactory
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export interface IntegrationFactory<T = any> {
|
||||
name: string
|
||||
context: T
|
||||
isAvailable: (context: T) => Promise<boolean> | boolean
|
||||
onConfigChange?: (context: T) => Promise<void> | void
|
||||
}
|
||||
|
||||
export function createIntegration<T>(
|
||||
options: IntegrationFactory<T>,
|
||||
) {
|
||||
return options
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { platform } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { kill } from 'node:process'
|
||||
|
||||
import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node'
|
||||
|
||||
import log from '~/utils/log'
|
||||
|
||||
import { createIntegration } from './integration'
|
||||
|
||||
interface Context {
|
||||
pid: number | undefined
|
||||
}
|
||||
|
||||
export default createIntegration<Context>({
|
||||
name: 'Kubernetes (k8s)',
|
||||
context: {
|
||||
pid: undefined,
|
||||
},
|
||||
isAvailable: async (context) => {
|
||||
if (platform() !== 'linux') {
|
||||
log.error('INTG', 'Kubernetes is only available on Linux')
|
||||
return false
|
||||
}
|
||||
|
||||
const svcRoot = Config.SERVICEACCOUNT_ROOT
|
||||
try {
|
||||
log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot)
|
||||
const files = await readdir(svcRoot)
|
||||
if (files.length === 0) {
|
||||
log.error('INTG', 'Kubernetes service account not found')
|
||||
return false
|
||||
}
|
||||
|
||||
const mappedFiles = new Set(files.map(file => join(svcRoot, file)))
|
||||
const expectedFiles = [
|
||||
Config.SERVICEACCOUNT_CA_PATH,
|
||||
Config.SERVICEACCOUNT_TOKEN_PATH,
|
||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||
]
|
||||
|
||||
log.debug('INTG', 'Looking for %s', expectedFiles.join(', '))
|
||||
if (!expectedFiles.every(file => mappedFiles.has(file))) {
|
||||
log.error('INTG', 'Malformed Kubernetes service account')
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to access %s: %s', svcRoot, error)
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot)
|
||||
const namespace = await readFile(
|
||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
// Some very ugly nesting but it's necessary
|
||||
if (process.env.HEADSCALE_INTEGRATION_UNSTRICT === 'true') {
|
||||
log.warn('INTG', 'Skipping strict Pod status check')
|
||||
} else {
|
||||
const pod = process.env.POD_NAME
|
||||
if (!pod) {
|
||||
log.error('INTG', 'Missing POD_NAME variable')
|
||||
return false
|
||||
}
|
||||
|
||||
if (pod.trim().length === 0) {
|
||||
log.error('INTG', 'Pod name is empty')
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Checking Kubernetes pod %s in namespace %s',
|
||||
pod,
|
||||
namespace,
|
||||
)
|
||||
|
||||
try {
|
||||
log.debug('INTG', 'Attempgin to get cluster KubeConfig')
|
||||
const kc = new KubeConfig()
|
||||
kc.loadFromCluster()
|
||||
|
||||
const cluster = kc.getCurrentCluster()
|
||||
if (!cluster) {
|
||||
log.error('INTG', 'Malformed kubeconfig')
|
||||
return false
|
||||
}
|
||||
|
||||
log.info('INTG', 'Service account connected to %s (%s)',
|
||||
cluster.name,
|
||||
cluster.server,
|
||||
)
|
||||
|
||||
const kCoreV1Api = kc.makeApiClient(CoreV1Api)
|
||||
|
||||
log.info('INTG', 'Checking pod %s in namespace %s (%s)',
|
||||
pod,
|
||||
namespace,
|
||||
kCoreV1Api.basePath,
|
||||
)
|
||||
|
||||
log.debug('INTG', 'Reading pod info for %s', pod)
|
||||
const { response, body } = await kCoreV1Api.readNamespacedPod(
|
||||
pod,
|
||||
namespace,
|
||||
)
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
log.error('INTG', 'Failed to read pod info: http %d',
|
||||
response.statusCode,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Got pod info: %o', body.spec)
|
||||
const shared = body.spec?.shareProcessNamespace
|
||||
if (shared === undefined) {
|
||||
log.error(
|
||||
'INTG',
|
||||
'Pod does not have spec.shareProcessNamespace set',
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!shared) {
|
||||
log.error(
|
||||
'INTG',
|
||||
'Pod has set but disabled spec.shareProcessNamespace',
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
log.info('INTG', 'Pod %s enabled shared processes', pod)
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to read pod info: %s', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Looking for namespaced process in /proc')
|
||||
const dir = resolve('/proc')
|
||||
try {
|
||||
const subdirs = await readdir(dir)
|
||||
const promises = subdirs.map(async (dir) => {
|
||||
const pid = Number.parseInt(dir, 10)
|
||||
|
||||
if (Number.isNaN(pid)) {
|
||||
return
|
||||
}
|
||||
|
||||
const path = join('/proc', dir, 'cmdline')
|
||||
try {
|
||||
log.debug('INTG', 'Reading %s', path)
|
||||
const data = await readFile(path, 'utf8')
|
||||
if (data.includes('headscale')) {
|
||||
return pid
|
||||
}
|
||||
} catch (error) {
|
||||
log.debug('INTG', 'Failed to read %s: %s', path, error)
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(promises)
|
||||
const pids = []
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
pids.push(result.value)
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Found Headscale processes: %o', pids)
|
||||
if (pids.length > 1) {
|
||||
log.error('INTG', 'Found %d Headscale processes: %s',
|
||||
pids.length,
|
||||
pids.join(', '),
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
if (pids.length === 0) {
|
||||
log.error('INTG', 'Could not find Headscale process')
|
||||
return false
|
||||
}
|
||||
|
||||
context.pid = pids[0]
|
||||
log.info('INTG', 'Found Headscale process with PID: %d', context.pid)
|
||||
return true
|
||||
} catch {
|
||||
log.error('INTG', 'Failed to read /proc')
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
onConfigChange: (context) => {
|
||||
if (!context.pid) {
|
||||
return
|
||||
}
|
||||
|
||||
log.info('INTG', 'Sending SIGTERM to Headscale')
|
||||
kill(context.pid, 'SIGTERM')
|
||||
},
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { platform } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { kill } from 'node:process'
|
||||
|
||||
import log from '~/utils/log'
|
||||
|
||||
import { createIntegration } from './integration'
|
||||
|
||||
interface Context {
|
||||
pid: number | undefined
|
||||
}
|
||||
|
||||
export default createIntegration<Context>({
|
||||
name: 'Native Linux (/proc)',
|
||||
context: {
|
||||
pid: undefined,
|
||||
},
|
||||
isAvailable: async (context) => {
|
||||
if (platform() !== 'linux') {
|
||||
log.error('INTG', '/proc is only available on Linux')
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Checking /proc for Headscale process')
|
||||
const dir = resolve('/proc')
|
||||
try {
|
||||
const subdirs = await readdir(dir)
|
||||
const promises = subdirs.map(async (dir) => {
|
||||
const pid = Number.parseInt(dir, 10)
|
||||
|
||||
if (Number.isNaN(pid)) {
|
||||
return
|
||||
}
|
||||
|
||||
const path = join('/proc', dir, 'cmdline')
|
||||
try {
|
||||
log.debug('INTG', 'Reading %s', path)
|
||||
const data = await readFile(path, 'utf8')
|
||||
if (data.includes('headscale')) {
|
||||
return pid
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to read %s: %s', path, error)
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(promises)
|
||||
const pids = []
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
pids.push(result.value)
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Found Headscale processes: %o', pids)
|
||||
if (pids.length > 1) {
|
||||
log.error('INTG', 'Found %d Headscale processes: %s',
|
||||
pids.length,
|
||||
pids.join(', '),
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
if (pids.length === 0) {
|
||||
log.error('INTG', 'Could not find Headscale process')
|
||||
return false
|
||||
}
|
||||
|
||||
context.pid = pids[0]
|
||||
log.info('INTG', 'Found Headscale process with PID: %d', context.pid)
|
||||
return true
|
||||
} catch {
|
||||
log.error('INTG', 'Failed to read /proc')
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router";
|
||||
|
||||
import { ErrorBanner } from "~/components/error-banner";
|
||||
import StatusBanner from "~/components/status-banner";
|
||||
import { pruneEphemeralNodes } from "~/server/db/pruner";
|
||||
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
|
||||
import { usersResource } from "~/server/headscale/live-store";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { Route } from "./+types/app";
|
||||
import Footer from "./footer";
|
||||
import Header from "./header";
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = ({
|
||||
currentUrl,
|
||||
nextUrl,
|
||||
formAction,
|
||||
defaultShouldRevalidate,
|
||||
}) => {
|
||||
if (formAction) {
|
||||
return defaultShouldRevalidate;
|
||||
}
|
||||
|
||||
// Allow programmatic revalidations (e.g. SSE-triggered) where the URL hasn't changed
|
||||
if (currentUrl.href === nextUrl.href) {
|
||||
return defaultShouldRevalidate;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
||||
try {
|
||||
const principal = await context.auth.require(request);
|
||||
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
|
||||
const user =
|
||||
principal.kind === "oidc"
|
||||
? {
|
||||
email: principal.profile.email,
|
||||
name: principal.profile.name,
|
||||
picture: principal.profile.picture,
|
||||
subject: principal.user.subject,
|
||||
username: principal.profile.username,
|
||||
}
|
||||
: { name: principal.displayName, subject: "api_key" };
|
||||
|
||||
// MARK: The session should stay valid if Headscale isn't healthy
|
||||
const isHealthy = await api.isHealthy();
|
||||
if (isHealthy) {
|
||||
try {
|
||||
await api.getApiKeys();
|
||||
await pruneEphemeralNodes({ context, request, ...rest });
|
||||
} catch (error) {
|
||||
if (isDataUnauthorizedError(error)) {
|
||||
const displayName =
|
||||
principal.kind === "oidc" ? principal.profile.name : principal.displayName;
|
||||
log.warn("auth", "Logging out %s due to expired API key", displayName);
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.destroySession(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Self-heal: if the linked Headscale user was deleted, clear the
|
||||
// stale link so the user gets prompted to re-link.
|
||||
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
|
||||
try {
|
||||
const usersSnap = await context.hsLive.get(usersResource, api);
|
||||
if (!usersSnap.data.some((u) => u.id === principal.user.headscaleUserId)) {
|
||||
await context.auth.unlinkHeadscaleUser(principal.user.id);
|
||||
}
|
||||
} catch {
|
||||
// API call failed, skip validation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
access: {
|
||||
dns: context.auth.can(principal, Capabilities.read_network),
|
||||
machines: context.auth.can(principal, Capabilities.read_machines),
|
||||
policy: context.auth.can(principal, Capabilities.read_policy),
|
||||
settings: context.auth.can(principal, Capabilities.read_feature),
|
||||
ui: context.auth.can(principal, Capabilities.ui_access),
|
||||
users: context.auth.can(principal, Capabilities.read_users),
|
||||
},
|
||||
baseUrl: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
configAvailable: context.hs.readable(),
|
||||
isDebug: context.config.debug,
|
||||
isHealthy,
|
||||
user,
|
||||
};
|
||||
} catch {
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.destroySession(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function AppLayout({ loaderData }: Route.ComponentProps) {
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
access={loaderData.access}
|
||||
configAvailable={loaderData.configAvailable}
|
||||
user={loaderData.user}
|
||||
/>
|
||||
<main className="container mt-4 mb-24 overscroll-contain">
|
||||
{!loaderData.isHealthy && (
|
||||
<StatusBanner
|
||||
className="mb-4"
|
||||
dismissable={false}
|
||||
title="Headscale Unreachable"
|
||||
variant="critical"
|
||||
>
|
||||
Unable to connect to the Headscale server. Data shown may be stale and changes cannot be
|
||||
saved until the connection is restored.
|
||||
</StatusBanner>
|
||||
)}
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer isDebug={loaderData.isDebug} baseUrl={loaderData.baseUrl} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
return (
|
||||
<div className="mx-auto my-24 w-fit overscroll-contain">
|
||||
<ErrorBanner className="max-w-2xl" error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import Link from "~/components/link";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface FooterProps {
|
||||
isDebug: boolean;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export default function Footer({ isDebug, baseUrl }: FooterProps) {
|
||||
const [urlVisible, setUrlVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
"fixed w-full bottom-0 left-0 z-20",
|
||||
"bg-mist-50 dark:bg-mist-950",
|
||||
"dark:border-t dark:border-mist-800",
|
||||
)}
|
||||
>
|
||||
<div className="container flex items-center justify-between py-2">
|
||||
<p className="text-xs">
|
||||
Headplane is free and open-source. Please consider{" "}
|
||||
<Link external styled to="https://tale.me/sponsor">
|
||||
sponsoring
|
||||
</Link>{" "}
|
||||
to support development.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{isDebug && (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 font-medium",
|
||||
"bg-amber-100 text-amber-800",
|
||||
"dark:bg-amber-900/50 dark:text-amber-300",
|
||||
)}
|
||||
>
|
||||
Debug
|
||||
</span>
|
||||
)}
|
||||
<p className="text-mist-500 dark:text-mist-400">
|
||||
{__VERSION__} ·{" "}
|
||||
{urlVisible ? (
|
||||
<code>{baseUrl}</code>
|
||||
) : (
|
||||
<span aria-hidden="true">•••••</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={urlVisible ? "Hide server URL" : "Show server URL"}
|
||||
className={cn(
|
||||
"ml-1 inline-flex align-middle rounded-xs p-0.5",
|
||||
"text-mist-400 hover:text-mist-600",
|
||||
"dark:text-mist-500 dark:hover:text-mist-300",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
)}
|
||||
onClick={() => setUrlVisible((v) => !v)}
|
||||
>
|
||||
{urlVisible ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { CircleQuestionMark, CircleUser, Globe, Lock, Server, Settings, Users } from "lucide-react";
|
||||
import { NavLink, useSubmit } from "react-router";
|
||||
|
||||
import Link from "~/components/link";
|
||||
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
|
||||
import logoBg from "~/logo/dark-bg.svg";
|
||||
import logoDark from "~/logo/dark.svg";
|
||||
import logoLight from "~/logo/light.svg";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface HeaderProps {
|
||||
user: {
|
||||
subject: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
access: {
|
||||
ui: boolean;
|
||||
machines: boolean;
|
||||
dns: boolean;
|
||||
users: boolean;
|
||||
policy: boolean;
|
||||
settings: boolean;
|
||||
};
|
||||
configAvailable: boolean;
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ to: "/machines", icon: Server, label: "Machines", key: "machines" },
|
||||
{ to: "/users", icon: Users, label: "Users", key: "users" },
|
||||
{ to: "/acls", icon: Lock, label: "Access Control", key: "policy" },
|
||||
{ to: "/dns", icon: Globe, label: "DNS", key: "dns" },
|
||||
{ to: "/settings", icon: Settings, label: "Settings", key: "settings" },
|
||||
] as const;
|
||||
|
||||
export default function Header({ user, access, configAvailable }: HeaderProps) {
|
||||
const submit = useSubmit();
|
||||
const showTabs = access.ui;
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"bg-mist-200 dark:bg-mist-950 text-mist-800 dark:text-mist-200",
|
||||
"dark:border-b dark:border-mist-800 shadow-inner",
|
||||
)}
|
||||
>
|
||||
<div className="container flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-x-8">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<picture>
|
||||
<source srcSet={logoLight} media="(prefers-color-scheme: dark)" />
|
||||
<source srcSet={logoDark} media="(prefers-color-scheme: light)" />
|
||||
<img src={logoBg} alt="Headplane logo" />
|
||||
</picture>
|
||||
<h1 className="text-2xl font-semibold">headplane</h1>
|
||||
</div>
|
||||
{showTabs && (
|
||||
<nav className="hidden items-center gap-x-2 text-sm font-medium md:flex">
|
||||
{tabs.map((tab) => {
|
||||
if (!access[tab.key]) return null;
|
||||
if ((tab.key === "dns" || tab.key === "settings") && !configAvailable) return null;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"px-3 py-1.5 flex items-center gap-x-1.5 rounded-md text-nowrap",
|
||||
"hover:bg-mist-300/50 dark:hover:bg-mist-800",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
isActive
|
||||
? "bg-mist-300/70 dark:bg-mist-800 text-mist-900 dark:text-mist-50"
|
||||
: "text-mist-600 dark:text-mist-300",
|
||||
)
|
||||
}
|
||||
prefetch="intent"
|
||||
to={tab.to}
|
||||
>
|
||||
<tab.icon className="w-4" />
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
<Menu>
|
||||
<MenuTrigger className="size-8 rounded-full p-1">
|
||||
<CircleQuestionMark className="w-5" />
|
||||
</MenuTrigger>
|
||||
<MenuContent align="end">
|
||||
<MenuItem>
|
||||
<Link external to="https://headplane.net">
|
||||
Docs
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Link external to="https://headscale.net">
|
||||
Headscale
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Link external to="https://tailscale.com/download">
|
||||
Download
|
||||
</Link>
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
<Menu>
|
||||
<MenuTrigger className="size-8 overflow-hidden rounded-full">
|
||||
{user.picture ? (
|
||||
<img alt={user.name} className="size-8" src={user.picture} />
|
||||
) : (
|
||||
<CircleUser className="size-8" />
|
||||
)}
|
||||
</MenuTrigger>
|
||||
<MenuContent align="end">
|
||||
<MenuItem disabled>
|
||||
<div className="text-mist-900 dark:text-mist-50">
|
||||
{user.subject === "api_key" ? (
|
||||
<>
|
||||
<p className="font-bold">API Key</p>
|
||||
<p>{user.name}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-bold">{user.name}</p>
|
||||
{user.email && <p>{user.email}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
variant="danger"
|
||||
onClick={() => submit({}, { action: "/logout", method: "POST" })}
|
||||
>
|
||||
Logout
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Form } from "react-router";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import Card from "~/components/card";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface LinkAccountProps {
|
||||
headscaleUsers: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export default function LinkAccount({ headscaleUsers }: LinkAccountProps) {
|
||||
return (
|
||||
<div className="mx-auto mt-6 flex max-w-xl flex-col items-center justify-center py-36">
|
||||
<Card variant="flat" className="max-w-xl items-center gap-4">
|
||||
<Card.Title>Link your Headscale account</Card.Title>
|
||||
<Card.Text>
|
||||
Headplane could not automatically match your SSO identity to an existing Headscale user.
|
||||
Please select your user from the list below to link your account and continue.
|
||||
</Card.Text>
|
||||
<Form method="POST" className="mt-4">
|
||||
<select
|
||||
className={cn(
|
||||
"mb-4 w-full rounded-lg border p-2",
|
||||
"border-mist-200 dark:border-mist-700",
|
||||
"bg-mist-50 dark:bg-mist-900",
|
||||
)}
|
||||
name="headscale_user_id"
|
||||
required
|
||||
>
|
||||
<option value="">Select a user...</option>
|
||||
{headscaleUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button className="w-full" type="submit" variant="heavy">
|
||||
Link and Continue
|
||||
</Button>
|
||||
</Form>
|
||||
<Card.Text className="mt-8 text-center text-xs text-mist-600 dark:text-mist-300">
|
||||
If you don't see your user listed, please contact your administrator. To automatically
|
||||
link new users in the future, ensure that the Headscale user has the same email address as
|
||||
the SSO identity.
|
||||
</Card.Text>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 30 30">
|
||||
<g transform="translate(-142.5 -229.5)">
|
||||
<path d="M120 147.5h20a5 5 45 0 1 5 5v20a5 5 135 0 1-5 5h-20a5 5 45 0 1-5-5v-20a5 5 135 0 1 5-5" style="fill:#181717" transform="translate(27.5 82)"/>
|
||||
<circle cx="150" cy="237" r="2.5" style="fill:#f7f5f4"/>
|
||||
<circle cx="157.5" cy="237" r="2.5" style="fill:#444343"/>
|
||||
<circle cx="165" cy="237" r="2.5" style="fill:#f7f5f4"/>
|
||||
<circle cx="150" cy="244.5" r="2.5" style="fill:#f7f5f4"/>
|
||||
<circle cx="157.5" cy="244.5" r="2.5" style="fill:#f7f5f4"/>
|
||||
<circle cx="165" cy="244.5" r="2.5" style="fill:#f7f5f4"/>
|
||||
<circle cx="150" cy="252" r="2.5" style="fill:#f7f5f4"/>
|
||||
<circle cx="157.5" cy="252" r="2.5" style="fill:#444343"/>
|
||||
<circle cx="165" cy="252" r="2.5" style="fill:#f7f5f4"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 901 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 30 30">
|
||||
<g transform="translate(-37.5 -229.5)">
|
||||
<circle cx="45" cy="237" r="2.5" style="fill:#1f1e1e"/>
|
||||
<circle cx="52.5" cy="237" r="2.5" style="fill:#cdcccb"/>
|
||||
<circle cx="60" cy="237" r="2.5" style="fill:#1f1e1e"/>
|
||||
<circle cx="45" cy="244.5" r="2.5" style="fill:#1f1e1e"/>
|
||||
<circle cx="52.5" cy="244.5" r="2.5" style="fill:#1f1e1e"/>
|
||||
<circle cx="60" cy="244.5" r="2.5" style="fill:#1f1e1e"/>
|
||||
<circle cx="45" cy="252" r="2.5" style="fill:#1f1e1e"/>
|
||||
<circle cx="52.5" cy="252" r="2.5" style="fill:#cdcccb"/>
|
||||
<circle cx="60" cy="252" r="2.5" style="fill:#1f1e1e"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 732 B |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 30 30">
|
||||
<g transform="translate(-32.5 -240.587)">
|
||||
<path d="M70 147.5h20a5 5 45 0 1 5 5v20a5 5 135 0 1-5 5H70a5 5 45 0 1-5-5v-20a5 5 135 0 1 5-5" style="fill:#f9f7f6" transform="translate(-32.5 93.087)"/>
|
||||
<circle cx="40" cy="248.087" r="2.5" style="fill:#1f1e1e"/>
|
||||
<circle cx="47.5" cy="248.087" r="2.5" style="fill:#cdcccb"/>
|
||||
<circle cx="55" cy="248.087" r="2.5" style="fill:#1f1e1e"/>
|
||||
<circle cx="40" cy="255.587" r="2.5" style="fill:#1f1e1e"/>
|
||||
<circle cx="47.5" cy="255.587" r="2.5" style="fill:#1f1e1e"/>
|
||||
<circle cx="55" cy="255.587" r="2.5" style="fill:#1f1e1e"/>
|
||||
<circle cx="40" cy="263.087" r="2.5" style="fill:#1f1e1e"/>
|
||||
<circle cx="47.5" cy="263.087" r="2.5" style="fill:#cdcccb"/>
|
||||
<circle cx="55" cy="263.087" r="2.5" style="fill:#1f1e1e"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 926 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 30 30">
|
||||
<g transform="translate(-142.5 -229.5)">
|
||||
<circle cx="150" cy="237" r="2.5" style="fill:#f7f5f4"/>
|
||||
<circle cx="157.5" cy="237" r="2.5" style="fill:#444343"/>
|
||||
<circle cx="165" cy="237" r="2.5" style="fill:#f7f5f4"/>
|
||||
<circle cx="150" cy="244.5" r="2.5" style="fill:#f7f5f4"/>
|
||||
<circle cx="157.5" cy="244.5" r="2.5" style="fill:#f7f5f4"/>
|
||||
<circle cx="165" cy="244.5" r="2.5" style="fill:#f7f5f4"/>
|
||||
<circle cx="150" cy="252" r="2.5" style="fill:#f7f5f4"/>
|
||||
<circle cx="157.5" cy="252" r="2.5" style="fill:#444343"/>
|
||||
<circle cx="165" cy="252" r="2.5" style="fill:#f7f5f4"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 742 B |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"0.26.1": ["0.26.0", "0.26.1"],
|
||||
"0.27.0": ["0.27.0"],
|
||||
"0.27.1": ["0.27.1"],
|
||||
"0.28.0": ["0.28.0"]
|
||||
}
|
||||