From fece6e01f1a7b55da60413cd444c4b46b2264c3b Mon Sep 17 00:00:00 2001 From: Ravinou Date: Tue, 14 Jul 2026 16:20:47 +0200 Subject: [PATCH 1/6] =?UTF-8?q?docker:=20=F0=9F=90=B3=20generate=20unique?= =?UTF-8?q?=20SSH=20host=20keys=20and=20fix=20named=20volume=20permissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #600 --- Dockerfile | 5 +++++ docker/docker-bw-init.sh | 46 +++++++++++++++++++++++++++++++--------- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3daacfdf..26397727 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,6 +38,11 @@ RUN apt-get update && apt-get install -y \ supervisor curl jq jc borgbackup/bookworm-backports openssh-server gosu && \ apt-get clean && rm -rf /var/lib/apt/lists/* +# Remove the SSH host keys generated by the openssh-server install so the image +# never ships shared host keys. Fresh keys are generated at first boot by the +# entrypoint. +RUN rm -f /etc/ssh/ssh_host_* + # Remove the default 'node' user (UID 1000) to avoid conflicts with PUID=1000 RUN userdel -r node 2>/dev/null || true diff --git a/docker/docker-bw-init.sh b/docker/docker-bw-init.sh index 6d0c8b66..bb8125da 100755 --- a/docker/docker-bw-init.sh +++ b/docker/docker-bw-init.sh @@ -28,25 +28,42 @@ remap_user() { # App files stay root:root (set in Dockerfile) so the running app cannot # modify its own code. Only chown the home dir itself; volume mounts - # (.ssh, repos, app/config) are checked separately by check_volume. + # (.ssh, repos, app/config) are handled separately by prepare_volume. chown borgwarehouse:borgwarehouse /home/borgwarehouse } -# 2. Check volume is mounted and writable +# 2. Check volume is mounted, fix ownership and check it is writable -check_volume() { +# Detect a real mount (named volume or bind mount) via /proc/mounts. #615 +is_mounted() { + grep -q " $1 " /proc/mounts +} + +prepare_volume() { local dir=$1 local name=$2 + local mode=$3 - if [ ! -d "$dir" ]; then + if ! is_mounted "$dir"; then print_red "[ERROR] Volume '$name' is not mounted. Expected path: $dir" print_red " Check the volumes section in your docker-compose.yml." exit 1 fi + # We run as root here: align the volume ownership to PUID:PGID so that named + # volumes (created root:root or as the build-time user) become writable by the + # app, whatever PUID/PGID is used. + if [ "$mode" = "recursive" ]; then + chown -R "$PUID:$PGID" "$dir" 2>/dev/null || true + else + # Top-level only: avoids walking a potentially huge repos tree. Existing + # repository sub-directories were already created by the app user. + chown "$PUID:$PGID" "$dir" 2>/dev/null || true + fi + if ! gosu borgwarehouse test -w "$dir" 2>/dev/null; then print_red "[ERROR] Volume '$name' ($dir) is not writable by UID=$PUID GID=$PGID." - print_red " Fix on the host: chown -R $PUID:$PGID " + print_red " If it is a bind mount, fix on the host: chown -R $PUID:$PGID " exit 1 fi } @@ -54,9 +71,18 @@ check_volume() { # 3. Generate SSH host keys if needed init_ssh_server() { - if [ -z "$(ls -A /etc/ssh)" ]; then - print_green "/etc/ssh is empty, generating SSH host keys..." + # Generate any MISSING host key type. `ssh-keygen -A` never overwrites existing + # keys, so custom / pre-provisioned keys are preserved; it only fills in the + # key types BorgWarehouse needs (rsa, ecdsa, ed25519), which get_SSH_fingerprints + # reads later. We check all three files (not just one, and not "/etc/ssh empty") + # so a volume providing only a subset of key types is completed correctly. #615 + if [ ! -f /etc/ssh/ssh_host_ed25519_key ] \ + || [ ! -f /etc/ssh/ssh_host_rsa_key ] \ + || [ ! -f /etc/ssh/ssh_host_ecdsa_key ]; then + print_green "Generating missing SSH host keys..." ssh-keygen -A + fi + if [ ! -f /etc/ssh/moduli ]; then cp /home/borgwarehouse/moduli /etc/ssh/ fi if [ ! -f "/etc/ssh/sshd_config" ]; then @@ -111,9 +137,9 @@ remap_user check_env mkdir -p /run/sshd init_ssh_server -check_volume "$SSH_DIR" ".ssh" -check_volume "$REPOS_DIR" "repos" -check_volume "$CONFIG_DIR" "config" +prepare_volume "$SSH_DIR" ".ssh" recursive +prepare_volume "$REPOS_DIR" "repos" +prepare_volume "$CONFIG_DIR" "config" recursive setup_authorized_keys get_SSH_fingerprints From d99f178621787ffb6e0b92be52c63402760cb7af Mon Sep 17 00:00:00 2001 From: Ravinou Date: Sat, 18 Jul 2026 15:02:27 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E2=9C=A8=20adds=20compact=20reposi?= =?UTF-8?q?tory=20feature=20on=20click?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Components/Repo/Repo.module.css | 22 ++++ Components/Repo/Repo.tsx | 23 +++- helpers/functions/compactRepository.test.ts | 106 +++++++++++++++ helpers/functions/compactRepository.ts | 50 ++++++++ helpers/shells/compactRepo.sh | 55 ++++++++ pages/api/v1/account/wizard-env.test.ts | 1 + pages/api/v1/account/wizard-env.ts | 1 + .../v1/repositories/[slug]/compact.test.ts | 121 ++++++++++++++++++ pages/api/v1/repositories/[slug]/compact.ts | 65 ++++++++++ services/shell.service.ts | 23 ++++ types/domain/config.types.ts | 1 + 11 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 helpers/functions/compactRepository.test.ts create mode 100644 helpers/functions/compactRepository.ts create mode 100755 helpers/shells/compactRepo.sh create mode 100644 pages/api/v1/repositories/[slug]/compact.test.ts create mode 100644 pages/api/v1/repositories/[slug]/compact.ts diff --git a/Components/Repo/Repo.module.css b/Components/Repo/Repo.module.css index 05e5886d..871b596a 100644 --- a/Components/Repo/Repo.module.css +++ b/Components/Repo/Repo.module.css @@ -286,6 +286,28 @@ align-items: center; justify-content: flex-end; align-self: center; + gap: 4px; +} + +.compactButton { + display: inline-flex; + align-items: center; + justify-content: center; + height: 30px; + width: 30px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--primary); + cursor: pointer; + transition: background 0.15s ease; +} +.compactButton:hover:not(:disabled) { + background: #6d4aff14; +} +.compactButton:disabled { + opacity: 0.5; + cursor: not-allowed; } @media all and (max-width: 1000px) { diff --git a/Components/Repo/Repo.tsx b/Components/Repo/Repo.tsx index 88194764..e8852e94 100644 --- a/Components/Repo/Repo.tsx +++ b/Components/Repo/Repo.tsx @@ -1,6 +1,6 @@ import { useState, useMemo } from 'react'; import classes from './Repo.module.css'; -import { IconSettings, IconChevronDown, IconBellOff, IconLockPlus, IconCloud } from '@tabler/icons-react'; +import { IconSettings, IconChevronDown, IconBellOff, IconLockPlus, IconCloud, IconPackage } from '@tabler/icons-react'; import StorageBar from '../UI/StorageBar/StorageBar'; import InfoTooltip from '../UI/InfoTooltip/InfoTooltip'; import RepoIcon from './RepoIcon'; @@ -8,6 +8,7 @@ import QuickCommands from './QuickCommands/QuickCommands'; import { Repository, WizardEnvType, Optional, DateFormatEnum } from '~/types'; import { fromUnixTime, formatDistanceStrict } from 'date-fns'; import { formatDate } from '~/helpers/functions'; +import { compactRepository } from '~/helpers/functions/compactRepository'; import useMedia from 'use-media'; type RepoProps = Omit & { @@ -50,6 +51,15 @@ export default function Repo(props: RepoProps) { //States const [displayDetails, setDisplayDetails] = useState(displayDetailsFromLS); + const [isCompacting, setIsCompacting] = useState(false); + + const compactEnabled = props.wizardEnv?.DISABLE_COMPACT_REPO !== 'true'; + + const compactHandler = async () => { + setIsCompacting(true); + await compactRepository(props.repositoryName); + setIsCompacting(false); + }; //BUTTON : Display or not repo details for ONE repo const displayDetailsForOneHandler = (boolean: boolean) => { @@ -195,6 +205,17 @@ export default function Repo(props: RepoProps) {
+ {compactEnabled && ( + + )}
+ ) : breakLockDialog ? ( +
+
+ +
+

+ Break the lock on{' '} + {targetRepo?.repositoryName} ? +

+
+
+ This releases a stale lock left by an interrupted operation (e.g. a container + restart during a compaction). +
+
+ Only do this if backups fail with a lock error and no backup or compaction is + currently running on this repository. +
+
+
+ + +
+
) : (
{props.mode == 'edit' && ( @@ -527,9 +595,21 @@ export default function RepoManage(props: RepoManageProps) { {props.mode == 'edit' ? ( - +
+ + +
) : null}
)} diff --git a/helpers/shells/breakLockRepo.sh b/helpers/shells/breakLockRepo.sh new file mode 100755 index 00000000..577fdf20 --- /dev/null +++ b/helpers/shells/breakLockRepo.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +### DEPRECATED ### NodeJS will handle this in the future. + +# Shell created by Raven for BorgWarehouse. +# This shell takes 1 arg : [repositoryName] with 8 char. length only. +# It runs `borg break-lock` on the repository to release a stale lock left behind +# by an interrupted operation (e.g. a `borg compact` killed by a container +# restart). `borg break-lock` never needs the repository passphrase, so it is +# safe to run server-side. + +# Exit when any command fails +set -e + +# Load .env if exists +if [[ -f .env ]]; then + source .env +fi + +# Default value if .env not exists +: "${home:=/home/borgwarehouse}" + +# Some variables +pool="${home}/repos" + +# Check arg +if [[ $# -ne 1 || $1 = "" ]]; then + echo -n "You must provide a repositoryName in argument." >&2 + exit 1 +fi + +# Check if the repositoryName pattern is an hexa 8 char. With createRepo.sh our randoms are hexa of 8 characters. +# If we receive another pattern there is necessarily a problem. +repositoryName=$1 +if ! [[ "$repositoryName" =~ ^[a-f0-9]{8}$ ]]; then + echo "Invalid repository name. Must be an 8-character hex string." >&2 + exit 2 +fi + +# The repository can be a real directory or a symlink to an external storage. +repo_path="${pool}/${repositoryName}" +if [ ! -d "${repo_path}" ]; then + echo "The repository ${repositoryName} does not exist or has never been initialized." >&2 + exit 3 +fi + +# Release the lock. No passphrase is required for `borg break-lock`. +# Disable interactive prompts so the command can never hang waiting for input. +export BORG_RELOCATED_REPO_ACCESS_IS_OK=yes +export BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK=yes +borg break-lock "${repo_path}" + +echo -n "The lock on repository ${repositoryName} has been released." diff --git a/pages/api/v1/repositories/[slug]/break-lock.test.ts b/pages/api/v1/repositories/[slug]/break-lock.test.ts new file mode 100644 index 00000000..5035c7f7 --- /dev/null +++ b/pages/api/v1/repositories/[slug]/break-lock.test.ts @@ -0,0 +1,102 @@ +import { createMocks } from 'node-mocks-http'; +import handler from '~/pages/api/v1/repositories/[slug]/break-lock'; +import { getSession } from '~/helpers/getServerSession'; +import { ConfigService, AuthService, ShellService } from '~/services'; +import { Repository } from '~/types/domain/config.types'; + +vi.mock('~/helpers/getServerSession', () => ({ + getSession: vi.fn(), +})); +vi.mock('~/services'); + +const mockRepoList: Repository[] = [ + { + id: 1, + alias: 'repo1', + repositoryName: 'abcd1234', + status: true, + lastSave: 1678901234, + alert: 1, + storageSize: 100, + storageUsed: 50, + sshPublicKey: 'ssh-rsa AAAAB3Nza...fakekey1', + comment: 'Test repository 1', + displayDetails: true, + unixUser: 'user1', + lanCommand: false, + appendOnlyMode: false, + lastStatusAlertSend: 1678901234, + }, +]; + +describe('Repository break-lock', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.resetModules(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('should return 405 if method is not POST', async () => { + vi.mocked(getSession).mockResolvedValue({ user: { name: 'USER' } }); + const { req, res } = createMocks({ method: 'GET', query: { slug: 'abcd1234' } }); + await handler(req, res); + expect(res._getStatusCode()).toBe(405); + }); + + it('should return 401 if no session or authorization header is provided', async () => { + const { req, res } = createMocks({ method: 'POST', query: { slug: 'abcd1234' } }); + await handler(req, res); + expect(res._getStatusCode()).toBe(401); + }); + + it('should return 400 if slug is not a valid repository name', async () => { + vi.mocked(getSession).mockResolvedValue({ user: { name: 'USER' } }); + const { req, res } = createMocks({ method: 'POST', query: { slug: 'invalid' } }); + await handler(req, res); + expect(res._getStatusCode()).toBe(400); + }); + + it('should return 403 if API key lacks update permission', async () => { + vi.mocked(getSession).mockResolvedValue(null); + vi.mocked(AuthService.tokenController).mockResolvedValue({ + create: false, + read: true, + update: false, + delete: false, + }); + const { req, res } = createMocks({ + method: 'POST', + query: { slug: 'abcd1234' }, + headers: { authorization: 'Bearer token' }, + }); + await handler(req, res); + expect(res._getStatusCode()).toBe(403); + }); + + it('should return 404 if repository does not exist', async () => { + vi.mocked(getSession).mockResolvedValue({ user: { name: 'USER' } }); + vi.mocked(ConfigService.getRepoList).mockResolvedValue(mockRepoList); + const { req, res } = createMocks({ method: 'POST', query: { slug: 'ffffffff' } }); + await handler(req, res); + expect(res._getStatusCode()).toBe(404); + }); + + it('should return 200 and release the lock', async () => { + vi.mocked(getSession).mockResolvedValue({ user: { name: 'USER' } }); + vi.mocked(ConfigService.getRepoList).mockResolvedValue(mockRepoList); + vi.mocked(ShellService.breakLockRepo).mockResolvedValue({ stdout: 'ok', stderr: '' }); + const { req, res } = createMocks({ method: 'POST', query: { slug: 'abcd1234' } }); + await handler(req, res); + expect(ShellService.breakLockRepo).toHaveBeenCalledWith('abcd1234'); + expect(res._getStatusCode()).toBe(200); + }); + + it('should return 500 if the shell fails', async () => { + vi.mocked(getSession).mockResolvedValue({ user: { name: 'USER' } }); + vi.mocked(ConfigService.getRepoList).mockResolvedValue(mockRepoList); + vi.mocked(ShellService.breakLockRepo).mockRejectedValue(new Error('borg failed')); + const { req, res } = createMocks({ method: 'POST', query: { slug: 'abcd1234' } }); + await handler(req, res); + expect(res._getStatusCode()).toBe(500); + }); +}); diff --git a/pages/api/v1/repositories/[slug]/break-lock.ts b/pages/api/v1/repositories/[slug]/break-lock.ts new file mode 100644 index 00000000..80463236 --- /dev/null +++ b/pages/api/v1/repositories/[slug]/break-lock.ts @@ -0,0 +1,59 @@ +import { getSession } from '~/helpers/getServerSession'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { BorgWarehouseApiResponse } from '~/types'; +import ApiResponse from '~/helpers/functions/apiResponse'; +import { ConfigService, AuthService, ShellService } from '~/services'; +import repositoryNameCheck from '~/helpers/functions/repositoryNameCheck'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const session = await getSession(req, res); + const { authorization } = req.headers; + if (!session && !authorization) { + return ApiResponse.unauthorized(res); + } + + if (req.method !== 'POST') { + return ApiResponse.methodNotAllowed(res); + } + + // Validate slug + const slug = Array.isArray(req.query.slug) ? req.query.slug[0] : req.query.slug; + if (!slug || !repositoryNameCheck(slug)) { + return ApiResponse.badRequest( + res, + 'Slug must be a valid repository name (8-character hexadecimal string)' + ); + } + + try { + if (!session && authorization) { + const permissions = await AuthService.tokenController(req.headers); + if (!permissions) { + return ApiResponse.unauthorized(res, 'Invalid API key'); + } + if (!permissions.update) { + return ApiResponse.forbidden(res, 'Insufficient permissions'); + } + } + } catch (error) { + return ApiResponse.serverError(res, error); + } + + try { + const repoList = await ConfigService.getRepoList(); + const repo = repoList.find((repo) => repo.repositoryName === slug); + if (!repo) { + return ApiResponse.notFound(res, 'Repository with name ' + slug + ' not found'); + } + + await ShellService.breakLockRepo(repo.repositoryName); + + return ApiResponse.success(res, `Lock on repository ${repo.repositoryName} has been released`); + } catch (error) { + console.log(error); + return ApiResponse.serverError(res, error); + } +} diff --git a/services/shell.service.ts b/services/shell.service.ts index 5140c8b5..7f3e54fa 100644 --- a/services/shell.service.ts +++ b/services/shell.service.ts @@ -74,6 +74,16 @@ export const ShellService = { } }, + breakLockRepo: async (repositoryName: string) => { + if (!repositoryNameCheck(repositoryName)) { + throw new Error('Invalid repository name format'); + } + const { stdout, stderr } = await execFile(`${shellsDirectory}/breakLockRepo.sh`, [ + repositoryName, + ]); + return { stdout, stderr }; + }, + updateRepo: async ( repositoryName: string, sshPublicKey: string, From 27e1ef31db3ecd783ce19c9a01cc2c8fc93b9ba1 Mon Sep 17 00:00:00 2001 From: Ravinou Date: Sat, 18 Jul 2026 15:24:46 +0200 Subject: [PATCH 4/6] =?UTF-8?q?test:=20=E2=9C=85=20bats=20against=20compac?= =?UTF-8?q?t=20and=20break-lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/bats/Dockerfile | 2 + tests/bats/breakLockRepo.bats | 84 +++++++++++++++++++++++++++++++++++ tests/bats/compactRepo.bats | 69 ++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 tests/bats/breakLockRepo.bats create mode 100644 tests/bats/compactRepo.bats diff --git a/tests/bats/Dockerfile b/tests/bats/Dockerfile index ca73aa48..ffaa4d6a 100644 --- a/tests/bats/Dockerfile +++ b/tests/bats/Dockerfile @@ -10,6 +10,8 @@ RUN apk add --no-cache \ COPY helpers/shells/ /test/scripts/ COPY tests/bats/createRepo.bats /test/tests/createRepo.bats COPY tests/bats/deleteRepo.bats /test/tests/deleteRepo.bats +COPY tests/bats/compactRepo.bats /test/tests/compactRepo.bats +COPY tests/bats/breakLockRepo.bats /test/tests/breakLockRepo.bats COPY tests/bats/updateRepo.bats /test/tests/updateRepo.bats COPY tests/bats/getLastSave.bats /test/tests/getLastSave.bats COPY tests/bats/getStorageUsed.bats /test/tests/getStorageUsed.bats diff --git a/tests/bats/breakLockRepo.bats b/tests/bats/breakLockRepo.bats new file mode 100644 index 00000000..d8c7e986 --- /dev/null +++ b/tests/bats/breakLockRepo.bats @@ -0,0 +1,84 @@ +#!/usr/bin/env bats + +setup() { + # Setup the environment for each test + export home="/tmp/borgwarehouse" + mkdir -p "${home}/repos" + + # borg break-lock runs without a passphrase; disable interactive prompts so an + # unencrypted test repository can be initialized without hanging. + export BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK=yes +} + +teardown() { + # Cleanup after each test + rm -rf /tmp/borgwarehouse + rm -rf /tmp/borgwarehouse-ext +} + +@test "Test breakLockRepo.sh with missing arguments" { + run bash /test/scripts/breakLockRepo.sh + [ "$status" -eq 1 ] + [ "$output" == "You must provide a repositoryName in argument." ] +} + +@test "Test breakLockRepo.sh with repositoryName shorter than 8 characters" { + run bash /test/scripts/breakLockRepo.sh "1234567" + [ "$status" -eq 2 ] + [ "$output" == "Invalid repository name. Must be an 8-character hex string." ] +} + +@test "Test breakLockRepo.sh with repositoryName longer than 8 characters" { + run bash /test/scripts/breakLockRepo.sh "123456789" + [ "$status" -eq 2 ] + [ "$output" == "Invalid repository name. Must be an 8-character hex string." ] +} + +@test "Test breakLockRepo.sh with unexpected character in repositoryName" { + run bash /test/scripts/breakLockRepo.sh "ffff/123" + [ "$status" -eq 2 ] + [ "$output" == "Invalid repository name. Must be an 8-character hex string." ] +} + +@test "Test breakLockRepo.sh for non-existing repository" { + run bash /test/scripts/breakLockRepo.sh "abcdef12" + [ "$status" -eq 3 ] + [ "$output" == "The repository abcdef12 does not exist or has never been initialized." ] +} + +@test "Test breakLockRepo.sh on a real initialized repository" { + # Initialize a real (unencrypted) borg repository. break-lock succeeds even + # when there is no lock to release. + borg init --encryption=none "${home}/repos/abcdef12" + + run bash /test/scripts/breakLockRepo.sh "abcdef12" + + [ "$status" -eq 0 ] + [ "$output" == "The lock on repository abcdef12 has been released." ] +} + +@test "Test breakLockRepo.sh releases an existing stale lock" { + # Initialize a repository and manually create a lock directory to simulate a + # stale lock left behind by an interrupted operation. + borg init --encryption=none "${home}/repos/abcdef12" + mkdir -p "${home}/repos/abcdef12/lock.exclusive" + + run bash /test/scripts/breakLockRepo.sh "abcdef12" + + [ "$status" -eq 0 ] + [ "$output" == "The lock on repository abcdef12 has been released." ] + # The lock must be gone after break-lock. + [ ! -d "${home}/repos/abcdef12/lock.exclusive" ] +} + +@test "Test breakLockRepo.sh on an external storage repository (symlink)" { + # Simulate an external storage: real data lives outside repos, linked into it. + mkdir -p "/tmp/borgwarehouse-ext" + borg init --encryption=none "/tmp/borgwarehouse-ext/abcdef12" + ln -s "/tmp/borgwarehouse-ext/abcdef12" "${home}/repos/abcdef12" + + run bash /test/scripts/breakLockRepo.sh "abcdef12" + + [ "$status" -eq 0 ] + [ "$output" == "The lock on repository abcdef12 has been released." ] +} diff --git a/tests/bats/compactRepo.bats b/tests/bats/compactRepo.bats new file mode 100644 index 00000000..5be7e92e --- /dev/null +++ b/tests/bats/compactRepo.bats @@ -0,0 +1,69 @@ +#!/usr/bin/env bats + +setup() { + # Setup the environment for each test + export home="/tmp/borgwarehouse" + mkdir -p "${home}/repos" + + # borg compact runs without a passphrase; disable interactive prompts so an + # unencrypted test repository can be initialized without hanging. + export BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK=yes +} + +teardown() { + # Cleanup after each test + rm -rf /tmp/borgwarehouse + rm -rf /tmp/borgwarehouse-ext +} + +@test "Test compactRepo.sh with missing arguments" { + run bash /test/scripts/compactRepo.sh + [ "$status" -eq 1 ] + [ "$output" == "You must provide a repositoryName in argument." ] +} + +@test "Test compactRepo.sh with repositoryName shorter than 8 characters" { + run bash /test/scripts/compactRepo.sh "1234567" + [ "$status" -eq 2 ] + [ "$output" == "Invalid repository name. Must be an 8-character hex string." ] +} + +@test "Test compactRepo.sh with repositoryName longer than 8 characters" { + run bash /test/scripts/compactRepo.sh "123456789" + [ "$status" -eq 2 ] + [ "$output" == "Invalid repository name. Must be an 8-character hex string." ] +} + +@test "Test compactRepo.sh with unexpected character in repositoryName" { + run bash /test/scripts/compactRepo.sh "ffff/123" + [ "$status" -eq 2 ] + [ "$output" == "Invalid repository name. Must be an 8-character hex string." ] +} + +@test "Test compactRepo.sh for non-existing repository" { + run bash /test/scripts/compactRepo.sh "abcdef12" + [ "$status" -eq 3 ] + [ "$output" == "The repository abcdef12 does not exist or has never been initialized." ] +} + +@test "Test compactRepo.sh on a real initialized repository" { + # Initialize a real (unencrypted) borg repository to compact. + borg init --encryption=none "${home}/repos/abcdef12" + + run bash /test/scripts/compactRepo.sh "abcdef12" + + [ "$status" -eq 0 ] + [ "$output" == "The repository abcdef12 has been compacted." ] +} + +@test "Test compactRepo.sh on an external storage repository (symlink)" { + # Simulate an external storage: real data lives outside repos, linked into it. + mkdir -p "/tmp/borgwarehouse-ext" + borg init --encryption=none "/tmp/borgwarehouse-ext/abcdef12" + ln -s "/tmp/borgwarehouse-ext/abcdef12" "${home}/repos/abcdef12" + + run bash /test/scripts/compactRepo.sh "abcdef12" + + [ "$status" -eq 0 ] + [ "$output" == "The repository abcdef12 has been compacted." ] +} From d503674a4eb5348075f1a39f8046461ef794745a Mon Sep 17 00:00:00 2001 From: Ravinou Date: Sun, 19 Jul 2026 15:43:13 +0200 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=E2=9C=A8=20adds=20a=20new=20mode?= =?UTF-8?q?=20to=20archived=20or=20unarchived=20repository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Components/Repo/Repo.module.css | 23 + Components/Repo/Repo.tsx | 41 +- Containers/RepoList/RepoList.module.css | 46 +- Containers/RepoList/RepoList.tsx | 72 +- .../RepoManage/ConfirmDialog.module.css | 153 +++++ Containers/RepoManage/ConfirmDialog.tsx | 62 ++ .../RepoManage/RepoActionsFooter.module.css | 64 ++ Containers/RepoManage/RepoActionsFooter.tsx | 69 ++ Containers/RepoManage/RepoManage.module.css | 230 +------ Containers/RepoManage/RepoManage.tsx | 619 ++++++++---------- Containers/RepoManage/useRepoActions.ts | 171 +++++ helpers/shells/archiveRepo.sh | 66 ++ helpers/shells/breakLockRepo.sh | 4 + helpers/shells/compactRepo.sh | 4 + pages/api/v1/cron/status.test.ts | 60 ++ pages/api/v1/cron/status.ts | 3 + .../v1/repositories/[slug]/archive.test.ts | 153 +++++ pages/api/v1/repositories/[slug]/archive.ts | 76 +++ pages/api/v1/repositories/[slug]/index.ts | 4 + pages/manage-repo/add.tsx | 29 +- pages/manage-repo/edit/[slug].tsx | 29 +- services/shell.service.ts | 11 + tests/bats/Dockerfile | 1 + tests/bats/archiveRepo.bats | 98 +++ types/domain/config.types.ts | 1 + 25 files changed, 1450 insertions(+), 639 deletions(-) create mode 100644 Containers/RepoManage/ConfirmDialog.module.css create mode 100644 Containers/RepoManage/ConfirmDialog.tsx create mode 100644 Containers/RepoManage/RepoActionsFooter.module.css create mode 100644 Containers/RepoManage/RepoActionsFooter.tsx create mode 100644 Containers/RepoManage/useRepoActions.ts create mode 100755 helpers/shells/archiveRepo.sh create mode 100644 pages/api/v1/repositories/[slug]/archive.test.ts create mode 100644 pages/api/v1/repositories/[slug]/archive.ts create mode 100644 tests/bats/archiveRepo.bats diff --git a/Components/Repo/Repo.module.css b/Components/Repo/Repo.module.css index 871b596a..1fa13429 100644 --- a/Components/Repo/Repo.module.css +++ b/Components/Repo/Repo.module.css @@ -33,6 +33,26 @@ 0 10px 30px var(--shadow-md); } +.cardArchived { + background: var(--surface-2); + border-style: dashed; + box-shadow: none; + opacity: 0.62; + filter: grayscale(0.7); +} +.cardArchived::before { + background: var(--text-faint); + opacity: 0.6; +} +.cardArchived:hover { + opacity: 1; + filter: grayscale(0); + border-color: var(--border); + box-shadow: + 0 1px 2px var(--shadow-sm), + 0 6px 16px -8px var(--shadow-md); +} + /* Left thiny rail */ .card::before { content: ''; @@ -127,6 +147,9 @@ animation: pulseRed 4s infinite; animation-delay: 0.5s; } +.statusArchived { + background: var(--text-muted); +} @keyframes pulseGreen { 0% { diff --git a/Components/Repo/Repo.tsx b/Components/Repo/Repo.tsx index e8852e94..18461165 100644 --- a/Components/Repo/Repo.tsx +++ b/Components/Repo/Repo.tsx @@ -1,6 +1,6 @@ import { useState, useMemo } from 'react'; import classes from './Repo.module.css'; -import { IconSettings, IconChevronDown, IconBellOff, IconLockPlus, IconCloud, IconPackage } from '@tabler/icons-react'; +import { IconSettings, IconChevronDown, IconBellOff, IconLockPlus, IconCloud, IconPackage, IconArchive } from '@tabler/icons-react'; import StorageBar from '../UI/StorageBar/StorageBar'; import InfoTooltip from '../UI/InfoTooltip/InfoTooltip'; import RepoIcon from './RepoIcon'; @@ -53,7 +53,7 @@ export default function Repo(props: RepoProps) { const [displayDetails, setDisplayDetails] = useState(displayDetailsFromLS); const [isCompacting, setIsCompacting] = useState(false); - const compactEnabled = props.wizardEnv?.DISABLE_COMPACT_REPO !== 'true'; + const compactEnabled = props.wizardEnv?.DISABLE_COMPACT_REPO !== 'true' && !props.archived; const compactHandler = async () => { setIsCompacting(true); @@ -79,19 +79,34 @@ export default function Repo(props: RepoProps) { props.lastSave === 0 ? undefined : formatDate(props.lastSave, props.dateFormat); //Repo identity: gradient icon avatar with status badge - const repoIdentity = () => ( - - ); + const getStatusInfo = (): { className: string; title: string } => { + if (props.archived) { + return { className: classes.statusArchived, title: 'Archived' }; + } + if (props.status) { + return { className: classes.statusOk, title: 'Status OK' }; + } + return { className: classes.statusKo, title: 'Status error' }; + }; + + const repoIdentity = () => { + const status = getStatusInfo(); + return ( + + ); + }; //Indicator chips (append-only, alert, comment) const indicatorChips = () => ( <> + {props.archived && ( +
+ +
+ )} {props.appendOnlyMode && (
@@ -124,7 +139,7 @@ export default function Repo(props: RepoProps) { // ---------- MOBILE ---------- if (isMobile) { return ( -
+
{repoIdentity()} @@ -143,7 +158,7 @@ export default function Repo(props: RepoProps) { // ---------- DESKTOP ---------- return ( -
+
{repoIdentity()} diff --git a/Containers/RepoList/RepoList.module.css b/Containers/RepoList/RepoList.module.css index 8c137ce1..0eaaba67 100644 --- a/Containers/RepoList/RepoList.module.css +++ b/Containers/RepoList/RepoList.module.css @@ -15,7 +15,7 @@ .containerRepoList { display: flex; - flex-direction: row; + flex-direction: column; } .containerAddRepo { @@ -88,36 +88,42 @@ margin: 5px auto; } -.unfoldButton { - cursor: pointer; - position: sticky; +/* Toolbar toggle button for archived repositories */ +.archivedToggleBtn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 36px; + padding: 0 13px; + background: var(--surface-2); + border: 1px solid var(--border-strong); + border-radius: 20px; color: var(--text-faint); - padding-top: 49px; - align-self: flex-start; - top: 0; + cursor: pointer; + font-size: 0.8rem; + font-weight: 700; + box-sizing: border-box; + transition: + color 0.2s ease, + border-color 0.2s ease, + background 0.2s ease; } -.foldButton { - cursor: pointer; - position: sticky; - color: var(--text-faint); - padding-top: 49px; - align-self: flex-start; - top: 0; +.archivedToggleBtn:hover { + color: var(--primary); + border-color: var(--primary); } -.unfoldButton:active, -.foldButton:active { - transform: scale(0.96); +.archivedToggleBtnActive { + color: var(--primary); + border-color: var(--primary); + background: var(--primary-soft); } @media all and (max-width: 1000px) { .newRepoButton { display: none; } - .chevron { - display: none; - } .containerAddRepo { display: none; } diff --git a/Containers/RepoList/RepoList.tsx b/Containers/RepoList/RepoList.tsx index ca8d3c9d..b72ed103 100644 --- a/Containers/RepoList/RepoList.tsx +++ b/Containers/RepoList/RepoList.tsx @@ -1,32 +1,30 @@ -import classes from './RepoList.module.css'; -import React, { useState, useMemo } from 'react'; import { + IconArchive, + IconCalendarDown, + IconCalendarUp, IconPlus, + IconRefresh, + IconSearch, IconSortAscendingLetters, - IconSortDescendingLetters, - IconSortAscending2, - IconSortDescending2, - IconDatabase, - IconX, - IconClock, - IconCalendarUp, - IconCalendarDown, IconSortAscendingSmallBig, - IconSortDescendingSmallBig, + IconSortDescending2, IconSortDescending2Filled, - IconRefresh, - IconSearch, + IconSortDescendingLetters, + IconSortDescendingSmallBig, + IconX, } from '@tabler/icons-react'; -import { useRouter } from 'next/router'; import Link from 'next/link'; -import useSWR, { useSWRConfig } from 'swr'; +import { useRouter } from 'next/router'; +import React, { useMemo, useState } from 'react'; import { ToastContainer, ToastOptions, toast } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; +import useSWR, { useSWRConfig } from 'swr'; +import classes from './RepoList.module.css'; import Repo from '~/Components/Repo/Repo'; -import RepoManage from '../RepoManage/RepoManage'; import ShimmerRepoList from '~/Components/UI/ShimmerRepoList/ShimmerRepoList'; -import { Repository, WizardEnvType, DateFormatEnum, StorageTarget } from '~/types'; +import { DateFormatEnum, Repository, StorageTarget, WizardEnvType } from '~/types'; +import RepoManage from '../RepoManage/RepoManage'; type SortOption = | 'alias-asc' @@ -50,6 +48,17 @@ export default function RepoList() { return (savedSort as SortOption) || 'alias-asc'; }); const [isRefreshing, setIsRefreshing] = useState(false); + const [showArchived, setShowArchived] = useState( + () => localStorage.getItem('repoShowArchived') === 'true' + ); + + const toggleShowArchived = () => { + setShowArchived((prev) => { + const next = !prev; + localStorage.setItem('repoShowArchived', String(next)); + return next; + }); + }; const [searchQuery, setSearchQuery] = useState(() => { const savedSearch = localStorage.getItem('repoSearch'); @@ -177,7 +186,7 @@ export default function RepoList() { } }; - const renderRepoList = getSortedRepoList().map((repo: Repository) => ( + const renderRepo = (repo: Repository) => ( manageRepoEditHandler(repo.id)} wizardEnv={wizardEnv} dateFormat={dateFormat} /> - )); + ); + + const sortedRepoList = getSortedRepoList(); + const archivedCount = data.repoList.filter((repo: Repository) => repo.archived).length; + const effectiveShowArchived = showArchived && archivedCount > 0; + const visibleRepoList = sortedRepoList.filter((repo: Repository) => + effectiveShowArchived ? repo.archived : !repo.archived + ); + const renderRepoList = visibleRepoList.map(renderRepo); return ( <> @@ -291,6 +309,22 @@ export default function RepoList() { title='Manually refresh status & storage. Does not replace a scheduled cron job.' size={18} /> + {archivedCount > 0 && ( + + )}
diff --git a/Containers/RepoManage/ConfirmDialog.module.css b/Containers/RepoManage/ConfirmDialog.module.css new file mode 100644 index 00000000..4c909c4f --- /dev/null +++ b/Containers/RepoManage/ConfirmDialog.module.css @@ -0,0 +1,153 @@ +.wrapper { + text-align: center; + margin: auto; + width: 100%; + max-width: 520px; + color: var(--text-strong); + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 0 4px; +} + +.iconCircle { + display: flex; + align-items: center; + justify-content: center; + height: 72px; + width: 72px; + border-radius: 50%; + margin-bottom: 18px; +} + +.iconCircleDanger { + background: var(--danger-soft); + border: 1px solid var(--danger-border); + color: var(--danger); +} + +.iconCircleWarning { + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.35); + color: var(--amber); +} + +.wrapper h1 { + font-size: 1.4rem; + font-weight: 700; + letter-spacing: -0.01em; + margin: 0 0 4px; + color: var(--text-strong); +} + +.repoName { + color: var(--primary); + font-weight: 700; +} + +.message { + border-radius: 14px; + padding: 16px 18px; + margin: 18px 0 22px; + font-size: 0.9rem; + line-height: 1.5; + text-align: left; +} + +.message b { + font-weight: 700; +} + +.messageDanger { + background: var(--danger-soft); + border: 1px solid var(--danger-border); + color: var(--danger-text); +} + +.messageWarning { + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.3); + color: var(--text-secondary); +} + +.messageWarning b { + color: var(--text-strong); +} + +.buttons { + display: flex; + justify-content: center; + gap: 12px; + width: 100%; +} + +.cancel { + border: 1px solid var(--border-strong); + padding: 0.7rem 1.4rem; + background: var(--surface); + color: var(--text-secondary); + border-radius: 12px; + cursor: pointer; + font-weight: 600; + font-size: 0.95rem; + transition: + background 0.15s ease, + border-color 0.15s ease; +} + +.cancel:hover:not(:disabled) { + background: var(--surface-hover); + border-color: var(--border-strong); +} + +.cancel:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.confirm { + border: 0; + padding: 0.7rem 1.5rem; + color: #fff; + border-radius: 12px; + cursor: pointer; + font-weight: 600; + font-size: 0.95rem; + transition: + transform 0.15s ease, + box-shadow 0.15s ease, + filter 0.15s ease; +} + +.confirm:hover:not(:disabled) { + filter: brightness(1.05); + transform: translateY(-1px); +} + +.confirm:active:not(:disabled) { + transform: translateY(0); +} + +.confirm:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.confirmDanger { + background: linear-gradient(135deg, #ff5d5d 0%, #ff2d2d 100%); + box-shadow: 0 3px 8px -3px rgba(255, 45, 45, 0.3); +} + +.confirmDanger:hover:not(:disabled) { + box-shadow: 0 5px 12px -4px rgba(255, 45, 45, 0.35); +} + +.confirmWarning { + background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); + color: #3a2c05; + box-shadow: 0 3px 8px -3px rgba(245, 158, 11, 0.3); +} + +.confirmWarning:hover:not(:disabled) { + box-shadow: 0 5px 12px -4px rgba(245, 158, 11, 0.35); +} diff --git a/Containers/RepoManage/ConfirmDialog.tsx b/Containers/RepoManage/ConfirmDialog.tsx new file mode 100644 index 00000000..3d39e6fc --- /dev/null +++ b/Containers/RepoManage/ConfirmDialog.tsx @@ -0,0 +1,62 @@ +import { ReactNode } from 'react'; +import classes from './ConfirmDialog.module.css'; + +type ConfirmDialogVariant = 'danger' | 'warning'; + +type ConfirmDialogProps = { + variant: ConfirmDialogVariant; + icon: ReactNode; + title: ReactNode; + children: ReactNode; + confirmLabel: string; + busyLabel?: string; + isBusy?: boolean; + onConfirm: () => void; + onCancel: () => void; +}; + +/** Highlights a repository name inside a dialog title. */ +export function DialogHighlight({ children }: { children: ReactNode }) { + return {children}; +} + +/** + * Generic confirmation dialog used for the repository maintenance/danger + * actions (delete, break-lock, archive). + */ +export default function ConfirmDialog({ + variant, + icon, + title, + children, + confirmLabel, + busyLabel, + isBusy = false, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + const iconCircleClass = + variant === 'danger' ? classes.iconCircleDanger : classes.iconCircleWarning; + const messageClass = variant === 'danger' ? classes.messageDanger : classes.messageWarning; + const confirmClass = variant === 'danger' ? classes.confirmDanger : classes.confirmWarning; + + return ( +
+
{icon}
+

{title}

+
{children}
+
+ + +
+
+ ); +} diff --git a/Containers/RepoManage/RepoActionsFooter.module.css b/Containers/RepoManage/RepoActionsFooter.module.css new file mode 100644 index 00000000..d485e7ae --- /dev/null +++ b/Containers/RepoManage/RepoActionsFooter.module.css @@ -0,0 +1,64 @@ +.footer { + margin-top: 40px; + display: flex; + flex-direction: column; +} + +.divider { + height: 1px; + background: var(--border-strong); + margin: 0 0 16px; +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.maintenanceActions { + display: flex; + align-items: center; + gap: 12px; +} + +.actionButton, +.deleteButton { + display: inline-flex; + align-items: center; + gap: 7px; + border: 1px solid var(--border-strong); + background: transparent; + color: var(--text-secondary); + border-radius: 10px; + padding: 0.5rem 0.9rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; +} + +.actionButton:hover:not(:disabled) { + background: var(--surface-hover); + color: var(--text-strong); +} + +.actionButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.deleteButton { + border-color: var(--danger-border); + color: var(--danger-text); +} + +.deleteButton:hover { + background: linear-gradient(135deg, #ff5d5d 0%, #ff2d2d 100%); + border-color: transparent; + color: #fff; +} diff --git a/Containers/RepoManage/RepoActionsFooter.tsx b/Containers/RepoManage/RepoActionsFooter.tsx new file mode 100644 index 00000000..df99b848 --- /dev/null +++ b/Containers/RepoManage/RepoActionsFooter.tsx @@ -0,0 +1,69 @@ +import { IconArchive, IconArchiveOff, IconLockOpen, IconTrash } from '@tabler/icons-react'; +import classes from './RepoActionsFooter.module.css'; + +type RepoActionsFooterProps = { + isArchived: boolean; + isArchiving: boolean; + onBreakLock: () => void; + onArchive: () => void; + onUnarchive: () => void; + onDelete: () => void; +}; + +/** + * Maintenance and danger actions shown at the bottom of the edit form: + * break-lock, archive/unarchive and delete. + */ +export default function RepoActionsFooter({ + isArchived, + isArchiving, + onBreakLock, + onArchive, + onUnarchive, + onDelete, +}: RepoActionsFooterProps) { + return ( +
+
+
+
+ + {isArchived ? ( + + ) : ( + + )} +
+ +
+
+ ); +} diff --git a/Containers/RepoManage/RepoManage.module.css b/Containers/RepoManage/RepoManage.module.css index 9191bea6..606bb94e 100644 --- a/Containers/RepoManage/RepoManage.module.css +++ b/Containers/RepoManage/RepoManage.module.css @@ -19,6 +19,8 @@ border: 1px solid var(--border); padding: 30px 32px 28px; overflow: auto; + scrollbar-width: none; + -ms-overflow-style: none; border-radius: 20px; box-shadow: 0 1px 2px var(--shadow-sm), @@ -27,6 +29,10 @@ animation: modaleIn 0.26s cubic-bezier(0.16, 1, 0.3, 1) both; } +.modale::-webkit-scrollbar { + display: none; +} + .modale h2 { margin: 0 0 0.4rem; font-size: 1.35rem; @@ -238,220 +244,48 @@ color: var(--text-secondary); } -/* DELETE DIALOG */ - -.deleteDialogWrapper { - text-align: center; - margin: auto; - width: 100%; - max-width: 520px; - color: var(--text-strong); - display: flex; - flex-direction: column; - align-items: center; - padding: 8px 0 4px; -} - -.deleteIconCircle { - display: flex; - align-items: center; - justify-content: center; - height: 72px; - width: 72px; - border-radius: 50%; - background: var(--danger-soft); - border: 1px solid var(--danger-border); - color: var(--danger); - margin-bottom: 18px; -} - -.deleteDialogWrapper h1 { - font-size: 1.4rem; - font-weight: 700; - letter-spacing: -0.01em; - margin: 0 0 4px; - color: var(--text-strong); -} - -.deleteRepoName { - color: var(--primary); - font-weight: 700; -} - -.deleteDialogMessage { - background: var(--danger-soft); - border: 1px solid var(--danger-border); - color: var(--danger-text); - border-radius: 14px; - padding: 16px 18px; - margin: 18px 0 22px; - font-size: 0.9rem; - line-height: 1.5; - text-align: left; -} - -.deleteDialogMessage b { - font-weight: 700; -} - -.deleteDialogButtonWrapper { - display: flex; - justify-content: center; - gap: 12px; - width: 100%; -} - -.cancelButton { - border: 1px solid var(--border-strong); - padding: 0.7rem 1.4rem; - background: var(--surface); - color: var(--text-secondary); - border-radius: 12px; - cursor: pointer; - font-weight: 600; - font-size: 0.95rem; - transition: - background 0.15s ease, - border-color 0.15s ease; -} - -.cancelButton:hover:not(:disabled) { - background: var(--surface-hover); - border-color: var(--border-strong); -} - -.cancelButton:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.deleteButton { +.formFieldset { border: 0; - padding: 0.7rem 1.5rem; - background: linear-gradient(135deg, #ff5d5d 0%, #ff2d2d 100%); - color: #fff; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - font-size: 0.95rem; - box-shadow: 0 3px 8px -3px rgba(255, 45, 45, 0.3); + margin: 0; + padding: 0; + min-width: 0; transition: - transform 0.15s ease, - box-shadow 0.15s ease, - filter 0.15s ease; -} - -.deleteButton:hover { - filter: brightness(1.05); - transform: translateY(-1px); - box-shadow: 0 5px 12px -4px rgba(255, 45, 45, 0.35); -} - -.deleteButton:active { - transform: translateY(0); -} - -.littleDeleteButton { - margin-top: 14px; - border: none; - font-weight: 500; - font-size: 0.85rem; - color: var(--danger-text); - background: none; - cursor: pointer; - transition: color 0.15s ease; -} - -.littleDeleteButton:hover { - color: var(--danger-strong); - text-decoration: underline; -} - -.editActionsWrapper { - display: flex; - justify-content: center; - align-items: center; - gap: 22px; + opacity 0.2s ease, + filter 0.2s ease; } -.littleBreakLockButton { - margin-top: 14px; - border: none; - font-weight: 500; - font-size: 0.85rem; - color: var(--text-muted); - background: none; - cursor: pointer; - transition: color 0.15s ease; -} - -.littleBreakLockButton:hover:not(:disabled) { - color: var(--text-strong); - text-decoration: underline; -} - -.littleBreakLockButton:disabled { +.formFieldsetArchived { opacity: 0.5; - cursor: not-allowed; + filter: grayscale(0.7); + pointer-events: none; + user-select: none; } -.breakLockIconCircle { +.archivedBanner { display: flex; align-items: center; - justify-content: center; - height: 72px; - width: 72px; - border-radius: 50%; - background: rgba(245, 158, 11, 0.12); - border: 1px solid rgba(245, 158, 11, 0.35); - color: var(--amber); - margin-bottom: 18px; -} - -.breakLockDialogMessage { + gap: 8px; + text-align: left; + width: 100%; + max-width: 600px; + margin: 6px auto 16px; background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.3); color: var(--text-secondary); - border-radius: 14px; - padding: 16px 18px; - margin: 18px 0 22px; - font-size: 0.9rem; - line-height: 1.5; - text-align: left; -} - -.breakLockDialogMessage b { - font-weight: 700; - color: var(--text-strong); -} - -.breakLockConfirmButton { - border: 0; - padding: 0.7rem 1.5rem; - background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); - color: #3a2c05; border-radius: 12px; - cursor: pointer; - font-weight: 600; - font-size: 0.95rem; - box-shadow: 0 3px 8px -3px rgba(245, 158, 11, 0.3); - transition: - transform 0.15s ease, - box-shadow 0.15s ease, - filter 0.15s ease; + padding: 10px 14px; + font-size: 0.85rem; + line-height: 1.4; + box-sizing: border-box; } -.breakLockConfirmButton:hover:not(:disabled) { - filter: brightness(1.05); - transform: translateY(-1px); - box-shadow: 0 5px 12px -4px rgba(245, 158, 11, 0.35); +.archivedBanner svg { + color: var(--amber); + flex-shrink: 0; } -.breakLockConfirmButton:active:not(:disabled) { - transform: translateY(0); +.archivedBanner b { + color: var(--text-strong); + font-weight: 700; } -.breakLockConfirmButton:disabled { - opacity: 0.5; - cursor: not-allowed; -} diff --git a/Containers/RepoManage/RepoManage.tsx b/Containers/RepoManage/RepoManage.tsx index 25479fa2..0bc8e51b 100644 --- a/Containers/RepoManage/RepoManage.tsx +++ b/Containers/RepoManage/RepoManage.tsx @@ -1,4 +1,10 @@ -import { IconAlertCircle, IconExternalLink, IconLockOpen, IconX } from '@tabler/icons-react'; +import { + IconArchive, + IconAlertCircle, + IconExternalLink, + IconLockOpen, + IconX, +} from '@tabler/icons-react'; import dynamic from 'next/dynamic'; import Link from 'next/link'; import { useRouter } from 'next/router'; @@ -8,9 +14,13 @@ import Select from 'react-select'; import { bwSelectStyles, bwSelectTheme } from '~/Components/UI/Select/bwSelectStyles'; import { toast, ToastOptions } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; +import { useSWRConfig } from 'swr'; import { useLoader } from '~/contexts/LoaderContext'; import { alertOptions, Optional, Repository, StorageTarget } from '~/types'; import { DEFAULT_REPO_ICON } from '~/Components/Repo/repoIcons'; +import ConfirmDialog, { DialogHighlight } from './ConfirmDialog'; +import RepoActionsFooter from './RepoActionsFooter'; +import { useRepoActions } from './useRepoActions'; import classes from './RepoManage.module.css'; // Lazy-loaded: the curated icon grid only ships when the add/edit form is opened. @@ -36,6 +46,7 @@ type DataForm = { export default function RepoManage(props: RepoManageProps) { const router = useRouter(); + const { mutate } = useSWRConfig(); const targetRepo = props.mode === 'edit' && router.query.slug ? props.repoList?.find((repo) => repo.id.toString() === router.query.slug) @@ -58,10 +69,9 @@ export default function RepoManage(props: RepoManageProps) { progress: undefined, }; - const [deleteDialog, setDeleteDialog] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [isBreakingLock, setIsBreakingLock] = useState(false); - const [breakLockDialog, setBreakLockDialog] = useState(false); + const isArchived = !!targetRepo?.archived; + const actions = useRepoActions(targetRepo); const [icon, setIcon] = useState( (props.mode === 'edit' ? targetRepo?.icon : undefined) ?? DEFAULT_REPO_ICON ); @@ -102,85 +112,6 @@ export default function RepoManage(props: RepoManageProps) { } } - //Delete a repo - const deleteHandler = async (repositoryName?: string) => { - start(); - if (!repositoryName) { - stop(); - toast.error('Repository name not found', toastOptions); - router.replace('/'); - return; - } - //API Call for delete - await fetch('/api/v1/repositories/' + repositoryName, { - method: 'DELETE', - headers: { - 'Content-type': 'application/json', - }, - }) - .then(async (response) => { - if (response.ok) { - toast.success( - '🗑 The repository ' + repositoryName + ' has been successfully deleted', - toastOptions - ); - router.replace('/'); - } else { - if (response.status == 403) { - toast.warning( - '🔒 The server is currently protected against repository deletion.', - toastOptions - ); - setIsLoading(false); - router.replace('/'); - } else { - const errorMessage = await response.json(); - toast.error(`An error has occurred : ${errorMessage.message.stderr}`, toastOptions); - router.replace('/'); - console.log('Fail to delete'); - } - } - }) - .catch((error) => { - toast.error('An error has occurred', toastOptions); - router.replace('/'); - console.log(error); - }) - .finally(() => { - stop(); - }); - }; - - //Break a stale lock on a repo (server-side `borg break-lock`, no passphrase required) - const breakLockHandler = async () => { - const repositoryName = targetRepo?.repositoryName; - if (!repositoryName) { - toast.error('Repository name not found', toastOptions); - return; - } - setIsBreakingLock(true); - try { - const response = await fetch('/api/v1/repositories/' + repositoryName + '/break-lock', { - method: 'POST', - headers: { 'Content-type': 'application/json' }, - }); - if (response.ok) { - toast.success(`🔓 The lock on ${repositoryName} has been released.`, toastOptions); - } else { - const errorMessage = await response.json(); - toast.error( - `An error has occurred : ${errorMessage.message?.stderr ?? errorMessage.message}`, - toastOptions - ); - } - } catch (error) { - toast.error('An error has occurred', toastOptions); - } finally { - setIsBreakingLock(false); - setBreakLockDialog(false); - } - }; - const isSSHKeyUnique = async (sshPublicKey: string): Promise => { try { // Extract the first two columns of the SSH key in the form @@ -250,6 +181,7 @@ export default function RepoManage(props: RepoManageProps) { .then(async (response) => { if (response.ok) { toast.success('New repository added ! 🥳', toastOptions); + await mutate('/api/v1/repositories'); router.replace('/'); } else { const errorMessage = await response.json(); @@ -292,6 +224,7 @@ export default function RepoManage(props: RepoManageProps) { 'The repository ' + targetRepo?.repositoryName + ' has been successfully edited !', toastOptions ); + await mutate('/api/v1/repositories'); router.replace('/'); } else { const errorMessage = await response.json(); @@ -319,79 +252,75 @@ export default function RepoManage(props: RepoManageProps) {
- {deleteDialog ? ( -
-
- -
-

- Delete the repository{' '} - {targetRepo?.repositoryName} ? -

-
-
- You are about to permanently delete the repository{' '} - {targetRepo?.repositoryName} and all the backups it contains. -
-
The data will not be recoverable and it will not be possible to go back.
+ {actions.deleteDialog ? ( + } + title={ + <> + Delete the repository{' '} + {targetRepo?.repositoryName} ? + + } + confirmLabel='Yes, delete it !' + isBusy={actions.isDeleting} + onCancel={actions.closeDeleteDialog} + onConfirm={actions.confirmDelete} + > +
+ You are about to permanently delete the repository {targetRepo?.repositoryName}{' '} + and all the backups it contains.
-
+
The data will not be recoverable and it will not be possible to go back.
+ + ) : actions.breakLockDialog ? ( + } + title={ <> - - + Break the lock on {targetRepo?.repositoryName} ? + } + confirmLabel='Break the lock' + busyLabel='Releasing…' + isBusy={actions.isBreakingLock} + onCancel={actions.closeBreakLockDialog} + onConfirm={actions.confirmBreakLock} + > +
+ This releases a stale lock left by an interrupted operation (e.g. a container restart + during a compaction).
-
- ) : breakLockDialog ? ( -
-
- +
+ Only do this if backups fail with a lock error and{' '} + no backup or compaction is currently running on this repository.
-

- Break the lock on{' '} - {targetRepo?.repositoryName} ? -

-
-
- This releases a stale lock left by an interrupted operation (e.g. a container - restart during a compaction). -
-
- Only do this if backups fail with a lock error and no backup or compaction is - currently running on this repository. -
+ + ) : actions.archiveDialog ? ( + } + title={ + <> + Archive {targetRepo?.repositoryName} ? + + } + confirmLabel='Archive it' + busyLabel='Archiving…' + isBusy={actions.isArchiving} + onCancel={actions.closeArchiveDialog} + onConfirm={actions.confirmArchive} + > +
+ Archiving freezes this repository: the client can no longer connect (no backup, + prune or restore) and it stops triggering notifications.
-
- - +
+ The data is kept untouched and this is fully reversible — you can unarchive it + at any time.
-
+
) : (
{props.mode == 'edit' && ( @@ -407,209 +336,217 @@ export default function RepoManage(props: RepoManageProps) { )} {props.mode == 'add' &&

Add a repository

} + {isArchived && ( +
+ + + This repository is archived and frozen. Unarchive it below to make changes. + +
+ )}
- {/* ALIAS */} - - - {errors.alias && {errors.alias.message}} - {/* ICON */} - - - {/* SSH KEY */} - -