Compare commits
1 commit
main
...
coderabbit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
884efd079c |
924 changed files with 23515 additions and 167758 deletions
|
|
@ -1,19 +1,19 @@
|
||||||
**
|
*
|
||||||
|
|
||||||
|
!turbo.json
|
||||||
!bun.lock
|
!bun.lock
|
||||||
!package.json
|
!package.json
|
||||||
!.gitignore
|
|
||||||
!bunfig.toml
|
|
||||||
|
|
||||||
!**/package.json
|
!**/package.json
|
||||||
!**/bun.lock
|
!**/bun.lock
|
||||||
!**/tsconfig.json
|
!**/tsconfig.json
|
||||||
!**/vite.config.ts
|
!**/vite.config.ts
|
||||||
|
!**/react-router.config.ts
|
||||||
|
!**/build.ts
|
||||||
!**/components.json
|
!**/components.json
|
||||||
|
|
||||||
|
!src/**
|
||||||
!app/**
|
!app/**
|
||||||
!apps/agent/**
|
|
||||||
!packages/**
|
|
||||||
!public/**
|
!public/**
|
||||||
|
|
||||||
# License files and attributions
|
# License files and attributions
|
||||||
|
|
@ -21,7 +21,3 @@
|
||||||
!NOTICES.md
|
!NOTICES.md
|
||||||
!LICENSES/**
|
!LICENSES/**
|
||||||
|
|
||||||
node_modules/**
|
|
||||||
dist/**
|
|
||||||
.output/**
|
|
||||||
app/test/integration/artifacts/**
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
end_of_line = lf
|
|
||||||
charset = utf-8
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
|
|
||||||
[*.{ts,tsx,js,json}]
|
|
||||||
indent_style = tab
|
|
||||||
tab_width = 4
|
|
||||||
|
|
||||||
[*.md]
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
SERVER_IP=localhost
|
|
||||||
ZEROBYTE_DATABASE_URL=./data/zerobyte.db
|
|
||||||
RESTIC_CACHE_DIR=./data/restic/cache
|
|
||||||
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
|
|
||||||
ZEROBYTE_VOLUMES_DIR=./data/volumes
|
|
||||||
APP_SECRET=<openssl rand -hex 32>
|
|
||||||
BASE_URL=http://*.localhost
|
|
||||||
TRUST_PROXY=false
|
|
||||||
PORT=3000
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
ZEROBYTE_DATABASE_URL=:memory:
|
|
||||||
APP_SECRET=8b9acd4456dd5db0a4a3c4f4e1240b2c3ae08bb59690167197425e4a25dd9a69
|
|
||||||
BASE_URL=http://localhost:4096
|
|
||||||
ENABLE_LOCAL_AGENT=true
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"entry": [
|
|
||||||
"app/client.tsx",
|
|
||||||
"app/router.tsx",
|
|
||||||
"app/server.ts",
|
|
||||||
"app/server/plugins/bootstrap.ts",
|
|
||||||
"openapi-ts.config.ts",
|
|
||||||
"scripts/create-test-files.ts",
|
|
||||||
"scripts/patch-api-client.ts"
|
|
||||||
],
|
|
||||||
"ignorePatterns": ["node_modules/**", ".output/**", "**/api-client/**", "**/routeTree.gen.ts", "apps/docs/**"],
|
|
||||||
"ignoreDependencies": ["@tailwindcss/typography", "babel-plugin-react-compiler"]
|
|
||||||
}
|
|
||||||
31
.gitattributes
vendored
31
.gitattributes
vendored
|
|
@ -1,31 +0,0 @@
|
||||||
# Set default behavior to automatically normalize line endings
|
|
||||||
* text=auto eol=lf
|
|
||||||
|
|
||||||
# Explicitly declare text files
|
|
||||||
*.ts text eol=lf
|
|
||||||
*.tsx text eol=lf
|
|
||||||
*.js text eol=lf
|
|
||||||
*.json text eol=lf
|
|
||||||
*.md text eol=lf
|
|
||||||
*.css text eol=lf
|
|
||||||
*.html text eol=lf
|
|
||||||
*.yml text eol=lf
|
|
||||||
*.yaml text eol=lf
|
|
||||||
*.sql text eol=lf
|
|
||||||
*.sh text eol=lf
|
|
||||||
*.toml text eol=lf
|
|
||||||
Dockerfile* text eol=lf
|
|
||||||
.dockerignore text eol=lf
|
|
||||||
docker-compose*.yml text eol=lf
|
|
||||||
|
|
||||||
# Binary files
|
|
||||||
*.png binary
|
|
||||||
*.jpg binary
|
|
||||||
*.jpeg binary
|
|
||||||
*.gif binary
|
|
||||||
*.ico binary
|
|
||||||
*.webp binary
|
|
||||||
*.woff binary
|
|
||||||
*.woff2 binary
|
|
||||||
*.ttf binary
|
|
||||||
*.eot binary
|
|
||||||
37
.github/ISSUE_TEMPLATE/1_feature_request.yml
vendored
37
.github/ISSUE_TEMPLATE/1_feature_request.yml
vendored
|
|
@ -1,37 +0,0 @@
|
||||||
name: Feature request
|
|
||||||
description: Suggest an enhancement or new feature
|
|
||||||
title: "[FEATURE] "
|
|
||||||
labels: ["enhancement"]
|
|
||||||
assignees: []
|
|
||||||
body:
|
|
||||||
- type: textarea
|
|
||||||
id: summary
|
|
||||||
attributes:
|
|
||||||
label: Summary
|
|
||||||
description: One-line summary of the requested feature
|
|
||||||
placeholder: Brief description of what you'd like to see added
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: motivation
|
|
||||||
attributes:
|
|
||||||
label: Motivation / Use case
|
|
||||||
description: Why is this feature needed? What problem does it solve?
|
|
||||||
placeholder: Describe the problem or use case that this feature would address
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: proposal
|
|
||||||
attributes:
|
|
||||||
label: Proposed solution
|
|
||||||
description: Brief implementation idea or API surface
|
|
||||||
placeholder: How do you envision this feature working?
|
|
||||||
- type: dropdown
|
|
||||||
id: impact
|
|
||||||
attributes:
|
|
||||||
label: Impact
|
|
||||||
options:
|
|
||||||
- Low
|
|
||||||
- Medium
|
|
||||||
- High
|
|
||||||
description: Estimated impact on users or project
|
|
||||||
79
.github/ISSUE_TEMPLATE/2_bug_report.yml
vendored
79
.github/ISSUE_TEMPLATE/2_bug_report.yml
vendored
|
|
@ -1,79 +0,0 @@
|
||||||
name: Bug report
|
|
||||||
description: Create a report to help us fix a bug
|
|
||||||
title: "[BUG] "
|
|
||||||
labels: ["bug"]
|
|
||||||
assignees: []
|
|
||||||
body:
|
|
||||||
- type: checkboxes
|
|
||||||
id: troubleshooting
|
|
||||||
attributes:
|
|
||||||
label: Prerequisites
|
|
||||||
description: Please confirm you have completed the following steps before submitting this bug report
|
|
||||||
options:
|
|
||||||
- label: I have read the [troubleshooting guide](https://github.com/nicotsx/zerobyte/blob/main/TROUBLESHOOTING.md)
|
|
||||||
required: true
|
|
||||||
- type: checkboxes
|
|
||||||
id: rclone_preflight
|
|
||||||
attributes:
|
|
||||||
label: Rclone Pre-flight Checklist (if applicable)
|
|
||||||
description: If your issue involves rclone, confirm you've tested on the host first as described in the troubleshooting guide
|
|
||||||
options:
|
|
||||||
- label: This issue is NOT related to rclone (skip if not using rclone)
|
|
||||||
- label: I have tested `rclone listremotes` and `rclone lsd remote:` on the **host** and they work
|
|
||||||
- label: I have verified the rclone config is mounted into the container
|
|
||||||
- label: I have restarted the container after config changes
|
|
||||||
- type: textarea
|
|
||||||
id: description
|
|
||||||
attributes:
|
|
||||||
label: Bug Description
|
|
||||||
description: Brief description of what went wrong
|
|
||||||
placeholder: Describe the issue...
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: steps
|
|
||||||
attributes:
|
|
||||||
label: Steps to Reproduce
|
|
||||||
description: List the steps to reproduce the bug
|
|
||||||
placeholder: |
|
|
||||||
1. Go to...
|
|
||||||
2. Click on...
|
|
||||||
3. See error...
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: expected
|
|
||||||
attributes:
|
|
||||||
label: Expected Behavior
|
|
||||||
description: What should have happened?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: input
|
|
||||||
id: version
|
|
||||||
attributes:
|
|
||||||
label: Zerobyte version / commit
|
|
||||||
placeholder: e.g., v1.2.3 or commit sha
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: dropdown
|
|
||||||
id: deployment
|
|
||||||
attributes:
|
|
||||||
label: Deployment Method
|
|
||||||
options:
|
|
||||||
- Docker Compose
|
|
||||||
- Docker
|
|
||||||
- Local Development
|
|
||||||
- Other
|
|
||||||
- type: textarea
|
|
||||||
id: backup_context
|
|
||||||
attributes:
|
|
||||||
label: Backup/Repository Context
|
|
||||||
description: If this is related to backup operations, provide details about the repository type, storage backend, etc.
|
|
||||||
placeholder: e.g., S3 repository, local filesystem, specific backup operation that failed
|
|
||||||
- type: textarea
|
|
||||||
id: logs
|
|
||||||
attributes:
|
|
||||||
label: Logs / Error Messages
|
|
||||||
description: Check browser console (F12) or server logs for errors. Remove any sensitive information.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
20
.github/ISSUE_TEMPLATE/3_question.yml
vendored
20
.github/ISSUE_TEMPLATE/3_question.yml
vendored
|
|
@ -1,20 +0,0 @@
|
||||||
name: Question
|
|
||||||
description: General questions about usage or implementation
|
|
||||||
title: "[QUESTION] "
|
|
||||||
labels: ["question"]
|
|
||||||
assignees: []
|
|
||||||
body:
|
|
||||||
- type: textarea
|
|
||||||
id: question
|
|
||||||
attributes:
|
|
||||||
label: Question
|
|
||||||
description: Provide a clear question and any minimal code samples
|
|
||||||
placeholder: What would you like to know about Zerobyte?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: context
|
|
||||||
attributes:
|
|
||||||
label: Context / what you've tried
|
|
||||||
description: Provide context about what you're trying to accomplish and what you've already tried
|
|
||||||
placeholder: I'm trying to... and I've already tried...
|
|
||||||
21
.github/ISSUE_TEMPLATE/4_security.yml
vendored
21
.github/ISSUE_TEMPLATE/4_security.yml
vendored
|
|
@ -1,21 +0,0 @@
|
||||||
name: Security
|
|
||||||
description: Report a security vulnerability (do not file public issues)
|
|
||||||
title: "[SECURITY] "
|
|
||||||
labels: ["security"]
|
|
||||||
assignees: []
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: >-
|
|
||||||
**⚠️ SECURITY VULNERABILITY NOTICE ⚠️**
|
|
||||||
|
|
||||||
If you believe you've found a security vulnerability, **do not open a public issue**.
|
|
||||||
|
|
||||||
Please report security vulnerabilities privately through one of these methods:
|
|
||||||
|
|
||||||
- Use GitHub's private security advisory feature: [Report a vulnerability](https://github.com/nicotsx/zerobyte/security/advisories/new)
|
|
||||||
- Contact the maintainers directly via email if listed in the repository
|
|
||||||
|
|
||||||
This helps us address security issues responsibly before they can be exploited.
|
|
||||||
|
|
||||||
**Thank you for helping keep Zerobyte secure! 🔒**
|
|
||||||
19
.github/ISSUE_TEMPLATE/5_blank.yml
vendored
19
.github/ISSUE_TEMPLATE/5_blank.yml
vendored
|
|
@ -1,19 +0,0 @@
|
||||||
name: Blank Issue
|
|
||||||
description: Create a blank issue for anything that doesn't fit other templates
|
|
||||||
title: ""
|
|
||||||
labels: []
|
|
||||||
assignees: []
|
|
||||||
body:
|
|
||||||
- type: textarea
|
|
||||||
id: content
|
|
||||||
attributes:
|
|
||||||
label: Issue Details
|
|
||||||
description: Describe your issue, question, or request
|
|
||||||
placeholder: |
|
|
||||||
Please provide as much detail as possible about your issue.
|
|
||||||
|
|
||||||
If this is a bug, consider using the Bug Report template instead.
|
|
||||||
If this is a feature request, consider using the Feature Request template instead.
|
|
||||||
If you have a question, consider using the Question template instead.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
31
.github/actions/install-dependencies/action.yml
vendored
31
.github/actions/install-dependencies/action.yml
vendored
|
|
@ -1,31 +0,0 @@
|
||||||
name: Install dependencies
|
|
||||||
|
|
||||||
description: Install dependencies
|
|
||||||
inputs:
|
|
||||||
workingDirectory:
|
|
||||||
description: "The working directory to install dependencies in"
|
|
||||||
required: false
|
|
||||||
default: "."
|
|
||||||
|
|
||||||
runs:
|
|
||||||
using: "composite"
|
|
||||||
steps:
|
|
||||||
- uses: socketdev/action@937f824ec476dfd164d4a4d9995751427b0be143 # v1
|
|
||||||
with:
|
|
||||||
mode: firewall
|
|
||||||
|
|
||||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
|
||||||
name: Install Bun
|
|
||||||
with:
|
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
cd ${{ inputs.workingDirectory }}
|
|
||||||
sfw vp install --frozen-lockfile
|
|
||||||
19
.github/renovate.json
vendored
19
.github/renovate.json
vendored
|
|
@ -1,19 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
||||||
"extends": ["config:recommended", "helpers:pinGitHubActionDigests"],
|
|
||||||
"enabledManagers": ["bun", "github-actions"],
|
|
||||||
"minimumReleaseAge": "3 days",
|
|
||||||
"rangeStrategy": "bump",
|
|
||||||
"packageRules": [
|
|
||||||
{
|
|
||||||
"matchManagers": ["github-actions"],
|
|
||||||
"matchUpdateTypes": ["digest", "pinDigest"],
|
|
||||||
"minimumReleaseAge": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matchManagers": ["bun"],
|
|
||||||
"matchUpdateTypes": ["minor", "patch"],
|
|
||||||
"groupName": "bun minor and patch dependencies"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
96
.github/workflows/checks.yml
vendored
96
.github/workflows/checks.yml
vendored
|
|
@ -1,96 +0,0 @@
|
||||||
name: Checks
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: checks-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
timeout-minutes: 10
|
|
||||||
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 lint
|
|
||||||
shell: bash
|
|
||||||
run: vp lint --type-aware --deny-warnings
|
|
||||||
|
|
||||||
typecheck:
|
|
||||||
timeout-minutes: 10
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
uses: "./.github/actions/install-dependencies"
|
|
||||||
|
|
||||||
- name: Run type checks
|
|
||||||
shell: bash
|
|
||||||
run: bun run tsc
|
|
||||||
|
|
||||||
test-server:
|
|
||||||
timeout-minutes: 15
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
uses: "./.github/actions/install-dependencies"
|
|
||||||
|
|
||||||
- name: Run server tests
|
|
||||||
shell: bash
|
|
||||||
run: bun run test:server
|
|
||||||
|
|
||||||
test-client:
|
|
||||||
timeout-minutes: 15
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
uses: "./.github/actions/install-dependencies"
|
|
||||||
|
|
||||||
- name: Run client tests
|
|
||||||
shell: bash
|
|
||||||
run: bun run test:client
|
|
||||||
|
|
||||||
build:
|
|
||||||
timeout-minutes: 15
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
uses: "./.github/actions/install-dependencies"
|
|
||||||
|
|
||||||
- name: Build project
|
|
||||||
shell: bash
|
|
||||||
run: bun run build
|
|
||||||
41
.github/workflows/docs-deploy.yml
vendored
41
.github/workflows/docs-deploy.yml
vendored
|
|
@ -1,41 +0,0 @@
|
||||||
name: Docs deploy
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- "apps/docs/**"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
name: Deploy docs
|
|
||||||
if: github.event.repository.default_branch == github.ref_name
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
uses: "./.github/actions/install-dependencies"
|
|
||||||
|
|
||||||
- name: Build docs
|
|
||||||
working-directory: apps/docs
|
|
||||||
run: bun run build
|
|
||||||
|
|
||||||
- name: Build and deploy docs
|
|
||||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4
|
|
||||||
with:
|
|
||||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
workingDirectory: apps/docs
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
108
.github/workflows/e2e.yml
vendored
108
.github/workflows/e2e.yml
vendored
|
|
@ -1,108 +0,0 @@
|
||||||
name: E2E Tests
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
workflow_call:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
playwright:
|
|
||||||
name: Playwright
|
|
||||||
timeout-minutes: 20
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
uses: "./.github/actions/install-dependencies"
|
|
||||||
|
|
||||||
- name: Get Playwright version
|
|
||||||
id: playwright-version
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
playwright_version=$(bun -e "console.log((await Bun.file('./package.json').json()).devDependencies['@playwright/test'])")
|
|
||||||
echo "version=$playwright_version" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Cache Playwright Browsers
|
|
||||||
id: playwright-cache
|
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
|
||||||
with:
|
|
||||||
path: ~/.cache/ms-playwright
|
|
||||||
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-playwright-
|
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
|
||||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
|
||||||
run: bunx playwright install --with-deps
|
|
||||||
|
|
||||||
- name: Install Playwright System Dependencies
|
|
||||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
|
||||||
run: bunx playwright install-deps
|
|
||||||
|
|
||||||
- name: Prepare environment
|
|
||||||
run: |
|
|
||||||
mkdir -p data
|
|
||||||
mkdir -p playwright/temp
|
|
||||||
mkdir -p playwright/data
|
|
||||||
mkdir -p playwright/tinyauth/app-data
|
|
||||||
mkdir -p playwright/tinyauth/caddy-data
|
|
||||||
chmod -R 777 playwright/temp
|
|
||||||
touch .env.local
|
|
||||||
echo "SERVER_IP=localhost" >> .env.local
|
|
||||||
echo "ZEROBYTE_DATABASE_URL=./data/zerobyte.db" >> .env.local
|
|
||||||
echo "BASE_URL=http://localhost:4096" >> .env.local
|
|
||||||
echo "E2E_TINYAUTH_ORIGIN=https://tinyauth.example.com:5557" >> .env.local
|
|
||||||
echo "TZ=Europe/Zurich" >> .env.local
|
|
||||||
|
|
||||||
- name: Start zerobyte-e2e service
|
|
||||||
run: bun run start:e2e -- -d
|
|
||||||
|
|
||||||
- name: Check CLI version
|
|
||||||
run: |
|
|
||||||
expected_version=$(docker exec zerobyte printenv APP_VERSION)
|
|
||||||
cli_version=$(docker exec zerobyte bun run --silent cli --version)
|
|
||||||
echo "$cli_version"
|
|
||||||
test "$cli_version" = "$expected_version"
|
|
||||||
|
|
||||||
- name: Wait for zerobyte to be ready
|
|
||||||
run: |
|
|
||||||
timeout 30s bash -c 'until curl -f http://localhost:4096/api/healthcheck; do echo "Waiting for server..." && sleep 2; done'
|
|
||||||
|
|
||||||
- name: Wait for Tinyauth to be ready
|
|
||||||
run: |
|
|
||||||
timeout 30s bash -c 'until curl -skf https://localhost:5557/.well-known/openid-configuration; do echo "Waiting for Tinyauth..." && sleep 2; done'
|
|
||||||
|
|
||||||
- name: Print docker logs if failed to start
|
|
||||||
if: failure()
|
|
||||||
run: docker compose logs zerobyte-e2e tinyauth tinyauth-app || true
|
|
||||||
|
|
||||||
- name: Make playwright directory writable
|
|
||||||
run: sudo chmod 777 playwright/data/zerobyte.db
|
|
||||||
|
|
||||||
- name: Run Playwright tests
|
|
||||||
run: bun run test:e2e
|
|
||||||
|
|
||||||
- name: Dump container logs in playwright-report folder
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
tree playwright || true
|
|
||||||
docker compose logs zerobyte-e2e > playwright-report/container-logs.txt || true
|
|
||||||
docker compose logs tinyauth tinyauth-app > playwright-report/tinyauth-logs.txt || true
|
|
||||||
|
|
||||||
- name: Debug - print content of /test-data in container
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
docker exec zerobyte /bin/ash -c "ls -la /test-data" || true
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: playwright-report
|
|
||||||
path: playwright-report/
|
|
||||||
retention-days: 5
|
|
||||||
|
|
||||||
- name: Stop Docker Compose
|
|
||||||
if: always()
|
|
||||||
run: docker compose down
|
|
||||||
35
.github/workflows/integration.yml
vendored
35
.github/workflows/integration.yml
vendored
|
|
@ -1,35 +0,0 @@
|
||||||
name: Integration Tests
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
workflow_call:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
integration:
|
|
||||||
name: Integration
|
|
||||||
timeout-minutes: 30
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
uses: "./.github/actions/install-dependencies"
|
|
||||||
|
|
||||||
- name: Run integration tests
|
|
||||||
env:
|
|
||||||
SKIP_VOLUME_MOUNT_INTEGRATION_TESTS: "true"
|
|
||||||
run: bun run test:integration
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
||||||
if: failure()
|
|
||||||
with:
|
|
||||||
name: integration-artifacts
|
|
||||||
path: |
|
|
||||||
app/test/integration/artifacts/compose.log
|
|
||||||
app/test/integration/artifacts/docker-output.log
|
|
||||||
app/test/integration/artifacts/runs/**
|
|
||||||
retention-days: 5
|
|
||||||
60
.github/workflows/nightly.yml
vendored
60
.github/workflows/nightly.yml
vendored
|
|
@ -1,60 +0,0 @@
|
||||||
name: Nightly Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 2 * * *"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
timeout-minutes: 15
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
|
||||||
with:
|
|
||||||
driver: cloud
|
|
||||||
endpoint: "meienberger/runtipi-builder"
|
|
||||||
install: true
|
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
|
|
||||||
with:
|
|
||||||
images: ghcr.io/${{ github.repository_owner }}/zerobyte
|
|
||||||
tags: |
|
|
||||||
type=raw,value=nightly
|
|
||||||
|
|
||||||
- name: Push images to GitHub Container Registry
|
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
target: production
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
build-args: |
|
|
||||||
APP_VERSION=nightly
|
|
||||||
67
.github/workflows/release.yml
vendored
67
.github/workflows/release.yml
vendored
|
|
@ -7,17 +7,12 @@ on:
|
||||||
- "v*.*.*-beta.*"
|
- "v*.*.*-beta.*"
|
||||||
- "v*.*.*-alpha.*"
|
- "v*.*.*-alpha.*"
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
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,52 +26,25 @@ 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:
|
|
||||||
uses: ./.github/workflows/checks.yml
|
|
||||||
|
|
||||||
e2e-tests:
|
|
||||||
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]
|
||||||
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@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ github.ref }}
|
ref: ${{ github.ref }}
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Set up QEMU
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
uses: docker/setup-qemu-action@v3
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
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:
|
|
||||||
driver: cloud
|
|
||||||
endpoint: "meienberger/runtipi-builder"
|
|
||||||
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 }}
|
||||||
|
|
@ -84,7 +52,7 @@ 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: |
|
||||||
|
|
@ -95,8 +63,8 @@ jobs:
|
||||||
flavor: |
|
flavor: |
|
||||||
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: Build and push images
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
target: production
|
target: production
|
||||||
|
|
@ -106,19 +74,19 @@ jobs:
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
build-args: |
|
build-args: |
|
||||||
APP_VERSION=${{ needs.determine-release-type.outputs.tagname }}
|
APP_VERSION=${{ needs.determine-release-type.outputs.tagname }}
|
||||||
|
cache-from: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache
|
||||||
|
cache-to: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache,mode=max
|
||||||
|
|
||||||
publish-release:
|
publish-release:
|
||||||
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 +96,4 @@ jobs:
|
||||||
name: ${{ needs.determine-release-type.outputs.tagname }}
|
name: ${{ needs.determine-release-type.outputs.tagname }}
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: true
|
prerelease: true
|
||||||
|
files: cli/runtipi-cli-*
|
||||||
request-docs-version-update:
|
|
||||||
uses: ./.github/workflows/request-docs-version-update.yml
|
|
||||||
needs: [publish-release, determine-release-type]
|
|
||||||
if: needs.determine-release-type.outputs.release_type == 'release' && needs.determine-release-type.outputs.is_minor_release == 'true'
|
|
||||||
with:
|
|
||||||
release_tag: ${{ needs.determine-release-type.outputs.tagname }}
|
|
||||||
base_branch: ${{ github.event.repository.default_branch }}
|
|
||||||
secrets:
|
|
||||||
COPILOT_ASSIGNMENT_TOKEN: ${{ secrets.COPILOT_ASSIGNMENT_TOKEN }}
|
|
||||||
|
|
|
||||||
122
.github/workflows/request-docs-version-update.yml
vendored
122
.github/workflows/request-docs-version-update.yml
vendored
|
|
@ -1,122 +0,0 @@
|
||||||
name: Request docs version update
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
release_tag:
|
|
||||||
description: Minor release tag, for example v0.35.0
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
base_branch:
|
|
||||||
description: Branch Copilot should branch from
|
|
||||||
required: false
|
|
||||||
default: main
|
|
||||||
type: string
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
release_tag:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
base_branch:
|
|
||||||
required: false
|
|
||||||
default: main
|
|
||||||
type: string
|
|
||||||
secrets:
|
|
||||||
COPILOT_ASSIGNMENT_TOKEN:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
request-docs-version-update:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Create Copilot issue
|
|
||||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.COPILOT_ASSIGNMENT_TOKEN }}
|
|
||||||
script: |
|
|
||||||
if (!process.env.COPILOT_ASSIGNMENT_TOKEN) {
|
|
||||||
core.setFailed("COPILOT_ASSIGNMENT_TOKEN is required to assign issues to GitHub Copilot.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const releaseTag = process.env.RELEASE_TAG;
|
|
||||||
const baseBranch = process.env.BASE_BRANCH;
|
|
||||||
const minorRelease = releaseTag.match(/^(v\d+\.\d+)\.0$/);
|
|
||||||
if (!minorRelease) {
|
|
||||||
core.setFailed(`Docs version updates only run for minor release tags like v0.35.0. Received: ${releaseTag}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const docsVersion = minorRelease[1];
|
|
||||||
const { owner, repo } = context.repo;
|
|
||||||
const targetRepo = `${owner}/${repo}`;
|
|
||||||
const releaseUrl = `https://github.com/${targetRepo}/releases/tag/${releaseTag}`;
|
|
||||||
const title = `Update Docker Compose examples to ${docsVersion}`;
|
|
||||||
const bodyLines = [
|
|
||||||
`A new Zerobyte release was published: ${releaseTag}`,
|
|
||||||
"",
|
|
||||||
`Release: ${releaseUrl}`,
|
|
||||||
"",
|
|
||||||
"Please create a pull request that updates Docker Compose documentation examples to use:",
|
|
||||||
"",
|
|
||||||
"```yaml",
|
|
||||||
`image: ghcr.io/nicotsx/zerobyte:${docsVersion}`,
|
|
||||||
"```",
|
|
||||||
"",
|
|
||||||
"Scope:",
|
|
||||||
"",
|
|
||||||
"- Update Docker image tags in `README.md`.",
|
|
||||||
"- Update Docker image tags in `apps/docs/`.",
|
|
||||||
"- Only update Zerobyte image versions in Docker Compose examples.",
|
|
||||||
"- Do not change unrelated prose, formatting, or code.",
|
|
||||||
"",
|
|
||||||
"Verification:",
|
|
||||||
"",
|
|
||||||
"- Search for stale `ghcr.io/nicotsx/zerobyte:v...` references in `README.md` and `apps/docs/`.",
|
|
||||||
"- Run any formatting or documentation checks that are appropriate for the touched files.",
|
|
||||||
];
|
|
||||||
|
|
||||||
const issue = await github.rest.issues.create({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
title,
|
|
||||||
body: bodyLines.join("\n"),
|
|
||||||
});
|
|
||||||
|
|
||||||
await github.rest.issues.update({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: issue.data.number,
|
|
||||||
body: [
|
|
||||||
...bodyLines,
|
|
||||||
"",
|
|
||||||
"Pull request requirement:",
|
|
||||||
"",
|
|
||||||
`- Include \`Closes #${issue.data.number}\` in the pull request body.`,
|
|
||||||
].join("\n"),
|
|
||||||
});
|
|
||||||
|
|
||||||
await github.request("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees", {
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: issue.data.number,
|
|
||||||
assignees: ["copilot-swe-agent[bot]"],
|
|
||||||
agent_assignment: {
|
|
||||||
target_repo: targetRepo,
|
|
||||||
base_branch: baseBranch,
|
|
||||||
custom_instructions: "Create a focused documentation-only PR for the release version update.",
|
|
||||||
custom_agent: "",
|
|
||||||
model: "",
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
accept: "application/vnd.github+json",
|
|
||||||
"X-GitHub-Api-Version": "2022-11-28",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
env:
|
|
||||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
|
||||||
BASE_BRANCH: ${{ inputs.base_branch }}
|
|
||||||
COPILOT_ASSIGNMENT_TOKEN: ${{ secrets.COPILOT_ASSIGNMENT_TOKEN }}
|
|
||||||
41
.github/workflows/scorecard.yml
vendored
41
.github/workflows/scorecard.yml
vendored
|
|
@ -1,41 +0,0 @@
|
||||||
name: Scorecard analysis
|
|
||||||
on:
|
|
||||||
branch_protection_rule:
|
|
||||||
push:
|
|
||||||
branches: [ "main" ]
|
|
||||||
|
|
||||||
permissions: read-all
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analysis:
|
|
||||||
name: Scorecard analysis
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
|
|
||||||
permissions:
|
|
||||||
security-events: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Run analysis
|
|
||||||
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186
|
|
||||||
with:
|
|
||||||
results_file: results.sarif
|
|
||||||
results_format: sarif
|
|
||||||
publish_results: true
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1
|
|
||||||
with:
|
|
||||||
name: SARIF file
|
|
||||||
path: results.sarif
|
|
||||||
retention-days: 5
|
|
||||||
|
|
||||||
- name: Upload to code-scanning
|
|
||||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
|
||||||
with:
|
|
||||||
sarif_file: results.sarif
|
|
||||||
38
.gitignore
vendored
38
.gitignore
vendored
|
|
@ -12,41 +12,3 @@ CLAUDE.md
|
||||||
|
|
||||||
mutagen.yml.lock
|
mutagen.yml.lock
|
||||||
notes.md
|
notes.md
|
||||||
smb-password.txt
|
|
||||||
cache.db
|
|
||||||
|
|
||||||
/data
|
|
||||||
|
|
||||||
.env*
|
|
||||||
!.env.example
|
|
||||||
!.env.test
|
|
||||||
|
|
||||||
# Playwright
|
|
||||||
node_modules/
|
|
||||||
/test-results/
|
|
||||||
/playwright-report/
|
|
||||||
/blob-report/
|
|
||||||
/playwright/.cache/
|
|
||||||
/playwright/.auth/
|
|
||||||
/playwright/*.pass
|
|
||||||
/playwright/tinyauth/app-data/
|
|
||||||
/playwright/tinyauth/caddy-data/
|
|
||||||
|
|
||||||
playwright/.auth
|
|
||||||
playwright/temp
|
|
||||||
playwright/data
|
|
||||||
|
|
||||||
.idea/
|
|
||||||
.deepsec/
|
|
||||||
.codex/
|
|
||||||
|
|
||||||
# OpenAPI error logs
|
|
||||||
openapi-ts-error-*.log
|
|
||||||
.output
|
|
||||||
tmp/
|
|
||||||
qa-output
|
|
||||||
|
|
||||||
.worktrees/
|
|
||||||
.turbo
|
|
||||||
.superset
|
|
||||||
.fallow
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
vp staged
|
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
|
||||||
}
|
|
||||||
220
AGENTS.md
220
AGENTS.md
|
|
@ -1,29 +1,33 @@
|
||||||
|
# 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 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, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage).
|
||||||
|
|
||||||
### Development Server
|
## Technology Stack
|
||||||
|
|
||||||
```bash
|
- **Runtime**: Bun 1.3.1
|
||||||
# Start the dev server through Portless
|
- **Server**: Hono (web framework) with Bun runtime
|
||||||
bun run dev
|
- **Client**: React Router v7 (SSR) with React 19
|
||||||
|
- **Database**: SQLite with Drizzle ORM
|
||||||
|
- **Validation**: ArkType for runtime schema validation
|
||||||
|
- **Styling**: Tailwind CSS v4 + Radix UI components
|
||||||
|
- **Architecture**: Unified application structure (not a monorepo)
|
||||||
|
- **Code Quality**: Biome (formatter & linter)
|
||||||
|
|
||||||
# Get the current app URL for this worktree
|
## Repository Structure
|
||||||
portless get zerobyte
|
|
||||||
|
|
||||||
# Inspect active Portless routes if needed
|
This is a unified application with the following structure:
|
||||||
portless list
|
|
||||||
```
|
|
||||||
|
|
||||||
Portless applies git worktree prefixes automatically, so linked worktrees may return URLs like `https://branch-name.zerobyte.localhost`. Use the Portless URL for browser testing and manual verification.
|
- `app/server` - Bun-based API server with Hono
|
||||||
|
- `app/client` - React Router SSR frontend components and modules
|
||||||
|
- `app/schemas` - Shared ArkType schemas for validation
|
||||||
|
- `app/drizzle` - Database migrations
|
||||||
|
|
||||||
### Type Checking
|
### Type Checking
|
||||||
|
|
||||||
|
|
@ -32,16 +36,6 @@ Portless applies git worktree prefixes automatically, so linked worktrees may re
|
||||||
bun run tsc
|
bun run tsc
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
bun run test
|
|
||||||
|
|
||||||
# Run a specific test file
|
|
||||||
bunx dotenv-cli -e .env.test -- bunx --bun vitest run --project server path/to/test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -63,20 +57,184 @@ bunx drizzle-kit generate --custom --name=fix-timestamps-to-ms
|
||||||
### API Client Generation
|
### API Client Generation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Generate TypeScript API client from OpenAPI spec
|
||||||
|
# Note: Server is always running don't need to start it separately
|
||||||
bun run gen:api-client
|
bun run gen:api-client
|
||||||
```
|
```
|
||||||
|
|
||||||
### Code Quality
|
### Code Quality
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Format
|
# Format and lint (Biome)
|
||||||
vp fmt <path> --write
|
bunx biome check --write .
|
||||||
|
|
||||||
# Lint
|
# Format only
|
||||||
bun run lint
|
bunx biome format --write .
|
||||||
|
|
||||||
|
# Lint only
|
||||||
|
bunx biome lint .
|
||||||
```
|
```
|
||||||
|
|
||||||
### Invalidation
|
## Architecture
|
||||||
|
|
||||||
The frontend has an automatic invalidation setup which runs after every mutation.
|
### Server Architecture
|
||||||
Do not implement any invalidation logic in the frontend.
|
|
||||||
|
The server follows a modular service-oriented architecture:
|
||||||
|
|
||||||
|
**Entry Point**: `app/server/index.ts`
|
||||||
|
|
||||||
|
- Initializes main API server on port 4096 (REST API + serves static frontend)
|
||||||
|
|
||||||
|
**Modules** (`app/server/modules/`):
|
||||||
|
Each module follows a controller <20> service <20> database pattern:
|
||||||
|
|
||||||
|
- `auth/` - User authentication and session management
|
||||||
|
- `volumes/` - Volume mounting/unmounting (NFS, SMB, WebDAV, directories)
|
||||||
|
- `repositories/` - Restic repository management (S3, Azure, GCS, local, rclone)
|
||||||
|
- `backups/` - Backup schedule management and execution
|
||||||
|
- `notifications/` - Notification system with multiple providers (Discord, email, Gotify, Ntfy, Slack, Pushover)
|
||||||
|
- `driver/` - Docker volume plugin implementation
|
||||||
|
- `events/` - Server-Sent Events for real-time updates
|
||||||
|
- `system/` - System information and capabilities
|
||||||
|
- `lifecycle/` - Application startup/shutdown hooks
|
||||||
|
|
||||||
|
**Backends** (`app/server/modules/backends/`):
|
||||||
|
Each volume backend (NFS, SMB, WebDAV, directory) implements mounting logic using system tools (mount.nfs, mount.cifs, davfs2).
|
||||||
|
|
||||||
|
**Jobs** (`app/server/jobs/`):
|
||||||
|
Cron-based background jobs managed by the Scheduler:
|
||||||
|
|
||||||
|
- `backup-execution.ts` - Runs scheduled backups (every minute)
|
||||||
|
- `cleanup-dangling.ts` - Removes stale mounts (hourly)
|
||||||
|
- `healthchecks.ts` - Checks volume health (every 5 minutes)
|
||||||
|
- `repository-healthchecks.ts` - Validates repositories (every 10 minutes)
|
||||||
|
- `cleanup-sessions.ts` - Expires old sessions (daily)
|
||||||
|
|
||||||
|
**Core** (`app/server/core/`):
|
||||||
|
|
||||||
|
- `scheduler.ts` - Job scheduling system using node-cron
|
||||||
|
- `capabilities.ts` - Detects available system features
|
||||||
|
- `constants.ts` - Application-wide constants
|
||||||
|
|
||||||
|
**Utils** (`app/server/utils/`):
|
||||||
|
|
||||||
|
- `restic.ts` - Restic CLI wrapper with type-safe output parsing
|
||||||
|
- `spawn.ts` - Safe subprocess execution helpers
|
||||||
|
- `logger.ts` - Winston-based logging
|
||||||
|
- `crypto.ts` - Encryption utilities
|
||||||
|
- `errors.ts` - Error handling middleware
|
||||||
|
|
||||||
|
**Database** (`app/server/db/`):
|
||||||
|
|
||||||
|
- Uses Drizzle ORM with SQLite
|
||||||
|
- Schema in `schema.ts` defines: volumes, repositories, backup schedules, notifications, users, sessions
|
||||||
|
- Migrations: `app/drizzle/`
|
||||||
|
|
||||||
|
### Client Architecture
|
||||||
|
|
||||||
|
**Framework**: React Router v7 with SSR
|
||||||
|
**Entry Point**: `app/root.tsx`
|
||||||
|
|
||||||
|
The client uses:
|
||||||
|
|
||||||
|
- TanStack Query for server state management
|
||||||
|
- Auto-generated API client from OpenAPI spec (in `app/client/api-client/`)
|
||||||
|
- Radix UI primitives with custom Tailwind styling
|
||||||
|
- Server-Sent Events hook (`use-server-events.ts`) for real-time updates
|
||||||
|
|
||||||
|
Routes are organized in feature modules at `app/client/modules/*/routes/`.
|
||||||
|
|
||||||
|
### Shared Schemas
|
||||||
|
|
||||||
|
`app/schemas/` contains ArkType schemas used by both client and server:
|
||||||
|
|
||||||
|
- Volume configurations (NFS, SMB, WebDAV, directory)
|
||||||
|
- Repository configurations (S3, Azure, GCS, local, rclone)
|
||||||
|
- Restic command output parsing types
|
||||||
|
- Backend status types
|
||||||
|
|
||||||
|
These schemas provide runtime validation and TypeScript types.
|
||||||
|
|
||||||
|
## Restic Integration
|
||||||
|
|
||||||
|
Zerobyte is a wrapper around Restic for backup operations. Key integration points:
|
||||||
|
|
||||||
|
**Repository Management**:
|
||||||
|
|
||||||
|
- Creates/initializes Restic repositories via `restic init`
|
||||||
|
- Supports multiple backends: local, S3, Azure Blob Storage, Google Cloud Storage, or any rclone-supported backend
|
||||||
|
- Stores single encryption password in `/var/lib/zerobyte/restic/password` (auto-generated on first run)
|
||||||
|
|
||||||
|
**Backup Operations**:
|
||||||
|
|
||||||
|
- Executes `restic backup` with user-defined schedules (cron expressions)
|
||||||
|
- Supports include/exclude patterns for selective backups
|
||||||
|
- Parses JSON output for progress tracking and statistics
|
||||||
|
- Implements retention policies via `restic forget --prune`
|
||||||
|
|
||||||
|
**Repository Utilities** (`utils/restic.ts`):
|
||||||
|
|
||||||
|
- `buildRepoUrl()` - Constructs repository URLs for different backends
|
||||||
|
- `buildEnv()` - Sets environment variables (credentials, cache dir)
|
||||||
|
- `ensurePassfile()` - Manages encryption password file
|
||||||
|
- Type-safe parsing of Restic JSON output using ArkType schemas
|
||||||
|
|
||||||
|
**Rclone Integration** (`app/server/modules/repositories/`):
|
||||||
|
|
||||||
|
- Allows using any rclone backend as a Restic repository
|
||||||
|
- Dynamically generates rclone config and passes via environment variables
|
||||||
|
- Supports backends like Dropbox, Google Drive, OneDrive, Backblaze B2, etc.
|
||||||
|
|
||||||
|
## Environment & Configuration
|
||||||
|
|
||||||
|
**Runtime Environment Variables**:
|
||||||
|
|
||||||
|
- Database path: `./data/zerobyte.db` (configurable via `drizzle.config.ts`)
|
||||||
|
- Restic cache: `/var/lib/zerobyte/restic/cache`
|
||||||
|
- Restic password: `/var/lib/zerobyte/restic/password`
|
||||||
|
- Volume mounts: `/var/lib/zerobyte/mounts/<volume-name>`
|
||||||
|
- Local repositories: `/var/lib/zerobyte/repositories/<repo-name>`
|
||||||
|
|
||||||
|
**Capabilities Detection**:
|
||||||
|
On startup, the server detects available capabilities (see `core/capabilities.ts`):
|
||||||
|
|
||||||
|
- **rclone**: Requires `/root/.config/rclone` directory access
|
||||||
|
- System will gracefully degrade if capabilities are unavailable
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### Adding a New Volume Backend
|
||||||
|
|
||||||
|
1. Create backend implementation in `app/server/modules/backends/<backend>/`
|
||||||
|
2. Implement `mount()` and `unmount()` methods
|
||||||
|
3. Add schema to `app/schemas/volumes.ts`
|
||||||
|
4. Update `volumeConfigSchema` discriminated union
|
||||||
|
5. Update backend factory in `app/server/modules/backends/backend.ts`
|
||||||
|
|
||||||
|
### Adding a New Repository Backend
|
||||||
|
|
||||||
|
1. Add backend type to `app/schemas/restic.ts`
|
||||||
|
2. Update `buildRepoUrl()` in `app/server/utils/restic.ts`
|
||||||
|
3. Update `buildEnv()` to handle credentials/configuration
|
||||||
|
4. Add DTO schemas in `app/server/modules/repositories/repositories.dto.ts`
|
||||||
|
5. Update repository service to handle new backend
|
||||||
|
|
||||||
|
### Adding a New Scheduled Job
|
||||||
|
|
||||||
|
1. Create job class in `app/server/jobs/<job-name>.ts` extending `Job`
|
||||||
|
2. Implement `run()` method
|
||||||
|
3. Register in `app/server/modules/lifecycle/startup.ts` with cron expression:
|
||||||
|
```typescript
|
||||||
|
Scheduler.build(YourJob).schedule("* * * * *");
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- **Code Style**: Uses Biome with tabs (not spaces), 120 char line width, double quotes
|
||||||
|
- **Imports**: Organize imports is disabled in Biome - do not auto-organize
|
||||||
|
- **TypeScript**: Uses `"type": "module"` - all imports must include extensions when targeting Node/Bun
|
||||||
|
- **Validation**: Prefer ArkType over Zod - it's used throughout the codebase
|
||||||
|
- **Database**: Timestamps are stored as Unix epoch integers, not ISO strings
|
||||||
|
- **Security**: Restic password file has 0600 permissions - never expose it
|
||||||
|
- **Mounting**: Requires privileged container or CAP_SYS_ADMIN for FUSE mounts
|
||||||
|
- **API Documentation**: OpenAPI spec auto-generated at `/api/v1/openapi.json`, docs at `/api/v1/docs`
|
||||||
|
|
|
||||||
81
Dockerfile
81
Dockerfile
|
|
@ -1,18 +1,8 @@
|
||||||
FROM oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0 AS base
|
ARG BUN_VERSION="1.3.3"
|
||||||
|
|
||||||
ARG RESTIC_VERSION="0.18.1"
|
FROM oven/bun:${BUN_VERSION}-alpine AS base
|
||||||
ARG RCLONE_VERSION="1.74.2"
|
|
||||||
ARG SHOUTRRR_VERSION="0.15.1"
|
|
||||||
|
|
||||||
ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
|
RUN apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3
|
||||||
VITE_RCLONE_VERSION=${RCLONE_VERSION} \
|
|
||||||
VITE_SHOUTRRR_VERSION=${SHOUTRRR_VERSION}
|
|
||||||
|
|
||||||
RUN apk update --no-cache && \
|
|
||||||
apk upgrade --no-cache && \
|
|
||||||
apk add --no-cache acl attr cifs-utils davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata
|
|
||||||
|
|
||||||
ENTRYPOINT ["/sbin/tini", "-s", "--"]
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
|
|
@ -23,44 +13,34 @@ FROM base AS deps
|
||||||
WORKDIR /deps
|
WORKDIR /deps
|
||||||
|
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
ARG RESTIC_VERSION="0.18.1"
|
||||||
|
ARG SHOUTRRR_VERSION="0.12.1"
|
||||||
ENV TARGETARCH=${TARGETARCH}
|
ENV TARGETARCH=${TARGETARCH}
|
||||||
|
|
||||||
RUN apk add --no-cache curl bzip2 unzip tar
|
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 -O https://downloads.rclone.org/rclone-current-linux-arm64.zip; \
|
||||||
unzip rclone.zip; \
|
unzip rclone-current-linux-arm64.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 -O https://downloads.rclone.org/rclone-current-linux-amd64.zip; \
|
||||||
unzip rclone.zip; \
|
unzip rclone-current-linux-amd64.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-*-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
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
FROM base AS development
|
FROM base AS development
|
||||||
|
|
||||||
ARG APP_VERSION=dev
|
|
||||||
ENV APP_VERSION=${APP_VERSION}
|
|
||||||
ENV VITE_APP_VERSION=${APP_VERSION}
|
|
||||||
ENV NODE_ENV="development"
|
ENV NODE_ENV="development"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
@ -70,58 +50,48 @@ 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
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 4096
|
||||||
|
|
||||||
CMD ["bun", "run", "dev"]
|
CMD ["bun", "run", "dev"]
|
||||||
|
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
# PRODUCTION
|
# PRODUCTION
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
FROM base AS builder
|
FROM oven/bun:${BUN_VERSION} AS builder
|
||||||
|
|
||||||
ARG APP_VERSION=dev
|
ARG APP_VERSION=dev
|
||||||
ENV VITE_APP_VERSION=${APP_VERSION}
|
|
||||||
ENV PORT=4096
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY ./package.json ./bun.lock ./
|
COPY ./package.json ./bun.lock ./
|
||||||
COPY ./packages/core/package.json ./packages/core/package.json
|
RUN bun install --frozen-lockfile
|
||||||
COPY ./packages/contracts/package.json ./packages/contracts/package.json
|
|
||||||
COPY ./apps/agent/package.json ./apps/agent/package.json
|
|
||||||
COPY ./apps/docs/package.json ./apps/docs/package.json
|
|
||||||
|
|
||||||
RUN VITE_GIT_HOOKS=0 bun install --frozen-lockfile
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN touch .env
|
||||||
|
RUN echo "VITE_APP_VERSION=${APP_VERSION}" >> .env
|
||||||
|
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
RUN bun build apps/agent/src/index.ts --outfile .output/agent/index.mjs --target bun
|
|
||||||
|
|
||||||
FROM base AS production
|
FROM base AS production
|
||||||
|
|
||||||
ARG APP_VERSION=dev
|
|
||||||
ENV APP_VERSION=${APP_VERSION}
|
|
||||||
ENV NODE_ENV="production"
|
ENV NODE_ENV="production"
|
||||||
ENV PORT=4096
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/package.json ./
|
COPY --from=builder /app/package.json ./
|
||||||
|
RUN bun install --production --frozen-lockfile --verbose
|
||||||
|
|
||||||
COPY --from=deps /deps/restic /usr/local/bin/restic
|
COPY --from=deps /deps/restic /usr/local/bin/restic
|
||||||
COPY --from=deps /deps/rclone /usr/local/bin/rclone
|
COPY --from=deps /deps/rclone /usr/local/bin/rclone
|
||||||
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
|
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
|
||||||
COPY --from=builder /app/.output ./.output
|
COPY --from=builder /app/dist/client ./dist/client
|
||||||
|
COPY --from=builder /app/dist/server ./dist/server
|
||||||
COPY --from=builder /app/app/drizzle ./assets/migrations
|
COPY --from=builder /app/app/drizzle ./assets/migrations
|
||||||
|
|
||||||
# Include third-party licenses and attribution
|
# Include third-party licenses and attribution
|
||||||
|
|
@ -131,4 +101,5 @@ COPY ./LICENSE ./LICENSE.md
|
||||||
|
|
||||||
EXPOSE 4096
|
EXPOSE 4096
|
||||||
|
|
||||||
CMD ["bun", ".output/server/index.mjs"]
|
CMD ["bun", "run", "start"]
|
||||||
|
|
||||||
|
|
|
||||||
233
README.md
233
README.md
|
|
@ -15,33 +15,23 @@
|
||||||
</figure>
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
#### Join the community
|
|
||||||
|
|
||||||
[](https://discord.gg/XjgVyXrcEH)
|
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedback. Please open issues for bugs or feature requests.
|
> Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedbacks. Expect bugs! Please open issues or feature requests
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://www.buymeacoffee.com/nicotsx" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
|
<a href="https://www.buymeacoffee.com/nicotsx" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Introduction
|
## Intro
|
||||||
|
|
||||||
Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage.
|
Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage.
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
The official documentation website is available at [zerobyte.app](https://zerobyte.app).
|
|
||||||
|
|
||||||
It contains up-to-date setup guides, configuration reference, and usage documentation for running Zerobyte in production.
|
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **Automated backups** with encryption, compression, and retention policies, powered by Restic
|
- **Automated backups** with encryption, compression and retention policies powered by Restic
|
||||||
- **Flexible scheduling** for automated backup jobs with fine-grained retention policies
|
- **Flexible scheduling** For automated backup jobs with fine-grained retention policies
|
||||||
- **End-to-end encryption** will ensure your data is always protected
|
- **End-to-end encryption** ensuring your data is always protected
|
||||||
- **Multi-protocol support** for backup from NFS, SMB, WebDAV, SFTP, or local directories
|
- **Multi-protocol support**: Backup from NFS, SMB, WebDAV, 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.18
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|
@ -60,30 +50,14 @@ services:
|
||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Zurich # Set your timezone here
|
- TZ=Europe/Paris # Set your timezone here
|
||||||
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
|
|
||||||
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
|
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> It is highly discouraged to run Zerobyte on a server that is accessible from the internet (VPS or home server with port forwarding). If you do, make sure to change the port mapping to "127.0.0.1:4096:4096" and use a secure tunnel (SSH tunnel, Cloudflare Tunnel, etc.) with authentication.
|
> Do not try to point `/var/lib/zerobyte` on a network share. You will face permission issues and strong performance degradation.
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> Do not try to point `/var/lib/zerobyte` to a network share. You will face permission issues and strong performance degradation.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> **TrueNAS Users:** The host path `/var/lib` is ephemeral on TrueNAS and will be reset during system upgrades. Instead of using `/var/lib/zerobyte:/var/lib/zerobyte`, create a dedicated ZFS dataset (e.g., `tank/docker/zerobyte`) and mount it instead:
|
|
||||||
>
|
|
||||||
> ```yaml
|
|
||||||
> volumes:
|
|
||||||
> - /etc/localtime:/etc/localtime:ro
|
|
||||||
> - /mnt/tank/docker/zerobyte:/var/lib/zerobyte
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> This ensures your configuration, encryption keys, and database persist across TrueNAS upgrades.
|
|
||||||
|
|
||||||
Then, run the following command to start Zerobyte:
|
Then, run the following command to start Zerobyte:
|
||||||
|
|
||||||
|
|
@ -93,78 +67,20 @@ docker compose up -d
|
||||||
|
|
||||||
Once the container is running, you can access the web interface at `http://<your-server-ip>:4096`.
|
Once the container is running, you can access the web interface at `http://<your-server-ip>:4096`.
|
||||||
|
|
||||||
## Configuration
|
### Simplified setup (No remote mounts)
|
||||||
|
|
||||||
Zerobyte can be customized using environment variables. Below are the available options:
|
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`:
|
||||||
|
|
||||||
### Environment variables
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
| :------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- |
|
|
||||||
| `BASE_URL` | **Required.** The base URL of your Zerobyte instance (e.g., `https://zerobyte.example.com`). See [Authentication](#authentication) below. | (none) |
|
|
||||||
| `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) |
|
|
||||||
| `APP_SECRET_FILE` | Path to a file containing `APP_SECRET`, useful with Docker or Kubernetes secrets. Mutually exclusive with `APP_SECRET`. | (none) |
|
|
||||||
| `PORT` | The port the web interface and API will listen on. | `4096` |
|
|
||||||
| `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` |
|
|
||||||
| `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` |
|
|
||||||
| `TRUST_PROXY` | When `true`, trust an existing `X-Forwarded-For` header from your reverse proxy. Leave `false` for direct deployments. | `false` |
|
|
||||||
| `TRUSTED_ORIGINS` | Comma-separated list of extra trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (none) |
|
|
||||||
| `WEBHOOK_ALLOWED_ORIGINS` | Comma-separated list of HTTP origins allowed for backup webhooks and outbound HTTP notification destinations. | (none) |
|
|
||||||
| `WEBHOOK_TIMEOUT` | Timeout for backup webhook requests in seconds. | `60` |
|
|
||||||
| `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` |
|
|
||||||
| `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` |
|
|
||||||
| `RCLONE_CONFIG_DIR` | Path to the directory containing `rclone.conf` inside the container. Change this if running as a non-root user. | `/root/.config/rclone` |
|
|
||||||
| `PROVISIONING_PATH` | Path to a JSON file with operator-managed repositories and volumes to sync at startup. | (none) |
|
|
||||||
|
|
||||||
### Webhook and notification network policy
|
|
||||||
|
|
||||||
Backup webhooks and outbound notification destinations that can target arbitrary network hosts are restricted by `WEBHOOK_ALLOWED_ORIGINS`.
|
|
||||||
|
|
||||||
The allowlist matches exact origins only: scheme, host, and port must match. Paths are ignored, so `https://hooks.example.com/backups` allows any path on `https://hooks.example.com`, but it does not allow `http://hooks.example.com`, `https://hooks.example.com:8443`, or `https://other.example.com`.
|
|
||||||
|
|
||||||
This policy applies to:
|
|
||||||
|
|
||||||
- backup pre/post webhook URLs
|
|
||||||
- Generic HTTP notification URLs
|
|
||||||
- Gotify server URLs
|
|
||||||
- self-hosted ntfy server URLs
|
|
||||||
- custom Shoutrrr URLs that point at generic HTTP or SMTP network targets
|
|
||||||
|
|
||||||
The public ntfy.sh service and fixed-provider notification services such as Slack, Discord, Pushover, and Telegram do not need `WEBHOOK_ALLOWED_ORIGINS`.
|
|
||||||
|
|
||||||
Backup webhooks do not follow redirects. Add the final destination origin to `WEBHOOK_ALLOWED_ORIGINS` and configure that final URL directly.
|
|
||||||
|
|
||||||
Webhook headers are stored as plain text and must use one `Key: Value` header per line. `WEBHOOK_TIMEOUT` controls backup pre/post webhook request timeouts; notification delivery uses the underlying provider sender behavior.
|
|
||||||
|
|
||||||
### Provisioned resources
|
|
||||||
|
|
||||||
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.18
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Zurich # Set your timezone here
|
- TZ=Europe/Paris # Set your timezone here
|
||||||
- BASE_URL=http://localhost:4096 # Change this to your actual URL (use https:// for secure cookies)
|
|
||||||
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
|
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||||
|
|
@ -172,30 +88,25 @@ 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, or WebDAV 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`.
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
See [examples/README.md](examples/README.md) for runnable, copy/paste-friendly examples.
|
|
||||||
|
|
||||||
## Adding your first volume
|
## Adding your first volume
|
||||||
|
|
||||||
Zerobyte supports multiple volume backends including NFS, SMB, WebDAV, SFTP, and local directories. A volume represents the source data you want to back up and monitor.
|
Zerobyte supports multiple volume backends including NFS, SMB, WebDAV, and local directories. A volume represents the source data you want to back up and monitor.
|
||||||
|
|
||||||
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.18
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|
@ -205,9 +116,7 @@ services:
|
||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Zurich
|
- TZ=Europe/Paris
|
||||||
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
|
|
||||||
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
|
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||||
|
|
@ -223,17 +132,17 @@ docker compose up -d
|
||||||
|
|
||||||
Now, when adding a new volume in the Zerobyte web interface, you can select "Directory" as the volume type and search for your mounted path (e.g., `/mydata`) as the source path.
|
Now, when adding a new volume in the Zerobyte web interface, you can select "Directory" as the volume type and search for your mounted path (e.g., `/mydata`) as the source path.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Creating a repository
|
## Creating a repository
|
||||||
|
|
||||||
A repository is where your backups will be securely stored encrypted. Zerobyte supports multiple storage backends for your backup repositories:
|
A repository is where your backups will be securely stored encrypted. Zerobyte supports multiple storage backends for your backup repositories:
|
||||||
|
|
||||||
- **Local directories**: Store backups on local disk subfolders in `/var/lib/zerobyte/repositories/` or any other (mounted) path
|
- **Local directories** - Store backups on local disk at `/var/lib/zerobyte/repositories/<repository-name>`
|
||||||
- **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.
|
||||||
|
|
||||||
|
|
@ -246,31 +155,26 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
||||||
**Setup instructions:**
|
**Setup instructions:**
|
||||||
|
|
||||||
1. **Install rclone on your host system** (if not already installed):
|
1. **Install rclone on your host system** (if not already installed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl https://rclone.org/install.sh | sudo bash
|
curl https://rclone.org/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Configure your cloud storage remote** using rclone's interactive config:
|
2. **Configure your cloud storage remote** using rclone's interactive config:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rclone config
|
rclone config
|
||||||
```
|
```
|
||||||
|
|
||||||
Follow the prompts to set up your cloud storage provider. For OAuth providers (Google Drive, Dropbox, etc.), rclone will guide you through the authentication flow.
|
Follow the prompts to set up your cloud storage provider. For OAuth providers (Google Drive, Dropbox, etc.), rclone will guide you through the authentication flow.
|
||||||
|
|
||||||
3. **Verify your remote is configured**:
|
3. **Verify your remote is configured**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rclone listremotes
|
rclone listremotes
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Mount the rclone config into the Zerobyte container** by updating your `docker-compose.yml`:
|
4. **Mount the rclone config into the Zerobyte container** by updating your `docker-compose.yml`:
|
||||||
|
|
||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.38
|
image: ghcr.io/nicotsx/zerobyte:v0.18
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|
@ -280,25 +184,14 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Zurich
|
- TZ=Europe/Paris
|
||||||
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
|
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||||
+ - ~/.config/rclone:/root/.config/rclone:ro
|
+ - ~/.config/rclone:/root/.config/rclone
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note for non-root users:** If your container runs as a different user (e.g., TrueNAS apps), mount your config to the appropriate location and set `RCLONE_CONFIG_DIR`:
|
|
||||||
>
|
|
||||||
> ```yaml
|
|
||||||
> environment:
|
|
||||||
> - RCLONE_CONFIG_DIR=/home/appuser/.config/rclone
|
|
||||||
> volumes:
|
|
||||||
> - ~/.config/rclone:/home/appuser/.config/rclone:ro
|
|
||||||
> ```
|
|
||||||
|
|
||||||
5. **Restart the Zerobyte container**:
|
5. **Restart the Zerobyte container**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down
|
docker compose down
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
@ -316,7 +209,6 @@ For a complete list of supported providers, see the [rclone documentation](https
|
||||||
Once you have added a volume and created a repository, you can create your first backup job. A backup job defines the schedule and parameters for backing up a specific volume to a designated repository.
|
Once you have added a volume and created a repository, you can create your first backup job. A backup job defines the schedule and parameters for backing up a specific volume to a designated repository.
|
||||||
|
|
||||||
When creating a backup job, you can specify the following settings:
|
When creating a backup job, you can specify the following settings:
|
||||||
|
|
||||||
- **Schedule**: Define how often the backup should run (e.g., daily, weekly)
|
- **Schedule**: Define how often the backup should run (e.g., daily, weekly)
|
||||||
- **Retention Policy**: Set rules for how long backups should be retained (e.g., keep daily backups for 7 days, weekly backups for 4 weeks)
|
- **Retention Policy**: Set rules for how long backups should be retained (e.g., keep daily backups for 7 days, weekly backups for 4 weeks)
|
||||||
- **Paths**: Specify which files or directories to include in the backup
|
- **Paths**: Specify which files or directories to include in the backup
|
||||||
|
|
@ -324,49 +216,15 @@ When creating a backup job, you can specify the following settings:
|
||||||
After configuring the backup job, save it and Zerobyte will automatically execute the backup according to the defined schedule.
|
After configuring the backup job, save it and Zerobyte will automatically execute the backup according to the defined schedule.
|
||||||
You can monitor the progress and status of your backup jobs in the "Backups" section of the web interface.
|
You can monitor the progress and status of your backup jobs in the "Backups" section of the web interface.
|
||||||
|
|
||||||

|

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

|

|
||||||
|
|
||||||
## Authentication
|
## Third-Party Software
|
||||||
|
|
||||||
Zerobyte uses [better-auth](https://github.com/better-auth/better-auth) for authentication and session management. The authentication system automatically adapts to your deployment scenario:
|
|
||||||
|
|
||||||
### Users and roles
|
|
||||||
|
|
||||||
Zerobyte does not currently provide fine-grained RBAC for backup operations. Authenticated organization members are trusted operators for repositories, volumes, backup schedules, restores, and notification destinations in their organization.
|
|
||||||
|
|
||||||
Organization roles mainly restrict organization-management and instance-management actions. For example, normal members cannot manage members, SSO settings, invitations, registration, or global users, but they can still operate backup resources. Only add users to an organization if you are comfortable with them using the storage, volume, and notification capabilities configured for the instance.
|
|
||||||
|
|
||||||
### Cookie security
|
|
||||||
|
|
||||||
- **IP Address/HTTP access**: Set `BASE_URL=http://192.168.1.50:4096` (or your IP). Cookies will use `Secure: false`, allowing immediate login without SSL.
|
|
||||||
- **Domain/HTTPS access**: Set `BASE_URL=https://zerobyte.example.com`. Cookies will automatically use `Secure: true` for protected sessions.
|
|
||||||
|
|
||||||
### Reverse proxy setup
|
|
||||||
|
|
||||||
If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.):
|
|
||||||
|
|
||||||
1. **Set `BASE_URL`** to your HTTPS domain (e.g., `https://zerobyte.example.com`)
|
|
||||||
2. The app will automatically enable secure cookies based on the `https://` prefix
|
|
||||||
3. Ensure your proxy passes the `X-Forwarded-Proto` header
|
|
||||||
|
|
||||||
### Important notes
|
|
||||||
|
|
||||||
- The `BASE_URL` must start with `https://` for secure cookies to be enabled
|
|
||||||
- Local IP addresses (e.g., `http://192.168.x.x`) are **not** treated as secure contexts by browsers, so secure cookies are disabled automatically
|
|
||||||
- `TRUSTED_ORIGINS` only allows additional origins for auth-related requests. It does not disable secure cookies or make authenticated HTTP access work when `BASE_URL` is HTTPS.
|
|
||||||
- If `BASE_URL` is HTTPS, browsers will only send Zerobyte's auth cookies over HTTPS. Plain HTTP access may still show the login page, but authenticated flows will fail because no session cookie is available.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
For troubleshooting common issues, please refer to the [TROUBLESHOOTING.md](TROUBLESHOOTING.md) file.
|
|
||||||
|
|
||||||
## Third-party software
|
|
||||||
|
|
||||||
This project includes the following third-party software components:
|
This project includes the following third-party software components:
|
||||||
|
|
||||||
|
|
@ -384,30 +242,3 @@ For a complete list of third-party software licenses and attributions, please re
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions by anyone are welcome! If you find a bug or have a feature request, please open an issue on GitHub. If you want to contribute code, feel free to fork the repository and submit a pull request. We require that all contributors sign a Contributor License Agreement (CLA) before we can accept your contributions. This is to protect both you and the project. Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for more details.
|
Contributions by anyone are welcome! If you find a bug or have a feature request, please open an issue on GitHub. If you want to contribute code, feel free to fork the repository and submit a pull request. We require that all contributors sign a Contributor License Agreement (CLA) before we can accept your contributions. This is to protect both you and the project. Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for more details.
|
||||||
|
|
||||||
## Development (no Docker)
|
|
||||||
|
|
||||||
After installing Vite+ globally, you can run Zerobyte locally during development without Docker:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
vp install
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
For local development, create a `.env.local` file at the repo root and override the Docker paths:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Example
|
|
||||||
ZEROBYTE_DATABASE_URL=./data/zerobyte.db
|
|
||||||
APP_SECRET=your_app_secret_here
|
|
||||||
RESTIC_PASS_FILE=./data/restic.pass
|
|
||||||
RESTIC_CACHE_DIR=./data/restic/cache
|
|
||||||
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
|
|
||||||
ZEROBYTE_VOLUMES_DIR=./data/volumes
|
|
||||||
BASE_URL=https://*.localhost
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- Remote mount backends (NFS/SMB/WebDAV/SFTP) rely on Linux mount tooling and `CAP_SYS_ADMIN`; on macOS they are expected to be unavailable.
|
|
||||||
- To actually run backups/repository checks, install `restic` on your machine (e.g., via Homebrew). If `restic` is not installed, the app still starts but backup operations will fail with a clear error.
|
|
||||||
|
|
|
||||||
75
SECURITY.md
75
SECURITY.md
|
|
@ -1,75 +0,0 @@
|
||||||
# Security Policy
|
|
||||||
|
|
||||||
Thank you for helping keep Zerobyte and its users secure.
|
|
||||||
|
|
||||||
Zerobyte is currently in `0.x`, and releases may include breaking changes between versions. For that reason, security fixes are only guaranteed for the most recent stable release line.
|
|
||||||
|
|
||||||
## Supported Versions
|
|
||||||
|
|
||||||
| Version | Supported |
|
|
||||||
| --------------------------------------------- | ------------------ |
|
|
||||||
| Latest stable `0.x` release | :white_check_mark: |
|
|
||||||
| Older stable `0.x` releases | :x: |
|
|
||||||
| Pre-release builds (`-alpha`, `-beta`, `-rc`) | :x: |
|
|
||||||
| Development snapshots from `main` | :x: |
|
|
||||||
|
|
||||||
Security fixes are generally released in the latest stable version only. If you are running an older release, you may be asked to upgrade before a fix is provided.
|
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
|
||||||
|
|
||||||
Please do **not** report security vulnerabilities through public GitHub issues, discussions, or pull requests.
|
|
||||||
|
|
||||||
Use one of these private channels instead:
|
|
||||||
|
|
||||||
1. Preferred: GitHub private vulnerability reporting
|
|
||||||
https://github.com/nicotsx/zerobyte/security/advisories/new
|
|
||||||
2. Alternative: contact the maintainer directly by email if an address is listed in the repository or GitHub profile
|
|
||||||
|
|
||||||
When reporting, include as much of the following as you can:
|
|
||||||
|
|
||||||
- affected Zerobyte version
|
|
||||||
- deployment details, including whether you are using Docker, reverse proxies, or exposed ports
|
|
||||||
- clear reproduction steps or a proof of concept
|
|
||||||
- impact assessment and what an attacker could do
|
|
||||||
- any relevant logs, screenshots, or configuration excerpts with secrets removed
|
|
||||||
|
|
||||||
### What to expect
|
|
||||||
|
|
||||||
- We aim to acknowledge new reports within 7 days.
|
|
||||||
- We aim to provide status updates at least every 7 days while the report is being investigated.
|
|
||||||
- If the report is accepted, we will work on a fix, coordinate disclosure, and publish a security advisory when appropriate.
|
|
||||||
- If the report is declined, out of scope, or cannot be reproduced, we will explain why when possible.
|
|
||||||
|
|
||||||
Please avoid public disclosure until a fix has been released and maintainers have had reasonable time to notify users.
|
|
||||||
|
|
||||||
## Trust model baseline
|
|
||||||
|
|
||||||
Zerobyte is a self-hosted operator tool. Treat any authenticated user as a trusted machine/operator user with intentional access to:
|
|
||||||
|
|
||||||
- Browse/select host directories for volumes
|
|
||||||
- Configure local, network, and cloud storage backends
|
|
||||||
- Trigger mounts/unmounts, backups, restores, and Restic maintenance
|
|
||||||
- Read/write files through intended backup/restore workflows
|
|
||||||
- Access repository/volume metadata needed to operate backups
|
|
||||||
|
|
||||||
Do **not** report these as vulnerabilities by themselves:
|
|
||||||
|
|
||||||
- Authenticated host filesystem browsing
|
|
||||||
- Local directory volume pointing to broad host paths
|
|
||||||
- Backing up arbitrary readable host paths
|
|
||||||
- Restoring snapshots to arbitrary writable host paths
|
|
||||||
- Authenticated Restic/mount/rclone execution through intended UI flows
|
|
||||||
- Information disclosure to authenticated operators about filesystem paths or backend errors
|
|
||||||
|
|
||||||
Only report issues when they violate this trust model, for example:
|
|
||||||
|
|
||||||
- Unauthenticated access to operator features
|
|
||||||
- CSRF/cross-origin abuse causing a trusted operator’s browser to perform actions
|
|
||||||
- Shell/command injection beyond intended argument-based execution
|
|
||||||
- Path traversal that escapes a deliberately configured root/volume/repository boundary
|
|
||||||
- Secret leakage to logs, unauthenticated users, or non-operator contexts
|
|
||||||
- Cross-organization data access despite authenticated trust
|
|
||||||
- Privilege bypass between global admin/org admin/member where the product explicitly distinguishes roles
|
|
||||||
- Unsafe dev-only features enabled without the documented gate
|
|
||||||
- Vulnerabilities in parsing untrusted external data from repositories/backends/notifications
|
|
||||||
- Persistence corruption, data loss, or workflow bypass not intended by operator actions
|
|
||||||
|
|
@ -1,474 +0,0 @@
|
||||||
# Troubleshooting
|
|
||||||
|
|
||||||
If you encounter any issues while using Zerobyte, you can check the application logs for more information.
|
|
||||||
These logs can help you identify and resolve common problems; you should also check existing and closed issues on GitHub.
|
|
||||||
In case you need further assistance, feel free to open a new issue with detailed information about the problem you are facing and any relevant log entries.
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> Make sure to never share sensitive information such as passwords, access keys, or personal data in public issues so remove them from logs before posting.
|
|
||||||
|
|
||||||
Before troubleshooting, enable debug logging so the logs contain enough detail to diagnose issues.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
zerobyte:
|
|
||||||
environment:
|
|
||||||
- LOG_LEVEL=debug
|
|
||||||
```
|
|
||||||
|
|
||||||
After adding `LOG_LEVEL=debug`, restart Zerobyte and then collect logs.
|
|
||||||
|
|
||||||
To view the logs, run the command below:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# replace 'zerobyte' with your container name if different
|
|
||||||
docker logs -f zerobyte
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
- [Common Issues](#common-issues)
|
|
||||||
- [Permission Denied Errors When Mounting Remote Shares](#permission-denied-errors-when-mounting-remote-shares)
|
|
||||||
- [Security Levels for Mounting Remote Shares](#security-levels-for-mounting-remote-shares)
|
|
||||||
- [Permission Errors with Remote Shares](#permission-errors-with-remote-shares)
|
|
||||||
- [Container Cannot Perform Mounts](#container-cannot-perform-mounts)
|
|
||||||
- [AppArmor-Enabled Systems (Ubuntu/Debian)](#apparmor-enabled-systems-ubuntudebian)
|
|
||||||
- [Seccomp-Restricted Environments](#seccomp-restricted-environments)
|
|
||||||
- [SELinux-Enabled Systems (CentOS/Fedora)](#selinux-enabled-systems-centosfedora)
|
|
||||||
- [Still Getting Permission Errors After SYS_ADMIN](#still-getting-permission-errors-after-sys_admin)
|
|
||||||
- [FUSE Mount Failures](#fuse-mount-failures)
|
|
||||||
- [Rclone Issues](#rclone-issues)
|
|
||||||
- [Test on Host First](#critical-test-on-host-first)
|
|
||||||
- [Pre-flight Checklist](#pre-flight-checklist)
|
|
||||||
- [Common Rclone Errors](#common-rclone-errors)
|
|
||||||
- ["No Remotes Available" in Dropdown](#no-remotes-available-in-dropdown)
|
|
||||||
- ["Failed to Create File System" Error](#failed-to-create-file-system-error)
|
|
||||||
- [EACCES Errors](#eacces-errors)
|
|
||||||
- [Rclone SFTP Repository Authentication Failures](#rclone-sftp-repository-authentication-failures)
|
|
||||||
- [Rclone Volume Mount Issues](#rclone-volume-mount-issues)
|
|
||||||
- [Prerequisites Check](#prerequisites-check)
|
|
||||||
- [Common Mount Errors](#common-mount-errors)
|
|
||||||
- [Still Having Issues?](#still-having-issues)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Issues
|
|
||||||
|
|
||||||
### Permission Denied Errors When Mounting Remote Shares
|
|
||||||
|
|
||||||
Mounting remote filesystems (such as SMB/CIFS) requires kernel-level privileges. When Zerobyte attempts to perform mounts from inside a container, additional permissions may be required.
|
|
||||||
|
|
||||||
Ensure that:
|
|
||||||
|
|
||||||
- Remote share credentials are correct
|
|
||||||
- The host kernel supports the target filesystem (e.g. CIFS module is available)
|
|
||||||
- Docker is running in **rootful mode** (rootless Docker cannot perform kernel mounts)
|
|
||||||
|
|
||||||
In some environments, Linux security mechanisms such as AppArmor or seccomp may block mount-related operations even when the required capabilities are present.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Security Levels for Mounting Remote Shares
|
|
||||||
|
|
||||||
Zerobyte supports multiple deployment models depending on your security requirements and environment.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Permission Errors with Remote Shares
|
|
||||||
|
|
||||||
**Problem:** You're getting permission errors when Zerobyte tries to mount remote shares (SMB/CIFS, NFS, etc.).
|
|
||||||
|
|
||||||
**Solution:** Mount remote shares **outside of Zerobyte** (on the host) and point Zerobyte to an already-mounted local path. This avoids container permission issues entirely.
|
|
||||||
|
|
||||||
**How to fix:**
|
|
||||||
|
|
||||||
1. Mount your remote share on the host first using `systemd`, `autofs`, or manual mount:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo mount -t cifs //server/share /mnt/your-remote-share -o credentials=/path/to/creds
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Then mount that local path into Zerobyte:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
zerobyte:
|
|
||||||
volumes:
|
|
||||||
- /mnt/your-remote-share:/data
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Restart the container:
|
|
||||||
```bash
|
|
||||||
docker compose down && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Container Cannot Perform Mounts
|
|
||||||
|
|
||||||
**Problem:** Zerobyte shows "Operation not permitted" errors when trying to mount remote shares directly.
|
|
||||||
|
|
||||||
**Solution:** Grant the `SYS_ADMIN` capability to allow the container to perform mount operations.
|
|
||||||
|
|
||||||
**Warning:** This grants significant privileges to the container and should only be used when necessary.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
zerobyte:
|
|
||||||
cap_add:
|
|
||||||
- SYS_ADMIN
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ Granting `SYS_ADMIN` allows the container to perform mount operations and should be used only when strictly necessary.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### AppArmor-Enabled Systems (Ubuntu/Debian)
|
|
||||||
|
|
||||||
On hosts using AppArmor, the default Docker profile (`docker-default`) may block mount operations even when `SYS_ADMIN` is present.
|
|
||||||
|
|
||||||
If mount operations fail with permission errors, you may need to disable AppArmor confinement for the container. Check first if AppArmor is enabled on your system and the profile of the container:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# check if AppArmor is enabled
|
|
||||||
sudo aa-status
|
|
||||||
# if next command returns 'docker-default', AppArmor is enabled on the container
|
|
||||||
docker inspect --format='{{.AppArmorProfile}}' zerobyte
|
|
||||||
```
|
|
||||||
|
|
||||||
If AppArmor is enabled, you can disable it for the Zerobyte container by adding the following to your `docker-compose.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
zerobyte:
|
|
||||||
cap_add:
|
|
||||||
- SYS_ADMIN
|
|
||||||
security_opt:
|
|
||||||
- apparmor:unconfined
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Seccomp-Restricted Environments
|
|
||||||
|
|
||||||
Docker's default seccomp profile may block mount-related syscalls required by filesystem operations.
|
|
||||||
|
|
||||||
If mount operations continue to fail, you may need to disable seccomp filtering for the container:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
zerobyte:
|
|
||||||
cap_add:
|
|
||||||
- SYS_ADMIN
|
|
||||||
security_opt:
|
|
||||||
- seccomp:unconfined
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### SELinux-Enabled Systems (CentOS/Fedora)
|
|
||||||
|
|
||||||
On hosts using SELinux, you may need to adjust the security context to allow mount operations.
|
|
||||||
If mount operations fail with permission errors, you can try adding the following label:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
zerobyte:
|
|
||||||
cap_add:
|
|
||||||
- SYS_ADMIN
|
|
||||||
security_opt:
|
|
||||||
- label:type:container_runtime_t
|
|
||||||
```
|
|
||||||
|
|
||||||
or disable SELinux enforcement for the container:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
zerobyte:
|
|
||||||
cap_add:
|
|
||||||
- SYS_ADMIN
|
|
||||||
security_opt:
|
|
||||||
- label:disable
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Still Getting Permission Errors After SYS_ADMIN
|
|
||||||
|
|
||||||
**Problem:** Mount operations still fail even with `SYS_ADMIN` capability.
|
|
||||||
|
|
||||||
**Solution (Last Resort):** Run the container in privileged mode. This disables most container isolation mechanisms and significantly increases the attack surface.
|
|
||||||
|
|
||||||
**Warning:** Only use this as a last resort for troubleshooting. Remove and switch to a more secure solution once you identify the actual issue.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
zerobyte:
|
|
||||||
privileged: true
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### FUSE Mount Failures
|
|
||||||
|
|
||||||
**Problem:** FUSE-based mounts (sshfs, rclone mount) are failing with device access errors.
|
|
||||||
|
|
||||||
**Cause:** Access to `/dev/fuse` is required for FUSE-based filesystems.
|
|
||||||
|
|
||||||
**Solution:** Ensure `/dev/fuse` is mounted into the container. This is **not required** for SMB/CIFS mounts.
|
|
||||||
|
|
||||||
````yaml
|
|
||||||
services:
|
|
||||||
zerobyte:
|
|
||||||
devices:
|
|
||||||
- /dev/fuse:/dev/fuse
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rclone Issues
|
|
||||||
|
|
||||||
### ⚠️ Critical: Test on Host First
|
|
||||||
|
|
||||||
**Before reporting any rclone-related issue, you MUST verify that rclone works correctly on your Docker host.**
|
|
||||||
|
|
||||||
Most rclone issues are due to misconfigured remotes, not Zerobyte bugs. Follow this checklist on your host machine:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. List all configured remotes
|
|
||||||
rclone listremotes
|
|
||||||
|
|
||||||
# 2. Test listing a remote (replace 'myremote' with your remote name)
|
|
||||||
rclone lsd myremote:
|
|
||||||
|
|
||||||
# 3. Test reading from a remote
|
|
||||||
rclone ls myremote:path/to/test
|
|
||||||
|
|
||||||
# 4. For volume mounts: Test mounting on host first
|
|
||||||
mkdir -p /tmp/rclone-test
|
|
||||||
rclone mount myremote:path /tmp/rclone-test --daemon --vfs-cache-mode writes
|
|
||||||
ls /tmp/rclone-test
|
|
||||||
fusermount -u /tmp/rclone-test # Unmount when done
|
|
||||||
````
|
|
||||||
|
|
||||||
**If these commands fail on your host, fix your rclone configuration before using Zerobyte.**
|
|
||||||
|
|
||||||
Common issues include:
|
|
||||||
|
|
||||||
- Expired OAuth tokens (run `rclone config` to re-authenticate)
|
|
||||||
- Incorrect credentials
|
|
||||||
- Missing permissions on cloud provider side
|
|
||||||
- Network/firewall issues
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Pre-flight Checklist
|
|
||||||
|
|
||||||
If you're experiencing rclone issues, verify all of the following:
|
|
||||||
|
|
||||||
- [ ] Rclone is installed and configured on the Docker **host**
|
|
||||||
- [ ] `rclone listremotes` shows your remote
|
|
||||||
- [ ] `rclone lsd remote:` successfully lists directories
|
|
||||||
- [ ] The rclone config directory is mounted into the container
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Rclone Errors
|
|
||||||
|
|
||||||
#### "No Remotes Available" in Dropdown
|
|
||||||
|
|
||||||
**Cause:** Zerobyte cannot find your rclone configuration file.
|
|
||||||
|
|
||||||
**Diagnosis:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check which config file rclone will use inside the container
|
|
||||||
docker exec zerobyte sh -lc 'echo HOME=$HOME; rclone config file'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. Ensure you've mounted your host's rclone config:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
volumes:
|
|
||||||
- ~/.config/rclone:/root/.config/rclone:ro
|
|
||||||
```
|
|
||||||
|
|
||||||
2. For non-root containers (e.g., TrueNAS), set the correct path:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
environment:
|
|
||||||
- RCLONE_CONFIG_DIR=/home/appuser/.config/rclone
|
|
||||||
volumes:
|
|
||||||
- ~/.config/rclone:/home/appuser/.config/rclone:ro
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Restart the container** after mounting the config:
|
|
||||||
```bash
|
|
||||||
docker compose down
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
#### "Failed to Create File System" Error
|
|
||||||
|
|
||||||
**Cause:** Authentication failure with the cloud provider.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
|
|
||||||
1. On your host, run: `rclone config`
|
|
||||||
2. Re-authenticate the remote (especially for OAuth providers like Google Drive, Dropbox, OneDrive)
|
|
||||||
3. Verify with: `rclone lsd remote:`
|
|
||||||
4. Restart the Zerobyte container
|
|
||||||
|
|
||||||
#### EACCES Errors
|
|
||||||
|
|
||||||
**Cause:** AppArmor or seccomp is blocking rclone execution.
|
|
||||||
|
|
||||||
**Solution:** Disable security profiles as described in:
|
|
||||||
|
|
||||||
- [AppArmor-enabled systems](#apparmor-enabled-systems-ubuntudebian)
|
|
||||||
- [Seccomp-restricted environments](#seccomp-restricted-environments)
|
|
||||||
|
|
||||||
#### Rclone SFTP Repository Authentication Failures
|
|
||||||
|
|
||||||
When creating an **rclone repository** that uses an **SFTP remote**, you may encounter authentication errors even though:
|
|
||||||
|
|
||||||
- The rclone config is mounted correctly
|
|
||||||
- The SFTP remote appears in the dropdown
|
|
||||||
- The same config works on the host
|
|
||||||
|
|
||||||
**The issue:** If your rclone SFTP remote uses `key_file` for SSH key authentication, the key file path in your rclone config points to a location on the **host** (e.g., `~/.ssh/id_rsa`). When rclone runs inside the container, it cannot access that path.
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
**Option 1: Mount your SSH keys (Recommended)**
|
|
||||||
|
|
||||||
Add your SSH directory to the container volumes:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
zerobyte:
|
|
||||||
volumes:
|
|
||||||
- ~/.config/rclone:/root/.config/rclone:ro
|
|
||||||
- ~/.ssh:/root/.ssh:ro # Required for SFTP remotes using key_file
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option 2: Embed the SSH key in rclone config**
|
|
||||||
|
|
||||||
Use `key_pem` instead of `key_file` to embed the private key directly in your rclone.conf:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Convert your key to single-line format
|
|
||||||
awk '{printf "%s\\n", $0}' < ~/.ssh/id_rsa
|
|
||||||
```
|
|
||||||
|
|
||||||
Then update your rclone config:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[sftp-remote]
|
|
||||||
type = sftp
|
|
||||||
host = example.com
|
|
||||||
user = backup
|
|
||||||
key_pem = -----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5v...\n-----END OPENSSH PRIVATE KEY-----
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option 3: Use ssh-agent**
|
|
||||||
|
|
||||||
Configure your rclone remote to use ssh-agent instead of a key file:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[sftp-remote]
|
|
||||||
type = sftp
|
|
||||||
host = example.com
|
|
||||||
user = backup
|
|
||||||
key_use_agent = true
|
|
||||||
```
|
|
||||||
|
|
||||||
Then mount the SSH agent socket and set the environment variable:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
zerobyte:
|
|
||||||
environment:
|
|
||||||
- SSH_AUTH_SOCK=/ssh-agent
|
|
||||||
volumes:
|
|
||||||
- ~/.config/rclone:/root/.config/rclone:ro
|
|
||||||
- ${SSH_AUTH_SOCK}:/ssh-agent
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Rclone Volume Mount Issues
|
|
||||||
|
|
||||||
When using rclone as a **volume backend** (mounting cloud storage to back up from), additional requirements apply:
|
|
||||||
|
|
||||||
#### Prerequisites Check
|
|
||||||
|
|
||||||
If rclone volume mounting isn't working, verify these prerequisites on your system:
|
|
||||||
|
|
||||||
- **Linux host required:** Windows/macOS hosts cannot use rclone volumes. If you're on these platforms, you'll need to use a different volume backend.
|
|
||||||
- **`/dev/fuse` device:** Check if FUSE is available and properly passed to the container
|
|
||||||
- **`SYS_ADMIN` capability:** Required for mount operations inside containers
|
|
||||||
- **FUSE support on host:** Verify FUSE is installed and working on your Docker host
|
|
||||||
|
|
||||||
#### Common Mount Errors
|
|
||||||
|
|
||||||
**"mount helper error: fusermount3: failed to open /dev/fuse: Permission denied"**
|
|
||||||
|
|
||||||
Solutions in order of preference:
|
|
||||||
|
|
||||||
1. **Verify FUSE works on host first:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# On host
|
|
||||||
rclone mount remote:path /tmp/test --daemon
|
|
||||||
ls /tmp/test
|
|
||||||
fusermount -u /tmp/test
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Ensure /dev/fuse is mounted in container:**
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
devices:
|
|
||||||
- /dev/fuse:/dev/fuse
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Check user permissions inside container:**
|
|
||||||
```bash
|
|
||||||
docker exec zerobyte ls -la /dev/fuse
|
|
||||||
```
|
|
||||||
|
|
||||||
**"mount helper error: fusermount3: mount failed: Operation not permitted"**
|
|
||||||
|
|
||||||
This indicates the container lacks `SYS_ADMIN` capability:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
cap_add:
|
|
||||||
- SYS_ADMIN
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Still having issues?
|
|
||||||
|
|
||||||
If you've verified rclone works on the host and followed all troubleshooting steps above, gather this information for your issue report:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Host verification
|
|
||||||
rclone version
|
|
||||||
rclone listremotes
|
|
||||||
rclone lsd remote: 2>&1
|
|
||||||
|
|
||||||
# Container verification
|
|
||||||
docker exec zerobyte ls -la /root/.config/rclone/
|
|
||||||
docker exec zerobyte env | grep RCLONE
|
|
||||||
docker logs zerobyte 2>&1 | tail -50
|
|
||||||
|
|
||||||
# Check container capabilities
|
|
||||||
docker inspect zerobyte --format='{{.HostConfig.CapAdd}}'
|
|
||||||
docker inspect zerobyte --format='{{.HostConfig.Devices}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Include the output (with sensitive data redacted) in your GitHub issue.
|
|
||||||
114
app/app.css
114
app/app.css
|
|
@ -1,20 +1,10 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "@tailwindcss/typography";
|
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "dither-plugin";
|
@import "dither-plugin";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@custom-variant @narrow (@container (min-width: 28rem));
|
|
||||||
@custom-variant @medium (@container (min-width: 40rem));
|
|
||||||
@custom-variant @wide (@container (min-width: 60rem));
|
|
||||||
@custom-variant @xwide (@container (min-width: 80rem));
|
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--container-narrow: 28rem;
|
|
||||||
--container-medium: 40rem;
|
|
||||||
--container-wide: 60rem;
|
|
||||||
--container-xwide: 80rem;
|
|
||||||
--breakpoint-xs: 32rem;
|
--breakpoint-xs: 32rem;
|
||||||
--font-sans:
|
--font-sans:
|
||||||
"Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
"Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||||
|
|
@ -26,17 +16,12 @@ body {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
overscroll-behavior: none;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 48rem) {
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
overscroll-behavior: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
@apply bg-[#131313];
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,8 +51,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 +72,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 +128,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 +158,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 {
|
||||||
|
|
@ -194,20 +173,3 @@ body {
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.snapshot-scrollable::-webkit-scrollbar {
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
.snapshot-scrollable::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
.snapshot-scrollable::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border);
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide built-in password reveal/clear controls (notably in Edge on Windows) */
|
|
||||||
[data-secret-input] input[type="password"]::-ms-reveal,
|
|
||||||
[data-secret-input] input[type="password"]::-ms-clear {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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()}` : ""}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -1,11 +1,61 @@
|
||||||
// @ts-nocheck
|
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
import { type DefaultError, 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, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, 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, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new user
|
||||||
|
*/
|
||||||
|
export const registerMutation = (options?: Partial<Options<RegisterData>>): UseMutationOptions<RegisterResponse, DefaultError, Options<RegisterData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<RegisterResponse, DefaultError, Options<RegisterData>> = {
|
||||||
|
mutationFn: async (fnOptions) => {
|
||||||
|
const { data } = await register({
|
||||||
|
...options,
|
||||||
|
...fnOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with username and password
|
||||||
|
*/
|
||||||
|
export const loginMutation = (options?: Partial<Options<LoginData>>): UseMutationOptions<LoginResponse, DefaultError, Options<LoginData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<LoginResponse, DefaultError, Options<LoginData>> = {
|
||||||
|
mutationFn: async (fnOptions) => {
|
||||||
|
const { data } = await login({
|
||||||
|
...options,
|
||||||
|
...fnOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout current user
|
||||||
|
*/
|
||||||
|
export const logoutMutation = (options?: Partial<Options<LogoutData>>): UseMutationOptions<LogoutResponse, DefaultError, Options<LogoutData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<LogoutResponse, DefaultError, Options<LogoutData>> = {
|
||||||
|
mutationFn: async (fnOptions) => {
|
||||||
|
const { data } = await logout({
|
||||||
|
...options,
|
||||||
|
...fnOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
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'> & {
|
||||||
|
|
@ -40,6 +90,24 @@ const createQueryKey = <TOptions extends Options>(id: string, options?: TOptions
|
||||||
return [params];
|
return [params];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getMeQueryKey = (options?: Options<GetMeData>) => createQueryKey('getMe', options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current authenticated user
|
||||||
|
*/
|
||||||
|
export const getMeOptions = (options?: Options<GetMeData>) => queryOptions<GetMeResponse, DefaultError, GetMeResponse, ReturnType<typeof getMeQueryKey>>({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await getMe({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: getMeQueryKey(options)
|
||||||
|
});
|
||||||
|
|
||||||
export const getStatusQueryKey = (options?: Options<GetStatusData>) => createQueryKey('getStatus', options);
|
export const getStatusQueryKey = (options?: Options<GetStatusData>) => createQueryKey('getStatus', options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -58,188 +126,13 @@ 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
|
* Change current user password
|
||||||
*/
|
*/
|
||||||
export const getAdminUsersOptions = (options?: Options<GetAdminUsersData>) => queryOptions<GetAdminUsersResponse, DefaultError, GetAdminUsersResponse, ReturnType<typeof getAdminUsersQueryKey>>({
|
export const changePasswordMutation = (options?: Partial<Options<ChangePasswordData>>): UseMutationOptions<ChangePasswordResponse, DefaultError, Options<ChangePasswordData>> => {
|
||||||
queryFn: async ({ queryKey, signal }) => {
|
const mutationOptions: UseMutationOptions<ChangePasswordResponse, DefaultError, Options<ChangePasswordData>> = {
|
||||||
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) => {
|
mutationFn: async (fnOptions) => {
|
||||||
const { data } = await deleteUserAccount({
|
const { data } = await changePassword({
|
||||||
...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);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get public SSO providers for the instance
|
|
||||||
*/
|
|
||||||
export const getPublicSsoProvidersOptions = (options?: Options<GetPublicSsoProvidersData>) => queryOptions<GetPublicSsoProvidersResponse, DefaultError, GetPublicSsoProvidersResponse, ReturnType<typeof getPublicSsoProvidersQueryKey>>({
|
|
||||||
queryFn: async ({ queryKey, signal }) => {
|
|
||||||
const { data } = await getPublicSsoProviders({
|
|
||||||
...options,
|
|
||||||
...queryKey[0],
|
|
||||||
signal,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
queryKey: getPublicSsoProvidersQueryKey(options)
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getSsoSettingsQueryKey = (options?: Options<GetSsoSettingsData>) => createQueryKey('getSsoSettings', options);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get SSO providers and invitations for the active organization
|
|
||||||
*/
|
|
||||||
export const getSsoSettingsOptions = (options?: Options<GetSsoSettingsData>) => queryOptions<GetSsoSettingsResponse, DefaultError, GetSsoSettingsResponse, ReturnType<typeof getSsoSettingsQueryKey>>({
|
|
||||||
queryFn: async ({ queryKey, signal }) => {
|
|
||||||
const { data } = await getSsoSettings({
|
|
||||||
...options,
|
|
||||||
...queryKey[0],
|
|
||||||
signal,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
queryKey: getSsoSettingsQueryKey(options)
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an SSO provider
|
|
||||||
*/
|
|
||||||
export const deleteSsoProviderMutation = (options?: Partial<Options<DeleteSsoProviderData>>): UseMutationOptions<unknown, DefaultError, Options<DeleteSsoProviderData>> => {
|
|
||||||
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<DeleteSsoProviderData>> = {
|
|
||||||
mutationFn: async (fnOptions) => {
|
|
||||||
const { data } = await deleteSsoProvider({
|
|
||||||
...options,
|
|
||||||
...fnOptions,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return mutationOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update whether SSO sign-in can auto-link existing accounts by email
|
|
||||||
*/
|
|
||||||
export const updateSsoProviderAutoLinkingMutation = (options?: Partial<Options<UpdateSsoProviderAutoLinkingData>>): UseMutationOptions<unknown, DefaultError, Options<UpdateSsoProviderAutoLinkingData>> => {
|
|
||||||
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<UpdateSsoProviderAutoLinkingData>> = {
|
|
||||||
mutationFn: async (fnOptions) => {
|
|
||||||
const { data } = await updateSsoProviderAutoLinking({
|
|
||||||
...options,
|
|
||||||
...fnOptions,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return mutationOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an SSO invitation
|
|
||||||
*/
|
|
||||||
export const deleteSsoInvitationMutation = (options?: Partial<Options<DeleteSsoInvitationData>>): UseMutationOptions<unknown, DefaultError, Options<DeleteSsoInvitationData>> => {
|
|
||||||
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<DeleteSsoInvitationData>> = {
|
|
||||||
mutationFn: async (fnOptions) => {
|
|
||||||
const { data } = await deleteSsoInvitation({
|
|
||||||
...options,
|
...options,
|
||||||
...fnOptions,
|
...fnOptions,
|
||||||
throwOnError: true
|
throwOnError: true
|
||||||
|
|
@ -423,62 +316,6 @@ export const listFilesOptions = (options: Options<ListFilesData>) => queryOption
|
||||||
queryKey: listFilesQueryKey(options)
|
queryKey: listFilesQueryKey(options)
|
||||||
});
|
});
|
||||||
|
|
||||||
const createInfiniteParams = <K extends Pick<QueryKey<Options>[0], 'body' | 'headers' | 'path' | 'query'>>(queryKey: QueryKey<Options>, page: K) => {
|
|
||||||
const params = { ...queryKey[0] };
|
|
||||||
if (page.body) {
|
|
||||||
params.body = {
|
|
||||||
...queryKey[0].body as any,
|
|
||||||
...page.body as any
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (page.headers) {
|
|
||||||
params.headers = {
|
|
||||||
...queryKey[0].headers,
|
|
||||||
...page.headers
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (page.path) {
|
|
||||||
params.path = {
|
|
||||||
...queryKey[0].path as any,
|
|
||||||
...page.path as any
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (page.query) {
|
|
||||||
params.query = {
|
|
||||||
...queryKey[0].query as any,
|
|
||||||
...page.query as any
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return params as unknown as typeof page;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listFilesInfiniteQueryKey = (options: Options<ListFilesData>): QueryKey<Options<ListFilesData>> => createQueryKey('listFiles', options, true);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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'>>(
|
|
||||||
// @ts-ignore
|
|
||||||
{
|
|
||||||
queryFn: async ({ pageParam, queryKey, signal }) => {
|
|
||||||
// @ts-ignore
|
|
||||||
const page: Pick<QueryKey<Options<ListFilesData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : {
|
|
||||||
query: {
|
|
||||||
offset: pageParam
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const params = createInfiniteParams(queryKey, page);
|
|
||||||
const { data } = await listFiles({
|
|
||||||
...options,
|
|
||||||
...params,
|
|
||||||
signal,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
queryKey: listFilesInfiniteQueryKey(options)
|
|
||||||
});
|
|
||||||
|
|
||||||
export const browseFilesystemQueryKey = (options?: Options<BrowseFilesystemData>) => createQueryKey('browseFilesystem', options);
|
export const browseFilesystemQueryKey = (options?: Options<BrowseFilesystemData>) => createQueryKey('browseFilesystem', options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -570,7 +407,7 @@ export const deleteRepositoryMutation = (options?: Partial<Options<DeleteReposit
|
||||||
export const getRepositoryQueryKey = (options: Options<GetRepositoryData>) => createQueryKey('getRepository', options);
|
export const getRepositoryQueryKey = (options: Options<GetRepositoryData>) => createQueryKey('getRepository', options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single repository by ID
|
* Get a single repository by name
|
||||||
*/
|
*/
|
||||||
export const getRepositoryOptions = (options: Options<GetRepositoryData>) => queryOptions<GetRepositoryResponse, DefaultError, GetRepositoryResponse, ReturnType<typeof getRepositoryQueryKey>>({
|
export const getRepositoryOptions = (options: Options<GetRepositoryData>) => queryOptions<GetRepositoryResponse, DefaultError, GetRepositoryResponse, ReturnType<typeof getRepositoryQueryKey>>({
|
||||||
queryFn: async ({ queryKey, signal }) => {
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
|
@ -602,58 +439,6 @@ export const updateRepositoryMutation = (options?: Partial<Options<UpdateReposit
|
||||||
return mutationOptions;
|
return mutationOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getRepositoryStatsQueryKey = (options: Options<GetRepositoryStatsData>) => createQueryKey('getRepositoryStats', options);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get repository storage and compression statistics
|
|
||||||
*/
|
|
||||||
export const getRepositoryStatsOptions = (options: Options<GetRepositoryStatsData>) => queryOptions<GetRepositoryStatsResponse, DefaultError, GetRepositoryStatsResponse, ReturnType<typeof getRepositoryStatsQueryKey>>({
|
|
||||||
queryFn: async ({ queryKey, signal }) => {
|
|
||||||
const { data } = await getRepositoryStats({
|
|
||||||
...options,
|
|
||||||
...queryKey[0],
|
|
||||||
signal,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
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
|
|
||||||
*/
|
|
||||||
export const deleteSnapshotsMutation = (options?: Partial<Options<DeleteSnapshotsData>>): UseMutationOptions<DeleteSnapshotsResponse, DefaultError, Options<DeleteSnapshotsData>> => {
|
|
||||||
const mutationOptions: UseMutationOptions<DeleteSnapshotsResponse, DefaultError, Options<DeleteSnapshotsData>> = {
|
|
||||||
mutationFn: async (fnOptions) => {
|
|
||||||
const { data } = await deleteSnapshots({
|
|
||||||
...options,
|
|
||||||
...fnOptions,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return mutationOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listSnapshotsQueryKey = (options: Options<ListSnapshotsData>) => createQueryKey('listSnapshots', options);
|
export const listSnapshotsQueryKey = (options: Options<ListSnapshotsData>) => createQueryKey('listSnapshots', options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -672,23 +457,6 @@ export const listSnapshotsOptions = (options: Options<ListSnapshotsData>) => que
|
||||||
queryKey: listSnapshotsQueryKey(options)
|
queryKey: listSnapshotsQueryKey(options)
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear snapshot cache and force refresh from repository
|
|
||||||
*/
|
|
||||||
export const refreshSnapshotsMutation = (options?: Partial<Options<RefreshSnapshotsData>>): UseMutationOptions<RefreshSnapshotsResponse, DefaultError, Options<RefreshSnapshotsData>> => {
|
|
||||||
const mutationOptions: UseMutationOptions<RefreshSnapshotsResponse, DefaultError, Options<RefreshSnapshotsData>> = {
|
|
||||||
mutationFn: async (fnOptions) => {
|
|
||||||
const { data } = await refreshSnapshots({
|
|
||||||
...options,
|
|
||||||
...fnOptions,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return mutationOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a specific snapshot from a repository
|
* Delete a specific snapshot from a repository
|
||||||
*/
|
*/
|
||||||
|
|
@ -742,51 +510,6 @@ export const listSnapshotFilesOptions = (options: Options<ListSnapshotFilesData>
|
||||||
queryKey: listSnapshotFilesQueryKey(options)
|
queryKey: listSnapshotFilesQueryKey(options)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const listSnapshotFilesInfiniteQueryKey = (options: Options<ListSnapshotFilesData>): QueryKey<Options<ListSnapshotFilesData>> => createQueryKey('listSnapshotFiles', options, true);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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'>>(
|
|
||||||
// @ts-ignore
|
|
||||||
{
|
|
||||||
queryFn: async ({ pageParam, queryKey, signal }) => {
|
|
||||||
// @ts-ignore
|
|
||||||
const page: Pick<QueryKey<Options<ListSnapshotFilesData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : {
|
|
||||||
query: {
|
|
||||||
offset: pageParam
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const params = createInfiniteParams(queryKey, page);
|
|
||||||
const { data } = await listSnapshotFiles({
|
|
||||||
...options,
|
|
||||||
...params,
|
|
||||||
signal,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
queryKey: listSnapshotFilesInfiniteQueryKey(options)
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dumpSnapshotQueryKey = (options: Options<DumpSnapshotData>) => createQueryKey('dumpSnapshot', options);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download a snapshot path as a tar archive (folders) or raw file stream (single files)
|
|
||||||
*/
|
|
||||||
export const dumpSnapshotOptions = (options: Options<DumpSnapshotData>) => queryOptions<DumpSnapshotResponse, DefaultError, DumpSnapshotResponse, ReturnType<typeof dumpSnapshotQueryKey>>({
|
|
||||||
queryFn: async ({ queryKey, signal }) => {
|
|
||||||
const { data } = await dumpSnapshot({
|
|
||||||
...options,
|
|
||||||
...queryKey[0],
|
|
||||||
signal,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
queryKey: dumpSnapshotQueryKey(options)
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore a snapshot to a target path on the filesystem
|
* Restore a snapshot to a target path on the filesystem
|
||||||
*/
|
*/
|
||||||
|
|
@ -805,63 +528,12 @@ export const restoreSnapshotMutation = (options?: Partial<Options<RestoreSnapsho
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel a running doctor operation on a repository
|
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
|
||||||
*/
|
*/
|
||||||
export const cancelDoctorMutation = (options?: Partial<Options<CancelDoctorData>>): UseMutationOptions<CancelDoctorResponse, DefaultError, Options<CancelDoctorData>> => {
|
export const doctorRepositoryMutation = (options?: Partial<Options<DoctorRepositoryData>>): UseMutationOptions<DoctorRepositoryResponse, DefaultError, Options<DoctorRepositoryData>> => {
|
||||||
const mutationOptions: UseMutationOptions<CancelDoctorResponse, DefaultError, Options<CancelDoctorData>> = {
|
const mutationOptions: UseMutationOptions<DoctorRepositoryResponse, DefaultError, Options<DoctorRepositoryData>> = {
|
||||||
mutationFn: async (fnOptions) => {
|
mutationFn: async (fnOptions) => {
|
||||||
const { data } = await cancelDoctor({
|
const { data } = await doctorRepository({
|
||||||
...options,
|
|
||||||
...fnOptions,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return mutationOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start an asynchronous doctor operation on a repository to fix common issues (unlock, check, repair index). The operation runs in the background and sends results via SSE events.
|
|
||||||
*/
|
|
||||||
export const startDoctorMutation = (options?: Partial<Options<StartDoctorData>>): UseMutationOptions<StartDoctorResponse, DefaultError, Options<StartDoctorData>> => {
|
|
||||||
const mutationOptions: UseMutationOptions<StartDoctorResponse, DefaultError, Options<StartDoctorData>> = {
|
|
||||||
mutationFn: async (fnOptions) => {
|
|
||||||
const { data } = await startDoctor({
|
|
||||||
...options,
|
|
||||||
...fnOptions,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return mutationOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unlock a repository by removing all stale locks
|
|
||||||
*/
|
|
||||||
export const unlockRepositoryMutation = (options?: Partial<Options<UnlockRepositoryData>>): UseMutationOptions<UnlockRepositoryResponse, DefaultError, Options<UnlockRepositoryData>> => {
|
|
||||||
const mutationOptions: UseMutationOptions<UnlockRepositoryResponse, DefaultError, Options<UnlockRepositoryData>> = {
|
|
||||||
mutationFn: async (fnOptions) => {
|
|
||||||
const { data } = await unlockRepository({
|
|
||||||
...options,
|
|
||||||
...fnOptions,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return mutationOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tag multiple snapshots in a repository
|
|
||||||
*/
|
|
||||||
export const tagSnapshotsMutation = (options?: Partial<Options<TagSnapshotsData>>): UseMutationOptions<TagSnapshotsResponse, DefaultError, Options<TagSnapshotsData>> => {
|
|
||||||
const mutationOptions: UseMutationOptions<TagSnapshotsResponse, DefaultError, Options<TagSnapshotsData>> = {
|
|
||||||
mutationFn: async (fnOptions) => {
|
|
||||||
const { data } = await tagSnapshots({
|
|
||||||
...options,
|
...options,
|
||||||
...fnOptions,
|
...fnOptions,
|
||||||
throwOnError: true
|
throwOnError: true
|
||||||
|
|
@ -1098,41 +770,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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1151,41 +788,6 @@ export const getMirrorCompatibilityOptions = (options: Options<GetMirrorCompatib
|
||||||
queryKey: getMirrorCompatibilityQueryKey(options)
|
queryKey: getMirrorCompatibilityQueryKey(options)
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Reorder backup schedules by providing an array of schedule short IDs in the desired order
|
|
||||||
*/
|
|
||||||
export const reorderBackupSchedulesMutation = (options?: Partial<Options<ReorderBackupSchedulesData>>): UseMutationOptions<ReorderBackupSchedulesResponse, DefaultError, Options<ReorderBackupSchedulesData>> => {
|
|
||||||
const mutationOptions: UseMutationOptions<ReorderBackupSchedulesResponse, DefaultError, Options<ReorderBackupSchedulesData>> = {
|
|
||||||
mutationFn: async (fnOptions) => {
|
|
||||||
const { data } = await reorderBackupSchedules({
|
|
||||||
...options,
|
|
||||||
...fnOptions,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1308,61 +910,8 @@ export const getSystemInfoOptions = (options?: Options<GetSystemInfoData>) => qu
|
||||||
queryKey: getSystemInfoQueryKey(options)
|
queryKey: getSystemInfoQueryKey(options)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getUpdatesQueryKey = (options?: Options<GetUpdatesData>) => createQueryKey('getUpdates', options);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check for application updates from GitHub
|
* Download the Restic password file for backup recovery. Requires password re-authentication.
|
||||||
*/
|
|
||||||
export const getUpdatesOptions = (options?: Options<GetUpdatesData>) => queryOptions<GetUpdatesResponse, DefaultError, GetUpdatesResponse, ReturnType<typeof getUpdatesQueryKey>>({
|
|
||||||
queryFn: async ({ queryKey, signal }) => {
|
|
||||||
const { data } = await getUpdates({
|
|
||||||
...options,
|
|
||||||
...queryKey[0],
|
|
||||||
signal,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
queryKey: getUpdatesQueryKey(options)
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getRegistrationStatusQueryKey = (options?: Options<GetRegistrationStatusData>) => createQueryKey('getRegistrationStatus', options);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current registration status for new users
|
|
||||||
*/
|
|
||||||
export const getRegistrationStatusOptions = (options?: Options<GetRegistrationStatusData>) => queryOptions<GetRegistrationStatusResponse, DefaultError, GetRegistrationStatusResponse, ReturnType<typeof getRegistrationStatusQueryKey>>({
|
|
||||||
queryFn: async ({ queryKey, signal }) => {
|
|
||||||
const { data } = await getRegistrationStatus({
|
|
||||||
...options,
|
|
||||||
...queryKey[0],
|
|
||||||
signal,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
queryKey: getRegistrationStatusQueryKey(options)
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the registration status for new users. Requires global admin role.
|
|
||||||
*/
|
|
||||||
export const setRegistrationStatusMutation = (options?: Partial<Options<SetRegistrationStatusData>>): UseMutationOptions<SetRegistrationStatusResponse, DefaultError, Options<SetRegistrationStatusData>> => {
|
|
||||||
const mutationOptions: UseMutationOptions<SetRegistrationStatusResponse, DefaultError, Options<SetRegistrationStatusData>> = {
|
|
||||||
mutationFn: async (fnOptions) => {
|
|
||||||
const { data } = await setRegistrationStatus({
|
|
||||||
...options,
|
|
||||||
...fnOptions,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return mutationOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication.
|
|
||||||
*/
|
*/
|
||||||
export const downloadResticPasswordMutation = (options?: Partial<Options<DownloadResticPasswordData>>): UseMutationOptions<DownloadResticPasswordResponse, DefaultError, Options<DownloadResticPasswordData>> => {
|
export const downloadResticPasswordMutation = (options?: Partial<Options<DownloadResticPasswordData>>): UseMutationOptions<DownloadResticPasswordResponse, DefaultError, Options<DownloadResticPasswordData>> => {
|
||||||
const mutationOptions: UseMutationOptions<DownloadResticPasswordResponse, DefaultError, Options<DownloadResticPasswordData>> = {
|
const mutationOptions: UseMutationOptions<DownloadResticPasswordResponse, DefaultError, Options<DownloadResticPasswordData>> = {
|
||||||
|
|
@ -1377,21 +926,3 @@ export const downloadResticPasswordMutation = (options?: Partial<Options<Downloa
|
||||||
};
|
};
|
||||||
return mutationOptions;
|
return mutationOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDevPanelQueryKey = (options?: Options<GetDevPanelData>) => createQueryKey('getDevPanel', options);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the dev panel status
|
|
||||||
*/
|
|
||||||
export const getDevPanelOptions = (options?: Options<GetDevPanelData>) => queryOptions<GetDevPanelResponse, DefaultError, GetDevPanelResponse, ReturnType<typeof getDevPanelQueryKey>>({
|
|
||||||
queryFn: async ({ queryKey, signal }) => {
|
|
||||||
const { data } = await getDevPanel({
|
|
||||||
...options,
|
|
||||||
...queryKey[0],
|
|
||||||
signal,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
queryKey: getDevPanelQueryKey(options)
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,16 @@
|
||||||
// @ts-nocheck
|
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
import { type ClientOptions, type Config, createClient, createConfig } from "./client";
|
|
||||||
import type { ClientOptions as ClientOptions2 } from "./types.gen";
|
|
||||||
import { getRequestClient } from "../../lib/request-client";
|
|
||||||
|
|
||||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (
|
import { type ClientOptions, type Config, createClient, createConfig } from './client';
|
||||||
override?: Config<ClientOptions & T>,
|
import type { ClientOptions as ClientOptions2 } from './types.gen';
|
||||||
) => Config<Required<ClientOptions> & T>;
|
|
||||||
|
|
||||||
const fallbackClient = createClient(createConfig<ClientOptions2>({ baseUrl: "http://localhost:4096" }));
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy client that automatically uses the per-request client from request-scoped server storage.
|
* The `createClientConfig()` function will be called on client initialization
|
||||||
* Falls back to a default client if no request context is available.
|
* and the returned object will become the client's initial configuration.
|
||||||
|
*
|
||||||
|
* You may want to initialize your client this way instead of calling
|
||||||
|
* `setConfig()`. This is useful for example if you're using Next.js
|
||||||
|
* to ensure your client always has the correct values.
|
||||||
*/
|
*/
|
||||||
export const client = new Proxy(fallbackClient, {
|
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
|
||||||
get(target, prop, receiver) {
|
|
||||||
try {
|
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: 'http://192.168.2.42:4096' }));
|
||||||
const requestClient = getRequestClient();
|
|
||||||
return Reflect.get(requestClient, prop, receiver);
|
|
||||||
} catch {
|
|
||||||
// No request context available, use fallback
|
|
||||||
return Reflect.get(target, prop, receiver);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
// @ts-nocheck
|
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import { createSseClient } from '../core/serverSentEvents.gen';
|
import { createSseClient } from '../core/serverSentEvents.gen';
|
||||||
import type { HttpMethod } from '../core/types.gen';
|
import type { HttpMethod } from '../core/types.gen';
|
||||||
import { getValidRequestBody } from '../core/utils.gen';
|
import { getValidRequestBody } from '../core/utils.gen';
|
||||||
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen';
|
import type {
|
||||||
|
Client,
|
||||||
|
Config,
|
||||||
|
RequestOptions,
|
||||||
|
ResolvedRequestOptions,
|
||||||
|
} from './types.gen';
|
||||||
import {
|
import {
|
||||||
buildUrl,
|
buildUrl,
|
||||||
createConfig,
|
createConfig,
|
||||||
|
|
@ -30,26 +34,27 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
return getConfig();
|
return getConfig();
|
||||||
};
|
};
|
||||||
|
|
||||||
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
|
const interceptors = createInterceptors<
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
unknown,
|
||||||
|
ResolvedRequestOptions
|
||||||
|
>();
|
||||||
|
|
||||||
const beforeRequest = async <
|
const beforeRequest = async (options: RequestOptions) => {
|
||||||
TData = unknown,
|
|
||||||
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 +62,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,191 +70,209 @@ 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
for (const fn of interceptors.response.fns) {
|
||||||
request({ ...options, method });
|
if (fn) {
|
||||||
|
response = await fn(response, request, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
const result = {
|
||||||
const { opts, url } = await beforeRequest(options);
|
request,
|
||||||
return createSseClient({
|
response,
|
||||||
...opts,
|
};
|
||||||
body: opts.body as BodyInit | null | undefined,
|
|
||||||
method,
|
if (response.ok) {
|
||||||
onRequest: async (url, init) => {
|
const parseAs =
|
||||||
let request = new Request(url, init);
|
(opts.parseAs === 'auto'
|
||||||
for (const fn of interceptors.request.fns) {
|
? getParseAs(response.headers.get('Content-Type'))
|
||||||
if (fn) {
|
: opts.parseAs) ?? 'json';
|
||||||
request = await fn(request, opts);
|
|
||||||
}
|
if (
|
||||||
|
response.status === 204 ||
|
||||||
|
response.headers.get('Content-Length') === '0'
|
||||||
|
) {
|
||||||
|
let emptyData: any;
|
||||||
|
switch (parseAs) {
|
||||||
|
case 'arrayBuffer':
|
||||||
|
case 'blob':
|
||||||
|
case 'text':
|
||||||
|
emptyData = await response[parseAs]();
|
||||||
|
break;
|
||||||
|
case 'formData':
|
||||||
|
emptyData = new FormData();
|
||||||
|
break;
|
||||||
|
case 'stream':
|
||||||
|
emptyData = response.body;
|
||||||
|
break;
|
||||||
|
case 'json':
|
||||||
|
default:
|
||||||
|
emptyData = {};
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return request;
|
return opts.responseStyle === 'data'
|
||||||
},
|
? emptyData
|
||||||
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
|
: {
|
||||||
url,
|
data: emptyData,
|
||||||
});
|
...result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: any;
|
||||||
|
switch (parseAs) {
|
||||||
|
case 'arrayBuffer':
|
||||||
|
case 'blob':
|
||||||
|
case 'formData':
|
||||||
|
case 'json':
|
||||||
|
case 'text':
|
||||||
|
data = await response[parseAs]();
|
||||||
|
break;
|
||||||
|
case 'stream':
|
||||||
|
return opts.responseStyle === 'data'
|
||||||
|
? response.body
|
||||||
|
: {
|
||||||
|
data: response.body,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options });
|
const makeMethodFn =
|
||||||
|
(method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
||||||
|
request({ ...options, method });
|
||||||
|
|
||||||
|
const makeSseFn =
|
||||||
|
(method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||||
|
const { opts, url } = await beforeRequest(options);
|
||||||
|
return createSseClient({
|
||||||
|
...opts,
|
||||||
|
body: opts.body as BodyInit | null | undefined,
|
||||||
|
headers: opts.headers as unknown as Record<string, string>,
|
||||||
|
method,
|
||||||
|
onRequest: async (url, init) => {
|
||||||
|
let request = new Request(url, init);
|
||||||
|
for (const fn of interceptors.request.fns) {
|
||||||
|
if (fn) {
|
||||||
|
request = await fn(request, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buildUrl: _buildUrl,
|
buildUrl,
|
||||||
connect: makeMethodFn('CONNECT'),
|
connect: makeMethodFn('CONNECT'),
|
||||||
delete: makeMethodFn('DELETE'),
|
delete: makeMethodFn('DELETE'),
|
||||||
get: makeMethodFn('GET'),
|
get: makeMethodFn('GET'),
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
// @ts-nocheck
|
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
export type { Auth } from "../core/auth.gen";
|
export type { Auth } from '../core/auth.gen';
|
||||||
export type { QuerySerializerOptions } from "../core/bodySerializer.gen";
|
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||||
export { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer } from "../core/bodySerializer.gen";
|
export {
|
||||||
export { buildClientParams } from "../core/params.gen";
|
formDataBodySerializer,
|
||||||
export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen";
|
jsonBodySerializer,
|
||||||
export { createClient } from "./client.gen";
|
urlSearchParamsBodySerializer,
|
||||||
|
} from '../core/bodySerializer.gen';
|
||||||
|
export { buildClientParams } from '../core/params.gen';
|
||||||
|
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
|
||||||
|
export { createClient } from './client.gen';
|
||||||
export type {
|
export type {
|
||||||
Client,
|
Client,
|
||||||
ClientOptions,
|
ClientOptions,
|
||||||
Config,
|
Config,
|
||||||
CreateClientConfig,
|
CreateClientConfig,
|
||||||
Options,
|
Options,
|
||||||
RequestOptions,
|
RequestOptions,
|
||||||
RequestResult,
|
RequestResult,
|
||||||
ResolvedRequestOptions,
|
ResolvedRequestOptions,
|
||||||
ResponseStyle,
|
ResponseStyle,
|
||||||
TDataShape,
|
TDataShape,
|
||||||
} from "./types.gen";
|
} from './types.gen';
|
||||||
export { createConfig, mergeHeaders } from "./utils.gen";
|
export { createConfig, mergeHeaders } from './utils.gen';
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -6,13 +5,17 @@ import type {
|
||||||
ServerSentEventsOptions,
|
ServerSentEventsOptions,
|
||||||
ServerSentEventsResult,
|
ServerSentEventsResult,
|
||||||
} from '../core/serverSentEvents.gen';
|
} from '../core/serverSentEvents.gen';
|
||||||
import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen';
|
import type {
|
||||||
|
Client as CoreClient,
|
||||||
|
Config as CoreConfig,
|
||||||
|
} from '../core/types.gen';
|
||||||
import type { Middleware } from './utils.gen';
|
import type { Middleware } from './utils.gen';
|
||||||
|
|
||||||
export type ResponseStyle = 'data' | 'fields';
|
export type ResponseStyle = 'data' | 'fields';
|
||||||
|
|
||||||
export interface Config<T extends ClientOptions = ClientOptions>
|
export interface Config<T extends ClientOptions = ClientOptions>
|
||||||
extends Omit<RequestInit, 'body' | 'headers' | 'method'>, CoreConfig {
|
extends Omit<RequestInit, 'body' | 'headers' | 'method'>,
|
||||||
|
CoreConfig {
|
||||||
/**
|
/**
|
||||||
* Base URL for all requests made by this client.
|
* Base URL for all requests made by this client.
|
||||||
*/
|
*/
|
||||||
|
|
@ -39,7 +42,14 @@ export interface Config<T extends ClientOptions = ClientOptions>
|
||||||
*
|
*
|
||||||
* @default 'auto'
|
* @default 'auto'
|
||||||
*/
|
*/
|
||||||
parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text';
|
parseAs?:
|
||||||
|
| 'arrayBuffer'
|
||||||
|
| 'auto'
|
||||||
|
| 'blob'
|
||||||
|
| 'formData'
|
||||||
|
| 'json'
|
||||||
|
| 'stream'
|
||||||
|
| 'text';
|
||||||
/**
|
/**
|
||||||
* Should we return only data or multiple fields (data, error, response, etc.)?
|
* Should we return only data or multiple fields (data, error, response, etc.)?
|
||||||
*
|
*
|
||||||
|
|
@ -59,15 +69,12 @@ export interface RequestOptions<
|
||||||
TResponseStyle extends ResponseStyle = 'fields',
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
ThrowOnError extends boolean = boolean,
|
ThrowOnError extends boolean = boolean,
|
||||||
Url extends string = string,
|
Url extends string = string,
|
||||||
>
|
> extends Config<{
|
||||||
extends
|
|
||||||
Config<{
|
|
||||||
responseStyle: TResponseStyle;
|
responseStyle: TResponseStyle;
|
||||||
throwOnError: ThrowOnError;
|
throwOnError: ThrowOnError;
|
||||||
}>,
|
}>,
|
||||||
Pick<
|
Pick<
|
||||||
ServerSentEventsOptions<TData>,
|
ServerSentEventsOptions<TData>,
|
||||||
| 'onRequest'
|
|
||||||
| 'onSseError'
|
| 'onSseError'
|
||||||
| 'onSseEvent'
|
| 'onSseEvent'
|
||||||
| 'sseDefaultRetryDelay'
|
| 'sseDefaultRetryDelay'
|
||||||
|
|
@ -94,7 +101,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,28 +116,36 @@ export type RequestResult<
|
||||||
? TData[keyof TData]
|
? TData[keyof TData]
|
||||||
: TData
|
: TData
|
||||||
: {
|
: {
|
||||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
data: TData extends Record<string, unknown>
|
||||||
|
? TData[keyof TData]
|
||||||
|
: TData;
|
||||||
request: Request;
|
request: Request;
|
||||||
response: Response;
|
response: Response;
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
: Promise<
|
: Promise<
|
||||||
TResponseStyle extends 'data'
|
TResponseStyle extends 'data'
|
||||||
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
|
?
|
||||||
|
| (TData extends Record<string, unknown>
|
||||||
|
? TData[keyof TData]
|
||||||
|
: TData)
|
||||||
|
| undefined
|
||||||
: (
|
: (
|
||||||
| {
|
| {
|
||||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
data: TData extends Record<string, unknown>
|
||||||
|
? TData[keyof TData]
|
||||||
|
: TData;
|
||||||
error: undefined;
|
error: undefined;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
data: undefined;
|
data: undefined;
|
||||||
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 +170,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 = <
|
||||||
|
|
@ -166,7 +180,10 @@ type RequestFn = <
|
||||||
TResponseStyle extends ResponseStyle = 'fields',
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
>(
|
>(
|
||||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
|
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
|
||||||
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, 'method'>,
|
Pick<
|
||||||
|
Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>,
|
||||||
|
'method'
|
||||||
|
>,
|
||||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||||
|
|
||||||
type BuildUrlFn = <
|
type BuildUrlFn = <
|
||||||
|
|
@ -180,7 +197,13 @@ type BuildUrlFn = <
|
||||||
options: TData & Options<TData>,
|
options: TData & Options<TData>,
|
||||||
) => string;
|
) => string;
|
||||||
|
|
||||||
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
|
export type Client = CoreClient<
|
||||||
|
RequestFn,
|
||||||
|
Config,
|
||||||
|
MethodFn,
|
||||||
|
BuildUrlFn,
|
||||||
|
SseFn
|
||||||
|
> & {
|
||||||
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -66,7 +65,9 @@ export const createQuerySerializer = <T = unknown>({
|
||||||
/**
|
/**
|
||||||
* Infers parseAs value from provided Content-Type header.
|
* Infers parseAs value from provided Content-Type header.
|
||||||
*/
|
*/
|
||||||
export const getParseAs = (contentType: string | null): Exclude<Config['parseAs'], 'auto'> => {
|
export const getParseAs = (
|
||||||
|
contentType: string | null,
|
||||||
|
): Exclude<Config['parseAs'], 'auto'> => {
|
||||||
if (!contentType) {
|
if (!contentType) {
|
||||||
// If no Content-Type header is provided, the best we can do is return the raw response body,
|
// If no Content-Type header is provided, the best we can do is return the raw response body,
|
||||||
// which is effectively the same as the 'stream' option.
|
// which is effectively the same as the 'stream' option.
|
||||||
|
|
@ -79,7 +80,10 @@ export const getParseAs = (contentType: string | null): Exclude<Config['parseAs'
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) {
|
if (
|
||||||
|
cleanContent.startsWith('application/json') ||
|
||||||
|
cleanContent.endsWith('+json')
|
||||||
|
) {
|
||||||
return 'json';
|
return 'json';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,7 +92,9 @@ export const getParseAs = (contentType: string | null): Exclude<Config['parseAs'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type))
|
['application/', 'audio/', 'image/', 'video/'].some((type) =>
|
||||||
|
cleanContent.startsWith(type),
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
return 'blob';
|
return 'blob';
|
||||||
}
|
}
|
||||||
|
|
@ -119,12 +125,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 +161,7 @@ export async function setAuthParams(
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const buildUrl: Client['buildUrl'] = (options) =>
|
export const buildUrl: Client['buildUrl'] = (options) =>
|
||||||
getUrl({
|
getUrl({
|
||||||
|
|
@ -193,7 +201,10 @@ export const mergeHeaders = (
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
|
const iterator =
|
||||||
|
header instanceof Headers
|
||||||
|
? headersEntries(header)
|
||||||
|
: Object.entries(header);
|
||||||
|
|
||||||
for (const [key, value] of iterator) {
|
for (const [key, value] of iterator) {
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
|
|
@ -203,7 +214,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,14 +228,15 @@ 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>;
|
||||||
|
|
||||||
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>;
|
type ReqInterceptor<Req, Options> = (
|
||||||
|
request: Req,
|
||||||
|
options: Options,
|
||||||
|
) => Req | Promise<Req>;
|
||||||
|
|
||||||
type ResInterceptor<Res, Req, Options> = (
|
type ResInterceptor<Res, Req, Options> = (
|
||||||
response: Res,
|
response: Res,
|
||||||
|
|
@ -258,7 +270,10 @@ class Interceptors<Interceptor> {
|
||||||
return this.fns.indexOf(id);
|
return this.fns.indexOf(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
|
update(
|
||||||
|
id: number | Interceptor,
|
||||||
|
fn: Interceptor,
|
||||||
|
): number | Interceptor | false {
|
||||||
const index = this.getInterceptorIndex(id);
|
const index = this.getInterceptorIndex(id);
|
||||||
if (this.fns[index]) {
|
if (this.fns[index]) {
|
||||||
this.fns[index] = fn;
|
this.fns[index] = fn;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -24,7 +23,8 @@ export const getAuthToken = async (
|
||||||
auth: Auth,
|
auth: Auth,
|
||||||
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
||||||
): Promise<string | undefined> => {
|
): Promise<string | undefined> => {
|
||||||
const token = typeof callback === 'function' ? await callback(auth) : callback;
|
const token =
|
||||||
|
typeof callback === 'function' ? await callback(auth) : callback;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
// @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;
|
||||||
|
|
@ -21,7 +24,11 @@ export type QuerySerializerOptions = QuerySerializerOptionsObject & {
|
||||||
parameters?: Record<string, QuerySerializerOptionsObject>;
|
parameters?: Record<string, QuerySerializerOptionsObject>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
|
const serializeFormDataPair = (
|
||||||
|
data: FormData,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
): void => {
|
||||||
if (typeof value === 'string' || value instanceof Blob) {
|
if (typeof value === 'string' || value instanceof Blob) {
|
||||||
data.append(key, value);
|
data.append(key, value);
|
||||||
} else if (value instanceof Date) {
|
} else if (value instanceof Date) {
|
||||||
|
|
@ -31,7 +38,11 @@ const serializeFormDataPair = (data: FormData, key: string, value: unknown): voi
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
|
const serializeUrlSearchParamsPair = (
|
||||||
|
data: URLSearchParams,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
): void => {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
data.append(key, value);
|
data.append(key, value);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -40,10 +51,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 +72,19 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,13 +96,16 @@ 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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
|
export const buildClientParams = (
|
||||||
|
args: ReadonlyArray<unknown>,
|
||||||
|
fields: FieldsConfig,
|
||||||
|
) => {
|
||||||
const params: Params = {
|
const params: Params = {
|
||||||
body: {},
|
body: {},
|
||||||
headers: {},
|
headers: {},
|
||||||
|
|
@ -146,11 +148,15 @@ export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsCo
|
||||||
params[field.map] = value;
|
params[field.map] = value;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
|
const extra = extraPrefixes.find(([prefix]) =>
|
||||||
|
key.startsWith(prefix),
|
||||||
|
);
|
||||||
|
|
||||||
if (extra) {
|
if (extra) {
|
||||||
const [prefix, slot] = extra;
|
const [prefix, slot] = extra;
|
||||||
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
|
(params[slot] as Record<string, unknown>)[
|
||||||
|
key.slice(prefix.length)
|
||||||
|
] = value;
|
||||||
} else if ('allowExtra' in config && config.allowExtra) {
|
} else if ('allowExtra' in config && config.allowExtra) {
|
||||||
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
||||||
if (allowed) {
|
if (allowed) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
// @ts-nocheck
|
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
|
interface SerializeOptions<T>
|
||||||
|
extends SerializePrimitiveOptions,
|
||||||
|
SerializerOptions<T> {}
|
||||||
|
|
||||||
interface SerializePrimitiveOptions {
|
interface SerializePrimitiveOptions {
|
||||||
allowReserved?: boolean;
|
allowReserved?: boolean;
|
||||||
|
|
@ -104,7 +105,9 @@ export const serializeArrayParam = ({
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.join(separator);
|
.join(separator);
|
||||||
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
|
return style === 'label' || style === 'matrix'
|
||||||
|
? separator + joinedValues
|
||||||
|
: joinedValues;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const serializePrimitiveParam = ({
|
export const serializePrimitiveParam = ({
|
||||||
|
|
@ -143,7 +146,11 @@ export const serializeObjectParam = ({
|
||||||
if (style !== 'deepObject' && !explode) {
|
if (style !== 'deepObject' && !explode) {
|
||||||
let values: string[] = [];
|
let values: string[] = [];
|
||||||
Object.entries(value).forEach(([key, v]) => {
|
Object.entries(value).forEach(([key, v]) => {
|
||||||
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
|
values = [
|
||||||
|
...values,
|
||||||
|
key,
|
||||||
|
allowReserved ? (v as string) : encodeURIComponent(v as string),
|
||||||
|
];
|
||||||
});
|
});
|
||||||
const joinedValues = values.join(',');
|
const joinedValues = values.join(',');
|
||||||
switch (style) {
|
switch (style) {
|
||||||
|
|
@ -168,5 +175,7 @@ export const serializeObjectParam = ({
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.join(separator);
|
.join(separator);
|
||||||
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
|
return style === 'label' || style === 'matrix'
|
||||||
|
? separator + joinedValues
|
||||||
|
: joinedValues;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -16,7 +15,11 @@ export type JsonValue =
|
||||||
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
|
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
|
||||||
*/
|
*/
|
||||||
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
|
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
|
||||||
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
if (
|
||||||
|
value === undefined ||
|
||||||
|
typeof value === 'function' ||
|
||||||
|
typeof value === 'symbol'
|
||||||
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (typeof value === 'bigint') {
|
if (typeof value === 'bigint') {
|
||||||
|
|
@ -58,7 +61,9 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||||
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
|
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
|
||||||
*/
|
*/
|
||||||
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
||||||
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
|
const entries = Array.from(params.entries()).sort(([a], [b]) =>
|
||||||
|
a.localeCompare(b),
|
||||||
|
);
|
||||||
const result: Record<string, JsonValue> = {};
|
const result: Record<string, JsonValue> = {};
|
||||||
|
|
||||||
for (const [key, value] of entries) {
|
for (const [key, value] of entries) {
|
||||||
|
|
@ -81,16 +86,26 @@ const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
||||||
/**
|
/**
|
||||||
* Normalizes any accepted value into a JSON-friendly shape for query keys.
|
* Normalizes any accepted value into a JSON-friendly shape for query keys.
|
||||||
*/
|
*/
|
||||||
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
|
export const serializeQueryKeyValue = (
|
||||||
|
value: unknown,
|
||||||
|
): JsonValue | undefined => {
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
if (
|
||||||
|
typeof value === 'string' ||
|
||||||
|
typeof value === 'number' ||
|
||||||
|
typeof value === 'boolean'
|
||||||
|
) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
if (
|
||||||
|
value === undefined ||
|
||||||
|
typeof value === 'function' ||
|
||||||
|
typeof value === 'symbol'
|
||||||
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,7 +121,10 @@ export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined =>
|
||||||
return stringifyToJsonValue(value);
|
return stringifyToJsonValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) {
|
if (
|
||||||
|
typeof URLSearchParams !== 'undefined' &&
|
||||||
|
value instanceof URLSearchParams
|
||||||
|
) {
|
||||||
return serializeSearchParams(value);
|
return serializeSearchParams(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
// @ts-nocheck
|
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import type { Config } from './types.gen';
|
import type { Config } from './types.gen';
|
||||||
|
|
||||||
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, 'method'> &
|
export type ServerSentEventsOptions<TData = unknown> = Omit<
|
||||||
|
RequestInit,
|
||||||
|
'method'
|
||||||
|
> &
|
||||||
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
|
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
|
||||||
/**
|
/**
|
||||||
* Fetch API implementation. You can use this option to provide a custom
|
* Fetch API implementation. You can use this option to provide a custom
|
||||||
|
|
@ -72,7 +74,11 @@ export interface StreamEvent<TData = unknown> {
|
||||||
retry?: number;
|
retry?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
|
export type ServerSentEventsResult<
|
||||||
|
TData = unknown,
|
||||||
|
TReturn = void,
|
||||||
|
TNext = unknown,
|
||||||
|
> = {
|
||||||
stream: AsyncGenerator<
|
stream: AsyncGenerator<
|
||||||
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
|
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
|
||||||
TReturn,
|
TReturn,
|
||||||
|
|
@ -80,7 +86,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,10 +98,12 @@ 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)));
|
||||||
|
|
||||||
const createStream = async function* () {
|
const createStream = async function* () {
|
||||||
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
||||||
|
|
@ -133,11 +141,16 @@ export function createSseClient<TData = unknown>({
|
||||||
const _fetch = options.fetch ?? globalThis.fetch;
|
const _fetch = options.fetch ?? globalThis.fetch;
|
||||||
const response = await _fetch(request);
|
const response = await _fetch(request);
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
|
if (!response.ok)
|
||||||
|
throw new Error(
|
||||||
|
`SSE failed: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.body) throw new Error('No body in SSE response');
|
if (!response.body) throw new Error('No body in SSE response');
|
||||||
|
|
||||||
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
const reader = response.body
|
||||||
|
.pipeThrough(new TextDecoderStream())
|
||||||
|
.getReader();
|
||||||
|
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
|
|
||||||
|
|
@ -156,7 +169,6 @@ 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
|
|
||||||
|
|
||||||
const chunks = buffer.split('\n\n');
|
const chunks = buffer.split('\n\n');
|
||||||
buffer = chunks.pop() ?? '';
|
buffer = chunks.pop() ?? '';
|
||||||
|
|
@ -174,7 +186,10 @@ export function createSseClient<TData = unknown>({
|
||||||
} else if (line.startsWith('id:')) {
|
} else if (line.startsWith('id:')) {
|
||||||
lastEventId = line.replace(/^id:\s*/, '');
|
lastEventId = line.replace(/^id:\s*/, '');
|
||||||
} else if (line.startsWith('retry:')) {
|
} else if (line.startsWith('retry:')) {
|
||||||
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10);
|
const parsed = Number.parseInt(
|
||||||
|
line.replace(/^retry:\s*/, ''),
|
||||||
|
10,
|
||||||
|
);
|
||||||
if (!Number.isNaN(parsed)) {
|
if (!Number.isNaN(parsed)) {
|
||||||
retryDelay = parsed;
|
retryDelay = parsed;
|
||||||
}
|
}
|
||||||
|
|
@ -226,12 +241,18 @@ export function createSseClient<TData = unknown>({
|
||||||
// connection failed or aborted; retry after delay
|
// connection failed or aborted; retry after delay
|
||||||
onSseError?.(error);
|
onSseError?.(error);
|
||||||
|
|
||||||
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
|
if (
|
||||||
|
sseMaxRetryAttempts !== undefined &&
|
||||||
|
attempt >= sseMaxRetryAttempts
|
||||||
|
) {
|
||||||
break; // stop after firing error
|
break; // stop after firing error
|
||||||
}
|
}
|
||||||
|
|
||||||
// exponential backoff: double retry each attempt, cap at 30s
|
// exponential backoff: double retry each attempt, cap at 30s
|
||||||
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
|
const backoff = Math.min(
|
||||||
|
retryDelay * 2 ** (attempt - 1),
|
||||||
|
sseMaxRetryDelay ?? 30000,
|
||||||
|
);
|
||||||
await sleep(backoff);
|
await sleep(backoff);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -240,4 +261,4 @@ export function createSseClient<TData = unknown>({
|
||||||
const stream = createStream();
|
const stream = createStream();
|
||||||
|
|
||||||
return { stream };
|
return { stream };
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
// @ts-nocheck
|
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import type { Auth, AuthToken } from './auth.gen';
|
import type { Auth, AuthToken } from './auth.gen';
|
||||||
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen';
|
import type {
|
||||||
|
BodySerializer,
|
||||||
|
QuerySerializer,
|
||||||
|
QuerySerializerOptions,
|
||||||
|
} from './bodySerializer.gen';
|
||||||
|
|
||||||
export type HttpMethod =
|
export type HttpMethod =
|
||||||
| 'connect'
|
| 'connect'
|
||||||
|
|
@ -31,7 +34,9 @@ export type Client<
|
||||||
setConfig: (config: Config) => Config;
|
setConfig: (config: Config) => Config;
|
||||||
} & {
|
} & {
|
||||||
[K in HttpMethod]: MethodFn;
|
[K in HttpMethod]: MethodFn;
|
||||||
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
|
} & ([SseFn] extends [never]
|
||||||
|
? { sse?: never }
|
||||||
|
: { sse: { [K in HttpMethod]: SseFn } });
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
/**
|
/**
|
||||||
|
|
@ -54,7 +59,13 @@ export interface Config {
|
||||||
| RequestInit['headers']
|
| RequestInit['headers']
|
||||||
| Record<
|
| Record<
|
||||||
string,
|
string,
|
||||||
string | number | boolean | (string | number | boolean)[] | null | undefined | unknown
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| (string | number | boolean)[]
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
| unknown
|
||||||
>;
|
>;
|
||||||
/**
|
/**
|
||||||
* The request method.
|
* The request method.
|
||||||
|
|
@ -81,7 +92,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>;
|
||||||
/**
|
/**
|
||||||
|
|
@ -101,5 +112,7 @@ type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
export type OmitNever<T extends Record<string, unknown>> = {
|
export type OmitNever<T extends Record<string, unknown>> = {
|
||||||
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K];
|
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true
|
||||||
|
? never
|
||||||
|
: K]: T[K];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -45,7 +44,10 @@ export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
|
url = url.replace(
|
||||||
|
match,
|
||||||
|
serializeArrayParam({ explode, name, style, value }),
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,7 +129,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,318 +1,4 @@
|
||||||
// @ts-nocheck
|
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
export {
|
export type * from './types.gen';
|
||||||
browseFilesystem,
|
export * from './sdk.gen';
|
||||||
cancelDoctor,
|
|
||||||
createBackupSchedule,
|
|
||||||
createNotificationDestination,
|
|
||||||
createRepository,
|
|
||||||
createVolume,
|
|
||||||
deleteBackupSchedule,
|
|
||||||
deleteNotificationDestination,
|
|
||||||
deleteRepository,
|
|
||||||
deleteSnapshot,
|
|
||||||
deleteSnapshots,
|
|
||||||
deleteSsoInvitation,
|
|
||||||
deleteSsoProvider,
|
|
||||||
deleteUserAccount,
|
|
||||||
deleteVolume,
|
|
||||||
devPanelExec,
|
|
||||||
downloadResticPassword,
|
|
||||||
dumpSnapshot,
|
|
||||||
getAdminUsers,
|
|
||||||
getBackupProgress,
|
|
||||||
getBackupSchedule,
|
|
||||||
getBackupScheduleForVolume,
|
|
||||||
getDevPanel,
|
|
||||||
getMirrorCompatibility,
|
|
||||||
getMirrorSyncStatus,
|
|
||||||
getNotificationDestination,
|
|
||||||
getOrgMembers,
|
|
||||||
getPublicSsoProviders,
|
|
||||||
getRegistrationStatus,
|
|
||||||
getRepository,
|
|
||||||
getRepositoryStats,
|
|
||||||
getScheduleMirrors,
|
|
||||||
getScheduleNotifications,
|
|
||||||
getSnapshotDetails,
|
|
||||||
getSsoSettings,
|
|
||||||
getStatus,
|
|
||||||
getSystemInfo,
|
|
||||||
getUpdates,
|
|
||||||
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";
|
|
||||||
export type {
|
|
||||||
BrowseFilesystemData,
|
|
||||||
BrowseFilesystemResponse,
|
|
||||||
BrowseFilesystemResponses,
|
|
||||||
CancelDoctorData,
|
|
||||||
CancelDoctorErrors,
|
|
||||||
CancelDoctorResponse,
|
|
||||||
CancelDoctorResponses,
|
|
||||||
ClientOptions,
|
|
||||||
CreateBackupScheduleData,
|
|
||||||
CreateBackupScheduleResponse,
|
|
||||||
CreateBackupScheduleResponses,
|
|
||||||
CreateNotificationDestinationData,
|
|
||||||
CreateNotificationDestinationResponse,
|
|
||||||
CreateNotificationDestinationResponses,
|
|
||||||
CreateRepositoryData,
|
|
||||||
CreateRepositoryResponse,
|
|
||||||
CreateRepositoryResponses,
|
|
||||||
CreateVolumeData,
|
|
||||||
CreateVolumeResponse,
|
|
||||||
CreateVolumeResponses,
|
|
||||||
DeleteBackupScheduleData,
|
|
||||||
DeleteBackupScheduleResponse,
|
|
||||||
DeleteBackupScheduleResponses,
|
|
||||||
DeleteNotificationDestinationData,
|
|
||||||
DeleteNotificationDestinationErrors,
|
|
||||||
DeleteNotificationDestinationResponse,
|
|
||||||
DeleteNotificationDestinationResponses,
|
|
||||||
DeleteRepositoryData,
|
|
||||||
DeleteRepositoryResponse,
|
|
||||||
DeleteRepositoryResponses,
|
|
||||||
DeleteSnapshotData,
|
|
||||||
DeleteSnapshotResponse,
|
|
||||||
DeleteSnapshotResponses,
|
|
||||||
DeleteSnapshotsData,
|
|
||||||
DeleteSnapshotsResponse,
|
|
||||||
DeleteSnapshotsResponses,
|
|
||||||
DeleteSsoInvitationData,
|
|
||||||
DeleteSsoInvitationErrors,
|
|
||||||
DeleteSsoInvitationResponses,
|
|
||||||
DeleteSsoProviderData,
|
|
||||||
DeleteSsoProviderErrors,
|
|
||||||
DeleteSsoProviderResponses,
|
|
||||||
DeleteUserAccountData,
|
|
||||||
DeleteUserAccountErrors,
|
|
||||||
DeleteUserAccountResponses,
|
|
||||||
DeleteVolumeData,
|
|
||||||
DeleteVolumeResponse,
|
|
||||||
DeleteVolumeResponses,
|
|
||||||
DevPanelExecData,
|
|
||||||
DevPanelExecErrors,
|
|
||||||
DevPanelExecResponse,
|
|
||||||
DevPanelExecResponses,
|
|
||||||
DownloadResticPasswordData,
|
|
||||||
DownloadResticPasswordResponse,
|
|
||||||
DownloadResticPasswordResponses,
|
|
||||||
DumpSnapshotData,
|
|
||||||
DumpSnapshotResponse,
|
|
||||||
DumpSnapshotResponses,
|
|
||||||
GetAdminUsersData,
|
|
||||||
GetAdminUsersResponse,
|
|
||||||
GetAdminUsersResponses,
|
|
||||||
GetBackupProgressData,
|
|
||||||
GetBackupProgressResponse,
|
|
||||||
GetBackupProgressResponses,
|
|
||||||
GetBackupScheduleData,
|
|
||||||
GetBackupScheduleForVolumeData,
|
|
||||||
GetBackupScheduleForVolumeResponse,
|
|
||||||
GetBackupScheduleForVolumeResponses,
|
|
||||||
GetBackupScheduleResponse,
|
|
||||||
GetBackupScheduleResponses,
|
|
||||||
GetDevPanelData,
|
|
||||||
GetDevPanelResponse,
|
|
||||||
GetDevPanelResponses,
|
|
||||||
GetMirrorCompatibilityData,
|
|
||||||
GetMirrorCompatibilityResponse,
|
|
||||||
GetMirrorCompatibilityResponses,
|
|
||||||
GetMirrorSyncStatusData,
|
|
||||||
GetMirrorSyncStatusResponse,
|
|
||||||
GetMirrorSyncStatusResponses,
|
|
||||||
GetNotificationDestinationData,
|
|
||||||
GetNotificationDestinationErrors,
|
|
||||||
GetNotificationDestinationResponse,
|
|
||||||
GetNotificationDestinationResponses,
|
|
||||||
GetOrgMembersData,
|
|
||||||
GetOrgMembersResponse,
|
|
||||||
GetOrgMembersResponses,
|
|
||||||
GetPublicSsoProvidersData,
|
|
||||||
GetPublicSsoProvidersResponse,
|
|
||||||
GetPublicSsoProvidersResponses,
|
|
||||||
GetRegistrationStatusData,
|
|
||||||
GetRegistrationStatusResponse,
|
|
||||||
GetRegistrationStatusResponses,
|
|
||||||
GetRepositoryData,
|
|
||||||
GetRepositoryResponse,
|
|
||||||
GetRepositoryResponses,
|
|
||||||
GetRepositoryStatsData,
|
|
||||||
GetRepositoryStatsResponse,
|
|
||||||
GetRepositoryStatsResponses,
|
|
||||||
GetScheduleMirrorsData,
|
|
||||||
GetScheduleMirrorsResponse,
|
|
||||||
GetScheduleMirrorsResponses,
|
|
||||||
GetScheduleNotificationsData,
|
|
||||||
GetScheduleNotificationsResponse,
|
|
||||||
GetScheduleNotificationsResponses,
|
|
||||||
GetSnapshotDetailsData,
|
|
||||||
GetSnapshotDetailsResponse,
|
|
||||||
GetSnapshotDetailsResponses,
|
|
||||||
GetSsoSettingsData,
|
|
||||||
GetSsoSettingsResponse,
|
|
||||||
GetSsoSettingsResponses,
|
|
||||||
GetStatusData,
|
|
||||||
GetStatusResponse,
|
|
||||||
GetStatusResponses,
|
|
||||||
GetSystemInfoData,
|
|
||||||
GetSystemInfoResponse,
|
|
||||||
GetSystemInfoResponses,
|
|
||||||
GetUpdatesData,
|
|
||||||
GetUpdatesResponse,
|
|
||||||
GetUpdatesResponses,
|
|
||||||
GetUserDeletionImpactData,
|
|
||||||
GetUserDeletionImpactResponse,
|
|
||||||
GetUserDeletionImpactResponses,
|
|
||||||
GetVolumeData,
|
|
||||||
GetVolumeErrors,
|
|
||||||
GetVolumeResponse,
|
|
||||||
GetVolumeResponses,
|
|
||||||
HealthCheckVolumeData,
|
|
||||||
HealthCheckVolumeErrors,
|
|
||||||
HealthCheckVolumeResponse,
|
|
||||||
HealthCheckVolumeResponses,
|
|
||||||
ListBackupSchedulesData,
|
|
||||||
ListBackupSchedulesResponse,
|
|
||||||
ListBackupSchedulesResponses,
|
|
||||||
ListFilesData,
|
|
||||||
ListFilesResponse,
|
|
||||||
ListFilesResponses,
|
|
||||||
ListNotificationDestinationsData,
|
|
||||||
ListNotificationDestinationsResponse,
|
|
||||||
ListNotificationDestinationsResponses,
|
|
||||||
ListRcloneRemotesData,
|
|
||||||
ListRcloneRemotesResponse,
|
|
||||||
ListRcloneRemotesResponses,
|
|
||||||
ListRepositoriesData,
|
|
||||||
ListRepositoriesResponse,
|
|
||||||
ListRepositoriesResponses,
|
|
||||||
ListSnapshotFilesData,
|
|
||||||
ListSnapshotFilesResponse,
|
|
||||||
ListSnapshotFilesResponses,
|
|
||||||
ListSnapshotsData,
|
|
||||||
ListSnapshotsResponse,
|
|
||||||
ListSnapshotsResponses,
|
|
||||||
ListVolumesData,
|
|
||||||
ListVolumesResponse,
|
|
||||||
ListVolumesResponses,
|
|
||||||
MountVolumeData,
|
|
||||||
MountVolumeResponse,
|
|
||||||
MountVolumeResponses,
|
|
||||||
RefreshRepositoryStatsData,
|
|
||||||
RefreshRepositoryStatsResponse,
|
|
||||||
RefreshRepositoryStatsResponses,
|
|
||||||
RefreshSnapshotsData,
|
|
||||||
RefreshSnapshotsResponse,
|
|
||||||
RefreshSnapshotsResponses,
|
|
||||||
RemoveOrgMemberData,
|
|
||||||
RemoveOrgMemberErrors,
|
|
||||||
RemoveOrgMemberResponses,
|
|
||||||
ReorderBackupSchedulesData,
|
|
||||||
ReorderBackupSchedulesResponse,
|
|
||||||
ReorderBackupSchedulesResponses,
|
|
||||||
RestoreSnapshotData,
|
|
||||||
RestoreSnapshotResponse,
|
|
||||||
RestoreSnapshotResponses,
|
|
||||||
RunBackupNowData,
|
|
||||||
RunBackupNowResponse,
|
|
||||||
RunBackupNowResponses,
|
|
||||||
RunForgetData,
|
|
||||||
RunForgetResponse,
|
|
||||||
RunForgetResponses,
|
|
||||||
SetRegistrationStatusData,
|
|
||||||
SetRegistrationStatusResponse,
|
|
||||||
SetRegistrationStatusResponses,
|
|
||||||
StartDoctorData,
|
|
||||||
StartDoctorErrors,
|
|
||||||
StartDoctorResponse,
|
|
||||||
StartDoctorResponses,
|
|
||||||
StopBackupData,
|
|
||||||
StopBackupErrors,
|
|
||||||
StopBackupResponse,
|
|
||||||
StopBackupResponses,
|
|
||||||
SyncMirrorData,
|
|
||||||
SyncMirrorErrors,
|
|
||||||
SyncMirrorResponse,
|
|
||||||
SyncMirrorResponses,
|
|
||||||
TagSnapshotsData,
|
|
||||||
TagSnapshotsResponse,
|
|
||||||
TagSnapshotsResponses,
|
|
||||||
TestConnectionData,
|
|
||||||
TestConnectionResponse,
|
|
||||||
TestConnectionResponses,
|
|
||||||
TestNotificationDestinationData,
|
|
||||||
TestNotificationDestinationErrors,
|
|
||||||
TestNotificationDestinationResponse,
|
|
||||||
TestNotificationDestinationResponses,
|
|
||||||
UnlockRepositoryData,
|
|
||||||
UnlockRepositoryResponse,
|
|
||||||
UnlockRepositoryResponses,
|
|
||||||
UnmountVolumeData,
|
|
||||||
UnmountVolumeResponse,
|
|
||||||
UnmountVolumeResponses,
|
|
||||||
UpdateBackupScheduleData,
|
|
||||||
UpdateBackupScheduleResponse,
|
|
||||||
UpdateBackupScheduleResponses,
|
|
||||||
UpdateMemberRoleData,
|
|
||||||
UpdateMemberRoleErrors,
|
|
||||||
UpdateMemberRoleResponses,
|
|
||||||
UpdateNotificationDestinationData,
|
|
||||||
UpdateNotificationDestinationErrors,
|
|
||||||
UpdateNotificationDestinationResponse,
|
|
||||||
UpdateNotificationDestinationResponses,
|
|
||||||
UpdateRepositoryData,
|
|
||||||
UpdateRepositoryErrors,
|
|
||||||
UpdateRepositoryResponse,
|
|
||||||
UpdateRepositoryResponses,
|
|
||||||
UpdateScheduleMirrorsData,
|
|
||||||
UpdateScheduleMirrorsResponse,
|
|
||||||
UpdateScheduleMirrorsResponses,
|
|
||||||
UpdateScheduleNotificationsData,
|
|
||||||
UpdateScheduleNotificationsResponse,
|
|
||||||
UpdateScheduleNotificationsResponses,
|
|
||||||
UpdateSsoProviderAutoLinkingData,
|
|
||||||
UpdateSsoProviderAutoLinkingErrors,
|
|
||||||
UpdateSsoProviderAutoLinkingResponses,
|
|
||||||
UpdateVolumeData,
|
|
||||||
UpdateVolumeErrors,
|
|
||||||
UpdateVolumeResponse,
|
|
||||||
UpdateVolumeResponses,
|
|
||||||
} from "./types.gen";
|
|
||||||
|
|
|
||||||
|
|
@ -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 { 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, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, 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
|
||||||
|
|
@ -19,80 +18,57 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
|
||||||
meta?: Record<string, unknown>;
|
meta?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new user
|
||||||
|
*/
|
||||||
|
export const register = <ThrowOnError extends boolean = false>(options?: Options<RegisterData, ThrowOnError>) => (options?.client ?? client).post<RegisterResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/v1/auth/register',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options?.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with username and password
|
||||||
|
*/
|
||||||
|
export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => (options?.client ?? client).post<LoginResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/v1/auth/login',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options?.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout current user
|
||||||
|
*/
|
||||||
|
export const logout = <ThrowOnError extends boolean = false>(options?: Options<LogoutData, ThrowOnError>) => (options?.client ?? client).post<LogoutResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/logout', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current authenticated user
|
||||||
|
*/
|
||||||
|
export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>) => (options?.client ?? client).get<GetMeResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/me', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get authentication system status
|
* Get authentication system status
|
||||||
*/
|
*/
|
||||||
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
|
* Change current user password
|
||||||
*/
|
*/
|
||||||
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 });
|
export const changePassword = <ThrowOnError extends boolean = false>(options?: Options<ChangePasswordData, ThrowOnError>) => (options?.client ?? client).post<ChangePasswordResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/v1/auth/change-password',
|
||||||
/**
|
|
||||||
* 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,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options.headers
|
...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
|
|
||||||
*/
|
|
||||||
export const getPublicSsoProviders = <ThrowOnError extends boolean = false>(options?: Options<GetPublicSsoProvidersData, ThrowOnError>) => (options?.client ?? client).get<GetPublicSsoProvidersResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-providers', ...options });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get SSO providers and invitations for the active organization
|
|
||||||
*/
|
|
||||||
export const getSsoSettings = <ThrowOnError extends boolean = false>(options?: Options<GetSsoSettingsData, ThrowOnError>) => (options?.client ?? client).get<GetSsoSettingsResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-settings', ...options });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an SSO provider
|
|
||||||
*/
|
|
||||||
export const deleteSsoProvider = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoProviderData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoProviderResponses, DeleteSsoProviderErrors, ThrowOnError>({ url: '/api/v1/auth/sso-providers/{providerId}', ...options });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update whether SSO sign-in can auto-link existing accounts by email
|
|
||||||
*/
|
|
||||||
export const updateSsoProviderAutoLinking = <ThrowOnError extends boolean = false>(options: Options<UpdateSsoProviderAutoLinkingData, ThrowOnError>) => (options.client ?? client).patch<UpdateSsoProviderAutoLinkingResponses, UpdateSsoProviderAutoLinkingErrors, ThrowOnError>({
|
|
||||||
url: '/api/v1/auth/sso-providers/{providerId}/auto-linking',
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an SSO invitation
|
|
||||||
*/
|
|
||||||
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 all volumes
|
* List all volumes
|
||||||
*/
|
*/
|
||||||
|
|
@ -101,42 +77,42 @@ 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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a volume
|
* Delete a volume
|
||||||
*/
|
*/
|
||||||
export const deleteVolume = <ThrowOnError extends boolean = false>(options: Options<DeleteVolumeData, ThrowOnError>) => (options.client ?? client).delete<DeleteVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{shortId}', ...options });
|
export const deleteVolume = <ThrowOnError extends boolean = false>(options: Options<DeleteVolumeData, ThrowOnError>) => (options.client ?? client).delete<DeleteVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{name}', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a volume by name
|
* Get a volume by name
|
||||||
*/
|
*/
|
||||||
export const getVolume = <ThrowOnError extends boolean = false>(options: Options<GetVolumeData, ThrowOnError>) => (options.client ?? client).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({ url: '/api/v1/volumes/{shortId}', ...options });
|
export const getVolume = <ThrowOnError extends boolean = false>(options: Options<GetVolumeData, ThrowOnError>) => (options.client ?? client).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({ url: '/api/v1/volumes/{name}', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a volume's configuration
|
* Update a volume's configuration
|
||||||
*/
|
*/
|
||||||
export const updateVolume = <ThrowOnError extends boolean = false>(options: Options<UpdateVolumeData, ThrowOnError>) => (options.client ?? client).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({
|
export const updateVolume = <ThrowOnError extends boolean = false>(options: Options<UpdateVolumeData, ThrowOnError>) => (options.client ?? client).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({
|
||||||
url: '/api/v1/volumes/{shortId}',
|
url: '/api/v1/volumes/{name}',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -147,22 +123,22 @@ export const updateVolume = <ThrowOnError extends boolean = false>(options: Opti
|
||||||
/**
|
/**
|
||||||
* Mount a volume
|
* Mount a volume
|
||||||
*/
|
*/
|
||||||
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => (options.client ?? client).post<MountVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{shortId}/mount', ...options });
|
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => (options.client ?? client).post<MountVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{name}/mount', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unmount a volume
|
* Unmount a volume
|
||||||
*/
|
*/
|
||||||
export const unmountVolume = <ThrowOnError extends boolean = false>(options: Options<UnmountVolumeData, ThrowOnError>) => (options.client ?? client).post<UnmountVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{shortId}/unmount', ...options });
|
export const unmountVolume = <ThrowOnError extends boolean = false>(options: Options<UnmountVolumeData, ThrowOnError>) => (options.client ?? client).post<UnmountVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{name}/unmount', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform a health check on a volume
|
* Perform a health check on a volume
|
||||||
*/
|
*/
|
||||||
export const healthCheckVolume = <ThrowOnError extends boolean = false>(options: Options<HealthCheckVolumeData, ThrowOnError>) => (options.client ?? client).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({ url: '/api/v1/volumes/{shortId}/health-check', ...options });
|
export const healthCheckVolume = <ThrowOnError extends boolean = false>(options: Options<HealthCheckVolumeData, ThrowOnError>) => (options.client ?? client).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({ url: '/api/v1/volumes/{name}/health-check', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List files in a volume directory
|
* List files in a volume directory
|
||||||
*/
|
*/
|
||||||
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => (options.client ?? client).get<ListFilesResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{shortId}/files', ...options });
|
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => (options.client ?? client).get<ListFilesResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{name}/files', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browse directories on the host filesystem
|
* Browse directories on the host filesystem
|
||||||
|
|
@ -177,12 +153,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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -194,40 +170,18 @@ export const listRcloneRemotes = <ThrowOnError extends boolean = false>(options?
|
||||||
/**
|
/**
|
||||||
* Delete a repository
|
* Delete a repository
|
||||||
*/
|
*/
|
||||||
export const deleteRepository = <ThrowOnError extends boolean = false>(options: Options<DeleteRepositoryData, ThrowOnError>) => (options.client ?? client).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}', ...options });
|
export const deleteRepository = <ThrowOnError extends boolean = false>(options: Options<DeleteRepositoryData, ThrowOnError>) => (options.client ?? client).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single repository by ID
|
* Get a single repository by name
|
||||||
*/
|
*/
|
||||||
export const getRepository = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryData, ThrowOnError>) => (options.client ?? client).get<GetRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}', ...options });
|
export const getRepository = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryData, ThrowOnError>) => (options.client ?? client).get<GetRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a repository's name or settings
|
* Update a repository's name or settings
|
||||||
*/
|
*/
|
||||||
export const updateRepository = <ThrowOnError extends boolean = false>(options: Options<UpdateRepositoryData, ThrowOnError>) => (options.client ?? client).patch<UpdateRepositoryResponses, UpdateRepositoryErrors, ThrowOnError>({
|
export const updateRepository = <ThrowOnError extends boolean = false>(options: Options<UpdateRepositoryData, ThrowOnError>) => (options.client ?? client).patch<UpdateRepositoryResponses, UpdateRepositoryErrors, ThrowOnError>({
|
||||||
url: '/api/v1/repositories/{shortId}',
|
url: '/api/v1/repositories/{name}',
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get repository storage and compression statistics
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
*/
|
|
||||||
export const deleteSnapshots = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotsData, ThrowOnError>) => (options.client ?? client).delete<DeleteSnapshotsResponses, unknown, ThrowOnError>({
|
|
||||||
url: '/api/v1/repositories/{shortId}/snapshots',
|
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -238,38 +192,28 @@ export const deleteSnapshots = <ThrowOnError extends boolean = false>(options: O
|
||||||
/**
|
/**
|
||||||
* List all snapshots in a repository
|
* List all snapshots in a repository
|
||||||
*/
|
*/
|
||||||
export const listSnapshots = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotsData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots', ...options });
|
export const listSnapshots = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotsData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots', ...options });
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear snapshot cache and force refresh from repository
|
|
||||||
*/
|
|
||||||
export const refreshSnapshots = <ThrowOnError extends boolean = false>(options: Options<RefreshSnapshotsData, ThrowOnError>) => (options.client ?? client).post<RefreshSnapshotsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots/refresh', ...options });
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a specific snapshot from a repository
|
* Delete a specific snapshot from a repository
|
||||||
*/
|
*/
|
||||||
export const deleteSnapshot = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotData, ThrowOnError>) => (options.client ?? client).delete<DeleteSnapshotResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}', ...options });
|
export const deleteSnapshot = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotData, ThrowOnError>) => (options.client ?? client).delete<DeleteSnapshotResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get details of a specific snapshot
|
* Get details of a specific snapshot
|
||||||
*/
|
*/
|
||||||
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(options: Options<GetSnapshotDetailsData, ThrowOnError>) => (options.client ?? client).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}', ...options });
|
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(options: Options<GetSnapshotDetailsData, ThrowOnError>) => (options.client ?? client).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List files and directories in a snapshot
|
* List files and directories in a snapshot
|
||||||
*/
|
*/
|
||||||
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotFilesData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}/files', ...options });
|
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotFilesData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}/files', ...options });
|
||||||
|
|
||||||
/**
|
|
||||||
* Download a snapshot path as a tar archive (folders) or raw file stream (single files)
|
|
||||||
*/
|
|
||||||
export const dumpSnapshot = <ThrowOnError extends boolean = false>(options: Options<DumpSnapshotData, ThrowOnError>) => (options.client ?? client).get<DumpSnapshotResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}/dump', ...options });
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore a snapshot to a target path on the filesystem
|
* Restore a snapshot to a target path on the filesystem
|
||||||
*/
|
*/
|
||||||
export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: Options<RestoreSnapshotData, ThrowOnError>) => (options.client ?? client).post<RestoreSnapshotResponses, unknown, ThrowOnError>({
|
export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: Options<RestoreSnapshotData, ThrowOnError>) => (options.client ?? client).post<RestoreSnapshotResponses, unknown, ThrowOnError>({
|
||||||
url: '/api/v1/repositories/{shortId}/restore',
|
url: '/api/v1/repositories/{name}/restore',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -278,43 +222,9 @@ export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: O
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel a running doctor operation on a repository
|
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
|
||||||
*/
|
*/
|
||||||
export const cancelDoctor = <ThrowOnError extends boolean = false>(options: Options<CancelDoctorData, ThrowOnError>) => (options.client ?? client).delete<CancelDoctorResponses, CancelDoctorErrors, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/doctor', ...options });
|
export const doctorRepository = <ThrowOnError extends boolean = false>(options: Options<DoctorRepositoryData, ThrowOnError>) => (options.client ?? client).post<DoctorRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/doctor', ...options });
|
||||||
|
|
||||||
/**
|
|
||||||
* Start an asynchronous doctor operation on a repository to fix common issues (unlock, check, repair index). The operation runs in the background and sends results via SSE events.
|
|
||||||
*/
|
|
||||||
export const startDoctor = <ThrowOnError extends boolean = false>(options: Options<StartDoctorData, ThrowOnError>) => (options.client ?? client).post<StartDoctorResponses, StartDoctorErrors, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/doctor', ...options });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unlock a repository by removing all stale locks
|
|
||||||
*/
|
|
||||||
export const unlockRepository = <ThrowOnError extends boolean = false>(options: Options<UnlockRepositoryData, ThrowOnError>) => (options.client ?? client).post<UnlockRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/unlock', ...options });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tag multiple snapshots in a repository
|
|
||||||
*/
|
|
||||||
export const tagSnapshots = <ThrowOnError extends boolean = false>(options: Options<TagSnapshotsData, ThrowOnError>) => (options.client ?? client).post<TagSnapshotsResponses, unknown, ThrowOnError>({
|
|
||||||
url: '/api/v1/repositories/{shortId}/snapshots/tag',
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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>({
|
|
||||||
url: '/api/v1/repositories/{shortId}/exec',
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all backup schedules
|
* List all backup schedules
|
||||||
|
|
@ -324,30 +234,30 @@ 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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a backup schedule
|
* Delete a backup schedule
|
||||||
*/
|
*/
|
||||||
export const deleteBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<DeleteBackupScheduleData, ThrowOnError>) => (options.client ?? client).delete<DeleteBackupScheduleResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}', ...options });
|
export const deleteBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<DeleteBackupScheduleData, ThrowOnError>) => (options.client ?? client).delete<DeleteBackupScheduleResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a backup schedule by ID
|
* Get a backup schedule by ID
|
||||||
*/
|
*/
|
||||||
export const getBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleData, ThrowOnError>) => (options.client ?? client).get<GetBackupScheduleResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}', ...options });
|
export const getBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleData, ThrowOnError>) => (options.client ?? client).get<GetBackupScheduleResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a backup schedule
|
* Update a backup schedule
|
||||||
*/
|
*/
|
||||||
export const updateBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<UpdateBackupScheduleData, ThrowOnError>) => (options.client ?? client).patch<UpdateBackupScheduleResponses, unknown, ThrowOnError>({
|
export const updateBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<UpdateBackupScheduleData, ThrowOnError>) => (options.client ?? client).patch<UpdateBackupScheduleResponses, unknown, ThrowOnError>({
|
||||||
url: '/api/v1/backups/{shortId}',
|
url: '/api/v1/backups/{scheduleId}',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -358,33 +268,33 @@ export const updateBackupSchedule = <ThrowOnError extends boolean = false>(optio
|
||||||
/**
|
/**
|
||||||
* Get a backup schedule for a specific volume
|
* Get a backup schedule for a specific volume
|
||||||
*/
|
*/
|
||||||
export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleForVolumeData, ThrowOnError>) => (options.client ?? client).get<GetBackupScheduleForVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/volume/{volumeShortId}', ...options });
|
export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleForVolumeData, ThrowOnError>) => (options.client ?? client).get<GetBackupScheduleForVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/volume/{volumeId}', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger a backup immediately for a schedule
|
* Trigger a backup immediately for a schedule
|
||||||
*/
|
*/
|
||||||
export const runBackupNow = <ThrowOnError extends boolean = false>(options: Options<RunBackupNowData, ThrowOnError>) => (options.client ?? client).post<RunBackupNowResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/run', ...options });
|
export const runBackupNow = <ThrowOnError extends boolean = false>(options: Options<RunBackupNowData, ThrowOnError>) => (options.client ?? client).post<RunBackupNowResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/run', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop a backup that is currently in progress
|
* Stop a backup that is currently in progress
|
||||||
*/
|
*/
|
||||||
export const stopBackup = <ThrowOnError extends boolean = false>(options: Options<StopBackupData, ThrowOnError>) => (options.client ?? client).post<StopBackupResponses, StopBackupErrors, ThrowOnError>({ url: '/api/v1/backups/{shortId}/stop', ...options });
|
export const stopBackup = <ThrowOnError extends boolean = false>(options: Options<StopBackupData, ThrowOnError>) => (options.client ?? client).post<StopBackupResponses, StopBackupErrors, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/stop', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manually apply retention policy to clean up old snapshots
|
* Manually apply retention policy to clean up old snapshots
|
||||||
*/
|
*/
|
||||||
export const runForget = <ThrowOnError extends boolean = false>(options: Options<RunForgetData, ThrowOnError>) => (options.client ?? client).post<RunForgetResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/forget', ...options });
|
export const runForget = <ThrowOnError extends boolean = false>(options: Options<RunForgetData, ThrowOnError>) => (options.client ?? client).post<RunForgetResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/forget', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get notification assignments for a backup schedule
|
* Get notification assignments for a backup schedule
|
||||||
*/
|
*/
|
||||||
export const getScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<GetScheduleNotificationsData, ThrowOnError>) => (options.client ?? client).get<GetScheduleNotificationsResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/notifications', ...options });
|
export const getScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<GetScheduleNotificationsData, ThrowOnError>) => (options.client ?? client).get<GetScheduleNotificationsResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/notifications', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update notification assignments for a backup schedule
|
* Update notification assignments for a backup schedule
|
||||||
*/
|
*/
|
||||||
export const updateScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleNotificationsData, ThrowOnError>) => (options.client ?? client).put<UpdateScheduleNotificationsResponses, unknown, ThrowOnError>({
|
export const updateScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleNotificationsData, ThrowOnError>) => (options.client ?? client).put<UpdateScheduleNotificationsResponses, unknown, ThrowOnError>({
|
||||||
url: '/api/v1/backups/{shortId}/notifications',
|
url: '/api/v1/backups/{scheduleId}/notifications',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -395,30 +305,13 @@ export const updateScheduleNotifications = <ThrowOnError extends boolean = false
|
||||||
/**
|
/**
|
||||||
* Get mirror repository assignments for a backup schedule
|
* Get mirror repository assignments for a backup schedule
|
||||||
*/
|
*/
|
||||||
export const getScheduleMirrors = <ThrowOnError extends boolean = false>(options: Options<GetScheduleMirrorsData, ThrowOnError>) => (options.client ?? client).get<GetScheduleMirrorsResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/mirrors', ...options });
|
export const getScheduleMirrors = <ThrowOnError extends boolean = false>(options: Options<GetScheduleMirrorsData, ThrowOnError>) => (options.client ?? client).get<GetScheduleMirrorsResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/mirrors', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update mirror repository assignments for a backup schedule
|
* Update mirror repository assignments for a backup schedule
|
||||||
*/
|
*/
|
||||||
export const updateScheduleMirrors = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleMirrorsData, ThrowOnError>) => (options.client ?? client).put<UpdateScheduleMirrorsResponses, unknown, ThrowOnError>({
|
export const updateScheduleMirrors = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleMirrorsData, ThrowOnError>) => (options.client ?? client).put<UpdateScheduleMirrorsResponses, unknown, ThrowOnError>({
|
||||||
url: '/api/v1/backups/{shortId}/mirrors',
|
url: '/api/v1/backups/{scheduleId}/mirrors',
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -429,24 +322,7 @@ export const syncMirror = <ThrowOnError extends boolean = false>(options: Option
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
*/
|
*/
|
||||||
export const getMirrorCompatibility = <ThrowOnError extends boolean = false>(options: Options<GetMirrorCompatibilityData, ThrowOnError>) => (options.client ?? client).get<GetMirrorCompatibilityResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/mirrors/compatibility', ...options });
|
export const getMirrorCompatibility = <ThrowOnError extends boolean = false>(options: Options<GetMirrorCompatibilityData, ThrowOnError>) => (options.client ?? client).get<GetMirrorCompatibilityResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/mirrors/compatibility', ...options });
|
||||||
|
|
||||||
/**
|
|
||||||
* 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>({
|
|
||||||
url: '/api/v1/backups/reorder',
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...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 +332,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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -498,40 +374,13 @@ export const testNotificationDestination = <ThrowOnError extends boolean = false
|
||||||
export const getSystemInfo = <ThrowOnError extends boolean = false>(options?: Options<GetSystemInfoData, ThrowOnError>) => (options?.client ?? client).get<GetSystemInfoResponses, unknown, ThrowOnError>({ url: '/api/v1/system/info', ...options });
|
export const getSystemInfo = <ThrowOnError extends boolean = false>(options?: Options<GetSystemInfoData, ThrowOnError>) => (options?.client ?? client).get<GetSystemInfoResponses, unknown, ThrowOnError>({ url: '/api/v1/system/info', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check for application updates from GitHub
|
* Download the Restic password file for backup recovery. Requires password re-authentication.
|
||||||
*/
|
*/
|
||||||
export const getUpdates = <ThrowOnError extends boolean = false>(options?: Options<GetUpdatesData, ThrowOnError>) => (options?.client ?? client).get<GetUpdatesResponses, unknown, ThrowOnError>({ url: '/api/v1/system/updates', ...options });
|
export const downloadResticPassword = <ThrowOnError extends boolean = false>(options?: Options<DownloadResticPasswordData, ThrowOnError>) => (options?.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current registration status for new users
|
|
||||||
*/
|
|
||||||
export const getRegistrationStatus = <ThrowOnError extends boolean = false>(options?: Options<GetRegistrationStatusData, ThrowOnError>) => (options?.client ?? client).get<GetRegistrationStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/system/registration-status', ...options });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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>({
|
|
||||||
url: '/api/v1/system/registration-status',
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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>({
|
|
||||||
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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the dev panel status
|
|
||||||
*/
|
|
||||||
export const getDevPanel = <ThrowOnError extends boolean = false>(options?: Options<GetDevPanelData, ThrowOnError>) => (options?.client ?? client).get<GetDevPanelResponses, unknown, ThrowOnError>({ url: '/api/v1/system/dev-panel', ...options });
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,276 +0,0 @@
|
||||||
/** biome-ignore-all lint/style/noNonNullAssertion: Testing file - non-null assertions are acceptable here */
|
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
|
||||||
import { cleanup, render, screen, fireEvent, within } from "@testing-library/react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { FileTree, type FileEntry } from "../file-tree";
|
|
||||||
|
|
||||||
const getCheckboxFor = (name: string) => {
|
|
||||||
const row = screen.getByRole("button", { name });
|
|
||||||
return within(row).getByRole("checkbox");
|
|
||||||
};
|
|
||||||
|
|
||||||
const FileTreeSelection = ({
|
|
||||||
files,
|
|
||||||
initialSelectedPaths = [],
|
|
||||||
expandedFolders,
|
|
||||||
}: {
|
|
||||||
files: FileEntry[];
|
|
||||||
initialSelectedPaths?: string[];
|
|
||||||
expandedFolders?: Set<string>;
|
|
||||||
}) => {
|
|
||||||
const [selectedPaths, setSelectedPaths] = useState(() => new Set(initialSelectedPaths));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FileTree
|
|
||||||
files={files}
|
|
||||||
withCheckboxes={true}
|
|
||||||
selectedPaths={selectedPaths}
|
|
||||||
onSelectionChange={setSelectedPaths}
|
|
||||||
expandedFolders={expandedFolders}
|
|
||||||
/>
|
|
||||||
<output aria-label="Selected paths">{JSON.stringify(Array.from(selectedPaths).sort())}</output>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSelectedPaths = () => {
|
|
||||||
const selectedPaths = screen.getByLabelText("Selected paths").textContent;
|
|
||||||
return JSON.parse(selectedPaths ?? "[]") as string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("FileTree Pagination", () => {
|
|
||||||
const testFiles: FileEntry[] = [
|
|
||||||
{ name: "root", path: "/root", type: "folder" },
|
|
||||||
{ name: "file1", path: "/root/file1", type: "file" },
|
|
||||||
{ name: "file2", path: "/root/file2", type: "file" },
|
|
||||||
];
|
|
||||||
|
|
||||||
test("shows load more button when hasMore is true", () => {
|
|
||||||
render(
|
|
||||||
<FileTree
|
|
||||||
files={testFiles}
|
|
||||||
expandedFolders={new Set(["/root"])}
|
|
||||||
getFolderPagination={(path) => {
|
|
||||||
if (path === "/root") {
|
|
||||||
return { hasMore: true, isLoadingMore: false };
|
|
||||||
}
|
|
||||||
return { hasMore: false, isLoadingMore: false };
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("Load more files")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not show load more button when hasMore is false", () => {
|
|
||||||
render(
|
|
||||||
<FileTree
|
|
||||||
files={testFiles}
|
|
||||||
expandedFolders={new Set(["/root"])}
|
|
||||||
getFolderPagination={() => ({ hasMore: false, isLoadingMore: false })}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByText("Load more files")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("calls onLoadMore with folder path when load more button is clicked", () => {
|
|
||||||
let loadMoreCalled = false;
|
|
||||||
let loadMorePath = "";
|
|
||||||
|
|
||||||
render(
|
|
||||||
<FileTree
|
|
||||||
files={testFiles}
|
|
||||||
expandedFolders={new Set(["/root"])}
|
|
||||||
getFolderPagination={(path) => {
|
|
||||||
if (path === "/root") {
|
|
||||||
return { hasMore: true, isLoadingMore: false };
|
|
||||||
}
|
|
||||||
return { hasMore: false, isLoadingMore: false };
|
|
||||||
}}
|
|
||||||
onLoadMore={(path) => {
|
|
||||||
loadMoreCalled = true;
|
|
||||||
loadMorePath = path;
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadMoreButton = screen.getByText("Load more files");
|
|
||||||
fireEvent.click(loadMoreButton);
|
|
||||||
|
|
||||||
expect(loadMoreCalled).toBe(true);
|
|
||||||
expect(loadMorePath).toBe("/root");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows loading state when isLoadingMore is true", () => {
|
|
||||||
render(
|
|
||||||
<FileTree
|
|
||||||
files={testFiles}
|
|
||||||
expandedFolders={new Set(["/root"])}
|
|
||||||
getFolderPagination={(path) => {
|
|
||||||
if (path === "/root") {
|
|
||||||
return { hasMore: true, isLoadingMore: true };
|
|
||||||
}
|
|
||||||
return { hasMore: false, isLoadingMore: false };
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("Loading more...")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows load more button for root-level files when root has more", () => {
|
|
||||||
const rootFiles: FileEntry[] = [
|
|
||||||
{ name: "file1", path: "/file1", type: "file" },
|
|
||||||
{ name: "file2", path: "/file2", type: "file" },
|
|
||||||
];
|
|
||||||
|
|
||||||
render(
|
|
||||||
<FileTree
|
|
||||||
files={rootFiles}
|
|
||||||
getFolderPagination={(path) => {
|
|
||||||
if (path === "/") {
|
|
||||||
return { hasMore: true, isLoadingMore: false };
|
|
||||||
}
|
|
||||||
return { hasMore: false, isLoadingMore: false };
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("Load more files")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("load more button appears for nested folders with hasMore", () => {
|
|
||||||
const nestedFiles: FileEntry[] = [
|
|
||||||
{ name: "root", path: "/root", type: "folder" },
|
|
||||||
{ name: "child", path: "/root/child", type: "folder" },
|
|
||||||
{ name: "file1", path: "/root/child/file1", type: "file" },
|
|
||||||
];
|
|
||||||
|
|
||||||
render(
|
|
||||||
<FileTree
|
|
||||||
files={nestedFiles}
|
|
||||||
expandedFolders={new Set(["/root", "/root/child"])}
|
|
||||||
getFolderPagination={(path) => {
|
|
||||||
if (path === "/root/child") {
|
|
||||||
return { hasMore: true, isLoadingMore: false };
|
|
||||||
}
|
|
||||||
return { hasMore: false, isLoadingMore: false };
|
|
||||||
}}
|
|
||||||
onLoadMore={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("Load more files")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("load more button does not appear when folder is collapsed", () => {
|
|
||||||
render(
|
|
||||||
<FileTree
|
|
||||||
files={testFiles}
|
|
||||||
expandedFolders={new Set([])}
|
|
||||||
getFolderPagination={(path) => {
|
|
||||||
if (path === "/root") {
|
|
||||||
return { hasMore: true, isLoadingMore: false };
|
|
||||||
}
|
|
||||||
return { hasMore: false, isLoadingMore: false };
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByText("Load more files")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders missing ancestor folders for nested paths", () => {
|
|
||||||
render(
|
|
||||||
<FileTree
|
|
||||||
files={[
|
|
||||||
{ name: "subdir", path: "/project/subdir", type: "folder" },
|
|
||||||
{ name: "file1", path: "/project/subdir/file1", type: "file" },
|
|
||||||
]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: "project" })).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("FileTree Selection Logic", () => {
|
|
||||||
const testFiles: FileEntry[] = [
|
|
||||||
{ name: "root", path: "/root", type: "folder" },
|
|
||||||
{ name: "photos", path: "/root/photos", type: "folder" },
|
|
||||||
{ name: "backups", path: "/root/photos/backups", type: "folder" },
|
|
||||||
{ name: "library", path: "/root/photos/library", type: "folder" },
|
|
||||||
{ name: "profile", path: "/root/photos/profile", type: "folder" },
|
|
||||||
{ name: "upload", path: "/root/photos/upload", type: "folder" },
|
|
||||||
];
|
|
||||||
|
|
||||||
test("selecting a folder simplifies to parent if it's the only child", () => {
|
|
||||||
render(<FileTreeSelection files={testFiles} expandedFolders={new Set(testFiles.map((f) => f.path))} />);
|
|
||||||
|
|
||||||
fireEvent.click(getCheckboxFor("photos"));
|
|
||||||
|
|
||||||
expect(getSelectedPaths()).toEqual(["/root"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unselecting a child removes the parent from selection", () => {
|
|
||||||
render(
|
|
||||||
<FileTreeSelection
|
|
||||||
files={testFiles}
|
|
||||||
initialSelectedPaths={["/root"]}
|
|
||||||
expandedFolders={new Set(testFiles.map((f) => f.path))}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(getCheckboxFor("library"));
|
|
||||||
|
|
||||||
expect(getSelectedPaths()).toEqual(["/root/photos/backups", "/root/photos/profile", "/root/photos/upload"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("recursive simplification when all children are selected", () => {
|
|
||||||
render(<FileTreeSelection files={testFiles} expandedFolders={new Set(testFiles.map((f) => f.path))} />);
|
|
||||||
|
|
||||||
const children = ["backups", "library", "profile", "upload"];
|
|
||||||
|
|
||||||
for (const name of children) {
|
|
||||||
fireEvent.click(getCheckboxFor(name));
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(getSelectedPaths()).toEqual(["/root"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not simplify to parent if not all children are selected", () => {
|
|
||||||
const multipleFiles: FileEntry[] = [
|
|
||||||
{ name: "root", path: "/root", type: "folder" },
|
|
||||||
{ name: "child1", path: "/root/child1", type: "folder" },
|
|
||||||
{ name: "child2", path: "/root/child2", type: "folder" },
|
|
||||||
];
|
|
||||||
|
|
||||||
render(<FileTreeSelection files={multipleFiles} expandedFolders={new Set(multipleFiles.map((f) => f.path))} />);
|
|
||||||
|
|
||||||
fireEvent.click(getCheckboxFor("child1"));
|
|
||||||
|
|
||||||
expect(getSelectedPaths()).toEqual(["/root/child1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("simplifies existing deep paths when parent is selected", () => {
|
|
||||||
const files: FileEntry[] = [
|
|
||||||
{ name: "hello", path: "/hello", type: "folder" },
|
|
||||||
{ name: "hello_prev", path: "/hello_prev", type: "folder" },
|
|
||||||
{ name: "service", path: "/service", type: "folder" },
|
|
||||||
];
|
|
||||||
|
|
||||||
render(
|
|
||||||
<FileTreeSelection files={files} initialSelectedPaths={["/hello", "/hello_prev", "/service/app/data/upload"]} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(getCheckboxFor("service"));
|
|
||||||
|
|
||||||
expect(getSelectedPaths()).toEqual(["/hello", "/hello_prev", "/service"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { HttpResponse, http, server } from "~/test/msw/server";
|
|
||||||
import { cleanup, render, screen, userEvent, waitFor, within } from "~/test/test-utils";
|
|
||||||
import { fromAny } from "@total-typescript/shoehorn";
|
|
||||||
|
|
||||||
vi.mock("@tanstack/react-router", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
useNavigate: (() => vi.fn(async () => {})) as typeof actual.useNavigate,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
import { RestoreForm } from "../restore-form";
|
|
||||||
|
|
||||||
class MockEventSource {
|
|
||||||
addEventListener() {}
|
|
||||||
close() {}
|
|
||||||
onerror: ((event: Event) => void) | null = null;
|
|
||||||
|
|
||||||
constructor(public url: string) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalEventSource = globalThis.EventSource;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.EventSource = originalEventSource;
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("RestoreForm", () => {
|
|
||||||
test("restores the selected ancestor folder path from a broader display root", async () => {
|
|
||||||
let restoreRequestBody: unknown;
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
http.get("/api/v1/repositories/:shortId/snapshots/:snapshotId/files", () => {
|
|
||||||
return HttpResponse.json({
|
|
||||||
files: [
|
|
||||||
{ name: "subdir", path: "/mnt/project/subdir", type: "dir" },
|
|
||||||
{ name: "deep.tx", path: "/mnt/project/subdir/deep.tx", type: "file" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
http.post("/api/v1/repositories/:shortId/restore", async ({ request }) => {
|
|
||||||
restoreRequestBody = await request.json();
|
|
||||||
return HttpResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: "Snapshot restored successfully",
|
|
||||||
filesRestored: 1,
|
|
||||||
filesSkipped: 0,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<RestoreForm
|
|
||||||
repository={fromAny({ shortId: "repo-1", name: "Repo 1" })}
|
|
||||||
snapshotId="snap-1"
|
|
||||||
returnPath="/repositories/repo-1/snap-1"
|
|
||||||
queryBasePath="/mnt/project/subdir"
|
|
||||||
displayBasePath="/mnt"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const row = await screen.findByRole("button", { name: "project" });
|
|
||||||
await userEvent.click(within(row).getByRole("checkbox"));
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Restore 1 item" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(restoreRequestBody).toEqual({
|
|
||||||
snapshotId: "snap-1",
|
|
||||||
include: ["/mnt/project"],
|
|
||||||
selectedItemKind: "dir",
|
|
||||||
overwrite: "always",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("restores the selected full path when the display root is unrelated", async () => {
|
|
||||||
let restoreRequestBody: unknown;
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
http.get("/api/v1/repositories/:shortId/snapshots/:snapshotId/files", () => {
|
|
||||||
return HttpResponse.json({
|
|
||||||
files: [
|
|
||||||
{ name: "project", path: "/mnt/project", type: "dir" },
|
|
||||||
{ name: "a.txt", path: "/mnt/project/a.txt", type: "file" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
http.post("/api/v1/repositories/:shortId/restore", async ({ request }) => {
|
|
||||||
restoreRequestBody = await request.json();
|
|
||||||
return HttpResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: "Snapshot restored successfully",
|
|
||||||
filesRestored: 1,
|
|
||||||
filesSkipped: 0,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
http.get("/api/v1/volumes/filesystem/browse", () => {
|
|
||||||
return HttpResponse.json({
|
|
||||||
path: "/",
|
|
||||||
directories: [{ name: "restore-target", path: "/restore-target", type: "dir" }],
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<RestoreForm
|
|
||||||
repository={fromAny({ shortId: "repo-1", name: "Repo 1" })}
|
|
||||||
snapshotId="snap-1"
|
|
||||||
returnPath="/repositories/repo-1/snap-1"
|
|
||||||
queryBasePath="/mnt/project"
|
|
||||||
displayBasePath="/other/root"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.getByText(
|
|
||||||
"This snapshot was created from source paths that do not match this Zerobyte server or the current linked volume. Restoring to the original location is unavailable. Restore it to a custom location, or download it instead.",
|
|
||||||
),
|
|
||||||
).toBeTruthy();
|
|
||||||
expect(screen.getByRole("button", { name: "Original location" }).hasAttribute("disabled")).toBe(true);
|
|
||||||
expect(screen.getByRole("button", { name: "Restore All" }).hasAttribute("disabled")).toBe(true);
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Change" }));
|
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "restore-target" }));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole("button", { name: "Restore All" }).hasAttribute("disabled")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
const row = await screen.findByRole("button", { name: "mnt" });
|
|
||||||
await userEvent.click(within(row).getByRole("checkbox"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole("button", { name: "Restore 1 item" }).hasAttribute("disabled")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Restore 1 item" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(restoreRequestBody).toEqual({
|
|
||||||
snapshotId: "snap-1",
|
|
||||||
include: ["/mnt"],
|
|
||||||
selectedItemKind: "dir",
|
|
||||||
targetPath: "/restore-target",
|
|
||||||
overwrite: "always",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { Fragment } from "react";
|
import { Link, useMatches, type UIMatch } from "react-router";
|
||||||
import { useMatches, Link } from "@tanstack/react-router";
|
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
|
|
@ -14,35 +13,38 @@ export interface BreadcrumbItemData {
|
||||||
href?: string;
|
href?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type BreadcrumbFunction = (match: ReturnType<typeof useMatches>[number]) => BreadcrumbItemData[] | null;
|
interface RouteHandle {
|
||||||
|
breadcrumb?: (match: UIMatch) => BreadcrumbItemData[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function AppBreadcrumb() {
|
export function AppBreadcrumb() {
|
||||||
const matches = useMatches();
|
const matches = useMatches();
|
||||||
|
|
||||||
|
// Find the last match with a breadcrumb handler
|
||||||
const lastMatchWithBreadcrumb = [...matches].reverse().find((match) => {
|
const lastMatchWithBreadcrumb = [...matches].reverse().find((match) => {
|
||||||
const breadcrumbFn = match.staticData?.breadcrumb as BreadcrumbFunction | undefined;
|
const handle = match.handle as RouteHandle | undefined;
|
||||||
return breadcrumbFn;
|
return handle?.breadcrumb;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!lastMatchWithBreadcrumb) {
|
if (!lastMatchWithBreadcrumb) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const breadcrumbFn = lastMatchWithBreadcrumb.staticData?.breadcrumb as BreadcrumbFunction;
|
const handle = lastMatchWithBreadcrumb.handle as RouteHandle;
|
||||||
const breadcrumbs = breadcrumbFn?.(lastMatchWithBreadcrumb);
|
const breadcrumbs = handle.breadcrumb?.(lastMatchWithBreadcrumb);
|
||||||
|
|
||||||
if (!breadcrumbs || breadcrumbs.length === 0) {
|
if (!breadcrumbs || breadcrumbs.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Breadcrumb className="min-w-0">
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
{breadcrumbs.map((breadcrumb, index) => {
|
{breadcrumbs.map((breadcrumb, index) => {
|
||||||
const isLast = index === breadcrumbs.length - 1;
|
const isLast = index === breadcrumbs.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={`${breadcrumb.label}-${index}`}>
|
<div key={`${breadcrumb.label}-${index}`} className="contents">
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
{isLast || !breadcrumb.href ? (
|
{isLast || !breadcrumb.href ? (
|
||||||
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
|
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
|
||||||
|
|
@ -53,7 +55,7 @@ export function AppBreadcrumb() {
|
||||||
)}
|
)}
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
{!isLast && <BreadcrumbSeparator />}
|
{!isLast && <BreadcrumbSeparator />}
|
||||||
</Fragment>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Bell, CalendarClock, Database, HardDrive, Settings, ShieldCheck } from "lucide-react";
|
import { Bell, CalendarClock, Database, HardDrive, Settings } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { Link, NavLink } from "react-router";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
|
|
@ -10,17 +10,11 @@ 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";
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "~/client/components/ui/hover-card";
|
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { APP_VERSION, RCLONE_VERSION, RESTIC_VERSION, SHOUTRRR_VERSION } from "~/client/lib/version";
|
import { APP_VERSION } from "~/client/lib/version";
|
||||||
import { useUpdates } from "~/client/hooks/use-updates";
|
|
||||||
import { ReleaseNotesDialog } from "./release-notes-dialog";
|
|
||||||
import { OrganizationSwitcher } from "./organization-switcher";
|
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
|
|
@ -50,31 +44,17 @@ const items = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
type Props = {
|
export function AppSidebar() {
|
||||||
isInstanceAdmin: boolean;
|
const { state } = useSidebar();
|
||||||
};
|
|
||||||
|
|
||||||
export function AppSidebar({ isInstanceAdmin }: Props) {
|
|
||||||
const { state, isMobile, setOpenMobile } = useSidebar();
|
|
||||||
const { updates, hasUpdate } = useUpdates();
|
|
||||||
const [showReleaseNotes, setShowReleaseNotes] = useState(false);
|
|
||||||
|
|
||||||
const isCollapsed = state === "collapsed";
|
|
||||||
|
|
||||||
const displayVersion = APP_VERSION.startsWith("v") || APP_VERSION === "dev" ? APP_VERSION : `v${APP_VERSION}`;
|
|
||||||
const releaseUrl =
|
|
||||||
APP_VERSION === "dev"
|
|
||||||
? "https://github.com/nicotsx/zerobyte"
|
|
||||||
: `https://github.com/nicotsx/zerobyte/releases/tag/${displayVersion}`;
|
|
||||||
|
|
||||||
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-[65px] 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 +70,18 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<SidebarMenuButton asChild className="relative overflow-hidden">
|
<SidebarMenuButton asChild>
|
||||||
<Link
|
<NavLink to={item.url}>
|
||||||
to={item.url}
|
|
||||||
onClick={() => isMobile && setOpenMobile(false)}
|
|
||||||
activeProps={{ className: "bg-strong-accent/10" }}
|
|
||||||
className="w-full flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<>
|
<>
|
||||||
{isActive && (
|
<item.icon className={cn({ "text-strong-accent": isActive })} />
|
||||||
<div
|
<span className={cn({ "text-strong-accent": isActive })}>{item.title}</span>
|
||||||
className={cn("absolute left-0 top-0 h-full w-0.75 bg-strong-accent mr-2", {
|
|
||||||
hidden: isCollapsed,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<item.icon
|
|
||||||
className={cn("transition-all duration-200", {
|
|
||||||
"text-strong-accent": isActive,
|
|
||||||
"ml-1": isActive && !isCollapsed,
|
|
||||||
"text-muted-foreground": !isActive,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={cn({
|
|
||||||
"text-foreground font-medium": isActive,
|
|
||||||
"text-muted-foreground": !isActive,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</NavLink>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" className={cn({ hidden: !isCollapsed })}>
|
<TooltipContent side="right" className={cn({ hidden: state !== "collapsed" })}>
|
||||||
<p>{item.title}</p>
|
<p>{item.title}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
@ -136,104 +91,15 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
{isInstanceAdmin && (
|
|
||||||
<>
|
|
||||||
<SidebarSeparator />
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<SidebarMenuButton asChild className="relative overflow-hidden">
|
|
||||||
<Link
|
|
||||||
to="/admin"
|
|
||||||
onClick={() => isMobile && setOpenMobile(false)}
|
|
||||||
activeProps={{ className: "bg-strong-accent/10" }}
|
|
||||||
className="w-full flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<>
|
|
||||||
{isActive && (
|
|
||||||
<div
|
|
||||||
className={cn("absolute left-0 top-0 h-full w-0.75 bg-strong-accent mr-2", {
|
|
||||||
hidden: isCollapsed,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ShieldCheck
|
|
||||||
className={cn("transition-all duration-200", {
|
|
||||||
"text-strong-accent": isActive,
|
|
||||||
"ml-1": isActive && !isCollapsed,
|
|
||||||
"text-muted-foreground": !isActive,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={cn({
|
|
||||||
"text-foreground font-medium": isActive,
|
|
||||||
"text-muted-foreground": !isActive,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
Administration
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" className={cn({ hidden: !isCollapsed })}>
|
|
||||||
<p>Administration</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter className="p-4 border-r border-border/80 dark:border-border/50">
|
<SidebarFooter className="p-4 border-r border-t border-border/50">
|
||||||
<OrganizationSwitcher />
|
<div
|
||||||
<div className="flex items-center justify-between gap-2">
|
className={cn("text-xs text-muted-foreground transition-all duration-200", {
|
||||||
<HoverCard openDelay={200}>
|
"opacity-0 w-0 overflow-hidden": state === "collapsed",
|
||||||
<HoverCardTrigger asChild>
|
})}
|
||||||
<a
|
>
|
||||||
href={releaseUrl}
|
{APP_VERSION}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={cn("text-xs text-muted-foreground hover:text-foreground", {
|
|
||||||
"opacity-0 w-0 overflow-hidden": state === "collapsed",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{displayVersion}
|
|
||||||
</a>
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent side="top" align="start" className="w-fit p-3">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[11px]">
|
|
||||||
<span className="text-muted-foreground">Restic:</span>
|
|
||||||
<span className="font-mono">{RESTIC_VERSION}</span>
|
|
||||||
<span className="text-muted-foreground">Rclone:</span>
|
|
||||||
<span className="font-mono">{RCLONE_VERSION}</span>
|
|
||||||
<span className="text-muted-foreground">Shoutrrr:</span>
|
|
||||||
<span className="font-mono">{SHOUTRRR_VERSION}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
{hasUpdate && state !== "collapsed" && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowReleaseNotes(true)}
|
|
||||||
className="text-[10px] font-medium text-destructive hover:underline cursor-pointer"
|
|
||||||
>
|
|
||||||
Update available
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<ReleaseNotesDialog open={showReleaseNotes} onOpenChange={setShowReleaseNotes} updates={updates} />
|
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import { Card, CardContent } from "~/client/components/ui/card";
|
|
||||||
import { ByteSize } from "~/client/components/bytes-size";
|
|
||||||
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
|
|
||||||
import type { ResticSnapshotSummaryDto } from "@zerobyte/core/restic";
|
|
||||||
import { formatDuration } from "~/utils/utils";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
summary?: ResticSnapshotSummaryDto | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDurationLabel = (start: string, end: string) => {
|
|
||||||
const startMs = new Date(start).getTime();
|
|
||||||
const endMs = new Date(end).getTime();
|
|
||||||
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) return "-";
|
|
||||||
return formatDuration(Math.round((endMs - startMs) / 1000));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BackupSummaryCard = ({ summary }: Props) => {
|
|
||||||
const { locale } = useRootLoaderData();
|
|
||||||
|
|
||||||
if (!summary) return null;
|
|
||||||
|
|
||||||
const formatCount = (value: number) => value.toLocaleString(locale);
|
|
||||||
const durationLabel = getDurationLabel(summary.backup_start, summary.backup_end);
|
|
||||||
|
|
||||||
const topStats = [
|
|
||||||
{
|
|
||||||
label: "Data added",
|
|
||||||
value: <ByteSize bytes={summary.data_added} base={1024} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Data stored",
|
|
||||||
value: <ByteSize bytes={summary.data_added_packed ?? 0} base={1024} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Files processed",
|
|
||||||
value: formatCount(summary.total_files_processed),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Bytes processed",
|
|
||||||
value: <ByteSize bytes={summary.total_bytes_processed} base={1024} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Duration",
|
|
||||||
value: durationLabel,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const detailStats = [
|
|
||||||
{ label: "New files", value: formatCount(summary.files_new) },
|
|
||||||
{ label: "Changed files", value: formatCount(summary.files_changed) },
|
|
||||||
{ label: "Unmodified files", value: formatCount(summary.files_unmodified) },
|
|
||||||
{ label: "New dirs", value: formatCount(summary.dirs_new) },
|
|
||||||
{ label: "Changed dirs", value: formatCount(summary.dirs_changed) },
|
|
||||||
{ label: "Unmodified dirs", value: formatCount(summary.dirs_unmodified) },
|
|
||||||
{ label: "Data blobs", value: formatCount(summary.data_blobs) },
|
|
||||||
{ label: "Tree blobs", value: formatCount(summary.tree_blobs) },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="p-4">
|
|
||||||
<CardContent className="px-4">
|
|
||||||
<div className="grid gap-6 grid-cols-2 lg:grid-cols-5">
|
|
||||||
{topStats.map((stat) => (
|
|
||||||
<div key={stat.label} className="flex flex-col gap-1">
|
|
||||||
<span className="text-[11px] uppercase tracking-wide text-muted-foreground">{stat.label}</span>
|
|
||||||
<span className="text-sm font-semibold text-foreground">{stat.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 border-t border-border/60 pt-3">
|
|
||||||
<div className="grid gap-x-6 gap-y-2 grid-cols-2 lg:grid-cols-4">
|
|
||||||
{detailStats.map((stat) => (
|
|
||||||
<div key={stat.label} className="flex items-center justify-start text-xs gap-2">
|
|
||||||
<span className="font-semibold text-foreground">{stat.value}</span>
|
|
||||||
<span className="text-muted-foreground">{stat.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useFormatBytes } from "~/client/hooks/use-format-bytes";
|
|
||||||
|
|
||||||
type ByteSizeProps = {
|
type ByteSizeProps = {
|
||||||
bytes: number;
|
bytes: number;
|
||||||
|
|
@ -13,8 +12,72 @@ type ByteSizeProps = {
|
||||||
fallback?: string; // shown if bytes is not a finite number (default: '—')
|
fallback?: string; // shown if bytes is not a finite number (default: '—')
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SI_UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] as const;
|
||||||
|
const IEC_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"] as const;
|
||||||
|
|
||||||
|
type FormatBytesResult = {
|
||||||
|
text: string;
|
||||||
|
unit: string;
|
||||||
|
unitIndex: number;
|
||||||
|
numeric: number; // numeric value before formatting (with sign)
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatBytes(
|
||||||
|
bytes: number,
|
||||||
|
options?: {
|
||||||
|
base?: 1000 | 1024;
|
||||||
|
maximumFractionDigits?: number;
|
||||||
|
smartRounding?: boolean;
|
||||||
|
locale?: string | string[];
|
||||||
|
},
|
||||||
|
): FormatBytesResult {
|
||||||
|
const { base = 1000, maximumFractionDigits = 2, smartRounding = true, locale } = options ?? {};
|
||||||
|
|
||||||
|
if (!Number.isFinite(bytes)) {
|
||||||
|
return {
|
||||||
|
text: "—",
|
||||||
|
unit: "",
|
||||||
|
unitIndex: 0,
|
||||||
|
numeric: NaN,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = base === 1024 ? IEC_UNITS : SI_UNITS;
|
||||||
|
|
||||||
|
const sign = Math.sign(bytes) || 1;
|
||||||
|
const abs = Math.abs(bytes);
|
||||||
|
|
||||||
|
let idx = 0;
|
||||||
|
if (abs > 0) {
|
||||||
|
idx = Math.floor(Math.log(abs) / Math.log(base));
|
||||||
|
if (!Number.isFinite(idx)) idx = 0;
|
||||||
|
idx = Math.max(0, Math.min(idx, units.length - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = (abs / base ** idx) * sign;
|
||||||
|
|
||||||
|
const maxFrac = (() => {
|
||||||
|
if (!smartRounding) return maximumFractionDigits;
|
||||||
|
const v = Math.abs(numeric);
|
||||||
|
if (v >= 100) return 0;
|
||||||
|
if (v >= 10) return Math.min(1, maximumFractionDigits);
|
||||||
|
return maximumFractionDigits;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const text = new Intl.NumberFormat(locale, {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: maxFrac,
|
||||||
|
}).format(numeric);
|
||||||
|
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
unit: units[idx],
|
||||||
|
unitIndex: idx,
|
||||||
|
numeric,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function ByteSize(props: ByteSizeProps) {
|
export function ByteSize(props: ByteSizeProps) {
|
||||||
const formatBytes = useFormatBytes();
|
|
||||||
const {
|
const {
|
||||||
bytes,
|
bytes,
|
||||||
base = 1000,
|
base = 1000,
|
||||||
|
|
@ -32,10 +95,9 @@ export function ByteSize(props: ByteSizeProps) {
|
||||||
maximumFractionDigits,
|
maximumFractionDigits,
|
||||||
smartRounding,
|
smartRounding,
|
||||||
locale,
|
locale,
|
||||||
fallback,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (text === fallback) {
|
if (text === "—") {
|
||||||
return (
|
return (
|
||||||
<span className={className} style={style}>
|
<span className={className} style={style}>
|
||||||
{fallback}
|
{fallback}
|
||||||
|
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
import { CronExpressionParser } from "cron-parser";
|
|
||||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
|
||||||
import { Input } from "~/client/components/ui/input";
|
|
||||||
import { useTimeFormat } from "~/client/lib/datetime";
|
|
||||||
import { cn } from "~/client/lib/utils";
|
|
||||||
|
|
||||||
interface CronInputProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CronInput({ value, onChange, error }: CronInputProps) {
|
|
||||||
const { formatDateTime } = useTimeFormat();
|
|
||||||
const { isValid, nextRuns, parseError } = useMemo(() => {
|
|
||||||
if (!value) {
|
|
||||||
return { isValid: false, nextRuns: [], parseError: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = value.trim().split(/\s+/);
|
|
||||||
|
|
||||||
if (parts.length !== 5) {
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
nextRuns: [],
|
|
||||||
parseError: "Expression must have exactly 5 fields (minute, hour, day, month, day-of-week)",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const interval = CronExpressionParser.parse(value);
|
|
||||||
const runs: Date[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
runs.push(interval.next().toDate());
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isValid: true, nextRuns: runs, parseError: null };
|
|
||||||
} catch (e) {
|
|
||||||
return { isValid: false, nextRuns: [], parseError: (e as Error).message };
|
|
||||||
}
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormItem className="md:col-span-2">
|
|
||||||
<FormLabel>Cron expression</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
placeholder="* * * * *"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className={cn("font-mono", {
|
|
||||||
"border-destructive": error || (value && !isValid),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
|
||||||
{value && (
|
|
||||||
<div>
|
|
||||||
{isValid ? (
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
|
||||||
) : (
|
|
||||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Standard cron format: <code className="bg-muted px-1 rounded">minute hour day month day-of-week</code>.
|
|
||||||
</FormDescription>
|
|
||||||
{value && !isValid && parseError && <p className="text-xs text-destructive mt-1">{parseError}</p>}
|
|
||||||
{isValid && nextRuns.length > 0 && (
|
|
||||||
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
|
||||||
<p className="text-xs font-medium mb-2 text-muted-foreground uppercase tracking-wider">Next 5 executions:</p>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{nextRuns.map((date, i) => (
|
|
||||||
<li key={date.toISOString()} className="text-xs font-mono flex items-center gap-2">
|
|
||||||
<span className="text-muted-foreground w-4">{i + 1}.</span>
|
|
||||||
{formatDateTime(date)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
import type { Column } from "@tanstack/react-table";
|
|
||||||
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
|
|
||||||
import { Button } from "~/client/components/ui/button";
|
|
||||||
import { cn } from "~/client/lib/utils";
|
|
||||||
|
|
||||||
export function DataTableSortHeader<TData, TValue>({
|
|
||||||
column,
|
|
||||||
title,
|
|
||||||
sortDirection,
|
|
||||||
center = false,
|
|
||||||
}: {
|
|
||||||
column: Column<TData, TValue>;
|
|
||||||
title: string;
|
|
||||||
sortDirection: false | "asc" | "desc";
|
|
||||||
center?: boolean;
|
|
||||||
}) {
|
|
||||||
const icon =
|
|
||||||
sortDirection === "desc" ? (
|
|
||||||
<ArrowDown className="ml-2 h-3.5 w-3.5" />
|
|
||||||
) : sortDirection === "asc" ? (
|
|
||||||
<ArrowUp className="ml-2 h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<ArrowUpDown className="ml-2 h-3.5 w-3.5" />
|
|
||||||
);
|
|
||||||
const iconVisibility = sortDirection ? "" : "lg:invisible lg:group-hover/sort:visible";
|
|
||||||
|
|
||||||
if (center) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
className="h-auto! w-full! p-0! font-inherit hover:bg-transparent uppercase group/sort relative"
|
|
||||||
>
|
|
||||||
<span className="relative flex w-full items-center justify-center">
|
|
||||||
{title}
|
|
||||||
<span className={cn("lg:absolute lg:-right-6 lg:top-1/2 lg:-translate-y-1/2", iconVisibility)}>{icon}</span>
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
className="h-auto! p-0! font-inherit hover:bg-transparent uppercase group/sort"
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
<span className={iconVisibility}>{icon}</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useHotkeySequence } from "@tanstack/react-hotkeys";
|
|
||||||
import { getDevPanelOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
|
||||||
import { DevPanel } from "./dev-panel";
|
|
||||||
|
|
||||||
export function DevPanelListener() {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const { data: devPanelStatus } = useQuery({
|
|
||||||
...getDevPanelOptions(),
|
|
||||||
});
|
|
||||||
|
|
||||||
useHotkeySequence(["D", "E", "V"], () => setIsOpen(true), {
|
|
||||||
enabled: !!devPanelStatus?.enabled,
|
|
||||||
preventDefault: true,
|
|
||||||
ignoreInputs: true,
|
|
||||||
timeout: 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!devPanelStatus?.enabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <DevPanel open={isOpen} onOpenChange={setIsOpen} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,229 +0,0 @@
|
||||||
import { useRef, useState, useCallback } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Play, Loader2, Trash2, Terminal } from "lucide-react";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { cn } from "~/client/lib/utils";
|
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "./ui/sheet";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
|
||||||
import { Label } from "./ui/label";
|
|
||||||
import { Textarea } from "./ui/textarea";
|
|
||||||
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
|
||||||
import { devPanelExec } from "~/client/api-client/sdk.gen";
|
|
||||||
import { parseError } from "../lib/errors";
|
|
||||||
|
|
||||||
type DevPanelProps = {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SseOutputEvent = {
|
|
||||||
type: "stdout" | "stderr";
|
|
||||||
line: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SseDoneEvent = {
|
|
||||||
type: "done";
|
|
||||||
exitCode: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SseErrorEvent = {
|
|
||||||
type: "error";
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SseEvent = SseOutputEvent | SseDoneEvent | SseErrorEvent;
|
|
||||||
|
|
||||||
export function DevPanel({ open, onOpenChange }: DevPanelProps) {
|
|
||||||
const { data: repositories = [] } = useQuery({
|
|
||||||
...listRepositoriesOptions(),
|
|
||||||
enabled: open,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [selectedRepoId, setSelectedRepoId] = useState<string>("");
|
|
||||||
const [commandLine, setCommandLine] = useState("snapshots");
|
|
||||||
const [output, setOutput] = useState<string[]>([]);
|
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
|
||||||
const outputRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
|
||||||
if (outputRef.current) {
|
|
||||||
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const appendOutput = useCallback(
|
|
||||||
(line: string) => {
|
|
||||||
setOutput((prev) => {
|
|
||||||
const newOutput = [...prev, line];
|
|
||||||
setTimeout(scrollToBottom, 0);
|
|
||||||
return newOutput;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[scrollToBottom],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRun = async () => {
|
|
||||||
if (!selectedRepoId || !commandLine.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOutput([]);
|
|
||||||
setIsRunning(true);
|
|
||||||
appendOutput(`$ restic ${commandLine}`.trim());
|
|
||||||
appendOutput("---");
|
|
||||||
|
|
||||||
abortControllerRef.current = new AbortController();
|
|
||||||
|
|
||||||
const trimmedLine = commandLine.trim();
|
|
||||||
const parts = trimmedLine.split(/\s+/);
|
|
||||||
const command = parts[0];
|
|
||||||
const argsArray = parts.slice(1);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await devPanelExec({
|
|
||||||
path: { shortId: selectedRepoId },
|
|
||||||
body: { command, args: argsArray.length > 0 ? argsArray : undefined },
|
|
||||||
signal: abortControllerRef.current.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
for await (const event of result.stream) {
|
|
||||||
if (abortControllerRef.current.signal.aborted) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const data = event as unknown as SseEvent;
|
|
||||||
|
|
||||||
if (!data || typeof data !== "object") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === "stdout" || data.type === "stderr") {
|
|
||||||
appendOutput(data.line);
|
|
||||||
} else if (data.type === "done") {
|
|
||||||
appendOutput(`---`);
|
|
||||||
appendOutput(`Command finished with exit code: ${data.exitCode}`);
|
|
||||||
} else if (data.type === "error") {
|
|
||||||
appendOutput(`Error: ${data.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof Error && err.name === "AbortError") {
|
|
||||||
appendOutput("---");
|
|
||||||
appendOutput("Command cancelled");
|
|
||||||
} else {
|
|
||||||
appendOutput(`Error: ${parseError(err)?.message}`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsRunning(false);
|
|
||||||
abortControllerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
abortControllerRef.current?.abort();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClear = () => {
|
|
||||||
setOutput([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (isRunning) {
|
|
||||||
handleCancel();
|
|
||||||
}
|
|
||||||
onOpenChange(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sheet open={open} onOpenChange={handleClose}>
|
|
||||||
<SheetContent side="right" className="w-full sm:max-w-xl flex flex-col px-4">
|
|
||||||
<SheetHeader>
|
|
||||||
<SheetTitle className="flex items-center gap-2">
|
|
||||||
<Terminal className="h-5 w-5" />
|
|
||||||
Dev Panel
|
|
||||||
</SheetTitle>
|
|
||||||
<SheetDescription>Execute restic commands against a repository</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 flex-1 min-h-0 mt-4">
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="repository">Repository</Label>
|
|
||||||
<Select value={selectedRepoId} onValueChange={setSelectedRepoId}>
|
|
||||||
<SelectTrigger id="repository">
|
|
||||||
<SelectValue placeholder="Select a repository" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{repositories.map((repo) => (
|
|
||||||
<SelectItem key={repo.id} value={repo.shortId}>
|
|
||||||
{repo.name} ({repo.type})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="command">Command</Label>
|
|
||||||
<Textarea
|
|
||||||
id="command"
|
|
||||||
placeholder="e.g., snapshots, check --dry-run, forget --keep-last 10"
|
|
||||||
value={commandLine}
|
|
||||||
onChange={(e) => setCommandLine(e.target.value)}
|
|
||||||
className="font-mono text-sm"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={handleRun} disabled={isRunning || !selectedRepoId || !commandLine.trim()}>
|
|
||||||
<Loader2 className={cn("h-4 w-4 animate-spin mr-2", { hidden: !isRunning })} />
|
|
||||||
<Play className={cn("h-4 w-4 mr-2", { hidden: isRunning })} />
|
|
||||||
<span className={cn({ hidden: !isRunning })}>Running...</span>
|
|
||||||
<span className={cn({ hidden: isRunning })}>Run</span>
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" onClick={handleCancel} className={cn({ hidden: !isRunning })}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={handleClear} disabled={isRunning}>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 border rounded-md bg-muted/30">
|
|
||||||
<div ref={outputRef} className="h-full overflow-auto p-3 font-mono text-xs">
|
|
||||||
<div className={cn("text-muted-foreground", { hidden: output.length > 0 })}>
|
|
||||||
Output will appear here...
|
|
||||||
</div>
|
|
||||||
<div className={cn("space-y-0.5", { hidden: output.length === 0 })}>
|
|
||||||
{output.map((line, i) => {
|
|
||||||
let displayLine = line;
|
|
||||||
let isJson = false;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(line);
|
|
||||||
displayLine = JSON.stringify(parsed, null, 2);
|
|
||||||
isJson = true;
|
|
||||||
} catch {
|
|
||||||
// Not valid JSON, display as-is
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<pre
|
|
||||||
key={`${i}-${line.slice(0, 20)}`}
|
|
||||||
className={cn("whitespace-pre-wrap break-all text-xs", {
|
|
||||||
"wrap-break-word text-[10px] leading-tight": isJson,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{displayLine}
|
|
||||||
</pre>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
74
app/client/components/directory-browser.tsx
Normal file
74
app/client/components/directory-browser.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { FileTree } from "./file-tree";
|
||||||
|
import { ScrollArea } from "./ui/scroll-area";
|
||||||
|
import { browseFilesystemOptions } from "../api-client/@tanstack/react-query.gen";
|
||||||
|
import { useFileBrowser } from "../hooks/use-file-browser";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onSelectPath: (path: string) => void;
|
||||||
|
selectedPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
...browseFilesystemOptions({ query: { path: "/" } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileBrowser = useFileBrowser({
|
||||||
|
initialData: data,
|
||||||
|
isLoading,
|
||||||
|
fetchFolder: async (path) => {
|
||||||
|
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
|
||||||
|
},
|
||||||
|
prefetchFolder: (path) => {
|
||||||
|
queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fileBrowser.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<div className="text-sm text-gray-500 p-4">Loading directories...</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileBrowser.isEmpty) {
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<div className="text-sm text-gray-500 p-4">No subdirectories found</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<FileTree
|
||||||
|
files={fileBrowser.fileArray}
|
||||||
|
onFolderExpand={fileBrowser.handleFolderExpand}
|
||||||
|
onFolderHover={fileBrowser.handleFolderHover}
|
||||||
|
expandedFolders={fileBrowser.expandedFolders}
|
||||||
|
loadingFolders={fileBrowser.loadingFolders}
|
||||||
|
foldersOnly
|
||||||
|
selectableFolders
|
||||||
|
selectedFolder={selectedPath}
|
||||||
|
onFolderSelect={onSelectPath}
|
||||||
|
/>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{selectedPath && (
|
||||||
|
<div className="bg-muted/50 border-t p-2 text-sm">
|
||||||
|
<div className="font-medium text-muted-foreground">Selected path:</div>
|
||||||
|
<div className="font-mono text-xs break-all">{selectedPath}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { LocalFileBrowser } from "./local-file-browser";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onSelectPath: (path: string) => void;
|
|
||||||
selectedPath?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
|
|
||||||
return (
|
|
||||||
<LocalFileBrowser
|
|
||||||
className="border rounded-lg overflow-hidden"
|
|
||||||
useScrollArea
|
|
||||||
scrollAreaClassName="h-64"
|
|
||||||
foldersOnly
|
|
||||||
selectableFolders
|
|
||||||
selectedFolder={selectedPath}
|
|
||||||
onFolderSelect={onSelectPath}
|
|
||||||
showSelectedPathFooter
|
|
||||||
selectedPath={selectedPath}
|
|
||||||
loadingMessage="Loading directories..."
|
|
||||||
emptyMessage="No subdirectories found"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
import { type ReactNode } from "react";
|
|
||||||
import { FolderOpen } from "lucide-react";
|
|
||||||
import { FileTree, type FileEntry } from "~/client/components/file-tree";
|
|
||||||
import { ScrollArea } from "~/client/components/ui/scroll-area";
|
|
||||||
import { cn } from "~/client/lib/utils";
|
|
||||||
|
|
||||||
type PaginationState = {
|
|
||||||
hasMore: boolean;
|
|
||||||
isLoadingMore: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FileBrowserUiProps = {
|
|
||||||
className?: string;
|
|
||||||
treeContainerClassName?: string;
|
|
||||||
treeClassName?: string;
|
|
||||||
useScrollArea?: boolean;
|
|
||||||
scrollAreaClassName?: string;
|
|
||||||
stateClassName?: string;
|
|
||||||
loadingMessage?: string;
|
|
||||||
emptyMessage?: string;
|
|
||||||
emptyDescription?: string;
|
|
||||||
emptyIcon?: ReactNode;
|
|
||||||
withCheckboxes?: boolean;
|
|
||||||
selectedPaths?: Set<string>;
|
|
||||||
onSelectionChange?: (paths: Set<string>) => void;
|
|
||||||
foldersOnly?: boolean;
|
|
||||||
selectableFolders?: boolean;
|
|
||||||
onFolderSelect?: (folderPath: string) => void;
|
|
||||||
selectedFolder?: string;
|
|
||||||
onFileSelect?: (filePath: string) => void;
|
|
||||||
selectedFile?: string;
|
|
||||||
showSelectedPathFooter?: boolean;
|
|
||||||
selectedPath?: string;
|
|
||||||
selectedPathLabel?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type FileBrowserProps = FileBrowserUiProps & {
|
|
||||||
isLoading: boolean;
|
|
||||||
isEmpty: boolean;
|
|
||||||
errorMessage?: string;
|
|
||||||
fileArray: FileEntry[];
|
|
||||||
expandedFolders: Set<string>;
|
|
||||||
loadingFolders: Set<string>;
|
|
||||||
onFolderExpand: (folderPath: string) => void | Promise<void>;
|
|
||||||
onFolderHover: (folderPath: string) => void;
|
|
||||||
onLoadMore: (folderPath: string) => void | Promise<void>;
|
|
||||||
getFolderPagination: (folderPath: string) => PaginationState;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FileBrowser = (props: FileBrowserProps) => {
|
|
||||||
const {
|
|
||||||
className,
|
|
||||||
treeContainerClassName,
|
|
||||||
treeClassName,
|
|
||||||
useScrollArea = false,
|
|
||||||
scrollAreaClassName,
|
|
||||||
stateClassName,
|
|
||||||
loadingMessage = "Loading files...",
|
|
||||||
emptyMessage = "No files found.",
|
|
||||||
emptyDescription,
|
|
||||||
emptyIcon,
|
|
||||||
withCheckboxes = false,
|
|
||||||
selectedPaths,
|
|
||||||
onSelectionChange,
|
|
||||||
foldersOnly = false,
|
|
||||||
selectableFolders = false,
|
|
||||||
onFolderSelect,
|
|
||||||
selectedFolder,
|
|
||||||
onFileSelect,
|
|
||||||
selectedFile,
|
|
||||||
showSelectedPathFooter = false,
|
|
||||||
selectedPath,
|
|
||||||
selectedPathLabel = "Selected path:",
|
|
||||||
isLoading,
|
|
||||||
isEmpty,
|
|
||||||
errorMessage,
|
|
||||||
fileArray,
|
|
||||||
expandedFolders,
|
|
||||||
loadingFolders,
|
|
||||||
onFolderExpand,
|
|
||||||
onFolderHover,
|
|
||||||
onLoadMore,
|
|
||||||
getFolderPagination,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const resolvedSelectedPath = selectedPath ?? selectedFolder;
|
|
||||||
const resolvedEmptyIcon =
|
|
||||||
emptyIcon === undefined ? <FolderOpen className="mb-2 h-12 w-12 text-muted-foreground" /> : emptyIcon;
|
|
||||||
|
|
||||||
let body: ReactNode;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
body = (
|
|
||||||
<div className={cn("flex min-h-50 flex-col items-center justify-center p-6 text-center", stateClassName)}>
|
|
||||||
<p className="text-muted-foreground">{loadingMessage}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (errorMessage) {
|
|
||||||
body = (
|
|
||||||
<div className={cn("flex min-h-50 flex-col items-center justify-center p-6 text-center", stateClassName)}>
|
|
||||||
<p className="text-destructive">{errorMessage}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (isEmpty) {
|
|
||||||
body = (
|
|
||||||
<div className={cn("flex min-h-50 flex-col items-center justify-center p-6 text-center", stateClassName)}>
|
|
||||||
{resolvedEmptyIcon}
|
|
||||||
<p className="text-muted-foreground">{emptyMessage}</p>
|
|
||||||
{emptyDescription && <p className="mt-2 text-sm text-muted-foreground">{emptyDescription}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
body = (
|
|
||||||
<FileTree
|
|
||||||
files={fileArray}
|
|
||||||
onFolderExpand={onFolderExpand}
|
|
||||||
onFolderHover={onFolderHover}
|
|
||||||
onLoadMore={onLoadMore}
|
|
||||||
getFolderPagination={getFolderPagination}
|
|
||||||
expandedFolders={expandedFolders}
|
|
||||||
loadingFolders={loadingFolders}
|
|
||||||
className={treeClassName}
|
|
||||||
withCheckboxes={withCheckboxes}
|
|
||||||
selectedPaths={selectedPaths}
|
|
||||||
onSelectionChange={onSelectionChange}
|
|
||||||
foldersOnly={foldersOnly}
|
|
||||||
selectableFolders={selectableFolders}
|
|
||||||
onFolderSelect={onFolderSelect}
|
|
||||||
selectedFolder={selectedFolder}
|
|
||||||
onFileSelect={onFileSelect}
|
|
||||||
selectedFile={selectedFile}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyWithScroll = useScrollArea ? <ScrollArea className={scrollAreaClassName}>{body}</ScrollArea> : body;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<div className={treeContainerClassName}>{bodyWithScroll}</div>
|
|
||||||
{showSelectedPathFooter && resolvedSelectedPath && (
|
|
||||||
<div className="bg-muted/50 border-t p-2 text-sm">
|
|
||||||
<div className="font-medium text-muted-foreground">{selectedPathLabel}</div>
|
|
||||||
<div className="font-mono text-xs break-all">{resolvedSelectedPath}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { browseFilesystemOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
|
||||||
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
|
|
||||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
|
||||||
import { parseError } from "~/client/lib/errors";
|
|
||||||
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
|
||||||
import { logger } from "~/client/lib/logger";
|
|
||||||
|
|
||||||
type LocalFileBrowserProps = FileBrowserUiProps & {
|
|
||||||
initialPath?: string;
|
|
||||||
enabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LocalFileBrowser = ({ initialPath = "/", enabled = true, ...uiProps }: LocalFileBrowserProps) => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const normalizedInitialPath = normalizeAbsolutePath(initialPath);
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
|
||||||
...browseFilesystemOptions({ query: { path: normalizedInitialPath } }),
|
|
||||||
enabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileBrowser = useFileBrowser({
|
|
||||||
initialData: data,
|
|
||||||
isLoading,
|
|
||||||
fetchFolder: async (path) => {
|
|
||||||
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
|
|
||||||
},
|
|
||||||
prefetchFolder: (path) => {
|
|
||||||
void queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } })).catch((e) => logger.error(e));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FileBrowser
|
|
||||||
{...uiProps}
|
|
||||||
fileArray={fileBrowser.fileArray}
|
|
||||||
expandedFolders={fileBrowser.expandedFolders}
|
|
||||||
loadingFolders={fileBrowser.loadingFolders}
|
|
||||||
onFolderExpand={fileBrowser.handleFolderExpand}
|
|
||||||
onFolderHover={fileBrowser.handleFolderHover}
|
|
||||||
onLoadMore={fileBrowser.handleLoadMore}
|
|
||||||
getFolderPagination={fileBrowser.getFolderPagination}
|
|
||||||
isLoading={fileBrowser.isLoading}
|
|
||||||
isEmpty={fileBrowser.isEmpty}
|
|
||||||
errorMessage={parseError(error)?.message}
|
|
||||||
loadingMessage={uiProps.loadingMessage ?? "Loading directories..."}
|
|
||||||
emptyMessage={uiProps.emptyMessage ?? "No subdirectories found"}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
|
||||||
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
|
|
||||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
|
||||||
import { parseError } from "~/client/lib/errors";
|
|
||||||
import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils";
|
|
||||||
|
|
||||||
function createPathPrefixFns(basePath: string) {
|
|
||||||
return {
|
|
||||||
strip(path: string) {
|
|
||||||
if (basePath === "/") return path;
|
|
||||||
if (path === basePath) return "/";
|
|
||||||
if (path.startsWith(`${basePath}/`)) return path.slice(basePath.length);
|
|
||||||
return path;
|
|
||||||
},
|
|
||||||
add(displayPath: string) {
|
|
||||||
if (basePath === "/") return displayPath;
|
|
||||||
if (displayPath === "/") return basePath;
|
|
||||||
return `${basePath}${displayPath}`;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type SnapshotTreeBrowserProps = FileBrowserUiProps & {
|
|
||||||
repositoryId: string;
|
|
||||||
snapshotId: string;
|
|
||||||
queryBasePath?: string;
|
|
||||||
displayBasePath?: string;
|
|
||||||
pageSize?: number;
|
|
||||||
enabled?: boolean;
|
|
||||||
onSingleSelectionKindChange?: (kind: "file" | "dir" | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
|
|
||||||
const {
|
|
||||||
repositoryId,
|
|
||||||
snapshotId,
|
|
||||||
queryBasePath = "/",
|
|
||||||
displayBasePath,
|
|
||||||
pageSize = 500,
|
|
||||||
enabled = true,
|
|
||||||
...uiProps
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const { selectedPaths, onSelectionChange, onSingleSelectionKindChange, ...fileBrowserUiProps } = uiProps;
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const normalizedQueryBasePath = normalizeAbsolutePath(queryBasePath);
|
|
||||||
const normalizedDisplayBasePath = normalizeAbsolutePath(displayBasePath ?? "/");
|
|
||||||
const effectiveDisplayBasePath = isPathWithin(normalizedDisplayBasePath, normalizedQueryBasePath)
|
|
||||||
? normalizedDisplayBasePath
|
|
||||||
: "/";
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
|
||||||
...listSnapshotFilesOptions({
|
|
||||||
path: { shortId: repositoryId, snapshotId },
|
|
||||||
query: { path: normalizedQueryBasePath },
|
|
||||||
}),
|
|
||||||
enabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
const displayPathFns = useMemo(() => createPathPrefixFns(effectiveDisplayBasePath), [effectiveDisplayBasePath]);
|
|
||||||
|
|
||||||
const displaySelectedPaths = useMemo(() => {
|
|
||||||
if (!selectedPaths) return undefined;
|
|
||||||
|
|
||||||
const displayPaths = new Set<string>();
|
|
||||||
for (const fullPath of selectedPaths) {
|
|
||||||
displayPaths.add(displayPathFns.strip(fullPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
return displayPaths;
|
|
||||||
}, [displayPathFns, selectedPaths]);
|
|
||||||
|
|
||||||
const fileBrowser = useFileBrowser({
|
|
||||||
initialData: data,
|
|
||||||
isLoading,
|
|
||||||
fetchFolder: async (displayPath, offset = 0) => {
|
|
||||||
return await queryClient.ensureQueryData(
|
|
||||||
listSnapshotFilesOptions({
|
|
||||||
path: { shortId: repositoryId, snapshotId },
|
|
||||||
query: { path: displayPath, offset: offset, limit: pageSize },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
pathTransform: displayPathFns,
|
|
||||||
});
|
|
||||||
|
|
||||||
const displayPathKinds = useMemo(() => {
|
|
||||||
const kinds = new Map<string, "file" | "dir">();
|
|
||||||
for (const entry of fileBrowser.fileArray) {
|
|
||||||
kinds.set(entry.path, entry.type === "file" ? "file" : "dir");
|
|
||||||
|
|
||||||
let parentPath = entry.path;
|
|
||||||
while (true) {
|
|
||||||
const lastSlashIndex = parentPath.lastIndexOf("/");
|
|
||||||
if (lastSlashIndex <= 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
parentPath = parentPath.slice(0, lastSlashIndex);
|
|
||||||
if (kinds.has(parentPath)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
kinds.set(parentPath, "dir");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return kinds;
|
|
||||||
}, [fileBrowser.fileArray]);
|
|
||||||
|
|
||||||
const handleSelectionChange = useCallback(
|
|
||||||
(nextDisplayPaths: Set<string>) => {
|
|
||||||
if (!onSelectionChange) return;
|
|
||||||
|
|
||||||
const nextFullPaths = new Set<string>();
|
|
||||||
for (const displayPath of nextDisplayPaths) {
|
|
||||||
nextFullPaths.add(displayPathFns.add(displayPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onSingleSelectionKindChange) {
|
|
||||||
if (nextDisplayPaths.size === 1) {
|
|
||||||
const [selectedDisplayPath] = nextDisplayPaths;
|
|
||||||
if (selectedDisplayPath) {
|
|
||||||
onSingleSelectionKindChange(displayPathKinds.get(selectedDisplayPath) ?? null);
|
|
||||||
} else {
|
|
||||||
onSingleSelectionKindChange(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onSingleSelectionKindChange(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelectionChange(nextFullPaths);
|
|
||||||
},
|
|
||||||
[displayPathFns, displayPathKinds, onSelectionChange, onSingleSelectionKindChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FileBrowser
|
|
||||||
{...fileBrowserUiProps}
|
|
||||||
fileArray={fileBrowser.fileArray}
|
|
||||||
expandedFolders={fileBrowser.expandedFolders}
|
|
||||||
loadingFolders={fileBrowser.loadingFolders}
|
|
||||||
onFolderExpand={fileBrowser.handleFolderExpand}
|
|
||||||
onFolderHover={fileBrowser.handleFolderHover}
|
|
||||||
onLoadMore={fileBrowser.handleLoadMore}
|
|
||||||
getFolderPagination={fileBrowser.getFolderPagination}
|
|
||||||
isLoading={fileBrowser.isLoading}
|
|
||||||
isEmpty={fileBrowser.isEmpty}
|
|
||||||
errorMessage={parseError(error)?.message}
|
|
||||||
loadingMessage={fileBrowserUiProps.loadingMessage ?? "Loading files..."}
|
|
||||||
selectedPaths={displaySelectedPaths}
|
|
||||||
onSelectionChange={onSelectionChange ? handleSelectionChange : undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { listFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
|
||||||
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
|
|
||||||
import { useFileBrowser, type FetchFolderResult } from "~/client/hooks/use-file-browser";
|
|
||||||
import { parseError } from "~/client/lib/errors";
|
|
||||||
import { logger } from "~/client/lib/logger";
|
|
||||||
|
|
||||||
type VolumeFileBrowserProps = FileBrowserUiProps & {
|
|
||||||
volumeId: string;
|
|
||||||
enabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: VolumeFileBrowserProps) => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
|
||||||
...listFilesOptions({ path: { shortId: volumeId } }),
|
|
||||||
enabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileBrowser = useFileBrowser({
|
|
||||||
initialData: data,
|
|
||||||
isLoading,
|
|
||||||
fetchFolder: async (path, offset): Promise<FetchFolderResult> => {
|
|
||||||
return await queryClient.ensureQueryData(
|
|
||||||
listFilesOptions({
|
|
||||||
path: { shortId: volumeId },
|
|
||||||
query: { path, offset: offset },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
prefetchFolder: (path) => {
|
|
||||||
void queryClient
|
|
||||||
.prefetchQuery(
|
|
||||||
listFilesOptions({
|
|
||||||
path: { shortId: volumeId },
|
|
||||||
query: { path },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.catch((e) => logger.error(e));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FileBrowser
|
|
||||||
{...uiProps}
|
|
||||||
fileArray={fileBrowser.fileArray}
|
|
||||||
expandedFolders={fileBrowser.expandedFolders}
|
|
||||||
loadingFolders={fileBrowser.loadingFolders}
|
|
||||||
onFolderExpand={fileBrowser.handleFolderExpand}
|
|
||||||
onFolderHover={fileBrowser.handleFolderHover}
|
|
||||||
onLoadMore={fileBrowser.handleLoadMore}
|
|
||||||
getFolderPagination={fileBrowser.getFolderPagination}
|
|
||||||
isLoading={fileBrowser.isLoading}
|
|
||||||
isEmpty={fileBrowser.isEmpty}
|
|
||||||
errorMessage={parseError(error)?.message}
|
|
||||||
loadingMessage={uiProps.loadingMessage ?? "Loading files..."}
|
|
||||||
emptyMessage={uiProps.emptyMessage ?? "This volume appears to be empty."}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -8,19 +8,10 @@
|
||||||
* Original source: https://github.com/stackblitz/bolt.new
|
* Original source: https://github.com/stackblitz/bolt.new
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { ChevronDown, ChevronRight, File as FileIcon, Folder as FolderIcon, FolderOpen, Loader2 } from "lucide-react";
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
File as FileIcon,
|
|
||||||
Folder as FolderIcon,
|
|
||||||
FolderOpen,
|
|
||||||
Loader2,
|
|
||||||
MoreHorizontal,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||||
import { ByteSize } from "~/client/components/bytes-size";
|
|
||||||
|
|
||||||
const NODE_PADDING_LEFT = 12;
|
const NODE_PADDING_LEFT = 12;
|
||||||
|
|
||||||
|
|
@ -32,19 +23,12 @@ export interface FileEntry {
|
||||||
modifiedAt?: number;
|
modifiedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaginationState {
|
|
||||||
hasMore: boolean;
|
|
||||||
isLoadingMore: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
files?: FileEntry[];
|
files?: FileEntry[];
|
||||||
selectedFile?: string;
|
selectedFile?: string;
|
||||||
onFileSelect?: (filePath: string) => void;
|
onFileSelect?: (filePath: string) => void;
|
||||||
onFolderExpand?: (folderPath: string) => void;
|
onFolderExpand?: (folderPath: string) => void;
|
||||||
onFolderHover?: (folderPath: string) => void;
|
onFolderHover?: (folderPath: string) => void;
|
||||||
onLoadMore?: (folderPath: string) => void;
|
|
||||||
getFolderPagination?: (folderPath: string) => PaginationState;
|
|
||||||
expandedFolders?: Set<string>;
|
expandedFolders?: Set<string>;
|
||||||
loadingFolders?: Set<string>;
|
loadingFolders?: Set<string>;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -64,8 +48,6 @@ export const FileTree = memo((props: Props) => {
|
||||||
selectedFile,
|
selectedFile,
|
||||||
onFolderExpand,
|
onFolderExpand,
|
||||||
onFolderHover,
|
onFolderHover,
|
||||||
onLoadMore,
|
|
||||||
getFolderPagination,
|
|
||||||
expandedFolders = new Set(),
|
expandedFolders = new Set(),
|
||||||
loadingFolders = new Set(),
|
loadingFolders = new Set(),
|
||||||
className,
|
className,
|
||||||
|
|
@ -114,39 +96,32 @@ 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
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCollapsedFolders((prevSet) => {
|
setCollapsedFolders((prevSet) => {
|
||||||
let hasChanges = false;
|
|
||||||
const newSet = new Set(prevSet);
|
const newSet = new Set(prevSet);
|
||||||
for (const item of fileList) {
|
for (const item of fileList) {
|
||||||
if (item.kind === "folder" && !newSet.has(item.fullPath) && !expandedFolders.has(item.fullPath)) {
|
if (item.kind === "folder" && !newSet.has(item.fullPath) && !expandedFolders.has(item.fullPath)) {
|
||||||
newSet.add(item.fullPath);
|
newSet.add(item.fullPath);
|
||||||
hasChanges = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return hasChanges ? newSet : prevSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
}, [fileList, expandedFolders]);
|
}, [fileList, expandedFolders]);
|
||||||
|
|
||||||
|
|
@ -173,9 +148,9 @@ export const FileTree = memo((props: Props) => {
|
||||||
newSelection.add(path);
|
newSelection.add(path);
|
||||||
|
|
||||||
// Remove any descendants from selection since parent now covers them
|
// Remove any descendants from selection since parent now covers them
|
||||||
for (const selectedPath of newSelection) {
|
for (const item of fileList) {
|
||||||
if (selectedPath.startsWith(`${path}/`)) {
|
if (item.fullPath.startsWith(`${path}/`)) {
|
||||||
newSelection.delete(selectedPath);
|
newSelection.delete(item.fullPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -206,8 +181,7 @@ export const FileTree = memo((props: Props) => {
|
||||||
if (
|
if (
|
||||||
item.fullPath.startsWith(`${selectedParentPath}/`) &&
|
item.fullPath.startsWith(`${selectedParentPath}/`) &&
|
||||||
!item.fullPath.startsWith(`${path}/`) &&
|
!item.fullPath.startsWith(`${path}/`) &&
|
||||||
item.fullPath !== path &&
|
item.fullPath !== path
|
||||||
!path.startsWith(`${item.fullPath}/`)
|
|
||||||
) {
|
) {
|
||||||
newSelection.add(item.fullPath);
|
newSelection.add(item.fullPath);
|
||||||
}
|
}
|
||||||
|
|
@ -215,45 +189,39 @@ export const FileTree = memo((props: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let changed = true;
|
const childrenByParent = new Map<string, string[]>();
|
||||||
while (changed) {
|
for (const selectedPath of newSelection) {
|
||||||
changed = false;
|
const lastSlashIndex = selectedPath.lastIndexOf("/");
|
||||||
const childrenByParent = new Map<string, string[]>();
|
if (lastSlashIndex > 0) {
|
||||||
for (const selectedPath of newSelection) {
|
const parentPath = selectedPath.slice(0, lastSlashIndex);
|
||||||
const lastSlashIndex = selectedPath.lastIndexOf("/");
|
if (!childrenByParent.has(parentPath)) {
|
||||||
if (lastSlashIndex > 0) {
|
childrenByParent.set(parentPath, []);
|
||||||
const parentPath = selectedPath.slice(0, lastSlashIndex);
|
|
||||||
if (!childrenByParent.has(parentPath)) {
|
|
||||||
childrenByParent.set(parentPath, []);
|
|
||||||
}
|
|
||||||
childrenByParent.get(parentPath)?.push(selectedPath);
|
|
||||||
}
|
}
|
||||||
|
childrenByParent.get(parentPath)?.push(selectedPath);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// For each parent, check if all its children are selected
|
// For each parent, check if all its children are selected
|
||||||
for (const [parentPath, selectedChildren] of childrenByParent.entries()) {
|
for (const [parentPath, selectedChildren] of childrenByParent.entries()) {
|
||||||
// Get all children of this parent from the file list
|
// Get all children of this parent from the file list
|
||||||
const allChildren = fileList.filter((item) => {
|
const allChildren = fileList.filter((item) => {
|
||||||
const itemParentPath = item.fullPath.slice(0, item.fullPath.lastIndexOf("/"));
|
const itemParentPath = item.fullPath.slice(0, item.fullPath.lastIndexOf("/"));
|
||||||
return itemParentPath === parentPath;
|
return itemParentPath === parentPath;
|
||||||
});
|
});
|
||||||
|
|
||||||
// If all children are selected, replace them with the parent
|
// If all children are selected, replace them with the parent
|
||||||
if (allChildren.length > 0 && selectedChildren.length === allChildren.length) {
|
if (allChildren.length > 0 && selectedChildren.length === allChildren.length) {
|
||||||
// Check that we have every child
|
// Check that we have every child
|
||||||
const allChildrenPaths = new Set(allChildren.map((c) => c.fullPath));
|
const allChildrenPaths = new Set(allChildren.map((c) => c.fullPath));
|
||||||
const allChildrenSelected = selectedChildren.every((c) => allChildrenPaths.has(c));
|
const allChildrenSelected = selectedChildren.every((c) => allChildrenPaths.has(c));
|
||||||
|
|
||||||
if (allChildrenSelected) {
|
if (allChildrenSelected) {
|
||||||
// Remove all children
|
// Remove all children
|
||||||
for (const childPath of selectedChildren) {
|
for (const childPath of selectedChildren) {
|
||||||
newSelection.delete(childPath);
|
newSelection.delete(childPath);
|
||||||
}
|
|
||||||
// Add the parent
|
|
||||||
newSelection.add(parentPath);
|
|
||||||
changed = true;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
// Add the parent
|
||||||
|
newSelection.add(parentPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -314,32 +282,12 @@ export const FileTree = memo((props: Props) => {
|
||||||
[selectedPaths],
|
[selectedPaths],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build a map of folder paths that need pagination to their last child's index
|
|
||||||
const folderPaginationMap = useMemo(() => {
|
|
||||||
const map = new Map<string, number>();
|
|
||||||
|
|
||||||
for (let i = 0; i < filteredFileList.length; i++) {
|
|
||||||
const item = filteredFileList[i];
|
|
||||||
const parentPath = item.fullPath.slice(0, item.fullPath.lastIndexOf("/")) || "/";
|
|
||||||
const pagination = getFolderPagination?.(parentPath);
|
|
||||||
if (pagination?.hasMore && !collapsedFolders.has(parentPath)) {
|
|
||||||
// Update the last index for this parent
|
|
||||||
map.set(parentPath, i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return map;
|
|
||||||
}, [filteredFileList, getFolderPagination, collapsedFolders]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("text-sm", className)}>
|
<div className={cn("text-sm", className)}>
|
||||||
{filteredFileList.map((fileOrFolder, index) => {
|
{filteredFileList.map((fileOrFolder) => {
|
||||||
const elements: React.ReactNode[] = [];
|
|
||||||
|
|
||||||
// Render the current file or folder
|
|
||||||
switch (fileOrFolder.kind) {
|
switch (fileOrFolder.kind) {
|
||||||
case "file": {
|
case "file": {
|
||||||
elements.push(
|
return (
|
||||||
<File
|
<File
|
||||||
key={fileOrFolder.id}
|
key={fileOrFolder.id}
|
||||||
selected={selectedFile === fileOrFolder.fullPath}
|
selected={selectedFile === fileOrFolder.fullPath}
|
||||||
|
|
@ -348,12 +296,11 @@ export const FileTree = memo((props: Props) => {
|
||||||
withCheckbox={withCheckboxes}
|
withCheckbox={withCheckboxes}
|
||||||
checked={isPathSelected(fileOrFolder.fullPath)}
|
checked={isPathSelected(fileOrFolder.fullPath)}
|
||||||
onCheckboxChange={handleSelectionChange}
|
onCheckboxChange={handleSelectionChange}
|
||||||
/>,
|
/>
|
||||||
);
|
);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
case "folder": {
|
case "folder": {
|
||||||
elements.push(
|
return (
|
||||||
<Folder
|
<Folder
|
||||||
key={fileOrFolder.id}
|
key={fileOrFolder.id}
|
||||||
folder={fileOrFolder}
|
folder={fileOrFolder}
|
||||||
|
|
@ -368,31 +315,13 @@ export const FileTree = memo((props: Props) => {
|
||||||
selectableMode={selectableFolders}
|
selectableMode={selectableFolders}
|
||||||
onFolderSelect={handleFolderSelect}
|
onFolderSelect={handleFolderSelect}
|
||||||
selected={selectedFolder === fileOrFolder.fullPath}
|
selected={selectedFolder === fileOrFolder.fullPath}
|
||||||
/>,
|
/>
|
||||||
);
|
);
|
||||||
break;
|
}
|
||||||
|
default: {
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is the last child of any folder with more files to load
|
|
||||||
for (const [folderPath, lastIndex] of folderPaginationMap.entries()) {
|
|
||||||
if (lastIndex === index) {
|
|
||||||
// This is the last loaded child of folderPath
|
|
||||||
const pagination = getFolderPagination?.(folderPath);
|
|
||||||
if (pagination?.hasMore) {
|
|
||||||
elements.push(
|
|
||||||
<LoadMoreButton
|
|
||||||
key={`load-more-${folderPath}`}
|
|
||||||
depth={fileOrFolder.depth}
|
|
||||||
onClick={() => onLoadMore?.(folderPath)}
|
|
||||||
isLoading={pagination.isLoadingMore}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return elements;
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -502,7 +431,7 @@ interface FileProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const File = memo(({ file, onFileSelect, selected, withCheckbox, checked, onCheckboxChange }: FileProps) => {
|
const File = memo(({ file, onFileSelect, selected, withCheckbox, checked, onCheckboxChange }: FileProps) => {
|
||||||
const { depth, name, fullPath, size } = file;
|
const { depth, name, fullPath } = file;
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
onFileSelect(fullPath);
|
onFileSelect(fullPath);
|
||||||
|
|
@ -529,36 +458,6 @@ const File = memo(({ file, onFileSelect, selected, withCheckbox, checked, onChec
|
||||||
<Checkbox checked={checked} onCheckedChange={handleCheckboxChange} onClick={(e) => e.stopPropagation()} />
|
<Checkbox checked={checked} onCheckedChange={handleCheckboxChange} onClick={(e) => e.stopPropagation()} />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{name}</span>
|
<span className="truncate">{name}</span>
|
||||||
{typeof size === "number" && (
|
|
||||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
|
||||||
<ByteSize bytes={size} base={1024} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</NodeButton>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
interface LoadMoreButtonProps {
|
|
||||||
depth: number;
|
|
||||||
onClick: () => void;
|
|
||||||
isLoading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LoadMoreButton = memo(({ depth, onClick, isLoading }: LoadMoreButtonProps) => {
|
|
||||||
return (
|
|
||||||
<NodeButton
|
|
||||||
depth={depth}
|
|
||||||
className="text-muted-foreground hover:bg-accent/50 cursor-pointer"
|
|
||||||
icon={
|
|
||||||
isLoading ? (
|
|
||||||
<Loader2 className="w-4 h-4 shrink-0 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<MoreHorizontal className="w-4 h-4 shrink-0" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<span className="text-xs">{isLoading ? "Loading more..." : "Load more files"}</span>
|
|
||||||
</NodeButton>
|
</NodeButton>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -575,30 +474,17 @@ interface ButtonProps {
|
||||||
const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, children }: ButtonProps) => {
|
const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, children }: ButtonProps) => {
|
||||||
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
|
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
onClick?.();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onClick],
|
|
||||||
);
|
|
||||||
|
|
||||||
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}
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
>
|
||||||
{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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -613,7 +499,6 @@ interface BaseNode {
|
||||||
|
|
||||||
interface FileNode extends BaseNode {
|
interface FileNode extends BaseNode {
|
||||||
kind: "file";
|
kind: "file";
|
||||||
size?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FolderNode extends BaseNode {
|
interface FolderNode extends BaseNode {
|
||||||
|
|
@ -632,46 +517,13 @@ 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";
|
|
||||||
fileMap.set(file.path, {
|
fileMap.set(file.path, {
|
||||||
kind: isFile ? "file" : "folder",
|
kind: file.type === "file" ? "file" : "folder",
|
||||||
id: fileMap.size,
|
id: fileMap.size,
|
||||||
name,
|
name,
|
||||||
fullPath: file.path,
|
fullPath: file.path,
|
||||||
depth,
|
depth,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -740,7 +592,5 @@ function compareNodes(a: Node, b: Node): number {
|
||||||
return a.kind === "folder" ? -1 : 1;
|
return a.kind === "folder" ? -1 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (a.name < b.name) return -1;
|
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
|
||||||
if (a.name > b.name) return 1;
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 overflow-x-hidden",
|
||||||
"pointer-events-none absolute inset-0 w-full h-full",
|
"bg-size-[20px_20px] sm:bg-size-[40px_40px]",
|
||||||
"bg-[size:40px_40px]",
|
"bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
|
||||||
"bg-[linear-gradient(to_right,rgba(0,0,0,0.04)_1px,transparent_1px),linear-gradient(to_bottom,rgba(0,0,0,0.04)_1px,transparent_1px)]",
|
"dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
|
||||||
"dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
|
containerClassName,
|
||||||
"[mask-image:radial-gradient(ellipse_at_top,black_70%,transparent_100%)]",
|
)}
|
||||||
)}
|
>
|
||||||
/>
|
<div className={cn("relative container m-auto", className)}>{children}</div>
|
||||||
<div className={cn("relative container m-auto z-10", className)}>{children}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,80 @@
|
||||||
import { LifeBuoy, LogOut } from "lucide-react";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { LifeBuoy } from "lucide-react";
|
||||||
|
import { Outlet, redirect, useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { type AppContext } from "~/context";
|
import { appContext } from "~/context";
|
||||||
|
import { authMiddleware } from "~/middleware/auth";
|
||||||
|
import type { Route } from "./+types/layout";
|
||||||
|
import { AppBreadcrumb } from "./app-breadcrumb";
|
||||||
import { GridBackground } from "./grid-background";
|
import { GridBackground } from "./grid-background";
|
||||||
import { Button, buttonVariants } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
|
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
|
||||||
import { AppSidebar } from "./app-sidebar";
|
import { AppSidebar } from "./app-sidebar";
|
||||||
import { authClient } from "../lib/auth-client";
|
import { logoutMutation } from "../api-client/@tanstack/react-query.gen";
|
||||||
import { DevPanelListener } from "./dev-panel-listener";
|
|
||||||
import { Outlet, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { AppBreadcrumb } from "./app-breadcrumb";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
|
||||||
import { ThemeToggle } from "./theme-toggle";
|
|
||||||
|
|
||||||
type Props = {
|
export const clientMiddleware = [authMiddleware];
|
||||||
loaderData: AppContext;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Layout({ loaderData }: Props) {
|
export async function clientLoader({ context }: Route.LoaderArgs) {
|
||||||
|
const ctx = context.get(appContext);
|
||||||
|
|
||||||
|
if (ctx.user && !ctx.user.hasDownloadedResticPassword) {
|
||||||
|
throw redirect("/download-recovery-key");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout({ loaderData }: Route.ComponentProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const logout = useMutation({
|
||||||
await authClient.signOut({
|
...logoutMutation(),
|
||||||
fetchOptions: {
|
onSuccess: async () => {
|
||||||
onSuccess: () => {
|
navigate("/login", { replace: true });
|
||||||
void navigate({ to: "/login", replace: true });
|
},
|
||||||
},
|
onError: (error) => {
|
||||||
onError: ({ error }) => {
|
console.error(error);
|
||||||
toast.error("Logout failed", { description: error.message });
|
toast.error("Logout failed", { description: error.message });
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider defaultOpen={loaderData.sidebarOpen}>
|
<SidebarProvider defaultOpen={true}>
|
||||||
<AppSidebar isInstanceAdmin={loaderData.user?.role === "admin"} />
|
<AppSidebar />
|
||||||
<div className="w-full relative flex flex-col min-h-screen md:h-screen md:overflow-hidden">
|
<div className="w-full relative flex flex-col h-screen overflow-hidden">
|
||||||
<header className="z-50 bg-card-header border-b border-border/80 dark:border-border/50 shrink-0 h-16.25">
|
<header className="z-50 bg-card-header border-b border-border/50 shrink-0">
|
||||||
<div className="flex items-center h-full justify-between px-2 sm:px-8 mx-auto container gap-4">
|
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container">
|
||||||
<div className="flex items-center gap-4 min-w-0">
|
<div className="flex items-center gap-4">
|
||||||
<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,
|
||||||
|
<span className="text-strong-accent">{loaderData.user?.username}</span>
|
||||||
</span>
|
</span>
|
||||||
<ThemeToggle />
|
<Button variant="default" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}>
|
||||||
<Tooltip>
|
Logout
|
||||||
<TooltipTrigger asChild>
|
</Button>
|
||||||
<Button
|
<Button variant="default" size="sm" className="relative overflow-hidden hidden lg:inline-flex">
|
||||||
variant="ghost"
|
<a
|
||||||
size="icon"
|
href="https://github.com/nicotsx/zerobyte/issues/new"
|
||||||
className="rounded-full h-7 text-xs text-muted-foreground hover:text-foreground"
|
target="_blank"
|
||||||
onClick={handleLogout}
|
rel="noreferrer"
|
||||||
>
|
className="flex items-center gap-2"
|
||||||
<LogOut className="w-4 h-4" />
|
>
|
||||||
</Button>
|
<span className="flex items-center gap-2">
|
||||||
</TooltipTrigger>
|
<LifeBuoy />
|
||||||
<TooltipContent>Logout</TooltipContent>
|
<span>Report an issue</span>
|
||||||
</Tooltip>
|
</span>
|
||||||
<Tooltip>
|
</a>
|
||||||
<TooltipTrigger asChild>
|
</Button>
|
||||||
<a
|
|
||||||
href="https://github.com/nicotsx/zerobyte/issues/new/choose"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className={buttonVariants({
|
|
||||||
variant: "ghost",
|
|
||||||
size: "icon",
|
|
||||||
className:
|
|
||||||
"relative overflow-hidden hidden lg:inline-flex rounded-full h-7 w-7 text-muted-foreground hover:text-foreground",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<LifeBuoy className="w-4 h-4" />
|
|
||||||
</a>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Report an issue</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="main-content flex-1 md:overflow-y-auto">
|
<div className="main-content flex-1 overflow-y-auto">
|
||||||
<GridBackground>
|
<GridBackground>
|
||||||
<main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto">
|
<main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
@ -91,7 +82,6 @@ export function Layout({ loaderData }: Props) {
|
||||||
</GridBackground>
|
</GridBackground>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DevPanelListener />
|
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { Badge } from "~/client/components/ui/badge";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
|
||||||
|
|
||||||
type ManagedBadgeProps = {
|
|
||||||
label?: string;
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultMessage =
|
|
||||||
"This resource is provisioned at startup. Changes are useful for testing, but the next provisioning sync can overwrite or recreate it.";
|
|
||||||
|
|
||||||
export const ManagedBadge = ({ label = "Managed", message = defaultMessage }: ManagedBadgeProps) => {
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Badge variant="secondary">{label}</Badge>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p className="max-w-80 text-sm">{message}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -15,7 +15,7 @@ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: P
|
||||||
className={cn(
|
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-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
import { ChevronsUpDown } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuShortcut,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "~/client/components/ui/dropdown-menu";
|
|
||||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "~/client/components/ui/sidebar";
|
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { useOrganizationContext } from "../hooks/use-org-context";
|
|
||||||
|
|
||||||
function getOrganizationInitials(name?: string): string {
|
|
||||||
const trimmedName = name?.trim();
|
|
||||||
|
|
||||||
if (!trimmedName) {
|
|
||||||
return "O";
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmedName
|
|
||||||
.split(/\s+/)
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((part) => part.charAt(0).toUpperCase())
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OrganizationSwitcher() {
|
|
||||||
const { isMobile } = useSidebar();
|
|
||||||
const { organizations, activeOrganization } = useOrganizationContext();
|
|
||||||
|
|
||||||
const switchOrganizationMutation = useMutation({
|
|
||||||
mutationFn: async (organizationId: string) => {
|
|
||||||
const { error } = await authClient.organization.setActive({ organizationId });
|
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const message = error instanceof Error ? error.message : "Unexpected error while switching organizations";
|
|
||||||
toast.error("Failed to switch organization", { description: message });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (organizations === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (organizations.length <= 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarMenu className="mb-3">
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<SidebarMenuButton
|
|
||||||
size="lg"
|
|
||||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
|
||||||
>
|
|
||||||
<div className="bg-foreground text-sidebar-primary-foreground flex aspect-square size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg">
|
|
||||||
{activeOrganization?.logo ? (
|
|
||||||
<img
|
|
||||||
src={activeOrganization.logo}
|
|
||||||
alt={`${activeOrganization.name} logo`}
|
|
||||||
className="size-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs font-semibold">{getOrganizationInitials(activeOrganization?.name)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
||||||
<span className="truncate font-medium">{activeOrganization?.name}</span>
|
|
||||||
<span className="truncate text-xs">{organizations.length} organizations</span>
|
|
||||||
</div>
|
|
||||||
<ChevronsUpDown className="ml-auto group-data-[collapsible=icon]:hidden" />
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
|
||||||
align="start"
|
|
||||||
side={isMobile ? "bottom" : "right"}
|
|
||||||
sideOffset={4}
|
|
||||||
>
|
|
||||||
<DropdownMenuLabel className="text-muted-foreground text-xs">Organizations</DropdownMenuLabel>
|
|
||||||
{organizations.map((organization) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={organization.id}
|
|
||||||
onClick={() => switchOrganizationMutation.mutate(organization.id)}
|
|
||||||
className="gap-2 p-2"
|
|
||||||
disabled={switchOrganizationMutation.isPending}
|
|
||||||
>
|
|
||||||
<div className="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-md border">
|
|
||||||
{organization.logo ? (
|
|
||||||
<img src={organization.logo} alt={`${organization.name} logo`} className="size-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<span className="text-[10px] font-semibold">{getOrganizationInitials(organization.name)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="min-w-0 flex-1 truncate">{organization.name}</span>
|
|
||||||
<DropdownMenuShortcut>{organization.id === activeOrganization?.id && "Current"}</DropdownMenuShortcut>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { DirectoryBrowser } from "./file-browsers/directory-browser";
|
import { DirectoryBrowser } from "./directory-browser";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -15,11 +15,11 @@ export const PathSelector = ({ value, onChange }: Props) => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<DirectoryBrowser
|
<DirectoryBrowser
|
||||||
selectedPath={value}
|
|
||||||
onSelectPath={(path) => {
|
onSelectPath={(path) => {
|
||||||
onChange(path);
|
onChange(path);
|
||||||
setShowBrowser(false);
|
setShowBrowser(false);
|
||||||
}}
|
}}
|
||||||
|
selectedPath={value}
|
||||||
/>
|
/>
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setShowBrowser(false)}>
|
<Button type="button" variant="ghost" size="sm" onClick={() => setShowBrowser(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog";
|
|
||||||
import { ScrollArea } from "~/client/components/ui/scroll-area";
|
|
||||||
import { useTimeFormat } from "~/client/lib/datetime";
|
|
||||||
import type { UpdateInfoDto } from "~/server/modules/system/system.dto";
|
|
||||||
|
|
||||||
interface ReleaseNotesDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
updates: UpdateInfoDto | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReleaseNotesDialog({ open, onOpenChange, updates }: ReleaseNotesDialogProps) {
|
|
||||||
const { formatDate } = useTimeFormat();
|
|
||||||
|
|
||||||
if (!updates) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-h-[80vh] flex flex-col p-0">
|
|
||||||
<DialogHeader className="p-6 pb-2">
|
|
||||||
<DialogTitle className="flex items-center gap-2">Release Notes</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Current version: {updates.currentVersion} • Latest version: {updates.latestVersion}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<ScrollArea className="p-6 pt-2 h-[50vh] min-w-0">
|
|
||||||
<div className="space-y-8">
|
|
||||||
{updates.missedReleases.map((release) => (
|
|
||||||
<div key={release.version} className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between border-b pb-2">
|
|
||||||
<h3 className="text-lg font-bold text-foreground">{release.version}</h3>
|
|
||||||
<span className="text-sm text-muted-foreground">{formatDate(release.publishedAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none prose-pre:bg-muted prose-pre:text-muted-foreground prose-a:text-primary hover:prose-a:underline wrap-anywhere text-wrap prose-pre:whitespace-pre-wrap prose-pre:wrap-anywhere">
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{release.body}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -1,283 +1,181 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AlertTriangle, ChevronDown, Download, FolderOpen, RotateCcw } from "lucide-react";
|
import { useNavigate } from "react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ChevronDown, FileIcon, FolderOpen, RotateCcw } from "lucide-react";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
|
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "~/client/components/ui/alert-dialog";
|
|
||||||
import { PathSelector } from "~/client/components/path-selector";
|
import { PathSelector } from "~/client/components/path-selector";
|
||||||
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser";
|
import { FileTree } from "~/client/components/file-tree";
|
||||||
import { RestoreProgress } from "~/client/components/restore-progress";
|
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||||
import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events";
|
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
|
||||||
import { OVERWRITE_MODES, type OverwriteMode } from "@zerobyte/core/restic";
|
import type { Snapshot } from "~/client/lib/types";
|
||||||
import { isPathWithin } from "@zerobyte/core/utils";
|
|
||||||
import type { Repository } from "~/client/lib/types";
|
|
||||||
import { handleRepositoryError } from "~/client/lib/errors";
|
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
|
||||||
import { cn } from "~/client/lib/utils";
|
|
||||||
|
|
||||||
type RestoreLocation = "original" | "custom";
|
type RestoreLocation = "original" | "custom";
|
||||||
|
|
||||||
interface RestoreFormProps {
|
interface RestoreFormProps {
|
||||||
repository: Repository;
|
snapshot: Snapshot;
|
||||||
|
repositoryName: string;
|
||||||
snapshotId: string;
|
snapshotId: string;
|
||||||
returnPath: string;
|
returnPath: string;
|
||||||
queryBasePath?: string;
|
|
||||||
displayBasePath?: string;
|
|
||||||
hasNonPosixSnapshotPaths?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RestoreForm({
|
export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }: RestoreFormProps) {
|
||||||
repository,
|
|
||||||
snapshotId,
|
|
||||||
returnPath,
|
|
||||||
queryBasePath,
|
|
||||||
displayBasePath,
|
|
||||||
hasNonPosixSnapshotPaths = false,
|
|
||||||
}: RestoreFormProps) {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { addEventListener } = useServerEvents();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const snapshotBasePath = queryBasePath ?? "/";
|
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
|
||||||
const hasMismatchedDisplayBasePath = displayBasePath && !isPathWithin(displayBasePath, snapshotBasePath);
|
|
||||||
const restoreRequiresCustomTarget = hasNonPosixSnapshotPaths || hasMismatchedDisplayBasePath;
|
|
||||||
|
|
||||||
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>(
|
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>("original");
|
||||||
restoreRequiresCustomTarget ? "custom" : "original",
|
|
||||||
);
|
|
||||||
const [customTargetPath, setCustomTargetPath] = useState("");
|
const [customTargetPath, setCustomTargetPath] = useState("");
|
||||||
const [overwriteMode, setOverwriteMode] = useState<OverwriteMode>("always");
|
const [overwriteMode, setOverwriteMode] = useState<OverwriteMode>("always");
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
const [excludeXattr, setExcludeXattr] = useState("");
|
const [excludeXattr, setExcludeXattr] = useState("");
|
||||||
const [isRestoreActive, setIsRestoreActive] = useState(false);
|
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
|
||||||
const [restoreResult, setRestoreResult] = useState<RestoreCompletedEvent | null>(null);
|
|
||||||
const [showRestoreResultAlert, setShowRestoreResultAlert] = useState(false);
|
|
||||||
const restoreCompletedRef = useRef(false);
|
|
||||||
|
|
||||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||||
const [selectedPathKind, setSelectedPathKind] = useState<"file" | "dir" | null>(null);
|
|
||||||
const trimmedCustomTargetPath = customTargetPath.trim();
|
|
||||||
const hasCustomTargetPath = trimmedCustomTargetPath !== "";
|
|
||||||
const selectedPathCount = selectedPaths.size;
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { data: filesData, isLoading: filesLoading } = useQuery({
|
||||||
if (restoreRequiresCustomTarget) {
|
...listSnapshotFilesOptions({
|
||||||
setRestoreLocation("custom");
|
path: { name: repositoryName, snapshotId },
|
||||||
}
|
query: { path: volumeBasePath },
|
||||||
}, [restoreRequiresCustomTarget]);
|
}),
|
||||||
|
enabled: !!repositoryName && !!snapshotId,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const stripBasePath = useCallback(
|
||||||
const abortController = new AbortController();
|
(path: string): string => {
|
||||||
const signal = abortController.signal;
|
if (!volumeBasePath) return path;
|
||||||
|
if (path === volumeBasePath) return "/";
|
||||||
|
if (path.startsWith(`${volumeBasePath}/`)) {
|
||||||
|
const stripped = path.slice(volumeBasePath.length);
|
||||||
|
return stripped;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
},
|
||||||
|
[volumeBasePath],
|
||||||
|
);
|
||||||
|
|
||||||
addEventListener(
|
const addBasePath = useCallback(
|
||||||
"restore:started",
|
(displayPath: string): string => {
|
||||||
(startedData) => {
|
const vbp = volumeBasePath === "/" ? "" : volumeBasePath;
|
||||||
if (startedData.repositoryId === repository.shortId && startedData.snapshotId === snapshotId) {
|
|
||||||
restoreCompletedRef.current = false;
|
|
||||||
setIsRestoreActive(true);
|
|
||||||
setRestoreResult(null);
|
|
||||||
setShowRestoreResultAlert(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ signal },
|
|
||||||
);
|
|
||||||
|
|
||||||
addEventListener(
|
if (!vbp) return displayPath;
|
||||||
"restore:progress",
|
if (displayPath === "/") return vbp;
|
||||||
(progressData) => {
|
return `${vbp}${displayPath}`;
|
||||||
if (progressData.repositoryId === repository.shortId && progressData.snapshotId === snapshotId) {
|
},
|
||||||
if (restoreCompletedRef.current) {
|
[volumeBasePath],
|
||||||
return;
|
);
|
||||||
}
|
|
||||||
setIsRestoreActive(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ signal },
|
|
||||||
);
|
|
||||||
|
|
||||||
addEventListener(
|
const fileBrowser = useFileBrowser({
|
||||||
"restore:completed",
|
initialData: filesData,
|
||||||
(completedData) => {
|
isLoading: filesLoading,
|
||||||
if (completedData.repositoryId === repository.shortId && completedData.snapshotId === snapshotId) {
|
fetchFolder: async (path) => {
|
||||||
restoreCompletedRef.current = true;
|
return await queryClient.ensureQueryData(
|
||||||
setIsRestoreActive(false);
|
listSnapshotFilesOptions({
|
||||||
setRestoreResult(completedData);
|
path: { name: repositoryName, snapshotId },
|
||||||
setShowRestoreResultAlert(true);
|
query: { path },
|
||||||
}
|
}),
|
||||||
},
|
);
|
||||||
{ signal },
|
},
|
||||||
);
|
prefetchFolder: (path) => {
|
||||||
|
queryClient.prefetchQuery(
|
||||||
return () => {
|
listSnapshotFilesOptions({
|
||||||
abortController.abort();
|
path: { name: repositoryName, snapshotId },
|
||||||
};
|
query: { path },
|
||||||
}, [addEventListener, repository.shortId, snapshotId]);
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
pathTransform: {
|
||||||
|
strip: stripBasePath,
|
||||||
|
add: addBasePath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
|
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
|
||||||
...restoreSnapshotMutation(),
|
...restoreSnapshotMutation(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success("Restore completed", {
|
||||||
|
description: `Successfully restored ${data.filesRestored} file(s). ${data.filesSkipped} file(s) skipped.`,
|
||||||
|
});
|
||||||
|
navigate(returnPath);
|
||||||
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
restoreCompletedRef.current = true;
|
toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" });
|
||||||
setIsRestoreActive(false);
|
|
||||||
handleRepositoryError("Restore failed", error, repository.shortId);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleRestore = useCallback(() => {
|
const handleRestore = useCallback(() => {
|
||||||
const excludeXattrValues = excludeXattr
|
if (!repositoryName || !snapshotId) return;
|
||||||
.split(",")
|
|
||||||
.map((value) => value.trim())
|
const excludeXattrArray = excludeXattr
|
||||||
|
?.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const isCustomLocation = restoreLocation === "custom";
|
const isCustomLocation = restoreLocation === "custom";
|
||||||
const targetPath = isCustomLocation && hasCustomTargetPath ? trimmedCustomTargetPath : undefined;
|
const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined;
|
||||||
|
|
||||||
const includePaths = Array.from(selectedPaths);
|
const pathsArray = Array.from(selectedPaths);
|
||||||
|
const includePaths = pathsArray.map((path) => addBasePath(path));
|
||||||
restoreCompletedRef.current = false;
|
|
||||||
setIsRestoreActive(true);
|
|
||||||
setRestoreResult(null);
|
|
||||||
setShowRestoreResultAlert(false);
|
|
||||||
|
|
||||||
restoreSnapshot({
|
restoreSnapshot({
|
||||||
path: { shortId: repository.shortId },
|
path: { name: repositoryName },
|
||||||
body: {
|
body: {
|
||||||
snapshotId,
|
snapshotId,
|
||||||
include: includePaths.length > 0 ? includePaths : undefined,
|
include: includePaths.length > 0 ? includePaths : undefined,
|
||||||
selectedItemKind: includePaths.length === 1 ? (selectedPathKind ?? undefined) : undefined,
|
delete: deleteExtraFiles,
|
||||||
excludeXattr: excludeXattrValues.length > 0 ? excludeXattrValues : undefined,
|
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
|
||||||
targetPath,
|
targetPath,
|
||||||
overwrite: overwriteMode,
|
overwrite: overwriteMode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
repository.shortId,
|
repositoryName,
|
||||||
snapshotId,
|
snapshotId,
|
||||||
excludeXattr,
|
excludeXattr,
|
||||||
hasCustomTargetPath,
|
|
||||||
restoreLocation,
|
restoreLocation,
|
||||||
trimmedCustomTargetPath,
|
customTargetPath,
|
||||||
selectedPaths,
|
selectedPaths,
|
||||||
selectedPathKind,
|
addBasePath,
|
||||||
|
deleteExtraFiles,
|
||||||
overwriteMode,
|
overwriteMode,
|
||||||
restoreSnapshot,
|
restoreSnapshot,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const canRestore = restoreLocation === "original" || customTargetPath.trim();
|
||||||
if (selectedPaths.size > 1) return;
|
|
||||||
|
|
||||||
const url = new URL(
|
|
||||||
`/api/v1/repositories/${repository.shortId}/snapshots/${snapshotId}/dump`,
|
|
||||||
window.location.origin,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectedPath] = selectedPaths;
|
|
||||||
if (selectedPath) {
|
|
||||||
url.searchParams.set("path", selectedPath);
|
|
||||||
if (selectedPathKind) {
|
|
||||||
url.searchParams.set("kind", selectedPathKind);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.assign(url.toString());
|
|
||||||
}, [repository.shortId, snapshotId, selectedPathKind, selectedPaths]);
|
|
||||||
|
|
||||||
const acknowledgeRestoreResult = useCallback(() => {
|
|
||||||
setShowRestoreResultAlert(false);
|
|
||||||
setRestoreResult(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleResultAlertOpenChange = useCallback((open: boolean) => {
|
|
||||||
if (open) {
|
|
||||||
setShowRestoreResultAlert(true);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const canRestore = restoreRequiresCustomTarget
|
|
||||||
? hasCustomTargetPath
|
|
||||||
: restoreLocation === "original" || hasCustomTargetPath;
|
|
||||||
const canDownload = selectedPathCount <= 1;
|
|
||||||
const isRestoreRunning = isRestoring || isRestoreActive;
|
|
||||||
|
|
||||||
function getDownloadButtonText(): string {
|
|
||||||
if (selectedPathCount > 0) {
|
|
||||||
return `Download ${selectedPathCount} ${selectedPathCount === 1 ? "item" : "items"}`;
|
|
||||||
}
|
|
||||||
return "Download All";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRestoreButtonText(): string {
|
|
||||||
if (isRestoreRunning) {
|
|
||||||
return "Restoring...";
|
|
||||||
}
|
|
||||||
if (selectedPathCount > 0) {
|
|
||||||
return `Restore ${selectedPathCount} ${selectedPathCount === 1 ? "item" : "items"}`;
|
|
||||||
}
|
|
||||||
return "Restore All";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Restore Snapshot</h1>
|
<h1 className="text-2xl font-bold">Restore Snapshot</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{repository.name} / {snapshotId}
|
{repositoryName} / {snapshotId}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={() => navigate({ to: returnPath })}>
|
<Button variant="outline" onClick={() => navigate(returnPath)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Tooltip>
|
<Button variant="primary" onClick={handleRestore} disabled={isRestoring || !canRestore}>
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="inline-flex">
|
|
||||||
<Button variant="outline" onClick={handleDownload} disabled={!canDownload}>
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
{getDownloadButtonText()}
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className={cn({ hidden: canDownload })}>
|
|
||||||
<p>Download is available only for one selected item, or with no selection to download everything.</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Button variant="primary" onClick={handleRestore} disabled={isRestoreRunning || !canRestore}>
|
|
||||||
<RotateCcw className="h-4 w-4 mr-2" />
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
{getRestoreButtonText()}
|
{isRestoring
|
||||||
|
? "Restoring..."
|
||||||
|
: selectedPaths.size > 0
|
||||||
|
? `Restore ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`
|
||||||
|
: "Restore All"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{isRestoreRunning && <RestoreProgress repositoryId={repository.shortId} snapshotId={snapshotId} />}
|
|
||||||
|
|
||||||
{restoreRequiresCustomTarget && (
|
|
||||||
<Alert variant="warning">
|
|
||||||
<AlertTriangle className="size-4" />
|
|
||||||
<AlertTitle>Source paths do not match</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
This snapshot was created from source paths that do not match this Zerobyte server or the current linked
|
|
||||||
volume. Restoring to the original location is unavailable. Restore it to a custom location, or download
|
|
||||||
it instead.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Restore Location</CardTitle>
|
<CardTitle>Restore Location</CardTitle>
|
||||||
|
|
@ -291,7 +189,6 @@ export function RestoreForm({
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex justify-start gap-2"
|
className="flex justify-start gap-2"
|
||||||
onClick={() => setRestoreLocation("original")}
|
onClick={() => setRestoreLocation("original")}
|
||||||
disabled={!!restoreRequiresCustomTarget}
|
|
||||||
>
|
>
|
||||||
<RotateCcw size={16} className="mr-1" />
|
<RotateCcw size={16} className="mr-1" />
|
||||||
Original location
|
Original location
|
||||||
|
|
@ -369,6 +266,16 @@ export function RestoreForm({
|
||||||
Exclude specific extended attributes during restore (comma-separated)
|
Exclude specific extended attributes during restore (comma-separated)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="delete-extra"
|
||||||
|
checked={deleteExtraFiles}
|
||||||
|
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
|
||||||
|
Delete files not present in the snapshot
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -383,44 +290,37 @@ export function RestoreForm({
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
|
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
|
||||||
<SnapshotTreeBrowser
|
{fileBrowser.isLoading && (
|
||||||
repositoryId={repository.shortId}
|
<div className="flex items-center justify-center flex-1">
|
||||||
snapshotId={snapshotId}
|
<p className="text-muted-foreground">Loading files...</p>
|
||||||
queryBasePath={snapshotBasePath}
|
</div>
|
||||||
displayBasePath={displayBasePath}
|
)}
|
||||||
pageSize={500}
|
|
||||||
className="flex flex-1 min-h-0 flex-col"
|
{fileBrowser.isEmpty && (
|
||||||
treeContainerClassName="overflow-auto flex-1 min-h-0 border border-border rounded-md bg-card m-4"
|
<div className="flex flex-col items-center justify-center flex-1 text-center p-8">
|
||||||
treeClassName="px-2 py-2"
|
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
||||||
loadingMessage="Loading files..."
|
<p className="text-muted-foreground">No files in this snapshot</p>
|
||||||
emptyMessage="No files in this snapshot"
|
</div>
|
||||||
withCheckboxes
|
)}
|
||||||
selectedPaths={selectedPaths}
|
|
||||||
onSelectionChange={setSelectedPaths}
|
{!fileBrowser.isLoading && !fileBrowser.isEmpty && (
|
||||||
onSingleSelectionKindChange={setSelectedPathKind}
|
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
|
||||||
stateClassName="flex-1 min-h-0"
|
<FileTree
|
||||||
/>
|
files={fileBrowser.fileArray}
|
||||||
|
onFolderExpand={fileBrowser.handleFolderExpand}
|
||||||
|
onFolderHover={fileBrowser.handleFolderHover}
|
||||||
|
expandedFolders={fileBrowser.expandedFolders}
|
||||||
|
loadingFolders={fileBrowser.loadingFolders}
|
||||||
|
className="px-2 py-2"
|
||||||
|
withCheckboxes={true}
|
||||||
|
selectedPaths={selectedPaths}
|
||||||
|
onSelectionChange={setSelectedPaths}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AlertDialog open={showRestoreResultAlert} onOpenChange={handleResultAlertOpenChange}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
{restoreResult?.status === "success" ? "Restore completed" : "Restore failed"}
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{restoreResult?.status === "success"
|
|
||||||
? `Snapshot ${snapshotId} was restored successfully.`
|
|
||||||
: restoreResult?.error || `Snapshot ${snapshotId} could not be restored.`}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogAction onClick={acknowledgeRestoreResult}>OK</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { ByteSize } from "~/client/components/bytes-size";
|
|
||||||
import { useFormatBytes } from "~/client/hooks/use-format-bytes";
|
|
||||||
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
|
|
||||||
import { Card } from "~/client/components/ui/card";
|
|
||||||
import { Progress } from "~/client/components/ui/progress";
|
|
||||||
import { type RestoreProgressEvent, useServerEvents } from "~/client/hooks/use-server-events";
|
|
||||||
import { formatDuration } from "~/utils/utils";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
repositoryId: string;
|
|
||||||
snapshotId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RestoreProgress = ({ repositoryId, snapshotId }: Props) => {
|
|
||||||
const formatBytes = useFormatBytes();
|
|
||||||
const { locale } = useRootLoaderData();
|
|
||||||
const { addEventListener } = useServerEvents();
|
|
||||||
const [progress, setProgress] = useState<RestoreProgressEvent | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const abortController = new AbortController();
|
|
||||||
|
|
||||||
addEventListener(
|
|
||||||
"restore:progress",
|
|
||||||
(progressData) => {
|
|
||||||
if (progressData.repositoryId === repositoryId && progressData.snapshotId === snapshotId) {
|
|
||||||
setProgress(progressData);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ signal: abortController.signal },
|
|
||||||
);
|
|
||||||
|
|
||||||
addEventListener(
|
|
||||||
"restore:completed",
|
|
||||||
(completedData) => {
|
|
||||||
if (completedData.repositoryId === repositoryId && completedData.snapshotId === snapshotId) {
|
|
||||||
setProgress(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ signal: abortController.signal },
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => abortController.abort();
|
|
||||||
}, [addEventListener, repositoryId, snapshotId]);
|
|
||||||
|
|
||||||
if (!progress) {
|
|
||||||
return (
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
||||||
<span className="font-medium">Restore in progress</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const percentDone = Math.round(progress.percent_done * 100);
|
|
||||||
const speed = progress.seconds_elapsed > 0 ? formatBytes(progress.bytes_restored / progress.seconds_elapsed) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
||||||
<span className="font-medium">Restore in progress</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium text-primary">{percentDone}%</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Progress value={percentDone} className="h-2 mb-4" />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase text-muted-foreground">Files</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{progress.files_restored.toLocaleString(locale)}
|
|
||||||
/
|
|
||||||
{progress.total_files.toLocaleString(locale)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase text-muted-foreground">Data</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
<ByteSize bytes={progress.bytes_restored} base={1024} />
|
|
||||||
/
|
|
||||||
<ByteSize bytes={progress.total_bytes} base={1024} />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase text-muted-foreground">Elapsed</p>
|
|
||||||
<p className="font-medium">{formatDuration(progress.seconds_elapsed)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase text-muted-foreground">Speed</p>
|
|
||||||
<p className="font-medium">{speed ? `${speed.text} ${speed.unit}/s` : "Calculating..."}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { cn } from "~/client/lib/utils";
|
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
|
|
||||||
|
|
||||||
interface RetentionCategoryBadgesProps {
|
|
||||||
categories: string[];
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryColors: Record<string, string> = {
|
|
||||||
last: "bg-blue-500/20 text-blue-700 dark:text-blue-300 border-blue-500/30",
|
|
||||||
hourly: "bg-cyan-500/20 text-cyan-700 dark:text-cyan-300 border-cyan-500/30",
|
|
||||||
daily: "bg-green-500/20 text-green-700 dark:text-green-300 border-green-500/30",
|
|
||||||
weekly: "bg-orange-500/20 text-orange-700 dark:text-orange-300 border-orange-500/30",
|
|
||||||
monthly: "bg-purple-500/20 text-purple-700 dark:text-purple-300 border-purple-500/30",
|
|
||||||
yearly: "bg-red-500/20 text-red-700 dark:text-red-300 border-red-500/30",
|
|
||||||
};
|
|
||||||
|
|
||||||
const categoryLabels: Record<string, string> = {
|
|
||||||
last: "Last",
|
|
||||||
hourly: "Hourly",
|
|
||||||
daily: "Daily",
|
|
||||||
weekly: "Weekly",
|
|
||||||
monthly: "Monthly",
|
|
||||||
yearly: "Yearly",
|
|
||||||
};
|
|
||||||
|
|
||||||
function Badge({ category }: { category: string }) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border",
|
|
||||||
categoryColors[category] || "bg-gray-500/20 text-gray-700 dark:text-gray-300 border-gray-500/30",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{categoryLabels[category] || category}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RetentionCategoryBadges({ categories, className }: RetentionCategoryBadgesProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
if (categories.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const order = ["last", "hourly", "daily", "weekly", "monthly", "yearly"];
|
|
||||||
const sortedCategories = [...categories].sort((a, b) => {
|
|
||||||
const indexA = order.indexOf(a);
|
|
||||||
const indexB = order.indexOf(b);
|
|
||||||
if (indexA === -1) return 1;
|
|
||||||
if (indexB === -1) return -1;
|
|
||||||
return indexA - indexB;
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstCategory = sortedCategories[0];
|
|
||||||
const hasMore = sortedCategories.length > 1;
|
|
||||||
|
|
||||||
if (!hasMore) {
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<Badge category={firstCategory} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HoverCard open={open} onOpenChange={setOpen}>
|
|
||||||
<HoverCardTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn("cursor-pointer bg-transparent p-0 border-0", className)}
|
|
||||||
aria-label={`View ${categories.length} retention categories`}
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<Badge category={`${categories.length} tags`} />
|
|
||||||
</button>
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent className="w-auto p-2">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{sortedCategories.map((category) => (
|
|
||||||
<Badge key={category} category={category} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Calendar, Clock, Database, HardDrive, Tag, Trash2, X } from "lucide-react";
|
import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } from "lucide-react";
|
||||||
|
import { Link, useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ByteSize } from "~/client/components/bytes-size";
|
import { ByteSize } from "~/client/components/bytes-size";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -16,167 +17,83 @@ import {
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "~/client/components/ui/alert-dialog";
|
} from "~/client/components/ui/alert-dialog";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "~/client/components/ui/dialog";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
|
||||||
import { useTimeFormat } from "~/client/lib/datetime";
|
|
||||||
import { formatDuration } from "~/utils/utils";
|
import { formatDuration } from "~/utils/utils";
|
||||||
import {
|
import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
deleteSnapshotsMutation,
|
|
||||||
listSnapshotsQueryKey,
|
|
||||||
tagSnapshotsMutation,
|
|
||||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import type { BackupSchedule, Snapshot } from "../lib/types";
|
import type { BackupSchedule, Snapshot } from "../lib/types";
|
||||||
import { cn } from "../lib/utils";
|
|
||||||
import { Link, useNavigate } from "@tanstack/react-router";
|
|
||||||
import type { ListSnapshotsData } from "~/client/api-client/types.gen";
|
|
||||||
import type { Options } from "~/client/api-client/client/types.gen";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
snapshots: Snapshot[];
|
snapshots: Snapshot[];
|
||||||
backups: BackupSchedule[];
|
backups: BackupSchedule[];
|
||||||
repositoryId: string;
|
repositoryName: string;
|
||||||
listSnapshotsQueryOptions: Options<ListSnapshotsData>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshotsQueryOptions }: Props) => {
|
export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) => {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { formatDateTime } = useTimeFormat();
|
const queryClient = useQueryClient();
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const deleteSnapshot = useMutation({
|
||||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
...deleteSnapshotMutation(),
|
||||||
const [showReTagDialog, setShowReTagDialog] = useState(false);
|
|
||||||
const [targetScheduleId, setTargetScheduleId] = useState<string>("");
|
|
||||||
|
|
||||||
const deleteSnapshots = useMutation({
|
|
||||||
...deleteSnapshotsMutation(),
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
const snapshotIds = variables.body?.snapshotIds ?? [];
|
|
||||||
const queryKey = listSnapshotsQueryKey(listSnapshotsQueryOptions);
|
|
||||||
|
|
||||||
queryClient.setQueryData<Snapshot[]>(queryKey, (old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return old.filter((snapshot) => !snapshotIds.includes(snapshot.short_id));
|
|
||||||
});
|
|
||||||
|
|
||||||
void queryClient.invalidateQueries({ queryKey });
|
|
||||||
setShowBulkDeleteConfirm(false);
|
|
||||||
setSelectedIds(new Set());
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const tagSnapshots = useMutation({
|
|
||||||
...tagSnapshotsMutation(),
|
|
||||||
onMutate: () => {
|
|
||||||
setShowReTagDialog(false);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setShowReTagDialog(false);
|
queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
|
||||||
setSelectedIds(new Set());
|
setShowDeleteConfirm(false);
|
||||||
setTargetScheduleId("");
|
setSnapshotToDelete(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleRowClick = (snapshotId: string) => {
|
const handleDeleteClick = (e: React.MouseEvent, snapshotId: string) => {
|
||||||
void navigate({ to: `/repositories/${repositoryId}/${snapshotId}` });
|
e.stopPropagation();
|
||||||
|
setSnapshotToDelete(snapshotId);
|
||||||
|
setShowDeleteConfirm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSelectAll = () => {
|
const handleConfirmDelete = () => {
|
||||||
if (selectedIds.size === snapshots.length) {
|
if (snapshotToDelete) {
|
||||||
setSelectedIds(new Set());
|
toast.promise(
|
||||||
} else {
|
deleteSnapshot.mutateAsync({
|
||||||
setSelectedIds(new Set(snapshots.map((s) => s.short_id)));
|
path: { name: repositoryName, snapshotId: snapshotToDelete },
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading: "Deleting snapshot...",
|
||||||
|
success: "Snapshot deleted successfully",
|
||||||
|
error: (error) => parseError(error)?.message || "Failed to delete snapshot",
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkDelete = () => {
|
const handleRowClick = (snapshotId: string) => {
|
||||||
toast.promise(
|
navigate(`/repositories/${repositoryName}/${snapshotId}`);
|
||||||
deleteSnapshots.mutateAsync({
|
|
||||||
path: { shortId: repositoryId },
|
|
||||||
body: { snapshotIds: Array.from(selectedIds) },
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
loading: `Deleting ${selectedIds.size} snapshots...`,
|
|
||||||
success: "Snapshots deleted successfully",
|
|
||||||
error: (error) => parseError(error)?.message || "Failed to delete snapshots",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkReTag = () => {
|
|
||||||
const schedule = backups.find((b) => b.shortId === targetScheduleId);
|
|
||||||
if (!schedule) return;
|
|
||||||
|
|
||||||
toast.promise(
|
|
||||||
tagSnapshots.mutateAsync({
|
|
||||||
path: { shortId: repositoryId },
|
|
||||||
body: {
|
|
||||||
snapshotIds: Array.from(selectedIds),
|
|
||||||
set: [schedule.shortId],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
loading: `Re-tagging ${selectedIds.size} snapshots...`,
|
|
||||||
success: `Snapshots re-tagged to ${schedule.name}`,
|
|
||||||
error: (error) => parseError(error)?.message || "Failed to re-tag snapshots",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto relative">
|
<div className="overflow-x-auto">
|
||||||
<Table className="border-t">
|
<Table className="border-t">
|
||||||
<TableHeader className="bg-card-header">
|
<TableHeader className="bg-card-header">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-10">
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedIds.size === snapshots.length && snapshots.length > 0}
|
|
||||||
onCheckedChange={toggleSelectAll}
|
|
||||||
aria-label="Select all"
|
|
||||||
/>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="uppercase">Snapshot ID</TableHead>
|
<TableHead className="uppercase">Snapshot ID</TableHead>
|
||||||
<TableHead className="uppercase">Schedule</TableHead>
|
<TableHead className="uppercase">Schedule</TableHead>
|
||||||
<TableHead className="uppercase">Date & Time</TableHead>
|
<TableHead className="uppercase">Date & Time</TableHead>
|
||||||
<TableHead className="uppercase">Size</TableHead>
|
<TableHead className="uppercase">Size</TableHead>
|
||||||
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
|
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
|
||||||
|
<TableHead className="uppercase hidden text-right lg:table-cell">Paths</TableHead>
|
||||||
|
<TableHead className="uppercase text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{snapshots.map((snapshot) => {
|
{snapshots.map((snapshot) => {
|
||||||
const backup = backups.find((b) => snapshot.tags.includes(b.shortId));
|
const backupIds = snapshot.tags.map(Number).filter((tag) => !Number.isNaN(tag));
|
||||||
const isSelected = selectedIds.has(snapshot.short_id);
|
const backup = backups.find((b) => backupIds.includes(b.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={snapshot.short_id}
|
key={snapshot.short_id}
|
||||||
className={cn("hover:bg-accent/50 cursor-pointer", isSelected && "bg-accent/30")}
|
className="hover:bg-accent/50 cursor-pointer"
|
||||||
onClick={() => handleRowClick(snapshot.short_id)}
|
onClick={() => handleRowClick(snapshot.short_id)}
|
||||||
>
|
>
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Checkbox
|
|
||||||
checked={isSelected}
|
|
||||||
onCheckedChange={() => {
|
|
||||||
const newSelected = new Set(selectedIds);
|
|
||||||
if (newSelected.has(snapshot.short_id)) {
|
|
||||||
newSelected.delete(snapshot.short_id);
|
|
||||||
} else {
|
|
||||||
newSelected.add(snapshot.short_id);
|
|
||||||
}
|
|
||||||
setSelectedIds(newSelected);
|
|
||||||
}}
|
|
||||||
aria-label={`Select snapshot ${snapshot.short_id}` as string}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-sm">
|
<TableCell className="font-mono text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||||
|
|
@ -187,12 +104,11 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
hidden={!backup}
|
hidden={!backup}
|
||||||
to={backup ? `/backups/$backupId` : "."}
|
to={backup ? `/backups/${backup.id}` : "#"}
|
||||||
params={backup ? { backupId: backup.shortId } : {}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
<span className="text-sm">{backup ? backup.name : "-"}</span>
|
<span className="text-sm">{backup ? backup.id : "-"}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<span hidden={!!backup} className="text-sm text-muted-foreground">
|
<span hidden={!!backup} className="text-sm text-muted-foreground">
|
||||||
-
|
-
|
||||||
|
|
@ -202,7 +118,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm">{formatDateTime(snapshot.time)}</span>
|
<span className="text-sm">{new Date(snapshot.time).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|
@ -219,6 +135,37 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots
|
||||||
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
|
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<FolderTree className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1 cursor-help">
|
||||||
|
{snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-md">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{snapshot.paths.map((path) => (
|
||||||
|
<div key={`${snapshot.short_id}-${path}`} className="text-xs font-mono">
|
||||||
|
{path}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => handleDeleteClick(e, snapshot.short_id)}
|
||||||
|
disabled={deleteSnapshot.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -226,99 +173,27 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedIds.size > 0 && (
|
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-4 duration-300">
|
|
||||||
<div className="bg-card border shadow-2xl rounded-full px-4 py-2 flex items-center gap-4 min-w-75 justify-between">
|
|
||||||
<div className="flex items-center gap-3 border-r pr-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-full"
|
|
||||||
onClick={() => setSelectedIds(new Set())}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm font-medium">{selectedIds.size} selected</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="rounded-full gap-2"
|
|
||||||
onClick={() => setShowReTagDialog(true)}
|
|
||||||
>
|
|
||||||
<Tag className="h-4 w-4 mr-2" />
|
|
||||||
Re-tag
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
className="rounded-full gap-2"
|
|
||||||
onClick={() => setShowBulkDeleteConfirm(true)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AlertDialog open={showBulkDeleteConfirm} onOpenChange={setShowBulkDeleteConfirm}>
|
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete {selectedIds.size} snapshots?</AlertDialogTitle>
|
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the selected snapshots and all their data from
|
This action cannot be undone. This will permanently delete the snapshot and all its data from the
|
||||||
the repository.
|
repository.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={handleBulkDelete}
|
onClick={handleConfirmDelete}
|
||||||
disabled={deleteSnapshots.isPending}
|
disabled={deleteSnapshot.isPending}
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
>
|
>
|
||||||
Delete {selectedIds.size} snapshots
|
Delete snapshot
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
<Dialog open={showReTagDialog} onOpenChange={setShowReTagDialog}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Re-tag snapshots</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Select a backup schedule to re-tag the {selectedIds.size} selected snapshots. All {selectedIds.size}{" "}
|
|
||||||
selected snapshots will be associated with the chosen schedule.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="py-4">
|
|
||||||
<Select value={targetScheduleId} onValueChange={setTargetScheduleId}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a schedule" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{backups.map((backup) => (
|
|
||||||
<SelectItem key={backup.shortId} value={backup.shortId}>
|
|
||||||
{backup.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setShowReTagDialog(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleBulkReTag} disabled={!targetScheduleId}>
|
|
||||||
Apply tags
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
|
||||||
import { ClientOnly } from "@tanstack/react-router";
|
|
||||||
import { GripVertical } from "lucide-react";
|
|
||||||
import type { PropsWithChildren } from "react";
|
|
||||||
|
|
||||||
interface SortableBackupCardProps {
|
|
||||||
isDragging?: boolean;
|
|
||||||
uniqueId: string | number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SortableCard({ isDragging, uniqueId, children }: PropsWithChildren<SortableBackupCardProps>) {
|
|
||||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
|
||||||
id: uniqueId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
opacity: isDragging ? 0.5 : 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={setNodeRef} style={style} className="relative group">
|
|
||||||
<ClientOnly>
|
|
||||||
<div
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
className="absolute left-1/2 -translate-x-1/2 top-1 z-10 cursor-grab active:cursor-grabbing opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-muted/50 bg-background/80 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
<GripVertical className="h-4 w-4 text-muted-foreground rotate-90" />
|
|
||||||
</div>
|
|
||||||
</ClientOnly>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { createContext, useCallback, useContext, useState } from "react";
|
|
||||||
|
|
||||||
type Theme = "light" | "dark";
|
|
||||||
|
|
||||||
type ThemeContextValue = {
|
|
||||||
theme: Theme;
|
|
||||||
setTheme: (theme: Theme) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
||||||
|
|
||||||
export const THEME_COOKIE_NAME = "theme";
|
|
||||||
const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
|
||||||
const DEFAULT_THEME: Theme = "dark";
|
|
||||||
|
|
||||||
export function ThemeProvider({ children, initialTheme }: { children: React.ReactNode; initialTheme?: Theme }) {
|
|
||||||
const [theme, setThemeState] = useState<Theme>(initialTheme ?? DEFAULT_THEME);
|
|
||||||
|
|
||||||
const setTheme = useCallback((newTheme: Theme) => {
|
|
||||||
setThemeState(newTheme);
|
|
||||||
document.cookie = `${THEME_COOKIE_NAME}=${newTheme}; path=/; max-age=${THEME_COOKIE_MAX_AGE}`;
|
|
||||||
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
|
||||||
document.documentElement.style.colorScheme = newTheme;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return <ThemeContext value={{ theme, setTheme }}>{children}</ThemeContext>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTheme() {
|
|
||||||
const context = useContext(ThemeContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useTheme must be used within a ThemeProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { Moon, Sun } from "lucide-react";
|
|
||||||
import { useTheme } from "./theme-provider";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
|
||||||
|
|
||||||
export function ThemeToggle() {
|
|
||||||
const { theme, setTheme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="relative rounded-full h-7 w-7 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
|
||||||
aria-label="Toggle theme"
|
|
||||||
>
|
|
||||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
|
||||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Toggle theme</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { useTimeFormat, type DateInput } from "~/client/lib/datetime";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
date: DateInput;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function TimeAgo({ date, className }: Props) {
|
|
||||||
const { formatTimeAgo } = useTimeFormat();
|
|
||||||
|
|
||||||
return <span className={className}>{formatTimeAgo(date)}</span>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// fallow-ignore-file unused-export
|
|
||||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
import * 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";
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,9 @@ const Alert = React.forwardRef<
|
||||||
));
|
));
|
||||||
Alert.displayName = "Alert";
|
Alert.displayName = "Alert";
|
||||||
|
|
||||||
const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props}>{props.children}</h5>
|
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
AlertTitle.displayName = "AlertTitle";
|
AlertTitle.displayName = "AlertTitle";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// fallow-ignore-file unused-export
|
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
// 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";
|
||||||
|
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" className={cn("min-w-0", className)} {...props} />;
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
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 flex-wrap items-center gap-1.5 text-sm wrap-break-words sm:gap-2.5",
|
||||||
|
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", 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({
|
||||||
|
|
@ -35,25 +35,20 @@ function BreadcrumbLink({
|
||||||
const Comp = asChild ? Slot : "a";
|
const Comp = asChild ? Slot : "a";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp data-slot="breadcrumb-link" className={cn("hover:text-foreground transition-colors", className)} {...props} />
|
||||||
data-slot="breadcrumb-link"
|
|
||||||
className={cn("hover:text-foreground transition-colors truncate", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BreadcrumbPage({ className, children, ...props }: React.ComponentProps<"a">) {
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
return (
|
return (
|
||||||
<a
|
<span
|
||||||
data-slot="breadcrumb-page"
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
aria-current="page"
|
aria-current="page"
|
||||||
className={cn("text-foreground font-normal truncate", className)}
|
className={cn("text-foreground font-normal", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
/>
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,21 @@
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import * as React from "react";
|
import type * as React from "react";
|
||||||
|
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { useMinimumDuration } from "~/client/hooks/useMinimumDuration";
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex cursor-pointer uppercase rounded-sm items-center justify-center gap-2 whitespace-nowrap text-xs font-semibold tracking-wide transition-all active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring border-0",
|
"inline-flex cursor-pointer uppercase rounded-sm items-center justify-center gap-2 whitespace-nowrap text-xs font-semibold tracking-wide transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring border-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-transparent text-foreground hover:bg-muted border hover:border-border",
|
default: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80",
|
||||||
primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50",
|
primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50",
|
||||||
destructive:
|
destructive:
|
||||||
"border border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground focus-visible:ring-destructive/50",
|
"border border-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/50 text-destructive hover:text-white",
|
||||||
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
|
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
secondary: "bg-transparent text-foreground hover:bg-muted border",
|
secondary: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
|
|
@ -34,32 +33,28 @@ const buttonVariants = cva(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const MINIMUM_LOADING_DURATION = 300;
|
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
variant,
|
variant,
|
||||||
size,
|
size,
|
||||||
asChild = false,
|
asChild = false,
|
||||||
loading,
|
loading,
|
||||||
disabled,
|
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: React.ComponentProps<"button"> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
} & { loading?: boolean }) {
|
} & { loading?: boolean }) {
|
||||||
const Comp = asChild ? Slot : "button";
|
const Comp = asChild ? Slot : "button";
|
||||||
const isLoading = useMinimumDuration(loading ?? false, MINIMUM_LOADING_DURATION);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
{...props}
|
disabled={loading}
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
className={cn(buttonVariants({ variant, size, className }), "transition-all")}
|
className={cn(buttonVariants({ variant, size, className }), "transition-all")}
|
||||||
disabled={disabled || loading || isLoading}
|
{...props}
|
||||||
>
|
>
|
||||||
<Loader2 className={cn("h-4 w-4 animate-spin absolute", { invisible: !isLoading })} />
|
<Loader2 className={cn("h-4 w-4 animate-spin absolute", { invisible: !loading })} />
|
||||||
<div className={cn("flex items-center justify-center", { invisible: isLoading })}>{props.children}</div>
|
<div className={cn("flex items-center justify-center", { invisible: loading })}>{props.children}</div>
|
||||||
</Comp>
|
</Comp>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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-[-2px] top-[-2px] h-0.5 w-4 bg-white/80" />
|
||||||
className={cn(
|
<span className="absolute left-[-2px] top-[-2px] 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-[-2px] top-[-2px] h-0.5 w-4 bg-white/80" />
|
||||||
{
|
<span className="absolute right-[-2px] top-[-2px] h-4 w-0.5 bg-white/80" />
|
||||||
"group-hover:opacity-100": interactive,
|
<span className="absolute left-[-2px] bottom-[-2px] h-0.5 w-4 bg-white/80" />
|
||||||
},
|
<span className="absolute left-[-2px] bottom-[-2px] h-4 w-0.5 bg-white/80" />
|
||||||
)}
|
<span className="absolute right-[-2px] bottom-[-2px] h-0.5 w-4 bg-white/80" />
|
||||||
>
|
<span className="absolute right-[-2px] bottom-[-2px] 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>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,13 @@
|
||||||
// fallow-ignore-file unused-export
|
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
// biome-ignore-all lint: reason
|
// biome-ignore-all lint: reason
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as RechartsPrimitive from "recharts";
|
import * as RechartsPrimitive from "recharts";
|
||||||
|
|
||||||
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
|
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
const THEMES = { light: "", dark: ".dark" } as const;
|
const THEMES = { light: "", dark: ".dark" } as const;
|
||||||
|
|
||||||
// fallow-ignore-next-line unused-type
|
|
||||||
export type ChartConfig = {
|
export type ChartConfig = {
|
||||||
[k in string]: {
|
[k in string]: {
|
||||||
label?: React.ReactNode;
|
label?: React.ReactNode;
|
||||||
|
|
@ -119,7 +116,6 @@ function ChartTooltipContent({
|
||||||
labelKey?: string;
|
labelKey?: string;
|
||||||
}) {
|
}) {
|
||||||
const { config } = useChart();
|
const { config } = useChart();
|
||||||
const { locale } = useRootLoaderData();
|
|
||||||
|
|
||||||
const tooltipLabel = React.useMemo(() => {
|
const tooltipLabel = React.useMemo(() => {
|
||||||
if (hideLabel || !payload?.length) {
|
if (hideLabel || !payload?.length) {
|
||||||
|
|
@ -209,7 +205,7 @@ function ChartTooltipContent({
|
||||||
</div>
|
</div>
|
||||||
{item.value && (
|
{item.value && (
|
||||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
{item.value.toLocaleString(locale)}
|
{item.value.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { copyToClipboard } from "~/utils/clipboard";
|
||||||
|
|
||||||
interface CodeBlockProps {
|
interface CodeBlockProps {
|
||||||
code: string;
|
code: string;
|
||||||
|
|
@ -7,18 +9,30 @@ interface CodeBlockProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
|
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
|
||||||
|
const handleCopy = async () => {
|
||||||
|
await copyToClipboard(code);
|
||||||
|
toast.success("Code copied to clipboard");
|
||||||
|
};
|
||||||
|
|
||||||
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" />
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
|
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
|
||||||
{filename && <span className="ml-3 font-medium">{filename}</span>}
|
{filename && <span className="ml-3 font-medium">{filename}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopy()}
|
||||||
|
className="cursor-pointer rounded-md bg-white/5 px-2 py-1 text-[11px] font-medium ring-1 ring-inset ring-white/10 transition hover:bg-white/10 active:translate-y-px"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
</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">{code}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
import * as React from "react";
|
|
||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
import { cn } from "~/client/lib/utils";
|
|
||||||
|
|
||||||
interface CollapsibleProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
||||||
open?: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
defaultOpen?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CollapsibleContext = React.createContext<{
|
|
||||||
open: boolean;
|
|
||||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
}>({
|
|
||||||
open: false,
|
|
||||||
setOpen: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const Collapsible = React.forwardRef<HTMLDivElement, CollapsibleProps>(
|
|
||||||
({ className, open: controlledOpen, onOpenChange, defaultOpen = false, children, ...props }, ref) => {
|
|
||||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
|
|
||||||
|
|
||||||
const isControlled = controlledOpen !== undefined;
|
|
||||||
const open = isControlled ? controlledOpen : uncontrolledOpen;
|
|
||||||
|
|
||||||
const setOpen = React.useCallback(
|
|
||||||
(value: React.SetStateAction<boolean>) => {
|
|
||||||
const newValue = typeof value === "function" ? value(open) : value;
|
|
||||||
if (!isControlled) {
|
|
||||||
setUncontrolledOpen(newValue);
|
|
||||||
}
|
|
||||||
onOpenChange?.(newValue);
|
|
||||||
},
|
|
||||||
[isControlled, open, onOpenChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CollapsibleContext.Provider value={{ open, setOpen }}>
|
|
||||||
<div ref={ref} className={cn(className)} {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</CollapsibleContext.Provider>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
Collapsible.displayName = "Collapsible";
|
|
||||||
|
|
||||||
interface CollapsibleTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
|
||||||
|
|
||||||
const CollapsibleTrigger = React.forwardRef<HTMLButtonElement, CollapsibleTriggerProps>(
|
|
||||||
({ className, children, ...props }, ref) => {
|
|
||||||
const { open, setOpen } = React.useContext(CollapsibleContext);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
ref={ref}
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex gap-2 items-center justify-between py-2 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
data-state={open ? "open" : "closed"}
|
|
||||||
onClick={() => setOpen(!open)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
CollapsibleTrigger.displayName = "CollapsibleTrigger";
|
|
||||||
|
|
||||||
interface CollapsibleContentProps extends React.HTMLAttributes<HTMLDivElement> {}
|
|
||||||
|
|
||||||
const CollapsibleContent = React.forwardRef<HTMLDivElement, CollapsibleContentProps>(
|
|
||||||
({ className, children, ...props }, ref) => {
|
|
||||||
const { open } = React.useContext(CollapsibleContext);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
data-state={open ? "open" : "closed"}
|
|
||||||
hidden={!open}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{open && children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
CollapsibleContent.displayName = "CollapsibleContent";
|
|
||||||
|
|
||||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// fallow-ignore-file unused-export
|
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
|
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
// fallow-ignore-file unused-export
|
|
||||||
import * as React from "react";
|
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
|
||||||
|
|
||||||
import { cn } from "~/client/lib/utils";
|
|
||||||
|
|
||||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
|
||||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
|
||||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
|
||||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuContent({
|
|
||||||
className,
|
|
||||||
sideOffset = 4,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Portal>
|
|
||||||
<DropdownMenuPrimitive.Content
|
|
||||||
data-slot="dropdown-menu-content"
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</DropdownMenuPrimitive.Portal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
|
||||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuItem({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
variant = "default",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
|
||||||
inset?: boolean;
|
|
||||||
variant?: "default" | "destructive";
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Item
|
|
||||||
data-slot="dropdown-menu-item"
|
|
||||||
data-inset={inset}
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
checked,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
|
||||||
data-slot="dropdown-menu-checkbox-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
checked={checked}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
|
||||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuRadioItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.RadioItem
|
|
||||||
data-slot="dropdown-menu-radio-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<CircleIcon className="size-2 fill-current" />
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuLabel({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
|
||||||
inset?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Label
|
|
||||||
data-slot="dropdown-menu-label"
|
|
||||||
data-inset={inset}
|
|
||||||
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Separator
|
|
||||||
data-slot="dropdown-menu-separator"
|
|
||||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="dropdown-menu-shortcut"
|
|
||||||
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
|
||||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
|
||||||
inset?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
|
||||||
data-slot="dropdown-menu-sub-trigger"
|
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.SubContent
|
|
||||||
data-slot="dropdown-menu-sub-content"
|
|
||||||
className={cn(
|
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuShortcut,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
};
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// fallow-ignore-file unused-export
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import type * as LabelPrimitive from "@radix-ui/react-label";
|
import type * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
|
||||||
|
|
||||||
import { cn } from "~/client/lib/utils"
|
|
||||||
|
|
||||||
function HoverCard({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
|
||||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function HoverCardTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function HoverCardContent({
|
|
||||||
className,
|
|
||||||
align = "center",
|
|
||||||
sideOffset = 4,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
|
||||||
<HoverCardPrimitive.Content
|
|
||||||
data-slot="hover-card-content"
|
|
||||||
align={align}
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</HoverCardPrimitive.Portal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import * as React from "react";
|
|
||||||
import { OTPInput, OTPInputContext } from "input-otp";
|
|
||||||
|
|
||||||
import { cn } from "~/client/lib/utils";
|
|
||||||
|
|
||||||
function InputOTP({
|
|
||||||
className,
|
|
||||||
containerClassName,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof OTPInput> & {
|
|
||||||
containerClassName?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<OTPInput
|
|
||||||
data-slot="input-otp"
|
|
||||||
containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
|
|
||||||
className={cn("disabled:cursor-not-allowed", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return <div data-slot="input-otp-group" className={cn("flex items-center", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function InputOTPSlot({
|
|
||||||
index,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & {
|
|
||||||
index: number;
|
|
||||||
}) {
|
|
||||||
const inputOTPContext = React.useContext(OTPInputContext);
|
|
||||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="input-otp-slot"
|
|
||||||
data-active={isActive}
|
|
||||||
className={cn(
|
|
||||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{char}
|
|
||||||
{hasFakeCaret && (
|
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"hr">) {
|
|
||||||
return <hr data-slot="input-otp-separator" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue