Compare commits
141 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87a9219fd3 | |||
| ad7e58570f | |||
| 04ff2138d2 | |||
| fb4b0b1404 | |||
| 1e0ff7ead6 | |||
| c6b6cbc122 | |||
| deb284e2b4 | |||
| 5a2098eea5 | |||
| b961b339bb | |||
| ac6f9e4f7e | |||
| 3026b33834 | |||
| ecd284b5d8 | |||
| 4cf4e5c040 | |||
| 9e5e5a613a | |||
| 4d252833ef | |||
| 9238f69bfc | |||
| d110dd2bcb | |||
| addef55f30 | |||
| 67c6c0b453 | |||
| 418c3bc255 | |||
| 946921fff7 | |||
| e4030ed254 | |||
| fccd2eefc4 | |||
| 93be180479 | |||
| 6582f8ae07 | |||
| f0f02b3c4c | |||
| c030d1fbe4 | |||
| 724e454466 | |||
| dc80c93184 | |||
| c164e07336 | |||
| d26c23313c | |||
| d5f76637f5 | |||
| 272233ae62 | |||
| 974c3b0e48 | |||
| acd3ff3403 | |||
| 1961608e12 | |||
| c4ac28f90b | |||
| a0b2077c7b | |||
| 0f19fdf0da | |||
| d255098128 | |||
| 98e0806e5a | |||
| 44dffeaff0 | |||
| 33f7bbb0cf | |||
| 10278d0cc9 | |||
| bdcd4c5bad | |||
| 43cff2f4b7 | |||
| 61e7303363 | |||
| dc44cc4155 | |||
| 30ce5e2727 | |||
| 55a09476ab | |||
| ac90b4e8bb | |||
| 4b47b1bbed | |||
| 003985d192 | |||
| 1259642f8a | |||
| 4cd0c1e206 | |||
| b2dfa773b0 | |||
| 73b5d5514e | |||
| 838a2cd732 | |||
| 0191da4ce2 | |||
| ad1d47b223 | |||
| a5e2af9b30 | |||
| 9ac3ab1861 | |||
| 44f45c396c | |||
| ee59a2d06d | |||
| 2c57187628 | |||
| 57c15f7c6b | |||
| 131c54f501 | |||
| 333220a153 | |||
| 61ca6693c4 | |||
| 30bbcbab2a | |||
| a1eb6d35db | |||
| 98d8dfe235 | |||
| 54f236a332 | |||
| 873e6005d9 | |||
| 3213385c0f | |||
| 2e2d5d1dff | |||
| 925d78a175 | |||
| 4e94e82a01 | |||
| 3ec00e17d8 | |||
| dfda629b12 | |||
| 04debdcadf | |||
| 718cbf3c40 | |||
| 246bb90ef0 | |||
| 7403ebea06 | |||
| 941971b8c7 | |||
| 5aa17e448d | |||
| fc9df0c536 | |||
| b6b773fa6d | |||
| cc5aa99046 | |||
| 5f81822366 | |||
| 4fb1cf059e | |||
| b1361e9062 | |||
| dd0587dad1 | |||
| 4997687a58 | |||
| 8f6fe05c83 | |||
| 25dc09e025 | |||
| 27f8fa0b42 | |||
| eda5713700 | |||
| dc2e2dcc98 | |||
| f0df663c90 | |||
| 1025a657b0 | |||
| 0741567754 | |||
| a26faab139 | |||
| 55ddbe0987 | |||
| e255407115 | |||
| 225a52ded6 | |||
| 38cf93e5ae | |||
| b7a85684a9 | |||
| e1808bee1e | |||
| cf4c163c92 | |||
| 6470f5a821 | |||
| a53fd31d27 | |||
| 37d0080cba | |||
| 0ce411e92b | |||
| df5e04f239 | |||
| e6d90c3124 | |||
| a967731461 | |||
| a1efe36ff1 | |||
| 270b99f063 | |||
| 434f886034 | |||
| 684a95b5e8 | |||
| 45984ec639 | |||
| c7f3b60b62 | |||
| 0f8e192b5c | |||
| 6d70497758 | |||
| f4a2811afb | |||
| 5f2cbebbcd | |||
| da3132323c | |||
| cb97b73abd | |||
| 432cd29cd0 | |||
| 1feb109a36 | |||
| f6613f1faf | |||
| 210d473bc2 | |||
| 67b1611d13 | |||
| df11aefd0c | |||
| f9dc9d454b | |||
| 64edd2041b | |||
| 1462016a39 | |||
| 779103147d | |||
| be79413178 | |||
| 787f8decbc |
@@ -0,0 +1,7 @@
|
||||
FROM mcr.microsoft.com/devcontainers/base:bookworm
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends build-essential libnss3-tools mkcert pkg-config && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "headplane",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile"
|
||||
},
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/common-utils:2": {
|
||||
"upgradePackages": true,
|
||||
"username": "vscode"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"nodeGypDependencies": true,
|
||||
"version": "24"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/go:1": {
|
||||
"version": "1.25.1"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/nix:1": {
|
||||
"extraNixConfig": "experimental-features = nix-command flakes,accept-flake-config = true"
|
||||
}
|
||||
},
|
||||
"containerEnv": {
|
||||
"COREPACK_ENABLE_DOWNLOAD_PROMPT": "0"
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"golang.go",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"ms-vscode.vscode-typescript-next"
|
||||
],
|
||||
"settings": {
|
||||
"editor.formatOnSave": true,
|
||||
"go.useLanguageServer": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"forwardPorts": [3000, 5173],
|
||||
"postCreateCommand": "pnpm config set store-dir $HOME/.pnpm-store --global && pnpm install",
|
||||
"remoteUser": "vscode",
|
||||
"updateRemoteUserUID": true
|
||||
}
|
||||
@@ -1 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Question or Support
|
||||
url: https://github.com/tale/headplane/discussions/new?category=q-a
|
||||
about: Ask questions or get help with your setup
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
Agent:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- cmd/hp_agent/**
|
||||
- internal/**
|
||||
- go.mod
|
||||
- go.sum
|
||||
|
||||
UI/UX:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- app/components/**
|
||||
- app/layouts/**
|
||||
- app/root.tsx
|
||||
- app/tailwind.css
|
||||
|
||||
Authentication:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- app/server/web/**
|
||||
- app/routes/auth*/**
|
||||
|
||||
Config:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- app/server/config/**
|
||||
- internal/config/**
|
||||
- config.example.yaml
|
||||
|
||||
Integrations:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- app/server/headscale/**
|
||||
- app/openapi-*.json
|
||||
|
||||
Web SSH:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- cmd/hp_ssh/**
|
||||
|
||||
Docs:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- docs/**
|
||||
- README.md
|
||||
@@ -1,5 +1,11 @@
|
||||
name: Automated
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "pnpm-lock.yaml"
|
||||
- "package.json"
|
||||
branches:
|
||||
- "main"
|
||||
schedule:
|
||||
- cron: "0 8 * * 0"
|
||||
workflow_dispatch:
|
||||
@@ -9,24 +15,70 @@ concurrency:
|
||||
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
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
flake-inputs:
|
||||
name: flake-inputs
|
||||
nix-pnpm-hash:
|
||||
name: Update Nix PNPM Hash
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
|
||||
- uses: DeterminateSystems/update-flake-lock@main
|
||||
- name: Cache Nix store
|
||||
uses: DeterminateSystems/magic-nix-cache-action@main
|
||||
|
||||
- name: Compute correct PNPM dependencies hash
|
||||
run: |
|
||||
CURRENT_HASH=$(grep -oP "hash = \"\K[^\"]*" nix/package.nix)
|
||||
sed -i "s|hash = \"$CURRENT_HASH\"|hash = \"\"|" nix/package.nix
|
||||
|
||||
NEW_HASH=$(nix build .#headplane.pnpmDeps --no-link 2>&1 \
|
||||
| grep 'got:' \
|
||||
| awk '{print $2}')
|
||||
|
||||
if [ -n "$NEW_HASH" ]; then
|
||||
sed -i "s|hash = \"\"|hash = \"$NEW_HASH\"|" nix/package.nix
|
||||
else
|
||||
git checkout nix/package.nix
|
||||
fi
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
if git diff --quiet nix/package.nix; then
|
||||
echo "Hash is already up to date, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add nix/package.nix
|
||||
git commit -m "chore: update nix pnpm deps hash"
|
||||
git push
|
||||
|
||||
flake-inputs:
|
||||
name: Update Flake Inputs
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
|
||||
- name: Update flake.lock
|
||||
uses: DeterminateSystems/update-flake-lock@main
|
||||
with:
|
||||
pr-title: "chore: update flake.lock"
|
||||
pr-labels: |
|
||||
|
||||
@@ -15,8 +15,8 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
actions: write # Allow canceling in-progress runs
|
||||
contents: read # Read access to the repository
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -41,8 +41,9 @@ jobs:
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: CI pipeline
|
||||
- name: Build
|
||||
run: ./build.sh --skip-pnpm-prune
|
||||
|
||||
- name: Run unit tests
|
||||
@@ -52,19 +53,22 @@ jobs:
|
||||
run: pnpm run test:integration
|
||||
|
||||
nix:
|
||||
name: nix
|
||||
name: Nix Flake Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
|
||||
- name: Cache Nix store
|
||||
uses: DeterminateSystems/magic-nix-cache-action@main
|
||||
|
||||
- name: Check flake inputs
|
||||
uses: DeterminateSystems/flake-checker-action@main
|
||||
|
||||
- name: Check flake outputs
|
||||
run: nix flake check --all-systems
|
||||
run: nix flake check
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Docs
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
branches:
|
||||
- "main"
|
||||
- "release/docs"
|
||||
|
||||
concurrency:
|
||||
group: docs-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
beta:
|
||||
name: Deploy Beta Docs
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build docs
|
||||
run: pnpm docs:build
|
||||
env:
|
||||
HEADPLANE_BETA_DOCS: "true"
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
command: pages deploy docs/.vitepress/dist --project-name=headplane-docs-beta
|
||||
|
||||
stable:
|
||||
name: Deploy Stable Docs
|
||||
if: >
|
||||
(startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-'))
|
||||
|| github.ref == 'refs/heads/release/docs'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build docs
|
||||
run: pnpm docs:build
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
command: pages deploy docs/.vitepress/dist --project-name=headplane-docs
|
||||
@@ -0,0 +1,18 @@
|
||||
name: Labeler
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Label Pull Request
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels based on changed files
|
||||
uses: actions/labeler@v6
|
||||
with:
|
||||
sync-labels: true
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Release Lifecycle
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
beta:
|
||||
name: Beta Release Lifecycle
|
||||
if: contains(github.ref_name, '-')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Label and comment on referenced issues
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "No previous tag found, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Processing issues referenced between ${PREV_TAG} and ${TAG}"
|
||||
ISSUES=$(git log --format="%B" "${PREV_TAG}..HEAD" | grep -oE '#[0-9]+' | sort -u | tr -d '#')
|
||||
|
||||
for ISSUE in $ISSUES; do
|
||||
[ -z "$ISSUE" ] && continue
|
||||
|
||||
STATE=$(gh issue view "$ISSUE" --json state --jq '.state' \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || echo "")
|
||||
[ "$STATE" != "OPEN" ] && continue
|
||||
|
||||
echo "Labeling and commenting on #${ISSUE}"
|
||||
gh issue edit "$ISSUE" --add-label "In Beta" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
gh issue comment "$ISSUE" \
|
||||
--body "🧪 Available in [\`${TAG}\`](https://github.com/${{ github.repository }}/releases/tag/${TAG})" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
done
|
||||
|
||||
stable:
|
||||
name: Stable Release Lifecycle
|
||||
if: ${{ !contains(github.ref_name, '-') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close milestone issues
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
VERSION="${TAG#v}"
|
||||
|
||||
MILESTONE_NUMBER=$(gh api "repos/${{ github.repository }}/milestones" \
|
||||
--jq ".[] | select(.title == \"${VERSION}\") | .number" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$MILESTONE_NUMBER" ]; then
|
||||
echo "No milestone found for ${VERSION}, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Processing milestone ${VERSION} (#${MILESTONE_NUMBER})"
|
||||
gh api "repos/${{ github.repository }}/issues?milestone=${MILESTONE_NUMBER}&state=open&per_page=100" \
|
||||
--paginate --jq '.[].number' 2>/dev/null | while IFS= read -r ISSUE; do
|
||||
[ -z "$ISSUE" ] && continue
|
||||
|
||||
echo "Closing #${ISSUE}"
|
||||
gh issue edit "$ISSUE" --remove-label "In Beta" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
gh issue comment "$ISSUE" \
|
||||
--body "✅ Released in [\`${TAG}\`](https://github.com/${{ github.repository }}/releases/tag/${TAG})" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
gh issue close "$ISSUE" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
done
|
||||
|
||||
echo "Closing milestone ${VERSION}"
|
||||
gh api -X PATCH "repos/${{ github.repository }}/milestones/${MILESTONE_NUMBER}" \
|
||||
-f state=closed 2>/dev/null || true
|
||||
@@ -1,82 +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
|
||||
id-token: write # For the attest action to push
|
||||
attestations: write # For the attest action to push
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# Ensure the action only runs if manually dispatched or a PR on the `next` branch in the *main* repository is opened or synchronized.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'next') }}
|
||||
name: Docker Pre-release
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: final
|
||||
tag: "next"
|
||||
- target: debug-shell
|
||||
tag: "next-shell"
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=${{ matrix.tag }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and publish ghcr.io/${{ github.repository }}:${{ matrix.tag }}
|
||||
uses: docker/build-push-action@v6
|
||||
id: push
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: ${{ matrix.target }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ matrix.tag }}
|
||||
HEADPLANE_VERSION=next+${{ github.sha }}
|
||||
secrets: |
|
||||
gh_token=${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Attestation Provenance for ghcr.io/${{ github.repository }}:${{ matrix.tag }}
|
||||
uses: actions/attest-build-provenance@v2
|
||||
id: attest
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
@@ -9,15 +9,15 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
actions: write # Allow canceling in-progress runs
|
||||
contents: read # Read access to the repository
|
||||
packages: write # Write access to the container registry
|
||||
id-token: write # For the attest action to push
|
||||
attestations: write # For the attest action to push
|
||||
actions: write
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
name: Docker Release
|
||||
name: Docker Release (${{ matrix.target }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -26,12 +26,11 @@ jobs:
|
||||
tag_suffix: ""
|
||||
- target: debug-shell
|
||||
tag_suffix: "-shell"
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Docker Metadata
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
@@ -55,9 +54,9 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
|
||||
uses: docker/build-push-action@v6
|
||||
- name: Build and push
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -71,9 +70,9 @@ jobs:
|
||||
build-args: |
|
||||
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
HEADPLANE_VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
- name: Attestation Provenance for ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
|
||||
|
||||
- name: Attest build provenance
|
||||
uses: actions/attest-build-provenance@v2
|
||||
id: attest
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Stale
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
name: Close Stale Issues
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mark and close stale issues
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
stale-issue-label: Stale
|
||||
only-labels: "Needs Info"
|
||||
days-before-stale: 30
|
||||
stale-issue-message: >
|
||||
This issue has been waiting for information for 30 days.
|
||||
It will be closed in 7 days if there is no further activity.
|
||||
Feel free to reopen if you can provide the requested details.
|
||||
close-issue-message: >
|
||||
Closed due to inactivity. Feel free to reopen with the
|
||||
requested information.
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Triage
|
||||
on:
|
||||
issues:
|
||||
types: [milestoned]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
remove-triage:
|
||||
name: Remove Needs Triage Label
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Remove Needs Triage label
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh issue edit ${{ github.event.issue.number }} \
|
||||
--remove-label "Needs Triage" \
|
||||
--repo ${{ github.repository }} 2>/dev/null || true
|
||||
@@ -1,6 +1,8 @@
|
||||
node_modules
|
||||
/.fate
|
||||
/.react-router
|
||||
/.cache
|
||||
/.data
|
||||
/build
|
||||
/test
|
||||
.env
|
||||
@@ -13,3 +15,4 @@ node_modules
|
||||
/docs/.vitepress/dist/
|
||||
/docs/.vitepress/cache/
|
||||
/.direnv
|
||||
/vendor
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Core Concepts
|
||||
|
||||
Headplane is a web application to manage Headscale, a self-hosted implementation
|
||||
of the Tailscale control server. There are a few tenets that guide the entire
|
||||
development of the project:
|
||||
|
||||
- **Simple starts**: We want to make it as easy as possible to set up and use
|
||||
Headplane, while still providing powerful features for advanced users. This
|
||||
means that we prioritize a clean and intuitive user interface, as well as
|
||||
straightforward installation and configuration processes.
|
||||
|
||||
- **No breaking changes**: We want to avoid making breaking changes to the
|
||||
project as much as possible. This means that we will strive to maintain
|
||||
backward compatibility and provide clear migration paths when necessary.
|
||||
|
||||
- **Documentation**: This is the most important part of the project, without it
|
||||
the entire project falls apart and is hard to use.
|
||||
|
||||
## Project Management
|
||||
|
||||
It's hard to manage this project easily, use the `gh` CLI when responding to
|
||||
prompts to get context. Some common issue tags to keep track of include a
|
||||
"Needs Triage", "Needs Info", "Bug", "Enhancement", and several other tags based
|
||||
on what parts of the project are affected.
|
||||
|
||||
## Headplane Agent
|
||||
|
||||
The Headplane Agent is a lightweight component that runs on the same server as
|
||||
Headplane and connects directly to the Tailnet in order to pull in details about
|
||||
nodes that aren't available through the Headscale API such as versions, etc.
|
||||
|
||||
## WebSSH
|
||||
|
||||
This is an ephemeral WASM shim that runs in the browser and connects directly
|
||||
to the Tailnet using Tailscale's go packages. It allows anyone to open up an
|
||||
ephemeral machine in the Tailnet that directly SSHes into a target node.
|
||||
|
||||
## Build/Tooling
|
||||
|
||||
Headplane is a React Router 7 (framework mode) project built with Vite. Take
|
||||
care to use our preferred PNPM version and Node version as defined in the
|
||||
`engines` field of `package.json`. We also use TypeScript Go and Oxfmt for
|
||||
type-checking and formatting respectively.
|
||||
|
||||
When typechecking, use `pnpm run typecheck`, when linting and formatting, use
|
||||
the respective `lint` and `format` scripts, you can pass flags to them. You can
|
||||
also run Headscale CLI commands with `docker exec headscale headscale <command>`
|
||||
when the dev environment is running.
|
||||
|
||||
## Docs
|
||||
|
||||
The project has a documentation site available at the `docs/` directory built
|
||||
with VitePress. The documentation is written in Markdown and can be easily
|
||||
edited and extended. If making changes to staple features, please take care to
|
||||
also update the documentation to reflect any changes in functionality or usage.
|
||||
@@ -1,3 +1,50 @@
|
||||
# 0.7.0-beta.3 (May 14, 2026)
|
||||
|
||||
> This is a beta release. Please report any issues you encounter.
|
||||
|
||||
- Fixed GHSA-vgj6-hcf2-fqf6, a path traversal / RBAC bypass in Headscale node and user rename API calls.
|
||||
|
||||
---
|
||||
|
||||
# 0.6.3 (May 14, 2026)
|
||||
|
||||
- Fixed GHSA-vgj6-hcf2-fqf6, a path traversal / RBAC bypass in Headscale node and user rename API calls.
|
||||
|
||||
---
|
||||
|
||||
# 0.7.0-beta.2 (April 9, 2026)
|
||||
|
||||
> This is a beta release. Please report any issues you encounter.
|
||||
|
||||
- **Rebuilt the user model to enable "account linking" between Headplane and Headscale.** OIDC users are automatically linked to their Headscale counterparts based on subject and email. Users who cannot be automatically linked can claim an unlinked Headscale user during onboarding. See the [SSO docs](/features/sso) for details (via [#489](https://github.com/tale/headplane/pull/489)).
|
||||
- **Rebuilt Browser SSH** with a new terminal powered by [Ghostty WASM](https://restty.dev), improved session handling, and support for custom DERP ports. See the [Browser SSH docs](/features/ssh) for details (closes [#515](https://github.com/tale/headplane/issues/515), closes [#386](https://github.com/tale/headplane/issues/386)).
|
||||
- **Rearchitected the Headplane Agent** with a periodic sync model and extensive caching. The agent now auto-generates ephemeral pre-auth keys (requires Headscale 0.28+). See the [Agent docs](/features/agent) for details (closes [#350](https://github.com/tale/headplane/issues/350), closes [#455](https://github.com/tale/headplane/issues/455)).
|
||||
- **Replaced `openid-client` with a new OIDC implementation.** Fixes `client_secret_basic` not working with Google SSO and other providers (closes [#493](https://github.com/tale/headplane/issues/493), closes [#516](https://github.com/tale/headplane/issues/516)).
|
||||
- **Migrated all UI components from react-aria to [Base UI](https://base-ui.com).**
|
||||
- **Consolidated the Headscale API key** under `headscale.api_key` (and `headscale.api_key_path`). Deprecated `oidc.headscale_api_key` — it is still read as a fallback but will be removed in a future release.
|
||||
- Added machine list filters for user, tag, status, and route (via [#507](https://github.com/tale/headplane/pull/507), closes [#506](https://github.com/tale/headplane/issues/506)).
|
||||
- Added self-service pre-auth key creation for auditor role users (via [#478](https://github.com/tale/headplane/pull/478), closes [#453](https://github.com/tale/headplane/issues/453)).
|
||||
- Added an agent status page at `/settings/agent` showing sync status, node count, and errors.
|
||||
- Added local endpoint and address information to the machine detail page.
|
||||
- Improved the ACL editor appearance and fixed a CodeMirror version mismatch.
|
||||
- Store OIDC profile pictures in the database to prevent cookie overflow (via [#510](https://github.com/tale/headplane/pull/510), closes [#326](https://github.com/tale/headplane/issues/326)).
|
||||
- Detect unsupported Docker API versions early with a clear error message (via [#497](https://github.com/tale/headplane/pull/497)).
|
||||
- Fixed "No expiry" badge not displaying for nodes with zero-time expiry values (via [#527](https://github.com/tale/headplane/pull/527), closes [#526](https://github.com/tale/headplane/issues/526)).
|
||||
- Fixed first user not being assigned the owner role on OIDC login (via [#480](https://github.com/tale/headplane/pull/480), closes [#266](https://github.com/tale/headplane/issues/266)).
|
||||
- Fixed login errors throwing a server error instead of showing form validation (via [#475](https://github.com/tale/headplane/pull/475), closes [#474](https://github.com/tale/headplane/issues/474)).
|
||||
- Fixed pre-auth key expiration on Headscale 0.28+ (closes [#519](https://github.com/tale/headplane/issues/519)).
|
||||
- Fixed OIDC subject matching for providers with special characters in user IDs, e.g. Auth0 (closes [#428](https://github.com/tale/headplane/issues/428)).
|
||||
- Fixed `headscale.api_key` not being used consistently across all code paths.
|
||||
- Fixed agent HostInfo not refreshing periodically using `cache_ttl` (via [#477](https://github.com/tale/headplane/pull/477), closes [#427](https://github.com/tale/headplane/issues/427)).
|
||||
- Fixed agent working directory being wiped on restart.
|
||||
- Fixed a race condition where the SSE controller could be used after being closed.
|
||||
- Fixed cookie secret generation using incorrect byte length (via [#501](https://github.com/tale/headplane/pull/501)).
|
||||
- Fixed OIDC configuration error troubleshooting link (via [#518](https://github.com/tale/headplane/pull/518), closes [#517](https://github.com/tale/headplane/issues/517)).
|
||||
- Fixed deprecated Nix package attributes (via [#521](https://github.com/tale/headplane/pull/521)).
|
||||
- Updated NixOS module options: removed deprecated agent fields, added `headscale.api_key_path` and `integration.agent.executable_path`.
|
||||
|
||||
---
|
||||
|
||||
# 0.6.2 (February 26, 2026)
|
||||
|
||||
- **Added support for Headscale 0.28.0** including all API and data model changes.
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
# Headplane codebase findings
|
||||
|
||||
Date: 2026-05-16
|
||||
|
||||
Scope: server-side TypeScript, React Router route modules, client components, live-data/data-fetching paths, auth/session/RBAC, Headscale config/API integration, Go agent/WebSSH code, runtime/build/CI, and docs. This is an architecture and pattern review, not a completed security audit.
|
||||
|
||||
## Executive summary
|
||||
|
||||
Headplane's biggest codebase risks are not isolated style issues; they cluster around ownership boundaries:
|
||||
|
||||
- **Auth/session boundary:** API-key sessions put the raw Headscale API key in the auth cookie, and self-service account linking can claim arbitrary unclaimed Headscale users.
|
||||
- **Live-data boundary:** the current live path polls globally, caches globally, and tells React Router to re-run whole active route loaders on any resource change.
|
||||
- **Lifecycle boundary:** process-lifetime services have `setInterval`, child processes, SSE streams, Undici agents, and WASM/tsnet state, but there is no coordinated shutdown/disposal model.
|
||||
- **Mutation boundary:** config-file edits and several admin/user mutations rely on UI constraints, mutable process state, non-transactional writes, or inconsistent server-side validation.
|
||||
- **Framework/tooling boundary:** React Router loaders/actions currently carry a lot of app state orchestration. Fate could be a good fit for live `nodes`/`users` data, but it will not fix auth, authorization, config mutation, WebSSH, or agent lifecycle issues by itself.
|
||||
|
||||
## Highest-priority findings
|
||||
|
||||
### 1. OIDC self-linking can claim arbitrary unclaimed Headscale users
|
||||
|
||||
**Severity:** Critical
|
||||
**Area:** AuthZ / account linking
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/routes/home.tsx:84-97` accepts a posted `headscale_user_id` and calls `context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId)`.
|
||||
- `app/server/web/auth.ts:225-237` grants machine ownership based on `principal.user.headscaleUserId === node.user?.id`.
|
||||
- `app/routes/machines/machine-actions.ts:57-68` authorizes machine mutations through `canManageNode`.
|
||||
|
||||
**Why it matters:** a logged-in OIDC user can forge the form POST and link themselves to any unclaimed Headscale user ID. Once linked, they can manage that user's machines through the machine action authorization path.
|
||||
|
||||
**Suggested direction:** recompute allowed self-link targets server-side inside the action and reject anything not in that set. Prefer auto-linking only by verified OIDC subject/email match, and reserve arbitrary linking for admins.
|
||||
|
||||
### 2. API-key sessions put the raw Headscale API key in a signed cookie
|
||||
|
||||
**Severity:** Critical
|
||||
**Area:** Auth/session security
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/server/web/auth.ts:96-105` manually base64url-encodes JSON and signs it with HMAC.
|
||||
- `app/server/web/auth.ts:269-283` serializes `{ sid, api_key }` for API-key sessions.
|
||||
- `app/server/web/auth.ts:144-173` resolves API-key principals from `payload.api_key`; the DB `api_key_hash` is not used to retrieve a server-side secret.
|
||||
- `app/utils/oidc-state.ts:13-19` explicitly sets `httpOnly`, while the main auth cookie options are not explicit in `app/server/context.ts:49-54` / `app/server/web/auth.ts:96-105`.
|
||||
- `docs/install/index.md` describes `server.cookie_secret` as encrypting cookies, but the implementation signs rather than encrypts.
|
||||
|
||||
**Why it matters:** cookie compromise becomes direct Headscale API compromise, not just Headplane session compromise. HMAC protects integrity, not confidentiality. The auth cookie should be an opaque session handle, not a transport for upstream credentials.
|
||||
|
||||
**Suggested direction:** store only `sid` in the cookie; keep the API key encrypted or server-side in the DB/secret store; verify `api_key_hash` if retaining API-key sessions; explicitly set `httpOnly`, `sameSite`, `secure`, `path`, and consider key rotation/versioning. Use timing-safe HMAC comparison if manual signatures remain.
|
||||
|
||||
## Live data and data-fetching findings
|
||||
|
||||
### 3. SSE events trigger whole-route revalidation instead of resource-scoped updates
|
||||
|
||||
**Severity:** High
|
||||
**Area:** Client data fetching / live data
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/utils/live-data.tsx:35-39` calls `revalidator.revalidate()`.
|
||||
- `app/utils/live-data.tsx:63-82` treats every `changed` event as a global route revalidation.
|
||||
- `app/routes/util/live.ts:34-37` sends only `{ resource, version }`, not field/object-level deltas or subscribed views.
|
||||
|
||||
**Why it matters:** any node/user change can re-run every active loader for the current route tree. Pages such as machines/users load multiple resources and agent metadata, so frequent Headscale changes can become over-fetching and UI churn.
|
||||
|
||||
**Suggested direction:** short term, include enough resource metadata to revalidate only affected routes or debounce/coalesce revalidation. Long term, move live data to normalized object/list subscriptions instead of request/route invalidation.
|
||||
|
||||
### 4. Live-data pause state is global, boolean, and can stick disabled
|
||||
|
||||
**Severity:** High
|
||||
**Area:** Client state / live data
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/root.tsx:41-66` mounts `LiveDataProvider` above all routes.
|
||||
- `app/routes/auth/login/page.tsx:59-66` calls `pause()` with no dependency array and no cleanup/resume.
|
||||
- `app/components/dialog.tsx:21-27` uses the same single boolean pause model for dialogs.
|
||||
- `app/utils/live-data.tsx:136-141` exposes imperative `pause()` / `resume()` with no ownership token.
|
||||
|
||||
**Why it matters:** visiting `/login` can leave live updates disabled after navigating into the app. Multiple pause consumers can also fight: closing one dialog can resume live updates while another still expects them paused.
|
||||
|
||||
**Suggested direction:** add immediate cleanup in login (`pause(); return resume`) and a dependency array. Replace the boolean with a refcount/token model so each consumer releases only its own pause.
|
||||
|
||||
### 5. `hsLive` is process-global but stores one mutable API client
|
||||
|
||||
**Severity:** High
|
||||
**Area:** Server live data / cache scoping
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/server/context.ts:86-101` creates one `hsLive` for the entire process.
|
||||
- `app/server/headscale/live-store.ts:69` keeps `storedApiClient` in shared closure state.
|
||||
- `app/server/headscale/live-store.ts:113-120` polling uses that shared client.
|
||||
- `app/server/headscale/live-store.ts:143` and `app/server/headscale/live-store.ts:158` overwrite it from each caller.
|
||||
|
||||
**Why it matters:** the last request/action to touch `hsLive` controls which API key future background polls use. A bad/expired user-supplied API-key session can poison live refresh for everyone until another request resets it. Snapshots are also not principal/session scoped.
|
||||
|
||||
**Suggested direction:** poll with one stable server credential only, or scope cache/polling by principal/session. If adopting Fate, make this one of the first seams to replace.
|
||||
|
||||
### 6. Live store polling is coarse and lifecycle-unmanaged
|
||||
|
||||
**Severity:** Medium
|
||||
**Area:** Server lifecycle / live data
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/server/headscale/live-store.ts:38-45` polls nodes every 5s and users every 15s.
|
||||
- `app/server/headscale/live-store.ts:82-104` compares `JSON.stringify(data)` for the whole resource.
|
||||
- `app/server/headscale/live-store.ts:113-123` starts intervals lazily but never stops them unless `dispose()` is called.
|
||||
- `app/server/context.ts:94` creates the store, but no app shutdown path calls `hsLive.dispose()`.
|
||||
|
||||
**Why it matters:** whole-resource JSON comparison is order-sensitive and grows with tailnet size. The polling intervals continue for process lifetime and can leak during dev HMR or repeated context creation.
|
||||
|
||||
**Suggested direction:** introduce app-level lifecycle management and subscription-aware polling, or replace with event/object-scoped live subscriptions.
|
||||
|
||||
### 7. Loader data is mirrored into local state and can overwrite edits during revalidation
|
||||
|
||||
**Severity:** Medium
|
||||
**Area:** Client state / React patterns
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/routes/acls/overview.tsx:30-41` mirrors `policy` into `codePolicy` and updates it when loader data changes.
|
||||
- `app/routes/dns/components/manage-domains.tsx:25-31` mirrors `searchDomains` into local state.
|
||||
|
||||
**Why it matters:** global SSE revalidation can replace local edit state. The ACL editor is especially risky because an unsaved policy can be overwritten by background loader refresh.
|
||||
|
||||
**Suggested direction:** separate “initial server value” from “dirty draft” state; never overwrite dirty drafts on background revalidation without prompting.
|
||||
|
||||
### 8. Fetcher/dialog orchestration is imperative and duplicated
|
||||
|
||||
**Severity:** Medium
|
||||
**Area:** Client forms / mutations
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/routes/machines/dialogs/tags.tsx:34-43` uses `submittingRef` + effects to close/unlock.
|
||||
- `app/routes/settings/auth-keys/dialogs/add-auth-key.tsx:56-71` uses similar orchestration and directly assigns `fetcher.data = undefined`.
|
||||
- `app/routes/machines/dialogs/routes.tsx:49-63` submits on switch changes without optimistic/error state.
|
||||
|
||||
**Why it matters:** each dialog reimplements mutation lifecycle handling. Direct mutation of `fetcher.data` fights router-owned state and can hide stale or failed submissions.
|
||||
|
||||
**Suggested direction:** centralize fetcher mutation state handling, or move to an action/mutation primitive with explicit pending/error/success states and optimistic updates.
|
||||
|
||||
## Auth, authorization, and route handling findings
|
||||
|
||||
### 9. Agent settings route/action lacks capability checks
|
||||
|
||||
**Severity:** High
|
||||
**Area:** Authorization
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/routes/settings/agent.tsx:13-27` only authenticates.
|
||||
- `app/routes/settings/agent.tsx:29-39` only authenticates before triggering sync.
|
||||
|
||||
**Why it matters:** any authenticated user can view agent status and trigger sync work if they know the URL, regardless of settings/admin capability.
|
||||
|
||||
**Suggested direction:** gate read and sync separately, probably using `read_feature`/`write_feature` or a dedicated agent capability.
|
||||
|
||||
### 10. App layout catches all loader errors and destroys sessions
|
||||
|
||||
**Severity:** High
|
||||
**Area:** Error handling / auth UX
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/layout/app.tsx:32-103` wraps the whole loader in `try/catch` and redirects to `/login` with `destroySession()` for any error.
|
||||
- `app/layout/app.tsx:50-67` handles one expected expired-API-key case, but the outer catch covers unrelated failures too.
|
||||
|
||||
**Why it matters:** network errors, programming errors, and transient Headscale failures can become silent logouts. It also hides diagnostics from route error boundaries.
|
||||
|
||||
**Suggested direction:** catch only expected auth/session errors; let operational/programming errors hit the error boundary or a health banner.
|
||||
|
||||
### 11. RBAC is inconsistent across routes/actions
|
||||
|
||||
**Severity:** Medium/High
|
||||
**Area:** Authorization
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/routes/machines/machine-actions.ts:64-68` performs resource-specific checks via `canManageNode`.
|
||||
- `app/routes/users/user-actions.ts:10-17` checks only broad `write_users` before operations.
|
||||
- `app/routes/home.tsx:84-97` lets OIDC users link Headscale identities without revalidating target ownership.
|
||||
- `app/routes/settings/auth-keys/actions.ts:23-35` has more careful self-service ownership checks, showing the pattern exists but is not universal.
|
||||
|
||||
**Why it matters:** permissions are enforced route-by-route with no single policy layer, so new actions can accidentally rely on UI hiding. The self-linking issue is one concrete outcome.
|
||||
|
||||
**Suggested direction:** create server-side action guards for common ownership/role decisions and require every mutation to call one.
|
||||
|
||||
### 12. Runtime role input is not validated and ownership updates are non-transactional
|
||||
|
||||
**Severity:** Medium
|
||||
**Area:** Auth/RBAC data integrity
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/routes/users/user-actions.ts:82-104` casts `newRole as Role` from form data.
|
||||
- `app/server/web/auth.ts:467-484` upserts that role into the DB.
|
||||
- `app/server/web/auth.ts:430-464` transfers ownership with two separate updates.
|
||||
- `app/server/web/auth.ts:324-342` makes the first user owner with a count-after-insert flow.
|
||||
|
||||
**Why it matters:** forged role values can reach storage, and failures/races in ownership transfer or first-login owner selection can produce invalid owner state.
|
||||
|
||||
**Suggested direction:** validate `newRole` against `Roles` at the route boundary, use transactions for ownership transfer, and make first-owner assignment atomic.
|
||||
|
||||
### 13. Login page has live-data and URL cleanup bugs
|
||||
|
||||
**Severity:** Medium
|
||||
**Area:** Client route behavior
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/routes/auth/login/page.tsx:59-66` pauses live data on every render and never resumes.
|
||||
- `app/routes/auth/login/page.tsx:68-84` uses `window.history.replaceState` manually.
|
||||
- `app/routes/auth/login/page.tsx:78-80` builds `newUrl` with `` `{${window.location.pathname}?...` ``, leaving a literal `{` in the URL.
|
||||
|
||||
**Why it matters:** this can disable live updates and corrupt/uglify login URLs. Manual history mutation bypasses router state.
|
||||
|
||||
**Suggested direction:** use a loader redirect or router navigation where possible; otherwise fix the string and make the effect one-shot with cleanup.
|
||||
|
||||
## Headscale API and config mutation findings
|
||||
|
||||
### 14. Headscale API errors are collapsed into `502 Bad Gateway`
|
||||
|
||||
**Severity:** Medium/High
|
||||
**Area:** API wrapper / error handling
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/server/headscale/api/index.ts:139-145` converts network errors to React Router `data(..., 502)`.
|
||||
- `app/server/headscale/api/index.ts:183-203` converts every Headscale `>=400` response to outer status 502 while preserving the real status only inside the body.
|
||||
- Many callers inspect raw strings/statuses manually, e.g. `app/routes/auth/login/action.ts:78-93` and `app/routes/acls/acl-action.ts:39-107`.
|
||||
|
||||
**Why it matters:** UI and route logic must understand wrapper internals. HTTP semantics are obscured, and handling becomes string-fragile.
|
||||
|
||||
**Suggested direction:** preserve upstream status classes where safe, expose typed domain errors, and make expected Headscale quirks explicit in one adapter layer.
|
||||
|
||||
### 15. OpenAPI polling interval has no disposal and returned interface exposes stale values
|
||||
|
||||
**Severity:** Medium
|
||||
**Area:** Server lifecycle / API versioning
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/server/headscale/api/index.ts:241-250` starts a `setInterval` inside `createHeadscaleInterface`.
|
||||
- `app/server/headscale/api/index.ts:252-272` returns `openapiHashes` and `apiVersion` as values captured at return time, while `clientHelpers.isAtleast` reads the mutable closure.
|
||||
- No `dispose()` exists on the interface.
|
||||
|
||||
**Why it matters:** lifecycle leaks in dev/reload scenarios, and consumers reading `context.hsApi.apiVersion` can see stale data while helpers see updated data.
|
||||
|
||||
**Suggested direction:** add lifecycle, expose getters for mutable version state, or only detect once at startup.
|
||||
|
||||
### 16. Config patching is not safely serialized
|
||||
|
||||
**Severity:** Medium/High
|
||||
**Area:** Config mutation / concurrency
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/server/headscale/config-loader.ts:63-127` mutates the YAML document before acquiring `writeLock`.
|
||||
- `app/server/headscale/config-loader.ts:120-127` sets `writeLock = true` without `try/finally`; a failed write can leave the lock stuck.
|
||||
- `app/server/headscale/config-dns.ts` has a similar write-lock pattern (see `writeLock` usage).
|
||||
|
||||
**Why it matters:** concurrent admin actions can interleave mutations, lose updates, or deadlock future writes after an exception.
|
||||
|
||||
**Suggested direction:** replace the boolean lock/spin loop with a promise queue or mutex; acquire before document mutation; release in `finally`; write atomically via temp file + rename.
|
||||
|
||||
### 17. DNS/config actions mutate shared config arrays and lack input validation
|
||||
|
||||
**Severity:** Medium
|
||||
**Area:** Config mutation / validation
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/routes/dns/dns-actions.ts:103-118` pushes nameservers into arrays read from `context.hs.c`.
|
||||
- `app/routes/dns/dns-actions.ts:147-164` pushes search domains into arrays read from `context.hs.c`.
|
||||
- `app/routes/dns/dns-actions.ts:189-209` writes DNS record type/value from form data with minimal validation.
|
||||
|
||||
**Why it matters:** in-memory config can be mutated before persistence succeeds, and invalid values can be written directly to Headscale config.
|
||||
|
||||
**Suggested direction:** clone before modification, validate domain/IP/record types server-side, and return actionable typed errors.
|
||||
|
||||
### 18. Restrictions config actions fire integration restarts without awaiting them
|
||||
|
||||
**Severity:** Medium
|
||||
**Area:** Config mutation / operational consistency
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/routes/settings/restrictions/actions.ts:51`, `:79`, `:100`, `:129`, `:150`, `:179` call `context.integration?.onConfigChange(api)` without `await`.
|
||||
- `app/routes/dns/dns-actions.ts` generally awaits the same hook.
|
||||
|
||||
**Why it matters:** the response can report success while restart/reload fails in the background, and unhandled rejections can be lost.
|
||||
|
||||
**Suggested direction:** await the hook consistently or queue/retry restarts through an explicit background job with surfaced status.
|
||||
|
||||
## Server lifecycle and runtime findings
|
||||
|
||||
### 19. Process-lifetime services have no composition-root `stop()`
|
||||
|
||||
**Severity:** High
|
||||
**Area:** Server lifecycle
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/server/context.ts:21-101` constructs DB, Headscale API, live store, auth service, OIDC, optional agent manager, and integration.
|
||||
- `app/server/app.ts:38-40` starts auth pruning.
|
||||
- `app/server/hp-agent.ts:319-359` starts an interval and child process with `dispose()`.
|
||||
- `app/server/headscale/live-store.ts:182-191` has `dispose()`.
|
||||
- `runtime/http.ts:192-200` handles SIGINT/SIGTERM by closing the HTTP server and exiting, but has no app context cleanup.
|
||||
- `runtime/vite-plugin.ts:44-50` uses `ssrLoadModule` in dev; HMR can recreate modules/services without calling old disposers.
|
||||
|
||||
**Why it matters:** intervals, child processes, SSE listeners, and Undici agents can leak in dev and are force-killed in production instead of being drained.
|
||||
|
||||
**Suggested direction:** return an app runtime with `stop()` from the composition root; call `auth.stop()`, `hsLive.dispose()`, `agents.dispose()`, `hsApi.undiciAgent.close()`, and integration cleanup from production shutdown and Vite HMR dispose hooks.
|
||||
|
||||
### 20. Architecture docs describe patterns the code no longer fully follows
|
||||
|
||||
**Severity:** Medium
|
||||
**Area:** Documentation / maintainability
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `docs/development/architecture.md:15-20` says all services are closure factories, no classes/globals.
|
||||
- `app/server/headscale/config-loader.ts:21-213` uses a mutable class for Headscale config.
|
||||
- `docs/development/architecture.md:162-218` describes `server/index.ts`, `AppRuntime`, and `context.runtime`, while current code uses `app/server/app.ts`, `createAppContext`, and direct `context.<service>`.
|
||||
|
||||
**Why it matters:** docs are important to this project. Stale architecture guidance makes future changes less consistent and harder for agents/contributors to follow.
|
||||
|
||||
**Suggested direction:** update the architecture docs to the current composition root and explicitly document exceptions such as config-file wrappers.
|
||||
|
||||
## Go agent and WebSSH findings
|
||||
|
||||
### 21. WebSSH WASM lacks a top-level disposal model and leaks JS functions
|
||||
|
||||
**Severity:** High
|
||||
**Area:** WebSSH lifecycle / browser resources
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `app/routes/ssh/page.tsx:156-188` creates the WASM/IPN instance and cleanup only sets `cancelled = true`.
|
||||
- `app/routes/ssh/wasm.client.ts:27-29` exposes `openTunnel` only; no `dispose()`.
|
||||
- `cmd/hp_ssh/hp_ssh.go:16-80` creates JS functions but never releases them with `js.Func.Release()`.
|
||||
- `internal/hp_ipn/ipnserver.go:107-133` starts backend/server work but exposes no shutdown API.
|
||||
|
||||
**Why it matters:** repeated SSH sessions can leave in-browser tsnet/backend resources and JS function handles alive until tab refresh.
|
||||
|
||||
**Suggested direction:** add top-level `dispose()` to the JS API, release Go `js.Func` handles, cancel contexts, close sessions/backend/server, and call dispose from React cleanup.
|
||||
|
||||
### 22. WebSSH disables SSH host key verification
|
||||
|
||||
**Severity:** Medium/High
|
||||
**Area:** Security / WebSSH
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `internal/hp_ipn/ssh.go:59-63` returns `nil` from `HostKeyCallback`.
|
||||
|
||||
**Why it matters:** this accepts any host key and allows MITM within the network path. Tailscale identity reduces exposure, but SSH host identity is still bypassed.
|
||||
|
||||
**Suggested direction:** document the tradeoff clearly at minimum. Prefer known_hosts-style pinning, Tailscale SSH identity integration, or an explicit trust-on-first-use flow.
|
||||
|
||||
### 23. Go agent can panic on peers without Tailscale IPs
|
||||
|
||||
**Severity:** Medium
|
||||
**Area:** Go agent robustness
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `internal/tsnet/peers.go:61` indexes `peer.TailscaleIPs[0]`.
|
||||
- `internal/tsnet/peers.go:119` also indexes `peer.TailscaleIPs[0]` before checking `len(ip) == 0`.
|
||||
|
||||
**Why it matters:** transient or malformed peer state can crash host-info collection.
|
||||
|
||||
**Suggested direction:** check `len(peer.TailscaleIPs) > 0` before indexing in both paths.
|
||||
|
||||
### 24. Go agent preflight uses `http.Get` without timeout and does not close response bodies
|
||||
|
||||
**Severity:** Medium
|
||||
**Area:** Go agent robustness
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `internal/config/preflight.go:40-56` calls `http.Get(testURL)` directly.
|
||||
- `internal/config/preflight.go:47-54` never closes `resp.Body`.
|
||||
|
||||
**Why it matters:** startup can hang indefinitely on network issues, and response bodies leak.
|
||||
|
||||
**Suggested direction:** use `http.Client{Timeout: ...}` and `defer resp.Body.Close()`.
|
||||
|
||||
### 25. Go library-like code exits the process and logging is hand-rolled
|
||||
|
||||
**Severity:** Medium
|
||||
**Area:** Go maintainability
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `internal/tsnet/server.go:22-72` uses `log.Fatal` inside `NewAgent`/`Connect` rather than returning errors.
|
||||
- `internal/util/logger.go:25-29` defines `encoder` and `pool`, but `internal/util/logger.go:59-66` only writes plain stderr lines and exits on fatal.
|
||||
|
||||
**Why it matters:** callers cannot recover or return structured errors, and the logger has unused complexity while still lacking structured output.
|
||||
|
||||
**Suggested direction:** return errors from `NewAgent`/`Connect`, let `cmd/hp_agent` decide process exit, and simplify or replace the logger.
|
||||
|
||||
## Build, CI, and tooling findings
|
||||
|
||||
### 26. CI does not explicitly run typecheck or lint
|
||||
|
||||
**Severity:** Medium
|
||||
**Area:** Tooling / quality gates
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `package.json` has `typecheck`, `lint`, and `format` scripts.
|
||||
- `.github/workflows/build.yaml:32-37` runs `./build.sh --skip-pnpm-prune`, unit tests, and integration tests.
|
||||
- `build.sh:157-173` runs `pnpm run build`, not `pnpm run typecheck` or `pnpm run lint`.
|
||||
|
||||
**Why it matters:** Vite/React Router builds transpile TypeScript but are not a substitute for `tsgo` typechecking. Lint-only issues can land despite local scripts existing.
|
||||
|
||||
**Suggested direction:** add `pnpm run typecheck` and `pnpm run lint` to CI. Consider `pnpm run format --check` if supported by oxfmt.
|
||||
|
||||
### 27. Build script leaves temporary `vendor/` behind if WASM build fails
|
||||
|
||||
**Severity:** Low/Medium
|
||||
**Area:** Build hygiene
|
||||
|
||||
**Evidence:**
|
||||
|
||||
- `build.sh:143-154` runs `go mod vendor`, applies a patch, builds, then removes `vendor` only after success.
|
||||
|
||||
**Why it matters:** failed local builds can leave a large generated directory in the worktree, which is easy to accidentally inspect or commit around.
|
||||
|
||||
**Suggested direction:** add a trap around the vendoring step to remove `vendor` on failure.
|
||||
|
||||
## Fate/SPA/Vite+ migration assessment
|
||||
|
||||
### Updated direction: SPA first, not Void first
|
||||
|
||||
After comparing the shape of Headplane with Void's current platform/runtime direction, the better target is **not** a Void app. Headplane is a self-hosted local control-plane UI that needs a predictable Node process, local filesystem/config access, SQLite, child process/agent management, long-lived SSE, and a packaging story that can eventually compile into a Node static SEA.
|
||||
|
||||
The preferred target is now a one-way SPA cutover:
|
||||
|
||||
```text
|
||||
Hono Node server + Vite SPA + TanStack Router + raw Fate
|
||||
```
|
||||
|
||||
The important architectural choice is that **Fate becomes the data framework directly** while the app shell stays thin. The shell should own static assets, cookies/session plumbing, routing, and lifecycle. Fate should own reads, mutations/actions, normalized cache updates, and live object/list subscriptions. Do not build a Headplane-specific abstraction layer over Fate's live bus, view resolution, actions, or native HTTP handlers unless a concrete repeated problem appears after using the raw APIs.
|
||||
|
||||
Void may still be a useful reference implementation for Fate integration, but it should not drive Headplane's runtime architecture unless it later proves a first-class self-hosted Node mode that fits Headplane's install model.
|
||||
|
||||
### Current Fate fit
|
||||
|
||||
Fate is directly aimed at the pain Headplane is showing: declarative views, normalized cache, data masking, Async React, optimistic actions/mutations, and live views over SSE. Fate 1.0 says it now includes production-ready live views, Drizzle support, garbage collection, and native HTTP transport. The docs also still contain an alpha warning in the getting-started page, so I would treat the ecosystem as promising but still worth piloting behind a branch.
|
||||
|
||||
Headplane is already close on prerequisites:
|
||||
|
||||
- React is `19.2.5` and Fate requires React 19.2+.
|
||||
- The app already uses Vite 8-era tooling, Vitest, Oxlint, Oxfmt, tsgo, and pnpm.
|
||||
- Headplane already has Drizzle, but only for Headplane-local data (`users`, sessions, host info), while core tailnet data comes from the remote Headscale API.
|
||||
|
||||
### What Fate would improve
|
||||
|
||||
- Replace `LiveDataProvider` + `useRevalidator()` with object/list-level subscriptions (`useLiveView` / `useLiveListView`).
|
||||
- Normalize `nodes`, `users`, pre-auth keys, and agent host info instead of passing large loader payloads around.
|
||||
- Let components declare data needs near rendering rather than building large page-level loader DTOs.
|
||||
- Make mutations return selected updated data and update dependent views without manual `context.hsLive.refresh(...)` calls.
|
||||
|
||||
### What Fate will not fix
|
||||
|
||||
- API-key-in-cookie session design.
|
||||
- Self-linking authorization.
|
||||
- Missing route/action capability checks.
|
||||
- Headscale config-file write races.
|
||||
- WebSSH/WASM lifecycle and SSH host-key verification.
|
||||
- Go agent robustness issues.
|
||||
|
||||
These should be fixed before or alongside any framework migration.
|
||||
|
||||
### Headplane-specific adoption challenges
|
||||
|
||||
- **Remote API source:** Fate's Drizzle adapter helps for local DB rows, but `Machine`, `User`, `PreAuthKey`, ACL policy, and DNS/config data mostly come from Headscale REST/config files. A Headscale Fate source/adapter or custom native HTTP query layer would be needed.
|
||||
- **Authorization:** Fate views must be scoped by the authenticated principal. Do not reproduce the current process-global cache or mutable API client.
|
||||
- **Live events:** Headscale does not appear to push the exact object-level events Headplane needs, so Headplane may still need polling or mutation-triggered `live.update(...)` calls. The win is to send object/list updates to subscribed views, not to revalidate whole routes.
|
||||
- **Server framework:** the desired end state is no longer another heavyweight metaframework. Hono is a good fit for the server shell because it uses the Fetch `Request`/`Response` model Fate already targets, while still running as a normal self-hosted Node process.
|
||||
|
||||
### Target runtime shape
|
||||
|
||||
```text
|
||||
╭────────────────────────────────────────────╮
|
||||
│ Node static SEA / Docker image │
|
||||
│ - bundled server JS │
|
||||
│ - embedded or adjacent Vite client assets │
|
||||
╰───────────────────┬────────────────────────╯
|
||||
│
|
||||
▼
|
||||
╭────────────────────────────────────────────╮
|
||||
│ Hono Node server │
|
||||
│ - process lifecycle start/stop │
|
||||
│ - static asset + SPA fallback serving │
|
||||
│ - cookie/session middleware │
|
||||
│ - /fate and /fate/live │
|
||||
│ - /api, /events during transition │
|
||||
╰───────────────────┬────────────────────────╯
|
||||
│
|
||||
▼
|
||||
╭────────────────────────────────────────────╮
|
||||
│ Framework-neutral Headplane server core │
|
||||
│ - auth/session/authorization │
|
||||
│ - Headscale API/config adapters │
|
||||
│ - agent manager │
|
||||
│ - Fate live bus used directly │
|
||||
╰───────────────────┬────────────────────────╯
|
||||
│
|
||||
▼
|
||||
╭────────────────────────────────────────────╮
|
||||
│ Vite SPA │
|
||||
│ - TanStack Router for navigation/search │
|
||||
│ - raw Fate for all app data and mutations │
|
||||
│ - no route loaders/actions/fetchers │
|
||||
╰────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
### Node SEA packaging implications
|
||||
|
||||
The SPA direction is a better fit for a Node static SEA than SSR framework mode because the runtime can become one server entry plus a finite static asset set. The server should be structured so that production can first serve assets from `build/client`, then later swap that out for an embedded asset manifest without changing application routing.
|
||||
|
||||
Practical constraints for the SEA target:
|
||||
|
||||
- keep one explicit production server entry instead of framework-generated adapter code;
|
||||
- avoid dynamic runtime imports for route modules in production;
|
||||
- make client assets addressable by a generated manifest rather than filesystem discovery;
|
||||
- keep mutable data outside the SEA (`data_path`, Headscale config paths, SQLite, logs);
|
||||
- preserve direct file serving for large WASM artifacts until we decide whether they should be embedded or adjacent assets.
|
||||
|
||||
### Raw Fate rule
|
||||
|
||||
Use Fate's public APIs directly:
|
||||
|
||||
- `createFateServer(...)` and the native HTTP handler for `/fate`;
|
||||
- Fate's live bus directly for `live.update(...)`, `live.delete(...)`, and connection/list invalidations;
|
||||
- Fate's context callback directly for request auth and Headscale API access;
|
||||
- `FateClient`, `createClient` / `createHTTPTransport` while bootstrapping, then generated `createFateClient(...)` once the Fate Vite plugin has a real server module;
|
||||
- `useRequest`, `useView`, `useLiveView`, `useLiveListView`, and Fate actions directly in React.
|
||||
|
||||
Do not add project-level wrappers such as `HeadplaneLivePublisher`, `HeadplaneDataContext`, or a custom Fate transport abstraction at the beginning. If raw Fate usage becomes repetitive, extract only the smallest local helper at the repetition site.
|
||||
|
||||
### Current scaffold on `tale/fate-spa`
|
||||
|
||||
- Removed the earlier `HeadplaneRuntime`, `HeadplaneDataContext`, and `HeadplaneLivePublisher` scaffolding.
|
||||
- Added direct dependencies: `react-fate`, `@nkzw/fate`, `@tanstack/react-router`, `@tanstack/router-plugin`, and `@vitejs/plugin-react`.
|
||||
- Added a plain Vite SPA `index.html` and `app/spa` entry with TanStack Router and the generated raw Fate client from `react-fate/client`.
|
||||
- Moved the old React Router Vite config to `vite-old.config.ts` and replaced `vite.config.ts` with a clean SPA config.
|
||||
- Added Hono and `@hono/node-server`, plus a minimal Hono server shell in `app/server/hono-app.ts`, `app/server/hono-dev.ts`, and `app/server/hono-main.ts`.
|
||||
- Added `app/server/fate.ts`, which exports a raw Fate server and live bus. Hono mounts Fate's `createHonoFateHandler(fate)` at `/admin/fate` and `/admin/fate/*`, passing the existing app context through Hono variables.
|
||||
- Wired the official `react-fate/vite` plugin to `app/server/fate.ts`, ignored generated `.fate/` output, and made `pnpm run typecheck` run `fate generate` before `tsgo` so generated `react-fate/client` typings exist from a clean checkout.
|
||||
- Added the first real Fate read roots: `machines` and `users`. They use Fate `dataView(...)`, `list(...)`, and source executors directly, call the existing principal-scoped Headscale runtime API client, and enforce existing `read_machines` / `read_users` capabilities.
|
||||
- Added minimal SPA `/machines` and `/users` routes that fetch with raw `useRequest(...)`, render records with `useLiveView(...)`, and subscribe to root list connections with `useLiveListView(...)`. These routes intentionally do not port filters, actions, or optimistic updates yet.
|
||||
- Bridged existing `hsLive` resource changes directly to Fate connection invalidations: `nodes` invalidates the `machines` root connection and `users` invalidates the `users` root connection. This is a temporary seam so converted routes can exercise Fate live primitives before the old live store is deleted.
|
||||
- Added the first raw Fate mutation, `machine.rename`. It reuses existing `canManageNode` authorization, calls the Headscale API, refreshes the transitional `hsLive` nodes resource, emits direct Fate entity/list live events, and returns the client-selected `Machine` view.
|
||||
- `pnpm dev` now runs the Hono/Vite middleware shell with local `.data` storage for the example config; `pnpm build` now runs `vite build` for the SPA.
|
||||
- Fate's Drizzle peer currently warns against the repo's Drizzle `1.0.0-beta.21`; avoid Fate's Drizzle adapter until that compatibility is resolved, and start with a direct Headscale source/resolver instead.
|
||||
|
||||
### Fate context decision
|
||||
|
||||
The current Fate request context should stay pragmatic rather than heavily decomposed:
|
||||
|
||||
- expose `api`, the principal-scoped Headscale runtime client, as the primary data access path for remote Headscale data;
|
||||
- keep `principal` and `request` available for authorization and future audit/session needs;
|
||||
- keep `app` available during the migration so resolvers can reuse the existing auth/config/agent services without inventing a new service layer first;
|
||||
- do not pass an unstructured context into every helper by default once a data domain settles. If a `machines`, `users`, or `authKeys` module becomes large, give that module explicit functions that accept the concrete pieces it uses.
|
||||
|
||||
In other words: full app context is acceptable as migration scaffolding, but the resolver code should prefer the smallest direct dependency (`ctx.api`, `ctx.app.auth`, etc.) and should not become a new `HeadplaneRuntime` abstraction.
|
||||
|
||||
### Recommended migration sequence
|
||||
|
||||
1. **Remove transitional abstractions** and make the branch clearly one-way toward SPA + raw Fate.
|
||||
2. **Install the direct dependencies**: `react-fate`, `@nkzw/fate`, `@tanstack/react-router`, `@tanstack/router-plugin`, and the plain Vite React plugin if the React Router plugin is removed.
|
||||
3. **Replace the build/dev entry shape**:
|
||||
- add a Vite SPA entry and TanStack route tree;
|
||||
- stop generating new React Router route types;
|
||||
- keep the existing Node server entry as the self-hosted process.
|
||||
4. **Mount raw Fate endpoints** in the current Node request path:
|
||||
- `/fate` for native RPC;
|
||||
- `/fate/live` for SSE and subscription control;
|
||||
- request context resolves auth using the existing auth service and calls `context.hsApi.getRuntimeClient(...)` directly.
|
||||
5. **Convert the machines page first** using raw Fate views and live list/view hooks.
|
||||
6. **Add live updates to the converted lists** by publishing Fate list/entity invalidations from the existing polling/mutation seams. Keep this raw Fate live bus usage, not a Headplane live wrapper.
|
||||
7. **Port one mutation at a time**, starting with a low-risk machine mutation such as rename. The mutation should call the existing Headscale API, return the selected entity, and emit the relevant Fate live event.
|
||||
8. **Delete the old React Router loader/action/SSE path for converted data**, rather than running duplicate data models side-by-side.
|
||||
9. **Fix critical auth/session issues early**: self-linking, raw API-key cookie, agent-route authz.
|
||||
10. **Keep runtime changes minimal** until the SPA actually needs them: Hono routes, static SPA fallback, Fate routes, and later SEA asset serving.
|
||||
|
||||
### Vite+ / VoidZero tooling assessment
|
||||
|
||||
Vite+ is a unified CLI (`vp`) for Vite, Vitest, Oxlint, Oxfmt, Rolldown, tsdown, type checking, package-manager/runtime management, and task caching. Headplane already uses most of these tools separately, so Vite+ would mostly consolidate tooling and improve task ergonomics; it will not solve the data-layer problems by itself.
|
||||
|
||||
Recommended Vite+ approach:
|
||||
|
||||
- Try `vp migrate` in a separate branch only after adding CI typecheck/lint gates.
|
||||
- Expect manual work around the custom React Router SSR entry and `runtime/vite-plugin.ts`.
|
||||
- Do not combine Vite+ migration with Fate/Void/router migration in the same PR.
|
||||
|
||||
## Suggested immediate backlog
|
||||
|
||||
1. Replace the temporary `hsLive` bridge with direct Fate events from converted mutations and, if needed, a principal-safe polling source.
|
||||
2. Move the machine rename UI out of the throwaway table row controls once the permanent SPA machines page layout exists.
|
||||
3. Port the next machine mutations: expire/delete/tags/routes, one at a time, each returning selected data or deleting/updating the normalized cache explicitly.
|
||||
4. Fix OIDC self-linking authorization.
|
||||
5. Make API-key sessions opaque/server-side and set explicit auth cookie flags.
|
||||
6. Add capability checks to `/settings/agent` loader/action.
|
||||
7. Fix live-data pause cleanup/refcounting for unconverted React Router routes.
|
||||
8. Make `hsLive` use a stable server credential or principal-scoped cache while it still exists.
|
||||
9. Serialize config patches with a real mutex/queue and clone config arrays before editing.
|
||||
10. Add CI `pnpm run typecheck` and `pnpm run lint`.
|
||||
11. Add WebSSH top-level dispose and release Go `js.Func` values.
|
||||
@@ -1,7 +1,9 @@
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.1 AS go-base
|
||||
WORKDIR /run
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends patch && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY go.mod go.sum build.sh ./
|
||||
COPY patches/ ./patches/
|
||||
RUN go mod download
|
||||
|
||||
COPY cmd/ ./cmd/
|
||||
@@ -25,25 +27,24 @@ RUN chmod +x /bin/hp_healthcheck
|
||||
# Folder needs to exist for later stages
|
||||
RUN mkdir -p /var/lib/headplane/agent
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:22.16-slim AS js-base
|
||||
FROM --platform=$BUILDPLATFORM node:24-slim AS js-base
|
||||
WORKDIR /run
|
||||
|
||||
RUN corepack enable
|
||||
COPY patches ./patches
|
||||
COPY package.json pnpm-lock.yaml build.sh ./
|
||||
|
||||
COPY --from=go-base /bin/hp_ssh.wasm /run/app/hp_ssh.wasm
|
||||
COPY --from=go-base /bin/wasm_exec.js /run/app/wasm_exec.js
|
||||
COPY --from=go-base /bin/hp_ssh.wasm /run/public/hp_ssh.wasm
|
||||
COPY --from=go-base /bin/wasm_exec.js /run/public/wasm_exec.js
|
||||
RUN ./build.sh --app --app-install-only
|
||||
|
||||
COPY . .
|
||||
ARG HEADPLANE_VERSION
|
||||
RUN HEADPLANE_VERSION=$HEADPLANE_VERSION ./build.sh --app
|
||||
|
||||
FROM gcr.io/distroless/nodejs22-debian12:latest AS final
|
||||
FROM gcr.io/distroless/nodejs24-debian13:latest AS final
|
||||
COPY --from=js-base /run/build /app/build
|
||||
COPY --from=js-base /run/drizzle /app/drizzle
|
||||
COPY --from=js-base /run/node_modules /app/node_modules
|
||||
|
||||
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
|
||||
COPY --from=go-base /var/lib/headplane /var/lib/headplane
|
||||
@@ -59,12 +60,11 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
WORKDIR /app
|
||||
CMD [ "/app/build/server/index.js" ]
|
||||
|
||||
FROM node:22-alpine AS debug-shell
|
||||
FROM node:24-alpine AS debug-shell
|
||||
RUN apk add --no-cache bash curl
|
||||
|
||||
COPY --from=js-base /run/build /app/build
|
||||
COPY --from=js-base /run/drizzle /app/drizzle
|
||||
COPY --from=js-base /run/node_modules /app/node_modules
|
||||
|
||||
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
|
||||
COPY --from=go-base /var/lib/headplane /var/lib/headplane
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# Headplane
|
||||
|
||||
> A feature-complete web UI for [Headscale](https://headscale.net)
|
||||
|
||||
<picture>
|
||||
@@ -32,14 +33,17 @@ These are some of the features that Headplane offers:
|
||||
- Configurability for Headscale's settings
|
||||
|
||||
## Deployment
|
||||
|
||||
Refer to the [website](https://headplane.net) for detailed installation instructions.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 63 36" height="36" width="63" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M47.0239 10.6522L52.1669 1.64895C52.4571 1.14423 52.2816 0.503129 51.7889 0.209825C51.2962 -0.0834073 50.655 0.0871143 50.3716 0.59178L45.161 9.71096C41.1856 7.87622 36.7108 6.8531 31.8985 6.8531C27.0861 6.8531 22.6113 7.87622 18.6359 9.71096L13.4253 0.59178C13.1351 0.0870609 12.5007 -0.0834429 12.0012 0.209825C11.5018 0.50304 11.333 1.14423 11.6232 1.64895L16.7663 10.6522C7.89757 15.5085 1.89061 24.5799 0.898438 35.1997H62.8984C61.9063 24.5799 55.8993 15.5085 47.0238 10.6522H47.0239ZM17.664 26.4011C16.2264 26.4011 15.0655 25.2211 15.0655 23.7751C15.0655 22.3223 16.2331 21.1492 17.664 21.1492C19.1016 21.1492 20.2625 22.3292 20.2625 23.7751C20.2695 25.2211 19.1016 26.4011 17.664 26.4011ZM46.1262 26.4011C44.6886 26.4011 43.5277 25.2211 43.5277 23.7751C43.5277 22.3223 44.6953 21.1492 46.1262 21.1492C47.5638 21.1492 48.7247 22.3292 48.7247 23.7751C48.7318 25.2211 47.5638 26.4011 46.1262 26.4011Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 39 46" height="46" width="39" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M21.6443 4.03452C24.6748 0.0855621 28.8877 0.0663452 28.8877 0.0663452C28.8877 0.0663452 29.5143 3.779 26.5038 7.35551C23.2893 11.1745 19.6355 10.5496 19.6355 10.5496C19.6355 10.5496 18.9495 7.54621 21.6443 4.03452V4.03452ZM20.021 13.1503C21.58 13.1503 24.4733 11.0315 28.2395 11.0315C34.7224 11.0315 37.2728 15.5923 37.2728 15.5923C37.2728 15.5923 32.2847 18.1137 32.2847 24.2317C32.2847 31.1335 38.4984 33.5121 38.4984 33.5121C38.4984 33.5121 34.1548 45.5994 28.2878 45.5994C25.5931 45.5994 23.4982 43.8041 20.6589 43.8041C17.7656 43.8041 14.8944 45.6663 13.0241 45.6663C7.66679 45.6665 0.898438 34.2004 0.898438 24.9837C0.898438 15.9156 6.6274 11.1585 12.0009 11.1585C15.4941 11.1585 18.2049 13.1503 20.021 13.1503V13.1503Z" clip-rule="evenodd" fill-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 903 B |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 55 40" height="40" width="55" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M0.613281 0.225403H54.3397V39.0278H0.613281V0.225403ZM8.36288 8.47665C8.75143 8.08811 9.38138 8.08811 9.76993 8.47665L14.7446 13.4513C15.1331 13.8399 15.1331 14.4698 14.7446 14.8584L9.76993 19.833C9.38138 20.2216 8.75143 20.2216 8.36288 19.833C7.97434 19.4445 7.97434 18.8145 8.36288 18.426L12.634 14.1548L8.36288 9.8837C7.97434 9.49515 7.97434 8.8652 8.36288 8.47665ZM16.534 22.1143C15.9845 22.1143 15.5391 22.5597 15.5391 23.1092C15.5391 23.6587 15.9845 24.1041 16.534 24.1041H25.4884C26.0379 24.1041 26.4833 23.6587 26.4833 23.1092C26.4833 22.5597 26.0379 22.1143 25.4884 22.1143H16.534Z" clip-rule="evenodd" fill-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 767 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="44" height="44" viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.7588 27.4022C22.0336 22.6094 24.079 14.802 28.5236 6H6.00006C3.79092 6 2.00006 7.79086 2.00006 10V42C2.00006 44.2092 3.79092 46 6.00006 46H28.735C28.3672 43.6252 28.2408 41.2548 28.2502 39.0346C27.353 39.1516 26.4424 39.2148 25.5294 39.2148C22.3036 39.2148 19.106 38.427 16.4322 37.2544C13.7899 36.0954 11.5029 34.4902 10.2351 32.7296C9.76718 32.0798 9.91469 31.1736 10.5647 30.7056C11.2145 30.2378 12.1207 30.3852 12.5885 31.0352C13.4385 32.2156 15.2105 33.5518 17.5972 34.5986C19.9528 35.6318 22.7552 36.3148 25.5294 36.3148C26.4688 36.3148 27.4114 36.2364 28.339 36.0926C28.4724 33.65 28.7306 31.5148 28.9364 29.9336H24.1764C22.8458 29.9336 21.6766 28.8396 21.7588 27.4022ZM31.156 38.4622C32.3768 38.1404 33.5446 37.729 34.6266 37.2544C37.2692 36.0954 39.5562 34.4902 40.8238 32.7296C41.2918 32.0798 41.1442 31.1736 40.4944 30.7056C39.8444 30.2378 38.9382 30.3852 38.4704 31.0352C37.6204 32.2156 35.8486 33.5518 33.4618 34.5986C32.7714 34.9014 32.0426 35.174 31.2876 35.4082C31.4398 33.1888 31.688 31.2466 31.8794 29.7964C32.073 28.328 30.9296 27.0336 29.4554 27.0336H24.692C25.0766 22.4392 27.179 14.7405 31.7868 6H46C48.2092 6 50 7.79086 50 10V42C50 44.2092 48.2092 46 46 46H31.6716C31.2398 43.446 31.1176 40.8638 31.156 38.4622ZM15.0001 14.55C15.8009 14.55 16.4501 15.1992 16.4501 16V20.7059C16.4501 21.5067 15.8009 22.1558 15.0001 22.1558C14.1993 22.1558 13.5501 21.5067 13.5501 20.7059V16C13.5501 15.1992 14.1993 14.55 15.0001 14.55ZM38.45 16C38.45 15.1992 37.8008 14.55 37 14.55C36.1992 14.55 35.55 15.1992 35.55 16V20.7059C35.55 21.5067 36.1992 22.1558 37 22.1558C37.8008 22.1558 38.45 21.5067 38.45 20.7059V16Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 46 46" height="46" width="46" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M0.296875 6.56799L18.9443 4.0173V21.9762H0.296875V6.56799ZM21.1903 3.66333L45.8969 0.0663452V21.8252H21.1903V3.66333ZM0.296875 23.8762H18.9443V41.8872L0.296875 39.2844V23.8762ZM21.1903 24.1157H45.8969V45.6663L21.1903 42.1787" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 361 B |
@@ -1,86 +0,0 @@
|
||||
import { Check, Copy, Info } from 'lucide-react';
|
||||
import cn from '~/utils/cn';
|
||||
import toast from '~/utils/toast';
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
export interface AttributeProps {
|
||||
name: string;
|
||||
value: string;
|
||||
tooltip?: string;
|
||||
isCopyable?: boolean;
|
||||
}
|
||||
|
||||
export default function Attribute({
|
||||
name,
|
||||
value,
|
||||
tooltip,
|
||||
isCopyable,
|
||||
}: AttributeProps) {
|
||||
return (
|
||||
<dl className="flex gap-1 items-center text-sm">
|
||||
<dt
|
||||
className={cn(
|
||||
'w-1/3 sm:w-1/4 lg:w-1/3 shrink-0 min-w-0',
|
||||
'text-headplane-500 dark:text-headplane-400',
|
||||
tooltip ? 'flex items-center gap-1' : undefined,
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
{tooltip ? (
|
||||
<Tooltip>
|
||||
<Info className="size-4" />
|
||||
<Tooltip.Body>{tooltip}</Tooltip.Body>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
'min-w-0 px-1.5 py-1 rounded-lg border border-transparent',
|
||||
...(isCopyable
|
||||
? [
|
||||
'cursor-pointer hover:shadow-xs',
|
||||
'hover:bg-headplane-50 dark:hover:bg-headplane-800',
|
||||
'hover:border-headplane-100 dark:hover:border-headplane-700',
|
||||
]
|
||||
: []),
|
||||
)}
|
||||
>
|
||||
{isCopyable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 relative min-w-0 w-full"
|
||||
onClick={async (event) => {
|
||||
const svgs = event.currentTarget.querySelectorAll('svg');
|
||||
for (const svg of svgs) {
|
||||
svg.toggleAttribute('data-copied', true);
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast(`Copied ${name} to clipboard`);
|
||||
|
||||
setTimeout(() => {
|
||||
for (const svg of svgs) {
|
||||
svg.toggleAttribute('data-copied', false);
|
||||
}
|
||||
}, 1000);
|
||||
}}
|
||||
>
|
||||
<div suppressHydrationWarning className="truncate">
|
||||
{value}
|
||||
</div>
|
||||
{isCopyable ? (
|
||||
<div>
|
||||
<Check className="size-4 hidden data-copied:block" />
|
||||
<Copy className="size-4 block data-copied:hidden" />
|
||||
</div>
|
||||
) : undefined}
|
||||
</button>
|
||||
) : (
|
||||
<div className="relative min-w-0 truncate" suppressHydrationWarning>
|
||||
{value}
|
||||
</div>
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { type AriaButtonOptions, useButton } from 'react-aria';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
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);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
{...buttonProps}
|
||||
className={cn(
|
||||
'w-fit text-sm rounded-xl px-3 py-2',
|
||||
'focus:outline-hidden focus:ring-3',
|
||||
props.isDisabled && 'opacity-60 cursor-not-allowed',
|
||||
...(variant === 'heavy'
|
||||
? [
|
||||
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
|
||||
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
|
||||
'text-headplane-200 dark:text-headplane-800',
|
||||
]
|
||||
: variant === 'danger'
|
||||
? ['bg-red-500 text-white font-semibold', 'hover:bg-red-500/90']
|
||||
: [
|
||||
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
||||
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
||||
]),
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from 'react';
|
||||
import Text from '~/components/Text';
|
||||
import Title from '~/components/Title';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
interface Props extends React.HTMLProps<HTMLDivElement> {
|
||||
variant?: 'raised' | 'flat';
|
||||
}
|
||||
|
||||
function Card({ variant = 'raised', ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'w-full max-w-md rounded-3xl p-5',
|
||||
variant === 'flat'
|
||||
? 'bg-transparent shadow-none'
|
||||
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-xs',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Card, { Title, Text });
|
||||
@@ -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(
|
||||
'h-5 text-xs py-0.5 px-1 rounded-md text-nowrap',
|
||||
'text-headplane-700 dark:text-headplane-100',
|
||||
'bg-headplane-100 dark:bg-headplane-700',
|
||||
'inline-flex items-center gap-x-1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{leftIcon}
|
||||
{text}
|
||||
{rightIcon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { HTMLProps } from 'react';
|
||||
import cn from '~/utils/cn';
|
||||
import toast from '~/utils/toast';
|
||||
|
||||
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
|
||||
isCopyable?: boolean;
|
||||
children: string | string[] | number;
|
||||
}
|
||||
|
||||
export default function Code({ isCopyable, children, className }: CodeProps) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
'bg-headplane-100 dark:bg-headplane-800 px-1 py-0.5 font-mono',
|
||||
'rounded-lg focus-within:outline-hidden focus-within:ring-2',
|
||||
isCopyable && 'relative pr-7',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{isCopyable && (
|
||||
<button
|
||||
className="bottom-0 right-0 absolute"
|
||||
onClick={async (event) => {
|
||||
const text = Array.isArray(children) ? children.join('') : children;
|
||||
|
||||
const svgs = event.currentTarget.querySelectorAll('svg');
|
||||
for (const svg of svgs) {
|
||||
svg.toggleAttribute('data-copied', true);
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast('Copied to clipboard');
|
||||
|
||||
setTimeout(() => {
|
||||
for (const svg of svgs) {
|
||||
svg.toggleAttribute('data-copied', false);
|
||||
}
|
||||
}, 1000);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import React, { cloneElement, useEffect, useRef } 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';
|
||||
|
||||
export interface DialogProps extends OverlayTriggerProps {
|
||||
children:
|
||||
| [
|
||||
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
||||
React.ReactElement<DialogPanelProps>,
|
||||
]
|
||||
| React.ReactElement<DialogPanelProps>;
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
className={cn(
|
||||
'outline-hidden rounded-3xl w-full max-w-lg',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
)}
|
||||
method={method ?? 'POST'}
|
||||
onSubmit={(event) => {
|
||||
if (onSubmit) {
|
||||
onSubmit(event);
|
||||
}
|
||||
|
||||
close?.();
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<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
|
||||
isDisabled={isDisabled}
|
||||
type="submit"
|
||||
variant={variant === 'destructive' ? 'danger' : 'heavy'}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
interface DModalProps extends AriaModalOverlayProps {
|
||||
children: React.ReactNode;
|
||||
state: OverlayTriggerState;
|
||||
}
|
||||
|
||||
function DModal(props: DModalProps) {
|
||||
const { children, state } = props;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { modalProps, underlayProps } = useModalOverlay(props, state, ref);
|
||||
|
||||
if (!state.isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Overlay>
|
||||
<div
|
||||
{...underlayProps}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'fixed inset-0 h-screen w-screen z-20',
|
||||
'flex items-center justify-center',
|
||||
'bg-headplane-900/15 dark:bg-headplane-900/30',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'entering:fade-in entering:duration-100 entering:ease-out',
|
||||
'exiting:fade-out exiting:duration-50 exiting:ease-in',
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
{...modalProps}
|
||||
className={cn(
|
||||
'fixed inset-0 h-screen w-screen z-20',
|
||||
'flex items-center justify-center',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Dialog, {
|
||||
Button,
|
||||
IconButton,
|
||||
Panel,
|
||||
Title,
|
||||
Text,
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { CircleX } from 'lucide-react';
|
||||
import Link from '~/components/Link';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
interface FooterProps {
|
||||
url: string;
|
||||
debug: boolean;
|
||||
healthy: boolean;
|
||||
}
|
||||
|
||||
export default function Footer({ url, debug, healthy }: FooterProps) {
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
'fixed w-full bottom-0 left-0 z-40 h-12',
|
||||
'flex items-center justify-center',
|
||||
'bg-headplane-50 dark:bg-headplane-950',
|
||||
'dark:border-t dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-rows-1 items-center container mx-auto',
|
||||
!healthy && 'md:grid-cols-[1fr_auto] grid-cols-1',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('text-xs leading-none', !healthy && 'hidden md:block')}
|
||||
>
|
||||
<p>
|
||||
Headplane is free. Please consider{' '}
|
||||
<Link
|
||||
to="https://github.com/sponsors/tale"
|
||||
name="Aarnav's GitHub Sponsors"
|
||||
>
|
||||
donating
|
||||
</Link>{' '}
|
||||
to support development.{' '}
|
||||
</p>
|
||||
<p className="opacity-75">
|
||||
Version: {__VERSION__}
|
||||
{' — '}
|
||||
Connecting to{' '}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0} // Allows keyboard focus
|
||||
className={cn(
|
||||
'blur-sm hover:blur-none focus:blur-none transition',
|
||||
'focus:outline-hidden focus:ring-2 rounded-xs',
|
||||
)}
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
{debug && ' (Debug mode enabled)'}
|
||||
</p>
|
||||
</div>
|
||||
{!healthy ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-1.5 items-center p-2 rounded-xl text-sm',
|
||||
'bg-red-500 text-white font-semibold',
|
||||
)}
|
||||
>
|
||||
<CircleX size={16} strokeWidth={3} />
|
||||
<p className="text-nowrap">Headscale is unreachable</p>
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import {
|
||||
CircleUser,
|
||||
Globe2,
|
||||
Lock,
|
||||
Server,
|
||||
Settings,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { NavLink, useSubmit } from 'react-router';
|
||||
import Logo from '~/components/Logo';
|
||||
import Menu from '~/components/Menu';
|
||||
import { AuthSession } from '~/server/web/sessions';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
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
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5',
|
||||
'after:absolute after:bottom-0 after:left-3 after:right-3',
|
||||
'after:h-0.5 after:bg-headplane-900 dark:after:bg-headplane-200',
|
||||
'hover:bg-headplane-200 dark:hover:bg-headplane-900',
|
||||
'focus:outline-hidden focus:ring-3',
|
||||
isActive ? 'after:visible' : 'after:invisible',
|
||||
)
|
||||
}
|
||||
prefetch="intent"
|
||||
to={to}
|
||||
>
|
||||
{icon} {name}
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Link({ href, text }: LinkProps) {
|
||||
return (
|
||||
<a
|
||||
className={cn(
|
||||
'hidden sm:block hover:underline text-sm',
|
||||
'focus:outline-hidden focus:ring-3 rounded-md',
|
||||
)}
|
||||
href={href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Header(data: 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">
|
||||
<Logo />
|
||||
<h1 className="text-2xl font-semibold">headplane</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Link href="https://tailscale.com/download" text="Download" />
|
||||
<Link href="https://github.com/tale/headplane" text="GitHub" />
|
||||
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
|
||||
{data.user ? (
|
||||
<Menu>
|
||||
<Menu.IconButton
|
||||
className={cn(data.user.picture ? 'p-0' : '')}
|
||||
label="User"
|
||||
>
|
||||
{data.user.picture ? (
|
||||
<img
|
||||
alt={data.user.name}
|
||||
className="w-8 h-8 rounded-full"
|
||||
src={data.user.picture}
|
||||
/>
|
||||
) : (
|
||||
<CircleUser />
|
||||
)}
|
||||
</Menu.IconButton>
|
||||
<Menu.Panel
|
||||
disabledKeys={['profile']}
|
||||
onAction={(key) => {
|
||||
if (key === 'logout') {
|
||||
submit(
|
||||
{},
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/logout',
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Menu.Section>
|
||||
<Menu.Item key="profile" textValue="Profile">
|
||||
<div className="text-black dark:text-headplane-50">
|
||||
<p className="font-bold">{data.user.name}</p>
|
||||
<p>{data.user.email}</p>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Item 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
|
||||
icon={<Server className="w-5" />}
|
||||
name="Machines"
|
||||
to="/machines"
|
||||
/>
|
||||
) : undefined}
|
||||
{data.access.users ? (
|
||||
<TabLink
|
||||
icon={<Users className="w-5" />}
|
||||
name="Users"
|
||||
to="/users"
|
||||
/>
|
||||
) : undefined}
|
||||
{data.access.policy ? (
|
||||
<TabLink
|
||||
icon={<Lock className="w-5" />}
|
||||
name="Access Control"
|
||||
to="/acls"
|
||||
/>
|
||||
) : undefined}
|
||||
{data.configAvailable ? (
|
||||
<>
|
||||
{data.access.dns ? (
|
||||
<TabLink
|
||||
icon={<Globe2 className="w-5" />}
|
||||
name="DNS"
|
||||
to="/dns"
|
||||
/>
|
||||
) : undefined}
|
||||
{data.access.settings ? (
|
||||
<TabLink
|
||||
icon={<Settings className="w-5" />}
|
||||
name="Settings"
|
||||
to="/settings"
|
||||
/>
|
||||
) : undefined}
|
||||
</>
|
||||
) : undefined}
|
||||
</nav>
|
||||
) : undefined}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -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-hidden focus:ring-3',
|
||||
props.isDisabled && 'opacity-60 cursor-not-allowed',
|
||||
...(variant === 'heavy'
|
||||
? [
|
||||
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
|
||||
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
|
||||
'text-headplane-200 dark:text-headplane-800',
|
||||
]
|
||||
: [
|
||||
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
||||
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
||||
]),
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Asterisk } from 'lucide-react';
|
||||
import { useRef } from 'react';
|
||||
import { type AriaTextFieldProps, useId, useTextField } from 'react-aria';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
|
||||
label: string;
|
||||
labelHidden?: boolean;
|
||||
isRequired?: boolean;
|
||||
className?: string;
|
||||
isInvalid?: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export default function Input(props: InputProps) {
|
||||
const {
|
||||
label,
|
||||
labelHidden,
|
||||
className,
|
||||
isInvalid: customIsInvalid,
|
||||
errorMessage,
|
||||
} = props;
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const id = useId(props.id);
|
||||
|
||||
const {
|
||||
labelProps,
|
||||
inputProps,
|
||||
descriptionProps,
|
||||
errorMessageProps,
|
||||
isInvalid: ariaIsInvalid,
|
||||
validationErrors,
|
||||
} = useTextField(
|
||||
{
|
||||
...props,
|
||||
label,
|
||||
'aria-label': label,
|
||||
},
|
||||
ref,
|
||||
);
|
||||
|
||||
const isInvalid = customIsInvalid ?? ariaIsInvalid;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<label
|
||||
{...labelProps}
|
||||
className={cn(
|
||||
'text-xs font-medium px-3 mb-0.5',
|
||||
'text-headplane-700 dark:text-headplane-100',
|
||||
labelHidden && 'sr-only',
|
||||
)}
|
||||
htmlFor={id}
|
||||
>
|
||||
{label}
|
||||
{props.isRequired && (
|
||||
<Asterisk className="inline w-3.5 text-red-500 pb-1 ml-0.5" />
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
{...inputProps}
|
||||
className={cn(
|
||||
'rounded-xl px-3 py-2',
|
||||
'focus:outline-hidden focus:ring-3',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
required={props.isRequired}
|
||||
/>
|
||||
{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')}
|
||||
>
|
||||
{errorMessage ?? validationErrors.join(' ')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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-hidden focus:ring-3 rounded-md',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<ExternalLink className="w-3.5" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import cn from '~/utils/cn';
|
||||
import LogoSvg from '../../public/logo-light.svg';
|
||||
|
||||
export interface LogoProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Logo({ className }: LogoProps) {
|
||||
return <img alt="Logo" className={cn(className)} src={LogoSvg} />;
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
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 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';
|
||||
|
||||
interface MenuProps extends MenuTriggerProps {
|
||||
placement?: Placement;
|
||||
isDisabled?: boolean;
|
||||
disabledKeys?: Key[];
|
||||
children: [
|
||||
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
||||
React.ReactElement<MenuPanelProps>,
|
||||
];
|
||||
}
|
||||
|
||||
// TODO: onAction is called twice for some reason?
|
||||
// TODO: isDisabled per-prop
|
||||
function Menu(props: MenuProps) {
|
||||
const { placement = 'bottom', isDisabled, disabledKeys = [] } = props;
|
||||
const state = useMenuTriggerState(props);
|
||||
const ref = useRef<HTMLButtonElement | null>(null);
|
||||
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
|
||||
{},
|
||||
state,
|
||||
ref,
|
||||
);
|
||||
|
||||
// cloneElement is necessary because the button is a union type
|
||||
// of multiple things and we need to join props from our hooks
|
||||
const [button, panel] = props.children;
|
||||
return (
|
||||
<div>
|
||||
{cloneElement(button, {
|
||||
...menuTriggerProps,
|
||||
isDisabled: isDisabled,
|
||||
ref,
|
||||
})}
|
||||
{state.isOpen && (
|
||||
<Popover state={state} triggerRef={ref} placement={placement}>
|
||||
{cloneElement(panel, {
|
||||
...menuProps,
|
||||
autoFocus: state.focusStrategy ?? true,
|
||||
onClose: () => state.close(),
|
||||
disabledKeys,
|
||||
})}
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuPanelProps extends AriaMenuProps<object> {
|
||||
onClose?: () => void;
|
||||
disabledKeys?: Key[];
|
||||
}
|
||||
|
||||
function Panel(props: MenuPanelProps) {
|
||||
const state = useTreeState(props);
|
||||
const ref = useRef(null);
|
||||
|
||||
const { menuProps } = useMenu(props, state, ref);
|
||||
return (
|
||||
<ul
|
||||
{...menuProps}
|
||||
ref={ref}
|
||||
className="pt-1 pb-1 shadow-2xs rounded-md min-w-[200px] focus:outline-hidden"
|
||||
>
|
||||
{[...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}
|
||||
className={cn(
|
||||
'py-2 px-3 mx-1 rounded-lg',
|
||||
'focus:outline-hidden select-none',
|
||||
isFocused && 'bg-headplane-100/50 dark:bg-headplane-800',
|
||||
isDisabled
|
||||
? 'text-headplane-400 dark:text-headplane-600'
|
||||
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800 cursor-pointer',
|
||||
)}
|
||||
>
|
||||
{item.rendered}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Menu, {
|
||||
Button,
|
||||
IconButton,
|
||||
Panel,
|
||||
Section,
|
||||
Item,
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import {
|
||||
CircleAlert,
|
||||
CircleSlash2,
|
||||
LucideProps,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import Card from '~/components/Card';
|
||||
|
||||
export interface NoticeProps {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
variant?: 'default' | 'error' | 'warning';
|
||||
icon?: React.ReactElement<LucideProps>;
|
||||
}
|
||||
|
||||
export default function Notice({
|
||||
children,
|
||||
title,
|
||||
variant,
|
||||
icon,
|
||||
}: NoticeProps) {
|
||||
return (
|
||||
<Card variant="flat" className="max-w-2xl my-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{title ? (
|
||||
<Card.Title className="text-xl mb-0">{title}</Card.Title>
|
||||
) : undefined}
|
||||
{!variant && icon ? icon : iconForVariant(variant)}
|
||||
</div>
|
||||
<Card.Text className="mt-4">{children}</Card.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function iconForVariant(variant?: 'default' | 'error' | 'warning') {
|
||||
switch (variant) {
|
||||
case 'error':
|
||||
return <TriangleAlert className="text-red-500" />;
|
||||
case 'warning':
|
||||
return <CircleAlert className="text-yellow-500" />;
|
||||
default:
|
||||
return <CircleSlash2 />;
|
||||
}
|
||||
}
|
||||
@@ -1,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-hidden focus-within:ring-3',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
{...inputProps}
|
||||
required={props.isRequired}
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-hidden"
|
||||
/>
|
||||
<input type="hidden" name={name} value={state.numberValue} />
|
||||
<IconButton
|
||||
{...decrementButtonProps}
|
||||
label="Decrement"
|
||||
className="w-7.5 h-7.5 rounded-lg"
|
||||
>
|
||||
<Minus className="p-1" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
{...incrementButtonProps}
|
||||
label="Increment"
|
||||
className="w-7.5 h-7.5 rounded-lg"
|
||||
>
|
||||
<Plus className="p-1" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{props.description && (
|
||||
<div
|
||||
{...descriptionProps}
|
||||
className={cn(
|
||||
'text-xs px-3 mt-1',
|
||||
'text-headplane-500 dark:text-headplane-400',
|
||||
)}
|
||||
>
|
||||
{props.description}
|
||||
</div>
|
||||
)}
|
||||
{isInvalid && (
|
||||
<div
|
||||
{...errorMessageProps}
|
||||
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
|
||||
>
|
||||
{validationErrors.join(' ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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-hidden focus:ring-3 z-10',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
{rendered}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface OptionsPanelProps extends AriaTabPanelProps {
|
||||
state: TabListState<object>;
|
||||
}
|
||||
|
||||
function OptionsPanel({ state, ...props }: OptionsPanelProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const { tabPanelProps } = useTabPanel(props, state, ref);
|
||||
return (
|
||||
<div {...tabPanelProps} ref={ref} className="w-full mt-2">
|
||||
{state.selectedItem?.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Options, { Item });
|
||||
@@ -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-xs rounded-xl',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
'border border-headplane-200 dark:border-headplane-800',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DismissButton onDismiss={state.close} />
|
||||
{children}
|
||||
<DismissButton onDismiss={state.close} />
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -1,174 +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}
|
||||
className={cn(
|
||||
'text-xs font-medium px-3 mb-0.5',
|
||||
'text-headplane-700 dark:text-headplane-100',
|
||||
)}
|
||||
htmlFor={id}
|
||||
>
|
||||
{props.label}
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
'flex rounded-xl focus:outline-hidden focus-within:ring-3',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
props.isInvalid && 'ring-red-400',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
{...inputProps}
|
||||
className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent"
|
||||
data-1p-ignore
|
||||
id={id}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<button
|
||||
{...buttonProps}
|
||||
className={cn(
|
||||
'flex items-center justify-center p-1 rounded-lg m-1',
|
||||
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
||||
props.isDisabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
||||
)}
|
||||
ref={buttonRef}
|
||||
>
|
||||
<ChevronDown className="p-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
{props.description && (
|
||||
<div
|
||||
{...descriptionProps}
|
||||
className={cn(
|
||||
'text-xs px-3 mt-1',
|
||||
'text-headplane-500 dark:text-headplane-400',
|
||||
)}
|
||||
>
|
||||
{props.description}
|
||||
</div>
|
||||
)}
|
||||
{state.isOpen && (
|
||||
<Popover
|
||||
className="w-full max-w-xs"
|
||||
isNonModal
|
||||
placement="bottom start"
|
||||
popoverRef={popoverRef}
|
||||
state={state}
|
||||
triggerRef={inputRef}
|
||||
>
|
||||
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListBoxProps extends AriaListBoxOptions<object> {
|
||||
listBoxRef: React.RefObject<HTMLUListElement | null>;
|
||||
state: ListState<object>;
|
||||
}
|
||||
|
||||
function ListBox(props: ListBoxProps) {
|
||||
const { listBoxRef, state } = props;
|
||||
const { listBoxProps } = useListBox(props, state, listBoxRef);
|
||||
|
||||
return (
|
||||
<ul
|
||||
{...listBoxProps}
|
||||
className="w-full max-h-72 overflow-auto outline-hidden pt-1"
|
||||
ref={listBoxRef}
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<Option item={item} key={item.key} state={state} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
interface OptionProps {
|
||||
item: Node<unknown>;
|
||||
state: ListState<unknown>;
|
||||
}
|
||||
|
||||
function Option({ item, state }: OptionProps) {
|
||||
const ref = useRef<HTMLLIElement | null>(null);
|
||||
const { optionProps, isDisabled, isSelected, isFocused } = useOption(
|
||||
{
|
||||
key: item.key,
|
||||
},
|
||||
state,
|
||||
ref,
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
{...optionProps}
|
||||
className={cn(
|
||||
'flex items-center justify-between',
|
||||
'py-2 px-3 mx-1 rounded-lg mb-1',
|
||||
'focus:outline-hidden select-none',
|
||||
isFocused || isSelected
|
||||
? 'bg-headplane-100/50 dark:bg-headplane-800'
|
||||
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
|
||||
isDisabled && 'text-headplane-300 dark:text-headplane-600',
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{item.rendered}
|
||||
{isSelected && <Check className="p-0.5" />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Select, { Item });
|
||||
@@ -1,21 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Spinner({ className }: Props) {
|
||||
return (
|
||||
<div className={clsx('inline-block align-middle mb-0.5', className)}>
|
||||
<div
|
||||
className={clsx(
|
||||
'animate-spin rounded-full w-full h-full',
|
||||
'border-2 border-current border-t-transparent',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
export interface StatusCircleProps {
|
||||
isOnline: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function StatusCircle({
|
||||
isOnline,
|
||||
className,
|
||||
}: StatusCircleProps) {
|
||||
return (
|
||||
<svg
|
||||
className={cn(
|
||||
isOnline
|
||||
? 'text-green-600 dark:text-green-500'
|
||||
: 'text-headplane-200 dark:text-headplane-800',
|
||||
className,
|
||||
)}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<title>{isOnline ? 'Online' : 'Offline'}</title>
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useRef } from 'react';
|
||||
import {
|
||||
AriaSwitchProps,
|
||||
VisuallyHidden,
|
||||
useFocusRing,
|
||||
useSwitch,
|
||||
} from 'react-aria';
|
||||
import { useToggleState } from 'react-stately';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
export interface SwitchProps extends AriaSwitchProps {
|
||||
label: string;
|
||||
className?: string;
|
||||
switchClassName?: string;
|
||||
}
|
||||
|
||||
export default function Switch(props: SwitchProps) {
|
||||
const state = useToggleState(props);
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const { focusProps, isFocusVisible } = useFocusRing();
|
||||
const { inputProps } = useSwitch(
|
||||
{
|
||||
...props,
|
||||
'aria-label': props.label,
|
||||
},
|
||||
state,
|
||||
ref,
|
||||
);
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-x-2">
|
||||
<VisuallyHidden elementType="span">
|
||||
<input
|
||||
{...inputProps}
|
||||
{...focusProps}
|
||||
aria-label={props.label}
|
||||
ref={ref}
|
||||
/>
|
||||
</VisuallyHidden>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'flex h-[28px] w-[46px] p-[4px] shrink-0 rounded-full',
|
||||
'bg-headplane-300 dark:bg-headplane-700',
|
||||
'border border-transparent dark:border-headplane-800',
|
||||
state.isSelected && 'bg-headplane-900 dark:bg-headplane-950',
|
||||
isFocusVisible && 'ring-2',
|
||||
props.isDisabled && 'opacity-50',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'h-[18px] w-[18px] transform rounded-full',
|
||||
'bg-white transition duration-50 ease-in-out',
|
||||
'translate-x-0 group-selected:translate-x-full',
|
||||
state.isSelected && 'translate-x-full',
|
||||
props.switchClassName,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { HTMLProps } from 'react';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
function TableList(props: HTMLProps<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'rounded-xl',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Item(props: HTMLProps<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'flex items-center justify-between p-2 last:border-b-0',
|
||||
'border-b border-headplane-100 dark:border-headplane-800',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(TableList, { Item });
|
||||
@@ -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}
|
||||
className={cn(
|
||||
'flex items-center rounded-t-xl w-fit max-w-full overflow-x-auto',
|
||||
'border-headplane-100 dark:border-headplane-800',
|
||||
'border-t border-x',
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<Tab item={item} key={item.key} 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}
|
||||
className={cn(
|
||||
'pl-2 pr-3 py-2.5',
|
||||
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
|
||||
'focus:outline-hidden focus:ring-3 z-10',
|
||||
'border-r border-headplane-100 dark:border-headplane-800',
|
||||
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{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}
|
||||
className={cn(
|
||||
'w-full overflow-clip rounded-b-xl rounded-r-xl',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{state.selectedItem?.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Tabs, { Item });
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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-hidden focus:ring-3 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-hidden rounded-3xl text-sm w-48',
|
||||
'bg-white dark:bg-headplane-950',
|
||||
'text-black dark:text-white',
|
||||
'shadow-lg dark:shadow-md rounded-xl',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Tooltip, {
|
||||
Body,
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Check, Copy, Info } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
import toast from "~/utils/toast";
|
||||
|
||||
import Tooltip from "./tooltip";
|
||||
|
||||
export interface AttributeProps {
|
||||
name: string;
|
||||
value: string;
|
||||
tooltip?: string;
|
||||
isCopyable?: boolean;
|
||||
}
|
||||
|
||||
export default function Attribute({ name, value, tooltip, isCopyable }: AttributeProps) {
|
||||
return (
|
||||
<dl className="group/attr flex items-baseline gap-1 text-sm">
|
||||
<dt
|
||||
className={cn(
|
||||
"w-1/3 sm:w-1/4 lg:w-1/3 shrink-0 min-w-0",
|
||||
"text-mist-600 dark:text-mist-300",
|
||||
tooltip ? "flex items-baseline gap-1" : undefined,
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
{tooltip ? (
|
||||
<Tooltip content={tooltip}>
|
||||
<Info className="size-3.5 translate-y-0.5 opacity-40 transition-opacity hover:opacity-100" />
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
"min-w-0 px-1.5 py-1 rounded-lg border border-transparent",
|
||||
...(isCopyable
|
||||
? [
|
||||
"cursor-pointer hover:shadow-xs",
|
||||
"hover:bg-mist-50 dark:hover:bg-mist-800",
|
||||
"hover:border-mist-100 dark:hover:border-mist-700",
|
||||
]
|
||||
: []),
|
||||
)}
|
||||
>
|
||||
{isCopyable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex w-full min-w-0 items-center gap-1.5"
|
||||
onClick={async (event) => {
|
||||
const svgs = event.currentTarget.querySelectorAll("svg");
|
||||
for (const svg of svgs) {
|
||||
svg.toggleAttribute("data-copied", true);
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast(`Copied ${name} to clipboard`);
|
||||
|
||||
setTimeout(() => {
|
||||
for (const svg of svgs) {
|
||||
svg.toggleAttribute("data-copied", false);
|
||||
}
|
||||
}, 1000);
|
||||
}}
|
||||
>
|
||||
<div suppressHydrationWarning className="truncate">
|
||||
{value}
|
||||
</div>
|
||||
<div className="opacity-0 transition-opacity group-hover/attr:opacity-100">
|
||||
<Check className="hidden size-3.5 data-copied:block" />
|
||||
<Copy className="block size-3.5 data-copied:hidden" />
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="relative min-w-0" suppressHydrationWarning>
|
||||
{value.includes("\n") ? (
|
||||
value.split("\n").map((line) => (
|
||||
<div key={line} className="truncate">
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="truncate">{value}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Button as BaseButton } from "@base-ui/react/button";
|
||||
import React from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface ButtonProps extends React.ComponentProps<typeof BaseButton> {
|
||||
variant?: "heavy" | "light" | "danger" | "ghost";
|
||||
}
|
||||
|
||||
export default function Button({ variant = "light", className, ...props }: ButtonProps) {
|
||||
return (
|
||||
<BaseButton
|
||||
{...props}
|
||||
className={cn(
|
||||
"inline-flex w-fit items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm",
|
||||
"transition-colors duration-100",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
props.disabled && "pointer-events-none opacity-50",
|
||||
...(variant === "heavy"
|
||||
? [
|
||||
"bg-indigo-500 font-semibold text-white",
|
||||
"hover:bg-indigo-500/90",
|
||||
"dark:bg-indigo-500/90 dark:hover:bg-indigo-500/80",
|
||||
]
|
||||
: variant === "danger"
|
||||
? [
|
||||
"bg-red-600 font-semibold text-white",
|
||||
"hover:bg-red-600/90",
|
||||
"dark:bg-red-500 dark:hover:bg-red-500/90",
|
||||
]
|
||||
: variant === "ghost"
|
||||
? [
|
||||
"font-medium text-indigo-600 dark:text-indigo-400",
|
||||
"hover:bg-indigo-50 dark:hover:bg-indigo-500/10",
|
||||
]
|
||||
: [
|
||||
"border border-mist-200 bg-white font-medium",
|
||||
"hover:bg-mist-50",
|
||||
"dark:border-mist-700 dark:bg-mist-800/50",
|
||||
"dark:hover:bg-mist-700/50",
|
||||
]),
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface Props extends React.HTMLProps<HTMLDivElement> {
|
||||
variant?: "raised" | "flat";
|
||||
}
|
||||
|
||||
function Card({ variant = "raised", ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
"w-full max-w-md rounded-lg p-5",
|
||||
"bg-mist-50/50 dark:bg-mist-950/50",
|
||||
variant === "flat" ? "shadow-none" : "shadow",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Card, { Title, Text });
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface ChipProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Chip({ text, className, leftIcon, rightIcon }: ChipProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"h-5 text-xs py-0.5 px-1 rounded-md text-nowrap",
|
||||
"text-mist-700 dark:text-mist-100",
|
||||
"bg-mist-100 dark:bg-mist-700",
|
||||
"inline-flex items-center gap-x-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{leftIcon}
|
||||
{text}
|
||||
{rightIcon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Copy } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
import toast from "~/utils/toast";
|
||||
|
||||
export interface CodeBlockProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function CodeBlock({ children, className }: CodeBlockProps) {
|
||||
const text = children.trim();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full cursor-pointer rounded-md bg-mist-100 text-left dark:bg-mist-800",
|
||||
"hover:bg-mist-200 dark:hover:bg-mist-700 transition-colors",
|
||||
className,
|
||||
)}
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<code className="block px-3 pt-2 pb-1 text-sm break-all">{text}</code>
|
||||
<span className="mt-0.5 flex items-center gap-1 px-3 pb-2 text-xs text-mist-500 dark:text-mist-400">
|
||||
<Copy className="size-3" />
|
||||
Click to copy
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface CodeProps {
|
||||
children: string | string[] | number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Code({ children, className }: CodeProps) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"bg-mist-100 dark:bg-mist-800 px-1.5 py-0.5 font-mono rounded-sm text-[0.875em]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { AlertDialog } from "@base-ui/react/alert-dialog";
|
||||
import React, { cloneElement, useEffect, useRef } from "react";
|
||||
import { Form, type HTMLFormMethod } from "react-router";
|
||||
|
||||
import Button, { type ButtonProps } from "~/components/button";
|
||||
import cn from "~/utils/cn";
|
||||
import { useLiveData } from "~/utils/live-data";
|
||||
|
||||
export interface DialogProps {
|
||||
children:
|
||||
| [React.ReactElement<ButtonProps>, React.ReactElement<DialogPanelProps>]
|
||||
| React.ReactElement<DialogPanelProps>;
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
function Dialog(props: DialogProps) {
|
||||
const { pause, resume } = useLiveData();
|
||||
const { isOpen, onOpenChange } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (Array.isArray(props.children)) {
|
||||
const [button, panel] = props.children;
|
||||
return (
|
||||
<AlertDialog.Root open={isOpen} onOpenChange={(open) => onOpenChange?.(open)}>
|
||||
<AlertDialog.Trigger render={cloneElement(button)} />
|
||||
<DialogOverlay>{panel}</DialogOverlay>
|
||||
</AlertDialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog.Root open={isOpen} onOpenChange={(open) => onOpenChange?.(open)}>
|
||||
<DialogOverlay>{props.children}</DialogOverlay>
|
||||
</AlertDialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DialogPanelProps {
|
||||
children: React.ReactNode;
|
||||
variant?: "normal" | "destructive" | "unactionable";
|
||||
onSubmit?: React.FormEventHandler<HTMLFormElement>;
|
||||
method?: HTMLFormMethod;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
function Panel(props: DialogPanelProps) {
|
||||
const { children, onSubmit, isDisabled, variant, method = "POST" } = props;
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<AlertDialog.Popup
|
||||
className={cn(
|
||||
"w-full max-w-lg rounded-xl p-4",
|
||||
"outline-hidden",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
"shadow-overlay",
|
||||
)}
|
||||
>
|
||||
<Form
|
||||
method={method ?? "POST"}
|
||||
onSubmit={(event) => {
|
||||
if (onSubmit) {
|
||||
onSubmit(event);
|
||||
}
|
||||
|
||||
if (!event.defaultPrevented) {
|
||||
closeRef.current?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">{children}</div>
|
||||
<div className="mt-5 flex justify-end gap-3">
|
||||
{variant === "unactionable" ? (
|
||||
<AlertDialog.Close render={<Button>Close</Button>} />
|
||||
) : (
|
||||
<>
|
||||
<AlertDialog.Close render={<Button>Cancel</Button>} />
|
||||
<AlertDialog.Close ref={closeRef} className="hidden" aria-hidden tabIndex={-1} />
|
||||
<Button
|
||||
disabled={isDisabled}
|
||||
type="submit"
|
||||
variant={variant === "destructive" ? "danger" : "heavy"}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</AlertDialog.Popup>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogOverlay({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Backdrop
|
||||
className={cn(
|
||||
"fixed inset-0 z-20 h-screen w-screen",
|
||||
"bg-mist-900/30 dark:bg-mist-950/60",
|
||||
"transition-opacity duration-100",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-20 h-screen w-screen",
|
||||
"flex items-center justify-center p-4",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AlertDialog.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Panel as DialogPanel };
|
||||
export default Dialog;
|
||||
@@ -1,211 +1,194 @@
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { isRouteErrorResponse } from 'react-router';
|
||||
import {
|
||||
isApiError,
|
||||
isConnectionError,
|
||||
} from '~/server/headscale/api/error-client';
|
||||
import cn from '~/utils/cn';
|
||||
import Card from './Card';
|
||||
import Code from './Code';
|
||||
import Link from './Link';
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { isRouteErrorResponse } from "react-router";
|
||||
|
||||
import { isApiError, isConnectionError } from "~/server/headscale/api/error-client";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Card from "./card";
|
||||
import Code from "./code";
|
||||
import Link from "./link";
|
||||
|
||||
export function getErrorMessage(error: Error | unknown): {
|
||||
title: string;
|
||||
jsxMessage: React.ReactNode;
|
||||
title: string;
|
||||
jsxMessage: React.ReactNode;
|
||||
} {
|
||||
if (isRouteErrorResponse(error)) {
|
||||
if (isApiError(error.data)) {
|
||||
const { statusCode, rawData, data, requestUrl } = error.data;
|
||||
if (statusCode >= 500) {
|
||||
return {
|
||||
title: 'Headscale API Error',
|
||||
jsxMessage: (
|
||||
<>
|
||||
<Card.Text>
|
||||
There was an error communicating with the Headscale API.
|
||||
<br />
|
||||
The server responded with a status code of{' '}
|
||||
<strong>{statusCode}</strong>, indicating a server-side issue.
|
||||
Please check the Headscale server status and try again later.
|
||||
</Card.Text>
|
||||
{(error.data.data != null || error.data.rawData != null) && (
|
||||
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
||||
{error.data.data != null ? (
|
||||
<code>{JSON.stringify(error.data.data, null, 2)}</code>
|
||||
) : (
|
||||
<code>{error.data.rawData}</code>
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
if (isRouteErrorResponse(error)) {
|
||||
if (isApiError(error.data)) {
|
||||
const { statusCode, rawData, data, requestUrl } = error.data;
|
||||
if (statusCode >= 500) {
|
||||
return {
|
||||
jsxMessage: (
|
||||
<>
|
||||
<Card.Text>
|
||||
There was an error communicating with the Headscale API.
|
||||
<br />
|
||||
The server responded with a status code of <strong>{statusCode}</strong>, indicating
|
||||
a server-side issue. Please check the Headscale server status and try again later.
|
||||
</Card.Text>
|
||||
{(error.data.data != null || error.data.rawData != null) && (
|
||||
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
|
||||
{error.data.data != null ? (
|
||||
<code>{JSON.stringify(error.data.data, null, 2)}</code>
|
||||
) : (
|
||||
<code>{error.data.rawData}</code>
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
title: "Headscale API Error",
|
||||
};
|
||||
}
|
||||
|
||||
const authError =
|
||||
error.data.statusCode === 401 || error.data.statusCode === 403;
|
||||
const authError = error.data.statusCode === 401 || error.data.statusCode === 403;
|
||||
|
||||
return {
|
||||
title: 'Invalid response from Headscale API',
|
||||
jsxMessage: (
|
||||
<>
|
||||
<Card.Text className="leading-snug">
|
||||
The Headscale API returned an unexpected response.
|
||||
{authError ? (
|
||||
<>
|
||||
{' '}
|
||||
The status code indicates an authentication error. Please
|
||||
verify your API key and Headplane configuration.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{' '}
|
||||
You may be using an unsupported version of Headscale or this
|
||||
may be a bug.
|
||||
</>
|
||||
)}
|
||||
</Card.Text>
|
||||
<ul className="list-disc list-inside mt-2">
|
||||
<li>
|
||||
Request URL: <Code>{requestUrl}</Code>
|
||||
</li>
|
||||
<li>
|
||||
Status Code:{' '}
|
||||
<Code>
|
||||
{/* @ts-expect-error */}
|
||||
{data === null ? (
|
||||
<>
|
||||
{statusCode} {rawData}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{statusCode} {error.statusText}
|
||||
</>
|
||||
)}
|
||||
</Code>
|
||||
</li>
|
||||
</ul>
|
||||
<Card.Text className="text-lg font-semibold mt-4">
|
||||
Error Details
|
||||
</Card.Text>
|
||||
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
||||
<code>{JSON.stringify(error.data, null, 2)}</code>
|
||||
</pre>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
jsxMessage: (
|
||||
<>
|
||||
<Card.Text className="leading-snug">
|
||||
The Headscale API returned an unexpected response.
|
||||
{authError ? (
|
||||
<>
|
||||
{" "}
|
||||
The status code indicates an authentication error. Please verify your API key and
|
||||
Headplane configuration.
|
||||
</>
|
||||
) : (
|
||||
<> You may be using an unsupported version of Headscale or this may be a bug.</>
|
||||
)}
|
||||
</Card.Text>
|
||||
<ul className="mt-2 list-inside list-disc">
|
||||
<li>
|
||||
Request URL: <Code>{requestUrl}</Code>
|
||||
</li>
|
||||
<li>
|
||||
Status Code:{" "}
|
||||
<Code>
|
||||
{/* @ts-expect-error */}
|
||||
{data === null ? (
|
||||
<>
|
||||
{statusCode} {rawData}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{statusCode} {error.statusText}
|
||||
</>
|
||||
)}
|
||||
</Code>
|
||||
</li>
|
||||
</ul>
|
||||
<Card.Text className="mt-4 text-lg font-semibold">Error Details</Card.Text>
|
||||
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
|
||||
<code>{JSON.stringify(error.data, null, 2)}</code>
|
||||
</pre>
|
||||
</>
|
||||
),
|
||||
title: "Invalid response from Headscale API",
|
||||
};
|
||||
}
|
||||
|
||||
if (isConnectionError(error.data)) {
|
||||
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
|
||||
return {
|
||||
title: 'Cannot connect to Headscale API',
|
||||
jsxMessage: (
|
||||
<>
|
||||
<Card.Text className="leading-snug">
|
||||
Headplane was unable to reach the Headscale API. Please check your
|
||||
network setup and configuration to ensure Headplane is able to
|
||||
connect.
|
||||
</Card.Text>
|
||||
<Card.Text className="text-lg font-semibold mt-4">
|
||||
Error Details
|
||||
</Card.Text>
|
||||
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
||||
{requestUrl}
|
||||
<br />
|
||||
{errorCode}: {errorMessage}
|
||||
{extraData != null && (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
<code>{JSON.stringify(extraData, null, 2)}</code>
|
||||
</>
|
||||
)}
|
||||
</pre>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
if (isConnectionError(error.data)) {
|
||||
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
|
||||
return {
|
||||
jsxMessage: (
|
||||
<>
|
||||
<Card.Text className="leading-snug">
|
||||
Headplane was unable to reach the Headscale API. Please check your network setup and
|
||||
configuration to ensure Headplane is able to connect.
|
||||
</Card.Text>
|
||||
<Card.Text className="mt-4 text-lg font-semibold">Error Details</Card.Text>
|
||||
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
|
||||
{requestUrl}
|
||||
<br />
|
||||
{errorCode}: {errorMessage}
|
||||
{extraData != null && (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
<code>{JSON.stringify(extraData, null, 2)}</code>
|
||||
</>
|
||||
)}
|
||||
</pre>
|
||||
</>
|
||||
),
|
||||
title: "Cannot connect to Headscale API",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `Error ${error.status}`,
|
||||
jsxMessage: (
|
||||
<>
|
||||
There was an error processing your request.
|
||||
<br />
|
||||
Status Code: <strong>{error.status}</strong>
|
||||
<br />
|
||||
Status Text: <strong>{error.data}</strong>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
jsxMessage: (
|
||||
<>
|
||||
There was an error processing your request.
|
||||
<br />
|
||||
Status Code: <strong>{error.status}</strong>
|
||||
<br />
|
||||
Status Text: <strong>{error.data}</strong>
|
||||
</>
|
||||
),
|
||||
title: `Error ${error.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!(error instanceof Error)) {
|
||||
return {
|
||||
title: 'Unexpected Error',
|
||||
jsxMessage: (
|
||||
<>
|
||||
<Card.Text>
|
||||
An unexpected error occurred which is most likely a bug. Please
|
||||
consider reporting filing an issue on the{' '}
|
||||
<Link
|
||||
name="Headplane GitHub"
|
||||
to="https://github.com/tale/headplane/issues"
|
||||
>
|
||||
Headplane GitHub
|
||||
</Link>{' '}
|
||||
repository with the details below.
|
||||
</Card.Text>
|
||||
<Card.Text className="text-lg font-semibold mt-4">
|
||||
Error Details
|
||||
</Card.Text>
|
||||
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
||||
<code>{JSON.stringify(error, null, 2)}</code>
|
||||
</pre>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
if (!(error instanceof Error)) {
|
||||
return {
|
||||
jsxMessage: (
|
||||
<>
|
||||
<Card.Text>
|
||||
An unexpected error occurred which is most likely a bug. Please consider reporting
|
||||
filing an issue on the{" "}
|
||||
<Link external styled to="https://github.com/tale/headplane/issues">
|
||||
Headplane GitHub
|
||||
</Link>{" "}
|
||||
repository with the details below.
|
||||
</Card.Text>
|
||||
<Card.Text className="mt-4 text-lg font-semibold">Error Details</Card.Text>
|
||||
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
|
||||
<code>{JSON.stringify(error, null, 2)}</code>
|
||||
</pre>
|
||||
</>
|
||||
),
|
||||
title: "Unexpected Error",
|
||||
};
|
||||
}
|
||||
|
||||
// Traverse the error chain to find the root cause
|
||||
let rootError = error;
|
||||
if (error.cause != null) {
|
||||
rootError = error.cause as Error;
|
||||
while (rootError.cause != null) {
|
||||
rootError = rootError.cause as Error;
|
||||
}
|
||||
}
|
||||
// Traverse the error chain to find the root cause
|
||||
let rootError = error;
|
||||
if (error.cause != null) {
|
||||
rootError = error.cause as Error;
|
||||
while (rootError.cause != null) {
|
||||
rootError = rootError.cause as Error;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: If we are aggregate, concat into a single message
|
||||
if (rootError instanceof AggregateError) {
|
||||
throw new Error('AggregateError handling not implemented yet');
|
||||
}
|
||||
// TODO: If we are aggregate, concat into a single message
|
||||
if (rootError instanceof AggregateError) {
|
||||
throw new Error("AggregateError handling not implemented yet");
|
||||
}
|
||||
|
||||
return {
|
||||
title:
|
||||
rootError.name.length > 0 && rootError.name !== 'Error'
|
||||
? `Error: ${rootError.name}`
|
||||
: 'Error',
|
||||
jsxMessage: rootError.message,
|
||||
};
|
||||
return {
|
||||
jsxMessage: rootError.message,
|
||||
title:
|
||||
rootError.name.length > 0 && rootError.name !== "Error"
|
||||
? `Error: ${rootError.name}`
|
||||
: "Error",
|
||||
};
|
||||
}
|
||||
|
||||
interface ErrorBannerProps {
|
||||
error: unknown;
|
||||
className?: string;
|
||||
error: unknown;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ErrorBanner({ error, className }: ErrorBannerProps) {
|
||||
const { title, jsxMessage } = getErrorMessage(error);
|
||||
const { title, jsxMessage } = getErrorMessage(error);
|
||||
|
||||
return (
|
||||
<Card className={cn('w-screen', className)} variant="flat">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title>{title}</Card.Title>
|
||||
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
|
||||
</div>
|
||||
{jsxMessage}
|
||||
</Card>
|
||||
);
|
||||
return (
|
||||
<Card className={cn("w-screen", className)} variant="flat">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title>{title}</Card.Title>
|
||||
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
{jsxMessage}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Field } from "@base-ui/react/field";
|
||||
import { Input as BaseInput } from "@base-ui/react/input";
|
||||
import { Asterisk } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface InputProps extends Omit<ComponentProps<typeof BaseInput>, "onChange"> {
|
||||
label: string;
|
||||
labelHidden?: boolean;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
invalid?: boolean;
|
||||
errorMessage?: string;
|
||||
description?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function Input(props: InputProps) {
|
||||
const {
|
||||
label,
|
||||
labelHidden,
|
||||
className,
|
||||
invalid,
|
||||
errorMessage,
|
||||
description,
|
||||
required,
|
||||
onChange,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Field.Root className={cn("flex w-full flex-col gap-1", className)} invalid={invalid}>
|
||||
<Field.Label
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
"text-mist-700 dark:text-mist-200",
|
||||
labelHidden && "sr-only",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{required && <Asterisk className="ml-0.5 inline w-3.5 pb-1 text-red-500" />}
|
||||
</Field.Label>
|
||||
<BaseInput
|
||||
{...rest}
|
||||
required={required}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-2 text-sm",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
)}
|
||||
onChange={
|
||||
onChange
|
||||
? (e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{description && (
|
||||
<Field.Description className={cn("text-xs", "text-mist-500 dark:text-mist-400")}>
|
||||
{description}
|
||||
</Field.Description>
|
||||
)}
|
||||
{invalid && errorMessage ? (
|
||||
<Field.Error className={cn("text-xs", "text-red-500 dark:text-red-400")}>
|
||||
{errorMessage}
|
||||
</Field.Error>
|
||||
) : null}
|
||||
</Field.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import type { JSX, ReactNode } from "react";
|
||||
import { Link as RouterLink } from "react-router";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export type LinkProps =
|
||||
| {
|
||||
external: true;
|
||||
to: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
styled?: boolean;
|
||||
}
|
||||
| {
|
||||
external?: false;
|
||||
to: string;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Link(props: LinkProps): JSX.Element {
|
||||
if (props.external) {
|
||||
return (
|
||||
<a
|
||||
href={props.to}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
props.styled && [
|
||||
"inline-flex items-center gap-x-0.5",
|
||||
"text-blue-500 hover:text-blue-700",
|
||||
"dark:text-blue-400 dark:hover:text-blue-300",
|
||||
],
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
{props.styled && <ExternalLink className="w-3.5" />}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RouterLink to={props.to} prefetch="intent" className={props.className}>
|
||||
{props.children}
|
||||
</RouterLink>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Menu as BaseMenu } from "@base-ui/react/menu";
|
||||
import type { ComponentProps, JSX, ReactNode } from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
const SIDE_OFFSET = 8;
|
||||
|
||||
export const Menu = ({
|
||||
children,
|
||||
disabled,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
}): JSX.Element => <BaseMenu.Root disabled={disabled}>{children}</BaseMenu.Root>;
|
||||
|
||||
export const MenuTrigger = ({
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
}: Pick<
|
||||
ComponentProps<typeof BaseMenu.Trigger>,
|
||||
"className" | "children" | "disabled" | "onClick"
|
||||
>): JSX.Element => (
|
||||
<BaseMenu.Trigger
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</BaseMenu.Trigger>
|
||||
);
|
||||
|
||||
export const MenuContent = ({
|
||||
children,
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = SIDE_OFFSET,
|
||||
align = "start",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
side?: ComponentProps<typeof BaseMenu.Positioner>["side"];
|
||||
sideOffset?: number;
|
||||
align?: ComponentProps<typeof BaseMenu.Positioner>["align"];
|
||||
}): JSX.Element => (
|
||||
<BaseMenu.Portal>
|
||||
<BaseMenu.Positioner side={side} sideOffset={sideOffset} align={align}>
|
||||
<BaseMenu.Popup
|
||||
className={cn(
|
||||
"min-w-50 rounded-lg py-1",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"shadow-lg dark:shadow-none",
|
||||
"border border-mist-200 dark:border-mist-700",
|
||||
"focus:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</BaseMenu.Popup>
|
||||
</BaseMenu.Positioner>
|
||||
</BaseMenu.Portal>
|
||||
);
|
||||
|
||||
export const MenuItem = ({
|
||||
className,
|
||||
variant,
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
}: Pick<ComponentProps<typeof BaseMenu.Item>, "className" | "children" | "disabled" | "onClick"> & {
|
||||
variant?: "danger";
|
||||
}): JSX.Element => (
|
||||
<BaseMenu.Item
|
||||
className={cn(
|
||||
"py-2 px-3 mx-1 rounded-md",
|
||||
"select-none cursor-pointer",
|
||||
"focus:outline-hidden",
|
||||
"text-mist-700 dark:text-mist-300",
|
||||
"data-highlighted:bg-mist-100/50 dark:data-highlighted:bg-mist-800",
|
||||
"data-disabled:text-mist-400 dark:data-disabled:text-mist-600 data-disabled:cursor-default",
|
||||
variant === "danger" && "text-red-500 dark:text-red-400",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</BaseMenu.Item>
|
||||
);
|
||||
|
||||
export const MenuSeparator = ({ className }: { className?: string }): JSX.Element => (
|
||||
<BaseMenu.Separator
|
||||
className={cn("mx-2 my-1 border-t border-mist-200 dark:border-mist-800", className)}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CircleAlert, CircleSlash2, LucideProps, TriangleAlert } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
import Card from "~/components/card";
|
||||
|
||||
export interface NoticeProps {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
variant?: "default" | "error" | "warning";
|
||||
icon?: React.ReactElement<LucideProps>;
|
||||
}
|
||||
|
||||
export default function Notice({ children, title, variant, icon }: NoticeProps) {
|
||||
return (
|
||||
<Card variant="flat" className="my-6 max-w-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
{title ? <Card.Title className="mb-0 text-xl">{title}</Card.Title> : undefined}
|
||||
{!variant && icon ? icon : iconForVariant(variant)}
|
||||
</div>
|
||||
<Card.Text className="mt-4">{children}</Card.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function iconForVariant(variant?: "default" | "error" | "warning") {
|
||||
switch (variant) {
|
||||
case "error":
|
||||
return <TriangleAlert className="text-red-500" />;
|
||||
case "warning":
|
||||
return <CircleAlert className="text-yellow-500" />;
|
||||
default:
|
||||
return <CircleSlash2 />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NumberField } from "@base-ui/react/number-field";
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface NumberInputProps {
|
||||
label?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
defaultValue?: number;
|
||||
value?: number;
|
||||
onValueChange?: (value: number | null) => void;
|
||||
}
|
||||
|
||||
export default function NumberInput(props: NumberInputProps) {
|
||||
const { label, name, description } = props;
|
||||
|
||||
return (
|
||||
<NumberField.Root
|
||||
className="flex flex-col gap-1"
|
||||
defaultValue={props.defaultValue}
|
||||
value={props.value}
|
||||
onValueChange={props.onValueChange}
|
||||
min={props.min}
|
||||
max={props.max}
|
||||
step={props.step}
|
||||
disabled={props.disabled}
|
||||
required={props.required}
|
||||
>
|
||||
{label && (
|
||||
<NumberField.ScrubArea>
|
||||
<label className={cn("text-sm font-medium", "text-mist-700 dark:text-mist-200")}>
|
||||
<NumberField.ScrubAreaCursor />
|
||||
{label}
|
||||
</label>
|
||||
</NumberField.ScrubArea>
|
||||
)}
|
||||
<NumberField.Group
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md pr-1",
|
||||
"focus-within:outline-hidden focus-within:ring-2 focus-within:ring-indigo-500/40 focus-within:ring-offset-1",
|
||||
"dark:focus-within:ring-indigo-400/40 dark:focus-within:ring-offset-mist-900",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
)}
|
||||
>
|
||||
<NumberField.Input
|
||||
name={name}
|
||||
className="w-full rounded-l-md bg-transparent py-2 pl-3 text-sm focus:outline-hidden"
|
||||
/>
|
||||
<NumberField.Decrement aria-label="Decrement" className="h-7.5 w-7.5 rounded-lg p-1">
|
||||
<Minus className="h-4 w-4" />
|
||||
</NumberField.Decrement>
|
||||
<NumberField.Increment aria-label="Increment" className="h-7.5 w-7.5 rounded-lg p-1">
|
||||
<Plus className="h-4 w-4" />
|
||||
</NumberField.Increment>
|
||||
</NumberField.Group>
|
||||
{description && (
|
||||
<div className={cn("text-xs", "text-mist-500 dark:text-mist-400")}>{description}</div>
|
||||
)}
|
||||
</NumberField.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { RefreshCw, ServerOff } from "lucide-react";
|
||||
import { isRouteErrorResponse, useRevalidator } from "react-router";
|
||||
|
||||
import { isConnectionError } from "~/server/headscale/api/error-client";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Button from "./button";
|
||||
import { ErrorBanner } from "./error-banner";
|
||||
|
||||
interface PageErrorProps {
|
||||
error: unknown;
|
||||
page: string;
|
||||
}
|
||||
|
||||
export default function PageError({ error, page }: PageErrorProps) {
|
||||
const { revalidate, state } = useRevalidator();
|
||||
|
||||
if (isRouteErrorResponse(error) && isConnectionError(error.data)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<ServerOff className={cn("h-12 w-12", "text-mist-400 dark:text-mist-500")} />
|
||||
<h2 className="mt-4 text-lg font-semibold">{page} Unavailable</h2>
|
||||
<p className="mt-1 max-w-sm text-sm text-mist-500 dark:text-mist-400">
|
||||
This page could not be loaded because the Headscale server is unreachable. It will be
|
||||
available once the connection is restored.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-6"
|
||||
variant="light"
|
||||
onClick={() => revalidate()}
|
||||
disabled={state === "loading"}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("mr-2 inline-block h-4 w-4", state === "loading" && "animate-spin")}
|
||||
/>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ErrorBanner className="max-w-2xl" error={error} />;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Radio } from "@base-ui/react/radio";
|
||||
import { RadioGroup as BaseRadioGroup } from "@base-ui/react/radio-group";
|
||||
import type React from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface RadioGroupProps {
|
||||
children: React.ReactNode;
|
||||
label: string;
|
||||
className?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onValueChange?: (value: string, eventDetails: BaseRadioGroup.ChangeEventDetails) => void;
|
||||
}
|
||||
|
||||
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
|
||||
return (
|
||||
<BaseRadioGroup
|
||||
{...props}
|
||||
aria-label={label}
|
||||
className={cn("flex flex-col gap-2.5", className)}
|
||||
>
|
||||
{children}
|
||||
</BaseRadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface RadioItemProps {
|
||||
value: string;
|
||||
label: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function RadioItem({ children, label, className, value, disabled }: RadioItemProps) {
|
||||
return (
|
||||
<label className="flex items-center gap-2.5 text-sm">
|
||||
<Radio.Root
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"w-5 h-5 aspect-square rounded-full border-2",
|
||||
"border-mist-400 dark:border-mist-500",
|
||||
"focus-visible:ring-2 focus-visible:ring-indigo-500/40 focus-visible:ring-offset-1 dark:focus-visible:ring-indigo-400/40 dark:focus-visible:ring-offset-mist-900",
|
||||
"data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed",
|
||||
"data-[checked]:border-[6px] data-[checked]:border-mist-900 dark:data-[checked]:border-mist-100",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Radio.Indicator />
|
||||
</Radio.Root>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(RadioGroup, { Radio: RadioItem });
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Combobox } from "@base-ui/react/combobox";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface SelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SelectProps {
|
||||
items: SelectItem[];
|
||||
label?: string;
|
||||
"aria-label"?: string;
|
||||
name?: string;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
invalid?: boolean;
|
||||
value?: string | null;
|
||||
defaultValue?: string | null;
|
||||
onValueChange?: (value: string | null) => void;
|
||||
}
|
||||
|
||||
export default function Select({
|
||||
items,
|
||||
label,
|
||||
className,
|
||||
placeholder,
|
||||
description,
|
||||
required,
|
||||
disabled,
|
||||
invalid,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
name,
|
||||
...props
|
||||
}: SelectProps) {
|
||||
const selectedItem =
|
||||
value !== undefined ? (items.find((i) => i.value === value) ?? null) : undefined;
|
||||
const defaultSelectedItem =
|
||||
defaultValue !== undefined ? (items.find((i) => i.value === defaultValue) ?? null) : undefined;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", className)}>
|
||||
{label && (
|
||||
<label className={cn("text-sm font-medium", "text-mist-700 dark:text-mist-200")}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<Combobox.Root
|
||||
items={items}
|
||||
value={selectedItem}
|
||||
defaultValue={defaultSelectedItem}
|
||||
onValueChange={(item) => onValueChange?.(item?.value ?? null)}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
aria-label={props["aria-label"]}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-md",
|
||||
"focus-within:ring-2 focus-within:ring-indigo-500/40 focus-within:ring-offset-1",
|
||||
"dark:focus-within:ring-indigo-400/40 dark:focus-within:ring-offset-mist-900",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
invalid && "ring-red-400",
|
||||
)}
|
||||
>
|
||||
<Combobox.Input
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
className="w-full rounded-md bg-transparent px-3 py-2 pr-9 text-sm outline-hidden"
|
||||
data-1p-ignore
|
||||
/>
|
||||
<Combobox.Trigger
|
||||
className={cn(
|
||||
"absolute inset-y-0 right-0 flex items-center pr-3",
|
||||
"text-mist-400 dark:text-mist-500",
|
||||
disabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "hover:text-mist-600 dark:hover:text-mist-300",
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Combobox.Trigger>
|
||||
</div>
|
||||
<Combobox.Portal>
|
||||
<Combobox.Positioner
|
||||
className="z-50"
|
||||
sideOffset={8}
|
||||
style={{ width: "var(--anchor-width)" }}
|
||||
>
|
||||
<Combobox.Popup
|
||||
className={cn(
|
||||
"max-h-72 w-full overflow-auto rounded-lg p-1",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
"shadow-overlay",
|
||||
)}
|
||||
>
|
||||
<Combobox.Empty className="px-3 py-2 text-sm text-mist-500 empty:hidden">
|
||||
No results found.
|
||||
</Combobox.Empty>
|
||||
<Combobox.List>
|
||||
{(item: SelectItem) => (
|
||||
<Combobox.Item
|
||||
key={item.value}
|
||||
value={item}
|
||||
className={cn(
|
||||
"flex items-center justify-between text-sm",
|
||||
"py-1.5 px-2.5 rounded-md",
|
||||
"outline-hidden select-none cursor-default",
|
||||
"data-[highlighted]:bg-mist-100 dark:data-[highlighted]:bg-mist-800",
|
||||
"data-[selected]:font-medium",
|
||||
"data-[disabled]:text-mist-300 dark:data-[disabled]:text-mist-600",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
<Combobox.ItemIndicator>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Combobox.ItemIndicator>
|
||||
</Combobox.Item>
|
||||
)}
|
||||
</Combobox.List>
|
||||
</Combobox.Popup>
|
||||
</Combobox.Positioner>
|
||||
</Combobox.Portal>
|
||||
</Combobox.Root>
|
||||
{description && (
|
||||
<div className={cn("text-xs", "text-mist-500 dark:text-mist-400")}>{description}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { AlertCircle, CloudOff, Info, TriangleAlert, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
type Variant = "info" | "warning" | "error" | "critical";
|
||||
|
||||
interface StatusBannerProps {
|
||||
variant: Variant;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
dismissable?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const icons: Record<Variant, React.ReactNode> = {
|
||||
info: <Info className="h-5 w-5 shrink-0" />,
|
||||
warning: <TriangleAlert className="h-5 w-5 shrink-0" />,
|
||||
error: <AlertCircle className="h-5 w-5 shrink-0" />,
|
||||
critical: <CloudOff className="h-5 w-5 shrink-0" />,
|
||||
};
|
||||
|
||||
export default function StatusBanner({
|
||||
variant,
|
||||
title,
|
||||
children,
|
||||
dismissable = true,
|
||||
className,
|
||||
}: StatusBannerProps) {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-lg border px-4 py-3 text-sm",
|
||||
variant === "info" && [
|
||||
"border-indigo-200 bg-indigo-50 text-indigo-900",
|
||||
"dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-200",
|
||||
],
|
||||
variant === "warning" && [
|
||||
"border-yellow-300 bg-yellow-50 text-yellow-900",
|
||||
"dark:border-yellow-500/30 dark:bg-yellow-500/10 dark:text-yellow-200",
|
||||
],
|
||||
variant === "error" && [
|
||||
"border-red-300 bg-red-50 text-red-900",
|
||||
"dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200",
|
||||
],
|
||||
variant === "critical" && [
|
||||
"border-red-400 bg-red-100 text-red-900",
|
||||
"dark:border-red-500/40 dark:bg-red-500/20 dark:text-red-100",
|
||||
],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icons[variant]}
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{title}</p>
|
||||
<div className="mt-0.5 text-current/80">{children}</div>
|
||||
</div>
|
||||
{dismissable && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
onClick={() => setDismissed(true)}
|
||||
className={cn(
|
||||
"shrink-0 rounded p-0.5 transition-colors",
|
||||
"hover:bg-black/10 dark:hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface StatusCircleProps {
|
||||
isOnline: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function StatusCircle({ isOnline, className }: StatusCircleProps) {
|
||||
return (
|
||||
<svg
|
||||
className={cn(
|
||||
isOnline ? "text-green-600 dark:text-green-500" : "text-mist-200 dark:text-mist-800",
|
||||
className,
|
||||
)}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<title>{isOnline ? "Online" : "Offline"}</title>
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Switch as BaseSwitch } from "@base-ui/react/switch";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface SwitchProps {
|
||||
label: string;
|
||||
className?: string;
|
||||
switchClassName?: string;
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Switch(props: SwitchProps) {
|
||||
return (
|
||||
<BaseSwitch.Root
|
||||
aria-label={props.label}
|
||||
checked={props.checked}
|
||||
className={cn(
|
||||
"flex h-[22px] w-[38px] p-[3px] shrink-0 rounded-full",
|
||||
"bg-mist-300 dark:bg-mist-700",
|
||||
"border border-transparent dark:border-mist-800",
|
||||
"data-[checked]:bg-mist-900 dark:data-[checked]:bg-mist-950",
|
||||
"focus-visible:ring-2 focus-visible:ring-indigo-500/40 focus-visible:ring-offset-1 dark:focus-visible:ring-indigo-400/40 dark:focus-visible:ring-offset-mist-900",
|
||||
props.disabled && "opacity-50",
|
||||
props.className,
|
||||
)}
|
||||
defaultChecked={props.defaultChecked}
|
||||
disabled={props.disabled}
|
||||
name={props.name}
|
||||
onCheckedChange={props.onCheckedChange}
|
||||
>
|
||||
<BaseSwitch.Thumb
|
||||
className={cn(
|
||||
"h-[14px] w-[14px] transform rounded-full",
|
||||
"bg-white transition duration-50 ease-in-out",
|
||||
"translate-x-0 data-[checked]:translate-x-full",
|
||||
props.switchClassName,
|
||||
)}
|
||||
/>
|
||||
</BaseSwitch.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { HTMLProps } from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
function TableList(props: HTMLProps<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn("rounded-lg", "border border-mist-200 dark:border-mist-800", props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Item(props: HTMLProps<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
"flex items-center justify-between p-2 last:border-b-0",
|
||||
"border-b border-mist-200 dark:border-mist-800",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(TableList, { Item });
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Tabs as BaseTabs } from "@base-ui/react/tabs";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface TabsProps {
|
||||
label: string;
|
||||
className?: string;
|
||||
defaultValue?: string | number;
|
||||
value?: string | number;
|
||||
onValueChange?: (value: string | number) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function Tabs({ label, className, children, ...props }: TabsProps) {
|
||||
return (
|
||||
<BaseTabs.Root
|
||||
{...props}
|
||||
defaultValue={props.defaultValue ?? 0}
|
||||
aria-label={label}
|
||||
className={cn("flex flex-col", className)}
|
||||
>
|
||||
{children}
|
||||
</BaseTabs.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function TabList({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<BaseTabs.List
|
||||
className={cn(
|
||||
"flex items-center rounded-t-md w-fit max-w-full",
|
||||
"border-mist-200 dark:border-mist-800",
|
||||
"border-t border-x",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</BaseTabs.List>
|
||||
);
|
||||
}
|
||||
|
||||
function Tab({
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof BaseTabs.Tab> & { className?: string }) {
|
||||
return (
|
||||
<BaseTabs.Tab
|
||||
value={value}
|
||||
{...props}
|
||||
className={cn(
|
||||
"pl-2 pr-3 py-2.5",
|
||||
"data-[selected]:bg-mist-50 dark:data-[selected]:bg-mist-950",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1 z-10",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
"border-r border-mist-200 dark:border-mist-800",
|
||||
"first:rounded-tl-md last:rounded-tr-md last:border-r-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</BaseTabs.Tab>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof BaseTabs.Panel> & { className?: string }) {
|
||||
return (
|
||||
<BaseTabs.Panel
|
||||
value={value}
|
||||
{...props}
|
||||
className={cn(
|
||||
"w-full overflow-clip rounded-b-md rounded-r-md",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</BaseTabs.Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabList as TabsList, Tab as TabsTab, Panel as TabsPanel };
|
||||
@@ -1,32 +1,33 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import cn from '~/utils/cn';
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export interface ExitNodeTagProps {
|
||||
isEnabled?: boolean;
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function ExitNodeTag({ isEnabled }: ExitNodeTagProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Exit Node"
|
||||
className={cn(
|
||||
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
|
||||
)}
|
||||
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
{isEnabled ? (
|
||||
<>This machine is acting as an exit node.</>
|
||||
) : (
|
||||
<>
|
||||
This machine is requesting to be used as an exit node. Review this
|
||||
from the "Edit route settings..." option in the machine's menu.
|
||||
</>
|
||||
)}
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
isEnabled ? (
|
||||
<>This machine is acting as an exit node.</>
|
||||
) : (
|
||||
<>
|
||||
This machine is requesting to be used as an exit node. Review this from the "Edit route
|
||||
settings..." option in the machine's menu.
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
text="Exit Node"
|
||||
className={cn("bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300")}
|
||||
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +1,37 @@
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export interface ExpiryTagProps {
|
||||
variant: 'expired' | 'no-expiry';
|
||||
expiry?: string;
|
||||
variant: "expired" | "no-expiry";
|
||||
expiry?: string;
|
||||
}
|
||||
|
||||
export function ExpiryTag({ variant, expiry }: ExpiryTagProps) {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text={
|
||||
variant === 'expired'
|
||||
? `Expired ${formatter.format(new Date(expiry!))}`
|
||||
: 'No expiry'
|
||||
}
|
||||
className="bg-headplane-200 text-headplane-800 dark:bg-headplane-800 dark:text-headplane-200"
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
{variant === 'expired' ? (
|
||||
<>
|
||||
This machine is expired and will not be able to connect to the
|
||||
network. Re-authenticate with Tailscale on the machine to re-enable
|
||||
it.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This machine has key expiry disabled and will never need to
|
||||
re-authenticate.
|
||||
</>
|
||||
)}
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
variant === "expired" ? (
|
||||
<>
|
||||
This machine is expired and will not be able to connect to the network. Re-authenticate
|
||||
with Tailscale on the machine to re-enable it.
|
||||
</>
|
||||
) : (
|
||||
<>This machine has key expiry disabled and will never need to re-authenticate.</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
text={
|
||||
variant === "expired" ? `Expired ${formatter.format(new Date(expiry!))}` : "No expiry"
|
||||
}
|
||||
className="bg-mist-200 text-mist-800 dark:bg-mist-800 dark:text-mist-200"
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import cn from '~/utils/cn';
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export function HeadplaneAgentTag() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Headplane Agent"
|
||||
className={cn(
|
||||
'bg-purple-300 text-purple-900 dark:bg-purple-900 dark:text-purple-300',
|
||||
)}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
This machine is running the Headplane agent, which allows it to provide
|
||||
host information in the web UI.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
return (
|
||||
<Tooltip content="This machine is running the Headplane agent, which allows it to provide host information in the web UI.">
|
||||
<Chip
|
||||
text="Headplane Agent"
|
||||
className={cn("bg-purple-300 text-purple-900 dark:bg-purple-900 dark:text-purple-300")}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import cn from '~/utils/cn';
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export interface SubnetTagProps {
|
||||
isEnabled?: boolean;
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function SubnetTag({ isEnabled }: SubnetTagProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Subnets"
|
||||
className={cn(
|
||||
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
|
||||
)}
|
||||
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
{isEnabled ? (
|
||||
<>This machine advertises subnet routes.</>
|
||||
) : (
|
||||
<>
|
||||
This machine has unadvertised subnet routes. Review this from the
|
||||
"Edit route settings..." option in the machine's menu.
|
||||
</>
|
||||
)}
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
isEnabled ? (
|
||||
<>This machine advertises subnet routes.</>
|
||||
) : (
|
||||
<>
|
||||
This machine has unadvertised subnet routes. Review this from the "Edit route
|
||||
settings..." option in the machine's menu.
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
text="Subnets"
|
||||
className={cn("bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300")}
|
||||
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import cn from '~/utils/cn';
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export function TailscaleSSHTag() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Tailscale SSH"
|
||||
className={cn(
|
||||
'bg-lime-500 text-lime-900 dark:bg-lime-900 dark:text-lime-500',
|
||||
)}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
This machine advertises Tailscale SSH, which allows you to authenticate
|
||||
SSH credentials using your Tailscale account and via the Headplane web
|
||||
UI.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
return (
|
||||
<Tooltip content="This machine advertises Tailscale SSH, which allows you to authenticate SSH credentials using your Tailscale account and via the Headplane web UI.">
|
||||
<Chip
|
||||
text="Tailscale SSH"
|
||||
className={cn("bg-lime-500 text-lime-900 dark:bg-lime-900 dark:text-lime-500")}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface TextProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Text({ children, className }: TextProps) {
|
||||
return <p className={cn("text-md my-0", className)}>{children}</p>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface TitleProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Title({ children, className }: TitleProps) {
|
||||
return <h3 className={cn("text-2xl font-bold mb-2", className)}>{children}</h3>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface TooltipProps {
|
||||
children: ReactNode;
|
||||
content: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Tooltip({ children, content, className }: TooltipProps) {
|
||||
return (
|
||||
<BaseTooltip.Root>
|
||||
<BaseTooltip.Trigger
|
||||
delay={0}
|
||||
closeDelay={0}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</BaseTooltip.Trigger>
|
||||
<BaseTooltip.Portal>
|
||||
<BaseTooltip.Positioner sideOffset={4}>
|
||||
<BaseTooltip.Popup
|
||||
className={cn(
|
||||
"z-50 rounded-lg p-3 text-sm w-48",
|
||||
"outline-hidden",
|
||||
"bg-white dark:bg-mist-950",
|
||||
"text-black dark:text-white",
|
||||
"shadow-overlay",
|
||||
"border border-mist-100 dark:border-mist-800",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</BaseTooltip.Popup>
|
||||
</BaseTooltip.Positioner>
|
||||
</BaseTooltip.Portal>
|
||||
</BaseTooltip.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { StrictMode, startTransition } from 'react';
|
||||
import { hydrateRoot } from 'react-dom/client';
|
||||
import { HydratedRouter } from 'react-router/dom';
|
||||
import { StrictMode, startTransition } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import { HydratedRouter } from "react-router/dom";
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<HydratedRouter />
|
||||
</StrictMode>,
|
||||
);
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<HydratedRouter />
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||
import type { AppLoadContext, EntryContext } from "react-router";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||
import { isbot } from "isbot";
|
||||
import { PassThrough } from "node:stream";
|
||||
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
import type { AppLoadContext, EntryContext } from "react-router";
|
||||
import { ServerRouter } from "react-router";
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Globals replaced at build time by Vite (`define` in `vite.config.ts`).
|
||||
|
||||
declare const __PREFIX__: string;
|
||||
declare const __VERSION__: string;
|
||||
@@ -0,0 +1,181 @@
|
||||
import { type, type Type } from "arktype";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type FormValue = string | number | boolean | null;
|
||||
|
||||
interface FormState {
|
||||
values: Record<string, FormValue>;
|
||||
errors: Record<string, string>;
|
||||
modified: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface FormFieldProps {
|
||||
name: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur: () => void;
|
||||
invalid: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface UseFormOptions<T extends Record<string, unknown>> {
|
||||
schema: Type<T>;
|
||||
defaultValues?: Partial<Record<keyof T & string, FormValue>>;
|
||||
actionData?: unknown;
|
||||
validate?: (values: Record<string, FormValue>) => Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
// arktype's Type<T> doesn't expose `.props` in the type system,
|
||||
// but it exists at runtime. This extracts schema keys safely.
|
||||
function schemaKeys(schema: Type): string[] {
|
||||
const props = (schema as unknown as { props?: { key: string }[] }).props;
|
||||
return props?.map((p) => p.key) ?? [];
|
||||
}
|
||||
|
||||
export function useForm<T extends Record<string, unknown>>(options: UseFormOptions<T>) {
|
||||
const { schema, actionData, validate } = options;
|
||||
const keys = schemaKeys(schema);
|
||||
const validateRef = useRef(validate);
|
||||
validateRef.current = validate;
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
const values: Record<string, FormValue> = {};
|
||||
for (const key of keys) {
|
||||
values[key] =
|
||||
options.defaultValues && key in options.defaultValues
|
||||
? (options.defaultValues[key as keyof T & string] as FormValue)
|
||||
: "";
|
||||
}
|
||||
return values;
|
||||
}, []);
|
||||
|
||||
const [state, setState] = useState<FormState>({
|
||||
values: initialValues,
|
||||
errors: {},
|
||||
modified: {},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData && typeof actionData === "object" && "errors" in actionData) {
|
||||
const errors = actionData.errors as Record<string, string>;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
errors: { ...prev.errors, ...errors },
|
||||
}));
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
const setValue = useCallback((name: keyof T & string, raw: FormValue) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
values: { ...prev.values, [name]: raw },
|
||||
modified: { ...prev.modified, [name]: true },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
function validateField(name: string) {
|
||||
if (!state.modified[name]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
// Schema validation
|
||||
const result = schema({ ...state.values } as never);
|
||||
if (result instanceof type.errors) {
|
||||
const fieldProblems = result.flatProblemsByPath[name];
|
||||
if (fieldProblems) {
|
||||
errors[name] = fieldProblems[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-field validation
|
||||
if (!errors[name]) {
|
||||
const customErrors = validateRef.current?.(state.values);
|
||||
if (customErrors?.[name]) {
|
||||
errors[name] = customErrors[name];
|
||||
}
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
errors: {
|
||||
...prev.errors,
|
||||
[name]: errors[name] ?? "",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function field(name: keyof T & string): FormFieldProps {
|
||||
const error = state.errors[name];
|
||||
return {
|
||||
name,
|
||||
value: String(state.values[name] ?? ""),
|
||||
onChange: (value: string) => setValue(name, value),
|
||||
onBlur: () => validateField(name),
|
||||
invalid: !!error,
|
||||
errorMessage: error || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function computeCanSubmit(): boolean {
|
||||
const result = schema({ ...state.values } as never);
|
||||
if (result instanceof type.errors) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const customErrors = validateRef.current?.(state.values);
|
||||
if (customErrors && Object.values(customErrors).some(Boolean)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({
|
||||
values: { ...initialValues },
|
||||
errors: {},
|
||||
modified: {},
|
||||
});
|
||||
}, [initialValues]);
|
||||
|
||||
return {
|
||||
values: state.values,
|
||||
errors: state.errors,
|
||||
field,
|
||||
canSubmit: computeCanSubmit(),
|
||||
setValue,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
type ServerValidationResult<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; errors: Record<string, string> };
|
||||
|
||||
export async function validateFormData<T extends Record<string, unknown>>(
|
||||
request: Request,
|
||||
schema: Type<T>,
|
||||
): Promise<ServerValidationResult<T>> {
|
||||
const formData = await request.formData();
|
||||
const parsed: Record<string, unknown> = {};
|
||||
|
||||
for (const key of schemaKeys(schema)) {
|
||||
const raw = formData.get(key);
|
||||
if (raw !== null) {
|
||||
parsed[key] = raw.toString();
|
||||
}
|
||||
}
|
||||
|
||||
const result = schema(parsed as never);
|
||||
if (result instanceof type.errors) {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const [path, problems] of Object.entries(result.flatProblemsByPath)) {
|
||||
errors[path] = problems[0];
|
||||
}
|
||||
return { success: false, errors };
|
||||
}
|
||||
|
||||
return { success: true, data: result as T };
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router";
|
||||
|
||||
import { ErrorBanner } from "~/components/error-banner";
|
||||
import StatusBanner from "~/components/status-banner";
|
||||
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
|
||||
import { usersResource } from "~/server/headscale/live-store";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { Route } from "./+types/app";
|
||||
import Footer from "./footer";
|
||||
import Header from "./header";
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = ({
|
||||
currentUrl,
|
||||
nextUrl,
|
||||
formAction,
|
||||
defaultShouldRevalidate,
|
||||
}) => {
|
||||
if (formAction) {
|
||||
return defaultShouldRevalidate;
|
||||
}
|
||||
|
||||
// Allow programmatic revalidations (e.g. SSE-triggered) where the URL hasn't changed
|
||||
if (currentUrl.href === nextUrl.href) {
|
||||
return defaultShouldRevalidate;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
const principal = await context.auth.require(request);
|
||||
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
|
||||
const user =
|
||||
principal.kind === "oidc"
|
||||
? {
|
||||
email: principal.profile.email,
|
||||
name: principal.profile.name,
|
||||
picture: principal.profile.picture,
|
||||
subject: principal.user.subject,
|
||||
username: principal.profile.username,
|
||||
}
|
||||
: { name: principal.displayName, subject: "api_key" };
|
||||
|
||||
// MARK: The session should stay valid if Headscale isn't healthy
|
||||
const isHealthy = await api.isHealthy();
|
||||
if (isHealthy) {
|
||||
try {
|
||||
await api.getApiKeys();
|
||||
} catch (error) {
|
||||
if (isDataUnauthorizedError(error)) {
|
||||
const displayName =
|
||||
principal.kind === "oidc" ? principal.profile.name : principal.displayName;
|
||||
log.warn("auth", "Logging out %s due to expired API key", displayName);
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.destroySession(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Self-heal: if the linked Headscale user was deleted, clear the
|
||||
// stale link so the user gets prompted to re-link.
|
||||
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
|
||||
try {
|
||||
const usersSnap = await context.hsLive.get(usersResource, api);
|
||||
if (!usersSnap.data.some((u) => u.id === principal.user.headscaleUserId)) {
|
||||
await context.auth.unlinkHeadscaleUser(principal.user.id);
|
||||
}
|
||||
} catch {
|
||||
// API call failed, skip validation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
access: {
|
||||
dns: context.auth.can(principal, Capabilities.read_network),
|
||||
machines: context.auth.can(principal, Capabilities.read_machines),
|
||||
policy: context.auth.can(principal, Capabilities.read_policy),
|
||||
settings: context.auth.can(principal, Capabilities.read_feature),
|
||||
ui: context.auth.can(principal, Capabilities.ui_access),
|
||||
users: context.auth.can(principal, Capabilities.read_users),
|
||||
},
|
||||
baseUrl: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
configAvailable: context.hs.readable(),
|
||||
isDebug: context.config.debug,
|
||||
isHealthy,
|
||||
user,
|
||||
};
|
||||
} catch {
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.destroySession(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function AppLayout({ loaderData }: Route.ComponentProps) {
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
access={loaderData.access}
|
||||
configAvailable={loaderData.configAvailable}
|
||||
user={loaderData.user}
|
||||
/>
|
||||
<main className="container mt-4 mb-24 overscroll-contain">
|
||||
{!loaderData.isHealthy && (
|
||||
<StatusBanner
|
||||
className="mb-4"
|
||||
dismissable={false}
|
||||
title="Headscale Unreachable"
|
||||
variant="critical"
|
||||
>
|
||||
Unable to connect to the Headscale server. Data shown may be stale and changes cannot be
|
||||
saved until the connection is restored.
|
||||
</StatusBanner>
|
||||
)}
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer isDebug={loaderData.isDebug} baseUrl={loaderData.baseUrl} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
return (
|
||||
<div className="mx-auto my-24 w-fit overscroll-contain">
|
||||
<ErrorBanner className="max-w-2xl" error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import Link from "~/components/link";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface FooterProps {
|
||||
isDebug: boolean;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export default function Footer({ isDebug, baseUrl }: FooterProps) {
|
||||
const [urlVisible, setUrlVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
"fixed w-full bottom-0 left-0 z-20",
|
||||
"bg-mist-50 dark:bg-mist-950",
|
||||
"dark:border-t dark:border-mist-800",
|
||||
)}
|
||||
>
|
||||
<div className="container flex items-center justify-between py-2">
|
||||
<p className="text-xs">
|
||||
Headplane is free and open-source. Please consider{" "}
|
||||
<Link external styled to="https://tale.me/sponsor">
|
||||
sponsoring
|
||||
</Link>{" "}
|
||||
to support development.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{isDebug && (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 font-medium",
|
||||
"bg-amber-100 text-amber-800",
|
||||
"dark:bg-amber-900/50 dark:text-amber-300",
|
||||
)}
|
||||
>
|
||||
Debug
|
||||
</span>
|
||||
)}
|
||||
<p className="text-mist-500 dark:text-mist-400">
|
||||
{__VERSION__} ·{" "}
|
||||
{urlVisible ? (
|
||||
<code>{baseUrl}</code>
|
||||
) : (
|
||||
<span aria-hidden="true">•••••</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={urlVisible ? "Hide server URL" : "Show server URL"}
|
||||
className={cn(
|
||||
"ml-1 inline-flex align-middle rounded-xs p-0.5",
|
||||
"text-mist-400 hover:text-mist-600",
|
||||
"dark:text-mist-500 dark:hover:text-mist-300",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
)}
|
||||
onClick={() => setUrlVisible((v) => !v)}
|
||||
>
|
||||
{urlVisible ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
Check,
|
||||
CircleQuestionMark,
|
||||
CircleUser,
|
||||
Globe,
|
||||
Lock,
|
||||
Monitor,
|
||||
Moon,
|
||||
Server,
|
||||
Settings,
|
||||
Sun,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { NavLink, unstable_useRoute as useRoute, useLocation, useSubmit } from "react-router";
|
||||
|
||||
import Link from "~/components/link";
|
||||
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
|
||||
import logoBg from "~/logo/dark-bg.svg";
|
||||
import logoDark from "~/logo/dark.svg";
|
||||
import logoLight from "~/logo/light.svg";
|
||||
import cn from "~/utils/cn";
|
||||
import type { ColorScheme } from "~/utils/color-scheme";
|
||||
|
||||
export interface HeaderProps {
|
||||
user: {
|
||||
subject: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
access: {
|
||||
ui: boolean;
|
||||
machines: boolean;
|
||||
dns: boolean;
|
||||
users: boolean;
|
||||
policy: boolean;
|
||||
settings: boolean;
|
||||
};
|
||||
configAvailable: boolean;
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ to: "/machines", icon: Server, label: "Machines", key: "machines" },
|
||||
{ to: "/users", icon: Users, label: "Users", key: "users" },
|
||||
{ to: "/acls", icon: Lock, label: "Access Control", key: "policy" },
|
||||
{ to: "/dns", icon: Globe, label: "DNS", key: "dns" },
|
||||
{ to: "/settings", icon: Settings, label: "Settings", key: "settings" },
|
||||
] as const;
|
||||
|
||||
const colorSchemes = [
|
||||
{ value: "system", label: "System", icon: Monitor },
|
||||
{ value: "light", label: "Light", icon: Sun },
|
||||
{ value: "dark", label: "Dark", icon: Moon },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
value: ColorScheme;
|
||||
label: string;
|
||||
icon: typeof Monitor;
|
||||
}>;
|
||||
|
||||
export default function Header({ user, access, configAvailable }: HeaderProps) {
|
||||
const submit = useSubmit();
|
||||
const showTabs = access.ui;
|
||||
const rootRoute = useRoute("root");
|
||||
const currentColorScheme: ColorScheme = rootRoute?.loaderData?.colorScheme ?? "system";
|
||||
// useLocation returns the path with the basename already stripped, which is
|
||||
// what `redirect()` expects — react-router re-applies the basename when
|
||||
// following the redirect on the client.
|
||||
const location = useLocation();
|
||||
const returnTo = location.pathname + location.search;
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"bg-mist-200 dark:bg-mist-950 text-mist-800 dark:text-mist-200",
|
||||
"dark:border-b dark:border-mist-800 shadow-inner",
|
||||
)}
|
||||
>
|
||||
<div className="container flex items-center gap-x-4 py-4">
|
||||
<div className="flex min-w-0 items-center gap-x-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<picture className="min-w-8">
|
||||
<source srcSet={logoLight} media="(prefers-color-scheme: dark)" />
|
||||
<source srcSet={logoDark} media="(prefers-color-scheme: light)" />
|
||||
<img src={logoBg} alt="Headplane logo" />
|
||||
</picture>
|
||||
<h1 className="text-2xl font-semibold">headplane</h1>
|
||||
</div>
|
||||
{showTabs && (
|
||||
<nav className="hidden items-center gap-x-2 overflow-x-auto p-1 text-sm font-medium md:flex">
|
||||
{tabs.map((tab) => {
|
||||
if (!access[tab.key]) return null;
|
||||
if ((tab.key === "dns" || tab.key === "settings") && !configAvailable) return null;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"px-3 py-1.5 flex items-center gap-x-1.5 rounded-md text-nowrap",
|
||||
"hover:bg-mist-300/50 dark:hover:bg-mist-800",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
isActive
|
||||
? "bg-mist-300/70 dark:bg-mist-800 text-mist-900 dark:text-mist-50"
|
||||
: "text-mist-600 dark:text-mist-300",
|
||||
)
|
||||
}
|
||||
prefetch="intent"
|
||||
to={tab.to}
|
||||
>
|
||||
<tab.icon className="w-4" />
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto grid shrink-0 grid-cols-2 gap-x-4">
|
||||
<Menu>
|
||||
<MenuTrigger className="size-8 rounded-full p-1">
|
||||
<CircleQuestionMark className="w-5" />
|
||||
</MenuTrigger>
|
||||
<MenuContent align="end">
|
||||
<MenuItem>
|
||||
<Link external to="https://headplane.net">
|
||||
Docs
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Link external to="https://headscale.net">
|
||||
Headscale
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Link external to="https://tailscale.com/download">
|
||||
Download
|
||||
</Link>
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
<Menu>
|
||||
<MenuTrigger className="size-8 overflow-hidden rounded-full">
|
||||
{user.picture ? (
|
||||
<img alt={user.name} className="size-8" src={user.picture} />
|
||||
) : (
|
||||
<CircleUser className="size-8" />
|
||||
)}
|
||||
</MenuTrigger>
|
||||
<MenuContent align="end">
|
||||
<MenuItem disabled>
|
||||
<div className="text-mist-900 dark:text-mist-50">
|
||||
{user.subject === "api_key" ? (
|
||||
<>
|
||||
<p className="font-bold">API Key</p>
|
||||
<p>{user.name}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-bold">{user.name}</p>
|
||||
{user.email && <p>{user.email}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
{colorSchemes.map(({ value, label, icon: Icon }) => (
|
||||
<MenuItem
|
||||
key={value}
|
||||
onClick={() =>
|
||||
submit(
|
||||
{ colorScheme: value, returnTo },
|
||||
{ action: "/api/color-scheme", method: "POST" },
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Icon className="size-4" />
|
||||
<span className="flex-1">{label}</span>
|
||||
{currentColorScheme === value && <Check className="size-4" />}
|
||||
</div>
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
variant="danger"
|
||||
onClick={() => submit({}, { action: "/logout", method: "POST" })}
|
||||
>
|
||||
Logout
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
{showTabs && (
|
||||
<div className="block overflow-x-auto p-2 md:hidden">
|
||||
<nav className="flex items-center gap-x-2 text-sm font-medium">
|
||||
{tabs.map((tab) => {
|
||||
if (!access[tab.key]) return null;
|
||||
if ((tab.key === "dns" || tab.key === "settings") && !configAvailable) return null;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"relative px-3 py-1.5 flex items-center gap-x-1.5 rounded-md text-nowrap",
|
||||
"hover:bg-mist-300/50 dark:hover:bg-mist-800",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
"text-mist-600 dark:text-mist-300",
|
||||
isActive &&
|
||||
"text-mist-900 dark:text-mist-50 after:content-[''] after:absolute after:-bottom-2 after:inset-x-1 after:h-0.5 after:rounded-full after:bg-indigo-500",
|
||||
)
|
||||
}
|
||||
prefetch="intent"
|
||||
to={tab.to}
|
||||
>
|
||||
<tab.icon className="w-4" />
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Form } from "react-router";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import Card from "~/components/card";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface LinkAccountProps {
|
||||
headscaleUsers: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export default function LinkAccount({ headscaleUsers }: LinkAccountProps) {
|
||||
return (
|
||||
<div className="mx-auto mt-6 flex max-w-xl flex-col items-center justify-center py-36">
|
||||
<Card variant="flat" className="max-w-xl items-center gap-4">
|
||||
<Card.Title>Link your Headscale account</Card.Title>
|
||||
<Card.Text>
|
||||
Headplane could not automatically match your SSO identity to an existing Headscale user.
|
||||
Please select your user from the list below to link your account and continue.
|
||||
</Card.Text>
|
||||
<Form method="POST" className="mt-4">
|
||||
<select
|
||||
className={cn(
|
||||
"mb-4 w-full rounded-lg border p-2",
|
||||
"border-mist-200 dark:border-mist-700",
|
||||
"bg-mist-50 dark:bg-mist-900",
|
||||
)}
|
||||
name="headscale_user_id"
|
||||
required
|
||||
>
|
||||
<option value="">Select a user...</option>
|
||||
{headscaleUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button className="w-full" type="submit" variant="heavy">
|
||||
Link and Continue
|
||||
</Button>
|
||||
</Form>
|
||||
<Card.Text className="mt-8 text-center text-xs text-mist-600 dark:text-mist-300">
|
||||
If you don't see your user listed, please contact your administrator. To automatically
|
||||
link new users in the future, ensure that the Headscale user has the same email address as
|
||||
the SSO identity.
|
||||
</Card.Text>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Outlet, redirect } from 'react-router';
|
||||
import { ErrorBanner } from '~/components/error-banner';
|
||||
import { pruneEphemeralNodes } from '~/server/db/pruner';
|
||||
import { isDataUnauthorizedError } from '~/server/headscale/api/error-client';
|
||||
import log from '~/utils/log';
|
||||
import type { Route } from './+types/dashboard';
|
||||
|
||||
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
|
||||
// MARK: The session should stay valid if Headscale isn't healthy
|
||||
const healthy = await api.isHealthy();
|
||||
if (healthy) {
|
||||
try {
|
||||
await api.getApiKeys();
|
||||
await pruneEphemeralNodes({ context, request, ...rest });
|
||||
} catch (error) {
|
||||
if (isDataUnauthorizedError(error)) {
|
||||
log.warn(
|
||||
'auth',
|
||||
'Logging out %s due to expired API key',
|
||||
session.user.name,
|
||||
);
|
||||
return redirect('/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.destroySession(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
healthy,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
return (
|
||||
<div className="w-fit mx-auto overscroll-contain my-24">
|
||||
<ErrorBanner className="max-w-2xl" error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Outlet, redirect } from "react-router";
|
||||
|
||||
import Footer from "~/components/Footer";
|
||||
import Header from "~/components/Header";
|
||||
import { users } from "~/server/db/schema";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import { Route } from "./+types/shell";
|
||||
|
||||
// This loads the bare minimum for the application to function
|
||||
// So we know that if context fails to load then well, oops?
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (
|
||||
typeof context.oidc === "object" &&
|
||||
session.user.subject !== "unknown-non-oauth" &&
|
||||
!request.url.endsWith("/onboarding")
|
||||
) {
|
||||
const [user] = await context.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, session.user.subject))
|
||||
.limit(1);
|
||||
|
||||
if (!user?.onboarded) {
|
||||
return redirect("/onboarding");
|
||||
}
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const check = await context.sessions.check(request, Capabilities.ui_access);
|
||||
|
||||
// OIDC users without ui_access go to pending approval
|
||||
if (
|
||||
!check &&
|
||||
session.user.subject !== "unknown-non-oauth" &&
|
||||
!request.url.endsWith("/onboarding")
|
||||
) {
|
||||
return redirect("/pending-approval");
|
||||
}
|
||||
|
||||
return {
|
||||
config: context.hs.c,
|
||||
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
configAvailable: context.hs.readable(),
|
||||
debug: context.config.debug,
|
||||
user: session.user,
|
||||
uiAccess: check,
|
||||
access: {
|
||||
ui: await context.sessions.check(request, Capabilities.ui_access),
|
||||
dns: await context.sessions.check(request, Capabilities.read_network),
|
||||
users: await context.sessions.check(request, Capabilities.read_users),
|
||||
policy: await context.sessions.check(request, Capabilities.read_policy),
|
||||
machines: await context.sessions.check(request, Capabilities.read_machines),
|
||||
settings: await context.sessions.check(request, Capabilities.read_feature),
|
||||
},
|
||||
onboarding: request.url.endsWith("/onboarding"),
|
||||
healthy: await api.isHealthy(),
|
||||
};
|
||||
} catch {
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.sessions.destroySession(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Shell({ loaderData }: Route.ComponentProps) {
|
||||
return (
|
||||
<>
|
||||
<Header {...loaderData} />
|
||||
<Outlet />
|
||||
<Footer {...loaderData} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 901 B After Width: | Height: | Size: 901 B |
|
Before Width: | Height: | Size: 732 B After Width: | Height: | Size: 732 B |
|
Before Width: | Height: | Size: 926 B After Width: | Height: | Size: 926 B |
|
Before Width: | Height: | Size: 742 B After Width: | Height: | Size: 742 B |
@@ -1,17 +1,22 @@
|
||||
import type { LinksFunction, MetaFunction } from "react-router";
|
||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from "react-router";
|
||||
import "@fontsource-variable/inter";
|
||||
import { ExternalScripts } from "remix-utils/external-scripts";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import {
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
unstable_useRoute as useRoute,
|
||||
} from "react-router";
|
||||
|
||||
import ProgressBar from "~/components/ProgressBar";
|
||||
import ToastProvider from "~/components/ToastProvider";
|
||||
import { LiveDataProvider } from "~/utils/live-data";
|
||||
import { useToastQueue } from "~/utils/toast";
|
||||
import ToastProvider from "~/utils/toast-provider";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import { ErrorBanner } from "./components/error-banner";
|
||||
|
||||
import stylesheet from "~/tailwind.css?url";
|
||||
import "@fontsource-variable/inter/opsz.css";
|
||||
import "./tailwind.css";
|
||||
import { getColorScheme } from "./utils/color-scheme";
|
||||
|
||||
export const meta: MetaFunction = () => [
|
||||
{ title: "Headplane" },
|
||||
@@ -21,17 +26,29 @@ export const meta: MetaFunction = () => [
|
||||
},
|
||||
];
|
||||
|
||||
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const colorScheme = await getColorScheme(request);
|
||||
return { colorScheme };
|
||||
}
|
||||
|
||||
export function Layout({ children }: { readonly children: React.ReactNode }) {
|
||||
const toastQueue = useToastQueue();
|
||||
const { loaderData } = useRoute("root");
|
||||
|
||||
// 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">
|
||||
<html
|
||||
lang="en"
|
||||
className={
|
||||
loaderData?.colorScheme === "dark"
|
||||
? "dark"
|
||||
: loaderData?.colorScheme === "light"
|
||||
? "light"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
@@ -39,12 +56,11 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
|
||||
<Links />
|
||||
<link href={`${__PREFIX__}/favicon.ico`} rel="icon" />
|
||||
</head>
|
||||
<body className="dark:bg-headplane-900 dark:text-headplane-50 overflow-x-hidden overscroll-none">
|
||||
<body className="w-full overflow-x-hidden overscroll-none dark:bg-mist-900 dark:text-mist-50">
|
||||
{children}
|
||||
<ToastProvider queue={toastQueue} />
|
||||
<ToastProvider />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
<ExternalScripts />
|
||||
</body>
|
||||
</html>
|
||||
</LiveDataProvider>
|
||||
@@ -60,12 +76,5 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const nav = useNavigation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProgressBar isVisible={nav.state === "loading"} />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@@ -2,41 +2,39 @@ 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"),
|
||||
|
||||
// API Routes
|
||||
...prefix("/api", [route("/info", "routes/util/info.ts")]),
|
||||
...prefix("/api", [
|
||||
route("/info", "routes/util/info.ts"),
|
||||
route("/color-scheme", "routes/util/color-scheme.ts"),
|
||||
]),
|
||||
...prefix("/events", [route("/live", "routes/util/live.ts")]),
|
||||
|
||||
// Authentication Routes
|
||||
route("/login", "routes/auth/login/page.tsx"),
|
||||
route("/logout", "routes/auth/logout.ts"),
|
||||
route("/oidc/callback", "routes/auth/oidc-callback.ts"),
|
||||
route("/oidc/start", "routes/auth/oidc-start.ts"),
|
||||
route("/pending-approval", "routes/auth/pending-approval.tsx"),
|
||||
route("/ssh", "routes/ssh/console.tsx"),
|
||||
route("/ssh/:id", "routes/ssh/page.tsx"),
|
||||
|
||||
// All the main logged-in dashboard routes
|
||||
// Double nested to separate error propagations
|
||||
layout("layouts/shell.tsx", [
|
||||
route("/onboarding", "routes/users/onboarding.tsx"),
|
||||
route("/onboarding/skip", "routes/users/onboarding-skip.tsx"),
|
||||
layout("layouts/dashboard.tsx", [
|
||||
...prefix("/machines", [
|
||||
index("routes/machines/overview.tsx"),
|
||||
route("/:id", "routes/machines/machine.tsx"),
|
||||
]),
|
||||
// All the main logged-in routes
|
||||
layout("layout/app.tsx", [
|
||||
index("routes/home.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"),
|
||||
route("/users", "routes/users/overview.tsx"),
|
||||
route("/acls", "routes/acls/overview.tsx"),
|
||||
route("/dns", "routes/dns/overview.tsx"),
|
||||
|
||||
...prefix("/settings", [
|
||||
index("routes/settings/overview.tsx"),
|
||||
route("/auth-keys", "routes/settings/auth-keys/overview.tsx"),
|
||||
route("/restrictions", "routes/settings/restrictions/overview.tsx"),
|
||||
// route('/local-agent', 'routes/settings/local-agent.tsx'),
|
||||
]),
|
||||
...prefix("/settings", [
|
||||
index("routes/settings/overview.tsx"),
|
||||
route("/auth-keys", "routes/settings/auth-keys/overview.tsx"),
|
||||
route("/restrictions", "routes/settings/restrictions/overview.tsx"),
|
||||
route("/agent", "routes/settings/agent.tsx"),
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
@@ -1,139 +1,129 @@
|
||||
import { data } from 'react-router';
|
||||
import { isDataWithApiError } from '~/server/headscale/api/error-client';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Route } from './+types/overview';
|
||||
import { data } from "react-router";
|
||||
|
||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
// 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 }: Route.ActionArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_policy,
|
||||
);
|
||||
if (!check) {
|
||||
throw data('You do not have permission to write to the ACL policy', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.write_policy);
|
||||
if (!check) {
|
||||
throw data("You do not have permission to write to the ACL policy", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
// 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 data('Missing `policy` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
// 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 data("Missing `policy` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
try {
|
||||
const { policy, updatedAt } = await api.setPolicy(policyData);
|
||||
return data({
|
||||
success: true,
|
||||
error: undefined,
|
||||
policy,
|
||||
updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isDataWithApiError(error)) {
|
||||
const rawData = error.data.rawData;
|
||||
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11
|
||||
if (rawData.includes('update is disabled')) {
|
||||
throw data('Policy is not writable', { status: 403 });
|
||||
}
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
try {
|
||||
const { policy, updatedAt } = await api.setPolicy(policyData);
|
||||
return data({
|
||||
success: true,
|
||||
error: undefined,
|
||||
policy,
|
||||
updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isDataWithApiError(error)) {
|
||||
const rawData = error.data.rawData;
|
||||
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11
|
||||
if (rawData.includes("update is disabled")) {
|
||||
throw data("Policy is not writable", { status: 403 });
|
||||
}
|
||||
|
||||
const message =
|
||||
error.data.data != null &&
|
||||
'message' in error.data.data &&
|
||||
typeof error.data.data.message === 'string'
|
||||
? error.data.data.message
|
||||
: undefined;
|
||||
const message =
|
||||
error.data.data != null &&
|
||||
"message" in error.data.data &&
|
||||
typeof error.data.data.message === "string"
|
||||
? error.data.data.message
|
||||
: undefined;
|
||||
|
||||
if (message == null) {
|
||||
throw error;
|
||||
}
|
||||
if (message == null) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
|
||||
// we need to reference other error messages based on API version.
|
||||
if (context.hsApi.clientHelpers.isAtleast('0.27.0')) {
|
||||
if (message.includes('parsing HuJSON:')) {
|
||||
const cutIndex = message.indexOf('parsing HuJSON:');
|
||||
const trimmed =
|
||||
cutIndex > -1
|
||||
? `Syntax error: ${message.slice(cutIndex + 16).trim()}`
|
||||
: message;
|
||||
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
|
||||
// we need to reference other error messages based on API version.
|
||||
if (context.hsApi.clientHelpers.isAtleast("0.27.0")) {
|
||||
if (message.includes("parsing HuJSON:")) {
|
||||
const cutIndex = message.indexOf("parsing HuJSON:");
|
||||
const trimmed =
|
||||
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('parsing policy from bytes:')) {
|
||||
const cutIndex = message.indexOf('parsing policy from bytes:');
|
||||
const trimmed =
|
||||
cutIndex > -1
|
||||
? `Syntax error: ${message.slice(cutIndex + 26).trim()}`
|
||||
: message;
|
||||
if (message.includes("parsing policy from bytes:")) {
|
||||
const cutIndex = message.indexOf("parsing policy from bytes:");
|
||||
const trimmed =
|
||||
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Pre-0.27.0 error messages
|
||||
if (message.includes('parsing hujson')) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Pre-0.27.0 error messages
|
||||
if (message.includes("parsing hujson")) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('unmarshalling policy')) {
|
||||
const cutIndex = message.indexOf('err:');
|
||||
const trimmed =
|
||||
cutIndex > -1
|
||||
? `Syntax error: ${message.slice(cutIndex + 5)}`
|
||||
: message;
|
||||
if (message.includes("unmarshalling policy")) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, this is a Headscale error that we can just propagate.
|
||||
throw error;
|
||||
}
|
||||
// Otherwise, this is a Headscale error that we can just propagate.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||