Compare commits

..

No commits in common. "main" and "0.1.2" have entirely different histories.
main ... 0.1.2

219 changed files with 11521 additions and 19043 deletions

7
.env.example Normal file
View File

@ -0,0 +1,7 @@
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

1
.envrc
View File

@ -1 +0,0 @@
use_flake

3
.eslintrc Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "tale"
}

2
.github/FUNDING.yml vendored
View File

@ -1,2 +0,0 @@
github: tale
ko_fi: atale

View File

@ -1,31 +0,0 @@
name: Bug Report
description: Report an issue with Headplane
assignees: [tale]
labels: [bug, triage]
body:
- type: textarea
attributes:
label: Description
description: |
A detailed description of the issue and steps to reproduce it.
If applicable, include any error messages or screenshots.
If this is not an issue with Headplane, but an issue with your
environment, please consider opening a discussion instead.
placeholder: e.g. "When I try to upload a file, I get an error message."
validations:
required: true
- type: input
attributes:
label: Headplane Version
description: What version of Headplane are you using?
placeholder: e.g. "v0.5.5"
validations:
required: true
- type: input
attributes:
label: Headscale Version
description: What version of Headscale are you using?
placeholder: e.g. "v0.25.1"
validations:
required: true

View File

@ -1 +0,0 @@
blank_issues_enabled: false

View File

@ -1,15 +0,0 @@
name: Feature Request
description: Request a new feature or enhancement for Headplane
assignees: [tale]
labels: [enhancement, triage]
body:
- type: textarea
attributes:
label: Description
description: |
A detailed description of the feature you would like to see added.
Please include any relevant context, such as why this feature is
important and how it would benefit other users beyond yourself.
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
validations:
required: true

View File

@ -1,33 +0,0 @@
name: Automated
on:
schedule:
- cron: "0 8 * * 0"
workflow_dispatch:
concurrency:
group: automation-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: write # Read/write access to the repository
pull-requests: write # Allow creating pull requests
jobs:
flake-inputs:
name: flake-inputs
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: DeterminateSystems/update-flake-lock@main
with:
pr-title: "chore: update flake.lock"
pr-labels: |
automated

View File

@ -1,76 +0,0 @@
name: Build
on:
push:
paths-ignore:
- ".zed/**"
- "assets/**"
- "docs/**"
- "CHANGELOG.md"
- "README.md"
branches:
- "main"
- "next"
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
jobs:
native:
name: native
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install node.js
uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
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
nix:
name: nix
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- name: Check flake inputs
uses: DeterminateSystems/flake-checker-action@main
- name: Check flake outputs
run: nix flake check --all-systems

View File

@ -1,55 +0,0 @@
name: Pre-release (next)
on:
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: pre-release-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
packages: write # Write access to the container registry
jobs:
publish:
# Ensure the action only runs if manually dispatched or a PR on the `next` branch in the *main* repository is opened or synchronized.
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'next') }}
name: Docker Pre-release
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=next
- 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 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

View File

@ -1,34 +1,17 @@
name: Release
name: Publish Docker Image
on:
push:
tags:
- "*"
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
packages: write # Write access to the container registry
- '*'
jobs:
docker:
name: Docker Release
publish:
name: Build and Publish
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@ -42,6 +25,12 @@ jobs:
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:

3
.gitignore vendored
View File

@ -1,6 +1,5 @@
node_modules
/.react-router
/.cache
/build
/test
.env

2
.npmrc
View File

@ -1,2 +0,0 @@
side-effects-cache = false
public-hoist-pattern[]=*hono*

View File

@ -1,2 +0,0 @@
pnpm 10.4.0
node 22

View File

@ -1,17 +0,0 @@
{
"formatter": {
"language_server": {
"name": "biome"
}
},
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true
},
"languages": {
"YAML": {
"tab_size": 2,
"hard_tabs": false
}
}
}

View File

@ -1,233 +1,3 @@
### 0.5.10 (April 4, 2025)
- Fix an issue where other prefernences 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.
- Improve documentation and support when running Headplane outside of Docker.
- Removing Split DNS records will no longer result in an error (fixes [#40](https://github.com/tale/headplane/issues/40))
- Removing the last ACL tag on a machine no longer results in an error (fixes [#41](https://github.com/tale/headplane/issues/41))
- Added full support for Exit Nodes in the UI and redesigned the machines page (fixes [#36](https://github.com/tale/headplane/issues/36))
- Added a basic check to see if the API keys passed via cookies are invalid.
### 0.3.3 (October 28, 2024)
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
- Improved ACL editor performance by switching back to CodeMirror 6
- Fixed an issue where editing the ACL policy would cause it to revert on the UI (fixes [#34](https://github.com/tale/headplane/issues/34))
- Updated to the next stable beta of the React 19 Compiler ([See More](https://react.dev/learn/react-compiler))
### 0.3.2 (October 11, 2024)
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
- Handle `null` values on machine expiry when using a database like PostgreSQL.
- Use `X-Forwarded-Proto` and `Host` headers for building the OIDC callback URL.
### 0.3.1 (October 3, 2024)
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
- Added the option for debug logs with the `DEBUG=true` environment variable.
### 0.3.0 (September 25, 2024)
- Bumped the minimum supported version of Headscale to 0.23.
- Updated the UI to respect `dns.use_username_in_magic_dns`.
### 0.2.4 (August 24, 2024)
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
- Removed the `ACL_FILE` environment variable since it's no longer needed.
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
- Fixed a bug where removing Split DNS configurations would crash the UI.
### 0.2.3 (August 23, 2024)
- Change the minimum required version of Headscale to 0.23-beta2
- Support the new API policy mode for Headscale 0.23-beta1
- Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29))
- If OIDC environment variables are defined, don't use configuration file values (fixes [#24](https://github.com/tale/headplane/issues/24))
### 0.2.2 (August 2, 2024)
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
- Added a new logger utility that shows categories, levels, and timestamps.
- Reimplemented the integration system to be more resilient and log more information.
- Fixed an issue where the /proc integration found `undefined` PIDs.
### 0.2.1 (July 7, 2024)
- Added the ability to manage custom DNS records on your Tailnet.
- ACL tags for machines are now able to be changed via the machine menu.
- Fixed a bug where the ACL editor did not show the diffs correctly.
- Fixed an issue that stopped the "Discard changes" button in the ACL editor from working.
### 0.2.0 (June 23, 2024)
- Fix the dropdown options for machines not working on the machines page.
- Add an option to change the machine owner in the dropdown (aside from the users page).
### 0.1.9 (June 2, 2024)
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
- Utilize magic DNS hostnames for the machine overview page.
- Fixed the expiry issue once and for all.
- Add a nightly build with the `ghcr.io/tale/headplane:edge` tag
### 0.1.8 (June 2, 2024)
- Built basic functionality for the machine overview page (by machine ID).
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
- Prevent users from being deleted if they still have ownership of machines.
- Fixed some type issues where `Date` was being used instead of `string` for timestamps.
### 0.1.7 (May 30, 2024)
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
- Made the strict configuration loader more lenient to allow for more flexibility.
- Added `HEADSCALE_CONFIG_UNSTRICT`=true to revert back to a weaker configuration loader.
- Headplane's context now only loads once at start instead of being lazy-loaded.
- Improved logging and error propagation so that it's easier to debug issues.
### 0.1.6 (May 22, 2024)
- Added experimental support for advanced integration without Docker.
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
### 0.1.5 (May 20, 2024)
- Robust configuration handling with fallbacks based on the headscale source.
- Support for `client_secret_path` on configuration file based OIDC.
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
- `API_KEY` is renamed to `ROOT_API_KEY` for better clarity (old variable still works).
- Fixed button responders not actually being invoked (should fix the ACL page).
### 0.1.4 (May 15, 2024)
- Users can now be created, renamed, and deleted on the users page.
- Machines can be dragged between users to change their ownership.
- The login page actually respects the `DISABLE_API_KEY_LOGIN` variable.
- Implemented some fixes that should stop dialogs from hanging a webpage.
- Upgrade to React 19 beta to take advantage of the compiler (may revert if it causes issues).
- Upgrade other dependencies
### 0.1.3 (May 4, 2024)
- Switched to a better icon set for the UI.
- Support stable scrollbar gutter if supported by the browser.
- Cleaned up the header which fixed a bug that could crash the entire application on fetch errors.
### 0.1.2 (May 1, 2024)
- Added support for renaming, expiring, removing, and managing the routes of a machine.

View File

@ -1,18 +1,21 @@
FROM node:22-alpine AS build
FROM node:20-alpine AS build
WORKDIR /app
RUN npm install -g pnpm@10
RUN apk add --no-cache git
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml ./
COPY patches ./patches
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
RUN pnpm prune --prod
FROM node:22-alpine
RUN mkdir -p /var/lib/headplane
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/build /app/build
CMD [ "node", "./build/server/index.js" ]
COPY --from=build /app/node_modules /app/node_modules
RUN echo '{"type":"module"}' > /app/package.json
EXPOSE 3000
ENV NODE_ENV=production
ENV HOST=0.0.0.0
CMD [ "node_modules/.bin/remix-serve", "./build/server/index.js" ]

View File

@ -1,89 +1,24 @@
# Headplane
> A feature-complete web UI for [Headscale](https://headscale.net)
> An advanced UI for [juanfont/headscale](https://github.com/juanfont/headscale)
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./assets/preview-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./assets/preview-light.png"
>
<img
alt="Preview"
src="./assets/preview-dark.png"
>
</picture>
![Preview](/assets/main-preview.png)
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.
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:
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
- Machine/Node expiry, network routing, name, and owner management
- Access Control List (ACL) and tagging configuration
- Support for OpenID Connect (OIDC) as a login provider
- The ability to edit DNS settings and automatically provision Headscale
- Configurability for Headscale's settings
- DNS and *safe* Headscale configuration management
## Deployment
Headplane runs as a server-based web-application, meaning you'll need a server to run it.
It's available as a Docker image (recommended) or through a manual installation.
There are 2 ways to deploy Headplane:
- If you run Headscale in a Docker container, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
- If you run Headscale natively, see the [Basic Deployment](/docs/Basic-Integration.md) guide.
- For more configuration options, refer to the [Configuration](/docs/Configuration.md) guide.
- ### [Integrated Mode (Recommended)](/docs/Integrated-Mode.md)
Integrated mode unlocks all the features of Headplane and is the most
feature-complete deployment method. It communicates with Headscale directly.
## 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.
- ### [Simple Mode](/docs/Simple-Mode.md)
Simple mode does not include the automatic management of DNS and Headplane
settings, requiring manual editing and reloading when making changes.
### 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
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.
---
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./assets/acls-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./assets/acls-light.png"
>
<img
alt="ACLs"
src="./assets/acls-dark.png"
>
</picture>
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./assets/machine-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./assets/machine-light.png"
>
<img
alt="Machine Management"
src="./assets/machine-dark.png"
>
</picture>
> Copyright (c) 2025 Aarnav Tale
> Copyright (c) 2024 Aarnav Tale

View File

@ -1,15 +0,0 @@
FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY agent/ ./agent
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags "-s -w" \
-o /app/hp_agent ./agent/cmd/hp_agent
FROM scratch
COPY --from=builder /app/hp_agent /hp_agent
ENTRYPOINT ["/hp_agent"]

View File

@ -1,40 +0,0 @@
package main
import (
_ "github.com/joho/godotenv/autoload"
"github.com/tale/headplane/agent/config"
"github.com/tale/headplane/agent/tsnet"
"github.com/tale/headplane/agent/hpagent"
"log"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %s", err)
}
agent := tsnet.NewAgent(
cfg.Hostname,
cfg.TSControlURL,
cfg.TSAuthKey,
cfg.Debug,
)
agent.StartAndFetchID()
defer agent.Shutdown()
ws, err := hpagent.NewSocket(
agent,
cfg.HPControlURL,
cfg.HPAuthKey,
cfg.Debug,
)
if err != nil {
log.Fatalf("Failed to create websocket: %s", err)
}
defer ws.StopListening()
ws.StartListening()
}

View File

@ -1,56 +0,0 @@
package config
import (
"os"
_ "github.com/joho/godotenv/autoload"
)
// Config represents the configuration for the agent.
type Config struct {
Debug bool
Hostname string
TSControlURL string
TSAuthKey string
HPControlURL string
HPAuthKey string
}
const (
DebugEnv = "HEADPLANE_AGENT_DEBUG"
HostnameEnv = "HEADPLANE_AGENT_HOSTNAME"
TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER"
TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY"
HPControlURLEnv = "HEADPLANE_AGENT_HP_SERVER"
HPAuthKeyEnv = "HEADPLANE_AGENT_HP_AUTHKEY"
)
// Load reads the agent configuration from environment variables.
func Load() (*Config, error) {
c := &Config{
Debug: false,
Hostname: os.Getenv(HostnameEnv),
TSControlURL: os.Getenv(TSControlURLEnv),
TSAuthKey: os.Getenv(TSAuthKeyEnv),
HPControlURL: os.Getenv(HPControlURLEnv),
HPAuthKey: os.Getenv(HPAuthKeyEnv),
}
if os.Getenv(DebugEnv) == "true" {
c.Debug = true
}
if err := validateRequired(c); err != nil {
return nil, err
}
if err := validateTSReady(c); err != nil {
return nil, err
}
if err := validateHPReady(c); err != nil {
return nil, err
}
return c, nil
}

View File

@ -1,73 +0,0 @@
package config
import (
"fmt"
"net/http"
"strings"
)
// Checks to make sure all required environment variables are set
func validateRequired(config *Config) error {
if config.Hostname == "" {
return fmt.Errorf("%s is required", HostnameEnv)
}
if config.TSControlURL == "" {
return fmt.Errorf("%s is required", TSControlURLEnv)
}
if config.HPControlURL == "" {
return fmt.Errorf("%s is required", HPControlURLEnv)
}
if config.TSAuthKey == "" {
return fmt.Errorf("%s is required", TSAuthKeyEnv)
}
if config.HPAuthKey == "" {
return fmt.Errorf("%s is required", HPAuthKeyEnv)
}
return nil
}
// Pings the Tailscale control server to make sure it's up and running
func validateTSReady(config *Config) error {
testURL := config.TSControlURL
if strings.HasSuffix(testURL, "/") {
testURL = testURL[:len(testURL)-1]
}
// TODO: Consequences of switching to /health (headscale only)
testURL = fmt.Sprintf("%s/key?v=109", testURL)
resp, err := http.Get(testURL)
if err != nil {
return fmt.Errorf("Failed to connect to TS control server: %s", err)
}
if resp.StatusCode != 200 {
return fmt.Errorf("Failed to connect to TS control server: %s", resp.Status)
}
return nil
}
// Pings the Headplane server to make sure it's up and running
func validateHPReady(config *Config) error {
testURL := config.HPControlURL
if strings.HasSuffix(testURL, "/") {
testURL = testURL[:len(testURL)-1]
}
testURL = fmt.Sprintf("%s/healthz", testURL)
resp, err := http.Get(testURL)
if err != nil {
return fmt.Errorf("Failed to connect to HP control server: %s", err)
}
if resp.StatusCode != 200 {
return fmt.Errorf("Failed to connect to HP control server: %s", resp.Status)
}
return nil
}

View File

@ -1,83 +0,0 @@
package hpagent
import (
"encoding/json"
"log"
"sync"
"tailscale.com/tailcfg"
)
// Represents messages from the Headplane master
type RecvMessage struct {
NodeIDs []string `json:omitempty`
}
// Starts listening for messages from the Headplane master
func (s *Socket) StartListening() {
for {
_, message, err := s.ReadMessage()
if err != nil {
log.Printf("error reading message: %v", err)
return
}
var msg RecvMessage
err = json.Unmarshal(message, &msg)
if err != nil {
log.Printf("error unmarshalling message: %v", err)
continue
}
if s.Debug {
log.Printf("got message: %s", message)
}
if len(msg.NodeIDs) == 0 {
log.Printf("got a message with no node IDs? %s", message)
continue
}
// Accumulate the results since we invoke via gofunc
results := make(map[string]*tailcfg.HostinfoView)
mu := sync.Mutex{}
wg := sync.WaitGroup{}
for _, nodeID := range msg.NodeIDs {
wg.Add(1)
go func(nodeID string) {
defer wg.Done()
result, err := s.Agent.GetStatusForPeer(nodeID)
if err != nil {
log.Printf("error getting status: %v", err)
return
}
if result == nil {
return
}
mu.Lock()
results[nodeID] = result
mu.Unlock()
}(nodeID)
}
wg.Wait()
// Send the results back to the Headplane master
err = s.SendStatus(results)
if err != nil {
log.Printf("error sending status: %v", err)
return
}
if s.Debug {
log.Printf("sent status: %s", results)
}
}
}
// Stops listening for messages from the Headplane master
func (s *Socket) StopListening() {
s.Close()
}

View File

@ -1,11 +0,0 @@
package hpagent
import (
"tailscale.com/tailcfg"
)
// Sends the status to the Headplane master
func (s *Socket) SendStatus(status map[string]*tailcfg.HostinfoView) error {
err := s.WriteJSON(status)
return err
}

View File

@ -1,63 +0,0 @@
package hpagent
import (
"fmt"
"log"
"net/http"
"net/url"
"github.com/gorilla/websocket"
"github.com/tale/headplane/agent/tsnet"
)
type Socket struct {
*websocket.Conn
Debug bool
Agent *tsnet.TSAgent
}
// Creates a new websocket connection to the Headplane server.
func NewSocket(agent *tsnet.TSAgent, controlURL, authKey string, debug bool) (*Socket, error) {
wsURL, err := httpToWs(controlURL)
if err != nil {
return nil, err
}
headers := http.Header{}
headers.Add("X-Headplane-Tailnet-ID", agent.ID)
auth := fmt.Sprintf("Bearer %s", authKey)
headers.Add("Authorization", auth)
log.Printf("dialing websocket at %s", wsURL)
ws, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err != nil {
return nil, err
}
return &Socket{ws, debug, agent}, nil
}
// We need to convert the control URL to a websocket URL
func httpToWs(controlURL string) (string, error) {
u, err := url.Parse(controlURL)
if err != nil {
return "", err
}
if u.Scheme == "http" {
u.Scheme = "ws"
} else if u.Scheme == "https" {
u.Scheme = "wss"
} else {
return "", fmt.Errorf("unsupported scheme: %s", u.Scheme)
}
// We also need to append /_dial to the path
if u.Path[len(u.Path)-1] != '/' {
u.Path += "/"
}
u.Path += "_dial"
return u.String(), nil
}

View File

@ -1,47 +0,0 @@
package tsnet
import (
"context"
"fmt"
"log"
"strings"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"go4.org/mem"
)
// Returns the raw hostinfo for a peer based on node ID.
func (s *TSAgent) GetStatusForPeer(id string) (*tailcfg.HostinfoView, error) {
if !strings.HasPrefix(id, "nodekey:") {
return nil, fmt.Errorf("invalid node ID: %s", id)
}
if s.Debug {
log.Printf("querying peer state for %s", id)
}
status, err := s.Lc.Status(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get status: %w", err)
}
nodeKey, err := key.ParseNodePublicUntyped(mem.S(id[8:]))
peer := status.Peer[nodeKey]
if peer == nil {
// Check if we are on Self.
if status.Self.PublicKey == nodeKey {
peer = status.Self
} else {
return nil, nil
}
}
ip := peer.TailscaleIPs[0].String()
whois, err := s.Lc.WhoIs(context.Background(), ip)
if err != nil {
return nil, fmt.Errorf("failed to get whois: %w", err)
}
return &whois.Node.Hostinfo, nil
}

View File

@ -1,61 +0,0 @@
package tsnet
import (
"context"
"fmt"
"log"
"os"
"tailscale.com/client/tailscale"
"tailscale.com/tsnet"
)
// Wrapper type so we can add methods to the server.
type TSAgent struct {
*tsnet.Server
Lc *tailscale.LocalClient
ID string
Debug bool
}
// Creates a new tsnet agent and returns an instance of the server.
func NewAgent(hostname, controlURL, authKey string, debug bool) *TSAgent {
s := &tsnet.Server{
Hostname: hostname,
ControlURL: controlURL,
AuthKey: authKey,
Logf: func(string, ...interface{}) {}, // Disabled by default
}
if debug {
s.Logf = log.New(
os.Stderr,
fmt.Sprintf("[DBG:%s] ", hostname),
log.LstdFlags,
).Printf
}
return &TSAgent{s, nil, "", debug}
}
// Starts the tsnet agent and sets the node ID.
func (s *TSAgent) StartAndFetchID() {
// Waits until the agent is up and running.
status, err := s.Up(context.Background())
if err != nil {
log.Fatalf("Failed to start agent: %v", err)
}
s.Lc, err = s.LocalClient()
if err != nil {
log.Fatalf("Failed to create local client: %v", err)
}
log.Printf("Agent running with ID: %s", status.Self.PublicKey)
s.ID = string(status.Self.ID)
}
// Shuts down the tsnet agent.
func (s *TSAgent) Shutdown() {
s.Close()
}

View File

@ -1,75 +1,40 @@
import { Check, Copy } from 'lucide-react';
import cn from '~/utils/cn';
import toast from '~/utils/toast';
import { ClipboardIcon } from '@heroicons/react/24/outline'
export interface AttributeProps {
name: string;
value: string;
isCopyable?: boolean;
link?: string;
suppressHydrationWarning?: boolean;
import { toast } from './Toaster'
type Properties = {
readonly name: string;
readonly value: string;
readonly isCopyable?: boolean;
}
export default function Attribute({
name,
value,
link,
isCopyable,
suppressHydrationWarning,
}: AttributeProps) {
export default function Attribute({ name, value, isCopyable }: Properties) {
const canCopy = isCopyable ?? false
return (
<dl className="flex items-center w-full gap-x-1">
<dt className="font-semibold w-1/4 shrink-0 text-sm">
{link ? (
<a className="hover:underline" href={link}>
{name}
</a>
) : (
name
)}
<dl className='flex gap-1 text-sm w-full'>
<dt className='w-1/4 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1'>
{name}
</dt>
<dd
suppressHydrationWarning={suppressHydrationWarning}
className={cn(
'rounded-lg truncate w-full px-2.5 py-1 text-sm',
'flex items-center gap-x-1',
'focus-within:outline-none focus-within:ring-2',
isCopyable && 'hover:bg-headplane-100 dark:hover:bg-headplane-800',
)}
>
{isCopyable ? (
<button
type="button"
className="w-full flex items-center gap-x-1 outline-none"
onClick={async (event) => {
const svgs = event.currentTarget.querySelectorAll('svg');
for (const svg of svgs) {
svg.toggleAttribute('data-copied', true);
}
await navigator.clipboard.writeText(value);
toast('Copied to clipboard');
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute('data-copied', false);
}
}, 1000);
}}
>
<p
suppressHydrationWarning={suppressHydrationWarning}
className="truncate"
>
{value}
</p>
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
</button>
) : (
value
)}
</dd>
{(canCopy ?? false) ? (
<button
type='button'
className='focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md'
onClick={async () => {
await navigator.clipboard.writeText(value)
toast(`Copied ${name}`)
}}
>
<dd className='min-w-0 truncate px-2 py-1'>
{value}
</dd>
<ClipboardIcon className='text-gray-600 dark:text-gray-200 pr-2 w-max h-4'/>
</button>
) : (
<dd className='min-w-0 truncate px-2 py-1'>
{value}
</dd>
)}
</dl>
);
)
}

View File

@ -1,43 +1,26 @@
import React, { useRef } from 'react';
import { type AriaButtonOptions, useButton } from 'react-aria';
import cn from '~/utils/cn';
import clsx from 'clsx'
import { type ButtonHTMLAttributes, type DetailedHTMLProps } from 'react'
export interface ButtonProps extends AriaButtonOptions<'button'> {
variant?: 'heavy' | 'light' | 'danger';
className?: string;
children?: React.ReactNode;
ref?: React.RefObject<HTMLButtonElement | null>;
}
export default function Button({ variant = 'light', ...props }: ButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
type Properties = {
readonly variant?: 'emphasized' | 'normal' | 'destructive';
} & DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
export default function Action(properties: Properties) {
return (
<button
ref={ref}
{...buttonProps}
className={cn(
'w-fit text-sm rounded-xl px-3 py-2',
'focus:outline-none focus:ring',
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
'text-headplane-200 dark:text-headplane-800',
]
: variant === 'danger'
? ['bg-red-500 text-white font-semibold', 'hover:bg-red-500/90']
: [
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
]),
props.className,
type='button'
{...properties}
className={clsx(
'focus:outline-none focus:ring focus:ring-1',
'focus:ring-blue-500 dark:focus:ring-blue-300',
properties.className,
properties.disabled && 'opacity-50 cursor-not-allowed',
properties.variant === 'destructive' ? 'text-red-700 dark:text-red-500' : '',
properties.variant === 'emphasized' ? 'rounded-lg px-4 py-2 bg-gray-800 dark:bg-gray-700 text-white' : '',
!properties.variant || properties.variant === 'normal' ? 'text-blue-700 dark:text-blue-400' : ''
)}
>
{props.children}
{properties.children}
</button>
);
)
}

View File

@ -1,28 +1,18 @@
import React from 'react';
import Text from '~/components/Text';
import Title from '~/components/Title';
import cn from '~/utils/cn';
import clsx from 'clsx'
import { type HTMLProps } from 'react'
interface Props extends React.HTMLProps<HTMLDivElement> {
variant?: 'raised' | 'flat';
}
type Properties = HTMLProps<HTMLDivElement>
function Card({ variant = 'raised', ...props }: Props) {
export default function Card(properties: Properties) {
return (
<div
{...props}
className={cn(
'w-full max-w-md rounded-3xl p-5',
variant === 'flat'
? 'bg-transparent shadow-none'
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-sm',
'border border-headplane-100 dark:border-headplane-800',
props.className,
{...properties}
className={clsx(
'p-4 md:p-6 border dark:border-zinc-700 rounded-lg',
properties.className
)}
>
{props.children}
{properties.children}
</div>
);
)
}
export default Object.assign(Card, { Title, Text });

View File

@ -1,32 +0,0 @@
import React from 'react';
import cn from '~/utils/cn';
export interface ChipProps {
text: string;
className?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
}
export default function Chip({
text,
className,
leftIcon,
rightIcon,
}: ChipProps) {
return (
<span
className={cn(
'text-xs px-2 py-0.5 rounded-full',
'text-headplane-700 dark:text-headplane-100',
'bg-headplane-100 dark:bg-headplane-700',
leftIcon || rightIcon ? 'inline-flex items-center gap-x-1' : '',
className,
)}
>
{leftIcon}
{text}
{rightIcon}
</span>
);
}

View File

@ -1,50 +1,12 @@
import { Check, Copy } from 'lucide-react';
import { HTMLProps } from 'react';
import cn from '~/utils/cn';
import toast from '~/utils/toast';
import clsx from 'clsx'
import { type HTMLProps } from 'react'
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean;
children: string | string[];
}
type Properties = HTMLProps<HTMLSpanElement>
export default function Code({ isCopyable, children, className }: CodeProps) {
export default function Code(properties: Properties) {
return (
<code
className={cn(
'bg-headplane-100 dark:bg-headplane-800 px-1 py-0.5 font-mono',
'rounded-lg focus-within:outline-none focus-within:ring-2',
isCopyable && 'relative pr-7',
className,
)}
>
{children}
{isCopyable && (
<button
type="button"
className="bottom-0 right-0 absolute"
onClick={async (event) => {
const text = Array.isArray(children) ? children.join('') : children;
const svgs = event.currentTarget.querySelectorAll('svg');
for (const svg of svgs) {
svg.toggleAttribute('data-copied', true);
}
await navigator.clipboard.writeText(text);
toast('Copied to clipboard');
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute('data-copied', false);
}
}, 1000);
}}
>
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
</button>
)}
<code className={clsx('bg-gray-100 dark:bg-zinc-700 p-0.5 rounded-md', properties.className)}>
{properties.children}
</code>
);
)
}

View File

@ -1,194 +1,142 @@
import React, { cloneElement, useEffect, useRef } from 'react';
/* eslint-disable unicorn/no-keyword-prefix */
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
import {
type AriaDialogProps,
type AriaModalOverlayProps,
Overlay,
useDialog,
useModalOverlay,
useOverlayTrigger,
} from 'react-aria';
import { Form, type HTMLFormMethod } from 'react-router';
import {
type OverlayTriggerProps,
type OverlayTriggerState,
useOverlayTriggerState,
} from 'react-stately';
import Button, { ButtonProps } from '~/components/Button';
import Card from '~/components/Card';
import IconButton, { IconButtonProps } from '~/components/IconButton';
import Text from '~/components/Text';
import Title from '~/components/Title';
import cn from '~/utils/cn';
import { useLiveData } from '~/utils/live-data';
Button as AriaButton,
Dialog as AriaDialog,
DialogTrigger,
Heading as AriaHeading,
Modal,
ModalOverlay
} from 'react-aria-components'
export interface DialogProps extends OverlayTriggerProps {
children:
| [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<DialogPanelProps>,
]
| React.ReactElement<DialogPanelProps>;
import { cn } from '~/utils/cn'
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
}
function Dialog(props: DialogProps) {
const { pause, resume } = useLiveData();
const state = useOverlayTriggerState(props);
const { triggerProps, overlayProps } = useOverlayTrigger(
{
type: 'dialog',
},
state,
);
useEffect(() => {
if (state.isOpen) {
pause();
} else {
resume();
}
}, [state.isOpen]);
if (Array.isArray(props.children)) {
const [button, panel] = props.children;
return (
<>
{cloneElement(button, triggerProps)}
{state.isOpen && (
<DModal state={state}>
{cloneElement(panel, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
)}
</>
);
}
function Button(properties: ButtonProperties) {
return (
<DModal state={state}>
{cloneElement(props.children, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
);
}
export interface DialogPanelProps extends AriaDialogProps {
children: React.ReactNode;
variant?: 'normal' | 'destructive' | 'unactionable';
onSubmit?: React.FormEventHandler<HTMLFormElement>;
method?: HTMLFormMethod;
isDisabled?: boolean;
// Anonymous (passed by parent)
close?: () => void;
}
function Panel(props: DialogPanelProps) {
const {
children,
onSubmit,
isDisabled,
close,
variant,
method = 'POST',
} = props;
const ref = useRef<HTMLFormElement | null>(null);
const { dialogProps } = useDialog(
{
...props,
role: 'alertdialog',
},
ref,
);
return (
<Form
{...dialogProps}
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
}
close?.();
}}
method={method ?? 'POST'}
ref={ref}
<AriaButton
{...properties}
aria-label='Dialog'
className={cn(
'outline-none rounded-3xl w-full max-w-lg',
'bg-white dark:bg-headplane-900',
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className
)}
>
<Card className="w-full max-w-lg" variant="flat">
{children}
<div className="mt-6 flex justify-end gap-4">
{variant === 'unactionable' ? (
<Button onPress={close}>Close</Button>
) : (
<>
<Button onPress={close}>Cancel</Button>
<Button
type="submit"
variant={variant === 'destructive' ? 'danger' : 'heavy'}
isDisabled={isDisabled}
>
Confirm
</Button>
</>
)}
</div>
</Card>
</Form>
);
// If control is passed, set the state value
onPress={properties.control ? () => {
properties.control?.[1](true)
} : undefined}
/>
)
}
interface DModalProps extends AriaModalOverlayProps {
children: React.ReactNode;
state: OverlayTriggerState;
type ActionProperties = Parameters<typeof AriaButton>[0] & {
readonly variant: 'cancel' | 'confirm';
}
function DModal(props: DModalProps) {
const { children, state } = props;
const ref = useRef<HTMLDivElement>(null);
const { modalProps, underlayProps } = useModalOverlay(props, state, ref);
if (!state.isOpen) {
return null;
}
function Action(properties: ActionProperties) {
return (
<Overlay>
<div
{...underlayProps}
aria-hidden="true"
<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(
'fixed inset-0 h-screen w-screen z-20',
'flex items-center justify-center',
'bg-headplane-900/15 dark:bg-headplane-900/30',
'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',
'entering:fade-in entering:duration-100 entering:ease-out',
'exiting:fade-out exiting:duration-50 exiting:ease-in',
)}
/>
<div
{...modalProps}
className={cn(
'fixed inset-0 h-screen w-screen z-20',
'flex items-center justify-center',
'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'
)}
>
{children}
</div>
</Overlay>
);
<AriaDialog role='alertdialog' className='outline-none relative'>
{({ close }) => children(close)}
</AriaDialog>
</Modal>
</ModalOverlay>
)
}
export default Object.assign(Dialog, {
Button,
IconButton,
Panel,
Title,
Text,
});
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 })

View File

@ -1,89 +1,52 @@
import { AlertIcon } from '@primer/octicons-react';
import { isRouteErrorResponse, useRouteError } from 'react-router';
import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn';
import Card from './Card';
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
import { isRouteErrorResponse, useRouteError } from '@remix-run/react'
import { useState } from 'react'
interface Props {
type?: 'full' | 'embedded';
import { cn } from '~/utils/cn'
import Code from './Code'
import Dialog from './Dialog'
type Properties = {
readonly type?: 'full' | 'embedded';
}
function getMessage(error: Error | unknown): {
title: string;
message: string;
} {
if (error instanceof ResponseError) {
if (error.responseObject?.message) {
return {
title: 'Headscale Error',
message: String(error.responseObject.message),
};
}
export function ErrorPopup({ type = 'full' }: Properties) {
// eslint-disable-next-line react/hook-use-state
const open = useState(true)
return {
title: 'Headscale Error',
message: error.response,
};
}
if (!(error instanceof Error)) {
return {
title: 'Unknown Error',
message: String(error),
};
}
let rootError = error;
// Traverse the error chain to find the root cause
if (error.cause) {
rootError = error.cause as Error;
while (rootError.cause) {
rootError = rootError.cause as Error;
}
}
// If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
return {
title: 'Errors',
message: rootError.errors.map((error) => error.message).join('\n'),
};
}
return {
title: 'Error',
message: rootError.message,
};
}
export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError();
const routing = isRouteErrorResponse(error);
const { title, message } = getMessage(error);
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 : title}
</Card.Title>
<AlertIcon className="w-12 h-12 text-red-500" />
</div>
<Card.Text
className={cn('mt-4 text-lg', routing ? 'font-normal' : 'font-mono')}
>
{routing ? error.data.message : message}
</Card.Text>
</Card>
</div>
);
<Dialog>
<Dialog.Panel
className={cn(
type === 'embedded' ? 'pointer-events-none bg-transparent dark:bg-transparent' : '',
)}
control={open}
>
{() => (
<>
<div className='flex items-center justify-between'>
<Dialog.Title className='text-3xl mb-0'>
{routing ? error.status : 'Error'}
</Dialog.Title>
<ExclamationTriangleIcon className='w-12 h-12 text-red-500'/>
</div>
<Dialog.Text className='mt-4 text-lg'>
{routing ? (
error.statusText
) : (
<Code>
{message}
</Code>
)}
</Dialog.Text>
</>
)}
</Dialog.Panel>
</Dialog>
)
}

View File

@ -1,49 +0,0 @@
import Link from '~/components/Link';
import cn from '~/utils/cn';
interface FooterProps {
url: string;
debug: boolean;
}
export default function Footer({ url, debug }: FooterProps) {
return (
<footer
className={cn(
'fixed bottom-0 left-0 z-40 w-full h-14',
'flex flex-col justify-center gap-1 shadow-inner',
'bg-headplane-100 dark:bg-headplane-950',
'text-headplane-800 dark:text-headplane-200',
'dark:border-t dark:border-headplane-800',
)}
>
<p className="container text-xs">
Headplane is entirely free to use. If you find it useful, consider{' '}
<Link
to="https://github.com/sponsors/tale"
name="Aarnav's GitHub Sponsors"
>
donating
</Link>{' '}
to support development.{' '}
</p>
<p className="container text-xs opacity-75">
Version: {__VERSION__}
{' — '}
Connecting to{' '}
<button
type="button"
tabIndex={0} // Allows keyboard focus
className={cn(
'blur-sm hover:blur-none focus:blur-none transition',
'focus:outline-none focus:ring-2 rounded-sm',
)}
>
{url}
</button>
{/* Connecting to <strong className="blur-xs hover:blur-none">{url}</strong> */}
{debug && ' (Debug mode enabled)'}
</p>
</footer>
);
}

View File

@ -1,192 +0,0 @@
import {
CircleUser,
Globe2,
Lock,
PlaneTakeoff,
Server,
Settings,
Users,
} from 'lucide-react';
import type { ReactNode } from 'react';
import { NavLink, useSubmit } from 'react-router';
import Menu from '~/components/Menu';
import { AuthSession } from '~/server/web/sessions';
import cn from '~/utils/cn';
interface Props {
configAvailable: boolean;
onboarding: boolean;
user?: AuthSession['user'];
access: {
ui: boolean;
machines: boolean;
dns: boolean;
users: boolean;
policy: boolean;
settings: boolean;
};
}
interface LinkProps {
href: string;
text: string;
}
interface TabLinkProps {
name: string;
to: string;
icon: ReactNode;
}
function TabLink({ name, to, icon }: TabLinkProps) {
return (
<div className="relative py-2">
<NavLink
to={to}
prefetch="intent"
className={({ isActive }) =>
cn(
'px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5',
'after:absolute after:bottom-0 after:left-3 after:right-3',
'after:h-0.5 after:bg-headplane-900 dark:after:bg-headplane-200',
'hover:bg-headplane-200 dark:hover:bg-headplane-900',
'focus:outline-none focus:ring',
isActive ? 'after:visible' : 'after:invisible',
)
}
>
{icon} {name}
</NavLink>
</div>
);
}
function Link({ href, text }: LinkProps) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className={cn(
'hidden sm:block hover:underline text-sm',
'focus:outline-none focus:ring rounded-md',
)}
>
{text}
</a>
);
}
export default function Header(data: Props) {
const submit = useSubmit();
return (
<header
className={cn(
'bg-headplane-100 dark:bg-headplane-950',
'text-headplane-800 dark:text-headplane-200',
'dark:border-b dark:border-headplane-800',
'shadow-inner',
)}
>
<div className="container flex items-center justify-between py-4">
<div className="flex items-center gap-x-2">
<PlaneTakeoff />
<h1 className="text-2xl font-semibold">headplane</h1>
</div>
<div className="flex items-center gap-x-4">
<Link href="https://tailscale.com/download" text="Download" />
<Link href="https://github.com/tale/headplane" text="GitHub" />
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
{data.user ? (
<Menu>
<Menu.IconButton
label="User"
className={cn(data.user.picture ? 'p-0' : '')}
>
{data.user.picture ? (
<img
src={data.user.picture}
alt={data.user.name}
className="w-8 h-8 rounded-full"
/>
) : (
<CircleUser />
)}
</Menu.IconButton>
<Menu.Panel
onAction={(key) => {
if (key === 'logout') {
submit(
{},
{
method: 'POST',
action: '/logout',
},
);
}
}}
disabledKeys={['profile']}
>
<Menu.Section>
<Menu.Item key="profile" textValue="Profile">
<div className="text-black dark:text-headplane-50">
<p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p>
</div>
</Menu.Item>
<Menu.Item key="logout" textValue="Logout">
<p className="text-red-500 dark:text-red-400">Logout</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
) : undefined}
</div>
</div>
{data.access.ui && !data.onboarding ? (
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
{data.access.machines ? (
<TabLink
to="/machines"
name="Machines"
icon={<Server className="w-5" />}
/>
) : undefined}
{data.access.users ? (
<TabLink
to="/users"
name="Users"
icon={<Users className="w-5" />}
/>
) : undefined}
{data.access.policy ? (
<TabLink
to="/acls"
name="Access Control"
icon={<Lock className="w-5" />}
/>
) : undefined}
{data.configAvailable ? (
<>
{data.access.dns ? (
<TabLink
to="/dns"
name="DNS"
icon={<Globe2 className="w-5" />}
/>
) : undefined}
{data.access.settings ? (
<TabLink
to="/settings"
name="Settings"
icon={<Settings className="w-5" />}
/>
) : undefined}
</>
) : undefined}
</nav>
) : undefined}
</header>
);
}

View File

@ -1,46 +0,0 @@
import React, { useRef } from 'react';
import { type AriaButtonOptions, useButton } from 'react-aria';
import cn from '~/utils/cn';
export interface IconButtonProps extends AriaButtonOptions<'button'> {
variant?: 'heavy' | 'light';
className?: string;
children: React.ReactNode;
label: string;
ref?: React.RefObject<HTMLButtonElement | null>;
}
export default function IconButton({
variant = 'light',
...props
}: IconButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
return (
<button
ref={ref}
{...buttonProps}
aria-label={props.label}
className={cn(
'rounded-full flex items-center justify-center p-1',
'focus:outline-none focus:ring',
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
'text-headplane-200 dark:text-headplane-800',
]
: [
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
]),
props.className,
)}
>
{props.children}
</button>
);
}

View File

@ -1,84 +1,26 @@
import { Asterisk } from 'lucide-react';
import { useRef } from 'react';
import { type AriaTextFieldProps, useId, useTextField } from 'react-aria';
import cn from '~/utils/cn';
import clsx from 'clsx'
import { type DetailedHTMLProps, type InputHTMLAttributes } from 'react'
export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
label: string;
labelHidden?: boolean;
isRequired?: boolean;
className?: string;
}
// TODO: Custom isInvalid logic for custom error messages
export default function Input(props: InputProps) {
const { label, labelHidden, className } = props;
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const {
labelProps,
inputProps,
descriptionProps,
errorMessageProps,
isInvalid,
validationErrors,
} = useTextField(
{
...props,
label,
'aria-label': label,
},
ref,
);
type Properties = {
readonly variant?: 'embedded' | 'normal';
} & DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>
export default function Input(properties: Properties) {
return (
<div className="flex flex-col w-full" aria-label={label}>
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
labelHidden && 'sr-only',
)}
>
{label}
{props.isRequired && (
<Asterisk className="inline w-3.5 text-red-500 pb-1 ml-0.5" />
)}
</label>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
className={cn(
'rounded-xl px-3 py-2',
'focus:outline-none focus:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
className,
)}
/>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
<input
{...properties}
className={clsx(
'block w-full dark:text-gray-300',
'border-gray-300 dark:border-zinc-700',
'focus:outline-none focus:ring',
'focus:ring-blue-500 dark:focus:ring-blue-300',
properties.variant === 'embedded' ? 'bg-transparent' : 'dark:bg-zinc-800',
properties.variant === 'embedded' ? 'p-0' : 'px-2.5 py-1.5',
properties.variant === 'embedded' ? 'border-none' : 'border',
properties.variant === 'embedded' ? 'focus:ring-0' : 'focus:ring-1',
properties.variant === 'embedded' ? 'rounded-none' : 'rounded-lg',
properties.className
)}
{isInvalid && (
<div
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{validationErrors.join(' ')}
</div>
)}
</div>
);
/>
)
}

View File

@ -1,35 +0,0 @@
import { ExternalLink } from 'lucide-react';
import cn from '~/utils/cn';
export interface LinkProps {
to: string;
name: string;
children: string;
className?: string;
}
export default function Link({
to,
name: alt,
children,
className,
}: LinkProps) {
return (
<a
href={to}
aria-label={alt}
target="_blank"
rel="noreferrer"
className={cn(
'inline-flex items-center gap-x-0.5',
'text-blue-500 hover:text-blue-700',
'dark:text-blue-400 dark:hover:text-blue-300',
'focus:outline-none focus:ring rounded-md',
className,
)}
>
{children}
<ExternalLink className="w-3.5" />
</a>
);
}

View File

@ -1,170 +1,98 @@
import React, { useRef, cloneElement } from 'react';
import { type AriaMenuProps, Key, Placement, useMenuTrigger } from 'react-aria';
import { useMenu, useMenuItem, useMenuSection, useSeparator } from 'react-aria';
import { Item, Section } from 'react-stately';
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
import {
type MenuTriggerProps,
Node,
TreeState,
useMenuTriggerState,
useTreeState,
} from 'react-stately';
import Button, { ButtonProps } from '~/components/Button';
import IconButton, { IconButtonProps } from '~/components/IconButton';
import Popover from '~/components/Popover';
import cn from '~/utils/cn';
Button as AriaButton,
Menu as AriaMenu,
MenuItem,
MenuTrigger,
Popover
} from 'react-aria-components'
interface MenuProps extends MenuTriggerProps {
placement?: Placement;
isDisabled?: boolean;
disabledKeys?: Key[];
children: [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<MenuPanelProps>,
];
}
import { cn } from '~/utils/cn'
// TODO: onAction is called twice for some reason?
// TODO: isDisabled per-prop
function Menu(props: MenuProps) {
const { placement = 'bottom', isDisabled, disabledKeys = [] } = props;
const state = useMenuTriggerState(props);
const ref = useRef<HTMLButtonElement | null>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
{},
state,
ref,
);
// cloneElement is necessary because the button is a union type
// of multiple things and we need to join props from our hooks
const [button, panel] = props.children;
function Button(properties: Parameters<typeof AriaButton>[0]) {
return (
<div>
{cloneElement(button, {
...menuTriggerProps,
isDisabled: isDisabled,
ref,
})}
{state.isOpen && (
<Popover state={state} triggerRef={ref} placement={placement}>
{cloneElement(panel, {
...menuProps,
autoFocus: state.focusStrategy ?? true,
onClose: () => state.close(),
disabledKeys,
})}
</Popover>
)}
</div>
);
}
interface MenuPanelProps extends AriaMenuProps<object> {
onClose?: () => void;
disabledKeys?: Key[];
}
function Panel(props: MenuPanelProps) {
const state = useTreeState(props);
const ref = useRef(null);
const { menuProps } = useMenu(props, state, ref);
return (
<ul
{...menuProps}
ref={ref}
className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none"
>
{[...state.collection].map((item) => (
<MenuSection
key={item.key}
section={item}
state={state}
disabledKeys={props.disabledKeys}
/>
))}
</ul>
);
}
interface MenuSectionProps<T> {
section: Node<T>;
state: TreeState<T>;
disabledKeys?: Key[];
}
function MenuSection<T>({ section, state, disabledKeys }: MenuSectionProps<T>) {
const { itemProps, groupProps } = useMenuSection({
heading: section.rendered,
'aria-label': section['aria-label'],
});
const { separatorProps } = useSeparator({
elementType: 'li',
});
return (
<>
{section.key !== state.collection.getFirstKey() ? (
<li
{...separatorProps}
className={cn(
'mx-2 mt-1 mb-1 border-t',
'border-headplane-200 dark:border-headplane-800',
)}
/>
) : undefined}
<li {...itemProps}>
<ul {...groupProps}>
{[...section.childNodes].map((item) => (
<MenuItem
key={item.key}
item={item}
state={state}
isDisabled={disabledKeys?.includes(item.key)}
/>
))}
</ul>
</li>
</>
);
}
interface MenuItemProps<T> {
item: Node<T>;
state: TreeState<T>;
isDisabled?: boolean;
}
function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
const ref = useRef<HTMLLIElement | null>(null);
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
const isFocused = state.selectionManager.focusedKey === item.key;
return (
<li
{...menuItemProps}
ref={ref}
<AriaButton
{...properties}
className={cn(
'py-2 px-3 mx-1 rounded-lg',
'focus:outline-none select-none',
isFocused && 'bg-headplane-100/50 dark:bg-headplane-800',
isDisabled
? 'text-headplane-400 dark:text-headplane-600'
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800 cursor-pointer',
'outline-none',
properties.className
)}
>
{item.rendered}
</li>
);
aria-label='Menu'
/>
)
}
export default Object.assign(Menu, {
Button,
IconButton,
Panel,
Section,
Item,
});
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 })

View File

@ -1,16 +1,16 @@
import { CircleSlash2 } from 'lucide-react';
import React from 'react';
import Card from '~/components/Card';
import { InformationCircleIcon } from '@heroicons/react/24/outline'
import clsx from 'clsx'
import { type ReactNode } from 'react'
export interface NoticeProps {
children: React.ReactNode;
}
export default function Notice({ children }: NoticeProps) {
export default function Notice({ children }: { readonly children: ReactNode }) {
return (
<Card className="flex w-full max-w-full gap-4 font-semibold">
<CircleSlash2 />
<div className={clsx(
'p-4 rounded-md w-fit flex items-center gap-3',
'bg-slate-400 dark:bg-slate-700'
)}
>
<InformationCircleIcon className='h-6 w-6 text-white'/>
{children}
</Card>
);
</div>
)
}

View File

@ -1,102 +0,0 @@
import { Minus, Plus } from 'lucide-react';
import { useRef } from 'react';
import {
type AriaNumberFieldProps,
useId,
useLocale,
useNumberField,
} from 'react-aria';
import { useNumberFieldState } from 'react-stately';
import IconButton from '~/components/IconButton';
import cn from '~/utils/cn';
export interface InputProps extends AriaNumberFieldProps {
isRequired?: boolean;
name?: string;
}
export default function NumberInput(props: InputProps) {
const { label, name } = props;
const { locale } = useLocale();
const state = useNumberFieldState({ ...props, locale });
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const {
labelProps,
inputProps,
groupProps,
incrementButtonProps,
decrementButtonProps,
descriptionProps,
errorMessageProps,
isInvalid,
validationErrors,
} = useNumberField(props, state, ref);
return (
<div className="flex flex-col">
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
>
{label}
</label>
<div
{...groupProps}
className={cn(
'flex items-center gap-1 rounded-xl pr-1',
'focus-within:outline-none focus-within:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
)}
>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
id={id}
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-none"
/>
<input type="hidden" name={name} value={state.numberValue} />
<IconButton
{...decrementButtonProps}
label="Decrement"
className="w-7.5 h-7.5 rounded-lg"
>
<Minus className="p-1" />
</IconButton>
<IconButton
{...incrementButtonProps}
label="Increment"
className="w-7.5 h-7.5 rounded-lg"
>
<Plus className="p-1" />
</IconButton>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{isInvalid && (
<div
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{validationErrors.join(' ')}
</div>
)}
</div>
);
}

View File

@ -1,78 +0,0 @@
import { useRef } from 'react';
import {
AriaTabListProps,
AriaTabPanelProps,
useTab,
useTabList,
useTabPanel,
} from 'react-aria';
import { Item, Node, TabListState, useTabListState } from 'react-stately';
import cn from '~/utils/cn';
export interface OptionsProps extends AriaTabListProps<object> {
label: string;
className?: string;
}
function Options({ label, className, ...props }: OptionsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
ref={ref}
className="flex items-center gap-2 overflow-x-scroll"
>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</div>
<OptionsPanel key={state.selectedItem?.key} state={state} />
</div>
);
}
export interface OptionsOptionProps {
item: Node<object>;
state: TabListState<object>;
}
function Option({ item, state }: OptionsOptionProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
ref={ref}
className={cn(
'pl-0.5 pr-2 py-0.5 rounded-lg cursor-pointer',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-none focus:ring z-10',
'border border-headplane-100 dark:border-headplane-800',
)}
>
{rendered}
</div>
);
}
export interface OptionsPanelProps extends AriaTabPanelProps {
state: TabListState<object>;
}
function OptionsPanel({ state, ...props }: OptionsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div {...tabPanelProps} ref={ref} className="w-full mt-2">
{state.selectedItem?.props.children}
</div>
);
}
export default Object.assign(Options, { Item });

View File

@ -1,49 +0,0 @@
import React, { useRef } from 'react';
import {
type AriaPopoverProps,
DismissButton,
Overlay,
usePopover,
} from 'react-aria';
import type { OverlayTriggerState } from 'react-stately';
import cn from '~/utils/cn';
export interface PopoverProps extends Omit<AriaPopoverProps, 'popoverRef'> {
children: React.ReactNode;
state: OverlayTriggerState;
popoverRef?: React.RefObject<HTMLDivElement | null>;
className?: string;
}
export default function Popover(props: PopoverProps) {
const ref = props.popoverRef ?? useRef<HTMLDivElement | null>(null);
const { state, children, className } = props;
const { popoverProps, underlayProps } = usePopover(
{
...props,
popoverRef: ref,
offset: 8,
},
state,
);
return (
<Overlay>
<div {...underlayProps} className="fixed inset-0" />
<div
{...popoverProps}
ref={ref}
className={cn(
'z-10 shadow-sm rounded-xl',
'bg-white dark:bg-headplane-900',
'border border-headplane-200 dark:border-headplane-800',
className,
)}
>
<DismissButton onDismiss={state.close} />
{children}
<DismissButton onDismiss={state.close} />
</div>
</Overlay>
);
}

View File

@ -1,26 +0,0 @@
import { useProgressBar } from 'react-aria';
import cn from '~/utils/cn';
export interface ProgressBarProps {
isVisible: boolean;
}
export default function ProgressBar(props: ProgressBarProps) {
const { isVisible } = props;
const { progressBarProps } = useProgressBar({
label: 'Loading...',
isIndeterminate: true,
});
return (
<div
{...progressBarProps}
aria-hidden={!isVisible}
className={cn(
'fixed top-0 left-0 z-50 w-1/2 h-1 opacity-0',
'bg-headplane-950 dark:bg-headplane-50',
isVisible && 'animate-loading opacity-100',
)}
/>
);
}

View File

@ -1,83 +0,0 @@
import React, { createContext, useContext, useRef } from 'react';
import {
AriaRadioGroupProps,
AriaRadioProps,
VisuallyHidden,
useFocusRing,
} from 'react-aria';
import { RadioGroupState } from 'react-stately';
import cn from '~/utils/cn';
import { useRadio, useRadioGroup } from 'react-aria';
import { useRadioGroupState } from 'react-stately';
interface RadioGroupProps extends AriaRadioGroupProps {
children: React.ReactElement<RadioProps>[];
label: string;
className?: string;
}
const RadioContext = createContext<RadioGroupState | null>(null);
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
const state = useRadioGroupState(props);
const { radioGroupProps, labelProps } = useRadioGroup(
{
...props,
'aria-label': label,
},
state,
);
return (
<div {...radioGroupProps} className={cn('flex flex-col gap-2', className)}>
<VisuallyHidden>
<span {...labelProps}>{label}</span>
</VisuallyHidden>
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
</div>
);
}
interface RadioProps extends AriaRadioProps {
label: string;
className?: string;
}
function Radio({ children, label, className, ...props }: RadioProps) {
const state = useContext(RadioContext);
const ref = useRef(null);
const { inputProps, isSelected, isDisabled } = useRadio(
{
...props,
'aria-label': label,
},
state!,
ref,
);
const { isFocusVisible, focusProps } = useFocusRing();
return (
<label className="flex items-center gap-2 text-sm">
<VisuallyHidden>
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
</VisuallyHidden>
<div
aria-hidden="true"
className={cn(
'w-5 h-5 aspect-square rounded-full p-1 border-2',
'border border-headplane-600 dark:border-headplane-300',
isFocusVisible ? 'ring-4' : '',
isDisabled ? 'opacity-50 cursor-not-allowed' : '',
isSelected
? 'border-[6px] border-headplane-900 dark:border-headplane-100'
: '',
className,
)}
/>
{children}
</label>
);
}
export default Object.assign(RadioGroup, { Radio });

View File

@ -1,172 +0,0 @@
import { Check, ChevronDown } from 'lucide-react';
import { useRef } from 'react';
import {
AriaComboBoxProps,
AriaListBoxOptions,
useButton,
useComboBox,
useFilter,
useId,
useListBox,
useOption,
} from 'react-aria';
import { Item, ListState, Node, useComboBoxState } from 'react-stately';
import Popover from '~/components/Popover';
import cn from '~/utils/cn';
export interface SelectProps extends AriaComboBoxProps<object> {
className?: string;
}
function Select(props: SelectProps) {
const { contains } = useFilter({ sensitivity: 'base' });
const state = useComboBoxState({ ...props, defaultFilter: contains });
const id = useId(props.id);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const listBoxRef = useRef<HTMLUListElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const {
buttonProps: triggerProps,
inputProps,
listBoxProps,
labelProps,
descriptionProps,
} = useComboBox(
{
...props,
inputRef,
buttonRef,
listBoxRef,
popoverRef,
},
state,
);
const { buttonProps } = useButton(triggerProps, buttonRef);
return (
<div className={cn('flex flex-col', props.className)}>
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
>
{props.label}
</label>
<div
className={cn(
'flex rounded-xl focus:outline-none focus-within:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
)}
>
<input
{...inputProps}
ref={inputRef}
id={id}
className="outline-none px-3 py-2 rounded-l-xl w-full bg-transparent"
data-1p-ignore
/>
<button
{...buttonProps}
ref={buttonRef}
className={cn(
'flex items-center justify-center p-1 rounded-lg m-1',
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
)}
>
<ChevronDown className="p-0.5" />
</button>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{state.isOpen && (
<Popover
popoverRef={popoverRef}
triggerRef={inputRef}
state={state}
isNonModal
placement="bottom start"
className="w-full max-w-xs"
>
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover>
)}
</div>
);
}
interface ListBoxProps extends AriaListBoxOptions<object> {
listBoxRef?: React.RefObject<HTMLUListElement | null>;
state: ListState<object>;
}
function ListBox(props: ListBoxProps) {
const { listBoxRef, state } = props;
const ref = listBoxRef ?? useRef<HTMLUListElement | null>(null);
const { listBoxProps } = useListBox(props, state, ref);
return (
<ul
{...listBoxProps}
ref={listBoxRef}
className="w-full max-h-72 overflow-auto outline-none pt-1"
>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</ul>
);
}
interface OptionProps {
item: Node<unknown>;
state: ListState<unknown>;
}
function Option({ item, state }: OptionProps) {
const ref = useRef<HTMLLIElement | null>(null);
const { optionProps, isDisabled, isSelected, isFocused } = useOption(
{
key: item.key,
},
state,
ref,
);
return (
<li
{...optionProps}
ref={ref}
className={cn(
'flex items-center justify-between',
'py-2 px-3 mx-1 rounded-lg mb-1',
'focus:outline-none select-none',
isFocused || isSelected
? 'bg-headplane-100/50 dark:bg-headplane-800'
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
isDisabled && 'text-headplane-300 dark:text-headplane-600',
)}
>
{item.rendered}
{isSelected && <Check className="p-0.5" />}
</li>
);
}
export default Object.assign(Select, { Item });

View File

@ -1,21 +1,23 @@
import clsx from 'clsx';
import clsx from 'clsx'
interface Props {
type Properties = {
// eslint-disable-next-line unicorn/no-keyword-prefix
className?: string;
}
export default function Spinner({ className }: Props) {
export default function Spinner(properties: Properties) {
return (
<div className={clsx('inline-block align-middle mb-0.5', className)}>
<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',
className,
properties.className
)}
role='status'
>
<span className="sr-only">Loading...</span>
<span className='sr-only'>Loading...</span>
</div>
</div>
);
)
}

View File

@ -1,27 +1,24 @@
import cn from '~/utils/cn';
import clsx from 'clsx'
import { type HTMLProps } from 'react'
export interface StatusCircleProps {
isOnline: boolean;
className?: string;
type Properties = HTMLProps<SVGElement> & {
readonly isOnline: boolean;
}
export default function StatusCircle({
isOnline,
className,
}: StatusCircleProps) {
// eslint-disable-next-line unicorn/no-keyword-prefix
export default function StatusCircle({ isOnline, className }: Properties) {
return (
<svg
className={cn(
isOnline
? 'text-green-600 dark:text-green-500'
: 'text-headplane-200 dark:text-headplane-800',
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"
viewBox='0 0 24 24'
fill='currentColor'
>
<title>{isOnline ? 'Online' : 'Offline'}</title>
<circle cx="12" cy="12" r="8" />
<circle cx='12' cy='12' r='8'/>
</svg>
);
)
}

View File

@ -1,61 +1,40 @@
import { useRef } from 'react';
import {
AriaSwitchProps,
VisuallyHidden,
useFocusRing,
useSwitch,
} from 'react-aria';
import { useToggleState } from 'react-stately';
import cn from '~/utils/cn';
import { Switch as AriaSwitch } from 'react-aria-components'
export interface SwitchProps extends AriaSwitchProps {
label: string;
className?: string;
import { cn } from '~/utils/cn'
type SwitchProperties = Parameters<typeof AriaSwitch>[0] & {
readonly label: string;
}
export default function Switch(props: SwitchProps) {
const state = useToggleState(props);
const ref = useRef<HTMLInputElement | null>(null);
const { focusProps, isFocusVisible } = useFocusRing();
const { inputProps } = useSwitch(
{
...props,
'aria-label': props.label,
},
state,
ref,
);
export default function Switch(properties: SwitchProperties) {
return (
<label className="flex items-center gap-x-2">
<VisuallyHidden elementType="span">
<input
{...inputProps}
{...focusProps}
aria-label={props.label}
ref={ref}
/>
</VisuallyHidden>
<AriaSwitch
{...properties}
aria-label={properties.label}
className='group flex gap-2 items-center'
>
<div
aria-hidden
className={cn(
'flex h-[28px] w-[46px] p-[4px] shrink-0 rounded-full',
'bg-headplane-300 dark:bg-headplane-700',
'border border-transparent dark:border-headplane-800',
state.isSelected && 'bg-headplane-900 dark:bg-headplane-950',
isFocusVisible && 'ring-2',
props.isDisabled && 'opacity-50',
'flex h-[26px] w-[44px] shrink-0 cursor-default',
'rounded-full shadow-inner bg-clip-padding',
'border border-solid border-white/30 p-[3px]',
'box-border transition duration-100 ease-in-out',
'outline-none group-focus-visible:ring-2 ring-black',
'bg-main-700 dark:bg-main-800',
'group-pressed:bg-main-800 dark:group-pressed:bg-main-900',
'group-selected:bg-main-900 group-selected:group-pressed:bg-main-900',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className
)}
>
<span
className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white transition duration-50 ease-in-out',
'translate-x-0 group-selected:translate-x-[100%]',
state.isSelected && 'translate-x-[100%]',
)}
<span className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white shadow transition duration-100',
'ease-in-out translate-x-0 group-selected:translate-x-[100%]'
)}
/>
</div>
</label>
);
</AriaSwitch>
)
}

View File

@ -0,0 +1,23 @@
import { NavLink } from '@remix-run/react'
import clsx from 'clsx'
import type { ReactNode } from 'react'
type Properties = {
readonly name: string;
readonly to: string;
readonly icon: ReactNode;
}
export default function TabLink({ name, to, icon }: Properties) {
return (
<NavLink
to={to}
className={({ isActive, isPending }) => clsx(
'flex items-center gap-x-2 p-2 border-b-2 text-md',
isActive ? 'border-white' : 'border-transparent'
)}
>
{icon} {name}
</NavLink>
)
}

View File

@ -1,34 +1,37 @@
import type { HTMLProps } from 'react';
import cn from '~/utils/cn';
import clsx from 'clsx'
import { type HTMLProps } from 'react'
function TableList(props: HTMLProps<HTMLDivElement>) {
function TableList(properties: HTMLProps<HTMLDivElement>) {
return (
<div
{...props}
className={cn(
'rounded-xl',
'border border-headplane-100 dark:border-headplane-800',
props.className,
{...properties}
className={clsx(
'border border-gray-300 rounded-lg overflow-clip',
'dark:border-zinc-700 dark:text-gray-300',
// 'dark:bg-zinc-800',
properties.className
)}
>
{props.children}
{properties.children}
</div>
);
)
}
function Item(props: HTMLProps<HTMLDivElement>) {
function Item(properties: HTMLProps<HTMLDivElement>) {
return (
<div
{...props}
className={cn(
'flex items-center justify-between p-2 last:border-b-0',
'border-b border-headplane-100 dark:border-headplane-800',
props.className,
{...properties}
className={clsx(
'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0',
'dark:border-zinc-800',
properties.className
)}
>
{props.children}
{properties.children}
</div>
);
)
}
export default Object.assign(TableList, { Item });
export default Object.assign(TableList, { Item })

View File

@ -1,90 +0,0 @@
import { useRef } from 'react';
import {
AriaTabListProps,
AriaTabPanelProps,
useTab,
useTabList,
useTabPanel,
} from 'react-aria';
import { Item, Node, TabListState, useTabListState } from 'react-stately';
import cn from '~/utils/cn';
export interface TabsProps extends AriaTabListProps<object> {
label: string;
className?: string;
}
function Tabs({ label, className, ...props }: TabsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
ref={ref}
className={cn(
'flex items-center rounded-t-xl w-fit',
'border-headplane-100 dark:border-headplane-800',
'border-t border-x',
)}
>
{[...state.collection].map((item) => (
<Tab key={item.key} item={item} state={state} />
))}
</div>
<TabsPanel key={state.selectedItem?.key} state={state} />
</div>
);
}
export interface TabsTabProps {
item: Node<object>;
state: TabListState<object>;
}
function Tab({ item, state }: TabsTabProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
ref={ref}
className={cn(
'pl-2 pr-3 py-2.5',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-none focus:ring z-10',
'border-r border-headplane-100 dark:border-headplane-800',
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
)}
>
{rendered}
</div>
);
}
export interface TabsPanelProps extends AriaTabPanelProps {
state: TabListState<object>;
}
function TabsPanel({ state, ...props }: TabsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div
{...tabPanelProps}
ref={ref}
className={cn(
'w-full overflow-clip rounded-b-xl rounded-r-xl',
'border border-headplane-100 dark:border-headplane-800',
)}
>
{state.selectedItem?.props.children}
</div>
);
}
export default Object.assign(Tabs, { Item });

View File

@ -1,11 +0,0 @@
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>;
}

View File

@ -0,0 +1,38 @@
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>
)
}

View File

@ -1,13 +0,0 @@
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>
);
}

View File

@ -1,87 +0,0 @@
import {
AriaToastProps,
AriaToastRegionProps,
useToast,
useToastRegion,
} from '@react-aria/toast';
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast';
import { X } from 'lucide-react';
import React, { useRef } from 'react';
import IconButton from '~/components/IconButton';
import cn from '~/utils/cn';
interface ToastProps extends AriaToastProps<React.ReactNode> {
state: ToastState<React.ReactNode>;
}
function Toast({ state, ...props }: ToastProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
props,
state,
ref,
);
return (
<div
{...toastProps}
ref={ref}
className={cn(
'flex items-center justify-between gap-x-3 pl-4 pr-3',
'text-white shadow-lg dark:shadow-md rounded-xl py-3',
'bg-headplane-900 dark:bg-headplane-950',
)}
>
<div {...contentProps} className="flex flex-col gap-2">
<div {...titleProps}>{props.toast.content}</div>
</div>
<IconButton
{...closeButtonProps}
label="Close"
className={cn(
'bg-transparent hover:bg-headplane-700',
'dark:bg-transparent dark:hover:bg-headplane-800',
)}
>
<X className="p-1" />
</IconButton>
</div>
);
}
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<React.ReactNode>;
}
function ToastRegion({ state, ...props }: ToastRegionProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { regionProps } = useToastRegion(props, state, ref);
return (
<div
{...regionProps}
ref={ref}
className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
>
{state.visibleToasts.map((toast) => (
<Toast key={toast.key} toast={toast} state={state} />
))}
</div>
);
}
export interface ToastProviderProps extends AriaToastRegionProps {
queue: ToastQueue<React.ReactNode>;
}
export default function ToastProvider({ queue, ...props }: ToastProviderProps) {
const state = useToastQueue(queue);
return (
<>
{state.visibleToasts.length > 0 && (
<ToastRegion {...props} state={state} />
)}
</>
);
}

View File

@ -0,0 +1,79 @@
import { XMarkIcon } from '@heroicons/react/24/outline'
import { type AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
import { ToastQueue, type ToastState, useToastQueue } from '@react-stately/toast'
import { type ReactNode, useRef } from 'react'
import { Button } from 'react-aria-components'
import { createPortal } from 'react-dom'
import { ClientOnly } from 'remix-utils/client-only'
import { cn } from '~/utils/cn'
type ToastProperties = AriaToastProps<ReactNode> & {
readonly state: ToastState<ReactNode>;
}
function Toast({ state, ...properties }: ToastProperties) {
const reference = useRef(null)
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference)
return (
<div
{...toastProps}
ref={reference}
className={cn(
'bg-main-700 dark:bg-main-800 rounded-lg',
'text-main-100 dark:text-main-200 z-50',
'border border-main-600 dark:border-main-700',
'flex items-center justify-between p-3 pl-4 w-80'
)}
>
<div {...titleProps}>{properties.toast.content}</div>
<Button
{...closeButtonProps}
className={cn(
'outline-none rounded-full p-1',
'hover:bg-main-600 dark:hover:bg-main-700'
)}
>
<XMarkIcon className='w-4 h-4'/>
</Button>
</div>
)
}
const toasts = new ToastQueue<ReactNode>({
maxVisibleToasts: 5
})
export function toast(text: string) {
return toasts.add(text, { timeout: 5000 })
}
export function Toaster() {
const reference = useRef(null)
const state = useToastQueue(toasts)
const { regionProps } = useToastRegion({}, state, reference)
return (
<ClientOnly>
{() => createPortal(
state.visibleToasts.length >= 0 ? (
<div
className={cn(
'fixed bottom-4 right-4',
'flex flex-col gap-4'
)}
{...regionProps}
ref={reference}
>
{state.visibleToasts.map(toast => (
<Toast key={toast.key} toast={toast} state={state}/>
))}
</div>
) : undefined,
document.body
)}
</ClientOnly>
)
}

View File

@ -1,83 +0,0 @@
import React, { cloneElement, useRef } from 'react';
import {
AriaTooltipProps,
mergeProps,
useTooltip,
useTooltipTrigger,
} from 'react-aria';
import { TooltipTriggerState, useTooltipTriggerState } from 'react-stately';
import cn from '~/utils/cn';
export interface TooltipProps extends AriaTooltipProps {
children: [React.ReactElement, React.ReactElement<TooltipBodyProps>];
}
function Tooltip(props: TooltipProps) {
const state = useTooltipTriggerState({
...props,
delay: 0,
closeDelay: 0,
});
const ref = useRef<HTMLButtonElement | null>(null);
const { triggerProps, tooltipProps } = useTooltipTrigger(
{
...props,
delay: 0,
closeDelay: 0,
},
state,
ref,
);
const [component, body] = props.children;
return (
<span className="relative">
<button
ref={ref}
{...triggerProps}
className={cn(
'flex items-center justify-center',
'focus:outline-none focus:ring rounded-xl',
)}
>
{component}
</button>
{state.isOpen &&
cloneElement(body, {
...tooltipProps,
state,
})}
</span>
);
}
interface TooltipBodyProps extends AriaTooltipProps {
children: React.ReactNode;
state?: TooltipTriggerState;
className?: string;
}
function Body({ state, className, ...props }: TooltipBodyProps) {
const { tooltipProps } = useTooltip(props, state);
return (
<span
{...mergeProps(props, tooltipProps)}
className={cn(
'absolute z-50 p-3 top-full mt-1',
'outline-none rounded-3xl text-sm w-48',
'bg-white dark:bg-headplane-950',
'text-black dark:text-white',
'shadow-lg dark:shadow-md rounded-xl',
'border border-headplane-100 dark:border-headplane-800',
className,
)}
>
{props.children}
</span>
);
}
export default Object.assign(Tooltip, {
Body,
});

View File

@ -1,18 +1,18 @@
import { StrictMode, startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
/**
* By default, Remix will handle hydrating your app on the client for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
* For more information, see https://remix.run/file-conventions/entry.client
*/
if (import.meta.env.DEV) {
import('react-scan').then(({ scan }) => {
scan({ enabled: true });
});
}
import { RemixBrowser } from '@remix-run/react'
import { startTransition, StrictMode } from 'react'
import { hydrateRoot } from 'react-dom/client'
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>,
);
});
<RemixBrowser/>
</StrictMode>
)
})

View File

@ -1,66 +1,137 @@
import { PassThrough } from 'node:stream';
import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import { AppLoadContext, EntryContext, ServerRouter } from 'react-router';
/**
* By default, Remix will handle generating the HTTP Response for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
* For more information, see https://remix.run/file-conventions/entry.server
*/
export const streamTimeout = 5_000;
export default function handleRequest(
import { PassThrough } from 'node:stream'
import type { EntryContext } from '@remix-run/node'
import { createReadableStreamFromReadable } from '@remix-run/node'
import { RemixServer } from '@remix-run/react'
import { isbot } from 'isbot'
import { renderToPipeableStream } from 'react-dom/server'
// eslint-disable-next-line @typescript-eslint/naming-convention
const ABORT_DELAY = 5000
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext,
remixContext: EntryContext
) {
return isbot(request.headers.get('user-agent') ?? '')
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
}
async function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get('user-agent');
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
? 'onAllReady'
: 'onShellReady';
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
onAllReady() {
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set('Content-Type', 'text/html');
responseHeaders.set('Content-Type', 'text/html')
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
status: responseStatusCode
})
)
pipe(body);
pipe(body)
},
onShellError(error: unknown) {
reject(error);
reject(error)
},
onError(error: unknown) {
// biome-ignore lint/style/noParameterAssign: Lazy
responseStatusCode = 500;
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
console.error(error)
}
},
},
);
}
}
)
// Abort the rendering stream after the `streamTimeout` so it has tine to
// flush down the rejected boundaries
setTimeout(abort, streamTimeout + 1000);
});
setTimeout(abort, ABORT_DELAY)
})
}
async function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onShellReady() {
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set('Content-Type', 'text/html')
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode
})
)
pipe(body)
},
onShellError(error: unknown) {
reject(error)
},
onError(error: unknown) {
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error)
}
}
}
)
setTimeout(abort, ABORT_DELAY)
})
}

View File

@ -1,72 +0,0 @@
import { XCircleFillIcon } from '@primer/octicons-react';
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { Outlet, useLoaderData } from 'react-router';
import { ErrorPopup } from '~/components/Error';
import type { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn';
import log from '~/utils/log';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const healthy = await context.client.healthcheck();
const session = await context.sessions.auth(request);
// We shouldn't session invalidate if Headscale is down
// TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled
if (healthy) {
try {
await context.client.get('v1/apikey', session.get('api_key')!);
} catch (error) {
if (error instanceof ResponseError) {
log.debug('api', 'API Key validation failed %o', error);
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
},
});
}
}
}
return {
healthy,
};
}
export default function Layout() {
const { healthy } = useLoaderData<typeof loader>();
return (
<>
{!healthy ? (
<div
className={cn(
'fixed bottom-0 right-0 z-50 w-fit h-14',
'flex flex-col justify-center gap-1',
)}
>
<div
className={cn(
'flex items-center gap-1.5 mr-1.5 py-2 px-1.5',
'border rounded-lg text-white bg-red-500',
'border-red-600 dark:border-red-400 shadow-sm',
)}
>
<XCircleFillIcon className="w-4 h-4 text-white" />
Headscale is unreachable
</div>
</div>
) : undefined}
<main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet />
</main>
</>
);
}
export function ErrorBoundary() {
return <ErrorPopup type="embedded" />;
}

View File

@ -1,169 +0,0 @@
import { CircleCheckIcon } from 'lucide-react';
import {
LoaderFunctionArgs,
Outlet,
redirect,
useLoaderData,
} from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Footer from '~/components/Footer';
import Header from '~/components/Header';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { User } from '~/types';
import log from '~/utils/log';
import toast from '~/utils/toast';
// This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops?
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
try {
const session = await context.sessions.auth(request);
if (!session.has('api_key')) {
// There is a session, but it's not valid
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
},
});
}
// Onboarding is only a feature of the OIDC flow
if (context.oidc && !request.url.endsWith('/onboarding')) {
let onboarded = false;
const sessionUser = session.get('user');
if (sessionUser) {
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
onboarded = true;
} else {
try {
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
if (users.length === 0) {
onboarded = false;
}
const user = users.find((u) => {
if (u.provider !== 'oidc') {
return false;
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = u.providerId?.split('/').pop();
if (!subject) {
return false;
}
const sessionUser = session.get('user');
if (!sessionUser) {
return false;
}
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
return true;
}
return subject === sessionUser.subject;
});
if (user) {
onboarded = true;
}
} catch (e) {
// If we cannot lookup users, just assume our user is onboarded
log.debug('api', 'Failed to lookup users %o', e);
onboarded = true;
}
}
}
if (!onboarded) {
return redirect('/onboarding');
}
}
const check = await context.sessions.check(request, Capabilities.ui_access);
return {
config: context.hs.c,
url: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(),
debug: context.config.debug,
user: session.get('user'),
uiAccess: check,
access: {
ui: await context.sessions.check(request, Capabilities.ui_access),
dns: await context.sessions.check(request, Capabilities.read_network),
users: await context.sessions.check(request, Capabilities.read_users),
policy: await context.sessions.check(request, Capabilities.read_policy),
machines: await context.sessions.check(
request,
Capabilities.read_machines,
),
settings: await context.sessions.check(
request,
Capabilities.read_feature,
),
},
onboarding: request.url.endsWith('/onboarding'),
};
} catch {
// No session, so we can just return
return redirect('/login');
}
}
export default function Shell() {
const data = useLoaderData<typeof loader>();
return (
<>
<Header {...data} />
{/* Always show the outlet if we are onboarding */}
{(data.onboarding ? true : data.uiAccess) ? (
<Outlet />
) : (
<Card className="mx-auto w-fit mt-24">
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">Connected</Card.Title>
<CircleCheckIcon className="w-10 h-10" />
</div>
<Card.Text className="my-4 text-lg">
Connect to Tailscale with your devices to access this Tailnet. Use
this command to help you get started:
</Card.Text>
<Button
className="flex text-md font-mono"
onPress={async () => {
await navigator.clipboard.writeText(
`tailscale up --login-server=${data.url}`,
);
toast('Copied to clipboard');
}}
>
tailscale up --login-server={data.url}
</Button>
<p className="text-xs mt-1 opacity-50 text-center">
Click this button to copy the command.
</p>
<p className="mt-4 text-sm opacity-50">
Your account does not have access to the UI. Please contact your
administrator if you believe this is a mistake.
</p>
</Card>
)}
<Footer {...data} />
</>
);
}

View File

@ -1,69 +1,65 @@
import type { LinksFunction, MetaFunction } from 'react-router';
import type { LinksFunction, MetaFunction } from '@remix-run/node'
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useNavigation,
} from 'react-router';
import '@fontsource-variable/inter';
import { ErrorPopup } from '~/components/Error';
import ProgressBar from '~/components/ProgressBar';
import ToastProvider from '~/components/ToastProvider';
import stylesheet from '~/tailwind.css?url';
import { LiveDataProvider } from '~/utils/live-data';
import { useToastQueue } from '~/utils/toast';
ScrollRestoration
} from '@remix-run/react'
import { ErrorPopup } from '~/components/Error'
import { Toaster } from '~/components/Toaster'
import stylesheet from '~/tailwind.css?url'
import { getContext, registerConfigWatcher } from '~/utils/config'
export const meta: MetaFunction = () => [
{ title: 'Headplane' },
{
name: 'description',
content: 'A frontend for the headscale coordination server',
},
];
{ name: 'description', content: 'A frontend for the headscale coordination server' }
]
export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: stylesheet },
];
{ rel: 'stylesheet', href: stylesheet }
]
export async function loader() {
const context = await getContext()
registerConfigWatcher()
if (context.headscaleUrl.length === 0) {
throw new Error('No headscale URL was provided either by the HEADSCALE_URL environment variable or the config file')
}
if (!process.env.COOKIE_SECRET) {
throw new Error('The COOKIE_SECRET environment variable is required')
}
// eslint-disable-next-line unicorn/no-null
return null
}
export function Layout({ children }: { readonly children: React.ReactNode }) {
const toastQueue = useToastQueue();
// LiveDataProvider is wrapped at the top level since dialogs and things
// that control its state are usually open in portal containers which
// are not a part of the normal React tree.
return (
<LiveDataProvider>
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
{children}
<ToastProvider queue={toastQueue} />
<ScrollRestoration />
<Scripts />
</body>
</html>
</LiveDataProvider>
);
<html lang='en'>
<head>
<meta charSet='utf-8'/>
<meta name='viewport' content='width=device-width, initial-scale=1'/>
<Meta/>
<Links/>
</head>
<body className='overscroll-none dark:bg-zinc-900 dark:text-white'>
{children}
<Toaster/>
<ScrollRestoration/>
<Scripts/>
</body>
</html>
)
}
export function ErrorBoundary() {
return <ErrorPopup />;
return <ErrorPopup/>
}
export default function App() {
const nav = useNavigation();
return (
<>
<ProgressBar isVisible={nav.state === 'loading'} />
<Outlet />
</>
);
return <Outlet/>
}

View File

@ -1,36 +0,0 @@
import { index, layout, prefix, route } from '@react-router/dev/routes';
export default [
// Utility Routes
index('routes/util/redirect.ts'),
route('/healthz', 'routes/util/healthz.ts'),
// Authentication Routes
route('/login', 'routes/auth/login.tsx'),
route('/logout', 'routes/auth/logout.ts'),
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
route('/oidc/start', 'routes/auth/oidc-start.ts'),
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout('layouts/shell.tsx', [
route('/onboarding', 'routes/users/onboarding.tsx'),
route('/onboarding/skip', 'routes/users/onboarding-skip.tsx'),
layout('layouts/dashboard.tsx', [
...prefix('/machines', [
index('routes/machines/overview.tsx'),
route('/:id', 'routes/machines/machine.tsx'),
]),
route('/users', 'routes/users/overview.tsx'),
route('/acls', 'routes/acls/overview.tsx'),
route('/dns', 'routes/dns/overview.tsx'),
...prefix('/settings', [
index('routes/settings/overview.tsx'),
route('/auth-keys', 'routes/settings/auth-keys.tsx'),
// route('/local-agent', 'routes/settings/local-agent.tsx'),
]),
]),
]),
];

View File

@ -0,0 +1,122 @@
import { json } from '@codemirror/lang-json'
import { yaml } from '@codemirror/lang-yaml'
import { useFetcher } from '@remix-run/react'
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
import CodeMirror from '@uiw/react-codemirror'
import clsx from 'clsx'
import { useEffect, useMemo, useState } from 'react'
import CodeMirrorMerge from 'react-codemirror-merge'
import Button from '~/components/Button'
import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster'
import Fallback from './fallback'
type EditorProperties = {
readonly acl: string;
readonly setAcl: (acl: string) => void;
readonly mode: 'edit' | 'diff';
readonly data: {
hasAclWrite: boolean;
currentAcl: string;
aclType: string;
};
}
export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
const [light, setLight] = useState(false)
const [loading, setLoading] = useState(true)
const fetcher = useFetcher()
const aclType = useMemo(() => data.aclType === 'json' ? json() : yaml(), [data.aclType])
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)')
setLight(theme.matches)
theme.addEventListener('change', theme => {
setLight(theme.matches)
})
// Prevents the FOUC
setLoading(false)
}, [])
return (
<>
<div className={clsx(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden'
)}
>
<div className='overflow-y-scroll h-editor text-sm'>
{loading ? (
<Fallback acl={acl} where='client'/>
) : (
mode === 'edit' ? (
<CodeMirror
value={acl}
theme={light ? githubLight : githubDark}
extensions={[aclType]}
readOnly={!data.hasAclWrite}
onChange={value => {
setAcl(value)
}}
/>
) : (
<CodeMirrorMerge
theme={light ? githubLight : githubDark}
orientation='a-b'
>
<CodeMirrorMerge.Original
readOnly
value={data.currentAcl}
extensions={[aclType]}
/>
<CodeMirrorMerge.Modified
readOnly
value={acl}
extensions={[aclType]}
/>
</CodeMirrorMerge>
)
)}
</div>
</div>
<Button
variant='emphasized'
className='text-sm w-fit mr-2'
onClick={() => {
fetcher.submit({
acl
}, {
method: 'PATCH',
encType: 'application/json'
})
toast('Updated tailnet ACL policy')
}}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
)}
Save
</Button>
<Button
variant='emphasized'
className={clsx(
'text-sm w-fit bg-gray-100 dark:bg-transparent',
'border border-gray-200 dark:border-gray-700'
)}
onClick={() => {
setAcl(data.currentAcl)
}}
>
Discard Changes
</Button>
</>
)
}

View File

@ -0,0 +1,51 @@
import clsx from 'clsx'
import Button from '~/components/Button'
type FallbackProperties = {
readonly acl: string;
readonly where: 'client' | 'server';
}
export default function Fallback({ acl, where }: FallbackProperties) {
return (
<>
<div className={clsx(
where === 'server' ? 'mb-2 overflow-hidden rounded-tr-lg rounded-b-lg' : '',
where === 'server' ? 'border border-gray-200 dark:border-gray-700' : ''
)}
>
<textarea
readOnly
className={clsx(
'w-full h-editor font-mono resize-none',
'text-sm text-gray-600 dark:text-gray-300',
'pl-10 pt-1 leading-snug'
)}
value={acl}
/>
</div>
{where === 'server' ? (
<>
<Button
disabled
variant='emphasized'
className='text-sm w-fit mr-2'
>
Save
</Button>
<Button
disabled
variant='emphasized'
className={clsx(
'text-sm w-fit bg-gray-100 dark:bg-transparent',
'border border-gray-200 dark:border-gray-700'
)}
>
Discard Changes
</Button>
</>
) : undefined}
</>
)
}

View File

@ -0,0 +1,165 @@
import { BeakerIcon, CubeTransparentIcon, EyeIcon, PencilSquareIcon } from '@heroicons/react/24/outline'
import { type ActionFunctionArgs, json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { useState } from 'react'
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
import { ClientOnly } from 'remix-utils/client-only'
import Notice from '~/components/Notice'
import { cn } from '~/utils/cn'
import { getAcl, getContext, patchAcl } from '~/utils/config'
import { sighupHeadscale } from '~/utils/docker'
import { getSession } from '~/utils/sessions'
import Editor from './editor'
import Fallback from './fallback'
export async function loader() {
const context = await getContext()
if (!context.hasAcl) {
throw new Error('No ACL configuration is available')
}
const { data, type } = await getAcl()
return {
hasAclWrite: context.hasAclWrite,
currentAcl: data,
aclType: type
}
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ success: false }, {
status: 401
})
}
const context = await getContext()
if (!context.hasAclWrite) {
return json({ success: false }, {
status: 403
})
}
const data = await request.json() as { acl: string }
await patchAcl(data.acl)
if (context.hasDockerSock) {
await sighupHeadscale()
}
return json({ success: true })
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const [acl, setAcl] = useState(data.currentAcl)
return (
<div>
{data.hasAclWrite ? undefined : (
<div className='mb-4'>
<Notice>
The ACL policy file is readonly to Headplane.
You will not be able to make changes here.
</Notice>
</div>
)}
<h1 className='text-2xl font-medium mb-4'>
Access Control List (ACL)
</h1>
<p className='mb-4 max-w-prose'>
The ACL file is used to define the access control rules for your network.
You can find more information about the ACL file in the Tailscale documentation.
{' '}
<a
target='_blank'
rel='noreferrer'
href='https://tailscale.com/kb/1018/acls'
className='text-blue-500 dark:text-blue-400 hover:underline'
>
More information
</a>
</p>
<Tabs>
<TabList className={cn(
'flex border-t border-gray-200 dark:border-gray-700',
'w-fit rounded-t-lg overflow-hidden',
'text-gray-400 dark:text-gray-500'
)}
>
<Tab
id='edit'
className={({ isSelected }) => cn(
'px-4 py-2 rounded-tl-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
)}
>
<PencilSquareIcon className='w-5 h-5'/>
<p>Edit file</p>
</Tab>
<Tab
id='diff'
className={({ isSelected }) => cn(
'px-4 py-2',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
)}
>
<EyeIcon className='w-5 h-5'/>
<p>Preview changes</p>
</Tab>
<Tab
id='preview'
className={({ isSelected }) => cn(
'px-4 py-2 rounded-tr-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
)}
>
<BeakerIcon className='w-5 h-5'/>
<p>Preview rules</p>
</Tab>
</TabList>
<TabPanel id='edit'>
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
{() => (
<Editor data={data} acl={acl} setAcl={setAcl} mode='edit'/>
)}
</ClientOnly>
</TabPanel>
<TabPanel id='diff'>
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
{() => (
<Editor data={data} acl={acl} setAcl={setAcl} mode='diff'/>
)}
</ClientOnly>
</TabPanel>
<TabPanel id='preview'>
<div
className={cn(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
'p-16 flex flex-col items-center justify-center'
)}
>
<CubeTransparentIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
<p className='w-1/2 text-center mt-4'>
The Preview rules is very much still a work in progress.
It is a bit complicated to implement right now but hopefully it will be available soon.
</p>
</div>
</TabPanel>
</Tabs>
</div>
)
}

View File

@ -0,0 +1,221 @@
/* eslint-disable unicorn/no-keyword-prefix */
import {
closestCorners,
DndContext,
DragOverlay
} from '@dnd-kit/core'
import {
restrictToParentElement,
restrictToVerticalAxis
} from '@dnd-kit/modifiers'
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Bars3Icon, LockClosedIcon } from '@heroicons/react/24/outline'
import { type FetcherWithComponents, useFetcher } from '@remix-run/react'
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import Button from '~/components/Button'
import Input from '~/components/Input'
import Spinner from '~/components/Spinner'
import TableList from '~/components/TableList'
type Properties = {
readonly baseDomain?: string;
readonly searchDomains: string[];
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
}
export default function Domains({ baseDomain, searchDomains, disabled }: Properties) {
// eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types
const [activeId, setActiveId] = useState<number | string | null>(null)
const [localDomains, setLocalDomains] = useState(searchDomains)
const [newDomain, setNewDomain] = useState('')
const fetcher = useFetcher()
useEffect(() => {
setLocalDomains(searchDomains)
}, [searchDomains])
return (
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Search Domains</h1>
<p className='text-gray-700 dark:text-gray-300 mb-2'>
Set custom DNS search domains for your Tailnet.
When using Magic DNS, your tailnet domain is used as the first search domain.
</p>
<DndContext
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
collisionDetection={closestCorners}
onDragStart={event => {
setActiveId(event.active.id)
}}
onDragEnd={event => {
// eslint-disable-next-line unicorn/no-null
setActiveId(null)
const { active, over } = event
if (!over) {
return
}
const activeItem = localDomains[active.id as number - 1]
const overItem = localDomains[over.id as number - 1]
if (!activeItem || !overItem) {
return
}
const oldIndex = localDomains.indexOf(activeItem)
const newIndex = localDomains.indexOf(overItem)
if (oldIndex !== newIndex) {
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex))
}
}}
>
<TableList>
{baseDomain ? (
<TableList.Item key='magic-dns-sd'>
<p className='font-mono text-sm'>{baseDomain}</p>
<LockClosedIcon className='h-4 w-4'/>
</TableList.Item>
) : undefined}
<SortableContext
items={localDomains}
strategy={verticalListSortingStrategy}
>
{localDomains.map((sd, index) => (
<Domain
// eslint-disable-next-line react/no-array-index-key
key={index}
domain={sd}
id={index + 1}
localDomains={localDomains}
disabled={disabled}
fetcher={fetcher}
/>
))}
<DragOverlay adjustScale>
{activeId ? <Domain
isDrag
domain={localDomains[activeId as number - 1]}
localDomains={localDomains}
id={activeId as number - 1}
disabled={disabled}
fetcher={fetcher}
/> : undefined}
</DragOverlay>
</SortableContext>
{disabled ? undefined : (
<TableList.Item key='add-sd'>
<Input
variant='embedded'
type='text'
className='font-mono text-sm'
placeholder='Search Domain'
value={newDomain}
onChange={event => {
setNewDomain(event.target.value)
}}
/>
{fetcher.state === 'idle' ? (
<Button
className='text-sm'
disabled={newDomain.length === 0}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.domains': [...localDomains, newDomain]
}, {
method: 'PATCH',
encType: 'application/json'
})
setNewDomain('')
}}
>
Add
</Button>
) : (
<Spinner className='w-3 h-3 mr-0'/>
)}
</TableList.Item>
)}
</TableList>
</DndContext>
</div>
)
}
type DomainProperties = {
readonly domain: string;
readonly id: number;
readonly isDrag?: boolean;
readonly localDomains: string[];
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
readonly fetcher: FetcherWithComponents<unknown>;
}
function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainProperties) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id })
// TODO: Figure out why TableList.Item breaks dndkit
return (
<div
ref={setNodeRef}
className={clsx(
'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0 dark:border-zinc-800',
isDragging ? 'text-gray-400' : '',
isDrag ? 'outline outline-1 outline-gray-500 bg-gray-200 dark:bg-zinc-800' : ''
)}
style={{
transform: CSS.Transform.toString(transform),
transition
}}
>
<p className='font-mono text-sm flex items-center gap-4'>
{disabled ? undefined : (
<Bars3Icon
className='h-4 w-4 text-gray-400 focus:outline-none'
{...attributes}
{...listeners}
/>
)}
{domain}
</p>
{isDrag ? undefined : (
<Button
variant='destructive'
className='text-sm'
disabled={disabled}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.domains': localDomains.filter((_, index) => index !== id - 1)
}, {
method: 'PATCH',
encType: 'application/json'
})
}}
>
Remove
</Button>
)}
</div>
)
}

View File

@ -0,0 +1,70 @@
import { useFetcher } from '@remix-run/react'
import Dialog from '~/components/Dialog'
import Spinner from '~/components/Spinner'
import { cn } from '~/utils/cn'
type Properties = {
readonly isEnabled: boolean;
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
}
export default function Modal({ isEnabled, disabled }: Properties) {
const fetcher = useFetcher()
return (
<Dialog>
<Dialog.Button
isDisabled={disabled}
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
)}
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Title>
<Dialog.Text>
Devices will no longer be accessible via your tailnet domain.
The search domain will also be disabled.
</Dialog.Text>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant='cancel'
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.magic_dns': !isEnabled
}, {
method: 'PATCH',
encType: 'application/json'
})
close()
}}
>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
)
}

View File

@ -0,0 +1,103 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable unicorn/no-keyword-prefix */
import { useFetcher } from '@remix-run/react'
import { useState } from 'react'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import Input from '~/components/Input'
import Spinner from '~/components/Spinner'
import TextField from '~/components/TextField'
import { cn } from '~/utils/cn'
type Properties = {
readonly name: string;
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
}
export default function Modal({ name, disabled }: Properties) {
const [newName, setNewName] = useState(name)
const fetcher = useFetcher()
return (
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Tailnet Name</h1>
<p className='text-gray-700 dark:text-gray-300'>
This is the base domain name of your Tailnet.
Devices are accessible at
{' '}
<Code>
[device].[user].{name}
</Code>
{' '}
when Magic DNS is enabled.
</p>
<Input
readOnly
className='font-mono text-sm my-4 w-1/2'
type='text'
value={name}
onFocus={event => {
event.target.select()
}}
/>
<Dialog>
<Dialog.Button
isDisabled={disabled}
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
)}
Rename Tailnet
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Rename Tailnet
</Dialog.Title>
<Dialog.Text>
Keep in mind that changing this can lead to all sorts of unexpected behavior and may break existing devices in your tailnet.
</Dialog.Text>
<TextField
label='Tailnet name'
placeholder='ts.net'
state={[newName, setNewName]}
className='my-2'
/>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant='cancel'
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
onPress={() => {
fetcher.submit({
'dns_config.base_domain': newName
}, {
method: 'PATCH',
encType: 'application/json'
})
close()
}}
>
Rename
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,201 @@
import { type ActionFunctionArgs } from '@remix-run/node'
import { json, useFetcher, useLoaderData } from '@remix-run/react'
import { useState } from 'react'
import Button from '~/components/Button'
import Code from '~/components/Code'
import Input from '~/components/Input'
import Notice from '~/components/Notice'
import Spinner from '~/components/Spinner'
import Switch from '~/components/Switch'
import TableList from '~/components/TableList'
import { getConfig, getContext, patchConfig } from '~/utils/config'
import { restartHeadscale } from '~/utils/docker'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import Domains from './domains'
import MagicModal from './magic'
import RenameModal from './rename'
// We do not want to expose every config value
export async function loader() {
const context = await getContext()
if (!context.hasConfig) {
throw new Error('No configuration is available')
}
const config = await getConfig()
const dns = {
prefixes: config.prefixes,
magicDns: config.dns_config.magic_dns,
baseDomain: config.dns_config.base_domain,
overrideLocal: config.dns_config.override_local_dns,
nameservers: config.dns_config.nameservers,
splitDns: config.dns_config.restricted_nameservers,
searchDomains: config.dns_config.domains,
extraRecords: config.dns_config.extra_records
}
return {
...dns,
...context
}
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ success: false }, {
status: 401
})
}
const context = await getContext()
if (!context.hasConfigWrite) {
return json({ success: false }, {
status: 403
})
}
const data = await request.json() as Record<string, unknown>
await patchConfig(data)
await restartHeadscale()
return json({ success: true })
}
export default function Page() {
useLiveData({ interval: 5000 })
const data = useLoaderData<typeof loader>()
const fetcher = useFetcher()
const [localOverride, setLocalOverride] = useState(data.overrideLocal)
const [ns, setNs] = useState('')
return (
<div className='flex flex-col gap-16 max-w-screen-lg'>
{data.hasConfigWrite ? undefined : (
<Notice>
The Headscale configuration is read-only. You cannot make changes to the configuration
</Notice>
)}
<RenameModal name={data.baseDomain} disabled={!data.hasConfigWrite}/>
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Nameservers</h1>
<p className='text-gray-700 dark:text-gray-300'>
Set the nameservers used by devices on the Tailnet
to resolve DNS queries.
</p>
<div className='mt-4'>
<div className='flex items-center justify-between mb-2'>
<h2 className='text-md font-medium opacity-80'>
Global Nameservers
</h2>
<div className='flex gap-2 items-center'>
<span className='text-sm opacity-50'>
Override local DNS
</span>
<Switch
label='Override local DNS'
defaultSelected={localOverride}
isDisabled={!data.hasConfigWrite}
onChange={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.override_local_dns': !localOverride
}, {
method: 'PATCH',
encType: 'application/json'
})
setLocalOverride(!localOverride)
}}
/>
</div>
</div>
<TableList>
{data.nameservers.map((ns, index) => (
// eslint-disable-next-line react/no-array-index-key
<TableList.Item key={index}>
<p className='font-mono text-sm'>{ns}</p>
<Button
variant='destructive'
className='text-sm'
disabled={!data.hasConfigWrite}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.nameservers': data.nameservers.filter((_, index_) => index_ !== index)
}, {
method: 'PATCH',
encType: 'application/json'
})
}}
>
Remove
</Button>
</TableList.Item>
))}
{data.hasConfigWrite ? (
<TableList.Item>
<Input
variant='embedded'
type='text'
className='font-mono text-sm'
placeholder='Nameserver'
value={ns}
onChange={event => {
setNs(event.target.value)
}}
/>
{fetcher.state === 'idle' ? (
<Button
className='text-sm'
disabled={ns.length === 0}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.nameservers': [...data.nameservers, ns]
}, {
method: 'PATCH',
encType: 'application/json'
})
setNs('')
}}
>
Add
</Button>
) : (
<Spinner className='w-3 h-3 mr-0'/>
)}
</TableList.Item>
) : undefined}
</TableList>
{/* TODO: Split DNS and Custom A Records */}
</div>
</div>
<Domains
baseDomain={data.magicDns ? data.baseDomain : undefined}
searchDomains={data.searchDomains}
disabled={!data.hasConfigWrite}
/>
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Magic DNS</h1>
<p className='text-gray-700 dark:text-gray-300 mb-4'>
Automaticall register domain names for each device
on the tailnet. Devices will be accessible at
{' '}
<Code>
[device].[user].{data.baseDomain}
</Code>
{' '}
when Magic DNS is enabled.
</p>
<MagicModal isEnabled={data.magicDns} disabled={!data.hasConfigWrite}/>
</div>
</div>
)
}

View File

@ -0,0 +1,75 @@
import { type LoaderFunctionArgs } from '@remix-run/node'
import { Link, useLoaderData } from '@remix-run/react'
import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { type Machine } from '~/types'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
export async function loader({ request, params }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!params.id) {
throw new Error('No machine ID provided')
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const data = await pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!)
return data.node
}
export default function Page() {
const data = useLoaderData<typeof loader>()
useLiveData({ interval: 1000 })
return (
<div>
<p className='mb-4 text-gray-500 dark:text-gray-400 text-sm'>
<Link
to='/machines'
className='font-bold text-gray-700 dark:text-gray-300 hover:underline'
>
All Machines
</Link>
{' / '}
{data.givenName}
</p>
<span className='flex items-baseline gap-x-4 text-sm mb-4'>
<h1 className='text-2xl font-bold'>
{data.givenName}
</h1>
<StatusCircle isOnline={data.online} className='w-4 h-4'/>
</span>
<Card>
<Attribute name='Creator' value={data.user.name}/>
<Attribute name='Node ID' value={data.id}/>
<Attribute name='Node Name' value={data.givenName}/>
<Attribute name='Hostname' value={data.name}/>
<Attribute
isCopyable
name='Node Key'
value={data.nodeKey}
/>
<Attribute
name='Created'
value={new Date(data.createdAt).toLocaleString()}
/>
<Attribute
name='Last Seen'
value={new Date(data.lastSeen).toLocaleString()}
/>
<Attribute
name='Expiry'
value={new Date(data.expiry).toLocaleString()}
/>
<Attribute
isCopyable
name='Domain'
value={`${data.givenName}.${data.user.name}.ts.net`}
/>
</Card>
</div>
)
}

View File

@ -0,0 +1,57 @@
import { type FetcherWithComponents } from '@remix-run/react'
import { type Dispatch, type SetStateAction } from 'react'
import Dialog from '~/components/Dialog'
import { type Machine } from '~/types'
import { cn } from '~/utils/cn'
type DeleteProperties = {
readonly machine: Machine;
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
}
export default function Delete({ machine, fetcher, state }: DeleteProperties) {
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Remove {machine.givenName}
</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from
your network. To re-add it, you will need to
reauthenticate to your tailnet from the device.
</Dialog.Text>
<fetcher.Form method='POST'>
<input type='hidden' name='_method' value='delete'/>
<input type='hidden' name='id' value={machine.id}/>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant='cancel'
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
className={cn(
'bg-red-500 hover:border-red-700',
'dark:bg-red-600 dark:hover:border-red-700',
'pressed:bg-red-600 hover:bg-red-600',
'text-white dark:text-white'
)}
onPress={close}
>
Remove
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}

View File

@ -0,0 +1,57 @@
import { type FetcherWithComponents } from '@remix-run/react'
import { type Dispatch, type SetStateAction } from 'react'
import Dialog from '~/components/Dialog'
import { type Machine } from '~/types'
import { cn } from '~/utils/cn'
type DeleteProperties = {
readonly machine: Machine;
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
}
export default function Expire({ machine, fetcher, state }: DeleteProperties) {
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Expire {machine.givenName}
</Dialog.Title>
<Dialog.Text>
This will disconnect the machine from your Tailnet.
In order to reconnect, you will need to re-authenticate
from the device.
</Dialog.Text>
<fetcher.Form method='POST'>
<input type='hidden' name='_method' value='expire'/>
<input type='hidden' name='id' value={machine.id}/>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant='cancel'
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
className={cn(
'bg-red-500 hover:border-red-700',
'dark:bg-red-600 dark:hover:border-red-700',
'pressed:bg-red-600 hover:bg-red-600',
'text-white dark:text-white'
)}
onPress={close}
>
Expire
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}

View File

@ -0,0 +1,89 @@
import { type FetcherWithComponents } from '@remix-run/react'
import { type Dispatch, type SetStateAction, useState } from 'react'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import { type Machine } from '~/types'
type RenameProperties = {
readonly machine: Machine;
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
readonly magic?: string;
}
export default function Rename({ machine, fetcher, state, magic }: RenameProperties) {
const [name, setName] = useState(machine.givenName)
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Edit machine name for {machine.givenName}
</Dialog.Title>
<Dialog.Text>
This name is shown in the admin panel, in Tailscale clients,
and used when generating MagicDNS names.
</Dialog.Text>
<fetcher.Form method='POST'>
<input type='hidden' name='_method' value='rename'/>
<input type='hidden' name='id' value={machine.id}/>
<TextField
label='Machine name'
placeholder='Machine name'
name='name'
state={[name, setName]}
className='my-2'
/>
{magic ? (
name.length > 0 && name !== machine.givenName ? (
<p className='text-sm text-gray-500 dark:text-gray-300 leading-tight'>
This machine will be accessible by the hostname
{' '}
<Code className='text-sm'>
{name.toLowerCase().replaceAll(/\s+/g, '-')}
</Code>
{'. '}
The hostname
{' '}
<Code className='text-sm'>
{machine.givenName}
</Code>
{' '}
will no longer point to this machine.
</p>
) : (
<p className='text-sm text-gray-500 dark:text-gray-300 leading-tight'>
This machine is accessible by the hostname
{' '}
<Code className='text-sm'>
{machine.givenName}
</Code>
.
</p>
)
) : undefined}
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant='cancel'
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
onPress={close}
>
Rename
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}

View File

@ -0,0 +1,92 @@
import { type FetcherWithComponents } from '@remix-run/react'
import { type Dispatch, type SetStateAction } from 'react'
import Dialog from '~/components/Dialog'
import Switch from '~/components/Switch'
import { type Machine, type Route } from '~/types'
import { cn } from '~/utils/cn'
type RoutesProperties = {
readonly machine: Machine;
readonly routes: Route[];
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
}
// TODO: Support deleting routes
export default function Routes({ machine, routes, fetcher, state }: RoutesProperties) {
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Edit route settings of {machine.givenName}
</Dialog.Title>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on
by advertising IP ranges as subnet routes.
</Dialog.Text>
<div className={cn(
'rounded-lg overflow-y-auto my-2',
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border border-zinc-200 dark:border-zinc-700'
)}
>
{routes.length === 0 ? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300'
)}
>
<p>
No routes are advertised on this machine.
</p>
</div>
) : undefined}
{routes.map(route => (
<div
key={route.node.id}
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between'
)}
>
<p>
{route.prefix}
</p>
<Switch
defaultSelected={route.enabled}
label='Enabled'
onChange={checked => {
const form = new FormData()
form.set('id', machine.id)
form.set('_method', 'routes')
form.set('route', route.id)
form.set('enabled', String(checked))
fetcher.submit(form, {
method: 'POST'
})
}}
/>
</div>
))}
</div>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant='cancel'
isDisabled={fetcher.state === 'submitting'}
onPress={close}
>
Close
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
)
}

View File

@ -0,0 +1,204 @@
/* eslint-disable react/hook-use-state */
import { ChevronDownIcon, ClipboardIcon, EllipsisHorizontalIcon } from '@heroicons/react/24/outline'
import { type FetcherWithComponents, Link } from '@remix-run/react'
import { useState } from 'react'
import Menu from '~/components/Menu'
import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster'
import { type Machine, type Route } from '~/types'
import { cn } from '~/utils/cn'
import Delete from './dialogs/delete'
import Expire from './dialogs/expire'
import Rename from './dialogs/rename'
import Routes from './dialogs/routes'
type MachineProperties = {
readonly machine: Machine;
readonly routes: Route[];
readonly fetcher: FetcherWithComponents<unknown>;
readonly magic?: string;
}
export default function MachineRow({ machine, routes, fetcher, magic }: MachineProperties) {
const renameState = useState(false)
const expireState = useState(false)
const removeState = useState(false)
const routesState = useState(false)
const expired = new Date(machine.expiry).getTime() < Date.now()
const tags = [
...machine.forcedTags,
...machine.validTags
]
if (expired) {
tags.unshift('Expired')
}
return (
<tr
key={machine.id}
className='hover:bg-zinc-100 dark:hover:bg-zinc-800 group'
>
<td className='pl-0.5 py-2'>
<Link
to={`/machines/${machine.id}`}
className='group/link h-full'
>
<p className={cn(
'font-semibold leading-snug',
'group-hover/link:text-blue-600',
'group-hover/link:dark:text-blue-400'
)}
>
{machine.givenName}
</p>
<p className='text-sm text-gray-500 dark:text-gray-300 font-mono'>
{machine.name}
</p>
<div className='flex gap-1 mt-1'>
{tags.map(tag => (
<span
key={tag}
className={cn(
'text-xs rounded-sm px-1 py-0.5',
'bg-gray-100 dark:bg-zinc-700',
'text-gray-600 dark:text-gray-300'
)}
>
{tag}
</span>
))}
</div>
</Link>
</td>
<td className='py-2'>
<div className='flex items-center gap-x-1'>
{machine.ipAddresses[0]}
<Menu>
<Menu.Button>
<ChevronDownIcon className='w-4 h-4'/>
</Menu.Button>
<Menu.Items>
{machine.ipAddresses.map(ip => (
<Menu.ItemButton
key={ip}
type='button'
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full'
)}
onPress={async () => {
await navigator.clipboard.writeText(ip)
toast('Copied IP address to clipboard')
}}
>
{ip}
<ClipboardIcon className='w-3 h-3'/>
</Menu.ItemButton>
))}
{magic ? (
<Menu.ItemButton
type='button'
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full break-keep'
)}
onPress={async () => {
const ip = `${machine.givenName}.${machine.user.name}.${magic}`
await navigator.clipboard.writeText(ip)
toast('Copied hostname to clipboard')
}}
>
{machine.givenName}.{machine.user.name}.{magic}
<ClipboardIcon className='w-3 h-3'/>
</Menu.ItemButton>
) : undefined}
</Menu.Items>
</Menu>
</div>
</td>
<td className='py-2'>
<span className={cn(
'flex items-center gap-x-1 text-sm',
'text-gray-500 dark:text-gray-400'
)}
>
<StatusCircle
isOnline={machine.online && !expired}
className='w-4 h-4'
/>
<p>
{machine.online && !expired
? 'Connected'
: new Date(
machine.lastSeen
).toLocaleString()}
</p>
</span>
</td>
<td className='py-2 pr-0.5'>
<Rename
machine={machine}
fetcher={fetcher}
state={renameState}
magic={magic}
/>
<Delete
machine={machine}
fetcher={fetcher}
state={removeState}
/>
{expired ? undefined : (
<Expire
machine={machine}
fetcher={fetcher}
state={expireState}
/>
)}
<Routes
machine={machine}
routes={routes}
fetcher={fetcher}
state={routesState}
/>
<Menu>
<Menu.Button
className={cn(
'flex items-center justify-center',
'border border-transparent rounded-lg py-0.5 w-10',
'group-hover:border-gray-200 dark:group-hover:border-zinc-700'
)}
>
<EllipsisHorizontalIcon className='w-5'/>
</Menu.Button>
<Menu.Items>
<Menu.ItemButton control={renameState}>
Edit machine name
</Menu.ItemButton>
<Menu.ItemButton control={routesState}>
Edit route settings
</Menu.ItemButton>
<Menu.Item className='opacity-50 hover:bg-transparent'>
Edit ACL tags
</Menu.Item>
{expired ? undefined : (
<Menu.ItemButton control={expireState}>
Expire
</Menu.ItemButton>
)}
<Menu.ItemButton
className='text-red-500 dark:text-red-400'
control={removeState}
>
Remove
</Menu.ItemButton>
</Menu.Items>
</Menu>
</td>
</tr>
)
}

View File

@ -0,0 +1,165 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { InformationCircleIcon } from '@heroicons/react/24/outline'
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
import { useFetcher, useLoaderData } from '@remix-run/react'
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
import Code from '~/components/Code'
import { type Machine, type Route } from '~/types'
import { cn } from '~/utils/cn'
import { getConfig, getContext } from '~/utils/config'
import { del, post, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import MachineRow from './machine'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const machines = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
const routes = await pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!)
const context = await getContext()
let magic: string | undefined
if (context.hasConfig) {
const config = await getConfig()
if (config.dns_config.magic_dns) {
magic = config.dns_config.base_domain
}
}
return {
nodes: machines.nodes,
routes: routes.routes,
magic
}
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401
})
}
const data = await request.formData()
if (!data.has('_method') || !data.has('id')) {
return json({ message: 'No method or ID provided' }, {
status: 400
})
}
const id = String(data.get('id'))
const method = String(data.get('_method'))
switch (method) {
case 'delete': {
await del(`v1/node/${id}`, session.get('hsApiKey')!)
return json({ message: 'Machine removed' })
}
case 'expire': {
console.log('expire')
const data = await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
console.log(data)
return json({ message: 'Machine expired' })
}
case 'rename': {
if (!data.has('name')) {
return json({ message: 'No name provided' }, {
status: 400
})
}
const name = String(data.get('name'))
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
return json({ message: 'Machine renamed' })
}
case 'routes': {
if (!data.has('route') || !data.has('enabled')) {
return json({ message: 'No route or enabled provided' }, {
status: 400
})
}
const route = String(data.get('route'))
const enabled = data.get('enabled') === 'true'
const postfix = enabled ? 'enable' : 'disable'
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
return json({ message: 'Route updated' })
}
default: {
return json({ message: 'Invalid method' }, {
status: 400
})
}
}
}
export default function Page() {
useLiveData({ interval: 3000 })
const data = useLoaderData<typeof loader>()
const fetcher = useFetcher()
return (
<>
<h1 className='text-2xl font-medium mb-4'>Machines</h1>
<table className='table-auto w-full rounded-lg'>
<thead className='text-gray-500 dark:text-gray-400'>
<tr className='text-left uppercase text-xs font-bold px-0.5'>
<th className='pb-2'>Name</th>
<th className='pb-2'>
<div className='flex items-center gap-x-1'>
Addresses
{data.magic ? (
<TooltipTrigger delay={0}>
<Button>
<InformationCircleIcon className='w-4 h-4 text-gray-400'/>
</Button>
<Tooltip className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-zinc-800',
'border border-gray-200 dark:border-zinc-700'
)}
>
Since MagicDNS is enabled, you can access devices
based on their name and also at
{' '}
<Code>
[name].[user].{data.magic}
</Code>
</Tooltip>
</TooltipTrigger>
) : undefined}
</div>
</th>
<th className='pb-2'>Last Seen</th>
</tr>
</thead>
<tbody className={cn(
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border-t border-zinc-200 dark:border-zinc-700'
)}
>
{data.nodes.map(machine => (
<MachineRow
key={machine.id}
// Typescript isn't smart enough yet
machine={machine as unknown as Machine}
routes={data.routes.filter(route => route.node.id === machine.id) as unknown as Route[]}
fetcher={fetcher}
magic={data.magic}
/>
))}
</tbody>
</table>
</>
)
}

View File

@ -0,0 +1,13 @@
import { CubeTransparentIcon } from '@heroicons/react/24/outline'
export default function Page() {
return (
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center'>
<CubeTransparentIcon className='w-32 h-32 text-gray-500'/>
<p className='text-lg mt-8'>
The settings page is currently unavailable.
It will be available in a future release.
</p>
</div>
)
}

128
app/routes/_data.tsx Normal file
View File

@ -0,0 +1,128 @@
import { Cog8ToothIcon, CpuChipIcon, GlobeAltIcon, LockClosedIcon, ServerStackIcon, UserCircleIcon, UsersIcon } from '@heroicons/react/24/outline'
import { type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Form, Outlet, useLoaderData } from '@remix-run/react'
import { ErrorPopup } from '~/components/Error'
import Menu from '~/components/Menu'
import TabLink from '~/components/TabLink'
import { getContext } from '~/utils/config'
import { HeadscaleError, pull } from '~/utils/headscale'
import { destroySession, getSession } from '~/utils/sessions'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return redirect('/login')
}
try {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await pull('v1/apikey', session.get('hsApiKey')!)
} catch (error) {
if (error instanceof HeadscaleError) {
console.error(error)
// Safest to just redirect to login if we can't pull
return redirect('/login', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await destroySession(session)
}
})
}
// Otherwise propagate to boundary
throw error
}
const context = await getContext()
return {
...context,
user: session.get('user')
}
}
export default function Layout() {
const data = useLoaderData<typeof loader>()
return (
<>
<header className='mb-6 bg-main-700 dark:bg-main-800 text-white'>
<nav className='container mx-auto'>
<div className='flex items-center justify-between mb-8 pt-4'>
<div className='flex items-center gap-x-2'>
<CpuChipIcon className='w-8 h-8'/>
<h1 className='text-2xl'>Headplane</h1>
</div>
<div className='flex items-center gap-x-4'>
<a href='https://tailscale.com/download' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
Download
</a>
<a href='https://github.com/tale/headplane' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
GitHub
</a>
<a href='https://github.com/juanfont/headscale' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
Headscale
</a>
<Menu>
<Menu.Button>
<UserCircleIcon className='w-8 h-8'/>
</Menu.Button>
<Menu.Items>
<Menu.Item className='text-right'>
<p className='font-bold'>{data.user?.name}</p>
<p>{data.user?.email}</p>
</Menu.Item>
<Menu.Item className='text-red-500 dark:text-red-400'>
<Form method='POST' action='/logout'>
<button type='submit' className='w-full text-right'>
Logout
</button>
</Form>
</Menu.Item>
</Menu.Items>
</Menu>
</div>
</div>
<div className='flex items-center gap-x-4'>
<TabLink to='/machines' name='Machines' icon={<ServerStackIcon className='w-5 h-5'/>}/>
<TabLink to='/users' name='Users' icon={<UsersIcon className='w-5 h-5'/>}/>
{data.hasAcl ? <TabLink to='/acls' name='Access Control' icon={<LockClosedIcon className='w-5 h-5'/>}/> : undefined}
{data.hasConfig ? (
<>
<TabLink to='/dns' name='DNS' icon={<GlobeAltIcon className='w-5 h-5'/>}/>
<TabLink to='/settings' name='Settings' icon={<Cog8ToothIcon className='w-5 h-5'/>}/>
</>
) : undefined}
</div>
</nav>
</header>
<main className='container mx-auto overscroll-contain mb-24'>
<Outlet/>
</main>
</>
)
}
export function ErrorBoundary() {
return (
<>
<header className='mb-16 bg-main-700 dark:bg-main-800 text-white'>
<nav className='container mx-auto'>
<div className='flex items-center gap-x-2 mb-8 pt-4'>
<CpuChipIcon className='w-8 h-8'/>
<h1 className='text-2xl'>Headplane</h1>
</div>
<div className='flex items-center gap-x-4'>
<TabLink to='/machines' name='Machines' icon={<ServerStackIcon className='w-5 h-5'/>}/>
<TabLink to='/users' name='Users' icon={<UsersIcon className='w-5 h-5'/>}/>
<TabLink to='/acls' name='Access Control' icon={<LockClosedIcon className='w-5 h-5'/>}/>
<TabLink to='/dns' name='DNS' icon={<GlobeAltIcon className='w-5 h-5'/>}/>
<TabLink to='/settings' name='Settings' icon={<Cog8ToothIcon className='w-5 h-5'/>}/>
</div>
</nav>
</header>
<ErrorPopup type='embedded'/>
</>
)
}

View File

@ -0,0 +1,67 @@
/* eslint-disable unicorn/filename-case */
import { ClipboardIcon, UserIcon } from '@heroicons/react/24/outline'
import { type LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { toast } from 'react-hot-toast/headless'
import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { type Machine, type User } from '~/types'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
const users = new Map<string, Machine[]>()
for (const machine of data.nodes) {
const { user } = machine
if (!users.has(user.id)) {
users.set(user.id, [])
}
users.get(user.id)?.push(machine)
}
return [...users.values()].map(machines => {
const { user } = machines[0]
return {
...user,
machines
}
})
}
export default function Page() {
const data = useLoaderData<typeof loader>()
useLiveData({ interval: 3000 })
return (
<div className='grid grid-cols-2 gap-4 auto-rows-min'>
{data.map(user => (
<Card key={user.id}>
<div className='flex items-center gap-4'>
<UserIcon className='w-6 h-6'/>
<span className='text-lg font-mono'>
{user.name}
</span>
</div>
<div className='py-4'>
{user.machines.map(machine => (
<div key={machine.id} className='flex items-center w-full gap-4'>
<StatusCircle isOnline={machine.online} className='w-4 h-4 px-1 w-fit'/>
<Attribute name={`Node ${machine.id}`} value={machine.givenName}/>
</div>
))}
</div>
</Card>
))}
</div>
)
}

5
app/routes/_index.tsx Normal file
View File

@ -0,0 +1,5 @@
import { redirect } from '@remix-run/node'
export function loader() {
return redirect('/machines')
}

View File

@ -1,113 +0,0 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { Capabilities } from '~/server/web/roles';
import { data400, data403 } from '~/utils/res';
// We only check capabilities here and assume it is writable
// If it isn't, it'll gracefully error anyways, since this means some
// fishy client manipulation is happening.
export async function aclAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
Capabilities.write_policy,
);
if (!check) {
throw data403('You do not have permission to write to the ACL policy');
}
// Try to write to the ACL policy via the API or via config file (TODO).
const formData = await request.formData();
const policyData = formData.get('policy')?.toString();
if (!policyData) {
throw data400('Missing `policy` in the form data.');
}
try {
const { policy, updatedAt } = await context.client.put<{
policy: string;
updatedAt: string;
}>('v1/policy', session.get('api_key')!, {
policy: policyData,
});
return data({
success: true,
error: undefined,
policy,
updatedAt,
});
} catch (error) {
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('update is disabled')) {
// This means the policy is not writable
throw data403('Policy is not writable');
}
// https://github.com/juanfont/headscale/blob/main/hscontrol/policy/v1/acls.go#L81
if (message.includes('parsing hujson')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
const cutIndex = message.indexOf('err: hujson:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 12)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('unmarshalling policy')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
const cutIndex = message.indexOf('err:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 5)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('empty policy')) {
return data(
{
success: false,
error: 'Policy error: Supplied policy was empty',
policy: undefined,
updatedAt: undefined,
},
400,
);
}
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
}

View File

@ -1,64 +0,0 @@
import { LoaderFunctionArgs } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { Capabilities } from '~/server/web/roles';
import { data403 } from '~/utils/res';
// The logic for deciding policy factors is very complicated because
// there are so many factors that need to be accounted for:
// 1. Does the user have permission to read the policy?
// 2. Does the user have permission to write to the policy?
// 3. Is the Headscale policy in file or database mode?
// If database, we can read/write easily via the API.
// If in file mode, we can only write if context.config is available.
// TODO: Consider adding back file editing mode instead of database
export async function aclLoader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_policy);
if (!check) {
throw data403('You do not have permission to read the ACL policy.');
}
const flags = {
// Can the user write to the ACL policy
access: await context.sessions.check(request, Capabilities.write_policy),
writable: false,
policy: '',
};
// Try to load the ACL policy from the API.
try {
const { policy, updatedAt } = await context.client.get<{
policy: string;
updatedAt: string | null;
}>('v1/policy', session.get('api_key')!);
// Successfully loaded the policy, mark it as readable
// If `updatedAt` is null, it means the policy is in file mode.
flags.writable = updatedAt !== null;
flags.policy = policy;
return flags;
} catch (error) {
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('acl policy not found')) {
// This means the policy has never been initiated, and we can
// write to it to get it started or ignore it.
flags.policy = ''; // Start with an empty policy
flags.writable = true;
}
return flags;
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
}

View File

@ -1,112 +0,0 @@
import * as shopify from '@shopify/lang-jsonc';
import { xcodeDark, xcodeLight } from '@uiw/codemirror-theme-xcode';
import CodeMirror from '@uiw/react-codemirror';
import { BookCopy, CircleX } from 'lucide-react';
import { useEffect, useState } from 'react';
import Merge from 'react-codemirror-merge';
import { ErrorBoundary } from 'react-error-boundary';
import { ClientOnly } from 'remix-utils/client-only';
import Fallback from './fallback';
interface EditorProps {
isDisabled?: boolean;
value: string;
onChange: (value: string) => void;
}
// TODO: Remove ClientOnly
export function Editor(props: EditorProps) {
const [light, setLight] = useState(false);
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)');
setLight(theme.matches);
theme.addEventListener('change', (theme) => {
setLight(theme.matches);
});
});
return (
<div className="overflow-y-scroll h-editor text-sm">
<ErrorBoundary
fallback={
<div className="flex flex-col items-center gap-2.5 py-8">
<CircleX />
<p className="text-lg font-semibold">Failed to load the editor.</p>
</div>
}
>
<ClientOnly fallback={<Fallback acl={props.value} />}>
{() => (
<CodeMirror
value={props.value}
editable={!props.isDisabled} // Allow editing unless disabled
readOnly={props.isDisabled} // Use readOnly if disabled
height="100%"
extensions={[shopify.jsonc()]}
style={{ height: '100%' }}
theme={light ? xcodeLight : xcodeDark}
onChange={(value) => props.onChange(value)}
/>
)}
</ClientOnly>
</ErrorBoundary>
</div>
);
}
interface DifferProps {
left: string;
right: string;
}
export function Differ(props: DifferProps) {
const [light, setLight] = useState(false);
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)');
setLight(theme.matches);
theme.addEventListener('change', (theme) => {
setLight(theme.matches);
});
});
return (
<div className="text-sm">
{props.left === props.right ? (
<div className="flex flex-col items-center gap-2.5 py-8">
<BookCopy />
<p className="text-lg font-semibold">No changes</p>
</div>
) : (
<div className="h-editor overflow-y-scroll">
<ErrorBoundary
fallback={
<div className="flex flex-col items-center gap-2.5 py-8">
<CircleX />
<p className="text-lg font-semibold">
Failed to load the editor.
</p>
</div>
}
>
<ClientOnly fallback={<Fallback acl={props.right} />}>
{() => (
<Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}>
<Merge.Original
readOnly
value={props.left}
extensions={[shopify.jsonc()]}
/>
<Merge.Modified
readOnly
value={props.right}
extensions={[shopify.jsonc()]}
/>
</Merge>
)}
</ClientOnly>
</ErrorBoundary>
</div>
)}
</div>
);
}

View File

@ -1,45 +0,0 @@
import { AlertIcon } from '@primer/octicons-react';
import React from 'react';
import Card from '~/components/Card';
interface NoticeViewProps {
title: string;
children: React.ReactNode;
}
export function NoticeView({ children, title }: NoticeViewProps) {
return (
<Card variant="flat" className="max-w-2xl my-8">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">{title}</Card.Title>
<AlertIcon className="w-8 h-8 text-yellow-500" />
</div>
<Card.Text className="mt-4">{children}</Card.Text>
</Card>
);
}
interface ErrorViewProps {
children: string;
}
export function ErrorView({ children }: ErrorViewProps) {
const [title, ...rest] = children.split(':');
const formattedMessage = rest.length > 0 ? rest.join(':').trim() : children;
return (
<Card variant="flat" className="max-w-2xl mb-4">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">
{title.trim() ?? 'Error'}
</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500" />
</div>
<Card.Text className="mt-4">
Could not apply changes to the ACL policy:
<br />
<span className="font-mono">{formattedMessage}</span>
</Card.Text>
</Card>
);
}

View File

@ -1,36 +0,0 @@
import cn from '~/utils/cn';
interface Props {
readonly acl: string;
}
export default function Fallback({ acl }: Props) {
return (
<div className="relative w-full h-editor flex">
<div
className={cn(
'h-full w-8 flex justify-center p-1',
'border-r border-headscale-400 dark:border-headscale-800',
)}
>
<div
aria-hidden
className={cn(
'h-5 w-5 animate-spin rounded-full',
'border-headplane-900 dark:border-headplane-100',
'border-2 border-t-transparent dark:border-t-transparent',
)}
/>
</div>
<textarea
readOnly
className={cn(
'w-full h-editor font-mono resize-none text-sm',
'bg-headplane-50 dark:bg-headplane-950 opacity-60',
'pl-1 pt-1 leading-snug',
)}
value={acl}
/>
</div>
);
}

View File

@ -1,173 +0,0 @@
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
ActionFunctionArgs,
LoaderFunctionArgs,
useFetcher,
useLoaderData,
useRevalidator,
} from 'react-router';
import Button from '~/components/Button';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Tabs from '~/components/Tabs';
import type { LoadContext } from '~/server';
import toast from '~/utils/toast';
import { aclAction } from './acl-action';
import { aclLoader } from './acl-loader';
import { Differ, Editor } from './components/cm.client';
import { ErrorView, NoticeView } from './components/error';
export async function loader(request: LoaderFunctionArgs<LoadContext>) {
return aclLoader(request);
}
export async function action(request: ActionFunctionArgs<LoadContext>) {
return aclAction(request);
}
export default function Page() {
// Access is a write check here, we already check read in aclLoader
const { access, writable, policy } = useLoaderData<typeof loader>();
const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher<typeof action>();
const { revalidate } = useRevalidator();
const disabled = !access || !writable; // Disable if no permission or not writable
useEffect(() => {
// Update the codePolicy when the loader data changes
if (policy !== codePolicy) {
setCodePolicy(policy);
}
}, [policy]);
useEffect(() => {
if (!fetcher.data) {
// No data yet, return
return;
}
if (fetcher.data.success === true) {
toast('Updated policy');
revalidate();
}
}, [fetcher.data]);
return (
<div>
{!access ? (
<NoticeView title="ACL Policy restricted">
You do not have the necessary permissions to edit the Access Control
List policy. Please contact your administrator to request access or to
make changes to the ACL policy.
</NoticeView>
) : !writable ? (
<NoticeView title="Read-only ACL Policy">
The ACL policy mode is most likely set to <Code>file</Code> in your
Headscale configuration. This means that the ACL file cannot be edited
through the web interface. In order to resolve this, you'll need to
set <Code>acl.mode</Code> to <Code>database</Code> in your Headscale
configuration.
</NoticeView>
) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
{fetcher.data?.error !== undefined ? (
<ErrorView>{fetcher.data.error}</ErrorView>
) : undefined}
<Tabs label="ACL Editor" className="mb-4">
<Tabs.Item
key="edit"
title={
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
}
>
<Editor
isDisabled={disabled}
value={codePolicy}
onChange={setCodePolicy}
/>
</Tabs.Item>
<Tabs.Item
key="diff"
title={
<div className="flex items-center gap-2">
<Eye className="p-1" />
<span>Preview changes</span>
</div>
}
>
<Differ left={policy} right={codePolicy} />
</Tabs.Item>
<Tabs.Item
key="preview"
title={
<div className="flex items-center gap-2">
<FlaskConical className="p-1" />
<span>Preview rules</span>
</div>
}
>
<div className="flex flex-col items-center py-8">
<Construction />
<p className="w-1/2 text-center mt-4">
Previewing rules is not available yet. This feature is still in
development and is pretty complicated to implement. Hopefully I
will be able to get to it soon.
</p>
</div>
</Tabs.Item>
</Tabs>
<Button
variant="heavy"
className="mr-2"
isDisabled={
disabled ||
fetcher.state !== 'idle' ||
codePolicy.length === 0 ||
codePolicy === policy
}
onPress={() => {
const formData = new FormData();
console.log(codePolicy);
formData.append('policy', codePolicy);
fetcher.submit(formData, { method: 'PATCH' });
}}
>
Save
</Button>
<Button
isDisabled={
disabled || fetcher.state !== 'idle' || codePolicy === policy
}
onPress={() => {
// Reset the editor to the original policy
setCodePolicy(policy);
}}
>
Discard Changes
</Button>
</div>
);
}

View File

@ -1,186 +0,0 @@
import { useEffect } from 'react';
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
redirect,
useSearchParams,
} from 'react-router';
import { Form, useActionData, useLoaderData } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
import type { LoadContext } from '~/server';
import type { Key } from '~/types';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const qp = new URL(request.url).searchParams;
const state = qp.get('s');
try {
const session = await context.sessions.auth(request);
if (session.has('api_key')) {
return redirect('/machines');
}
} catch {}
const disableApiKeyLogin = context.config.oidc?.disable_api_key_login;
if (context.oidc && disableApiKeyLogin) {
// Prevents automatic redirect loop if OIDC is enabled and API key login is disabled
// Since logging out would just log back in based on the redirects
if (state !== 'logout') {
return redirect('/oidc/start');
}
}
return {
oidc: context.oidc,
disableApiKeyLogin,
state,
};
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const formData = await request.formData();
const oidcStart = formData.get('oidc-start');
const session = await context.sessions.getOrCreate(request);
if (oidcStart) {
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
return redirect('/oidc/start');
}
const apiKey = String(formData.get('api-key'));
// Test the API key
try {
const apiKeys = await context.client.get<{ apiKeys: Key[] }>(
'v1/apikey',
apiKey,
);
const key = apiKeys.apiKeys.find((k) => apiKey.startsWith(k.prefix));
if (!key) {
return {
error: 'Invalid API key',
};
}
const expiry = new Date(key.expiration);
const expiresIn = expiry.getTime() - Date.now();
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24);
session.set('state', 'auth');
session.set('api_key', apiKey);
session.set('user', {
subject: 'unknown-non-oauth',
name: key.prefix,
email: `${expiresDays.toString()} days`,
});
return redirect('/machines', {
headers: {
'Set-Cookie': await context.sessions.commit(session, {
maxAge: expiresIn,
}),
},
});
} catch {
return {
error: 'Invalid API key',
};
}
}
export default function Page() {
const { state, disableApiKeyLogin, oidc } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const [params] = useSearchParams();
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (state !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete('s');
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
window.history.replaceState(null, '', newUrl);
}
}, [state, params]);
if (state === 'logout') {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>You have been logged out</Card.Title>
<Card.Text>
You can now close this window. If you would like to log in again,
please refresh the page.
</Card.Text>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>Welcome to Headplane</Card.Title>
{!disableApiKeyLogin ? (
<Form method="post">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate
one by running <Code>headscale apikeys create</Code> in your
terminal.
</Card.Text>
{actionData?.error ? (
<p className="text-red-500 text-sm mb-2">{actionData.error}</p>
) : undefined}
<Input
isRequired
labelHidden
label="API Key"
name="api-key"
placeholder="API Key"
type="password"
className="mt-4 mb-2"
/>
<Button className="w-full" variant="heavy" type="submit">
Sign In
</Button>
</Form>
) : undefined}
{oidc ? (
<Form method="POST">
<input type="hidden" name="oidc-start" value="true" />
<Button
className="w-full mt-2"
variant={disableApiKeyLogin ? 'heavy' : 'light'}
type="submit"
>
Single Sign-On
</Button>
</Form>
) : undefined}
</Card>
</div>
);
}

