Compare commits

..

1 commit

Author SHA1 Message Date
Nicolas Meienberger
2e4d416c7d refactor(restic): split into multiple files 2026-01-14 08:23:34 +01:00
906 changed files with 38653 additions and 163819 deletions

View file

@ -1,19 +1,19 @@
** **
!turbo.json
!bun.lock !bun.lock
!package.json !package.json
!.gitignore
!bunfig.toml
!**/package.json !**/package.json
!**/bun.lock !**/bun.lock
!**/tsconfig.json !**/tsconfig.json
!**/vite.config.ts !**/vite.config.ts
!**/react-router.config.ts
!**/build.ts
!**/components.json !**/components.json
!src/**
!app/** !app/**
!apps/agent/**
!packages/**
!public/** !public/**
# License files and attributions # License files and attributions
@ -22,6 +22,3 @@
!LICENSES/** !LICENSES/**
node_modules/** node_modules/**
dist/**
.output/**
app/test/integration/artifacts/**

View file

@ -1,9 +1,6 @@
SERVER_IP=localhost SERVER_IP=localhost
ZEROBYTE_DATABASE_URL=./data/zerobyte.db DATABASE_URL=./data/zerobyte.db
RESTIC_PASS_FILE=./data/restic.pass
RESTIC_CACHE_DIR=./data/restic/cache RESTIC_CACHE_DIR=./data/restic/cache
ZEROBYTE_REPOSITORIES_DIR=./data/repositories ZEROBYTE_REPOSITORIES_DIR=./data/repositories
ZEROBYTE_VOLUMES_DIR=./data/volumes ZEROBYTE_VOLUMES_DIR=./data/volumes
APP_SECRET=<openssl rand -hex 32>
BASE_URL=http://*.localhost
TRUST_PROXY=false
PORT=3000

View file

@ -1,4 +1 @@
ZEROBYTE_DATABASE_URL=:memory: DATABASE_URL=:memory:
APP_SECRET=8b9acd4456dd5db0a4a3c4f4e1240b2c3ae08bb59690167197425e4a25dd9a69
BASE_URL=http://localhost:4096
ENABLE_LOCAL_AGENT=true

View file

@ -1,13 +0,0 @@
{
"entry": [
"app/client.tsx",
"app/router.tsx",
"app/server.ts",
"app/server/plugins/bootstrap.ts",
"openapi-ts.config.ts",
"scripts/create-test-files.ts",
"scripts/patch-api-client.ts"
],
"ignorePatterns": ["node_modules/**", ".output/**", "**/api-client/**", "**/routeTree.gen.ts", "apps/docs/**"],
"ignoreDependencies": ["@tailwindcss/typography", "babel-plugin-react-compiler"]
}

View file

@ -1,37 +0,0 @@
name: Feature request
description: Suggest an enhancement or new feature
title: "[FEATURE] "
labels: ["enhancement"]
assignees: []
body:
- type: textarea
id: summary
attributes:
label: Summary
description: One-line summary of the requested feature
placeholder: Brief description of what you'd like to see added
validations:
required: true
- type: textarea
id: motivation
attributes:
label: Motivation / Use case
description: Why is this feature needed? What problem does it solve?
placeholder: Describe the problem or use case that this feature would address
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: Brief implementation idea or API surface
placeholder: How do you envision this feature working?
- type: dropdown
id: impact
attributes:
label: Impact
options:
- Low
- Medium
- High
description: Estimated impact on users or project

View file

@ -1,79 +0,0 @@
name: Bug report
description: Create a report to help us fix a bug
title: "[BUG] "
labels: ["bug"]
assignees: []
body:
- type: checkboxes
id: troubleshooting
attributes:
label: Prerequisites
description: Please confirm you have completed the following steps before submitting this bug report
options:
- label: I have read the [troubleshooting guide](https://github.com/nicotsx/zerobyte/blob/main/TROUBLESHOOTING.md)
required: true
- type: checkboxes
id: rclone_preflight
attributes:
label: Rclone Pre-flight Checklist (if applicable)
description: If your issue involves rclone, confirm you've tested on the host first as described in the troubleshooting guide
options:
- label: This issue is NOT related to rclone (skip if not using rclone)
- label: I have tested `rclone listremotes` and `rclone lsd remote:` on the **host** and they work
- label: I have verified the rclone config is mounted into the container
- label: I have restarted the container after config changes
- type: textarea
id: description
attributes:
label: Bug Description
description: Brief description of what went wrong
placeholder: Describe the issue...
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: List the steps to reproduce the bug
placeholder: |
1. Go to...
2. Click on...
3. See error...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What should have happened?
validations:
required: true
- type: input
id: version
attributes:
label: Zerobyte version / commit
placeholder: e.g., v1.2.3 or commit sha
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: Deployment Method
options:
- Docker Compose
- Docker
- Local Development
- Other
- type: textarea
id: backup_context
attributes:
label: Backup/Repository Context
description: If this is related to backup operations, provide details about the repository type, storage backend, etc.
placeholder: e.g., S3 repository, local filesystem, specific backup operation that failed
- type: textarea
id: logs
attributes:
label: Logs / Error Messages
description: Check browser console (F12) or server logs for errors. Remove any sensitive information.
validations:
required: true

View file

@ -1,20 +0,0 @@
name: Question
description: General questions about usage or implementation
title: "[QUESTION] "
labels: ["question"]
assignees: []
body:
- type: textarea
id: question
attributes:
label: Question
description: Provide a clear question and any minimal code samples
placeholder: What would you like to know about Zerobyte?
validations:
required: true
- type: textarea
id: context
attributes:
label: Context / what you've tried
description: Provide context about what you're trying to accomplish and what you've already tried
placeholder: I'm trying to... and I've already tried...

View file

@ -1,21 +0,0 @@
name: Security
description: Report a security vulnerability (do not file public issues)
title: "[SECURITY] "
labels: ["security"]
assignees: []
body:
- type: markdown
attributes:
value: >-
**⚠️ SECURITY VULNERABILITY NOTICE ⚠️**
If you believe you've found a security vulnerability, **do not open a public issue**.
Please report security vulnerabilities privately through one of these methods:
- Use GitHub's private security advisory feature: [Report a vulnerability](https://github.com/nicotsx/zerobyte/security/advisories/new)
- Contact the maintainers directly via email if listed in the repository
This helps us address security issues responsibly before they can be exploited.
**Thank you for helping keep Zerobyte secure! 🔒**

View file

@ -1,19 +0,0 @@
name: Blank Issue
description: Create a blank issue for anything that doesn't fit other templates
title: ""
labels: []
assignees: []
body:
- type: textarea
id: content
attributes:
label: Issue Details
description: Describe your issue, question, or request
placeholder: |
Please provide as much detail as possible about your issue.
If this is a bug, consider using the Bug Report template instead.
If this is a feature request, consider using the Feature Request template instead.
If you have a question, consider using the Question template instead.
validations:
required: true

View file

@ -1,31 +1,15 @@
name: Install dependencies name: Install dependencies
description: Install dependencies description: Install dependencies
inputs:
workingDirectory:
description: "The working directory to install dependencies in"
required: false
default: "."
runs: runs:
using: "composite" using: "composite"
steps: steps:
- uses: socketdev/action@937f824ec476dfd164d4a4d9995751427b0be143 # v1 - uses: oven-sh/setup-bun@v2
with:
mode: firewall
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
name: Install Bun name: Install Bun
with: with:
bun-version: "1.3.14" bun-version: "1.3.5"
- uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1
with:
node-version: '24'
cache: true
- name: Install dependencies - name: Install dependencies
shell: bash shell: bash
run: | run: bun install --frozen-lockfile
cd ${{ inputs.workingDirectory }}
sfw vp install --frozen-lockfile

18
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,18 @@
version: 2
updates:
- package-ecosystem: "bun"
directory: "/"
schedule:
interval: "daily"
rebase-strategy: 'auto'
groups:
minor-patch:
update-types:
- minor
- patch
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'daily'
rebase-strategy: 'auto'

19
.github/renovate.json vendored
View file

@ -1,19 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", "helpers:pinGitHubActionDigests"],
"enabledManagers": ["bun", "github-actions"],
"minimumReleaseAge": "3 days",
"rangeStrategy": "bump",
"packageRules": [
{
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["digest", "pinDigest"],
"minimumReleaseAge": null
},
{
"matchManagers": ["bun"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "bun minor and patch dependencies"
}
]
}

View file

@ -3,6 +3,8 @@ name: Checks
on: on:
workflow_call: workflow_call:
pull_request: pull_request:
branches:
- main
push: push:
branches: branches:
- main - main
@ -10,86 +12,31 @@ on:
permissions: permissions:
contents: read contents: read
concurrency:
group: checks-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs: jobs:
lint: checks:
timeout-minutes: 10 timeout-minutes: 15
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@v6
with: with:
fetch-depth: 1 fetch-depth: 0
- name: Install dependencies - name: Install dependencies
uses: "./.github/actions/install-dependencies" uses: "./.github/actions/install-dependencies"
- name: Run lint - uses: oxc-project/oxlint-action@latest
shell: bash
run: vp lint --type-aware --deny-warnings
typecheck:
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with: with:
fetch-depth: 1 config: .oxlintrc.json
deny-warnings: true
- name: Install dependencies
uses: "./.github/actions/install-dependencies"
- name: Run type checks - name: Run type checks
shell: bash shell: bash
run: bun run tsc run: bun run tsc
test-server: - name: Run tests
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 1
- name: Install dependencies
uses: "./.github/actions/install-dependencies"
- name: Run server tests
shell: bash shell: bash
run: bun run test:server run: bun run test --ci --coverage
test-client:
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 1
- name: Install dependencies
uses: "./.github/actions/install-dependencies"
- name: Run client tests
shell: bash
run: bun run test:client
build:
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 1
- name: Install dependencies
uses: "./.github/actions/install-dependencies"
- name: Build project - name: Build project
shell: bash shell: bash

View file

@ -1,41 +0,0 @@
name: Docs deploy
permissions:
contents: read
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "apps/docs/**"
jobs:
deploy:
name: Deploy docs
if: github.event.repository.default_branch == github.ref_name
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Install dependencies
uses: "./.github/actions/install-dependencies"
- name: Build docs
working-directory: apps/docs
run: bun run build
- name: Build and deploy docs
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/docs

View file

@ -12,7 +12,7 @@ jobs:
timeout-minutes: 20 timeout-minutes: 20
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/checkout@v4
- name: Install dependencies - name: Install dependencies
uses: "./.github/actions/install-dependencies" uses: "./.github/actions/install-dependencies"
@ -26,7 +26,7 @@ jobs:
- name: Cache Playwright Browsers - name: Cache Playwright Browsers
id: playwright-cache id: playwright-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 uses: actions/cache@v4
with: with:
path: ~/.cache/ms-playwright path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }} key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
@ -46,37 +46,17 @@ jobs:
mkdir -p data mkdir -p data
mkdir -p playwright/temp mkdir -p playwright/temp
mkdir -p playwright/data mkdir -p playwright/data
mkdir -p playwright/tinyauth/app-data
mkdir -p playwright/tinyauth/caddy-data
chmod -R 777 playwright/temp chmod -R 777 playwright/temp
touch .env.local touch .env.local
echo "SERVER_IP=localhost" >> .env.local echo "SERVER_IP=localhost" >> .env.local
echo "ZEROBYTE_DATABASE_URL=./data/zerobyte.db" >> .env.local echo "DATABASE_URL=./data/zerobyte.db" >> .env.local
echo "BASE_URL=http://localhost:4096" >> .env.local
echo "E2E_TINYAUTH_ORIGIN=https://tinyauth.example.com:5557" >> .env.local
echo "TZ=Europe/Zurich" >> .env.local
- name: Start zerobyte-e2e service - name: Start zerobyte-e2e service
run: bun run start:e2e -- -d run: bun run start:e2e -- -d
- name: Check CLI version
run: |
expected_version=$(docker exec zerobyte printenv APP_VERSION)
cli_version=$(docker exec zerobyte bun run --silent cli --version)
echo "$cli_version"
test "$cli_version" = "$expected_version"
- name: Wait for zerobyte to be ready - name: Wait for zerobyte to be ready
run: | run: |
timeout 30s bash -c 'until curl -f http://localhost:4096/api/healthcheck; do echo "Waiting for server..." && sleep 2; done' timeout 30s bash -c 'until curl -f http://localhost:4096/healthcheck; do echo "Waiting for server..." && sleep 2; done'
- name: Wait for Tinyauth to be ready
run: |
timeout 30s bash -c 'until curl -skf https://localhost:5557/.well-known/openid-configuration; do echo "Waiting for Tinyauth..." && sleep 2; done'
- name: Print docker logs if failed to start
if: failure()
run: docker compose logs zerobyte-e2e tinyauth tinyauth-app || true
- name: Make playwright directory writable - name: Make playwright directory writable
run: sudo chmod 777 playwright/data/zerobyte.db run: sudo chmod 777 playwright/data/zerobyte.db
@ -87,16 +67,15 @@ jobs:
- name: Dump container logs in playwright-report folder - name: Dump container logs in playwright-report folder
if: always() if: always()
run: | run: |
tree playwright || true tree playwright
docker compose logs zerobyte-e2e > playwright-report/container-logs.txt || true docker logs zerobyte > playwright-report/container-logs.txt || true
docker compose logs tinyauth tinyauth-app > playwright-report/tinyauth-logs.txt || true
- name: Debug - print content of /test-data in container - name: Debug - print content of /test-data in container
if: failure() if: failure()
run: | run: |
docker exec zerobyte /bin/ash -c "ls -la /test-data" || true docker exec zerobyte /bin/ash -c "ls -la /test-data" || true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - uses: actions/upload-artifact@v5
if: always() if: always()
with: with:
name: playwright-report name: playwright-report

View file

@ -1,35 +0,0 @@
name: Integration Tests
permissions:
contents: read
on:
workflow_dispatch:
workflow_call:
jobs:
integration:
name: Integration
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Install dependencies
uses: "./.github/actions/install-dependencies"
- name: Run integration tests
env:
SKIP_VOLUME_MOUNT_INTEGRATION_TESTS: "true"
run: bun run test:integration
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: failure()
with:
name: integration-artifacts
path: |
app/test/integration/artifacts/compose.log
app/test/integration/artifacts/docker-output.log
app/test/integration/artifacts/runs/**
retention-days: 5

View file

@ -1,60 +0,0 @@
name: Nightly Build
on:
schedule:
- cron: "0 2 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
build-and-push:
timeout-minutes: 15
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
with:
driver: cloud
endpoint: "meienberger/runtipi-builder"
install: true
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
with:
images: ghcr.io/${{ github.repository_owner }}/zerobyte
tags: |
type=raw,value=nightly
- name: Push images to GitHub Container Registry
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
context: .
target: production
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APP_VERSION=nightly

View file

@ -8,16 +8,16 @@ on:
- "v*.*.*-alpha.*" - "v*.*.*-alpha.*"
permissions: permissions:
contents: read contents: write
packages: write
security-events: write
jobs: jobs:
determine-release-type: determine-release-type:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: {}
outputs: outputs:
release_type: ${{ steps.set-type.outputs.release_type }} release_type: ${{ steps.set-type.outputs.release_type }}
tagname: ${{ github.ref_name }} tagname: ${{ github.ref_name }}
is_minor_release: ${{ steps.set-type.outputs.is_minor_release }}
steps: steps:
- name: Set release type - name: Set release type
id: set-type id: set-type
@ -31,60 +31,74 @@ jobs:
echo "release_type=release" >> $GITHUB_OUTPUT echo "release_type=release" >> $GITHUB_OUTPUT
fi fi
if [[ $TAG =~ ^v[0-9]+\.[0-9]+\.0$ ]]; then
echo "is_minor_release=true" >> $GITHUB_OUTPUT
else
echo "is_minor_release=false" >> $GITHUB_OUTPUT
fi
checks: checks:
uses: ./.github/workflows/checks.yml uses: ./.github/workflows/checks.yml
e2e-tests: e2e-tests:
uses: ./.github/workflows/e2e.yml uses: ./.github/workflows/e2e.yml
integration-tests:
uses: ./.github/workflows/integration.yml
build-images: build-images:
environment: release
timeout-minutes: 15 timeout-minutes: 15
needs: [determine-release-type, checks, e2e-tests, integration-tests] needs: [determine-release-type, checks, e2e-tests]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.ref }} ref: ${{ github.ref }}
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 uses: docker/setup-buildx-action@v3
with: with:
driver: cloud driver: cloud
endpoint: "meienberger/runtipi-builder" endpoint: "meienberger/runtipi-builder"
install: true install: true
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build docker image
uses: docker/build-push-action@v6
with:
context: .
target: production
platforms: linux/amd64
push: false
load: true
tags: local/zerobyte:ci
build-args: |
APP_VERSION=${{ needs.determine-release-type.outputs.tagname }}
- name: Scan new image for vulnerabilities
if: needs.determine-release-type.outputs.release_type == 'release'
uses: anchore/scan-action@v7
id: scan
with:
image: local/zerobyte:ci
fail-build: true
only-fixed: true
severity-cutoff: critical
- name: upload Anchore scan report
if: needs.determine-release-type.outputs.release_type == 'release'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: ${{ steps.scan.outputs.sarif }}
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6 uses: docker/metadata-action@v5
with: with:
images: ghcr.io/${{ github.repository_owner }}/zerobyte images: ghcr.io/${{ github.repository_owner }}/zerobyte
tags: | tags: |
@ -96,7 +110,7 @@ jobs:
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }} latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
- name: Push images to GitHub Container Registry - name: Push images to GitHub Container Registry
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 uses: docker/build-push-action@v6
with: with:
context: . context: .
target: production target: production
@ -111,14 +125,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build-images, determine-release-type] needs: [build-images, determine-release-type]
if: needs.determine-release-type.outputs.release_type == 'release' if: needs.determine-release-type.outputs.release_type == 'release'
permissions:
contents: write
outputs: outputs:
id: ${{ steps.create_release.outputs.id }} id: ${{ steps.create_release.outputs.id }}
steps: steps:
- name: Create GitHub release - name: Create GitHub release
id: create_release id: create_release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 uses: softprops/action-gh-release@v2
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
@ -128,13 +140,4 @@ jobs:
name: ${{ needs.determine-release-type.outputs.tagname }} name: ${{ needs.determine-release-type.outputs.tagname }}
draft: false draft: false
prerelease: true prerelease: true
files: cli/runtipi-cli-*
request-docs-version-update:
uses: ./.github/workflows/request-docs-version-update.yml
needs: [publish-release, determine-release-type]
if: needs.determine-release-type.outputs.release_type == 'release' && needs.determine-release-type.outputs.is_minor_release == 'true'
with:
release_tag: ${{ needs.determine-release-type.outputs.tagname }}
base_branch: ${{ github.event.repository.default_branch }}
secrets:
COPILOT_ASSIGNMENT_TOKEN: ${{ secrets.COPILOT_ASSIGNMENT_TOKEN }}

View file

@ -1,122 +0,0 @@
name: Request docs version update
on:
workflow_dispatch:
inputs:
release_tag:
description: Minor release tag, for example v0.35.0
required: true
type: string
base_branch:
description: Branch Copilot should branch from
required: false
default: main
type: string
workflow_call:
inputs:
release_tag:
required: true
type: string
base_branch:
required: false
default: main
type: string
secrets:
COPILOT_ASSIGNMENT_TOKEN:
required: true
permissions:
contents: read
jobs:
request-docs-version-update:
runs-on: ubuntu-latest
steps:
- name: Create Copilot issue
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
github-token: ${{ secrets.COPILOT_ASSIGNMENT_TOKEN }}
script: |
if (!process.env.COPILOT_ASSIGNMENT_TOKEN) {
core.setFailed("COPILOT_ASSIGNMENT_TOKEN is required to assign issues to GitHub Copilot.");
return;
}
const releaseTag = process.env.RELEASE_TAG;
const baseBranch = process.env.BASE_BRANCH;
const minorRelease = releaseTag.match(/^(v\d+\.\d+)\.0$/);
if (!minorRelease) {
core.setFailed(`Docs version updates only run for minor release tags like v0.35.0. Received: ${releaseTag}`);
return;
}
const docsVersion = minorRelease[1];
const { owner, repo } = context.repo;
const targetRepo = `${owner}/${repo}`;
const releaseUrl = `https://github.com/${targetRepo}/releases/tag/${releaseTag}`;
const title = `Update Docker Compose examples to ${docsVersion}`;
const bodyLines = [
`A new Zerobyte release was published: ${releaseTag}`,
"",
`Release: ${releaseUrl}`,
"",
"Please create a pull request that updates Docker Compose documentation examples to use:",
"",
"```yaml",
`image: ghcr.io/nicotsx/zerobyte:${docsVersion}`,
"```",
"",
"Scope:",
"",
"- Update Docker image tags in `README.md`.",
"- Update Docker image tags in `apps/docs/`.",
"- Only update Zerobyte image versions in Docker Compose examples.",
"- Do not change unrelated prose, formatting, or code.",
"",
"Verification:",
"",
"- Search for stale `ghcr.io/nicotsx/zerobyte:v...` references in `README.md` and `apps/docs/`.",
"- Run any formatting or documentation checks that are appropriate for the touched files.",
];
const issue = await github.rest.issues.create({
owner,
repo,
title,
body: bodyLines.join("\n"),
});
await github.rest.issues.update({
owner,
repo,
issue_number: issue.data.number,
body: [
...bodyLines,
"",
"Pull request requirement:",
"",
`- Include \`Closes #${issue.data.number}\` in the pull request body.`,
].join("\n"),
});
await github.request("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees", {
owner,
repo,
issue_number: issue.data.number,
assignees: ["copilot-swe-agent[bot]"],
agent_assignment: {
target_repo: targetRepo,
base_branch: baseBranch,
custom_instructions: "Create a focused documentation-only PR for the release version update.",
custom_agent: "",
model: "",
},
headers: {
accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
env:
RELEASE_TAG: ${{ inputs.release_tag }}
BASE_BRANCH: ${{ inputs.base_branch }}
COPILOT_ASSIGNMENT_TOKEN: ${{ secrets.COPILOT_ASSIGNMENT_TOKEN }}

View file

@ -1,41 +0,0 @@
name: Scorecard analysis
on:
branch_protection_rule:
push:
branches: [ "main" ]
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
permissions:
security-events: write
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Run analysis
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- name: Upload artifact
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1
with:
name: SARIF file
path: results.sarif
retention-days: 5
- name: Upload to code-scanning
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
with:
sarif_file: results.sarif

21
.gitignore vendored
View file

@ -15,7 +15,7 @@ notes.md
smb-password.txt smb-password.txt
cache.db cache.db
/data data/
.env* .env*
!.env.example !.env.example
@ -28,25 +28,6 @@ node_modules/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/playwright/.auth/ /playwright/.auth/
/playwright/*.pass
/playwright/tinyauth/app-data/
/playwright/tinyauth/caddy-data/
playwright/.auth playwright/.auth
playwright/temp playwright/temp
playwright/data
.idea/
.deepsec/
.codex/
# OpenAPI error logs
openapi-ts-error-*.log
.output
tmp/
qa-output
.worktrees/
.turbo
.superset
.fallow

6
.oxfmtrc.json Normal file
View file

@ -0,0 +1,6 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 120,
"useTabs": true,
"endOfLine": "lf"
}

152
.oxlintrc.json Normal file
View file

@ -0,0 +1,152 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "unicorn", "typescript", "oxc", "import", "react", "react-perf", "node", "jsx-a11y"],
"categories": {},
"rules": {
"constructor-super": "warn",
"for-direction": "warn",
"no-async-promise-executor": "warn",
"no-caller": "warn",
"no-class-assign": "warn",
"no-compare-neg-zero": "warn",
"no-cond-assign": "warn",
"no-const-assign": "warn",
"no-constant-binary-expression": "warn",
"no-constant-condition": "warn",
"no-control-regex": "warn",
"no-debugger": "warn",
"no-delete-var": "warn",
"no-dupe-class-members": "warn",
"no-dupe-else-if": "warn",
"no-dupe-keys": "warn",
"no-duplicate-case": "warn",
"no-empty-character-class": "warn",
"no-empty-pattern": "warn",
"no-empty-static-block": "warn",
"no-eval": "warn",
"no-ex-assign": "warn",
"no-extra-boolean-cast": "warn",
"no-func-assign": "warn",
"no-global-assign": "warn",
"no-import-assign": "warn",
"no-invalid-regexp": "warn",
"no-irregular-whitespace": "warn",
"no-loss-of-precision": "warn",
"no-new-native-nonconstructor": "warn",
"no-nonoctal-decimal-escape": "warn",
"no-obj-calls": "warn",
"no-self-assign": "warn",
"no-setter-return": "warn",
"no-shadow-restricted-names": "warn",
"no-sparse-arrays": "warn",
"no-this-before-super": "warn",
"no-unassigned-vars": "warn",
"no-unsafe-finally": "warn",
"no-unsafe-negation": "warn",
"no-unsafe-optional-chaining": "warn",
"no-unused-expressions": "warn",
"no-unused-labels": "warn",
"no-unused-private-class-members": "warn",
"no-unused-vars": [
"warn",
{
"caughtErrorsIgnorePattern": "^_",
"argsIgnorePattern": "^_"
}
],
"no-useless-backreference": "warn",
"no-useless-catch": "warn",
"no-useless-escape": "warn",
"no-useless-rename": "warn",
"no-with": "warn",
"require-yield": "warn",
"use-isnan": "warn",
"valid-typeof": "warn",
"oxc/bad-array-method-on-arguments": "warn",
"oxc/bad-char-at-comparison": "warn",
"oxc/bad-comparison-sequence": "warn",
"oxc/bad-min-max-func": "warn",
"oxc/bad-object-literal-comparison": "warn",
"oxc/bad-replace-all-arg": "warn",
"oxc/const-comparisons": "warn",
"oxc/double-comparisons": "warn",
"oxc/erasing-op": "warn",
"oxc/missing-throw": "warn",
"oxc/number-arg-out-of-range": "warn",
"oxc/only-used-in-recursion": "warn",
"oxc/uninvoked-array-callback": "warn",
"typescript/await-thenable": "warn",
"typescript/no-array-delete": "warn",
"typescript/no-base-to-string": "warn",
"typescript/no-duplicate-enum-values": "warn",
"typescript/no-duplicate-type-constituents": "warn",
"typescript/no-extra-non-null-assertion": "warn",
"typescript/no-floating-promises": "warn",
"typescript/no-for-in-array": "warn",
"typescript/no-implied-eval": "warn",
"typescript/no-meaningless-void-operator": "warn",
"typescript/no-misused-new": "warn",
"typescript/no-misused-spread": "warn",
"typescript/no-non-null-asserted-optional-chain": "warn",
"typescript/no-redundant-type-constituents": "warn",
"typescript/no-this-alias": "warn",
"typescript/no-unnecessary-parameter-property-assignment": "warn",
"typescript/no-unsafe-declaration-merging": "warn",
"typescript/no-unsafe-unary-minus": "warn",
"typescript/no-useless-empty-export": "warn",
"typescript/no-wrapper-object-types": "warn",
"typescript/prefer-as-const": "warn",
"typescript/require-array-sort-compare": "warn",
"typescript/restrict-template-expressions": "warn",
"typescript/triple-slash-reference": "warn",
"typescript/unbound-method": "warn",
"unicorn/no-await-in-promise-methods": "warn",
"unicorn/no-empty-file": "warn",
"unicorn/no-invalid-fetch-options": "warn",
"unicorn/no-invalid-remove-event-listener": "warn",
"unicorn/no-new-array": "warn",
"unicorn/no-single-promise-in-promise-methods": "warn",
"unicorn/no-thenable": "warn",
"unicorn/no-unnecessary-await": "warn",
"unicorn/no-useless-fallback-in-spread": "warn",
"unicorn/no-useless-length-check": "warn",
"unicorn/no-useless-spread": "warn",
"unicorn/prefer-set-size": "warn",
"unicorn/prefer-string-starts-ends-with": "warn",
"import/no-cycle": "error",
"eslint/no-console": ["warn", { "allow": ["warn", "error", "info"] }]
},
"settings": {
"jsx-a11y": {
"polymorphicPropName": null,
"components": {},
"attributes": {}
},
"next": {
"rootDir": []
},
"react": {
"formComponents": [],
"linkComponents": [],
"version": null
},
"jsdoc": {
"ignorePrivate": false,
"ignoreInternal": false,
"ignoreReplacesDocs": true,
"overrideReplacesDocs": true,
"augmentsExtendsReplacesDocs": false,
"implementsReplacesDocs": false,
"exemptDestructuredRootsFromChecks": false,
"tagNamePreference": {}
},
"vitest": {
"typecheck": false
}
},
"env": {
"builtin": true
},
"globals": {},
"ignorePatterns": ["**/api-client/**"]
}

View file

@ -1 +0,0 @@
vp staged

View file

@ -1,3 +0,0 @@
{
"editor.defaultFormatter": "oxc.oxc-vscode"
}

211
AGENTS.md
View file

@ -1,29 +1,34 @@
# AGENTS.md
## Important instructions ## Important instructions
- Never create migration files manually. Always use the provided command to generate migrations - Never create migration files manually. Always use the provided command to generate migrations
- If you realize an automated migration is incorrect, make sure to remove all the associated entries from the `_journal.json` and the newly created files located in `app/drizzle/` before re-generating the migration - If you realize an automated migration is incorrect, make sure to remove all the associated entries from the `_journal.json` and the newly created files located in `app/drizzle/` before re-generating the migration
- The dev server runs through Portless. Start it with `bun run dev`, then use `portless get zerobyte` to get the current worktree-specific URL. Do not assume a fixed port like `3000` or `4096`. Username is `admin` and password is `password` - Never write barrel files. Always use explicit named imports/exports
- The repo is https://github.com/nicotsx/zerobyte
- If you need to run a specific restic command on a repository, you can open and use the dev panel with `Meta+Shift+D`
## Project Overview ## Project Overview
Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, SFTP, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage). Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, SFTP, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage).
### Development Server ## Technology Stack
```bash - **Runtime**: Bun 1.3.1
# Start the dev server through Portless - **Server**: Hono (web framework) with Bun runtime
bun run dev - **Client**: React Router v7 (SSR) with React 19
- **Database**: SQLite with Drizzle ORM
- **Validation**: ArkType for runtime schema validation
- **Styling**: Tailwind CSS v4 + Radix UI components
- **Architecture**: Unified application structure (not a monorepo)
- **Code Quality**: Biome (formatter & linter)
# Get the current app URL for this worktree ## Repository Structure
portless get zerobyte
# Inspect active Portless routes if needed This is a unified application with the following structure:
portless list
```
Portless applies git worktree prefixes automatically, so linked worktrees may return URLs like `https://branch-name.zerobyte.localhost`. Use the Portless URL for browser testing and manual verification. - `app/server` - Bun-based API server with Hono
- `app/client` - React Router SSR frontend components and modules
- `app/schemas` - Shared ArkType schemas for validation
- `app/drizzle` - Database migrations
### Type Checking ### Type Checking
@ -39,7 +44,7 @@ bun run tsc
bun run test bun run test
# Run a specific test file # Run a specific test file
bunx dotenv-cli -e .env.test -- bunx --bun vitest run --project server path/to/test.ts bunx dotenv-cli -e .env.test -- bun test --preload ./app/test/setup.ts path/to/test.ts
``` ```
### Building ### Building
@ -63,20 +68,184 @@ bunx drizzle-kit generate --custom --name=fix-timestamps-to-ms
### API Client Generation ### API Client Generation
```bash ```bash
# Generate TypeScript API client from OpenAPI spec
# Note: Server is always running don't need to start it separately
bun run gen:api-client bun run gen:api-client
``` ```
### Code Quality ### Code Quality
```bash ```bash
# Format # Format and lint (Biome)
vp fmt <path> --write bunx biome check --write .
# Lint # Format only
bun run lint bunx biome format --write .
# Lint only
bunx biome lint .
``` ```
### Invalidation ## Architecture
The frontend has an automatic invalidation setup which runs after every mutation. ### Server Architecture
Do not implement any invalidation logic in the frontend.
The server follows a modular service-oriented architecture:
**Entry Point**: `app/server/index.ts`
- Initializes main API server on port 4096 (REST API + serves static frontend)
**Modules** (`app/server/modules/`):
Each module follows a controller <20> service <20> database pattern:
- `auth/` - User authentication and session management
- `volumes/` - Volume mounting/unmounting (NFS, SMB, WebDAV, SFTP, directories)
- `repositories/` - Restic repository management (S3, Azure, GCS, local, rclone)
- `backups/` - Backup schedule management and execution
- `notifications/` - Notification system with multiple providers (Discord, email, Gotify, Ntfy, Slack, Pushover)
- `driver/` - Docker volume plugin implementation
- `events/` - Server-Sent Events for real-time updates
- `system/` - System information and capabilities
- `lifecycle/` - Application startup/shutdown hooks
**Backends** (`app/server/modules/backends/`):
Each volume backend (NFS, SMB, WebDAV, SFTP, directory) implements mounting logic using system tools (mount.nfs, mount.cifs, davfs2, sshfs).
**Jobs** (`app/server/jobs/`):
Cron-based background jobs managed by the Scheduler:
- `backup-execution.ts` - Runs scheduled backups (every minute)
- `cleanup-dangling.ts` - Removes stale mounts (hourly)
- `healthchecks.ts` - Checks volume health (every 5 minutes)
- `repository-healthchecks.ts` - Validates repositories (every 10 minutes)
- `cleanup-sessions.ts` - Expires old sessions (daily)
**Core** (`app/server/core/`):
- `scheduler.ts` - Job scheduling system using node-cron
- `capabilities.ts` - Detects available system features
- `constants.ts` - Application-wide constants
**Utils** (`app/server/utils/`):
- `restic.ts` - Restic CLI wrapper with type-safe output parsing
- `spawn.ts` - Safe subprocess execution helpers
- `logger.ts` - Winston-based logging
- `crypto.ts` - Encryption utilities
- `errors.ts` - Error handling middleware
**Database** (`app/server/db/`):
- Uses Drizzle ORM with SQLite
- Schema in `schema.ts` defines: volumes, repositories, backup schedules, notifications, users, sessions
- Migrations: `app/drizzle/`
### Client Architecture
**Framework**: React Router v7 with SSR
**Entry Point**: `app/root.tsx`
The client uses:
- TanStack Query for server state management
- Auto-generated API client from OpenAPI spec (in `app/client/api-client/`)
- Radix UI primitives with custom Tailwind styling
- Server-Sent Events hook (`use-server-events.ts`) for real-time updates
Routes are organized in feature modules at `app/client/modules/*/routes/`.
### Shared Schemas
`app/schemas/` contains ArkType schemas used by both client and server:
- Volume configurations (NFS, SMB, WebDAV, SFTP, directory)
- Repository configurations (S3, Azure, GCS, local, rclone)
- Restic command output parsing types
- Backend status types
These schemas provide runtime validation and TypeScript types.
## Restic Integration
Zerobyte is a wrapper around Restic for backup operations. Key integration points:
**Repository Management**:
- Creates/initializes Restic repositories via `restic init`
- Supports multiple backends: local, S3, Azure Blob Storage, Google Cloud Storage, or any rclone-supported backend
- Stores single encryption password in `/var/lib/zerobyte/restic/password` (auto-generated on first run)
**Backup Operations**:
- Executes `restic backup` with user-defined schedules (cron expressions)
- Supports include/exclude patterns for selective backups
- Parses JSON output for progress tracking and statistics
- Implements retention policies via `restic forget --prune`
**Repository Utilities** (`utils/restic.ts`):
- `buildRepoUrl()` - Constructs repository URLs for different backends
- `buildEnv()` - Sets environment variables (credentials, cache dir)
- `ensurePassfile()` - Manages encryption password file
- Type-safe parsing of Restic JSON output using ArkType schemas
**Rclone Integration** (`app/server/modules/repositories/`):
- Allows using any rclone backend as a Restic repository
- Dynamically generates rclone config and passes via environment variables
- Supports backends like Dropbox, Google Drive, OneDrive, Backblaze B2, etc.
## Environment & Configuration
**Runtime Environment Variables**:
- Database path: `./data/zerobyte.db` (configurable via `drizzle.config.ts`)
- Restic cache: `/var/lib/zerobyte/restic/cache`
- Restic password: `/var/lib/zerobyte/restic/password`
- Volume mounts: `/var/lib/zerobyte/mounts/<volume-name>`
- Local repositories: `/var/lib/zerobyte/repositories/<repo-name>`
**Capabilities Detection**:
On startup, the server detects available capabilities (see `core/capabilities.ts`):
- **rclone**: Requires `/root/.config/rclone` directory access
- System will gracefully degrade if capabilities are unavailable
## Common Workflows
### Adding a New Volume Backend
1. Create backend implementation in `app/server/modules/backends/<backend>/`
2. Implement `mount()` and `unmount()` methods
3. Add schema to `app/schemas/volumes.ts`
4. Update `volumeConfigSchema` discriminated union
5. Update backend factory in `app/server/modules/backends/backend.ts`
### Adding a New Repository Backend
1. Add backend type to `app/schemas/restic.ts`
2. Update `buildRepoUrl()` in `app/server/utils/restic.ts`
3. Update `buildEnv()` to handle credentials/configuration
4. Add DTO schemas in `app/server/modules/repositories/repositories.dto.ts`
5. Update repository service to handle new backend
### Adding a New Scheduled Job
1. Create job class in `app/server/jobs/<job-name>.ts` extending `Job`
2. Implement `run()` method
3. Register in `app/server/modules/lifecycle/startup.ts` with cron expression:
```typescript
Scheduler.build(YourJob).schedule("* * * * *");
```
## Important Notes
- **Code Style**: Uses Biome with tabs (not spaces), 120 char line width, double quotes
- **Imports**: Organize imports is disabled in Biome - do not auto-organize
- **TypeScript**: Uses `"type": "module"` - all imports must include extensions when targeting Node/Bun
- **Validation**: Prefer ArkType over Zod - it's used throughout the codebase
- **Database**: Timestamps are stored as Unix epoch integers, not ISO strings
- **Security**: Restic password file has 0600 permissions - never expose it
- **Mounting**: Requires privileged container or CAP_SYS_ADMIN for FUSE mounts
- **API Documentation**: OpenAPI spec auto-generated at `/api/v1/openapi.json`, docs at `/api/v1/docs`

View file

@ -1,8 +1,10 @@
FROM oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0 AS base ARG BUN_VERSION="1.3.5"
FROM oven/bun:${BUN_VERSION}-alpine AS base
ARG RESTIC_VERSION="0.18.1" ARG RESTIC_VERSION="0.18.1"
ARG RCLONE_VERSION="1.74.2" ARG RCLONE_VERSION="1.72.1"
ARG SHOUTRRR_VERSION="0.15.1" ARG SHOUTRRR_VERSION="0.13.1"
ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
VITE_RCLONE_VERSION=${RCLONE_VERSION} \ VITE_RCLONE_VERSION=${RCLONE_VERSION} \
@ -10,7 +12,7 @@ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
RUN apk update --no-cache && \ RUN apk update --no-cache && \
apk upgrade --no-cache && \ apk upgrade --no-cache && \
apk add --no-cache acl attr cifs-utils davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini nfs-utils cifs-utils util-linux
ENTRYPOINT ["/sbin/tini", "-s", "--"] ENTRYPOINT ["/sbin/tini", "-s", "--"]
@ -29,30 +31,21 @@ RUN apk add --no-cache curl bzip2 unzip tar
RUN echo "Building for ${TARGETARCH}" RUN echo "Building for ${TARGETARCH}"
RUN if [ "${TARGETARCH}" = "arm64" ]; then \ RUN if [ "${TARGETARCH}" = "arm64" ]; then \
curl -fL -o restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_arm64.bz2"; \ curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_arm64.bz2"; \
curl -fL -o rclone.zip "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-arm64.zip"; \ curl -L -o rclone.zip "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-arm64.zip"; \
unzip rclone.zip; \ unzip rclone.zip; \
curl -fL -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v${SHOUTRRR_VERSION}/shoutrrr_linux_arm64v8_${SHOUTRRR_VERSION}.tar.gz"; \ curl -L -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v${SHOUTRRR_VERSION}/shoutrrr_linux_arm64v8_${SHOUTRRR_VERSION}.tar.gz"; \
elif [ "${TARGETARCH}" = "amd64" ]; then \ elif [ "${TARGETARCH}" = "amd64" ]; then \
curl -fL -o restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_amd64.bz2"; \ curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_amd64.bz2"; \
curl -fL -o rclone.zip "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-amd64.zip"; \ curl -L -o rclone.zip "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-amd64.zip"; \
unzip rclone.zip; \ unzip rclone.zip; \
curl -fL -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v$SHOUTRRR_VERSION/shoutrrr_linux_amd64_${SHOUTRRR_VERSION}.tar.gz"; \ curl -L -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v$SHOUTRRR_VERSION/shoutrrr_linux_amd64_${SHOUTRRR_VERSION}.tar.gz"; \
fi fi
RUN bzip2 -d restic.bz2 && chmod +x restic RUN bzip2 -d restic.bz2 && chmod +x restic
RUN mv rclone-v*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone RUN mv rclone-v*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone
RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr
# ------------------------------
# RUNTIME TOOLS
# ------------------------------
FROM base AS runtime-tools
COPY --from=deps /deps/restic /usr/local/bin/restic
COPY --from=deps /deps/rclone /usr/local/bin/rclone
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
# ------------------------------ # ------------------------------
# DEVELOPMENT # DEVELOPMENT
# ------------------------------ # ------------------------------
@ -70,16 +63,12 @@ COPY --from=deps /deps/rclone /usr/local/bin/rclone
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
COPY ./package.json ./bun.lock ./ COPY ./package.json ./bun.lock ./
COPY ./packages/core/package.json ./packages/core/package.json
COPY ./packages/contracts/package.json ./packages/contracts/package.json
COPY ./apps/agent/package.json ./apps/agent/package.json
COPY ./apps/docs/package.json ./apps/docs/package.json
RUN VITE_GIT_HOOKS=0 bun install --frozen-lockfile --ignore-scripts RUN bun install --frozen-lockfile --ignore-scripts
COPY . . COPY . .
EXPOSE 3000 EXPOSE 4096
CMD ["bun", "run", "dev"] CMD ["bun", "run", "dev"]
@ -90,38 +79,32 @@ FROM base AS builder
ARG APP_VERSION=dev ARG APP_VERSION=dev
ENV VITE_APP_VERSION=${APP_VERSION} ENV VITE_APP_VERSION=${APP_VERSION}
ENV PORT=4096
WORKDIR /app WORKDIR /app
COPY ./package.json ./bun.lock ./ COPY ./package.json ./bun.lock ./
COPY ./packages/core/package.json ./packages/core/package.json RUN bun install --frozen-lockfile
COPY ./packages/contracts/package.json ./packages/contracts/package.json
COPY ./apps/agent/package.json ./apps/agent/package.json
COPY ./apps/docs/package.json ./apps/docs/package.json
RUN VITE_GIT_HOOKS=0 bun install --frozen-lockfile
COPY . . COPY . .
RUN bun run build RUN bun run build
RUN bun build apps/agent/src/index.ts --outfile .output/agent/index.mjs --target bun
FROM base AS production FROM base AS production
ARG APP_VERSION=dev ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION} ENV APP_VERSION=${APP_VERSION}
ENV NODE_ENV="production" ENV NODE_ENV="production"
ENV PORT=4096
WORKDIR /app WORKDIR /app
COPY --from=builder /app/package.json ./ COPY --from=builder /app/package.json ./
RUN bun install --production --frozen-lockfile
COPY --from=deps /deps/restic /usr/local/bin/restic COPY --from=deps /deps/restic /usr/local/bin/restic
COPY --from=deps /deps/rclone /usr/local/bin/rclone COPY --from=deps /deps/rclone /usr/local/bin/rclone
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
COPY --from=builder /app/.output ./.output COPY --from=builder /app/dist/client ./dist/client
COPY --from=builder /app/dist/server ./dist/server
COPY --from=builder /app/app/drizzle ./assets/migrations COPY --from=builder /app/app/drizzle ./assets/migrations
# Include third-party licenses and attribution # Include third-party licenses and attribution
@ -131,4 +114,5 @@ COPY ./LICENSE ./LICENSE.md
EXPOSE 4096 EXPOSE 4096
CMD ["bun", ".output/server/index.mjs"] CMD ["bun", "run", "start"]

200
README.md
View file

@ -15,33 +15,23 @@
</figure> </figure>
</div> </div>
#### Join the community
[![Discord](https://img.shields.io/discord/1466834119873925263?label=discord&logo=discord)](https://discord.gg/XjgVyXrcEH)
> [!WARNING] > [!WARNING]
> Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedback. Please open issues for bugs or feature requests. > Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedbacks. Expect bugs! Please open issues or feature requests
<p align="center"> <p align="center">
<a href="https://www.buymeacoffee.com/nicotsx" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a> <a href="https://www.buymeacoffee.com/nicotsx" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
</p> </p>
## Introduction ## Intro
Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage. Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage.
## Documentation
The official documentation website is available at [zerobyte.app](https://zerobyte.app).
It contains up-to-date setup guides, configuration reference, and usage documentation for running Zerobyte in production.
### Features ### Features
- **Automated backups** with encryption, compression, and retention policies, powered by Restic - &nbsp; **Automated backups** with encryption, compression and retention policies powered by Restic
- **Flexible scheduling** for automated backup jobs with fine-grained retention policies - &nbsp; **Flexible scheduling** For automated backup jobs with fine-grained retention policies
- **End-to-end encryption** will ensure your data is always protected - &nbsp; **End-to-end encryption** ensuring your data is always protected
- **Multi-protocol support** for backup from NFS, SMB, WebDAV, SFTP, or local directories - &nbsp; **Multi-protocol support**: Backup from NFS, SMB, WebDAV, SFTP, or local directories
## Installation ## Installation
@ -50,7 +40,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
```yaml ```yaml
services: services:
zerobyte: zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.38 image: ghcr.io/nicotsx/zerobyte:v0.22
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@ -60,30 +50,17 @@ services:
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
environment: environment:
- TZ=Europe/Zurich # Set your timezone here - TZ=Europe/Paris # Set your timezone here
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
volumes: volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte - /var/lib/zerobyte:/var/lib/zerobyte
``` ```
> [!WARNING] > [!WARNING]
> It is highly discouraged to run Zerobyte on a server that is accessible from the internet (VPS or home server with port forwarding). If you do, make sure to change the port mapping to "127.0.0.1:4096:4096" and use a secure tunnel (SSH tunnel, Cloudflare Tunnel, etc.) with authentication. > It is highly discouraged to run Zerobyte on a server that is accessible from the internet (VPS or home server with port forwarding) If you do, make sure to change the port mapping to "127.0.0.1:4096:4096" and use a secure tunnel (SSH tunnel, Cloudflare Tunnel, etc.) with authentication.
> [!WARNING] > [!WARNING]
> Do not try to point `/var/lib/zerobyte` to a network share. You will face permission issues and strong performance degradation. > Do not try to point `/var/lib/zerobyte` on a network share. You will face permission issues and strong performance degradation.
> [!NOTE]
> **TrueNAS Users:** The host path `/var/lib` is ephemeral on TrueNAS and will be reset during system upgrades. Instead of using `/var/lib/zerobyte:/var/lib/zerobyte`, create a dedicated ZFS dataset (e.g., `tank/docker/zerobyte`) and mount it instead:
>
> ```yaml
> volumes:
> - /etc/localtime:/etc/localtime:ro
> - /mnt/tank/docker/zerobyte:/var/lib/zerobyte
> ```
>
> This ensures your configuration, encryption keys, and database persist across TrueNAS upgrades.
Then, run the following command to start Zerobyte: Then, run the following command to start Zerobyte:
@ -97,74 +74,41 @@ Once the container is running, you can access the web interface at `http://<your
Zerobyte can be customized using environment variables. Below are the available options: Zerobyte can be customized using environment variables. Below are the available options:
### Environment variables ### Environment Variables
| Variable | Description | Default | | Variable | Description | Default |
| :------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- | | :-------------------- | :----------------------------------------------------------------------------------------------------------------- | :--------- |
| `BASE_URL` | **Required.** The base URL of your Zerobyte instance (e.g., `https://zerobyte.example.com`). See [Authentication](#authentication) below. | (none) | | `PORT` | The port the web interface and API will listen on. | `4096` |
| `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) | | `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` |
| `APP_SECRET_FILE` | Path to a file containing `APP_SECRET`, useful with Docker or Kubernetes secrets. Mutually exclusive with `APP_SECRET`. | (none) | | `TZ` | Timezone for the container (e.g., `Europe/Paris`). **Crucial for accurate backup scheduling.** | `UTC` |
| `PORT` | The port the web interface and API will listen on. | `4096` | | `TRUSTED_ORIGINS` | Comma-separated list of trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (none) |
| `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` | | `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` |
| `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` | | `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` |
| `TRUST_PROXY` | When `true`, trust an existing `X-Forwarded-For` header from your reverse proxy. Leave `false` for direct deployments. | `false` |
| `TRUSTED_ORIGINS` | Comma-separated list of extra trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (none) |
| `WEBHOOK_ALLOWED_ORIGINS` | Comma-separated list of HTTP origins allowed for backup webhooks and outbound HTTP notification destinations. | (none) |
| `WEBHOOK_TIMEOUT` | Timeout for backup webhook requests in seconds. | `60` |
| `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` |
| `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` |
| `RCLONE_CONFIG_DIR` | Path to the directory containing `rclone.conf` inside the container. Change this if running as a non-root user. | `/root/.config/rclone` |
| `PROVISIONING_PATH` | Path to a JSON file with operator-managed repositories and volumes to sync at startup. | (none) |
### Webhook and notification network policy ### Secret References
Backup webhooks and outbound notification destinations that can target arbitrary network hosts are restricted by `WEBHOOK_ALLOWED_ORIGINS`. For enhanced security, Zerobyte supports dynamic secret resolution for sensitive fields (like passwords, access keys, etc.) in volume and repository configurations. Instead of storing the encrypted secret in the database, you can use one of the following prefixes:
The allowlist matches exact origins only: scheme, host, and port must match. Paths are ignored, so `https://hooks.example.com/backups` allows any path on `https://hooks.example.com`, but it does not allow `http://hooks.example.com`, `https://hooks.example.com:8443`, or `https://other.example.com`. - `env://VAR_NAME`: Reads the secret from the environment variable `VAR_NAME`.
- `file://SECRET_NAME`: Reads the secret from `/run/secrets/SECRET_NAME` (standard Docker Secrets path).
This policy applies to: **Example:**
When configuring an S3 repository, you can set the Secret Access Key to `env://S3_SECRET_KEY` and then provide that variable in your `docker-compose.yml`.
- backup pre/post webhook URLs ### Simplified setup (No remote mounts)
- Generic HTTP notification URLs
- Gotify server URLs
- self-hosted ntfy server URLs
- custom Shoutrrr URLs that point at generic HTTP or SMTP network targets
The public ntfy.sh service and fixed-provider notification services such as Slack, Discord, Pushover, and Telegram do not need `WEBHOOK_ALLOWED_ORIGINS`. If you only need to back up locally mounted folders and don't require remote share mounting capabilities, you can remove the `SYS_ADMIN` capability and FUSE device from your `docker-compose.yml`:
Backup webhooks do not follow redirects. Add the final destination origin to `WEBHOOK_ALLOWED_ORIGINS` and configure that final URL directly.
Webhook headers are stored as plain text and must use one `Key: Value` header per line. `WEBHOOK_TIMEOUT` controls backup pre/post webhook request timeouts; notification delivery uses the underlying provider sender behavior.
### Provisioned resources
Zerobyte can sync operator-managed repositories and volumes from a JSON file at startup. This is useful when you want credentials or connection details to live in deployment-time configuration instead of being entered through the UI.
Provisioned resources:
- appear in the normal repositories and volumes screens
- can resolve credential fields from environment variables or `/run/secrets/*` during startup sync
The complete provisioning documentation is available at [zerobyte.app/docs/guides/provisioning](https://zerobyte.app/docs/guides/provisioning).
See `examples/provisioned-resources/README.md` for a full example.
### Simplified setup (no remote mounts)
If you only need to back up locally-mounted folders and don't require remote share mounting capabilities, you can remove the `SYS_ADMIN` capability and FUSE device from your `docker-compose.yml`:
```yaml ```yaml
services: services:
zerobyte: zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.38 image: ghcr.io/nicotsx/zerobyte:v0.22
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
ports: ports:
- "4096:4096" - "4096:4096"
environment: environment:
- TZ=Europe/Zurich # Set your timezone here - TZ=Europe/Paris # Set your timezone here
- BASE_URL=http://localhost:4096 # Change this to your actual URL (use https:// for secure cookies)
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
volumes: volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte - /var/lib/zerobyte:/var/lib/zerobyte
@ -174,8 +118,8 @@ services:
**Trade-offs:** **Trade-offs:**
- ✅ Improved security by reducing container capabilities - ✅ Improved security by reducing container capabilities
- ✅ Support for local directories as backup sources - ✅ Support for local directories
- ✅ Support all repository types, local and remote (S3, GCS, Azure, rclone) - ✅ Keep support all repository types (local, S3, GCS, Azure, rclone)
- ❌ Cannot mount NFS, SMB, WebDAV, or SFTP shares directly from Zerobyte - ❌ Cannot mount NFS, SMB, WebDAV, or SFTP shares directly from Zerobyte
If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`. If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`.
@ -190,12 +134,12 @@ Zerobyte supports multiple volume backends including NFS, SMB, WebDAV, SFTP, and
To add your first volume, navigate to the "Volumes" section in the web interface and click on "Create volume". Fill in the required details such as volume name, type, and connection settings. To add your first volume, navigate to the "Volumes" section in the web interface and click on "Create volume". Fill in the required details such as volume name, type, and connection settings.
If you want to backup a local directory on the same host where Zerobyte is running, you'll first need to mount that directory into the Zerobyte container. You can do this by adding a volume mapping in your `docker-compose.yml` file. For example, to mount `/path/to/your/directory` from the host to `/mydata` in the container, you would add the following line under the `volumes` section: If you want to track a local directory on the same server where Zerobyte is running, you'll first need to mount that directory into the Zerobyte container. You can do this by adding a volume mapping in your `docker-compose.yml` file. For example, to mount `/path/to/your/directory` from the host to `/mydata` in the container, you would add the following line under the `volumes` section:
```diff ```diff
services: services:
zerobyte: zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.38 image: ghcr.io/nicotsx/zerobyte:v0.22
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@ -205,9 +149,7 @@ services:
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
environment: environment:
- TZ=Europe/Zurich - TZ=Europe/Paris
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
volumes: volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte - /var/lib/zerobyte:/var/lib/zerobyte
@ -223,17 +165,17 @@ docker compose up -d
Now, when adding a new volume in the Zerobyte web interface, you can select "Directory" as the volume type and search for your mounted path (e.g., `/mydata`) as the source path. Now, when adding a new volume in the Zerobyte web interface, you can select "Directory" as the volume type and search for your mounted path (e.g., `/mydata`) as the source path.
![Add a new volume UI](https://github.com/nicotsx/zerobyte/blob/main/screenshots/add-volume.png?raw=true) ![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/add-volume.png?raw=true)
## Creating a repository ## Creating a repository
A repository is where your backups will be securely stored encrypted. Zerobyte supports multiple storage backends for your backup repositories: A repository is where your backups will be securely stored encrypted. Zerobyte supports multiple storage backends for your backup repositories:
- **Local directories**: Store backups on local disk subfolders in `/var/lib/zerobyte/repositories/` or any other (mounted) path - **Local directories** - Store backups on local disk subfolder of `/var/lib/zerobyte/repositories/` or any other (mounted) path
- **S3-compatible storage**: Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc. - **S3-compatible storage** - Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
- **Google Cloud Storage**: Google's cloud storage service - **Google Cloud Storage** - Google's cloud storage service
- **Azure Blob Storage**: Microsoft Azure storage - **Azure Blob Storage** - Microsoft Azure storage
- **rclone remotes**: 40+ cloud storage providers via rclone (see below) - **rclone remotes** - 40+ cloud storage providers via rclone (see below)
Repositories are optimized for storage efficiency and data integrity, leveraging Restic's deduplication and encryption features. Repositories are optimized for storage efficiency and data integrity, leveraging Restic's deduplication and encryption features.
@ -270,7 +212,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
```diff ```diff
services: services:
zerobyte: zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.38 image: ghcr.io/nicotsx/zerobyte:v0.22
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@ -280,23 +222,13 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
environment: environment:
- TZ=Europe/Zurich - TZ=Europe/Paris
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
volumes: volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte - /var/lib/zerobyte:/var/lib/zerobyte
+ - ~/.config/rclone:/root/.config/rclone:ro + - ~/.config/rclone:/root/.config/rclone
``` ```
> **Note for non-root users:** If your container runs as a different user (e.g., TrueNAS apps), mount your config to the appropriate location and set `RCLONE_CONFIG_DIR`:
>
> ```yaml
> environment:
> - RCLONE_CONFIG_DIR=/home/appuser/.config/rclone
> volumes:
> - ~/.config/rclone:/home/appuser/.config/rclone:ro
> ```
5. **Restart the Zerobyte container**: 5. **Restart the Zerobyte container**:
```bash ```bash
@ -324,49 +256,19 @@ When creating a backup job, you can specify the following settings:
After configuring the backup job, save it and Zerobyte will automatically execute the backup according to the defined schedule. After configuring the backup job, save it and Zerobyte will automatically execute the backup according to the defined schedule.
You can monitor the progress and status of your backup jobs in the "Backups" section of the web interface. You can monitor the progress and status of your backup jobs in the "Backups" section of the web interface.
![Backups UI](https://github.com/nicotsx/zerobyte/blob/main/screenshots/backups-list.png?raw=true) ![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/backups-list.png?raw=true)
## Restoring data ## Restoring data
Zerobyte allows you to easily restore your data from backups. To restore data, navigate to the "Backups" section and select the backup job from which you want to restore data. You can then choose a specific backup snapshot and select the files or directories you wish to restore. The data you select will be restored to their original location. Zerobyte allows you to easily restore your data from backups. To restore data, navigate to the "Backups" section and select the backup job from which you want to restore data. You can then choose a specific backup snapshot and select the files or directories you wish to restore. The data you select will be restored to their original location.
![Restore UI](https://github.com/nicotsx/zerobyte/blob/main/screenshots/restoring.png?raw=true) ![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/restoring.png?raw=true)
## Authentication
Zerobyte uses [better-auth](https://github.com/better-auth/better-auth) for authentication and session management. The authentication system automatically adapts to your deployment scenario:
### Users and roles
Zerobyte does not currently provide fine-grained RBAC for backup operations. Authenticated organization members are trusted operators for repositories, volumes, backup schedules, restores, and notification destinations in their organization.
Organization roles mainly restrict organization-management and instance-management actions. For example, normal members cannot manage members, SSO settings, invitations, registration, or global users, but they can still operate backup resources. Only add users to an organization if you are comfortable with them using the storage, volume, and notification capabilities configured for the instance.
### Cookie security
- **IP Address/HTTP access**: Set `BASE_URL=http://192.168.1.50:4096` (or your IP). Cookies will use `Secure: false`, allowing immediate login without SSL.
- **Domain/HTTPS access**: Set `BASE_URL=https://zerobyte.example.com`. Cookies will automatically use `Secure: true` for protected sessions.
### Reverse proxy setup
If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.):
1. **Set `BASE_URL`** to your HTTPS domain (e.g., `https://zerobyte.example.com`)
2. The app will automatically enable secure cookies based on the `https://` prefix
3. Ensure your proxy passes the `X-Forwarded-Proto` header
### Important notes
- The `BASE_URL` must start with `https://` for secure cookies to be enabled
- Local IP addresses (e.g., `http://192.168.x.x`) are **not** treated as secure contexts by browsers, so secure cookies are disabled automatically
- `TRUSTED_ORIGINS` only allows additional origins for auth-related requests. It does not disable secure cookies or make authenticated HTTP access work when `BASE_URL` is HTTPS.
- If `BASE_URL` is HTTPS, browsers will only send Zerobyte's auth cookies over HTTPS. Plain HTTP access may still show the login page, but authenticated flows will fail because no session cookie is available.
## Troubleshooting ## Troubleshooting
For troubleshooting common issues, please refer to the [TROUBLESHOOTING.md](TROUBLESHOOTING.md) file. For troubleshooting common issues, please refer to the [TROUBLESHOOTING.md](TROUBLESHOOTING.md) file.
## Third-party software ## Third-Party Software
This project includes the following third-party software components: This project includes the following third-party software components:
@ -387,10 +289,10 @@ Contributions by anyone are welcome! If you find a bug or have a feature request
## Development (no Docker) ## Development (no Docker)
After installing Vite+ globally, you can run Zerobyte locally during development without Docker: You can run Zerobyte locally during development without Docker:
```bash ```bash
vp install bun install
bun run dev bun run dev
``` ```
@ -398,16 +300,14 @@ For local development, create a `.env.local` file at the repo root and override
```bash ```bash
# Example # Example
ZEROBYTE_DATABASE_URL=./data/zerobyte.db DATABASE_URL=./data/zerobyte.db
APP_SECRET=your_app_secret_here
RESTIC_PASS_FILE=./data/restic.pass RESTIC_PASS_FILE=./data/restic.pass
RESTIC_CACHE_DIR=./data/restic/cache RESTIC_CACHE_DIR=./data/restic/cache
ZEROBYTE_REPOSITORIES_DIR=./data/repositories ZEROBYTE_REPOSITORIES_DIR=./data/repositories
ZEROBYTE_VOLUMES_DIR=./data/volumes ZEROBYTE_VOLUMES_DIR=./data/volumes
BASE_URL=https://*.localhost
``` ```
Notes: Notes:
- Remote mount backends (NFS/SMB/WebDAV/SFTP) rely on Linux mount tooling and `CAP_SYS_ADMIN`; on macOS they are expected to be unavailable. - Remote mount backends (NFS/SMB/WebDAV/SFTP) rely on Linux mount tooling and `CAP_SYS_ADMIN`; on macOS they are expected to be unavailable.
- To actually run backups/repository checks, install `restic` on your machine (e.g., via Homebrew). If `restic` is not installed, the app still starts but backup operations will fail with a clear error. - To actually run backups/repository checks, install `restic` on your machine (e.g. via Homebrew). If `restic` is not installed, the app still starts but backup operations will fail with a clear error.

View file

@ -1,75 +0,0 @@
# Security Policy
Thank you for helping keep Zerobyte and its users secure.
Zerobyte is currently in `0.x`, and releases may include breaking changes between versions. For that reason, security fixes are only guaranteed for the most recent stable release line.
## Supported Versions
| Version | Supported |
| --------------------------------------------- | ------------------ |
| Latest stable `0.x` release | :white_check_mark: |
| Older stable `0.x` releases | :x: |
| Pre-release builds (`-alpha`, `-beta`, `-rc`) | :x: |
| Development snapshots from `main` | :x: |
Security fixes are generally released in the latest stable version only. If you are running an older release, you may be asked to upgrade before a fix is provided.
## Reporting a Vulnerability
Please do **not** report security vulnerabilities through public GitHub issues, discussions, or pull requests.
Use one of these private channels instead:
1. Preferred: GitHub private vulnerability reporting
https://github.com/nicotsx/zerobyte/security/advisories/new
2. Alternative: contact the maintainer directly by email if an address is listed in the repository or GitHub profile
When reporting, include as much of the following as you can:
- affected Zerobyte version
- deployment details, including whether you are using Docker, reverse proxies, or exposed ports
- clear reproduction steps or a proof of concept
- impact assessment and what an attacker could do
- any relevant logs, screenshots, or configuration excerpts with secrets removed
### What to expect
- We aim to acknowledge new reports within 7 days.
- We aim to provide status updates at least every 7 days while the report is being investigated.
- If the report is accepted, we will work on a fix, coordinate disclosure, and publish a security advisory when appropriate.
- If the report is declined, out of scope, or cannot be reproduced, we will explain why when possible.
Please avoid public disclosure until a fix has been released and maintainers have had reasonable time to notify users.
## Trust model baseline
Zerobyte is a self-hosted operator tool. Treat any authenticated user as a trusted machine/operator user with intentional access to:
- Browse/select host directories for volumes
- Configure local, network, and cloud storage backends
- Trigger mounts/unmounts, backups, restores, and Restic maintenance
- Read/write files through intended backup/restore workflows
- Access repository/volume metadata needed to operate backups
Do **not** report these as vulnerabilities by themselves:
- Authenticated host filesystem browsing
- Local directory volume pointing to broad host paths
- Backing up arbitrary readable host paths
- Restoring snapshots to arbitrary writable host paths
- Authenticated Restic/mount/rclone execution through intended UI flows
- Information disclosure to authenticated operators about filesystem paths or backend errors
Only report issues when they violate this trust model, for example:
- Unauthenticated access to operator features
- CSRF/cross-origin abuse causing a trusted operators browser to perform actions
- Shell/command injection beyond intended argument-based execution
- Path traversal that escapes a deliberately configured root/volume/repository boundary
- Secret leakage to logs, unauthenticated users, or non-operator contexts
- Cross-organization data access despite authenticated trust
- Privilege bypass between global admin/org admin/member where the product explicitly distinguishes roles
- Unsafe dev-only features enabled without the documented gate
- Vulnerabilities in parsing untrusted external data from repositories/backends/notifications
- Persistence corruption, data loss, or workflow bypass not intended by operator actions

View file

@ -7,17 +7,6 @@ In case you need further assistance, feel free to open a new issue with detailed
> [!WARNING] > [!WARNING]
> Make sure to never share sensitive information such as passwords, access keys, or personal data in public issues so remove them from logs before posting. > Make sure to never share sensitive information such as passwords, access keys, or personal data in public issues so remove them from logs before posting.
Before troubleshooting, enable debug logging so the logs contain enough detail to diagnose issues.
```yaml
services:
zerobyte:
environment:
- LOG_LEVEL=debug
```
After adding `LOG_LEVEL=debug`, restart Zerobyte and then collect logs.
To view the logs, run the command below: To view the logs, run the command below:
```bash ```bash
@ -27,92 +16,48 @@ docker logs -f zerobyte
--- ---
## Table of Contents ## Common issues
- [Common Issues](#common-issues) ### Permission denied errors when mounting remote shares
- [Permission Denied Errors When Mounting Remote Shares](#permission-denied-errors-when-mounting-remote-shares)
- [Security Levels for Mounting Remote Shares](#security-levels-for-mounting-remote-shares)
- [Permission Errors with Remote Shares](#permission-errors-with-remote-shares)
- [Container Cannot Perform Mounts](#container-cannot-perform-mounts)
- [AppArmor-Enabled Systems (Ubuntu/Debian)](#apparmor-enabled-systems-ubuntudebian)
- [Seccomp-Restricted Environments](#seccomp-restricted-environments)
- [SELinux-Enabled Systems (CentOS/Fedora)](#selinux-enabled-systems-centosfedora)
- [Still Getting Permission Errors After SYS_ADMIN](#still-getting-permission-errors-after-sys_admin)
- [FUSE Mount Failures](#fuse-mount-failures)
- [Rclone Issues](#rclone-issues)
- [Test on Host First](#critical-test-on-host-first)
- [Pre-flight Checklist](#pre-flight-checklist)
- [Common Rclone Errors](#common-rclone-errors)
- ["No Remotes Available" in Dropdown](#no-remotes-available-in-dropdown)
- ["Failed to Create File System" Error](#failed-to-create-file-system-error)
- [EACCES Errors](#eacces-errors)
- [Rclone SFTP Repository Authentication Failures](#rclone-sftp-repository-authentication-failures)
- [Rclone Volume Mount Issues](#rclone-volume-mount-issues)
- [Prerequisites Check](#prerequisites-check)
- [Common Mount Errors](#common-mount-errors)
- [Still Having Issues?](#still-having-issues)
---
## Common Issues
### Permission Denied Errors When Mounting Remote Shares
Mounting remote filesystems (such as SMB/CIFS) requires kernel-level privileges. When Zerobyte attempts to perform mounts from inside a container, additional permissions may be required. Mounting remote filesystems (such as SMB/CIFS) requires kernel-level privileges. When Zerobyte attempts to perform mounts from inside a container, additional permissions may be required.
Ensure that: Ensure that:
- Remote share credentials are correct * Remote share credentials are correct
- The host kernel supports the target filesystem (e.g. CIFS module is available) * The host kernel supports the target filesystem (e.g. CIFS module is available)
- Docker is running in **rootful mode** (rootless Docker cannot perform kernel mounts) * Docker is running in **rootful mode** (rootless Docker cannot perform kernel mounts)
In some environments, Linux security mechanisms such as AppArmor or seccomp may block mount-related operations even when the required capabilities are present. In some environments, Linux security mechanisms such as AppArmor or seccomp may block mount-related operations even when the required capabilities are present.
--- ---
### Security Levels for Mounting Remote Shares ### Security levels for mounting remote shares
Zerobyte supports multiple deployment models depending on your security requirements and environment. Zerobyte supports multiple deployment models depending on your security requirements and environment.
--- ---
#### Permission Errors with Remote Shares #### **Secure** (recommended)
**Problem:** You're getting permission errors when Zerobyte tries to mount remote shares (SMB/CIFS, NFS, etc.). Mount remote shares **outside of Zerobyte** (on the host) and point Zerobyte to an already mounted local path.
**Solution:** Mount remote shares **outside of Zerobyte** (on the host) and point Zerobyte to an already-mounted local path. This avoids container permission issues entirely. This approach avoids granting additional privileges to the container and is the most portable and secure option.
**How to fix:** ```yaml
services:
zerobyte:
volumes:
- /mnt/your-remote-share:/data
```
1. Mount your remote share on the host first using `systemd`, `autofs`, or manual mount: Remote mounts can be managed via `systemd`, `autofs`, or manual host mounts.
```bash
sudo mount -t cifs //server/share /mnt/your-remote-share -o credentials=/path/to/creds
```
2. Then mount that local path into Zerobyte:
```yaml
services:
zerobyte:
volumes:
- /mnt/your-remote-share:/data
```
3. Restart the container:
```bash
docker compose down && docker compose up -d
```
--- ---
#### Container Cannot Perform Mounts #### **Advanced** (Zerobyte performs mounts)
**Problem:** Zerobyte shows "Operation not permitted" errors when trying to mount remote shares directly. If Zerobyte must perform filesystem mounts itself, the container requires the `SYS_ADMIN` capability.
**Solution:** Grant the `SYS_ADMIN` capability to allow the container to perform mount operations.
**Warning:** This grants significant privileges to the container and should only be used when necessary.
```yaml ```yaml
services: services:
@ -125,7 +70,7 @@ services:
--- ---
#### AppArmor-Enabled Systems (Ubuntu/Debian) #### AppArmor-enabled systems (Ubuntu/Debian)
On hosts using AppArmor, the default Docker profile (`docker-default`) may block mount operations even when `SYS_ADMIN` is present. On hosts using AppArmor, the default Docker profile (`docker-default`) may block mount operations even when `SYS_ADMIN` is present.
@ -151,7 +96,7 @@ services:
--- ---
#### Seccomp-Restricted Environments #### Seccomp-restricted environments
Docker's default seccomp profile may block mount-related syscalls required by filesystem operations. Docker's default seccomp profile may block mount-related syscalls required by filesystem operations.
@ -168,7 +113,7 @@ services:
--- ---
#### SELinux-Enabled Systems (CentOS/Fedora) #### SELinux-enabled systems (CentOS/Fedora)
On hosts using SELinux, you may need to adjust the security context to allow mount operations. On hosts using SELinux, you may need to adjust the security context to allow mount operations.
If mount operations fail with permission errors, you can try adding the following label: If mount operations fail with permission errors, you can try adding the following label:
@ -195,13 +140,11 @@ services:
--- ---
#### Still Getting Permission Errors After SYS_ADMIN #### **Not recommended** (When all else fails)
**Problem:** Mount operations still fail even with `SYS_ADMIN` capability. Running the container in privileged mode disables most container isolation mechanisms and significantly increases the attack surface.
**Solution (Last Resort):** Run the container in privileged mode. This disables most container isolation mechanisms and significantly increases the attack surface. This option should be used only as a last resort for troubleshooting.
**Warning:** Only use this as a last resort for troubleshooting. Remove and switch to a more secure solution once you identify the actual issue.
```yaml ```yaml
services: services:
@ -211,264 +154,19 @@ services:
--- ---
### FUSE Mount Failures ### Notes on FUSE-based backends
**Problem:** FUSE-based mounts (sshfs, rclone mount) are failing with device access errors. Access to `/dev/fuse` is required **only for FUSE-based filesystems** (such as `sshfs` or `rclone mount`).
**Cause:** Access to `/dev/fuse` is required for FUSE-based filesystems. It is **not required** for SMB/CIFS mounts.
**Solution:** Ensure `/dev/fuse` is mounted into the container. This is **not required** for SMB/CIFS mounts. ### Rclone mount issues
````yaml When using `rclone`, you may get errors like:
services:
zerobyte:
devices:
- /dev/fuse:/dev/fuse
---
## Rclone Issues
### ⚠️ Critical: Test on Host First
**Before reporting any rclone-related issue, you MUST verify that rclone works correctly on your Docker host.**
Most rclone issues are due to misconfigured remotes, not Zerobyte bugs. Follow this checklist on your host machine:
```bash ```bash
# 1. List all configured remotes bun: Failed to spawn process: EACCES
rclone listremotes error > Failed to list rclone remotes: bun: Failed to spawn process: EACCES
# 2. Test listing a remote (replace 'myremote' with your remote name)
rclone lsd myremote:
# 3. Test reading from a remote
rclone ls myremote:path/to/test
# 4. For volume mounts: Test mounting on host first
mkdir -p /tmp/rclone-test
rclone mount myremote:path /tmp/rclone-test --daemon --vfs-cache-mode writes
ls /tmp/rclone-test
fusermount -u /tmp/rclone-test # Unmount when done
````
**If these commands fail on your host, fix your rclone configuration before using Zerobyte.**
Common issues include:
- Expired OAuth tokens (run `rclone config` to re-authenticate)
- Incorrect credentials
- Missing permissions on cloud provider side
- Network/firewall issues
---
### Pre-flight Checklist
If you're experiencing rclone issues, verify all of the following:
- [ ] Rclone is installed and configured on the Docker **host**
- [ ] `rclone listremotes` shows your remote
- [ ] `rclone lsd remote:` successfully lists directories
- [ ] The rclone config directory is mounted into the container
---
### Common Rclone Errors
#### "No Remotes Available" in Dropdown
**Cause:** Zerobyte cannot find your rclone configuration file.
**Diagnosis:**
```bash
# Check which config file rclone will use inside the container
docker exec zerobyte sh -lc 'echo HOME=$HOME; rclone config file'
``` ```
**Solutions:** Try to disable the AppArmor confinement for the container as described in the [AppArmor-enabled systems](#apparmor-enabled-systems-ubuntudebian) section above or [Seccomp-restricted environments](#seccomp-restricted-environments) section.
1. Ensure you've mounted your host's rclone config:
```yaml
volumes:
- ~/.config/rclone:/root/.config/rclone:ro
```
2. For non-root containers (e.g., TrueNAS), set the correct path:
```yaml
environment:
- RCLONE_CONFIG_DIR=/home/appuser/.config/rclone
volumes:
- ~/.config/rclone:/home/appuser/.config/rclone:ro
```
3. **Restart the container** after mounting the config:
```bash
docker compose down
docker compose up -d
```
#### "Failed to Create File System" Error
**Cause:** Authentication failure with the cloud provider.
**Solution:**
1. On your host, run: `rclone config`
2. Re-authenticate the remote (especially for OAuth providers like Google Drive, Dropbox, OneDrive)
3. Verify with: `rclone lsd remote:`
4. Restart the Zerobyte container
#### EACCES Errors
**Cause:** AppArmor or seccomp is blocking rclone execution.
**Solution:** Disable security profiles as described in:
- [AppArmor-enabled systems](#apparmor-enabled-systems-ubuntudebian)
- [Seccomp-restricted environments](#seccomp-restricted-environments)
#### Rclone SFTP Repository Authentication Failures
When creating an **rclone repository** that uses an **SFTP remote**, you may encounter authentication errors even though:
- The rclone config is mounted correctly
- The SFTP remote appears in the dropdown
- The same config works on the host
**The issue:** If your rclone SFTP remote uses `key_file` for SSH key authentication, the key file path in your rclone config points to a location on the **host** (e.g., `~/.ssh/id_rsa`). When rclone runs inside the container, it cannot access that path.
**Solutions:**
**Option 1: Mount your SSH keys (Recommended)**
Add your SSH directory to the container volumes:
```yaml
services:
zerobyte:
volumes:
- ~/.config/rclone:/root/.config/rclone:ro
- ~/.ssh:/root/.ssh:ro # Required for SFTP remotes using key_file
```
**Option 2: Embed the SSH key in rclone config**
Use `key_pem` instead of `key_file` to embed the private key directly in your rclone.conf:
```bash
# Convert your key to single-line format
awk '{printf "%s\\n", $0}' < ~/.ssh/id_rsa
```
Then update your rclone config:
```ini
[sftp-remote]
type = sftp
host = example.com
user = backup
key_pem = -----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5v...\n-----END OPENSSH PRIVATE KEY-----
```
**Option 3: Use ssh-agent**
Configure your rclone remote to use ssh-agent instead of a key file:
```ini
[sftp-remote]
type = sftp
host = example.com
user = backup
key_use_agent = true
```
Then mount the SSH agent socket and set the environment variable:
```yaml
services:
zerobyte:
environment:
- SSH_AUTH_SOCK=/ssh-agent
volumes:
- ~/.config/rclone:/root/.config/rclone:ro
- ${SSH_AUTH_SOCK}:/ssh-agent
```
---
### Rclone Volume Mount Issues
When using rclone as a **volume backend** (mounting cloud storage to back up from), additional requirements apply:
#### Prerequisites Check
If rclone volume mounting isn't working, verify these prerequisites on your system:
- **Linux host required:** Windows/macOS hosts cannot use rclone volumes. If you're on these platforms, you'll need to use a different volume backend.
- **`/dev/fuse` device:** Check if FUSE is available and properly passed to the container
- **`SYS_ADMIN` capability:** Required for mount operations inside containers
- **FUSE support on host:** Verify FUSE is installed and working on your Docker host
#### Common Mount Errors
**"mount helper error: fusermount3: failed to open /dev/fuse: Permission denied"**
Solutions in order of preference:
1. **Verify FUSE works on host first:**
```bash
# On host
rclone mount remote:path /tmp/test --daemon
ls /tmp/test
fusermount -u /tmp/test
```
2. **Ensure /dev/fuse is mounted in container:**
```yaml
devices:
- /dev/fuse:/dev/fuse
```
3. **Check user permissions inside container:**
```bash
docker exec zerobyte ls -la /dev/fuse
```
**"mount helper error: fusermount3: mount failed: Operation not permitted"**
This indicates the container lacks `SYS_ADMIN` capability:
```yaml
cap_add:
- SYS_ADMIN
```
---
### Still having issues?
If you've verified rclone works on the host and followed all troubleshooting steps above, gather this information for your issue report:
```bash
# Host verification
rclone version
rclone listremotes
rclone lsd remote: 2>&1
# Container verification
docker exec zerobyte ls -la /root/.config/rclone/
docker exec zerobyte env | grep RCLONE
docker logs zerobyte 2>&1 | tail -50
# Check container capabilities
docker inspect zerobyte --format='{{.HostConfig.CapAdd}}'
docker inspect zerobyte --format='{{.HostConfig.Devices}}'
```
Include the output (with sensitive data redacted) in your GitHub issue.

View file

@ -5,16 +5,7 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@custom-variant @narrow (@container (min-width: 28rem));
@custom-variant @medium (@container (min-width: 40rem));
@custom-variant @wide (@container (min-width: 60rem));
@custom-variant @xwide (@container (min-width: 80rem));
@theme { @theme {
--container-narrow: 28rem;
--container-medium: 40rem;
--container-wide: 60rem;
--container-xwide: 80rem;
--breakpoint-xs: 32rem; --breakpoint-xs: 32rem;
--font-sans: --font-sans:
"Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
@ -26,17 +17,12 @@ body {
overflow-x: hidden; overflow-x: hidden;
width: 100%; width: 100%;
position: relative; position: relative;
overscroll-behavior: none;
scrollbar-width: thin; scrollbar-width: thin;
} }
@media (min-width: 48rem) {
html,
body {
overscroll-behavior: none;
}
}
body { body {
@apply bg-[#131313];
min-height: 100dvh; min-height: 100dvh;
} }
@ -66,8 +52,6 @@ body {
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
@ -89,40 +73,38 @@ body {
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(0.98 0.002 250); --background: oklch(1 0 0);
--foreground: oklch(0.20 0.01 260); --foreground: oklch(0.145 0 0);
--card: #ffffff; --card: oklch(1 0 0);
--card-foreground: oklch(0.20 0.01 260); --card-foreground: oklch(0.145 0 0);
--card-header: oklch(0.99 0.001 260); --card-header: oklch(0.922 0 0);
--popover: #ffffff; --popover: oklch(1 0 0);
--popover-foreground: oklch(0.20 0.01 260); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.20 0.01 260); --primary: oklch(0.205 0 0);
--primary-foreground: #ffffff; --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.96 0.005 260); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.20 0.01 260); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.95 0.005 260); --muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.45 0.01 260); --muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.96 0.005 260); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.20 0.01 260); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: #fff; --border: oklch(0.922 0 0);
--success: oklch(0.57 0.16 148); --input: oklch(0.922 0 0);
--border: oklch(0.90 0.01 260); --ring: oklch(0.708 0 0);
--input: oklch(0.90 0.01 260); --chart-1: oklch(0.646 0.222 41.116);
--ring: oklch(0.7 0.01 260); --chart-2: oklch(0.6 0.118 184.704);
--chart-1: oklch(0.55 0.22 260); --chart-3: oklch(0.398 0.07 227.392);
--chart-2: oklch(0.6 0.15 200); --chart-4: oklch(0.828 0.189 84.429);
--chart-3: oklch(0.45 0.12 280); --chart-5: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.7 0.15 190); --sidebar: oklch(0.985 0 0);
--chart-5: oklch(0.55 0.18 300); --sidebar-foreground: oklch(0.145 0 0);
--sidebar: #ffffff; --sidebar-primary: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.35 0.01 260); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.20 0.01 260); --sidebar-accent: oklch(0.97 0 0);
--sidebar-primary-foreground: #ffffff; --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-accent: oklch(0.97 0.005 260); --sidebar-border: oklch(0.922 0 0);
--sidebar-accent-foreground: oklch(0.20 0.01 260); --sidebar-ring: oklch(0.708 0 0);
--sidebar-border: oklch(0.90 0.01 260);
--sidebar-ring: oklch(0.7 0.01 260);
--strong-accent: #ff543a; --strong-accent: #ff543a;
} }
@ -147,8 +129,6 @@ body {
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: #ff543a; --destructive: #ff543a;
--destructive-foreground: #fff;
--success: oklch(0.696 0.17 162.48);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
@ -179,11 +159,11 @@ body {
@layer base { @layer base {
:root { :root {
--chart-1: oklch(0.55 0.22 260); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.15 200); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.45 0.12 280); --chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.7 0.15 190); --chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.55 0.18 300); --chart-5: oklch(0.769 0.188 70.08);
} }
.dark { .dark {
@ -195,17 +175,6 @@ body {
} }
} }
.snapshot-scrollable::-webkit-scrollbar {
height: 6px;
}
.snapshot-scrollable::-webkit-scrollbar-track {
background: transparent;
}
.snapshot-scrollable::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 999px;
}
/* Hide built-in password reveal/clear controls (notably in Edge on Windows) */ /* Hide built-in password reveal/clear controls (notably in Edge on Windows) */
[data-secret-input] input[type="password"]::-ms-reveal, [data-secret-input] input[type="password"]::-ms-reveal,
[data-secret-input] input[type="password"]::-ms-clear { [data-secret-input] input[type="password"]::-ms-clear {

View file

@ -1,20 +0,0 @@
import { StrictMode, startTransition } from "react";
import { hydrateRoot } from "react-dom/client";
import { StartClient } from "@tanstack/react-start/client";
import { parseError } from "./client/lib/errors";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<StartClient />
</StrictMode>,
{
onRecoverableError: (error, errorInfo) => {
console.error(
`[react-recoverable-error] ${parseError(error)?.message}${errorInfo.componentStack ? `\n${errorInfo.componentStack.trim()}` : ""}`,
);
},
},
);
});

File diff suppressed because it is too large Load diff

View file

@ -1,27 +1,25 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import { type ClientOptions, type Config, createClient, createConfig } from "./client";
import type { ClientOptions as ClientOptions2 } from "./types.gen";
import { getRequestClient } from "../../lib/request-client";
import {
type ClientOptions,
type Config,
createClient,
createConfig,
} from "./client";
import type { ClientOptions as ClientOptions2 } from "./types.gen";
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = ( export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (
override?: Config<ClientOptions & T>, override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T>; ) => Config<Required<ClientOptions> & T>;
const fallbackClient = createClient(createConfig<ClientOptions2>({ baseUrl: "http://localhost:4096" })); export const client = createClient(
createConfig<ClientOptions2>({ baseUrl: "http://localhost:4096" }),
/** );
* Proxy client that automatically uses the per-request client from request-scoped server storage.
* Falls back to a default client if no request context is available.
*/
export const client = new Proxy(fallbackClient, {
get(target, prop, receiver) {
try {
const requestClient = getRequestClient();
return Reflect.get(requestClient, prop, receiver);
} catch {
// No request context available, use fallback
return Reflect.get(target, prop, receiver);
}
},
});

View file

@ -1,278 +1,279 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import { createSseClient } from '../core/serverSentEvents.gen'; import { createSseClient } from "../core/serverSentEvents.gen";
import type { HttpMethod } from '../core/types.gen'; import type { HttpMethod } from "../core/types.gen";
import { getValidRequestBody } from '../core/utils.gen'; import { getValidRequestBody } from "../core/utils.gen";
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen'; import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen";
import { import {
buildUrl, buildUrl,
createConfig, createConfig,
createInterceptors, createInterceptors,
getParseAs, getParseAs,
mergeConfigs, mergeConfigs,
mergeHeaders, mergeHeaders,
setAuthParams, setAuthParams,
} from './utils.gen'; } from "./utils.gen";
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & { type ReqInit = Omit<RequestInit, "body" | "headers"> & {
body?: any; body?: any;
headers: ReturnType<typeof mergeHeaders>; headers: ReturnType<typeof mergeHeaders>;
}; };
export const createClient = (config: Config = {}): Client => { export const createClient = (config: Config = {}): Client => {
let _config = mergeConfigs(createConfig(), config); let _config = mergeConfigs(createConfig(), config);
const getConfig = (): Config => ({ ..._config }); const getConfig = (): Config => ({ ..._config });
const setConfig = (config: Config): Config => { const setConfig = (config: Config): Config => {
_config = mergeConfigs(_config, config); _config = mergeConfigs(_config, config);
return getConfig(); return getConfig();
}; };
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>(); const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
const beforeRequest = async < const beforeRequest = async (options: RequestOptions) => {
TData = unknown, const opts = {
TResponseStyle extends 'data' | 'fields' = 'fields', ..._config,
ThrowOnError extends boolean = boolean, ...options,
Url extends string = string, fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
>( headers: mergeHeaders(_config.headers, options.headers),
options: RequestOptions<TData, TResponseStyle, ThrowOnError, Url>, serializedBody: undefined,
) => { };
const opts = {
..._config,
...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers),
serializedBody: undefined as string | undefined,
};
if (opts.security) { if (opts.security) {
await setAuthParams(opts); await setAuthParams({
} ...opts,
security: opts.security,
});
}
if (opts.requestValidator) { if (opts.requestValidator) {
await opts.requestValidator(opts); await opts.requestValidator(opts);
} }
if (opts.body !== undefined && opts.bodySerializer) { if (opts.body !== undefined && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined; opts.serializedBody = opts.bodySerializer(opts.body);
} }
// remove Content-Type header if body is empty to avoid sending invalid requests // remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.body === undefined || opts.serializedBody === '') { if (opts.body === undefined || opts.serializedBody === "") {
opts.headers.delete('Content-Type'); opts.headers.delete("Content-Type");
} }
const resolvedOpts = opts as typeof opts & const url = buildUrl(opts);
ResolvedRequestOptions<TResponseStyle, ThrowOnError, Url>;
const url = buildUrl(resolvedOpts);
return { opts: resolvedOpts, url }; return { opts, url };
}; };
const request: Client['request'] = async (options) => { const request: Client["request"] = async (options) => {
const throwOnError = options.throwOnError ?? _config.throwOnError; // @ts-expect-error
const responseStyle = options.responseStyle ?? _config.responseStyle; const { opts, url } = await beforeRequest(options);
const requestInit: ReqInit = {
redirect: "follow",
...opts,
body: getValidRequestBody(opts),
};
let request: Request | undefined; let request = new Request(url, requestInit);
let response: Response | undefined;
try { for (const fn of interceptors.request.fns) {
const { opts, url } = await beforeRequest(options); if (fn) {
const requestInit: ReqInit = { request = await fn(request, opts);
redirect: 'follow', }
...opts, }
body: getValidRequestBody(opts),
};
request = new Request(url, requestInit); // fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
let response: Response;
for (const fn of interceptors.request.fns) { try {
if (fn) { response = await _fetch(request);
request = await fn(request, opts); } catch (error) {
} // Handle fetch exceptions (AbortError, network errors, etc.)
} let finalError = error;
// fetch must be assigned here, otherwise it would throw the error: for (const fn of interceptors.error.fns) {
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation if (fn) {
const _fetch = opts.fetch!; finalError = (await fn(error, undefined as any, request, opts)) as unknown;
}
}
response = await _fetch(request); finalError = finalError || ({} as unknown);
for (const fn of interceptors.response.fns) { if (opts.throwOnError) {
if (fn) { throw finalError;
response = await fn(response, request, opts); }
}
}
const result = { // Return error response
request, return opts.responseStyle === "data"
response, ? undefined
}; : {
error: finalError,
request,
response: undefined as any,
};
}
if (response.ok) { for (const fn of interceptors.response.fns) {
const parseAs = if (fn) {
(opts.parseAs === 'auto' response = await fn(response, request, opts);
? getParseAs(response.headers.get('Content-Type')) }
: opts.parseAs) ?? 'json'; }
if (response.status === 204 || response.headers.get('Content-Length') === '0') { const result = {
let emptyData: any; request,
switch (parseAs) { response,
case 'arrayBuffer': };
case 'blob':
case 'text':
emptyData = await response[parseAs]();
break;
case 'formData':
emptyData = new FormData();
break;
case 'stream':
emptyData = response.body;
break;
case 'json':
default:
emptyData = {};
break;
}
return opts.responseStyle === 'data'
? emptyData
: {
data: emptyData,
...result,
};
}
let data: any; if (response.ok) {
switch (parseAs) { const parseAs =
case 'arrayBuffer': (opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json";
case 'blob':
case 'formData':
case 'text':
data = await response[parseAs]();
break;
case 'json': {
// Some servers return 200 with no Content-Length and empty body.
// response.json() would throw; read as text and parse if non-empty.
const text = await response.text();
data = text ? JSON.parse(text) : {};
break;
}
case 'stream':
return opts.responseStyle === 'data'
? response.body
: {
data: response.body,
...result,
};
}
if (parseAs === 'json') { if (response.status === 204 || response.headers.get("Content-Length") === "0") {
if (opts.responseValidator) { let emptyData: any;
await opts.responseValidator(data); switch (parseAs) {
} case "arrayBuffer":
case "blob":
case "text":
emptyData = await response[parseAs]();
break;
case "formData":
emptyData = new FormData();
break;
case "stream":
emptyData = response.body;
break;
case "json":
default:
emptyData = {};
break;
}
return opts.responseStyle === "data"
? emptyData
: {
data: emptyData,
...result,
};
}
if (opts.responseTransformer) { let data: any;
data = await opts.responseTransformer(data); switch (parseAs) {
} case "arrayBuffer":
} case "blob":
case "formData":
case "json":
case "text":
data = await response[parseAs]();
break;
case "stream":
return opts.responseStyle === "data"
? response.body
: {
data: response.body,
...result,
};
}
return opts.responseStyle === 'data' if (parseAs === "json") {
? data if (opts.responseValidator) {
: { await opts.responseValidator(data);
data, }
...result,
};
}
const textError = await response.text(); if (opts.responseTransformer) {
let jsonError: unknown; data = await opts.responseTransformer(data);
}
}
try { return opts.responseStyle === "data"
jsonError = JSON.parse(textError); ? data
} catch { : {
// noop data,
} ...result,
};
}
throw jsonError ?? textError; const textError = await response.text();
} catch (error) { let jsonError: unknown;
let finalError = error;
for (const fn of interceptors.error.fns) { try {
if (fn) { jsonError = JSON.parse(textError);
finalError = await fn(finalError, response, request, options as ResolvedRequestOptions); } catch {
} // noop
} }
finalError = finalError || {}; const error = jsonError ?? textError;
let finalError = error;
if (throwOnError) { for (const fn of interceptors.error.fns) {
throw finalError; if (fn) {
} finalError = (await fn(error, response, request, opts)) as string;
}
}
// TODO: we probably want to return error and improve types finalError = finalError || ({} as string);
return responseStyle === 'data'
? undefined
: {
error: finalError,
request,
response,
};
}
};
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) => if (opts.throwOnError) {
request({ ...options, method }); throw finalError;
}
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => { // TODO: we probably want to return error and improve types
const { opts, url } = await beforeRequest(options); return opts.responseStyle === "data"
return createSseClient({ ? undefined
...opts, : {
body: opts.body as BodyInit | null | undefined, error: finalError,
method, ...result,
onRequest: async (url, init) => { };
let request = new Request(url, init); };
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
return request;
},
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
url,
});
};
const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options }); const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) => request({ ...options, method });
return { const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
buildUrl: _buildUrl, const { opts, url } = await beforeRequest(options);
connect: makeMethodFn('CONNECT'), return createSseClient({
delete: makeMethodFn('DELETE'), ...opts,
get: makeMethodFn('GET'), body: opts.body as BodyInit | null | undefined,
getConfig, headers: opts.headers as unknown as Record<string, string>,
head: makeMethodFn('HEAD'), method,
interceptors, onRequest: async (url, init) => {
options: makeMethodFn('OPTIONS'), let request = new Request(url, init);
patch: makeMethodFn('PATCH'), for (const fn of interceptors.request.fns) {
post: makeMethodFn('POST'), if (fn) {
put: makeMethodFn('PUT'), request = await fn(request, opts);
request, }
setConfig, }
sse: { return request;
connect: makeSseFn('CONNECT'), },
delete: makeSseFn('DELETE'), serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
get: makeSseFn('GET'), url,
head: makeSseFn('HEAD'), });
options: makeSseFn('OPTIONS'), };
patch: makeSseFn('PATCH'),
post: makeSseFn('POST'), return {
put: makeSseFn('PUT'), buildUrl,
trace: makeSseFn('TRACE'), connect: makeMethodFn("CONNECT"),
}, delete: makeMethodFn("DELETE"),
trace: makeMethodFn('TRACE'), get: makeMethodFn("GET"),
} as Client; getConfig,
head: makeMethodFn("HEAD"),
interceptors,
options: makeMethodFn("OPTIONS"),
patch: makeMethodFn("PATCH"),
post: makeMethodFn("POST"),
put: makeMethodFn("PUT"),
request,
setConfig,
sse: {
connect: makeSseFn("CONNECT"),
delete: makeSseFn("DELETE"),
get: makeSseFn("GET"),
head: makeSseFn("HEAD"),
options: makeSseFn("OPTIONS"),
patch: makeSseFn("PATCH"),
post: makeSseFn("POST"),
put: makeSseFn("PUT"),
trace: makeSseFn("TRACE"),
},
trace: makeMethodFn("TRACE"),
} as Client;
}; };

View file

@ -1,9 +1,12 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
export type { Auth } from "../core/auth.gen"; export type { Auth } from "../core/auth.gen";
export type { QuerySerializerOptions } from "../core/bodySerializer.gen"; export type { QuerySerializerOptions } from "../core/bodySerializer.gen";
export { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer } from "../core/bodySerializer.gen"; export {
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from "../core/bodySerializer.gen";
export { buildClientParams } from "../core/params.gen"; export { buildClientParams } from "../core/params.gen";
export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen"; export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen";
export { createClient } from "./client.gen"; export { createClient } from "./client.gen";

View file

@ -1,187 +1,210 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { Auth } from '../core/auth.gen'; import type { Auth } from "../core/auth.gen";
import type { import type {
ServerSentEventsOptions, ServerSentEventsOptions,
ServerSentEventsResult, ServerSentEventsResult,
} from '../core/serverSentEvents.gen'; } from "../core/serverSentEvents.gen";
import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen'; import type {
import type { Middleware } from './utils.gen'; Client as CoreClient,
Config as CoreConfig,
} from "../core/types.gen";
import type { Middleware } from "./utils.gen";
export type ResponseStyle = 'data' | 'fields'; export type ResponseStyle = "data" | "fields";
export interface Config<T extends ClientOptions = ClientOptions> export interface Config<T extends ClientOptions = ClientOptions>
extends Omit<RequestInit, 'body' | 'headers' | 'method'>, CoreConfig { extends Omit<RequestInit, "body" | "headers" | "method">,
/** CoreConfig {
* Base URL for all requests made by this client. /**
*/ * Base URL for all requests made by this client.
baseUrl?: T['baseUrl']; */
/** baseUrl?: T["baseUrl"];
* Fetch API implementation. You can use this option to provide a custom /**
* fetch instance. * Fetch API implementation. You can use this option to provide a custom
* * fetch instance.
* @default globalThis.fetch *
*/ * @default globalThis.fetch
fetch?: typeof fetch; */
/** fetch?: typeof fetch;
* Please don't use the Fetch client for Next.js applications. The `next` /**
* options won't have any effect. * Please don't use the Fetch client for Next.js applications. The `next`
* * options won't have any effect.
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. *
*/ * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
next?: never; */
/** next?: never;
* Return the response data parsed in a specified format. By default, `auto` /**
* will infer the appropriate method from the `Content-Type` response header. * Return the response data parsed in a specified format. By default, `auto`
* You can override this behavior with any of the {@link Body} methods. * will infer the appropriate method from the `Content-Type` response header.
* Select `stream` if you don't want to parse response data at all. * You can override this behavior with any of the {@link Body} methods.
* * Select `stream` if you don't want to parse response data at all.
* @default 'auto' *
*/ * @default 'auto'
parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text'; */
/** parseAs?:
* Should we return only data or multiple fields (data, error, response, etc.)? | "arrayBuffer"
* | "auto"
* @default 'fields' | "blob"
*/ | "formData"
responseStyle?: ResponseStyle; | "json"
/** | "stream"
* Throw an error instead of returning it in the response? | "text";
* /**
* @default false * Should we return only data or multiple fields (data, error, response, etc.)?
*/ *
throwOnError?: T['throwOnError']; * @default 'fields'
*/
responseStyle?: ResponseStyle;
/**
* Throw an error instead of returning it in the response?
*
* @default false
*/
throwOnError?: T["throwOnError"];
} }
export interface RequestOptions< export interface RequestOptions<
TData = unknown, TData = unknown,
TResponseStyle extends ResponseStyle = 'fields', TResponseStyle extends ResponseStyle = "fields",
ThrowOnError extends boolean = boolean, ThrowOnError extends boolean = boolean,
Url extends string = string, Url extends string = string,
> > extends Config<{
extends responseStyle: TResponseStyle;
Config<{ throwOnError: ThrowOnError;
responseStyle: TResponseStyle; }>,
throwOnError: ThrowOnError; Pick<
}>, ServerSentEventsOptions<TData>,
Pick< | "onSseError"
ServerSentEventsOptions<TData>, | "onSseEvent"
| 'onRequest' | "sseDefaultRetryDelay"
| 'onSseError' | "sseMaxRetryAttempts"
| 'onSseEvent' | "sseMaxRetryDelay"
| 'sseDefaultRetryDelay' > {
| 'sseMaxRetryAttempts' /**
| 'sseMaxRetryDelay' * Any body that you want to add to your request.
> { *
/** * {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
* Any body that you want to add to your request. */
* body?: unknown;
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body} path?: Record<string, unknown>;
*/ query?: Record<string, unknown>;
body?: unknown; /**
path?: Record<string, unknown>; * Security mechanism(s) to use for the request.
query?: Record<string, unknown>; */
/** security?: ReadonlyArray<Auth>;
* Security mechanism(s) to use for the request. url: Url;
*/
security?: ReadonlyArray<Auth>;
url: Url;
} }
export interface ResolvedRequestOptions< export interface ResolvedRequestOptions<
TResponseStyle extends ResponseStyle = 'fields', TResponseStyle extends ResponseStyle = "fields",
ThrowOnError extends boolean = boolean, ThrowOnError extends boolean = boolean,
Url extends string = string, Url extends string = string,
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> { > extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
headers: Headers; serializedBody?: string;
serializedBody?: string;
} }
export type RequestResult< export type RequestResult<
TData = unknown, TData = unknown,
TError = unknown, TError = unknown,
ThrowOnError extends boolean = boolean, ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = 'fields', TResponseStyle extends ResponseStyle = "fields",
> = ThrowOnError extends true > = ThrowOnError extends true
? Promise< ? Promise<
TResponseStyle extends 'data' TResponseStyle extends "data"
? TData extends Record<string, unknown> ? TData extends Record<string, unknown>
? TData[keyof TData] ? TData[keyof TData]
: TData : TData
: { : {
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData; data: TData extends Record<string, unknown>
request: Request; ? TData[keyof TData]
response: Response; : TData;
} request: Request;
> response: Response;
: Promise< }
TResponseStyle extends 'data' >
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined : Promise<
: ( TResponseStyle extends "data"
| { ?
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData; | (TData extends Record<string, unknown>
error: undefined; ? TData[keyof TData]
} : TData)
| { | undefined
data: undefined; : (
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError; | {
} data: TData extends Record<string, unknown>
) & { ? TData[keyof TData]
/** request may be undefined, because error may be from building the request object itself */ : TData;
request?: Request; error: undefined;
/** response may be undefined, because error may be from building the request object itself or from a network error */ }
response?: Response; | {
} data: undefined;
>; error: TError extends Record<string, unknown>
? TError[keyof TError]
: TError;
}
) & {
request: Request;
response: Response;
}
>;
export interface ClientOptions { export interface ClientOptions {
baseUrl?: string; baseUrl?: string;
responseStyle?: ResponseStyle; responseStyle?: ResponseStyle;
throwOnError?: boolean; throwOnError?: boolean;
} }
type MethodFn = < type MethodFn = <
TData = unknown, TData = unknown,
TError = unknown, TError = unknown,
ThrowOnError extends boolean = false, ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields', TResponseStyle extends ResponseStyle = "fields",
>( >(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>, options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>; ) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type SseFn = < type SseFn = <
TData = unknown, TData = unknown,
TError = unknown, TError = unknown,
ThrowOnError extends boolean = false, ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields', TResponseStyle extends ResponseStyle = "fields",
>( >(
options: Omit<RequestOptions<never, TResponseStyle, ThrowOnError>, 'method'>, options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
) => Promise<ServerSentEventsResult<TData, TError>>; ) => Promise<ServerSentEventsResult<TData, TError>>;
type RequestFn = < type RequestFn = <
TData = unknown, TData = unknown,
TError = unknown, TError = unknown,
ThrowOnError extends boolean = false, ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields', TResponseStyle extends ResponseStyle = "fields",
>( >(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> & options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method"> &
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, 'method'>, Pick<
Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>,
"method"
>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>; ) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type BuildUrlFn = < type BuildUrlFn = <
TData extends { TData extends {
body?: unknown; body?: unknown;
path?: Record<string, unknown>; path?: Record<string, unknown>;
query?: Record<string, unknown>; query?: Record<string, unknown>;
url: string; url: string;
}, },
>( >(
options: TData & Options<TData>, options: TData & Options<TData>,
) => string; ) => string;
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & { export type Client = CoreClient<
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>; RequestFn,
Config,
MethodFn,
BuildUrlFn,
SseFn
> & {
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
}; };
/** /**
@ -193,26 +216,26 @@ export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn>
* to ensure your client always has the correct values. * to ensure your client always has the correct values.
*/ */
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = ( export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
override?: Config<ClientOptions & T>, override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T>; ) => Config<Required<ClientOptions> & T>;
export interface TDataShape { export interface TDataShape {
body?: unknown; body?: unknown;
headers?: unknown; headers?: unknown;
path?: unknown; path?: unknown;
query?: unknown; query?: unknown;
url: string; url: string;
} }
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>; type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
export type Options< export type Options<
TData extends TDataShape = TDataShape, TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean, ThrowOnError extends boolean = boolean,
TResponse = unknown, TResponse = unknown,
TResponseStyle extends ResponseStyle = 'fields', TResponseStyle extends ResponseStyle = "fields",
> = OmitKeys< > = OmitKeys<
RequestOptions<TResponse, TResponseStyle, ThrowOnError>, RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
'body' | 'path' | 'query' | 'url' "body" | "path" | "query" | "url"
> & > &
([TData] extends [never] ? unknown : Omit<TData, 'url'>); ([TData] extends [never] ? unknown : Omit<TData, "url">);

View file

@ -1,317 +1,337 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import { getAuthToken } from '../core/auth.gen'; import { getAuthToken } from "../core/auth.gen";
import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; import type { QuerySerializerOptions } from "../core/bodySerializer.gen";
import { jsonBodySerializer } from '../core/bodySerializer.gen'; import { jsonBodySerializer } from "../core/bodySerializer.gen";
import { import {
serializeArrayParam, serializeArrayParam,
serializeObjectParam, serializeObjectParam,
serializePrimitiveParam, serializePrimitiveParam,
} from '../core/pathSerializer.gen'; } from "../core/pathSerializer.gen";
import { getUrl } from '../core/utils.gen'; import { getUrl } from "../core/utils.gen";
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; import type {
Client,
ClientOptions,
Config,
RequestOptions,
} from "./types.gen";
export const createQuerySerializer = <T = unknown>({ export const createQuerySerializer = <T = unknown>({
parameters = {}, parameters = {},
...args ...args
}: QuerySerializerOptions = {}) => { }: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => { const querySerializer = (queryParams: T) => {
const search: string[] = []; const search: string[] = [];
if (queryParams && typeof queryParams === 'object') { if (queryParams && typeof queryParams === "object") {
for (const name in queryParams) { for (const name in queryParams) {
const value = queryParams[name]; const value = queryParams[name];
if (value === undefined || value === null) { if (value === undefined || value === null) {
continue; continue;
} }
const options = parameters[name] || args; const options = parameters[name] || args;
if (Array.isArray(value)) { if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({ const serializedArray = serializeArrayParam({
allowReserved: options.allowReserved, allowReserved: options.allowReserved,
explode: true, explode: true,
name, name,
style: 'form', style: "form",
value, value,
...options.array, ...options.array,
}); });
if (serializedArray) search.push(serializedArray); if (serializedArray) search.push(serializedArray);
} else if (typeof value === 'object') { } else if (typeof value === "object") {
const serializedObject = serializeObjectParam({ const serializedObject = serializeObjectParam({
allowReserved: options.allowReserved, allowReserved: options.allowReserved,
explode: true, explode: true,
name, name,
style: 'deepObject', style: "deepObject",
value: value as Record<string, unknown>, value: value as Record<string, unknown>,
...options.object, ...options.object,
}); });
if (serializedObject) search.push(serializedObject); if (serializedObject) search.push(serializedObject);
} else { } else {
const serializedPrimitive = serializePrimitiveParam({ const serializedPrimitive = serializePrimitiveParam({
allowReserved: options.allowReserved, allowReserved: options.allowReserved,
name, name,
value: value as string, value: value as string,
}); });
if (serializedPrimitive) search.push(serializedPrimitive); if (serializedPrimitive) search.push(serializedPrimitive);
} }
} }
} }
return search.join('&'); return search.join("&");
}; };
return querySerializer; return querySerializer;
}; };
/** /**
* Infers parseAs value from provided Content-Type header. * Infers parseAs value from provided Content-Type header.
*/ */
export const getParseAs = (contentType: string | null): Exclude<Config['parseAs'], 'auto'> => { export const getParseAs = (
if (!contentType) { contentType: string | null,
// If no Content-Type header is provided, the best we can do is return the raw response body, ): Exclude<Config["parseAs"], "auto"> => {
// which is effectively the same as the 'stream' option. if (!contentType) {
return 'stream'; // If no Content-Type header is provided, the best we can do is return the raw response body,
} // which is effectively the same as the 'stream' option.
return "stream";
}
const cleanContent = contentType.split(';')[0]?.trim(); const cleanContent = contentType.split(";")[0]?.trim();
if (!cleanContent) { if (!cleanContent) {
return; return;
} }
if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) { if (
return 'json'; cleanContent.startsWith("application/json") ||
} cleanContent.endsWith("+json")
) {
return "json";
}
if (cleanContent === 'multipart/form-data') { if (cleanContent === "multipart/form-data") {
return 'formData'; return "formData";
} }
if ( if (
['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type)) ["application/", "audio/", "image/", "video/"].some((type) =>
) { cleanContent.startsWith(type),
return 'blob'; )
} ) {
return "blob";
}
if (cleanContent.startsWith('text/')) { if (cleanContent.startsWith("text/")) {
return 'text'; return "text";
} }
return; return;
}; };
const checkForExistence = ( const checkForExistence = (
options: Pick<RequestOptions, 'auth' | 'query'> & { options: Pick<RequestOptions, "auth" | "query"> & {
headers: Headers; headers: Headers;
}, },
name?: string, name?: string,
): boolean => { ): boolean => {
if (!name) { if (!name) {
return false; return false;
} }
if ( if (
options.headers.has(name) || options.headers.has(name) ||
options.query?.[name] || options.query?.[name] ||
options.headers.get('Cookie')?.includes(`${name}=`) options.headers.get("Cookie")?.includes(`${name}=`)
) { ) {
return true; return true;
} }
return false; return false;
}; };
export async function setAuthParams( export const setAuthParams = async ({
options: Pick<RequestOptions, 'auth' | 'query' | 'security'> & { security,
headers: Headers; ...options
}, }: Pick<Required<RequestOptions>, "security"> &
): Promise<void> { Pick<RequestOptions, "auth" | "query"> & {
for (const auth of options.security ?? []) { headers: Headers;
if (checkForExistence(options, auth.name)) { }) => {
continue; for (const auth of security) {
} if (checkForExistence(options, auth.name)) {
continue;
}
const token = await getAuthToken(auth, options.auth); const token = await getAuthToken(auth, options.auth);
if (!token) { if (!token) {
continue; continue;
} }
const name = auth.name ?? 'Authorization'; const name = auth.name ?? "Authorization";
switch (auth.in) { switch (auth.in) {
case 'query': case "query":
if (!options.query) { if (!options.query) {
options.query = {}; options.query = {};
} }
options.query[name] = token; options.query[name] = token;
break; break;
case 'cookie': case "cookie":
options.headers.append('Cookie', `${name}=${token}`); options.headers.append("Cookie", `${name}=${token}`);
break; break;
case 'header': case "header":
default: default:
options.headers.set(name, token); options.headers.set(name, token);
break; break;
} }
} }
} };
export const buildUrl: Client['buildUrl'] = (options) => export const buildUrl: Client["buildUrl"] = (options) =>
getUrl({ getUrl({
baseUrl: options.baseUrl as string, baseUrl: options.baseUrl as string,
path: options.path, path: options.path,
query: options.query, query: options.query,
querySerializer: querySerializer:
typeof options.querySerializer === 'function' typeof options.querySerializer === "function"
? options.querySerializer ? options.querySerializer
: createQuerySerializer(options.querySerializer), : createQuerySerializer(options.querySerializer),
url: options.url, url: options.url,
}); });
export const mergeConfigs = (a: Config, b: Config): Config => { export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b }; const config = { ...a, ...b };
if (config.baseUrl?.endsWith('/')) { if (config.baseUrl?.endsWith("/")) {
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
} }
config.headers = mergeHeaders(a.headers, b.headers); config.headers = mergeHeaders(a.headers, b.headers);
return config; return config;
}; };
const headersEntries = (headers: Headers): Array<[string, string]> => { const headersEntries = (headers: Headers): Array<[string, string]> => {
const entries: Array<[string, string]> = []; const entries: Array<[string, string]> = [];
headers.forEach((value, key) => { headers.forEach((value, key) => {
entries.push([key, value]); entries.push([key, value]);
}); });
return entries; return entries;
}; };
export const mergeHeaders = ( export const mergeHeaders = (
...headers: Array<Required<Config>['headers'] | undefined> ...headers: Array<Required<Config>["headers"] | undefined>
): Headers => { ): Headers => {
const mergedHeaders = new Headers(); const mergedHeaders = new Headers();
for (const header of headers) { for (const header of headers) {
if (!header) { if (!header) {
continue; continue;
} }
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); const iterator =
header instanceof Headers
? headersEntries(header)
: Object.entries(header);
for (const [key, value] of iterator) { for (const [key, value] of iterator) {
if (value === null) { if (value === null) {
mergedHeaders.delete(key); mergedHeaders.delete(key);
} else if (Array.isArray(value)) { } else if (Array.isArray(value)) {
for (const v of value) { for (const v of value) {
mergedHeaders.append(key, v as string); mergedHeaders.append(key, v as string);
} }
} else if (value !== undefined) { } else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e., their // assume object headers are meant to be JSON stringified, i.e. their
// content value in OpenAPI specification is 'application/json' // content value in OpenAPI specification is 'application/json'
mergedHeaders.set( mergedHeaders.set(
key, key,
typeof value === 'object' ? JSON.stringify(value) : (value as string), typeof value === "object" ? JSON.stringify(value) : (value as string),
); );
} }
} }
} }
return mergedHeaders; return mergedHeaders;
}; };
type ErrInterceptor<Err, Res, Req, Options> = ( type ErrInterceptor<Err, Res, Req, Options> = (
error: Err, error: Err,
/** response may be undefined due to a network error where no response object is produced */ response: Res,
response: Res | undefined, request: Req,
/** request may be undefined, because error may be from building the request object itself */ options: Options,
request: Req | undefined,
options: Options,
) => Err | Promise<Err>; ) => Err | Promise<Err>;
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>; type ReqInterceptor<Req, Options> = (
request: Req,
options: Options,
) => Req | Promise<Req>;
type ResInterceptor<Res, Req, Options> = ( type ResInterceptor<Res, Req, Options> = (
response: Res, response: Res,
request: Req, request: Req,
options: Options, options: Options,
) => Res | Promise<Res>; ) => Res | Promise<Res>;
class Interceptors<Interceptor> { class Interceptors<Interceptor> {
fns: Array<Interceptor | null> = []; fns: Array<Interceptor | null> = [];
clear(): void { clear(): void {
this.fns = []; this.fns = [];
} }
eject(id: number | Interceptor): void { eject(id: number | Interceptor): void {
const index = this.getInterceptorIndex(id); const index = this.getInterceptorIndex(id);
if (this.fns[index]) { if (this.fns[index]) {
this.fns[index] = null; this.fns[index] = null;
} }
} }
exists(id: number | Interceptor): boolean { exists(id: number | Interceptor): boolean {
const index = this.getInterceptorIndex(id); const index = this.getInterceptorIndex(id);
return Boolean(this.fns[index]); return Boolean(this.fns[index]);
} }
getInterceptorIndex(id: number | Interceptor): number { getInterceptorIndex(id: number | Interceptor): number {
if (typeof id === 'number') { if (typeof id === "number") {
return this.fns[id] ? id : -1; return this.fns[id] ? id : -1;
} }
return this.fns.indexOf(id); return this.fns.indexOf(id);
} }
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { update(
const index = this.getInterceptorIndex(id); id: number | Interceptor,
if (this.fns[index]) { fn: Interceptor,
this.fns[index] = fn; ): number | Interceptor | false {
return id; const index = this.getInterceptorIndex(id);
} if (this.fns[index]) {
return false; this.fns[index] = fn;
} return id;
}
return false;
}
use(fn: Interceptor): number { use(fn: Interceptor): number {
this.fns.push(fn); this.fns.push(fn);
return this.fns.length - 1; return this.fns.length - 1;
} }
} }
export interface Middleware<Req, Res, Err, Options> { export interface Middleware<Req, Res, Err, Options> {
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>; error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
request: Interceptors<ReqInterceptor<Req, Options>>; request: Interceptors<ReqInterceptor<Req, Options>>;
response: Interceptors<ResInterceptor<Res, Req, Options>>; response: Interceptors<ResInterceptor<Res, Req, Options>>;
} }
export const createInterceptors = <Req, Res, Err, Options>(): Middleware< export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
Req, Req,
Res, Res,
Err, Err,
Options Options
> => ({ > => ({
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(), error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
request: new Interceptors<ReqInterceptor<Req, Options>>(), request: new Interceptors<ReqInterceptor<Req, Options>>(),
response: new Interceptors<ResInterceptor<Res, Req, Options>>(), response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
}); });
const defaultQuerySerializer = createQuerySerializer({ const defaultQuerySerializer = createQuerySerializer({
allowReserved: false, allowReserved: false,
array: { array: {
explode: true, explode: true,
style: 'form', style: "form",
}, },
object: { object: {
explode: true, explode: true,
style: 'deepObject', style: "deepObject",
}, },
}); });
const defaultHeaders = { const defaultHeaders = {
'Content-Type': 'application/json', "Content-Type": "application/json",
}; };
export const createConfig = <T extends ClientOptions = ClientOptions>( export const createConfig = <T extends ClientOptions = ClientOptions>(
override: Config<Omit<ClientOptions, keyof T> & T> = {}, override: Config<Omit<ClientOptions, keyof T> & T> = {},
): Config<Omit<ClientOptions, keyof T> & T> => ({ ): Config<Omit<ClientOptions, keyof T> & T> => ({
...jsonBodySerializer, ...jsonBodySerializer,
headers: defaultHeaders, headers: defaultHeaders,
parseAs: 'auto', parseAs: "auto",
querySerializer: defaultQuerySerializer, querySerializer: defaultQuerySerializer,
...override, ...override,
}); });

View file

@ -1,42 +1,42 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
export type AuthToken = string | undefined; export type AuthToken = string | undefined;
export interface Auth { export interface Auth {
/** /**
* Which part of the request do we use to send the auth? * Which part of the request do we use to send the auth?
* *
* @default 'header' * @default 'header'
*/ */
in?: 'header' | 'query' | 'cookie'; in?: "header" | "query" | "cookie";
/** /**
* Header or query parameter name. * Header or query parameter name.
* *
* @default 'Authorization' * @default 'Authorization'
*/ */
name?: string; name?: string;
scheme?: 'basic' | 'bearer'; scheme?: "basic" | "bearer";
type: 'apiKey' | 'http'; type: "apiKey" | "http";
} }
export const getAuthToken = async ( export const getAuthToken = async (
auth: Auth, auth: Auth,
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken, callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
): Promise<string | undefined> => { ): Promise<string | undefined> => {
const token = typeof callback === 'function' ? await callback(auth) : callback; const token =
typeof callback === "function" ? await callback(auth) : callback;
if (!token) { if (!token) {
return; return;
} }
if (auth.scheme === 'bearer') { if (auth.scheme === "bearer") {
return `Bearer ${token}`; return `Bearer ${token}`;
} }
if (auth.scheme === 'basic') { if (auth.scheme === "basic") {
return `Basic ${btoa(token)}`; return `Basic ${btoa(token)}`;
} }
return token; return token;
}; };

View file

@ -1,83 +1,100 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen'; import type {
ArrayStyle,
ObjectStyle,
SerializerOptions,
} from "./pathSerializer.gen";
export type QuerySerializer = (query: Record<string, unknown>) => string; export type QuerySerializer = (query: Record<string, unknown>) => string;
export type BodySerializer = (body: unknown) => unknown; export type BodySerializer = (body: any) => any;
type QuerySerializerOptionsObject = { type QuerySerializerOptionsObject = {
allowReserved?: boolean; allowReserved?: boolean;
array?: Partial<SerializerOptions<ArrayStyle>>; array?: Partial<SerializerOptions<ArrayStyle>>;
object?: Partial<SerializerOptions<ObjectStyle>>; object?: Partial<SerializerOptions<ObjectStyle>>;
}; };
export type QuerySerializerOptions = QuerySerializerOptionsObject & { export type QuerySerializerOptions = QuerySerializerOptionsObject & {
/** /**
* Per-parameter serialization overrides. When provided, these settings * Per-parameter serialization overrides. When provided, these settings
* override the global array/object settings for specific parameter names. * override the global array/object settings for specific parameter names.
*/ */
parameters?: Record<string, QuerySerializerOptionsObject>; parameters?: Record<string, QuerySerializerOptionsObject>;
}; };
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { const serializeFormDataPair = (
if (typeof value === 'string' || value instanceof Blob) { data: FormData,
data.append(key, value); key: string,
} else if (value instanceof Date) { value: unknown,
data.append(key, value.toISOString()); ): void => {
} else { if (typeof value === "string" || value instanceof Blob) {
data.append(key, JSON.stringify(value)); data.append(key, value);
} } else if (value instanceof Date) {
data.append(key, value.toISOString());
} else {
data.append(key, JSON.stringify(value));
}
}; };
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { const serializeUrlSearchParamsPair = (
if (typeof value === 'string') { data: URLSearchParams,
data.append(key, value); key: string,
} else { value: unknown,
data.append(key, JSON.stringify(value)); ): void => {
} if (typeof value === "string") {
data.append(key, value);
} else {
data.append(key, JSON.stringify(value));
}
}; };
export const formDataBodySerializer = { export const formDataBodySerializer = {
bodySerializer: (body: unknown): FormData => { bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
const data = new FormData(); body: T,
): FormData => {
const data = new FormData();
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => { Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return; return;
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
value.forEach((v) => serializeFormDataPair(data, key, v)); value.forEach((v) => serializeFormDataPair(data, key, v));
} else { } else {
serializeFormDataPair(data, key, value); serializeFormDataPair(data, key, value);
} }
}); });
return data; return data;
}, },
}; };
export const jsonBodySerializer = { export const jsonBodySerializer = {
bodySerializer: (body: unknown): string => bodySerializer: <T>(body: T): string =>
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), JSON.stringify(body, (_key, value) =>
typeof value === "bigint" ? value.toString() : value,
),
}; };
export const urlSearchParamsBodySerializer = { export const urlSearchParamsBodySerializer = {
bodySerializer: (body: unknown): string => { bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
const data = new URLSearchParams(); body: T,
): string => {
const data = new URLSearchParams();
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => { Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return; return;
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
} else { } else {
serializeUrlSearchParamsPair(data, key, value); serializeUrlSearchParamsPair(data, key, value);
} }
}); });
return data.toString(); return data.toString();
}, },
}; };

View file

@ -1,170 +1,176 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
type Slot = 'body' | 'headers' | 'path' | 'query'; type Slot = "body" | "headers" | "path" | "query";
export type Field = export type Field =
| { | {
in: Exclude<Slot, 'body'>; in: Exclude<Slot, "body">;
/** /**
* Field name. This is the name we want the user to see and use. * Field name. This is the name we want the user to see and use.
*/ */
key: string; key: string;
/** /**
* Field mapped name. This is the name we want to use in the request. * Field mapped name. This is the name we want to use in the request.
* If omitted, we use the same value as `key`. * If omitted, we use the same value as `key`.
*/ */
map?: string; map?: string;
} }
| { | {
in: Extract<Slot, 'body'>; in: Extract<Slot, "body">;
/** /**
* Key isn't required for bodies. * Key isn't required for bodies.
*/ */
key?: string; key?: string;
map?: string; map?: string;
} }
| { | {
/** /**
* Field name. This is the name we want the user to see and use. * Field name. This is the name we want the user to see and use.
*/ */
key: string; key: string;
/** /**
* Field mapped name. This is the name we want to use in the request. * Field mapped name. This is the name we want to use in the request.
* If `in` is omitted, `map` aliases `key` to the transport layer. * If `in` is omitted, `map` aliases `key` to the transport layer.
*/ */
map: Slot; map: Slot;
}; };
export interface Fields { export interface Fields {
allowExtra?: Partial<Record<Slot, boolean>>; allowExtra?: Partial<Record<Slot, boolean>>;
args?: ReadonlyArray<Field>; args?: ReadonlyArray<Field>;
} }
export type FieldsConfig = ReadonlyArray<Field | Fields>; export type FieldsConfig = ReadonlyArray<Field | Fields>;
const extraPrefixesMap: Record<string, Slot> = { const extraPrefixesMap: Record<string, Slot> = {
$body_: 'body', $body_: "body",
$headers_: 'headers', $headers_: "headers",
$path_: 'path', $path_: "path",
$query_: 'query', $query_: "query",
}; };
const extraPrefixes = Object.entries(extraPrefixesMap); const extraPrefixes = Object.entries(extraPrefixesMap);
type KeyMap = Map< type KeyMap = Map<
string, string,
| { | {
in: Slot; in: Slot;
map?: string; map?: string;
} }
| { | {
in?: never; in?: never;
map: Slot; map: Slot;
} }
>; >;
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
if (!map) { if (!map) {
map = new Map(); map = new Map();
} }
for (const config of fields) { for (const config of fields) {
if ('in' in config) { if ("in" in config) {
if (config.key) { if (config.key) {
map.set(config.key, { map.set(config.key, {
in: config.in, in: config.in,
map: config.map, map: config.map,
}); });
} }
} else if ('key' in config) { } else if ("key" in config) {
map.set(config.key, { map.set(config.key, {
map: config.map, map: config.map,
}); });
} else if (config.args) { } else if (config.args) {
buildKeyMap(config.args, map); buildKeyMap(config.args, map);
} }
} }
return map; return map;
}; };
interface Params { interface Params {
body: unknown; body: unknown;
headers: Record<string, unknown>; headers: Record<string, unknown>;
path: Record<string, unknown>; path: Record<string, unknown>;
query: Record<string, unknown>; query: Record<string, unknown>;
} }
const stripEmptySlots = (params: Params) => { const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) { for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) { if (value && typeof value === "object" && !Object.keys(value).length) {
delete params[slot as Slot]; delete params[slot as Slot];
} }
} }
}; };
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => { export const buildClientParams = (
const params: Params = { args: ReadonlyArray<unknown>,
body: {}, fields: FieldsConfig,
headers: {}, ) => {
path: {}, const params: Params = {
query: {}, body: {},
}; headers: {},
path: {},
query: {},
};
const map = buildKeyMap(fields); const map = buildKeyMap(fields);
let config: FieldsConfig[number] | undefined; let config: FieldsConfig[number] | undefined;
for (const [index, arg] of args.entries()) { for (const [index, arg] of args.entries()) {
if (fields[index]) { if (fields[index]) {
config = fields[index]; config = fields[index];
} }
if (!config) { if (!config) {
continue; continue;
} }
if ('in' in config) { if ("in" in config) {
if (config.key) { if (config.key) {
const field = map.get(config.key)!; const field = map.get(config.key)!;
const name = field.map || config.key; const name = field.map || config.key;
if (field.in) { if (field.in) {
(params[field.in] as Record<string, unknown>)[name] = arg; (params[field.in] as Record<string, unknown>)[name] = arg;
} }
} else { } else {
params.body = arg; params.body = arg;
} }
} else { } else {
for (const [key, value] of Object.entries(arg ?? {})) { for (const [key, value] of Object.entries(arg ?? {})) {
const field = map.get(key); const field = map.get(key);
if (field) { if (field) {
if (field.in) { if (field.in) {
const name = field.map || key; const name = field.map || key;
(params[field.in] as Record<string, unknown>)[name] = value; (params[field.in] as Record<string, unknown>)[name] = value;
} else { } else {
params[field.map] = value; params[field.map] = value;
} }
} else { } else {
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); const extra = extraPrefixes.find(([prefix]) =>
key.startsWith(prefix),
);
if (extra) { if (extra) {
const [prefix, slot] = extra; const [prefix, slot] = extra;
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value; (params[slot] as Record<string, unknown>)[
} else if ('allowExtra' in config && config.allowExtra) { key.slice(prefix.length)
for (const [slot, allowed] of Object.entries(config.allowExtra)) { ] = value;
if (allowed) { } else if ("allowExtra" in config && config.allowExtra) {
(params[slot as Slot] as Record<string, unknown>)[key] = value; for (const [slot, allowed] of Object.entries(config.allowExtra)) {
break; if (allowed) {
} (params[slot as Slot] as Record<string, unknown>)[key] = value;
} break;
} }
} }
} }
} }
} }
}
}
stripEmptySlots(params); stripEmptySlots(params);
return params; return params;
}; };

View file

@ -1,172 +1,181 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {} interface SerializeOptions<T>
extends SerializePrimitiveOptions,
SerializerOptions<T> {}
interface SerializePrimitiveOptions { interface SerializePrimitiveOptions {
allowReserved?: boolean; allowReserved?: boolean;
name: string; name: string;
} }
export interface SerializerOptions<T> { export interface SerializerOptions<T> {
/** /**
* @default true * @default true
*/ */
explode: boolean; explode: boolean;
style: T; style: T;
} }
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited";
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
type MatrixStyle = 'label' | 'matrix' | 'simple'; type MatrixStyle = "label" | "matrix" | "simple";
export type ObjectStyle = 'form' | 'deepObject'; export type ObjectStyle = "form" | "deepObject";
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
interface SerializePrimitiveParam extends SerializePrimitiveOptions { interface SerializePrimitiveParam extends SerializePrimitiveOptions {
value: string; value: string;
} }
export const separatorArrayExplode = (style: ArraySeparatorStyle) => { export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
switch (style) { switch (style) {
case 'label': case "label":
return '.'; return ".";
case 'matrix': case "matrix":
return ';'; return ";";
case 'simple': case "simple":
return ','; return ",";
default: default:
return '&'; return "&";
} }
}; };
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
switch (style) { switch (style) {
case 'form': case "form":
return ','; return ",";
case 'pipeDelimited': case "pipeDelimited":
return '|'; return "|";
case 'spaceDelimited': case "spaceDelimited":
return '%20'; return "%20";
default: default:
return ','; return ",";
} }
}; };
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
switch (style) { switch (style) {
case 'label': case "label":
return '.'; return ".";
case 'matrix': case "matrix":
return ';'; return ";";
case 'simple': case "simple":
return ','; return ",";
default: default:
return '&'; return "&";
} }
}; };
export const serializeArrayParam = ({ export const serializeArrayParam = ({
allowReserved, allowReserved,
explode, explode,
name, name,
style, style,
value, value,
}: SerializeOptions<ArraySeparatorStyle> & { }: SerializeOptions<ArraySeparatorStyle> & {
value: unknown[]; value: unknown[];
}) => { }) => {
if (!explode) { if (!explode) {
const joinedValues = ( const joinedValues = (
allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
).join(separatorArrayNoExplode(style)); ).join(separatorArrayNoExplode(style));
switch (style) { switch (style) {
case 'label': case "label":
return `.${joinedValues}`; return `.${joinedValues}`;
case 'matrix': case "matrix":
return `;${name}=${joinedValues}`; return `;${name}=${joinedValues}`;
case 'simple': case "simple":
return joinedValues; return joinedValues;
default: default:
return `${name}=${joinedValues}`; return `${name}=${joinedValues}`;
} }
} }
const separator = separatorArrayExplode(style); const separator = separatorArrayExplode(style);
const joinedValues = value const joinedValues = value
.map((v) => { .map((v) => {
if (style === 'label' || style === 'simple') { if (style === "label" || style === "simple") {
return allowReserved ? v : encodeURIComponent(v as string); return allowReserved ? v : encodeURIComponent(v as string);
} }
return serializePrimitiveParam({ return serializePrimitiveParam({
allowReserved, allowReserved,
name, name,
value: v as string, value: v as string,
}); });
}) })
.join(separator); .join(separator);
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; return style === "label" || style === "matrix"
? separator + joinedValues
: joinedValues;
}; };
export const serializePrimitiveParam = ({ export const serializePrimitiveParam = ({
allowReserved, allowReserved,
name, name,
value, value,
}: SerializePrimitiveParam) => { }: SerializePrimitiveParam) => {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return ''; return "";
} }
if (typeof value === 'object') { if (typeof value === "object") {
throw new Error( throw new Error(
'Deeply-nested arrays/objects arent supported. Provide your own `querySerializer()` to handle these.', "Deeply-nested arrays/objects arent supported. Provide your own `querySerializer()` to handle these.",
); );
} }
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
}; };
export const serializeObjectParam = ({ export const serializeObjectParam = ({
allowReserved, allowReserved,
explode, explode,
name, name,
style, style,
value, value,
valueOnly, valueOnly,
}: SerializeOptions<ObjectSeparatorStyle> & { }: SerializeOptions<ObjectSeparatorStyle> & {
value: Record<string, unknown> | Date; value: Record<string, unknown> | Date;
valueOnly?: boolean; valueOnly?: boolean;
}) => { }) => {
if (value instanceof Date) { if (value instanceof Date) {
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
} }
if (style !== 'deepObject' && !explode) { if (style !== "deepObject" && !explode) {
let values: string[] = []; let values: string[] = [];
Object.entries(value).forEach(([key, v]) => { Object.entries(value).forEach(([key, v]) => {
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; values = [
}); ...values,
const joinedValues = values.join(','); key,
switch (style) { allowReserved ? (v as string) : encodeURIComponent(v as string),
case 'form': ];
return `${name}=${joinedValues}`; });
case 'label': const joinedValues = values.join(",");
return `.${joinedValues}`; switch (style) {
case 'matrix': case "form":
return `;${name}=${joinedValues}`; return `${name}=${joinedValues}`;
default: case "label":
return joinedValues; return `.${joinedValues}`;
} case "matrix":
} return `;${name}=${joinedValues}`;
default:
return joinedValues;
}
}
const separator = separatorObjectExplode(style); const separator = separatorObjectExplode(style);
const joinedValues = Object.entries(value) const joinedValues = Object.entries(value)
.map(([key, v]) => .map(([key, v]) =>
serializePrimitiveParam({ serializePrimitiveParam({
allowReserved, allowReserved,
name: style === 'deepObject' ? `${name}[${key}]` : key, name: style === "deepObject" ? `${name}[${key}]` : key,
value: v as string, value: v as string,
}), }),
) )
.join(separator); .join(separator);
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; return style === "label" || style === "matrix"
? separator + joinedValues
: joinedValues;
}; };

View file

@ -1,118 +1,136 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
/** /**
* JSON-friendly union that mirrors what Pinia Colada can hash. * JSON-friendly union that mirrors what Pinia Colada can hash.
*/ */
export type JsonValue = export type JsonValue =
| null | null
| string | string
| number | number
| boolean | boolean
| JsonValue[] | JsonValue[]
| { [key: string]: JsonValue }; | { [key: string]: JsonValue };
/** /**
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
*/ */
export const queryKeyJsonReplacer = (_key: string, value: unknown) => { export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { if (
return undefined; value === undefined ||
} typeof value === "function" ||
if (typeof value === 'bigint') { typeof value === "symbol"
return value.toString(); ) {
} return undefined;
if (value instanceof Date) { }
return value.toISOString(); if (typeof value === "bigint") {
} return value.toString();
return value; }
if (value instanceof Date) {
return value.toISOString();
}
return value;
}; };
/** /**
* Safely stringifies a value and parses it back into a JsonValue. * Safely stringifies a value and parses it back into a JsonValue.
*/ */
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
try { try {
const json = JSON.stringify(input, queryKeyJsonReplacer); const json = JSON.stringify(input, queryKeyJsonReplacer);
if (json === undefined) { if (json === undefined) {
return undefined; return undefined;
} }
return JSON.parse(json) as JsonValue; return JSON.parse(json) as JsonValue;
} catch { } catch {
return undefined; return undefined;
} }
}; };
/** /**
* Detects plain objects (including objects with a null prototype). * Detects plain objects (including objects with a null prototype).
*/ */
const isPlainObject = (value: unknown): value is Record<string, unknown> => { const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (value === null || typeof value !== 'object') { if (value === null || typeof value !== "object") {
return false; return false;
} }
const prototype = Object.getPrototypeOf(value as object); const prototype = Object.getPrototypeOf(value as object);
return prototype === Object.prototype || prototype === null; return prototype === Object.prototype || prototype === null;
}; };
/** /**
* Turns URLSearchParams into a sorted JSON object for deterministic keys. * Turns URLSearchParams into a sorted JSON object for deterministic keys.
*/ */
const serializeSearchParams = (params: URLSearchParams): JsonValue => { const serializeSearchParams = (params: URLSearchParams): JsonValue => {
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); const entries = Array.from(params.entries()).sort(([a], [b]) =>
const result: Record<string, JsonValue> = {}; a.localeCompare(b),
);
const result: Record<string, JsonValue> = {};
for (const [key, value] of entries) { for (const [key, value] of entries) {
const existing = result[key]; const existing = result[key];
if (existing === undefined) { if (existing === undefined) {
result[key] = value; result[key] = value;
continue; continue;
} }
if (Array.isArray(existing)) { if (Array.isArray(existing)) {
(existing as string[]).push(value); (existing as string[]).push(value);
} else { } else {
result[key] = [existing, value]; result[key] = [existing, value];
} }
} }
return result; return result;
}; };
/** /**
* Normalizes any accepted value into a JSON-friendly shape for query keys. * Normalizes any accepted value into a JSON-friendly shape for query keys.
*/ */
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { export const serializeQueryKeyValue = (
if (value === null) { value: unknown,
return null; ): JsonValue | undefined => {
} if (value === null) {
return null;
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { if (
return value; typeof value === "string" ||
} typeof value === "number" ||
typeof value === "boolean"
) {
return value;
}
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { if (
return undefined; value === undefined ||
} typeof value === "function" ||
typeof value === "symbol"
) {
return undefined;
}
if (typeof value === 'bigint') { if (typeof value === "bigint") {
return value.toString(); return value.toString();
} }
if (value instanceof Date) { if (value instanceof Date) {
return value.toISOString(); return value.toISOString();
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
return stringifyToJsonValue(value); return stringifyToJsonValue(value);
} }
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { if (
return serializeSearchParams(value); typeof URLSearchParams !== "undefined" &&
} value instanceof URLSearchParams
) {
return serializeSearchParams(value);
}
if (isPlainObject(value)) { if (isPlainObject(value)) {
return stringifyToJsonValue(value); return stringifyToJsonValue(value);
} }
return undefined; return undefined;
}; };

View file

@ -1,243 +1,266 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { Config } from './types.gen'; import type { Config } from "./types.gen";
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, 'method'> & export type ServerSentEventsOptions<TData = unknown> = Omit<
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & { RequestInit,
/** "method"
* Fetch API implementation. You can use this option to provide a custom > &
* fetch instance. Pick<Config, "method" | "responseTransformer" | "responseValidator"> & {
* /**
* @default globalThis.fetch * Fetch API implementation. You can use this option to provide a custom
*/ * fetch instance.
fetch?: typeof fetch; *
/** * @default globalThis.fetch
* Implementing clients can call request interceptors inside this hook. */
*/ fetch?: typeof fetch;
onRequest?: (url: string, init: RequestInit) => Promise<Request>; /**
/** * Implementing clients can call request interceptors inside this hook.
* Callback invoked when a network or parsing error occurs during streaming. */
* onRequest?: (url: string, init: RequestInit) => Promise<Request>;
* This option applies only if the endpoint returns a stream of events. /**
* * Callback invoked when a network or parsing error occurs during streaming.
* @param error The error that occurred. *
*/ * This option applies only if the endpoint returns a stream of events.
onSseError?: (error: unknown) => void; *
/** * @param error The error that occurred.
* Callback invoked when an event is streamed from the server. */
* onSseError?: (error: unknown) => void;
* This option applies only if the endpoint returns a stream of events. /**
* * Callback invoked when an event is streamed from the server.
* @param event Event streamed from the server. *
* @returns Nothing (void). * This option applies only if the endpoint returns a stream of events.
*/ *
onSseEvent?: (event: StreamEvent<TData>) => void; * @param event Event streamed from the server.
serializedBody?: RequestInit['body']; * @returns Nothing (void).
/** */
* Default retry delay in milliseconds. onSseEvent?: (event: StreamEvent<TData>) => void;
* serializedBody?: RequestInit["body"];
* This option applies only if the endpoint returns a stream of events. /**
* * Default retry delay in milliseconds.
* @default 3000 *
*/ * This option applies only if the endpoint returns a stream of events.
sseDefaultRetryDelay?: number; *
/** * @default 3000
* Maximum number of retry attempts before giving up. */
*/ sseDefaultRetryDelay?: number;
sseMaxRetryAttempts?: number; /**
/** * Maximum number of retry attempts before giving up.
* Maximum retry delay in milliseconds. */
* sseMaxRetryAttempts?: number;
* Applies only when exponential backoff is used. /**
* * Maximum retry delay in milliseconds.
* This option applies only if the endpoint returns a stream of events. *
* * Applies only when exponential backoff is used.
* @default 30000 *
*/ * This option applies only if the endpoint returns a stream of events.
sseMaxRetryDelay?: number; *
/** * @default 30000
* Optional sleep function for retry backoff. */
* sseMaxRetryDelay?: number;
* Defaults to using `setTimeout`. /**
*/ * Optional sleep function for retry backoff.
sseSleepFn?: (ms: number) => Promise<void>; *
url: string; * Defaults to using `setTimeout`.
}; */
sseSleepFn?: (ms: number) => Promise<void>;
url: string;
};
export interface StreamEvent<TData = unknown> { export interface StreamEvent<TData = unknown> {
data: TData; data: TData;
event?: string; event?: string;
id?: string; id?: string;
retry?: number; retry?: number;
} }
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = { export type ServerSentEventsResult<
stream: AsyncGenerator< TData = unknown,
TData extends Record<string, unknown> ? TData[keyof TData] : TData, TReturn = void,
TReturn, TNext = unknown,
TNext > = {
>; stream: AsyncGenerator<
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
TReturn,
TNext
>;
}; };
export function createSseClient<TData = unknown>({ export const createSseClient = <TData = unknown>({
onRequest, onRequest,
onSseError, onSseError,
onSseEvent, onSseEvent,
responseTransformer, responseTransformer,
responseValidator, responseValidator,
sseDefaultRetryDelay, sseDefaultRetryDelay,
sseMaxRetryAttempts, sseMaxRetryAttempts,
sseMaxRetryDelay, sseMaxRetryDelay,
sseSleepFn, sseSleepFn,
url, url,
...options ...options
}: ServerSentEventsOptions): ServerSentEventsResult<TData> { }: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
let lastEventId: string | undefined; let lastEventId: string | undefined;
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); const sleep =
sseSleepFn ??
((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
const createStream = async function* () { const createStream = async function* () {
let retryDelay: number = sseDefaultRetryDelay ?? 3000; let retryDelay: number = sseDefaultRetryDelay ?? 3000;
let attempt = 0; let attempt = 0;
const signal = options.signal ?? new AbortController().signal; const signal = options.signal ?? new AbortController().signal;
while (true) { while (true) {
if (signal.aborted) break; if (signal.aborted) break;
attempt++; attempt++;
const headers = const headers =
options.headers instanceof Headers options.headers instanceof Headers
? options.headers ? options.headers
: new Headers(options.headers as Record<string, string> | undefined); : new Headers(options.headers as Record<string, string> | undefined);
if (lastEventId !== undefined) { if (lastEventId !== undefined) {
headers.set('Last-Event-ID', lastEventId); headers.set("Last-Event-ID", lastEventId);
} }
try { try {
const requestInit: RequestInit = { const requestInit: RequestInit = {
redirect: 'follow', redirect: "follow",
...options, ...options,
body: options.serializedBody, body: options.serializedBody,
headers, headers,
signal, signal,
}; };
let request = new Request(url, requestInit); let request = new Request(url, requestInit);
if (onRequest) { if (onRequest) {
request = await onRequest(url, requestInit); request = await onRequest(url, requestInit);
} }
// fetch must be assigned here, otherwise it would throw the error: // fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = options.fetch ?? globalThis.fetch; const _fetch = options.fetch ?? globalThis.fetch;
const response = await _fetch(request); const response = await _fetch(request);
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); if (!response.ok)
throw new Error(
`SSE failed: ${response.status} ${response.statusText}`,
);
if (!response.body) throw new Error('No body in SSE response'); if (!response.body) throw new Error("No body in SSE response");
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = ''; let buffer = "";
const abortHandler = () => { const abortHandler = () => {
try { try {
reader.cancel(); reader.cancel();
} catch { } catch {
// noop // noop
} }
}; };
signal.addEventListener('abort', abortHandler); signal.addEventListener("abort", abortHandler);
try { try {
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
buffer += value; buffer += value;
buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings // Normalize line endings: CRLF -> LF, then CR -> LF
buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const chunks = buffer.split('\n\n'); const chunks = buffer.split("\n\n");
buffer = chunks.pop() ?? ''; buffer = chunks.pop() ?? "";
for (const chunk of chunks) { for (const chunk of chunks) {
const lines = chunk.split('\n'); const lines = chunk.split("\n");
const dataLines: Array<string> = []; const dataLines: Array<string> = [];
let eventName: string | undefined; let eventName: string | undefined;
for (const line of lines) { for (const line of lines) {
if (line.startsWith('data:')) { if (line.startsWith("data:")) {
dataLines.push(line.replace(/^data:\s*/, '')); dataLines.push(line.replace(/^data:\s*/, ""));
} else if (line.startsWith('event:')) { } else if (line.startsWith("event:")) {
eventName = line.replace(/^event:\s*/, ''); eventName = line.replace(/^event:\s*/, "");
} else if (line.startsWith('id:')) { } else if (line.startsWith("id:")) {
lastEventId = line.replace(/^id:\s*/, ''); lastEventId = line.replace(/^id:\s*/, "");
} else if (line.startsWith('retry:')) { } else if (line.startsWith("retry:")) {
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10); const parsed = Number.parseInt(
if (!Number.isNaN(parsed)) { line.replace(/^retry:\s*/, ""),
retryDelay = parsed; 10,
} );
} if (!Number.isNaN(parsed)) {
} retryDelay = parsed;
}
}
}
let data: unknown; let data: unknown;
let parsedJson = false; let parsedJson = false;
if (dataLines.length) { if (dataLines.length) {
const rawData = dataLines.join('\n'); const rawData = dataLines.join("\n");
try { try {
data = JSON.parse(rawData); data = JSON.parse(rawData);
parsedJson = true; parsedJson = true;
} catch { } catch {
data = rawData; data = rawData;
} }
} }
if (parsedJson) { if (parsedJson) {
if (responseValidator) { if (responseValidator) {
await responseValidator(data); await responseValidator(data);
} }
if (responseTransformer) { if (responseTransformer) {
data = await responseTransformer(data); data = await responseTransformer(data);
} }
} }
onSseEvent?.({ onSseEvent?.({
data, data,
event: eventName, event: eventName,
id: lastEventId, id: lastEventId,
retry: retryDelay, retry: retryDelay,
}); });
if (dataLines.length) { if (dataLines.length) {
yield data as any; yield data as any;
} }
} }
} }
} finally { } finally {
signal.removeEventListener('abort', abortHandler); signal.removeEventListener("abort", abortHandler);
reader.releaseLock(); reader.releaseLock();
} }
break; // exit loop on normal completion break; // exit loop on normal completion
} catch (error) { } catch (error) {
// connection failed or aborted; retry after delay // connection failed or aborted; retry after delay
onSseError?.(error); onSseError?.(error);
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { if (
break; // stop after firing error sseMaxRetryAttempts !== undefined &&
} attempt >= sseMaxRetryAttempts
) {
break; // stop after firing error
}
// exponential backoff: double retry each attempt, cap at 30s // exponential backoff: double retry each attempt, cap at 30s
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); const backoff = Math.min(
await sleep(backoff); retryDelay * 2 ** (attempt - 1),
} sseMaxRetryDelay ?? 30000,
} );
}; await sleep(backoff);
}
}
};
const stream = createStream(); const stream = createStream();
return { stream }; return { stream };
} };

View file

@ -1,105 +1,118 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { Auth, AuthToken } from './auth.gen'; import type { Auth, AuthToken } from "./auth.gen";
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen'; import type {
BodySerializer,
QuerySerializer,
QuerySerializerOptions,
} from "./bodySerializer.gen";
export type HttpMethod = export type HttpMethod =
| 'connect' | "connect"
| 'delete' | "delete"
| 'get' | "get"
| 'head' | "head"
| 'options' | "options"
| 'patch' | "patch"
| 'post' | "post"
| 'put' | "put"
| 'trace'; | "trace";
export type Client< export type Client<
RequestFn = never, RequestFn = never,
Config = unknown, Config = unknown,
MethodFn = never, MethodFn = never,
BuildUrlFn = never, BuildUrlFn = never,
SseFn = never, SseFn = never,
> = { > = {
/** /**
* Returns the final request URL. * Returns the final request URL.
*/ */
buildUrl: BuildUrlFn; buildUrl: BuildUrlFn;
getConfig: () => Config; getConfig: () => Config;
request: RequestFn; request: RequestFn;
setConfig: (config: Config) => Config; setConfig: (config: Config) => Config;
} & { } & {
[K in HttpMethod]: MethodFn; [K in HttpMethod]: MethodFn;
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }); } & ([SseFn] extends [never]
? { sse?: never }
: { sse: { [K in HttpMethod]: SseFn } });
export interface Config { export interface Config {
/** /**
* Auth token or a function returning auth token. The resolved value will be * Auth token or a function returning auth token. The resolved value will be
* added to the request payload as defined by its `security` array. * added to the request payload as defined by its `security` array.
*/ */
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken; auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
/** /**
* A function for serializing request body parameter. By default, * A function for serializing request body parameter. By default,
* {@link JSON.stringify()} will be used. * {@link JSON.stringify()} will be used.
*/ */
bodySerializer?: BodySerializer | null; bodySerializer?: BodySerializer | null;
/** /**
* An object containing any HTTP headers that you want to pre-populate your * An object containing any HTTP headers that you want to pre-populate your
* `Headers` object with. * `Headers` object with.
* *
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/ */
headers?: headers?:
| RequestInit['headers'] | RequestInit["headers"]
| Record< | Record<
string, string,
string | number | boolean | (string | number | boolean)[] | null | undefined | unknown | string
>; | number
/** | boolean
* The request method. | (string | number | boolean)[]
* | null
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} | undefined
*/ | unknown
method?: Uppercase<HttpMethod>; >;
/** /**
* A function for serializing request query parameters. By default, arrays * The request method.
* will be exploded in form style, objects will be exploded in deepObject *
* style, and reserved characters are percent-encoded. * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
* */
* This method will have no effect if the native `paramsSerializer()` Axios method?: Uppercase<HttpMethod>;
* API function is used. /**
* * A function for serializing request query parameters. By default, arrays
* {@link https://swagger.io/docs/specification/serialization/#query View examples} * will be exploded in form style, objects will be exploded in deepObject
*/ * style, and reserved characters are percent-encoded.
querySerializer?: QuerySerializer | QuerySerializerOptions; *
/** * This method will have no effect if the native `paramsSerializer()` Axios
* A function validating request data. This is useful if you want to ensure * API function is used.
* the request conforms to the desired shape, so it can be safely sent to *
* the server. * {@link https://swagger.io/docs/specification/serialization/#query View examples}
*/ */
requestValidator?: (data: unknown) => Promise<unknown>; querySerializer?: QuerySerializer | QuerySerializerOptions;
/** /**
* A function transforming response data before it's returned. This is useful * A function validating request data. This is useful if you want to ensure
* for post-processing data, e.g., converting ISO strings into Date objects. * the request conforms to the desired shape, so it can be safely sent to
*/ * the server.
responseTransformer?: (data: unknown) => Promise<unknown>; */
/** requestValidator?: (data: unknown) => Promise<unknown>;
* A function validating response data. This is useful if you want to ensure /**
* the response conforms to the desired shape, so it can be safely passed to * A function transforming response data before it's returned. This is useful
* the transformers and returned to the user. * for post-processing data, e.g. converting ISO strings into Date objects.
*/ */
responseValidator?: (data: unknown) => Promise<unknown>; responseTransformer?: (data: unknown) => Promise<unknown>;
/**
* A function validating response data. This is useful if you want to ensure
* the response conforms to the desired shape, so it can be safely passed to
* the transformers and returned to the user.
*/
responseValidator?: (data: unknown) => Promise<unknown>;
} }
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never] type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
? true ? true
: [T] extends [never | undefined] : [T] extends [never | undefined]
? [undefined] extends [T] ? [undefined] extends [T]
? false ? false
: true : true
: false; : false;
export type OmitNever<T extends Record<string, unknown>> = { export type OmitNever<T extends Record<string, unknown>> = {
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K]; [K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true
? never
: K]: T[K];
}; };

View file

@ -1,141 +1,143 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen";
import { import {
type ArraySeparatorStyle, type ArraySeparatorStyle,
serializeArrayParam, serializeArrayParam,
serializeObjectParam, serializeObjectParam,
serializePrimitiveParam, serializePrimitiveParam,
} from './pathSerializer.gen'; } from "./pathSerializer.gen";
export interface PathSerializer { export interface PathSerializer {
path: Record<string, unknown>; path: Record<string, unknown>;
url: string; url: string;
} }
export const PATH_PARAM_RE = /\{[^{}]+\}/g; export const PATH_PARAM_RE = /\{[^{}]+\}/g;
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url; let url = _url;
const matches = _url.match(PATH_PARAM_RE); const matches = _url.match(PATH_PARAM_RE);
if (matches) { if (matches) {
for (const match of matches) { for (const match of matches) {
let explode = false; let explode = false;
let name = match.substring(1, match.length - 1); let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = 'simple'; let style: ArraySeparatorStyle = "simple";
if (name.endsWith('*')) { if (name.endsWith("*")) {
explode = true; explode = true;
name = name.substring(0, name.length - 1); name = name.substring(0, name.length - 1);
} }
if (name.startsWith('.')) { if (name.startsWith(".")) {
name = name.substring(1); name = name.substring(1);
style = 'label'; style = "label";
} else if (name.startsWith(';')) { } else if (name.startsWith(";")) {
name = name.substring(1); name = name.substring(1);
style = 'matrix'; style = "matrix";
} }
const value = path[name]; const value = path[name];
if (value === undefined || value === null) { if (value === undefined || value === null) {
continue; continue;
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
url = url.replace(match, serializeArrayParam({ explode, name, style, value })); url = url.replace(
continue; match,
} serializeArrayParam({ explode, name, style, value }),
);
continue;
}
if (typeof value === 'object') { if (typeof value === "object") {
url = url.replace( url = url.replace(
match, match,
serializeObjectParam({ serializeObjectParam({
explode, explode,
name, name,
style, style,
value: value as Record<string, unknown>, value: value as Record<string, unknown>,
valueOnly: true, valueOnly: true,
}), }),
); );
continue; continue;
} }
if (style === 'matrix') { if (style === "matrix") {
url = url.replace( url = url.replace(
match, match,
`;${serializePrimitiveParam({ `;${serializePrimitiveParam({
name, name,
value: value as string, value: value as string,
})}`, })}`,
); );
continue; continue;
} }
const replaceValue = encodeURIComponent( const replaceValue = encodeURIComponent(
style === 'label' ? `.${value as string}` : (value as string), style === "label" ? `.${value as string}` : (value as string),
); );
url = url.replace(match, replaceValue); url = url.replace(match, replaceValue);
} }
} }
return url; return url;
}; };
export const getUrl = ({ export const getUrl = ({
baseUrl, baseUrl,
path, path,
query, query,
querySerializer, querySerializer,
url: _url, url: _url,
}: { }: {
baseUrl?: string; baseUrl?: string;
path?: Record<string, unknown>; path?: Record<string, unknown>;
query?: Record<string, unknown>; query?: Record<string, unknown>;
querySerializer: QuerySerializer; querySerializer: QuerySerializer;
url: string; url: string;
}) => { }) => {
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; const pathUrl = _url.startsWith("/") ? _url : `/${_url}`;
let url = (baseUrl ?? '') + pathUrl; let url = (baseUrl ?? "") + pathUrl;
if (path) { if (path) {
url = defaultPathSerializer({ path, url }); url = defaultPathSerializer({ path, url });
} }
let search = query ? querySerializer(query) : ''; let search = query ? querySerializer(query) : "";
if (search.startsWith('?')) { if (search.startsWith("?")) {
search = search.substring(1); search = search.substring(1);
} }
if (search) { if (search) {
url += `?${search}`; url += `?${search}`;
} }
return url; return url;
}; };
export function getValidRequestBody(options: { export function getValidRequestBody(options: {
body?: unknown; body?: unknown;
bodySerializer?: BodySerializer | null; bodySerializer?: BodySerializer | null;
serializedBody?: unknown; serializedBody?: unknown;
}) { }) {
const hasBody = options.body !== undefined; const hasBody = options.body !== undefined;
const isSerializedBody = hasBody && options.bodySerializer; const isSerializedBody = hasBody && options.bodySerializer;
if (isSerializedBody) { if (isSerializedBody) {
if ('serializedBody' in options) { if ("serializedBody" in options) {
const hasSerializedBody = const hasSerializedBody =
options.serializedBody !== undefined && options.serializedBody !== ''; options.serializedBody !== undefined && options.serializedBody !== "";
return hasSerializedBody ? options.serializedBody : null; return hasSerializedBody ? options.serializedBody : null;
} }
// not all clients implement a serializedBody property (i.e., client-axios) // not all clients implement a serializedBody property (i.e. client-axios)
return options.body !== '' ? options.body : null; return options.body !== "" ? options.body : null;
} }
// plain/text body // plain/text body
if (hasBody) { if (hasBody) {
return options.body; return options.body;
} }
// no body was provided // no body was provided
return undefined; return undefined;
} }

View file

@ -1,9 +1,7 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
export { export {
browseFilesystem, browseFilesystem,
cancelDoctor,
createBackupSchedule, createBackupSchedule,
createNotificationDestination, createNotificationDestination,
createRepository, createRepository,
@ -13,34 +11,20 @@ export {
deleteRepository, deleteRepository,
deleteSnapshot, deleteSnapshot,
deleteSnapshots, deleteSnapshots,
deleteSsoInvitation,
deleteSsoProvider,
deleteUserAccount,
deleteVolume, deleteVolume,
devPanelExec, doctorRepository,
downloadResticPassword, downloadResticPassword,
dumpSnapshot,
getAdminUsers,
getBackupProgress,
getBackupSchedule, getBackupSchedule,
getBackupScheduleForVolume, getBackupScheduleForVolume,
getDevPanel,
getMirrorCompatibility, getMirrorCompatibility,
getMirrorSyncStatus,
getNotificationDestination, getNotificationDestination,
getOrgMembers,
getPublicSsoProviders,
getRegistrationStatus,
getRepository, getRepository,
getRepositoryStats,
getScheduleMirrors, getScheduleMirrors,
getScheduleNotifications, getScheduleNotifications,
getSnapshotDetails, getSnapshotDetails,
getSsoSettings,
getStatus, getStatus,
getSystemInfo, getSystemInfo,
getUpdates, getUpdates,
getUserDeletionImpact,
getVolume, getVolume,
healthCheckVolume, healthCheckVolume,
listBackupSchedules, listBackupSchedules,
@ -53,39 +37,26 @@ export {
listVolumes, listVolumes,
mountVolume, mountVolume,
type Options, type Options,
refreshRepositoryStats,
refreshSnapshots,
removeOrgMember,
reorderBackupSchedules, reorderBackupSchedules,
restoreSnapshot, restoreSnapshot,
runBackupNow, runBackupNow,
runForget, runForget,
setRegistrationStatus,
startDoctor,
stopBackup, stopBackup,
syncMirror,
tagSnapshots, tagSnapshots,
testConnection, testConnection,
testNotificationDestination, testNotificationDestination,
unlockRepository,
unmountVolume, unmountVolume,
updateBackupSchedule, updateBackupSchedule,
updateMemberRole,
updateNotificationDestination, updateNotificationDestination,
updateRepository, updateRepository,
updateScheduleMirrors, updateScheduleMirrors,
updateScheduleNotifications, updateScheduleNotifications,
updateSsoProviderAutoLinking,
updateVolume, updateVolume,
} from "./sdk.gen"; } from "./sdk.gen";
export type { export type {
BrowseFilesystemData, BrowseFilesystemData,
BrowseFilesystemResponse, BrowseFilesystemResponse,
BrowseFilesystemResponses, BrowseFilesystemResponses,
CancelDoctorData,
CancelDoctorErrors,
CancelDoctorResponse,
CancelDoctorResponses,
ClientOptions, ClientOptions,
CreateBackupScheduleData, CreateBackupScheduleData,
CreateBackupScheduleResponse, CreateBackupScheduleResponse,
@ -115,68 +86,31 @@ export type {
DeleteSnapshotsData, DeleteSnapshotsData,
DeleteSnapshotsResponse, DeleteSnapshotsResponse,
DeleteSnapshotsResponses, DeleteSnapshotsResponses,
DeleteSsoInvitationData,
DeleteSsoInvitationErrors,
DeleteSsoInvitationResponses,
DeleteSsoProviderData,
DeleteSsoProviderErrors,
DeleteSsoProviderResponses,
DeleteUserAccountData,
DeleteUserAccountErrors,
DeleteUserAccountResponses,
DeleteVolumeData, DeleteVolumeData,
DeleteVolumeResponse, DeleteVolumeResponse,
DeleteVolumeResponses, DeleteVolumeResponses,
DevPanelExecData, DoctorRepositoryData,
DevPanelExecErrors, DoctorRepositoryResponse,
DevPanelExecResponse, DoctorRepositoryResponses,
DevPanelExecResponses,
DownloadResticPasswordData, DownloadResticPasswordData,
DownloadResticPasswordResponse, DownloadResticPasswordResponse,
DownloadResticPasswordResponses, DownloadResticPasswordResponses,
DumpSnapshotData,
DumpSnapshotResponse,
DumpSnapshotResponses,
GetAdminUsersData,
GetAdminUsersResponse,
GetAdminUsersResponses,
GetBackupProgressData,
GetBackupProgressResponse,
GetBackupProgressResponses,
GetBackupScheduleData, GetBackupScheduleData,
GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeData,
GetBackupScheduleForVolumeResponse, GetBackupScheduleForVolumeResponse,
GetBackupScheduleForVolumeResponses, GetBackupScheduleForVolumeResponses,
GetBackupScheduleResponse, GetBackupScheduleResponse,
GetBackupScheduleResponses, GetBackupScheduleResponses,
GetDevPanelData,
GetDevPanelResponse,
GetDevPanelResponses,
GetMirrorCompatibilityData, GetMirrorCompatibilityData,
GetMirrorCompatibilityResponse, GetMirrorCompatibilityResponse,
GetMirrorCompatibilityResponses, GetMirrorCompatibilityResponses,
GetMirrorSyncStatusData,
GetMirrorSyncStatusResponse,
GetMirrorSyncStatusResponses,
GetNotificationDestinationData, GetNotificationDestinationData,
GetNotificationDestinationErrors, GetNotificationDestinationErrors,
GetNotificationDestinationResponse, GetNotificationDestinationResponse,
GetNotificationDestinationResponses, GetNotificationDestinationResponses,
GetOrgMembersData,
GetOrgMembersResponse,
GetOrgMembersResponses,
GetPublicSsoProvidersData,
GetPublicSsoProvidersResponse,
GetPublicSsoProvidersResponses,
GetRegistrationStatusData,
GetRegistrationStatusResponse,
GetRegistrationStatusResponses,
GetRepositoryData, GetRepositoryData,
GetRepositoryResponse, GetRepositoryResponse,
GetRepositoryResponses, GetRepositoryResponses,
GetRepositoryStatsData,
GetRepositoryStatsResponse,
GetRepositoryStatsResponses,
GetScheduleMirrorsData, GetScheduleMirrorsData,
GetScheduleMirrorsResponse, GetScheduleMirrorsResponse,
GetScheduleMirrorsResponses, GetScheduleMirrorsResponses,
@ -186,9 +120,6 @@ export type {
GetSnapshotDetailsData, GetSnapshotDetailsData,
GetSnapshotDetailsResponse, GetSnapshotDetailsResponse,
GetSnapshotDetailsResponses, GetSnapshotDetailsResponses,
GetSsoSettingsData,
GetSsoSettingsResponse,
GetSsoSettingsResponses,
GetStatusData, GetStatusData,
GetStatusResponse, GetStatusResponse,
GetStatusResponses, GetStatusResponses,
@ -198,9 +129,6 @@ export type {
GetUpdatesData, GetUpdatesData,
GetUpdatesResponse, GetUpdatesResponse,
GetUpdatesResponses, GetUpdatesResponses,
GetUserDeletionImpactData,
GetUserDeletionImpactResponse,
GetUserDeletionImpactResponses,
GetVolumeData, GetVolumeData,
GetVolumeErrors, GetVolumeErrors,
GetVolumeResponse, GetVolumeResponse,
@ -236,15 +164,6 @@ export type {
MountVolumeData, MountVolumeData,
MountVolumeResponse, MountVolumeResponse,
MountVolumeResponses, MountVolumeResponses,
RefreshRepositoryStatsData,
RefreshRepositoryStatsResponse,
RefreshRepositoryStatsResponses,
RefreshSnapshotsData,
RefreshSnapshotsResponse,
RefreshSnapshotsResponses,
RemoveOrgMemberData,
RemoveOrgMemberErrors,
RemoveOrgMemberResponses,
ReorderBackupSchedulesData, ReorderBackupSchedulesData,
ReorderBackupSchedulesResponse, ReorderBackupSchedulesResponse,
ReorderBackupSchedulesResponses, ReorderBackupSchedulesResponses,
@ -257,21 +176,10 @@ export type {
RunForgetData, RunForgetData,
RunForgetResponse, RunForgetResponse,
RunForgetResponses, RunForgetResponses,
SetRegistrationStatusData,
SetRegistrationStatusResponse,
SetRegistrationStatusResponses,
StartDoctorData,
StartDoctorErrors,
StartDoctorResponse,
StartDoctorResponses,
StopBackupData, StopBackupData,
StopBackupErrors, StopBackupErrors,
StopBackupResponse, StopBackupResponse,
StopBackupResponses, StopBackupResponses,
SyncMirrorData,
SyncMirrorErrors,
SyncMirrorResponse,
SyncMirrorResponses,
TagSnapshotsData, TagSnapshotsData,
TagSnapshotsResponse, TagSnapshotsResponse,
TagSnapshotsResponses, TagSnapshotsResponses,
@ -282,18 +190,12 @@ export type {
TestNotificationDestinationErrors, TestNotificationDestinationErrors,
TestNotificationDestinationResponse, TestNotificationDestinationResponse,
TestNotificationDestinationResponses, TestNotificationDestinationResponses,
UnlockRepositoryData,
UnlockRepositoryResponse,
UnlockRepositoryResponses,
UnmountVolumeData, UnmountVolumeData,
UnmountVolumeResponse, UnmountVolumeResponse,
UnmountVolumeResponses, UnmountVolumeResponses,
UpdateBackupScheduleData, UpdateBackupScheduleData,
UpdateBackupScheduleResponse, UpdateBackupScheduleResponse,
UpdateBackupScheduleResponses, UpdateBackupScheduleResponses,
UpdateMemberRoleData,
UpdateMemberRoleErrors,
UpdateMemberRoleResponses,
UpdateNotificationDestinationData, UpdateNotificationDestinationData,
UpdateNotificationDestinationErrors, UpdateNotificationDestinationErrors,
UpdateNotificationDestinationResponse, UpdateNotificationDestinationResponse,
@ -308,9 +210,6 @@ export type {
UpdateScheduleNotificationsData, UpdateScheduleNotificationsData,
UpdateScheduleNotificationsResponse, UpdateScheduleNotificationsResponse,
UpdateScheduleNotificationsResponses, UpdateScheduleNotificationsResponses,
UpdateSsoProviderAutoLinkingData,
UpdateSsoProviderAutoLinkingErrors,
UpdateSsoProviderAutoLinkingResponses,
UpdateVolumeData, UpdateVolumeData,
UpdateVolumeErrors, UpdateVolumeErrors,
UpdateVolumeResponse, UpdateVolumeResponse,

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,205 +1,8 @@
/** biome-ignore-all lint/style/noNonNullAssertion: Testing file - non-null assertions are acceptable here */ /** biome-ignore-all lint/style/noNonNullAssertion: Testing file - non-null assertions are acceptable here */
import { afterEach, describe, expect, test } from "vitest"; import { expect, test, describe } from "bun:test";
import { cleanup, render, screen, fireEvent, within } from "@testing-library/react"; import { render, screen, fireEvent } from "@testing-library/react";
import { useState } from "react";
import { FileTree, type FileEntry } from "../file-tree"; import { FileTree, type FileEntry } from "../file-tree";
const getCheckboxFor = (name: string) => {
const row = screen.getByRole("button", { name });
return within(row).getByRole("checkbox");
};
const FileTreeSelection = ({
files,
initialSelectedPaths = [],
expandedFolders,
}: {
files: FileEntry[];
initialSelectedPaths?: string[];
expandedFolders?: Set<string>;
}) => {
const [selectedPaths, setSelectedPaths] = useState(() => new Set(initialSelectedPaths));
return (
<>
<FileTree
files={files}
withCheckboxes={true}
selectedPaths={selectedPaths}
onSelectionChange={setSelectedPaths}
expandedFolders={expandedFolders}
/>
<output aria-label="Selected paths">{JSON.stringify(Array.from(selectedPaths).sort())}</output>
</>
);
};
const getSelectedPaths = () => {
const selectedPaths = screen.getByLabelText("Selected paths").textContent;
return JSON.parse(selectedPaths ?? "[]") as string[];
};
afterEach(() => {
cleanup();
});
describe("FileTree Pagination", () => {
const testFiles: FileEntry[] = [
{ name: "root", path: "/root", type: "folder" },
{ name: "file1", path: "/root/file1", type: "file" },
{ name: "file2", path: "/root/file2", type: "file" },
];
test("shows load more button when hasMore is true", () => {
render(
<FileTree
files={testFiles}
expandedFolders={new Set(["/root"])}
getFolderPagination={(path) => {
if (path === "/root") {
return { hasMore: true, isLoadingMore: false };
}
return { hasMore: false, isLoadingMore: false };
}}
/>,
);
expect(screen.getByText("Load more files")).toBeTruthy();
});
test("does not show load more button when hasMore is false", () => {
render(
<FileTree
files={testFiles}
expandedFolders={new Set(["/root"])}
getFolderPagination={() => ({ hasMore: false, isLoadingMore: false })}
/>,
);
expect(screen.queryByText("Load more files")).toBeNull();
});
test("calls onLoadMore with folder path when load more button is clicked", () => {
let loadMoreCalled = false;
let loadMorePath = "";
render(
<FileTree
files={testFiles}
expandedFolders={new Set(["/root"])}
getFolderPagination={(path) => {
if (path === "/root") {
return { hasMore: true, isLoadingMore: false };
}
return { hasMore: false, isLoadingMore: false };
}}
onLoadMore={(path) => {
loadMoreCalled = true;
loadMorePath = path;
}}
/>,
);
const loadMoreButton = screen.getByText("Load more files");
fireEvent.click(loadMoreButton);
expect(loadMoreCalled).toBe(true);
expect(loadMorePath).toBe("/root");
});
test("shows loading state when isLoadingMore is true", () => {
render(
<FileTree
files={testFiles}
expandedFolders={new Set(["/root"])}
getFolderPagination={(path) => {
if (path === "/root") {
return { hasMore: true, isLoadingMore: true };
}
return { hasMore: false, isLoadingMore: false };
}}
/>,
);
expect(screen.getByText("Loading more...")).toBeTruthy();
});
test("shows load more button for root-level files when root has more", () => {
const rootFiles: FileEntry[] = [
{ name: "file1", path: "/file1", type: "file" },
{ name: "file2", path: "/file2", type: "file" },
];
render(
<FileTree
files={rootFiles}
getFolderPagination={(path) => {
if (path === "/") {
return { hasMore: true, isLoadingMore: false };
}
return { hasMore: false, isLoadingMore: false };
}}
/>,
);
expect(screen.getByText("Load more files")).toBeTruthy();
});
test("load more button appears for nested folders with hasMore", () => {
const nestedFiles: FileEntry[] = [
{ name: "root", path: "/root", type: "folder" },
{ name: "child", path: "/root/child", type: "folder" },
{ name: "file1", path: "/root/child/file1", type: "file" },
];
render(
<FileTree
files={nestedFiles}
expandedFolders={new Set(["/root", "/root/child"])}
getFolderPagination={(path) => {
if (path === "/root/child") {
return { hasMore: true, isLoadingMore: false };
}
return { hasMore: false, isLoadingMore: false };
}}
onLoadMore={() => {}}
/>,
);
expect(screen.getByText("Load more files")).toBeTruthy();
});
test("load more button does not appear when folder is collapsed", () => {
render(
<FileTree
files={testFiles}
expandedFolders={new Set([])}
getFolderPagination={(path) => {
if (path === "/root") {
return { hasMore: true, isLoadingMore: false };
}
return { hasMore: false, isLoadingMore: false };
}}
/>,
);
expect(screen.queryByText("Load more files")).toBeNull();
});
test("renders missing ancestor folders for nested paths", () => {
render(
<FileTree
files={[
{ name: "subdir", path: "/project/subdir", type: "folder" },
{ name: "file1", path: "/project/subdir/file1", type: "file" },
]}
/>,
);
expect(screen.getByRole("button", { name: "project" })).toBeTruthy();
});
});
describe("FileTree Selection Logic", () => { describe("FileTree Selection Logic", () => {
const testFiles: FileEntry[] = [ const testFiles: FileEntry[] = [
{ name: "root", path: "/root", type: "folder" }, { name: "root", path: "/root", type: "folder" },
@ -210,67 +13,154 @@ describe("FileTree Selection Logic", () => {
{ name: "upload", path: "/root/photos/upload", type: "folder" }, { name: "upload", path: "/root/photos/upload", type: "folder" },
]; ];
test("selecting a folder simplifies to parent if it's the only child", () => { test("selecting a folder simplifies to parent if it's the only child", async () => {
render(<FileTreeSelection files={testFiles} expandedFolders={new Set(testFiles.map((f) => f.path))} />); let currentSelection = new Set<string>();
const onSelectionChange = (selection: Set<string>) => {
currentSelection = selection;
};
fireEvent.click(getCheckboxFor("photos"));
expect(getSelectedPaths()).toEqual(["/root"]);
});
test("unselecting a child removes the parent from selection", () => {
render( render(
<FileTreeSelection <FileTree
files={testFiles} files={testFiles}
initialSelectedPaths={["/root"]} withCheckboxes={true}
selectedPaths={currentSelection}
onSelectionChange={onSelectionChange}
expandedFolders={new Set(testFiles.map((f) => f.path))} expandedFolders={new Set(testFiles.map((f) => f.path))}
/>, />,
); );
fireEvent.click(getCheckboxFor("library")); const photosCheckbox = screen.getByText("photos").parentElement?.querySelector('button[role="checkbox"]');
expect(photosCheckbox).toBeTruthy();
expect(getSelectedPaths()).toEqual(["/root/photos/backups", "/root/photos/profile", "/root/photos/upload"]); fireEvent.click(photosCheckbox!);
expect(currentSelection.has("/root")).toBe(true);
expect(currentSelection.size).toBe(1);
}); });
test("recursive simplification when all children are selected", () => { test("unselecting a child removes the parent from selection", async () => {
render(<FileTreeSelection files={testFiles} expandedFolders={new Set(testFiles.map((f) => f.path))} />); let currentSelection = new Set<string>(["/root"]);
const onSelectionChange = (selection: Set<string>) => {
currentSelection = selection;
};
render(
<FileTree
files={testFiles}
withCheckboxes={true}
selectedPaths={currentSelection}
onSelectionChange={onSelectionChange}
expandedFolders={new Set(testFiles.map((f) => f.path))}
/>,
);
const libraryCheckbox = screen.getByText("library").parentElement?.querySelector('button[role="checkbox"]');
fireEvent.click(libraryCheckbox!);
expect(currentSelection.has("/root")).toBe(false);
expect(currentSelection.has("/root/photos")).toBe(false);
expect(currentSelection.has("/root/photos/backups")).toBe(true);
expect(currentSelection.has("/root/photos/profile")).toBe(true);
expect(currentSelection.has("/root/photos/upload")).toBe(true);
expect(currentSelection.size).toBe(3);
});
test("recursive simplification when all children are selected", async () => {
let currentSelection = new Set<string>();
const onSelectionChange = (selection: Set<string>) => {
currentSelection = selection;
};
const { rerender } = render(
<FileTree
files={testFiles}
withCheckboxes={true}
selectedPaths={currentSelection}
onSelectionChange={onSelectionChange}
expandedFolders={new Set(testFiles.map((f) => f.path))}
/>,
);
const children = ["backups", "library", "profile", "upload"]; const children = ["backups", "library", "profile", "upload"];
for (const name of children) { for (const name of children) {
fireEvent.click(getCheckboxFor(name)); const checkbox = screen.getByText(name).parentElement?.querySelector('button[role="checkbox"]');
fireEvent.click(checkbox!);
rerender(
<FileTree
files={testFiles}
withCheckboxes={true}
selectedPaths={currentSelection}
onSelectionChange={onSelectionChange}
expandedFolders={new Set(testFiles.map((f) => f.path))}
/>,
);
} }
expect(getSelectedPaths()).toEqual(["/root"]); expect(currentSelection.has("/root")).toBe(true);
expect(currentSelection.size).toBe(1);
}); });
test("does not simplify to parent if not all children are selected", () => { test("does not simplify to parent if not all children are selected", async () => {
const multipleFiles: FileEntry[] = [ const multipleFiles: FileEntry[] = [
{ name: "root", path: "/root", type: "folder" }, { name: "root", path: "/root", type: "folder" },
{ name: "child1", path: "/root/child1", type: "folder" }, { name: "child1", path: "/root/child1", type: "folder" },
{ name: "child2", path: "/root/child2", type: "folder" }, { name: "child2", path: "/root/child2", type: "folder" },
]; ];
render(<FileTreeSelection files={multipleFiles} expandedFolders={new Set(multipleFiles.map((f) => f.path))} />); let currentSelection = new Set<string>();
const onSelectionChange = (selection: Set<string>) => {
currentSelection = selection;
};
fireEvent.click(getCheckboxFor("child1")); render(
<FileTree
files={multipleFiles}
withCheckboxes={true}
selectedPaths={currentSelection}
onSelectionChange={onSelectionChange}
expandedFolders={new Set(multipleFiles.map((f) => f.path))}
/>,
);
expect(getSelectedPaths()).toEqual(["/root/child1"]); const child1Checkbox = screen.getByText("child1").parentElement?.querySelector('button[role="checkbox"]');
fireEvent.click(child1Checkbox!);
expect(currentSelection.has("/root/child1")).toBe(true);
expect(currentSelection.has("/root")).toBe(false);
expect(currentSelection.size).toBe(1);
}); });
test("simplifies existing deep paths when parent is selected", () => { test("simplifies existing deep paths when parent is selected", async () => {
const files: FileEntry[] = [ const files: FileEntry[] = [
{ name: "hello", path: "/hello", type: "folder" }, { name: "hello", path: "/hello", type: "folder" },
{ name: "hello_prev", path: "/hello_prev", type: "folder" }, { name: "hello_prev", path: "/hello_prev", type: "folder" },
{ name: "service", path: "/service", type: "folder" }, { name: "service", path: "/service", type: "folder" },
]; ];
let currentSelection = new Set<string>(["/hello", "/hello_prev", "/service/app/data/upload"]);
const onSelectionChange = (selection: Set<string>) => {
currentSelection = selection;
};
render( render(
<FileTreeSelection files={files} initialSelectedPaths={["/hello", "/hello_prev", "/service/app/data/upload"]} />, <FileTree
files={files}
withCheckboxes={true}
selectedPaths={currentSelection}
onSelectionChange={onSelectionChange}
/>,
); );
fireEvent.click(getCheckboxFor("service")); const serviceCheckbox = screen.getByText("service").parentElement?.querySelector('button[role="checkbox"]');
expect(serviceCheckbox).toBeTruthy();
expect(getSelectedPaths()).toEqual(["/hello", "/hello_prev", "/service"]); fireEvent.click(serviceCheckbox!);
expect(currentSelection.has("/service")).toBe(true);
expect(currentSelection.has("/service/app/data/upload")).toBe(false);
expect(currentSelection.size).toBe(3); // /hello, /hello_prev, /service
}); });
}); });

View file

@ -1,155 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, render, screen, userEvent, waitFor, within } from "~/test/test-utils";
import { fromAny } from "@total-typescript/shoehorn";
vi.mock("@tanstack/react-router", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
return {
...actual,
useNavigate: (() => vi.fn(async () => {})) as typeof actual.useNavigate,
};
});
import { RestoreForm } from "../restore-form";
class MockEventSource {
addEventListener() {}
close() {}
onerror: ((event: Event) => void) | null = null;
constructor(public url: string) {}
}
const originalEventSource = globalThis.EventSource;
beforeEach(() => {
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
});
afterEach(() => {
globalThis.EventSource = originalEventSource;
cleanup();
});
describe("RestoreForm", () => {
test("restores the selected ancestor folder path from a broader display root", async () => {
let restoreRequestBody: unknown;
server.use(
http.get("/api/v1/repositories/:shortId/snapshots/:snapshotId/files", () => {
return HttpResponse.json({
files: [
{ name: "subdir", path: "/mnt/project/subdir", type: "dir" },
{ name: "deep.tx", path: "/mnt/project/subdir/deep.tx", type: "file" },
],
});
}),
http.post("/api/v1/repositories/:shortId/restore", async ({ request }) => {
restoreRequestBody = await request.json();
return HttpResponse.json({
success: true,
message: "Snapshot restored successfully",
filesRestored: 1,
filesSkipped: 0,
});
}),
);
render(
<RestoreForm
repository={fromAny({ shortId: "repo-1", name: "Repo 1" })}
snapshotId="snap-1"
returnPath="/repositories/repo-1/snap-1"
queryBasePath="/mnt/project/subdir"
displayBasePath="/mnt"
/>,
);
const row = await screen.findByRole("button", { name: "project" });
await userEvent.click(within(row).getByRole("checkbox"));
await userEvent.click(screen.getByRole("button", { name: "Restore 1 item" }));
await waitFor(() => {
expect(restoreRequestBody).toEqual({
snapshotId: "snap-1",
include: ["/mnt/project"],
selectedItemKind: "dir",
overwrite: "always",
});
});
});
test("restores the selected full path when the display root is unrelated", async () => {
let restoreRequestBody: unknown;
server.use(
http.get("/api/v1/repositories/:shortId/snapshots/:snapshotId/files", () => {
return HttpResponse.json({
files: [
{ name: "project", path: "/mnt/project", type: "dir" },
{ name: "a.txt", path: "/mnt/project/a.txt", type: "file" },
],
});
}),
http.post("/api/v1/repositories/:shortId/restore", async ({ request }) => {
restoreRequestBody = await request.json();
return HttpResponse.json({
success: true,
message: "Snapshot restored successfully",
filesRestored: 1,
filesSkipped: 0,
});
}),
http.get("/api/v1/volumes/filesystem/browse", () => {
return HttpResponse.json({
path: "/",
directories: [{ name: "restore-target", path: "/restore-target", type: "dir" }],
});
}),
);
render(
<RestoreForm
repository={fromAny({ shortId: "repo-1", name: "Repo 1" })}
snapshotId="snap-1"
returnPath="/repositories/repo-1/snap-1"
queryBasePath="/mnt/project"
displayBasePath="/other/root"
/>,
);
expect(
screen.getByText(
"This snapshot was created from source paths that do not match this Zerobyte server or the current linked volume. Restoring to the original location is unavailable. Restore it to a custom location, or download it instead.",
),
).toBeTruthy();
expect(screen.getByRole("button", { name: "Original location" }).hasAttribute("disabled")).toBe(true);
expect(screen.getByRole("button", { name: "Restore All" }).hasAttribute("disabled")).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "Change" }));
await userEvent.click(await screen.findByRole("button", { name: "restore-target" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Restore All" }).hasAttribute("disabled")).toBe(false);
});
const row = await screen.findByRole("button", { name: "mnt" });
await userEvent.click(within(row).getByRole("checkbox"));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Restore 1 item" }).hasAttribute("disabled")).toBe(false);
});
await userEvent.click(screen.getByRole("button", { name: "Restore 1 item" }));
await waitFor(() => {
expect(restoreRequestBody).toEqual({
snapshotId: "snap-1",
include: ["/mnt"],
selectedItemKind: "dir",
targetPath: "/restore-target",
overwrite: "always",
});
});
});
});

View file

@ -1,5 +1,4 @@
import { Fragment } from "react"; import { Link, useMatches, type UIMatch } from "react-router";
import { useMatches, Link } from "@tanstack/react-router";
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@ -14,22 +13,25 @@ export interface BreadcrumbItemData {
href?: string; href?: string;
} }
type BreadcrumbFunction = (match: ReturnType<typeof useMatches>[number]) => BreadcrumbItemData[] | null; interface RouteHandle {
breadcrumb?: (match: UIMatch) => BreadcrumbItemData[] | null;
}
export function AppBreadcrumb() { export function AppBreadcrumb() {
const matches = useMatches(); const matches = useMatches();
// Find the last match with a breadcrumb handler
const lastMatchWithBreadcrumb = [...matches].reverse().find((match) => { const lastMatchWithBreadcrumb = [...matches].reverse().find((match) => {
const breadcrumbFn = match.staticData?.breadcrumb as BreadcrumbFunction | undefined; const handle = match.handle as RouteHandle | undefined;
return breadcrumbFn; return handle?.breadcrumb;
}); });
if (!lastMatchWithBreadcrumb) { if (!lastMatchWithBreadcrumb) {
return null; return null;
} }
const breadcrumbFn = lastMatchWithBreadcrumb.staticData?.breadcrumb as BreadcrumbFunction; const handle = lastMatchWithBreadcrumb.handle as RouteHandle;
const breadcrumbs = breadcrumbFn?.(lastMatchWithBreadcrumb); const breadcrumbs = handle.breadcrumb?.(lastMatchWithBreadcrumb);
if (!breadcrumbs || breadcrumbs.length === 0) { if (!breadcrumbs || breadcrumbs.length === 0) {
return null; return null;
@ -42,7 +44,7 @@ export function AppBreadcrumb() {
const isLast = index === breadcrumbs.length - 1; const isLast = index === breadcrumbs.length - 1;
return ( return (
<Fragment key={`${breadcrumb.label}-${index}`}> <div key={`${breadcrumb.label}-${index}`} className="contents">
<BreadcrumbItem> <BreadcrumbItem>
{isLast || !breadcrumb.href ? ( {isLast || !breadcrumb.href ? (
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage> <BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
@ -53,7 +55,7 @@ export function AppBreadcrumb() {
)} )}
</BreadcrumbItem> </BreadcrumbItem>
{!isLast && <BreadcrumbSeparator />} {!isLast && <BreadcrumbSeparator />}
</Fragment> </div>
); );
})} })}
</BreadcrumbList> </BreadcrumbList>

View file

@ -1,4 +1,5 @@
import { Bell, CalendarClock, Database, HardDrive, Settings, ShieldCheck } from "lucide-react"; import { Bell, CalendarClock, Database, HardDrive, Settings } from "lucide-react";
import { Link, NavLink } from "react-router";
import { useState } from "react"; import { useState } from "react";
import { import {
Sidebar, Sidebar,
@ -10,7 +11,6 @@ import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarSeparator,
useSidebar, useSidebar,
} from "~/client/components/ui/sidebar"; } from "~/client/components/ui/sidebar";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
@ -19,8 +19,6 @@ import { cn } from "~/client/lib/utils";
import { APP_VERSION, RCLONE_VERSION, RESTIC_VERSION, SHOUTRRR_VERSION } from "~/client/lib/version"; import { APP_VERSION, RCLONE_VERSION, RESTIC_VERSION, SHOUTRRR_VERSION } from "~/client/lib/version";
import { useUpdates } from "~/client/hooks/use-updates"; import { useUpdates } from "~/client/hooks/use-updates";
import { ReleaseNotesDialog } from "./release-notes-dialog"; import { ReleaseNotesDialog } from "./release-notes-dialog";
import { OrganizationSwitcher } from "./organization-switcher";
import { Link } from "@tanstack/react-router";
const items = [ const items = [
{ {
@ -50,17 +48,11 @@ const items = [
}, },
]; ];
type Props = { export function AppSidebar() {
isInstanceAdmin: boolean; const { state } = useSidebar();
};
export function AppSidebar({ isInstanceAdmin }: Props) {
const { state, isMobile, setOpenMobile } = useSidebar();
const { updates, hasUpdate } = useUpdates(); const { updates, hasUpdate } = useUpdates();
const [showReleaseNotes, setShowReleaseNotes] = useState(false); const [showReleaseNotes, setShowReleaseNotes] = useState(false);
const isCollapsed = state === "collapsed";
const displayVersion = APP_VERSION.startsWith("v") || APP_VERSION === "dev" ? APP_VERSION : `v${APP_VERSION}`; const displayVersion = APP_VERSION.startsWith("v") || APP_VERSION === "dev" ? APP_VERSION : `v${APP_VERSION}`;
const releaseUrl = const releaseUrl =
APP_VERSION === "dev" APP_VERSION === "dev"
@ -69,12 +61,12 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
return ( return (
<Sidebar variant="inset" collapsible="icon" className="p-0"> <Sidebar variant="inset" collapsible="icon" className="p-0">
<SidebarHeader className="bg-card-header border-b border-border/80 dark:border-border/50 hidden md:flex h-16.25 flex-row items-center p-4"> <SidebarHeader className="bg-card-header border-b border-border/50 hidden md:flex h-16.25 flex-row items-center p-4">
<Link to="/volumes" className="flex items-center gap-3 font-semibold pl-2"> <Link to="/volumes" className="flex items-center gap-3 font-semibold pl-2">
<img src="/images/zerobyte.png" alt="Zerobyte Logo" className={cn("h-8 w-8 shrink-0 object-contain -ml-2")} /> <img src="/images/zerobyte.png" alt="Zerobyte Logo" className={cn("h-8 w-8 shrink-0 object-contain -ml-2")} />
<span <span
className={cn("text-base transition-all duration-200 -ml-1", { className={cn("text-base transition-all duration-200 -ml-1", {
"opacity-0 w-0 overflow-hidden ": isCollapsed, "opacity-0 w-0 overflow-hidden ": state === "collapsed",
})} })}
> >
Zerobyte Zerobyte
@ -90,43 +82,18 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<SidebarMenuButton asChild className="relative overflow-hidden"> <SidebarMenuButton asChild>
<Link <NavLink to={item.url}>
to={item.url}
onClick={() => isMobile && setOpenMobile(false)}
activeProps={{ className: "bg-strong-accent/10" }}
className="w-full flex items-center gap-2"
>
{({ isActive }) => ( {({ isActive }) => (
<> <>
{isActive && ( <item.icon className={cn({ "text-strong-accent": isActive })} />
<div <span className={cn({ "text-strong-accent": isActive })}>{item.title}</span>
className={cn("absolute left-0 top-0 h-full w-0.75 bg-strong-accent mr-2", {
hidden: isCollapsed,
})}
/>
)}
<item.icon
className={cn("transition-all duration-200", {
"text-strong-accent": isActive,
"ml-1": isActive && !isCollapsed,
"text-muted-foreground": !isActive,
})}
/>
<span
className={cn({
"text-foreground font-medium": isActive,
"text-muted-foreground": !isActive,
})}
>
{item.title}
</span>
</> </>
)} )}
</Link> </NavLink>
</SidebarMenuButton> </SidebarMenuButton>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" className={cn({ hidden: !isCollapsed })}> <TooltipContent side="right" className={cn({ hidden: state !== "collapsed" })}>
<p>{item.title}</p> <p>{item.title}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@ -136,66 +103,8 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
{isInstanceAdmin && (
<>
<SidebarSeparator />
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton asChild className="relative overflow-hidden">
<Link
to="/admin"
onClick={() => isMobile && setOpenMobile(false)}
activeProps={{ className: "bg-strong-accent/10" }}
className="w-full flex items-center gap-2"
>
{({ isActive }) => (
<>
{isActive && (
<div
className={cn("absolute left-0 top-0 h-full w-0.75 bg-strong-accent mr-2", {
hidden: isCollapsed,
})}
/>
)}
<ShieldCheck
className={cn("transition-all duration-200", {
"text-strong-accent": isActive,
"ml-1": isActive && !isCollapsed,
"text-muted-foreground": !isActive,
})}
/>
<span
className={cn({
"text-foreground font-medium": isActive,
"text-muted-foreground": !isActive,
})}
>
Administration
</span>
</>
)}
</Link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right" className={cn({ hidden: !isCollapsed })}>
<p>Administration</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</>
)}
</SidebarContent> </SidebarContent>
<SidebarFooter className="p-4 border-r border-border/80 dark:border-border/50"> <SidebarFooter className="p-4 border-r border-t border-border/50">
<OrganizationSwitcher />
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<HoverCard openDelay={200}> <HoverCard openDelay={200}>
<HoverCardTrigger asChild> <HoverCardTrigger asChild>

View file

@ -1,84 +0,0 @@
import { Card, CardContent } from "~/client/components/ui/card";
import { ByteSize } from "~/client/components/bytes-size";
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
import type { ResticSnapshotSummaryDto } from "@zerobyte/core/restic";
import { formatDuration } from "~/utils/utils";
type Props = {
summary?: ResticSnapshotSummaryDto | null;
};
const getDurationLabel = (start: string, end: string) => {
const startMs = new Date(start).getTime();
const endMs = new Date(end).getTime();
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) return "-";
return formatDuration(Math.round((endMs - startMs) / 1000));
};
export const BackupSummaryCard = ({ summary }: Props) => {
const { locale } = useRootLoaderData();
if (!summary) return null;
const formatCount = (value: number) => value.toLocaleString(locale);
const durationLabel = getDurationLabel(summary.backup_start, summary.backup_end);
const topStats = [
{
label: "Data added",
value: <ByteSize bytes={summary.data_added} base={1024} />,
},
{
label: "Data stored",
value: <ByteSize bytes={summary.data_added_packed ?? 0} base={1024} />,
},
{
label: "Files processed",
value: formatCount(summary.total_files_processed),
},
{
label: "Bytes processed",
value: <ByteSize bytes={summary.total_bytes_processed} base={1024} />,
},
{
label: "Duration",
value: durationLabel,
},
];
const detailStats = [
{ label: "New files", value: formatCount(summary.files_new) },
{ label: "Changed files", value: formatCount(summary.files_changed) },
{ label: "Unmodified files", value: formatCount(summary.files_unmodified) },
{ label: "New dirs", value: formatCount(summary.dirs_new) },
{ label: "Changed dirs", value: formatCount(summary.dirs_changed) },
{ label: "Unmodified dirs", value: formatCount(summary.dirs_unmodified) },
{ label: "Data blobs", value: formatCount(summary.data_blobs) },
{ label: "Tree blobs", value: formatCount(summary.tree_blobs) },
];
return (
<Card className="p-4">
<CardContent className="px-4">
<div className="grid gap-6 grid-cols-2 lg:grid-cols-5">
{topStats.map((stat) => (
<div key={stat.label} className="flex flex-col gap-1">
<span className="text-[11px] uppercase tracking-wide text-muted-foreground">{stat.label}</span>
<span className="text-sm font-semibold text-foreground">{stat.value}</span>
</div>
))}
</div>
<div className="mt-4 border-t border-border/60 pt-3">
<div className="grid gap-x-6 gap-y-2 grid-cols-2 lg:grid-cols-4">
{detailStats.map((stat) => (
<div key={stat.label} className="flex items-center justify-start text-xs gap-2">
<span className="font-semibold text-foreground">{stat.value}</span>
<span className="text-muted-foreground">{stat.label}</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
);
};

View file

@ -1,5 +1,4 @@
import type React from "react"; import type React from "react";
import { useFormatBytes } from "~/client/hooks/use-format-bytes";
type ByteSizeProps = { type ByteSizeProps = {
bytes: number; bytes: number;
@ -13,8 +12,72 @@ type ByteSizeProps = {
fallback?: string; // shown if bytes is not a finite number (default: '—') fallback?: string; // shown if bytes is not a finite number (default: '—')
}; };
const SI_UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] as const;
const IEC_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"] as const;
type FormatBytesResult = {
text: string;
unit: string;
unitIndex: number;
numeric: number; // numeric value before formatting (with sign)
};
export function formatBytes(
bytes: number,
options?: {
base?: 1000 | 1024;
maximumFractionDigits?: number;
smartRounding?: boolean;
locale?: string | string[];
},
): FormatBytesResult {
const { base = 1000, maximumFractionDigits = 2, smartRounding = true, locale } = options ?? {};
if (!Number.isFinite(bytes)) {
return {
text: "—",
unit: "",
unitIndex: 0,
numeric: NaN,
};
}
const units = base === 1024 ? IEC_UNITS : SI_UNITS;
const sign = Math.sign(bytes) || 1;
const abs = Math.abs(bytes);
let idx = 0;
if (abs > 0) {
idx = Math.floor(Math.log(abs) / Math.log(base));
if (!Number.isFinite(idx)) idx = 0;
idx = Math.max(0, Math.min(idx, units.length - 1));
}
const numeric = (abs / base ** idx) * sign;
const maxFrac = (() => {
if (!smartRounding) return maximumFractionDigits;
const v = Math.abs(numeric);
if (v >= 100) return 0;
if (v >= 10) return Math.min(1, maximumFractionDigits);
return maximumFractionDigits;
})();
const text = new Intl.NumberFormat(locale, {
minimumFractionDigits: 0,
maximumFractionDigits: maxFrac,
}).format(numeric);
return {
text,
unit: units[idx],
unitIndex: idx,
numeric,
};
}
export function ByteSize(props: ByteSizeProps) { export function ByteSize(props: ByteSizeProps) {
const formatBytes = useFormatBytes();
const { const {
bytes, bytes,
base = 1000, base = 1000,
@ -32,10 +95,9 @@ export function ByteSize(props: ByteSizeProps) {
maximumFractionDigits, maximumFractionDigits,
smartRounding, smartRounding,
locale, locale,
fallback,
}); });
if (text === fallback) { if (text === "—") {
return ( return (
<span className={className} style={style}> <span className={className} style={style}>
{fallback} {fallback}

View file

@ -3,7 +3,7 @@ import { AlertCircle, CheckCircle2 } from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form"; import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { useTimeFormat } from "~/client/lib/datetime"; import { formatDateTime } from "~/client/lib/datetime";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
interface CronInputProps { interface CronInputProps {
@ -13,7 +13,6 @@ interface CronInputProps {
} }
export function CronInput({ value, onChange, error }: CronInputProps) { export function CronInput({ value, onChange, error }: CronInputProps) {
const { formatDateTime } = useTimeFormat();
const { isValid, nextRuns, parseError } = useMemo(() => { const { isValid, nextRuns, parseError } = useMemo(() => {
if (!value) { if (!value) {
return { isValid: false, nextRuns: [], parseError: null }; return { isValid: false, nextRuns: [], parseError: null };
@ -60,7 +59,7 @@ export function CronInput({ value, onChange, error }: CronInputProps) {
{value && ( {value && (
<div> <div>
{isValid ? ( {isValid ? (
<CheckCircle2 className="h-4 w-4 text-success" /> <CheckCircle2 className="h-4 w-4 text-green-500" />
) : ( ) : (
<AlertCircle className="h-4 w-4 text-destructive" /> <AlertCircle className="h-4 w-4 text-destructive" />
)} )}

View file

@ -1,54 +0,0 @@
import type { Column } from "@tanstack/react-table";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import { Button } from "~/client/components/ui/button";
import { cn } from "~/client/lib/utils";
export function DataTableSortHeader<TData, TValue>({
column,
title,
sortDirection,
center = false,
}: {
column: Column<TData, TValue>;
title: string;
sortDirection: false | "asc" | "desc";
center?: boolean;
}) {
const icon =
sortDirection === "desc" ? (
<ArrowDown className="ml-2 h-3.5 w-3.5" />
) : sortDirection === "asc" ? (
<ArrowUp className="ml-2 h-3.5 w-3.5" />
) : (
<ArrowUpDown className="ml-2 h-3.5 w-3.5" />
);
const iconVisibility = sortDirection ? "" : "lg:invisible lg:group-hover/sort:visible";
if (center) {
return (
<Button
type="button"
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto! w-full! p-0! font-inherit hover:bg-transparent uppercase group/sort relative"
>
<span className="relative flex w-full items-center justify-center">
{title}
<span className={cn("lg:absolute lg:-right-6 lg:top-1/2 lg:-translate-y-1/2", iconVisibility)}>{icon}</span>
</span>
</Button>
);
}
return (
<Button
type="button"
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto! p-0! font-inherit hover:bg-transparent uppercase group/sort"
>
{title}
<span className={iconVisibility}>{icon}</span>
</Button>
);
}

View file

@ -1,25 +0,0 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useHotkeySequence } from "@tanstack/react-hotkeys";
import { getDevPanelOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { DevPanel } from "./dev-panel";
export function DevPanelListener() {
const [isOpen, setIsOpen] = useState(false);
const { data: devPanelStatus } = useQuery({
...getDevPanelOptions(),
});
useHotkeySequence(["D", "E", "V"], () => setIsOpen(true), {
enabled: !!devPanelStatus?.enabled,
preventDefault: true,
ignoreInputs: true,
timeout: 1000,
});
if (!devPanelStatus?.enabled) {
return null;
}
return <DevPanel open={isOpen} onOpenChange={setIsOpen} />;
}

View file

@ -1,229 +0,0 @@
import { useRef, useState, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { Play, Loader2, Trash2, Terminal } from "lucide-react";
import { Button } from "./ui/button";
import { cn } from "~/client/lib/utils";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "./ui/sheet";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { Label } from "./ui/label";
import { Textarea } from "./ui/textarea";
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { devPanelExec } from "~/client/api-client/sdk.gen";
import { parseError } from "../lib/errors";
type DevPanelProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
type SseOutputEvent = {
type: "stdout" | "stderr";
line: string;
};
type SseDoneEvent = {
type: "done";
exitCode: number;
};
type SseErrorEvent = {
type: "error";
message: string;
};
type SseEvent = SseOutputEvent | SseDoneEvent | SseErrorEvent;
export function DevPanel({ open, onOpenChange }: DevPanelProps) {
const { data: repositories = [] } = useQuery({
...listRepositoriesOptions(),
enabled: open,
});
const [selectedRepoId, setSelectedRepoId] = useState<string>("");
const [commandLine, setCommandLine] = useState("snapshots");
const [output, setOutput] = useState<string[]>([]);
const [isRunning, setIsRunning] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const outputRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, []);
const appendOutput = useCallback(
(line: string) => {
setOutput((prev) => {
const newOutput = [...prev, line];
setTimeout(scrollToBottom, 0);
return newOutput;
});
},
[scrollToBottom],
);
const handleRun = async () => {
if (!selectedRepoId || !commandLine.trim()) {
return;
}
setOutput([]);
setIsRunning(true);
appendOutput(`$ restic ${commandLine}`.trim());
appendOutput("---");
abortControllerRef.current = new AbortController();
const trimmedLine = commandLine.trim();
const parts = trimmedLine.split(/\s+/);
const command = parts[0];
const argsArray = parts.slice(1);
try {
const result = await devPanelExec({
path: { shortId: selectedRepoId },
body: { command, args: argsArray.length > 0 ? argsArray : undefined },
signal: abortControllerRef.current.signal,
});
for await (const event of result.stream) {
if (abortControllerRef.current.signal.aborted) {
break;
}
const data = event as unknown as SseEvent;
if (!data || typeof data !== "object") {
continue;
}
if (data.type === "stdout" || data.type === "stderr") {
appendOutput(data.line);
} else if (data.type === "done") {
appendOutput(`---`);
appendOutput(`Command finished with exit code: ${data.exitCode}`);
} else if (data.type === "error") {
appendOutput(`Error: ${data.message}`);
}
}
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
appendOutput("---");
appendOutput("Command cancelled");
} else {
appendOutput(`Error: ${parseError(err)?.message}`);
}
} finally {
setIsRunning(false);
abortControllerRef.current = null;
}
};
const handleCancel = () => {
abortControllerRef.current?.abort();
};
const handleClear = () => {
setOutput([]);
};
const handleClose = () => {
if (isRunning) {
handleCancel();
}
onOpenChange(false);
};
return (
<Sheet open={open} onOpenChange={handleClose}>
<SheetContent side="right" className="w-full sm:max-w-xl flex flex-col px-4">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" />
Dev Panel
</SheetTitle>
<SheetDescription>Execute restic commands against a repository</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-4 flex-1 min-h-0 mt-4">
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="repository">Repository</Label>
<Select value={selectedRepoId} onValueChange={setSelectedRepoId}>
<SelectTrigger id="repository">
<SelectValue placeholder="Select a repository" />
</SelectTrigger>
<SelectContent>
{repositories.map((repo) => (
<SelectItem key={repo.id} value={repo.shortId}>
{repo.name} ({repo.type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="command">Command</Label>
<Textarea
id="command"
placeholder="e.g., snapshots, check --dry-run, forget --keep-last 10"
value={commandLine}
onChange={(e) => setCommandLine(e.target.value)}
className="font-mono text-sm"
rows={2}
/>
</div>
<div className="flex gap-2">
<Button onClick={handleRun} disabled={isRunning || !selectedRepoId || !commandLine.trim()}>
<Loader2 className={cn("h-4 w-4 animate-spin mr-2", { hidden: !isRunning })} />
<Play className={cn("h-4 w-4 mr-2", { hidden: isRunning })} />
<span className={cn({ hidden: !isRunning })}>Running...</span>
<span className={cn({ hidden: isRunning })}>Run</span>
</Button>
<Button variant="destructive" onClick={handleCancel} className={cn({ hidden: !isRunning })}>
Cancel
</Button>
<Button variant="outline" onClick={handleClear} disabled={isRunning}>
<Trash2 className="h-4 w-4 mr-2" />
Clear
</Button>
</div>
</div>
<div className="flex-1 min-h-0 border rounded-md bg-muted/30">
<div ref={outputRef} className="h-full overflow-auto p-3 font-mono text-xs">
<div className={cn("text-muted-foreground", { hidden: output.length > 0 })}>
Output will appear here...
</div>
<div className={cn("space-y-0.5", { hidden: output.length === 0 })}>
{output.map((line, i) => {
let displayLine = line;
let isJson = false;
try {
const parsed = JSON.parse(line);
displayLine = JSON.stringify(parsed, null, 2);
isJson = true;
} catch {
// Not valid JSON, display as-is
}
return (
<pre
key={`${i}-${line.slice(0, 20)}`}
className={cn("whitespace-pre-wrap break-all text-xs", {
"wrap-break-word text-[10px] leading-tight": isJson,
})}
>
{displayLine}
</pre>
);
})}
</div>
</div>
</div>
</div>
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,74 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FileTree } from "./file-tree";
import { ScrollArea } from "./ui/scroll-area";
import { browseFilesystemOptions } from "../api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "../hooks/use-file-browser";
type Props = {
onSelectPath: (path: string) => void;
selectedPath?: string;
};
export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
...browseFilesystemOptions({ query: { path: "/" } }),
});
const fileBrowser = useFileBrowser({
initialData: data,
isLoading,
fetchFolder: async (path) => {
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
},
prefetchFolder: (path) => {
void queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
},
});
if (fileBrowser.isLoading) {
return (
<div className="border rounded-lg overflow-hidden">
<ScrollArea className="h-64">
<div className="text-sm text-gray-500 p-4">Loading directories...</div>
</ScrollArea>
</div>
);
}
if (fileBrowser.isEmpty) {
return (
<div className="border rounded-lg overflow-hidden">
<ScrollArea className="h-64">
<div className="text-sm text-gray-500 p-4">No subdirectories found</div>
</ScrollArea>
</div>
);
}
return (
<div className="border rounded-lg overflow-hidden">
<ScrollArea className="h-64">
<FileTree
files={fileBrowser.fileArray}
onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={fileBrowser.handleFolderHover}
expandedFolders={fileBrowser.expandedFolders}
loadingFolders={fileBrowser.loadingFolders}
foldersOnly
selectableFolders
selectedFolder={selectedPath}
onFolderSelect={onSelectPath}
/>
</ScrollArea>
{selectedPath && (
<div className="bg-muted/50 border-t p-2 text-sm">
<div className="font-medium text-muted-foreground">Selected path:</div>
<div className="font-mono text-xs break-all">{selectedPath}</div>
</div>
)}
</div>
);
};

View file

@ -1,244 +0,0 @@
import type { ComponentProps } from "react";
import { afterEach, describe, expect, test } from "vitest";
import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, fireEvent, render, screen, userEvent, waitFor, within } from "~/test/test-utils";
type SnapshotFilesRequest = {
shortId: string;
snapshotId: string;
path: string | null;
offset: string | null;
limit: string | null;
};
const snapshotFiles = {
files: [
{ name: "project", path: "/mnt/project", type: "dir" },
{ name: "a.txt", path: "/mnt/project/a.txt", type: "file" },
],
};
import { SnapshotTreeBrowser } from "../snapshot-tree-browser";
const mockListSnapshotFiles = (response = snapshotFiles) => {
const requests: SnapshotFilesRequest[] = [];
server.use(
http.get("/api/v1/repositories/:shortId/snapshots/:snapshotId/files", ({ params, request }) => {
const url = new URL(request.url);
requests.push({
shortId: String(params.shortId),
snapshotId: String(params.snapshotId),
path: url.searchParams.get("path"),
offset: url.searchParams.get("offset"),
limit: url.searchParams.get("limit"),
});
return HttpResponse.json(response);
}),
);
return requests;
};
const renderSnapshotTreeBrowser = (props: Partial<ComponentProps<typeof SnapshotTreeBrowser>> = {}) => {
return render(
<SnapshotTreeBrowser
repositoryId="repo-1"
snapshotId="snap-1"
queryBasePath="/mnt/project"
displayBasePath="/mnt"
{...props}
/>,
);
};
afterEach(() => {
cleanup();
});
describe("SnapshotTreeBrowser", () => {
test("renders the query root folder when display base path is broader than query base path", async () => {
mockListSnapshotFiles();
renderSnapshotTreeBrowser();
expect(await screen.findByRole("button", { name: "project" })).toBeTruthy();
});
test("renders ancestor folders when the query root is nested multiple levels below the display root", async () => {
mockListSnapshotFiles({
files: [
{ name: "subdir", path: "/mnt/project/subdir", type: "dir" },
{ name: "a.txt", path: "/mnt/project/subdir/a.txt", type: "file" },
],
});
renderSnapshotTreeBrowser({
queryBasePath: "/mnt/project/subdir",
displayBasePath: "/mnt",
});
expect(await screen.findByRole("button", { name: "project" })).toBeTruthy();
});
test("renders synthesized ancestor folders for a single file when no display base path is available", async () => {
const requests = mockListSnapshotFiles({
files: [{ name: "report.txt", path: "/mnt/project/report.txt", type: "file" }],
});
renderSnapshotTreeBrowser({
queryBasePath: "/mnt/project/report.txt",
displayBasePath: undefined,
});
const mntRow = await screen.findByRole("button", { name: "mnt" });
const mntExpandIcon = mntRow.querySelector("svg");
if (!mntExpandIcon) {
throw new Error("Expected expand icon for mnt row");
}
fireEvent.click(mntExpandIcon);
expect(await screen.findByRole("button", { name: "project" })).toBeTruthy();
expect(requests[0]).toEqual({
shortId: "repo-1",
snapshotId: "snap-1",
path: "/mnt/project/report.txt",
offset: null,
limit: null,
});
});
test("returns the ancestor folder path when selecting above the query root", async () => {
mockListSnapshotFiles({
files: [
{ name: "subdir", path: "/mnt/project/subdir", type: "dir" },
{ name: "a.txt", path: "/mnt/project/subdir/a.txt", type: "file" },
],
});
let selectedPaths: Set<string> | undefined;
let selectedKind: "file" | "dir" | null = null;
renderSnapshotTreeBrowser({
queryBasePath: "/mnt/project/subdir",
displayBasePath: "/mnt",
withCheckboxes: true,
onSelectionChange: (paths) => {
selectedPaths = paths;
},
onSingleSelectionKindChange: (kind) => {
selectedKind = kind;
},
});
const row = await screen.findByRole("button", { name: "project" });
const checkbox = within(row).getByRole("checkbox");
await userEvent.click(checkbox);
expect(selectedPaths ? Array.from(selectedPaths) : []).toEqual(["/mnt/project"]);
expect(selectedKind === "dir").toBe(true);
});
test("keeps full paths when the display root does not contain the query root", async () => {
mockListSnapshotFiles();
let selectedPaths: Set<string> | undefined;
let selectedKind: "file" | "dir" | null = null;
renderSnapshotTreeBrowser({
queryBasePath: "/mnt/project",
displayBasePath: "/other/root",
withCheckboxes: true,
onSelectionChange: (paths) => {
selectedPaths = paths;
},
onSingleSelectionKindChange: (kind) => {
selectedKind = kind;
},
});
const row = await screen.findByRole("button", { name: "mnt" });
const checkbox = within(row).getByRole("checkbox");
await userEvent.click(checkbox);
expect(selectedPaths ? Array.from(selectedPaths) : []).toEqual(["/mnt"]);
expect(selectedKind === "dir").toBe(true);
});
test("shows selected folder state when full paths are provided from the parent", async () => {
mockListSnapshotFiles();
renderSnapshotTreeBrowser({
withCheckboxes: true,
selectedPaths: new Set(["/mnt/project"]),
onSelectionChange: () => {},
});
const row = await screen.findByRole("button", { name: "project" });
const checkbox = within(row).getByRole("checkbox");
expect(checkbox.getAttribute("aria-checked")).toBe("true");
});
test("returns the full snapshot path and kind when selecting a displayed folder", async () => {
mockListSnapshotFiles();
let selectedPaths: Set<string> | undefined;
let selectedKind: "file" | "dir" | null = null;
renderSnapshotTreeBrowser({
withCheckboxes: true,
onSelectionChange: (paths) => {
selectedPaths = paths;
},
onSingleSelectionKindChange: (kind) => {
selectedKind = kind;
},
});
const row = await screen.findByRole("button", { name: "project" });
const checkbox = within(row).getByRole("checkbox");
await userEvent.click(checkbox);
expect(selectedPaths ? Array.from(selectedPaths) : []).toEqual(["/mnt/project"]);
expect(selectedKind === "dir").toBe(true);
});
test("uses the query base path for the initial request when display base path is broader", async () => {
const requests = mockListSnapshotFiles();
renderSnapshotTreeBrowser();
await waitFor(() => {
expect(requests[0]).toEqual({
shortId: "repo-1",
snapshotId: "snap-1",
path: "/mnt/project",
offset: null,
limit: null,
});
});
});
test("shows the query root contents when display and query roots differ", async () => {
mockListSnapshotFiles();
renderSnapshotTreeBrowser();
const row = await screen.findByRole("button", { name: "project" });
const expandIcon = row.querySelector("svg");
if (!expandIcon) {
throw new Error("Expected expand icon for folder row");
}
if (!screen.queryByRole("button", { name: "a.txt" })) {
fireEvent.click(expandIcon);
}
expect(await screen.findByRole("button", { name: "a.txt" })).toBeTruthy();
});
});

View file

@ -1,24 +0,0 @@
import { LocalFileBrowser } from "./local-file-browser";
type Props = {
onSelectPath: (path: string) => void;
selectedPath?: string;
};
export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
return (
<LocalFileBrowser
className="border rounded-lg overflow-hidden"
useScrollArea
scrollAreaClassName="h-64"
foldersOnly
selectableFolders
selectedFolder={selectedPath}
onFolderSelect={onSelectPath}
showSelectedPathFooter
selectedPath={selectedPath}
loadingMessage="Loading directories..."
emptyMessage="No subdirectories found"
/>
);
};

View file

@ -1,149 +0,0 @@
import { type ReactNode } from "react";
import { FolderOpen } from "lucide-react";
import { FileTree, type FileEntry } from "~/client/components/file-tree";
import { ScrollArea } from "~/client/components/ui/scroll-area";
import { cn } from "~/client/lib/utils";
type PaginationState = {
hasMore: boolean;
isLoadingMore: boolean;
};
export type FileBrowserUiProps = {
className?: string;
treeContainerClassName?: string;
treeClassName?: string;
useScrollArea?: boolean;
scrollAreaClassName?: string;
stateClassName?: string;
loadingMessage?: string;
emptyMessage?: string;
emptyDescription?: string;
emptyIcon?: ReactNode;
withCheckboxes?: boolean;
selectedPaths?: Set<string>;
onSelectionChange?: (paths: Set<string>) => void;
foldersOnly?: boolean;
selectableFolders?: boolean;
onFolderSelect?: (folderPath: string) => void;
selectedFolder?: string;
onFileSelect?: (filePath: string) => void;
selectedFile?: string;
showSelectedPathFooter?: boolean;
selectedPath?: string;
selectedPathLabel?: string;
};
type FileBrowserProps = FileBrowserUiProps & {
isLoading: boolean;
isEmpty: boolean;
errorMessage?: string;
fileArray: FileEntry[];
expandedFolders: Set<string>;
loadingFolders: Set<string>;
onFolderExpand: (folderPath: string) => void | Promise<void>;
onFolderHover: (folderPath: string) => void;
onLoadMore: (folderPath: string) => void | Promise<void>;
getFolderPagination: (folderPath: string) => PaginationState;
};
export const FileBrowser = (props: FileBrowserProps) => {
const {
className,
treeContainerClassName,
treeClassName,
useScrollArea = false,
scrollAreaClassName,
stateClassName,
loadingMessage = "Loading files...",
emptyMessage = "No files found.",
emptyDescription,
emptyIcon,
withCheckboxes = false,
selectedPaths,
onSelectionChange,
foldersOnly = false,
selectableFolders = false,
onFolderSelect,
selectedFolder,
onFileSelect,
selectedFile,
showSelectedPathFooter = false,
selectedPath,
selectedPathLabel = "Selected path:",
isLoading,
isEmpty,
errorMessage,
fileArray,
expandedFolders,
loadingFolders,
onFolderExpand,
onFolderHover,
onLoadMore,
getFolderPagination,
} = props;
const resolvedSelectedPath = selectedPath ?? selectedFolder;
const resolvedEmptyIcon =
emptyIcon === undefined ? <FolderOpen className="mb-2 h-12 w-12 text-muted-foreground" /> : emptyIcon;
let body: ReactNode;
if (isLoading) {
body = (
<div className={cn("flex min-h-50 flex-col items-center justify-center p-6 text-center", stateClassName)}>
<p className="text-muted-foreground">{loadingMessage}</p>
</div>
);
} else if (errorMessage) {
body = (
<div className={cn("flex min-h-50 flex-col items-center justify-center p-6 text-center", stateClassName)}>
<p className="text-destructive">{errorMessage}</p>
</div>
);
} else if (isEmpty) {
body = (
<div className={cn("flex min-h-50 flex-col items-center justify-center p-6 text-center", stateClassName)}>
{resolvedEmptyIcon}
<p className="text-muted-foreground">{emptyMessage}</p>
{emptyDescription && <p className="mt-2 text-sm text-muted-foreground">{emptyDescription}</p>}
</div>
);
} else {
body = (
<FileTree
files={fileArray}
onFolderExpand={onFolderExpand}
onFolderHover={onFolderHover}
onLoadMore={onLoadMore}
getFolderPagination={getFolderPagination}
expandedFolders={expandedFolders}
loadingFolders={loadingFolders}
className={treeClassName}
withCheckboxes={withCheckboxes}
selectedPaths={selectedPaths}
onSelectionChange={onSelectionChange}
foldersOnly={foldersOnly}
selectableFolders={selectableFolders}
onFolderSelect={onFolderSelect}
selectedFolder={selectedFolder}
onFileSelect={onFileSelect}
selectedFile={selectedFile}
/>
);
}
const bodyWithScroll = useScrollArea ? <ScrollArea className={scrollAreaClassName}>{body}</ScrollArea> : body;
return (
<div className={className}>
<div className={treeContainerClassName}>{bodyWithScroll}</div>
{showSelectedPathFooter && resolvedSelectedPath && (
<div className="bg-muted/50 border-t p-2 text-sm">
<div className="font-medium text-muted-foreground">{selectedPathLabel}</div>
<div className="font-mono text-xs break-all">{resolvedSelectedPath}</div>
</div>
)}
</div>
);
};

View file

@ -1,51 +0,0 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { browseFilesystemOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { parseError } from "~/client/lib/errors";
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
import { logger } from "~/client/lib/logger";
type LocalFileBrowserProps = FileBrowserUiProps & {
initialPath?: string;
enabled?: boolean;
};
export const LocalFileBrowser = ({ initialPath = "/", enabled = true, ...uiProps }: LocalFileBrowserProps) => {
const queryClient = useQueryClient();
const normalizedInitialPath = normalizeAbsolutePath(initialPath);
const { data, isLoading, error } = useQuery({
...browseFilesystemOptions({ query: { path: normalizedInitialPath } }),
enabled,
});
const fileBrowser = useFileBrowser({
initialData: data,
isLoading,
fetchFolder: async (path) => {
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
},
prefetchFolder: (path) => {
void queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } })).catch((e) => logger.error(e));
},
});
return (
<FileBrowser
{...uiProps}
fileArray={fileBrowser.fileArray}
expandedFolders={fileBrowser.expandedFolders}
loadingFolders={fileBrowser.loadingFolders}
onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={fileBrowser.handleFolderHover}
onLoadMore={fileBrowser.handleLoadMore}
getFolderPagination={fileBrowser.getFolderPagination}
isLoading={fileBrowser.isLoading}
isEmpty={fileBrowser.isEmpty}
errorMessage={parseError(error)?.message}
loadingMessage={uiProps.loadingMessage ?? "Loading directories..."}
emptyMessage={uiProps.emptyMessage ?? "No subdirectories found"}
/>
);
};

View file

@ -1,157 +0,0 @@
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { parseError } from "~/client/lib/errors";
import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils";
function createPathPrefixFns(basePath: string) {
return {
strip(path: string) {
if (basePath === "/") return path;
if (path === basePath) return "/";
if (path.startsWith(`${basePath}/`)) return path.slice(basePath.length);
return path;
},
add(displayPath: string) {
if (basePath === "/") return displayPath;
if (displayPath === "/") return basePath;
return `${basePath}${displayPath}`;
},
};
}
type SnapshotTreeBrowserProps = FileBrowserUiProps & {
repositoryId: string;
snapshotId: string;
queryBasePath?: string;
displayBasePath?: string;
pageSize?: number;
enabled?: boolean;
onSingleSelectionKindChange?: (kind: "file" | "dir" | null) => void;
};
export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
const {
repositoryId,
snapshotId,
queryBasePath = "/",
displayBasePath,
pageSize = 500,
enabled = true,
...uiProps
} = props;
const { selectedPaths, onSelectionChange, onSingleSelectionKindChange, ...fileBrowserUiProps } = uiProps;
const queryClient = useQueryClient();
const normalizedQueryBasePath = normalizeAbsolutePath(queryBasePath);
const normalizedDisplayBasePath = normalizeAbsolutePath(displayBasePath ?? "/");
const effectiveDisplayBasePath = isPathWithin(normalizedDisplayBasePath, normalizedQueryBasePath)
? normalizedDisplayBasePath
: "/";
const { data, isLoading, error } = useQuery({
...listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId },
query: { path: normalizedQueryBasePath },
}),
enabled,
});
const displayPathFns = useMemo(() => createPathPrefixFns(effectiveDisplayBasePath), [effectiveDisplayBasePath]);
const displaySelectedPaths = useMemo(() => {
if (!selectedPaths) return undefined;
const displayPaths = new Set<string>();
for (const fullPath of selectedPaths) {
displayPaths.add(displayPathFns.strip(fullPath));
}
return displayPaths;
}, [displayPathFns, selectedPaths]);
const fileBrowser = useFileBrowser({
initialData: data,
isLoading,
fetchFolder: async (displayPath, offset = 0) => {
return await queryClient.ensureQueryData(
listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId },
query: { path: displayPath, offset: offset, limit: pageSize },
}),
);
},
pathTransform: displayPathFns,
});
const displayPathKinds = useMemo(() => {
const kinds = new Map<string, "file" | "dir">();
for (const entry of fileBrowser.fileArray) {
kinds.set(entry.path, entry.type === "file" ? "file" : "dir");
let parentPath = entry.path;
while (true) {
const lastSlashIndex = parentPath.lastIndexOf("/");
if (lastSlashIndex <= 0) {
break;
}
parentPath = parentPath.slice(0, lastSlashIndex);
if (kinds.has(parentPath)) {
continue;
}
kinds.set(parentPath, "dir");
}
}
return kinds;
}, [fileBrowser.fileArray]);
const handleSelectionChange = useCallback(
(nextDisplayPaths: Set<string>) => {
if (!onSelectionChange) return;
const nextFullPaths = new Set<string>();
for (const displayPath of nextDisplayPaths) {
nextFullPaths.add(displayPathFns.add(displayPath));
}
if (onSingleSelectionKindChange) {
if (nextDisplayPaths.size === 1) {
const [selectedDisplayPath] = nextDisplayPaths;
if (selectedDisplayPath) {
onSingleSelectionKindChange(displayPathKinds.get(selectedDisplayPath) ?? null);
} else {
onSingleSelectionKindChange(null);
}
} else {
onSingleSelectionKindChange(null);
}
}
onSelectionChange(nextFullPaths);
},
[displayPathFns, displayPathKinds, onSelectionChange, onSingleSelectionKindChange],
);
return (
<FileBrowser
{...fileBrowserUiProps}
fileArray={fileBrowser.fileArray}
expandedFolders={fileBrowser.expandedFolders}
loadingFolders={fileBrowser.loadingFolders}
onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={fileBrowser.handleFolderHover}
onLoadMore={fileBrowser.handleLoadMore}
getFolderPagination={fileBrowser.getFolderPagination}
isLoading={fileBrowser.isLoading}
isEmpty={fileBrowser.isEmpty}
errorMessage={parseError(error)?.message}
loadingMessage={fileBrowserUiProps.loadingMessage ?? "Loading files..."}
selectedPaths={displaySelectedPaths}
onSelectionChange={onSelectionChange ? handleSelectionChange : undefined}
/>
);
};

View file

@ -1,61 +0,0 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { listFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
import { useFileBrowser, type FetchFolderResult } from "~/client/hooks/use-file-browser";
import { parseError } from "~/client/lib/errors";
import { logger } from "~/client/lib/logger";
type VolumeFileBrowserProps = FileBrowserUiProps & {
volumeId: string;
enabled?: boolean;
};
export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: VolumeFileBrowserProps) => {
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
...listFilesOptions({ path: { shortId: volumeId } }),
enabled,
});
const fileBrowser = useFileBrowser({
initialData: data,
isLoading,
fetchFolder: async (path, offset): Promise<FetchFolderResult> => {
return await queryClient.ensureQueryData(
listFilesOptions({
path: { shortId: volumeId },
query: { path, offset: offset },
}),
);
},
prefetchFolder: (path) => {
void queryClient
.prefetchQuery(
listFilesOptions({
path: { shortId: volumeId },
query: { path },
}),
)
.catch((e) => logger.error(e));
},
});
return (
<FileBrowser
{...uiProps}
fileArray={fileBrowser.fileArray}
expandedFolders={fileBrowser.expandedFolders}
loadingFolders={fileBrowser.loadingFolders}
onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={fileBrowser.handleFolderHover}
onLoadMore={fileBrowser.handleLoadMore}
getFolderPagination={fileBrowser.getFolderPagination}
isLoading={fileBrowser.isLoading}
isEmpty={fileBrowser.isEmpty}
errorMessage={parseError(error)?.message}
loadingMessage={uiProps.loadingMessage ?? "Loading files..."}
emptyMessage={uiProps.emptyMessage ?? "This volume appears to be empty."}
/>
);
};

View file

@ -8,15 +8,7 @@
* Original source: https://github.com/stackblitz/bolt.new * Original source: https://github.com/stackblitz/bolt.new
*/ */
import { import { ChevronDown, ChevronRight, File as FileIcon, Folder as FolderIcon, FolderOpen, Loader2 } from "lucide-react";
ChevronDown,
ChevronRight,
File as FileIcon,
Folder as FolderIcon,
FolderOpen,
Loader2,
MoreHorizontal,
} from "lucide-react";
import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { Checkbox } from "~/client/components/ui/checkbox"; import { Checkbox } from "~/client/components/ui/checkbox";
@ -32,19 +24,12 @@ export interface FileEntry {
modifiedAt?: number; modifiedAt?: number;
} }
interface PaginationState {
hasMore: boolean;
isLoadingMore: boolean;
}
interface Props { interface Props {
files?: FileEntry[]; files?: FileEntry[];
selectedFile?: string; selectedFile?: string;
onFileSelect?: (filePath: string) => void; onFileSelect?: (filePath: string) => void;
onFolderExpand?: (folderPath: string) => void; onFolderExpand?: (folderPath: string) => void;
onFolderHover?: (folderPath: string) => void; onFolderHover?: (folderPath: string) => void;
onLoadMore?: (folderPath: string) => void;
getFolderPagination?: (folderPath: string) => PaginationState;
expandedFolders?: Set<string>; expandedFolders?: Set<string>;
loadingFolders?: Set<string>; loadingFolders?: Set<string>;
className?: string; className?: string;
@ -64,8 +49,6 @@ export const FileTree = memo((props: Props) => {
selectedFile, selectedFile,
onFolderExpand, onFolderExpand,
onFolderHover, onFolderHover,
onLoadMore,
getFolderPagination,
expandedFolders = new Set(), expandedFolders = new Set(),
loadingFolders = new Set(), loadingFolders = new Set(),
className, className,
@ -114,25 +97,20 @@ export const FileTree = memo((props: Props) => {
const toggleCollapseState = useCallback( const toggleCollapseState = useCallback(
(fullPath: string) => { (fullPath: string) => {
const shouldExpand = collapsedFolders.has(fullPath);
setCollapsedFolders((prevSet) => { setCollapsedFolders((prevSet) => {
const newSet = new Set(prevSet); const newSet = new Set(prevSet);
if (newSet.has(fullPath)) { if (newSet.has(fullPath)) {
newSet.delete(fullPath); newSet.delete(fullPath);
onFolderExpand?.(fullPath);
} else { } else {
newSet.add(fullPath); newSet.add(fullPath);
} }
return newSet; return newSet;
}); });
if (shouldExpand) {
onFolderExpand?.(fullPath);
}
}, },
[collapsedFolders, onFolderExpand], [onFolderExpand],
); );
// Add new folders to collapsed set when file list changes // Add new folders to collapsed set when file list changes
@ -314,32 +292,12 @@ export const FileTree = memo((props: Props) => {
[selectedPaths], [selectedPaths],
); );
// Build a map of folder paths that need pagination to their last child's index
const folderPaginationMap = useMemo(() => {
const map = new Map<string, number>();
for (let i = 0; i < filteredFileList.length; i++) {
const item = filteredFileList[i];
const parentPath = item.fullPath.slice(0, item.fullPath.lastIndexOf("/")) || "/";
const pagination = getFolderPagination?.(parentPath);
if (pagination?.hasMore && !collapsedFolders.has(parentPath)) {
// Update the last index for this parent
map.set(parentPath, i);
}
}
return map;
}, [filteredFileList, getFolderPagination, collapsedFolders]);
return ( return (
<div className={cn("text-sm", className)}> <div className={cn("text-sm", className)}>
{filteredFileList.map((fileOrFolder, index) => { {filteredFileList.map((fileOrFolder) => {
const elements: React.ReactNode[] = [];
// Render the current file or folder
switch (fileOrFolder.kind) { switch (fileOrFolder.kind) {
case "file": { case "file": {
elements.push( return (
<File <File
key={fileOrFolder.id} key={fileOrFolder.id}
selected={selectedFile === fileOrFolder.fullPath} selected={selectedFile === fileOrFolder.fullPath}
@ -348,12 +306,11 @@ export const FileTree = memo((props: Props) => {
withCheckbox={withCheckboxes} withCheckbox={withCheckboxes}
checked={isPathSelected(fileOrFolder.fullPath)} checked={isPathSelected(fileOrFolder.fullPath)}
onCheckboxChange={handleSelectionChange} onCheckboxChange={handleSelectionChange}
/>, />
); );
break;
} }
case "folder": { case "folder": {
elements.push( return (
<Folder <Folder
key={fileOrFolder.id} key={fileOrFolder.id}
folder={fileOrFolder} folder={fileOrFolder}
@ -368,31 +325,13 @@ export const FileTree = memo((props: Props) => {
selectableMode={selectableFolders} selectableMode={selectableFolders}
onFolderSelect={handleFolderSelect} onFolderSelect={handleFolderSelect}
selected={selectedFolder === fileOrFolder.fullPath} selected={selectedFolder === fileOrFolder.fullPath}
/>, />
); );
break; }
default: {
return undefined;
} }
} }
// Check if this is the last child of any folder with more files to load
for (const [folderPath, lastIndex] of folderPaginationMap.entries()) {
if (lastIndex === index) {
// This is the last loaded child of folderPath
const pagination = getFolderPagination?.(folderPath);
if (pagination?.hasMore) {
elements.push(
<LoadMoreButton
key={`load-more-${folderPath}`}
depth={fileOrFolder.depth}
onClick={() => onLoadMore?.(folderPath)}
isLoading={pagination.isLoadingMore}
/>,
);
}
}
}
return elements;
})} })}
</div> </div>
); );
@ -531,38 +470,13 @@ const File = memo(({ file, onFileSelect, selected, withCheckbox, checked, onChec
<span className="truncate">{name}</span> <span className="truncate">{name}</span>
{typeof size === "number" && ( {typeof size === "number" && (
<span className="ml-auto shrink-0 text-xs text-muted-foreground"> <span className="ml-auto shrink-0 text-xs text-muted-foreground">
<ByteSize bytes={size} base={1024} /> <ByteSize bytes={size} />
</span> </span>
)} )}
</NodeButton> </NodeButton>
); );
}); });
interface LoadMoreButtonProps {
depth: number;
onClick: () => void;
isLoading?: boolean;
}
const LoadMoreButton = memo(({ depth, onClick, isLoading }: LoadMoreButtonProps) => {
return (
<NodeButton
depth={depth}
className="text-muted-foreground hover:bg-accent/50 cursor-pointer"
icon={
isLoading ? (
<Loader2 className="w-4 h-4 shrink-0 animate-spin" />
) : (
<MoreHorizontal className="w-4 h-4 shrink-0" />
)
}
onClick={onClick}
>
<span className="text-xs">{isLoading ? "Loading more..." : "Load more files"}</span>
</NodeButton>
);
});
interface ButtonProps { interface ButtonProps {
depth: number; depth: number;
icon: ReactNode; icon: ReactNode;
@ -586,10 +500,8 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
); );
return ( return (
<div <button
// oxlint-disable-next-line jsx_a11y/prefer-tag-over-role type="button"
role="button"
tabIndex={0}
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)} className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
style={{ paddingLeft }} style={{ paddingLeft }}
onClick={onClick} onClick={onClick}
@ -598,7 +510,7 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
> >
{icon} {icon}
<div className="truncate w-full flex items-center gap-2">{children}</div> <div className="truncate w-full flex items-center gap-2">{children}</div>
</div> </button>
); );
}); });
@ -632,10 +544,6 @@ function buildFileList(files: FileEntry[], foldersOnly = false): Node[] {
const depth = segments.length - 1; const depth = segments.length - 1;
const name = segments[segments.length - 1]; const name = segments[segments.length - 1];
if (!name) {
continue;
}
if (!fileMap.has(file.path)) { if (!fileMap.has(file.path)) {
const isFile = file.type === "file"; const isFile = file.type === "file";
fileMap.set(file.path, { fileMap.set(file.path, {
@ -647,33 +555,6 @@ function buildFileList(files: FileEntry[], foldersOnly = false): Node[] {
size: file.size, size: file.size,
}); });
} }
let parentPath = file.path;
while (true) {
const lastSlashIndex = parentPath.lastIndexOf("/");
if (lastSlashIndex <= 0) {
break;
}
parentPath = parentPath.slice(0, lastSlashIndex);
if (fileMap.has(parentPath)) {
continue;
}
const parentSegments = parentPath.split("/").filter((segment) => segment);
const parentName = parentSegments[parentSegments.length - 1];
if (!parentName) {
continue;
}
fileMap.set(parentPath, {
kind: "folder",
id: fileMap.size,
name: parentName,
fullPath: parentPath,
depth: parentSegments.length - 1,
});
}
} }
// Convert map to array and sort // Convert map to array and sort
@ -740,7 +621,5 @@ function compareNodes(a: Node, b: Node): number {
return a.kind === "folder" ? -1 : 1; return a.kind === "folder" ? -1 : 1;
} }
if (a.name < b.name) return -1; return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
if (a.name > b.name) return 1;
return 0;
} }

View file

@ -9,17 +9,16 @@ interface GridBackgroundProps {
export function GridBackground({ children, className, containerClassName }: GridBackgroundProps) { export function GridBackground({ children, className, containerClassName }: GridBackgroundProps) {
return ( return (
<div className={cn("relative min-h-full w-full", containerClassName)}> <div
<div className={cn(
className={cn( "relative min-h-full w-full",
"pointer-events-none absolute inset-0 w-full h-full", "bg-size-[20px_20px] sm:bg-size-[40px_40px]",
"bg-[size:40px_40px]", "bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
"bg-[linear-gradient(to_right,rgba(0,0,0,0.04)_1px,transparent_1px),linear-gradient(to_bottom,rgba(0,0,0,0.04)_1px,transparent_1px)]", "dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
"dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]", containerClassName,
"[mask-image:radial-gradient(ellipse_at_top,black_70%,transparent_100%)]", )}
)} >
/> <div className={cn("relative container m-auto", className)}>{children}</div>
<div className={cn("relative container m-auto z-10", className)}>{children}</div>
</div> </div>
); );
} }

View file

@ -1,29 +1,36 @@
import { LifeBuoy, LogOut } from "lucide-react"; import { LifeBuoy } from "lucide-react";
import { Outlet, redirect, useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { type AppContext } from "~/context"; import { appContext } from "~/context";
import { authMiddleware } from "~/middleware/auth";
import type { Route } from "./+types/layout";
import { AppBreadcrumb } from "./app-breadcrumb";
import { GridBackground } from "./grid-background"; import { GridBackground } from "./grid-background";
import { Button, buttonVariants } from "./ui/button"; import { Button } from "./ui/button";
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar"; import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
import { AppSidebar } from "./app-sidebar"; import { AppSidebar } from "./app-sidebar";
import { authClient } from "../lib/auth-client"; import { authClient } from "../lib/auth-client";
import { DevPanelListener } from "./dev-panel-listener";
import { Outlet, useNavigate } from "@tanstack/react-router";
import { AppBreadcrumb } from "./app-breadcrumb";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { ThemeToggle } from "./theme-toggle";
type Props = { export const clientMiddleware = [authMiddleware];
loaderData: AppContext;
};
export function Layout({ loaderData }: Props) { export async function clientLoader({ context }: Route.LoaderArgs) {
const ctx = context.get(appContext);
if (ctx.user && !ctx.user.hasDownloadedResticPassword) {
throw redirect("/download-recovery-key");
}
return ctx;
}
export default function Layout({ loaderData }: Route.ComponentProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const handleLogout = async () => { const handleLogout = async () => {
await authClient.signOut({ await authClient.signOut({
fetchOptions: { fetchOptions: {
onSuccess: () => { onSuccess: () => {
void navigate({ to: "/login", replace: true }); void navigate("/login", { replace: true });
}, },
onError: ({ error }) => { onError: ({ error }) => {
toast.error("Logout failed", { description: error.message }); toast.error("Logout failed", { description: error.message });
@ -33,65 +40,49 @@ export function Layout({ loaderData }: Props) {
}; };
return ( return (
<SidebarProvider defaultOpen={loaderData.sidebarOpen}> <SidebarProvider defaultOpen={true}>
<AppSidebar isInstanceAdmin={loaderData.user?.role === "admin"} /> <AppSidebar />
<div className="w-full relative flex flex-col min-h-screen md:h-screen md:overflow-hidden"> <div className="w-full relative flex flex-col h-screen overflow-hidden">
<header className="z-50 bg-card-header border-b border-border/80 dark:border-border/50 shrink-0 h-16.25"> <header className="z-50 bg-card-header border-b border-border/50 shrink-0">
<div className="flex items-center h-full justify-between px-2 sm:px-8 mx-auto container gap-4"> <div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container gap-4">
<div className="flex items-center gap-4 min-w-0"> <div className="flex items-center gap-4 min-w-0">
<SidebarTrigger /> <SidebarTrigger />
<AppBreadcrumb /> <AppBreadcrumb />
</div> </div>
{loaderData.user && ( {loaderData.user && (
<div className="flex items-center bg-card dark:bg-muted/30 border border-border/80 dark:border-border/50 px-2 py-1 rounded-full shadow-[0_2px_10px_-4px_rgba(0,0,0,0.05)] dark:shadow-sm"> <div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground hidden md:inline-flex pl-2 mr-5"> <span className="text-sm text-muted-foreground hidden md:inline-flex">
<span className="text-foreground">{loaderData.user.name}</span> Welcome,&nbsp;
<span className="text-strong-accent">{loaderData.user?.username}</span>
</span> </span>
<ThemeToggle /> <Button variant="default" size="sm" onClick={handleLogout}>
<Tooltip> Logout
<TooltipTrigger asChild> </Button>
<Button <Button variant="default" size="sm" className="relative overflow-hidden hidden lg:inline-flex">
variant="ghost" <a
size="icon" href="https://github.com/nicotsx/zerobyte/issues/new"
className="rounded-full h-7 text-xs text-muted-foreground hover:text-foreground" target="_blank"
onClick={handleLogout} rel="noreferrer"
> className="flex items-center gap-2"
<LogOut className="w-4 h-4" /> >
</Button> <span className="flex items-center gap-2">
</TooltipTrigger> <LifeBuoy />
<TooltipContent>Logout</TooltipContent> <span>Report an issue</span>
</Tooltip> </span>
<Tooltip> </a>
<TooltipTrigger asChild> </Button>
<a
href="https://github.com/nicotsx/zerobyte/issues/new/choose"
target="_blank"
rel="noreferrer"
className={buttonVariants({
variant: "ghost",
size: "icon",
className:
"relative overflow-hidden hidden lg:inline-flex rounded-full h-7 w-7 text-muted-foreground hover:text-foreground",
})}
>
<LifeBuoy className="w-4 h-4" />
</a>
</TooltipTrigger>
<TooltipContent>Report an issue</TooltipContent>
</Tooltip>
</div> </div>
)} )}
</div> </div>
</header> </header>
<div className="main-content flex-1 md:overflow-y-auto"> <div className="main-content flex-1 overflow-y-auto">
<GridBackground> <GridBackground>
<main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto"> <main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto @container">
<Outlet /> <Outlet />
</main> </main>
</GridBackground> </GridBackground>
</div> </div>
</div> </div>
<DevPanelListener />
</SidebarProvider> </SidebarProvider>
); );
} }

View file

@ -1,23 +0,0 @@
import { Badge } from "~/client/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
type ManagedBadgeProps = {
label?: string;
message?: string;
};
const defaultMessage =
"This resource is provisioned at startup. Changes are useful for testing, but the next provisioning sync can overwrite or recreate it.";
export const ManagedBadge = ({ label = "Managed", message = defaultMessage }: ManagedBadgeProps) => {
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary">{label}</Badge>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-80 text-sm">{message}</p>
</TooltipContent>
</Tooltip>
);
};

View file

@ -15,7 +15,7 @@ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: P
className={cn( className={cn(
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors", "flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors",
isOn isOn
? "border-success/30 bg-success/10 text-success" ? "border-green-200 bg-green-50 text-green-700 dark:border-green-500/40 dark:bg-green-500/10 dark:text-green-200"
: "border-muted bg-muted/40 text-muted-foreground dark:border-muted/60 dark:bg-muted/10", : "border-muted bg-muted/40 text-muted-foreground dark:border-muted/60 dark:bg-muted/10",
)} )}
> >

View file

@ -1,110 +0,0 @@
import { ChevronsUpDown } from "lucide-react";
import { toast } from "sonner";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "~/client/components/ui/dropdown-menu";
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "~/client/components/ui/sidebar";
import { authClient } from "~/client/lib/auth-client";
import { useMutation } from "@tanstack/react-query";
import { useOrganizationContext } from "../hooks/use-org-context";
function getOrganizationInitials(name?: string): string {
const trimmedName = name?.trim();
if (!trimmedName) {
return "O";
}
return trimmedName
.split(/\s+/)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join("");
}
export function OrganizationSwitcher() {
const { isMobile } = useSidebar();
const { organizations, activeOrganization } = useOrganizationContext();
const switchOrganizationMutation = useMutation({
mutationFn: async (organizationId: string) => {
const { error } = await authClient.organization.setActive({ organizationId });
if (error) throw new Error(error.message);
},
onError: (error) => {
const message = error instanceof Error ? error.message : "Unexpected error while switching organizations";
toast.error("Failed to switch organization", { description: message });
},
});
if (organizations === undefined) {
return null;
}
if (organizations.length <= 1) {
return null;
}
return (
<SidebarMenu className="mb-3">
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div className="bg-foreground text-sidebar-primary-foreground flex aspect-square size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg">
{activeOrganization?.logo ? (
<img
src={activeOrganization.logo}
alt={`${activeOrganization.name} logo`}
className="size-full object-cover"
/>
) : (
<span className="text-xs font-semibold">{getOrganizationInitials(activeOrganization?.name)}</span>
)}
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{activeOrganization?.name}</span>
<span className="truncate text-xs">{organizations.length} organizations</span>
</div>
<ChevronsUpDown className="ml-auto group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-muted-foreground text-xs">Organizations</DropdownMenuLabel>
{organizations.map((organization) => (
<DropdownMenuItem
key={organization.id}
onClick={() => switchOrganizationMutation.mutate(organization.id)}
className="gap-2 p-2"
disabled={switchOrganizationMutation.isPending}
>
<div className="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-md border">
{organization.logo ? (
<img src={organization.logo} alt={`${organization.name} logo`} className="size-full object-cover" />
) : (
<span className="text-[10px] font-semibold">{getOrganizationInitials(organization.name)}</span>
)}
</div>
<span className="min-w-0 flex-1 truncate">{organization.name}</span>
<DropdownMenuShortcut>{organization.id === activeOrganization?.id && "Current"}</DropdownMenuShortcut>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View file

@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { DirectoryBrowser } from "./file-browsers/directory-browser"; import { DirectoryBrowser } from "./directory-browser";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
type Props = { type Props = {
@ -15,11 +15,11 @@ export const PathSelector = ({ value, onChange }: Props) => {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<DirectoryBrowser <DirectoryBrowser
selectedPath={value}
onSelectPath={(path) => { onSelectPath={(path) => {
onChange(path); onChange(path);
setShowBrowser(false); setShowBrowser(false);
}} }}
selectedPath={value}
/> />
<Button type="button" variant="ghost" size="sm" onClick={() => setShowBrowser(false)}> <Button type="button" variant="ghost" size="sm" onClick={() => setShowBrowser(false)}>
Cancel Cancel

View file

@ -2,7 +2,7 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog";
import { ScrollArea } from "~/client/components/ui/scroll-area"; import { ScrollArea } from "~/client/components/ui/scroll-area";
import { useTimeFormat } from "~/client/lib/datetime"; import { formatDate } from "~/client/lib/datetime";
import type { UpdateInfoDto } from "~/server/modules/system/system.dto"; import type { UpdateInfoDto } from "~/server/modules/system/system.dto";
interface ReleaseNotesDialogProps { interface ReleaseNotesDialogProps {
@ -12,8 +12,6 @@ interface ReleaseNotesDialogProps {
} }
export function ReleaseNotesDialog({ open, onOpenChange, updates }: ReleaseNotesDialogProps) { export function ReleaseNotesDialog({ open, onOpenChange, updates }: ReleaseNotesDialogProps) {
const { formatDate } = useTimeFormat();
if (!updates) return null; if (!updates) return null;
return ( return (

View file

@ -1,5 +1,5 @@
import { Database, HardDrive, Cloud, Server } from "lucide-react"; import { Database, HardDrive, Cloud, Server } from "lucide-react";
import type { RepositoryBackend } from "@zerobyte/core/restic"; import type { RepositoryBackend } from "~/schemas/restic";
type Props = { type Props = {
backend: RepositoryBackend; backend: RepositoryBackend;

View file

@ -1,283 +1,176 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useState } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ChevronDown, Download, FolderOpen, RotateCcw } from "lucide-react"; import { useNavigate } from "react-router";
import { toast } from "sonner";
import { ChevronDown, FileIcon, FolderOpen, RotateCcw } from "lucide-react";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Checkbox } from "~/client/components/ui/checkbox";
import { Input } from "~/client/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label"; import { Label } from "~/client/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { PathSelector } from "~/client/components/path-selector"; import { PathSelector } from "~/client/components/path-selector";
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser"; import { FileTree } from "~/client/components/file-tree";
import { RestoreProgress } from "~/client/components/restore-progress"; import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events"; import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
import { OVERWRITE_MODES, type OverwriteMode } from "@zerobyte/core/restic"; import type { Repository, Snapshot } from "~/client/lib/types";
import { isPathWithin } from "@zerobyte/core/utils";
import type { Repository } from "~/client/lib/types";
import { handleRepositoryError } from "~/client/lib/errors";
import { useNavigate } from "@tanstack/react-router";
import { cn } from "~/client/lib/utils";
type RestoreLocation = "original" | "custom"; type RestoreLocation = "original" | "custom";
interface RestoreFormProps { interface RestoreFormProps {
snapshot: Snapshot;
repository: Repository; repository: Repository;
snapshotId: string; snapshotId: string;
returnPath: string; returnPath: string;
queryBasePath?: string;
displayBasePath?: string;
hasNonPosixSnapshotPaths?: boolean;
} }
export function RestoreForm({ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: RestoreFormProps) {
repository,
snapshotId,
returnPath,
queryBasePath,
displayBasePath,
hasNonPosixSnapshotPaths = false,
}: RestoreFormProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { addEventListener } = useServerEvents(); const queryClient = useQueryClient();
const snapshotBasePath = queryBasePath ?? "/"; const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
const hasMismatchedDisplayBasePath = displayBasePath && !isPathWithin(displayBasePath, snapshotBasePath);
const restoreRequiresCustomTarget = hasNonPosixSnapshotPaths || hasMismatchedDisplayBasePath;
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>( const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>("original");
restoreRequiresCustomTarget ? "custom" : "original",
);
const [customTargetPath, setCustomTargetPath] = useState(""); const [customTargetPath, setCustomTargetPath] = useState("");
const [overwriteMode, setOverwriteMode] = useState<OverwriteMode>("always"); const [overwriteMode, setOverwriteMode] = useState<OverwriteMode>("always");
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
const [excludeXattr, setExcludeXattr] = useState(""); const [excludeXattr, setExcludeXattr] = useState("");
const [isRestoreActive, setIsRestoreActive] = useState(false); const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
const [restoreResult, setRestoreResult] = useState<RestoreCompletedEvent | null>(null);
const [showRestoreResultAlert, setShowRestoreResultAlert] = useState(false);
const restoreCompletedRef = useRef(false);
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set()); const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [selectedPathKind, setSelectedPathKind] = useState<"file" | "dir" | null>(null);
const trimmedCustomTargetPath = customTargetPath.trim();
const hasCustomTargetPath = trimmedCustomTargetPath !== "";
const selectedPathCount = selectedPaths.size;
useEffect(() => { const { data: filesData, isLoading: filesLoading } = useQuery({
if (restoreRequiresCustomTarget) { ...listSnapshotFilesOptions({
setRestoreLocation("custom"); path: { id: repository.id, snapshotId },
} query: { path: volumeBasePath },
}, [restoreRequiresCustomTarget]); }),
});
useEffect(() => { const stripBasePath = useCallback(
const abortController = new AbortController(); (path: string): string => {
const signal = abortController.signal; if (!volumeBasePath) return path;
if (path === volumeBasePath) return "/";
if (path.startsWith(`${volumeBasePath}/`)) {
const stripped = path.slice(volumeBasePath.length);
return stripped;
}
return path;
},
[volumeBasePath],
);
addEventListener( const addBasePath = useCallback(
"restore:started", (displayPath: string): string => {
(startedData) => { const vbp = volumeBasePath === "/" ? "" : volumeBasePath;
if (startedData.repositoryId === repository.shortId && startedData.snapshotId === snapshotId) {
restoreCompletedRef.current = false;
setIsRestoreActive(true);
setRestoreResult(null);
setShowRestoreResultAlert(false);
}
},
{ signal },
);
addEventListener( if (!vbp) return displayPath;
"restore:progress", if (displayPath === "/") return vbp;
(progressData) => { return `${vbp}${displayPath}`;
if (progressData.repositoryId === repository.shortId && progressData.snapshotId === snapshotId) { },
if (restoreCompletedRef.current) { [volumeBasePath],
return; );
}
setIsRestoreActive(true);
}
},
{ signal },
);
addEventListener( const fileBrowser = useFileBrowser({
"restore:completed", initialData: filesData,
(completedData) => { isLoading: filesLoading,
if (completedData.repositoryId === repository.shortId && completedData.snapshotId === snapshotId) { fetchFolder: async (path) => {
restoreCompletedRef.current = true; return await queryClient.ensureQueryData(
setIsRestoreActive(false); listSnapshotFilesOptions({
setRestoreResult(completedData); path: { id: repository.id, snapshotId },
setShowRestoreResultAlert(true); query: { path },
} }),
}, );
{ signal }, },
); prefetchFolder: (path) => {
void queryClient.prefetchQuery(
return () => { listSnapshotFilesOptions({
abortController.abort(); path: { id: repository.id, snapshotId },
}; query: { path },
}, [addEventListener, repository.shortId, snapshotId]); }),
);
},
pathTransform: {
strip: stripBasePath,
add: addBasePath,
},
});
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({ const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
...restoreSnapshotMutation(), ...restoreSnapshotMutation(),
onSuccess: () => {
toast.success("Restore completed");
void navigate(returnPath);
},
onError: (error) => { onError: (error) => {
restoreCompletedRef.current = true; toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" });
setIsRestoreActive(false);
handleRepositoryError("Restore failed", error, repository.shortId);
}, },
}); });
const handleRestore = useCallback(() => { const handleRestore = useCallback(() => {
const excludeXattrValues = excludeXattr const excludeXattrArray = excludeXattr
.split(",") ?.split(",")
.map((value) => value.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean);
const isCustomLocation = restoreLocation === "custom"; const isCustomLocation = restoreLocation === "custom";
const targetPath = isCustomLocation && hasCustomTargetPath ? trimmedCustomTargetPath : undefined; const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined;
const includePaths = Array.from(selectedPaths); const pathsArray = Array.from(selectedPaths);
const includePaths = pathsArray.map((path) => addBasePath(path));
restoreCompletedRef.current = false;
setIsRestoreActive(true);
setRestoreResult(null);
setShowRestoreResultAlert(false);
restoreSnapshot({ restoreSnapshot({
path: { shortId: repository.shortId }, path: { id: repository.id },
body: { body: {
snapshotId, snapshotId,
include: includePaths.length > 0 ? includePaths : undefined, include: includePaths.length > 0 ? includePaths : undefined,
selectedItemKind: includePaths.length === 1 ? (selectedPathKind ?? undefined) : undefined, delete: deleteExtraFiles,
excludeXattr: excludeXattrValues.length > 0 ? excludeXattrValues : undefined, excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
targetPath, targetPath,
overwrite: overwriteMode, overwrite: overwriteMode,
}, },
}); });
}, [ }, [
repository.shortId, repository.id,
snapshotId, snapshotId,
excludeXattr, excludeXattr,
hasCustomTargetPath,
restoreLocation, restoreLocation,
trimmedCustomTargetPath, customTargetPath,
selectedPaths, selectedPaths,
selectedPathKind, addBasePath,
deleteExtraFiles,
overwriteMode, overwriteMode,
restoreSnapshot, restoreSnapshot,
]); ]);
const handleDownload = useCallback(() => { const canRestore = restoreLocation === "original" || customTargetPath.trim();
if (selectedPaths.size > 1) return;
const url = new URL(
`/api/v1/repositories/${repository.shortId}/snapshots/${snapshotId}/dump`,
window.location.origin,
);
const [selectedPath] = selectedPaths;
if (selectedPath) {
url.searchParams.set("path", selectedPath);
if (selectedPathKind) {
url.searchParams.set("kind", selectedPathKind);
}
}
window.location.assign(url.toString());
}, [repository.shortId, snapshotId, selectedPathKind, selectedPaths]);
const acknowledgeRestoreResult = useCallback(() => {
setShowRestoreResultAlert(false);
setRestoreResult(null);
}, []);
const handleResultAlertOpenChange = useCallback((open: boolean) => {
if (open) {
setShowRestoreResultAlert(true);
}
}, []);
const canRestore = restoreRequiresCustomTarget
? hasCustomTargetPath
: restoreLocation === "original" || hasCustomTargetPath;
const canDownload = selectedPathCount <= 1;
const isRestoreRunning = isRestoring || isRestoreActive;
function getDownloadButtonText(): string {
if (selectedPathCount > 0) {
return `Download ${selectedPathCount} ${selectedPathCount === 1 ? "item" : "items"}`;
}
return "Download All";
}
function getRestoreButtonText(): string {
if (isRestoreRunning) {
return "Restoring...";
}
if (selectedPathCount > 0) {
return `Restore ${selectedPathCount} ${selectedPathCount === 1 ? "item" : "items"}`;
}
return "Restore All";
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">Restore Snapshot</h1> <h1 className="text-2xl font-bold">Restore Snapshot</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{repository.name} / {snapshotId} {repository.name} / {snapshotId}
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex gap-2">
<Button variant="outline" onClick={() => navigate({ to: returnPath })}> <Button variant="outline" onClick={() => navigate(returnPath)}>
Cancel Cancel
</Button> </Button>
<Tooltip> <Button variant="primary" onClick={handleRestore} disabled={isRestoring || !canRestore}>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button variant="outline" onClick={handleDownload} disabled={!canDownload}>
<Download className="h-4 w-4 mr-2" />
{getDownloadButtonText()}
</Button>
</span>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: canDownload })}>
<p>Download is available only for one selected item, or with no selection to download everything.</p>
</TooltipContent>
</Tooltip>
<Button variant="primary" onClick={handleRestore} disabled={isRestoreRunning || !canRestore}>
<RotateCcw className="h-4 w-4 mr-2" /> <RotateCcw className="h-4 w-4 mr-2" />
{getRestoreButtonText()} {isRestoring
? "Restoring..."
: selectedPaths.size > 0
? `Restore ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`
: "Restore All"}
</Button> </Button>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="space-y-6"> <div className="space-y-6">
{isRestoreRunning && <RestoreProgress repositoryId={repository.shortId} snapshotId={snapshotId} />}
{restoreRequiresCustomTarget && (
<Alert variant="warning">
<AlertTriangle className="size-4" />
<AlertTitle>Source paths do not match</AlertTitle>
<AlertDescription>
This snapshot was created from source paths that do not match this Zerobyte server or the current linked
volume. Restoring to the original location is unavailable. Restore it to a custom location, or download
it instead.
</AlertDescription>
</Alert>
)}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Restore Location</CardTitle> <CardTitle>Restore Location</CardTitle>
@ -291,7 +184,6 @@ export function RestoreForm({
size="sm" size="sm"
className="flex justify-start gap-2" className="flex justify-start gap-2"
onClick={() => setRestoreLocation("original")} onClick={() => setRestoreLocation("original")}
disabled={!!restoreRequiresCustomTarget}
> >
<RotateCcw size={16} className="mr-1" /> <RotateCcw size={16} className="mr-1" />
Original location Original location
@ -369,6 +261,16 @@ export function RestoreForm({
Exclude specific extended attributes during restore (comma-separated) Exclude specific extended attributes during restore (comma-separated)
</p> </p>
</div> </div>
<div className="flex items-center space-x-2">
<Checkbox
id="delete-extra"
checked={deleteExtraFiles}
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
/>
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
Delete files not present in the snapshot
</Label>
</div>
</CardContent> </CardContent>
)} )}
</Card> </Card>
@ -383,44 +285,37 @@ export function RestoreForm({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col p-0"> <CardContent className="flex-1 overflow-hidden flex flex-col p-0">
<SnapshotTreeBrowser {fileBrowser.isLoading && (
repositoryId={repository.shortId} <div className="flex items-center justify-center flex-1">
snapshotId={snapshotId} <p className="text-muted-foreground">Loading files...</p>
queryBasePath={snapshotBasePath} </div>
displayBasePath={displayBasePath} )}
pageSize={500}
className="flex flex-1 min-h-0 flex-col" {fileBrowser.isEmpty && (
treeContainerClassName="overflow-auto flex-1 min-h-0 border border-border rounded-md bg-card m-4" <div className="flex flex-col items-center justify-center flex-1 text-center p-8">
treeClassName="px-2 py-2" <FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
loadingMessage="Loading files..." <p className="text-muted-foreground">No files in this snapshot</p>
emptyMessage="No files in this snapshot" </div>
withCheckboxes )}
selectedPaths={selectedPaths}
onSelectionChange={setSelectedPaths} {!fileBrowser.isLoading && !fileBrowser.isEmpty && (
onSingleSelectionKindChange={setSelectedPathKind} <div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
stateClassName="flex-1 min-h-0" <FileTree
/> files={fileBrowser.fileArray}
onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={fileBrowser.handleFolderHover}
expandedFolders={fileBrowser.expandedFolders}
loadingFolders={fileBrowser.loadingFolders}
className="px-2 py-2"
withCheckboxes={true}
selectedPaths={selectedPaths}
onSelectionChange={setSelectedPaths}
/>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<AlertDialog open={showRestoreResultAlert} onOpenChange={handleResultAlertOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{restoreResult?.status === "success" ? "Restore completed" : "Restore failed"}
</AlertDialogTitle>
<AlertDialogDescription>
{restoreResult?.status === "success"
? `Snapshot ${snapshotId} was restored successfully.`
: restoreResult?.error || `Snapshot ${snapshotId} could not be restored.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={acknowledgeRestoreResult}>OK</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }

View file

@ -1,101 +0,0 @@
import { useEffect, useState } from "react";
import { ByteSize } from "~/client/components/bytes-size";
import { useFormatBytes } from "~/client/hooks/use-format-bytes";
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
import { Card } from "~/client/components/ui/card";
import { Progress } from "~/client/components/ui/progress";
import { type RestoreProgressEvent, useServerEvents } from "~/client/hooks/use-server-events";
import { formatDuration } from "~/utils/utils";
type Props = {
repositoryId: string;
snapshotId: string;
};
export const RestoreProgress = ({ repositoryId, snapshotId }: Props) => {
const formatBytes = useFormatBytes();
const { locale } = useRootLoaderData();
const { addEventListener } = useServerEvents();
const [progress, setProgress] = useState<RestoreProgressEvent | null>(null);
useEffect(() => {
const abortController = new AbortController();
addEventListener(
"restore:progress",
(progressData) => {
if (progressData.repositoryId === repositoryId && progressData.snapshotId === snapshotId) {
setProgress(progressData);
}
},
{ signal: abortController.signal },
);
addEventListener(
"restore:completed",
(completedData) => {
if (completedData.repositoryId === repositoryId && completedData.snapshotId === snapshotId) {
setProgress(null);
}
},
{ signal: abortController.signal },
);
return () => abortController.abort();
}, [addEventListener, repositoryId, snapshotId]);
if (!progress) {
return (
<Card className="p-4">
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="font-medium">Restore in progress</span>
</div>
</Card>
);
}
const percentDone = Math.round(progress.percent_done * 100);
const speed = progress.seconds_elapsed > 0 ? formatBytes(progress.bytes_restored / progress.seconds_elapsed) : null;
return (
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="font-medium">Restore in progress</span>
</div>
<span className="text-sm font-medium text-primary">{percentDone}%</span>
</div>
<Progress value={percentDone} className="h-2 mb-4" />
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-xs uppercase text-muted-foreground">Files</p>
<p className="font-medium">
{progress.files_restored.toLocaleString(locale)}
&nbsp;/&nbsp;
{progress.total_files.toLocaleString(locale)}
</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Data</p>
<p className="font-medium">
<ByteSize bytes={progress.bytes_restored} base={1024} />
&nbsp;/&nbsp;
<ByteSize bytes={progress.total_bytes} base={1024} />
</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Elapsed</p>
<p className="font-medium">{formatDuration(progress.seconds_elapsed)}</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Speed</p>
<p className="font-medium">{speed ? `${speed.text} ${speed.unit}/s` : "Calculating..."}</p>
</div>
</div>
</Card>
);
};

View file

@ -1,89 +0,0 @@
import { useState } from "react";
import { cn } from "~/client/lib/utils";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
interface RetentionCategoryBadgesProps {
categories: string[];
className?: string;
}
const categoryColors: Record<string, string> = {
last: "bg-blue-500/20 text-blue-700 dark:text-blue-300 border-blue-500/30",
hourly: "bg-cyan-500/20 text-cyan-700 dark:text-cyan-300 border-cyan-500/30",
daily: "bg-green-500/20 text-green-700 dark:text-green-300 border-green-500/30",
weekly: "bg-orange-500/20 text-orange-700 dark:text-orange-300 border-orange-500/30",
monthly: "bg-purple-500/20 text-purple-700 dark:text-purple-300 border-purple-500/30",
yearly: "bg-red-500/20 text-red-700 dark:text-red-300 border-red-500/30",
};
const categoryLabels: Record<string, string> = {
last: "Last",
hourly: "Hourly",
daily: "Daily",
weekly: "Weekly",
monthly: "Monthly",
yearly: "Yearly",
};
function Badge({ category }: { category: string }) {
return (
<span
className={cn(
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border",
categoryColors[category] || "bg-gray-500/20 text-gray-700 dark:text-gray-300 border-gray-500/30",
)}
>
{categoryLabels[category] || category}
</span>
);
}
export function RetentionCategoryBadges({ categories, className }: RetentionCategoryBadgesProps) {
const [open, setOpen] = useState(false);
if (categories.length === 0) {
return null;
}
const order = ["last", "hourly", "daily", "weekly", "monthly", "yearly"];
const sortedCategories = [...categories].sort((a, b) => {
const indexA = order.indexOf(a);
const indexB = order.indexOf(b);
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
const firstCategory = sortedCategories[0];
const hasMore = sortedCategories.length > 1;
if (!hasMore) {
return (
<div className={className}>
<Badge category={firstCategory} />
</div>
);
}
return (
<HoverCard open={open} onOpenChange={setOpen}>
<HoverCardTrigger asChild>
<button
type="button"
className={cn("cursor-pointer bg-transparent p-0 border-0", className)}
aria-label={`View ${categories.length} retention categories`}
onClick={() => setOpen(true)}
>
<Badge category={`${categories.length} tags`} />
</button>
</HoverCardTrigger>
<HoverCardContent className="w-auto p-2">
<div className="flex flex-wrap gap-1">
{sortedCategories.map((category) => (
<Badge key={category} category={category} />
))}
</div>
</HoverCardContent>
</HoverCard>
);
}

View file

@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Calendar, Clock, Database, HardDrive, Tag, Trash2, X } from "lucide-react"; import { Calendar, Clock, Database, HardDrive, Tag, Trash2, X } from "lucide-react";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { ByteSize } from "~/client/components/bytes-size"; import { ByteSize } from "~/client/components/bytes-size";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
@ -25,31 +26,22 @@ import {
DialogTitle, DialogTitle,
} from "~/client/components/ui/dialog"; } from "~/client/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { useTimeFormat } from "~/client/lib/datetime";
import { formatDuration } from "~/utils/utils"; import { formatDuration } from "~/utils/utils";
import { import { formatDateTime } from "~/client/lib/datetime";
deleteSnapshotsMutation, import { deleteSnapshotsMutation, tagSnapshotsMutation } from "~/client/api-client/@tanstack/react-query.gen";
listSnapshotsQueryKey,
tagSnapshotsMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
import type { BackupSchedule, Snapshot } from "../lib/types"; import type { BackupSchedule, Snapshot } from "../lib/types";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { Link, useNavigate } from "@tanstack/react-router";
import type { ListSnapshotsData } from "~/client/api-client/types.gen";
import type { Options } from "~/client/api-client/client/types.gen";
type Props = { type Props = {
snapshots: Snapshot[]; snapshots: Snapshot[];
backups: BackupSchedule[]; backups: BackupSchedule[];
repositoryId: string; repositoryId: string;
listSnapshotsQueryOptions: Options<ListSnapshotsData>;
}; };
export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshotsQueryOptions }: Props) => { export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const { formatDateTime } = useTimeFormat(); const queryClient = useQueryClient();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false); const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
@ -58,16 +50,8 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots
const deleteSnapshots = useMutation({ const deleteSnapshots = useMutation({
...deleteSnapshotsMutation(), ...deleteSnapshotsMutation(),
onSuccess: (_data, variables) => { onSuccess: () => {
const snapshotIds = variables.body?.snapshotIds ?? []; void queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
const queryKey = listSnapshotsQueryKey(listSnapshotsQueryOptions);
queryClient.setQueryData<Snapshot[]>(queryKey, (old) => {
if (!old) return old;
return old.filter((snapshot) => !snapshotIds.includes(snapshot.short_id));
});
void queryClient.invalidateQueries({ queryKey });
setShowBulkDeleteConfirm(false); setShowBulkDeleteConfirm(false);
setSelectedIds(new Set()); setSelectedIds(new Set());
}, },
@ -79,6 +63,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots
setShowReTagDialog(false); setShowReTagDialog(false);
}, },
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
setShowReTagDialog(false); setShowReTagDialog(false);
setSelectedIds(new Set()); setSelectedIds(new Set());
setTargetScheduleId(""); setTargetScheduleId("");
@ -86,7 +71,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots
}); });
const handleRowClick = (snapshotId: string) => { const handleRowClick = (snapshotId: string) => {
void navigate({ to: `/repositories/${repositoryId}/${snapshotId}` }); void navigate(`/repositories/${repositoryId}/${snapshotId}`);
}; };
const toggleSelectAll = () => { const toggleSelectAll = () => {
@ -100,7 +85,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots
const handleBulkDelete = () => { const handleBulkDelete = () => {
toast.promise( toast.promise(
deleteSnapshots.mutateAsync({ deleteSnapshots.mutateAsync({
path: { shortId: repositoryId }, path: { id: repositoryId },
body: { snapshotIds: Array.from(selectedIds) }, body: { snapshotIds: Array.from(selectedIds) },
}), }),
{ {
@ -112,12 +97,12 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots
}; };
const handleBulkReTag = () => { const handleBulkReTag = () => {
const schedule = backups.find((b) => b.shortId === targetScheduleId); const schedule = backups.find((b) => String(b.id) === targetScheduleId);
if (!schedule) return; if (!schedule) return;
toast.promise( toast.promise(
tagSnapshots.mutateAsync({ tagSnapshots.mutateAsync({
path: { shortId: repositoryId }, path: { id: repositoryId },
body: { body: {
snapshotIds: Array.from(selectedIds), snapshotIds: Array.from(selectedIds),
set: [schedule.shortId], set: [schedule.shortId],
@ -187,8 +172,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
hidden={!backup} hidden={!backup}
to={backup ? `/backups/$backupId` : "."} to={backup ? `/backups/${backup.id}` : "#"}
params={backup ? { backupId: backup.shortId } : {}}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="hover:underline" className="hover:underline"
> >
@ -302,7 +286,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{backups.map((backup) => ( {backups.map((backup) => (
<SelectItem key={backup.shortId} value={backup.shortId}> <SelectItem key={backup.id} value={String(backup.id)}>
{backup.name} {backup.name}
</SelectItem> </SelectItem>
))} ))}

View file

@ -1,12 +1,11 @@
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { ClientOnly } from "@tanstack/react-router";
import { GripVertical } from "lucide-react"; import { GripVertical } from "lucide-react";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
interface SortableBackupCardProps { interface SortableBackupCardProps {
isDragging?: boolean; isDragging?: boolean;
uniqueId: string | number; uniqueId: number;
} }
export function SortableCard({ isDragging, uniqueId, children }: PropsWithChildren<SortableBackupCardProps>) { export function SortableCard({ isDragging, uniqueId, children }: PropsWithChildren<SortableBackupCardProps>) {
@ -22,15 +21,13 @@ export function SortableCard({ isDragging, uniqueId, children }: PropsWithChildr
return ( return (
<div ref={setNodeRef} style={style} className="relative group"> <div ref={setNodeRef} style={style} className="relative group">
<ClientOnly> <div
<div {...attributes}
{...attributes} {...listeners}
{...listeners} className="absolute left-1/2 -translate-x-1/2 top-1 z-10 cursor-grab active:cursor-grabbing opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-muted/50 bg-background/80 backdrop-blur-sm"
className="absolute left-1/2 -translate-x-1/2 top-1 z-10 cursor-grab active:cursor-grabbing opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-muted/50 bg-background/80 backdrop-blur-sm" >
> <GripVertical className="h-4 w-4 text-muted-foreground rotate-90" />
<GripVertical className="h-4 w-4 text-muted-foreground rotate-90" /> </div>
</div>
</ClientOnly>
{children} {children}
</div> </div>
); );

View file

@ -12,8 +12,8 @@ interface StatusDotProps {
export const StatusDot = ({ variant, label, animated }: StatusDotProps) => { export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
const statusMapping = { const statusMapping = {
success: { success: {
color: "bg-success shadow-[0_0_8px_rgba(16,185,129,0.5)]", color: "bg-green-500",
colorLight: "bg-success/60", colorLight: "bg-emerald-400",
animated: animated ?? true, animated: animated ?? true,
}, },
neutral: { neutral: {
@ -40,12 +40,12 @@ export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild aria-label={label}> <TooltipTrigger>
<span className="relative flex size-3 mx-auto"> <span className="relative flex size-3 mx-auto">
{statusMapping?.animated && ( {statusMapping?.animated && (
<span <span
className={cn( className={cn(
"absolute inline-flex h-full w-full animate-[ping_3s_cubic-bezier(0,0,0.2,1)_infinite] rounded-full opacity-50", "absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
`${statusMapping.colorLight}`, `${statusMapping.colorLight}`,
)} )}
/> />
@ -54,7 +54,7 @@ export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p className="capitalize">{label}</p> <p>{label}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );

View file

@ -1,35 +0,0 @@
import { createContext, useCallback, useContext, useState } from "react";
type Theme = "light" | "dark";
type ThemeContextValue = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
export const THEME_COOKIE_NAME = "theme";
const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
const DEFAULT_THEME: Theme = "dark";
export function ThemeProvider({ children, initialTheme }: { children: React.ReactNode; initialTheme?: Theme }) {
const [theme, setThemeState] = useState<Theme>(initialTheme ?? DEFAULT_THEME);
const setTheme = useCallback((newTheme: Theme) => {
setThemeState(newTheme);
document.cookie = `${THEME_COOKIE_NAME}=${newTheme}; path=/; max-age=${THEME_COOKIE_MAX_AGE}`;
document.documentElement.classList.toggle("dark", newTheme === "dark");
document.documentElement.style.colorScheme = newTheme;
}, []);
return <ThemeContext value={{ theme, setTheme }}>{children}</ThemeContext>;
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}

View file

@ -1,26 +0,0 @@
import { Moon, Sun } from "lucide-react";
import { useTheme } from "./theme-provider";
import { Button } from "./ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative rounded-full h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
aria-label="Toggle theme"
>
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</Button>
</TooltipTrigger>
<TooltipContent>Toggle theme</TooltipContent>
</Tooltip>
);
}

View file

@ -1,12 +0,0 @@
import { useTimeFormat, type DateInput } from "~/client/lib/datetime";
type Props = {
date: DateInput;
className?: string;
};
export function TimeAgo({ date, className }: Props) {
const { formatTimeAgo } = useTimeFormat();
return <span className={className}>{formatTimeAgo(date)}</span>;
}

View file

@ -1,4 +1,3 @@
// fallow-ignore-file unused-export
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import type * as React from "react"; import type * as React from "react";
import { buttonVariants } from "~/client/components/ui/button"; import { buttonVariants } from "~/client/components/ui/button";

View file

@ -1,4 +1,3 @@
// fallow-ignore-file unused-export
import type * as React from "react"; import type * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";

View file

@ -1,4 +1,3 @@
// fallow-ignore-file unused-export
import type * as React from "react"; import type * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react"; import { ChevronRight, MoreHorizontal } from "lucide-react";
@ -13,16 +12,17 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return ( return (
<ol <ol
data-slot="breadcrumb-list" data-slot="breadcrumb-list"
className={cn("text-muted-foreground flex items-center gap-1.5 text-sm sm:gap-2.5 min-w-0", className)} className={cn(
"text-muted-foreground flex items-center gap-1.5 text-sm sm:gap-2.5 min-w-0",
className,
)}
{...props} {...props}
/> />
); );
} }
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) { function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return ( return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5 min-w-0", className)} {...props} />;
<li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5 min-w-0", className)} {...props} />
);
} }
function BreadcrumbLink({ function BreadcrumbLink({

View file

@ -1,22 +1,21 @@
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import * as React from "react"; import type * as React from "react";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { useMinimumDuration } from "~/client/hooks/useMinimumDuration";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex cursor-pointer uppercase rounded-sm items-center justify-center gap-2 whitespace-nowrap text-xs font-semibold tracking-wide transition-all active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring border-0", "inline-flex cursor-pointer uppercase rounded-sm items-center justify-center gap-2 whitespace-nowrap text-xs font-semibold tracking-wide transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring border-0",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-transparent text-foreground hover:bg-muted border hover:border-border", default: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80",
primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50", primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50",
destructive: destructive:
"border border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground focus-visible:ring-destructive/50", "border border-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/50 text-destructive hover:text-white",
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground", outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-transparent text-foreground hover:bg-muted border", secondary: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
@ -34,32 +33,28 @@ const buttonVariants = cva(
}, },
); );
const MINIMUM_LOADING_DURATION = 300;
function Button({ function Button({
className, className,
variant, variant,
size, size,
asChild = false, asChild = false,
loading, loading,
disabled,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean; asChild?: boolean;
} & { loading?: boolean }) { } & { loading?: boolean }) {
const Comp = asChild ? Slot : "button"; const Comp = asChild ? Slot : "button";
const isLoading = useMinimumDuration(loading ?? false, MINIMUM_LOADING_DURATION);
return ( return (
<Comp <Comp
{...props} disabled={loading}
data-slot="button" data-slot="button"
className={cn(buttonVariants({ variant, size, className }), "transition-all")} className={cn(buttonVariants({ variant, size, className }), "transition-all")}
disabled={disabled || loading || isLoading} {...props}
> >
<Loader2 className={cn("h-4 w-4 animate-spin absolute", { invisible: !isLoading })} /> <Loader2 className={cn("h-4 w-4 animate-spin absolute", { invisible: !loading })} />
<div className={cn("flex items-center justify-center", { invisible: isLoading })}>{props.children}</div> <div className={cn("flex items-center justify-center", { invisible: loading })}>{props.children}</div>
</Comp> </Comp>
); );
} }

View file

@ -1,35 +1,23 @@
// fallow-ignore-file unused-export
import type * as React from "react"; import type * as React from "react";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
function Card({ className, children, interactive, ...props }: React.ComponentProps<"div"> & { interactive?: boolean }) { function Card({ className, children, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn("bg-card text-card-foreground relative flex flex-col gap-6 border-2 py-6 shadow-sm", className)}
"bg-card text-card-foreground group relative flex flex-col gap-6 border border-border py-6 shadow-[0_8px_30px_-15px_rgba(0,0,0,0.04)] dark:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.02)] transition-colors duration-300 ",
className,
)}
{...props} {...props}
> >
<span <span aria-hidden="true" className="pointer-events-none absolute inset-0 z-10 select-none">
aria-hidden="true" <span className="absolute -left-0.5 -top-0.5 h-0.5 w-4 bg-white/80" />
className={cn( <span className="absolute -left-0.5 -top-0.5 h-4 w-0.5 bg-white/80" />
"pointer-events-none absolute inset-0 z-10 select-none opacity-30 transition-opacity duration-300 ", <span className="absolute -right-0.5 -top-0.5 h-0.5 w-4 bg-white/80" />
{ <span className="absolute -right-0.5 -top-0.5 h-4 w-0.5 bg-white/80" />
"group-hover:opacity-100": interactive, <span className="absolute -left-0.5 -bottom-0.5 h-0.5 w-4 bg-white/80" />
}, <span className="absolute -left-0.5 -bottom-0.5 h-4 w-0.5 bg-white/80" />
)} <span className="absolute -right-0.5 -bottom-0.5 h-0.5 w-4 bg-white/80" />
> <span className="absolute -right-0.5 -bottom-0.5 h-4 w-0.5 bg-white/80" />
<span className="absolute -left-0.5 -top-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -left-0.5 -top-0.5 h-4 w-0.5 bg-foreground" />
<span className="absolute -right-0.5 -top-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -right-0.5 -top-0.5 h-4 w-0.5 bg-foreground" />
<span className="absolute -left-0.5 -bottom-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -left-0.5 -bottom-0.5 h-4 w-0.5 bg-foreground" />
<span className="absolute -right-0.5 -bottom-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -right-0.5 -bottom-0.5 h-4 w-0.5 bg-foreground" />
</span> </span>
{children} {children}
</div> </div>

View file

@ -1,16 +1,13 @@
// fallow-ignore-file unused-export
// @ts-nocheck // @ts-nocheck
// biome-ignore-all lint: reason // biome-ignore-all lint: reason
import * as React from "react"; import * as React from "react";
import * as RechartsPrimitive from "recharts"; import * as RechartsPrimitive from "recharts";
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR } // Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const; const THEMES = { light: "", dark: ".dark" } as const;
// fallow-ignore-next-line unused-type
export type ChartConfig = { export type ChartConfig = {
[k in string]: { [k in string]: {
label?: React.ReactNode; label?: React.ReactNode;
@ -119,7 +116,6 @@ function ChartTooltipContent({
labelKey?: string; labelKey?: string;
}) { }) {
const { config } = useChart(); const { config } = useChart();
const { locale } = useRootLoaderData();
const tooltipLabel = React.useMemo(() => { const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) { if (hideLabel || !payload?.length) {
@ -209,7 +205,7 @@ function ChartTooltipContent({
</div> </div>
{item.value && ( {item.value && (
<span className="text-foreground font-mono font-medium tabular-nums"> <span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString(locale)} {item.value.toLocaleString()}
</span> </span>
)} )}
</div> </div>

View file

@ -1,13 +1,10 @@
import * as React from "react"; import type * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
import { useWebHaptics } from "web-haptics/react";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) { function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
const trigger = useWebHaptics().trigger;
return ( return (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
@ -15,10 +12,6 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
onCheckedChange={(checked) => {
void trigger([{ duration: 8 }], { intensity: 0.3 });
props.onCheckedChange?.(checked);
}}
{...props} {...props}
> >
<CheckboxPrimitive.Indicator <CheckboxPrimitive.Indicator

View file

@ -8,8 +8,8 @@ interface CodeBlockProps {
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => { export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
return ( return (
<div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-border"> <div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-white/10">
<div className="flex items-center justify-between border-b border-border px-4 py-2 text-xs"> <div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-xs">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-rose-500" /> <span className="h-2.5 w-2.5 rounded-full bg-rose-500" />
<span className="h-2.5 w-2.5 rounded-full bg-amber-500" /> <span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
@ -18,7 +18,7 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
</div> </div>
</div> </div>
<pre className="text-xs m-0 px-4 py-2 bg-card-header"> <pre className="text-xs m-0 px-4 py-2 bg-card-header">
<code className="text-foreground/80 select-all">{code}</code> <code className="text-white/80 select-all">{code}</code>
</pre> </pre>
</div> </div>
); );

View file

@ -1,4 +1,3 @@
// fallow-ignore-file unused-export
import type * as React from "react"; import type * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";

View file

@ -1,218 +0,0 @@
// fallow-ignore-file unused-export
import * as React from "react";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "~/client/lib/utils";
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
{...props}
/>
);
}
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View file

@ -1,4 +1,3 @@
// fallow-ignore-file unused-export
import * as React from "react"; import * as React from "react";
import type * as LabelPrimitive from "@radix-ui/react-label"; import type * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";

View file

@ -1,5 +1,6 @@
import * as React from "react"; import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp"; import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
@ -54,8 +55,12 @@ function InputOTPSlot({
); );
} }
function InputOTPSeparator({ ...props }: React.ComponentProps<"hr">) { function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return <hr data-slot="input-otp-separator" {...props} />; return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
} }
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View file

@ -9,7 +9,6 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 border bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 border bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
// "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-black/5 dark:bg-white/5 flex h-9 w-full min-w-0 border-0 px-3 py-1 text-sm shadow-xs dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className, className,

View file

@ -1,4 +1,3 @@
// fallow-ignore-file unused-export
import type * as React from "react"; import type * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";

View file

@ -1,4 +1,3 @@
// fallow-ignore-file unused-export
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
import type * as React from "react"; import type * as React from "react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
@ -15,7 +14,7 @@ export const isStoredSecretValue = (value?: string): boolean => {
return value.startsWith("env://") || value.startsWith("file://") || value.startsWith("encv1:"); return value.startsWith("env://") || value.startsWith("file://") || value.startsWith("encv1:");
}; };
type SecretInputProps = Omit<React.ComponentProps<typeof Input>, "type">; type SecretInputProps = Omit<React.ComponentProps<typeof Input>, "type">
export const SecretInput = ({ className, value, ...props }: SecretInputProps) => { export const SecretInput = ({ className, value, ...props }: SecretInputProps) => {
const [revealed, setRevealed] = useState(false); const [revealed, setRevealed] = useState(false);
@ -37,7 +36,12 @@ export const SecretInput = ({ className, value, ...props }: SecretInputProps) =>
return ( return (
<div className="relative" data-secret-input> <div className="relative" data-secret-input>
<Input {...props} value={value} type={type} className={cn(!showAsPlaintext && "pr-10", className)} /> <Input
{...props}
value={value}
type={type}
className={cn(!showAsPlaintext && "pr-10", className)}
/>
{!showAsPlaintext && ( {!showAsPlaintext && (
<Button <Button
type="button" type="button"

View file

@ -1,73 +1,19 @@
// fallow-ignore-file unused-export import type * as React from "react";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select"; import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
const SelectSsrValueContext = React.createContext<{ function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
hydrated: boolean; return <SelectPrimitive.Root data-slot="select" {...props} />;
items: Map<string, React.ReactNode>;
value: string | undefined;
} | null>(null);
function collectSelectItems(children: React.ReactNode, items = new Map<string, React.ReactNode>()) {
for (const child of React.Children.toArray(children)) {
if (!React.isValidElement<{ children?: React.ReactNode; value?: string }>(child)) {
continue;
}
if ((child.type === SelectItem || child.type === SelectPrimitive.Item) && typeof child.props.value === "string") {
items.set(child.props.value, child.props.children);
}
if (child.props.children) {
collectSelectItems(child.props.children, items);
}
}
return items;
}
function Select({ children, value, ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
const [hydrated, setHydrated] = React.useState(false);
const items = collectSelectItems(children);
React.useEffect(() => {
setHydrated(true);
}, []);
return (
<SelectSsrValueContext.Provider value={{ hydrated, items, value }}>
<SelectPrimitive.Root data-slot="select" value={value} {...props}>
{children}
</SelectPrimitive.Root>
</SelectSsrValueContext.Provider>
);
} }
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) { function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />; return <SelectPrimitive.Group data-slot="select-group" {...props} />;
} }
function SelectValue({ children, placeholder, style, ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) { function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
const context = React.useContext(SelectSsrValueContext); return <SelectPrimitive.Value data-slot="select-value" {...props} />;
const selectedItem =
context?.value !== undefined && context.value !== "" ? context.items.get(context.value) : undefined;
if (!context?.hydrated) {
return (
<span data-slot="select-value" {...props} style={{ pointerEvents: "none", ...style }}>
{children ?? selectedItem ?? placeholder}
</span>
);
}
return (
<SelectPrimitive.Value data-slot="select-value" placeholder={placeholder} style={style} {...props}>
{children}
</SelectPrimitive.Value>
);
} }
function SelectTrigger({ function SelectTrigger({
@ -75,35 +21,19 @@ function SelectTrigger({
size = "default", size = "default",
children, children,
...props ...props
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"; size?: "sm" | "default";
}) { }) {
const context = React.useContext(SelectSsrValueContext);
const hasValue = context?.value !== undefined && context.value !== "";
const triggerClassName = cn(
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&+select[aria-hidden=true]]:hidden [&:has(+select[aria-hidden=true]:last-child)]:mb-0",
className,
);
if (!context?.hydrated) {
return (
<div
id={props.id}
aria-disabled="true"
aria-invalid={props["aria-invalid"]}
data-placeholder={hasValue ? undefined : ""}
data-size={size}
data-slot="select-trigger"
className={triggerClassName}
>
{children}
<ChevronDownIcon className="size-4 opacity-50" />
</div>
);
}
return ( return (
<SelectPrimitive.Trigger data-slot="select-trigger" data-size={size} className={triggerClassName} {...props}> <SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children} {children}
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" /> <ChevronDownIcon className="size-4 opacity-50" />

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