Compare commits

..

11 commits

Author SHA1 Message Date
Nicolas Meienberger
d5021566ac refactor(backups): split into helpers 2026-04-01 23:24:51 +02:00
Nicolas Meienberger
af4ac1c39c refactor: simplify singleton pattern 2026-04-01 23:11:51 +02:00
Nicolas Meienberger
23a2a168be fix: cancel started backups when agent disconnects 2026-04-01 22:06:08 +02:00
Nicolas Meienberger
f965348d02 fix: restore repo lock during agent backups 2026-04-01 21:51:39 +02:00
Nicolas Meienberger
296e13ba1f refactor: split commands in separate files 2026-04-02 23:17:06 +02:00
Nicolas Meienberger
5eb1d2b727 feat: backup through agent
wip
2026-04-02 23:12:51 +02:00
Nicolas Meienberger
e168a8ddf5 revert: remove db agents for now 2026-04-02 22:59:56 +02:00
Nicolas Meienberger
7cfe3b3ebe refactor: effect 2026-04-02 22:59:56 +02:00
Nicolas Meienberger
51bc6fefb4 feat: agent token 2026-04-02 22:59:56 +02:00
Nicolas Meienberger
136279a25d chore: init contracts package
chore: contracts package

feat: agent package
2026-04-02 22:59:56 +02:00
Nicolas Meienberger
4991d3e2ba feat: init agent/controller minimal 2026-04-02 22:59:56 +02:00
528 changed files with 7295 additions and 56352 deletions

View file

@ -3,7 +3,6 @@
!bun.lock
!package.json
!.gitignore
!bunfig.toml
!**/package.json
!**/bun.lock
@ -24,4 +23,3 @@
node_modules/**
dist/**
.output/**
app/test/integration/artifacts/**

View file

@ -4,6 +4,6 @@ RESTIC_CACHE_DIR=./data/restic/cache
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
ZEROBYTE_VOLUMES_DIR=./data/volumes
APP_SECRET=<openssl rand -hex 32>
BASE_URL=http://*.localhost
BASE_URL=http://localhost:300
TRUST_PROXY=false
PORT=3000

View file

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

View file

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

View file

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

View file

@ -3,13 +3,7 @@
"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"],

View file

@ -20,23 +20,25 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Install dependencies
uses: "./.github/actions/install-dependencies"
- name: Run lint
shell: bash
run: vp lint --type-aware --deny-warnings
- uses: oxc-project/oxlint-action@latest
with:
config: .oxlintrc.json
deny-warnings: true
version: 1.56.0
typecheck:
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v6
with:
fetch-depth: 1
@ -52,7 +54,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v6
with:
fetch-depth: 1
@ -68,7 +70,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v6
with:
fetch-depth: 1
@ -84,7 +86,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v6
with:
fetch-depth: 1

View file

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

View file

@ -12,7 +12,7 @@ jobs:
timeout-minutes: 20
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Install dependencies
uses: "./.github/actions/install-dependencies"
@ -26,7 +26,7 @@ jobs:
- name: Cache Playwright Browsers
id: playwright-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
@ -46,14 +46,12 @@ jobs:
mkdir -p data
mkdir -p playwright/temp
mkdir -p playwright/data
mkdir -p playwright/tinyauth/app-data
mkdir -p playwright/tinyauth/caddy-data
chmod -R 777 playwright/temp
touch .env.local
echo "SERVER_IP=localhost" >> .env.local
echo "ZEROBYTE_DATABASE_URL=./data/zerobyte.db" >> .env.local
echo "BASE_URL=http://localhost:4096" >> .env.local
echo "E2E_TINYAUTH_ORIGIN=https://tinyauth.example.com:5557" >> .env.local
echo "E2E_DEX_ORIGIN=http://dex:5557" >> .env.local
echo "TZ=Europe/Zurich" >> .env.local
- name: Start zerobyte-e2e service
@ -70,13 +68,13 @@ jobs:
run: |
timeout 30s bash -c 'until curl -f http://localhost:4096/api/healthcheck; do echo "Waiting for server..." && sleep 2; done'
- name: Wait for Tinyauth to be ready
- name: Wait for Dex to be ready
run: |
timeout 30s bash -c 'until curl -skf https://localhost:5557/.well-known/openid-configuration; do echo "Waiting for Tinyauth..." && sleep 2; done'
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
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
run: sudo chmod 777 playwright/data/zerobyte.db
@ -87,16 +85,16 @@ jobs:
- name: Dump container logs in playwright-report folder
if: always()
run: |
tree playwright || true
tree playwright
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
if: failure()
run: |
docker exec zerobyte /bin/ash -c "ls -la /test-data" || true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
- uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report

View file

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

View file

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

View file

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

View file

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

View file

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

8
.gitignore vendored
View file

@ -15,7 +15,7 @@ notes.md
smb-password.txt
cache.db
/data
data/
.env*
!.env.example
@ -29,16 +29,11 @@ node_modules/
/playwright/.cache/
/playwright/.auth/
/playwright/*.pass
/playwright/tinyauth/app-data/
/playwright/tinyauth/caddy-data/
playwright/.auth
playwright/temp
playwright/data
.idea/
.deepsec/
.codex/
# OpenAPI error logs
openapi-ts-error-*.log
@ -49,4 +44,3 @@ qa-output
.worktrees/
.turbo
.superset
.fallow

7
.oxfmtrc.json Normal file
View 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
View 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"
}
}
]
}

View file

@ -1 +0,0 @@
vp staged

View file

@ -2,7 +2,7 @@
- Never create migration files manually. Always use the provided command to generate migrations
- If you realize an automated migration is incorrect, make sure to remove all the associated entries from the `_journal.json` and the newly created files located in `app/drizzle/` before re-generating the migration
- The dev server runs through Portless. Start it with `bun run dev`, then use `portless get zerobyte` to get the current worktree-specific URL. Do not assume a fixed port like `3000` or `4096`. Username is `admin` and password is `password`
- The dev server is running at http://localhost:3000. Username is `admin` and password is `password`
- The repo is https://github.com/nicotsx/zerobyte
- If you need to run a specific restic command on a repository, you can open and use the dev panel with `Meta+Shift+D`
@ -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).
### Development Server
```bash
# Start the dev server through Portless
bun run dev
# Get the current app URL for this worktree
portless get zerobyte
# Inspect active Portless routes if needed
portless list
```
Portless applies git worktree prefixes automatically, so linked worktrees may return URLs like `https://branch-name.zerobyte.localhost`. Use the Portless URL for browser testing and manual verification.
### Type Checking
```bash
@ -70,7 +55,7 @@ bun run gen:api-client
```bash
# Format
vp fmt <path> --write
bunx oxfmt format --write <path>
# Lint
bun run lint

View file

@ -1,8 +1,10 @@
FROM oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0 AS base
ARG BUN_VERSION="1.3.11"
FROM oven/bun:${BUN_VERSION}-alpine AS base
ARG RESTIC_VERSION="0.18.1"
ARG RCLONE_VERSION="1.74.2"
ARG SHOUTRRR_VERSION="0.15.1"
ARG RCLONE_VERSION="1.73.3"
ARG SHOUTRRR_VERSION="0.14.2"
ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
VITE_RCLONE_VERSION=${RCLONE_VERSION} \
@ -10,7 +12,7 @@ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
RUN apk update --no-cache && \
apk upgrade --no-cache && \
apk add --no-cache acl attr cifs-utils davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata
apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata
ENTRYPOINT ["/sbin/tini", "-s", "--"]
@ -44,15 +46,6 @@ RUN bzip2 -d restic.bz2 && chmod +x restic
RUN mv rclone-v*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone
RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr
# ------------------------------
# RUNTIME TOOLS
# ------------------------------
FROM base AS runtime-tools
COPY --from=deps /deps/restic /usr/local/bin/restic
COPY --from=deps /deps/rclone /usr/local/bin/rclone
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
# ------------------------------
# DEVELOPMENT
# ------------------------------
@ -73,9 +66,8 @@ COPY ./package.json ./bun.lock ./
COPY ./packages/core/package.json ./packages/core/package.json
COPY ./packages/contracts/package.json ./packages/contracts/package.json
COPY ./apps/agent/package.json ./apps/agent/package.json
COPY ./apps/docs/package.json ./apps/docs/package.json
RUN VITE_GIT_HOOKS=0 bun install --frozen-lockfile --ignore-scripts
RUN bun install --frozen-lockfile --ignore-scripts
COPY . .
@ -98,9 +90,7 @@ COPY ./package.json ./bun.lock ./
COPY ./packages/core/package.json ./packages/core/package.json
COPY ./packages/contracts/package.json ./packages/contracts/package.json
COPY ./apps/agent/package.json ./apps/agent/package.json
COPY ./apps/docs/package.json ./apps/docs/package.json
RUN VITE_GIT_HOOKS=0 bun install --frozen-lockfile
RUN bun install --frozen-lockfile
COPY . .

143
README.md
View file

@ -15,33 +15,23 @@
</figure>
</div>
#### Join the community
[![Discord](https://img.shields.io/discord/1466834119873925263?label=discord&logo=discord)](https://discord.gg/XjgVyXrcEH)
> [!WARNING]
> Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedback. Please open issues for bugs or feature requests.
> Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedbacks. Expect bugs! Please open issues or feature requests
<p align="center">
<a href="https://www.buymeacoffee.com/nicotsx" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
</p>
## Introduction
## Intro
Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage.
## Documentation
The official documentation website is available at [zerobyte.app](https://zerobyte.app).
It contains up-to-date setup guides, configuration reference, and usage documentation for running Zerobyte in production.
### Features
- **Automated backups** with encryption, compression, and retention policies, powered by Restic
- **Flexible scheduling** for automated backup jobs with fine-grained retention policies
- **End-to-end encryption** will ensure your data is always protected
- **Multi-protocol support** for backup from NFS, SMB, WebDAV, SFTP, or local directories
- &nbsp; **Automated backups** with encryption, compression and retention policies powered by Restic
- &nbsp; **Flexible scheduling** For automated backup jobs with fine-grained retention policies
- &nbsp; **End-to-end encryption** ensuring your data is always protected
- &nbsp; **Multi-protocol support**: Backup from NFS, SMB, WebDAV, SFTP, or local directories
## Installation
@ -50,7 +40,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
```yaml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.38
image: ghcr.io/nicotsx/zerobyte:v0.32
container_name: zerobyte
restart: unless-stopped
cap_add:
@ -69,10 +59,10 @@ services:
```
> [!WARNING]
> It is highly discouraged to run Zerobyte on a server that is accessible from the internet (VPS or home server with port forwarding). If you do, make sure to change the port mapping to "127.0.0.1:4096:4096" and use a secure tunnel (SSH tunnel, Cloudflare Tunnel, etc.) with authentication.
> It is highly discouraged to run Zerobyte on a server that is accessible from the internet (VPS or home server with port forwarding) If you do, make sure to change the port mapping to "127.0.0.1:4096:4096" and use a secure tunnel (SSH tunnel, Cloudflare Tunnel, etc.) with authentication.
> [!WARNING]
> Do not try to point `/var/lib/zerobyte` to a network share. You will face permission issues and strong performance degradation.
> Do not try to point `/var/lib/zerobyte` on a network share. You will face permission issues and strong performance degradation.
> [!NOTE]
> **TrueNAS Users:** The host path `/var/lib` is ephemeral on TrueNAS and will be reset during system upgrades. Instead of using `/var/lib/zerobyte:/var/lib/zerobyte`, create a dedicated ZFS dataset (e.g., `tank/docker/zerobyte`) and mount it instead:
@ -97,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:
### Environment variables
### Environment Variables
| Variable | Description | Default |
| :------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- |
| `BASE_URL` | **Required.** The base URL of your Zerobyte instance (e.g., `https://zerobyte.example.com`). See [Authentication](#authentication) below. | (none) |
| `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) |
| `APP_SECRET_FILE` | Path to a file containing `APP_SECRET`, useful with Docker or Kubernetes secrets. Mutually exclusive with `APP_SECRET`. | (none) |
| `PORT` | The port the web interface and API will listen on. | `4096` |
| `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` |
| `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` |
| `TRUST_PROXY` | When `true`, trust an existing `X-Forwarded-For` header from your reverse proxy. Leave `false` for direct deployments. | `false` |
| `TRUSTED_ORIGINS` | Comma-separated list of extra trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (none) |
| `WEBHOOK_ALLOWED_ORIGINS` | Comma-separated list of HTTP origins allowed for backup webhooks and outbound HTTP notification destinations. | (none) |
| `WEBHOOK_TIMEOUT` | Timeout for backup webhook requests in seconds. | `60` |
| `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` |
| `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` |
| `RCLONE_CONFIG_DIR` | Path to the directory containing `rclone.conf` inside the container. Change this if running as a non-root user. | `/root/.config/rclone` |
| `PROVISIONING_PATH` | Path to a JSON file with operator-managed repositories and volumes to sync at startup. | (none) |
| Variable | Description | Default |
| :-------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- |
| `BASE_URL` | **Required.** The base URL of your Zerobyte instance (e.g., `https://zerobyte.example.com`). See [Authentication](#authentication) below. | (none) |
| `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) |
| `PORT` | The port the web interface and API will listen on. | `4096` |
| `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` |
| `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` |
| `TRUST_PROXY` | When `true`, trust an existing `X-Forwarded-For` header from your reverse proxy. Leave `false` for direct deployments. | `false` |
| `TRUSTED_ORIGINS` | Comma-separated list of extra trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (none) |
| `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` |
| `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` |
| `RCLONE_CONFIG_DIR` | Path to the rclone config directory inside the container. Change this if running as a non-root user. | `/root/.config/rclone` |
| `PROVISIONING_PATH` | Path to a JSON file with operator-managed repositories and volumes to sync at startup. | (none) |
### Webhook and notification network policy
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
### 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.
@ -145,18 +112,16 @@ Provisioned resources:
- appear in the normal repositories and volumes screens
- can resolve credential fields from environment variables or `/run/secrets/*` during startup sync
The complete provisioning documentation is available at [zerobyte.app/docs/guides/provisioning](https://zerobyte.app/docs/guides/provisioning).
See `examples/provisioned-resources/README.md` for a full example.
### Simplified setup (no remote mounts)
### Simplified setup (No remote mounts)
If you only need to back up locally-mounted folders and don't require remote share mounting capabilities, you can remove the `SYS_ADMIN` capability and FUSE device from your `docker-compose.yml`:
If you only need to back up locally mounted folders and don't require remote share mounting capabilities, you can remove the `SYS_ADMIN` capability and FUSE device from your `docker-compose.yml`:
```yaml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.38
image: ghcr.io/nicotsx/zerobyte:v0.32
container_name: zerobyte
restart: unless-stopped
ports:
@ -174,8 +139,8 @@ services:
**Trade-offs:**
- ✅ Improved security by reducing container capabilities
- ✅ Support for local directories as backup sources
- ✅ Support all repository types, local and remote (S3, GCS, Azure, rclone)
- ✅ Support for local directories
- ✅ Keep support all repository types (local, S3, GCS, Azure, rclone)
- ❌ Cannot mount NFS, SMB, WebDAV, or SFTP shares directly from Zerobyte
If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`.
@ -190,12 +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.
If you want to backup a local directory on the same host where Zerobyte is running, you'll first need to mount that directory into the Zerobyte container. You can do this by adding a volume mapping in your `docker-compose.yml` file. For example, to mount `/path/to/your/directory` from the host to `/mydata` in the container, you would add the following line under the `volumes` section:
If you want to track a local directory on the same server where Zerobyte is running, you'll first need to mount that directory into the Zerobyte container. You can do this by adding a volume mapping in your `docker-compose.yml` file. For example, to mount `/path/to/your/directory` from the host to `/mydata` in the container, you would add the following line under the `volumes` section:
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.38
image: ghcr.io/nicotsx/zerobyte:v0.32
container_name: zerobyte
restart: unless-stopped
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.
![Add a new volume UI](https://github.com/nicotsx/zerobyte/blob/main/screenshots/add-volume.png?raw=true)
![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/add-volume.png?raw=true)
## Creating a repository
A repository is where your backups will be securely stored encrypted. Zerobyte supports multiple storage backends for your backup repositories:
- **Local directories**: Store backups on local disk subfolders in `/var/lib/zerobyte/repositories/` or any other (mounted) path
- **S3-compatible storage**: Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
- **Google Cloud Storage**: Google's cloud storage service
- **Azure Blob Storage**: Microsoft Azure storage
- **rclone remotes**: 40+ cloud storage providers via rclone (see below)
- **Local directories** - Store backups on local disk subfolder of `/var/lib/zerobyte/repositories/` or any other (mounted) path
- **S3-compatible storage** - Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
- **Google Cloud Storage** - Google's cloud storage service
- **Azure Blob Storage** - Microsoft Azure storage
- **rclone remotes** - 40+ cloud storage providers via rclone (see below)
Repositories are optimized for storage efficiency and data integrity, leveraging Restic's deduplication and encryption features.
@ -270,7 +235,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.38
image: ghcr.io/nicotsx/zerobyte:v0.32
container_name: zerobyte
restart: unless-stopped
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
```
> **Note for non-root users:** If your container runs as a different user (e.g., TrueNAS apps), mount your config to the appropriate location and set `RCLONE_CONFIG_DIR`:
> **Note for non-root users:** If your container runs as a different user (e.g., TrueNAS apps), mount your config to the appropriate location and set `RCLONE_CONF_DIR`:
>
> ```yaml
> environment:
@ -324,30 +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.
You can monitor the progress and status of your backup jobs in the "Backups" section of the web interface.
![Backups UI](https://github.com/nicotsx/zerobyte/blob/main/screenshots/backups-list.png?raw=true)
![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/backups-list.png?raw=true)
## Restoring data
Zerobyte allows you to easily restore your data from backups. To restore data, navigate to the "Backups" section and select the backup job from which you want to restore data. You can then choose a specific backup snapshot and select the files or directories you wish to restore. The data you select will be restored to their original location.
![Restore UI](https://github.com/nicotsx/zerobyte/blob/main/screenshots/restoring.png?raw=true)
![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/restoring.png?raw=true)
## Authentication
Zerobyte uses [better-auth](https://github.com/better-auth/better-auth) for authentication and session management. The authentication system automatically adapts to your deployment scenario:
### Users and roles
### Cookie Security
Zerobyte does not currently provide fine-grained RBAC for backup operations. Authenticated organization members are trusted operators for repositories, volumes, backup schedules, restores, and notification destinations in their organization.
- **IP Address / HTTP access**: Set `BASE_URL=http://192.168.1.50:4096` (or your IP). Cookies will use `Secure: false`, allowing immediate login without SSL.
- **Domain / HTTPS access**: Set `BASE_URL=https://zerobyte.example.com`. Cookies will automatically use `Secure: true` for protected sessions.
Organization roles mainly restrict organization-management and instance-management actions. For example, normal members cannot manage members, SSO settings, invitations, registration, or global users, but they can still operate backup resources. Only add users to an organization if you are comfortable with them using the storage, volume, and notification capabilities configured for the instance.
### Cookie security
- **IP Address/HTTP access**: Set `BASE_URL=http://192.168.1.50:4096` (or your IP). Cookies will use `Secure: false`, allowing immediate login without SSL.
- **Domain/HTTPS access**: Set `BASE_URL=https://zerobyte.example.com`. Cookies will automatically use `Secure: true` for protected sessions.
### Reverse proxy setup
### Reverse Proxy Setup
If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.):
@ -355,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
3. Ensure your proxy passes the `X-Forwarded-Proto` header
### Important notes
### Important Notes
- The `BASE_URL` must start with `https://` for secure cookies to be enabled
- Local IP addresses (e.g., `http://192.168.x.x`) are **not** treated as secure contexts by browsers, so secure cookies are disabled automatically
@ -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.
## Third-party software
## Third-Party Software
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)
After installing Vite+ globally, you can run Zerobyte locally during development without Docker:
You can run Zerobyte locally during development without Docker:
```bash
vp install
bun install
bun run dev
```
@ -404,10 +363,10 @@ RESTIC_PASS_FILE=./data/restic.pass
RESTIC_CACHE_DIR=./data/restic/cache
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
ZEROBYTE_VOLUMES_DIR=./data/volumes
BASE_URL=https://*.localhost
BASE_URL=http://localhost:4096
```
Notes:
- Remote mount backends (NFS/SMB/WebDAV/SFTP) rely on Linux mount tooling and `CAP_SYS_ADMIN`; on macOS they are expected to be unavailable.
- To actually run backups/repository checks, install `restic` on your machine (e.g., via Homebrew). If `restic` is not installed, the app still starts but backup operations will fail with a clear error.
- To actually run backups/repository checks, install `restic` on your machine (e.g. via Homebrew). If `restic` is not installed, the app still starts but backup operations will fail with a clear error.

View file

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

View file

@ -283,8 +283,9 @@ If you're experiencing rclone issues, verify all of the following:
**Diagnosis:**
```bash
# Check which config file rclone will use inside the container
docker exec zerobyte sh -lc 'echo HOME=$HOME; rclone config file'
# Check if config is accessible inside container
docker exec zerobyte ls -la /root/.config/rclone/
docker exec zerobyte cat /root/.config/rclone/rclone.conf
```
**Solutions:**

View file

@ -4,8 +4,8 @@
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
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 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 { 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, 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> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
@ -1098,41 +1098,6 @@ export const updateScheduleMirrorsMutation = (options?: Partial<Options<UpdateSc
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);
/**

View file

@ -32,14 +32,7 @@ export const createClient = (config: Config = {}): Client => {
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
const beforeRequest = async <
TData = unknown,
TResponseStyle extends 'data' | 'fields' = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
>(
options: RequestOptions<TData, TResponseStyle, ThrowOnError, Url>,
) => {
const beforeRequest = async (options: RequestOptions) => {
const opts = {
..._config,
...options,
@ -49,7 +42,10 @@ export const createClient = (config: Config = {}): Client => {
};
if (opts.security) {
await setAuthParams(opts);
await setAuthParams({
...opts,
security: opts.security,
});
}
if (opts.requestValidator) {
@ -65,162 +61,178 @@ export const createClient = (config: Config = {}): Client => {
opts.headers.delete('Content-Type');
}
const resolvedOpts = opts as typeof opts &
ResolvedRequestOptions<TResponseStyle, ThrowOnError, Url>;
const url = buildUrl(resolvedOpts);
const url = buildUrl(opts);
return { opts: resolvedOpts, url };
return { opts, url };
};
const request: Client['request'] = async (options) => {
const throwOnError = options.throwOnError ?? _config.throwOnError;
const responseStyle = options.responseStyle ?? _config.responseStyle;
// @ts-expect-error
const { opts, url } = await beforeRequest(options);
const requestInit: ReqInit = {
redirect: 'follow',
...opts,
body: getValidRequestBody(opts),
};
let request: Request | undefined;
let response: Response | undefined;
let request = new Request(url, requestInit);
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 {
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);
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) {
// Handle fetch exceptions (AbortError, network errors, etc.)
let finalError = error;
for (const fn of interceptors.error.fns) {
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;
}
// TODO: we probably want to return error and improve types
return responseStyle === 'data'
// Return error response
return opts.responseStyle === 'data'
? undefined
: {
error: finalError,
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) =>
@ -231,6 +243,7 @@ export const createClient = (config: Config = {}): Client => {
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method,
onRequest: async (url, init) => {
let request = new Request(url, init);

View file

@ -94,7 +94,6 @@ export interface ResolvedRequestOptions<
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
headers: Headers;
serializedBody?: string;
}
@ -128,10 +127,8 @@ export type RequestResult<
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
}
) & {
/** request may be undefined, because error may be from building the request object itself */
request?: Request;
/** response may be undefined, because error may be from building the request object itself or from a network error */
response?: Response;
request: Request;
response: Response;
}
>;
@ -156,7 +153,7 @@ type SseFn = <
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<never, TResponseStyle, ThrowOnError>, 'method'>,
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
) => Promise<ServerSentEventsResult<TData, TError>>;
type RequestFn = <

View file

@ -119,12 +119,14 @@ const checkForExistence = (
return false;
};
export async function setAuthParams(
options: Pick<RequestOptions, 'auth' | 'query' | 'security'> & {
export const setAuthParams = async ({
security,
...options
}: Pick<Required<RequestOptions>, 'security'> &
Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers;
},
): Promise<void> {
for (const auth of options.security ?? []) {
}) => {
for (const auth of security) {
if (checkForExistence(options, auth.name)) {
continue;
}
@ -153,7 +155,7 @@ export async function setAuthParams(
break;
}
}
}
};
export const buildUrl: Client['buildUrl'] = (options) =>
getUrl({
@ -203,7 +205,7 @@ export const mergeHeaders = (
mergedHeaders.append(key, v as string);
}
} 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'
mergedHeaders.set(
key,
@ -217,10 +219,8 @@ export const mergeHeaders = (
type ErrInterceptor<Err, Res, Req, Options> = (
error: Err,
/** response may be undefined due to a network error where no response object is produced */
response: Res | undefined,
/** request may be undefined, because error may be from building the request object itself */
request: Req | undefined,
response: Res,
request: Req,
options: Options,
) => Err | Promise<Err>;

View file

@ -80,7 +80,7 @@ export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unkn
>;
};
export function createSseClient<TData = unknown>({
export const createSseClient = <TData = unknown>({
onRequest,
onSseError,
onSseEvent,
@ -92,7 +92,7 @@ export function createSseClient<TData = unknown>({
sseSleepFn,
url,
...options
}: ServerSentEventsOptions): ServerSentEventsResult<TData> {
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
let lastEventId: string | undefined;
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();
if (done) break;
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');
buffer = chunks.pop() ?? '';
@ -240,4 +241,4 @@ export function createSseClient<TData = unknown>({
const stream = createStream();
return { stream };
}
};

View file

@ -81,7 +81,7 @@ export interface Config {
requestValidator?: (data: unknown) => Promise<unknown>;
/**
* A function transforming response data before it's returned. This is useful
* for post-processing data, e.g., converting ISO strings into Date objects.
* for post-processing data, e.g. converting ISO strings into Date objects.
*/
responseTransformer?: (data: unknown) => Promise<unknown>;
/**

View file

@ -127,7 +127,7 @@ export function getValidRequestBody(options: {
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;
}

View file

@ -26,7 +26,6 @@ export {
getBackupScheduleForVolume,
getDevPanel,
getMirrorCompatibility,
getMirrorSyncStatus,
getNotificationDestination,
getOrgMembers,
getPublicSsoProviders,
@ -63,7 +62,6 @@ export {
setRegistrationStatus,
startDoctor,
stopBackup,
syncMirror,
tagSnapshots,
testConnection,
testNotificationDestination,
@ -155,9 +153,6 @@ export type {
GetMirrorCompatibilityData,
GetMirrorCompatibilityResponse,
GetMirrorCompatibilityResponses,
GetMirrorSyncStatusData,
GetMirrorSyncStatusResponse,
GetMirrorSyncStatusResponses,
GetNotificationDestinationData,
GetNotificationDestinationErrors,
GetNotificationDestinationResponse,
@ -268,10 +263,6 @@ export type {
StopBackupErrors,
StopBackupResponse,
StopBackupResponses,
SyncMirrorData,
SyncMirrorErrors,
SyncMirrorResponse,
SyncMirrorResponses,
TagSnapshotsData,
TagSnapshotsResponse,
TagSnapshotsResponses,

View file

@ -3,9 +3,9 @@
import type { Client, Options as Options2, TDataShape } from './client';
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
* 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)
*/
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',
...options,
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
*/

View file

@ -337,7 +337,14 @@ export type ListVolumesResponses = {
200: Array<{
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -352,7 +359,6 @@ export type ListVolumesResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -386,15 +392,7 @@ export type ListVolumesResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
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;
}>;
};
@ -418,7 +416,6 @@ export type CreateVolumeData = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -452,7 +449,6 @@ export type CreateVolumeData = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
};
path?: never;
@ -467,7 +463,14 @@ export type CreateVolumeResponses = {
201: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -482,7 +485,6 @@ export type CreateVolumeResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -516,15 +518,7 @@ export type CreateVolumeResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
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;
};
};
@ -547,7 +541,6 @@ export type TestConnectionData = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -581,7 +574,6 @@ export type TestConnectionData = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
};
path?: never;
@ -645,7 +637,14 @@ export type GetVolumeResponses = {
volume: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -660,7 +659,6 @@ export type GetVolumeResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -694,21 +692,13 @@ export type GetVolumeResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
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;
};
statfs: {
total?: number;
used?: number;
free?: number;
total: number;
used: number;
free: number;
};
};
};
@ -733,7 +723,6 @@ export type UpdateVolumeData = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -767,7 +756,6 @@ export type UpdateVolumeData = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
};
path: {
@ -791,7 +779,14 @@ export type UpdateVolumeResponses = {
200: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -806,7 +801,6 @@ export type UpdateVolumeResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -840,15 +834,7 @@ export type UpdateVolumeResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
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;
};
};
@ -869,8 +855,8 @@ export type MountVolumeResponses = {
* Volume mounted successfully
*/
200: {
status: 'mounted' | 'unmounted' | 'error';
error?: string;
status: 'mounted' | 'unmounted' | 'error';
};
};
@ -890,8 +876,8 @@ export type UnmountVolumeResponses = {
* Volume unmounted successfully
*/
200: {
status: 'mounted' | 'unmounted' | 'error';
error?: string;
status: 'mounted' | 'unmounted' | 'error';
};
};
@ -918,8 +904,8 @@ export type HealthCheckVolumeResponses = {
* Volume health check result
*/
200: {
status: 'mounted' | 'unmounted' | 'error';
error?: string;
status: 'mounted' | 'unmounted' | 'error';
};
};
@ -946,7 +932,7 @@ export type ListFilesResponses = {
files: Array<{
name: string;
path: string;
type: 'directory' | 'file';
type: 'file' | 'directory';
size?: number;
modifiedAt?: number;
}>;
@ -980,8 +966,8 @@ export type BrowseFilesystemResponses = {
directories: Array<{
name: string;
path: string;
type: 'directory';
size?: unknown;
type: 'file' | 'directory';
size?: number;
modifiedAt?: number;
}>;
path: string;
@ -1150,7 +1136,6 @@ export type ListRepositoriesResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -1334,7 +1319,6 @@ export type CreateRepositoryData = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -1573,7 +1557,6 @@ export type GetRepositoryResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -1757,7 +1740,6 @@ export type UpdateRepositoryData = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -1949,7 +1931,6 @@ export type UpdateRepositoryResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -2267,7 +2248,6 @@ export type RestoreSnapshotData = {
excludeXattr?: Array<string>;
delete?: boolean;
targetPath?: string;
targetAgentId?: string;
overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never';
};
path: {
@ -2279,11 +2259,13 @@ export type RestoreSnapshotData = {
export type RestoreSnapshotResponses = {
/**
* Snapshot restore started
* Snapshot restored successfully
*/
202: {
restoreId: string;
status: 'started';
200: {
success: boolean;
message: string;
filesRestored: number;
filesSkipped: number;
};
};
@ -2452,20 +2434,6 @@ export type ListBackupSchedulesResponses = {
includePatterns: Array<string> | null;
oneFileSystem: boolean;
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;
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
lastBackupError: string | null;
@ -2475,7 +2443,14 @@ export type ListBackupSchedulesResponses = {
volume: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -2490,7 +2465,6 @@ export type ListBackupSchedulesResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -2524,15 +2498,7 @@ export type ListBackupSchedulesResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
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;
};
repository: {
@ -2684,7 +2650,6 @@ export type ListBackupSchedulesResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -2745,20 +2710,6 @@ export type CreateBackupScheduleData = {
oneFileSystem?: boolean;
tags?: 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;
query?: never;
@ -2792,20 +2743,6 @@ export type CreateBackupScheduleResponses = {
includePatterns: Array<string> | null;
oneFileSystem: boolean;
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;
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
lastBackupError: string | null;
@ -2873,20 +2810,6 @@ export type GetBackupScheduleResponses = {
includePatterns: Array<string> | null;
oneFileSystem: boolean;
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;
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
lastBackupError: string | null;
@ -2896,7 +2819,14 @@ export type GetBackupScheduleResponses = {
volume: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -2911,7 +2841,6 @@ export type GetBackupScheduleResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -2945,15 +2874,7 @@ export type GetBackupScheduleResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
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;
};
repository: {
@ -3105,7 +3026,6 @@ export type GetBackupScheduleResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -3165,20 +3085,6 @@ export type UpdateBackupScheduleData = {
oneFileSystem?: boolean;
tags?: 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: {
shortId: string;
@ -3214,20 +3120,6 @@ export type UpdateBackupScheduleResponses = {
includePatterns: Array<string> | null;
oneFileSystem: boolean;
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;
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
lastBackupError: string | null;
@ -3275,20 +3167,6 @@ export type GetBackupScheduleForVolumeResponses = {
includePatterns: Array<string> | null;
oneFileSystem: boolean;
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;
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
lastBackupError: string | null;
@ -3298,7 +3176,14 @@ export type GetBackupScheduleForVolumeResponses = {
volume: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -3313,7 +3198,6 @@ export type GetBackupScheduleForVolumeResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -3347,15 +3231,7 @@ export type GetBackupScheduleForVolumeResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
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;
};
repository: {
@ -3507,7 +3383,6 @@ export type GetBackupScheduleForVolumeResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -3637,9 +3512,6 @@ export type GetScheduleNotificationsResponses = {
id: number;
name: string;
enabled: boolean;
status: 'healthy' | 'error' | 'unknown';
lastChecked: number | null;
lastError: string | null;
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
config: {
type: 'email';
@ -3654,6 +3526,7 @@ export type GetScheduleNotificationsResponses = {
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
username?: string;
iconEmoji?: string;
} | {
@ -3741,9 +3614,6 @@ export type UpdateScheduleNotificationsResponses = {
id: number;
name: string;
enabled: boolean;
status: 'healthy' | 'error' | 'unknown';
lastChecked: number | null;
lastError: string | null;
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
config: {
type: 'email';
@ -3758,6 +3628,7 @@ export type UpdateScheduleNotificationsResponses = {
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
username?: string;
iconEmoji?: string;
} | {
@ -3982,7 +3853,6 @@ export type GetScheduleMirrorsResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -4195,7 +4065,6 @@ export type UpdateScheduleMirrorsResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -4233,63 +4102,6 @@ export type 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 = {
body?: never;
path: {
@ -4377,9 +4189,6 @@ export type ListNotificationDestinationsResponses = {
id: number;
name: string;
enabled: boolean;
status: 'healthy' | 'error' | 'unknown';
lastChecked: number | null;
lastError: string | null;
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
config: {
type: 'email';
@ -4394,6 +4203,7 @@ export type ListNotificationDestinationsResponses = {
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
username?: string;
iconEmoji?: string;
} | {
@ -4463,6 +4273,7 @@ export type CreateNotificationDestinationData = {
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
username?: string;
iconEmoji?: string;
} | {
@ -4523,9 +4334,6 @@ export type CreateNotificationDestinationResponses = {
id: number;
name: string;
enabled: boolean;
status: 'healthy' | 'error' | 'unknown';
lastChecked: number | null;
lastError: string | null;
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
config: {
type: 'email';
@ -4540,6 +4348,7 @@ export type CreateNotificationDestinationResponses = {
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
username?: string;
iconEmoji?: string;
} | {
@ -4644,9 +4453,6 @@ export type GetNotificationDestinationResponses = {
id: number;
name: string;
enabled: boolean;
status: 'healthy' | 'error' | 'unknown';
lastChecked: number | null;
lastError: string | null;
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
config: {
type: 'email';
@ -4661,6 +4467,7 @@ export type GetNotificationDestinationResponses = {
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
username?: string;
iconEmoji?: string;
} | {
@ -4731,6 +4538,7 @@ export type UpdateNotificationDestinationData = {
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
username?: string;
iconEmoji?: string;
} | {
@ -4800,9 +4608,6 @@ export type UpdateNotificationDestinationResponses = {
id: number;
name: string;
enabled: boolean;
status: 'healthy' | 'error' | 'unknown';
lastChecked: number | null;
lastError: string | null;
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
config: {
type: 'email';
@ -4817,6 +4622,7 @@ export type UpdateNotificationDestinationResponses = {
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
username?: string;
iconEmoji?: string;
} | {

View file

@ -102,12 +102,6 @@ describe("RestoreForm", () => {
filesSkipped: 0,
});
}),
http.get("/api/v1/volumes/filesystem/browse", () => {
return HttpResponse.json({
path: "/",
directories: [{ name: "restore-target", path: "/restore-target", type: "dir" }],
});
}),
);
render(
@ -120,26 +114,8 @@ describe("RestoreForm", () => {
/>,
);
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(() => {
@ -147,7 +123,6 @@ describe("RestoreForm", () => {
snapshotId: "snap-1",
include: ["/mnt"],
selectedItemKind: "dir",
targetPath: "/restore-target",
overwrite: "always",
});
});

View file

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

View file

@ -1,6 +1,6 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useHotkeySequence } from "@tanstack/react-hotkeys";
import { useHotkey } from "@tanstack/react-hotkeys";
import { getDevPanelOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { DevPanel } from "./dev-panel";
@ -10,12 +10,7 @@ export function DevPanelListener() {
...getDevPanelOptions(),
});
useHotkeySequence(["D", "E", "V"], () => setIsOpen(true), {
enabled: !!devPanelStatus?.enabled,
preventDefault: true,
ignoreInputs: true,
timeout: 1000,
});
useHotkey("Mod+Shift+D", () => setIsOpen(true), { enabled: !!devPanelStatus?.enabled, preventDefault: true });
if (!devPanelStatus?.enabled) {
return null;

View file

@ -224,6 +224,29 @@ describe("SnapshotTreeBrowser", () => {
});
});
test("prefetches using the query path when display and query roots differ", async () => {
const requests = mockListSnapshotFiles();
renderSnapshotTreeBrowser();
const row = await screen.findByRole("button", { name: "project" });
const initialRequestCount = requests.length;
await userEvent.hover(row);
await waitFor(() => {
expect(requests.length).toBe(initialRequestCount + 1);
});
expect(requests.at(-1)).toEqual({
shortId: "repo-1",
snapshotId: "snap-1",
path: "/mnt/project",
offset: "0",
limit: "500",
});
});
test("shows the query root contents when display and query roots differ", async () => {
mockListSnapshotFiles();

View file

@ -5,6 +5,7 @@ import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-b
import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { parseError } from "~/client/lib/errors";
import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils";
import { logger } from "~/client/lib/logger";
function createPathPrefixFns(basePath: string) {
return {
@ -83,6 +84,16 @@ export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
}),
);
},
prefetchFolder: (displayPath) => {
void queryClient
.prefetchQuery(
listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId },
query: { path: displayPath, offset: 0, limit: pageSize },
}),
)
.catch((e) => logger.error(e));
},
pathTransform: displayPathFns,
});

View file

@ -64,7 +64,7 @@ export function Layout({ loaderData }: Props) {
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://github.com/nicotsx/zerobyte/issues/new/choose"
href="https://github.com/nicotsx/zerobyte/issues/new"
target="_blank"
rel="noreferrer"
className={buttonVariants({

View file

@ -1,9 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { AlertTriangle, ChevronDown, Download, FolderOpen, RotateCcw } from "lucide-react";
import { ChevronDown, Download, FolderOpen, RotateCcw } from "lucide-react";
import { Button } from "~/client/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
@ -23,7 +22,6 @@ import { RestoreProgress } from "~/client/components/restore-progress";
import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events";
import { OVERWRITE_MODES, type OverwriteMode } from "@zerobyte/core/restic";
import { isPathWithin } from "@zerobyte/core/utils";
import type { Repository } from "~/client/lib/types";
import { handleRepositoryError } from "~/client/lib/errors";
import { useNavigate } from "@tanstack/react-router";
@ -37,27 +35,15 @@ interface RestoreFormProps {
returnPath: string;
queryBasePath?: string;
displayBasePath?: string;
hasNonPosixSnapshotPaths?: boolean;
}
export function RestoreForm({
repository,
snapshotId,
returnPath,
queryBasePath,
displayBasePath,
hasNonPosixSnapshotPaths = false,
}: RestoreFormProps) {
export function RestoreForm({ repository, snapshotId, returnPath, queryBasePath, displayBasePath }: RestoreFormProps) {
const navigate = useNavigate();
const { addEventListener } = useServerEvents();
const snapshotBasePath = queryBasePath ?? "/";
const hasMismatchedDisplayBasePath = displayBasePath && !isPathWithin(displayBasePath, snapshotBasePath);
const restoreRequiresCustomTarget = hasNonPosixSnapshotPaths || hasMismatchedDisplayBasePath;
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>(
restoreRequiresCustomTarget ? "custom" : "original",
);
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>("original");
const [customTargetPath, setCustomTargetPath] = useState("");
const [overwriteMode, setOverwriteMode] = useState<OverwriteMode>("always");
const [showAdvanced, setShowAdvanced] = useState(false);
@ -69,15 +55,6 @@ export function RestoreForm({
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [selectedPathKind, setSelectedPathKind] = useState<"file" | "dir" | null>(null);
const trimmedCustomTargetPath = customTargetPath.trim();
const hasCustomTargetPath = trimmedCustomTargetPath !== "";
const selectedPathCount = selectedPaths.size;
useEffect(() => {
if (restoreRequiresCustomTarget) {
setRestoreLocation("custom");
}
}, [restoreRequiresCustomTarget]);
useEffect(() => {
const abortController = new AbortController();
@ -137,13 +114,13 @@ export function RestoreForm({
});
const handleRestore = useCallback(() => {
const excludeXattrValues = excludeXattr
.split(",")
.map((value) => value.trim())
const excludeXattrArray = excludeXattr
?.split(",")
.map((s) => s.trim())
.filter(Boolean);
const isCustomLocation = restoreLocation === "custom";
const targetPath = isCustomLocation && hasCustomTargetPath ? trimmedCustomTargetPath : undefined;
const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined;
const includePaths = Array.from(selectedPaths);
@ -158,7 +135,7 @@ export function RestoreForm({
snapshotId,
include: includePaths.length > 0 ? includePaths : undefined,
selectedItemKind: includePaths.length === 1 ? (selectedPathKind ?? undefined) : undefined,
excludeXattr: excludeXattrValues.length > 0 ? excludeXattrValues : undefined,
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
targetPath,
overwrite: overwriteMode,
},
@ -167,9 +144,8 @@ export function RestoreForm({
repository.shortId,
snapshotId,
excludeXattr,
hasCustomTargetPath,
restoreLocation,
trimmedCustomTargetPath,
customTargetPath,
selectedPaths,
selectedPathKind,
overwriteMode,
@ -206,15 +182,13 @@ export function RestoreForm({
}
}, []);
const canRestore = restoreRequiresCustomTarget
? hasCustomTargetPath
: restoreLocation === "original" || hasCustomTargetPath;
const canDownload = selectedPathCount <= 1;
const canRestore = restoreLocation === "original" || customTargetPath.trim();
const canDownload = selectedPaths.size <= 1;
const isRestoreRunning = isRestoring || isRestoreActive;
function getDownloadButtonText(): string {
if (selectedPathCount > 0) {
return `Download ${selectedPathCount} ${selectedPathCount === 1 ? "item" : "items"}`;
if (selectedPaths.size > 0) {
return `Download ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`;
}
return "Download All";
}
@ -223,8 +197,8 @@ export function RestoreForm({
if (isRestoreRunning) {
return "Restoring...";
}
if (selectedPathCount > 0) {
return `Restore ${selectedPathCount} ${selectedPathCount === 1 ? "item" : "items"}`;
if (selectedPaths.size > 0) {
return `Restore ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`;
}
return "Restore All";
}
@ -266,18 +240,6 @@ export function RestoreForm({
<div className="space-y-6">
{isRestoreRunning && <RestoreProgress repositoryId={repository.shortId} snapshotId={snapshotId} />}
{restoreRequiresCustomTarget && (
<Alert variant="warning">
<AlertTriangle className="size-4" />
<AlertTitle>Source paths do not match</AlertTitle>
<AlertDescription>
This snapshot was created from source paths that do not match this Zerobyte server or the current linked
volume. Restoring to the original location is unavailable. Restore it to a custom location, or download
it instead.
</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle>Restore Location</CardTitle>
@ -291,7 +253,6 @@ export function RestoreForm({
size="sm"
className="flex justify-start gap-2"
onClick={() => setRestoreLocation("original")}
disabled={!!restoreRequiresCustomTarget}
>
<RotateCcw size={16} className="mr-1" />
Original location

View file

@ -54,7 +54,7 @@ export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
</span>
</TooltipTrigger>
<TooltipContent>
<p className="capitalize">{label}</p>
<p>{label}</p>
</TooltipContent>
</Tooltip>
);

View file

@ -10,7 +10,7 @@ type ThemeContextValue = {
const ThemeContext = createContext<ThemeContextValue | null>(null);
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";
export function ThemeProvider({ children, initialTheme }: { children: React.ReactNode; initialTheme?: Theme }) {

View file

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

View file

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

View file

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

View file

@ -1,4 +1,3 @@
// fallow-ignore-file unused-export
import type * as React from "react";
import { cn } from "~/client/lib/utils";

View file

@ -1,4 +1,3 @@
// fallow-ignore-file unused-export
// @ts-nocheck
// biome-ignore-all lint: reason
import * as React from "react";
@ -10,7 +9,6 @@ import { cn } from "~/client/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
// fallow-ignore-next-line unused-type
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;

View file

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

View file

@ -1,4 +1,3 @@
// fallow-ignore-file unused-export
import * as React from "react";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,3 @@
// fallow-ignore-file unused-export
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";

View file

@ -1,4 +1,3 @@
// fallow-ignore-file unused-export
"use client";
import type * as React from "react";

View file

@ -1,4 +1,3 @@
// fallow-ignore-file unused-export
"use client";
import * as React from "react";

View file

@ -2,7 +2,7 @@ import { useTheme } from "~/client/components/theme-provider";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme } = useTheme();
const { theme = "dark" } = useTheme();
return (
<Sonner

View file

@ -1,4 +1,3 @@
// fallow-ignore-file unused-export
import type * as React from "react";
import { cn } from "~/client/lib/utils";

View file

@ -1,5 +1,5 @@
import { Cloud, Folder, Server } from "lucide-react";
import type { BackendType } from "@zerobyte/contracts/volumes";
import type { BackendType } from "~/schemas/volumes";
type VolumeIconProps = {
backend: BackendType;

View file

@ -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>
);
};

View file

@ -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);
});
});

View file

@ -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);

View file

@ -28,15 +28,16 @@ const invalidatingEvents = new Set<ServerEventType>([
"backup:completed",
"volume:updated",
"volume:status_changed",
"notification:updated",
"mirror:completed",
"doctor:started",
"doctor:completed",
"doctor:cancelled",
]);
export type RestoreEvent = ServerEventsPayloadMap["restore:started"] | ServerEventsPayloadMap["restore:completed"];
export type RestoreProgressEvent = ServerEventsPayloadMap["restore:progress"];
export type RestoreCompletedEvent = ServerEventsPayloadMap["restore:completed"];
export type BackupProgressEvent = ServerEventsPayloadMap["backup:progress"];
const sharedState: SharedServerEventsState = {
eventSource: null,

View file

@ -6,8 +6,7 @@ export function useUpdates() {
...getUpdatesOptions(),
staleTime: 60 * 60 * 1000,
gcTime: 24 * 60 * 60 * 1000,
refetchOnMount: "always",
refetchOnWindowFocus: "always",
refetchOnWindowFocus: false,
});
return {

View file

@ -64,13 +64,13 @@ describe("datetime formatters", () => {
});
test("formats calendar values with an explicit locale and timezone", () => {
expect(formatShortDateTime(sampleDate, { locale: "en-US", timeZone: "UTC" })).toBe("01/10, 2:30 PM");
expect(formatShortDateTime(sampleDate, { locale: "en-US", timeZone: "UTC" })).toBe("1/10, 2:30 PM");
});
test.each([
["MM/DD/YYYY", "01/10/2026"],
["DD/MM/YYYY", "10/01/2026"],
["YYYY/MM/DD", "2026/01/10"],
["MM/DD/YYYY", "1/10/2026"],
["DD/MM/YYYY", "10/1/2026"],
["YYYY/MM/DD", "2026/1/10"],
] as const)("formats numeric dates with %s order", (dateFormat, expected) => {
expect(formatDate(sampleDate, { locale: "en-US", timeZone: "UTC", dateFormat })).toBe(expected);
});
@ -106,6 +106,6 @@ describe("datetime formatters", () => {
dateFormat: "DD/MM/YYYY",
timeFormat: "24h",
}),
).toBe("10/01/2026, 14:30");
).toBe("10/1/2026, 14:30");
});
});

View file

@ -7,7 +7,6 @@ import {
inferAdditionalFields,
} from "better-auth/client/plugins";
import { ssoClient } from "@better-auth/sso/client";
import { passkeyClient } from "@better-auth/passkey/client";
import type { auth } from "~/server/lib/auth";
export const authClient = createAuthClient({
@ -18,6 +17,5 @@ export const authClient = createAuthClient({
organizationClient(),
ssoClient(),
twoFactorClient(),
passkeyClient(),
],
});

View file

@ -6,7 +6,7 @@ export type DateInput = Date | string | number | null | undefined;
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 DEFAULT_DATE_FORMAT: DateFormatPreference = "MM/DD/YYYY";
export const TIME_FORMATS = ["12h", "24h"] as const;
export type TimeFormatPreference = (typeof TIME_FORMATS)[number];
@ -67,8 +67,8 @@ function formatConfiguredDate(date: Date, options: DateFormatOptions, includeYea
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",
month: "numeric",
day: "numeric",
year: "numeric",
}).formatToParts(date);
const values = {
@ -148,7 +148,7 @@ export function inferDateTimePreferences(locale?: string) {
};
}
// 01/10/2026, 2:30 PM
// 1/10/2026, 2:30 PM
function formatDateTime(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(
date,
@ -161,7 +161,7 @@ function formatDateWithMonth(date: DateInput, options: DateFormatOptions = {}):
return formatValidDate(date, (validDate) => formatConfiguredDateWithMonth(validDate, options));
}
// 01/10/2026
// 1/10/2026
function formatDate(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(date, (validDate) => formatConfiguredDate(validDate, options, true));
}
@ -171,7 +171,7 @@ function formatShortDate(date: DateInput, options: DateFormatOptions = {}): stri
return formatValidDate(date, (validDate) => formatConfiguredDate(validDate, options, false));
}
// 01/10, 2:30 PM
// 1/10, 2:30 PM
function formatShortDateTime(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(
date,

View file

@ -3,7 +3,7 @@ import { unlockRepository } from "~/client/api-client/sdk.gen";
import { Button } from "~/client/components/ui/button";
import { Unlock } from "lucide-react";
const isLockError = (error: unknown): boolean => {
export const isLockError = (error: unknown): boolean => {
const errorMessage = parseError(error)?.message || "";
return (
@ -25,7 +25,7 @@ export const parseError = (error?: unknown) => {
return undefined;
};
const showLockErrorToast = (repositoryId: string, title: string) => {
export const showLockErrorToast = (repositoryId: string, title: string) => {
toast.error(title, {
description:
"The repository is currently locked by another operation. This can happen when a previous operation didn't complete properly.",

View file

@ -1,5 +1,6 @@
import { LOGIN_ERROR_CODES, type LoginErrorCode } from "~/lib/sso-errors";
export type { LoginErrorCode } from "~/lib/sso-errors";
export { getLoginErrorDescription } from "~/lib/sso-errors";
const VALID_ERROR_CODES = new Set<LoginErrorCode>(LOGIN_ERROR_CODES);

View file

@ -6,6 +6,29 @@ export function cn(...inputs: ClassValue[]) {
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 {
try {
return JSON.parse(input) as T;

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -1,49 +1,16 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, render, screen, userEvent, waitFor } from "~/test/test-utils";
import { getLoginErrorDescription, PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors";
const { mockGetLoginOptions, mockNavigate, mockPasskeySignIn } = vi.hoisted(() => ({
mockGetLoginOptions: vi.fn(async () => ({ hasPasskeySignIn: false })),
mockNavigate: vi.fn(async () => {}),
mockPasskeySignIn: vi.fn(
async (): Promise<{
data: unknown;
error: { code: string; message: string } | null;
}> => ({ data: null, error: null }),
),
}));
import { cleanup, render, screen } from "~/test/test-utils";
vi.mock("@tanstack/react-router", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
return {
...actual,
useNavigate: (() => mockNavigate) as typeof actual.useNavigate,
useNavigate: (() => vi.fn(async () => {})) 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", () => ({
authClient: {
signIn: {
passkey: mockPasskeySignIn,
},
},
}));
import { LoginPage } from "../login";
const inviteOnlyMessage =
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
@ -62,12 +29,6 @@ const mockSsoProvidersRequest = (
};
afterEach(() => {
mockGetLoginOptions.mockClear();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: false });
mockNavigate.mockClear();
mockPasskeySignIn.mockClear();
mockPasskeySignIn.mockResolvedValue({ data: null, error: null });
vi.unstubAllGlobals();
cleanup();
});
@ -120,14 +81,6 @@ describe("LoginPage", () => {
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 () => {
mockSsoProvidersRequest();
@ -137,141 +90,11 @@ describe("LoginPage", () => {
expect(screen.queryByText(inviteOnlyMessage)).toBeNull();
});
test("renders available SSO providers from the alternative sign-in section", async () => {
test("renders available SSO providers from the real SSO 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();
});
});

View file

@ -8,25 +8,11 @@ import { Button } from "~/client/components/ui/button";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
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";
const RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE =
"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) {
export function DownloadRecoveryKeyPage() {
const navigate = useNavigate();
const [password, setPassword] = useState("");
const [blockedMessage, setBlockedMessage] = useState<string | null>(null);
const downloadResticPassword = useMutation({
...downloadResticPasswordMutation(),
@ -39,16 +25,13 @@ export function DownloadRecoveryKeyPage({ hasCredentialPassword, userId }: Props
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
window.URL.revokeObjectURL(url);
toast.success("Recovery key downloaded successfully!");
setBlockedMessage(null);
void navigate({ to: "/volumes", replace: true });
},
onError: (error) => {
const message = parseError(error)?.message;
setBlockedMessage(message?.includes("credential password") ? message : null);
toast.error("Failed to download recovery key", { description: message });
toast.error("Failed to download recovery key", { description: error.message });
},
});
@ -60,15 +43,11 @@ export function DownloadRecoveryKeyPage({ hasCredentialPassword, userId }: Props
return;
}
setBlockedMessage(null);
downloadResticPassword.mutate({ 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 });
downloadResticPassword.mutate({
body: {
password,
},
});
};
return (
@ -80,56 +59,30 @@ export function DownloadRecoveryKeyPage({ hasCredentialPassword, userId }: Props
<AlertTriangle className="size-5" />
<AlertTitle>Important: Save This File Securely</AlertTitle>
<AlertDescription>
Your Restic password is essential for recovering your backup data. If you previously downloaded this
file, replace that saved copy with the new download. If you lose access to this server without this
file, your backups will be unrecoverable. Store it in a password manager or encrypted storage.
Your Restic password is essential for recovering your backup data. If you lose access to this server without
this file, your backups will be unrecoverable. Store it in a password manager or encrypted storage.
</AlertDescription>
</Alert>
<form onSubmit={handleSubmit} className="space-y-4">
{(!hasCredentialPassword || blockedMessage) && (
<Alert variant="warning">
<AlertTriangle className="size-5" />
<AlertTitle>Local password required</AlertTitle>
<AlertDescription>
{blockedMessage ?? RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE}
</AlertDescription>
</Alert>
)}
{hasCredentialPassword && (
<div className="space-y-2">
<Label htmlFor="password">Confirm Your Password</Label>
<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="space-y-2">
<Label htmlFor="password">Confirm Your Password</Label>
<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">
{hasCredentialPassword && (
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
<Download size={16} className="mr-2" />
Download Recovery Key
</Button>
)}
<Button
type="button"
variant="ghost"
onClick={handleSkip}
disabled={downloadResticPassword.isPending}
className="w-full"
>
Skip
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
<Download size={16} className="mr-2" />
Download Recovery Key
</Button>
</div>
</form>

View file

@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { AuthLayout } from "~/client/components/auth-layout";
@ -10,14 +10,12 @@ import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/clie
import { Label } from "~/client/components/ui/label";
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 { PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors";
import { ResetPasswordDialog } from "../components/reset-password-dialog";
import { useNavigate } from "@tanstack/react-router";
import { normalizeUsername } from "~/lib/username";
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";
const loginSchema = z.object({
@ -34,23 +32,6 @@ type LoginPageProps = {
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 = {}) {
const navigate = useNavigate();
const [showResetDialog, setShowResetDialog] = useState(false);
@ -62,52 +43,6 @@ export function LoginPage({ error }: LoginPageProps = {}) {
const errorCode = decodeLoginError(error);
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>({
resolver: zodResolver(loginSchema),
defaultValues: {
@ -141,7 +76,12 @@ export function LoginPage({ error }: LoginPageProps = {}) {
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 () => {
@ -173,11 +113,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
if (data) {
toast.success("Login successful");
const session = await authClient.getSession();
if (
session.data?.user &&
!session.data.user.hasDownloadedResticPassword &&
!hasSkippedRecoveryKeyDownload(session.data.user.id)
) {
if (session.data?.user && !session.data.user.hasDownloadedResticPassword) {
void navigate({ to: "/download-recovery-key" });
} else {
void navigate({ to: "/volumes" });
@ -194,10 +130,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
if (requires2FA) {
return (
<AuthLayout
title="Two-Factor Authentication"
description="Enter the 6-digit code from your authenticator app"
>
<AuthLayout title="Two-Factor Authentication" description="Enter the 6-digit code from your authenticator app">
<div className="space-y-6">
<div className="space-y-4 flex flex-col items-center">
<Label htmlFor="totp-code">Authentication code</Label>
@ -228,7 +161,6 @@ export function LoginPage({ error }: LoginPageProps = {}) {
<input
type="checkbox"
id="trust-device"
aria-label="Trust this device for 30 days"
checked={trustDevice}
onChange={(e) => setTrustDevice(e.target.checked)}
className="h-4 w-4"
@ -267,11 +199,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
<AuthLayout title="Login to your account" description="Enter your credentials below to login to your account">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div
className={cn("rounded-md border border-destructive/50 p-3 text-sm", {
hidden: !errorDescription,
})}
>
<div className={cn("rounded-md border border-destructive/50 p-3 text-sm", { hidden: !errorDescription })}>
{errorDescription}
</div>
<FormField
@ -281,13 +209,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input
{...field}
type="text"
placeholder="admin"
disabled={isLoggingIn}
autoComplete="username webauthn"
/>
<Input {...field} type="text" placeholder="admin" disabled={isLoggingIn} />
</FormControl>
<FormMessage />
</FormItem>
@ -309,12 +231,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
</button>
</div>
<FormControl>
<Input
{...field}
type="password"
disabled={isLoggingIn}
autoComplete="current-password webauthn"
/>
<Input {...field} type="password" disabled={isLoggingIn} />
</FormControl>
<FormMessage />
</FormItem>
@ -326,7 +243,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
</form>
</Form>
<AlternativeSignInSection onPasskeySignIn={navigateAfterLogin} />
<SsoLoginSection />
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
</AuthLayout>

View file

@ -10,6 +10,7 @@ import {
FormLabel,
FormMessage,
} from "~/client/components/ui/form";
import { authMiddleware } from "~/middleware/auth";
import { AuthLayout } from "~/client/components/auth-layout";
import { Input } from "~/client/components/ui/input";
import { Button } from "~/client/components/ui/button";
@ -21,6 +22,8 @@ import { normalizeUsername } from "~/lib/username";
import { z } from "zod";
import { inferDateTimePreferences } from "~/client/lib/datetime";
export const clientMiddleware = [authMiddleware];
const onboardingSchema = z.object({
username: z.string().min(2).max(30).transform(normalizeUsername),
email: z

View file

@ -68,39 +68,4 @@ describe("SnapshotFileBrowser", () => {
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();
});
});

View file

@ -1,165 +1,35 @@
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Textarea } from "~/client/components/ui/textarea";
import { type UseFormReturn } from "react-hook-form";
import type { UseFormReturn } from "react-hook-form";
import type { InternalFormValues } from "./types";
import { Input } from "~/client/components/ui/input";
type AdvancedSectionProps = {
form: UseFormReturn<InternalFormValues>;
};
type WebhookFieldsProps = {
form: UseFormReturn<InternalFormValues>;
phase: "pre" | "post";
urlPlaceholder: string;
bodyPlaceholder: string;
description: string;
};
const WebhookFields = ({ form, phase, urlPlaceholder, bodyPlaceholder, description }: WebhookFieldsProps) => {
const labelPrefix = phase === "pre" ? "Pre-backup" : "Post-backup";
const fieldPrefix = phase === "pre" ? "preBackupWebhook" : "postBackupWebhook";
return (
<>
<FormField
control={form.control}
name={`${fieldPrefix}Url`}
render={({ field }) => (
<FormItem>
<FormLabel>{labelPrefix} webhook</FormLabel>
<FormControl>
<Input {...field} type="url" placeholder={urlPlaceholder} />
</FormControl>
<FormDescription>
{description} The URL origin must be listed in WEBHOOK_ALLOWED_ORIGINS; redirects are not followed.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`${fieldPrefix}HeadersText`}
render={({ field }) => (
<FormItem>
<FormLabel>{labelPrefix} webhook headers</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="Authorization: Bearer token&#10;X-Custom-Header: value"
className="font-mono text-sm min-h-24"
/>
</FormControl>
<FormDescription>
One header per line in Key: Value format. Values are stored as plain text.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`${fieldPrefix}Body`}
render={({ field }) => (
<FormItem>
<FormLabel>{labelPrefix} webhook body</FormLabel>
<FormControl>
<Textarea {...field} placeholder={bodyPlaceholder} className="font-mono text-sm min-h-24" />
</FormControl>
<FormDescription>Optional raw POST body. Leave empty to send the backup context JSON.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};
export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
return (
<>
<FormField
control={form.control}
name="maxRetries"
render={({ field }) => (
<FormItem>
<FormLabel>Maximum retries</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={0}
max={32}
value={field.value ?? ""}
placeholder="e.g., 2"
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
<FormDescription>Maximum number of retry attempts if a backup fails (default: 2).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="retryDelay"
render={({ field }) => (
<FormItem>
<FormLabel>Retry delay</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={1}
max={1440}
step={1}
placeholder="e.g., 15"
value={field.value ?? ""}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
<FormDescription>Delay in minutes before retrying a failed backup (default: 15 minutes).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<WebhookFields
form={form}
phase="pre"
urlPlaceholder="http://host.docker.internal:8080/stop"
bodyPlaceholder='{"action":"stop"}'
description="Called with POST before restic starts. A non-2xx response stops the backup."
/>
<WebhookFields
form={form}
phase="post"
urlPlaceholder="http://host.docker.internal:8080/start"
bodyPlaceholder='{"action":"start"}'
description="Called with POST after restic finishes, including failed or cancelled runs."
/>
<FormField
control={form.control}
name="customResticParamsText"
render={({ field }) => (
<FormItem>
<FormLabel>Custom restic parameters</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="--exclude-larger-than 500M&#10;--no-scan&#10;--read-concurrency 8"
className="font-mono text-sm min-h-24"
/>
</FormControl>
<FormDescription>
Advanced: enter one restic flag per line (e.g.{" "}
<code className="bg-muted px-1 rounded">--exclude-larger-than 500M</code>). Only the supported flag list
is accepted.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
<FormField
control={form.control}
name="customResticParamsText"
render={({ field }) => (
<FormItem>
<FormLabel>Custom restic parameters</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="--exclude-larger-than 500M&#10;--no-scan&#10;--read-concurrency 8"
className="font-mono text-sm min-h-24"
/>
</FormControl>
<FormDescription>
Advanced: enter one restic flag per line (e.g.{" "}
<code className="bg-muted px-1 rounded">--exclude-larger-than 500M</code>). Only the supported flag list is
accepted.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
};

View file

@ -14,7 +14,7 @@ import { PathsSection } from "./paths-section";
import { RetentionSection } from "./retention-section";
import { SummarySection } from "./summary-section";
import { internalFormSchema, type BackupScheduleFormValues, type InternalFormValues } from "./types";
import { backupScheduleToFormValues, parseMultilineEntries, toWebhookConfig } from "./utils";
import { backupScheduleToFormValues, parseMultilineEntries } from "./utils";
export type { BackupScheduleFormValues };
@ -44,26 +44,12 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
customResticParamsText,
includePaths,
cronExpression,
maxRetries,
retryDelay,
preBackupWebhookUrl,
preBackupWebhookHeadersText,
preBackupWebhookBody,
postBackupWebhookUrl,
postBackupWebhookHeadersText,
postBackupWebhookBody,
...rest
} = data;
const excludePatterns = parseMultilineEntries(excludePatternsText);
const excludeIfPresent = parseMultilineEntries(excludeIfPresentText);
const parsedIncludePatterns = parseMultilineEntries(includePatterns);
const customResticParams = parseMultilineEntries(customResticParamsText);
const preBackupWebhook = toWebhookConfig(preBackupWebhookUrl, preBackupWebhookHeadersText, preBackupWebhookBody);
const postBackupWebhook = toWebhookConfig(
postBackupWebhookUrl,
postBackupWebhookHeadersText,
postBackupWebhookBody,
);
onSubmit({
...rest,
@ -73,10 +59,6 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
excludePatterns,
excludeIfPresent,
customResticParams,
backupWebhooks:
preBackupWebhook || postBackupWebhook ? { pre: preBackupWebhook, post: postBackupWebhook } : null,
maxRetries,
retryDelay,
});
},
[onSubmit],

View file

@ -1,19 +1,4 @@
import { z } from "zod";
import type { BackupWebhooks } from "@zerobyte/core/backup-hooks";
const webhookHeadersSchema = z.string().refine(
(value) =>
value
.split("\n")
.map((header) => header.trim())
.filter(Boolean)
.every((header) => {
const [key, value] = header.split(":", 2);
return /^[A-Za-z0-9-]+$/.test(key.trim()) && (value?.trim().length ?? 0) > 0;
}),
{ message: "Headers must use non-empty Key: Value format with valid header names" },
);
export const internalFormSchema = z.object({
name: z.string().min(1).max(128),
@ -35,14 +20,6 @@ export const internalFormSchema = z.object({
keepYearly: z.number().optional(),
oneFileSystem: z.boolean().optional(),
customResticParamsText: z.string().optional(),
preBackupWebhookUrl: z.union([z.url(), z.literal("")]).optional(),
preBackupWebhookHeadersText: webhookHeadersSchema.optional(),
preBackupWebhookBody: z.string().optional(),
postBackupWebhookUrl: z.union([z.url(), z.literal("")]).optional(),
postBackupWebhookHeadersText: webhookHeadersSchema.optional(),
postBackupWebhookBody: z.string().optional(),
maxRetries: z.number().min(0).max(32).optional(),
retryDelay: z.number().min(1).max(1440).optional(),
});
export const weeklyDays = [
@ -59,20 +36,10 @@ export type InternalFormValues = z.infer<typeof internalFormSchema>;
export type BackupScheduleFormValues = Omit<
InternalFormValues,
| "excludePatternsText"
| "excludeIfPresentText"
| "includePatterns"
| "customResticParamsText"
| "preBackupWebhookUrl"
| "preBackupWebhookHeadersText"
| "preBackupWebhookBody"
| "postBackupWebhookUrl"
| "postBackupWebhookHeadersText"
| "postBackupWebhookBody"
"excludePatternsText" | "excludeIfPresentText" | "includePatterns" | "customResticParamsText"
> & {
excludePatterns?: string[];
excludeIfPresent?: string[];
includePatterns?: string[];
customResticParams?: string[];
backupWebhooks?: BackupWebhooks | null;
};

View file

@ -2,29 +2,13 @@ import type { BackupSchedule } from "~/client/lib/types";
import { cronToFormValues } from "../../lib/cron-utils";
import type { InternalFormValues } from "./types";
export const parseMultilineEntries = (value?: string) => {
if (!value) {
return [];
}
return value
.split("\n")
.map((entry) => entry.trim())
.filter(Boolean);
};
export const toWebhookConfig = (url?: string, headers?: string, body?: string) => {
const trimmedUrl = url?.trim();
const parsedHeaders = parseMultilineEntries(headers);
return trimmedUrl
? {
url: trimmedUrl,
headers: parsedHeaders.length > 0 ? parsedHeaders : undefined,
body: body === "" ? undefined : body,
}
: null;
};
export const parseMultilineEntries = (value?: string) =>
value
? value
.split("\n")
.map((entry) => entry.trim())
.filter(Boolean)
: [];
export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => {
if (!schedule) {
@ -42,14 +26,6 @@ export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalF
excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined,
oneFileSystem: schedule.oneFileSystem ?? false,
customResticParamsText: schedule.customResticParams?.join("\n") ?? "",
preBackupWebhookUrl: schedule.backupWebhooks?.pre?.url ?? "",
preBackupWebhookHeadersText: schedule.backupWebhooks?.pre?.headers?.join("\n") ?? "",
preBackupWebhookBody: schedule.backupWebhooks?.pre?.body ?? "",
postBackupWebhookUrl: schedule.backupWebhooks?.post?.url ?? "",
postBackupWebhookHeadersText: schedule.backupWebhooks?.post?.headers?.join("\n") ?? "",
postBackupWebhookBody: schedule.backupWebhooks?.post?.body ?? "",
maxRetries: schedule.maxRetries,
retryDelay: schedule.retryDelay,
...cronValues,
...schedule.retentionPolicy,
};

View file

@ -1,5 +1,5 @@
import { useMutation, useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { Copy, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Copy, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
@ -8,33 +8,21 @@ import { Switch } from "~/client/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { Badge } from "~/client/components/ui/badge";
import { Checkbox } from "~/client/components/ui/checkbox";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/client/components/ui/dialog";
import {
getScheduleMirrorsOptions,
getMirrorCompatibilityOptions,
getMirrorSyncStatusOptions,
updateScheduleMirrorsMutation,
syncMirrorMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
import type { Repository } from "~/client/lib/types";
import { RepositoryIcon } from "~/client/components/repository-icon";
import { StatusDot } from "~/client/components/status-dot";
import { ByteSize } from "~/client/components/bytes-size";
import { useServerEvents } from "~/client/hooks/use-server-events";
import { formatDistanceToNow } from "date-fns";
import { cn } from "~/client/lib/utils";
import type { GetScheduleMirrorsResponse } from "~/client/api-client";
import { Link } from "@tanstack/react-router";
import { useTimeFormat } from "~/client/lib/datetime";
type Props = {
scheduleShortId: string;
@ -68,29 +56,11 @@ const buildAssignments = (mirrors: GetScheduleMirrorsResponse) =>
);
export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, repositories, initialData }: Props) => {
const { formatDateTime, formatTimeAgo } = useTimeFormat();
const [assignments, setAssignments] = useState<Map<string, MirrorAssignment>>(() => buildAssignments(initialData));
const [hasChanges, setHasChanges] = useState(false);
const [isAddingNew, setIsAddingNew] = useState(false);
const [syncDialogMirrorId, setSyncDialogMirrorId] = useState<string | null>(null);
const [selectedSnapshotIds, setSelectedSnapshotIds] = useState<Set<string>>(new Set());
const [syncDialogOpen, setSyncDialogOpen] = useState(false);
const { addEventListener } = useServerEvents();
const closeSyncDialog = () => {
setSyncDialogOpen(false);
setTimeout(() => {
setSyncDialogMirrorId(null);
setSelectedSnapshotIds(new Set());
}, 300);
};
const openSyncDialog = (mirrorShortId: string) => {
setSyncDialogOpen(true);
setSelectedSnapshotIds(new Set());
setSyncDialogMirrorId(mirrorShortId);
};
const { data: currentMirrors } = useSuspenseQuery({
...getScheduleMirrorsOptions({ path: { shortId: scheduleShortId } }),
});
@ -118,47 +88,6 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
},
});
const { data: syncStatus, isLoading: isSyncStatusLoading } = useQuery({
...getMirrorSyncStatusOptions({
path: { shortId: scheduleShortId, mirrorShortId: syncDialogMirrorId ?? "" },
}),
enabled: syncDialogMirrorId !== null,
});
const toggleSnapshotSelection = (shortId: string) => {
setSelectedSnapshotIds((prev) => {
const next = new Set(prev);
if (next.has(shortId)) {
next.delete(shortId);
} else {
next.add(shortId);
}
return next;
});
};
const toggleAllSnapshots = () => {
if (!syncStatus) return;
if (selectedSnapshotIds.size === syncStatus.missingSnapshots.length) {
setSelectedSnapshotIds(new Set());
} else {
setSelectedSnapshotIds(new Set(syncStatus.missingSnapshots.map((s) => s.short_id)));
}
};
const triggerSync = useMutation({
...syncMirrorMutation(),
onSuccess: () => {
toast.success("Full sync started");
closeSyncDialog();
},
onError: (error) => {
toast.error("Failed to start sync", {
description: parseError(error)?.message,
});
},
});
const compatibilityMap = useMemo(() => {
const map = new Map<string, { compatible: boolean; reason: string | null }>();
if (compatibility) {
@ -292,7 +221,7 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
return "Syncing...";
}
if (assignment.lastCopyAt) {
return formatTimeAgo(assignment.lastCopyAt);
return formatDistanceToNow(new Date(assignment.lastCopyAt), { addSuffix: true });
}
return "Never";
};
@ -310,8 +239,6 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
return "Never copied";
};
const selectedMirrorRepo = repositories.find((r) => r.shortId === syncDialogMirrorId);
return (
<Card>
<CardHeader>
@ -431,41 +358,23 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="w-3 shrink-0 mr-1">
<StatusDot
variant={getStatusVariant(assignment.lastCopyStatus)}
label={getStatusLabel(assignment)}
animated={isSyncing(assignment)}
/>
</div>
<StatusDot
variant={getStatusVariant(assignment.lastCopyStatus)}
label={getStatusLabel(assignment)}
animated={isSyncing(assignment)}
/>
<span className="text-sm text-muted-foreground">{getLabel(assignment)}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => openSyncDialog(repository.shortId)}
disabled={isSyncing(assignment) || hasChanges}
className="h-8 w-8 text-muted-foreground hover:text-foreground"
>
<RefreshCw className={cn("h-4 w-4", isSyncing(assignment) && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent>Sync more snapshots</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
onClick={() => removeRepository(repository.shortId)}
className="h-8 w-8 text-muted-foreground hover:text-destructive align-baseline"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => removeRepository(repository.shortId)}
className="h-8 w-8 text-muted-foreground hover:text-destructive align-baseline"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
@ -485,89 +394,6 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
</Button>
</div>
)}
<Dialog open={syncDialogOpen} onOpenChange={(open) => !open && closeSyncDialog()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Sync snapshots</DialogTitle>
<DialogDescription>
{`Sync missing snapshots to ${selectedMirrorRepo?.name || "mirror repository"}.`}
</DialogDescription>
</DialogHeader>
{isSyncStatusLoading && !syncStatus ? (
<div className="py-6 text-center text-muted-foreground text-sm">Loading snapshot status...</div>
) : syncStatus && syncStatus.missingSnapshots.length === 0 ? (
<div className="py-6 text-center text-muted-foreground text-sm">
All {syncStatus.sourceCount} snapshots are already synced to this mirror.
</div>
) : syncStatus ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
{syncStatus.missingSnapshots.length} of {syncStatus.sourceCount} snapshots are missing in this mirror.
</p>
<div className="rounded-md border max-h-64 overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={selectedSnapshotIds.size === syncStatus.missingSnapshots.length}
onCheckedChange={toggleAllSnapshots}
/>
</TableHead>
<TableHead>ID</TableHead>
<TableHead>Date</TableHead>
<TableHead className="text-right">Size</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{syncStatus.missingSnapshots.map((snapshot) => (
<TableRow
key={snapshot.short_id}
className="cursor-pointer"
onClick={() => toggleSnapshotSelection(snapshot.short_id)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedSnapshotIds.has(snapshot.short_id)}
onCheckedChange={() => toggleSnapshotSelection(snapshot.short_id)}
/>
</TableCell>
<TableCell className="font-mono text-xs">{snapshot.short_id}</TableCell>
<TableCell className="text-sm">{formatDateTime(new Date(snapshot.time))}</TableCell>
<TableCell className="text-right text-sm">
<ByteSize bytes={snapshot.size} base={1024} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={closeSyncDialog}>
Cancel
</Button>
<Button
onClick={() => {
if (syncDialogMirrorId) {
triggerSync.mutate({
path: { shortId: scheduleShortId, mirrorShortId: syncDialogMirrorId },
body: { snapshotIds: Array.from(selectedSnapshotIds) },
});
}
}}
loading={triggerSync.isPending}
disabled={selectedSnapshotIds.size === 0}
>
Sync {selectedSnapshotIds.size} snapshots
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);

View file

@ -30,8 +30,7 @@ export const SnapshotFileBrowser = (props: Props) => {
const { snapshot, repositoryId, backupId, displayBasePath, onDeleteSnapshot, isDeletingSnapshot } = props;
const { formatDateTime } = useTimeFormat();
const hasNonPosixSnapshotPaths = snapshot.paths.some((path) => !path.startsWith("/"));
const queryBasePath = hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshot.paths);
const queryBasePath = findCommonAncestor(snapshot.paths);
return (
<div className="space-y-4">

View file

@ -1,4 +1,4 @@
type CronFormValues = {
export type CronFormValues = {
frequency: string;
dailyTime?: string;
weeklyDay?: string;

View file

@ -79,7 +79,6 @@ const schedule = {
excludeIfPresent: [],
oneFileSystem: false,
customResticParams: [],
backupWebhooks: null,
};
const snapshot = {

View file

@ -1,6 +1,6 @@
import { afterEach, expect, test, vi } from "vitest";
import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, fireEvent, render, screen, userEvent } from "~/test/test-utils";
import { cleanup, render, screen, userEvent } from "~/test/test-utils";
const navigateMock = vi.fn(async () => {});
@ -15,33 +15,26 @@ vi.mock("@tanstack/react-router", async (importOriginal) => {
import { EditBackupPage } from "../edit-backup";
const repository = { shortId: "repo-1", name: "Repo 1", type: "local" };
const volume = {
id: "volume-1",
shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
};
const volumeFilesResponse = {
files: [{ name: "project", path: "/project", type: "directory" }],
path: "/",
offset: 0,
limit: 100,
total: 1,
hasMore: false,
};
afterEach(() => {
navigateMock.mockClear();
cleanup();
});
const renderEditBackupPage = ({ enabled, cronExpression }: { enabled: boolean; cronExpression: string }) => {
test("submits the computed cron expression when saving a daily schedule", async () => {
const submittedBody = new Promise<Record<string, unknown>>((resolve) => {
server.use(
http.get("/api/v1/backups/:shortId", () => {
return HttpResponse.json({
shortId: "backup-1",
name: "Backup 1",
enabled,
repository,
volume,
cronExpression,
repository: { shortId: "repo-1", name: "Repo 1", type: "local" },
volume: {
id: "volume-1",
shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
},
cronExpression: "0 2 * * *",
retentionPolicy: null,
includePaths: ["/project"],
includePatterns: [],
@ -49,14 +42,20 @@ const renderEditBackupPage = ({ enabled, cronExpression }: { enabled: boolean; c
excludeIfPresent: [],
oneFileSystem: false,
customResticParams: [],
backupWebhooks: null,
});
}),
http.get("/api/v1/repositories", () => {
return HttpResponse.json([repository]);
return HttpResponse.json([{ shortId: "repo-1", name: "Repo 1", type: "local" }]);
}),
http.get("/api/v1/volumes/:shortId/files", () => {
return HttpResponse.json(volumeFilesResponse);
return HttpResponse.json({
files: [{ name: "project", path: "/project", type: "directory" }],
path: "/",
offset: 0,
limit: 100,
total: 1,
hasMore: false,
});
}),
http.patch("/api/v1/backups/:shortId", async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
@ -64,8 +63,13 @@ const renderEditBackupPage = ({ enabled, cronExpression }: { enabled: boolean; c
return HttpResponse.json({
shortId: "backup-1",
volume,
repository,
volume: {
id: "volume-1",
shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
},
repository: { shortId: "repo-1", name: "Repo 1", type: "local" },
...body,
});
}),
@ -74,79 +78,10 @@ const renderEditBackupPage = ({ enabled, cronExpression }: { enabled: boolean; c
render(<EditBackupPage backupId="backup-1" />, { withSuspense: true });
return { submittedBody };
};
afterEach(() => {
navigateMock.mockClear();
cleanup();
});
test("submits the computed cron expression when saving a daily schedule", async () => {
const { submittedBody } = renderEditBackupPage({ enabled: true, cronExpression: "0 2 * * *" });
await userEvent.click(await screen.findByRole("button", { name: "Update schedule" }));
await expect(submittedBody).resolves.toMatchObject({
frequency: "daily",
enabled: true,
cronExpression: "00 02 * * *",
});
});
test("disables the schedule when switching an enabled custom cron schedule to manual only", async () => {
const { submittedBody } = renderEditBackupPage({ enabled: true, cronExpression: "*/13 * * * *" });
const nativeFrequencySelect = (await screen.findAllByRole("combobox"))
.at(1)
?.parentElement?.querySelector('select[aria-hidden="true"]');
if (!(nativeFrequencySelect instanceof HTMLSelectElement)) {
throw new Error("Expected hidden native select for frequency field");
}
fireEvent.change(nativeFrequencySelect, { target: { value: "manual" } });
await userEvent.click(screen.getByRole("button", { name: "Update schedule" }));
await expect(submittedBody).resolves.toMatchObject({
frequency: "manual",
enabled: false,
cronExpression: "",
});
});
test("preserves a disabled schedule when saving a non-manual frequency", async () => {
const { submittedBody } = renderEditBackupPage({ enabled: false, cronExpression: "0 2 * * *" });
await userEvent.click(await screen.findByRole("button", { name: "Update schedule" }));
await expect(submittedBody).resolves.toMatchObject({
frequency: "daily",
enabled: false,
cronExpression: "00 02 * * *",
});
});
test("submits webhook headers and body as plain config values", async () => {
const { submittedBody } = renderEditBackupPage({ enabled: true, cronExpression: "0 2 * * *" });
await userEvent.click(await screen.findByText("Advanced"));
await userEvent.type(screen.getByLabelText("Pre-backup webhook"), "http://localhost:8080/stop");
fireEvent.change(screen.getByLabelText("Pre-backup webhook headers"), {
target: { value: "Authorization: Bearer stop-token" },
});
fireEvent.change(screen.getByLabelText("Pre-backup webhook body"), {
target: { value: '{"action":"stop"}' },
});
await userEvent.click(screen.getByRole("button", { name: "Update schedule" }));
await expect(submittedBody).resolves.toMatchObject({
backupWebhooks: {
pre: {
url: "http://localhost:8080/stop",
headers: ["Authorization: Bearer stop-token"],
body: '{"action":"stop"}',
},
post: null,
},
});
});

View file

@ -63,7 +63,9 @@ export function ScheduleDetailsPage(props: Props) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const searchParams = useSearch({ from: "/(dashboard)/backups/$backupId/" });
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>(initialSnapshotId);
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>(
initialSnapshotId ?? loaderData.snapshots?.at(-1)?.short_id,
);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
@ -77,10 +79,7 @@ export function ScheduleDetailsPage(props: Props) {
isLoading,
failureReason,
} = useQuery({
...listSnapshotsOptions({
path: { shortId: schedule.repository.shortId },
query: { backupId: schedule.shortId },
}),
...listSnapshotsOptions({ path: { shortId: schedule.repository.shortId }, query: { backupId: schedule.shortId } }),
initialData: loaderData.snapshots,
});
@ -168,9 +167,6 @@ export function ScheduleDetailsPage(props: Props) {
excludeIfPresent: schedule.excludeIfPresent || [],
oneFileSystem: schedule.oneFileSystem,
customResticParams: schedule.customResticParams || [],
backupWebhooks: schedule.backupWebhooks,
maxRetries: schedule.maxRetries,
retryDelay: schedule.retryDelay,
},
});
};
@ -256,8 +252,8 @@ export function ScheduleDetailsPage(props: Props) {
<AlertDialogHeader>
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the snapshot and all its data
from the repository.
This action cannot be undone. This will permanently delete the snapshot and all its data from the
repository.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View file

@ -75,9 +75,6 @@ export function CreateBackupPage() {
excludeIfPresent: formValues.excludeIfPresent,
oneFileSystem: formValues.oneFileSystem,
customResticParams: formValues.customResticParams,
backupWebhooks: formValues.backupWebhooks,
maxRetries: formValues.maxRetries,
retryDelay: formValues.retryDelay,
},
});
};

View file

@ -51,7 +51,6 @@ export function EditBackupPage({ backupId }: { backupId: string }) {
path: { shortId: schedule.shortId },
body: {
...formValues,
enabled: formValues.frequency === "manual" ? false : schedule.enabled,
cronExpression,
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
},

View file

@ -39,7 +39,7 @@ import {
const baseFields = { name: z.string().min(2).max(32) };
const formSchema = z.discriminatedUnion("type", [
export const formSchema = z.discriminatedUnion("type", [
emailNotificationConfigSchema.extend(baseFields),
slackNotificationConfigSchema.extend(baseFields),
discordNotificationConfigSchema.extend(baseFields),
@ -75,6 +75,7 @@ const defaultValuesForType = {
slack: {
type: "slack" as const,
webhookUrl: "",
channel: "",
username: "",
iconEmoji: "",
},

View file

@ -31,8 +31,7 @@ export const CustomForm = ({ form }: Props) => {
>
Shoutrrr documentation
</a>
&nbsp;for supported services and URL formats. Custom HTTP, generic, and SMTP network targets must be listed
in WEBHOOK_ALLOWED_ORIGINS.
&nbsp;for supported services and URL formats.
</FormDescription>
<FormMessage />
</FormItem>

View file

@ -4,7 +4,8 @@ import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Checkbox } from "~/client/components/ui/checkbox";
import { Textarea } from "~/client/components/ui/textarea";
import { WebhookRequestPreview } from "~/client/components/webhook-request-preview";
import { CodeBlock } from "~/client/components/ui/code-block";
import { Label } from "~/client/components/ui/label";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
@ -15,7 +16,7 @@ const WebhookPreview = ({ values }: { values: Partial<NotificationFormValues> })
if (values.type !== "generic") return null;
const contentType = values.contentType || "application/json";
const headers = values.headers?.filter(Boolean) || [];
const headers = values.headers || [];
const useJson = values.useJson;
const titleKey = values.titleKey || "title";
const messageKey = values.messageKey || "message";
@ -34,14 +35,16 @@ const WebhookPreview = ({ values }: { values: Partial<NotificationFormValues> })
body = "Notification message";
}
const previewCode = `${values.method} ${values.url}\nContent-Type: ${contentType}${headers.length > 0 ? `\n${headers.join("\n")}` : ""}
${body}`;
return (
<WebhookRequestPreview
method={values.method || "POST"}
url={values.url}
contentType={contentType}
headers={headers}
body={body}
/>
<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>
);
};
@ -60,7 +63,7 @@ export const GenericForm = ({ form }: Props) => {
<FormControl>
<Input {...field} placeholder="https://api.example.com/webhook" />
</FormControl>
<FormDescription>The target origin must be listed in WEBHOOK_ALLOWED_ORIGINS.</FormDescription>
<FormDescription>The target URL for the webhook.</FormDescription>
<FormMessage />
</FormItem>
)}
@ -110,19 +113,10 @@ export const GenericForm = ({ form }: Props) => {
{...field}
placeholder="Authorization: Bearer token&#10;X-Custom-Header: value"
value={Array.isArray(field.value) ? field.value.join("\n") : ""}
onChange={(e) =>
field.onChange(
e.target.value
.split("\n")
.map((header) => header.trim())
.filter(Boolean),
)
}
onChange={(e) => field.onChange(e.target.value.split("\n"))}
/>
</FormControl>
<FormDescription>
One header per line in Key: Value format. Values are stored as plain text.
</FormDescription>
<FormDescription>One header per line in Key: Value format.</FormDescription>
<FormMessage />
</FormItem>
)}

View file

@ -20,9 +20,7 @@ export const GotifyForm = ({ form }: Props) => {
<FormControl>
<Input {...field} placeholder="https://gotify.example.com" />
</FormControl>
<FormDescription>
Your self-hosted Gotify server URL. Its origin must be listed in WEBHOOK_ALLOWED_ORIGINS.
</FormDescription>
<FormDescription>Your self-hosted Gotify server URL.</FormDescription>
<FormMessage />
</FormItem>
)}

View file

@ -21,10 +21,7 @@ export const NtfyForm = ({ form }: Props) => {
<FormControl>
<Input {...field} placeholder="https://ntfy.example.com" />
</FormControl>
<FormDescription>
Leave empty to use ntfy.sh public service. Self-hosted ntfy origins must be listed in
WEBHOOK_ALLOWED_ORIGINS.
</FormDescription>
<FormDescription>Leave empty to use ntfy.sh public service.</FormDescription>
<FormMessage />
</FormItem>
)}

View file

@ -27,6 +27,20 @@ export const SlackForm = ({ form }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="channel"
render={({ field }) => (
<FormItem>
<FormLabel>Channel (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="#backups" />
</FormControl>
<FormDescription>Override the default channel (use # for channels, @ for users).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"

View file

@ -40,8 +40,6 @@ import {
Lock,
Mail,
AtSign,
AlertCircle,
Clock,
MessageSquare,
Pencil,
Server,
@ -57,7 +55,6 @@ import type { GetNotificationDestinationResponse } from "~/client/api-client/typ
import { useTimeFormat } from "~/client/lib/datetime";
type NotificationConfig = GetNotificationDestinationResponse["config"];
type NotificationStatus = GetNotificationDestinationResponse["status"];
type Props = {
icon: React.ReactNode;
label: string;
@ -75,23 +72,13 @@ function ConfigRow({ icon, label, value, mono }: Props) {
);
}
function getStatusLabel(enabled: boolean, status: NotificationStatus) {
return enabled ? status : "disabled";
}
function getStatusClass(enabled: boolean, status: NotificationStatus) {
if (!enabled) return "bg-gray-500";
if (status === "healthy") return "bg-success";
if (status === "error") return "bg-red-500";
return "bg-yellow-500";
}
function NotificationConfigRows({ config }: { config: NotificationConfig }) {
switch (config.type) {
case "slack":
return (
<>
<ConfigRow icon={<Globe className="h-4 w-4" />} label="Webhook URL" value={config.webhookUrl} mono />
<ConfigRow icon={<Hash className="h-4 w-4" />} label="Channel" value={config.channel || "—"} />
<ConfigRow icon={<Bot className="h-4 w-4" />} label="Bot Username" value={config.username || "—"} />
<ConfigRow icon={<Smile className="h-4 w-4" />} label="Icon Emoji" value={config.iconEmoji || "—"} />
</>
@ -242,8 +229,13 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st
<h2 className="text-lg font-semibold tracking-tight">{data.name}</h2>
<Separator orientation="vertical" className="h-4 mx-1" />
<Badge variant="outline" className="capitalize gap-1.5">
<span className={cn("w-2 h-2 rounded-full shrink-0", getStatusClass(data.enabled, data.status))} />
{getStatusLabel(data.enabled, data.status)}
<span
className={cn("w-2 h-2 rounded-full shrink-0", {
"bg-success": data.enabled,
"bg-red-500": !data.enabled,
})}
/>
{data.enabled ? "Enabled" : "Disabled"}
</Badge>
<Badge variant="secondary" className="capitalize">
{data.type}
@ -298,34 +290,6 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st
<NotificationConfigRows config={data.config} />
</div>
</Card>
<Card className="px-6 py-6">
<CardTitle className="flex items-center gap-2 mb-5">
<Clock className="h-4 w-4 text-muted-foreground" />
Delivery Health
</CardTitle>
<div className="space-y-0 divide-y divide-border/50">
<ConfigRow
icon={<Bell className="h-4 w-4" />}
label="Status"
value={getStatusLabel(data.enabled, data.status)}
/>
<ConfigRow icon={<Settings className="h-4 w-4" />} label="Enabled" value={data.enabled ? "Yes" : "No"} />
<ConfigRow
icon={<Clock className="h-4 w-4" />}
label="Last Checked"
value={data.lastChecked ? formatDateTime(data.lastChecked) : "Never"}
/>
</div>
</Card>
<Card className={cn("px-6 py-6", { hidden: !data.lastError })}>
<CardTitle className="flex items-center gap-2 mb-3 text-red-600">
<AlertCircle className="h-4 w-4" />
Last Delivery Error
</CardTitle>
<p className="text-sm text-muted-foreground wrap-break-word">{data.lastError}</p>
</Card>
</div>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>

View file

@ -1,18 +1,6 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
type ColumnDef,
type ColumnFiltersState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { Bell, Plus, RotateCcw } from "lucide-react";
import { useState } from "react";
import { listNotificationDestinationsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { DataTableSortHeader } from "~/client/components/data-table-sort-header";
import { EmptyState } from "~/client/components/empty-state";
import { StatusDot } from "~/client/components/status-dot";
import { Button } from "~/client/components/ui/button";
@ -20,53 +8,20 @@ import { Card } from "~/client/components/ui/card";
import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { listNotificationDestinationsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { useNavigate } from "@tanstack/react-router";
import { cn } from "~/client/lib/utils";
type NotificationRow = {
id: number;
name: string;
type: "email" | "slack" | "discord" | "gotify" | "ntfy" | "pushover" | "telegram" | "custom" | "generic";
enabled: boolean;
status: "healthy" | "error" | "unknown";
};
const getNotificationStatus = (row: NotificationRow) => (row.enabled ? row.status : "disabled");
const getNotificationStatusVariant = (row: NotificationRow) => {
if (!row.enabled) return "neutral";
if (row.status === "healthy") return "success";
if (row.status === "error") return "error";
return "warning";
};
const notificationColumns: ColumnDef<NotificationRow>[] = [
{
accessorKey: "name",
header: ({ column }) => <DataTableSortHeader column={column} title="Name" sortDirection={column.getIsSorted()} />,
cell: ({ row }) => row.original.name,
},
{
accessorKey: "type",
header: ({ column }) => <DataTableSortHeader column={column} title="Type" sortDirection={column.getIsSorted()} />,
cell: ({ row }) => row.original.type,
filterFn: (row, id, value) => row.getValue(id) === value,
},
{
accessorFn: getNotificationStatus,
id: "status",
header: ({ column }) => (
<DataTableSortHeader column={column} title="Status" sortDirection={column.getIsSorted()} center />
),
cell: ({ row }) => (
<StatusDot variant={getNotificationStatusVariant(row.original)} label={getNotificationStatus(row.original)} />
),
filterFn: (row, id, value) => row.getValue(id) === value,
},
];
export function NotificationsPage() {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [sorting, setSorting] = useState<SortingState>([]);
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const clearFilters = () => {
setSearchQuery("");
setTypeFilter("");
setStatusFilter("");
};
const navigate = useNavigate();
@ -74,24 +29,15 @@ export function NotificationsPage() {
...listNotificationDestinationsOptions(),
});
const table = useReactTable({
data,
columns: notificationColumns,
state: { columnFilters, sorting },
onColumnFiltersChange: setColumnFilters,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
const filteredNotifications = data.filter((notification) => {
const matchesSearch = notification.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesType = !typeFilter || notification.type === typeFilter;
const matchesStatus = !statusFilter || (statusFilter === "enabled" ? notification.enabled : !notification.enabled);
return matchesSearch && matchesType && matchesStatus;
});
const rows = table.getRowModel().rows;
const hasFilters = columnFilters.length > 0;
const clearFilters = () => table.resetColumnFilters();
const hasNoNotifications = data.length === 0;
const hasNoFilteredNotifications = rows.length === 0 && !hasNoNotifications;
const hasNoFilteredNotifications = filteredNotifications.length === 0 && !hasNoNotifications;
if (hasNoNotifications) {
return (
@ -109,10 +55,6 @@ export function NotificationsPage() {
);
}
const search = (table.getColumn("name")?.getFilterValue() as string) ?? "";
const type = (table.getColumn("type")?.getFilterValue() as string) ?? "";
const status = (table.getColumn("status")?.getFilterValue() as string) ?? "";
return (
<Card className="p-0 gap-0">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
@ -120,10 +62,10 @@ export function NotificationsPage() {
<Input
className="w-full lg:w-45 min-w-45"
placeholder="Search…"
value={search}
onChange={(e) => table.getColumn("name")?.setFilterValue(e.target.value)}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Select value={type} onValueChange={(value) => table.getColumn("type")?.setFilterValue(value)}>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-full lg:w-45 min-w-45">
<SelectValue placeholder="All types" />
</SelectTrigger>
@ -135,22 +77,19 @@ export function NotificationsPage() {
<SelectItem value="ntfy">Ntfy</SelectItem>
<SelectItem value="pushover">Pushover</SelectItem>
<SelectItem value="telegram">Telegram</SelectItem>
<SelectItem value="generic">Generic</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
<Select value={status} onValueChange={(value) => table.getColumn("status")?.setFilterValue(value)}>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full lg:w-45 min-w-45">
<SelectValue placeholder="All status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="healthy">Healthy</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="unknown">Unknown</SelectItem>
<SelectItem value="enabled">Enabled</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
{hasFilters && (
{(searchQuery || typeFilter || statusFilter) && (
<Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2">
<RotateCcw className="h-4 w-4 mr-2" />
Clear filters
@ -165,22 +104,11 @@ export function NotificationsPage() {
<div className="overflow-x-auto">
<Table className="border-t">
<TableHeader className="bg-card-header">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={cn("uppercase", {
"w-25": header.column.id === "name",
"text-left": header.column.id === "type",
"text-center": header.column.id === "status",
})}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
<TableRow>
<TableHead className="w-25 uppercase">Name</TableHead>
<TableHead className="uppercase text-left">Type</TableHead>
<TableHead className="uppercase text-center">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className={cn({ hidden: !hasNoFilteredNotifications })}>
@ -194,38 +122,30 @@ export function NotificationsPage() {
</div>
</TableCell>
</TableRow>
{rows.map((row) => (
{filteredNotifications.map((notification) => (
<TableRow
key={row.original.id}
key={notification.id}
className="hover:bg-accent/50 hover:cursor-pointer h-12"
onClick={() => navigate({ to: `/notifications/${row.original.id}` })}
onClick={() => navigate({ to: `/notifications/${notification.id}` })}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cn({
"font-medium text-strong-accent": cell.column.id === "name",
"capitalize text-muted-foreground": cell.column.id === "type",
"text-center": cell.column.id === "status",
})}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
<TableCell className="font-medium text-strong-accent">{notification.name}</TableCell>
<TableCell className="capitalize text-muted-foreground">{notification.type}</TableCell>
<TableCell className="text-center">
<StatusDot
variant={notification.enabled ? "success" : "neutral"}
label={notification.enabled ? "Enabled" : "Disabled"}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t">
{hasNoFilteredNotifications ? (
"No destinations match filters."
) : (
<span>
<span className="text-strong-accent">{rows.length}</span> destination
{rows.length !== 1 ? "s" : ""}
</span>
)}
<span>
<span className="text-strong-accent">{filteredNotifications.length}</span> destination
{filteredNotifications.length !== 1 ? "s" : ""}
</span>
</div>
</Card>
);

View file

@ -92,13 +92,7 @@ const defaultValuesForType = (repoBase: string) => ({
azure: { backend: "azure" as const, compressionMode: "auto" as const },
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
rest: { backend: "rest" as const, compressionMode: "auto" as const },
sftp: {
backend: "sftp" as const,
compressionMode: "auto" as const,
port: 22,
skipHostKeyCheck: false,
allowLegacySshRsa: false,
},
sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22, skipHostKeyCheck: false },
});
export const CreateRepositoryForm = ({
@ -257,9 +251,7 @@ export const CreateRepositoryForm = ({
</FormControl>
<div className="space-y-1">
<FormLabel>Import existing repository</FormLabel>
<FormDescription>
Check this if the repository already exists at the specified location
</FormDescription>
<FormDescription>Check this if the repository already exists at the specified location</FormDescription>
</div>
</FormItem>
)}
@ -289,8 +281,8 @@ export const CreateRepositoryForm = ({
</SelectContent>
</Select>
<FormDescription>
Choose whether to use Zerobyte's recovery key (which you downloaded when creating your
account) or enter a custom password for the existing repository.
Choose whether to use Zerobyte's recovery key (which you downloaded when creating your account) or enter
a custom password for the existing repository.
</FormDescription>
</FormItem>

View file

@ -26,7 +26,6 @@ export const AdvancedForm = ({ form }: Props) => {
const cacert = useWatch({ control: form.control, name: "cacert" });
const uploadLimitEnabled = useWatch({ control: form.control, name: "uploadLimit.enabled" });
const downloadLimitEnabled = useWatch({ control: form.control, name: "downloadLimit.enabled" });
const backend = useWatch({ control: form.control, name: "backend" });
return (
<Collapsible>
@ -45,9 +44,7 @@ export const AdvancedForm = ({ form }: Props) => {
</FormControl>
<div className="space-y-1">
<FormLabel>Enable upload speed limit</FormLabel>
<FormDescription className="text-xs">
Limit upload speed to the repository
</FormDescription>
<FormDescription className="text-xs">Limit upload speed to the repository</FormDescription>
</div>
</FormItem>
)}
@ -69,9 +66,7 @@ export const AdvancedForm = ({ form }: Props) => {
placeholder="10"
className="pr-12"
{...field}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 1)
}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 1)}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
<div className="h-4 w-px bg-border" />
@ -125,9 +120,7 @@ export const AdvancedForm = ({ form }: Props) => {
</FormControl>
<div className="space-y-1">
<FormLabel>Enable download speed limit</FormLabel>
<FormDescription className="text-xs">
Limit download speed from the repository
</FormDescription>
<FormDescription className="text-xs">Limit download speed from the repository</FormDescription>
</div>
</FormItem>
)}
@ -151,9 +144,7 @@ export const AdvancedForm = ({ form }: Props) => {
disabled={!downloadLimitEnabled}
className="pr-12"
{...field}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 1)
}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 1)}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
<div className="h-4 w-px bg-border" />
@ -217,8 +208,8 @@ export const AdvancedForm = ({ form }: Props) => {
</TooltipTrigger>
<TooltipContent className={cn({ hidden: !cacert })}>
<p className="max-w-xs">
This option is disabled because a CA certificate is provided. Remove the CA
certificate to skip TLS validation instead.
This option is disabled because a CA certificate is provided. Remove the CA certificate to skip
TLS validation instead.
</p>
</TooltipContent>
</Tooltip>
@ -226,8 +217,8 @@ export const AdvancedForm = ({ form }: Props) => {
<div className="space-y-1 leading-none">
<FormLabel>Skip TLS certificate verification</FormLabel>
<FormDescription>
Disable TLS certificate verification for HTTPS connections with self-signed
certificates. This is insecure and should only be used for testing.
Disable TLS certificate verification for HTTPS connections with self-signed certificates. This is
insecure and should only be used for testing.
</FormDescription>
</div>
</FormItem>
@ -244,9 +235,7 @@ export const AdvancedForm = ({ form }: Props) => {
<TooltipTrigger asChild>
<div>
<Textarea
placeholder={
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
}
placeholder={"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"}
rows={6}
disabled={insecureTls}
{...field}
@ -255,8 +244,8 @@ export const AdvancedForm = ({ form }: Props) => {
</TooltipTrigger>
<TooltipContent className={cn({ hidden: !insecureTls })}>
<p className="max-w-xs">
CA certificate is disabled because TLS validation is being skipped. Uncheck
"Skip TLS Certificate Verification" to provide a custom CA certificate.
CA certificate is disabled because TLS validation is being skipped. Uncheck "Skip TLS Certificate
Verification" to provide a custom CA certificate.
</p>
</TooltipContent>
</Tooltip>
@ -277,26 +266,6 @@ export const AdvancedForm = ({ form }: Props) => {
</FormItem>
)}
/>
{backend === "sftp" && (
<FormField
control={form.control}
name="allowLegacySshRsa"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Allow legacy SSH RSA/SHA1 algorithms</FormLabel>
<FormDescription>
Only enable this for legacy SFTP servers that offer <code>ssh-rsa</code> only.
It permits RSA/SHA1 signatures, which are weaker than modern SSH algorithms.
</FormDescription>
</div>
<FormControl>
<Checkbox checked={field.value ?? false} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
)}
</CollapsibleContent>
</Collapsible>
);

View file

@ -1,18 +1,7 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
type ColumnDef,
type ColumnFiltersState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { Database, Plus, RotateCcw } from "lucide-react";
import { useState } from "react";
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { DataTableSortHeader } from "~/client/components/data-table-sort-header";
import { RepositoryIcon } from "~/client/components/repository-icon";
import { Button } from "~/client/components/ui/button";
import { Card } from "~/client/components/ui/card";
@ -23,73 +12,17 @@ import { cn } from "~/client/lib/utils";
import { StatusDot } from "~/client/components/status-dot";
import { EmptyState } from "~/client/components/empty-state";
import { useNavigate } from "@tanstack/react-router";
import type { RepositoryBackend } from "@zerobyte/core/restic";
type RepositoryRow = {
id: string;
shortId: string;
name: string;
type: RepositoryBackend;
status: string | null;
compressionMode?: string | null;
};
const repositoryColumns: ColumnDef<RepositoryRow>[] = [
{
accessorKey: "name",
header: ({ column }) => <DataTableSortHeader column={column} title="Name" sortDirection={column.getIsSorted()} />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span>{row.original.name}</span>
</div>
),
},
{
accessorKey: "type",
header: ({ column }) => (
<DataTableSortHeader column={column} title="Backend" sortDirection={column.getIsSorted()} />
),
cell: ({ row }) => (
<span className="flex items-center gap-2 text-muted-foreground">
<RepositoryIcon backend={row.original.type} />
{row.original.type}
</span>
),
filterFn: (row, id, value) => row.getValue(id) === value,
},
{
accessorFn: (row) => row.compressionMode || "off",
id: "compressionMode",
header: ({ column }) => (
<DataTableSortHeader column={column} title="Compression" sortDirection={column.getIsSorted()} />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs bg-primary/10 rounded-md px-2 py-1">
{row.original.compressionMode || "off"}
</span>
),
sortingFn: "alphanumeric",
},
{
accessorFn: (row) => row.status || "unknown",
id: "status",
header: ({ column }) => (
<DataTableSortHeader column={column} title="Status" sortDirection={column.getIsSorted()} center />
),
cell: ({ row }) => (
<StatusDot
variant={row.original.status === "healthy" ? "success" : row.original.status === "error" ? "error" : "warning"}
label={row.original.status || "unknown"}
/>
),
sortingFn: "alphanumeric",
filterFn: (row, id, value) => row.getValue(id) === value,
},
];
export function RepositoriesPage() {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [sorting, setSorting] = useState<SortingState>([{ id: "name", desc: false }]);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [backendFilter, setBackendFilter] = useState("");
const clearFilters = () => {
setSearchQuery("");
setStatusFilter("");
setBackendFilter("");
};
const navigate = useNavigate();
@ -97,23 +30,15 @@ export function RepositoriesPage() {
...listRepositoriesOptions(),
});
const table = useReactTable({
data,
columns: repositoryColumns,
state: { columnFilters, sorting },
onColumnFiltersChange: setColumnFilters,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
const filteredRepositories = data.filter((repository) => {
const matchesSearch = repository.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = !statusFilter || repository.status === statusFilter;
const matchesBackend = !backendFilter || repository.type === backendFilter;
return matchesSearch && matchesStatus && matchesBackend;
});
const rows = table.getRowModel().rows;
const hasFilters = columnFilters.length > 0;
const clearFilters = () => table.resetColumnFilters();
const hasNoRepositories = data.length === 0;
const hasNoFilteredRepositories = rows.length === 0 && !hasNoRepositories;
const hasNoFilteredRepositories = filteredRepositories.length === 0 && !hasNoRepositories;
if (hasNoRepositories) {
return (
@ -131,10 +56,6 @@ export function RepositoriesPage() {
);
}
const search = (table.getColumn("name")?.getFilterValue() as string) ?? "";
const type = (table.getColumn("type")?.getFilterValue() as string) ?? "";
const status = (table.getColumn("status")?.getFilterValue() as string) ?? "";
return (
<Card className="p-0 gap-0">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
@ -142,10 +63,10 @@ export function RepositoriesPage() {
<Input
className="w-full lg:w-45 min-w-45"
placeholder="Search…"
value={search}
onChange={(e) => table.getColumn("name")?.setFilterValue(e.target.value)}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Select value={status} onValueChange={(value) => table.getColumn("status")?.setFilterValue(value)}>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full lg:w-45 min-w-45">
<SelectValue placeholder="All status" />
</SelectTrigger>
@ -155,7 +76,7 @@ export function RepositoriesPage() {
<SelectItem value="unknown">Unknown</SelectItem>
</SelectContent>
</Select>
<Select value={type} onValueChange={(value) => table.getColumn("type")?.setFilterValue(value)}>
<Select value={backendFilter} onValueChange={setBackendFilter}>
<SelectTrigger className="w-full lg:w-45 min-w-45">
<SelectValue placeholder="All backends" />
</SelectTrigger>
@ -166,7 +87,7 @@ export function RepositoriesPage() {
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
</SelectContent>
</Select>
{hasFilters && (
{(searchQuery || statusFilter || backendFilter) && (
<Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2">
<RotateCcw className="h-4 w-4 mr-2" />
Clear filters
@ -181,23 +102,12 @@ export function RepositoriesPage() {
<div className="overflow-x-auto">
<Table className="border-t">
<TableHeader className="bg-card-header">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={cn("uppercase", {
"w-25": header.column.id === "name",
"text-left": header.column.id === "type",
"hidden sm:table-cell": header.column.id === "compressionMode",
"text-center": header.column.id === "status",
})}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
<TableRow>
<TableHead className="w-25 uppercase">Name</TableHead>
<TableHead className="uppercase text-left">Backend</TableHead>
<TableHead className="uppercase hidden sm:table-cell">Compression</TableHead>
<TableHead className="uppercase text-center">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className={cn({ hidden: !hasNoFilteredRepositories })}>
@ -211,24 +121,38 @@ export function RepositoriesPage() {
</div>
</TableCell>
</TableRow>
{rows.map((row) => (
{filteredRepositories.map((repository) => (
<TableRow
key={row.original.id}
key={repository.id}
className="hover:bg-accent/50 hover:cursor-pointer h-12"
onClick={() => navigate({ to: `/repositories/${row.original.shortId}` })}
onClick={() => navigate({ to: `/repositories/${repository.shortId}` })}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cn({
"font-medium text-strong-accent": cell.column.id === "name",
"hidden sm:table-cell": cell.column.id === "compressionMode",
"text-center": cell.column.id === "status",
})}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
<TableCell className="font-medium text-strong-accent">
<div className="flex items-center gap-2">
<span>{repository.name}</span>
</div>
</TableCell>
<TableCell>
<span className="flex items-center gap-2 text-muted-foreground">
<RepositoryIcon backend={repository.type} />
{repository.type}
</span>
</TableCell>
<TableCell className="hidden sm:table-cell">
<span className="text-muted-foreground text-xs bg-primary/10 rounded-md px-2 py-1">
{repository.compressionMode || "off"}
</span>
</TableCell>
<TableCell className="text-center">
<StatusDot
variant={
repository.status === "healthy" ? "success" : repository.status === "error" ? "error" : "warning"
}
label={
repository.status ? repository.status[0].toUpperCase() + repository.status.slice(1) : "Unknown"
}
/>
</TableCell>
</TableRow>
))}
</TableBody>
@ -239,8 +163,8 @@ export function RepositoriesPage() {
"No repositories match filters."
) : (
<span>
<span className="text-strong-accent">{rows.length}</span> repositor
{rows.length === 1 ? "y" : "ies"}
<span className="text-strong-accent">{filteredRepositories.length}</span> repositor
{filteredRepositories.length === 1 ? "y" : "ies"}
</span>
)}
</div>

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