View File

@ -1,28 +0,0 @@
import { type ActionFunctionArgs, redirect } from 'react-router';
import type { LoadContext } from '~/server';
export async function loader() {
return redirect('/machines');
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
if (!session.has('api_key')) {
return redirect('/login');
}
// When API key is disabled, we need to explicitly redirect
// with a logout state to prevent auto login again.
const url = context.config.oidc?.disable_api_key_login
? '/login?s=logout'
: '/login';
return redirect(url, {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
},
});
}

View File

@ -1,69 +0,0 @@
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import type { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { finishAuthFlow, formatError } from '~/utils/oidc';
import { send } from '~/utils/res';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
// Check if we have 0 query parameters
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect('/login');
}
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if (session.get('state') !== 'flow') {
return redirect('/login'); // Haven't started an OIDC flow
}
const payload = session.get('oidc')!;
const { code_verifier, state, nonce, redirect_uri } = payload;
if (!code_verifier || !state || !nonce || !redirect_uri) {
return send({ error: 'Missing OIDC state' }, { status: 400 });
}
// Reconstruct the redirect URI using the query parameters
// and the one we saved in the session
const flowRedirectUri = new URL(redirect_uri);
flowRedirectUri.search = url.search;
const flowOptions = {
redirect_uri: flowRedirectUri.toString(),
code_verifier,
state,
nonce: nonce === '<none>' ? undefined : nonce,
};
try {
const user = await finishAuthFlow(context.oidc, flowOptions);
session.unset('oidc');
const userSession = session as Session<AuthSession>;
// TODO: This is breaking, to stop the "over-generation" of API
// keys because they are currently non-deletable in the headscale
// database. Look at this in the future once we have a solution
// or we have permissioned API keys.
userSession.set('user', user);
userSession.set('api_key', context.config.oidc?.headscale_api_key!);
userSession.set('state', 'auth');
return redirect('/machines', {
headers: {
'Set-Cookie': await context.sessions.commit(userSession),
},
});
} catch (error) {
return new Response(JSON.stringify(formatError(error)), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
}

View File

@ -1,42 +0,0 @@
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if ((session as Session<AuthSession>).has('api_key')) {
return redirect('/machines');
}
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
const redirectUri =
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
const data = await beginAuthFlow(
context.oidc,
redirectUri,
// We can't get here without the OIDC config being defined
context.config.oidc!.token_endpoint_auth_method,
);
session.set('state', 'flow');
session.set('oidc', {
state: data.state,
nonce: data.nonce,
code_verifier: data.codeVerifier,
redirect_uri: redirectUri,
});
return redirect(data.url, {
status: 302,
headers: {
'Set-Cookie': await context.sessions.commit(session),
},
});
}

View File

@ -1,205 +0,0 @@
import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Lock } from 'lucide-react';
import { useEffect, useState } from 'react';
import { type FetcherWithComponents, Form, useFetcher } from 'react-router';
import Button from '~/components/Button';
import Input from '~/components/Input';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
interface Props {
searchDomains: string[];
isDisabled: boolean;
magic?: string;
}
export default function ManageDomains({
searchDomains,
isDisabled,
magic,
}: Props) {
const [activeId, setActiveId] = useState<number | string | null>(null);
const [localDomains, setLocalDomains] = useState(searchDomains);
useEffect(() => {
setLocalDomains(searchDomains);
}, [searchDomains]);
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
<p className="mb-4">
Set custom DNS search domains for your Tailnet. When using Magic DNS,
your tailnet domain is used as the first search domain.
</p>
<DndContext
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
collisionDetection={closestCorners}
onDragStart={(event) => {
setActiveId(event.active.id);
}}
onDragEnd={(event) => {
setActiveId(null);
const { active, over } = event;
if (!over) {
return;
}
const activeItem = localDomains[(active.id as number) - 1];
const overItem = localDomains[(over.id as number) - 1];
if (!activeItem || !overItem) {
return;
}
const oldIndex = localDomains.indexOf(activeItem);
const newIndex = localDomains.indexOf(overItem);
if (oldIndex !== newIndex) {
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
}
}}
>
<TableList>
{magic ? (
<TableList.Item key="magic-dns-sd">
<div
className={cn(
'flex items-center gap-4',
isDisabled ? 'flex-row-reverse justify-between w-full' : '',
)}
>
<Lock className="p-0.5" />
<p className="font-mono text-sm py-0.5">{magic}</p>
</div>
</TableList.Item>
) : undefined}
<SortableContext
items={localDomains}
strategy={verticalListSortingStrategy}
>
{localDomains.map((sd, index) => (
<Domain
key={sd}
domain={sd}
id={index + 1}
isDisabled={isDisabled}
/>
))}
<DragOverlay adjustScale>
{activeId ? (
<Domain
isDragging
domain={localDomains[(activeId as number) - 1]}
id={(activeId as number) - 1}
isDisabled={isDisabled}
/>
) : undefined}
</DragOverlay>
</SortableContext>
{isDisabled ? undefined : (
<TableList.Item key="add-sd">
<Form
method="POST"
className="flex items-center justify-between w-full"
>
<input type="hidden" name="action_id" value="add_domain" />
<Input
type="text"
className={cn(
'border-none font-mono p-0 text-sm',
'rounded-none focus:ring-0 w-full ml-1',
)}
placeholder="Search Domain"
label="Search Domain"
name="domain"
labelHidden
isRequired
/>
<Button
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-blue-500 dark:text-blue-400',
)}
>
Add
</Button>
</Form>
</TableList.Item>
)}
</TableList>
</DndContext>
</div>
);
}
interface DomainProps {
domain: string;
id: number;
isDragging?: boolean;
isDisabled: boolean;
}
function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging: isSortableDragging,
} = useSortable({ id });
return (
<TableList.Item
ref={setNodeRef}
className={cn(
isSortableDragging ? 'opacity-50' : '',
isDragging ? 'ring bg-white dark:bg-headplane-900' : '',
)}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
>
<p className="font-mono text-sm flex items-center gap-4">
{isDisabled ? undefined : (
<GripVertical
{...attributes}
{...listeners}
className="p-0.5 focus:ring outline-none rounded-md"
/>
)}
{domain}
</p>
{isDragging ? undefined : (
<Form method="POST">
<input type="hidden" name="action_id" value="remove_domain" />
<input type="hidden" name="domain" value={domain} />
<Button
type="submit"
isDisabled={isDisabled}
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
>
Remove
</Button>
</Form>
)}
</TableList.Item>
);
}

View File

@ -1,99 +0,0 @@
import { Form } from 'react-router';
import Button from '~/components/Button';
import Link from '~/components/Link';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
import AddNS from '../dialogs/add-ns';
interface Props {
nameservers: Record<string, string[]>;
isDisabled: boolean;
}
export default function ManageNS({ nameservers, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p>
Set the nameservers used by devices on the Tailnet to resolve DNS
queries.{' '}
<Link
to="https://tailscale.com/kb/1054/dns"
name="Tailscale DNS Documentation"
>
Learn more
</Link>
</p>
<div className="mt-4">
{Object.keys(nameservers).map((key) => (
<NameserverList
key={key}
isGlobal={key === 'global'}
isDisabled={isDisabled}
nameservers={nameservers}
name={key}
/>
))}
{isDisabled ? undefined : <AddNS nameservers={nameservers} />}
</div>
</div>
);
}
interface ListProps {
isGlobal: boolean;
isDisabled: boolean;
nameservers: Record<string, string[]>;
name: string;
}
function NameserverList({
isGlobal,
isDisabled,
nameservers,
name,
}: ListProps) {
const list = isGlobal ? nameservers.global : nameservers[name];
if (list.length === 0) {
return null;
}
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h2 className="text-md font-medium opacity-80">
{isGlobal ? 'Global Nameservers' : name}
</h2>
</div>
<TableList>
{list.length > 0
? list.map((ns) => (
<TableList.Item key={ns}>
<p className="font-mono text-sm">{ns}</p>
<Form method="POST">
<input type="hidden" name="action_id" value="remove_ns" />
<input type="hidden" name="ns" value={ns} />
<input
type="hidden"
name="split_name"
value={isGlobal ? 'global' : name}
/>
<Button
isDisabled={isDisabled}
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
>
Remove
</Button>
</Form>
</TableList.Item>
))
: undefined}
</TableList>
</div>
);
}

View File

@ -1,75 +0,0 @@
import { Form } from 'react-router';
import Button from '~/components/Button';
import Code from '~/components/Code';
import Link from '~/components/Link';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
import AddRecord from '../dialogs/add-record';
interface Props {
records: { name: string; type: 'A' | string; value: string }[];
isDisabled: boolean;
}
export default function ManageRecords({ records, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
<p>
Headscale supports adding custom DNS records to your Tailnet. As of now,
only <Code>A</Code> records are supported.{' '}
<Link
to="https://headscale.net/stable/ref/dns"
name="Headscale DNS Records documentation"
>
Learn More
</Link>
</p>
<div className="mt-4">
<TableList className="mb-8">
{records.length === 0 ? (
<TableList.Item>
<p className="opacity-50 mx-auto">No DNS records found</p>
</TableList.Item>
) : (
records.map((record, index) => (
<TableList.Item key={`${record.name}-${record.value}`}>
<div className="flex gap-24 items-center">
<div className="flex gap-4 items-center">
<p
className={cn(
'font-mono text-sm font-bold py-1 px-2 rounded-md',
'bg-headplane-100 dark:bg-headplane-700/30',
)}
>
{record.type}
</p>
<p className="font-mono text-sm">{record.name}</p>
</div>
<p className="font-mono text-sm">{record.value}</p>
</div>
<Form method="POST">
<input type="hidden" name="action_id" value="remove_record" />
<input type="hidden" name="record_name" value={record.name} />
<input type="hidden" name="record_type" value={record.type} />
<Button
type="submit"
isDisabled={isDisabled}
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
>
Remove
</Button>
</Form>
</TableList.Item>
))
)}
</TableList>
{isDisabled ? undefined : <AddRecord records={records} />}
</div>
</div>
);
}

View File

@ -1,48 +0,0 @@
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
interface Props {
name: string;
isDisabled: boolean;
}
export default function RenameTailnet({ name, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3 gap-y-4">
<h1 className="text-2xl font-medium mb-2">Tailnet Name</h1>
<p>
This is the base domain name of your Tailnet. Devices are accessible at{' '}
<Code>[device].{name}</Code> when Magic DNS is enabled.
</p>
<Input
isReadOnly
labelHidden
className="w-3/5 font-medium text-sm"
label="Tailnet name"
value={name}
onFocus={(event) => {
event.target.select();
}}
/>
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Rename Tailnet</Dialog.Button>
<Dialog.Panel isDisabled={isDisabled}>
<Dialog.Title>Rename Tailnet</Dialog.Title>
<Dialog.Text className="mb-8">
Keep in mind that changing this can lead to all sorts of unexpected
behavior and may break existing devices in your tailnet.
</Dialog.Text>
<input type="hidden" name="action_id" value="rename_tailnet" />
<Input
isRequired
label="Tailnet name"
placeholder="ts.net"
defaultValue={name}
name="new_name"
/>
</Dialog.Panel>
</Dialog>
</div>
);
}

View File

@ -1,31 +0,0 @@
import Dialog from '~/components/Dialog';
interface Props {
isEnabled: boolean;
isDisabled: boolean;
}
export default function Modal({ isEnabled, isDisabled }: Props) {
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Button>
<Dialog.Panel isDisabled={isDisabled}>
<Dialog.Title>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Title>
<Dialog.Text>
Devices will no longer be accessible via your tailnet domain. The
search domain will also be disabled.
</Dialog.Text>
<input type="hidden" name="action_id" value="toggle_magic" />
<input
type="hidden"
name="new_state"
value={isEnabled ? 'disabled' : 'enabled'}
/>
</Dialog.Panel>
</Dialog>
);
}

View File

@ -1,94 +0,0 @@
import { RepoForkedIcon } from '@primer/octicons-react';
import { useMemo, useState } from 'react';
import Chip from '~/components/Chip';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Switch from '~/components/Switch';
import Tooltip from '~/components/Tooltip';
import cn from '~/utils/cn';
interface Props {
nameservers: Record<string, string[]>;
}
export default function AddNameserver({ nameservers }: Props) {
const [split, setSplit] = useState(false);
const [ns, setNs] = useState('');
const [domain, setDomain] = useState('');
const isInvalid = useMemo(() => {
if (ns === '') return false;
// Test if it's a valid IPv4 or IPv6 address
const ipv4 = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
const ipv6 = /^([0-9a-fA-F:]+:+)+[0-9a-fA-F]+$/;
if (!ipv4.test(ns) && !ipv6.test(ns)) return true;
if (split) {
return nameservers[domain]?.includes(ns);
}
return Object.values(nameservers).some((nsList) => nsList.includes(ns));
}, [nameservers, ns]);
return (
<Dialog>
<Dialog.Button>Add nameserver</Dialog.Button>
<Dialog.Panel>
<Dialog.Title className="mb-4">Add nameserver</Dialog.Title>
<input type="hidden" name="action_id" value="add_ns" />
<Input
isRequired
label="Nameserver"
description="Use this IPv4 or IPv6 address to resolve names."
placeholder="1.2.3.4"
name="ns"
onChange={setNs}
isInvalid={isInvalid}
/>
<div className="flex items-center justify-between mt-8">
<div className="block">
<div className="inline-flex items-center gap-2">
<Dialog.Text className="font-semibold">
Restrict to domain
</Dialog.Text>
<Tooltip>
<Chip
text="Split DNS"
leftIcon={<RepoForkedIcon className="w-4 h-4 mr-0.5" />}
className={cn('inline-flex items-center')}
/>
<Tooltip.Body>
Only clients that support split DNS (Tailscale v1.8 or later
for most platforms) will use this nameserver. Older clients
will ignore it.
</Tooltip.Body>
</Tooltip>
</div>
<Dialog.Text className="text-sm">
This nameserver will only be used for some domains.
</Dialog.Text>
</div>
<Switch label="Split DNS" onChange={setSplit} />
</div>
{split ? (
<>
<Dialog.Text className="font-semibold mt-8">Domain</Dialog.Text>
<Input
isRequired={split === true}
label="Domain"
placeholder="example.com"
name="split_name"
onChange={setDomain}
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries matching this suffix
should use the nameserver.
</Dialog.Text>
</>
) : (
<input type="hidden" name="split_name" value="global" />
)}
</Dialog.Panel>
</Dialog>
);
}

View File

@ -1,59 +0,0 @@
import { useMemo, useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
interface Props {
records: { name: string; type: 'A' | string; value: string }[];
}
export default function AddRecord({ records }: Props) {
const [name, setName] = useState('');
const [ip, setIp] = useState('');
const isDuplicate = useMemo(() => {
if (name.length === 0 || ip.length === 0) return false;
const lookup = records.find((record) => record.name === name);
if (!lookup) return false;
return lookup.value === ip;
}, [records, name, ip]);
return (
<Dialog>
<Dialog.Button>Add DNS record</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Add DNS record</Dialog.Title>
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<div className="flex flex-col gap-2 mt-4">
<input type="hidden" name="action_id" value="add_record" />
<input type="hidden" name="record_type" value="A" />
<Input
isRequired
label="Domain"
placeholder="test.example.com"
name="record_name"
onChange={setName}
isInvalid={isDuplicate}
/>
<Input
isRequired
label="IP Address"
placeholder="101.101.101.101"
name="record_value"
onChange={setIp}
isInvalid={isDuplicate}
/>
{isDuplicate ? (
<p className="text-sm opacity-50">
A record with the domain name <Code>{name}</Code> and IP address{' '}
<Code>{ip}</Code> already exists.
</p>
) : undefined}
</div>
</Dialog.Panel>
</Dialog>
);
}

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