Compare commits
1 commit
main
...
02-19-refa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebefe410f4 |
723 changed files with 18364 additions and 98066 deletions
|
|
@ -1,19 +1,21 @@
|
|||
**
|
||||
|
||||
!turbo.json
|
||||
!bun.lock
|
||||
!package.json
|
||||
!server.ts
|
||||
!.gitignore
|
||||
!bunfig.toml
|
||||
|
||||
!**/package.json
|
||||
!**/bun.lock
|
||||
!**/tsconfig.json
|
||||
!**/vite.config.ts
|
||||
!**/react-router.config.ts
|
||||
!**/build.ts
|
||||
!**/components.json
|
||||
|
||||
!src/**
|
||||
!app/**
|
||||
!apps/agent/**
|
||||
!packages/**
|
||||
!public/**
|
||||
|
||||
# License files and attributions
|
||||
|
|
@ -24,4 +26,3 @@
|
|||
node_modules/**
|
||||
dist/**
|
||||
.output/**
|
||||
app/test/integration/artifacts/**
|
||||
|
|
|
|||
|
|
@ -4,6 +4,5 @@ RESTIC_CACHE_DIR=./data/restic/cache
|
|||
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
|
||||
ZEROBYTE_VOLUMES_DIR=./data/volumes
|
||||
APP_SECRET=<openssl rand -hex 32>
|
||||
BASE_URL=http://*.localhost
|
||||
TRUST_PROXY=false
|
||||
BASE_URL=http://localhost:300
|
||||
PORT=3000
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
ZEROBYTE_DATABASE_URL=:memory:
|
||||
APP_SECRET=8b9acd4456dd5db0a4a3c4f4e1240b2c3ae08bb59690167197425e4a25dd9a69
|
||||
BASE_URL=http://localhost:4096
|
||||
ENABLE_LOCAL_AGENT=true
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
22
.github/actions/install-dependencies/action.yml
vendored
22
.github/actions/install-dependencies/action.yml
vendored
|
|
@ -1,31 +1,15 @@
|
|||
name: Install dependencies
|
||||
|
||||
description: Install dependencies
|
||||
inputs:
|
||||
workingDirectory:
|
||||
description: "The working directory to install dependencies in"
|
||||
required: false
|
||||
default: "."
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: socketdev/action@937f824ec476dfd164d4a4d9995751427b0be143 # v1
|
||||
with:
|
||||
mode: firewall
|
||||
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
name: Install Bun
|
||||
with:
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: true
|
||||
bun-version: "1.3.6"
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
cd ${{ inputs.workingDirectory }}
|
||||
sfw vp install --frozen-lockfile
|
||||
run: bun install --frozen-lockfile
|
||||
|
|
|
|||
18
.github/dependabot.yml
vendored
Normal file
18
.github/dependabot.yml
vendored
Normal 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
19
.github/renovate.json
vendored
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
75
.github/workflows/checks.yml
vendored
75
.github/workflows/checks.yml
vendored
|
|
@ -3,6 +3,8 @@ name: Checks
|
|||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
|
@ -10,86 +12,31 @@ on:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: checks-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
timeout-minutes: 10
|
||||
checks:
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
uses: "./.github/actions/install-dependencies"
|
||||
|
||||
- name: Run lint
|
||||
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
|
||||
- uses: oxc-project/oxlint-action@latest
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install dependencies
|
||||
uses: "./.github/actions/install-dependencies"
|
||||
config: .oxlintrc.json
|
||||
deny-warnings: true
|
||||
|
||||
- name: Run type checks
|
||||
shell: bash
|
||||
run: bun run tsc
|
||||
|
||||
test-server:
|
||||
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
|
||||
- name: Run tests
|
||||
shell: bash
|
||||
run: bun run test:server
|
||||
|
||||
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"
|
||||
run: bun run test --ci --coverage
|
||||
|
||||
- name: Build project
|
||||
shell: bash
|
||||
|
|
|
|||
41
.github/workflows/docs-deploy.yml
vendored
41
.github/workflows/docs-deploy.yml
vendored
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
28
.github/workflows/e2e.yml
vendored
28
.github/workflows/e2e.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install dependencies
|
||||
uses: "./.github/actions/install-dependencies"
|
||||
|
|
@ -26,7 +26,7 @@ jobs:
|
|||
|
||||
- name: Cache Playwright Browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
|
||||
|
|
@ -46,37 +46,22 @@ jobs:
|
|||
mkdir -p data
|
||||
mkdir -p playwright/temp
|
||||
mkdir -p playwright/data
|
||||
mkdir -p playwright/tinyauth/app-data
|
||||
mkdir -p playwright/tinyauth/caddy-data
|
||||
chmod -R 777 playwright/temp
|
||||
touch .env.local
|
||||
echo "SERVER_IP=localhost" >> .env.local
|
||||
echo "ZEROBYTE_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
|
||||
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
|
||||
run: |
|
||||
timeout 30s bash -c 'until curl -f http://localhost:4096/api/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
|
||||
run: docker logs zerobyte || true
|
||||
|
||||
- name: Make playwright directory writable
|
||||
run: sudo chmod 777 playwright/data/zerobyte.db
|
||||
|
|
@ -87,16 +72,15 @@ jobs:
|
|||
- name: Dump container logs in playwright-report folder
|
||||
if: always()
|
||||
run: |
|
||||
tree playwright || true
|
||||
docker compose logs zerobyte-e2e > playwright-report/container-logs.txt || true
|
||||
docker compose logs tinyauth tinyauth-app > playwright-report/tinyauth-logs.txt || true
|
||||
tree playwright
|
||||
docker logs zerobyte > playwright-report/container-logs.txt || true
|
||||
|
||||
- name: Debug - print content of /test-data in container
|
||||
if: failure()
|
||||
run: |
|
||||
docker exec zerobyte /bin/ash -c "ls -la /test-data" || true
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
|
|
|
|||
35
.github/workflows/integration.yml
vendored
35
.github/workflows/integration.yml
vendored
|
|
@ -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
|
||||
17
.github/workflows/nightly.yml
vendored
17
.github/workflows/nightly.yml
vendored
|
|
@ -6,34 +6,31 @@ on:
|
|||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
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
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: cloud
|
||||
endpoint: "meienberger/runtipi-builder"
|
||||
install: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
|
@ -41,14 +38,14 @@ jobs:
|
|||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
|
||||
uses: docker/metadata-action@v5
|
||||
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
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: production
|
||||
|
|
|
|||
77
.github/workflows/release.yml
vendored
77
.github/workflows/release.yml
vendored
|
|
@ -8,16 +8,16 @@ on:
|
|||
- "v*.*.*-alpha.*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
determine-release-type:
|
||||
runs-on: ubuntu-latest
|
||||
permissions: {}
|
||||
outputs:
|
||||
release_type: ${{ steps.set-type.outputs.release_type }}
|
||||
tagname: ${{ github.ref_name }}
|
||||
is_minor_release: ${{ steps.set-type.outputs.is_minor_release }}
|
||||
steps:
|
||||
- name: Set release type
|
||||
id: set-type
|
||||
|
|
@ -31,60 +31,74 @@ jobs:
|
|||
echo "release_type=release" >> $GITHUB_OUTPUT
|
||||
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:
|
||||
uses: ./.github/workflows/checks.yml
|
||||
|
||||
e2e-tests:
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
|
||||
integration-tests:
|
||||
uses: ./.github/workflows/integration.yml
|
||||
|
||||
build-images:
|
||||
environment: release
|
||||
timeout-minutes: 15
|
||||
needs: [determine-release-type, checks, e2e-tests, integration-tests]
|
||||
needs: [determine-release-type, checks, e2e-tests]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: cloud
|
||||
endpoint: "meienberger/runtipi-builder"
|
||||
install: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
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: false
|
||||
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
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/zerobyte
|
||||
tags: |
|
||||
|
|
@ -96,7 +110,7 @@ jobs:
|
|||
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
|
||||
|
||||
- name: Push images to GitHub Container Registry
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: production
|
||||
|
|
@ -111,14 +125,12 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
needs: [build-images, determine-release-type]
|
||||
if: needs.determine-release-type.outputs.release_type == 'release'
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
id: ${{ steps.create_release.outputs.id }}
|
||||
steps:
|
||||
- name: Create GitHub release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
|
||||
uses: softprops/action-gh-release@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
|
@ -128,13 +140,4 @@ jobs:
|
|||
name: ${{ needs.determine-release-type.outputs.tagname }}
|
||||
draft: false
|
||||
prerelease: true
|
||||
|
||||
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 }}
|
||||
files: cli/runtipi-cli-*
|
||||
|
|
|
|||
122
.github/workflows/request-docs-version-update.yml
vendored
122
.github/workflows/request-docs-version-update.yml
vendored
|
|
@ -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 }}
|
||||
41
.github/workflows/scorecard.yml
vendored
41
.github/workflows/scorecard.yml
vendored
|
|
@ -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
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
|
|
@ -15,7 +15,7 @@ notes.md
|
|||
smb-password.txt
|
||||
cache.db
|
||||
|
||||
/data
|
||||
data/
|
||||
|
||||
.env*
|
||||
!.env.example
|
||||
|
|
@ -28,25 +28,13 @@ node_modules/
|
|||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
/playwright/*.pass
|
||||
/playwright/tinyauth/app-data/
|
||||
/playwright/tinyauth/caddy-data/
|
||||
|
||||
playwright/.auth
|
||||
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
6
.oxfmtrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 120,
|
||||
"useTabs": true,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
152
.oxlintrc.json
Normal file
152
.oxlintrc.json
Normal 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/**"]
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
vp staged
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
}
|
||||
30
AGENTS.md
30
AGENTS.md
|
|
@ -1,30 +1,15 @@
|
|||
# AGENTS.md
|
||||
|
||||
## Important instructions
|
||||
|
||||
- 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
|
||||
- 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`
|
||||
- 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`
|
||||
- The dev server is running at http://localhost:3000. Username is `admin` and password is `password`
|
||||
|
||||
## 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).
|
||||
|
||||
### Development Server
|
||||
|
||||
```bash
|
||||
# Start the dev server through Portless
|
||||
bun run dev
|
||||
|
||||
# Get the current app URL for this worktree
|
||||
portless get zerobyte
|
||||
|
||||
# Inspect active Portless routes if needed
|
||||
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.
|
||||
|
||||
### Type Checking
|
||||
|
||||
```bash
|
||||
|
|
@ -39,7 +24,7 @@ bun run tsc
|
|||
bun run test
|
||||
|
||||
# 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
|
||||
|
|
@ -70,13 +55,8 @@ bun run gen:api-client
|
|||
|
||||
```bash
|
||||
# Format
|
||||
vp fmt <path> --write
|
||||
bunx oxfmt format --write <path>
|
||||
|
||||
# Lint
|
||||
bun run lint
|
||||
```
|
||||
|
||||
### Invalidation
|
||||
|
||||
The frontend has an automatic invalidation setup which runs after every mutation.
|
||||
Do not implement any invalidation logic in the frontend.
|
||||
|
|
|
|||
45
Dockerfile
45
Dockerfile
|
|
@ -1,8 +1,10 @@
|
|||
FROM oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0 AS base
|
||||
ARG BUN_VERSION="1.3.6"
|
||||
|
||||
FROM oven/bun:${BUN_VERSION}-alpine AS base
|
||||
|
||||
ARG RESTIC_VERSION="0.18.1"
|
||||
ARG RCLONE_VERSION="1.74.2"
|
||||
ARG SHOUTRRR_VERSION="0.15.1"
|
||||
ARG RCLONE_VERSION="1.73.0"
|
||||
ARG SHOUTRRR_VERSION="0.13.2"
|
||||
|
||||
ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
|
||||
VITE_RCLONE_VERSION=${RCLONE_VERSION} \
|
||||
|
|
@ -10,7 +12,7 @@ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
|
|||
|
||||
RUN apk update --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
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "-s", "--"]
|
||||
|
||||
|
|
@ -29,30 +31,21 @@ RUN apk add --no-cache curl bzip2 unzip tar
|
|||
|
||||
RUN echo "Building for ${TARGETARCH}"
|
||||
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 -fL -o rclone.zip "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-arm64.zip"; \
|
||||
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_arm64.bz2"; \
|
||||
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; \
|
||||
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 \
|
||||
curl -fL -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 restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_amd64.bz2"; \
|
||||
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; \
|
||||
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
|
||||
|
||||
RUN bzip2 -d restic.bz2 && chmod +x restic
|
||||
RUN mv rclone-v*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone
|
||||
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
|
||||
# ------------------------------
|
||||
|
|
@ -70,12 +63,8 @@ COPY --from=deps /deps/rclone /usr/local/bin/rclone
|
|||
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
|
||||
|
||||
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 . .
|
||||
|
||||
|
|
@ -95,17 +84,11 @@ ENV PORT=4096
|
|||
WORKDIR /app
|
||||
|
||||
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
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN bun run build
|
||||
RUN bun build apps/agent/src/index.ts --outfile .output/agent/index.mjs --target bun
|
||||
|
||||
FROM base AS production
|
||||
|
||||
|
|
|
|||
162
README.md
162
README.md
|
|
@ -15,33 +15,23 @@
|
|||
</figure>
|
||||
</div>
|
||||
|
||||
#### Join the community
|
||||
|
||||
[](https://discord.gg/XjgVyXrcEH)
|
||||
|
||||
> [!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">
|
||||
<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>
|
||||
|
||||
## 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.
|
||||
|
||||
## 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
|
||||
|
||||
- **Automated backups** with encryption, compression, and retention policies, powered by Restic
|
||||
- **Flexible scheduling** for automated backup jobs with fine-grained retention policies
|
||||
- **End-to-end encryption** will ensure your data is always protected
|
||||
- **Multi-protocol support** for backup from NFS, SMB, WebDAV, SFTP, or local directories
|
||||
- **Automated backups** with encryption, compression and retention policies powered by Restic
|
||||
- **Flexible scheduling** For automated backup jobs with fine-grained retention policies
|
||||
- **End-to-end encryption** ensuring your data is always protected
|
||||
- **Multi-protocol support**: Backup from NFS, SMB, WebDAV, SFTP, or local directories
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -50,7 +40,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
|
|||
```yaml
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.38
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.28
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
|
@ -60,7 +50,7 @@ services:
|
|||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
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:
|
||||
|
|
@ -69,10 +59,10 @@ services:
|
|||
```
|
||||
|
||||
> [!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]
|
||||
> 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:
|
||||
|
|
@ -97,72 +87,44 @@ 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:
|
||||
|
||||
### Environment variables
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| :------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- |
|
||||
| `BASE_URL` | **Required.** The base URL of your Zerobyte instance (e.g., `https://zerobyte.example.com`). See [Authentication](#authentication) below. | (none) |
|
||||
| `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) |
|
||||
| `APP_SECRET_FILE` | Path to a file containing `APP_SECRET`, useful with Docker or Kubernetes secrets. Mutually exclusive with `APP_SECRET`. | (none) |
|
||||
| `PORT` | The port the web interface and API will listen on. | `4096` |
|
||||
| `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` |
|
||||
| `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` |
|
||||
| `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) |
|
||||
| Variable | Description | Default |
|
||||
| :-------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- |
|
||||
| `BASE_URL` | **Required.** The base URL of your Zerobyte instance (e.g., `https://zerobyte.example.com`). See [Authentication](#authentication) below. | (none) |
|
||||
| `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) |
|
||||
| `PORT` | The port the web interface and API will listen on. | `4096` |
|
||||
| `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` |
|
||||
| `TZ` | Timezone for the container (e.g., `Europe/Paris`). **Crucial for accurate backup scheduling.** | `UTC` |
|
||||
| `TRUSTED_ORIGINS` | Comma-separated list of extra trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (none) |
|
||||
| `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 rclone config directory inside the container. Change this if running as a non-root user. | `/root/.config/rclone` |
|
||||
|
||||
### 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
|
||||
- Generic HTTP notification URLs
|
||||
- Gotify server URLs
|
||||
- self-hosted ntfy server URLs
|
||||
- custom Shoutrrr URLs that point at generic HTTP or SMTP network targets
|
||||
### Simplified setup (No remote mounts)
|
||||
|
||||
The public ntfy.sh service and fixed-provider notification services such as Slack, Discord, Pushover, and Telegram do not need `WEBHOOK_ALLOWED_ORIGINS`.
|
||||
|
||||
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`:
|
||||
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
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.38
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.28
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4096:4096"
|
||||
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:
|
||||
|
|
@ -174,8 +136,8 @@ services:
|
|||
**Trade-offs:**
|
||||
|
||||
- ✅ Improved security by reducing container capabilities
|
||||
- ✅ Support for local directories as backup sources
|
||||
- ✅ Support all repository types, local and remote (S3, GCS, Azure, rclone)
|
||||
- ✅ Support for local directories
|
||||
- ✅ Keep support all repository types (local, S3, GCS, Azure, rclone)
|
||||
- ❌ 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`.
|
||||
|
|
@ -190,12 +152,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.
|
||||
|
||||
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
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.38
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.28
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
|
@ -205,7 +167,7 @@ services:
|
|||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
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:
|
||||
|
|
@ -223,17 +185,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.
|
||||
|
||||

|
||||

|
||||
|
||||
## Creating a repository
|
||||
|
||||
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
|
||||
- **S3-compatible storage**: Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
|
||||
- **Google Cloud Storage**: Google's cloud storage service
|
||||
- **Azure Blob Storage**: Microsoft Azure storage
|
||||
- **rclone remotes**: 40+ cloud storage providers via rclone (see below)
|
||||
- **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.
|
||||
- **Google Cloud Storage** - Google's cloud storage service
|
||||
- **Azure Blob Storage** - Microsoft Azure storage
|
||||
- **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.
|
||||
|
||||
|
|
@ -270,7 +232,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
|||
```diff
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.38
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.28
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
|
@ -280,7 +242,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
|||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
environment:
|
||||
- TZ=Europe/Zurich
|
||||
- TZ=Europe/Paris
|
||||
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
|
|
@ -288,7 +250,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
|||
+ - ~/.config/rclone:/root/.config/rclone:ro
|
||||
```
|
||||
|
||||
> **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`:
|
||||
> **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_CONF_DIR`:
|
||||
>
|
||||
> ```yaml
|
||||
> environment:
|
||||
|
|
@ -324,30 +286,24 @@ 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.
|
||||
You can monitor the progress and status of your backup jobs in the "Backups" section of the web interface.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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
|
||||
### Cookie Security
|
||||
|
||||
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.
|
||||
- **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.
|
||||
|
||||
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
|
||||
### Reverse Proxy Setup
|
||||
|
||||
If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.):
|
||||
|
||||
|
|
@ -355,18 +311,16 @@ If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.):
|
|||
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
|
||||
### 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
|
||||
|
||||
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:
|
||||
|
||||
|
|
@ -387,10 +341,10 @@ Contributions by anyone are welcome! If you find a bug or have a feature request
|
|||
|
||||
## 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
|
||||
vp install
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
|
|
@ -404,10 +358,10 @@ RESTIC_PASS_FILE=./data/restic.pass
|
|||
RESTIC_CACHE_DIR=./data/restic/cache
|
||||
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
|
||||
ZEROBYTE_VOLUMES_DIR=./data/volumes
|
||||
BASE_URL=https://*.localhost
|
||||
BASE_URL=http://localhost:4096
|
||||
```
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
|
|
|||
75
SECURITY.md
75
SECURITY.md
|
|
@ -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 operator’s 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
|
||||
|
|
@ -283,8 +283,9 @@ If you're experiencing rclone issues, verify all of the following:
|
|||
**Diagnosis:**
|
||||
|
||||
```bash
|
||||
# Check which config file rclone will use inside the container
|
||||
docker exec zerobyte sh -lc 'echo HOME=$HOME; rclone config file'
|
||||
# Check if config is accessible inside container
|
||||
docker exec zerobyte ls -la /root/.config/rclone/
|
||||
docker exec zerobyte cat /root/.config/rclone/rclone.conf
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
|
|
|
|||
90
app/app.css
90
app/app.css
|
|
@ -37,6 +37,7 @@ body {
|
|||
}
|
||||
|
||||
body {
|
||||
@apply bg-[#131313];
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
|
|
@ -66,8 +67,6 @@ body {
|
|||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-success: var(--success);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
|
@ -89,40 +88,38 @@ body {
|
|||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(0.98 0.002 250);
|
||||
--foreground: oklch(0.20 0.01 260);
|
||||
--card: #ffffff;
|
||||
--card-foreground: oklch(0.20 0.01 260);
|
||||
--card-header: oklch(0.99 0.001 260);
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: oklch(0.20 0.01 260);
|
||||
--primary: oklch(0.20 0.01 260);
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: oklch(0.96 0.005 260);
|
||||
--secondary-foreground: oklch(0.20 0.01 260);
|
||||
--muted: oklch(0.95 0.005 260);
|
||||
--muted-foreground: oklch(0.45 0.01 260);
|
||||
--accent: oklch(0.96 0.005 260);
|
||||
--accent-foreground: oklch(0.20 0.01 260);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--card-header: oklch(0.922 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: #fff;
|
||||
--success: oklch(0.57 0.16 148);
|
||||
--border: oklch(0.90 0.01 260);
|
||||
--input: oklch(0.90 0.01 260);
|
||||
--ring: oklch(0.7 0.01 260);
|
||||
--chart-1: oklch(0.55 0.22 260);
|
||||
--chart-2: oklch(0.6 0.15 200);
|
||||
--chart-3: oklch(0.45 0.12 280);
|
||||
--chart-4: oklch(0.7 0.15 190);
|
||||
--chart-5: oklch(0.55 0.18 300);
|
||||
--sidebar: #ffffff;
|
||||
--sidebar-foreground: oklch(0.35 0.01 260);
|
||||
--sidebar-primary: oklch(0.20 0.01 260);
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: oklch(0.97 0.005 260);
|
||||
--sidebar-accent-foreground: oklch(0.20 0.01 260);
|
||||
--sidebar-border: oklch(0.90 0.01 260);
|
||||
--sidebar-ring: oklch(0.7 0.01 260);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--strong-accent: #ff543a;
|
||||
}
|
||||
|
||||
|
|
@ -147,8 +144,6 @@ body {
|
|||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: #ff543a;
|
||||
--destructive-foreground: #fff;
|
||||
--success: oklch(0.696 0.17 162.48);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
|
|
@ -179,11 +174,11 @@ body {
|
|||
|
||||
@layer base {
|
||||
:root {
|
||||
--chart-1: oklch(0.55 0.22 260);
|
||||
--chart-2: oklch(0.6 0.15 200);
|
||||
--chart-3: oklch(0.45 0.12 280);
|
||||
--chart-4: oklch(0.7 0.15 190);
|
||||
--chart-5: oklch(0.55 0.18 300);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
@ -195,17 +190,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) */
|
||||
[data-secret-input] input[type="password"]::-ms-reveal,
|
||||
[data-secret-input] input[type="password"]::-ms-clear {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -1,278 +1,285 @@
|
|||
// @ts-nocheck
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { createSseClient } from '../core/serverSentEvents.gen';
|
||||
import type { HttpMethod } from '../core/types.gen';
|
||||
import { getValidRequestBody } from '../core/utils.gen';
|
||||
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen';
|
||||
import { createSseClient } from "../core/serverSentEvents.gen";
|
||||
import type { HttpMethod } from "../core/types.gen";
|
||||
import { getValidRequestBody } from "../core/utils.gen";
|
||||
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen";
|
||||
import {
|
||||
buildUrl,
|
||||
createConfig,
|
||||
createInterceptors,
|
||||
getParseAs,
|
||||
mergeConfigs,
|
||||
mergeHeaders,
|
||||
setAuthParams,
|
||||
} from './utils.gen';
|
||||
buildUrl,
|
||||
createConfig,
|
||||
createInterceptors,
|
||||
getParseAs,
|
||||
mergeConfigs,
|
||||
mergeHeaders,
|
||||
setAuthParams,
|
||||
} from "./utils.gen";
|
||||
|
||||
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
|
||||
body?: any;
|
||||
headers: ReturnType<typeof mergeHeaders>;
|
||||
type ReqInit = Omit<RequestInit, "body" | "headers"> & {
|
||||
body?: any;
|
||||
headers: ReturnType<typeof mergeHeaders>;
|
||||
};
|
||||
|
||||
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 => {
|
||||
_config = mergeConfigs(_config, config);
|
||||
return getConfig();
|
||||
};
|
||||
const setConfig = (config: Config): Config => {
|
||||
_config = mergeConfigs(_config, config);
|
||||
return getConfig();
|
||||
};
|
||||
|
||||
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
|
||||
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
|
||||
|
||||
const beforeRequest = async <
|
||||
TData = unknown,
|
||||
TResponseStyle extends 'data' | 'fields' = 'fields',
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
>(
|
||||
options: RequestOptions<TData, TResponseStyle, ThrowOnError, Url>,
|
||||
) => {
|
||||
const opts = {
|
||||
..._config,
|
||||
...options,
|
||||
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
||||
headers: mergeHeaders(_config.headers, options.headers),
|
||||
serializedBody: undefined as string | undefined,
|
||||
};
|
||||
const beforeRequest = async (options: RequestOptions) => {
|
||||
const opts = {
|
||||
..._config,
|
||||
...options,
|
||||
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
||||
headers: mergeHeaders(_config.headers, options.headers),
|
||||
serializedBody: undefined,
|
||||
};
|
||||
|
||||
if (opts.security) {
|
||||
await setAuthParams(opts);
|
||||
}
|
||||
if (opts.security) {
|
||||
await setAuthParams({
|
||||
...opts,
|
||||
security: opts.security,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.requestValidator) {
|
||||
await opts.requestValidator(opts);
|
||||
}
|
||||
if (opts.requestValidator) {
|
||||
await opts.requestValidator(opts);
|
||||
}
|
||||
|
||||
if (opts.body !== undefined && opts.bodySerializer) {
|
||||
opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined;
|
||||
}
|
||||
if (opts.body !== undefined && opts.bodySerializer) {
|
||||
opts.serializedBody = opts.bodySerializer(opts.body);
|
||||
}
|
||||
|
||||
// remove Content-Type header if body is empty to avoid sending invalid requests
|
||||
if (opts.body === undefined || opts.serializedBody === '') {
|
||||
opts.headers.delete('Content-Type');
|
||||
}
|
||||
// remove Content-Type header if body is empty to avoid sending invalid requests
|
||||
if (opts.body === undefined || opts.serializedBody === "") {
|
||||
opts.headers.delete("Content-Type");
|
||||
}
|
||||
|
||||
const resolvedOpts = opts as typeof opts &
|
||||
ResolvedRequestOptions<TResponseStyle, ThrowOnError, Url>;
|
||||
const url = buildUrl(resolvedOpts);
|
||||
const url = buildUrl(opts);
|
||||
|
||||
return { opts: resolvedOpts, url };
|
||||
};
|
||||
return { opts, url };
|
||||
};
|
||||
|
||||
const request: Client['request'] = async (options) => {
|
||||
const throwOnError = options.throwOnError ?? _config.throwOnError;
|
||||
const responseStyle = options.responseStyle ?? _config.responseStyle;
|
||||
const request: Client["request"] = async (options) => {
|
||||
// @ts-expect-error
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
const requestInit: ReqInit = {
|
||||
redirect: "follow",
|
||||
...opts,
|
||||
body: getValidRequestBody(opts),
|
||||
};
|
||||
|
||||
let request: Request | undefined;
|
||||
let response: Response | undefined;
|
||||
let request = new Request(url, requestInit);
|
||||
|
||||
try {
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
const requestInit: ReqInit = {
|
||||
redirect: 'follow',
|
||||
...opts,
|
||||
body: getValidRequestBody(opts),
|
||||
};
|
||||
for (const fn of interceptors.request.fns) {
|
||||
if (fn) {
|
||||
request = await fn(request, 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) {
|
||||
if (fn) {
|
||||
request = await fn(request, opts);
|
||||
}
|
||||
}
|
||||
try {
|
||||
response = await _fetch(request);
|
||||
} catch (error) {
|
||||
// Handle fetch exceptions (AbortError, network errors, etc.)
|
||||
let finalError = error;
|
||||
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = opts.fetch!;
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
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 (fn) {
|
||||
response = await fn(response, request, opts);
|
||||
}
|
||||
}
|
||||
if (opts.throwOnError) {
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
const result = {
|
||||
request,
|
||||
response,
|
||||
};
|
||||
// Return error response
|
||||
return opts.responseStyle === "data"
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
request,
|
||||
response: undefined as any,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const parseAs =
|
||||
(opts.parseAs === 'auto'
|
||||
? getParseAs(response.headers.get('Content-Type'))
|
||||
: opts.parseAs) ?? 'json';
|
||||
for (const fn of interceptors.response.fns) {
|
||||
if (fn) {
|
||||
response = await fn(response, request, opts);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
|
||||
let emptyData: any;
|
||||
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,
|
||||
};
|
||||
}
|
||||
const result = {
|
||||
request,
|
||||
response,
|
||||
};
|
||||
|
||||
let data: any;
|
||||
switch (parseAs) {
|
||||
case 'arrayBuffer':
|
||||
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 (response.ok) {
|
||||
const parseAs =
|
||||
(opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json";
|
||||
|
||||
if (parseAs === 'json') {
|
||||
if (opts.responseValidator) {
|
||||
await opts.responseValidator(data);
|
||||
}
|
||||
if (response.status === 204 || response.headers.get("Content-Length") === "0") {
|
||||
let emptyData: any;
|
||||
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) {
|
||||
data = await opts.responseTransformer(data);
|
||||
}
|
||||
}
|
||||
let data: any;
|
||||
switch (parseAs) {
|
||||
case "arrayBuffer":
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
return opts.responseStyle === 'data'
|
||||
? data
|
||||
: {
|
||||
data,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
if (parseAs === "json") {
|
||||
if (opts.responseValidator) {
|
||||
await opts.responseValidator(data);
|
||||
}
|
||||
|
||||
const textError = await response.text();
|
||||
let jsonError: unknown;
|
||||
if (opts.responseTransformer) {
|
||||
data = await opts.responseTransformer(data);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
jsonError = JSON.parse(textError);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
return opts.responseStyle === "data"
|
||||
? data
|
||||
: {
|
||||
data,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
throw jsonError ?? textError;
|
||||
} catch (error) {
|
||||
let finalError = error;
|
||||
const textError = await response.text();
|
||||
let jsonError: unknown;
|
||||
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
finalError = await fn(finalError, response, request, options as ResolvedRequestOptions);
|
||||
}
|
||||
}
|
||||
try {
|
||||
jsonError = JSON.parse(textError);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
finalError = finalError || {};
|
||||
const error = jsonError ?? textError;
|
||||
let finalError = error;
|
||||
|
||||
if (throwOnError) {
|
||||
throw finalError;
|
||||
}
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
finalError = (await fn(error, response, request, opts)) as string;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: we probably want to return error and improve types
|
||||
return responseStyle === 'data'
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
request,
|
||||
response,
|
||||
};
|
||||
}
|
||||
};
|
||||
finalError = finalError || ({} as string);
|
||||
|
||||
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
||||
request({ ...options, method });
|
||||
if (opts.throwOnError) {
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
return createSseClient({
|
||||
...opts,
|
||||
body: opts.body as BodyInit | null | undefined,
|
||||
method,
|
||||
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,
|
||||
});
|
||||
};
|
||||
// TODO: we probably want to return error and improve types
|
||||
return opts.responseStyle === "data"
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
...result,
|
||||
};
|
||||
};
|
||||
|
||||
const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options });
|
||||
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) => request({ ...options, method });
|
||||
|
||||
return {
|
||||
buildUrl: _buildUrl,
|
||||
connect: makeMethodFn('CONNECT'),
|
||||
delete: makeMethodFn('DELETE'),
|
||||
get: makeMethodFn('GET'),
|
||||
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;
|
||||
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
return createSseClient({
|
||||
...opts,
|
||||
body: opts.body as BodyInit | null | undefined,
|
||||
headers: opts.headers as unknown as Record<string, string>,
|
||||
method,
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
buildUrl,
|
||||
connect: makeMethodFn("CONNECT"),
|
||||
delete: makeMethodFn("DELETE"),
|
||||
get: makeMethodFn("GET"),
|
||||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// @ts-nocheck
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type { Auth } from "../core/auth.gen";
|
||||
|
|
|
|||
|
|
@ -1,187 +1,175 @@
|
|||
// @ts-nocheck
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Auth } from '../core/auth.gen';
|
||||
import type {
|
||||
ServerSentEventsOptions,
|
||||
ServerSentEventsResult,
|
||||
} from '../core/serverSentEvents.gen';
|
||||
import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen';
|
||||
import type { Middleware } from './utils.gen';
|
||||
import type { Auth } from "../core/auth.gen";
|
||||
import type { ServerSentEventsOptions, ServerSentEventsResult } from "../core/serverSentEvents.gen";
|
||||
import type { 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>
|
||||
extends Omit<RequestInit, 'body' | 'headers' | 'method'>, CoreConfig {
|
||||
/**
|
||||
* Base URL for all requests made by this client.
|
||||
*/
|
||||
baseUrl?: T['baseUrl'];
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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.
|
||||
* 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'
|
||||
*/
|
||||
parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text';
|
||||
/**
|
||||
* Should we return only data or multiple fields (data, error, response, etc.)?
|
||||
*
|
||||
* @default 'fields'
|
||||
*/
|
||||
responseStyle?: ResponseStyle;
|
||||
/**
|
||||
* Throw an error instead of returning it in the response?
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
throwOnError?: T['throwOnError'];
|
||||
extends Omit<RequestInit, "body" | "headers" | "method">, CoreConfig {
|
||||
/**
|
||||
* Base URL for all requests made by this client.
|
||||
*/
|
||||
baseUrl?: T["baseUrl"];
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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.
|
||||
* 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'
|
||||
*/
|
||||
parseAs?: "arrayBuffer" | "auto" | "blob" | "formData" | "json" | "stream" | "text";
|
||||
/**
|
||||
* Should we return only data or multiple fields (data, error, response, etc.)?
|
||||
*
|
||||
* @default 'fields'
|
||||
*/
|
||||
responseStyle?: ResponseStyle;
|
||||
/**
|
||||
* Throw an error instead of returning it in the response?
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
throwOnError?: T["throwOnError"];
|
||||
}
|
||||
|
||||
export interface RequestOptions<
|
||||
TData = unknown,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
TData = unknown,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
>
|
||||
extends
|
||||
Config<{
|
||||
responseStyle: TResponseStyle;
|
||||
throwOnError: ThrowOnError;
|
||||
}>,
|
||||
Pick<
|
||||
ServerSentEventsOptions<TData>,
|
||||
| 'onRequest'
|
||||
| 'onSseError'
|
||||
| 'onSseEvent'
|
||||
| 'sseDefaultRetryDelay'
|
||||
| 'sseMaxRetryAttempts'
|
||||
| 'sseMaxRetryDelay'
|
||||
> {
|
||||
/**
|
||||
* Any body that you want to add to your request.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
||||
*/
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
/**
|
||||
* Security mechanism(s) to use for the request.
|
||||
*/
|
||||
security?: ReadonlyArray<Auth>;
|
||||
url: Url;
|
||||
extends
|
||||
Config<{
|
||||
responseStyle: TResponseStyle;
|
||||
throwOnError: ThrowOnError;
|
||||
}>,
|
||||
Pick<
|
||||
ServerSentEventsOptions<TData>,
|
||||
"onSseError" | "onSseEvent" | "sseDefaultRetryDelay" | "sseMaxRetryAttempts" | "sseMaxRetryDelay"
|
||||
> {
|
||||
/**
|
||||
* Any body that you want to add to your request.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
||||
*/
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
/**
|
||||
* Security mechanism(s) to use for the request.
|
||||
*/
|
||||
security?: ReadonlyArray<Auth>;
|
||||
url: Url;
|
||||
}
|
||||
|
||||
export interface ResolvedRequestOptions<
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
||||
headers: Headers;
|
||||
serializedBody?: string;
|
||||
serializedBody?: string;
|
||||
}
|
||||
|
||||
export type RequestResult<
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
> = ThrowOnError extends true
|
||||
? Promise<
|
||||
TResponseStyle extends 'data'
|
||||
? TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData
|
||||
: {
|
||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>
|
||||
: Promise<
|
||||
TResponseStyle extends 'data'
|
||||
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
|
||||
: (
|
||||
| {
|
||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||
error: undefined;
|
||||
}
|
||||
| {
|
||||
data: undefined;
|
||||
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
|
||||
}
|
||||
) & {
|
||||
/** request may be undefined, because error may be from building the request object itself */
|
||||
request?: Request;
|
||||
/** response may be undefined, because error may be from building the request object itself or from a network error */
|
||||
response?: Response;
|
||||
}
|
||||
>;
|
||||
? Promise<
|
||||
TResponseStyle extends "data"
|
||||
? TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData
|
||||
: {
|
||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>
|
||||
: Promise<
|
||||
TResponseStyle extends "data"
|
||||
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
|
||||
: (
|
||||
| {
|
||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||
error: undefined;
|
||||
}
|
||||
| {
|
||||
data: undefined;
|
||||
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
|
||||
}
|
||||
) & {
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>;
|
||||
|
||||
export interface ClientOptions {
|
||||
baseUrl?: string;
|
||||
responseStyle?: ResponseStyle;
|
||||
throwOnError?: boolean;
|
||||
baseUrl?: string;
|
||||
responseStyle?: ResponseStyle;
|
||||
throwOnError?: boolean;
|
||||
}
|
||||
|
||||
type MethodFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
|
||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||
|
||||
type SseFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
>(
|
||||
options: Omit<RequestOptions<never, TResponseStyle, ThrowOnError>, 'method'>,
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
|
||||
) => Promise<ServerSentEventsResult<TData, TError>>;
|
||||
|
||||
type RequestFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
|
||||
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, 'method'>,
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method"> &
|
||||
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, "method">,
|
||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||
|
||||
type BuildUrlFn = <
|
||||
TData extends {
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
url: string;
|
||||
},
|
||||
TData extends {
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
url: string;
|
||||
},
|
||||
>(
|
||||
options: TData & Options<TData>,
|
||||
options: TData & Options<TData>,
|
||||
) => string;
|
||||
|
||||
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
|
||||
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
||||
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -193,26 +181,23 @@ export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn>
|
|||
* to ensure your client always has the correct values.
|
||||
*/
|
||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
|
||||
override?: Config<ClientOptions & T>,
|
||||
override?: Config<ClientOptions & T>,
|
||||
) => Config<Required<ClientOptions> & T>;
|
||||
|
||||
export interface TDataShape {
|
||||
body?: unknown;
|
||||
headers?: unknown;
|
||||
path?: unknown;
|
||||
query?: unknown;
|
||||
url: string;
|
||||
body?: unknown;
|
||||
headers?: unknown;
|
||||
path?: unknown;
|
||||
query?: unknown;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||
|
||||
export type Options<
|
||||
TData extends TDataShape = TDataShape,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponse = unknown,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
> = OmitKeys<
|
||||
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
|
||||
'body' | 'path' | 'query' | 'url'
|
||||
> &
|
||||
([TData] extends [never] ? unknown : Omit<TData, 'url'>);
|
||||
TData extends TDataShape = TDataShape,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponse = unknown,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
> = OmitKeys<RequestOptions<TResponse, TResponseStyle, ThrowOnError>, "body" | "path" | "query" | "url"> &
|
||||
([TData] extends [never] ? unknown : Omit<TData, "url">);
|
||||
|
|
|
|||
|
|
@ -1,317 +1,289 @@
|
|||
// @ts-nocheck
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { getAuthToken } from '../core/auth.gen';
|
||||
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||
import { jsonBodySerializer } from '../core/bodySerializer.gen';
|
||||
import {
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from '../core/pathSerializer.gen';
|
||||
import { getUrl } from '../core/utils.gen';
|
||||
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
|
||||
import { getAuthToken } from "../core/auth.gen";
|
||||
import type { QuerySerializerOptions } from "../core/bodySerializer.gen";
|
||||
import { jsonBodySerializer } from "../core/bodySerializer.gen";
|
||||
import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.gen";
|
||||
import { getUrl } from "../core/utils.gen";
|
||||
import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen";
|
||||
|
||||
export const createQuerySerializer = <T = unknown>({
|
||||
parameters = {},
|
||||
...args
|
||||
}: QuerySerializerOptions = {}) => {
|
||||
const querySerializer = (queryParams: T) => {
|
||||
const search: string[] = [];
|
||||
if (queryParams && typeof queryParams === 'object') {
|
||||
for (const name in queryParams) {
|
||||
const value = queryParams[name];
|
||||
export const createQuerySerializer = <T = unknown>({ parameters = {}, ...args }: QuerySerializerOptions = {}) => {
|
||||
const querySerializer = (queryParams: T) => {
|
||||
const search: string[] = [];
|
||||
if (queryParams && typeof queryParams === "object") {
|
||||
for (const name in queryParams) {
|
||||
const value = queryParams[name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const options = parameters[name] || args;
|
||||
const options = parameters[name] || args;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const serializedArray = serializeArrayParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: 'form',
|
||||
value,
|
||||
...options.array,
|
||||
});
|
||||
if (serializedArray) search.push(serializedArray);
|
||||
} else if (typeof value === 'object') {
|
||||
const serializedObject = serializeObjectParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: 'deepObject',
|
||||
value: value as Record<string, unknown>,
|
||||
...options.object,
|
||||
});
|
||||
if (serializedObject) search.push(serializedObject);
|
||||
} else {
|
||||
const serializedPrimitive = serializePrimitiveParam({
|
||||
allowReserved: options.allowReserved,
|
||||
name,
|
||||
value: value as string,
|
||||
});
|
||||
if (serializedPrimitive) search.push(serializedPrimitive);
|
||||
}
|
||||
}
|
||||
}
|
||||
return search.join('&');
|
||||
};
|
||||
return querySerializer;
|
||||
if (Array.isArray(value)) {
|
||||
const serializedArray = serializeArrayParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: "form",
|
||||
value,
|
||||
...options.array,
|
||||
});
|
||||
if (serializedArray) search.push(serializedArray);
|
||||
} else if (typeof value === "object") {
|
||||
const serializedObject = serializeObjectParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: "deepObject",
|
||||
value: value as Record<string, unknown>,
|
||||
...options.object,
|
||||
});
|
||||
if (serializedObject) search.push(serializedObject);
|
||||
} else {
|
||||
const serializedPrimitive = serializePrimitiveParam({
|
||||
allowReserved: options.allowReserved,
|
||||
name,
|
||||
value: value as string,
|
||||
});
|
||||
if (serializedPrimitive) search.push(serializedPrimitive);
|
||||
}
|
||||
}
|
||||
}
|
||||
return search.join("&");
|
||||
};
|
||||
return querySerializer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Infers parseAs value from provided Content-Type header.
|
||||
*/
|
||||
export const getParseAs = (contentType: string | null): Exclude<Config['parseAs'], 'auto'> => {
|
||||
if (!contentType) {
|
||||
// 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';
|
||||
}
|
||||
export const getParseAs = (contentType: string | null): Exclude<Config["parseAs"], "auto"> => {
|
||||
if (!contentType) {
|
||||
// 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) {
|
||||
return;
|
||||
}
|
||||
if (!cleanContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) {
|
||||
return 'json';
|
||||
}
|
||||
if (cleanContent.startsWith("application/json") || cleanContent.endsWith("+json")) {
|
||||
return "json";
|
||||
}
|
||||
|
||||
if (cleanContent === 'multipart/form-data') {
|
||||
return 'formData';
|
||||
}
|
||||
if (cleanContent === "multipart/form-data") {
|
||||
return "formData";
|
||||
}
|
||||
|
||||
if (
|
||||
['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type))
|
||||
) {
|
||||
return 'blob';
|
||||
}
|
||||
if (["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type))) {
|
||||
return "blob";
|
||||
}
|
||||
|
||||
if (cleanContent.startsWith('text/')) {
|
||||
return 'text';
|
||||
}
|
||||
if (cleanContent.startsWith("text/")) {
|
||||
return "text";
|
||||
}
|
||||
|
||||
return;
|
||||
return;
|
||||
};
|
||||
|
||||
const checkForExistence = (
|
||||
options: Pick<RequestOptions, 'auth' | 'query'> & {
|
||||
headers: Headers;
|
||||
},
|
||||
name?: string,
|
||||
options: Pick<RequestOptions, "auth" | "query"> & {
|
||||
headers: Headers;
|
||||
},
|
||||
name?: string,
|
||||
): boolean => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
options.headers.has(name) ||
|
||||
options.query?.[name] ||
|
||||
options.headers.get('Cookie')?.includes(`${name}=`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
if (options.headers.has(name) || options.query?.[name] || options.headers.get("Cookie")?.includes(`${name}=`)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function setAuthParams(
|
||||
options: Pick<RequestOptions, 'auth' | 'query' | 'security'> & {
|
||||
headers: Headers;
|
||||
},
|
||||
): Promise<void> {
|
||||
for (const auth of options.security ?? []) {
|
||||
if (checkForExistence(options, auth.name)) {
|
||||
continue;
|
||||
}
|
||||
export const setAuthParams = async ({
|
||||
security,
|
||||
...options
|
||||
}: Pick<Required<RequestOptions>, "security"> &
|
||||
Pick<RequestOptions, "auth" | "query"> & {
|
||||
headers: Headers;
|
||||
}) => {
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = auth.name ?? 'Authorization';
|
||||
const name = auth.name ?? "Authorization";
|
||||
|
||||
switch (auth.in) {
|
||||
case 'query':
|
||||
if (!options.query) {
|
||||
options.query = {};
|
||||
}
|
||||
options.query[name] = token;
|
||||
break;
|
||||
case 'cookie':
|
||||
options.headers.append('Cookie', `${name}=${token}`);
|
||||
break;
|
||||
case 'header':
|
||||
default:
|
||||
options.headers.set(name, token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
switch (auth.in) {
|
||||
case "query":
|
||||
if (!options.query) {
|
||||
options.query = {};
|
||||
}
|
||||
options.query[name] = token;
|
||||
break;
|
||||
case "cookie":
|
||||
options.headers.append("Cookie", `${name}=${token}`);
|
||||
break;
|
||||
case "header":
|
||||
default:
|
||||
options.headers.set(name, token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buildUrl: Client['buildUrl'] = (options) =>
|
||||
getUrl({
|
||||
baseUrl: options.baseUrl as string,
|
||||
path: options.path,
|
||||
query: options.query,
|
||||
querySerializer:
|
||||
typeof options.querySerializer === 'function'
|
||||
? options.querySerializer
|
||||
: createQuerySerializer(options.querySerializer),
|
||||
url: options.url,
|
||||
});
|
||||
export const buildUrl: Client["buildUrl"] = (options) =>
|
||||
getUrl({
|
||||
baseUrl: options.baseUrl as string,
|
||||
path: options.path,
|
||||
query: options.query,
|
||||
querySerializer:
|
||||
typeof options.querySerializer === "function"
|
||||
? options.querySerializer
|
||||
: createQuerySerializer(options.querySerializer),
|
||||
url: options.url,
|
||||
});
|
||||
|
||||
export const mergeConfigs = (a: Config, b: Config): Config => {
|
||||
const config = { ...a, ...b };
|
||||
if (config.baseUrl?.endsWith('/')) {
|
||||
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
||||
}
|
||||
config.headers = mergeHeaders(a.headers, b.headers);
|
||||
return config;
|
||||
const config = { ...a, ...b };
|
||||
if (config.baseUrl?.endsWith("/")) {
|
||||
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
||||
}
|
||||
config.headers = mergeHeaders(a.headers, b.headers);
|
||||
return config;
|
||||
};
|
||||
|
||||
const headersEntries = (headers: Headers): Array<[string, string]> => {
|
||||
const entries: Array<[string, string]> = [];
|
||||
headers.forEach((value, key) => {
|
||||
entries.push([key, value]);
|
||||
});
|
||||
return entries;
|
||||
const entries: Array<[string, string]> = [];
|
||||
headers.forEach((value, key) => {
|
||||
entries.push([key, value]);
|
||||
});
|
||||
return entries;
|
||||
};
|
||||
|
||||
export const mergeHeaders = (
|
||||
...headers: Array<Required<Config>['headers'] | undefined>
|
||||
): Headers => {
|
||||
const mergedHeaders = new Headers();
|
||||
for (const header of headers) {
|
||||
if (!header) {
|
||||
continue;
|
||||
}
|
||||
export const mergeHeaders = (...headers: Array<Required<Config>["headers"] | undefined>): Headers => {
|
||||
const mergedHeaders = new Headers();
|
||||
for (const header of headers) {
|
||||
if (!header) {
|
||||
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) {
|
||||
if (value === null) {
|
||||
mergedHeaders.delete(key);
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
mergedHeaders.append(key, v as string);
|
||||
}
|
||||
} else if (value !== undefined) {
|
||||
// assume object headers are meant to be JSON stringified, i.e., their
|
||||
// content value in OpenAPI specification is 'application/json'
|
||||
mergedHeaders.set(
|
||||
key,
|
||||
typeof value === 'object' ? JSON.stringify(value) : (value as string),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return mergedHeaders;
|
||||
for (const [key, value] of iterator) {
|
||||
if (value === null) {
|
||||
mergedHeaders.delete(key);
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
mergedHeaders.append(key, v as string);
|
||||
}
|
||||
} else if (value !== undefined) {
|
||||
// assume object headers are meant to be JSON stringified, i.e. their
|
||||
// content value in OpenAPI specification is 'application/json'
|
||||
mergedHeaders.set(key, typeof value === "object" ? JSON.stringify(value) : (value as string));
|
||||
}
|
||||
}
|
||||
}
|
||||
return mergedHeaders;
|
||||
};
|
||||
|
||||
type ErrInterceptor<Err, Res, Req, Options> = (
|
||||
error: Err,
|
||||
/** response may be undefined due to a network error where no response object is produced */
|
||||
response: Res | undefined,
|
||||
/** request may be undefined, because error may be from building the request object itself */
|
||||
request: Req | undefined,
|
||||
options: Options,
|
||||
error: Err,
|
||||
response: Res,
|
||||
request: Req,
|
||||
options: Options,
|
||||
) => Err | Promise<Err>;
|
||||
|
||||
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>;
|
||||
|
||||
type ResInterceptor<Res, Req, Options> = (
|
||||
response: Res,
|
||||
request: Req,
|
||||
options: Options,
|
||||
) => Res | Promise<Res>;
|
||||
type ResInterceptor<Res, Req, Options> = (response: Res, request: Req, options: Options) => Res | Promise<Res>;
|
||||
|
||||
class Interceptors<Interceptor> {
|
||||
fns: Array<Interceptor | null> = [];
|
||||
fns: Array<Interceptor | null> = [];
|
||||
|
||||
clear(): void {
|
||||
this.fns = [];
|
||||
}
|
||||
clear(): void {
|
||||
this.fns = [];
|
||||
}
|
||||
|
||||
eject(id: number | Interceptor): void {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = null;
|
||||
}
|
||||
}
|
||||
eject(id: number | Interceptor): void {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = null;
|
||||
}
|
||||
}
|
||||
|
||||
exists(id: number | Interceptor): boolean {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
return Boolean(this.fns[index]);
|
||||
}
|
||||
exists(id: number | Interceptor): boolean {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
return Boolean(this.fns[index]);
|
||||
}
|
||||
|
||||
getInterceptorIndex(id: number | Interceptor): number {
|
||||
if (typeof id === 'number') {
|
||||
return this.fns[id] ? id : -1;
|
||||
}
|
||||
return this.fns.indexOf(id);
|
||||
}
|
||||
getInterceptorIndex(id: number | Interceptor): number {
|
||||
if (typeof id === "number") {
|
||||
return this.fns[id] ? id : -1;
|
||||
}
|
||||
return this.fns.indexOf(id);
|
||||
}
|
||||
|
||||
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = fn;
|
||||
return id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = fn;
|
||||
return id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
use(fn: Interceptor): number {
|
||||
this.fns.push(fn);
|
||||
return this.fns.length - 1;
|
||||
}
|
||||
use(fn: Interceptor): number {
|
||||
this.fns.push(fn);
|
||||
return this.fns.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Middleware<Req, Res, Err, Options> {
|
||||
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
|
||||
request: Interceptors<ReqInterceptor<Req, Options>>;
|
||||
response: Interceptors<ResInterceptor<Res, Req, Options>>;
|
||||
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
|
||||
request: Interceptors<ReqInterceptor<Req, Options>>;
|
||||
response: Interceptors<ResInterceptor<Res, Req, Options>>;
|
||||
}
|
||||
|
||||
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
|
||||
Req,
|
||||
Res,
|
||||
Err,
|
||||
Options
|
||||
> => ({
|
||||
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
|
||||
request: new Interceptors<ReqInterceptor<Req, Options>>(),
|
||||
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
|
||||
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<Req, Res, Err, Options> => ({
|
||||
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
|
||||
request: new Interceptors<ReqInterceptor<Req, Options>>(),
|
||||
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
|
||||
});
|
||||
|
||||
const defaultQuerySerializer = createQuerySerializer({
|
||||
allowReserved: false,
|
||||
array: {
|
||||
explode: true,
|
||||
style: 'form',
|
||||
},
|
||||
object: {
|
||||
explode: true,
|
||||
style: 'deepObject',
|
||||
},
|
||||
allowReserved: false,
|
||||
array: {
|
||||
explode: true,
|
||||
style: "form",
|
||||
},
|
||||
object: {
|
||||
explode: true,
|
||||
style: "deepObject",
|
||||
},
|
||||
});
|
||||
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
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> => ({
|
||||
...jsonBodySerializer,
|
||||
headers: defaultHeaders,
|
||||
parseAs: 'auto',
|
||||
querySerializer: defaultQuerySerializer,
|
||||
...override,
|
||||
...jsonBodySerializer,
|
||||
headers: defaultHeaders,
|
||||
parseAs: "auto",
|
||||
querySerializer: defaultQuerySerializer,
|
||||
...override,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,42 +1,41 @@
|
|||
// @ts-nocheck
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type AuthToken = string | undefined;
|
||||
|
||||
export interface Auth {
|
||||
/**
|
||||
* Which part of the request do we use to send the auth?
|
||||
*
|
||||
* @default 'header'
|
||||
*/
|
||||
in?: 'header' | 'query' | 'cookie';
|
||||
/**
|
||||
* Header or query parameter name.
|
||||
*
|
||||
* @default 'Authorization'
|
||||
*/
|
||||
name?: string;
|
||||
scheme?: 'basic' | 'bearer';
|
||||
type: 'apiKey' | 'http';
|
||||
/**
|
||||
* Which part of the request do we use to send the auth?
|
||||
*
|
||||
* @default 'header'
|
||||
*/
|
||||
in?: "header" | "query" | "cookie";
|
||||
/**
|
||||
* Header or query parameter name.
|
||||
*
|
||||
* @default 'Authorization'
|
||||
*/
|
||||
name?: string;
|
||||
scheme?: "basic" | "bearer";
|
||||
type: "apiKey" | "http";
|
||||
}
|
||||
|
||||
export const getAuthToken = async (
|
||||
auth: Auth,
|
||||
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
||||
auth: Auth,
|
||||
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
||||
): Promise<string | undefined> => {
|
||||
const token = typeof callback === 'function' ? await callback(auth) : callback;
|
||||
const token = typeof callback === "function" ? await callback(auth) : callback;
|
||||
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.scheme === 'bearer') {
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
if (auth.scheme === "bearer") {
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (auth.scheme === 'basic') {
|
||||
return `Basic ${btoa(token)}`;
|
||||
}
|
||||
if (auth.scheme === "basic") {
|
||||
return `Basic ${btoa(token)}`;
|
||||
}
|
||||
|
||||
return token;
|
||||
return token;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,83 +1,82 @@
|
|||
// @ts-nocheck
|
||||
// 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 BodySerializer = (body: unknown) => unknown;
|
||||
export type BodySerializer = (body: any) => any;
|
||||
|
||||
type QuerySerializerOptionsObject = {
|
||||
allowReserved?: boolean;
|
||||
array?: Partial<SerializerOptions<ArrayStyle>>;
|
||||
object?: Partial<SerializerOptions<ObjectStyle>>;
|
||||
allowReserved?: boolean;
|
||||
array?: Partial<SerializerOptions<ArrayStyle>>;
|
||||
object?: Partial<SerializerOptions<ObjectStyle>>;
|
||||
};
|
||||
|
||||
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
|
||||
/**
|
||||
* Per-parameter serialization overrides. When provided, these settings
|
||||
* override the global array/object settings for specific parameter names.
|
||||
*/
|
||||
parameters?: Record<string, QuerySerializerOptionsObject>;
|
||||
/**
|
||||
* Per-parameter serialization overrides. When provided, these settings
|
||||
* override the global array/object settings for specific parameter names.
|
||||
*/
|
||||
parameters?: Record<string, QuerySerializerOptionsObject>;
|
||||
};
|
||||
|
||||
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
|
||||
if (typeof value === 'string' || value instanceof Blob) {
|
||||
data.append(key, value);
|
||||
} else if (value instanceof Date) {
|
||||
data.append(key, value.toISOString());
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
if (typeof value === "string" || value instanceof Blob) {
|
||||
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 => {
|
||||
if (typeof value === 'string') {
|
||||
data.append(key, value);
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
data.append(key, value);
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
export const formDataBodySerializer = {
|
||||
bodySerializer: (body: unknown): FormData => {
|
||||
const data = new FormData();
|
||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): FormData => {
|
||||
const data = new FormData();
|
||||
|
||||
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeFormDataPair(data, key, v));
|
||||
} else {
|
||||
serializeFormDataPair(data, key, value);
|
||||
}
|
||||
});
|
||||
Object.entries(body).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeFormDataPair(data, key, v));
|
||||
} else {
|
||||
serializeFormDataPair(data, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
export const jsonBodySerializer = {
|
||||
bodySerializer: (body: unknown): string =>
|
||||
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
|
||||
bodySerializer: <T>(body: T): string =>
|
||||
JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)),
|
||||
};
|
||||
|
||||
export const urlSearchParamsBodySerializer = {
|
||||
bodySerializer: (body: unknown): string => {
|
||||
const data = new URLSearchParams();
|
||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): string => {
|
||||
const data = new URLSearchParams();
|
||||
|
||||
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
||||
} else {
|
||||
serializeUrlSearchParamsPair(data, key, value);
|
||||
}
|
||||
});
|
||||
Object.entries(body).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
||||
} else {
|
||||
serializeUrlSearchParamsPair(data, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return data.toString();
|
||||
},
|
||||
return data.toString();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,170 +1,169 @@
|
|||
// @ts-nocheck
|
||||
// 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 =
|
||||
| {
|
||||
in: Exclude<Slot, 'body'>;
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Field mapped name. This is the name we want to use in the request.
|
||||
* If omitted, we use the same value as `key`.
|
||||
*/
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in: Extract<Slot, 'body'>;
|
||||
/**
|
||||
* Key isn't required for bodies.
|
||||
*/
|
||||
key?: string;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
map: Slot;
|
||||
};
|
||||
| {
|
||||
in: Exclude<Slot, "body">;
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Field mapped name. This is the name we want to use in the request.
|
||||
* If omitted, we use the same value as `key`.
|
||||
*/
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in: Extract<Slot, "body">;
|
||||
/**
|
||||
* Key isn't required for bodies.
|
||||
*/
|
||||
key?: string;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
map: Slot;
|
||||
};
|
||||
|
||||
export interface Fields {
|
||||
allowExtra?: Partial<Record<Slot, boolean>>;
|
||||
args?: ReadonlyArray<Field>;
|
||||
allowExtra?: Partial<Record<Slot, boolean>>;
|
||||
args?: ReadonlyArray<Field>;
|
||||
}
|
||||
|
||||
export type FieldsConfig = ReadonlyArray<Field | Fields>;
|
||||
|
||||
const extraPrefixesMap: Record<string, Slot> = {
|
||||
$body_: 'body',
|
||||
$headers_: 'headers',
|
||||
$path_: 'path',
|
||||
$query_: 'query',
|
||||
$body_: "body",
|
||||
$headers_: "headers",
|
||||
$path_: "path",
|
||||
$query_: "query",
|
||||
};
|
||||
const extraPrefixes = Object.entries(extraPrefixesMap);
|
||||
|
||||
type KeyMap = Map<
|
||||
string,
|
||||
| {
|
||||
in: Slot;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in?: never;
|
||||
map: Slot;
|
||||
}
|
||||
string,
|
||||
| {
|
||||
in: Slot;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in?: never;
|
||||
map: Slot;
|
||||
}
|
||||
>;
|
||||
|
||||
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
}
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
}
|
||||
|
||||
for (const config of fields) {
|
||||
if ('in' in config) {
|
||||
if (config.key) {
|
||||
map.set(config.key, {
|
||||
in: config.in,
|
||||
map: config.map,
|
||||
});
|
||||
}
|
||||
} else if ('key' in config) {
|
||||
map.set(config.key, {
|
||||
map: config.map,
|
||||
});
|
||||
} else if (config.args) {
|
||||
buildKeyMap(config.args, map);
|
||||
}
|
||||
}
|
||||
for (const config of fields) {
|
||||
if ("in" in config) {
|
||||
if (config.key) {
|
||||
map.set(config.key, {
|
||||
in: config.in,
|
||||
map: config.map,
|
||||
});
|
||||
}
|
||||
} else if ("key" in config) {
|
||||
map.set(config.key, {
|
||||
map: config.map,
|
||||
});
|
||||
} else if (config.args) {
|
||||
buildKeyMap(config.args, map);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
return map;
|
||||
};
|
||||
|
||||
interface Params {
|
||||
body: unknown;
|
||||
headers: Record<string, unknown>;
|
||||
path: Record<string, unknown>;
|
||||
query: Record<string, unknown>;
|
||||
body: unknown;
|
||||
headers: Record<string, unknown>;
|
||||
path: Record<string, unknown>;
|
||||
query: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const stripEmptySlots = (params: Params) => {
|
||||
for (const [slot, value] of Object.entries(params)) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) {
|
||||
delete params[slot as Slot];
|
||||
}
|
||||
}
|
||||
for (const [slot, value] of Object.entries(params)) {
|
||||
if (value && typeof value === "object" && !Object.keys(value).length) {
|
||||
delete params[slot as Slot];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
|
||||
const params: Params = {
|
||||
body: {},
|
||||
headers: {},
|
||||
path: {},
|
||||
query: {},
|
||||
};
|
||||
const params: Params = {
|
||||
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()) {
|
||||
if (fields[index]) {
|
||||
config = fields[index];
|
||||
}
|
||||
for (const [index, arg] of args.entries()) {
|
||||
if (fields[index]) {
|
||||
config = fields[index];
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
continue;
|
||||
}
|
||||
if (!config) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('in' in config) {
|
||||
if (config.key) {
|
||||
const field = map.get(config.key)!;
|
||||
const name = field.map || config.key;
|
||||
if (field.in) {
|
||||
(params[field.in] as Record<string, unknown>)[name] = arg;
|
||||
}
|
||||
} else {
|
||||
params.body = arg;
|
||||
}
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(arg ?? {})) {
|
||||
const field = map.get(key);
|
||||
if ("in" in config) {
|
||||
if (config.key) {
|
||||
const field = map.get(config.key)!;
|
||||
const name = field.map || config.key;
|
||||
if (field.in) {
|
||||
(params[field.in] as Record<string, unknown>)[name] = arg;
|
||||
}
|
||||
} else {
|
||||
params.body = arg;
|
||||
}
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(arg ?? {})) {
|
||||
const field = map.get(key);
|
||||
|
||||
if (field) {
|
||||
if (field.in) {
|
||||
const name = field.map || key;
|
||||
(params[field.in] as Record<string, unknown>)[name] = value;
|
||||
} else {
|
||||
params[field.map] = value;
|
||||
}
|
||||
} else {
|
||||
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
|
||||
if (field) {
|
||||
if (field.in) {
|
||||
const name = field.map || key;
|
||||
(params[field.in] as Record<string, unknown>)[name] = value;
|
||||
} else {
|
||||
params[field.map] = value;
|
||||
}
|
||||
} else {
|
||||
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
|
||||
|
||||
if (extra) {
|
||||
const [prefix, slot] = extra;
|
||||
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
|
||||
} else if ('allowExtra' in config && config.allowExtra) {
|
||||
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
||||
if (allowed) {
|
||||
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (extra) {
|
||||
const [prefix, slot] = extra;
|
||||
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
|
||||
} else if ("allowExtra" in config && config.allowExtra) {
|
||||
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
||||
if (allowed) {
|
||||
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stripEmptySlots(params);
|
||||
stripEmptySlots(params);
|
||||
|
||||
return params;
|
||||
return params;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,172 +1,167 @@
|
|||
// @ts-nocheck
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
|
||||
|
||||
interface SerializePrimitiveOptions {
|
||||
allowReserved?: boolean;
|
||||
name: string;
|
||||
allowReserved?: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SerializerOptions<T> {
|
||||
/**
|
||||
* @default true
|
||||
*/
|
||||
explode: boolean;
|
||||
style: T;
|
||||
/**
|
||||
* @default true
|
||||
*/
|
||||
explode: boolean;
|
||||
style: T;
|
||||
}
|
||||
|
||||
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
|
||||
export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited";
|
||||
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
|
||||
type MatrixStyle = 'label' | 'matrix' | 'simple';
|
||||
export type ObjectStyle = 'form' | 'deepObject';
|
||||
type MatrixStyle = "label" | "matrix" | "simple";
|
||||
export type ObjectStyle = "form" | "deepObject";
|
||||
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
|
||||
|
||||
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
||||
value: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return '.';
|
||||
case 'matrix':
|
||||
return ';';
|
||||
case 'simple':
|
||||
return ',';
|
||||
default:
|
||||
return '&';
|
||||
}
|
||||
switch (style) {
|
||||
case "label":
|
||||
return ".";
|
||||
case "matrix":
|
||||
return ";";
|
||||
case "simple":
|
||||
return ",";
|
||||
default:
|
||||
return "&";
|
||||
}
|
||||
};
|
||||
|
||||
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
|
||||
switch (style) {
|
||||
case 'form':
|
||||
return ',';
|
||||
case 'pipeDelimited':
|
||||
return '|';
|
||||
case 'spaceDelimited':
|
||||
return '%20';
|
||||
default:
|
||||
return ',';
|
||||
}
|
||||
switch (style) {
|
||||
case "form":
|
||||
return ",";
|
||||
case "pipeDelimited":
|
||||
return "|";
|
||||
case "spaceDelimited":
|
||||
return "%20";
|
||||
default:
|
||||
return ",";
|
||||
}
|
||||
};
|
||||
|
||||
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return '.';
|
||||
case 'matrix':
|
||||
return ';';
|
||||
case 'simple':
|
||||
return ',';
|
||||
default:
|
||||
return '&';
|
||||
}
|
||||
switch (style) {
|
||||
case "label":
|
||||
return ".";
|
||||
case "matrix":
|
||||
return ";";
|
||||
case "simple":
|
||||
return ",";
|
||||
default:
|
||||
return "&";
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeArrayParam = ({
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
}: SerializeOptions<ArraySeparatorStyle> & {
|
||||
value: unknown[];
|
||||
value: unknown[];
|
||||
}) => {
|
||||
if (!explode) {
|
||||
const joinedValues = (
|
||||
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
|
||||
).join(separatorArrayNoExplode(style));
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return `.${joinedValues}`;
|
||||
case 'matrix':
|
||||
return `;${name}=${joinedValues}`;
|
||||
case 'simple':
|
||||
return joinedValues;
|
||||
default:
|
||||
return `${name}=${joinedValues}`;
|
||||
}
|
||||
}
|
||||
if (!explode) {
|
||||
const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join(
|
||||
separatorArrayNoExplode(style),
|
||||
);
|
||||
switch (style) {
|
||||
case "label":
|
||||
return `.${joinedValues}`;
|
||||
case "matrix":
|
||||
return `;${name}=${joinedValues}`;
|
||||
case "simple":
|
||||
return joinedValues;
|
||||
default:
|
||||
return `${name}=${joinedValues}`;
|
||||
}
|
||||
}
|
||||
|
||||
const separator = separatorArrayExplode(style);
|
||||
const joinedValues = value
|
||||
.map((v) => {
|
||||
if (style === 'label' || style === 'simple') {
|
||||
return allowReserved ? v : encodeURIComponent(v as string);
|
||||
}
|
||||
const separator = separatorArrayExplode(style);
|
||||
const joinedValues = value
|
||||
.map((v) => {
|
||||
if (style === "label" || style === "simple") {
|
||||
return allowReserved ? v : encodeURIComponent(v as string);
|
||||
}
|
||||
|
||||
return serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name,
|
||||
value: v as string,
|
||||
});
|
||||
})
|
||||
.join(separator);
|
||||
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
|
||||
return serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name,
|
||||
value: v as string,
|
||||
});
|
||||
})
|
||||
.join(separator);
|
||||
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues;
|
||||
};
|
||||
|
||||
export const serializePrimitiveParam = ({
|
||||
allowReserved,
|
||||
name,
|
||||
value,
|
||||
}: SerializePrimitiveParam) => {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
throw new Error(
|
||||
'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.',
|
||||
);
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
throw new Error(
|
||||
"Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.",
|
||||
);
|
||||
}
|
||||
|
||||
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
||||
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
||||
};
|
||||
|
||||
export const serializeObjectParam = ({
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
valueOnly,
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
valueOnly,
|
||||
}: SerializeOptions<ObjectSeparatorStyle> & {
|
||||
value: Record<string, unknown> | Date;
|
||||
valueOnly?: boolean;
|
||||
value: Record<string, unknown> | Date;
|
||||
valueOnly?: boolean;
|
||||
}) => {
|
||||
if (value instanceof Date) {
|
||||
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
||||
}
|
||||
|
||||
if (style !== 'deepObject' && !explode) {
|
||||
let values: string[] = [];
|
||||
Object.entries(value).forEach(([key, v]) => {
|
||||
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
|
||||
});
|
||||
const joinedValues = values.join(',');
|
||||
switch (style) {
|
||||
case 'form':
|
||||
return `${name}=${joinedValues}`;
|
||||
case 'label':
|
||||
return `.${joinedValues}`;
|
||||
case 'matrix':
|
||||
return `;${name}=${joinedValues}`;
|
||||
default:
|
||||
return joinedValues;
|
||||
}
|
||||
}
|
||||
if (style !== "deepObject" && !explode) {
|
||||
let values: string[] = [];
|
||||
Object.entries(value).forEach(([key, v]) => {
|
||||
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
|
||||
});
|
||||
const joinedValues = values.join(",");
|
||||
switch (style) {
|
||||
case "form":
|
||||
return `${name}=${joinedValues}`;
|
||||
case "label":
|
||||
return `.${joinedValues}`;
|
||||
case "matrix":
|
||||
return `;${name}=${joinedValues}`;
|
||||
default:
|
||||
return joinedValues;
|
||||
}
|
||||
}
|
||||
|
||||
const separator = separatorObjectExplode(style);
|
||||
const joinedValues = Object.entries(value)
|
||||
.map(([key, v]) =>
|
||||
serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name: style === 'deepObject' ? `${name}[${key}]` : key,
|
||||
value: v as string,
|
||||
}),
|
||||
)
|
||||
.join(separator);
|
||||
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
|
||||
const separator = separatorObjectExplode(style);
|
||||
const joinedValues = Object.entries(value)
|
||||
.map(([key, v]) =>
|
||||
serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name: style === "deepObject" ? `${name}[${key}]` : key,
|
||||
value: v as string,
|
||||
}),
|
||||
)
|
||||
.join(separator);
|
||||
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,118 +1,111 @@
|
|||
// @ts-nocheck
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
/**
|
||||
* JSON-friendly union that mirrors what Pinia Colada can hash.
|
||||
*/
|
||||
export type JsonValue =
|
||||
| null
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
export type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
/**
|
||||
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
|
||||
*/
|
||||
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
|
||||
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
return value;
|
||||
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely stringifies a value and parses it back into a JsonValue.
|
||||
*/
|
||||
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
|
||||
try {
|
||||
const json = JSON.stringify(input, queryKeyJsonReplacer);
|
||||
if (json === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(json) as JsonValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const json = JSON.stringify(input, queryKeyJsonReplacer);
|
||||
if (json === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(json) as JsonValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects plain objects (including objects with a null prototype).
|
||||
*/
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value as object);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
if (value === null || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value as object);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
|
||||
*/
|
||||
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
||||
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||
const result: Record<string, JsonValue> = {};
|
||||
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||
const result: Record<string, JsonValue> = {};
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
const existing = result[key];
|
||||
if (existing === undefined) {
|
||||
result[key] = value;
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of entries) {
|
||||
const existing = result[key];
|
||||
if (existing === undefined) {
|
||||
result[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(existing)) {
|
||||
(existing as string[]).push(value);
|
||||
} else {
|
||||
result[key] = [existing, value];
|
||||
}
|
||||
}
|
||||
if (Array.isArray(existing)) {
|
||||
(existing as string[]).push(value);
|
||||
} else {
|
||||
result[key] = [existing, value];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes any accepted value into a JSON-friendly shape for query keys.
|
||||
*/
|
||||
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
||||
return undefined;
|
||||
}
|
||||
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
|
||||
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) {
|
||||
return serializeSearchParams(value);
|
||||
}
|
||||
if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) {
|
||||
return serializeSearchParams(value);
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,243 +1,239 @@
|
|||
// @ts-nocheck
|
||||
// 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'> &
|
||||
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Implementing clients can call request interceptors inside this hook.
|
||||
*/
|
||||
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
|
||||
/**
|
||||
* Callback invoked when a network or parsing error occurs during streaming.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param error The error that occurred.
|
||||
*/
|
||||
onSseError?: (error: unknown) => void;
|
||||
/**
|
||||
* Callback invoked when an event is streamed from the server.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param event Event streamed from the server.
|
||||
* @returns Nothing (void).
|
||||
*/
|
||||
onSseEvent?: (event: StreamEvent<TData>) => void;
|
||||
serializedBody?: RequestInit['body'];
|
||||
/**
|
||||
* Default retry delay in milliseconds.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 3000
|
||||
*/
|
||||
sseDefaultRetryDelay?: number;
|
||||
/**
|
||||
* Maximum number of retry attempts before giving up.
|
||||
*/
|
||||
sseMaxRetryAttempts?: number;
|
||||
/**
|
||||
* Maximum retry delay in milliseconds.
|
||||
*
|
||||
* Applies only when exponential backoff is used.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 30000
|
||||
*/
|
||||
sseMaxRetryDelay?: number;
|
||||
/**
|
||||
* Optional sleep function for retry backoff.
|
||||
*
|
||||
* Defaults to using `setTimeout`.
|
||||
*/
|
||||
sseSleepFn?: (ms: number) => Promise<void>;
|
||||
url: string;
|
||||
};
|
||||
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, "method"> &
|
||||
Pick<Config, "method" | "responseTransformer" | "responseValidator"> & {
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Implementing clients can call request interceptors inside this hook.
|
||||
*/
|
||||
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
|
||||
/**
|
||||
* Callback invoked when a network or parsing error occurs during streaming.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param error The error that occurred.
|
||||
*/
|
||||
onSseError?: (error: unknown) => void;
|
||||
/**
|
||||
* Callback invoked when an event is streamed from the server.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param event Event streamed from the server.
|
||||
* @returns Nothing (void).
|
||||
*/
|
||||
onSseEvent?: (event: StreamEvent<TData>) => void;
|
||||
serializedBody?: RequestInit["body"];
|
||||
/**
|
||||
* Default retry delay in milliseconds.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 3000
|
||||
*/
|
||||
sseDefaultRetryDelay?: number;
|
||||
/**
|
||||
* Maximum number of retry attempts before giving up.
|
||||
*/
|
||||
sseMaxRetryAttempts?: number;
|
||||
/**
|
||||
* Maximum retry delay in milliseconds.
|
||||
*
|
||||
* Applies only when exponential backoff is used.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 30000
|
||||
*/
|
||||
sseMaxRetryDelay?: number;
|
||||
/**
|
||||
* Optional sleep function for retry backoff.
|
||||
*
|
||||
* Defaults to using `setTimeout`.
|
||||
*/
|
||||
sseSleepFn?: (ms: number) => Promise<void>;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export interface StreamEvent<TData = unknown> {
|
||||
data: TData;
|
||||
event?: string;
|
||||
id?: string;
|
||||
retry?: number;
|
||||
data: TData;
|
||||
event?: string;
|
||||
id?: string;
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
|
||||
stream: AsyncGenerator<
|
||||
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
|
||||
TReturn,
|
||||
TNext
|
||||
>;
|
||||
stream: AsyncGenerator<TData extends Record<string, unknown> ? TData[keyof TData] : TData, TReturn, TNext>;
|
||||
};
|
||||
|
||||
export function createSseClient<TData = unknown>({
|
||||
onRequest,
|
||||
onSseError,
|
||||
onSseEvent,
|
||||
responseTransformer,
|
||||
responseValidator,
|
||||
sseDefaultRetryDelay,
|
||||
sseMaxRetryAttempts,
|
||||
sseMaxRetryDelay,
|
||||
sseSleepFn,
|
||||
url,
|
||||
...options
|
||||
}: ServerSentEventsOptions): ServerSentEventsResult<TData> {
|
||||
let lastEventId: string | undefined;
|
||||
export const createSseClient = <TData = unknown>({
|
||||
onRequest,
|
||||
onSseError,
|
||||
onSseEvent,
|
||||
responseTransformer,
|
||||
responseValidator,
|
||||
sseDefaultRetryDelay,
|
||||
sseMaxRetryAttempts,
|
||||
sseMaxRetryDelay,
|
||||
sseSleepFn,
|
||||
url,
|
||||
...options
|
||||
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
|
||||
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* () {
|
||||
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
||||
let attempt = 0;
|
||||
const signal = options.signal ?? new AbortController().signal;
|
||||
const createStream = async function* () {
|
||||
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
||||
let attempt = 0;
|
||||
const signal = options.signal ?? new AbortController().signal;
|
||||
|
||||
while (true) {
|
||||
if (signal.aborted) break;
|
||||
while (true) {
|
||||
if (signal.aborted) break;
|
||||
|
||||
attempt++;
|
||||
attempt++;
|
||||
|
||||
const headers =
|
||||
options.headers instanceof Headers
|
||||
? options.headers
|
||||
: new Headers(options.headers as Record<string, string> | undefined);
|
||||
const headers =
|
||||
options.headers instanceof Headers
|
||||
? options.headers
|
||||
: new Headers(options.headers as Record<string, string> | undefined);
|
||||
|
||||
if (lastEventId !== undefined) {
|
||||
headers.set('Last-Event-ID', lastEventId);
|
||||
}
|
||||
if (lastEventId !== undefined) {
|
||||
headers.set("Last-Event-ID", lastEventId);
|
||||
}
|
||||
|
||||
try {
|
||||
const requestInit: RequestInit = {
|
||||
redirect: 'follow',
|
||||
...options,
|
||||
body: options.serializedBody,
|
||||
headers,
|
||||
signal,
|
||||
};
|
||||
let request = new Request(url, requestInit);
|
||||
if (onRequest) {
|
||||
request = await onRequest(url, requestInit);
|
||||
}
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = options.fetch ?? globalThis.fetch;
|
||||
const response = await _fetch(request);
|
||||
try {
|
||||
const requestInit: RequestInit = {
|
||||
redirect: "follow",
|
||||
...options,
|
||||
body: options.serializedBody,
|
||||
headers,
|
||||
signal,
|
||||
};
|
||||
let request = new Request(url, requestInit);
|
||||
if (onRequest) {
|
||||
request = await onRequest(url, requestInit);
|
||||
}
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = options.fetch ?? globalThis.fetch;
|
||||
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 = () => {
|
||||
try {
|
||||
reader.cancel();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
const abortHandler = () => {
|
||||
try {
|
||||
reader.cancel();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', abortHandler);
|
||||
signal.addEventListener("abort", abortHandler);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += value;
|
||||
buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += value;
|
||||
// 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');
|
||||
buffer = chunks.pop() ?? '';
|
||||
const chunks = buffer.split("\n\n");
|
||||
buffer = chunks.pop() ?? "";
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const lines = chunk.split('\n');
|
||||
const dataLines: Array<string> = [];
|
||||
let eventName: string | undefined;
|
||||
for (const chunk of chunks) {
|
||||
const lines = chunk.split("\n");
|
||||
const dataLines: Array<string> = [];
|
||||
let eventName: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.replace(/^data:\s*/, ''));
|
||||
} else if (line.startsWith('event:')) {
|
||||
eventName = line.replace(/^event:\s*/, '');
|
||||
} else if (line.startsWith('id:')) {
|
||||
lastEventId = line.replace(/^id:\s*/, '');
|
||||
} else if (line.startsWith('retry:')) {
|
||||
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
retryDelay = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data:")) {
|
||||
dataLines.push(line.replace(/^data:\s*/, ""));
|
||||
} else if (line.startsWith("event:")) {
|
||||
eventName = line.replace(/^event:\s*/, "");
|
||||
} else if (line.startsWith("id:")) {
|
||||
lastEventId = line.replace(/^id:\s*/, "");
|
||||
} else if (line.startsWith("retry:")) {
|
||||
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
retryDelay = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
let parsedJson = false;
|
||||
let data: unknown;
|
||||
let parsedJson = false;
|
||||
|
||||
if (dataLines.length) {
|
||||
const rawData = dataLines.join('\n');
|
||||
try {
|
||||
data = JSON.parse(rawData);
|
||||
parsedJson = true;
|
||||
} catch {
|
||||
data = rawData;
|
||||
}
|
||||
}
|
||||
if (dataLines.length) {
|
||||
const rawData = dataLines.join("\n");
|
||||
try {
|
||||
data = JSON.parse(rawData);
|
||||
parsedJson = true;
|
||||
} catch {
|
||||
data = rawData;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedJson) {
|
||||
if (responseValidator) {
|
||||
await responseValidator(data);
|
||||
}
|
||||
if (parsedJson) {
|
||||
if (responseValidator) {
|
||||
await responseValidator(data);
|
||||
}
|
||||
|
||||
if (responseTransformer) {
|
||||
data = await responseTransformer(data);
|
||||
}
|
||||
}
|
||||
if (responseTransformer) {
|
||||
data = await responseTransformer(data);
|
||||
}
|
||||
}
|
||||
|
||||
onSseEvent?.({
|
||||
data,
|
||||
event: eventName,
|
||||
id: lastEventId,
|
||||
retry: retryDelay,
|
||||
});
|
||||
onSseEvent?.({
|
||||
data,
|
||||
event: eventName,
|
||||
id: lastEventId,
|
||||
retry: retryDelay,
|
||||
});
|
||||
|
||||
if (dataLines.length) {
|
||||
yield data as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
reader.releaseLock();
|
||||
}
|
||||
if (dataLines.length) {
|
||||
yield data as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abortHandler);
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
break; // exit loop on normal completion
|
||||
} catch (error) {
|
||||
// connection failed or aborted; retry after delay
|
||||
onSseError?.(error);
|
||||
break; // exit loop on normal completion
|
||||
} catch (error) {
|
||||
// connection failed or aborted; retry after delay
|
||||
onSseError?.(error);
|
||||
|
||||
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
|
||||
break; // stop after firing error
|
||||
}
|
||||
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
|
||||
break; // stop after firing error
|
||||
}
|
||||
|
||||
// exponential backoff: double retry each attempt, cap at 30s
|
||||
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
|
||||
await sleep(backoff);
|
||||
}
|
||||
}
|
||||
};
|
||||
// exponential backoff: double retry each attempt, cap at 30s
|
||||
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
|
||||
await sleep(backoff);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stream = createStream();
|
||||
const stream = createStream();
|
||||
|
||||
return { stream };
|
||||
}
|
||||
return { stream };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,105 +1,86 @@
|
|||
// @ts-nocheck
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Auth, AuthToken } from './auth.gen';
|
||||
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen';
|
||||
import type { Auth, AuthToken } from "./auth.gen";
|
||||
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen";
|
||||
|
||||
export type HttpMethod =
|
||||
| 'connect'
|
||||
| 'delete'
|
||||
| 'get'
|
||||
| 'head'
|
||||
| 'options'
|
||||
| 'patch'
|
||||
| 'post'
|
||||
| 'put'
|
||||
| 'trace';
|
||||
export type HttpMethod = "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";
|
||||
|
||||
export type Client<
|
||||
RequestFn = never,
|
||||
Config = unknown,
|
||||
MethodFn = never,
|
||||
BuildUrlFn = never,
|
||||
SseFn = never,
|
||||
> = {
|
||||
/**
|
||||
* Returns the final request URL.
|
||||
*/
|
||||
buildUrl: BuildUrlFn;
|
||||
getConfig: () => Config;
|
||||
request: RequestFn;
|
||||
setConfig: (config: Config) => Config;
|
||||
export type Client<RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never, SseFn = never> = {
|
||||
/**
|
||||
* Returns the final request URL.
|
||||
*/
|
||||
buildUrl: BuildUrlFn;
|
||||
getConfig: () => Config;
|
||||
request: RequestFn;
|
||||
setConfig: (config: Config) => Config;
|
||||
} & {
|
||||
[K in HttpMethod]: MethodFn;
|
||||
[K in HttpMethod]: MethodFn;
|
||||
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
|
||||
|
||||
export interface Config {
|
||||
/**
|
||||
* Auth token or a function returning auth token. The resolved value will be
|
||||
* added to the request payload as defined by its `security` array.
|
||||
*/
|
||||
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
||||
/**
|
||||
* A function for serializing request body parameter. By default,
|
||||
* {@link JSON.stringify()} will be used.
|
||||
*/
|
||||
bodySerializer?: BodySerializer | null;
|
||||
/**
|
||||
* An object containing any HTTP headers that you want to pre-populate your
|
||||
* `Headers` object with.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||
*/
|
||||
headers?:
|
||||
| RequestInit['headers']
|
||||
| Record<
|
||||
string,
|
||||
string | number | boolean | (string | number | boolean)[] | null | undefined | unknown
|
||||
>;
|
||||
/**
|
||||
* The request method.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
||||
*/
|
||||
method?: Uppercase<HttpMethod>;
|
||||
/**
|
||||
* A function for serializing request query parameters. By default, arrays
|
||||
* will be exploded in form style, objects will be exploded in deepObject
|
||||
* style, and reserved characters are percent-encoded.
|
||||
*
|
||||
* This method will have no effect if the native `paramsSerializer()` Axios
|
||||
* API function is used.
|
||||
*
|
||||
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
||||
*/
|
||||
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
||||
/**
|
||||
* A function validating request data. This is useful if you want to ensure
|
||||
* the request conforms to the desired shape, so it can be safely sent to
|
||||
* the server.
|
||||
*/
|
||||
requestValidator?: (data: unknown) => Promise<unknown>;
|
||||
/**
|
||||
* A function transforming response data before it's returned. This is useful
|
||||
* for post-processing data, e.g., converting ISO strings into Date objects.
|
||||
*/
|
||||
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>;
|
||||
/**
|
||||
* Auth token or a function returning auth token. The resolved value will be
|
||||
* added to the request payload as defined by its `security` array.
|
||||
*/
|
||||
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
||||
/**
|
||||
* A function for serializing request body parameter. By default,
|
||||
* {@link JSON.stringify()} will be used.
|
||||
*/
|
||||
bodySerializer?: BodySerializer | null;
|
||||
/**
|
||||
* An object containing any HTTP headers that you want to pre-populate your
|
||||
* `Headers` object with.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||
*/
|
||||
headers?:
|
||||
| RequestInit["headers"]
|
||||
| Record<string, string | number | boolean | (string | number | boolean)[] | null | undefined | unknown>;
|
||||
/**
|
||||
* The request method.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
||||
*/
|
||||
method?: Uppercase<HttpMethod>;
|
||||
/**
|
||||
* A function for serializing request query parameters. By default, arrays
|
||||
* will be exploded in form style, objects will be exploded in deepObject
|
||||
* style, and reserved characters are percent-encoded.
|
||||
*
|
||||
* This method will have no effect if the native `paramsSerializer()` Axios
|
||||
* API function is used.
|
||||
*
|
||||
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
||||
*/
|
||||
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
||||
/**
|
||||
* A function validating request data. This is useful if you want to ensure
|
||||
* the request conforms to the desired shape, so it can be safely sent to
|
||||
* the server.
|
||||
*/
|
||||
requestValidator?: (data: unknown) => Promise<unknown>;
|
||||
/**
|
||||
* A function transforming response data before it's returned. This is useful
|
||||
* for post-processing data, e.g. converting ISO strings into Date objects.
|
||||
*/
|
||||
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]
|
||||
? true
|
||||
: [T] extends [never | undefined]
|
||||
? [undefined] extends [T]
|
||||
? false
|
||||
: true
|
||||
: false;
|
||||
? true
|
||||
: [T] extends [never | undefined]
|
||||
? [undefined] extends [T]
|
||||
? false
|
||||
: true
|
||||
: false;
|
||||
|
||||
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];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,141 +1,137 @@
|
|||
// @ts-nocheck
|
||||
// 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 {
|
||||
type ArraySeparatorStyle,
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from './pathSerializer.gen';
|
||||
type ArraySeparatorStyle,
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from "./pathSerializer.gen";
|
||||
|
||||
export interface PathSerializer {
|
||||
path: Record<string, unknown>;
|
||||
url: string;
|
||||
path: Record<string, unknown>;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
||||
|
||||
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||
let url = _url;
|
||||
const matches = _url.match(PATH_PARAM_RE);
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
let explode = false;
|
||||
let name = match.substring(1, match.length - 1);
|
||||
let style: ArraySeparatorStyle = 'simple';
|
||||
let url = _url;
|
||||
const matches = _url.match(PATH_PARAM_RE);
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
let explode = false;
|
||||
let name = match.substring(1, match.length - 1);
|
||||
let style: ArraySeparatorStyle = "simple";
|
||||
|
||||
if (name.endsWith('*')) {
|
||||
explode = true;
|
||||
name = name.substring(0, name.length - 1);
|
||||
}
|
||||
if (name.endsWith("*")) {
|
||||
explode = true;
|
||||
name = name.substring(0, name.length - 1);
|
||||
}
|
||||
|
||||
if (name.startsWith('.')) {
|
||||
name = name.substring(1);
|
||||
style = 'label';
|
||||
} else if (name.startsWith(';')) {
|
||||
name = name.substring(1);
|
||||
style = 'matrix';
|
||||
}
|
||||
if (name.startsWith(".")) {
|
||||
name = name.substring(1);
|
||||
style = "label";
|
||||
} else if (name.startsWith(";")) {
|
||||
name = name.substring(1);
|
||||
style = "matrix";
|
||||
}
|
||||
|
||||
const value = path[name];
|
||||
const value = path[name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
url = url.replace(
|
||||
match,
|
||||
serializeObjectParam({
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value: value as Record<string, unknown>,
|
||||
valueOnly: true,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
url = url.replace(
|
||||
match,
|
||||
serializeObjectParam({
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value: value as Record<string, unknown>,
|
||||
valueOnly: true,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (style === 'matrix') {
|
||||
url = url.replace(
|
||||
match,
|
||||
`;${serializePrimitiveParam({
|
||||
name,
|
||||
value: value as string,
|
||||
})}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (style === "matrix") {
|
||||
url = url.replace(
|
||||
match,
|
||||
`;${serializePrimitiveParam({
|
||||
name,
|
||||
value: value as string,
|
||||
})}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const replaceValue = encodeURIComponent(
|
||||
style === 'label' ? `.${value as string}` : (value as string),
|
||||
);
|
||||
url = url.replace(match, replaceValue);
|
||||
}
|
||||
}
|
||||
return url;
|
||||
const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string));
|
||||
url = url.replace(match, replaceValue);
|
||||
}
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const getUrl = ({
|
||||
baseUrl,
|
||||
path,
|
||||
query,
|
||||
querySerializer,
|
||||
url: _url,
|
||||
baseUrl,
|
||||
path,
|
||||
query,
|
||||
querySerializer,
|
||||
url: _url,
|
||||
}: {
|
||||
baseUrl?: string;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
querySerializer: QuerySerializer;
|
||||
url: string;
|
||||
baseUrl?: string;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
querySerializer: QuerySerializer;
|
||||
url: string;
|
||||
}) => {
|
||||
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
|
||||
let url = (baseUrl ?? '') + pathUrl;
|
||||
if (path) {
|
||||
url = defaultPathSerializer({ path, url });
|
||||
}
|
||||
let search = query ? querySerializer(query) : '';
|
||||
if (search.startsWith('?')) {
|
||||
search = search.substring(1);
|
||||
}
|
||||
if (search) {
|
||||
url += `?${search}`;
|
||||
}
|
||||
return url;
|
||||
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`;
|
||||
let url = (baseUrl ?? "") + pathUrl;
|
||||
if (path) {
|
||||
url = defaultPathSerializer({ path, url });
|
||||
}
|
||||
let search = query ? querySerializer(query) : "";
|
||||
if (search.startsWith("?")) {
|
||||
search = search.substring(1);
|
||||
}
|
||||
if (search) {
|
||||
url += `?${search}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export function getValidRequestBody(options: {
|
||||
body?: unknown;
|
||||
bodySerializer?: BodySerializer | null;
|
||||
serializedBody?: unknown;
|
||||
body?: unknown;
|
||||
bodySerializer?: BodySerializer | null;
|
||||
serializedBody?: unknown;
|
||||
}) {
|
||||
const hasBody = options.body !== undefined;
|
||||
const isSerializedBody = hasBody && options.bodySerializer;
|
||||
const hasBody = options.body !== undefined;
|
||||
const isSerializedBody = hasBody && options.bodySerializer;
|
||||
|
||||
if (isSerializedBody) {
|
||||
if ('serializedBody' in options) {
|
||||
const hasSerializedBody =
|
||||
options.serializedBody !== undefined && options.serializedBody !== '';
|
||||
if (isSerializedBody) {
|
||||
if ("serializedBody" in options) {
|
||||
const hasSerializedBody = 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)
|
||||
return options.body !== '' ? options.body : null;
|
||||
}
|
||||
// not all clients implement a serializedBody property (i.e. client-axios)
|
||||
return options.body !== "" ? options.body : null;
|
||||
}
|
||||
|
||||
// plain/text body
|
||||
if (hasBody) {
|
||||
return options.body;
|
||||
}
|
||||
// plain/text body
|
||||
if (hasBody) {
|
||||
return options.body;
|
||||
}
|
||||
|
||||
// no body was provided
|
||||
return undefined;
|
||||
// no body was provided
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,30 +13,21 @@ export {
|
|||
deleteRepository,
|
||||
deleteSnapshot,
|
||||
deleteSnapshots,
|
||||
deleteSsoInvitation,
|
||||
deleteSsoProvider,
|
||||
deleteUserAccount,
|
||||
deleteVolume,
|
||||
devPanelExec,
|
||||
downloadResticPassword,
|
||||
dumpSnapshot,
|
||||
getAdminUsers,
|
||||
getBackupProgress,
|
||||
getBackupSchedule,
|
||||
getBackupScheduleForVolume,
|
||||
getDevPanel,
|
||||
getMirrorCompatibility,
|
||||
getMirrorSyncStatus,
|
||||
getNotificationDestination,
|
||||
getOrgMembers,
|
||||
getPublicSsoProviders,
|
||||
getRegistrationStatus,
|
||||
getRepository,
|
||||
getRepositoryStats,
|
||||
getScheduleMirrors,
|
||||
getScheduleNotifications,
|
||||
getSnapshotDetails,
|
||||
getSsoSettings,
|
||||
getStatus,
|
||||
getSystemInfo,
|
||||
getUpdates,
|
||||
|
|
@ -53,9 +44,7 @@ export {
|
|||
listVolumes,
|
||||
mountVolume,
|
||||
type Options,
|
||||
refreshRepositoryStats,
|
||||
refreshSnapshots,
|
||||
removeOrgMember,
|
||||
reorderBackupSchedules,
|
||||
restoreSnapshot,
|
||||
runBackupNow,
|
||||
|
|
@ -63,19 +52,16 @@ export {
|
|||
setRegistrationStatus,
|
||||
startDoctor,
|
||||
stopBackup,
|
||||
syncMirror,
|
||||
tagSnapshots,
|
||||
testConnection,
|
||||
testNotificationDestination,
|
||||
unlockRepository,
|
||||
unmountVolume,
|
||||
updateBackupSchedule,
|
||||
updateMemberRole,
|
||||
updateNotificationDestination,
|
||||
updateRepository,
|
||||
updateScheduleMirrors,
|
||||
updateScheduleNotifications,
|
||||
updateSsoProviderAutoLinking,
|
||||
updateVolume,
|
||||
} from "./sdk.gen";
|
||||
export type {
|
||||
|
|
@ -115,15 +101,6 @@ export type {
|
|||
DeleteSnapshotsData,
|
||||
DeleteSnapshotsResponse,
|
||||
DeleteSnapshotsResponses,
|
||||
DeleteSsoInvitationData,
|
||||
DeleteSsoInvitationErrors,
|
||||
DeleteSsoInvitationResponses,
|
||||
DeleteSsoProviderData,
|
||||
DeleteSsoProviderErrors,
|
||||
DeleteSsoProviderResponses,
|
||||
DeleteUserAccountData,
|
||||
DeleteUserAccountErrors,
|
||||
DeleteUserAccountResponses,
|
||||
DeleteVolumeData,
|
||||
DeleteVolumeResponse,
|
||||
DeleteVolumeResponses,
|
||||
|
|
@ -137,12 +114,6 @@ export type {
|
|||
DumpSnapshotData,
|
||||
DumpSnapshotResponse,
|
||||
DumpSnapshotResponses,
|
||||
GetAdminUsersData,
|
||||
GetAdminUsersResponse,
|
||||
GetAdminUsersResponses,
|
||||
GetBackupProgressData,
|
||||
GetBackupProgressResponse,
|
||||
GetBackupProgressResponses,
|
||||
GetBackupScheduleData,
|
||||
GetBackupScheduleForVolumeData,
|
||||
GetBackupScheduleForVolumeResponse,
|
||||
|
|
@ -155,19 +126,10 @@ export type {
|
|||
GetMirrorCompatibilityData,
|
||||
GetMirrorCompatibilityResponse,
|
||||
GetMirrorCompatibilityResponses,
|
||||
GetMirrorSyncStatusData,
|
||||
GetMirrorSyncStatusResponse,
|
||||
GetMirrorSyncStatusResponses,
|
||||
GetNotificationDestinationData,
|
||||
GetNotificationDestinationErrors,
|
||||
GetNotificationDestinationResponse,
|
||||
GetNotificationDestinationResponses,
|
||||
GetOrgMembersData,
|
||||
GetOrgMembersResponse,
|
||||
GetOrgMembersResponses,
|
||||
GetPublicSsoProvidersData,
|
||||
GetPublicSsoProvidersResponse,
|
||||
GetPublicSsoProvidersResponses,
|
||||
GetRegistrationStatusData,
|
||||
GetRegistrationStatusResponse,
|
||||
GetRegistrationStatusResponses,
|
||||
|
|
@ -186,9 +148,6 @@ export type {
|
|||
GetSnapshotDetailsData,
|
||||
GetSnapshotDetailsResponse,
|
||||
GetSnapshotDetailsResponses,
|
||||
GetSsoSettingsData,
|
||||
GetSsoSettingsResponse,
|
||||
GetSsoSettingsResponses,
|
||||
GetStatusData,
|
||||
GetStatusResponse,
|
||||
GetStatusResponses,
|
||||
|
|
@ -236,15 +195,9 @@ export type {
|
|||
MountVolumeData,
|
||||
MountVolumeResponse,
|
||||
MountVolumeResponses,
|
||||
RefreshRepositoryStatsData,
|
||||
RefreshRepositoryStatsResponse,
|
||||
RefreshRepositoryStatsResponses,
|
||||
RefreshSnapshotsData,
|
||||
RefreshSnapshotsResponse,
|
||||
RefreshSnapshotsResponses,
|
||||
RemoveOrgMemberData,
|
||||
RemoveOrgMemberErrors,
|
||||
RemoveOrgMemberResponses,
|
||||
ReorderBackupSchedulesData,
|
||||
ReorderBackupSchedulesResponse,
|
||||
ReorderBackupSchedulesResponses,
|
||||
|
|
@ -268,10 +221,6 @@ export type {
|
|||
StopBackupErrors,
|
||||
StopBackupResponse,
|
||||
StopBackupResponses,
|
||||
SyncMirrorData,
|
||||
SyncMirrorErrors,
|
||||
SyncMirrorResponse,
|
||||
SyncMirrorResponses,
|
||||
TagSnapshotsData,
|
||||
TagSnapshotsResponse,
|
||||
TagSnapshotsResponses,
|
||||
|
|
@ -291,9 +240,6 @@ export type {
|
|||
UpdateBackupScheduleData,
|
||||
UpdateBackupScheduleResponse,
|
||||
UpdateBackupScheduleResponses,
|
||||
UpdateMemberRoleData,
|
||||
UpdateMemberRoleErrors,
|
||||
UpdateMemberRoleResponses,
|
||||
UpdateNotificationDestinationData,
|
||||
UpdateNotificationDestinationErrors,
|
||||
UpdateNotificationDestinationResponse,
|
||||
|
|
@ -308,9 +254,6 @@ export type {
|
|||
UpdateScheduleNotificationsData,
|
||||
UpdateScheduleNotificationsResponse,
|
||||
UpdateScheduleNotificationsResponses,
|
||||
UpdateSsoProviderAutoLinkingData,
|
||||
UpdateSsoProviderAutoLinkingErrors,
|
||||
UpdateSsoProviderAutoLinkingResponses,
|
||||
UpdateVolumeData,
|
||||
UpdateVolumeErrors,
|
||||
UpdateVolumeResponse,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,48 +1,8 @@
|
|||
/** biome-ignore-all lint/style/noNonNullAssertion: Testing file - non-null assertions are acceptable here */
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { cleanup, render, screen, fireEvent, within } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
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" },
|
||||
|
|
@ -185,19 +145,6 @@ describe("FileTree Pagination", () => {
|
|||
|
||||
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", () => {
|
||||
|
|
@ -210,67 +157,154 @@ describe("FileTree Selection Logic", () => {
|
|||
{ name: "upload", path: "/root/photos/upload", type: "folder" },
|
||||
];
|
||||
|
||||
test("selecting a folder simplifies to parent if it's the only child", () => {
|
||||
render(<FileTreeSelection files={testFiles} expandedFolders={new Set(testFiles.map((f) => f.path))} />);
|
||||
test("selecting a folder simplifies to parent if it's the only child", async () => {
|
||||
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(
|
||||
<FileTreeSelection
|
||||
<FileTree
|
||||
files={testFiles}
|
||||
initialSelectedPaths={["/root"]}
|
||||
withCheckboxes={true}
|
||||
selectedPaths={currentSelection}
|
||||
onSelectionChange={onSelectionChange}
|
||||
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", () => {
|
||||
render(<FileTreeSelection files={testFiles} expandedFolders={new Set(testFiles.map((f) => f.path))} />);
|
||||
test("unselecting a child removes the parent from selection", async () => {
|
||||
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"];
|
||||
|
||||
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[] = [
|
||||
{ name: "root", path: "/root", type: "folder" },
|
||||
{ name: "child1", path: "/root/child1", 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[] = [
|
||||
{ name: "hello", path: "/hello", type: "folder" },
|
||||
{ name: "hello_prev", path: "/hello_prev", 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(
|
||||
<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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { Fragment } from "react";
|
||||
import { useMatches, Link } from "@tanstack/react-router";
|
||||
import {
|
||||
Breadcrumb,
|
||||
|
|
@ -42,7 +41,7 @@ export function AppBreadcrumb() {
|
|||
const isLast = index === breadcrumbs.length - 1;
|
||||
|
||||
return (
|
||||
<Fragment key={`${breadcrumb.label}-${index}`}>
|
||||
<div key={`${breadcrumb.label}-${index}`} className="contents">
|
||||
<BreadcrumbItem>
|
||||
{isLast || !breadcrumb.href ? (
|
||||
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
|
||||
|
|
@ -53,7 +52,7 @@ export function AppBreadcrumb() {
|
|||
)}
|
||||
</BreadcrumbItem>
|
||||
{!isLast && <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Bell, CalendarClock, Database, HardDrive, Settings, ShieldCheck } from "lucide-react";
|
||||
import { Bell, CalendarClock, Database, HardDrive, Settings } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Sidebar,
|
||||
|
|
@ -10,7 +10,6 @@ import {
|
|||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarSeparator,
|
||||
useSidebar,
|
||||
} from "~/client/components/ui/sidebar";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
|
|
@ -19,7 +18,6 @@ import { cn } from "~/client/lib/utils";
|
|||
import { APP_VERSION, RCLONE_VERSION, RESTIC_VERSION, SHOUTRRR_VERSION } from "~/client/lib/version";
|
||||
import { useUpdates } from "~/client/hooks/use-updates";
|
||||
import { ReleaseNotesDialog } from "./release-notes-dialog";
|
||||
import { OrganizationSwitcher } from "./organization-switcher";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
const items = [
|
||||
|
|
@ -50,17 +48,11 @@ const items = [
|
|||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
isInstanceAdmin: boolean;
|
||||
};
|
||||
|
||||
export function AppSidebar({ isInstanceAdmin }: Props) {
|
||||
export function AppSidebar() {
|
||||
const { state, isMobile, setOpenMobile } = useSidebar();
|
||||
const { updates, hasUpdate } = useUpdates();
|
||||
const [showReleaseNotes, setShowReleaseNotes] = useState(false);
|
||||
|
||||
const isCollapsed = state === "collapsed";
|
||||
|
||||
const displayVersion = APP_VERSION.startsWith("v") || APP_VERSION === "dev" ? APP_VERSION : `v${APP_VERSION}`;
|
||||
const releaseUrl =
|
||||
APP_VERSION === "dev"
|
||||
|
|
@ -69,12 +61,12 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
|
|||
|
||||
return (
|
||||
<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">
|
||||
<img src="/images/zerobyte.png" alt="Zerobyte Logo" className={cn("h-8 w-8 shrink-0 object-contain -ml-2")} />
|
||||
<span
|
||||
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
|
||||
|
|
@ -90,43 +82,18 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
|
|||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SidebarMenuButton asChild className="relative overflow-hidden">
|
||||
<Link
|
||||
to={item.url}
|
||||
onClick={() => isMobile && setOpenMobile(false)}
|
||||
activeProps={{ className: "bg-strong-accent/10" }}
|
||||
className="w-full flex items-center gap-2"
|
||||
>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link to={item.url} onClick={() => isMobile && setOpenMobile(false)}>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && (
|
||||
<div
|
||||
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>
|
||||
<item.icon className={cn({ "text-strong-accent": isActive })} />
|
||||
<span className={cn({ "text-strong-accent": isActive })}>{item.title}</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className={cn({ hidden: !isCollapsed })}>
|
||||
<TooltipContent side="right" className={cn({ hidden: state !== "collapsed" })}>
|
||||
<p>{item.title}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -136,66 +103,8 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
|
|||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</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>
|
||||
<SidebarFooter className="p-4 border-r border-border/80 dark:border-border/50">
|
||||
<OrganizationSwitcher />
|
||||
<SidebarFooter className="p-4 border-r border-t border-border/50">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
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 type { ResticSnapshotSummaryDto } from "~/schemas/restic-dto";
|
||||
import { formatDuration } from "~/utils/utils";
|
||||
|
||||
type Props = {
|
||||
summary?: ResticSnapshotSummaryDto | null;
|
||||
};
|
||||
|
||||
const formatCount = (value: number) => value.toLocaleString();
|
||||
|
||||
const getDurationLabel = (start: string, end: string) => {
|
||||
const startMs = new Date(start).getTime();
|
||||
const endMs = new Date(end).getTime();
|
||||
|
|
@ -16,11 +17,8 @@ const getDurationLabel = (start: string, end: string) => {
|
|||
};
|
||||
|
||||
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 = [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type React from "react";
|
||||
import { useFormatBytes } from "~/client/hooks/use-format-bytes";
|
||||
import { formatBytes } from "~/utils/format-bytes";
|
||||
|
||||
type ByteSizeProps = {
|
||||
bytes: number;
|
||||
|
|
@ -14,7 +14,6 @@ type ByteSizeProps = {
|
|||
};
|
||||
|
||||
export function ByteSize(props: ByteSizeProps) {
|
||||
const formatBytes = useFormatBytes();
|
||||
const {
|
||||
bytes,
|
||||
base = 1000,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { AlertCircle, CheckCircle2 } from "lucide-react";
|
|||
import { useMemo } from "react";
|
||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||
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";
|
||||
|
||||
interface CronInputProps {
|
||||
|
|
@ -13,7 +13,6 @@ interface CronInputProps {
|
|||
}
|
||||
|
||||
export function CronInput({ value, onChange, error }: CronInputProps) {
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
const { isValid, nextRuns, parseError } = useMemo(() => {
|
||||
if (!value) {
|
||||
return { isValid: false, nextRuns: [], parseError: null };
|
||||
|
|
@ -60,7 +59,7 @@ export function CronInput({ value, onChange, error }: CronInputProps) {
|
|||
{value && (
|
||||
<div>
|
||||
{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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useHotkeySequence } from "@tanstack/react-hotkeys";
|
||||
import { useHotkey } from "@tanstack/react-hotkeys";
|
||||
import { getDevPanelOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { DevPanel } from "./dev-panel";
|
||||
|
||||
|
|
@ -10,12 +10,7 @@ export function DevPanelListener() {
|
|||
...getDevPanelOptions(),
|
||||
});
|
||||
|
||||
useHotkeySequence(["D", "E", "V"], () => setIsOpen(true), {
|
||||
enabled: !!devPanelStatus?.enabled,
|
||||
preventDefault: true,
|
||||
ignoreInputs: true,
|
||||
timeout: 1000,
|
||||
});
|
||||
useHotkey("Mod+Shift+D", () => setIsOpen(true), { enabled: !!devPanelStatus?.enabled, preventDefault: true });
|
||||
|
||||
if (!devPanelStatus?.enabled) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,32 @@ type SseErrorEvent = {
|
|||
|
||||
type SseEvent = SseOutputEvent | SseDoneEvent | SseErrorEvent;
|
||||
|
||||
async function processStream(
|
||||
stream: AsyncIterable<unknown>,
|
||||
appendOutput: (line: string) => void,
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<void> {
|
||||
for await (const event of stream) {
|
||||
if (abortSignal.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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function DevPanel({ open, onOpenChange }: DevPanelProps) {
|
||||
const { data: repositories = [] } = useQuery({
|
||||
...listRepositoriesOptions(),
|
||||
|
|
@ -41,10 +67,11 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
|
|||
|
||||
const [selectedRepoId, setSelectedRepoId] = useState<string>("");
|
||||
const [commandLine, setCommandLine] = useState("snapshots");
|
||||
const [output, setOutput] = useState<string[]>([]);
|
||||
const [output, setOutput] = useState<Array<{ id: number; line: string }>>([]);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const outputRef = useRef<HTMLDivElement>(null);
|
||||
const outputIdRef = useRef(0);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (outputRef.current) {
|
||||
|
|
@ -55,7 +82,7 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
|
|||
const appendOutput = useCallback(
|
||||
(line: string) => {
|
||||
setOutput((prev) => {
|
||||
const newOutput = [...prev, line];
|
||||
const newOutput = [...prev, { id: outputIdRef.current++, line }];
|
||||
setTimeout(scrollToBottom, 0);
|
||||
return newOutput;
|
||||
});
|
||||
|
|
@ -69,6 +96,7 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
|
|||
}
|
||||
|
||||
setOutput([]);
|
||||
outputIdRef.current = 0;
|
||||
setIsRunning(true);
|
||||
appendOutput(`$ restic ${commandLine}`.trim());
|
||||
appendOutput("---");
|
||||
|
|
@ -80,6 +108,7 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
|
|||
const command = parts[0];
|
||||
const argsArray = parts.slice(1);
|
||||
|
||||
let success = false;
|
||||
try {
|
||||
const result = await devPanelExec({
|
||||
path: { shortId: selectedRepoId },
|
||||
|
|
@ -87,25 +116,8 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
|
|||
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}`);
|
||||
}
|
||||
}
|
||||
await processStream(result.stream, appendOutput, abortControllerRef.current.signal);
|
||||
success = true;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
appendOutput("---");
|
||||
|
|
@ -113,7 +125,9 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
|
|||
} else {
|
||||
appendOutput(`Error: ${parseError(err)?.message}`);
|
||||
}
|
||||
} finally {
|
||||
}
|
||||
|
||||
if (success || !abortControllerRef.current?.signal.aborted) {
|
||||
setIsRunning(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
|
|
@ -125,6 +139,7 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
|
|||
|
||||
const handleClear = () => {
|
||||
setOutput([]);
|
||||
outputIdRef.current = 0;
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
|
|
@ -198,7 +213,7 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
|
|||
Output will appear here...
|
||||
</div>
|
||||
<div className={cn("space-y-0.5", { hidden: output.length === 0 })}>
|
||||
{output.map((line, i) => {
|
||||
{output.map(({ id, line }) => {
|
||||
let displayLine = line;
|
||||
let isJson = false;
|
||||
try {
|
||||
|
|
@ -210,7 +225,7 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
|
|||
}
|
||||
return (
|
||||
<pre
|
||||
key={`${i}-${line.slice(0, 20)}`}
|
||||
key={id}
|
||||
className={cn("whitespace-pre-wrap break-all text-xs", {
|
||||
"wrap-break-word text-[10px] leading-tight": isJson,
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -134,10 +134,15 @@ export const FileBrowser = (props: FileBrowserProps) => {
|
|||
}
|
||||
|
||||
const bodyWithScroll = useScrollArea ? <ScrollArea className={scrollAreaClassName}>{body}</ScrollArea> : body;
|
||||
const wrappedBody = treeContainerClassName ? (
|
||||
<div className={treeContainerClassName}>{bodyWithScroll}</div>
|
||||
) : (
|
||||
bodyWithScroll
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={treeContainerClassName}>{bodyWithScroll}</div>
|
||||
{wrappedBody}
|
||||
{showSelectedPathFooter && resolvedSelectedPath && (
|
||||
<div className="bg-muted/50 border-t p-2 text-sm">
|
||||
<div className="font-medium text-muted-foreground">{selectedPathLabel}</div>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ import { browseFilesystemOptions } from "~/client/api-client/@tanstack/react-que
|
|||
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";
|
||||
import { normalizeAbsolutePath } from "~/utils/path";
|
||||
|
||||
type LocalFileBrowserProps = FileBrowserUiProps & {
|
||||
initialPath?: string;
|
||||
|
|
@ -27,10 +26,17 @@ export const LocalFileBrowser = ({ initialPath = "/", enabled = true, ...uiProps
|
|||
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
|
||||
},
|
||||
prefetchFolder: (path) => {
|
||||
void queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } })).catch((e) => logger.error(e));
|
||||
void queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
|
||||
},
|
||||
});
|
||||
|
||||
const errorDetails = parseError(error)?.message;
|
||||
const errorMessage = errorDetails
|
||||
? `Failed to load directories: ${errorDetails}`
|
||||
: error
|
||||
? "Failed to load directories"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<FileBrowser
|
||||
{...uiProps}
|
||||
|
|
@ -43,7 +49,7 @@ export const LocalFileBrowser = ({ initialPath = "/", enabled = true, ...uiProps
|
|||
getFolderPagination={fileBrowser.getFolderPagination}
|
||||
isLoading={fileBrowser.isLoading}
|
||||
isEmpty={fileBrowser.isEmpty}
|
||||
errorMessage={parseError(error)?.message}
|
||||
errorMessage={errorMessage}
|
||||
loadingMessage={uiProps.loadingMessage ?? "Loading directories..."}
|
||||
emptyMessage={uiProps.emptyMessage ?? "No subdirectories found"}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -4,107 +4,106 @@ import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-qu
|
|||
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}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
import { normalizeAbsolutePath } from "~/utils/path";
|
||||
|
||||
type SnapshotTreeBrowserProps = FileBrowserUiProps & {
|
||||
repositoryId: string;
|
||||
snapshotId: string;
|
||||
queryBasePath?: string;
|
||||
displayBasePath?: string;
|
||||
basePath?: 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;
|
||||
|
||||
export const SnapshotTreeBrowser = ({
|
||||
repositoryId,
|
||||
snapshotId,
|
||||
basePath = "/",
|
||||
pageSize = 500,
|
||||
enabled = true,
|
||||
...uiProps
|
||||
}: SnapshotTreeBrowserProps) => {
|
||||
const { selectedPaths, onSelectionChange, onSingleSelectionKindChange, ...fileBrowserUiProps } = uiProps;
|
||||
const queryClient = useQueryClient();
|
||||
const normalizedQueryBasePath = normalizeAbsolutePath(queryBasePath);
|
||||
const normalizedDisplayBasePath = normalizeAbsolutePath(displayBasePath ?? "/");
|
||||
const effectiveDisplayBasePath = isPathWithin(normalizedDisplayBasePath, normalizedQueryBasePath)
|
||||
? normalizedDisplayBasePath
|
||||
: "/";
|
||||
const normalizedBasePath = normalizeAbsolutePath(basePath);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
...listSnapshotFilesOptions({
|
||||
path: { shortId: repositoryId, snapshotId },
|
||||
query: { path: normalizedQueryBasePath },
|
||||
query: { path: normalizedBasePath },
|
||||
}),
|
||||
enabled,
|
||||
});
|
||||
|
||||
const displayPathFns = useMemo(() => createPathPrefixFns(effectiveDisplayBasePath), [effectiveDisplayBasePath]);
|
||||
const stripBasePath = useCallback(
|
||||
(path: string): string => {
|
||||
if (normalizedBasePath === "/") return path;
|
||||
if (path === normalizedBasePath) return "/";
|
||||
if (path.startsWith(`${normalizedBasePath}/`)) {
|
||||
return path.slice(normalizedBasePath.length);
|
||||
}
|
||||
return path;
|
||||
},
|
||||
[normalizedBasePath],
|
||||
);
|
||||
|
||||
const addBasePath = useCallback(
|
||||
(displayPath: string): string => {
|
||||
if (normalizedBasePath === "/") return displayPath;
|
||||
if (displayPath === "/") return normalizedBasePath;
|
||||
return `${normalizedBasePath}${displayPath}`;
|
||||
},
|
||||
[normalizedBasePath],
|
||||
);
|
||||
|
||||
const displaySelectedPaths = useMemo(() => {
|
||||
if (!selectedPaths) return undefined;
|
||||
|
||||
const displayPaths = new Set<string>();
|
||||
for (const fullPath of selectedPaths) {
|
||||
displayPaths.add(displayPathFns.strip(fullPath));
|
||||
displayPaths.add(stripBasePath(fullPath));
|
||||
}
|
||||
|
||||
return displayPaths;
|
||||
}, [displayPathFns, selectedPaths]);
|
||||
}, [selectedPaths, stripBasePath]);
|
||||
|
||||
const fileBrowser = useFileBrowser({
|
||||
initialData: data,
|
||||
isLoading,
|
||||
fetchFolder: async (displayPath, offset = 0) => {
|
||||
fetchFolder: async (path, offset = 0) => {
|
||||
return await queryClient.ensureQueryData(
|
||||
listSnapshotFilesOptions({
|
||||
path: { shortId: repositoryId, snapshotId },
|
||||
query: { path: displayPath, offset: offset, limit: pageSize },
|
||||
query: {
|
||||
path,
|
||||
offset: offset.toString(),
|
||||
limit: pageSize.toString(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
pathTransform: displayPathFns,
|
||||
prefetchFolder: (path) => {
|
||||
void queryClient.prefetchQuery(
|
||||
listSnapshotFilesOptions({
|
||||
path: { shortId: repositoryId, snapshotId },
|
||||
query: {
|
||||
path,
|
||||
offset: "0",
|
||||
limit: pageSize.toString(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
pathTransform: {
|
||||
strip: stripBasePath,
|
||||
add: addBasePath,
|
||||
},
|
||||
});
|
||||
|
||||
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]);
|
||||
|
|
@ -115,7 +114,7 @@ export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
|
|||
|
||||
const nextFullPaths = new Set<string>();
|
||||
for (const displayPath of nextDisplayPaths) {
|
||||
nextFullPaths.add(displayPathFns.add(displayPath));
|
||||
nextFullPaths.add(addBasePath(displayPath));
|
||||
}
|
||||
|
||||
if (onSingleSelectionKindChange) {
|
||||
|
|
@ -133,9 +132,16 @@ export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
|
|||
|
||||
onSelectionChange(nextFullPaths);
|
||||
},
|
||||
[displayPathFns, displayPathKinds, onSelectionChange, onSingleSelectionKindChange],
|
||||
[onSelectionChange, addBasePath, onSingleSelectionKindChange, displayPathKinds],
|
||||
);
|
||||
|
||||
const errorDetails = parseError(error)?.message;
|
||||
const errorMessage = errorDetails
|
||||
? `Failed to load files: ${errorDetails}`
|
||||
: error
|
||||
? "Failed to load files"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<FileBrowser
|
||||
{...fileBrowserUiProps}
|
||||
|
|
@ -148,7 +154,7 @@ export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
|
|||
getFolderPagination={fileBrowser.getFolderPagination}
|
||||
isLoading={fileBrowser.isLoading}
|
||||
isEmpty={fileBrowser.isEmpty}
|
||||
errorMessage={parseError(error)?.message}
|
||||
errorMessage={errorMessage}
|
||||
loadingMessage={fileBrowserUiProps.loadingMessage ?? "Loading files..."}
|
||||
selectedPaths={displaySelectedPaths}
|
||||
onSelectionChange={onSelectionChange ? handleSelectionChange : undefined}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ 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;
|
||||
|
|
@ -25,22 +24,27 @@ export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: Volu
|
|||
return await queryClient.ensureQueryData(
|
||||
listFilesOptions({
|
||||
path: { shortId: volumeId },
|
||||
query: { path, offset: offset },
|
||||
query: { path, offset: offset?.toString() },
|
||||
}),
|
||||
);
|
||||
},
|
||||
prefetchFolder: (path) => {
|
||||
void queryClient
|
||||
.prefetchQuery(
|
||||
listFilesOptions({
|
||||
path: { shortId: volumeId },
|
||||
query: { path },
|
||||
}),
|
||||
)
|
||||
.catch((e) => logger.error(e));
|
||||
void queryClient.prefetchQuery(
|
||||
listFilesOptions({
|
||||
path: { shortId: volumeId },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const errorDetails = parseError(error)?.message;
|
||||
const errorMessage = errorDetails
|
||||
? `Failed to load files: ${errorDetails}`
|
||||
: error
|
||||
? "Failed to load files"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<FileBrowser
|
||||
{...uiProps}
|
||||
|
|
@ -53,7 +57,7 @@ export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: Volu
|
|||
getFolderPagination={fileBrowser.getFolderPagination}
|
||||
isLoading={fileBrowser.isLoading}
|
||||
isEmpty={fileBrowser.isEmpty}
|
||||
errorMessage={parseError(error)?.message}
|
||||
errorMessage={errorMessage}
|
||||
loadingMessage={uiProps.loadingMessage ?? "Loading files..."}
|
||||
emptyMessage={uiProps.emptyMessage ?? "This volume appears to be empty."}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -114,25 +114,20 @@ export const FileTree = memo((props: Props) => {
|
|||
|
||||
const toggleCollapseState = useCallback(
|
||||
(fullPath: string) => {
|
||||
const shouldExpand = collapsedFolders.has(fullPath);
|
||||
|
||||
setCollapsedFolders((prevSet) => {
|
||||
const newSet = new Set(prevSet);
|
||||
|
||||
if (newSet.has(fullPath)) {
|
||||
newSet.delete(fullPath);
|
||||
onFolderExpand?.(fullPath);
|
||||
} else {
|
||||
newSet.add(fullPath);
|
||||
}
|
||||
|
||||
return newSet;
|
||||
});
|
||||
|
||||
if (shouldExpand) {
|
||||
onFolderExpand?.(fullPath);
|
||||
}
|
||||
},
|
||||
[collapsedFolders, onFolderExpand],
|
||||
[onFolderExpand],
|
||||
);
|
||||
|
||||
// Add new folders to collapsed set when file list changes
|
||||
|
|
@ -586,10 +581,8 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
|
|||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
// oxlint-disable-next-line jsx_a11y/prefer-tag-over-role
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
<button
|
||||
type="button"
|
||||
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
|
||||
style={{ paddingLeft }}
|
||||
onClick={onClick}
|
||||
|
|
@ -598,7 +591,7 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
|
|||
>
|
||||
{icon}
|
||||
<div className="truncate w-full flex items-center gap-2">{children}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -632,10 +625,6 @@ function buildFileList(files: FileEntry[], foldersOnly = false): Node[] {
|
|||
const depth = segments.length - 1;
|
||||
const name = segments[segments.length - 1];
|
||||
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fileMap.has(file.path)) {
|
||||
const isFile = file.type === "file";
|
||||
fileMap.set(file.path, {
|
||||
|
|
@ -647,33 +636,6 @@ function buildFileList(files: FileEntry[], foldersOnly = false): Node[] {
|
|||
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
|
||||
|
|
@ -740,7 +702,5 @@ function compareNodes(a: Node, b: Node): number {
|
|||
return a.kind === "folder" ? -1 : 1;
|
||||
}
|
||||
|
||||
if (a.name < b.name) return -1;
|
||||
if (a.name > b.name) return 1;
|
||||
return 0;
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,17 +9,16 @@ interface GridBackgroundProps {
|
|||
|
||||
export function GridBackground({ children, className, containerClassName }: GridBackgroundProps) {
|
||||
return (
|
||||
<div className={cn("relative min-h-full w-full", containerClassName)}>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 w-full h-full",
|
||||
"bg-[size:40px_40px]",
|
||||
"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)]",
|
||||
"[mask-image:radial-gradient(ellipse_at_top,black_70%,transparent_100%)]",
|
||||
)}
|
||||
/>
|
||||
<div className={cn("relative container m-auto z-10", className)}>{children}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-full w-full",
|
||||
"bg-size-[40px_40px]",
|
||||
"bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
|
||||
"dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
<div className={cn("relative container m-auto", className)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
import { LifeBuoy, LogOut } from "lucide-react";
|
||||
import { LifeBuoy } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { type AppContext } from "~/context";
|
||||
import { GridBackground } from "./grid-background";
|
||||
import { Button, buttonVariants } from "./ui/button";
|
||||
import { Button } from "./ui/button";
|
||||
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
|
||||
import { AppSidebar } from "./app-sidebar";
|
||||
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 = {
|
||||
loaderData: AppContext;
|
||||
|
|
@ -34,51 +32,36 @@ export function Layout({ loaderData }: Props) {
|
|||
|
||||
return (
|
||||
<SidebarProvider defaultOpen={loaderData.sidebarOpen}>
|
||||
<AppSidebar isInstanceAdmin={loaderData.user?.role === "admin"} />
|
||||
<AppSidebar />
|
||||
<div className="w-full relative flex flex-col min-h-screen md:h-screen md:overflow-hidden">
|
||||
<header className="z-50 bg-card-header border-b border-border/80 dark:border-border/50 shrink-0 h-16.25">
|
||||
<div className="flex items-center h-full justify-between px-2 sm:px-8 mx-auto container gap-4">
|
||||
<header className="z-50 bg-card-header border-b border-border/50 shrink-0">
|
||||
<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">
|
||||
<SidebarTrigger />
|
||||
<AppBreadcrumb />
|
||||
</div>
|
||||
{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">
|
||||
<span className="text-sm text-muted-foreground hidden md:inline-flex pl-2 mr-5">
|
||||
<span className="text-foreground">{loaderData.user.name}</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground hidden md:inline-flex">
|
||||
Welcome,
|
||||
<span className="text-strong-accent">{loaderData.user?.username}</span>
|
||||
</span>
|
||||
<ThemeToggle />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full h-7 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Logout</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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>
|
||||
<Button variant="default" size="sm" onClick={handleLogout}>
|
||||
Logout
|
||||
</Button>
|
||||
<Button variant="default" size="sm" className="relative overflow-hidden hidden lg:inline-flex">
|
||||
<a
|
||||
href="https://github.com/nicotsx/zerobyte/issues/new"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LifeBuoy />
|
||||
<span>Report an issue</span>
|
||||
</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -15,7 +15,7 @@ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: P
|
|||
className={cn(
|
||||
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors",
|
||||
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",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import ReactMarkdown from "react-markdown";
|
|||
import remarkGfm from "remark-gfm";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog";
|
||||
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";
|
||||
|
||||
interface ReleaseNotesDialogProps {
|
||||
|
|
@ -12,8 +12,6 @@ interface ReleaseNotesDialogProps {
|
|||
}
|
||||
|
||||
export function ReleaseNotesDialog({ open, onOpenChange, updates }: ReleaseNotesDialogProps) {
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
if (!updates) return null;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Database, HardDrive, Cloud, Server } from "lucide-react";
|
||||
import type { RepositoryBackend } from "@zerobyte/core/restic";
|
||||
import type { RepositoryBackend } from "~/schemas/restic";
|
||||
|
||||
type Props = {
|
||||
backend: RepositoryBackend;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AlertTriangle, ChevronDown, Download, FolderOpen, RotateCcw } from "lucide-react";
|
||||
import { ChevronDown, Download, FolderOpen, RotateCcw } from "lucide-react";
|
||||
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 { Input } from "~/client/components/ui/input";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
|
|
@ -22,8 +21,7 @@ import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-
|
|||
import { RestoreProgress } from "~/client/components/restore-progress";
|
||||
import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events";
|
||||
import { OVERWRITE_MODES, type OverwriteMode } from "@zerobyte/core/restic";
|
||||
import { isPathWithin } from "@zerobyte/core/utils";
|
||||
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
|
||||
import type { Repository } from "~/client/lib/types";
|
||||
import { handleRepositoryError } from "~/client/lib/errors";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
|
@ -35,29 +33,16 @@ interface RestoreFormProps {
|
|||
repository: Repository;
|
||||
snapshotId: string;
|
||||
returnPath: string;
|
||||
queryBasePath?: string;
|
||||
displayBasePath?: string;
|
||||
hasNonPosixSnapshotPaths?: boolean;
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
export function RestoreForm({
|
||||
repository,
|
||||
snapshotId,
|
||||
returnPath,
|
||||
queryBasePath,
|
||||
displayBasePath,
|
||||
hasNonPosixSnapshotPaths = false,
|
||||
}: RestoreFormProps) {
|
||||
export function RestoreForm({ repository, snapshotId, returnPath, basePath }: RestoreFormProps) {
|
||||
const navigate = useNavigate();
|
||||
const { addEventListener } = useServerEvents();
|
||||
|
||||
const snapshotBasePath = queryBasePath ?? "/";
|
||||
const hasMismatchedDisplayBasePath = displayBasePath && !isPathWithin(displayBasePath, snapshotBasePath);
|
||||
const restoreRequiresCustomTarget = hasNonPosixSnapshotPaths || hasMismatchedDisplayBasePath;
|
||||
const volumeBasePath = basePath ?? "/";
|
||||
|
||||
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>(
|
||||
restoreRequiresCustomTarget ? "custom" : "original",
|
||||
);
|
||||
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>("original");
|
||||
const [customTargetPath, setCustomTargetPath] = useState("");
|
||||
const [overwriteMode, setOverwriteMode] = useState<OverwriteMode>("always");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
|
@ -69,15 +54,6 @@ export function RestoreForm({
|
|||
|
||||
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(() => {
|
||||
if (restoreRequiresCustomTarget) {
|
||||
setRestoreLocation("custom");
|
||||
}
|
||||
}, [restoreRequiresCustomTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
|
|
@ -137,13 +113,13 @@ export function RestoreForm({
|
|||
});
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
const excludeXattrValues = excludeXattr
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
const excludeXattrArray = excludeXattr
|
||||
?.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const isCustomLocation = restoreLocation === "custom";
|
||||
const targetPath = isCustomLocation && hasCustomTargetPath ? trimmedCustomTargetPath : undefined;
|
||||
const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined;
|
||||
|
||||
const includePaths = Array.from(selectedPaths);
|
||||
|
||||
|
|
@ -157,8 +133,7 @@ export function RestoreForm({
|
|||
body: {
|
||||
snapshotId,
|
||||
include: includePaths.length > 0 ? includePaths : undefined,
|
||||
selectedItemKind: includePaths.length === 1 ? (selectedPathKind ?? undefined) : undefined,
|
||||
excludeXattr: excludeXattrValues.length > 0 ? excludeXattrValues : undefined,
|
||||
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
|
||||
targetPath,
|
||||
overwrite: overwriteMode,
|
||||
},
|
||||
|
|
@ -167,32 +142,38 @@ export function RestoreForm({
|
|||
repository.shortId,
|
||||
snapshotId,
|
||||
excludeXattr,
|
||||
hasCustomTargetPath,
|
||||
restoreLocation,
|
||||
trimmedCustomTargetPath,
|
||||
customTargetPath,
|
||||
selectedPaths,
|
||||
selectedPathKind,
|
||||
overwriteMode,
|
||||
restoreSnapshot,
|
||||
]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (selectedPaths.size > 1) return;
|
||||
if (selectedPaths.size > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(
|
||||
const dumpUrl = 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);
|
||||
if (selectedPaths.size === 1) {
|
||||
const [selectedPath] = selectedPaths;
|
||||
if (selectedPath) {
|
||||
dumpUrl.searchParams.set("path", selectedPath);
|
||||
if (selectedPathKind) {
|
||||
dumpUrl.searchParams.set("kind", selectedPathKind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.location.assign(url.toString());
|
||||
const link = document.createElement("a");
|
||||
link.href = dumpUrl.toString();
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}, [repository.shortId, snapshotId, selectedPathKind, selectedPaths]);
|
||||
|
||||
const acknowledgeRestoreResult = useCallback(() => {
|
||||
|
|
@ -206,15 +187,13 @@ export function RestoreForm({
|
|||
}
|
||||
}, []);
|
||||
|
||||
const canRestore = restoreRequiresCustomTarget
|
||||
? hasCustomTargetPath
|
||||
: restoreLocation === "original" || hasCustomTargetPath;
|
||||
const canDownload = selectedPathCount <= 1;
|
||||
const canRestore = restoreLocation === "original" || customTargetPath.trim();
|
||||
const canDownload = selectedPaths.size <= 1;
|
||||
const isRestoreRunning = isRestoring || isRestoreActive;
|
||||
|
||||
function getDownloadButtonText(): string {
|
||||
if (selectedPathCount > 0) {
|
||||
return `Download ${selectedPathCount} ${selectedPathCount === 1 ? "item" : "items"}`;
|
||||
if (selectedPaths.size > 0) {
|
||||
return `Download ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`;
|
||||
}
|
||||
return "Download All";
|
||||
}
|
||||
|
|
@ -223,8 +202,8 @@ export function RestoreForm({
|
|||
if (isRestoreRunning) {
|
||||
return "Restoring...";
|
||||
}
|
||||
if (selectedPathCount > 0) {
|
||||
return `Restore ${selectedPathCount} ${selectedPathCount === 1 ? "item" : "items"}`;
|
||||
if (selectedPaths.size > 0) {
|
||||
return `Restore ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`;
|
||||
}
|
||||
return "Restore All";
|
||||
}
|
||||
|
|
@ -266,18 +245,6 @@ export function RestoreForm({
|
|||
<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>
|
||||
<CardHeader>
|
||||
<CardTitle>Restore Location</CardTitle>
|
||||
|
|
@ -291,7 +258,6 @@ export function RestoreForm({
|
|||
size="sm"
|
||||
className="flex justify-start gap-2"
|
||||
onClick={() => setRestoreLocation("original")}
|
||||
disabled={!!restoreRequiresCustomTarget}
|
||||
>
|
||||
<RotateCcw size={16} className="mr-1" />
|
||||
Original location
|
||||
|
|
@ -386,8 +352,7 @@ export function RestoreForm({
|
|||
<SnapshotTreeBrowser
|
||||
repositoryId={repository.shortId}
|
||||
snapshotId={snapshotId}
|
||||
queryBasePath={snapshotBasePath}
|
||||
displayBasePath={displayBasePath}
|
||||
basePath={volumeBasePath}
|
||||
pageSize={500}
|
||||
className="flex flex-1 min-h-0 flex-col"
|
||||
treeContainerClassName="overflow-auto flex-1 min-h-0 border border-border rounded-md bg-card m-4"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
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 { formatBytes } from "~/utils/format-bytes";
|
||||
import { formatDuration } from "~/utils/utils";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -13,8 +12,6 @@ type Props = {
|
|||
};
|
||||
|
||||
export const RestoreProgress = ({ repositoryId, snapshotId }: Props) => {
|
||||
const formatBytes = useFormatBytes();
|
||||
const { locale } = useRootLoaderData();
|
||||
const { addEventListener } = useServerEvents();
|
||||
const [progress, setProgress] = useState<RestoreProgressEvent | null>(null);
|
||||
|
||||
|
|
@ -74,9 +71,9 @@ export const RestoreProgress = ({ repositoryId, snapshotId }: Props) => {
|
|||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Files</p>
|
||||
<p className="font-medium">
|
||||
{progress.files_restored.toLocaleString(locale)}
|
||||
{progress.files_restored.toLocaleString()}
|
||||
/
|
||||
{progress.total_files.toLocaleString(locale)}
|
||||
{progress.total_files.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ import {
|
|||
DialogTitle,
|
||||
} from "~/client/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { useTimeFormat } from "~/client/lib/datetime";
|
||||
import { formatDuration } from "~/utils/utils";
|
||||
import { formatDateTime } from "~/client/lib/datetime";
|
||||
import {
|
||||
deleteSnapshotsMutation,
|
||||
listSnapshotsQueryKey,
|
||||
|
|
@ -49,7 +49,6 @@ type Props = {
|
|||
export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshotsQueryOptions }: Props) => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ interface StatusDotProps {
|
|||
export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
|
||||
const statusMapping = {
|
||||
success: {
|
||||
color: "bg-success shadow-[0_0_8px_rgba(16,185,129,0.5)]",
|
||||
colorLight: "bg-success/60",
|
||||
color: "bg-green-500",
|
||||
colorLight: "bg-emerald-400",
|
||||
animated: animated ?? true,
|
||||
},
|
||||
neutral: {
|
||||
|
|
@ -40,12 +40,12 @@ export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
|
|||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild aria-label={label}>
|
||||
<TooltipTrigger>
|
||||
<span className="relative flex size-3 mx-auto">
|
||||
{statusMapping?.animated && (
|
||||
<span
|
||||
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}`,
|
||||
)}
|
||||
/>
|
||||
|
|
@ -54,7 +54,7 @@ export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
|
|||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="capitalize">{label}</p>
|
||||
<p>{label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
// fallow-ignore-file unused-export
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
import type * as React from "react";
|
||||
import { buttonVariants } from "~/client/components/ui/button";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// fallow-ignore-file unused-export
|
||||
import type * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// fallow-ignore-file unused-export
|
||||
import type * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
|
@ -13,16 +12,17 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
|||
return (
|
||||
<ol
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5 min-w-0", className)} {...props} />
|
||||
);
|
||||
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5 min-w-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
|
|
|
|||
|
|
@ -7,16 +7,16 @@ import { cn } from "~/client/lib/utils";
|
|||
import { useMinimumDuration } from "~/client/hooks/useMinimumDuration";
|
||||
|
||||
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: {
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,35 +1,23 @@
|
|||
// fallow-ignore-file unused-export
|
||||
import type * as React from "react";
|
||||
|
||||
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 (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"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,
|
||||
)}
|
||||
className={cn("bg-card text-card-foreground relative flex flex-col gap-6 border-2 py-6 shadow-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 z-10 select-none opacity-30 transition-opacity duration-300 ",
|
||||
{
|
||||
"group-hover:opacity-100": interactive,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<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 aria-hidden="true" className="pointer-events-none absolute inset-0 z-10 select-none">
|
||||
<span className="absolute -left-0.5 -top-0.5 h-0.5 w-4 bg-white/80" />
|
||||
<span className="absolute -left-0.5 -top-0.5 h-4 w-0.5 bg-white/80" />
|
||||
<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" />
|
||||
<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>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
// fallow-ignore-file unused-export
|
||||
// @ts-nocheck
|
||||
// biome-ignore-all lint: reason
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
// fallow-ignore-next-line unused-type
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
|
|
@ -119,7 +116,6 @@ function ChartTooltipContent({
|
|||
labelKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
const { locale } = useRootLoaderData();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
|
|
@ -209,7 +205,7 @@ function ChartTooltipContent({
|
|||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString(locale)}
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
import * as React from "react";
|
||||
import type * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { useWebHaptics } from "web-haptics/react";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
const trigger = useWebHaptics().trigger;
|
||||
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
void trigger([{ duration: 8 }], { intensity: 0.3 });
|
||||
props.onCheckedChange?.(checked);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ interface CodeBlockProps {
|
|||
|
||||
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-border">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2 text-xs">
|
||||
<div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-white/10">
|
||||
<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">
|
||||
<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" />
|
||||
|
|
@ -18,7 +18,7 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
|
|||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// fallow-ignore-file unused-export
|
||||
import type * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
// fallow-ignore-file unused-export
|
||||
import * as React from "react";
|
||||
import type * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as React from "react";
|
||||
import { OTPInput, OTPInputContext } from "input-otp";
|
||||
import { MinusIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
|
|
@ -54,8 +55,12 @@ function InputOTPSlot({
|
|||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"hr">) {
|
||||
return <hr data-slot="input-otp-separator" {...props} />;
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||
data-slot="input"
|
||||
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 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]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// fallow-ignore-file unused-export
|
||||
import type * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// fallow-ignore-file unused-export
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import type * as React 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:");
|
||||
};
|
||||
|
||||
type SecretInputProps = Omit<React.ComponentProps<typeof Input>, "type">;
|
||||
type SecretInputProps = Omit<React.ComponentProps<typeof Input>, "type">
|
||||
|
||||
export const SecretInput = ({ className, value, ...props }: SecretInputProps) => {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
|
@ -37,7 +36,12 @@ export const SecretInput = ({ className, value, ...props }: SecretInputProps) =>
|
|||
|
||||
return (
|
||||
<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 && (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,73 +1,19 @@
|
|||
// fallow-ignore-file unused-export
|
||||
import * as React from "react";
|
||||
import type * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
const SelectSsrValueContext = React.createContext<{
|
||||
hydrated: boolean;
|
||||
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 Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({ children, placeholder, style, ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
const context = React.useContext(SelectSsrValueContext);
|
||||
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 SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
|
|
@ -75,35 +21,19 @@ function SelectTrigger({
|
|||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
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 (
|
||||
<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}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// fallow-ignore-file unused-export
|
||||
"use client";
|
||||
|
||||
import type * as React from "react";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// fallow-ignore-file unused-export
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
|
@ -345,7 +344,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden group-data-[collapsible=icon]:overflow-hidden",
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -566,16 +565,10 @@ function SidebarMenuSkeleton({
|
|||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
const skeletonId = React.useId();
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
let hash = 0;
|
||||
|
||||
for (const char of skeletonId) {
|
||||
hash = (hash * 31 + char.charCodeAt(0)) % 40;
|
||||
}
|
||||
|
||||
return `${hash + 50}%`;
|
||||
}, [skeletonId]);
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useTheme } from "~/client/components/theme-provider";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme } = useTheme();
|
||||
const { theme = "dark" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { useWebHaptics } from "web-haptics/react";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
const trigger = useWebHaptics().trigger;
|
||||
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
|
|
@ -14,10 +11,6 @@ function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimi
|
|||
"cursor-pointer peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
void trigger([{ duration: 8 }], { intensity: 0.3 });
|
||||
props.onCheckedChange?.(checked);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// fallow-ignore-file unused-export
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import type * as React from "react";
|
||||
|
||||
import { useWebHaptics } from "web-haptics/react";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
|
|
@ -18,14 +17,7 @@ function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimi
|
|||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, onClick, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
const { trigger } = useWebHaptics();
|
||||
|
||||
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (event) => {
|
||||
void trigger([{ duration: 8 }], { intensity: 0.3 });
|
||||
onClick?.(event);
|
||||
};
|
||||
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
|
|
@ -37,25 +29,24 @@ function TabsTrigger({ className, onClick, ...props }: React.ComponentProps<type
|
|||
// Padding: 20px horizontal (8px for bracket tick + 12px gap to text)
|
||||
"px-5",
|
||||
// Transparent orange background for active state
|
||||
"data-[state=active]:bg-strong-accent/10",
|
||||
"data-[state=active]:bg-[#FF453A]/10",
|
||||
// Left bracket - vertical line
|
||||
"before:absolute before:left-0 before:top-0 before:h-7 before:w-0.5 before:bg-muted-foreground/60 before:transition-colors data-[state=active]:before:bg-strong-accent",
|
||||
"before:absolute before:left-0 before:top-0 before:h-7 before:w-0.5 before:bg-[#5D6570] before:transition-colors data-[state=active]:before:bg-[#FF453A]",
|
||||
// Left bracket - top tick
|
||||
"after:absolute after:left-0 after:-top-px after:w-2 after:h-0.5 after:bg-muted-foreground/60 after:transition-colors data-[state=active]:after:bg-strong-accent",
|
||||
"after:absolute after:left-0 after:top-[-1px] after:w-2 after:h-0.5 after:bg-[#5D6570] after:transition-colors data-[state=active]:after:bg-[#FF453A]",
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
<span className="relative z-10">{props.children}</span>
|
||||
{/* Left bracket - bottom tick */}
|
||||
<span className="absolute left-0 bottom-[-1px] h-0.5 w-2 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
|
||||
<span className="absolute left-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
|
||||
{/* Right bracket - top tick */}
|
||||
<span className="absolute right-0 top-[-1px] h-0.5 w-2 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
|
||||
<span className="absolute right-0 top-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
|
||||
{/* Right bracket - vertical line */}
|
||||
<span className="absolute right-0 top-0 h-7 w-0.5 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
|
||||
<span className="absolute right-0 top-0 h-7 w-0.5 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
|
||||
{/* Right bracket - bottom tick */}
|
||||
<span className="absolute right-0 bottom-[-1px] h-0.5 w-2 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
|
||||
<span className="absolute right-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Cloud, Folder, Server } from "lucide-react";
|
||||
import type { BackendType } from "@zerobyte/contracts/volumes";
|
||||
import type { BackendType } from "~/schemas/volumes";
|
||||
|
||||
type VolumeIconProps = {
|
||||
backend: BackendType;
|
||||
|
|
@ -49,7 +49,7 @@ export const VolumeIcon = ({ backend }: VolumeIconProps) => {
|
|||
const { icon: Icon, label } = getIconAndLabel(backend);
|
||||
|
||||
return (
|
||||
<span className={`flex items-center gap-2 rounded-md py-1`}>
|
||||
<span className={`flex items-center gap-2 rounded-md px-2 py-1`}>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
import { CodeBlock } from "~/client/components/ui/code-block";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
|
||||
type WebhookRequestPreviewProps = {
|
||||
method: string;
|
||||
url?: string;
|
||||
contentType?: string;
|
||||
headers?: string[];
|
||||
body: string;
|
||||
};
|
||||
|
||||
export const WebhookRequestPreview = ({ method, url, contentType, headers, body }: WebhookRequestPreviewProps) => {
|
||||
const headerLines = [contentType ? `Content-Type: ${contentType}` : undefined, ...(headers ?? [])].filter(Boolean);
|
||||
const previewCode = `${method} ${url || "https://api.example.com/webhook"}${headerLines.length > 0 ? `\n${headerLines.join("\n")}` : ""}
|
||||
|
||||
${body}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-4 border-t">
|
||||
<Label>Request Preview</Label>
|
||||
<CodeBlock code={previewCode} filename="HTTP Request" />
|
||||
<p className="text-[0.8rem] text-muted-foreground">This is a preview of the HTTP request that will be sent.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { getIsSecureContextForRequest, isSecureContextOrigin } from "../is-secure-context";
|
||||
|
||||
describe("isSecureContextOrigin", () => {
|
||||
test("treats HTTPS and loopback HTTP origins as secure contexts", () => {
|
||||
expect(isSecureContextOrigin("https://example.com")).toBe(true);
|
||||
expect(isSecureContextOrigin("http://localhost:3000")).toBe(true);
|
||||
expect(isSecureContextOrigin("http://app.localhost")).toBe(true);
|
||||
expect(isSecureContextOrigin("http://127.0.0.1:3000")).toBe(true);
|
||||
expect(isSecureContextOrigin("http://[::1]:3000")).toBe(true);
|
||||
});
|
||||
|
||||
test("treats non-local HTTP origins as insecure contexts", () => {
|
||||
expect(isSecureContextOrigin("http://example.com")).toBe(false);
|
||||
expect(isSecureContextOrigin("http://127.example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIsSecureContextForRequest", () => {
|
||||
test("uses forwarded protocol and host when present", () => {
|
||||
const request = new Request("http://internal.local/settings", {
|
||||
headers: {
|
||||
host: "internal.local",
|
||||
"x-forwarded-host": "zerobyte.example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
},
|
||||
});
|
||||
|
||||
expect(getIsSecureContextForRequest(request)).toBe(true);
|
||||
});
|
||||
|
||||
test("uses the request URL when forwarded headers are absent", () => {
|
||||
expect(getIsSecureContextForRequest(new Request("http://zerobyte.example.com/settings"))).toBe(false);
|
||||
expect(getIsSecureContextForRequest(new Request("https://zerobyte.example.com/settings"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { config } from "~/server/core/config";
|
||||
|
||||
export const getOrigin = createServerFn().handler(() => {
|
||||
return config.baseUrl;
|
||||
});
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import { createIsomorphicFn } from "@tanstack/react-start";
|
||||
import { getRequest } from "@tanstack/react-start/server";
|
||||
import ipaddr from "ipaddr.js";
|
||||
|
||||
const getFirstHeaderValue = (value: string | null) => value?.split(",")[0]?.trim();
|
||||
|
||||
const isLoopbackIp = (hostname: string) => {
|
||||
try {
|
||||
return ipaddr.parse(hostname).range() === "loopback";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getRequestOrigin = (request: Request) => {
|
||||
const requestUrl = new URL(request.url);
|
||||
const forwardedProto = getFirstHeaderValue(request.headers.get("x-forwarded-proto"));
|
||||
const forwardedHost = getFirstHeaderValue(request.headers.get("x-forwarded-host"));
|
||||
const host = forwardedHost || getFirstHeaderValue(request.headers.get("host"));
|
||||
|
||||
if (!host) {
|
||||
return requestUrl.origin;
|
||||
}
|
||||
|
||||
const protocol = (forwardedProto || requestUrl.protocol).replace(/:$/, "");
|
||||
return `${protocol}://${host}`;
|
||||
};
|
||||
|
||||
export const isSecureContextOrigin = (origin: string) => {
|
||||
const url = new URL(origin);
|
||||
|
||||
if (url.protocol === "https:") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hostname = url.hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase();
|
||||
|
||||
return hostname === "localhost" || hostname.endsWith(".localhost") || isLoopbackIp(hostname);
|
||||
};
|
||||
|
||||
export const getIsSecureContextForRequest = (request: Request) => isSecureContextOrigin(getRequestOrigin(request));
|
||||
|
||||
export const getIsSecureContext = createIsomorphicFn()
|
||||
.server(() => {
|
||||
try {
|
||||
return getIsSecureContextForRequest(getRequest());
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.client(() => window.isSecureContext ?? true);
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue