Compare commits
4 commits
main
...
03-11-feat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52b58e2f93 | ||
|
|
904bbfc473 | ||
|
|
f3ec88ddea | ||
|
|
abc7d38b96 |
621 changed files with 10303 additions and 66208 deletions
|
|
@ -3,7 +3,6 @@
|
||||||
!bun.lock
|
!bun.lock
|
||||||
!package.json
|
!package.json
|
||||||
!.gitignore
|
!.gitignore
|
||||||
!bunfig.toml
|
|
||||||
|
|
||||||
!**/package.json
|
!**/package.json
|
||||||
!**/bun.lock
|
!**/bun.lock
|
||||||
|
|
@ -12,7 +11,6 @@
|
||||||
!**/components.json
|
!**/components.json
|
||||||
|
|
||||||
!app/**
|
!app/**
|
||||||
!apps/agent/**
|
|
||||||
!packages/**
|
!packages/**
|
||||||
!public/**
|
!public/**
|
||||||
|
|
||||||
|
|
@ -24,4 +22,3 @@
|
||||||
node_modules/**
|
node_modules/**
|
||||||
dist/**
|
dist/**
|
||||||
.output/**
|
.output/**
|
||||||
app/test/integration/artifacts/**
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,6 @@ RESTIC_CACHE_DIR=./data/restic/cache
|
||||||
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
|
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
|
||||||
ZEROBYTE_VOLUMES_DIR=./data/volumes
|
ZEROBYTE_VOLUMES_DIR=./data/volumes
|
||||||
APP_SECRET=<openssl rand -hex 32>
|
APP_SECRET=<openssl rand -hex 32>
|
||||||
BASE_URL=http://*.localhost
|
BASE_URL=http://localhost:300
|
||||||
TRUST_PROXY=false
|
TRUST_PROXY=false
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
ZEROBYTE_DATABASE_URL=:memory:
|
ZEROBYTE_DATABASE_URL=:memory:
|
||||||
APP_SECRET=8b9acd4456dd5db0a4a3c4f4e1240b2c3ae08bb59690167197425e4a25dd9a69
|
APP_SECRET=8b9acd4456dd5db0a4a3c4f4e1240b2c3ae08bb59690167197425e4a25dd9a69
|
||||||
BASE_URL=http://localhost:4096
|
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
|
name: Install dependencies
|
||||||
|
|
||||||
description: Install dependencies
|
description: Install dependencies
|
||||||
inputs:
|
|
||||||
workingDirectory:
|
|
||||||
description: "The working directory to install dependencies in"
|
|
||||||
required: false
|
|
||||||
default: "."
|
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- uses: socketdev/action@937f824ec476dfd164d4a4d9995751427b0be143 # v1
|
- uses: oven-sh/setup-bun@v2
|
||||||
with:
|
|
||||||
mode: firewall
|
|
||||||
|
|
||||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
|
||||||
name: Install Bun
|
name: Install Bun
|
||||||
with:
|
with:
|
||||||
bun-version: "1.3.14"
|
bun-version: "1.3.6"
|
||||||
|
|
||||||
- uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: bun install --frozen-lockfile
|
||||||
cd ${{ inputs.workingDirectory }}
|
|
||||||
sfw vp install --frozen-lockfile
|
|
||||||
|
|
|
||||||
18
.github/dependabot.yml
vendored
Normal file
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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
17
.github/workflows/checks.yml
vendored
17
.github/workflows/checks.yml
vendored
|
|
@ -20,23 +20,24 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
uses: "./.github/actions/install-dependencies"
|
uses: "./.github/actions/install-dependencies"
|
||||||
|
|
||||||
- name: Run lint
|
- uses: oxc-project/oxlint-action@latest
|
||||||
shell: bash
|
with:
|
||||||
run: vp lint --type-aware --deny-warnings
|
config: .oxlintrc.json
|
||||||
|
deny-warnings: true
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
|
@ -52,7 +53,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
|
@ -68,7 +69,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
|
@ -84,7 +85,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
|
|
||||||
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
|
timeout-minutes: 20
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
uses: "./.github/actions/install-dependencies"
|
uses: "./.github/actions/install-dependencies"
|
||||||
|
|
@ -26,7 +26,7 @@ jobs:
|
||||||
|
|
||||||
- name: Cache Playwright Browsers
|
- name: Cache Playwright Browsers
|
||||||
id: playwright-cache
|
id: playwright-cache
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright
|
path: ~/.cache/ms-playwright
|
||||||
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
|
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
|
||||||
|
|
@ -46,37 +46,27 @@ jobs:
|
||||||
mkdir -p data
|
mkdir -p data
|
||||||
mkdir -p playwright/temp
|
mkdir -p playwright/temp
|
||||||
mkdir -p playwright/data
|
mkdir -p playwright/data
|
||||||
mkdir -p playwright/tinyauth/app-data
|
|
||||||
mkdir -p playwright/tinyauth/caddy-data
|
|
||||||
chmod -R 777 playwright/temp
|
chmod -R 777 playwright/temp
|
||||||
touch .env.local
|
touch .env.local
|
||||||
echo "SERVER_IP=localhost" >> .env.local
|
echo "SERVER_IP=localhost" >> .env.local
|
||||||
echo "ZEROBYTE_DATABASE_URL=./data/zerobyte.db" >> .env.local
|
echo "ZEROBYTE_DATABASE_URL=./data/zerobyte.db" >> .env.local
|
||||||
echo "BASE_URL=http://localhost:4096" >> .env.local
|
echo "BASE_URL=http://localhost:4096" >> .env.local
|
||||||
echo "E2E_TINYAUTH_ORIGIN=https://tinyauth.example.com:5557" >> .env.local
|
echo "E2E_DEX_ORIGIN=http://dex:5557" >> .env.local
|
||||||
echo "TZ=Europe/Zurich" >> .env.local
|
|
||||||
|
|
||||||
- name: Start zerobyte-e2e service
|
- name: Start zerobyte-e2e service
|
||||||
run: bun run start:e2e -- -d
|
run: bun run start:e2e -- -d
|
||||||
|
|
||||||
- name: Check CLI version
|
|
||||||
run: |
|
|
||||||
expected_version=$(docker exec zerobyte printenv APP_VERSION)
|
|
||||||
cli_version=$(docker exec zerobyte bun run --silent cli --version)
|
|
||||||
echo "$cli_version"
|
|
||||||
test "$cli_version" = "$expected_version"
|
|
||||||
|
|
||||||
- name: Wait for zerobyte to be ready
|
- name: Wait for zerobyte to be ready
|
||||||
run: |
|
run: |
|
||||||
timeout 30s bash -c 'until curl -f http://localhost:4096/api/healthcheck; do echo "Waiting for server..." && sleep 2; done'
|
timeout 30s bash -c 'until curl -f http://localhost:4096/api/healthcheck; do echo "Waiting for server..." && sleep 2; done'
|
||||||
|
|
||||||
- name: Wait for Tinyauth to be ready
|
- name: Wait for Dex to be ready
|
||||||
run: |
|
run: |
|
||||||
timeout 30s bash -c 'until curl -skf https://localhost:5557/.well-known/openid-configuration; do echo "Waiting for Tinyauth..." && sleep 2; done'
|
timeout 30s bash -c 'until curl -sf http://localhost:5557/dex/.well-known/openid-configuration; do echo "Waiting for Dex..." && sleep 2; done'
|
||||||
|
|
||||||
- name: Print docker logs if failed to start
|
- name: Print docker logs if failed to start
|
||||||
if: failure()
|
if: failure()
|
||||||
run: docker compose logs zerobyte-e2e tinyauth tinyauth-app || true
|
run: docker compose logs zerobyte-e2e dex || true
|
||||||
|
|
||||||
- name: Make playwright directory writable
|
- name: Make playwright directory writable
|
||||||
run: sudo chmod 777 playwright/data/zerobyte.db
|
run: sudo chmod 777 playwright/data/zerobyte.db
|
||||||
|
|
@ -87,16 +77,16 @@ jobs:
|
||||||
- name: Dump container logs in playwright-report folder
|
- name: Dump container logs in playwright-report folder
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
tree playwright || true
|
tree playwright
|
||||||
docker compose logs zerobyte-e2e > playwright-report/container-logs.txt || true
|
docker compose logs zerobyte-e2e > playwright-report/container-logs.txt || true
|
||||||
docker compose logs tinyauth tinyauth-app > playwright-report/tinyauth-logs.txt || true
|
docker compose logs dex > playwright-report/dex-logs.txt || true
|
||||||
|
|
||||||
- name: Debug - print content of /test-data in container
|
- name: Debug - print content of /test-data in container
|
||||||
if: failure()
|
if: failure()
|
||||||
run: |
|
run: |
|
||||||
docker exec zerobyte /bin/ash -c "ls -la /test-data" || true
|
docker exec zerobyte /bin/ash -c "ls -la /test-data" || true
|
||||||
|
|
||||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
- uses: actions/upload-artifact@v7
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
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:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
uses: docker/setup-buildx-action@v4
|
||||||
with:
|
with:
|
||||||
driver: cloud
|
driver: cloud
|
||||||
endpoint: "meienberger/runtipi-builder"
|
endpoint: "meienberger/runtipi-builder"
|
||||||
install: true
|
install: true
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
|
|
@ -41,14 +38,14 @@ jobs:
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/zerobyte
|
images: ghcr.io/${{ github.repository_owner }}/zerobyte
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=nightly
|
type=raw,value=nightly
|
||||||
|
|
||||||
- name: Push images to GitHub Container Registry
|
- name: Push images to GitHub Container Registry
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
target: production
|
target: production
|
||||||
|
|
|
||||||
77
.github/workflows/release.yml
vendored
77
.github/workflows/release.yml
vendored
|
|
@ -8,16 +8,16 @@ on:
|
||||||
- "v*.*.*-alpha.*"
|
- "v*.*.*-alpha.*"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
|
packages: write
|
||||||
|
security-events: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
determine-release-type:
|
determine-release-type:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions: {}
|
|
||||||
outputs:
|
outputs:
|
||||||
release_type: ${{ steps.set-type.outputs.release_type }}
|
release_type: ${{ steps.set-type.outputs.release_type }}
|
||||||
tagname: ${{ github.ref_name }}
|
tagname: ${{ github.ref_name }}
|
||||||
is_minor_release: ${{ steps.set-type.outputs.is_minor_release }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Set release type
|
- name: Set release type
|
||||||
id: set-type
|
id: set-type
|
||||||
|
|
@ -31,60 +31,74 @@ jobs:
|
||||||
echo "release_type=release" >> $GITHUB_OUTPUT
|
echo "release_type=release" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $TAG =~ ^v[0-9]+\.[0-9]+\.0$ ]]; then
|
|
||||||
echo "is_minor_release=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "is_minor_release=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
checks:
|
checks:
|
||||||
uses: ./.github/workflows/checks.yml
|
uses: ./.github/workflows/checks.yml
|
||||||
|
|
||||||
e2e-tests:
|
e2e-tests:
|
||||||
uses: ./.github/workflows/e2e.yml
|
uses: ./.github/workflows/e2e.yml
|
||||||
|
|
||||||
integration-tests:
|
|
||||||
uses: ./.github/workflows/integration.yml
|
|
||||||
|
|
||||||
build-images:
|
build-images:
|
||||||
environment: release
|
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
needs: [determine-release-type, checks, e2e-tests, integration-tests]
|
needs: [determine-release-type, checks, e2e-tests]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
security-events: write
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ github.ref }}
|
ref: ${{ github.ref }}
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
uses: docker/setup-buildx-action@v4
|
||||||
with:
|
with:
|
||||||
driver: cloud
|
driver: cloud
|
||||||
endpoint: "meienberger/runtipi-builder"
|
endpoint: "meienberger/runtipi-builder"
|
||||||
install: true
|
install: true
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build docker image
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
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
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/zerobyte
|
images: ghcr.io/${{ github.repository_owner }}/zerobyte
|
||||||
tags: |
|
tags: |
|
||||||
|
|
@ -96,7 +110,7 @@ jobs:
|
||||||
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
|
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
|
||||||
|
|
||||||
- name: Push images to GitHub Container Registry
|
- name: Push images to GitHub Container Registry
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
target: production
|
target: production
|
||||||
|
|
@ -111,14 +125,12 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build-images, determine-release-type]
|
needs: [build-images, determine-release-type]
|
||||||
if: needs.determine-release-type.outputs.release_type == 'release'
|
if: needs.determine-release-type.outputs.release_type == 'release'
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
outputs:
|
outputs:
|
||||||
id: ${{ steps.create_release.outputs.id }}
|
id: ${{ steps.create_release.outputs.id }}
|
||||||
steps:
|
steps:
|
||||||
- name: Create GitHub release
|
- name: Create GitHub release
|
||||||
id: create_release
|
id: create_release
|
||||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
|
uses: softprops/action-gh-release@v2
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
|
|
@ -128,13 +140,4 @@ jobs:
|
||||||
name: ${{ needs.determine-release-type.outputs.tagname }}
|
name: ${{ needs.determine-release-type.outputs.tagname }}
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: true
|
prerelease: true
|
||||||
|
files: cli/runtipi-cli-*
|
||||||
request-docs-version-update:
|
|
||||||
uses: ./.github/workflows/request-docs-version-update.yml
|
|
||||||
needs: [publish-release, determine-release-type]
|
|
||||||
if: needs.determine-release-type.outputs.release_type == 'release' && needs.determine-release-type.outputs.is_minor_release == 'true'
|
|
||||||
with:
|
|
||||||
release_tag: ${{ needs.determine-release-type.outputs.tagname }}
|
|
||||||
base_branch: ${{ github.event.repository.default_branch }}
|
|
||||||
secrets:
|
|
||||||
COPILOT_ASSIGNMENT_TOKEN: ${{ secrets.COPILOT_ASSIGNMENT_TOKEN }}
|
|
||||||
|
|
|
||||||
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
|
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -15,7 +15,7 @@ notes.md
|
||||||
smb-password.txt
|
smb-password.txt
|
||||||
cache.db
|
cache.db
|
||||||
|
|
||||||
/data
|
data/
|
||||||
|
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
@ -28,17 +28,12 @@ node_modules/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/playwright/.auth/
|
/playwright/.auth/
|
||||||
/playwright/*.pass
|
/playwright/restic.pass
|
||||||
/playwright/tinyauth/app-data/
|
|
||||||
/playwright/tinyauth/caddy-data/
|
|
||||||
|
|
||||||
playwright/.auth
|
playwright/.auth
|
||||||
playwright/temp
|
playwright/temp
|
||||||
playwright/data
|
|
||||||
|
|
||||||
.idea/
|
.idea/
|
||||||
.deepsec/
|
|
||||||
.codex/
|
|
||||||
|
|
||||||
# OpenAPI error logs
|
# OpenAPI error logs
|
||||||
openapi-ts-error-*.log
|
openapi-ts-error-*.log
|
||||||
|
|
@ -48,5 +43,3 @@ qa-output
|
||||||
|
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.turbo
|
.turbo
|
||||||
.superset
|
|
||||||
.fallow
|
|
||||||
|
|
|
||||||
7
.oxfmtrc.json
Normal file
7
.oxfmtrc.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
|
"printWidth": 120,
|
||||||
|
"useTabs": true,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"ignorePatterns": ["*.gen.ts"]
|
||||||
|
}
|
||||||
162
.oxlintrc.json
Normal file
162
.oxlintrc.json
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
{
|
||||||
|
"$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",
|
||||||
|
{
|
||||||
|
"varsIgnorePattern": "^_",
|
||||||
|
"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",
|
||||||
|
"import/no-unused-modules": ["error", { "unusedExports": true }],
|
||||||
|
"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/**"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["**/*.test.ts", "**/*.test.tsx"],
|
||||||
|
"rules": {
|
||||||
|
"typescript/await-thenable": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
vp staged
|
|
||||||
21
AGENTS.md
21
AGENTS.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
- Never create migration files manually. Always use the provided command to generate migrations
|
- Never create migration files manually. Always use the provided command to generate migrations
|
||||||
- If you realize an automated migration is incorrect, make sure to remove all the associated entries from the `_journal.json` and the newly created files located in `app/drizzle/` before re-generating the migration
|
- If you realize an automated migration is incorrect, make sure to remove all the associated entries from the `_journal.json` and the newly created files located in `app/drizzle/` before re-generating the migration
|
||||||
- The dev server runs through Portless. Start it with `bun run dev`, then use `portless get zerobyte` to get the current worktree-specific URL. Do not assume a fixed port like `3000` or `4096`. Username is `admin` and password is `password`
|
- The dev server is running at http://localhost:3000. Username is `admin` and password is `password`
|
||||||
- The repo is https://github.com/nicotsx/zerobyte
|
- 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`
|
- If you need to run a specific restic command on a repository, you can open and use the dev panel with `Meta+Shift+D`
|
||||||
|
|
||||||
|
|
@ -10,21 +10,6 @@
|
||||||
|
|
||||||
Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, SFTP, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage).
|
Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, SFTP, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage).
|
||||||
|
|
||||||
### Development Server
|
|
||||||
|
|
||||||
```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
|
### Type Checking
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -39,7 +24,7 @@ bun run tsc
|
||||||
bun run test
|
bun run test
|
||||||
|
|
||||||
# Run a specific test file
|
# Run a specific test file
|
||||||
bunx dotenv-cli -e .env.test -- bunx --bun vitest run --project server path/to/test.ts
|
bunx dotenv-cli -e .env.test -- bun test --preload ./app/test/setup.ts path/to/test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
@ -70,7 +55,7 @@ bun run gen:api-client
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Format
|
# Format
|
||||||
vp fmt <path> --write
|
bunx oxfmt format --write <path>
|
||||||
|
|
||||||
# Lint
|
# Lint
|
||||||
bun run lint
|
bun run lint
|
||||||
|
|
|
||||||
43
Dockerfile
43
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 RESTIC_VERSION="0.18.1"
|
||||||
ARG RCLONE_VERSION="1.74.2"
|
ARG RCLONE_VERSION="1.73.1"
|
||||||
ARG SHOUTRRR_VERSION="0.15.1"
|
ARG SHOUTRRR_VERSION="0.13.2"
|
||||||
|
|
||||||
ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
|
ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
|
||||||
VITE_RCLONE_VERSION=${RCLONE_VERSION} \
|
VITE_RCLONE_VERSION=${RCLONE_VERSION} \
|
||||||
|
|
@ -10,7 +12,7 @@ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
|
||||||
|
|
||||||
RUN apk update --no-cache && \
|
RUN apk update --no-cache && \
|
||||||
apk upgrade --no-cache && \
|
apk upgrade --no-cache && \
|
||||||
apk add --no-cache acl attr cifs-utils davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata
|
apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata
|
||||||
|
|
||||||
ENTRYPOINT ["/sbin/tini", "-s", "--"]
|
ENTRYPOINT ["/sbin/tini", "-s", "--"]
|
||||||
|
|
||||||
|
|
@ -29,30 +31,21 @@ RUN apk add --no-cache curl bzip2 unzip tar
|
||||||
|
|
||||||
RUN echo "Building for ${TARGETARCH}"
|
RUN echo "Building for ${TARGETARCH}"
|
||||||
RUN if [ "${TARGETARCH}" = "arm64" ]; then \
|
RUN if [ "${TARGETARCH}" = "arm64" ]; then \
|
||||||
curl -fL -o restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_arm64.bz2"; \
|
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_arm64.bz2"; \
|
||||||
curl -fL -o rclone.zip "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-arm64.zip"; \
|
curl -L -o rclone.zip "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-arm64.zip"; \
|
||||||
unzip rclone.zip; \
|
unzip rclone.zip; \
|
||||||
curl -fL -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v${SHOUTRRR_VERSION}/shoutrrr_linux_arm64v8_${SHOUTRRR_VERSION}.tar.gz"; \
|
curl -L -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v${SHOUTRRR_VERSION}/shoutrrr_linux_arm64v8_${SHOUTRRR_VERSION}.tar.gz"; \
|
||||||
elif [ "${TARGETARCH}" = "amd64" ]; then \
|
elif [ "${TARGETARCH}" = "amd64" ]; then \
|
||||||
curl -fL -o restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_amd64.bz2"; \
|
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_amd64.bz2"; \
|
||||||
curl -fL -o rclone.zip "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-amd64.zip"; \
|
curl -L -o rclone.zip "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-amd64.zip"; \
|
||||||
unzip rclone.zip; \
|
unzip rclone.zip; \
|
||||||
curl -fL -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v$SHOUTRRR_VERSION/shoutrrr_linux_amd64_${SHOUTRRR_VERSION}.tar.gz"; \
|
curl -L -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v$SHOUTRRR_VERSION/shoutrrr_linux_amd64_${SHOUTRRR_VERSION}.tar.gz"; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
RUN bzip2 -d restic.bz2 && chmod +x restic
|
RUN bzip2 -d restic.bz2 && chmod +x restic
|
||||||
RUN mv rclone-v*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone
|
RUN mv rclone-v*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone
|
||||||
RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr
|
RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr
|
||||||
|
|
||||||
# ------------------------------
|
|
||||||
# RUNTIME TOOLS
|
|
||||||
# ------------------------------
|
|
||||||
FROM base AS runtime-tools
|
|
||||||
|
|
||||||
COPY --from=deps /deps/restic /usr/local/bin/restic
|
|
||||||
COPY --from=deps /deps/rclone /usr/local/bin/rclone
|
|
||||||
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
|
|
||||||
|
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
# DEVELOPMENT
|
# DEVELOPMENT
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
|
|
@ -71,11 +64,8 @@ COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
|
||||||
|
|
||||||
COPY ./package.json ./bun.lock ./
|
COPY ./package.json ./bun.lock ./
|
||||||
COPY ./packages/core/package.json ./packages/core/package.json
|
COPY ./packages/core/package.json ./packages/core/package.json
|
||||||
COPY ./packages/contracts/package.json ./packages/contracts/package.json
|
|
||||||
COPY ./apps/agent/package.json ./apps/agent/package.json
|
|
||||||
COPY ./apps/docs/package.json ./apps/docs/package.json
|
|
||||||
|
|
||||||
RUN VITE_GIT_HOOKS=0 bun install --frozen-lockfile --ignore-scripts
|
RUN bun install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|
@ -96,16 +86,11 @@ WORKDIR /app
|
||||||
|
|
||||||
COPY ./package.json ./bun.lock ./
|
COPY ./package.json ./bun.lock ./
|
||||||
COPY ./packages/core/package.json ./packages/core/package.json
|
COPY ./packages/core/package.json ./packages/core/package.json
|
||||||
COPY ./packages/contracts/package.json ./packages/contracts/package.json
|
RUN bun install --frozen-lockfile
|
||||||
COPY ./apps/agent/package.json ./apps/agent/package.json
|
|
||||||
COPY ./apps/docs/package.json ./apps/docs/package.json
|
|
||||||
|
|
||||||
RUN VITE_GIT_HOOKS=0 bun install --frozen-lockfile
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
RUN bun build apps/agent/src/index.ts --outfile .output/agent/index.mjs --target bun
|
|
||||||
|
|
||||||
FROM base AS production
|
FROM base AS production
|
||||||
|
|
||||||
|
|
|
||||||
143
README.md
143
README.md
|
|
@ -15,33 +15,23 @@
|
||||||
</figure>
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
#### Join the community
|
|
||||||
|
|
||||||
[](https://discord.gg/XjgVyXrcEH)
|
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedback. Please open issues for bugs or feature requests.
|
> Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedbacks. Expect bugs! Please open issues or feature requests
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://www.buymeacoffee.com/nicotsx" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
|
<a href="https://www.buymeacoffee.com/nicotsx" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Introduction
|
## Intro
|
||||||
|
|
||||||
Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage.
|
Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage.
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
The official documentation website is available at [zerobyte.app](https://zerobyte.app).
|
|
||||||
|
|
||||||
It contains up-to-date setup guides, configuration reference, and usage documentation for running Zerobyte in production.
|
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **Automated backups** with encryption, compression, and retention policies, powered by Restic
|
- **Automated backups** with encryption, compression and retention policies powered by Restic
|
||||||
- **Flexible scheduling** for automated backup jobs with fine-grained retention policies
|
- **Flexible scheduling** For automated backup jobs with fine-grained retention policies
|
||||||
- **End-to-end encryption** will ensure your data is always protected
|
- **End-to-end encryption** ensuring your data is always protected
|
||||||
- **Multi-protocol support** for backup from NFS, SMB, WebDAV, SFTP, or local directories
|
- **Multi-protocol support**: Backup from NFS, SMB, WebDAV, SFTP, or local directories
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -50,7 +40,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.38
|
image: ghcr.io/nicotsx/zerobyte:v0.30
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|
@ -69,10 +59,10 @@ services:
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> It is highly discouraged to run Zerobyte on a server that is accessible from the internet (VPS or home server with port forwarding). If you do, make sure to change the port mapping to "127.0.0.1:4096:4096" and use a secure tunnel (SSH tunnel, Cloudflare Tunnel, etc.) with authentication.
|
> It is highly discouraged to run Zerobyte on a server that is accessible from the internet (VPS or home server with port forwarding) If you do, make sure to change the port mapping to "127.0.0.1:4096:4096" and use a secure tunnel (SSH tunnel, Cloudflare Tunnel, etc.) with authentication.
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Do not try to point `/var/lib/zerobyte` to a network share. You will face permission issues and strong performance degradation.
|
> Do not try to point `/var/lib/zerobyte` on a network share. You will face permission issues and strong performance degradation.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!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:
|
> **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,46 +87,23 @@ Once the container is running, you can access the web interface at `http://<your
|
||||||
|
|
||||||
Zerobyte can be customized using environment variables. Below are the available options:
|
Zerobyte can be customized using environment variables. Below are the available options:
|
||||||
|
|
||||||
### Environment variables
|
### Environment Variables
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
| :------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- |
|
| :-------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- |
|
||||||
| `BASE_URL` | **Required.** The base URL of your Zerobyte instance (e.g., `https://zerobyte.example.com`). See [Authentication](#authentication) below. | (none) |
|
| `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` | **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` |
|
||||||
| `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` |
|
||||||
| `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` |
|
||||||
| `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` |
|
||||||
| `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) |
|
||||||
| `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` |
|
||||||
| `WEBHOOK_ALLOWED_ORIGINS` | Comma-separated list of HTTP origins allowed for backup webhooks and outbound HTTP notification destinations. | (none) |
|
| `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` |
|
||||||
| `WEBHOOK_TIMEOUT` | Timeout for backup webhook requests 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` |
|
||||||
| `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` |
|
| `PROVISIONING_PATH` | Path to a JSON file with operator-managed repositories and volumes to sync at startup. | (none) |
|
||||||
| `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` |
|
|
||||||
| `RCLONE_CONFIG_DIR` | Path to the directory containing `rclone.conf` inside the container. Change this if running as a non-root user. | `/root/.config/rclone` |
|
|
||||||
| `PROVISIONING_PATH` | Path to a JSON file with operator-managed repositories and volumes to sync at startup. | (none) |
|
|
||||||
|
|
||||||
### Webhook and notification network policy
|
### Provisioned Resources
|
||||||
|
|
||||||
Backup webhooks and outbound notification destinations that can target arbitrary network hosts are restricted by `WEBHOOK_ALLOWED_ORIGINS`.
|
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
This policy applies to:
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|
@ -145,18 +112,16 @@ Provisioned resources:
|
||||||
- appear in the normal repositories and volumes screens
|
- appear in the normal repositories and volumes screens
|
||||||
- can resolve credential fields from environment variables or `/run/secrets/*` during startup sync
|
- 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.
|
See `examples/provisioned-resources/README.md` for a full example.
|
||||||
|
|
||||||
### Simplified setup (no remote mounts)
|
### 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
|
```yaml
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.38
|
image: ghcr.io/nicotsx/zerobyte:v0.30
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|
@ -174,8 +139,8 @@ services:
|
||||||
**Trade-offs:**
|
**Trade-offs:**
|
||||||
|
|
||||||
- ✅ Improved security by reducing container capabilities
|
- ✅ Improved security by reducing container capabilities
|
||||||
- ✅ Support for local directories as backup sources
|
- ✅ Support for local directories
|
||||||
- ✅ Support all repository types, local and remote (S3, GCS, Azure, rclone)
|
- ✅ Keep support all repository types (local, S3, GCS, Azure, rclone)
|
||||||
- ❌ Cannot mount NFS, SMB, WebDAV, or SFTP shares directly from Zerobyte
|
- ❌ Cannot mount NFS, SMB, WebDAV, or SFTP shares directly from Zerobyte
|
||||||
|
|
||||||
If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`.
|
If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`.
|
||||||
|
|
@ -190,12 +155,12 @@ Zerobyte supports multiple volume backends including NFS, SMB, WebDAV, SFTP, and
|
||||||
|
|
||||||
To add your first volume, navigate to the "Volumes" section in the web interface and click on "Create volume". Fill in the required details such as volume name, type, and connection settings.
|
To add your first volume, navigate to the "Volumes" section in the web interface and click on "Create volume". Fill in the required details such as volume name, type, and connection settings.
|
||||||
|
|
||||||
If you want to backup a local directory on the same host where Zerobyte is running, you'll first need to mount that directory into the Zerobyte container. You can do this by adding a volume mapping in your `docker-compose.yml` file. For example, to mount `/path/to/your/directory` from the host to `/mydata` in the container, you would add the following line under the `volumes` section:
|
If you want to track a local directory on the same server where Zerobyte is running, you'll first need to mount that directory into the Zerobyte container. You can do this by adding a volume mapping in your `docker-compose.yml` file. For example, to mount `/path/to/your/directory` from the host to `/mydata` in the container, you would add the following line under the `volumes` section:
|
||||||
|
|
||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.38
|
image: ghcr.io/nicotsx/zerobyte:v0.30
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|
@ -223,17 +188,17 @@ docker compose up -d
|
||||||
|
|
||||||
Now, when adding a new volume in the Zerobyte web interface, you can select "Directory" as the volume type and search for your mounted path (e.g., `/mydata`) as the source path.
|
Now, when adding a new volume in the Zerobyte web interface, you can select "Directory" as the volume type and search for your mounted path (e.g., `/mydata`) as the source path.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Creating a repository
|
## Creating a repository
|
||||||
|
|
||||||
A repository is where your backups will be securely stored encrypted. Zerobyte supports multiple storage backends for your backup repositories:
|
A repository is where your backups will be securely stored encrypted. Zerobyte supports multiple storage backends for your backup repositories:
|
||||||
|
|
||||||
- **Local directories**: Store backups on local disk subfolders in `/var/lib/zerobyte/repositories/` or any other (mounted) path
|
- **Local directories** - Store backups on local disk subfolder of `/var/lib/zerobyte/repositories/` or any other (mounted) path
|
||||||
- **S3-compatible storage**: Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
|
- **S3-compatible storage** - Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
|
||||||
- **Google Cloud Storage**: Google's cloud storage service
|
- **Google Cloud Storage** - Google's cloud storage service
|
||||||
- **Azure Blob Storage**: Microsoft Azure storage
|
- **Azure Blob Storage** - Microsoft Azure storage
|
||||||
- **rclone remotes**: 40+ cloud storage providers via rclone (see below)
|
- **rclone remotes** - 40+ cloud storage providers via rclone (see below)
|
||||||
|
|
||||||
Repositories are optimized for storage efficiency and data integrity, leveraging Restic's deduplication and encryption features.
|
Repositories are optimized for storage efficiency and data integrity, leveraging Restic's deduplication and encryption features.
|
||||||
|
|
||||||
|
|
@ -270,7 +235,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.38
|
image: ghcr.io/nicotsx/zerobyte:v0.30
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|
@ -288,7 +253,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
||||||
+ - ~/.config/rclone:/root/.config/rclone:ro
|
+ - ~/.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
|
> ```yaml
|
||||||
> environment:
|
> environment:
|
||||||
|
|
@ -324,30 +289,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.
|
After configuring the backup job, save it and Zerobyte will automatically execute the backup according to the defined schedule.
|
||||||
You can monitor the progress and status of your backup jobs in the "Backups" section of the web interface.
|
You can monitor the progress and status of your backup jobs in the "Backups" section of the web interface.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Restoring data
|
## Restoring data
|
||||||
|
|
||||||
Zerobyte allows you to easily restore your data from backups. To restore data, navigate to the "Backups" section and select the backup job from which you want to restore data. You can then choose a specific backup snapshot and select the files or directories you wish to restore. The data you select will be restored to their original location.
|
Zerobyte allows you to easily restore your data from backups. To restore data, navigate to the "Backups" section and select the backup job from which you want to restore data. You can then choose a specific backup snapshot and select the files or directories you wish to restore. The data you select will be restored to their original location.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Authentication
|
## 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:
|
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.
|
### Reverse Proxy Setup
|
||||||
|
|
||||||
### Cookie security
|
|
||||||
|
|
||||||
- **IP Address/HTTP access**: Set `BASE_URL=http://192.168.1.50:4096` (or your IP). Cookies will use `Secure: false`, allowing immediate login without SSL.
|
|
||||||
- **Domain/HTTPS access**: Set `BASE_URL=https://zerobyte.example.com`. Cookies will automatically use `Secure: true` for protected sessions.
|
|
||||||
|
|
||||||
### Reverse proxy setup
|
|
||||||
|
|
||||||
If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.):
|
If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.):
|
||||||
|
|
||||||
|
|
@ -355,7 +314,7 @@ 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
|
2. The app will automatically enable secure cookies based on the `https://` prefix
|
||||||
3. Ensure your proxy passes the `X-Forwarded-Proto` header
|
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
|
- 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
|
- Local IP addresses (e.g., `http://192.168.x.x`) are **not** treated as secure contexts by browsers, so secure cookies are disabled automatically
|
||||||
|
|
@ -366,7 +325,7 @@ If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.):
|
||||||
|
|
||||||
For troubleshooting common issues, please refer to the [TROUBLESHOOTING.md](TROUBLESHOOTING.md) file.
|
For troubleshooting common issues, please refer to the [TROUBLESHOOTING.md](TROUBLESHOOTING.md) file.
|
||||||
|
|
||||||
## Third-party software
|
## Third-Party Software
|
||||||
|
|
||||||
This project includes the following third-party software components:
|
This project includes the following third-party software components:
|
||||||
|
|
||||||
|
|
@ -387,10 +346,10 @@ Contributions by anyone are welcome! If you find a bug or have a feature request
|
||||||
|
|
||||||
## Development (no Docker)
|
## Development (no Docker)
|
||||||
|
|
||||||
After installing Vite+ globally, you can run Zerobyte locally during development without Docker:
|
You can run Zerobyte locally during development without Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
vp install
|
bun install
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -404,10 +363,10 @@ RESTIC_PASS_FILE=./data/restic.pass
|
||||||
RESTIC_CACHE_DIR=./data/restic/cache
|
RESTIC_CACHE_DIR=./data/restic/cache
|
||||||
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
|
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
|
||||||
ZEROBYTE_VOLUMES_DIR=./data/volumes
|
ZEROBYTE_VOLUMES_DIR=./data/volumes
|
||||||
BASE_URL=https://*.localhost
|
BASE_URL=http://localhost:4096
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Remote mount backends (NFS/SMB/WebDAV/SFTP) rely on Linux mount tooling and `CAP_SYS_ADMIN`; on macOS they are expected to be unavailable.
|
- Remote mount backends (NFS/SMB/WebDAV/SFTP) rely on Linux mount tooling and `CAP_SYS_ADMIN`; on macOS they are expected to be unavailable.
|
||||||
- To actually run backups/repository checks, install `restic` on your machine (e.g., via Homebrew). If `restic` is not installed, the app still starts but backup operations will fail with a clear error.
|
- To actually run backups/repository checks, install `restic` on your machine (e.g. via Homebrew). If `restic` is not installed, the app still starts but backup operations will fail with a clear error.
|
||||||
|
|
|
||||||
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:**
|
**Diagnosis:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check which config file rclone will use inside the container
|
# Check if config is accessible inside container
|
||||||
docker exec zerobyte sh -lc 'echo HOME=$HOME; rclone config file'
|
docker exec zerobyte ls -la /root/.config/rclone/
|
||||||
|
docker exec zerobyte cat /root/.config/rclone/rclone.conf
|
||||||
```
|
```
|
||||||
|
|
||||||
**Solutions:**
|
**Solutions:**
|
||||||
|
|
|
||||||
|
|
@ -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()}` : ""}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { client } from '../client.gen';
|
import { client } from '../client.gen';
|
||||||
import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getMirrorSyncStatus, getNotificationDestination, getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, syncMirror, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
|
import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
|
||||||
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetMirrorSyncStatusData, GetMirrorSyncStatusResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshRepositoryStatsData, RefreshRepositoryStatsResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, SyncMirrorData, SyncMirrorResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshRepositoryStatsData, RefreshRepositoryStatsResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
||||||
|
|
||||||
export type QueryKey<TOptions extends Options> = [
|
export type QueryKey<TOptions extends Options> = [
|
||||||
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
|
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
|
||||||
|
|
@ -1098,41 +1098,6 @@ export const updateScheduleMirrorsMutation = (options?: Partial<Options<UpdateSc
|
||||||
return mutationOptions;
|
return mutationOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMirrorSyncStatusQueryKey = (options: Options<GetMirrorSyncStatusData>) => createQueryKey('getMirrorSyncStatus', options);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get sync status for a specific mirror, including missing snapshots
|
|
||||||
*/
|
|
||||||
export const getMirrorSyncStatusOptions = (options: Options<GetMirrorSyncStatusData>) => queryOptions<GetMirrorSyncStatusResponse, DefaultError, GetMirrorSyncStatusResponse, ReturnType<typeof getMirrorSyncStatusQueryKey>>({
|
|
||||||
queryFn: async ({ queryKey, signal }) => {
|
|
||||||
const { data } = await getMirrorSyncStatus({
|
|
||||||
...options,
|
|
||||||
...queryKey[0],
|
|
||||||
signal,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
queryKey: getMirrorSyncStatusQueryKey(options)
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync selected snapshots to a specific mirror repository
|
|
||||||
*/
|
|
||||||
export const syncMirrorMutation = (options?: Partial<Options<SyncMirrorData>>): UseMutationOptions<SyncMirrorResponse, DefaultError, Options<SyncMirrorData>> => {
|
|
||||||
const mutationOptions: UseMutationOptions<SyncMirrorResponse, DefaultError, Options<SyncMirrorData>> = {
|
|
||||||
mutationFn: async (fnOptions) => {
|
|
||||||
const { data } = await syncMirror({
|
|
||||||
...options,
|
|
||||||
...fnOptions,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return mutationOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getMirrorCompatibilityQueryKey = (options: Options<GetMirrorCompatibilityData>) => createQueryKey('getMirrorCompatibility', options);
|
export const getMirrorCompatibilityQueryKey = (options: Options<GetMirrorCompatibilityData>) => createQueryKey('getMirrorCompatibility', options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -32,14 +32,7 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
|
|
||||||
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
|
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
|
||||||
|
|
||||||
const beforeRequest = async <
|
const beforeRequest = async (options: RequestOptions) => {
|
||||||
TData = unknown,
|
|
||||||
TResponseStyle extends 'data' | 'fields' = 'fields',
|
|
||||||
ThrowOnError extends boolean = boolean,
|
|
||||||
Url extends string = string,
|
|
||||||
>(
|
|
||||||
options: RequestOptions<TData, TResponseStyle, ThrowOnError, Url>,
|
|
||||||
) => {
|
|
||||||
const opts = {
|
const opts = {
|
||||||
..._config,
|
..._config,
|
||||||
...options,
|
...options,
|
||||||
|
|
@ -49,7 +42,10 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (opts.security) {
|
if (opts.security) {
|
||||||
await setAuthParams(opts);
|
await setAuthParams({
|
||||||
|
...opts,
|
||||||
|
security: opts.security,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.requestValidator) {
|
if (opts.requestValidator) {
|
||||||
|
|
@ -65,162 +61,178 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
opts.headers.delete('Content-Type');
|
opts.headers.delete('Content-Type');
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedOpts = opts as typeof opts &
|
const url = buildUrl(opts);
|
||||||
ResolvedRequestOptions<TResponseStyle, ThrowOnError, Url>;
|
|
||||||
const url = buildUrl(resolvedOpts);
|
|
||||||
|
|
||||||
return { opts: resolvedOpts, url };
|
return { opts, url };
|
||||||
};
|
};
|
||||||
|
|
||||||
const request: Client['request'] = async (options) => {
|
const request: Client['request'] = async (options) => {
|
||||||
const throwOnError = options.throwOnError ?? _config.throwOnError;
|
// @ts-expect-error
|
||||||
const responseStyle = options.responseStyle ?? _config.responseStyle;
|
const { opts, url } = await beforeRequest(options);
|
||||||
|
const requestInit: ReqInit = {
|
||||||
|
redirect: 'follow',
|
||||||
|
...opts,
|
||||||
|
body: getValidRequestBody(opts),
|
||||||
|
};
|
||||||
|
|
||||||
let request: Request | undefined;
|
let request = new Request(url, requestInit);
|
||||||
let response: Response | undefined;
|
|
||||||
|
for (const fn of interceptors.request.fns) {
|
||||||
|
if (fn) {
|
||||||
|
request = await fn(request, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { opts, url } = await beforeRequest(options);
|
|
||||||
const requestInit: ReqInit = {
|
|
||||||
redirect: 'follow',
|
|
||||||
...opts,
|
|
||||||
body: getValidRequestBody(opts),
|
|
||||||
};
|
|
||||||
|
|
||||||
request = new Request(url, requestInit);
|
|
||||||
|
|
||||||
for (const fn of interceptors.request.fns) {
|
|
||||||
if (fn) {
|
|
||||||
request = await fn(request, opts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetch must be assigned here, otherwise it would throw the error:
|
|
||||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
|
||||||
const _fetch = opts.fetch!;
|
|
||||||
|
|
||||||
response = await _fetch(request);
|
response = await _fetch(request);
|
||||||
|
|
||||||
for (const fn of interceptors.response.fns) {
|
|
||||||
if (fn) {
|
|
||||||
response = await fn(response, request, opts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
request,
|
|
||||||
response,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const parseAs =
|
|
||||||
(opts.parseAs === 'auto'
|
|
||||||
? getParseAs(response.headers.get('Content-Type'))
|
|
||||||
: opts.parseAs) ?? 'json';
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (parseAs === 'json') {
|
|
||||||
if (opts.responseValidator) {
|
|
||||||
await opts.responseValidator(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.responseTransformer) {
|
|
||||||
data = await opts.responseTransformer(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return opts.responseStyle === 'data'
|
|
||||||
? data
|
|
||||||
: {
|
|
||||||
data,
|
|
||||||
...result,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const textError = await response.text();
|
|
||||||
let jsonError: unknown;
|
|
||||||
|
|
||||||
try {
|
|
||||||
jsonError = JSON.parse(textError);
|
|
||||||
} catch {
|
|
||||||
// noop
|
|
||||||
}
|
|
||||||
|
|
||||||
throw jsonError ?? textError;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Handle fetch exceptions (AbortError, network errors, etc.)
|
||||||
let finalError = error;
|
let finalError = error;
|
||||||
|
|
||||||
for (const fn of interceptors.error.fns) {
|
for (const fn of interceptors.error.fns) {
|
||||||
if (fn) {
|
if (fn) {
|
||||||
finalError = await fn(finalError, response, request, options as ResolvedRequestOptions);
|
finalError = (await fn(error, undefined as any, request, opts)) as unknown;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finalError = finalError || {};
|
finalError = finalError || ({} as unknown);
|
||||||
|
|
||||||
if (throwOnError) {
|
if (opts.throwOnError) {
|
||||||
throw finalError;
|
throw finalError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: we probably want to return error and improve types
|
// Return error response
|
||||||
return responseStyle === 'data'
|
return opts.responseStyle === 'data'
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
error: finalError,
|
error: finalError,
|
||||||
request,
|
request,
|
||||||
response,
|
response: undefined as any,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const fn of interceptors.response.fns) {
|
||||||
|
if (fn) {
|
||||||
|
response = await fn(response, request, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const parseAs =
|
||||||
|
(opts.parseAs === 'auto'
|
||||||
|
? getParseAs(response.headers.get('Content-Type'))
|
||||||
|
: opts.parseAs) ?? 'json';
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (parseAs === 'json') {
|
||||||
|
if (opts.responseValidator) {
|
||||||
|
await opts.responseValidator(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.responseTransformer) {
|
||||||
|
data = await opts.responseTransformer(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts.responseStyle === 'data'
|
||||||
|
? data
|
||||||
|
: {
|
||||||
|
data,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const textError = await response.text();
|
||||||
|
let jsonError: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
jsonError = JSON.parse(textError);
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = jsonError ?? textError;
|
||||||
|
let finalError = error;
|
||||||
|
|
||||||
|
for (const fn of interceptors.error.fns) {
|
||||||
|
if (fn) {
|
||||||
|
finalError = (await fn(error, response, request, opts)) as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalError = finalError || ({} as string);
|
||||||
|
|
||||||
|
if (opts.throwOnError) {
|
||||||
|
throw finalError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: we probably want to return error and improve types
|
||||||
|
return opts.responseStyle === 'data'
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
error: finalError,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
||||||
|
|
@ -231,6 +243,7 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
return createSseClient({
|
return createSseClient({
|
||||||
...opts,
|
...opts,
|
||||||
body: opts.body as BodyInit | null | undefined,
|
body: opts.body as BodyInit | null | undefined,
|
||||||
|
headers: opts.headers as unknown as Record<string, string>,
|
||||||
method,
|
method,
|
||||||
onRequest: async (url, init) => {
|
onRequest: async (url, init) => {
|
||||||
let request = new Request(url, init);
|
let request = new Request(url, init);
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,6 @@ export interface ResolvedRequestOptions<
|
||||||
ThrowOnError extends boolean = boolean,
|
ThrowOnError extends boolean = boolean,
|
||||||
Url extends string = string,
|
Url extends string = string,
|
||||||
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
||||||
headers: Headers;
|
|
||||||
serializedBody?: string;
|
serializedBody?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,10 +127,8 @@ export type RequestResult<
|
||||||
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
|
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;
|
||||||
request?: Request;
|
response: Response;
|
||||||
/** response may be undefined, because error may be from building the request object itself or from a network error */
|
|
||||||
response?: Response;
|
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|
@ -156,7 +153,7 @@ type SseFn = <
|
||||||
ThrowOnError extends boolean = false,
|
ThrowOnError extends boolean = false,
|
||||||
TResponseStyle extends ResponseStyle = 'fields',
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
>(
|
>(
|
||||||
options: Omit<RequestOptions<never, TResponseStyle, ThrowOnError>, 'method'>,
|
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||||
) => Promise<ServerSentEventsResult<TData, TError>>;
|
) => Promise<ServerSentEventsResult<TData, TError>>;
|
||||||
|
|
||||||
type RequestFn = <
|
type RequestFn = <
|
||||||
|
|
|
||||||
|
|
@ -119,12 +119,14 @@ const checkForExistence = (
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function setAuthParams(
|
export const setAuthParams = async ({
|
||||||
options: Pick<RequestOptions, 'auth' | 'query' | 'security'> & {
|
security,
|
||||||
|
...options
|
||||||
|
}: Pick<Required<RequestOptions>, 'security'> &
|
||||||
|
Pick<RequestOptions, 'auth' | 'query'> & {
|
||||||
headers: Headers;
|
headers: Headers;
|
||||||
},
|
}) => {
|
||||||
): Promise<void> {
|
for (const auth of security) {
|
||||||
for (const auth of options.security ?? []) {
|
|
||||||
if (checkForExistence(options, auth.name)) {
|
if (checkForExistence(options, auth.name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -153,7 +155,7 @@ export async function setAuthParams(
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const buildUrl: Client['buildUrl'] = (options) =>
|
export const buildUrl: Client['buildUrl'] = (options) =>
|
||||||
getUrl({
|
getUrl({
|
||||||
|
|
@ -203,7 +205,7 @@ export const mergeHeaders = (
|
||||||
mergedHeaders.append(key, v as string);
|
mergedHeaders.append(key, v as string);
|
||||||
}
|
}
|
||||||
} else if (value !== undefined) {
|
} else if (value !== undefined) {
|
||||||
// assume object headers are meant to be JSON stringified, i.e., their
|
// assume object headers are meant to be JSON stringified, i.e. their
|
||||||
// content value in OpenAPI specification is 'application/json'
|
// content value in OpenAPI specification is 'application/json'
|
||||||
mergedHeaders.set(
|
mergedHeaders.set(
|
||||||
key,
|
key,
|
||||||
|
|
@ -217,10 +219,8 @@ export const mergeHeaders = (
|
||||||
|
|
||||||
type ErrInterceptor<Err, Res, Req, Options> = (
|
type ErrInterceptor<Err, Res, Req, Options> = (
|
||||||
error: Err,
|
error: Err,
|
||||||
/** response may be undefined due to a network error where no response object is produced */
|
response: Res,
|
||||||
response: Res | undefined,
|
request: Req,
|
||||||
/** request may be undefined, because error may be from building the request object itself */
|
|
||||||
request: Req | undefined,
|
|
||||||
options: Options,
|
options: Options,
|
||||||
) => Err | Promise<Err>;
|
) => Err | Promise<Err>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unkn
|
||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createSseClient<TData = unknown>({
|
export const createSseClient = <TData = unknown>({
|
||||||
onRequest,
|
onRequest,
|
||||||
onSseError,
|
onSseError,
|
||||||
onSseEvent,
|
onSseEvent,
|
||||||
|
|
@ -92,7 +92,7 @@ export function createSseClient<TData = unknown>({
|
||||||
sseSleepFn,
|
sseSleepFn,
|
||||||
url,
|
url,
|
||||||
...options
|
...options
|
||||||
}: ServerSentEventsOptions): ServerSentEventsResult<TData> {
|
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
|
||||||
let lastEventId: string | undefined;
|
let lastEventId: string | undefined;
|
||||||
|
|
||||||
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||||
|
|
@ -156,7 +156,8 @@ export function createSseClient<TData = unknown>({
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
buffer += value;
|
buffer += value;
|
||||||
buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings
|
// Normalize line endings: CRLF -> LF, then CR -> LF
|
||||||
|
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
|
||||||
const chunks = buffer.split('\n\n');
|
const chunks = buffer.split('\n\n');
|
||||||
buffer = chunks.pop() ?? '';
|
buffer = chunks.pop() ?? '';
|
||||||
|
|
@ -240,4 +241,4 @@ export function createSseClient<TData = unknown>({
|
||||||
const stream = createStream();
|
const stream = createStream();
|
||||||
|
|
||||||
return { stream };
|
return { stream };
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ export interface Config {
|
||||||
requestValidator?: (data: unknown) => Promise<unknown>;
|
requestValidator?: (data: unknown) => Promise<unknown>;
|
||||||
/**
|
/**
|
||||||
* A function transforming response data before it's returned. This is useful
|
* A function transforming response data before it's returned. This is useful
|
||||||
* for post-processing data, e.g., converting ISO strings into Date objects.
|
* for post-processing data, e.g. converting ISO strings into Date objects.
|
||||||
*/
|
*/
|
||||||
responseTransformer?: (data: unknown) => Promise<unknown>;
|
responseTransformer?: (data: unknown) => Promise<unknown>;
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ export function getValidRequestBody(options: {
|
||||||
return hasSerializedBody ? options.serializedBody : null;
|
return hasSerializedBody ? options.serializedBody : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// not all clients implement a serializedBody property (i.e., client-axios)
|
// not all clients implement a serializedBody property (i.e. client-axios)
|
||||||
return options.body !== '' ? options.body : null;
|
return options.body !== '' ? options.body : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ export {
|
||||||
getBackupScheduleForVolume,
|
getBackupScheduleForVolume,
|
||||||
getDevPanel,
|
getDevPanel,
|
||||||
getMirrorCompatibility,
|
getMirrorCompatibility,
|
||||||
getMirrorSyncStatus,
|
|
||||||
getNotificationDestination,
|
getNotificationDestination,
|
||||||
getOrgMembers,
|
getOrgMembers,
|
||||||
getPublicSsoProviders,
|
getPublicSsoProviders,
|
||||||
|
|
@ -63,7 +62,6 @@ export {
|
||||||
setRegistrationStatus,
|
setRegistrationStatus,
|
||||||
startDoctor,
|
startDoctor,
|
||||||
stopBackup,
|
stopBackup,
|
||||||
syncMirror,
|
|
||||||
tagSnapshots,
|
tagSnapshots,
|
||||||
testConnection,
|
testConnection,
|
||||||
testNotificationDestination,
|
testNotificationDestination,
|
||||||
|
|
@ -155,9 +153,6 @@ export type {
|
||||||
GetMirrorCompatibilityData,
|
GetMirrorCompatibilityData,
|
||||||
GetMirrorCompatibilityResponse,
|
GetMirrorCompatibilityResponse,
|
||||||
GetMirrorCompatibilityResponses,
|
GetMirrorCompatibilityResponses,
|
||||||
GetMirrorSyncStatusData,
|
|
||||||
GetMirrorSyncStatusResponse,
|
|
||||||
GetMirrorSyncStatusResponses,
|
|
||||||
GetNotificationDestinationData,
|
GetNotificationDestinationData,
|
||||||
GetNotificationDestinationErrors,
|
GetNotificationDestinationErrors,
|
||||||
GetNotificationDestinationResponse,
|
GetNotificationDestinationResponse,
|
||||||
|
|
@ -268,10 +263,6 @@ export type {
|
||||||
StopBackupErrors,
|
StopBackupErrors,
|
||||||
StopBackupResponse,
|
StopBackupResponse,
|
||||||
StopBackupResponses,
|
StopBackupResponses,
|
||||||
SyncMirrorData,
|
|
||||||
SyncMirrorErrors,
|
|
||||||
SyncMirrorResponse,
|
|
||||||
SyncMirrorResponses,
|
|
||||||
TagSnapshotsData,
|
TagSnapshotsData,
|
||||||
TagSnapshotsResponse,
|
TagSnapshotsResponse,
|
||||||
TagSnapshotsResponses,
|
TagSnapshotsResponses,
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
|
|
||||||
import type { Client, Options as Options2, TDataShape } from './client';
|
import type { Client, Options as Options2, TDataShape } from './client';
|
||||||
import { client } from './client.gen';
|
import { client } from './client.gen';
|
||||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponse, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetMirrorSyncStatusData, GetMirrorSyncStatusResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, SyncMirrorData, SyncMirrorErrors, SyncMirrorResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
||||||
|
|
||||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||||
/**
|
/**
|
||||||
* You can provide a client instance returned by `createClient()` instead of
|
* You can provide a client instance returned by `createClient()` instead of
|
||||||
* individual options. This might be also useful if you want to implement a
|
* individual options. This might be also useful if you want to implement a
|
||||||
|
|
@ -307,7 +307,7 @@ export const tagSnapshots = <ThrowOnError extends boolean = false>(options: Opti
|
||||||
/**
|
/**
|
||||||
* Execute a restic command against a repository (dev panel only)
|
* Execute a restic command against a repository (dev panel only)
|
||||||
*/
|
*/
|
||||||
export const devPanelExec = <ThrowOnError extends boolean = false>(options: Options<DevPanelExecData, ThrowOnError, DevPanelExecResponse>) => (options.client ?? client).sse.post<DevPanelExecResponses, DevPanelExecErrors, ThrowOnError>({
|
export const devPanelExec = <ThrowOnError extends boolean = false>(options: Options<DevPanelExecData, ThrowOnError>) => (options.client ?? client).sse.post<DevPanelExecResponses, DevPanelExecErrors, ThrowOnError>({
|
||||||
url: '/api/v1/repositories/{shortId}/exec',
|
url: '/api/v1/repositories/{shortId}/exec',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -409,23 +409,6 @@ export const updateScheduleMirrors = <ThrowOnError extends boolean = false>(opti
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Get sync status for a specific mirror, including missing snapshots
|
|
||||||
*/
|
|
||||||
export const getMirrorSyncStatus = <ThrowOnError extends boolean = false>(options: Options<GetMirrorSyncStatusData, ThrowOnError>) => (options.client ?? client).get<GetMirrorSyncStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/status', ...options });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync selected snapshots to a specific mirror repository
|
|
||||||
*/
|
|
||||||
export const syncMirror = <ThrowOnError extends boolean = false>(options: Options<SyncMirrorData, ThrowOnError>) => (options.client ?? client).post<SyncMirrorResponses, SyncMirrorErrors, ThrowOnError>({
|
|
||||||
url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/sync',
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get mirror compatibility info for all repositories relative to a backup schedule's primary repository
|
* Get mirror compatibility info for all repositories relative to a backup schedule's primary repository
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -337,7 +337,14 @@ export type ListVolumesResponses = {
|
||||||
200: Array<{
|
200: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
shortId: string;
|
shortId: string;
|
||||||
|
provisioningId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
|
lastError: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
lastHealthCheck: number;
|
||||||
config: {
|
config: {
|
||||||
backend: 'nfs';
|
backend: 'nfs';
|
||||||
server: string;
|
server: string;
|
||||||
|
|
@ -352,7 +359,6 @@ export type ListVolumesResponses = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -386,15 +392,7 @@ export type ListVolumesResponses = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
};
|
};
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
lastHealthCheck: number;
|
|
||||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
|
||||||
status: 'mounted' | 'unmounted' | 'error';
|
|
||||||
lastError: string | null;
|
|
||||||
provisioningId?: string | null;
|
|
||||||
autoRemount: boolean;
|
autoRemount: boolean;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
@ -418,7 +416,6 @@ export type CreateVolumeData = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -452,7 +449,6 @@ export type CreateVolumeData = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|
@ -467,7 +463,14 @@ export type CreateVolumeResponses = {
|
||||||
201: {
|
201: {
|
||||||
id: number;
|
id: number;
|
||||||
shortId: string;
|
shortId: string;
|
||||||
|
provisioningId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
|
lastError: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
lastHealthCheck: number;
|
||||||
config: {
|
config: {
|
||||||
backend: 'nfs';
|
backend: 'nfs';
|
||||||
server: string;
|
server: string;
|
||||||
|
|
@ -482,7 +485,6 @@ export type CreateVolumeResponses = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -516,15 +518,7 @@ export type CreateVolumeResponses = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
};
|
};
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
lastHealthCheck: number;
|
|
||||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
|
||||||
status: 'mounted' | 'unmounted' | 'error';
|
|
||||||
lastError: string | null;
|
|
||||||
provisioningId?: string | null;
|
|
||||||
autoRemount: boolean;
|
autoRemount: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -547,7 +541,6 @@ export type TestConnectionData = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -581,7 +574,6 @@ export type TestConnectionData = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|
@ -645,7 +637,14 @@ export type GetVolumeResponses = {
|
||||||
volume: {
|
volume: {
|
||||||
id: number;
|
id: number;
|
||||||
shortId: string;
|
shortId: string;
|
||||||
|
provisioningId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
|
lastError: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
lastHealthCheck: number;
|
||||||
config: {
|
config: {
|
||||||
backend: 'nfs';
|
backend: 'nfs';
|
||||||
server: string;
|
server: string;
|
||||||
|
|
@ -660,7 +659,6 @@ export type GetVolumeResponses = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -694,21 +692,13 @@ export type GetVolumeResponses = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
};
|
};
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
lastHealthCheck: number;
|
|
||||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
|
||||||
status: 'mounted' | 'unmounted' | 'error';
|
|
||||||
lastError: string | null;
|
|
||||||
provisioningId?: string | null;
|
|
||||||
autoRemount: boolean;
|
autoRemount: boolean;
|
||||||
};
|
};
|
||||||
statfs: {
|
statfs: {
|
||||||
total?: number;
|
total: number;
|
||||||
used?: number;
|
used: number;
|
||||||
free?: number;
|
free: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -733,7 +723,6 @@ export type UpdateVolumeData = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -767,7 +756,6 @@ export type UpdateVolumeData = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
path: {
|
path: {
|
||||||
|
|
@ -791,7 +779,14 @@ export type UpdateVolumeResponses = {
|
||||||
200: {
|
200: {
|
||||||
id: number;
|
id: number;
|
||||||
shortId: string;
|
shortId: string;
|
||||||
|
provisioningId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
|
lastError: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
lastHealthCheck: number;
|
||||||
config: {
|
config: {
|
||||||
backend: 'nfs';
|
backend: 'nfs';
|
||||||
server: string;
|
server: string;
|
||||||
|
|
@ -806,7 +801,6 @@ export type UpdateVolumeResponses = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -840,15 +834,7 @@ export type UpdateVolumeResponses = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
};
|
};
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
lastHealthCheck: number;
|
|
||||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
|
||||||
status: 'mounted' | 'unmounted' | 'error';
|
|
||||||
lastError: string | null;
|
|
||||||
provisioningId?: string | null;
|
|
||||||
autoRemount: boolean;
|
autoRemount: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -869,8 +855,8 @@ export type MountVolumeResponses = {
|
||||||
* Volume mounted successfully
|
* Volume mounted successfully
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
status: 'mounted' | 'unmounted' | 'error';
|
|
||||||
error?: string;
|
error?: string;
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -890,8 +876,8 @@ export type UnmountVolumeResponses = {
|
||||||
* Volume unmounted successfully
|
* Volume unmounted successfully
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
status: 'mounted' | 'unmounted' | 'error';
|
|
||||||
error?: string;
|
error?: string;
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -918,8 +904,8 @@ export type HealthCheckVolumeResponses = {
|
||||||
* Volume health check result
|
* Volume health check result
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
status: 'mounted' | 'unmounted' | 'error';
|
|
||||||
error?: string;
|
error?: string;
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -946,7 +932,7 @@ export type ListFilesResponses = {
|
||||||
files: Array<{
|
files: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
type: 'directory' | 'file';
|
type: 'file' | 'directory';
|
||||||
size?: number;
|
size?: number;
|
||||||
modifiedAt?: number;
|
modifiedAt?: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
@ -980,8 +966,8 @@ export type BrowseFilesystemResponses = {
|
||||||
directories: Array<{
|
directories: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
type: 'directory';
|
type: 'file' | 'directory';
|
||||||
size?: unknown;
|
size?: number;
|
||||||
modifiedAt?: number;
|
modifiedAt?: number;
|
||||||
}>;
|
}>;
|
||||||
path: string;
|
path: string;
|
||||||
|
|
@ -1150,7 +1136,6 @@ export type ListRepositoriesResponses = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -1334,7 +1319,6 @@ export type CreateRepositoryData = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -1573,7 +1557,6 @@ export type GetRepositoryResponses = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -1757,7 +1740,6 @@ export type UpdateRepositoryData = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -1949,7 +1931,6 @@ export type UpdateRepositoryResponses = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -2267,7 +2248,6 @@ export type RestoreSnapshotData = {
|
||||||
excludeXattr?: Array<string>;
|
excludeXattr?: Array<string>;
|
||||||
delete?: boolean;
|
delete?: boolean;
|
||||||
targetPath?: string;
|
targetPath?: string;
|
||||||
targetAgentId?: string;
|
|
||||||
overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never';
|
overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never';
|
||||||
};
|
};
|
||||||
path: {
|
path: {
|
||||||
|
|
@ -2279,11 +2259,13 @@ export type RestoreSnapshotData = {
|
||||||
|
|
||||||
export type RestoreSnapshotResponses = {
|
export type RestoreSnapshotResponses = {
|
||||||
/**
|
/**
|
||||||
* Snapshot restore started
|
* Snapshot restored successfully
|
||||||
*/
|
*/
|
||||||
202: {
|
200: {
|
||||||
restoreId: string;
|
success: boolean;
|
||||||
status: 'started';
|
message: string;
|
||||||
|
filesRestored: number;
|
||||||
|
filesSkipped: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2448,24 +2430,9 @@ export type ListBackupSchedulesResponses = {
|
||||||
} | null;
|
} | null;
|
||||||
excludePatterns: Array<string> | null;
|
excludePatterns: Array<string> | null;
|
||||||
excludeIfPresent: Array<string> | null;
|
excludeIfPresent: Array<string> | null;
|
||||||
includePaths: Array<string> | null;
|
|
||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
oneFileSystem: boolean;
|
oneFileSystem: boolean;
|
||||||
customResticParams: Array<string> | null;
|
customResticParams: Array<string> | null;
|
||||||
backupWebhooks: {
|
|
||||||
pre: {
|
|
||||||
url: string;
|
|
||||||
headers?: Array<string>;
|
|
||||||
body?: string;
|
|
||||||
} | null;
|
|
||||||
post: {
|
|
||||||
url: string;
|
|
||||||
headers?: Array<string>;
|
|
||||||
body?: string;
|
|
||||||
} | null;
|
|
||||||
} | null;
|
|
||||||
maxRetries: number;
|
|
||||||
retryDelay: number;
|
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
|
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
|
||||||
lastBackupError: string | null;
|
lastBackupError: string | null;
|
||||||
|
|
@ -2475,7 +2442,14 @@ export type ListBackupSchedulesResponses = {
|
||||||
volume: {
|
volume: {
|
||||||
id: number;
|
id: number;
|
||||||
shortId: string;
|
shortId: string;
|
||||||
|
provisioningId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
|
lastError: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
lastHealthCheck: number;
|
||||||
config: {
|
config: {
|
||||||
backend: 'nfs';
|
backend: 'nfs';
|
||||||
server: string;
|
server: string;
|
||||||
|
|
@ -2490,7 +2464,6 @@ export type ListBackupSchedulesResponses = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -2524,15 +2497,7 @@ export type ListBackupSchedulesResponses = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
};
|
};
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
lastHealthCheck: number;
|
|
||||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
|
||||||
status: 'mounted' | 'unmounted' | 'error';
|
|
||||||
lastError: string | null;
|
|
||||||
provisioningId?: string | null;
|
|
||||||
autoRemount: boolean;
|
autoRemount: boolean;
|
||||||
};
|
};
|
||||||
repository: {
|
repository: {
|
||||||
|
|
@ -2684,7 +2649,6 @@ export type ListBackupSchedulesResponses = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -2740,25 +2704,10 @@ export type CreateBackupScheduleData = {
|
||||||
};
|
};
|
||||||
excludePatterns?: Array<string>;
|
excludePatterns?: Array<string>;
|
||||||
excludeIfPresent?: Array<string>;
|
excludeIfPresent?: Array<string>;
|
||||||
includePaths?: Array<string>;
|
|
||||||
includePatterns?: Array<string>;
|
includePatterns?: Array<string>;
|
||||||
oneFileSystem?: boolean;
|
oneFileSystem?: boolean;
|
||||||
tags?: Array<string>;
|
tags?: Array<string>;
|
||||||
customResticParams?: Array<string>;
|
customResticParams?: Array<string>;
|
||||||
backupWebhooks?: {
|
|
||||||
pre: {
|
|
||||||
url: string;
|
|
||||||
headers?: Array<string>;
|
|
||||||
body?: string;
|
|
||||||
} | null;
|
|
||||||
post: {
|
|
||||||
url: string;
|
|
||||||
headers?: Array<string>;
|
|
||||||
body?: string;
|
|
||||||
} | null;
|
|
||||||
} | null;
|
|
||||||
maxRetries?: number;
|
|
||||||
retryDelay?: number;
|
|
||||||
};
|
};
|
||||||
path?: never;
|
path?: never;
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -2788,24 +2737,9 @@ export type CreateBackupScheduleResponses = {
|
||||||
} | null;
|
} | null;
|
||||||
excludePatterns: Array<string> | null;
|
excludePatterns: Array<string> | null;
|
||||||
excludeIfPresent: Array<string> | null;
|
excludeIfPresent: Array<string> | null;
|
||||||
includePaths: Array<string> | null;
|
|
||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
oneFileSystem: boolean;
|
oneFileSystem: boolean;
|
||||||
customResticParams: Array<string> | null;
|
customResticParams: Array<string> | null;
|
||||||
backupWebhooks: {
|
|
||||||
pre: {
|
|
||||||
url: string;
|
|
||||||
headers?: Array<string>;
|
|
||||||
body?: string;
|
|
||||||
} | null;
|
|
||||||
post: {
|
|
||||||
url: string;
|
|
||||||
headers?: Array<string>;
|
|
||||||
body?: string;
|
|
||||||
} | null;
|
|
||||||
} | null;
|
|
||||||
maxRetries: number;
|
|
||||||
retryDelay: number;
|
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
|
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
|
||||||
lastBackupError: string | null;
|
lastBackupError: string | null;
|
||||||
|
|
@ -2869,24 +2803,9 @@ export type GetBackupScheduleResponses = {
|
||||||
} | null;
|
} | null;
|
||||||
excludePatterns: Array<string> | null;
|
excludePatterns: Array<string> | null;
|
||||||
excludeIfPresent: Array<string> | null;
|
excludeIfPresent: Array<string> | null;
|
||||||
includePaths: Array<string> | null;
|
|
||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
oneFileSystem: boolean;
|
oneFileSystem: boolean;
|
||||||
customResticParams: Array<string> | null;
|
customResticParams: Array<string> | null;
|
||||||
backupWebhooks: {
|
|
||||||
pre: {
|
|
||||||
url: string;
|
|
||||||
headers?: Array<string>;
|
|
||||||
body?: string;
|
|
||||||
} | null;
|
|
||||||
post: {
|
|
||||||
url: string;
|
|
||||||
headers?: Array<string>;
|
|
||||||
body?: string;
|
|
||||||
} | null;
|
|
||||||
} | null;
|
|
||||||
maxRetries: number;
|
|
||||||
retryDelay: number;
|
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
|
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
|
||||||
lastBackupError: string | null;
|
lastBackupError: string | null;
|
||||||
|
|
@ -2896,7 +2815,14 @@ export type GetBackupScheduleResponses = {
|
||||||
volume: {
|
volume: {
|
||||||
id: number;
|
id: number;
|
||||||
shortId: string;
|
shortId: string;
|
||||||
|
provisioningId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
|
lastError: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
lastHealthCheck: number;
|
||||||
config: {
|
config: {
|
||||||
backend: 'nfs';
|
backend: 'nfs';
|
||||||
server: string;
|
server: string;
|
||||||
|
|
@ -2911,7 +2837,6 @@ export type GetBackupScheduleResponses = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -2945,15 +2870,7 @@ export type GetBackupScheduleResponses = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
};
|
};
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
lastHealthCheck: number;
|
|
||||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
|
||||||
status: 'mounted' | 'unmounted' | 'error';
|
|
||||||
lastError: string | null;
|
|
||||||
provisioningId?: string | null;
|
|
||||||
autoRemount: boolean;
|
autoRemount: boolean;
|
||||||
};
|
};
|
||||||
repository: {
|
repository: {
|
||||||
|
|
@ -3105,7 +3022,6 @@ export type GetBackupScheduleResponses = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -3160,25 +3076,10 @@ export type UpdateBackupScheduleData = {
|
||||||
};
|
};
|
||||||
excludePatterns?: Array<string>;
|
excludePatterns?: Array<string>;
|
||||||
excludeIfPresent?: Array<string>;
|
excludeIfPresent?: Array<string>;
|
||||||
includePaths?: Array<string>;
|
|
||||||
includePatterns?: Array<string>;
|
includePatterns?: Array<string>;
|
||||||
oneFileSystem?: boolean;
|
oneFileSystem?: boolean;
|
||||||
tags?: Array<string>;
|
tags?: Array<string>;
|
||||||
customResticParams?: Array<string>;
|
customResticParams?: Array<string>;
|
||||||
backupWebhooks?: {
|
|
||||||
pre: {
|
|
||||||
url: string;
|
|
||||||
headers?: Array<string>;
|
|
||||||
body?: string;
|
|
||||||
} | null;
|
|
||||||
post: {
|
|
||||||
url: string;
|
|
||||||
headers?: Array<string>;
|
|
||||||
body?: string;
|
|
||||||
} | null;
|
|
||||||
} | null;
|
|
||||||
maxRetries?: number;
|
|
||||||
retryDelay?: number;
|
|
||||||
};
|
};
|
||||||
path: {
|
path: {
|
||||||
shortId: string;
|
shortId: string;
|
||||||
|
|
@ -3210,24 +3111,9 @@ export type UpdateBackupScheduleResponses = {
|
||||||
} | null;
|
} | null;
|
||||||
excludePatterns: Array<string> | null;
|
excludePatterns: Array<string> | null;
|
||||||
excludeIfPresent: Array<string> | null;
|
excludeIfPresent: Array<string> | null;
|
||||||
includePaths: Array<string> | null;
|
|
||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
oneFileSystem: boolean;
|
oneFileSystem: boolean;
|
||||||
customResticParams: Array<string> | null;
|
customResticParams: Array<string> | null;
|
||||||
backupWebhooks: {
|
|
||||||
pre: {
|
|
||||||
url: string;
|
|
||||||
headers?: Array<string>;
|
|
||||||
body?: string;
|
|
||||||
} | null;
|
|
||||||
post: {
|
|
||||||
url: string;
|
|
||||||
headers?: Array<string>;
|
|
||||||
body?: string;
|
|
||||||
} | null;
|
|
||||||
} | null;
|
|
||||||
maxRetries: number;
|
|
||||||
retryDelay: number;
|
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
|
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
|
||||||
lastBackupError: string | null;
|
lastBackupError: string | null;
|
||||||
|
|
@ -3271,24 +3157,9 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||||
} | null;
|
} | null;
|
||||||
excludePatterns: Array<string> | null;
|
excludePatterns: Array<string> | null;
|
||||||
excludeIfPresent: Array<string> | null;
|
excludeIfPresent: Array<string> | null;
|
||||||
includePaths: Array<string> | null;
|
|
||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
oneFileSystem: boolean;
|
oneFileSystem: boolean;
|
||||||
customResticParams: Array<string> | null;
|
customResticParams: Array<string> | null;
|
||||||
backupWebhooks: {
|
|
||||||
pre: {
|
|
||||||
url: string;
|
|
||||||
headers?: Array<string>;
|
|
||||||
body?: string;
|
|
||||||
} | null;
|
|
||||||
post: {
|
|
||||||
url: string;
|
|
||||||
headers?: Array<string>;
|
|
||||||
body?: string;
|
|
||||||
} | null;
|
|
||||||
} | null;
|
|
||||||
maxRetries: number;
|
|
||||||
retryDelay: number;
|
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
|
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
|
||||||
lastBackupError: string | null;
|
lastBackupError: string | null;
|
||||||
|
|
@ -3298,7 +3169,14 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||||
volume: {
|
volume: {
|
||||||
id: number;
|
id: number;
|
||||||
shortId: string;
|
shortId: string;
|
||||||
|
provisioningId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
|
lastError: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
lastHealthCheck: number;
|
||||||
config: {
|
config: {
|
||||||
backend: 'nfs';
|
backend: 'nfs';
|
||||||
server: string;
|
server: string;
|
||||||
|
|
@ -3313,7 +3191,6 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -3347,15 +3224,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
};
|
};
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
lastHealthCheck: number;
|
|
||||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
|
||||||
status: 'mounted' | 'unmounted' | 'error';
|
|
||||||
lastError: string | null;
|
|
||||||
provisioningId?: string | null;
|
|
||||||
autoRemount: boolean;
|
autoRemount: boolean;
|
||||||
};
|
};
|
||||||
repository: {
|
repository: {
|
||||||
|
|
@ -3507,7 +3376,6 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -3637,9 +3505,6 @@ export type GetScheduleNotificationsResponses = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
status: 'healthy' | 'error' | 'unknown';
|
|
||||||
lastChecked: number | null;
|
|
||||||
lastError: string | null;
|
|
||||||
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
|
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
|
||||||
config: {
|
config: {
|
||||||
type: 'email';
|
type: 'email';
|
||||||
|
|
@ -3654,6 +3519,7 @@ export type GetScheduleNotificationsResponses = {
|
||||||
} | {
|
} | {
|
||||||
type: 'slack';
|
type: 'slack';
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
|
channel?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
iconEmoji?: string;
|
iconEmoji?: string;
|
||||||
} | {
|
} | {
|
||||||
|
|
@ -3741,9 +3607,6 @@ export type UpdateScheduleNotificationsResponses = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
status: 'healthy' | 'error' | 'unknown';
|
|
||||||
lastChecked: number | null;
|
|
||||||
lastError: string | null;
|
|
||||||
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
|
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
|
||||||
config: {
|
config: {
|
||||||
type: 'email';
|
type: 'email';
|
||||||
|
|
@ -3758,6 +3621,7 @@ export type UpdateScheduleNotificationsResponses = {
|
||||||
} | {
|
} | {
|
||||||
type: 'slack';
|
type: 'slack';
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
|
channel?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
iconEmoji?: string;
|
iconEmoji?: string;
|
||||||
} | {
|
} | {
|
||||||
|
|
@ -3982,7 +3846,6 @@ export type GetScheduleMirrorsResponses = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -4195,7 +4058,6 @@ export type UpdateScheduleMirrorsResponses = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -4233,63 +4095,6 @@ export type UpdateScheduleMirrorsResponses = {
|
||||||
|
|
||||||
export type UpdateScheduleMirrorsResponse = UpdateScheduleMirrorsResponses[keyof UpdateScheduleMirrorsResponses];
|
export type UpdateScheduleMirrorsResponse = UpdateScheduleMirrorsResponses[keyof UpdateScheduleMirrorsResponses];
|
||||||
|
|
||||||
export type GetMirrorSyncStatusData = {
|
|
||||||
body?: never;
|
|
||||||
path: {
|
|
||||||
shortId: string;
|
|
||||||
mirrorShortId: string;
|
|
||||||
};
|
|
||||||
query?: never;
|
|
||||||
url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/status';
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GetMirrorSyncStatusResponses = {
|
|
||||||
/**
|
|
||||||
* Mirror sync status with missing snapshots
|
|
||||||
*/
|
|
||||||
200: {
|
|
||||||
sourceCount: number;
|
|
||||||
mirrorCount: number;
|
|
||||||
missingSnapshots: Array<{
|
|
||||||
short_id: string;
|
|
||||||
time: string;
|
|
||||||
size: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GetMirrorSyncStatusResponse = GetMirrorSyncStatusResponses[keyof GetMirrorSyncStatusResponses];
|
|
||||||
|
|
||||||
export type SyncMirrorData = {
|
|
||||||
body: {
|
|
||||||
snapshotIds?: Array<string>;
|
|
||||||
};
|
|
||||||
path: {
|
|
||||||
shortId: string;
|
|
||||||
mirrorShortId: string;
|
|
||||||
};
|
|
||||||
query?: never;
|
|
||||||
url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/sync';
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SyncMirrorErrors = {
|
|
||||||
/**
|
|
||||||
* Mirror is already syncing
|
|
||||||
*/
|
|
||||||
409: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SyncMirrorResponses = {
|
|
||||||
/**
|
|
||||||
* Mirror sync started successfully
|
|
||||||
*/
|
|
||||||
200: {
|
|
||||||
success: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SyncMirrorResponse = SyncMirrorResponses[keyof SyncMirrorResponses];
|
|
||||||
|
|
||||||
export type GetMirrorCompatibilityData = {
|
export type GetMirrorCompatibilityData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|
@ -4349,13 +4154,13 @@ export type GetBackupProgressResponses = {
|
||||||
scheduleId: string;
|
scheduleId: string;
|
||||||
volumeName: string;
|
volumeName: string;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
seconds_elapsed?: number;
|
seconds_elapsed: number;
|
||||||
seconds_remaining?: number;
|
seconds_remaining?: number;
|
||||||
percent_done?: number;
|
percent_done: number;
|
||||||
total_files?: number;
|
total_files: number;
|
||||||
files_done?: number;
|
files_done: number;
|
||||||
total_bytes?: number;
|
total_bytes: number;
|
||||||
bytes_done?: number;
|
bytes_done: number;
|
||||||
current_files?: Array<string>;
|
current_files?: Array<string>;
|
||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
@ -4377,9 +4182,6 @@ export type ListNotificationDestinationsResponses = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
status: 'healthy' | 'error' | 'unknown';
|
|
||||||
lastChecked: number | null;
|
|
||||||
lastError: string | null;
|
|
||||||
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
|
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
|
||||||
config: {
|
config: {
|
||||||
type: 'email';
|
type: 'email';
|
||||||
|
|
@ -4394,6 +4196,7 @@ export type ListNotificationDestinationsResponses = {
|
||||||
} | {
|
} | {
|
||||||
type: 'slack';
|
type: 'slack';
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
|
channel?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
iconEmoji?: string;
|
iconEmoji?: string;
|
||||||
} | {
|
} | {
|
||||||
|
|
@ -4463,6 +4266,7 @@ export type CreateNotificationDestinationData = {
|
||||||
} | {
|
} | {
|
||||||
type: 'slack';
|
type: 'slack';
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
|
channel?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
iconEmoji?: string;
|
iconEmoji?: string;
|
||||||
} | {
|
} | {
|
||||||
|
|
@ -4523,9 +4327,6 @@ export type CreateNotificationDestinationResponses = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
status: 'healthy' | 'error' | 'unknown';
|
|
||||||
lastChecked: number | null;
|
|
||||||
lastError: string | null;
|
|
||||||
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
|
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
|
||||||
config: {
|
config: {
|
||||||
type: 'email';
|
type: 'email';
|
||||||
|
|
@ -4540,6 +4341,7 @@ export type CreateNotificationDestinationResponses = {
|
||||||
} | {
|
} | {
|
||||||
type: 'slack';
|
type: 'slack';
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
|
channel?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
iconEmoji?: string;
|
iconEmoji?: string;
|
||||||
} | {
|
} | {
|
||||||
|
|
@ -4644,9 +4446,6 @@ export type GetNotificationDestinationResponses = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
status: 'healthy' | 'error' | 'unknown';
|
|
||||||
lastChecked: number | null;
|
|
||||||
lastError: string | null;
|
|
||||||
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
|
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
|
||||||
config: {
|
config: {
|
||||||
type: 'email';
|
type: 'email';
|
||||||
|
|
@ -4661,6 +4460,7 @@ export type GetNotificationDestinationResponses = {
|
||||||
} | {
|
} | {
|
||||||
type: 'slack';
|
type: 'slack';
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
|
channel?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
iconEmoji?: string;
|
iconEmoji?: string;
|
||||||
} | {
|
} | {
|
||||||
|
|
@ -4731,6 +4531,7 @@ export type UpdateNotificationDestinationData = {
|
||||||
} | {
|
} | {
|
||||||
type: 'slack';
|
type: 'slack';
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
|
channel?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
iconEmoji?: string;
|
iconEmoji?: string;
|
||||||
} | {
|
} | {
|
||||||
|
|
@ -4800,9 +4601,6 @@ export type UpdateNotificationDestinationResponses = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
status: 'healthy' | 'error' | 'unknown';
|
|
||||||
lastChecked: number | null;
|
|
||||||
lastError: string | null;
|
|
||||||
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
|
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
|
||||||
config: {
|
config: {
|
||||||
type: 'email';
|
type: 'email';
|
||||||
|
|
@ -4817,6 +4615,7 @@ export type UpdateNotificationDestinationResponses = {
|
||||||
} | {
|
} | {
|
||||||
type: 'slack';
|
type: 'slack';
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
|
channel?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
iconEmoji?: string;
|
iconEmoji?: string;
|
||||||
} | {
|
} | {
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,8 @@
|
||||||
/** biome-ignore-all lint/style/noNonNullAssertion: Testing file - non-null assertions are acceptable here */
|
/** biome-ignore-all lint/style/noNonNullAssertion: Testing file - non-null assertions are acceptable here */
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { expect, test, describe } from "bun:test";
|
||||||
import { cleanup, render, screen, fireEvent, within } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { useState } from "react";
|
|
||||||
import { FileTree, type FileEntry } from "../file-tree";
|
import { FileTree, type FileEntry } from "../file-tree";
|
||||||
|
|
||||||
const getCheckboxFor = (name: string) => {
|
|
||||||
const row = screen.getByRole("button", { name });
|
|
||||||
return within(row).getByRole("checkbox");
|
|
||||||
};
|
|
||||||
|
|
||||||
const FileTreeSelection = ({
|
|
||||||
files,
|
|
||||||
initialSelectedPaths = [],
|
|
||||||
expandedFolders,
|
|
||||||
}: {
|
|
||||||
files: FileEntry[];
|
|
||||||
initialSelectedPaths?: string[];
|
|
||||||
expandedFolders?: Set<string>;
|
|
||||||
}) => {
|
|
||||||
const [selectedPaths, setSelectedPaths] = useState(() => new Set(initialSelectedPaths));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FileTree
|
|
||||||
files={files}
|
|
||||||
withCheckboxes={true}
|
|
||||||
selectedPaths={selectedPaths}
|
|
||||||
onSelectionChange={setSelectedPaths}
|
|
||||||
expandedFolders={expandedFolders}
|
|
||||||
/>
|
|
||||||
<output aria-label="Selected paths">{JSON.stringify(Array.from(selectedPaths).sort())}</output>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSelectedPaths = () => {
|
|
||||||
const selectedPaths = screen.getByLabelText("Selected paths").textContent;
|
|
||||||
return JSON.parse(selectedPaths ?? "[]") as string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("FileTree Pagination", () => {
|
describe("FileTree Pagination", () => {
|
||||||
const testFiles: FileEntry[] = [
|
const testFiles: FileEntry[] = [
|
||||||
{ name: "root", path: "/root", type: "folder" },
|
{ name: "root", path: "/root", type: "folder" },
|
||||||
|
|
@ -185,19 +145,6 @@ describe("FileTree Pagination", () => {
|
||||||
|
|
||||||
expect(screen.queryByText("Load more files")).toBeNull();
|
expect(screen.queryByText("Load more files")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders missing ancestor folders for nested paths", () => {
|
|
||||||
render(
|
|
||||||
<FileTree
|
|
||||||
files={[
|
|
||||||
{ name: "subdir", path: "/project/subdir", type: "folder" },
|
|
||||||
{ name: "file1", path: "/project/subdir/file1", type: "file" },
|
|
||||||
]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: "project" })).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("FileTree Selection Logic", () => {
|
describe("FileTree Selection Logic", () => {
|
||||||
|
|
@ -210,67 +157,154 @@ describe("FileTree Selection Logic", () => {
|
||||||
{ name: "upload", path: "/root/photos/upload", type: "folder" },
|
{ name: "upload", path: "/root/photos/upload", type: "folder" },
|
||||||
];
|
];
|
||||||
|
|
||||||
test("selecting a folder simplifies to parent if it's the only child", () => {
|
test("selecting a folder simplifies to parent if it's the only child", async () => {
|
||||||
render(<FileTreeSelection files={testFiles} expandedFolders={new Set(testFiles.map((f) => f.path))} />);
|
let currentSelection = new Set<string>();
|
||||||
|
const onSelectionChange = (selection: Set<string>) => {
|
||||||
|
currentSelection = selection;
|
||||||
|
};
|
||||||
|
|
||||||
fireEvent.click(getCheckboxFor("photos"));
|
|
||||||
|
|
||||||
expect(getSelectedPaths()).toEqual(["/root"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unselecting a child removes the parent from selection", () => {
|
|
||||||
render(
|
render(
|
||||||
<FileTreeSelection
|
<FileTree
|
||||||
files={testFiles}
|
files={testFiles}
|
||||||
initialSelectedPaths={["/root"]}
|
withCheckboxes={true}
|
||||||
|
selectedPaths={currentSelection}
|
||||||
|
onSelectionChange={onSelectionChange}
|
||||||
expandedFolders={new Set(testFiles.map((f) => f.path))}
|
expandedFolders={new Set(testFiles.map((f) => f.path))}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(getCheckboxFor("library"));
|
const photosCheckbox = screen.getByText("photos").parentElement?.querySelector('button[role="checkbox"]');
|
||||||
|
expect(photosCheckbox).toBeTruthy();
|
||||||
|
|
||||||
expect(getSelectedPaths()).toEqual(["/root/photos/backups", "/root/photos/profile", "/root/photos/upload"]);
|
fireEvent.click(photosCheckbox!);
|
||||||
|
|
||||||
|
expect(currentSelection.has("/root")).toBe(true);
|
||||||
|
expect(currentSelection.size).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("recursive simplification when all children are selected", () => {
|
test("unselecting a child removes the parent from selection", async () => {
|
||||||
render(<FileTreeSelection files={testFiles} expandedFolders={new Set(testFiles.map((f) => f.path))} />);
|
let currentSelection = new Set<string>(["/root"]);
|
||||||
|
const onSelectionChange = (selection: Set<string>) => {
|
||||||
|
currentSelection = selection;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FileTree
|
||||||
|
files={testFiles}
|
||||||
|
withCheckboxes={true}
|
||||||
|
selectedPaths={currentSelection}
|
||||||
|
onSelectionChange={onSelectionChange}
|
||||||
|
expandedFolders={new Set(testFiles.map((f) => f.path))}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const libraryCheckbox = screen.getByText("library").parentElement?.querySelector('button[role="checkbox"]');
|
||||||
|
fireEvent.click(libraryCheckbox!);
|
||||||
|
|
||||||
|
expect(currentSelection.has("/root")).toBe(false);
|
||||||
|
expect(currentSelection.has("/root/photos")).toBe(false);
|
||||||
|
|
||||||
|
expect(currentSelection.has("/root/photos/backups")).toBe(true);
|
||||||
|
expect(currentSelection.has("/root/photos/profile")).toBe(true);
|
||||||
|
expect(currentSelection.has("/root/photos/upload")).toBe(true);
|
||||||
|
expect(currentSelection.size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recursive simplification when all children are selected", async () => {
|
||||||
|
let currentSelection = new Set<string>();
|
||||||
|
const onSelectionChange = (selection: Set<string>) => {
|
||||||
|
currentSelection = selection;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<FileTree
|
||||||
|
files={testFiles}
|
||||||
|
withCheckboxes={true}
|
||||||
|
selectedPaths={currentSelection}
|
||||||
|
onSelectionChange={onSelectionChange}
|
||||||
|
expandedFolders={new Set(testFiles.map((f) => f.path))}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
const children = ["backups", "library", "profile", "upload"];
|
const children = ["backups", "library", "profile", "upload"];
|
||||||
|
|
||||||
for (const name of children) {
|
for (const name of children) {
|
||||||
fireEvent.click(getCheckboxFor(name));
|
const checkbox = screen.getByText(name).parentElement?.querySelector('button[role="checkbox"]');
|
||||||
|
fireEvent.click(checkbox!);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<FileTree
|
||||||
|
files={testFiles}
|
||||||
|
withCheckboxes={true}
|
||||||
|
selectedPaths={currentSelection}
|
||||||
|
onSelectionChange={onSelectionChange}
|
||||||
|
expandedFolders={new Set(testFiles.map((f) => f.path))}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(getSelectedPaths()).toEqual(["/root"]);
|
expect(currentSelection.has("/root")).toBe(true);
|
||||||
|
expect(currentSelection.size).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not simplify to parent if not all children are selected", () => {
|
test("does not simplify to parent if not all children are selected", async () => {
|
||||||
const multipleFiles: FileEntry[] = [
|
const multipleFiles: FileEntry[] = [
|
||||||
{ name: "root", path: "/root", type: "folder" },
|
{ name: "root", path: "/root", type: "folder" },
|
||||||
{ name: "child1", path: "/root/child1", type: "folder" },
|
{ name: "child1", path: "/root/child1", type: "folder" },
|
||||||
{ name: "child2", path: "/root/child2", type: "folder" },
|
{ name: "child2", path: "/root/child2", type: "folder" },
|
||||||
];
|
];
|
||||||
|
|
||||||
render(<FileTreeSelection files={multipleFiles} expandedFolders={new Set(multipleFiles.map((f) => f.path))} />);
|
let currentSelection = new Set<string>();
|
||||||
|
const onSelectionChange = (selection: Set<string>) => {
|
||||||
|
currentSelection = selection;
|
||||||
|
};
|
||||||
|
|
||||||
fireEvent.click(getCheckboxFor("child1"));
|
render(
|
||||||
|
<FileTree
|
||||||
|
files={multipleFiles}
|
||||||
|
withCheckboxes={true}
|
||||||
|
selectedPaths={currentSelection}
|
||||||
|
onSelectionChange={onSelectionChange}
|
||||||
|
expandedFolders={new Set(multipleFiles.map((f) => f.path))}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
expect(getSelectedPaths()).toEqual(["/root/child1"]);
|
const child1Checkbox = screen.getByText("child1").parentElement?.querySelector('button[role="checkbox"]');
|
||||||
|
fireEvent.click(child1Checkbox!);
|
||||||
|
|
||||||
|
expect(currentSelection.has("/root/child1")).toBe(true);
|
||||||
|
expect(currentSelection.has("/root")).toBe(false);
|
||||||
|
expect(currentSelection.size).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("simplifies existing deep paths when parent is selected", () => {
|
test("simplifies existing deep paths when parent is selected", async () => {
|
||||||
const files: FileEntry[] = [
|
const files: FileEntry[] = [
|
||||||
{ name: "hello", path: "/hello", type: "folder" },
|
{ name: "hello", path: "/hello", type: "folder" },
|
||||||
{ name: "hello_prev", path: "/hello_prev", type: "folder" },
|
{ name: "hello_prev", path: "/hello_prev", type: "folder" },
|
||||||
{ name: "service", path: "/service", type: "folder" },
|
{ name: "service", path: "/service", type: "folder" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let currentSelection = new Set<string>(["/hello", "/hello_prev", "/service/app/data/upload"]);
|
||||||
|
const onSelectionChange = (selection: Set<string>) => {
|
||||||
|
currentSelection = selection;
|
||||||
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<FileTreeSelection files={files} initialSelectedPaths={["/hello", "/hello_prev", "/service/app/data/upload"]} />,
|
<FileTree
|
||||||
|
files={files}
|
||||||
|
withCheckboxes={true}
|
||||||
|
selectedPaths={currentSelection}
|
||||||
|
onSelectionChange={onSelectionChange}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(getCheckboxFor("service"));
|
const serviceCheckbox = screen.getByText("service").parentElement?.querySelector('button[role="checkbox"]');
|
||||||
|
expect(serviceCheckbox).toBeTruthy();
|
||||||
|
|
||||||
expect(getSelectedPaths()).toEqual(["/hello", "/hello_prev", "/service"]);
|
fireEvent.click(serviceCheckbox!);
|
||||||
|
|
||||||
|
expect(currentSelection.has("/service")).toBe(true);
|
||||||
|
expect(currentSelection.has("/service/app/data/upload")).toBe(false);
|
||||||
|
expect(currentSelection.size).toBe(3); // /hello, /hello_prev, /service
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 { useMatches, Link } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
|
|
@ -42,7 +41,7 @@ export function AppBreadcrumb() {
|
||||||
const isLast = index === breadcrumbs.length - 1;
|
const isLast = index === breadcrumbs.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={`${breadcrumb.label}-${index}`}>
|
<div key={`${breadcrumb.label}-${index}`} className="contents">
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
{isLast || !breadcrumb.href ? (
|
{isLast || !breadcrumb.href ? (
|
||||||
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
|
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
|
||||||
|
|
@ -53,7 +52,7 @@ export function AppBreadcrumb() {
|
||||||
)}
|
)}
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
{!isLast && <BreadcrumbSeparator />}
|
{!isLast && <BreadcrumbSeparator />}
|
||||||
</Fragment>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { Card, CardContent } from "~/client/components/ui/card";
|
import { Card, CardContent } from "~/client/components/ui/card";
|
||||||
import { ByteSize } from "~/client/components/bytes-size";
|
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 "@zerobyte/core/restic";
|
||||||
import { formatDuration } from "~/utils/utils";
|
import { formatDuration } from "~/utils/utils";
|
||||||
|
|
||||||
|
|
@ -8,6 +7,8 @@ type Props = {
|
||||||
summary?: ResticSnapshotSummaryDto | null;
|
summary?: ResticSnapshotSummaryDto | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatCount = (value: number) => value.toLocaleString();
|
||||||
|
|
||||||
const getDurationLabel = (start: string, end: string) => {
|
const getDurationLabel = (start: string, end: string) => {
|
||||||
const startMs = new Date(start).getTime();
|
const startMs = new Date(start).getTime();
|
||||||
const endMs = new Date(end).getTime();
|
const endMs = new Date(end).getTime();
|
||||||
|
|
@ -16,11 +17,8 @@ const getDurationLabel = (start: string, end: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BackupSummaryCard = ({ summary }: Props) => {
|
export const BackupSummaryCard = ({ summary }: Props) => {
|
||||||
const { locale } = useRootLoaderData();
|
|
||||||
|
|
||||||
if (!summary) return null;
|
if (!summary) return null;
|
||||||
|
|
||||||
const formatCount = (value: number) => value.toLocaleString(locale);
|
|
||||||
const durationLabel = getDurationLabel(summary.backup_start, summary.backup_end);
|
const durationLabel = getDurationLabel(summary.backup_start, summary.backup_end);
|
||||||
|
|
||||||
const topStats = [
|
const topStats = [
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useFormatBytes } from "~/client/hooks/use-format-bytes";
|
import { formatBytes } from "~/utils/format-bytes";
|
||||||
|
|
||||||
type ByteSizeProps = {
|
type ByteSizeProps = {
|
||||||
bytes: number;
|
bytes: number;
|
||||||
|
|
@ -14,7 +14,6 @@ type ByteSizeProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ByteSize(props: ByteSizeProps) {
|
export function ByteSize(props: ByteSizeProps) {
|
||||||
const formatBytes = useFormatBytes();
|
|
||||||
const {
|
const {
|
||||||
bytes,
|
bytes,
|
||||||
base = 1000,
|
base = 1000,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { useTimeFormat } from "~/client/lib/datetime";
|
import { formatDateTime } from "~/client/lib/datetime";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
interface CronInputProps {
|
interface CronInputProps {
|
||||||
|
|
@ -13,7 +13,6 @@ interface CronInputProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CronInput({ value, onChange, error }: CronInputProps) {
|
export function CronInput({ value, onChange, error }: CronInputProps) {
|
||||||
const { formatDateTime } = useTimeFormat();
|
|
||||||
const { isValid, nextRuns, parseError } = useMemo(() => {
|
const { isValid, nextRuns, parseError } = useMemo(() => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return { isValid: false, nextRuns: [], parseError: null };
|
return { isValid: false, nextRuns: [], parseError: null };
|
||||||
|
|
|
||||||
|
|
@ -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 { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { getDevPanelOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { DevPanel } from "./dev-panel";
|
import { DevPanel } from "./dev-panel";
|
||||||
|
|
||||||
|
|
@ -10,12 +10,7 @@ export function DevPanelListener() {
|
||||||
...getDevPanelOptions(),
|
...getDevPanelOptions(),
|
||||||
});
|
});
|
||||||
|
|
||||||
useHotkeySequence(["D", "E", "V"], () => setIsOpen(true), {
|
useHotkey("Mod+Shift+D", () => setIsOpen(true), { enabled: !!devPanelStatus?.enabled, preventDefault: true });
|
||||||
enabled: !!devPanelStatus?.enabled,
|
|
||||||
preventDefault: true,
|
|
||||||
ignoreInputs: true,
|
|
||||||
timeout: 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!devPanelStatus?.enabled) {
|
if (!devPanelStatus?.enabled) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-b
|
||||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
||||||
import { logger } from "~/client/lib/logger";
|
|
||||||
|
|
||||||
type LocalFileBrowserProps = FileBrowserUiProps & {
|
type LocalFileBrowserProps = FileBrowserUiProps & {
|
||||||
initialPath?: string;
|
initialPath?: string;
|
||||||
|
|
@ -27,7 +26,7 @@ export const LocalFileBrowser = ({ initialPath = "/", enabled = true, ...uiProps
|
||||||
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
|
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
|
||||||
},
|
},
|
||||||
prefetchFolder: (path) => {
|
prefetchFolder: (path) => {
|
||||||
void queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } })).catch((e) => logger.error(e));
|
void queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,107 +4,98 @@ import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-qu
|
||||||
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
|
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
|
||||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils";
|
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
||||||
|
|
||||||
function createPathPrefixFns(basePath: string) {
|
|
||||||
return {
|
|
||||||
strip(path: string) {
|
|
||||||
if (basePath === "/") return path;
|
|
||||||
if (path === basePath) return "/";
|
|
||||||
if (path.startsWith(`${basePath}/`)) return path.slice(basePath.length);
|
|
||||||
return path;
|
|
||||||
},
|
|
||||||
add(displayPath: string) {
|
|
||||||
if (basePath === "/") return displayPath;
|
|
||||||
if (displayPath === "/") return basePath;
|
|
||||||
return `${basePath}${displayPath}`;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type SnapshotTreeBrowserProps = FileBrowserUiProps & {
|
type SnapshotTreeBrowserProps = FileBrowserUiProps & {
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
snapshotId: string;
|
snapshotId: string;
|
||||||
queryBasePath?: string;
|
basePath?: string;
|
||||||
displayBasePath?: string;
|
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
onSingleSelectionKindChange?: (kind: "file" | "dir" | null) => void;
|
onSingleSelectionKindChange?: (kind: "file" | "dir" | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
|
export const SnapshotTreeBrowser = ({
|
||||||
const {
|
repositoryId,
|
||||||
repositoryId,
|
snapshotId,
|
||||||
snapshotId,
|
basePath = "/",
|
||||||
queryBasePath = "/",
|
pageSize = 500,
|
||||||
displayBasePath,
|
enabled = true,
|
||||||
pageSize = 500,
|
...uiProps
|
||||||
enabled = true,
|
}: SnapshotTreeBrowserProps) => {
|
||||||
...uiProps
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const { selectedPaths, onSelectionChange, onSingleSelectionKindChange, ...fileBrowserUiProps } = uiProps;
|
const { selectedPaths, onSelectionChange, onSingleSelectionKindChange, ...fileBrowserUiProps } = uiProps;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const normalizedQueryBasePath = normalizeAbsolutePath(queryBasePath);
|
const normalizedBasePath = normalizeAbsolutePath(basePath);
|
||||||
const normalizedDisplayBasePath = normalizeAbsolutePath(displayBasePath ?? "/");
|
|
||||||
const effectiveDisplayBasePath = isPathWithin(normalizedDisplayBasePath, normalizedQueryBasePath)
|
|
||||||
? normalizedDisplayBasePath
|
|
||||||
: "/";
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
...listSnapshotFilesOptions({
|
...listSnapshotFilesOptions({
|
||||||
path: { shortId: repositoryId, snapshotId },
|
path: { shortId: repositoryId, snapshotId },
|
||||||
query: { path: normalizedQueryBasePath },
|
query: { path: normalizedBasePath },
|
||||||
}),
|
}),
|
||||||
enabled,
|
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(() => {
|
const displaySelectedPaths = useMemo(() => {
|
||||||
if (!selectedPaths) return undefined;
|
if (!selectedPaths) return undefined;
|
||||||
|
|
||||||
const displayPaths = new Set<string>();
|
const displayPaths = new Set<string>();
|
||||||
for (const fullPath of selectedPaths) {
|
for (const fullPath of selectedPaths) {
|
||||||
displayPaths.add(displayPathFns.strip(fullPath));
|
displayPaths.add(stripBasePath(fullPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
return displayPaths;
|
return displayPaths;
|
||||||
}, [displayPathFns, selectedPaths]);
|
}, [selectedPaths, stripBasePath]);
|
||||||
|
|
||||||
const fileBrowser = useFileBrowser({
|
const fileBrowser = useFileBrowser({
|
||||||
initialData: data,
|
initialData: data,
|
||||||
isLoading,
|
isLoading,
|
||||||
fetchFolder: async (displayPath, offset = 0) => {
|
fetchFolder: async (path, offset = 0) => {
|
||||||
return await queryClient.ensureQueryData(
|
return await queryClient.ensureQueryData(
|
||||||
listSnapshotFilesOptions({
|
listSnapshotFilesOptions({
|
||||||
path: { shortId: repositoryId, snapshotId },
|
path: { shortId: repositoryId, snapshotId },
|
||||||
query: { path: displayPath, offset: offset, limit: pageSize },
|
query: { path, offset: offset, limit: pageSize },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
pathTransform: displayPathFns,
|
prefetchFolder: (path) => {
|
||||||
|
void queryClient.prefetchQuery(
|
||||||
|
listSnapshotFilesOptions({
|
||||||
|
path: { shortId: repositoryId, snapshotId },
|
||||||
|
query: { path, offset: 0, limit: pageSize },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
pathTransform: {
|
||||||
|
strip: stripBasePath,
|
||||||
|
add: addBasePath,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const displayPathKinds = useMemo(() => {
|
const displayPathKinds = useMemo(() => {
|
||||||
const kinds = new Map<string, "file" | "dir">();
|
const kinds = new Map<string, "file" | "dir">();
|
||||||
for (const entry of fileBrowser.fileArray) {
|
for (const entry of fileBrowser.fileArray) {
|
||||||
kinds.set(entry.path, entry.type === "file" ? "file" : "dir");
|
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;
|
return kinds;
|
||||||
}, [fileBrowser.fileArray]);
|
}, [fileBrowser.fileArray]);
|
||||||
|
|
@ -115,7 +106,7 @@ export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
|
||||||
|
|
||||||
const nextFullPaths = new Set<string>();
|
const nextFullPaths = new Set<string>();
|
||||||
for (const displayPath of nextDisplayPaths) {
|
for (const displayPath of nextDisplayPaths) {
|
||||||
nextFullPaths.add(displayPathFns.add(displayPath));
|
nextFullPaths.add(addBasePath(displayPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onSingleSelectionKindChange) {
|
if (onSingleSelectionKindChange) {
|
||||||
|
|
@ -133,7 +124,7 @@ export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
|
||||||
|
|
||||||
onSelectionChange(nextFullPaths);
|
onSelectionChange(nextFullPaths);
|
||||||
},
|
},
|
||||||
[displayPathFns, displayPathKinds, onSelectionChange, onSingleSelectionKindChange],
|
[onSelectionChange, addBasePath, onSingleSelectionKindChange, displayPathKinds],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -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 { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
|
||||||
import { useFileBrowser, type FetchFolderResult } from "~/client/hooks/use-file-browser";
|
import { useFileBrowser, type FetchFolderResult } from "~/client/hooks/use-file-browser";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import { logger } from "~/client/lib/logger";
|
|
||||||
|
|
||||||
type VolumeFileBrowserProps = FileBrowserUiProps & {
|
type VolumeFileBrowserProps = FileBrowserUiProps & {
|
||||||
volumeId: string;
|
volumeId: string;
|
||||||
|
|
@ -30,14 +29,12 @@ export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: Volu
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
prefetchFolder: (path) => {
|
prefetchFolder: (path) => {
|
||||||
void queryClient
|
void queryClient.prefetchQuery(
|
||||||
.prefetchQuery(
|
listFilesOptions({
|
||||||
listFilesOptions({
|
path: { shortId: volumeId },
|
||||||
path: { shortId: volumeId },
|
query: { path },
|
||||||
query: { path },
|
}),
|
||||||
}),
|
);
|
||||||
)
|
|
||||||
.catch((e) => logger.error(e));
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,25 +114,20 @@ export const FileTree = memo((props: Props) => {
|
||||||
|
|
||||||
const toggleCollapseState = useCallback(
|
const toggleCollapseState = useCallback(
|
||||||
(fullPath: string) => {
|
(fullPath: string) => {
|
||||||
const shouldExpand = collapsedFolders.has(fullPath);
|
|
||||||
|
|
||||||
setCollapsedFolders((prevSet) => {
|
setCollapsedFolders((prevSet) => {
|
||||||
const newSet = new Set(prevSet);
|
const newSet = new Set(prevSet);
|
||||||
|
|
||||||
if (newSet.has(fullPath)) {
|
if (newSet.has(fullPath)) {
|
||||||
newSet.delete(fullPath);
|
newSet.delete(fullPath);
|
||||||
|
onFolderExpand?.(fullPath);
|
||||||
} else {
|
} else {
|
||||||
newSet.add(fullPath);
|
newSet.add(fullPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (shouldExpand) {
|
|
||||||
onFolderExpand?.(fullPath);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[collapsedFolders, onFolderExpand],
|
[onFolderExpand],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add new folders to collapsed set when file list changes
|
// Add new folders to collapsed set when file list changes
|
||||||
|
|
@ -586,10 +581,8 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
// oxlint-disable-next-line jsx_a11y/prefer-tag-over-role
|
type="button"
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
|
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
|
||||||
style={{ paddingLeft }}
|
style={{ paddingLeft }}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|
@ -598,7 +591,7 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<div className="truncate w-full flex items-center gap-2">{children}</div>
|
<div className="truncate w-full flex items-center gap-2">{children}</div>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -632,10 +625,6 @@ function buildFileList(files: FileEntry[], foldersOnly = false): Node[] {
|
||||||
const depth = segments.length - 1;
|
const depth = segments.length - 1;
|
||||||
const name = segments[segments.length - 1];
|
const name = segments[segments.length - 1];
|
||||||
|
|
||||||
if (!name) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fileMap.has(file.path)) {
|
if (!fileMap.has(file.path)) {
|
||||||
const isFile = file.type === "file";
|
const isFile = file.type === "file";
|
||||||
fileMap.set(file.path, {
|
fileMap.set(file.path, {
|
||||||
|
|
@ -647,33 +636,6 @@ function buildFileList(files: FileEntry[], foldersOnly = false): Node[] {
|
||||||
size: file.size,
|
size: file.size,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let parentPath = file.path;
|
|
||||||
while (true) {
|
|
||||||
const lastSlashIndex = parentPath.lastIndexOf("/");
|
|
||||||
if (lastSlashIndex <= 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
parentPath = parentPath.slice(0, lastSlashIndex);
|
|
||||||
if (fileMap.has(parentPath)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentSegments = parentPath.split("/").filter((segment) => segment);
|
|
||||||
const parentName = parentSegments[parentSegments.length - 1];
|
|
||||||
if (!parentName) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
fileMap.set(parentPath, {
|
|
||||||
kind: "folder",
|
|
||||||
id: fileMap.size,
|
|
||||||
name: parentName,
|
|
||||||
fullPath: parentPath,
|
|
||||||
depth: parentSegments.length - 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert map to array and sort
|
// Convert map to array and sort
|
||||||
|
|
@ -740,7 +702,5 @@ function compareNodes(a: Node, b: Node): number {
|
||||||
return a.kind === "folder" ? -1 : 1;
|
return a.kind === "folder" ? -1 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (a.name < b.name) return -1;
|
return a.name.localeCompare(b.name);
|
||||||
if (a.name > b.name) return 1;
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { LifeBuoy, LogOut } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { type AppContext } from "~/context";
|
import { type AppContext } from "~/context";
|
||||||
import { GridBackground } from "./grid-background";
|
import { GridBackground } from "./grid-background";
|
||||||
import { Button, buttonVariants } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
|
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
|
||||||
import { AppSidebar } from "./app-sidebar";
|
import { AppSidebar } from "./app-sidebar";
|
||||||
import { authClient } from "../lib/auth-client";
|
import { authClient } from "../lib/auth-client";
|
||||||
|
|
@ -63,19 +63,20 @@ export function Layout({ loaderData }: Props) {
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<a
|
<Button
|
||||||
href="https://github.com/nicotsx/zerobyte/issues/new/choose"
|
variant="ghost"
|
||||||
target="_blank"
|
size="icon"
|
||||||
rel="noreferrer"
|
className="relative overflow-hidden hidden lg:inline-flex rounded-full h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||||
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
|
||||||
</a>
|
href="https://github.com/nicotsx/zerobyte/issues/new"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center justify-center w-full h-full"
|
||||||
|
>
|
||||||
|
<LifeBuoy className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Report an issue</TooltipContent>
|
<TooltipContent>Report an issue</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog";
|
||||||
import { ScrollArea } from "~/client/components/ui/scroll-area";
|
import { ScrollArea } from "~/client/components/ui/scroll-area";
|
||||||
import { useTimeFormat } from "~/client/lib/datetime";
|
import { formatDate } from "~/client/lib/datetime";
|
||||||
import type { UpdateInfoDto } from "~/server/modules/system/system.dto";
|
import type { UpdateInfoDto } from "~/server/modules/system/system.dto";
|
||||||
|
|
||||||
interface ReleaseNotesDialogProps {
|
interface ReleaseNotesDialogProps {
|
||||||
|
|
@ -12,8 +12,6 @@ interface ReleaseNotesDialogProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReleaseNotesDialog({ open, onOpenChange, updates }: ReleaseNotesDialogProps) {
|
export function ReleaseNotesDialog({ open, onOpenChange, updates }: ReleaseNotesDialogProps) {
|
||||||
const { formatDate } = useTimeFormat();
|
|
||||||
|
|
||||||
if (!updates) return null;
|
if (!updates) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
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 { Button } from "~/client/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
|
|
@ -23,7 +22,6 @@ import { RestoreProgress } from "~/client/components/restore-progress";
|
||||||
import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events";
|
import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events";
|
||||||
import { OVERWRITE_MODES, type OverwriteMode } from "@zerobyte/core/restic";
|
import { OVERWRITE_MODES, type OverwriteMode } from "@zerobyte/core/restic";
|
||||||
import { isPathWithin } from "@zerobyte/core/utils";
|
|
||||||
import type { Repository } from "~/client/lib/types";
|
import type { Repository } from "~/client/lib/types";
|
||||||
import { handleRepositoryError } from "~/client/lib/errors";
|
import { handleRepositoryError } from "~/client/lib/errors";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
@ -35,29 +33,16 @@ interface RestoreFormProps {
|
||||||
repository: Repository;
|
repository: Repository;
|
||||||
snapshotId: string;
|
snapshotId: string;
|
||||||
returnPath: string;
|
returnPath: string;
|
||||||
queryBasePath?: string;
|
basePath?: string;
|
||||||
displayBasePath?: string;
|
|
||||||
hasNonPosixSnapshotPaths?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RestoreForm({
|
export function RestoreForm({ repository, snapshotId, returnPath, basePath }: RestoreFormProps) {
|
||||||
repository,
|
|
||||||
snapshotId,
|
|
||||||
returnPath,
|
|
||||||
queryBasePath,
|
|
||||||
displayBasePath,
|
|
||||||
hasNonPosixSnapshotPaths = false,
|
|
||||||
}: RestoreFormProps) {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { addEventListener } = useServerEvents();
|
const { addEventListener } = useServerEvents();
|
||||||
|
|
||||||
const snapshotBasePath = queryBasePath ?? "/";
|
const volumeBasePath = basePath ?? "/";
|
||||||
const hasMismatchedDisplayBasePath = displayBasePath && !isPathWithin(displayBasePath, snapshotBasePath);
|
|
||||||
const restoreRequiresCustomTarget = hasNonPosixSnapshotPaths || hasMismatchedDisplayBasePath;
|
|
||||||
|
|
||||||
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>(
|
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>("original");
|
||||||
restoreRequiresCustomTarget ? "custom" : "original",
|
|
||||||
);
|
|
||||||
const [customTargetPath, setCustomTargetPath] = useState("");
|
const [customTargetPath, setCustomTargetPath] = useState("");
|
||||||
const [overwriteMode, setOverwriteMode] = useState<OverwriteMode>("always");
|
const [overwriteMode, setOverwriteMode] = useState<OverwriteMode>("always");
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
@ -69,15 +54,6 @@ export function RestoreForm({
|
||||||
|
|
||||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||||
const [selectedPathKind, setSelectedPathKind] = useState<"file" | "dir" | null>(null);
|
const [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(() => {
|
useEffect(() => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
@ -137,13 +113,13 @@ export function RestoreForm({
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleRestore = useCallback(() => {
|
const handleRestore = useCallback(() => {
|
||||||
const excludeXattrValues = excludeXattr
|
const excludeXattrArray = excludeXattr
|
||||||
.split(",")
|
?.split(",")
|
||||||
.map((value) => value.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const isCustomLocation = restoreLocation === "custom";
|
const isCustomLocation = restoreLocation === "custom";
|
||||||
const targetPath = isCustomLocation && hasCustomTargetPath ? trimmedCustomTargetPath : undefined;
|
const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined;
|
||||||
|
|
||||||
const includePaths = Array.from(selectedPaths);
|
const includePaths = Array.from(selectedPaths);
|
||||||
|
|
||||||
|
|
@ -158,7 +134,7 @@ export function RestoreForm({
|
||||||
snapshotId,
|
snapshotId,
|
||||||
include: includePaths.length > 0 ? includePaths : undefined,
|
include: includePaths.length > 0 ? includePaths : undefined,
|
||||||
selectedItemKind: includePaths.length === 1 ? (selectedPathKind ?? undefined) : undefined,
|
selectedItemKind: includePaths.length === 1 ? (selectedPathKind ?? undefined) : undefined,
|
||||||
excludeXattr: excludeXattrValues.length > 0 ? excludeXattrValues : undefined,
|
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
|
||||||
targetPath,
|
targetPath,
|
||||||
overwrite: overwriteMode,
|
overwrite: overwriteMode,
|
||||||
},
|
},
|
||||||
|
|
@ -167,9 +143,8 @@ export function RestoreForm({
|
||||||
repository.shortId,
|
repository.shortId,
|
||||||
snapshotId,
|
snapshotId,
|
||||||
excludeXattr,
|
excludeXattr,
|
||||||
hasCustomTargetPath,
|
|
||||||
restoreLocation,
|
restoreLocation,
|
||||||
trimmedCustomTargetPath,
|
customTargetPath,
|
||||||
selectedPaths,
|
selectedPaths,
|
||||||
selectedPathKind,
|
selectedPathKind,
|
||||||
overwriteMode,
|
overwriteMode,
|
||||||
|
|
@ -206,15 +181,13 @@ export function RestoreForm({
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const canRestore = restoreRequiresCustomTarget
|
const canRestore = restoreLocation === "original" || customTargetPath.trim();
|
||||||
? hasCustomTargetPath
|
const canDownload = selectedPaths.size <= 1;
|
||||||
: restoreLocation === "original" || hasCustomTargetPath;
|
|
||||||
const canDownload = selectedPathCount <= 1;
|
|
||||||
const isRestoreRunning = isRestoring || isRestoreActive;
|
const isRestoreRunning = isRestoring || isRestoreActive;
|
||||||
|
|
||||||
function getDownloadButtonText(): string {
|
function getDownloadButtonText(): string {
|
||||||
if (selectedPathCount > 0) {
|
if (selectedPaths.size > 0) {
|
||||||
return `Download ${selectedPathCount} ${selectedPathCount === 1 ? "item" : "items"}`;
|
return `Download ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`;
|
||||||
}
|
}
|
||||||
return "Download All";
|
return "Download All";
|
||||||
}
|
}
|
||||||
|
|
@ -223,8 +196,8 @@ export function RestoreForm({
|
||||||
if (isRestoreRunning) {
|
if (isRestoreRunning) {
|
||||||
return "Restoring...";
|
return "Restoring...";
|
||||||
}
|
}
|
||||||
if (selectedPathCount > 0) {
|
if (selectedPaths.size > 0) {
|
||||||
return `Restore ${selectedPathCount} ${selectedPathCount === 1 ? "item" : "items"}`;
|
return `Restore ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`;
|
||||||
}
|
}
|
||||||
return "Restore All";
|
return "Restore All";
|
||||||
}
|
}
|
||||||
|
|
@ -266,18 +239,6 @@ export function RestoreForm({
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{isRestoreRunning && <RestoreProgress repositoryId={repository.shortId} snapshotId={snapshotId} />}
|
{isRestoreRunning && <RestoreProgress repositoryId={repository.shortId} snapshotId={snapshotId} />}
|
||||||
|
|
||||||
{restoreRequiresCustomTarget && (
|
|
||||||
<Alert variant="warning">
|
|
||||||
<AlertTriangle className="size-4" />
|
|
||||||
<AlertTitle>Source paths do not match</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
This snapshot was created from source paths that do not match this Zerobyte server or the current linked
|
|
||||||
volume. Restoring to the original location is unavailable. Restore it to a custom location, or download
|
|
||||||
it instead.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Restore Location</CardTitle>
|
<CardTitle>Restore Location</CardTitle>
|
||||||
|
|
@ -291,7 +252,6 @@ export function RestoreForm({
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex justify-start gap-2"
|
className="flex justify-start gap-2"
|
||||||
onClick={() => setRestoreLocation("original")}
|
onClick={() => setRestoreLocation("original")}
|
||||||
disabled={!!restoreRequiresCustomTarget}
|
|
||||||
>
|
>
|
||||||
<RotateCcw size={16} className="mr-1" />
|
<RotateCcw size={16} className="mr-1" />
|
||||||
Original location
|
Original location
|
||||||
|
|
@ -386,8 +346,7 @@ export function RestoreForm({
|
||||||
<SnapshotTreeBrowser
|
<SnapshotTreeBrowser
|
||||||
repositoryId={repository.shortId}
|
repositoryId={repository.shortId}
|
||||||
snapshotId={snapshotId}
|
snapshotId={snapshotId}
|
||||||
queryBasePath={snapshotBasePath}
|
basePath={volumeBasePath}
|
||||||
displayBasePath={displayBasePath}
|
|
||||||
pageSize={500}
|
pageSize={500}
|
||||||
className="flex flex-1 min-h-0 flex-col"
|
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"
|
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 { useEffect, useState } from "react";
|
||||||
import { ByteSize } from "~/client/components/bytes-size";
|
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 { Card } from "~/client/components/ui/card";
|
||||||
import { Progress } from "~/client/components/ui/progress";
|
import { Progress } from "~/client/components/ui/progress";
|
||||||
import { type RestoreProgressEvent, useServerEvents } from "~/client/hooks/use-server-events";
|
import { type RestoreProgressEvent, useServerEvents } from "~/client/hooks/use-server-events";
|
||||||
|
import { formatBytes } from "~/utils/format-bytes";
|
||||||
import { formatDuration } from "~/utils/utils";
|
import { formatDuration } from "~/utils/utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -13,8 +12,6 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RestoreProgress = ({ repositoryId, snapshotId }: Props) => {
|
export const RestoreProgress = ({ repositoryId, snapshotId }: Props) => {
|
||||||
const formatBytes = useFormatBytes();
|
|
||||||
const { locale } = useRootLoaderData();
|
|
||||||
const { addEventListener } = useServerEvents();
|
const { addEventListener } = useServerEvents();
|
||||||
const [progress, setProgress] = useState<RestoreProgressEvent | null>(null);
|
const [progress, setProgress] = useState<RestoreProgressEvent | null>(null);
|
||||||
|
|
||||||
|
|
@ -74,9 +71,9 @@ export const RestoreProgress = ({ repositoryId, snapshotId }: Props) => {
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase text-muted-foreground">Files</p>
|
<p className="text-xs uppercase text-muted-foreground">Files</p>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{progress.files_restored.toLocaleString(locale)}
|
{progress.files_restored.toLocaleString()}
|
||||||
/
|
/
|
||||||
{progress.total_files.toLocaleString(locale)}
|
{progress.total_files.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "~/client/components/ui/dialog";
|
} from "~/client/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||||
import { useTimeFormat } from "~/client/lib/datetime";
|
|
||||||
import { formatDuration } from "~/utils/utils";
|
import { formatDuration } from "~/utils/utils";
|
||||||
|
import { formatDateTime } from "~/client/lib/datetime";
|
||||||
import {
|
import {
|
||||||
deleteSnapshotsMutation,
|
deleteSnapshotsMutation,
|
||||||
listSnapshotsQueryKey,
|
listSnapshotsQueryKey,
|
||||||
|
|
@ -49,7 +49,6 @@ type Props = {
|
||||||
export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshotsQueryOptions }: Props) => {
|
export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshotsQueryOptions }: Props) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { formatDateTime } = useTimeFormat();
|
|
||||||
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild aria-label={label}>
|
<TooltipTrigger>
|
||||||
<span className="relative flex size-3 mx-auto">
|
<span className="relative flex size-3 mx-auto">
|
||||||
{statusMapping?.animated && (
|
{statusMapping?.animated && (
|
||||||
<span
|
<span
|
||||||
|
|
@ -54,7 +54,7 @@ export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p className="capitalize">{label}</p>
|
<p>{label}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ type ThemeContextValue = {
|
||||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
export const THEME_COOKIE_NAME = "theme";
|
export const THEME_COOKIE_NAME = "theme";
|
||||||
const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
export const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||||
const DEFAULT_THEME: Theme = "dark";
|
const DEFAULT_THEME: Theme = "dark";
|
||||||
|
|
||||||
export function ThemeProvider({ children, initialTheme }: { children: React.ReactNode; initialTheme?: Theme }) {
|
export function ThemeProvider({ children, initialTheme }: { children: React.ReactNode; initialTheme?: Theme }) {
|
||||||
|
|
|
||||||
|
|
@ -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 * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import { buttonVariants } from "~/client/components/ui/button";
|
import { buttonVariants } from "~/client/components/ui/button";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// fallow-ignore-file unused-export
|
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// fallow-ignore-file unused-export
|
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||||
|
|
@ -13,16 +12,17 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
return (
|
return (
|
||||||
<ol
|
<ol
|
||||||
data-slot="breadcrumb-list"
|
data-slot="breadcrumb-list"
|
||||||
className={cn("text-muted-foreground flex items-center gap-1.5 text-sm sm:gap-2.5 min-w-0", className)}
|
className={cn(
|
||||||
|
"text-muted-foreground flex items-center gap-1.5 text-sm sm:gap-2.5 min-w-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
return (
|
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5 min-w-0", className)} {...props} />;
|
||||||
<li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5 min-w-0", className)} {...props} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function BreadcrumbLink({
|
function BreadcrumbLink({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// fallow-ignore-file unused-export
|
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,13 @@
|
||||||
// fallow-ignore-file unused-export
|
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
// biome-ignore-all lint: reason
|
// biome-ignore-all lint: reason
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as RechartsPrimitive from "recharts";
|
import * as RechartsPrimitive from "recharts";
|
||||||
|
|
||||||
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
|
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
const THEMES = { light: "", dark: ".dark" } as const;
|
const THEMES = { light: "", dark: ".dark" } as const;
|
||||||
|
|
||||||
// fallow-ignore-next-line unused-type
|
|
||||||
export type ChartConfig = {
|
export type ChartConfig = {
|
||||||
[k in string]: {
|
[k in string]: {
|
||||||
label?: React.ReactNode;
|
label?: React.ReactNode;
|
||||||
|
|
@ -119,7 +116,6 @@ function ChartTooltipContent({
|
||||||
labelKey?: string;
|
labelKey?: string;
|
||||||
}) {
|
}) {
|
||||||
const { config } = useChart();
|
const { config } = useChart();
|
||||||
const { locale } = useRootLoaderData();
|
|
||||||
|
|
||||||
const tooltipLabel = React.useMemo(() => {
|
const tooltipLabel = React.useMemo(() => {
|
||||||
if (hideLabel || !payload?.length) {
|
if (hideLabel || !payload?.length) {
|
||||||
|
|
@ -209,7 +205,7 @@ function ChartTooltipContent({
|
||||||
</div>
|
</div>
|
||||||
{item.value && (
|
{item.value && (
|
||||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
{item.value.toLocaleString(locale)}
|
{item.value.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// fallow-ignore-file unused-export
|
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// fallow-ignore-file unused-export
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// fallow-ignore-file unused-export
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import type * as LabelPrimitive from "@radix-ui/react-label";
|
import type * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { OTPInput, OTPInputContext } from "input-otp";
|
import { OTPInput, OTPInputContext } from "input-otp";
|
||||||
|
import { MinusIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
|
|
@ -54,8 +55,12 @@ function InputOTPSlot({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"hr">) {
|
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||||
return <hr data-slot="input-otp-separator" {...props} />;
|
return (
|
||||||
|
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||||
|
<MinusIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// fallow-ignore-file unused-export
|
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// fallow-ignore-file unused-export
|
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
@ -15,7 +14,7 @@ export const isStoredSecretValue = (value?: string): boolean => {
|
||||||
return value.startsWith("env://") || value.startsWith("file://") || value.startsWith("encv1:");
|
return value.startsWith("env://") || value.startsWith("file://") || value.startsWith("encv1:");
|
||||||
};
|
};
|
||||||
|
|
||||||
type SecretInputProps = Omit<React.ComponentProps<typeof Input>, "type">;
|
type SecretInputProps = Omit<React.ComponentProps<typeof Input>, "type">
|
||||||
|
|
||||||
export const SecretInput = ({ className, value, ...props }: SecretInputProps) => {
|
export const SecretInput = ({ className, value, ...props }: SecretInputProps) => {
|
||||||
const [revealed, setRevealed] = useState(false);
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
|
@ -37,7 +36,12 @@ export const SecretInput = ({ className, value, ...props }: SecretInputProps) =>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" data-secret-input>
|
<div className="relative" data-secret-input>
|
||||||
<Input {...props} value={value} type={type} className={cn(!showAsPlaintext && "pr-10", className)} />
|
<Input
|
||||||
|
{...props}
|
||||||
|
value={value}
|
||||||
|
type={type}
|
||||||
|
className={cn(!showAsPlaintext && "pr-10", className)}
|
||||||
|
/>
|
||||||
{!showAsPlaintext && (
|
{!showAsPlaintext && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -1,73 +1,19 @@
|
||||||
// fallow-ignore-file unused-export
|
import type * as React from "react";
|
||||||
import * as React from "react";
|
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
const SelectSsrValueContext = React.createContext<{
|
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
hydrated: boolean;
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
items: Map<string, React.ReactNode>;
|
|
||||||
value: string | undefined;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
function collectSelectItems(children: React.ReactNode, items = new Map<string, React.ReactNode>()) {
|
|
||||||
for (const child of React.Children.toArray(children)) {
|
|
||||||
if (!React.isValidElement<{ children?: React.ReactNode; value?: string }>(child)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((child.type === SelectItem || child.type === SelectPrimitive.Item) && typeof child.props.value === "string") {
|
|
||||||
items.set(child.props.value, child.props.children);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (child.props.children) {
|
|
||||||
collectSelectItems(child.props.children, items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Select({ children, value, ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
|
||||||
const [hydrated, setHydrated] = React.useState(false);
|
|
||||||
const items = collectSelectItems(children);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setHydrated(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SelectSsrValueContext.Provider value={{ hydrated, items, value }}>
|
|
||||||
<SelectPrimitive.Root data-slot="select" value={value} {...props}>
|
|
||||||
{children}
|
|
||||||
</SelectPrimitive.Root>
|
|
||||||
</SelectSsrValueContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectValue({ children, placeholder, style, ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
const context = React.useContext(SelectSsrValueContext);
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
const selectedItem =
|
|
||||||
context?.value !== undefined && context.value !== "" ? context.items.get(context.value) : undefined;
|
|
||||||
|
|
||||||
if (!context?.hydrated) {
|
|
||||||
return (
|
|
||||||
<span data-slot="select-value" {...props} style={{ pointerEvents: "none", ...style }}>
|
|
||||||
{children ?? selectedItem ?? placeholder}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Value data-slot="select-value" placeholder={placeholder} style={style} {...props}>
|
|
||||||
{children}
|
|
||||||
</SelectPrimitive.Value>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectTrigger({
|
function SelectTrigger({
|
||||||
|
|
@ -75,35 +21,19 @@ function SelectTrigger({
|
||||||
size = "default",
|
size = "default",
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
size?: "sm" | "default";
|
size?: "sm" | "default";
|
||||||
}) {
|
}) {
|
||||||
const context = React.useContext(SelectSsrValueContext);
|
|
||||||
const hasValue = context?.value !== undefined && context.value !== "";
|
|
||||||
const triggerClassName = cn(
|
|
||||||
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&+select[aria-hidden=true]]:hidden [&:has(+select[aria-hidden=true]:last-child)]:mb-0",
|
|
||||||
className,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!context?.hydrated) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id={props.id}
|
|
||||||
aria-disabled="true"
|
|
||||||
aria-invalid={props["aria-invalid"]}
|
|
||||||
data-placeholder={hasValue ? undefined : ""}
|
|
||||||
data-size={size}
|
|
||||||
data-slot="select-trigger"
|
|
||||||
className={triggerClassName}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronDownIcon className="size-4 opacity-50" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger data-slot="select-trigger" data-size={size} className={triggerClassName} {...props}>
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
<SelectPrimitive.Icon asChild>
|
<SelectPrimitive.Icon asChild>
|
||||||
<ChevronDownIcon className="size-4 opacity-50" />
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// fallow-ignore-file unused-export
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// fallow-ignore-file unused-export
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
@ -566,16 +565,10 @@ function SidebarMenuSkeleton({
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
showIcon?: boolean;
|
showIcon?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const skeletonId = React.useId();
|
// Random width between 50 to 90%.
|
||||||
const width = React.useMemo(() => {
|
const width = React.useMemo(() => {
|
||||||
let hash = 0;
|
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||||
|
}, []);
|
||||||
for (const char of skeletonId) {
|
|
||||||
hash = (hash * 31 + char.charCodeAt(0)) % 40;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${hash + 50}%`;
|
|
||||||
}, [skeletonId]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useTheme } from "~/client/components/theme-provider";
|
||||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme } = useTheme();
|
const { theme = "dark" } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// fallow-ignore-file unused-export
|
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Cloud, Folder, Server } from "lucide-react";
|
import { Cloud, Folder, Server } from "lucide-react";
|
||||||
import type { BackendType } from "@zerobyte/contracts/volumes";
|
import type { BackendType } from "~/schemas/volumes";
|
||||||
|
|
||||||
type VolumeIconProps = {
|
type VolumeIconProps = {
|
||||||
backend: BackendType;
|
backend: BackendType;
|
||||||
|
|
|
||||||
|
|
@ -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,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);
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { cleanup, createTestQueryClient, render, screen } from "~/test/test-utils";
|
|
||||||
import { useServerEvents } from "../use-server-events";
|
import { useServerEvents } from "../use-server-events";
|
||||||
|
|
||||||
class MockEventSource {
|
class MockEventSource {
|
||||||
static instances: MockEventSource[] = [];
|
static instances: MockEventSource[] = [];
|
||||||
|
|
||||||
public onerror: ((event: Event) => void) | null = null;
|
public onerror: ((event: Event) => void) | null = null;
|
||||||
public close = vi.fn(() => {});
|
public close = mock(() => {});
|
||||||
private listeners = new Map<string, Set<(event: Event) => void>>();
|
private listeners = new Map<string, Set<(event: Event) => void>>();
|
||||||
|
|
||||||
constructor(public url: string) {
|
constructor(public url: string) {
|
||||||
|
|
@ -40,8 +40,21 @@ const originalEventSource = globalThis.EventSource;
|
||||||
const originalConsoleInfo = console.info;
|
const originalConsoleInfo = console.info;
|
||||||
const originalConsoleError = console.error;
|
const originalConsoleError = console.error;
|
||||||
|
|
||||||
const ConnectionConsumer = ({ enabled = true }: { enabled?: boolean }) => {
|
const createTestQueryClient = () =>
|
||||||
useServerEvents({ enabled });
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
gcTime: Infinity,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
gcTime: Infinity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ConnectionConsumer = () => {
|
||||||
|
useServerEvents();
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -68,21 +81,12 @@ const BackupCompletedListener = ({ scheduleId }: { scheduleId: string }) => {
|
||||||
return <div>{status}</div>;
|
return <div>{status}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const QueryStatusConsumer = ({ getValue }: { getValue: () => string }) => {
|
|
||||||
const { data } = useQuery({
|
|
||||||
queryKey: ["backup-status"],
|
|
||||||
queryFn: async () => getValue(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return <div>{data}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("useServerEvents", () => {
|
describe("useServerEvents", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
MockEventSource.reset();
|
MockEventSource.reset();
|
||||||
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
|
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
|
||||||
console.info = vi.fn(() => {});
|
console.info = mock(() => {});
|
||||||
console.error = vi.fn(() => {});
|
console.error = mock(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -93,23 +97,21 @@ describe("useServerEvents", () => {
|
||||||
MockEventSource.reset();
|
MockEventSource.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shares one EventSource across consumers and refreshes active queries on backup completion", async () => {
|
test("shares one EventSource across consumers and invalidates queries once on backup completion", async () => {
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
let queryValue = "before";
|
const invalidateQueries = mock(async () => undefined);
|
||||||
|
const refetchQueries = mock(async () => undefined);
|
||||||
|
queryClient.invalidateQueries = invalidateQueries as typeof queryClient.invalidateQueries;
|
||||||
|
queryClient.refetchQueries = refetchQueries as typeof queryClient.refetchQueries;
|
||||||
|
|
||||||
const view = render(
|
render(
|
||||||
<>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ConnectionConsumer />
|
<ConnectionConsumer />
|
||||||
<BackupCompletedListener scheduleId="0b9c940b" />
|
<BackupCompletedListener scheduleId="0b9c940b" />
|
||||||
<QueryStatusConsumer getValue={() => queryValue} />
|
</QueryClientProvider>,
|
||||||
</>,
|
|
||||||
{ queryClient },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(MockEventSource.instances).toHaveLength(1);
|
expect(MockEventSource.instances).toHaveLength(1);
|
||||||
expect(await screen.findByText("before")).toBeTruthy();
|
|
||||||
|
|
||||||
queryValue = "after";
|
|
||||||
|
|
||||||
MockEventSource.instances[0]?.emit("backup:completed", {
|
MockEventSource.instances[0]?.emit("backup:completed", {
|
||||||
organizationId: "default-org",
|
organizationId: "default-org",
|
||||||
|
|
@ -120,22 +122,11 @@ describe("useServerEvents", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await screen.findByText("success")).toBeTruthy();
|
expect(await screen.findByText("success")).toBeTruthy();
|
||||||
expect(await screen.findByText("after")).toBeTruthy();
|
expect(invalidateQueries).toHaveBeenCalledTimes(1);
|
||||||
|
expect(refetchQueries).not.toHaveBeenCalled();
|
||||||
|
|
||||||
view.unmount();
|
cleanup();
|
||||||
|
|
||||||
expect(MockEventSource.instances[0]?.close).toHaveBeenCalledTimes(1);
|
expect(MockEventSource.instances[0]?.close).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("waits to subscribe until enabled", () => {
|
|
||||||
const queryClient = createTestQueryClient();
|
|
||||||
const view = render(<ConnectionConsumer enabled={false} />, { queryClient });
|
|
||||||
|
|
||||||
expect(MockEventSource.instances).toHaveLength(0);
|
|
||||||
|
|
||||||
view.rerender(<ConnectionConsumer />);
|
|
||||||
|
|
||||||
expect(MockEventSource.instances).toHaveLength(1);
|
|
||||||
expect(MockEventSource.instances[0]?.url).toBe("/api/v1/events");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { logger } from "~/client/lib/logger";
|
|
||||||
import type { FileEntry } from "../components/file-tree";
|
import type { FileEntry } from "../components/file-tree";
|
||||||
|
|
||||||
export type FetchFolderResult = {
|
export type FetchFolderResult = {
|
||||||
|
|
@ -45,7 +44,7 @@ export const useFileBrowser = (props: UseFileBrowserOptions) => {
|
||||||
const stripPath = pathTransform?.strip;
|
const stripPath = pathTransform?.strip;
|
||||||
const addPath = pathTransform?.add;
|
const addPath = pathTransform?.add;
|
||||||
|
|
||||||
useEffect(() => {
|
useMemo(() => {
|
||||||
if (initialData?.files) {
|
if (initialData?.files) {
|
||||||
const files = initialData.files;
|
const files = initialData.files;
|
||||||
setAllFiles((prev) => {
|
setAllFiles((prev) => {
|
||||||
|
|
@ -136,7 +135,7 @@ export const useFileBrowser = (props: UseFileBrowserOptions) => {
|
||||||
|
|
||||||
setFetchedFolders((prev) => new Set(prev).add(folderPath));
|
setFetchedFolders((prev) => new Set(prev).add(folderPath));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Failed to fetch folder contents:", error);
|
console.error("Failed to fetch folder contents:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingFolders((prev) => {
|
setLoadingFolders((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
|
|
@ -191,7 +190,7 @@ export const useFileBrowser = (props: UseFileBrowserOptions) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Failed to load more files:", error);
|
console.error("Failed to load more files:", error);
|
||||||
setFolderPagination((prev) => {
|
setFolderPagination((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(folderPath, { ...pagination, isLoadingMore: false });
|
next.set(folderPath, { ...pagination, isLoadingMore: false });
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
|
|
||||||
import { formatBytes, type FormatBytesOptions } from "~/utils/format-bytes";
|
|
||||||
|
|
||||||
export const useFormatBytes = () => {
|
|
||||||
const { locale } = useRootLoaderData();
|
|
||||||
|
|
||||||
return (bytes: number, options?: FormatBytesOptions) =>
|
|
||||||
formatBytes(bytes, {
|
|
||||||
...options,
|
|
||||||
locale: options?.locale ?? locale,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import { Route as RootRoute } from "~/routes/__root";
|
|
||||||
|
|
||||||
export const useRootLoaderData = () => RootRoute.useLoaderData();
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
|
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { logger } from "~/client/lib/logger";
|
|
||||||
import { serverEventNames, type ServerEventPayloadMap } from "~/schemas/server-events";
|
import { serverEventNames, type ServerEventPayloadMap } from "~/schemas/server-events";
|
||||||
|
|
||||||
type LifecycleEventPayloadMap = {
|
type LifecycleEventPayloadMap = {
|
||||||
|
|
@ -28,15 +27,16 @@ const invalidatingEvents = new Set<ServerEventType>([
|
||||||
"backup:completed",
|
"backup:completed",
|
||||||
"volume:updated",
|
"volume:updated",
|
||||||
"volume:status_changed",
|
"volume:status_changed",
|
||||||
"notification:updated",
|
|
||||||
"mirror:completed",
|
"mirror:completed",
|
||||||
"doctor:started",
|
"doctor:started",
|
||||||
"doctor:completed",
|
"doctor:completed",
|
||||||
"doctor:cancelled",
|
"doctor:cancelled",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export type RestoreEvent = ServerEventsPayloadMap["restore:started"] | ServerEventsPayloadMap["restore:completed"];
|
||||||
export type RestoreProgressEvent = ServerEventsPayloadMap["restore:progress"];
|
export type RestoreProgressEvent = ServerEventsPayloadMap["restore:progress"];
|
||||||
export type RestoreCompletedEvent = ServerEventsPayloadMap["restore:completed"];
|
export type RestoreCompletedEvent = ServerEventsPayloadMap["restore:completed"];
|
||||||
|
export type BackupProgressEvent = ServerEventsPayloadMap["backup:progress"];
|
||||||
|
|
||||||
const sharedState: SharedServerEventsState = {
|
const sharedState: SharedServerEventsState = {
|
||||||
eventSource: null,
|
eventSource: null,
|
||||||
|
|
@ -64,7 +64,7 @@ const refreshQueriesForEvent = (eventName: ServerEventType) => {
|
||||||
|
|
||||||
void sharedState.queryClient.invalidateQueries().catch((error) => {
|
void sharedState.queryClient.invalidateQueries().catch((error) => {
|
||||||
if (!isAbortError(error)) {
|
if (!isAbortError(error)) {
|
||||||
logger.error(`[SSE] Failed to refresh queries after ${eventName}:`, error);
|
console.error(`[SSE] Failed to refresh queries after ${eventName}:`, error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -80,7 +80,7 @@ const connectEventSource = (queryClient: QueryClient) => {
|
||||||
|
|
||||||
eventSource.addEventListener("connected", (event) => {
|
eventSource.addEventListener("connected", (event) => {
|
||||||
const data = parseEventData<"connected">(event);
|
const data = parseEventData<"connected">(event);
|
||||||
logger.info("[SSE] Connected to server events");
|
console.info("[SSE] Connected to server events");
|
||||||
emit("connected", data);
|
emit("connected", data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -91,7 +91,7 @@ const connectEventSource = (queryClient: QueryClient) => {
|
||||||
for (const eventName of serverEventNames) {
|
for (const eventName of serverEventNames) {
|
||||||
eventSource.addEventListener(eventName, (event) => {
|
eventSource.addEventListener(eventName, (event) => {
|
||||||
const data = parseEventData<typeof eventName>(event);
|
const data = parseEventData<typeof eventName>(event);
|
||||||
logger.info(`[SSE] ${eventName}:`, data);
|
console.info(`[SSE] ${eventName}:`, data);
|
||||||
|
|
||||||
refreshQueriesForEvent(eventName);
|
refreshQueriesForEvent(eventName);
|
||||||
|
|
||||||
|
|
@ -107,7 +107,7 @@ const connectEventSource = (queryClient: QueryClient) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = (error) => {
|
||||||
logger.error("[SSE] Connection error:", error);
|
console.error("[SSE] Connection error:", error);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -116,7 +116,7 @@ const disconnectEventSource = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("[SSE] Disconnecting from server events");
|
console.info("[SSE] Disconnecting from server events");
|
||||||
sharedState.eventSource.close();
|
sharedState.eventSource.close();
|
||||||
sharedState.eventSource = null;
|
sharedState.eventSource = null;
|
||||||
sharedState.queryClient = null;
|
sharedState.queryClient = null;
|
||||||
|
|
@ -159,16 +159,12 @@ const addSharedEventListener = <T extends ServerEventType>(
|
||||||
* Hook to listen to Server-Sent Events (SSE) from the backend
|
* Hook to listen to Server-Sent Events (SSE) from the backend
|
||||||
* Automatically handles cache invalidation for backup and volume events
|
* Automatically handles cache invalidation for backup and volume events
|
||||||
*/
|
*/
|
||||||
export function useServerEvents({ enabled = true }: { enabled?: boolean } = {}) {
|
export function useServerEvents() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const addEventListener = useCallback(addSharedEventListener, []);
|
const addEventListener = useCallback(addSharedEventListener, []);
|
||||||
const hasMountedRef = useRef(false);
|
const hasMountedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectEventSource(queryClient);
|
connectEventSource(queryClient);
|
||||||
if (!hasMountedRef.current) {
|
if (!hasMountedRef.current) {
|
||||||
sharedState.subscribers += 1;
|
sharedState.subscribers += 1;
|
||||||
|
|
@ -186,7 +182,7 @@ export function useServerEvents({ enabled = true }: { enabled?: boolean } = {})
|
||||||
disconnectEventSource();
|
disconnectEventSource();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [enabled, queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
return { addEventListener };
|
return { addEventListener };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,7 @@ export function useUpdates() {
|
||||||
...getUpdatesOptions(),
|
...getUpdatesOptions(),
|
||||||
staleTime: 60 * 60 * 1000,
|
staleTime: 60 * 60 * 1000,
|
||||||
gcTime: 24 * 60 * 60 * 1000,
|
gcTime: 24 * 60 * 60 * 1000,
|
||||||
refetchOnMount: "always",
|
refetchOnWindowFocus: false,
|
||||||
refetchOnWindowFocus: "always",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { DEFAULT_TIME_FORMAT, inferDateTimePreferences, rawFormatters } from "../datetime";
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
formatDate,
|
|
||||||
formatDateTime,
|
|
||||||
formatDateWithMonth,
|
|
||||||
formatShortDate,
|
|
||||||
formatShortDateTime,
|
|
||||||
formatTime,
|
|
||||||
formatTimeAgo,
|
|
||||||
} = rawFormatters;
|
|
||||||
|
|
||||||
const sampleDate = new Date("2026-01-10T14:30:00.000Z");
|
|
||||||
|
|
||||||
describe("datetime formatters", () => {
|
|
||||||
test.each([
|
|
||||||
formatDateTime,
|
|
||||||
formatDateWithMonth,
|
|
||||||
formatDate,
|
|
||||||
formatShortDate,
|
|
||||||
formatShortDateTime,
|
|
||||||
formatTime,
|
|
||||||
formatTimeAgo,
|
|
||||||
])("returns Never when no date is provided", (formatValue) => {
|
|
||||||
expect(formatValue(null)).toBe("Never");
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
formatDateTime,
|
|
||||||
formatDateWithMonth,
|
|
||||||
formatDate,
|
|
||||||
formatShortDate,
|
|
||||||
formatShortDateTime,
|
|
||||||
formatTime,
|
|
||||||
formatTimeAgo,
|
|
||||||
])("returns Invalid Date when the input cannot be parsed", (formatValue) => {
|
|
||||||
expect(formatValue("not-a-date")).toBe("Invalid Date");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts Date, string, and timestamp inputs for calendar formatters", () => {
|
|
||||||
const isoDate = sampleDate.toISOString();
|
|
||||||
const timestamp = sampleDate.getTime();
|
|
||||||
|
|
||||||
expect(formatDateTime(isoDate)).toBe(formatDateTime(sampleDate));
|
|
||||||
expect(formatDateTime(timestamp)).toBe(formatDateTime(sampleDate));
|
|
||||||
expect(formatDateWithMonth(isoDate)).toBe(formatDateWithMonth(sampleDate));
|
|
||||||
expect(formatDate(timestamp)).toBe(formatDate(sampleDate));
|
|
||||||
expect(formatShortDate(isoDate)).toBe(formatShortDate(sampleDate));
|
|
||||||
expect(formatShortDateTime(timestamp)).toBe(formatShortDateTime(sampleDate));
|
|
||||||
expect(formatTime(isoDate)).toBe(formatTime(sampleDate));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("formats relative times without approximation prefixes", () => {
|
|
||||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(new Date("2026-01-10T14:35:00.000Z").getTime());
|
|
||||||
|
|
||||||
expect(formatTimeAgo(sampleDate)).toBe("5 minutes ago");
|
|
||||||
|
|
||||||
nowSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("formats calendar values with an explicit locale and timezone", () => {
|
|
||||||
expect(formatShortDateTime(sampleDate, { locale: "en-US", timeZone: "UTC" })).toBe("01/10, 2:30 PM");
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
["MM/DD/YYYY", "01/10/2026"],
|
|
||||||
["DD/MM/YYYY", "10/01/2026"],
|
|
||||||
["YYYY/MM/DD", "2026/01/10"],
|
|
||||||
] as const)("formats numeric dates with %s order", (dateFormat, expected) => {
|
|
||||||
expect(formatDate(sampleDate, { locale: "en-US", timeZone: "UTC", dateFormat })).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
["MM/DD/YYYY", "Jan 10, 2026"],
|
|
||||||
["DD/MM/YYYY", "10 Jan 2026"],
|
|
||||||
["YYYY/MM/DD", "2026 Jan 10"],
|
|
||||||
] as const)("formats month dates with %s order", (dateFormat, expected) => {
|
|
||||||
expect(formatDateWithMonth(sampleDate, { locale: "en-US", timeZone: "UTC", dateFormat })).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
[DEFAULT_TIME_FORMAT, "2:30 PM"],
|
|
||||||
["24h", "14:30"],
|
|
||||||
] as const)("formats times with %s clock", (timeFormat, expected) => {
|
|
||||||
expect(formatTime(sampleDate, { locale: "en-US", timeZone: "UTC", timeFormat })).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
["en-US", { dateFormat: "MM/DD/YYYY", timeFormat: "12h" }],
|
|
||||||
["en-GB", { dateFormat: "DD/MM/YYYY", timeFormat: "24h" }],
|
|
||||||
["ja-JP", { dateFormat: "YYYY/MM/DD", timeFormat: "24h" }],
|
|
||||||
] as const)("infers date and time preferences for %s", (locale, expected) => {
|
|
||||||
expect(inferDateTimePreferences(locale)).toEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("formats combined values with custom date and time preferences", () => {
|
|
||||||
expect(
|
|
||||||
formatDateTime(sampleDate, {
|
|
||||||
locale: "en-US",
|
|
||||||
timeZone: "UTC",
|
|
||||||
dateFormat: "DD/MM/YYYY",
|
|
||||||
timeFormat: "24h",
|
|
||||||
}),
|
|
||||||
).toBe("10/01/2026, 14:30");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -7,7 +7,6 @@ import {
|
||||||
inferAdditionalFields,
|
inferAdditionalFields,
|
||||||
} from "better-auth/client/plugins";
|
} from "better-auth/client/plugins";
|
||||||
import { ssoClient } from "@better-auth/sso/client";
|
import { ssoClient } from "@better-auth/sso/client";
|
||||||
import { passkeyClient } from "@better-auth/passkey/client";
|
|
||||||
import type { auth } from "~/server/lib/auth";
|
import type { auth } from "~/server/lib/auth";
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
|
|
@ -18,6 +17,5 @@ export const authClient = createAuthClient({
|
||||||
organizationClient(),
|
organizationClient(),
|
||||||
ssoClient(),
|
ssoClient(),
|
||||||
twoFactorClient(),
|
twoFactorClient(),
|
||||||
passkeyClient(),
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,234 +1,93 @@
|
||||||
import { formatDistanceToNow, isValid } from "date-fns";
|
import { formatDistanceToNow, isValid } from "date-fns";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
|
|
||||||
|
|
||||||
export type DateInput = Date | string | number | null | undefined;
|
// 1/10/2026, 2:30 PM
|
||||||
|
export function formatDateTime(date: Date | string | number | null | undefined): string {
|
||||||
export const DATE_FORMATS = ["MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD"] as const;
|
|
||||||
export type DateFormatPreference = (typeof DATE_FORMATS)[number];
|
|
||||||
const DEFAULT_DATE_FORMAT: DateFormatPreference = "MM/DD/YYYY";
|
|
||||||
|
|
||||||
export const TIME_FORMATS = ["12h", "24h"] as const;
|
|
||||||
export type TimeFormatPreference = (typeof TIME_FORMATS)[number];
|
|
||||||
export const DEFAULT_TIME_FORMAT: TimeFormatPreference = "12h";
|
|
||||||
|
|
||||||
const BROWSER_PREFERENCE_SAMPLE_DATE = new Date(Date.UTC(2006, 0, 2, 15, 4, 5));
|
|
||||||
|
|
||||||
const DATE_PART_ORDERS = {
|
|
||||||
"MM/DD/YYYY": ["month", "day", "year"],
|
|
||||||
"DD/MM/YYYY": ["day", "month", "year"],
|
|
||||||
"YYYY/MM/DD": ["year", "month", "day"],
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const SHORT_DATE_PART_ORDERS = {
|
|
||||||
"MM/DD/YYYY": ["month", "day"],
|
|
||||||
"DD/MM/YYYY": ["day", "month"],
|
|
||||||
"YYYY/MM/DD": ["month", "day"],
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type DateFormatOptions = {
|
|
||||||
locale?: string | string[];
|
|
||||||
timeZone?: string;
|
|
||||||
dateFormat?: DateFormatPreference;
|
|
||||||
timeFormat?: TimeFormatPreference;
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatValidDate(date: DateInput, formatter: (date: Date) => string): string {
|
|
||||||
if (!date) return "Never";
|
if (!date) return "Never";
|
||||||
|
const d = new Date(date);
|
||||||
|
if (!isValid(d)) return "Invalid Date";
|
||||||
|
|
||||||
const parsedDate = new Date(date);
|
return Intl.DateTimeFormat(navigator.languages, {
|
||||||
if (!isValid(parsedDate)) return "Invalid Date";
|
|
||||||
|
|
||||||
return formatter(parsedDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDateTimeFormat(
|
|
||||||
locale: DateFormatOptions["locale"],
|
|
||||||
timeZone: DateFormatOptions["timeZone"],
|
|
||||||
options: Intl.DateTimeFormatOptions,
|
|
||||||
) {
|
|
||||||
return Intl.DateTimeFormat(locale, {
|
|
||||||
...options,
|
|
||||||
timeZone,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRequiredPart(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes) {
|
|
||||||
const value = parts.find((part) => part.type === type)?.value;
|
|
||||||
|
|
||||||
if (!value) {
|
|
||||||
throw new Error(`Missing ${type} in formatted date`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatConfiguredDate(date: Date, options: DateFormatOptions, includeYear: boolean) {
|
|
||||||
const dateFormat = options.dateFormat ?? DEFAULT_DATE_FORMAT;
|
|
||||||
const safeDateFormat = DATE_FORMATS.includes(dateFormat) ? dateFormat : DEFAULT_DATE_FORMAT;
|
|
||||||
const parts = getDateTimeFormat(options.locale, options.timeZone, {
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
}).formatToParts(date);
|
|
||||||
const values = {
|
|
||||||
month: getRequiredPart(parts, "month"),
|
|
||||||
day: getRequiredPart(parts, "day"),
|
|
||||||
year: getRequiredPart(parts, "year"),
|
|
||||||
};
|
|
||||||
const order = includeYear ? DATE_PART_ORDERS[safeDateFormat] : SHORT_DATE_PART_ORDERS[safeDateFormat];
|
|
||||||
|
|
||||||
return order.map((part) => values[part]).join("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatConfiguredDateWithMonth(date: Date, options: DateFormatOptions) {
|
|
||||||
const dateFormat = options.dateFormat ?? DEFAULT_DATE_FORMAT;
|
|
||||||
const parts = getDateTimeFormat(options.locale, options.timeZone, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
}).formatToParts(date);
|
|
||||||
const month = getRequiredPart(parts, "month");
|
|
||||||
const day = getRequiredPart(parts, "day");
|
|
||||||
const year = getRequiredPart(parts, "year");
|
|
||||||
|
|
||||||
if (dateFormat === "DD/MM/YYYY") {
|
|
||||||
return `${day} ${month} ${year}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dateFormat === "YYYY/MM/DD") {
|
|
||||||
return `${year} ${month} ${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${month} ${day}, ${year}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatConfiguredTime(date: Date, options: DateFormatOptions) {
|
|
||||||
return getDateTimeFormat(options.locale, options.timeZone, {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "numeric",
|
|
||||||
hour12: (options.timeFormat ?? DEFAULT_TIME_FORMAT) === "12h",
|
|
||||||
}).format(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function inferDateTimePreferences(locale?: string) {
|
|
||||||
const dateOrder = getDateTimeFormat(locale, undefined, {
|
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
})
|
hour: "numeric",
|
||||||
.formatToParts(BROWSER_PREFERENCE_SAMPLE_DATE)
|
minute: "numeric",
|
||||||
.flatMap((part) => {
|
}).format(d);
|
||||||
if (part.type === "year" || part.type === "month" || part.type === "day") {
|
|
||||||
return [part.type];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
})
|
|
||||||
.join("/");
|
|
||||||
|
|
||||||
let dateFormat = DEFAULT_DATE_FORMAT;
|
|
||||||
|
|
||||||
if (dateOrder === "day/month/year") {
|
|
||||||
dateFormat = "DD/MM/YYYY";
|
|
||||||
} else if (dateOrder === "year/month/day") {
|
|
||||||
dateFormat = "YYYY/MM/DD";
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeFormat: TimeFormatPreference = "12h";
|
|
||||||
const hour12 = getDateTimeFormat(locale, undefined, { hour: "numeric" }).resolvedOptions().hour12;
|
|
||||||
|
|
||||||
if (hour12 === false) {
|
|
||||||
timeFormat = "24h";
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
dateFormat,
|
|
||||||
timeFormat,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 01/10/2026, 2:30 PM
|
|
||||||
function formatDateTime(date: DateInput, options: DateFormatOptions = {}): string {
|
|
||||||
return formatValidDate(
|
|
||||||
date,
|
|
||||||
(validDate) => `${formatConfiguredDate(validDate, options, true)}, ${formatConfiguredTime(validDate, options)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jan 10, 2026
|
// Jan 10, 2026
|
||||||
function formatDateWithMonth(date: DateInput, options: DateFormatOptions = {}): string {
|
export function formatDateWithMonth(date: Date | string | number | null | undefined): string {
|
||||||
return formatValidDate(date, (validDate) => formatConfiguredDateWithMonth(validDate, options));
|
if (!date) return "Never";
|
||||||
|
const d = new Date(date);
|
||||||
|
if (!isValid(d)) return "Invalid Date";
|
||||||
|
return Intl.DateTimeFormat(navigator.languages, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 01/10/2026
|
// 1/10/2026
|
||||||
function formatDate(date: DateInput, options: DateFormatOptions = {}): string {
|
export function formatDate(date: Date | string | number | null | undefined): string {
|
||||||
return formatValidDate(date, (validDate) => formatConfiguredDate(validDate, options, true));
|
if (!date) return "Never";
|
||||||
|
const d = new Date(date);
|
||||||
|
if (!isValid(d)) return "Invalid Date";
|
||||||
|
|
||||||
|
return Intl.DateTimeFormat(navigator.languages, {
|
||||||
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1/10
|
// 1/10
|
||||||
function formatShortDate(date: DateInput, options: DateFormatOptions = {}): string {
|
export function formatShortDate(date: Date | string | number | null | undefined): string {
|
||||||
return formatValidDate(date, (validDate) => formatConfiguredDate(validDate, options, false));
|
if (!date) return "Never";
|
||||||
|
const d = new Date(date);
|
||||||
|
if (!isValid(d)) return "Invalid Date";
|
||||||
|
|
||||||
|
return Intl.DateTimeFormat(navigator.languages, {
|
||||||
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 01/10, 2:30 PM
|
// 1/10, 2:30 PM
|
||||||
function formatShortDateTime(date: DateInput, options: DateFormatOptions = {}): string {
|
export function formatShortDateTime(date: Date | string | number | null | undefined): string {
|
||||||
return formatValidDate(
|
if (!date) return "Never";
|
||||||
date,
|
const d = new Date(date);
|
||||||
(validDate) => `${formatConfiguredDate(validDate, options, false)}, ${formatConfiguredTime(validDate, options)}`,
|
if (!isValid(d)) return "Invalid Date";
|
||||||
);
|
|
||||||
|
return Intl.DateTimeFormat(navigator.languages, {
|
||||||
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
}).format(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2:30 PM
|
// 2:30 PM
|
||||||
function formatTime(date: DateInput, options: DateFormatOptions = {}): string {
|
export function formatTime(date: Date | string | number | null | undefined): string {
|
||||||
return formatValidDate(date, (validDate) => formatConfiguredTime(validDate, options));
|
if (!date) return "Never";
|
||||||
|
const d = new Date(date);
|
||||||
|
if (!isValid(d)) return "Invalid Date";
|
||||||
|
|
||||||
|
return Intl.DateTimeFormat(navigator.languages, {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
}).format(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5 minutes ago
|
// 5 minutes ago
|
||||||
function formatTimeAgo(date: DateInput, now = Date.now()): string {
|
export function formatTimeAgo(date: Date | string | number | null | undefined): string {
|
||||||
return formatValidDate(date, (validDate) => {
|
if (!date) return "Never";
|
||||||
if (Math.abs(now - validDate.getTime()) < 120_000) {
|
const d = new Date(date);
|
||||||
return "just now";
|
if (!isValid(d)) return "Invalid Date";
|
||||||
}
|
|
||||||
|
|
||||||
const timeAgo = formatDistanceToNow(validDate, {
|
const timeAgo = formatDistanceToNow(d, {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
includeSeconds: true,
|
includeSeconds: true,
|
||||||
});
|
|
||||||
|
|
||||||
return timeAgo.replace("about ", "").replace("over ", "").replace("almost ", "").replace("less than ", "");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return timeAgo.replace("about ", "").replace("over ", "").replace("almost ", "").replace("less than ", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTimeFormat() {
|
|
||||||
const { locale, timeZone, dateFormat, timeFormat, now } = useRootLoaderData();
|
|
||||||
const [currentNow, setCurrentNow] = useState(now);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const nextNow = Date.now();
|
|
||||||
setCurrentNow(nextNow === now ? now : nextNow);
|
|
||||||
}, [now]);
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
formatDateTime: (date: DateInput) => formatDateTime(date, { locale, timeZone, dateFormat, timeFormat }),
|
|
||||||
formatDateWithMonth: (date: DateInput) => formatDateWithMonth(date, { locale, timeZone, dateFormat, timeFormat }),
|
|
||||||
formatDate: (date: DateInput) => formatDate(date, { locale, timeZone, dateFormat, timeFormat }),
|
|
||||||
formatShortDate: (date: DateInput) => formatShortDate(date, { locale, timeZone, dateFormat, timeFormat }),
|
|
||||||
formatShortDateTime: (date: DateInput) => formatShortDateTime(date, { locale, timeZone, dateFormat, timeFormat }),
|
|
||||||
formatTime: (date: DateInput) => formatTime(date, { locale, timeZone, dateFormat, timeFormat }),
|
|
||||||
formatTimeAgo: (date: DateInput) => formatTimeAgo(date, currentNow),
|
|
||||||
}),
|
|
||||||
[locale, timeZone, currentNow, dateFormat, timeFormat],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const rawFormatters = {
|
|
||||||
formatDateTime,
|
|
||||||
formatDateWithMonth,
|
|
||||||
formatDate,
|
|
||||||
formatShortDate,
|
|
||||||
formatShortDateTime,
|
|
||||||
formatTime,
|
|
||||||
formatTimeAgo,
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { unlockRepository } from "~/client/api-client/sdk.gen";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Unlock } from "lucide-react";
|
import { Unlock } from "lucide-react";
|
||||||
|
|
||||||
const isLockError = (error: unknown): boolean => {
|
export const isLockError = (error: unknown): boolean => {
|
||||||
const errorMessage = parseError(error)?.message || "";
|
const errorMessage = parseError(error)?.message || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -25,7 +25,7 @@ export const parseError = (error?: unknown) => {
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const showLockErrorToast = (repositoryId: string, title: string) => {
|
export const showLockErrorToast = (repositoryId: string, title: string) => {
|
||||||
toast.error(title, {
|
toast.error(title, {
|
||||||
description:
|
description:
|
||||||
"The repository is currently locked by another operation. This can happen when a previous operation didn't complete properly.",
|
"The repository is currently locked by another operation. This can happen when a previous operation didn't complete properly.",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
const PREFIX = "[APP]";
|
|
||||||
|
|
||||||
export const logger = {
|
|
||||||
error: (...args: unknown[]) => console.error(PREFIX, ...args),
|
|
||||||
warn: (...args: unknown[]) => console.warn(PREFIX, ...args),
|
|
||||||
info: (...args: unknown[]) => console.info(PREFIX, ...args),
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { LOGIN_ERROR_CODES, type LoginErrorCode } from "~/lib/sso-errors";
|
import { LOGIN_ERROR_CODES, type LoginErrorCode } from "~/lib/sso-errors";
|
||||||
|
|
||||||
|
export type { LoginErrorCode } from "~/lib/sso-errors";
|
||||||
export { getLoginErrorDescription } from "~/lib/sso-errors";
|
export { getLoginErrorDescription } from "~/lib/sso-errors";
|
||||||
|
|
||||||
const VALID_ERROR_CODES = new Set<LoginErrorCode>(LOGIN_ERROR_CODES);
|
const VALID_ERROR_CODES = new Set<LoginErrorCode>(LOGIN_ERROR_CODES);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,29 @@ export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an arbitrary string into a URL-safe slug:
|
||||||
|
* - lowercase
|
||||||
|
* - trims whitespace
|
||||||
|
* - replaces non-alphanumeric runs with "-"
|
||||||
|
* - collapses multiple hyphens
|
||||||
|
* - trims leading/trailing hyphens
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Live slugify for UI: lowercases, normalizes dashes, replaces invalid runs with "-",
|
||||||
|
* collapses repeats, but DOES NOT trim leading/trailing hyphens so the user can type
|
||||||
|
* spaces/dashes progressively while editing.
|
||||||
|
*/
|
||||||
|
export function slugify(input: string): string {
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[ ]/g, "-")
|
||||||
|
.replace(/[^a-z0-9_-]+/g, "")
|
||||||
|
.replace(/[-]{2,}/g, "-")
|
||||||
|
.replace(/[_]{2,}/g, "_")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function safeJsonParse<T>(input: string): T | null {
|
export function safeJsonParse<T>(input: string): T | null {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(input) as T;
|
return JSON.parse(input) as T;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { getVolumeMountPath } from "./volume-path";
|
import { getVolumeMountPath } from "./volume-path";
|
||||||
import { fromAny } from "@total-typescript/shoehorn";
|
import { fromAny } from "@total-typescript/shoehorn";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
|
||||||
import { useServerFn } from "@tanstack/react-start";
|
|
||||||
import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
|
||||||
import { SsoLoginButtons } from "~/client/modules/sso/components/sso-login-buttons";
|
|
||||||
import { getLoginOptions } from "~/server/lib/functions/login-options";
|
|
||||||
import { PasskeySignInButton } from "./passkey-sign-in-button";
|
|
||||||
|
|
||||||
type AlternativeSignInSectionProps = {
|
|
||||||
onPasskeySignIn: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AlternativeSignInSection({ onPasskeySignIn }: AlternativeSignInSectionProps) {
|
|
||||||
const getOptions = useServerFn(getLoginOptions);
|
|
||||||
const { data: ssoProviders } = useSuspenseQuery({
|
|
||||||
...getPublicSsoProvidersOptions(),
|
|
||||||
});
|
|
||||||
const { data: loginOptions } = useSuspenseQuery({
|
|
||||||
queryKey: ["login-options"],
|
|
||||||
queryFn: getOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ssoProviders.providers.length === 0 && !loginOptions.hasPasskeySignIn) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pt-4 border-t border-border/60 space-y-3">
|
|
||||||
<p className="text-sm font-medium">Alternative Sign-in</p>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{loginOptions.hasPasskeySignIn && <PasskeySignInButton onSignIn={onPasskeySignIn} />}
|
|
||||||
<SsoLoginButtons providers={ssoProviders.providers} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
|
||||||
import { Fingerprint } from "lucide-react";
|
|
||||||
import { Button } from "~/client/components/ui/button";
|
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
|
||||||
import { logger } from "~/client/lib/logger";
|
|
||||||
import { LOGIN_ERROR_CODES, PASSKEY_LOGIN_FAILED_ERROR, type LoginErrorCode } from "~/lib/sso-errors";
|
|
||||||
|
|
||||||
type PasskeySignInButtonProps = {
|
|
||||||
onSignIn: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PasskeySignInError = {
|
|
||||||
code?: string;
|
|
||||||
message?: string;
|
|
||||||
status?: number;
|
|
||||||
statusText?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LOGIN_ERROR_CODE_SET = new Set<string>(LOGIN_ERROR_CODES);
|
|
||||||
|
|
||||||
function getPasskeyLoginErrorCode(code: string | undefined): LoginErrorCode {
|
|
||||||
if (code && LOGIN_ERROR_CODE_SET.has(code)) {
|
|
||||||
return code as LoginErrorCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
return PASSKEY_LOGIN_FAILED_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PasskeySignInButton({ onSignIn }: PasskeySignInButtonProps) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const passkeyLoginMutation = useMutation<void, PasskeySignInError>({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const { error } = await authClient.signIn.passkey();
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
await onSignIn();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
logger.error(error);
|
|
||||||
|
|
||||||
void navigate({
|
|
||||||
to: "/login",
|
|
||||||
search: {
|
|
||||||
error: getPasskeyLoginErrorCode(error.code),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
loading={passkeyLoginMutation.isPending}
|
|
||||||
disabled={passkeyLoginMutation.isPending}
|
|
||||||
onClick={() => passkeyLoginMutation.mutate()}
|
|
||||||
>
|
|
||||||
<Fingerprint className="h-4 w-4 mr-2" />
|
|
||||||
Sign in with passkey
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,89 +1,71 @@
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, mock, test } from "bun:test";
|
||||||
import { HttpResponse, http, server } from "~/test/msw/server";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { cleanup, render, screen, userEvent, waitFor } from "~/test/test-utils";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { getLoginErrorDescription, PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors";
|
|
||||||
|
|
||||||
const { mockGetLoginOptions, mockNavigate, mockPasskeySignIn } = vi.hoisted(() => ({
|
await mock.module("@tanstack/react-router", () => ({
|
||||||
mockGetLoginOptions: vi.fn(async () => ({ hasPasskeySignIn: false })),
|
useNavigate: () => mock(() => {}),
|
||||||
mockNavigate: vi.fn(async () => {}),
|
|
||||||
mockPasskeySignIn: vi.fn(
|
|
||||||
async (): Promise<{
|
|
||||||
data: unknown;
|
|
||||||
error: { code: string; message: string } | null;
|
|
||||||
}> => ({ data: null, error: null }),
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@tanstack/react-router", async (importOriginal) => {
|
await mock.module("~/client/api-client/@tanstack/react-query.gen", () => ({
|
||||||
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
|
getPublicSsoProvidersOptions: () => ({
|
||||||
|
queryKey: ["public-sso-providers"],
|
||||||
return {
|
queryFn: async () => ({ providers: [] }),
|
||||||
...actual,
|
}),
|
||||||
useNavigate: (() => mockNavigate) as typeof actual.useNavigate,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@tanstack/react-start", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof import("@tanstack/react-start")>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
useServerFn: (fn: unknown) => fn,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("~/server/lib/functions/login-options", () => ({
|
|
||||||
getLoginOptions: mockGetLoginOptions,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("~/client/lib/auth-client", () => ({
|
await mock.module("~/client/lib/auth-client", () => ({
|
||||||
authClient: {
|
authClient: {
|
||||||
|
getSession: mock(async () => ({ data: null })),
|
||||||
signIn: {
|
signIn: {
|
||||||
passkey: mockPasskeySignIn,
|
username: mock(async () => ({ data: null, error: null })),
|
||||||
|
sso: mock(async () => ({ data: null, error: null })),
|
||||||
|
},
|
||||||
|
twoFactor: {
|
||||||
|
verifyTotp: mock(async () => ({ data: null, error: null })),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { LoginPage } from "../login";
|
import { LoginPage } from "../login";
|
||||||
|
|
||||||
|
const createTestQueryClient = () =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
gcTime: Infinity,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
gcTime: Infinity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
const inviteOnlyMessage =
|
const inviteOnlyMessage =
|
||||||
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
|
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
|
||||||
|
|
||||||
const mockSsoProvidersRequest = (
|
|
||||||
providers: Array<{
|
|
||||||
providerId: string;
|
|
||||||
organizationSlug: string;
|
|
||||||
}> = [],
|
|
||||||
) => {
|
|
||||||
server.use(
|
|
||||||
http.get("/api/v1/auth/sso-providers", () => {
|
|
||||||
return HttpResponse.json({ providers });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mockGetLoginOptions.mockClear();
|
|
||||||
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: false });
|
|
||||||
mockNavigate.mockClear();
|
|
||||||
mockPasskeySignIn.mockClear();
|
|
||||||
mockPasskeySignIn.mockResolvedValue({ data: null, error: null });
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("LoginPage", () => {
|
describe("LoginPage", () => {
|
||||||
test("shows an invite-only message when SSO returns INVITE_REQUIRED code", async () => {
|
test("shows an invite-only message when SSO returns INVITE_REQUIRED code", async () => {
|
||||||
mockSsoProvidersRequest();
|
const queryClient = createTestQueryClient();
|
||||||
|
render(
|
||||||
render(<LoginPage error="INVITE_REQUIRED" />, { withSuspense: true });
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<LoginPage error="INVITE_REQUIRED" />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
expect(await screen.findByText(inviteOnlyMessage)).toBeTruthy();
|
expect(await screen.findByText(inviteOnlyMessage)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows account link required message when SSO returns ACCOUNT_LINK_REQUIRED code", async () => {
|
test("shows account link required message when SSO returns ACCOUNT_LINK_REQUIRED code", async () => {
|
||||||
mockSsoProvidersRequest();
|
const queryClient = createTestQueryClient();
|
||||||
|
render(
|
||||||
render(<LoginPage error="ACCOUNT_LINK_REQUIRED" />, { withSuspense: true });
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<LoginPage error="ACCOUNT_LINK_REQUIRED" />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(
|
await screen.findByText(
|
||||||
|
|
@ -93,9 +75,12 @@ describe("LoginPage", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows banned message when SSO returns BANNED_USER code", async () => {
|
test("shows banned message when SSO returns BANNED_USER code", async () => {
|
||||||
mockSsoProvidersRequest();
|
const queryClient = createTestQueryClient();
|
||||||
|
render(
|
||||||
render(<LoginPage error="BANNED_USER" />, { withSuspense: true });
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<LoginPage error="BANNED_USER" />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(
|
await screen.findByText(
|
||||||
|
|
@ -105,173 +90,36 @@ describe("LoginPage", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows email not verified message when SSO returns EMAIL_NOT_VERIFIED code", async () => {
|
test("shows email not verified message when SSO returns EMAIL_NOT_VERIFIED code", async () => {
|
||||||
mockSsoProvidersRequest();
|
const queryClient = createTestQueryClient();
|
||||||
|
render(
|
||||||
render(<LoginPage error="EMAIL_NOT_VERIFIED" />, { withSuspense: true });
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<LoginPage error="EMAIL_NOT_VERIFIED" />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
expect(await screen.findByText("Your identity provider did not mark your email as verified.")).toBeTruthy();
|
expect(await screen.findByText("Your identity provider did not mark your email as verified.")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows generic SSO error message when SSO returns SSO_LOGIN_FAILED code", async () => {
|
test("shows generic SSO error message when SSO returns SSO_LOGIN_FAILED code", async () => {
|
||||||
mockSsoProvidersRequest();
|
const queryClient = createTestQueryClient();
|
||||||
|
render(
|
||||||
render(<LoginPage error="SSO_LOGIN_FAILED" />, { withSuspense: true });
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<LoginPage error="SSO_LOGIN_FAILED" />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
expect(await screen.findByText("SSO authentication failed. Please try again.")).toBeTruthy();
|
expect(await screen.findByText("SSO authentication failed. Please try again.")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows passkey login failure message when passkey returns the login error code", async () => {
|
|
||||||
mockSsoProvidersRequest();
|
|
||||||
|
|
||||||
render(<LoginPage error={"PASSKEY_LOGIN_FAILED"} />, { withSuspense: true });
|
|
||||||
|
|
||||||
expect(await screen.findByText(getLoginErrorDescription(PASSKEY_LOGIN_FAILED_ERROR))).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not show error message for invalid error codes", async () => {
|
test("does not show error message for invalid error codes", async () => {
|
||||||
mockSsoProvidersRequest();
|
const queryClient = createTestQueryClient();
|
||||||
|
render(
|
||||||
render(<LoginPage error="some_random_error" />, { withSuspense: true });
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<LoginPage error="some_random_error" />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
expect(await screen.findByText("Login to your account")).toBeTruthy();
|
expect(await screen.findByText("Login to your account")).toBeTruthy();
|
||||||
expect(screen.queryByText(inviteOnlyMessage)).toBeNull();
|
expect(screen.queryByText(inviteOnlyMessage)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders available SSO providers from the alternative sign-in section", async () => {
|
|
||||||
mockSsoProvidersRequest([{ providerId: "acme", organizationSlug: "acme-org" }]);
|
|
||||||
|
|
||||||
render(<LoginPage />, { withSuspense: true });
|
|
||||||
|
|
||||||
expect(await screen.findByRole("button", { name: "Log in with acme" })).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders passkey sign-in when an active user has a passkey", async () => {
|
|
||||||
mockSsoProvidersRequest();
|
|
||||||
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
|
|
||||||
|
|
||||||
render(<LoginPage />, { withSuspense: true });
|
|
||||||
|
|
||||||
expect(await screen.findByRole("button", { name: "Sign in with passkey" })).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("redirects passkey verification failures to the login error box", async () => {
|
|
||||||
mockSsoProvidersRequest();
|
|
||||||
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
|
|
||||||
mockPasskeySignIn.mockResolvedValue({
|
|
||||||
data: null,
|
|
||||||
error: { code: "AUTHENTICATION_FAILED", message: "Authentication failed" },
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<LoginPage />, { withSuspense: true });
|
|
||||||
|
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockNavigate).toHaveBeenCalledWith({
|
|
||||||
to: "/login",
|
|
||||||
search: {
|
|
||||||
error: PASSKEY_LOGIN_FAILED_ERROR,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("redirects unauthorized passkey failures to the login error box", async () => {
|
|
||||||
mockSsoProvidersRequest();
|
|
||||||
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
|
|
||||||
mockPasskeySignIn.mockResolvedValue({
|
|
||||||
data: null,
|
|
||||||
error: { code: "UNAUTHORIZED", message: "Unauthorized" },
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<LoginPage />, { withSuspense: true });
|
|
||||||
|
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockNavigate).toHaveBeenCalledWith({
|
|
||||||
to: "/login",
|
|
||||||
search: {
|
|
||||||
error: PASSKEY_LOGIN_FAILED_ERROR,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("preserves specific passkey login error codes", async () => {
|
|
||||||
mockSsoProvidersRequest();
|
|
||||||
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
|
|
||||||
mockPasskeySignIn.mockResolvedValue({
|
|
||||||
data: null,
|
|
||||||
error: { code: "ERROR_INVALID_RP_ID", message: "Auth cancelled" },
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<LoginPage />, { withSuspense: true });
|
|
||||||
|
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockNavigate).toHaveBeenCalledWith({
|
|
||||||
to: "/login",
|
|
||||||
search: {
|
|
||||||
error: "ERROR_INVALID_RP_ID",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("redirects conditional passkey autofill failures to the login error box", async () => {
|
|
||||||
mockSsoProvidersRequest();
|
|
||||||
mockPasskeySignIn.mockResolvedValue({
|
|
||||||
data: null,
|
|
||||||
error: { code: "AUTHENTICATION_FAILED", message: "Authentication failed" },
|
|
||||||
});
|
|
||||||
vi.stubGlobal("PublicKeyCredential", {
|
|
||||||
isConditionalMediationAvailable: vi.fn(async () => true),
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<LoginPage />, { withSuspense: true });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockPasskeySignIn).toHaveBeenCalledWith({
|
|
||||||
autoFill: true,
|
|
||||||
});
|
|
||||||
expect(mockNavigate).toHaveBeenCalledWith({
|
|
||||||
to: "/login",
|
|
||||||
search: {
|
|
||||||
error: "PASSKEY_LOGIN_FAILED",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ignores conditional passkey autofill cancellation", async () => {
|
|
||||||
mockSsoProvidersRequest();
|
|
||||||
mockPasskeySignIn.mockResolvedValue({
|
|
||||||
data: null,
|
|
||||||
error: { code: "AUTH_CANCELLED", message: "Authentication cancelled" },
|
|
||||||
});
|
|
||||||
vi.stubGlobal("PublicKeyCredential", {
|
|
||||||
isConditionalMediationAvailable: vi.fn(async () => true),
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<LoginPage />, { withSuspense: true });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockPasskeySignIn).toHaveBeenCalledWith({
|
|
||||||
autoFill: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(mockNavigate).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hides alternative sign-in when no SSO providers or passkeys are available", async () => {
|
|
||||||
mockSsoProvidersRequest();
|
|
||||||
|
|
||||||
render(<LoginPage />, { withSuspense: true });
|
|
||||||
|
|
||||||
await screen.findByText("Login to your account");
|
|
||||||
expect(screen.queryByText("Alternative Sign-in")).toBeNull();
|
|
||||||
expect(screen.queryByRole("button", { name: "Sign in with passkey" })).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,25 +8,11 @@ import { Button } from "~/client/components/ui/button";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { parseError } from "~/client/lib/errors";
|
|
||||||
import {
|
|
||||||
RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_MAX_AGE,
|
|
||||||
RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME,
|
|
||||||
} from "~/lib/recovery-key-skip";
|
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
const RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE =
|
export function DownloadRecoveryKeyPage() {
|
||||||
"Downloading the recovery key requires a local credential password. Ask an operator to run `docker exec -it zerobyte bun run cli reset-password` for your user, then sign in with that password and try again.";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
hasCredentialPassword: boolean;
|
|
||||||
userId: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DownloadRecoveryKeyPage({ hasCredentialPassword, userId }: Props) {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [blockedMessage, setBlockedMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const downloadResticPassword = useMutation({
|
const downloadResticPassword = useMutation({
|
||||||
...downloadResticPasswordMutation(),
|
...downloadResticPasswordMutation(),
|
||||||
|
|
@ -39,16 +25,13 @@ export function DownloadRecoveryKeyPage({ hasCredentialPassword, userId }: Props
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
toast.success("Recovery key downloaded successfully!");
|
toast.success("Recovery key downloaded successfully!");
|
||||||
setBlockedMessage(null);
|
|
||||||
void navigate({ to: "/volumes", replace: true });
|
void navigate({ to: "/volumes", replace: true });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = parseError(error)?.message;
|
toast.error("Failed to download recovery key", { description: error.message });
|
||||||
setBlockedMessage(message?.includes("credential password") ? message : null);
|
|
||||||
toast.error("Failed to download recovery key", { description: message });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -60,15 +43,11 @@ export function DownloadRecoveryKeyPage({ hasCredentialPassword, userId }: Props
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setBlockedMessage(null);
|
downloadResticPassword.mutate({
|
||||||
downloadResticPassword.mutate({ body: { password } });
|
body: {
|
||||||
};
|
password,
|
||||||
|
},
|
||||||
const handleSkip = () => {
|
});
|
||||||
if (!userId) return;
|
|
||||||
|
|
||||||
document.cookie = `${RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME}=${userId}; path=/; max-age=${RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_MAX_AGE}`;
|
|
||||||
void navigate({ to: "/volumes", replace: true });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -80,56 +59,30 @@ export function DownloadRecoveryKeyPage({ hasCredentialPassword, userId }: Props
|
||||||
<AlertTriangle className="size-5" />
|
<AlertTriangle className="size-5" />
|
||||||
<AlertTitle>Important: Save This File Securely</AlertTitle>
|
<AlertTitle>Important: Save This File Securely</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Your Restic password is essential for recovering your backup data. If you previously downloaded this
|
Your Restic password is essential for recovering your backup data. If you lose access to this server without
|
||||||
file, replace that saved copy with the new download. If you lose access to this server without this
|
this file, your backups will be unrecoverable. Store it in a password manager or encrypted storage.
|
||||||
file, your backups will be unrecoverable. Store it in a password manager or encrypted storage.
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{(!hasCredentialPassword || blockedMessage) && (
|
<div className="space-y-2">
|
||||||
<Alert variant="warning">
|
<Label htmlFor="password">Confirm Your Password</Label>
|
||||||
<AlertTriangle className="size-5" />
|
<Input
|
||||||
<AlertTitle>Local password required</AlertTitle>
|
id="password"
|
||||||
<AlertDescription>
|
type="password"
|
||||||
{blockedMessage ?? RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE}
|
value={password}
|
||||||
</AlertDescription>
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
</Alert>
|
placeholder="Enter your password"
|
||||||
)}
|
required
|
||||||
|
disabled={downloadResticPassword.isPending}
|
||||||
{hasCredentialPassword && (
|
/>
|
||||||
<div className="space-y-2">
|
<p className="text-xs text-muted-foreground">Enter your account password to download the recovery key</p>
|
||||||
<Label htmlFor="password">Confirm Your Password</Label>
|
</div>
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="Enter your password"
|
|
||||||
required
|
|
||||||
disabled={downloadResticPassword.isPending}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Enter your account password to download the recovery key
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{hasCredentialPassword && (
|
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
|
||||||
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
|
<Download size={16} className="mr-2" />
|
||||||
<Download size={16} className="mr-2" />
|
Download Recovery Key
|
||||||
Download Recovery Key
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleSkip}
|
|
||||||
disabled={downloadResticPassword.isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
Skip
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AuthLayout } from "~/client/components/auth-layout";
|
import { AuthLayout } from "~/client/components/auth-layout";
|
||||||
|
|
@ -9,15 +9,12 @@ import { Input } from "~/client/components/ui/input";
|
||||||
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp";
|
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
import { logger } from "~/client/lib/logger";
|
|
||||||
import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip";
|
|
||||||
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors";
|
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors";
|
||||||
import { PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors";
|
|
||||||
import { ResetPasswordDialog } from "../components/reset-password-dialog";
|
import { ResetPasswordDialog } from "../components/reset-password-dialog";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { normalizeUsername } from "~/lib/username";
|
import { normalizeUsername } from "~/lib/username";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { AlternativeSignInSection } from "../components/alternative-sign-in-section";
|
import { SsoLoginSection } from "~/client/modules/sso/components/sso-login-section";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
|
|
@ -34,23 +31,6 @@ type LoginPageProps = {
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PasskeySignInError = {
|
|
||||||
code?: string;
|
|
||||||
message?: string;
|
|
||||||
status?: number;
|
|
||||||
statusText?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isPasskeyVerificationFailure(error: PasskeySignInError | null) {
|
|
||||||
return error?.code === "AUTHENTICATION_FAILED" || error?.code === "UNAUTHORIZED";
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasSkippedRecoveryKeyDownload(userId: string) {
|
|
||||||
return document.cookie
|
|
||||||
.split(";")
|
|
||||||
.some((cookie) => cookie.trim() === `${RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME}=${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LoginPage({ error }: LoginPageProps = {}) {
|
export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [showResetDialog, setShowResetDialog] = useState(false);
|
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||||
|
|
@ -62,52 +42,6 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
const errorCode = decodeLoginError(error);
|
const errorCode = decodeLoginError(error);
|
||||||
const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
|
const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
|
||||||
|
|
||||||
const navigateAfterLogin = useCallback(async () => {
|
|
||||||
const session = await authClient.getSession();
|
|
||||||
|
|
||||||
if (
|
|
||||||
session.data?.user &&
|
|
||||||
!session.data.user.hasDownloadedResticPassword &&
|
|
||||||
!hasSkippedRecoveryKeyDownload(session.data.user.id)
|
|
||||||
) {
|
|
||||||
void navigate({ to: "/download-recovery-key" });
|
|
||||||
} else {
|
|
||||||
void navigate({ to: "/volumes" });
|
|
||||||
}
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const autoSignIn = async () => {
|
|
||||||
if (
|
|
||||||
typeof PublicKeyCredential === "undefined" ||
|
|
||||||
!PublicKeyCredential.isConditionalMediationAvailable ||
|
|
||||||
!(await PublicKeyCredential.isConditionalMediationAvailable())
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await authClient.signIn.passkey({
|
|
||||||
autoFill: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isPasskeyVerificationFailure(error)) {
|
|
||||||
void navigate({
|
|
||||||
to: "/login",
|
|
||||||
search: {
|
|
||||||
error: PASSKEY_LOGIN_FAILED_ERROR,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
await navigateAfterLogin();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void autoSignIn();
|
|
||||||
}, [navigate, navigateAfterLogin]);
|
|
||||||
|
|
||||||
const form = useForm<LoginFormValues>({
|
const form = useForm<LoginFormValues>({
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
|
@ -131,7 +65,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
logger.error(error);
|
console.error(error);
|
||||||
toast.error("Login failed", { description: error.message });
|
toast.error("Login failed", { description: error.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -141,7 +75,12 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await navigateAfterLogin();
|
const d = await authClient.getSession();
|
||||||
|
if (data.user && !d.data?.user.hasDownloadedResticPassword) {
|
||||||
|
void navigate({ to: "/download-recovery-key" });
|
||||||
|
} else {
|
||||||
|
void navigate({ to: "/volumes" });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVerify2FA = async () => {
|
const handleVerify2FA = async () => {
|
||||||
|
|
@ -164,7 +103,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
logger.error(error);
|
console.error(error);
|
||||||
toast.error("Verification failed", { description: error.message });
|
toast.error("Verification failed", { description: error.message });
|
||||||
setTotpCode("");
|
setTotpCode("");
|
||||||
return;
|
return;
|
||||||
|
|
@ -173,11 +112,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
if (data) {
|
if (data) {
|
||||||
toast.success("Login successful");
|
toast.success("Login successful");
|
||||||
const session = await authClient.getSession();
|
const session = await authClient.getSession();
|
||||||
if (
|
if (session.data?.user && !session.data.user.hasDownloadedResticPassword) {
|
||||||
session.data?.user &&
|
|
||||||
!session.data.user.hasDownloadedResticPassword &&
|
|
||||||
!hasSkippedRecoveryKeyDownload(session.data.user.id)
|
|
||||||
) {
|
|
||||||
void navigate({ to: "/download-recovery-key" });
|
void navigate({ to: "/download-recovery-key" });
|
||||||
} else {
|
} else {
|
||||||
void navigate({ to: "/volumes" });
|
void navigate({ to: "/volumes" });
|
||||||
|
|
@ -194,10 +129,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
|
|
||||||
if (requires2FA) {
|
if (requires2FA) {
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthLayout title="Two-Factor Authentication" description="Enter the 6-digit code from your authenticator app">
|
||||||
title="Two-Factor Authentication"
|
|
||||||
description="Enter the 6-digit code from your authenticator app"
|
|
||||||
>
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-4 flex flex-col items-center">
|
<div className="space-y-4 flex flex-col items-center">
|
||||||
<Label htmlFor="totp-code">Authentication code</Label>
|
<Label htmlFor="totp-code">Authentication code</Label>
|
||||||
|
|
@ -228,7 +160,6 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="trust-device"
|
id="trust-device"
|
||||||
aria-label="Trust this device for 30 days"
|
|
||||||
checked={trustDevice}
|
checked={trustDevice}
|
||||||
onChange={(e) => setTrustDevice(e.target.checked)}
|
onChange={(e) => setTrustDevice(e.target.checked)}
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
|
|
@ -267,11 +198,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
<AuthLayout title="Login to your account" description="Enter your credentials below to login to your account">
|
<AuthLayout title="Login to your account" description="Enter your credentials below to login to your account">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<div
|
<div className={cn("rounded-md border border-destructive/50 p-3 text-sm", { hidden: !errorDescription })}>
|
||||||
className={cn("rounded-md border border-destructive/50 p-3 text-sm", {
|
|
||||||
hidden: !errorDescription,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{errorDescription}
|
{errorDescription}
|
||||||
</div>
|
</div>
|
||||||
<FormField
|
<FormField
|
||||||
|
|
@ -281,13 +208,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Username</FormLabel>
|
<FormLabel>Username</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} type="text" placeholder="admin" disabled={isLoggingIn} />
|
||||||
{...field}
|
|
||||||
type="text"
|
|
||||||
placeholder="admin"
|
|
||||||
disabled={isLoggingIn}
|
|
||||||
autoComplete="username webauthn"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
@ -309,12 +230,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} type="password" disabled={isLoggingIn} />
|
||||||
{...field}
|
|
||||||
type="password"
|
|
||||||
disabled={isLoggingIn}
|
|
||||||
autoComplete="current-password webauthn"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
@ -326,7 +242,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<AlternativeSignInSection onPasskeySignIn={navigateAfterLogin} />
|
<SsoLoginSection />
|
||||||
|
|
||||||
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
|
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
|
||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,17 @@ import {
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "~/client/components/ui/form";
|
} from "~/client/components/ui/form";
|
||||||
|
import { authMiddleware } from "~/middleware/auth";
|
||||||
import { AuthLayout } from "~/client/components/auth-layout";
|
import { AuthLayout } from "~/client/components/auth-layout";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
import { logger } from "~/client/lib/logger";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { normalizeUsername } from "~/lib/username";
|
import { normalizeUsername } from "~/lib/username";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { inferDateTimePreferences } from "~/client/lib/datetime";
|
|
||||||
|
export const clientMiddleware = [authMiddleware];
|
||||||
|
|
||||||
const onboardingSchema = z.object({
|
const onboardingSchema = z.object({
|
||||||
username: z.string().min(2).max(30).transform(normalizeUsername),
|
username: z.string().min(2).max(30).transform(normalizeUsername),
|
||||||
|
|
@ -56,12 +57,8 @@ export function OnboardingPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { dateFormat, timeFormat } = inferDateTimePreferences(navigator.language);
|
|
||||||
|
|
||||||
const { data, error } = await authClient.signUp.email({
|
const { data, error } = await authClient.signUp.email({
|
||||||
username: normalizeUsername(values.username),
|
username: normalizeUsername(values.username),
|
||||||
dateFormat,
|
|
||||||
timeFormat,
|
|
||||||
password: values.password,
|
password: values.password,
|
||||||
email: values.email.toLowerCase().trim(),
|
email: values.email.toLowerCase().trim(),
|
||||||
name: values.username,
|
name: values.username,
|
||||||
|
|
@ -81,7 +78,7 @@ export function OnboardingPage() {
|
||||||
toast.success("Admin user created successfully!");
|
toast.success("Admin user created successfully!");
|
||||||
void navigate({ to: "/download-recovery-key" });
|
void navigate({ to: "/download-recovery-key" });
|
||||||
} else if (error) {
|
} else if (error) {
|
||||||
logger.error(error);
|
console.error(error);
|
||||||
const errorMessage = error.message ?? "Unknown error";
|
const errorMessage = error.message ?? "Unknown error";
|
||||||
toast.error("Failed to create admin user", { description: errorMessage });
|
toast.error("Failed to create admin user", { description: errorMessage });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { HttpResponse, http, server } from "~/test/msw/server";
|
|
||||||
import { cleanup, render, screen, waitFor } 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,
|
|
||||||
Link: (({ children }: { children?: ReactNode }) => <a href="/">{children}</a>) as typeof actual.Link,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("~/client/lib/datetime", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof import("~/client/lib/datetime")>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
useTimeFormat: () => ({
|
|
||||||
...actual.useTimeFormat(),
|
|
||||||
formatDateTime: () => "2026-03-26 00:00",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
import { SnapshotFileBrowser } from "../snapshot-file-browser";
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("SnapshotFileBrowser", () => {
|
|
||||||
test("uses the snapshot common ancestor as query root while keeping a broader display root", async () => {
|
|
||||||
const requests: string[] = [];
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
http.get("/api/v1/repositories/:shortId/snapshots/:snapshotId/files", ({ request }) => {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
requests.push(url.searchParams.get("path") ?? "");
|
|
||||||
|
|
||||||
return HttpResponse.json({
|
|
||||||
files: [
|
|
||||||
{ name: "subdir", path: "/mnt/project/subdir", type: "dir" },
|
|
||||||
{ name: "a.txt", path: "/mnt/project/subdir/a.txt", type: "file" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<SnapshotFileBrowser
|
|
||||||
snapshot={fromAny({
|
|
||||||
short_id: "snap-1",
|
|
||||||
time: "2026-03-26T00:00:00.000Z",
|
|
||||||
paths: ["/mnt/project/subdir/a.txt", "/mnt/project/subdir/b.txt"],
|
|
||||||
})}
|
|
||||||
repositoryId="repo-1"
|
|
||||||
backupId="backup-1"
|
|
||||||
displayBasePath="/mnt"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(requests[0]).toBe("/mnt/project/subdir");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await screen.findByRole("button", { name: "project" })).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders snapshots backed up from Windows absolute paths", async () => {
|
|
||||||
const requests: string[] = [];
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
http.get("/api/v1/repositories/:shortId/snapshots/:snapshotId/files", ({ request }) => {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
requests.push(url.searchParams.get("path") ?? "");
|
|
||||||
|
|
||||||
return HttpResponse.json({
|
|
||||||
files: [
|
|
||||||
{ name: "tmp", path: "/tmp", type: "dir" },
|
|
||||||
{ name: "source", path: "/tmp/source", type: "dir" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<SnapshotFileBrowser
|
|
||||||
snapshot={fromAny({
|
|
||||||
short_id: "snap-1",
|
|
||||||
time: "2026-04-02T00:00:00.000Z",
|
|
||||||
paths: ["d:\\some\\path"],
|
|
||||||
})}
|
|
||||||
repositoryId="repo-1"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(requests[0]).toBe("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await screen.findByRole("button", { name: "tmp" })).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
import { CalendarClock, Database, HardDrive } from "lucide-react";
|
import { CalendarClock, Database, HardDrive } from "lucide-react";
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
import { TimeAgo } from "~/client/components/time-ago";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { useTimeFormat } from "~/client/lib/datetime";
|
|
||||||
import type { BackupSchedule } from "~/client/lib/types";
|
import type { BackupSchedule } from "~/client/lib/types";
|
||||||
import { BackupStatusDot } from "./backup-status-dot";
|
import { BackupStatusDot } from "./backup-status-dot";
|
||||||
|
import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
|
export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
|
||||||
const { formatShortDateTime } = useTimeFormat();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link key={schedule.shortId} to="/backups/$backupId" params={{ backupId: schedule.shortId }}>
|
<Link key={schedule.shortId} to="/backups/$backupId" params={{ backupId: schedule.shortId }}>
|
||||||
<Card interactive key={schedule.shortId} className="flex flex-col h-full">
|
<Card interactive key={schedule.shortId} className="flex flex-col h-full">
|
||||||
|
|
@ -20,8 +17,7 @@ export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
|
||||||
</div>
|
</div>
|
||||||
<BackupStatusDot
|
<BackupStatusDot
|
||||||
enabled={schedule.enabled}
|
enabled={schedule.enabled}
|
||||||
hasError={schedule.lastBackupStatus === "error"}
|
hasError={!!schedule.lastBackupError}
|
||||||
hasWarning={schedule.lastBackupStatus === "warning"}
|
|
||||||
isInProgress={schedule.lastBackupStatus === "in_progress"}
|
isInProgress={schedule.lastBackupStatus === "in_progress"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -39,13 +35,13 @@ export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
|
||||||
<span className="text-muted-foreground shrink-0">Schedule</span>
|
<span className="text-muted-foreground shrink-0">Schedule</span>
|
||||||
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
|
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
|
||||||
<code className="text-xs text-foreground font-mono bg-muted px-2 py-1 rounded shrink-0">
|
<code className="text-xs text-foreground font-mono bg-muted px-2 py-1 rounded shrink-0">
|
||||||
{schedule.cronExpression || "Manual only"}
|
{schedule.cronExpression}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-sm gap-2">
|
<div className="flex items-center text-sm gap-2">
|
||||||
<span className="text-muted-foreground shrink-0">Last backup</span>
|
<span className="text-muted-foreground shrink-0">Last backup</span>
|
||||||
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
|
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
|
||||||
<TimeAgo date={schedule.lastBackupAt} className="text-foreground font-mono text-sm shrink-0" />
|
<span className="text-foreground font-mono text-sm shrink-0">{formatTimeAgo(schedule.lastBackupAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-sm gap-2">
|
<div className="flex items-center text-sm gap-2">
|
||||||
<span className="text-muted-foreground shrink-0">Next backup</span>
|
<span className="text-muted-foreground shrink-0">Next backup</span>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useEffect, useState } from "react";
|
||||||
import { ByteSize } from "~/client/components/bytes-size";
|
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 { Card } from "~/client/components/ui/card";
|
||||||
import { Progress } from "~/client/components/ui/progress";
|
import { Progress } from "~/client/components/ui/progress";
|
||||||
import { getBackupProgressOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { useServerEvents } from "~/client/hooks/use-server-events";
|
||||||
import type { GetBackupProgressResponse } from "~/client/api-client/types.gen";
|
import type { GetBackupProgressResponse } from "~/client/api-client/types.gen";
|
||||||
import { formatDuration } from "~/utils/utils";
|
import { formatDuration } from "~/utils/utils";
|
||||||
|
import { formatBytes } from "~/utils/format-bytes";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
scheduleShortId: string;
|
scheduleShortId: string;
|
||||||
|
|
@ -14,27 +13,39 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props) => {
|
export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props) => {
|
||||||
const formatBytes = useFormatBytes();
|
const { addEventListener } = useServerEvents();
|
||||||
const { locale } = useRootLoaderData();
|
const [progress, setProgress] = useState<GetBackupProgressResponse>(initialProgress ?? null);
|
||||||
const { data: progress } = useQuery({
|
|
||||||
...getBackupProgressOptions({ path: { shortId: scheduleShortId } }),
|
|
||||||
initialData: initialProgress,
|
|
||||||
refetchInterval: 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
useEffect(() => {
|
||||||
percent_done = 0,
|
const abortController = new AbortController();
|
||||||
bytes_done = 0,
|
|
||||||
total_bytes = 0,
|
|
||||||
seconds_elapsed = 0,
|
|
||||||
files_done = 0,
|
|
||||||
total_files = 0,
|
|
||||||
} = progress ?? {};
|
|
||||||
|
|
||||||
const percentDone = progress ? Math.round(percent_done * 100) : 0;
|
addEventListener(
|
||||||
|
"backup:progress",
|
||||||
|
(progressData) => {
|
||||||
|
if (progressData.scheduleId === scheduleShortId) {
|
||||||
|
setProgress(progressData);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ signal: abortController.signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
addEventListener(
|
||||||
|
"backup:completed",
|
||||||
|
(completedData) => {
|
||||||
|
if (completedData.scheduleId === scheduleShortId) {
|
||||||
|
setProgress(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ signal: abortController.signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [addEventListener, scheduleShortId]);
|
||||||
|
|
||||||
|
const percentDone = progress ? Math.round(progress.percent_done * 100) : 0;
|
||||||
const currentFile = progress?.current_files?.[0] || "";
|
const currentFile = progress?.current_files?.[0] || "";
|
||||||
const fileName = currentFile.split("/").pop() || currentFile;
|
const fileName = currentFile.split("/").pop() || currentFile;
|
||||||
const speed = progress ? formatBytes(bytes_done / seconds_elapsed) : null;
|
const speed = progress ? formatBytes(progress.bytes_done / progress.seconds_elapsed) : null;
|
||||||
const eta = progress?.seconds_remaining ? formatDuration(progress.seconds_remaining) : null;
|
const eta = progress?.seconds_remaining ? formatDuration(progress.seconds_remaining) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -55,7 +66,7 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props)
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{progress ? (
|
{progress ? (
|
||||||
<>
|
<>
|
||||||
{files_done.toLocaleString(locale)} / {total_files.toLocaleString(locale)}
|
{progress.files_done.toLocaleString()} / {progress.total_files.toLocaleString()}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
|
|
@ -67,9 +78,9 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props)
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{progress ? (
|
{progress ? (
|
||||||
<>
|
<>
|
||||||
<ByteSize bytes={bytes_done} base={1024} />
|
<ByteSize bytes={progress.bytes_done} base={1024} />
|
||||||
/
|
/
|
||||||
<ByteSize bytes={total_bytes} base={1024} />
|
<ByteSize bytes={progress.total_bytes} base={1024} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
|
|
@ -78,12 +89,12 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props)
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase text-muted-foreground">Elapsed</p>
|
<p className="text-xs uppercase text-muted-foreground">Elapsed</p>
|
||||||
<p className="font-medium">{progress ? formatDuration(seconds_elapsed) : "—"}</p>
|
<p className="font-medium">{progress ? formatDuration(progress.seconds_elapsed) : "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase text-muted-foreground">Speed</p>
|
<p className="text-xs uppercase text-muted-foreground">Speed</p>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{progress ? (seconds_elapsed > 0 ? `${speed?.text} ${speed?.unit}/s` : "Calculating...") : "—"}
|
{progress ? (progress.seconds_elapsed > 0 ? `${speed?.text} ${speed?.unit}/s` : "Calculating...") : "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,13 @@ import { StatusDot } from "~/client/components/status-dot";
|
||||||
export const BackupStatusDot = ({
|
export const BackupStatusDot = ({
|
||||||
enabled,
|
enabled,
|
||||||
hasError,
|
hasError,
|
||||||
hasWarning,
|
|
||||||
isInProgress,
|
isInProgress,
|
||||||
}: {
|
}: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
hasError?: boolean;
|
hasError?: boolean;
|
||||||
hasWarning?: boolean;
|
|
||||||
isInProgress?: boolean;
|
isInProgress?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
let variant: "success" | "neutral" | "error" | "warning" | "info";
|
let variant: "success" | "neutral" | "error" | "info";
|
||||||
let label: string;
|
let label: string;
|
||||||
|
|
||||||
if (isInProgress) {
|
if (isInProgress) {
|
||||||
|
|
@ -20,9 +18,6 @@ export const BackupStatusDot = ({
|
||||||
} else if (hasError) {
|
} else if (hasError) {
|
||||||
variant = "error";
|
variant = "error";
|
||||||
label = "Error";
|
label = "Error";
|
||||||
} else if (hasWarning) {
|
|
||||||
variant = "warning";
|
|
||||||
label = "Warning";
|
|
||||||
} else if (enabled) {
|
} else if (enabled) {
|
||||||
variant = "success";
|
variant = "success";
|
||||||
label = "Active";
|
label = "Active";
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue