Compare commits

..

10 commits

Author SHA1 Message Date
Nicolas Meienberger
e870cdc815 WIP 2026-02-24 19:02:25 +01:00
Nicolas Meienberger
d4f585d5c8 chore: fix lint issues 2026-02-24 11:55:05 +01:00
Nicolas Meienberger
2d2183baaf refactor: cleanup / pr review 2026-02-24 11:55:05 +01:00
Nicolas Meienberger
5e4f3fca62 refactor: restrict provider configuration to super admins only 2026-02-24 11:55:05 +01:00
Nicolas Meienberger
efc112820d refactor: user existing check 2026-02-24 11:55:05 +01:00
Nicolas Meienberger
a30af6026d refactor: code style 2026-02-24 11:55:05 +01:00
Nicolas Meienberger
de34fd841c feat: per-user account management 2026-02-24 11:55:05 +01:00
Nicolas Meienberger
0bf0094dbd refactor: own page for sso registration 2026-02-24 11:55:05 +01:00
Nicolas Meienberger
4619d9d976 feat: link current account 2026-02-24 11:55:05 +01:00
Nicolas Meienberger
a3af29ec4e feat: oidc
feat: organization switcher

refactor: org context

feat: invitations

GLM
2026-02-24 11:55:05 +01:00
729 changed files with 14751 additions and 89862 deletions

View file

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

View file

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

View file

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

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

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

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

19
.github/renovate.json vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

14
.gitignore vendored
View file

@ -15,7 +15,7 @@ notes.md
smb-password.txt smb-password.txt
cache.db cache.db
/data data/
.env* .env*
!.env.example !.env.example
@ -28,25 +28,13 @@ node_modules/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/playwright/.auth/ /playwright/.auth/
/playwright/*.pass
/playwright/tinyauth/app-data/
/playwright/tinyauth/caddy-data/
playwright/.auth playwright/.auth
playwright/temp playwright/temp
playwright/data
.idea/ .idea/
.deepsec/
.codex/
# OpenAPI error logs # OpenAPI error logs
openapi-ts-error-*.log openapi-ts-error-*.log
.output .output
tmp/ tmp/
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"]
}

152
.oxlintrc.json Normal file
View file

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

View file

@ -1 +0,0 @@
vp staged

View file

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

View file

@ -1,30 +1,15 @@
# AGENTS.md
## Important instructions ## Important instructions
- Never create migration files manually. Always use the provided command to generate migrations - Never create migration files manually. Always use the provided command to generate migrations
- If you realize an automated migration is incorrect, make sure to remove all the associated entries from the `_journal.json` and the newly created files located in `app/drizzle/` before re-generating the migration - If you realize an automated migration is incorrect, make sure to remove all the associated entries from the `_journal.json` and the newly created files located in `app/drizzle/` before re-generating the migration
- The dev server runs through Portless. Start it with `bun run dev`, then use `portless get zerobyte` to get the current worktree-specific URL. Do not assume a fixed port like `3000` or `4096`. Username is `admin` and password is `password` - 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`
## Project Overview ## Project Overview
Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, SFTP, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage). Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, SFTP, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage).
### Development Server
```bash
# Start the dev server through Portless
bun run dev
# Get the current app URL for this worktree
portless get zerobyte
# Inspect active Portless routes if needed
portless list
```
Portless applies git worktree prefixes automatically, so linked worktrees may return URLs like `https://branch-name.zerobyte.localhost`. Use the Portless URL for browser testing and manual verification.
### Type Checking ### Type Checking
```bash ```bash
@ -39,7 +24,7 @@ bun run tsc
bun run test bun run test
# Run a specific test file # Run a specific test file
bunx dotenv-cli -e .env.test -- bunx --bun vitest run --project server path/to/test.ts bunx dotenv-cli -e .env.test -- bun test --preload ./app/test/setup.ts path/to/test.ts
``` ```
### Building ### Building
@ -70,7 +55,7 @@ bun run gen:api-client
```bash ```bash
# Format # Format
vp fmt <path> --write bunx oxfmt format --write <path>
# Lint # Lint
bun run lint bun run lint

View file

@ -1,8 +1,10 @@
FROM oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0 AS base ARG BUN_VERSION="1.3.6"
FROM oven/bun:${BUN_VERSION}-alpine AS base
ARG RESTIC_VERSION="0.18.1" ARG RESTIC_VERSION="0.18.1"
ARG RCLONE_VERSION="1.74.2" ARG RCLONE_VERSION="1.73.0"
ARG SHOUTRRR_VERSION="0.15.1" ARG SHOUTRRR_VERSION="0.13.2"
ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
VITE_RCLONE_VERSION=${RCLONE_VERSION} \ VITE_RCLONE_VERSION=${RCLONE_VERSION} \
@ -10,7 +12,7 @@ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
RUN apk update --no-cache && \ RUN apk update --no-cache && \
apk upgrade --no-cache && \ apk upgrade --no-cache && \
apk add --no-cache acl attr cifs-utils davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini
ENTRYPOINT ["/sbin/tini", "-s", "--"] ENTRYPOINT ["/sbin/tini", "-s", "--"]
@ -29,30 +31,21 @@ RUN apk add --no-cache curl bzip2 unzip tar
RUN echo "Building for ${TARGETARCH}" RUN echo "Building for ${TARGETARCH}"
RUN if [ "${TARGETARCH}" = "arm64" ]; then \ RUN if [ "${TARGETARCH}" = "arm64" ]; then \
curl -fL -o restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_arm64.bz2"; \ curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_arm64.bz2"; \
curl -fL -o rclone.zip "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-arm64.zip"; \ curl -L -o rclone.zip "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-arm64.zip"; \
unzip rclone.zip; \ unzip rclone.zip; \
curl -fL -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v${SHOUTRRR_VERSION}/shoutrrr_linux_arm64v8_${SHOUTRRR_VERSION}.tar.gz"; \ curl -L -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v${SHOUTRRR_VERSION}/shoutrrr_linux_arm64v8_${SHOUTRRR_VERSION}.tar.gz"; \
elif [ "${TARGETARCH}" = "amd64" ]; then \ elif [ "${TARGETARCH}" = "amd64" ]; then \
curl -fL -o restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_amd64.bz2"; \ curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_amd64.bz2"; \
curl -fL -o rclone.zip "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-amd64.zip"; \ curl -L -o rclone.zip "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-amd64.zip"; \
unzip rclone.zip; \ unzip rclone.zip; \
curl -fL -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v$SHOUTRRR_VERSION/shoutrrr_linux_amd64_${SHOUTRRR_VERSION}.tar.gz"; \ curl -L -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v$SHOUTRRR_VERSION/shoutrrr_linux_amd64_${SHOUTRRR_VERSION}.tar.gz"; \
fi fi
RUN bzip2 -d restic.bz2 && chmod +x restic RUN bzip2 -d restic.bz2 && chmod +x restic
RUN mv rclone-v*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone RUN mv rclone-v*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone
RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr
# ------------------------------
# RUNTIME TOOLS
# ------------------------------
FROM base AS runtime-tools
COPY --from=deps /deps/restic /usr/local/bin/restic
COPY --from=deps /deps/rclone /usr/local/bin/rclone
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
# ------------------------------ # ------------------------------
# DEVELOPMENT # DEVELOPMENT
# ------------------------------ # ------------------------------
@ -70,12 +63,8 @@ COPY --from=deps /deps/rclone /usr/local/bin/rclone
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
COPY ./package.json ./bun.lock ./ COPY ./package.json ./bun.lock ./
COPY ./packages/core/package.json ./packages/core/package.json
COPY ./packages/contracts/package.json ./packages/contracts/package.json
COPY ./apps/agent/package.json ./apps/agent/package.json
COPY ./apps/docs/package.json ./apps/docs/package.json
RUN VITE_GIT_HOOKS=0 bun install --frozen-lockfile --ignore-scripts RUN bun install --frozen-lockfile --ignore-scripts
COPY . . COPY . .
@ -95,17 +84,11 @@ ENV PORT=4096
WORKDIR /app WORKDIR /app
COPY ./package.json ./bun.lock ./ COPY ./package.json ./bun.lock ./
COPY ./packages/core/package.json ./packages/core/package.json RUN bun install --frozen-lockfile
COPY ./packages/contracts/package.json ./packages/contracts/package.json
COPY ./apps/agent/package.json ./apps/agent/package.json
COPY ./apps/docs/package.json ./apps/docs/package.json
RUN VITE_GIT_HOOKS=0 bun install --frozen-lockfile
COPY . . COPY . .
RUN bun run build RUN bun run build
RUN bun build apps/agent/src/index.ts --outfile .output/agent/index.mjs --target bun
FROM base AS production FROM base AS production

162
README.md
View file

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

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:** **Diagnosis:**
```bash ```bash
# Check which config file rclone will use inside the container # Check if config is accessible inside container
docker exec zerobyte sh -lc 'echo HOME=$HOME; rclone config file' docker exec zerobyte ls -la /root/.config/rclone/
docker exec zerobyte cat /root/.config/rclone/rclone.conf
``` ```
**Solutions:** **Solutions:**

View file

@ -37,6 +37,7 @@ body {
} }
body { body {
@apply bg-[#131313];
min-height: 100dvh; min-height: 100dvh;
} }
@ -66,8 +67,6 @@ body {
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
@ -89,40 +88,38 @@ body {
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(0.98 0.002 250); --background: oklch(1 0 0);
--foreground: oklch(0.20 0.01 260); --foreground: oklch(0.145 0 0);
--card: #ffffff; --card: oklch(1 0 0);
--card-foreground: oklch(0.20 0.01 260); --card-foreground: oklch(0.145 0 0);
--card-header: oklch(0.99 0.001 260); --card-header: oklch(0.922 0 0);
--popover: #ffffff; --popover: oklch(1 0 0);
--popover-foreground: oklch(0.20 0.01 260); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.20 0.01 260); --primary: oklch(0.205 0 0);
--primary-foreground: #ffffff; --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.96 0.005 260); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.20 0.01 260); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.95 0.005 260); --muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.45 0.01 260); --muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.96 0.005 260); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.20 0.01 260); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: #fff; --border: oklch(0.922 0 0);
--success: oklch(0.57 0.16 148); --input: oklch(0.922 0 0);
--border: oklch(0.90 0.01 260); --ring: oklch(0.708 0 0);
--input: oklch(0.90 0.01 260); --chart-1: oklch(0.646 0.222 41.116);
--ring: oklch(0.7 0.01 260); --chart-2: oklch(0.6 0.118 184.704);
--chart-1: oklch(0.55 0.22 260); --chart-3: oklch(0.398 0.07 227.392);
--chart-2: oklch(0.6 0.15 200); --chart-4: oklch(0.828 0.189 84.429);
--chart-3: oklch(0.45 0.12 280); --chart-5: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.7 0.15 190); --sidebar: oklch(0.985 0 0);
--chart-5: oklch(0.55 0.18 300); --sidebar-foreground: oklch(0.145 0 0);
--sidebar: #ffffff; --sidebar-primary: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.35 0.01 260); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.20 0.01 260); --sidebar-accent: oklch(0.97 0 0);
--sidebar-primary-foreground: #ffffff; --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-accent: oklch(0.97 0.005 260); --sidebar-border: oklch(0.922 0 0);
--sidebar-accent-foreground: oklch(0.20 0.01 260); --sidebar-ring: oklch(0.708 0 0);
--sidebar-border: oklch(0.90 0.01 260);
--sidebar-ring: oklch(0.7 0.01 260);
--strong-accent: #ff543a; --strong-accent: #ff543a;
} }
@ -147,8 +144,6 @@ body {
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: #ff543a; --destructive: #ff543a;
--destructive-foreground: #fff;
--success: oklch(0.696 0.17 162.48);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
@ -179,11 +174,11 @@ body {
@layer base { @layer base {
:root { :root {
--chart-1: oklch(0.55 0.22 260); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.15 200); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.45 0.12 280); --chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.7 0.15 190); --chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.55 0.18 300); --chart-5: oklch(0.769 0.188 70.08);
} }
.dark { .dark {
@ -195,17 +190,6 @@ body {
} }
} }
.snapshot-scrollable::-webkit-scrollbar {
height: 6px;
}
.snapshot-scrollable::-webkit-scrollbar-track {
background: transparent;
}
.snapshot-scrollable::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 999px;
}
/* Hide built-in password reveal/clear controls (notably in Edge on Windows) */ /* Hide built-in password reveal/clear controls (notably in Edge on Windows) */
[data-secret-input] input[type="password"]::-ms-reveal, [data-secret-input] input[type="password"]::-ms-reveal,
[data-secret-input] input[type="password"]::-ms-clear { [data-secret-input] input[type="password"]::-ms-clear {

View file

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

View file

@ -4,8 +4,8 @@
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen'; import { client } from '../client.gen';
import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getMirrorSyncStatus, getNotificationDestination, getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, syncMirror, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen'; import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, devPanelExec, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, 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, refreshSnapshots, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetMirrorSyncStatusData, GetMirrorSyncStatusResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshRepositoryStatsData, RefreshRepositoryStatsResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, SyncMirrorData, SyncMirrorResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DevPanelExecData, DevPanelExecResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, 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, RefreshSnapshotsData, RefreshSnapshotsResponse, 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, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
export type QueryKey<TOptions extends Options> = [ export type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & { Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
@ -58,111 +58,6 @@ export const getStatusOptions = (options?: Options<GetStatusData>) => queryOptio
queryKey: getStatusQueryKey(options) queryKey: getStatusQueryKey(options)
}); });
export const getAdminUsersQueryKey = (options?: Options<GetAdminUsersData>) => createQueryKey('getAdminUsers', options);
/**
* List admin users for settings management
*/
export const getAdminUsersOptions = (options?: Options<GetAdminUsersData>) => queryOptions<GetAdminUsersResponse, DefaultError, GetAdminUsersResponse, ReturnType<typeof getAdminUsersQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getAdminUsers({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getAdminUsersQueryKey(options)
});
/**
* Delete an account linked to a user
*/
export const deleteUserAccountMutation = (options?: Partial<Options<DeleteUserAccountData>>): UseMutationOptions<unknown, DefaultError, Options<DeleteUserAccountData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<DeleteUserAccountData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteUserAccount({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getUserDeletionImpactQueryKey = (options: Options<GetUserDeletionImpactData>) => createQueryKey('getUserDeletionImpact', options);
/**
* Get impact of deleting a user
*/
export const getUserDeletionImpactOptions = (options: Options<GetUserDeletionImpactData>) => queryOptions<GetUserDeletionImpactResponse, DefaultError, GetUserDeletionImpactResponse, ReturnType<typeof getUserDeletionImpactQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getUserDeletionImpact({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getUserDeletionImpactQueryKey(options)
});
export const getOrgMembersQueryKey = (options?: Options<GetOrgMembersData>) => createQueryKey('getOrgMembers', options);
/**
* Get members of the active organization
*/
export const getOrgMembersOptions = (options?: Options<GetOrgMembersData>) => queryOptions<GetOrgMembersResponse, DefaultError, GetOrgMembersResponse, ReturnType<typeof getOrgMembersQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getOrgMembers({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getOrgMembersQueryKey(options)
});
/**
* Update a member's role in the active organization
*/
export const updateMemberRoleMutation = (options?: Partial<Options<UpdateMemberRoleData>>): UseMutationOptions<unknown, DefaultError, Options<UpdateMemberRoleData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<UpdateMemberRoleData>> = {
mutationFn: async (fnOptions) => {
const { data } = await updateMemberRole({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Remove a member from the active organization
*/
export const removeOrgMemberMutation = (options?: Partial<Options<RemoveOrgMemberData>>): UseMutationOptions<unknown, DefaultError, Options<RemoveOrgMemberData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<RemoveOrgMemberData>> = {
mutationFn: async (fnOptions) => {
const { data } = await removeOrgMember({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getPublicSsoProvidersQueryKey = (options?: Options<GetPublicSsoProvidersData>) => createQueryKey('getPublicSsoProviders', options); export const getPublicSsoProvidersQueryKey = (options?: Options<GetPublicSsoProvidersData>) => createQueryKey('getPublicSsoProviders', options);
/** /**
@ -250,6 +145,59 @@ export const deleteSsoInvitationMutation = (options?: Partial<Options<DeleteSsoI
return mutationOptions; return mutationOptions;
}; };
export const getAdminUsersQueryKey = (options?: Options<GetAdminUsersData>) => createQueryKey('getAdminUsers', options);
/**
* List admin users for settings management
*/
export const getAdminUsersOptions = (options?: Options<GetAdminUsersData>) => queryOptions<GetAdminUsersResponse, DefaultError, GetAdminUsersResponse, ReturnType<typeof getAdminUsersQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getAdminUsers({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getAdminUsersQueryKey(options)
});
/**
* Delete an account linked to a user
*/
export const deleteUserAccountMutation = (options?: Partial<Options<DeleteUserAccountData>>): UseMutationOptions<unknown, DefaultError, Options<DeleteUserAccountData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<DeleteUserAccountData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteUserAccount({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getUserDeletionImpactQueryKey = (options: Options<GetUserDeletionImpactData>) => createQueryKey('getUserDeletionImpact', options);
/**
* Get impact of deleting a user
*/
export const getUserDeletionImpactOptions = (options: Options<GetUserDeletionImpactData>) => queryOptions<GetUserDeletionImpactResponse, DefaultError, GetUserDeletionImpactResponse, ReturnType<typeof getUserDeletionImpactQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getUserDeletionImpact({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getUserDeletionImpactQueryKey(options)
});
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey('listVolumes', options); export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey('listVolumes', options);
/** /**
@ -457,7 +405,7 @@ export const listFilesInfiniteQueryKey = (options: Options<ListFilesData>): Quer
/** /**
* List files in a volume directory * List files in a volume directory
*/ */
export const listFilesInfiniteOptions = (options: Options<ListFilesData>) => infiniteQueryOptions<ListFilesResponse, DefaultError, InfiniteData<ListFilesResponse>, QueryKey<Options<ListFilesData>>, number | Pick<QueryKey<Options<ListFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>( export const listFilesInfiniteOptions = (options: Options<ListFilesData>) => infiniteQueryOptions<ListFilesResponse, DefaultError, InfiniteData<ListFilesResponse>, QueryKey<Options<ListFilesData>>, string | Pick<QueryKey<Options<ListFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
// @ts-ignore // @ts-ignore
{ {
queryFn: async ({ pageParam, queryKey, signal }) => { queryFn: async ({ pageParam, queryKey, signal }) => {
@ -620,23 +568,6 @@ export const getRepositoryStatsOptions = (options: Options<GetRepositoryStatsDat
queryKey: getRepositoryStatsQueryKey(options) queryKey: getRepositoryStatsQueryKey(options)
}); });
/**
* Refresh repository storage and compression statistics
*/
export const refreshRepositoryStatsMutation = (options?: Partial<Options<RefreshRepositoryStatsData>>): UseMutationOptions<RefreshRepositoryStatsResponse, DefaultError, Options<RefreshRepositoryStatsData>> => {
const mutationOptions: UseMutationOptions<RefreshRepositoryStatsResponse, DefaultError, Options<RefreshRepositoryStatsData>> = {
mutationFn: async (fnOptions) => {
const { data } = await refreshRepositoryStats({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/** /**
* Delete multiple snapshots from a repository * Delete multiple snapshots from a repository
*/ */
@ -747,7 +678,7 @@ export const listSnapshotFilesInfiniteQueryKey = (options: Options<ListSnapshotF
/** /**
* List files and directories in a snapshot * List files and directories in a snapshot
*/ */
export const listSnapshotFilesInfiniteOptions = (options: Options<ListSnapshotFilesData>) => infiniteQueryOptions<ListSnapshotFilesResponse, DefaultError, InfiniteData<ListSnapshotFilesResponse>, QueryKey<Options<ListSnapshotFilesData>>, number | Pick<QueryKey<Options<ListSnapshotFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>( export const listSnapshotFilesInfiniteOptions = (options: Options<ListSnapshotFilesData>) => infiniteQueryOptions<ListSnapshotFilesResponse, DefaultError, InfiniteData<ListSnapshotFilesResponse>, QueryKey<Options<ListSnapshotFilesData>>, string | Pick<QueryKey<Options<ListSnapshotFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
// @ts-ignore // @ts-ignore
{ {
queryFn: async ({ pageParam, queryKey, signal }) => { queryFn: async ({ pageParam, queryKey, signal }) => {
@ -872,6 +803,23 @@ export const tagSnapshotsMutation = (options?: Partial<Options<TagSnapshotsData>
return mutationOptions; return mutationOptions;
}; };
/**
* Execute a restic command against a repository (dev panel only)
*/
export const devPanelExecMutation = (options?: Partial<Options<DevPanelExecData>>): UseMutationOptions<DevPanelExecResponse, DefaultError, Options<DevPanelExecData>> => {
const mutationOptions: UseMutationOptions<DevPanelExecResponse, DefaultError, Options<DevPanelExecData>> = {
mutationFn: async (fnOptions) => {
const { data } = await devPanelExec({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) => createQueryKey('listBackupSchedules', options); export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) => createQueryKey('listBackupSchedules', options);
/** /**
@ -1098,41 +1046,6 @@ export const updateScheduleMirrorsMutation = (options?: Partial<Options<UpdateSc
return mutationOptions; return mutationOptions;
}; };
export const getMirrorSyncStatusQueryKey = (options: Options<GetMirrorSyncStatusData>) => createQueryKey('getMirrorSyncStatus', options);
/**
* Get sync status for a specific mirror, including missing snapshots
*/
export const getMirrorSyncStatusOptions = (options: Options<GetMirrorSyncStatusData>) => queryOptions<GetMirrorSyncStatusResponse, DefaultError, GetMirrorSyncStatusResponse, ReturnType<typeof getMirrorSyncStatusQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getMirrorSyncStatus({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getMirrorSyncStatusQueryKey(options)
});
/**
* Sync selected snapshots to a specific mirror repository
*/
export const syncMirrorMutation = (options?: Partial<Options<SyncMirrorData>>): UseMutationOptions<SyncMirrorResponse, DefaultError, Options<SyncMirrorData>> => {
const mutationOptions: UseMutationOptions<SyncMirrorResponse, DefaultError, Options<SyncMirrorData>> = {
mutationFn: async (fnOptions) => {
const { data } = await syncMirror({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getMirrorCompatibilityQueryKey = (options: Options<GetMirrorCompatibilityData>) => createQueryKey('getMirrorCompatibility', options); export const getMirrorCompatibilityQueryKey = (options: Options<GetMirrorCompatibilityData>) => createQueryKey('getMirrorCompatibility', options);
/** /**
@ -1168,24 +1081,6 @@ export const reorderBackupSchedulesMutation = (options?: Partial<Options<Reorder
return mutationOptions; return mutationOptions;
}; };
export const getBackupProgressQueryKey = (options: Options<GetBackupProgressData>) => createQueryKey('getBackupProgress', options);
/**
* Get the last known progress for a currently running backup. Returns null if no progress has been reported yet.
*/
export const getBackupProgressOptions = (options: Options<GetBackupProgressData>) => queryOptions<GetBackupProgressResponse, DefaultError, GetBackupProgressResponse, ReturnType<typeof getBackupProgressQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getBackupProgress({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getBackupProgressQueryKey(options)
});
export const listNotificationDestinationsQueryKey = (options?: Options<ListNotificationDestinationsData>) => createQueryKey('listNotificationDestinations', options); export const listNotificationDestinationsQueryKey = (options?: Options<ListNotificationDestinationsData>) => createQueryKey('listNotificationDestinations', options);
/** /**

View file

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

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
export type { Auth } from "../core/auth.gen"; export type { Auth } from "../core/auth.gen";

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { Auth } from '../core/auth.gen'; import type { Auth } from '../core/auth.gen';
@ -67,7 +66,6 @@ export interface RequestOptions<
}>, }>,
Pick< Pick<
ServerSentEventsOptions<TData>, ServerSentEventsOptions<TData>,
| 'onRequest'
| 'onSseError' | 'onSseError'
| 'onSseEvent' | 'onSseEvent'
| 'sseDefaultRetryDelay' | 'sseDefaultRetryDelay'
@ -94,7 +92,6 @@ export interface ResolvedRequestOptions<
ThrowOnError extends boolean = boolean, ThrowOnError extends boolean = boolean,
Url extends string = string, Url extends string = string,
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> { > extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
headers: Headers;
serializedBody?: string; serializedBody?: string;
} }
@ -128,10 +125,8 @@ export type RequestResult<
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError; error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
} }
) & { ) & {
/** request may be undefined, because error may be from building the request object itself */ request: Request;
request?: Request; response: Response;
/** response may be undefined, because error may be from building the request object itself or from a network error */
response?: Response;
} }
>; >;
@ -156,7 +151,7 @@ type SseFn = <
ThrowOnError extends boolean = false, ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields', TResponseStyle extends ResponseStyle = 'fields',
>( >(
options: Omit<RequestOptions<never, TResponseStyle, ThrowOnError>, 'method'>, options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
) => Promise<ServerSentEventsResult<TData, TError>>; ) => Promise<ServerSentEventsResult<TData, TError>>;
type RequestFn = < type RequestFn = <

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import { getAuthToken } from '../core/auth.gen'; import { getAuthToken } from '../core/auth.gen';
@ -119,12 +118,14 @@ const checkForExistence = (
return false; return false;
}; };
export async function setAuthParams( export const setAuthParams = async ({
options: Pick<RequestOptions, 'auth' | 'query' | 'security'> & { security,
...options
}: Pick<Required<RequestOptions>, 'security'> &
Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers; headers: Headers;
}, }) => {
): Promise<void> { for (const auth of security) {
for (const auth of options.security ?? []) {
if (checkForExistence(options, auth.name)) { if (checkForExistence(options, auth.name)) {
continue; continue;
} }
@ -153,7 +154,7 @@ export async function setAuthParams(
break; break;
} }
} }
} };
export const buildUrl: Client['buildUrl'] = (options) => export const buildUrl: Client['buildUrl'] = (options) =>
getUrl({ getUrl({
@ -203,7 +204,7 @@ export const mergeHeaders = (
mergedHeaders.append(key, v as string); mergedHeaders.append(key, v as string);
} }
} else if (value !== undefined) { } else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e., their // assume object headers are meant to be JSON stringified, i.e. their
// content value in OpenAPI specification is 'application/json' // content value in OpenAPI specification is 'application/json'
mergedHeaders.set( mergedHeaders.set(
key, key,
@ -217,10 +218,8 @@ export const mergeHeaders = (
type ErrInterceptor<Err, Res, Req, Options> = ( type ErrInterceptor<Err, Res, Req, Options> = (
error: Err, error: Err,
/** response may be undefined due to a network error where no response object is produced */ response: Res,
response: Res | undefined, request: Req,
/** request may be undefined, because error may be from building the request object itself */
request: Req | undefined,
options: Options, options: Options,
) => Err | Promise<Err>; ) => Err | Promise<Err>;

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
export type AuthToken = string | undefined; export type AuthToken = string | undefined;

View file

@ -1,11 +1,10 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen'; import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen';
export type QuerySerializer = (query: Record<string, unknown>) => string; export type QuerySerializer = (query: Record<string, unknown>) => string;
export type BodySerializer = (body: unknown) => unknown; export type BodySerializer = (body: any) => any;
type QuerySerializerOptionsObject = { type QuerySerializerOptionsObject = {
allowReserved?: boolean; allowReserved?: boolean;
@ -40,10 +39,12 @@ const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value:
}; };
export const formDataBodySerializer = { export const formDataBodySerializer = {
bodySerializer: (body: unknown): FormData => { bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): FormData => {
const data = new FormData(); const data = new FormData();
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => { Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return; return;
} }
@ -59,15 +60,15 @@ export const formDataBodySerializer = {
}; };
export const jsonBodySerializer = { export const jsonBodySerializer = {
bodySerializer: (body: unknown): string => bodySerializer: <T>(body: T): string =>
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
}; };
export const urlSearchParamsBodySerializer = { export const urlSearchParamsBodySerializer = {
bodySerializer: (body: unknown): string => { bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): string => {
const data = new URLSearchParams(); const data = new URLSearchParams();
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => { Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return; return;
} }

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
type Slot = 'body' | 'headers' | 'path' | 'query'; type Slot = 'body' | 'headers' | 'path' | 'query';
@ -97,7 +96,7 @@ interface Params {
const stripEmptySlots = (params: Params) => { const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) { for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) { if (value && typeof value === 'object' && !Object.keys(value).length) {
delete params[slot as Slot]; delete params[slot as Slot];
} }
} }

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {} interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
/** /**

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { Config } from './types.gen'; import type { Config } from './types.gen';
@ -80,7 +79,7 @@ export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unkn
>; >;
}; };
export function createSseClient<TData = unknown>({ export const createSseClient = <TData = unknown>({
onRequest, onRequest,
onSseError, onSseError,
onSseEvent, onSseEvent,
@ -92,7 +91,7 @@ export function createSseClient<TData = unknown>({
sseSleepFn, sseSleepFn,
url, url,
...options ...options
}: ServerSentEventsOptions): ServerSentEventsResult<TData> { }: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
let lastEventId: string | undefined; let lastEventId: string | undefined;
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
@ -156,7 +155,8 @@ export function createSseClient<TData = unknown>({
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
buffer += value; buffer += value;
buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings // Normalize line endings: CRLF -> LF, then CR -> LF
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const chunks = buffer.split('\n\n'); const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? ''; buffer = chunks.pop() ?? '';
@ -240,4 +240,4 @@ export function createSseClient<TData = unknown>({
const stream = createStream(); const stream = createStream();
return { stream }; return { stream };
} };

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { Auth, AuthToken } from './auth.gen'; import type { Auth, AuthToken } from './auth.gen';
@ -81,7 +80,7 @@ export interface Config {
requestValidator?: (data: unknown) => Promise<unknown>; requestValidator?: (data: unknown) => Promise<unknown>;
/** /**
* A function transforming response data before it's returned. This is useful * A function transforming response data before it's returned. This is useful
* for post-processing data, e.g., converting ISO strings into Date objects. * for post-processing data, e.g. converting ISO strings into Date objects.
*/ */
responseTransformer?: (data: unknown) => Promise<unknown>; responseTransformer?: (data: unknown) => Promise<unknown>;
/** /**

View file

@ -1,4 +1,3 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
@ -127,7 +126,7 @@ export function getValidRequestBody(options: {
return hasSerializedBody ? options.serializedBody : null; return hasSerializedBody ? options.serializedBody : null;
} }
// not all clients implement a serializedBody property (i.e., client-axios) // not all clients implement a serializedBody property (i.e. client-axios)
return options.body !== '' ? options.body : null; return options.body !== '' ? options.body : null;
} }

View file

@ -21,14 +21,11 @@ export {
downloadResticPassword, downloadResticPassword,
dumpSnapshot, dumpSnapshot,
getAdminUsers, getAdminUsers,
getBackupProgress,
getBackupSchedule, getBackupSchedule,
getBackupScheduleForVolume, getBackupScheduleForVolume,
getDevPanel, getDevPanel,
getMirrorCompatibility, getMirrorCompatibility,
getMirrorSyncStatus,
getNotificationDestination, getNotificationDestination,
getOrgMembers,
getPublicSsoProviders, getPublicSsoProviders,
getRegistrationStatus, getRegistrationStatus,
getRepository, getRepository,
@ -53,9 +50,7 @@ export {
listVolumes, listVolumes,
mountVolume, mountVolume,
type Options, type Options,
refreshRepositoryStats,
refreshSnapshots, refreshSnapshots,
removeOrgMember,
reorderBackupSchedules, reorderBackupSchedules,
restoreSnapshot, restoreSnapshot,
runBackupNow, runBackupNow,
@ -63,14 +58,12 @@ export {
setRegistrationStatus, setRegistrationStatus,
startDoctor, startDoctor,
stopBackup, stopBackup,
syncMirror,
tagSnapshots, tagSnapshots,
testConnection, testConnection,
testNotificationDestination, testNotificationDestination,
unlockRepository, unlockRepository,
unmountVolume, unmountVolume,
updateBackupSchedule, updateBackupSchedule,
updateMemberRole,
updateNotificationDestination, updateNotificationDestination,
updateRepository, updateRepository,
updateScheduleMirrors, updateScheduleMirrors,
@ -140,9 +133,6 @@ export type {
GetAdminUsersData, GetAdminUsersData,
GetAdminUsersResponse, GetAdminUsersResponse,
GetAdminUsersResponses, GetAdminUsersResponses,
GetBackupProgressData,
GetBackupProgressResponse,
GetBackupProgressResponses,
GetBackupScheduleData, GetBackupScheduleData,
GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeData,
GetBackupScheduleForVolumeResponse, GetBackupScheduleForVolumeResponse,
@ -155,16 +145,10 @@ export type {
GetMirrorCompatibilityData, GetMirrorCompatibilityData,
GetMirrorCompatibilityResponse, GetMirrorCompatibilityResponse,
GetMirrorCompatibilityResponses, GetMirrorCompatibilityResponses,
GetMirrorSyncStatusData,
GetMirrorSyncStatusResponse,
GetMirrorSyncStatusResponses,
GetNotificationDestinationData, GetNotificationDestinationData,
GetNotificationDestinationErrors, GetNotificationDestinationErrors,
GetNotificationDestinationResponse, GetNotificationDestinationResponse,
GetNotificationDestinationResponses, GetNotificationDestinationResponses,
GetOrgMembersData,
GetOrgMembersResponse,
GetOrgMembersResponses,
GetPublicSsoProvidersData, GetPublicSsoProvidersData,
GetPublicSsoProvidersResponse, GetPublicSsoProvidersResponse,
GetPublicSsoProvidersResponses, GetPublicSsoProvidersResponses,
@ -236,15 +220,9 @@ export type {
MountVolumeData, MountVolumeData,
MountVolumeResponse, MountVolumeResponse,
MountVolumeResponses, MountVolumeResponses,
RefreshRepositoryStatsData,
RefreshRepositoryStatsResponse,
RefreshRepositoryStatsResponses,
RefreshSnapshotsData, RefreshSnapshotsData,
RefreshSnapshotsResponse, RefreshSnapshotsResponse,
RefreshSnapshotsResponses, RefreshSnapshotsResponses,
RemoveOrgMemberData,
RemoveOrgMemberErrors,
RemoveOrgMemberResponses,
ReorderBackupSchedulesData, ReorderBackupSchedulesData,
ReorderBackupSchedulesResponse, ReorderBackupSchedulesResponse,
ReorderBackupSchedulesResponses, ReorderBackupSchedulesResponses,
@ -268,10 +246,6 @@ export type {
StopBackupErrors, StopBackupErrors,
StopBackupResponse, StopBackupResponse,
StopBackupResponses, StopBackupResponses,
SyncMirrorData,
SyncMirrorErrors,
SyncMirrorResponse,
SyncMirrorResponses,
TagSnapshotsData, TagSnapshotsData,
TagSnapshotsResponse, TagSnapshotsResponse,
TagSnapshotsResponses, TagSnapshotsResponses,
@ -291,9 +265,6 @@ export type {
UpdateBackupScheduleData, UpdateBackupScheduleData,
UpdateBackupScheduleResponse, UpdateBackupScheduleResponse,
UpdateBackupScheduleResponses, UpdateBackupScheduleResponses,
UpdateMemberRoleData,
UpdateMemberRoleErrors,
UpdateMemberRoleResponses,
UpdateNotificationDestinationData, UpdateNotificationDestinationData,
UpdateNotificationDestinationErrors, UpdateNotificationDestinationErrors,
UpdateNotificationDestinationResponse, UpdateNotificationDestinationResponse,

View file

@ -3,9 +3,9 @@
import type { Client, Options as Options2, TDataShape } from './client'; import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen'; import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponse, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetMirrorSyncStatusData, GetMirrorSyncStatusResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, SyncMirrorData, SyncMirrorErrors, SyncMirrorResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, 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, RefreshSnapshotsData, RefreshSnapshotsResponses, 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, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/** /**
* You can provide a client instance returned by `createClient()` instead of * You can provide a client instance returned by `createClient()` instead of
* individual options. This might be also useful if you want to implement a * individual options. This might be also useful if you want to implement a
@ -24,43 +24,6 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
*/ */
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/status', ...options }); export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/status', ...options });
/**
* List admin users for settings management
*/
export const getAdminUsers = <ThrowOnError extends boolean = false>(options?: Options<GetAdminUsersData, ThrowOnError>) => (options?.client ?? client).get<GetAdminUsersResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/admin-users', ...options });
/**
* Delete an account linked to a user
*/
export const deleteUserAccount = <ThrowOnError extends boolean = false>(options: Options<DeleteUserAccountData, ThrowOnError>) => (options.client ?? client).delete<DeleteUserAccountResponses, DeleteUserAccountErrors, ThrowOnError>({ url: '/api/v1/auth/admin-users/{userId}/accounts/{accountId}', ...options });
/**
* Get impact of deleting a user
*/
export const getUserDeletionImpact = <ThrowOnError extends boolean = false>(options: Options<GetUserDeletionImpactData, ThrowOnError>) => (options.client ?? client).get<GetUserDeletionImpactResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/deletion-impact/{userId}', ...options });
/**
* Get members of the active organization
*/
export const getOrgMembers = <ThrowOnError extends boolean = false>(options?: Options<GetOrgMembersData, ThrowOnError>) => (options?.client ?? client).get<GetOrgMembersResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/org-members', ...options });
/**
* Update a member's role in the active organization
*/
export const updateMemberRole = <ThrowOnError extends boolean = false>(options: Options<UpdateMemberRoleData, ThrowOnError>) => (options.client ?? client).patch<UpdateMemberRoleResponses, UpdateMemberRoleErrors, ThrowOnError>({
url: '/api/v1/auth/org-members/{memberId}/role',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Remove a member from the active organization
*/
export const removeOrgMember = <ThrowOnError extends boolean = false>(options: Options<RemoveOrgMemberData, ThrowOnError>) => (options.client ?? client).delete<RemoveOrgMemberResponses, RemoveOrgMemberErrors, ThrowOnError>({ url: '/api/v1/auth/org-members/{memberId}', ...options });
/** /**
* Get public SSO providers for the instance * Get public SSO providers for the instance
*/ */
@ -93,6 +56,21 @@ export const updateSsoProviderAutoLinking = <ThrowOnError extends boolean = fals
*/ */
export const deleteSsoInvitation = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoInvitationData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoInvitationResponses, DeleteSsoInvitationErrors, ThrowOnError>({ url: '/api/v1/auth/sso-invitations/{invitationId}', ...options }); export const deleteSsoInvitation = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoInvitationData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoInvitationResponses, DeleteSsoInvitationErrors, ThrowOnError>({ url: '/api/v1/auth/sso-invitations/{invitationId}', ...options });
/**
* List admin users for settings management
*/
export const getAdminUsers = <ThrowOnError extends boolean = false>(options?: Options<GetAdminUsersData, ThrowOnError>) => (options?.client ?? client).get<GetAdminUsersResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/admin-users', ...options });
/**
* Delete an account linked to a user
*/
export const deleteUserAccount = <ThrowOnError extends boolean = false>(options: Options<DeleteUserAccountData, ThrowOnError>) => (options.client ?? client).delete<DeleteUserAccountResponses, DeleteUserAccountErrors, ThrowOnError>({ url: '/api/v1/auth/admin-users/{userId}/accounts/{accountId}', ...options });
/**
* Get impact of deleting a user
*/
export const getUserDeletionImpact = <ThrowOnError extends boolean = false>(options: Options<GetUserDeletionImpactData, ThrowOnError>) => (options.client ?? client).get<GetUserDeletionImpactResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/deletion-impact/{userId}', ...options });
/** /**
* List all volumes * List all volumes
*/ */
@ -101,24 +79,24 @@ export const listVolumes = <ThrowOnError extends boolean = false>(options?: Opti
/** /**
* Create a new volume * Create a new volume
*/ */
export const createVolume = <ThrowOnError extends boolean = false>(options: Options<CreateVolumeData, ThrowOnError>) => (options.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({ export const createVolume = <ThrowOnError extends boolean = false>(options?: Options<CreateVolumeData, ThrowOnError>) => (options?.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes', url: '/api/v1/volumes',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers ...options?.headers
} }
}); });
/** /**
* Test connection to backend * Test connection to backend
*/ */
export const testConnection = <ThrowOnError extends boolean = false>(options: Options<TestConnectionData, ThrowOnError>) => (options.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({ export const testConnection = <ThrowOnError extends boolean = false>(options?: Options<TestConnectionData, ThrowOnError>) => (options?.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes/test-connection', url: '/api/v1/volumes/test-connection',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers ...options?.headers
} }
}); });
@ -177,12 +155,12 @@ export const listRepositories = <ThrowOnError extends boolean = false>(options?:
/** /**
* Create a new restic repository * Create a new restic repository
*/ */
export const createRepository = <ThrowOnError extends boolean = false>(options: Options<CreateRepositoryData, ThrowOnError>) => (options.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({ export const createRepository = <ThrowOnError extends boolean = false>(options?: Options<CreateRepositoryData, ThrowOnError>) => (options?.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories', url: '/api/v1/repositories',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers ...options?.headers
} }
}); });
@ -218,11 +196,6 @@ export const updateRepository = <ThrowOnError extends boolean = false>(options:
*/ */
export const getRepositoryStats = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryStatsData, ThrowOnError>) => (options.client ?? client).get<GetRepositoryStatsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/stats', ...options }); export const getRepositoryStats = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryStatsData, ThrowOnError>) => (options.client ?? client).get<GetRepositoryStatsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/stats', ...options });
/**
* Refresh repository storage and compression statistics
*/
export const refreshRepositoryStats = <ThrowOnError extends boolean = false>(options: Options<RefreshRepositoryStatsData, ThrowOnError>) => (options.client ?? client).post<RefreshRepositoryStatsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/stats/refresh', ...options });
/** /**
* Delete multiple snapshots from a repository * Delete multiple snapshots from a repository
*/ */
@ -307,7 +280,7 @@ export const tagSnapshots = <ThrowOnError extends boolean = false>(options: Opti
/** /**
* Execute a restic command against a repository (dev panel only) * Execute a restic command against a repository (dev panel only)
*/ */
export const devPanelExec = <ThrowOnError extends boolean = false>(options: Options<DevPanelExecData, ThrowOnError, DevPanelExecResponse>) => (options.client ?? client).sse.post<DevPanelExecResponses, DevPanelExecErrors, ThrowOnError>({ export const devPanelExec = <ThrowOnError extends boolean = false>(options: Options<DevPanelExecData, ThrowOnError>) => (options.client ?? client).sse.post<DevPanelExecResponses, DevPanelExecErrors, ThrowOnError>({
url: '/api/v1/repositories/{shortId}/exec', url: '/api/v1/repositories/{shortId}/exec',
...options, ...options,
headers: { headers: {
@ -324,12 +297,12 @@ export const listBackupSchedules = <ThrowOnError extends boolean = false>(option
/** /**
* Create a new backup schedule for a volume * Create a new backup schedule for a volume
*/ */
export const createBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<CreateBackupScheduleData, ThrowOnError>) => (options.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({ export const createBackupSchedule = <ThrowOnError extends boolean = false>(options?: Options<CreateBackupScheduleData, ThrowOnError>) => (options?.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
url: '/api/v1/backups', url: '/api/v1/backups',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers ...options?.headers
} }
}); });
@ -409,23 +382,6 @@ export const updateScheduleMirrors = <ThrowOnError extends boolean = false>(opti
} }
}); });
/**
* Get sync status for a specific mirror, including missing snapshots
*/
export const getMirrorSyncStatus = <ThrowOnError extends boolean = false>(options: Options<GetMirrorSyncStatusData, ThrowOnError>) => (options.client ?? client).get<GetMirrorSyncStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/status', ...options });
/**
* Sync selected snapshots to a specific mirror repository
*/
export const syncMirror = <ThrowOnError extends boolean = false>(options: Options<SyncMirrorData, ThrowOnError>) => (options.client ?? client).post<SyncMirrorResponses, SyncMirrorErrors, ThrowOnError>({
url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/sync',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/** /**
* Get mirror compatibility info for all repositories relative to a backup schedule's primary repository * Get mirror compatibility info for all repositories relative to a backup schedule's primary repository
*/ */
@ -434,20 +390,15 @@ export const getMirrorCompatibility = <ThrowOnError extends boolean = false>(opt
/** /**
* Reorder backup schedules by providing an array of schedule short IDs in the desired order * Reorder backup schedules by providing an array of schedule short IDs in the desired order
*/ */
export const reorderBackupSchedules = <ThrowOnError extends boolean = false>(options: Options<ReorderBackupSchedulesData, ThrowOnError>) => (options.client ?? client).post<ReorderBackupSchedulesResponses, unknown, ThrowOnError>({ export const reorderBackupSchedules = <ThrowOnError extends boolean = false>(options?: Options<ReorderBackupSchedulesData, ThrowOnError>) => (options?.client ?? client).post<ReorderBackupSchedulesResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/reorder', url: '/api/v1/backups/reorder',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers ...options?.headers
} }
}); });
/**
* Get the last known progress for a currently running backup. Returns null if no progress has been reported yet.
*/
export const getBackupProgress = <ThrowOnError extends boolean = false>(options: Options<GetBackupProgressData, ThrowOnError>) => (options.client ?? client).get<GetBackupProgressResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/progress', ...options });
/** /**
* List all notification destinations * List all notification destinations
*/ */
@ -456,12 +407,12 @@ export const listNotificationDestinations = <ThrowOnError extends boolean = fals
/** /**
* Create a new notification destination * Create a new notification destination
*/ */
export const createNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<CreateNotificationDestinationData, ThrowOnError>) => (options.client ?? client).post<CreateNotificationDestinationResponses, unknown, ThrowOnError>({ export const createNotificationDestination = <ThrowOnError extends boolean = false>(options?: Options<CreateNotificationDestinationData, ThrowOnError>) => (options?.client ?? client).post<CreateNotificationDestinationResponses, unknown, ThrowOnError>({
url: '/api/v1/notifications/destinations', url: '/api/v1/notifications/destinations',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers ...options?.headers
} }
}); });
@ -510,24 +461,24 @@ export const getRegistrationStatus = <ThrowOnError extends boolean = false>(opti
/** /**
* Update the registration status for new users. Requires global admin role. * Update the registration status for new users. Requires global admin role.
*/ */
export const setRegistrationStatus = <ThrowOnError extends boolean = false>(options: Options<SetRegistrationStatusData, ThrowOnError>) => (options.client ?? client).put<SetRegistrationStatusResponses, unknown, ThrowOnError>({ export const setRegistrationStatus = <ThrowOnError extends boolean = false>(options?: Options<SetRegistrationStatusData, ThrowOnError>) => (options?.client ?? client).put<SetRegistrationStatusResponses, unknown, ThrowOnError>({
url: '/api/v1/system/registration-status', url: '/api/v1/system/registration-status',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers ...options?.headers
} }
}); });
/** /**
* Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication. * Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication.
*/ */
export const downloadResticPassword = <ThrowOnError extends boolean = false>(options: Options<DownloadResticPasswordData, ThrowOnError>) => (options.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({ export const downloadResticPassword = <ThrowOnError extends boolean = false>(options?: Options<DownloadResticPasswordData, ThrowOnError>) => (options?.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
url: '/api/v1/system/restic-password', url: '/api/v1/system/restic-password',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers ...options?.headers
} }
}); });

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -1,4 +1,3 @@
import { Fragment } from "react";
import { useMatches, Link } from "@tanstack/react-router"; import { useMatches, Link } from "@tanstack/react-router";
import { import {
Breadcrumb, Breadcrumb,
@ -42,7 +41,7 @@ export function AppBreadcrumb() {
const isLast = index === breadcrumbs.length - 1; const isLast = index === breadcrumbs.length - 1;
return ( return (
<Fragment key={`${breadcrumb.label}-${index}`}> <div key={`${breadcrumb.label}-${index}`} className="contents">
<BreadcrumbItem> <BreadcrumbItem>
{isLast || !breadcrumb.href ? ( {isLast || !breadcrumb.href ? (
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage> <BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
@ -53,7 +52,7 @@ export function AppBreadcrumb() {
)} )}
</BreadcrumbItem> </BreadcrumbItem>
{!isLast && <BreadcrumbSeparator />} {!isLast && <BreadcrumbSeparator />}
</Fragment> </div>
); );
})} })}
</BreadcrumbList> </BreadcrumbList>

View file

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

View file

@ -1,13 +1,14 @@
import { Card, CardContent } from "~/client/components/ui/card"; import { Card, CardContent } from "~/client/components/ui/card";
import { ByteSize } from "~/client/components/bytes-size"; import { ByteSize } from "~/client/components/bytes-size";
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data"; import type { ResticSnapshotSummaryDto } from "~/schemas/restic-dto";
import type { ResticSnapshotSummaryDto } from "@zerobyte/core/restic";
import { formatDuration } from "~/utils/utils"; import { formatDuration } from "~/utils/utils";
type Props = { type Props = {
summary?: ResticSnapshotSummaryDto | null; summary?: ResticSnapshotSummaryDto | null;
}; };
const formatCount = (value: number) => value.toLocaleString();
const getDurationLabel = (start: string, end: string) => { const getDurationLabel = (start: string, end: string) => {
const startMs = new Date(start).getTime(); const startMs = new Date(start).getTime();
const endMs = new Date(end).getTime(); const endMs = new Date(end).getTime();
@ -16,11 +17,8 @@ const getDurationLabel = (start: string, end: string) => {
}; };
export const BackupSummaryCard = ({ summary }: Props) => { export const BackupSummaryCard = ({ summary }: Props) => {
const { locale } = useRootLoaderData();
if (!summary) return null; if (!summary) return null;
const formatCount = (value: number) => value.toLocaleString(locale);
const durationLabel = getDurationLabel(summary.backup_start, summary.backup_end); const durationLabel = getDurationLabel(summary.backup_start, summary.backup_end);
const topStats = [ const topStats = [

View file

@ -1,5 +1,5 @@
import type React from "react"; import type React from "react";
import { useFormatBytes } from "~/client/hooks/use-format-bytes"; import { formatBytes } from "~/utils/format-bytes";
type ByteSizeProps = { type ByteSizeProps = {
bytes: number; bytes: number;
@ -14,7 +14,6 @@ type ByteSizeProps = {
}; };
export function ByteSize(props: ByteSizeProps) { export function ByteSize(props: ByteSizeProps) {
const formatBytes = useFormatBytes();
const { const {
bytes, bytes,
base = 1000, base = 1000,

View file

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

View file

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

View file

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

View file

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

View file

@ -134,10 +134,15 @@ export const FileBrowser = (props: FileBrowserProps) => {
} }
const bodyWithScroll = useScrollArea ? <ScrollArea className={scrollAreaClassName}>{body}</ScrollArea> : body; const bodyWithScroll = useScrollArea ? <ScrollArea className={scrollAreaClassName}>{body}</ScrollArea> : body;
const wrappedBody = treeContainerClassName ? (
<div className={treeContainerClassName}>{bodyWithScroll}</div>
) : (
bodyWithScroll
);
return ( return (
<div className={className}> <div className={className}>
<div className={treeContainerClassName}>{bodyWithScroll}</div> {wrappedBody}
{showSelectedPathFooter && resolvedSelectedPath && ( {showSelectedPathFooter && resolvedSelectedPath && (
<div className="bg-muted/50 border-t p-2 text-sm"> <div className="bg-muted/50 border-t p-2 text-sm">
<div className="font-medium text-muted-foreground">{selectedPathLabel}</div> <div className="font-medium text-muted-foreground">{selectedPathLabel}</div>

View file

@ -3,8 +3,7 @@ import { browseFilesystemOptions } from "~/client/api-client/@tanstack/react-que
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser"; import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
import { useFileBrowser } from "~/client/hooks/use-file-browser"; import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
import { normalizeAbsolutePath } from "@zerobyte/core/utils"; import { normalizeAbsolutePath } from "~/utils/path";
import { logger } from "~/client/lib/logger";
type LocalFileBrowserProps = FileBrowserUiProps & { type LocalFileBrowserProps = FileBrowserUiProps & {
initialPath?: string; initialPath?: string;
@ -27,10 +26,17 @@ export const LocalFileBrowser = ({ initialPath = "/", enabled = true, ...uiProps
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } })); return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
}, },
prefetchFolder: (path) => { prefetchFolder: (path) => {
void queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } })).catch((e) => logger.error(e)); void queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
}, },
}); });
const errorDetails = parseError(error)?.message;
const errorMessage = errorDetails
? `Failed to load directories: ${errorDetails}`
: error
? "Failed to load directories"
: undefined;
return ( return (
<FileBrowser <FileBrowser
{...uiProps} {...uiProps}
@ -43,7 +49,7 @@ export const LocalFileBrowser = ({ initialPath = "/", enabled = true, ...uiProps
getFolderPagination={fileBrowser.getFolderPagination} getFolderPagination={fileBrowser.getFolderPagination}
isLoading={fileBrowser.isLoading} isLoading={fileBrowser.isLoading}
isEmpty={fileBrowser.isEmpty} isEmpty={fileBrowser.isEmpty}
errorMessage={parseError(error)?.message} errorMessage={errorMessage}
loadingMessage={uiProps.loadingMessage ?? "Loading directories..."} loadingMessage={uiProps.loadingMessage ?? "Loading directories..."}
emptyMessage={uiProps.emptyMessage ?? "No subdirectories found"} emptyMessage={uiProps.emptyMessage ?? "No subdirectories found"}
/> />

View file

@ -4,107 +4,106 @@ import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-qu
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser"; import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
import { useFileBrowser } from "~/client/hooks/use-file-browser"; import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils"; import { normalizeAbsolutePath } from "~/utils/path";
function createPathPrefixFns(basePath: string) {
return {
strip(path: string) {
if (basePath === "/") return path;
if (path === basePath) return "/";
if (path.startsWith(`${basePath}/`)) return path.slice(basePath.length);
return path;
},
add(displayPath: string) {
if (basePath === "/") return displayPath;
if (displayPath === "/") return basePath;
return `${basePath}${displayPath}`;
},
};
}
type SnapshotTreeBrowserProps = FileBrowserUiProps & { type SnapshotTreeBrowserProps = FileBrowserUiProps & {
repositoryId: string; repositoryId: string;
snapshotId: string; snapshotId: string;
queryBasePath?: string; basePath?: string;
displayBasePath?: string;
pageSize?: number; pageSize?: number;
enabled?: boolean; enabled?: boolean;
onSingleSelectionKindChange?: (kind: "file" | "dir" | null) => void; onSingleSelectionKindChange?: (kind: "file" | "dir" | null) => void;
}; };
export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => { export const SnapshotTreeBrowser = ({
const { repositoryId,
repositoryId, snapshotId,
snapshotId, basePath = "/",
queryBasePath = "/", pageSize = 500,
displayBasePath, enabled = true,
pageSize = 500, ...uiProps
enabled = true, }: SnapshotTreeBrowserProps) => {
...uiProps
} = props;
const { selectedPaths, onSelectionChange, onSingleSelectionKindChange, ...fileBrowserUiProps } = uiProps; const { selectedPaths, onSelectionChange, onSingleSelectionKindChange, ...fileBrowserUiProps } = uiProps;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const normalizedQueryBasePath = normalizeAbsolutePath(queryBasePath); const normalizedBasePath = normalizeAbsolutePath(basePath);
const normalizedDisplayBasePath = normalizeAbsolutePath(displayBasePath ?? "/");
const effectiveDisplayBasePath = isPathWithin(normalizedDisplayBasePath, normalizedQueryBasePath)
? normalizedDisplayBasePath
: "/";
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
...listSnapshotFilesOptions({ ...listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId }, path: { shortId: repositoryId, snapshotId },
query: { path: normalizedQueryBasePath }, query: { path: normalizedBasePath },
}), }),
enabled, enabled,
}); });
const displayPathFns = useMemo(() => createPathPrefixFns(effectiveDisplayBasePath), [effectiveDisplayBasePath]); const stripBasePath = useCallback(
(path: string): string => {
if (normalizedBasePath === "/") return path;
if (path === normalizedBasePath) return "/";
if (path.startsWith(`${normalizedBasePath}/`)) {
return path.slice(normalizedBasePath.length);
}
return path;
},
[normalizedBasePath],
);
const addBasePath = useCallback(
(displayPath: string): string => {
if (normalizedBasePath === "/") return displayPath;
if (displayPath === "/") return normalizedBasePath;
return `${normalizedBasePath}${displayPath}`;
},
[normalizedBasePath],
);
const displaySelectedPaths = useMemo(() => { const displaySelectedPaths = useMemo(() => {
if (!selectedPaths) return undefined; if (!selectedPaths) return undefined;
const displayPaths = new Set<string>(); const displayPaths = new Set<string>();
for (const fullPath of selectedPaths) { for (const fullPath of selectedPaths) {
displayPaths.add(displayPathFns.strip(fullPath)); displayPaths.add(stripBasePath(fullPath));
} }
return displayPaths; return displayPaths;
}, [displayPathFns, selectedPaths]); }, [selectedPaths, stripBasePath]);
const fileBrowser = useFileBrowser({ const fileBrowser = useFileBrowser({
initialData: data, initialData: data,
isLoading, isLoading,
fetchFolder: async (displayPath, offset = 0) => { fetchFolder: async (path, offset = 0) => {
return await queryClient.ensureQueryData( return await queryClient.ensureQueryData(
listSnapshotFilesOptions({ listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId }, path: { shortId: repositoryId, snapshotId },
query: { path: displayPath, offset: offset, limit: pageSize }, query: {
path,
offset: offset.toString(),
limit: pageSize.toString(),
},
}), }),
); );
}, },
pathTransform: displayPathFns, prefetchFolder: (path) => {
void queryClient.prefetchQuery(
listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId },
query: {
path,
offset: "0",
limit: pageSize.toString(),
},
}),
);
},
pathTransform: {
strip: stripBasePath,
add: addBasePath,
},
}); });
const displayPathKinds = useMemo(() => { const displayPathKinds = useMemo(() => {
const kinds = new Map<string, "file" | "dir">(); const kinds = new Map<string, "file" | "dir">();
for (const entry of fileBrowser.fileArray) { for (const entry of fileBrowser.fileArray) {
kinds.set(entry.path, entry.type === "file" ? "file" : "dir"); kinds.set(entry.path, entry.type === "file" ? "file" : "dir");
let parentPath = entry.path;
while (true) {
const lastSlashIndex = parentPath.lastIndexOf("/");
if (lastSlashIndex <= 0) {
break;
}
parentPath = parentPath.slice(0, lastSlashIndex);
if (kinds.has(parentPath)) {
continue;
}
kinds.set(parentPath, "dir");
}
} }
return kinds; return kinds;
}, [fileBrowser.fileArray]); }, [fileBrowser.fileArray]);
@ -115,7 +114,7 @@ export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
const nextFullPaths = new Set<string>(); const nextFullPaths = new Set<string>();
for (const displayPath of nextDisplayPaths) { for (const displayPath of nextDisplayPaths) {
nextFullPaths.add(displayPathFns.add(displayPath)); nextFullPaths.add(addBasePath(displayPath));
} }
if (onSingleSelectionKindChange) { if (onSingleSelectionKindChange) {
@ -133,9 +132,16 @@ export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
onSelectionChange(nextFullPaths); onSelectionChange(nextFullPaths);
}, },
[displayPathFns, displayPathKinds, onSelectionChange, onSingleSelectionKindChange], [onSelectionChange, addBasePath, onSingleSelectionKindChange, displayPathKinds],
); );
const errorDetails = parseError(error)?.message;
const errorMessage = errorDetails
? `Failed to load files: ${errorDetails}`
: error
? "Failed to load files"
: undefined;
return ( return (
<FileBrowser <FileBrowser
{...fileBrowserUiProps} {...fileBrowserUiProps}
@ -148,7 +154,7 @@ export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
getFolderPagination={fileBrowser.getFolderPagination} getFolderPagination={fileBrowser.getFolderPagination}
isLoading={fileBrowser.isLoading} isLoading={fileBrowser.isLoading}
isEmpty={fileBrowser.isEmpty} isEmpty={fileBrowser.isEmpty}
errorMessage={parseError(error)?.message} errorMessage={errorMessage}
loadingMessage={fileBrowserUiProps.loadingMessage ?? "Loading files..."} loadingMessage={fileBrowserUiProps.loadingMessage ?? "Loading files..."}
selectedPaths={displaySelectedPaths} selectedPaths={displaySelectedPaths}
onSelectionChange={onSelectionChange ? handleSelectionChange : undefined} onSelectionChange={onSelectionChange ? handleSelectionChange : undefined}

View file

@ -3,7 +3,6 @@ import { listFilesOptions } from "~/client/api-client/@tanstack/react-query.gen"
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser"; import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
import { useFileBrowser, type FetchFolderResult } from "~/client/hooks/use-file-browser"; import { useFileBrowser, type FetchFolderResult } from "~/client/hooks/use-file-browser";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
import { logger } from "~/client/lib/logger";
type VolumeFileBrowserProps = FileBrowserUiProps & { type VolumeFileBrowserProps = FileBrowserUiProps & {
volumeId: string; volumeId: string;
@ -25,22 +24,27 @@ export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: Volu
return await queryClient.ensureQueryData( return await queryClient.ensureQueryData(
listFilesOptions({ listFilesOptions({
path: { shortId: volumeId }, path: { shortId: volumeId },
query: { path, offset: offset }, query: { path, offset: offset?.toString() },
}), }),
); );
}, },
prefetchFolder: (path) => { prefetchFolder: (path) => {
void queryClient void queryClient.prefetchQuery(
.prefetchQuery( listFilesOptions({
listFilesOptions({ path: { shortId: volumeId },
path: { shortId: volumeId }, query: { path },
query: { path }, }),
}), );
)
.catch((e) => logger.error(e));
}, },
}); });
const errorDetails = parseError(error)?.message;
const errorMessage = errorDetails
? `Failed to load files: ${errorDetails}`
: error
? "Failed to load files"
: undefined;
return ( return (
<FileBrowser <FileBrowser
{...uiProps} {...uiProps}
@ -53,7 +57,7 @@ export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: Volu
getFolderPagination={fileBrowser.getFolderPagination} getFolderPagination={fileBrowser.getFolderPagination}
isLoading={fileBrowser.isLoading} isLoading={fileBrowser.isLoading}
isEmpty={fileBrowser.isEmpty} isEmpty={fileBrowser.isEmpty}
errorMessage={parseError(error)?.message} errorMessage={errorMessage}
loadingMessage={uiProps.loadingMessage ?? "Loading files..."} loadingMessage={uiProps.loadingMessage ?? "Loading files..."}
emptyMessage={uiProps.emptyMessage ?? "This volume appears to be empty."} emptyMessage={uiProps.emptyMessage ?? "This volume appears to be empty."}
/> />

View file

@ -114,25 +114,20 @@ export const FileTree = memo((props: Props) => {
const toggleCollapseState = useCallback( const toggleCollapseState = useCallback(
(fullPath: string) => { (fullPath: string) => {
const shouldExpand = collapsedFolders.has(fullPath);
setCollapsedFolders((prevSet) => { setCollapsedFolders((prevSet) => {
const newSet = new Set(prevSet); const newSet = new Set(prevSet);
if (newSet.has(fullPath)) { if (newSet.has(fullPath)) {
newSet.delete(fullPath); newSet.delete(fullPath);
onFolderExpand?.(fullPath);
} else { } else {
newSet.add(fullPath); newSet.add(fullPath);
} }
return newSet; return newSet;
}); });
if (shouldExpand) {
onFolderExpand?.(fullPath);
}
}, },
[collapsedFolders, onFolderExpand], [onFolderExpand],
); );
// Add new folders to collapsed set when file list changes // Add new folders to collapsed set when file list changes
@ -586,10 +581,8 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
); );
return ( return (
<div <button
// oxlint-disable-next-line jsx_a11y/prefer-tag-over-role type="button"
role="button"
tabIndex={0}
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)} className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
style={{ paddingLeft }} style={{ paddingLeft }}
onClick={onClick} onClick={onClick}
@ -598,7 +591,7 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
> >
{icon} {icon}
<div className="truncate w-full flex items-center gap-2">{children}</div> <div className="truncate w-full flex items-center gap-2">{children}</div>
</div> </button>
); );
}); });
@ -632,10 +625,6 @@ function buildFileList(files: FileEntry[], foldersOnly = false): Node[] {
const depth = segments.length - 1; const depth = segments.length - 1;
const name = segments[segments.length - 1]; const name = segments[segments.length - 1];
if (!name) {
continue;
}
if (!fileMap.has(file.path)) { if (!fileMap.has(file.path)) {
const isFile = file.type === "file"; const isFile = file.type === "file";
fileMap.set(file.path, { fileMap.set(file.path, {
@ -647,33 +636,6 @@ function buildFileList(files: FileEntry[], foldersOnly = false): Node[] {
size: file.size, size: file.size,
}); });
} }
let parentPath = file.path;
while (true) {
const lastSlashIndex = parentPath.lastIndexOf("/");
if (lastSlashIndex <= 0) {
break;
}
parentPath = parentPath.slice(0, lastSlashIndex);
if (fileMap.has(parentPath)) {
continue;
}
const parentSegments = parentPath.split("/").filter((segment) => segment);
const parentName = parentSegments[parentSegments.length - 1];
if (!parentName) {
continue;
}
fileMap.set(parentPath, {
kind: "folder",
id: fileMap.size,
name: parentName,
fullPath: parentPath,
depth: parentSegments.length - 1,
});
}
} }
// Convert map to array and sort // Convert map to array and sort
@ -740,7 +702,5 @@ function compareNodes(a: Node, b: Node): number {
return a.kind === "folder" ? -1 : 1; return a.kind === "folder" ? -1 : 1;
} }
if (a.name < b.name) return -1; return a.name.localeCompare(b.name);
if (a.name > b.name) return 1;
return 0;
} }

View file

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

View file

@ -1,16 +1,14 @@
import { LifeBuoy, LogOut } from "lucide-react"; import { LifeBuoy } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { type AppContext } from "~/context"; import { type AppContext } from "~/context";
import { GridBackground } from "./grid-background"; import { GridBackground } from "./grid-background";
import { Button, buttonVariants } from "./ui/button"; import { Button } from "./ui/button";
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar"; import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
import { AppSidebar } from "./app-sidebar"; import { AppSidebar } from "./app-sidebar";
import { authClient } from "../lib/auth-client"; import { authClient } from "../lib/auth-client";
import { DevPanelListener } from "./dev-panel-listener"; import { DevPanelListener } from "./dev-panel-listener";
import { Outlet, useNavigate } from "@tanstack/react-router"; import { Outlet, useNavigate } from "@tanstack/react-router";
import { AppBreadcrumb } from "./app-breadcrumb"; import { AppBreadcrumb } from "./app-breadcrumb";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { ThemeToggle } from "./theme-toggle";
type Props = { type Props = {
loaderData: AppContext; loaderData: AppContext;
@ -34,51 +32,36 @@ export function Layout({ loaderData }: Props) {
return ( return (
<SidebarProvider defaultOpen={loaderData.sidebarOpen}> <SidebarProvider defaultOpen={loaderData.sidebarOpen}>
<AppSidebar isInstanceAdmin={loaderData.user?.role === "admin"} /> <AppSidebar />
<div className="w-full relative flex flex-col min-h-screen md:h-screen md:overflow-hidden"> <div className="w-full relative flex flex-col min-h-screen md:h-screen md:overflow-hidden">
<header className="z-50 bg-card-header border-b border-border/80 dark:border-border/50 shrink-0 h-16.25"> <header className="z-50 bg-card-header border-b border-border/50 shrink-0">
<div className="flex items-center h-full justify-between px-2 sm:px-8 mx-auto container gap-4"> <div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container gap-4">
<div className="flex items-center gap-4 min-w-0"> <div className="flex items-center gap-4 min-w-0">
<SidebarTrigger /> <SidebarTrigger />
<AppBreadcrumb /> <AppBreadcrumb />
</div> </div>
{loaderData.user && ( {loaderData.user && (
<div className="flex items-center bg-card dark:bg-muted/30 border border-border/80 dark:border-border/50 px-2 py-1 rounded-full shadow-[0_2px_10px_-4px_rgba(0,0,0,0.05)] dark:shadow-sm"> <div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground hidden md:inline-flex pl-2 mr-5"> <span className="text-sm text-muted-foreground hidden md:inline-flex">
<span className="text-foreground">{loaderData.user.name}</span> Welcome,&nbsp;
<span className="text-strong-accent">{loaderData.user.name}</span>
</span> </span>
<ThemeToggle /> <Button variant="default" size="sm" onClick={handleLogout}>
<Tooltip> Logout
<TooltipTrigger asChild> </Button>
<Button <Button variant="default" size="sm" className="relative overflow-hidden hidden lg:inline-flex">
variant="ghost" <a
size="icon" href="https://github.com/nicotsx/zerobyte/issues/new"
className="rounded-full h-7 text-xs text-muted-foreground hover:text-foreground" target="_blank"
onClick={handleLogout} rel="noreferrer"
> className="flex items-center gap-2"
<LogOut className="w-4 h-4" /> >
</Button> <span className="flex items-center gap-2">
</TooltipTrigger> <LifeBuoy />
<TooltipContent>Logout</TooltipContent> <span>Report an issue</span>
</Tooltip> </span>
<Tooltip> </a>
<TooltipTrigger asChild> </Button>
<a
href="https://github.com/nicotsx/zerobyte/issues/new/choose"
target="_blank"
rel="noreferrer"
className={buttonVariants({
variant: "ghost",
size: "icon",
className:
"relative overflow-hidden hidden lg:inline-flex rounded-full h-7 w-7 text-muted-foreground hover:text-foreground",
})}
>
<LifeBuoy className="w-4 h-4" />
</a>
</TooltipTrigger>
<TooltipContent>Report an issue</TooltipContent>
</Tooltip>
</div> </div>
)} )}
</div> </div>

View file

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

View file

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

View file

@ -59,7 +59,7 @@ export function OrganizationSwitcher() {
size="lg" size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
> >
<div className="bg-foreground text-sidebar-primary-foreground flex aspect-square size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg"> <div className="bg-black text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg">
{activeOrganization?.logo ? ( {activeOrganization?.logo ? (
<img <img
src={activeOrganization.logo} src={activeOrganization.logo}
@ -74,7 +74,7 @@ export function OrganizationSwitcher() {
<span className="truncate font-medium">{activeOrganization?.name}</span> <span className="truncate font-medium">{activeOrganization?.name}</span>
<span className="truncate text-xs">{organizations.length} organizations</span> <span className="truncate text-xs">{organizations.length} organizations</span>
</div> </div>
<ChevronsUpDown className="ml-auto group-data-[collapsible=icon]:hidden" /> <ChevronsUpDown className="ml-auto" />
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent

View file

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

View file

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

View file

@ -1,9 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { AlertTriangle, ChevronDown, Download, FolderOpen, RotateCcw } from "lucide-react"; import { ChevronDown, Download, FolderOpen, RotateCcw } from "lucide-react";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Input } from "~/client/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label"; import { Label } from "~/client/components/ui/label";
@ -22,8 +21,7 @@ import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-
import { RestoreProgress } from "~/client/components/restore-progress"; import { RestoreProgress } from "~/client/components/restore-progress";
import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events"; import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events";
import { OVERWRITE_MODES, type OverwriteMode } from "@zerobyte/core/restic"; import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
import { isPathWithin } from "@zerobyte/core/utils";
import type { Repository } from "~/client/lib/types"; import type { Repository } from "~/client/lib/types";
import { handleRepositoryError } from "~/client/lib/errors"; import { handleRepositoryError } from "~/client/lib/errors";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
@ -35,29 +33,16 @@ interface RestoreFormProps {
repository: Repository; repository: Repository;
snapshotId: string; snapshotId: string;
returnPath: string; returnPath: string;
queryBasePath?: string; basePath?: string;
displayBasePath?: string;
hasNonPosixSnapshotPaths?: boolean;
} }
export function RestoreForm({ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: RestoreFormProps) {
repository,
snapshotId,
returnPath,
queryBasePath,
displayBasePath,
hasNonPosixSnapshotPaths = false,
}: RestoreFormProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { addEventListener } = useServerEvents(); const { addEventListener } = useServerEvents();
const snapshotBasePath = queryBasePath ?? "/"; const volumeBasePath = basePath ?? "/";
const hasMismatchedDisplayBasePath = displayBasePath && !isPathWithin(displayBasePath, snapshotBasePath);
const restoreRequiresCustomTarget = hasNonPosixSnapshotPaths || hasMismatchedDisplayBasePath;
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>( const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>("original");
restoreRequiresCustomTarget ? "custom" : "original",
);
const [customTargetPath, setCustomTargetPath] = useState(""); const [customTargetPath, setCustomTargetPath] = useState("");
const [overwriteMode, setOverwriteMode] = useState<OverwriteMode>("always"); const [overwriteMode, setOverwriteMode] = useState<OverwriteMode>("always");
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
@ -69,15 +54,6 @@ export function RestoreForm({
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set()); const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [selectedPathKind, setSelectedPathKind] = useState<"file" | "dir" | null>(null); const [selectedPathKind, setSelectedPathKind] = useState<"file" | "dir" | null>(null);
const trimmedCustomTargetPath = customTargetPath.trim();
const hasCustomTargetPath = trimmedCustomTargetPath !== "";
const selectedPathCount = selectedPaths.size;
useEffect(() => {
if (restoreRequiresCustomTarget) {
setRestoreLocation("custom");
}
}, [restoreRequiresCustomTarget]);
useEffect(() => { useEffect(() => {
const abortController = new AbortController(); const abortController = new AbortController();
@ -137,13 +113,13 @@ export function RestoreForm({
}); });
const handleRestore = useCallback(() => { const handleRestore = useCallback(() => {
const excludeXattrValues = excludeXattr const excludeXattrArray = excludeXattr
.split(",") ?.split(",")
.map((value) => value.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean);
const isCustomLocation = restoreLocation === "custom"; const isCustomLocation = restoreLocation === "custom";
const targetPath = isCustomLocation && hasCustomTargetPath ? trimmedCustomTargetPath : undefined; const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined;
const includePaths = Array.from(selectedPaths); const includePaths = Array.from(selectedPaths);
@ -157,8 +133,7 @@ export function RestoreForm({
body: { body: {
snapshotId, snapshotId,
include: includePaths.length > 0 ? includePaths : undefined, include: includePaths.length > 0 ? includePaths : undefined,
selectedItemKind: includePaths.length === 1 ? (selectedPathKind ?? undefined) : undefined, excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
excludeXattr: excludeXattrValues.length > 0 ? excludeXattrValues : undefined,
targetPath, targetPath,
overwrite: overwriteMode, overwrite: overwriteMode,
}, },
@ -167,32 +142,38 @@ export function RestoreForm({
repository.shortId, repository.shortId,
snapshotId, snapshotId,
excludeXattr, excludeXattr,
hasCustomTargetPath,
restoreLocation, restoreLocation,
trimmedCustomTargetPath, customTargetPath,
selectedPaths, selectedPaths,
selectedPathKind,
overwriteMode, overwriteMode,
restoreSnapshot, restoreSnapshot,
]); ]);
const handleDownload = useCallback(() => { const handleDownload = useCallback(() => {
if (selectedPaths.size > 1) return; if (selectedPaths.size > 1) {
return;
}
const url = new URL( const dumpUrl = new URL(
`/api/v1/repositories/${repository.shortId}/snapshots/${snapshotId}/dump`, `/api/v1/repositories/${repository.shortId}/snapshots/${snapshotId}/dump`,
window.location.origin, window.location.origin,
); );
const [selectedPath] = selectedPaths; if (selectedPaths.size === 1) {
if (selectedPath) { const [selectedPath] = selectedPaths;
url.searchParams.set("path", selectedPath); if (selectedPath) {
if (selectedPathKind) { dumpUrl.searchParams.set("path", selectedPath);
url.searchParams.set("kind", selectedPathKind); if (selectedPathKind) {
dumpUrl.searchParams.set("kind", selectedPathKind);
}
} }
} }
window.location.assign(url.toString()); const link = document.createElement("a");
link.href = dumpUrl.toString();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, [repository.shortId, snapshotId, selectedPathKind, selectedPaths]); }, [repository.shortId, snapshotId, selectedPathKind, selectedPaths]);
const acknowledgeRestoreResult = useCallback(() => { const acknowledgeRestoreResult = useCallback(() => {
@ -206,15 +187,13 @@ export function RestoreForm({
} }
}, []); }, []);
const canRestore = restoreRequiresCustomTarget const canRestore = restoreLocation === "original" || customTargetPath.trim();
? hasCustomTargetPath const canDownload = selectedPaths.size <= 1;
: restoreLocation === "original" || hasCustomTargetPath;
const canDownload = selectedPathCount <= 1;
const isRestoreRunning = isRestoring || isRestoreActive; const isRestoreRunning = isRestoring || isRestoreActive;
function getDownloadButtonText(): string { function getDownloadButtonText(): string {
if (selectedPathCount > 0) { if (selectedPaths.size > 0) {
return `Download ${selectedPathCount} ${selectedPathCount === 1 ? "item" : "items"}`; return `Download ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`;
} }
return "Download All"; return "Download All";
} }
@ -223,8 +202,8 @@ export function RestoreForm({
if (isRestoreRunning) { if (isRestoreRunning) {
return "Restoring..."; return "Restoring...";
} }
if (selectedPathCount > 0) { if (selectedPaths.size > 0) {
return `Restore ${selectedPathCount} ${selectedPathCount === 1 ? "item" : "items"}`; return `Restore ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`;
} }
return "Restore All"; return "Restore All";
} }
@ -266,18 +245,6 @@ export function RestoreForm({
<div className="space-y-6"> <div className="space-y-6">
{isRestoreRunning && <RestoreProgress repositoryId={repository.shortId} snapshotId={snapshotId} />} {isRestoreRunning && <RestoreProgress repositoryId={repository.shortId} snapshotId={snapshotId} />}
{restoreRequiresCustomTarget && (
<Alert variant="warning">
<AlertTriangle className="size-4" />
<AlertTitle>Source paths do not match</AlertTitle>
<AlertDescription>
This snapshot was created from source paths that do not match this Zerobyte server or the current linked
volume. Restoring to the original location is unavailable. Restore it to a custom location, or download
it instead.
</AlertDescription>
</Alert>
)}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Restore Location</CardTitle> <CardTitle>Restore Location</CardTitle>
@ -291,7 +258,6 @@ export function RestoreForm({
size="sm" size="sm"
className="flex justify-start gap-2" className="flex justify-start gap-2"
onClick={() => setRestoreLocation("original")} onClick={() => setRestoreLocation("original")}
disabled={!!restoreRequiresCustomTarget}
> >
<RotateCcw size={16} className="mr-1" /> <RotateCcw size={16} className="mr-1" />
Original location Original location
@ -386,8 +352,7 @@ export function RestoreForm({
<SnapshotTreeBrowser <SnapshotTreeBrowser
repositoryId={repository.shortId} repositoryId={repository.shortId}
snapshotId={snapshotId} snapshotId={snapshotId}
queryBasePath={snapshotBasePath} basePath={volumeBasePath}
displayBasePath={displayBasePath}
pageSize={500} pageSize={500}
className="flex flex-1 min-h-0 flex-col" className="flex flex-1 min-h-0 flex-col"
treeContainerClassName="overflow-auto flex-1 min-h-0 border border-border rounded-md bg-card m-4" treeContainerClassName="overflow-auto flex-1 min-h-0 border border-border rounded-md bg-card m-4"

View file

@ -1,10 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ByteSize } from "~/client/components/bytes-size"; import { ByteSize } from "~/client/components/bytes-size";
import { useFormatBytes } from "~/client/hooks/use-format-bytes";
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
import { Card } from "~/client/components/ui/card"; import { Card } from "~/client/components/ui/card";
import { Progress } from "~/client/components/ui/progress"; import { Progress } from "~/client/components/ui/progress";
import { type RestoreProgressEvent, useServerEvents } from "~/client/hooks/use-server-events"; import { type RestoreProgressEvent, useServerEvents } from "~/client/hooks/use-server-events";
import { formatBytes } from "~/utils/format-bytes";
import { formatDuration } from "~/utils/utils"; import { formatDuration } from "~/utils/utils";
type Props = { type Props = {
@ -13,8 +12,6 @@ type Props = {
}; };
export const RestoreProgress = ({ repositoryId, snapshotId }: Props) => { export const RestoreProgress = ({ repositoryId, snapshotId }: Props) => {
const formatBytes = useFormatBytes();
const { locale } = useRootLoaderData();
const { addEventListener } = useServerEvents(); const { addEventListener } = useServerEvents();
const [progress, setProgress] = useState<RestoreProgressEvent | null>(null); const [progress, setProgress] = useState<RestoreProgressEvent | null>(null);
@ -74,9 +71,9 @@ export const RestoreProgress = ({ repositoryId, snapshotId }: Props) => {
<div> <div>
<p className="text-xs uppercase text-muted-foreground">Files</p> <p className="text-xs uppercase text-muted-foreground">Files</p>
<p className="font-medium"> <p className="font-medium">
{progress.files_restored.toLocaleString(locale)} {progress.files_restored.toLocaleString()}
&nbsp;/&nbsp; &nbsp;/&nbsp;
{progress.total_files.toLocaleString(locale)} {progress.total_files.toLocaleString()}
</p> </p>
</div> </div>
<div> <div>

View file

@ -25,8 +25,8 @@ import {
DialogTitle, DialogTitle,
} from "~/client/components/ui/dialog"; } from "~/client/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { useTimeFormat } from "~/client/lib/datetime";
import { formatDuration } from "~/utils/utils"; import { formatDuration } from "~/utils/utils";
import { formatDateTime } from "~/client/lib/datetime";
import { import {
deleteSnapshotsMutation, deleteSnapshotsMutation,
listSnapshotsQueryKey, listSnapshotsQueryKey,
@ -49,7 +49,6 @@ type Props = {
export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshotsQueryOptions }: Props) => { export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshotsQueryOptions }: Props) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const { formatDateTime } = useTimeFormat();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false); const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,16 +7,16 @@ import { cn } from "~/client/lib/utils";
import { useMinimumDuration } from "~/client/hooks/useMinimumDuration"; import { useMinimumDuration } from "~/client/hooks/useMinimumDuration";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex cursor-pointer uppercase rounded-sm items-center justify-center gap-2 whitespace-nowrap text-xs font-semibold tracking-wide transition-all active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring border-0", "inline-flex cursor-pointer uppercase rounded-sm items-center justify-center gap-2 whitespace-nowrap text-xs font-semibold tracking-wide transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring border-0",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-transparent text-foreground hover:bg-muted border hover:border-border", default: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80",
primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50", primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50",
destructive: destructive:
"border border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground focus-visible:ring-destructive/50", "border border-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/50 text-destructive hover:text-white",
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground", outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-transparent text-foreground hover:bg-muted border", secondary: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,6 @@
// fallow-ignore-file unused-export
import * as React from "react"; import * as React from "react";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,3 @@
// fallow-ignore-file unused-export
"use client"; "use client";
import * as React from "react"; import * as React from "react";
@ -345,7 +344,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-content" data-slot="sidebar-content"
data-sidebar="content" data-sidebar="content"
className={cn( className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden group-data-[collapsible=icon]:overflow-hidden", "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className, className,
)} )}
{...props} {...props}
@ -566,16 +565,10 @@ function SidebarMenuSkeleton({
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
showIcon?: boolean; showIcon?: boolean;
}) { }) {
const skeletonId = React.useId(); // Random width between 50 to 90%.
const width = React.useMemo(() => { const width = React.useMemo(() => {
let hash = 0; return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
for (const char of skeletonId) {
hash = (hash * 31 + char.charCodeAt(0)) % 40;
}
return `${hash + 50}%`;
}, [skeletonId]);
return ( return (
<div <div

View file

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

View file

@ -1,12 +1,9 @@
import * as SwitchPrimitive from "@radix-ui/react-switch"; import * as SwitchPrimitive from "@radix-ui/react-switch";
import * as React from "react"; import type * as React from "react";
import { useWebHaptics } from "web-haptics/react";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) { function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
const trigger = useWebHaptics().trigger;
return ( return (
<SwitchPrimitive.Root <SwitchPrimitive.Root
data-slot="switch" data-slot="switch"
@ -14,10 +11,6 @@ function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimi
"cursor-pointer peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "cursor-pointer peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
onCheckedChange={(checked) => {
void trigger([{ duration: 8 }], { intensity: 0.3 });
props.onCheckedChange?.(checked);
}}
{...props} {...props}
> >
<SwitchPrimitive.Thumb <SwitchPrimitive.Thumb

View file

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

View file

@ -1,7 +1,6 @@
import * as TabsPrimitive from "@radix-ui/react-tabs"; import * as TabsPrimitive from "@radix-ui/react-tabs";
import type * as React from "react"; import type * as React from "react";
import { useWebHaptics } from "web-haptics/react";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) { function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
@ -18,14 +17,7 @@ function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimi
); );
} }
function TabsTrigger({ className, onClick, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) { function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
const { trigger } = useWebHaptics();
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (event) => {
void trigger([{ duration: 8 }], { intensity: 0.3 });
onClick?.(event);
};
return ( return (
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
data-slot="tabs-trigger" data-slot="tabs-trigger"
@ -37,25 +29,24 @@ function TabsTrigger({ className, onClick, ...props }: React.ComponentProps<type
// Padding: 20px horizontal (8px for bracket tick + 12px gap to text) // Padding: 20px horizontal (8px for bracket tick + 12px gap to text)
"px-5", "px-5",
// Transparent orange background for active state // Transparent orange background for active state
"data-[state=active]:bg-strong-accent/10", "data-[state=active]:bg-[#FF453A]/10",
// Left bracket - vertical line // Left bracket - vertical line
"before:absolute before:left-0 before:top-0 before:h-7 before:w-0.5 before:bg-muted-foreground/60 before:transition-colors data-[state=active]:before:bg-strong-accent", "before:absolute before:left-0 before:top-0 before:h-7 before:w-0.5 before:bg-[#5D6570] before:transition-colors data-[state=active]:before:bg-[#FF453A]",
// Left bracket - top tick // Left bracket - top tick
"after:absolute after:left-0 after:-top-px after:w-2 after:h-0.5 after:bg-muted-foreground/60 after:transition-colors data-[state=active]:after:bg-strong-accent", "after:absolute after:left-0 after:top-[-1px] after:w-2 after:h-0.5 after:bg-[#5D6570] after:transition-colors data-[state=active]:after:bg-[#FF453A]",
className, className,
)} )}
onClick={handleClick}
{...props} {...props}
> >
<span className="relative z-10">{props.children}</span> <span className="relative z-10">{props.children}</span>
{/* Left bracket - bottom tick */} {/* Left bracket - bottom tick */}
<span className="absolute left-0 bottom-[-1px] h-0.5 w-2 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" /> <span className="absolute left-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
{/* Right bracket - top tick */} {/* Right bracket - top tick */}
<span className="absolute right-0 top-[-1px] h-0.5 w-2 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" /> <span className="absolute right-0 top-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
{/* Right bracket - vertical line */} {/* Right bracket - vertical line */}
<span className="absolute right-0 top-0 h-7 w-0.5 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" /> <span className="absolute right-0 top-0 h-7 w-0.5 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
{/* Right bracket - bottom tick */} {/* Right bracket - bottom tick */}
<span className="absolute right-0 bottom-[-1px] h-0.5 w-2 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" /> <span className="absolute right-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
</TabsPrimitive.Trigger> </TabsPrimitive.Trigger>
); );
} }

View file

@ -1,5 +1,5 @@
import { Cloud, Folder, Server } from "lucide-react"; import { Cloud, Folder, Server } from "lucide-react";
import type { BackendType } from "@zerobyte/contracts/volumes"; import type { BackendType } from "~/schemas/volumes";
type VolumeIconProps = { type VolumeIconProps = {
backend: BackendType; backend: BackendType;
@ -49,7 +49,7 @@ export const VolumeIcon = ({ backend }: VolumeIconProps) => {
const { icon: Icon, label } = getIconAndLabel(backend); const { icon: Icon, label } = getIconAndLabel(backend);
return ( return (
<span className={`flex items-center gap-2 rounded-md py-1`}> <span className={`flex items-center gap-2 rounded-md px-2 py-1`}>
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
{label} {label}
</span> </span>

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,6 +1,6 @@
import { createServerFn } from "@tanstack/react-start"; import { createIsomorphicFn } from "@tanstack/react-start";
import { config } from "~/server/core/config"; import { config } from "~/server/core/config";
export const getOrigin = createServerFn().handler(() => { export const getOrigin = createIsomorphicFn()
return config.baseUrl; .server(() => config.baseUrl)
}); .client(() => window.location.origin);

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

@ -1,141 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { cleanup, createTestQueryClient, render, screen } from "~/test/test-utils";
import { useServerEvents } from "../use-server-events";
class MockEventSource {
static instances: MockEventSource[] = [];
public onerror: ((event: Event) => void) | null = null;
public close = vi.fn(() => {});
private listeners = new Map<string, Set<(event: Event) => void>>();
constructor(public url: string) {
MockEventSource.instances.push(this);
}
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
const listeners = this.listeners.get(type) ?? new Set<(event: Event) => void>();
const callback = typeof listener === "function" ? listener : (event: Event) => listener.handleEvent(event);
listeners.add(callback);
this.listeners.set(type, listeners);
}
emit(type: string, data: unknown) {
const event = new MessageEvent(type, {
data: JSON.stringify(data),
});
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
static reset() {
MockEventSource.instances = [];
}
}
const originalEventSource = globalThis.EventSource;
const originalConsoleInfo = console.info;
const originalConsoleError = console.error;
const ConnectionConsumer = ({ enabled = true }: { enabled?: boolean }) => {
useServerEvents({ enabled });
return null;
};
const BackupCompletedListener = ({ scheduleId }: { scheduleId: string }) => {
const { addEventListener } = useServerEvents();
const [status, setStatus] = useState("pending");
useEffect(() => {
const abortController = new AbortController();
addEventListener(
"backup:completed",
(event) => {
if (event.scheduleId === scheduleId) {
setStatus(event.status);
}
},
{ signal: abortController.signal },
);
return () => abortController.abort();
}, [addEventListener, scheduleId]);
return <div>{status}</div>;
};
const QueryStatusConsumer = ({ getValue }: { getValue: () => string }) => {
const { data } = useQuery({
queryKey: ["backup-status"],
queryFn: async () => getValue(),
});
return <div>{data}</div>;
};
describe("useServerEvents", () => {
beforeEach(() => {
MockEventSource.reset();
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
console.info = vi.fn(() => {});
console.error = vi.fn(() => {});
});
afterEach(() => {
cleanup();
globalThis.EventSource = originalEventSource;
console.info = originalConsoleInfo;
console.error = originalConsoleError;
MockEventSource.reset();
});
test("shares one EventSource across consumers and refreshes active queries on backup completion", async () => {
const queryClient = createTestQueryClient();
let queryValue = "before";
const view = render(
<>
<ConnectionConsumer />
<BackupCompletedListener scheduleId="0b9c940b" />
<QueryStatusConsumer getValue={() => queryValue} />
</>,
{ queryClient },
);
expect(MockEventSource.instances).toHaveLength(1);
expect(await screen.findByText("before")).toBeTruthy();
queryValue = "after";
MockEventSource.instances[0]?.emit("backup:completed", {
organizationId: "default-org",
scheduleId: "0b9c940b",
volumeName: "synology",
repositoryName: "swiss-backup",
status: "success",
});
expect(await screen.findByText("success")).toBeTruthy();
expect(await screen.findByText("after")).toBeTruthy();
view.unmount();
expect(MockEventSource.instances[0]?.close).toHaveBeenCalledTimes(1);
});
test("waits to subscribe until enabled", () => {
const queryClient = createTestQueryClient();
const view = render(<ConnectionConsumer enabled={false} />, { queryClient });
expect(MockEventSource.instances).toHaveLength(0);
view.rerender(<ConnectionConsumer />);
expect(MockEventSource.instances).toHaveLength(1);
expect(MockEventSource.instances[0]?.url).toBe("/api/v1/events");
});
});

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