From 013fe5a521605fff03320354faa50463f0d07bd9 Mon Sep 17 00:00:00 2001
From: Nico <47644445+nicotsx@users.noreply.github.com>
Date: Fri, 2 Jan 2026 16:56:13 +0100
Subject: [PATCH 01/12] Fix/file tree selection parents (#276)
* fix(file-tree): propagate path simplification to parents
When all childs of a folder are selected in the tree, the selected path
was only simplifiyng one level. But if the reduction was also making its
own parent "fully selected" it was not correctly reporting only the
parent as selected
* test(file-tree): ensure path selection correctness
* fix(file-tree): cleanup based on given path not what's visible
---
.../components/__test__/file-tree.test.tsx | 166 ++++++++++++++++++
app/client/components/file-tree.tsx | 90 +++++-----
app/client/components/volume-file-browser.tsx | 6 +-
app/test/setup-client.ts | 4 +
bun.lock | 47 ++++-
package.json | 7 +-
6 files changed, 274 insertions(+), 46 deletions(-)
create mode 100644 app/client/components/__test__/file-tree.test.tsx
create mode 100644 app/test/setup-client.ts
diff --git a/app/client/components/__test__/file-tree.test.tsx b/app/client/components/__test__/file-tree.test.tsx
new file mode 100644
index 00000000..7589fe63
--- /dev/null
+++ b/app/client/components/__test__/file-tree.test.tsx
@@ -0,0 +1,166 @@
+/** biome-ignore-all lint/style/noNonNullAssertion: Testing file - non-null assertions are acceptable here */
+import { expect, test, describe } from "bun:test";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { FileTree, type FileEntry } from "../file-tree";
+
+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", async () => {
+ let currentSelection = new Set();
+ const onSelectionChange = (selection: Set) => {
+ currentSelection = selection;
+ };
+
+ render(
+ f.path))}
+ />,
+ );
+
+ const photosCheckbox = screen.getByText("photos").parentElement?.querySelector('button[role="checkbox"]');
+ expect(photosCheckbox).toBeTruthy();
+
+ fireEvent.click(photosCheckbox!);
+
+ expect(currentSelection.has("/root")).toBe(true);
+ expect(currentSelection.size).toBe(1);
+ });
+
+ test("unselecting a child removes the parent from selection", async () => {
+ let currentSelection = new Set(["/root"]);
+ const onSelectionChange = (selection: Set) => {
+ currentSelection = selection;
+ };
+
+ render(
+ f.path))}
+ />,
+ );
+
+ const libraryCheckbox = screen.getByText("library").parentElement?.querySelector('button[role="checkbox"]');
+ fireEvent.click(libraryCheckbox!);
+
+ expect(currentSelection.has("/root")).toBe(false);
+ expect(currentSelection.has("/root/photos")).toBe(false);
+
+ expect(currentSelection.has("/root/photos/backups")).toBe(true);
+ expect(currentSelection.has("/root/photos/profile")).toBe(true);
+ expect(currentSelection.has("/root/photos/upload")).toBe(true);
+ expect(currentSelection.size).toBe(3);
+ });
+
+ test("recursive simplification when all children are selected", async () => {
+ let currentSelection = new Set();
+ const onSelectionChange = (selection: Set) => {
+ currentSelection = selection;
+ };
+
+ const { rerender } = render(
+ f.path))}
+ />,
+ );
+
+ const children = ["backups", "library", "profile", "upload"];
+
+ for (const name of children) {
+ const checkbox = screen.getByText(name).parentElement?.querySelector('button[role="checkbox"]');
+ fireEvent.click(checkbox!);
+
+ rerender(
+ f.path))}
+ />,
+ );
+ }
+
+ expect(currentSelection.has("/root")).toBe(true);
+ expect(currentSelection.size).toBe(1);
+ });
+
+ test("does not simplify to parent if not all children are selected", async () => {
+ const multipleFiles: FileEntry[] = [
+ { name: "root", path: "/root", type: "folder" },
+ { name: "child1", path: "/root/child1", type: "folder" },
+ { name: "child2", path: "/root/child2", type: "folder" },
+ ];
+
+ let currentSelection = new Set();
+ const onSelectionChange = (selection: Set) => {
+ currentSelection = selection;
+ };
+
+ render(
+ f.path))}
+ />,
+ );
+
+ const child1Checkbox = screen.getByText("child1").parentElement?.querySelector('button[role="checkbox"]');
+ fireEvent.click(child1Checkbox!);
+
+ expect(currentSelection.has("/root/child1")).toBe(true);
+ expect(currentSelection.has("/root")).toBe(false);
+ expect(currentSelection.size).toBe(1);
+ });
+
+ test("simplifies existing deep paths when parent is selected", async () => {
+ const files: FileEntry[] = [
+ { name: "hello", path: "/hello", type: "folder" },
+ { name: "hello_prev", path: "/hello_prev", type: "folder" },
+ { name: "service", path: "/service", type: "folder" },
+ ];
+
+ let currentSelection = new Set(["/hello", "/hello_prev", "/service/app/data/upload"]);
+ const onSelectionChange = (selection: Set) => {
+ currentSelection = selection;
+ };
+
+ render(
+ ,
+ );
+
+ const serviceCheckbox = screen.getByText("service").parentElement?.querySelector('button[role="checkbox"]');
+ expect(serviceCheckbox).toBeTruthy();
+
+ fireEvent.click(serviceCheckbox!);
+
+ expect(currentSelection.has("/service")).toBe(true);
+ expect(currentSelection.has("/service/app/data/upload")).toBe(false);
+ expect(currentSelection.size).toBe(3); // /hello, /hello_prev, /service
+ });
+});
diff --git a/app/client/components/file-tree.tsx b/app/client/components/file-tree.tsx
index 7789c821..fa9559c2 100644
--- a/app/client/components/file-tree.tsx
+++ b/app/client/components/file-tree.tsx
@@ -116,13 +116,15 @@ export const FileTree = memo((props: Props) => {
// Add new folders to collapsed set when file list changes
useEffect(() => {
setCollapsedFolders((prevSet) => {
+ let hasChanges = false;
const newSet = new Set(prevSet);
for (const item of fileList) {
if (item.kind === "folder" && !newSet.has(item.fullPath) && !expandedFolders.has(item.fullPath)) {
newSet.add(item.fullPath);
+ hasChanges = true;
}
}
- return newSet;
+ return hasChanges ? newSet : prevSet;
});
}, [fileList, expandedFolders]);
@@ -149,9 +151,9 @@ export const FileTree = memo((props: Props) => {
newSelection.add(path);
// Remove any descendants from selection since parent now covers them
- for (const item of fileList) {
- if (item.fullPath.startsWith(`${path}/`)) {
- newSelection.delete(item.fullPath);
+ for (const selectedPath of newSelection) {
+ if (selectedPath.startsWith(`${path}/`)) {
+ newSelection.delete(selectedPath);
}
}
} else {
@@ -182,7 +184,8 @@ export const FileTree = memo((props: Props) => {
if (
item.fullPath.startsWith(`${selectedParentPath}/`) &&
!item.fullPath.startsWith(`${path}/`) &&
- item.fullPath !== path
+ item.fullPath !== path &&
+ !path.startsWith(`${item.fullPath}/`)
) {
newSelection.add(item.fullPath);
}
@@ -190,39 +193,45 @@ export const FileTree = memo((props: Props) => {
}
}
- const childrenByParent = new Map();
- for (const selectedPath of newSelection) {
- const lastSlashIndex = selectedPath.lastIndexOf("/");
- if (lastSlashIndex > 0) {
- const parentPath = selectedPath.slice(0, lastSlashIndex);
- if (!childrenByParent.has(parentPath)) {
- childrenByParent.set(parentPath, []);
- }
- childrenByParent.get(parentPath)?.push(selectedPath);
- }
- }
-
- // For each parent, check if all its children are selected
- for (const [parentPath, selectedChildren] of childrenByParent.entries()) {
- // Get all children of this parent from the file list
- const allChildren = fileList.filter((item) => {
- const itemParentPath = item.fullPath.slice(0, item.fullPath.lastIndexOf("/"));
- return itemParentPath === parentPath;
- });
-
- // If all children are selected, replace them with the parent
- if (allChildren.length > 0 && selectedChildren.length === allChildren.length) {
- // Check that we have every child
- const allChildrenPaths = new Set(allChildren.map((c) => c.fullPath));
- const allChildrenSelected = selectedChildren.every((c) => allChildrenPaths.has(c));
-
- if (allChildrenSelected) {
- // Remove all children
- for (const childPath of selectedChildren) {
- newSelection.delete(childPath);
+ let changed = true;
+ while (changed) {
+ changed = false;
+ const childrenByParent = new Map();
+ for (const selectedPath of newSelection) {
+ const lastSlashIndex = selectedPath.lastIndexOf("/");
+ if (lastSlashIndex > 0) {
+ const parentPath = selectedPath.slice(0, lastSlashIndex);
+ if (!childrenByParent.has(parentPath)) {
+ childrenByParent.set(parentPath, []);
+ }
+ childrenByParent.get(parentPath)?.push(selectedPath);
+ }
+ }
+
+ // For each parent, check if all its children are selected
+ for (const [parentPath, selectedChildren] of childrenByParent.entries()) {
+ // Get all children of this parent from the file list
+ const allChildren = fileList.filter((item) => {
+ const itemParentPath = item.fullPath.slice(0, item.fullPath.lastIndexOf("/"));
+ return itemParentPath === parentPath;
+ });
+
+ // If all children are selected, replace them with the parent
+ if (allChildren.length > 0 && selectedChildren.length === allChildren.length) {
+ // Check that we have every child
+ const allChildrenPaths = new Set(allChildren.map((c) => c.fullPath));
+ const allChildrenSelected = selectedChildren.every((c) => allChildrenPaths.has(c));
+
+ if (allChildrenSelected) {
+ // Remove all children
+ for (const childPath of selectedChildren) {
+ newSelection.delete(childPath);
+ }
+ // Add the parent
+ newSelection.add(parentPath);
+ changed = true;
+ break;
}
- // Add the parent
- newSelection.add(parentPath);
}
}
}
@@ -481,8 +490,9 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
return (
-
{icon}
{children}
-
+
);
});
diff --git a/app/client/components/volume-file-browser.tsx b/app/client/components/volume-file-browser.tsx
index 84ea09e5..6e9cd53e 100644
--- a/app/client/components/volume-file-browser.tsx
+++ b/app/client/components/volume-file-browser.tsx
@@ -57,7 +57,7 @@ export const VolumeFileBrowser = ({
if (fileBrowser.isLoading) {
return (
-
+
);
@@ -65,7 +65,7 @@ export const VolumeFileBrowser = ({
if (error) {
return (
-
+
Failed to load files: {(error as Error).message}
);
@@ -73,7 +73,7 @@ export const VolumeFileBrowser = ({
if (fileBrowser.isEmpty) {
return (
-
+
{emptyMessage}
{emptyDescription &&
{emptyDescription}
}
diff --git a/app/test/setup-client.ts b/app/test/setup-client.ts
new file mode 100644
index 00000000..f737ee3e
--- /dev/null
+++ b/app/test/setup-client.ts
@@ -0,0 +1,4 @@
+import "./setup.ts";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+
+GlobalRegistrator.register();
diff --git a/bun.lock b/bun.lock
index 73092752..936ed491 100644
--- a/bun.lock
+++ b/bun.lock
@@ -61,10 +61,13 @@
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"@faker-js/faker": "^10.1.0",
+ "@happy-dom/global-registrator": "^20.0.11",
"@hey-api/openapi-ts": "^0.88.0",
"@react-router/dev": "^7.10.0",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query-devtools": "^5.91.1",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/react": "^16.3.1",
"@types/bun": "^1.3.4",
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
@@ -140,6 +143,8 @@
"@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="],
+ "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
+
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
@@ -244,6 +249,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
+ "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
+
"@hey-api/codegen-core": ["@hey-api/codegen-core@0.3.3", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-vArVDtrvdzFewu1hnjUm4jX1NBITlSCeO81EdWq676MxQbyxsGcDPAgohaSA+Wvr4HjPSvsg2/1s2zYxUtXebg=="],
"@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.2.2", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.1", "lodash": "^4.17.21" } }, "sha512-oS+5yAdwnK20lSeFO1d53Ku+yaGCsY8PcrmSq2GtSs3bsBfRnHAbpPKSVzQcaxAOrzj5NB+f34WhZglVrNayBA=="],
@@ -494,6 +501,12 @@
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.1", "", { "dependencies": { "@tanstack/query-devtools": "5.91.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.10", "react": "^18 || ^19" } }, "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ=="],
+ "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
+
+ "@testing-library/react": ["@testing-library/react@16.3.1", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw=="],
+
+ "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
+
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
@@ -528,13 +541,15 @@
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
+ "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
+
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
- "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
- "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
+ "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
@@ -544,6 +559,8 @@
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
+ "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
+
"arkregex": ["arkregex@0.0.5", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw=="],
"arktype": ["arktype@2.1.29", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.5" } }, "sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ=="],
@@ -676,6 +693,8 @@
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
@@ -686,6 +705,8 @@
"dither-plugin": ["dither-plugin@1.1.1", "", {}, "sha512-PsgAcSoNVKkwh+Q/OopRn/2qb9HW1LRyGqT1bQe8iooYvVY1FIIqePFN9JkEIVK9rkfZdj7nXn9EUB4B7mNh6g=="],
+ "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
+
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"dotenv-cli": ["dotenv-cli@11.0.0", "", { "dependencies": { "cross-spawn": "^7.0.6", "dotenv": "^17.1.0", "dotenv-expand": "^12.0.0", "minimist": "^1.2.6" }, "bin": { "dotenv": "cli.js" } }, "sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww=="],
@@ -778,6 +799,8 @@
"h3": ["h3@1.15.4", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.2", "radix3": "^1.1.2", "ufo": "^1.6.1", "uncrypto": "^0.1.3" } }, "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ=="],
+ "happy-dom": ["happy-dom@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g=="],
+
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
@@ -864,6 +887,8 @@
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
+ "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
+
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -948,6 +973,8 @@
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
+ "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
+
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
@@ -1124,6 +1151,8 @@
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.0.3", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-7bL7FPX/DSviaZGYUKowWF1AiDVWjMjxNbE8lyaVGDezkedWqfGhlnQ4BZXre0ZN5P4kAgIJfAlgFDVyjrCIyg=="],
+ "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
+
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="],
@@ -1150,6 +1179,8 @@
"@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+ "@happy-dom/global-registrator/@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="],
+
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -1210,6 +1241,8 @@
"giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+ "happy-dom/@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="],
+
"mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"morgan/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -1220,14 +1253,22 @@
"pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+ "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
+
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"react-router-hono-server/@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
+ "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+
"vite-node/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+ "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
+
+ "@happy-dom/global-registrator/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@@ -1236,6 +1277,8 @@
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
+ "happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
"morgan/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
diff --git a/package.json b/package.json
index 4b6861b1..385dfc3c 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,9 @@
"gen:api-client": "openapi-ts",
"gen:migrations": "drizzle-kit generate",
"studio": "drizzle-kit studio",
- "test": "dotenv -e .env.test -- bun test --preload ./app/test/setup.ts"
+ "test:server": "dotenv -e .env.test -- bun test app/server --preload ./app/test/setup.ts",
+ "test:client": "dotenv -e .env.test -- bun test app/client --preload ./app/test/setup-client.ts",
+ "test": "bun run test:server && bun run test:client"
},
"overrides": {
"esbuild": "^0.27.2"
@@ -79,10 +81,13 @@
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"@faker-js/faker": "^10.1.0",
+ "@happy-dom/global-registrator": "^20.0.11",
"@hey-api/openapi-ts": "^0.88.0",
"@react-router/dev": "^7.10.0",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query-devtools": "^5.91.1",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/react": "^16.3.1",
"@types/bun": "^1.3.4",
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
From 2c786dfb351fa34fd3e91b3b54a71da00dc5b257 Mon Sep 17 00:00:00 2001
From: Nico <47644445+nicotsx@users.noreply.github.com>
Date: Fri, 2 Jan 2026 17:04:06 +0100
Subject: [PATCH 02/12] feat: snapshots bulk actions (#256)
* feat: delete snapshots in bulk
* feat: re-tag snapshots in bulk
* chore: pr feedbacks
---
.../api-client/@tanstack/react-query.gen.ts | 38 ++-
app/client/api-client/sdk.gen.ts | 26 +-
app/client/api-client/types.gen.ts | 47 ++++
app/client/components/snapshots-table.tsx | 244 +++++++++++++-----
.../__tests__/repositories.controller.test.ts | 1 +
.../repositories/repositories.controller.ts | 22 ++
.../modules/repositories/repositories.dto.ts | 61 +++++
.../repositories/repositories.service.ts | 36 +++
app/server/utils/restic.ts | 59 ++++-
9 files changed, 464 insertions(+), 70 deletions(-)
diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts
index cb1ffead..f0e1b7d8 100644
--- a/app/client/api-client/@tanstack/react-query.gen.ts
+++ b/app/client/api-client/@tanstack/react-query.gen.ts
@@ -3,8 +3,8 @@
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.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, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.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, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, 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';
+import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, 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, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen';
+import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, 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, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, 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
@@ -439,6 +439,23 @@ export const updateRepositoryMutation = (options?: Partial
>): UseMutationOptions> => {
+ const mutationOptions: UseMutationOptions> = {
+ mutationFn: async (fnOptions) => {
+ const { data } = await deleteSnapshots({
+ ...options,
+ ...fnOptions,
+ throwOnError: true
+ });
+ return data;
+ }
+ };
+ return mutationOptions;
+};
+
export const listSnapshotsQueryKey = (options: Options) => createQueryKey('listSnapshots', options);
/**
@@ -544,6 +561,23 @@ export const doctorRepositoryMutation = (options?: Partial>): UseMutationOptions> => {
+ const mutationOptions: UseMutationOptions> = {
+ mutationFn: async (fnOptions) => {
+ const { data } = await tagSnapshots({
+ ...options,
+ ...fnOptions,
+ throwOnError: true
+ });
+ return data;
+ }
+ };
+ return mutationOptions;
+};
+
export const listBackupSchedulesQueryKey = (options?: Options) => createQueryKey('listBackupSchedules', options);
/**
diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts
index 6c1978ce..a0e13c5b 100644
--- a/app/client/api-client/sdk.gen.ts
+++ b/app/client/api-client/sdk.gen.ts
@@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.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, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, 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';
+import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, 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, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, 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 = Options2 & {
/**
@@ -189,6 +189,18 @@ export const updateRepository = (options:
}
});
+/**
+ * Delete multiple snapshots from a repository
+ */
+export const deleteSnapshots = (options: Options) => (options.client ?? client).delete({
+ url: '/api/v1/repositories/{id}/snapshots',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ }
+});
+
/**
* List all snapshots in a repository
*/
@@ -226,6 +238,18 @@ export const restoreSnapshot = (options: O
*/
export const doctorRepository = (options: Options) => (options.client ?? client).post({ url: '/api/v1/repositories/{id}/doctor', ...options });
+/**
+ * Tag multiple snapshots in a repository
+ */
+export const tagSnapshots = (options: Options) => (options.client ?? client).post({
+ url: '/api/v1/repositories/{id}/snapshots/tag',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ }
+});
+
/**
* List all backup schedules
*/
diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts
index 3abd1800..9146e070 100644
--- a/app/client/api-client/types.gen.ts
+++ b/app/client/api-client/types.gen.ts
@@ -1195,6 +1195,28 @@ export type UpdateRepositoryResponses = {
export type UpdateRepositoryResponse = UpdateRepositoryResponses[keyof UpdateRepositoryResponses];
+export type DeleteSnapshotsData = {
+ body?: {
+ snapshotIds: Array;
+ };
+ path: {
+ id: string;
+ };
+ query?: never;
+ url: '/api/v1/repositories/{id}/snapshots';
+};
+
+export type DeleteSnapshotsResponses = {
+ /**
+ * Snapshots deleted successfully
+ */
+ 200: {
+ message: string;
+ };
+};
+
+export type DeleteSnapshotsResponse = DeleteSnapshotsResponses[keyof DeleteSnapshotsResponses];
+
export type ListSnapshotsData = {
body?: never;
path: {
@@ -1367,6 +1389,31 @@ export type DoctorRepositoryResponses = {
export type DoctorRepositoryResponse = DoctorRepositoryResponses[keyof DoctorRepositoryResponses];
+export type TagSnapshotsData = {
+ body?: {
+ snapshotIds: Array;
+ add?: Array;
+ remove?: Array;
+ set?: Array;
+ };
+ path: {
+ id: string;
+ };
+ query?: never;
+ url: '/api/v1/repositories/{id}/snapshots/tag';
+};
+
+export type TagSnapshotsResponses = {
+ /**
+ * Snapshots tagged successfully
+ */
+ 200: {
+ message: string;
+ };
+};
+
+export type TagSnapshotsResponse = TagSnapshotsResponses[keyof TagSnapshotsResponses];
+
export type ListBackupSchedulesData = {
body?: never;
path?: never;
diff --git a/app/client/components/snapshots-table.tsx b/app/client/components/snapshots-table.tsx
index dfee2792..0f8574bb 100644
--- a/app/client/components/snapshots-table.tsx
+++ b/app/client/components/snapshots-table.tsx
@@ -1,11 +1,12 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { Calendar, Clock, Database, HardDrive, Server, Trash2 } from "lucide-react";
+import { Calendar, Clock, Database, HardDrive, Tag, Trash2, X } from "lucide-react";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import { ByteSize } from "~/client/components/bytes-size";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { Button } from "~/client/components/ui/button";
+import { Checkbox } from "~/client/components/ui/checkbox";
import {
AlertDialog,
AlertDialogAction,
@@ -16,8 +17,17 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} 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 { formatDuration } from "~/utils/utils";
-import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
+import { deleteSnapshotsMutation, tagSnapshotsMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
import type { BackupSchedule, Snapshot } from "../lib/types";
import { cn } from "../lib/utils";
@@ -31,68 +41,126 @@ type Props = {
export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
- const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
- const [snapshotToDelete, setSnapshotToDelete] = useState(null);
- const deleteSnapshot = useMutation({
- ...deleteSnapshotMutation(),
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+ const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
+ const [showReTagDialog, setShowReTagDialog] = useState(false);
+ const [targetScheduleId, setTargetScheduleId] = useState("");
+
+ const deleteSnapshots = useMutation({
+ ...deleteSnapshotsMutation(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
- setShowDeleteConfirm(false);
- setSnapshotToDelete(null);
+ setShowBulkDeleteConfirm(false);
+ setSelectedIds(new Set());
},
});
- const handleDeleteClick = (e: React.MouseEvent, snapshotId: string) => {
- e.stopPropagation();
- setSnapshotToDelete(snapshotId);
- setShowDeleteConfirm(true);
- };
-
- const handleConfirmDelete = () => {
- if (snapshotToDelete) {
- toast.promise(
- deleteSnapshot.mutateAsync({
- path: { id: repositoryId, snapshotId: snapshotToDelete },
- }),
- {
- loading: "Deleting snapshot...",
- success: "Snapshot deleted successfully",
- error: (error) => parseError(error)?.message || "Failed to delete snapshot",
- },
- );
- }
- };
+ const tagSnapshots = useMutation({
+ ...tagSnapshotsMutation(),
+ onMutate: () => {
+ setShowReTagDialog(false);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
+ setShowReTagDialog(false);
+ setSelectedIds(new Set());
+ setTargetScheduleId("");
+ },
+ });
const handleRowClick = (snapshotId: string) => {
navigate(`/repositories/${repositoryId}/${snapshotId}`);
};
+ const toggleSelectAll = () => {
+ if (selectedIds.size === snapshots.length) {
+ setSelectedIds(new Set());
+ } else {
+ setSelectedIds(new Set(snapshots.map((s) => s.short_id)));
+ }
+ };
+
+ const handleBulkDelete = () => {
+ toast.promise(
+ deleteSnapshots.mutateAsync({
+ path: { id: 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) => String(b.id) === targetScheduleId);
+ if (!schedule) return;
+
+ toast.promise(
+ tagSnapshots.mutateAsync({
+ path: { id: 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 (
<>
-
+
+
+ 0}
+ onCheckedChange={toggleSelectAll}
+ aria-label="Select all"
+ />
+
Snapshot ID
Schedule
Date & Time
Size
Duration
- Volume
- Actions
{snapshots.map((snapshot) => {
const backup = backups.find((b) => snapshot.tags.includes(b.shortId));
+ const isSelected = selectedIds.has(snapshot.short_id);
return (
handleRowClick(snapshot.short_id)}
>
+ e.stopPropagation()}>
+ {
+ 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}`}
+ />
+
@@ -134,32 +202,6 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
{formatDuration(snapshot.duration / 1000)}
-
-
-
- e.stopPropagation()}
- className="hover:underline"
- >
- {backup ? backup.volume.name : "-"}
-
-
- -
-
-
-
-
- handleDeleteClick(e, snapshot.short_id)}
- disabled={deleteSnapshot.isPending}
- >
-
-
-
);
})}
@@ -167,27 +209,99 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
-
+ {selectedIds.size > 0 && (
+
+
+
+ setSelectedIds(new Set())}
+ >
+
+
+ {selectedIds.size} selected
+
+
+ setShowReTagDialog(true)}
+ >
+
+ Re-tag
+
+ setShowBulkDeleteConfirm(true)}
+ >
+
+ Delete
+
+
+
+
+ )}
+
+
- Delete snapshot?
+ Delete {selectedIds.size} snapshots?
- This action cannot be undone. This will permanently delete the snapshot and all its data from the
- repository.
+ This action cannot be undone. This will permanently delete the selected snapshots and all their data from
+ the repository.
Cancel
- Delete snapshot
+ Delete {selectedIds.size} snapshots
+
+
+
+
+ Re-tag snapshots
+
+ Select a backup schedule to re-tag the {selectedIds.size} selected snapshots. All {selectedIds.size}{" "}
+ selected snapshots will be associated with the chosen schedule.
+
+
+
+
+
+
+
+
+ {backups.map((backup) => (
+
+ {backup.name}
+
+ ))}
+
+
+
+
+ setShowReTagDialog(false)}>
+ Cancel
+
+
+ Apply tags
+
+
+
+
>
);
};
diff --git a/app/server/modules/repositories/__tests__/repositories.controller.test.ts b/app/server/modules/repositories/__tests__/repositories.controller.test.ts
index d7afc68d..3f375df4 100644
--- a/app/server/modules/repositories/__tests__/repositories.controller.test.ts
+++ b/app/server/modules/repositories/__tests__/repositories.controller.test.ts
@@ -50,6 +50,7 @@ describe("repositories security", () => {
{ method: "POST", path: "/api/v1/repositories/test-repo/restore" },
{ method: "POST", path: "/api/v1/repositories/test-repo/doctor" },
{ method: "DELETE", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot" },
+ { method: "DELETE", path: "/api/v1/repositories/test-repo/snapshots" },
{ method: "PATCH", path: "/api/v1/repositories/test-repo" },
];
diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts
index 142f6eeb..b36d8c25 100644
--- a/app/server/modules/repositories/repositories.controller.ts
+++ b/app/server/modules/repositories/repositories.controller.ts
@@ -5,6 +5,8 @@ import {
createRepositoryDto,
deleteRepositoryDto,
deleteSnapshotDto,
+ deleteSnapshotsBody,
+ deleteSnapshotsDto,
doctorRepositoryDto,
getRepositoryDto,
getSnapshotDetailsDto,
@@ -16,10 +18,13 @@ import {
listSnapshotsFilters,
restoreSnapshotBody,
restoreSnapshotDto,
+ tagSnapshotsBody,
+ tagSnapshotsDto,
updateRepositoryBody,
updateRepositoryDto,
type DeleteRepositoryDto,
type DeleteSnapshotDto,
+ type DeleteSnapshotsResponseDto,
type DoctorRepositoryDto,
type GetRepositoryDto,
type GetSnapshotDetailsDto,
@@ -27,6 +32,7 @@ import {
type ListSnapshotFilesDto,
type ListSnapshotsDto,
type RestoreSnapshotDto,
+ type TagSnapshotsResponseDto,
type UpdateRepositoryDto,
} from "./repositories.dto";
import { repositoriesService } from "./repositories.service";
@@ -160,6 +166,22 @@ export const repositoriesController = new Hono()
return c.json({ message: "Snapshot deleted" }, 200);
})
+ .delete("/:id/snapshots", deleteSnapshotsDto, validator("json", deleteSnapshotsBody), async (c) => {
+ const { id } = c.req.param();
+ const { snapshotIds } = c.req.valid("json");
+
+ await repositoriesService.deleteSnapshots(id, snapshotIds);
+
+ return c.json({ message: "Snapshots deleted" }, 200);
+ })
+ .post("/:id/snapshots/tag", tagSnapshotsDto, validator("json", tagSnapshotsBody), async (c) => {
+ const { id } = c.req.param();
+ const { snapshotIds, ...tags } = c.req.valid("json");
+
+ await repositoriesService.tagSnapshots(id, snapshotIds, tags);
+
+ return c.json({ message: "Snapshots tagged" }, 200);
+ })
.patch("/:id", updateRepositoryDto, validator("json", updateRepositoryBody), async (c) => {
const { id } = c.req.param();
const body = c.req.valid("json");
diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts
index dc28777b..56a215e3 100644
--- a/app/server/modules/repositories/repositories.dto.ts
+++ b/app/server/modules/repositories/repositories.dto.ts
@@ -400,3 +400,64 @@ export const deleteSnapshotDto = describeRoute({
},
},
});
+
+/**
+ * Delete multiple snapshots
+ */
+export const deleteSnapshotsBody = type({
+ snapshotIds: "string[]>=1",
+});
+
+export const deleteSnapshotsResponse = type({
+ message: "string",
+});
+
+export type DeleteSnapshotsResponseDto = typeof deleteSnapshotsResponse.infer;
+
+export const deleteSnapshotsDto = describeRoute({
+ description: "Delete multiple snapshots from a repository",
+ tags: ["Repositories"],
+ operationId: "deleteSnapshots",
+ responses: {
+ 200: {
+ description: "Snapshots deleted successfully",
+ content: {
+ "application/json": {
+ schema: resolver(deleteSnapshotsResponse),
+ },
+ },
+ },
+ },
+});
+
+/**
+ * Tag multiple snapshots
+ */
+export const tagSnapshotsBody = type({
+ snapshotIds: "string[]>=1",
+ add: "string[]?",
+ remove: "string[]?",
+ set: "string[]?",
+});
+
+export const tagSnapshotsResponse = type({
+ message: "string",
+});
+
+export type TagSnapshotsResponseDto = typeof tagSnapshotsResponse.infer;
+
+export const tagSnapshotsDto = describeRoute({
+ description: "Tag multiple snapshots in a repository",
+ tags: ["Repositories"],
+ operationId: "tagSnapshots",
+ responses: {
+ 200: {
+ description: "Snapshots tagged successfully",
+ content: {
+ "application/json": {
+ schema: resolver(tagSnapshotsResponse),
+ },
+ },
+ },
+ },
+});
diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts
index 23f16a08..cdd8bb1d 100644
--- a/app/server/modules/repositories/repositories.service.ts
+++ b/app/server/modules/repositories/repositories.service.ts
@@ -389,6 +389,40 @@ const deleteSnapshot = async (id: string, snapshotId: string) => {
}
};
+const deleteSnapshots = async (id: string, snapshotIds: string[]) => {
+ const repository = await findRepository(id);
+
+ if (!repository) {
+ throw new NotFoundError("Repository not found");
+ }
+
+ const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:bulk`);
+ try {
+ await restic.deleteSnapshots(repository.config, snapshotIds);
+ } finally {
+ releaseLock();
+ }
+};
+
+const tagSnapshots = async (
+ id: string,
+ snapshotIds: string[],
+ tags: { add?: string[]; remove?: string[]; set?: string[] },
+) => {
+ const repository = await findRepository(id);
+
+ if (!repository) {
+ throw new NotFoundError("Repository not found");
+ }
+
+ const releaseLock = await repoMutex.acquireExclusive(repository.id, `tag:bulk`);
+ try {
+ await restic.tagSnapshots(repository.config, snapshotIds, tags);
+ } finally {
+ releaseLock();
+ }
+};
+
const updateRepository = async (id: string, updates: { name?: string; compressionMode?: CompressionMode }) => {
const existing = await findRepository(id);
@@ -439,4 +473,6 @@ export const repositoriesService = {
checkHealth,
doctorRepository,
deleteSnapshot,
+ deleteSnapshots,
+ tagSnapshots,
};
diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts
index 8f696f35..a4880431 100644
--- a/app/server/utils/restic.ts
+++ b/app/server/utils/restic.ts
@@ -567,11 +567,15 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
return { success: true };
};
-const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
+const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[]) => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
- const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
+ if (snapshotIds.length === 0) {
+ throw new Error("No snapshot IDs provided for deletion.");
+ }
+
+ const args: string[] = ["--repo", repoUrl, "forget", ...snapshotIds, "--prune"];
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
@@ -585,6 +589,55 @@ const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
return { success: true };
};
+const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
+ return deleteSnapshots(config, [snapshotId]);
+};
+
+const tagSnapshots = async (
+ config: RepositoryConfig,
+ snapshotIds: string[],
+ tags: { add?: string[]; remove?: string[]; set?: string[] },
+) => {
+ const repoUrl = buildRepoUrl(config);
+ const env = await buildEnv(config);
+
+ if (snapshotIds.length === 0) {
+ throw new Error("No snapshot IDs provided for tagging.");
+ }
+
+ const args: string[] = ["--repo", repoUrl, "tag", ...snapshotIds];
+
+ if (tags.add) {
+ for (const tag of tags.add) {
+ args.push("--add", tag);
+ }
+ }
+
+ if (tags.remove) {
+ for (const tag of tags.remove) {
+ args.push("--remove", tag);
+ }
+ }
+
+ if (tags.set) {
+ for (const tag of tags.set) {
+ args.push("--set", tag);
+ }
+ }
+
+ addCommonArgs(args, env);
+
+ const res = await safeSpawn({ command: "restic", args, env });
+ await cleanupTemporaryKeys(config, env);
+
+ if (res.exitCode !== 0) {
+ logger.error(`Restic snapshot tagging failed: ${res.stderr}`);
+ throw new ResticError(res.exitCode, res.stderr);
+ }
+
+ return { success: true };
+};
+
const lsNodeSchema = type({
name: "string",
type: "string",
@@ -841,6 +894,8 @@ export const restic = {
snapshots,
forget,
deleteSnapshot,
+ deleteSnapshots,
+ tagSnapshots,
unlock,
ls,
check,
From 07e68b9fe754bc427e837bd8f78dae72061d96f7 Mon Sep 17 00:00:00 2001
From: tvories
Date: Thu, 18 Dec 2025 23:52:59 -0700
Subject: [PATCH 03/12] feat: add support for a cacert configuration as well as
allowing the user to skip tls validation
---
.../repository-forms/rest-repository-form.tsx | 39 +++++++++++++++++++
app/client/modules/repositories/tabs/info.tsx | 24 ++++++++++++
app/schemas/restic.ts | 2 +
.../repositories/repositories.service.ts | 3 ++
app/server/utils/restic.ts | 17 ++++++++
5 files changed, 85 insertions(+)
diff --git a/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx b/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx
index 401b099c..e99e44ef 100644
--- a/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx
+++ b/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx
@@ -9,6 +9,8 @@ import {
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
+import { Textarea } from "../../../../components/ui/textarea";
+import { Checkbox } from "../../../../components/ui/checkbox";
import type { RepositoryFormValues } from "../create-repository-form";
type Props = {
@@ -74,6 +76,43 @@ export const RestRepositoryForm = ({ form }: Props) => {
)}
/>
+ (
+
+ CA Certificate (Optional)
+
+
+
+
+ Custom CA certificate for self-signed certificates (PEM format). https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#rest-server
+
+
+
+ )}
+ />
+ (
+
+
+
+
+
+ Skip TLS Certificate Verification
+
+ Disable TLS certificate verification. This is insecure and should only be used for testing. To trust a self-signed certificate without skipping verification, paste the CA certificate in the CA Certificate field.
+
+
+
+ )}
+ />
>
);
};
diff --git a/app/client/modules/repositories/tabs/info.tsx b/app/client/modules/repositories/tabs/info.tsx
index 64d641b9..c5b6697b 100644
--- a/app/client/modules/repositories/tabs/info.tsx
+++ b/app/client/modules/repositories/tabs/info.tsx
@@ -116,6 +116,30 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
+ {repository.type === "rest" && (
+ <>
+
+
CA Certificate
+
+ {repository.config.cacert ? (
+ configured
+ ) : (
+ not configured
+ )}
+
+
+
+
TLS Certificate Validation
+
+ {repository.config.insecureTls ? (
+ disabled
+ ) : (
+ enabled
+ )}
+
+
+ >
+ )}
diff --git a/app/schemas/restic.ts b/app/schemas/restic.ts
index 0ae46710..1b41a5a7 100644
--- a/app/schemas/restic.ts
+++ b/app/schemas/restic.ts
@@ -68,6 +68,8 @@ export const restRepositoryConfigSchema = type({
username: "string?",
password: "string?",
path: "string?",
+ cacert: "string?",
+ insecureTls: "boolean?",
}).and(baseRepositoryConfigSchema);
export const sftpRepositoryConfigSchema = type({
diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts
index cdd8bb1d..01b1983a 100644
--- a/app/server/modules/repositories/repositories.service.ts
+++ b/app/server/modules/repositories/repositories.service.ts
@@ -53,6 +53,9 @@ const encryptConfig = async (config: RepositoryConfig): Promise
{
if (config.password) {
env.RESTIC_REST_PASSWORD = await cryptoUtils.resolveSecret(config.password);
}
+ if (config.cacert) {
+ const decryptedCert = await cryptoUtils.resolveSecret(config.cacert);
+ const certPath = path.join("/tmp", `rest-server-cacert-${crypto.randomBytes(8).toString("hex")}.pem`);
+ await fs.writeFile(certPath, decryptedCert, { mode: 0o600 });
+ env.RESTIC_CACERT = certPath;
+ }
+ if (config.insecureTls) {
+ env._REST_INSECURE_TLS = "true";
+ }
break;
}
case "sftp": {
@@ -876,6 +885,10 @@ export const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record
} else if (config.backend === "gcs" && env.GOOGLE_APPLICATION_CREDENTIALS) {
await fs.unlink(env.GOOGLE_APPLICATION_CREDENTIALS).catch(() => {});
}
+
+ if (config.backend === "rest" && env.RESTIC_CACERT) {
+ await fs.unlink(env.RESTIC_CACERT).catch(() => {});
+ }
};
export const addCommonArgs = (args: string[], env: Record) => {
@@ -884,6 +897,10 @@ export const addCommonArgs = (args: string[], env: Record) => {
if (env._SFTP_SSH_ARGS) {
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
}
+
+ if (env._REST_INSECURE_TLS === "true") {
+ args.push("--insecure-tls");
+ }
};
export const restic = {
From e78af76beb2178f0a7c089400a4dbf59f398e7ec Mon Sep 17 00:00:00 2001
From: tvories
Date: Fri, 19 Dec 2025 17:29:05 -0700
Subject: [PATCH 04/12] feat: added additional validation, moved field
location, made skip tls and ca certificate mutually exclusive with tooltips
---
.../components/create-repository-form.tsx | 1 +
.../repository-forms/rest-repository-form.tsx | 185 ++++++++++++++----
app/schemas/restic.ts | 42 +++-
3 files changed, 190 insertions(+), 38 deletions(-)
diff --git a/app/client/modules/repositories/components/create-repository-form.tsx b/app/client/modules/repositories/components/create-repository-form.tsx
index 08fa6e0b..01a2d270 100644
--- a/app/client/modules/repositories/components/create-repository-form.tsx
+++ b/app/client/modules/repositories/components/create-repository-form.tsx
@@ -72,6 +72,7 @@ export const CreateRepositoryForm = ({
const form = useForm({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues,
+ mode: "onTouched",
resetOptions: {
keepDefaultValues: true,
keepDirtyValues: false,
diff --git a/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx b/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx
index e99e44ef..d0ff8395 100644
--- a/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx
+++ b/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx
@@ -1,3 +1,4 @@
+import { useState, useRef } from "react";
import type { UseFormReturn } from "react-hook-form";
import {
FormControl,
@@ -11,6 +12,7 @@ import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
import { Textarea } from "../../../../components/ui/textarea";
import { Checkbox } from "../../../../components/ui/checkbox";
+import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../components/ui/tooltip";
import type { RepositoryFormValues } from "../create-repository-form";
type Props = {
@@ -18,6 +20,13 @@ type Props = {
};
export const RestRepositoryForm = ({ form }: Props) => {
+ const insecureTls = form.watch("insecureTls");
+ const cacert = form.watch("cacert");
+ const [showCertTooltip, setShowCertTooltip] = useState(false);
+ const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 });
+ const tooltipTimeoutRef = useRef(null);
+ const tooltipHideTimeoutRef = useRef(null);
+
return (
<>
{
)}
/>
+ (
+
+
+
+
+
+ {
+ field.onChange(checked);
+ if (checked) {
+ form.setValue("cacert", "");
+ }
+ }}
+ />
+
+
+ {cacert && (
+
+
+ This option is disabled because a CA certificate is provided. Remove the CA certificate to skip
+ TLS validation instead.
+
+
+ )}
+
+
+
+ Skip TLS Certificate Verification
+
+ Disable TLS certificate verification if rest server is https and uses a self-signed certificate. This is
+ insecure and should only be used for testing.
+
+
+
+ )}
+ />
+ (
+
+ CA Certificate (Optional)
+
+
+
+
+ Custom CA certificate for self-signed certificates (PEM format).{" "}
+
+ Learn more
+
+
+
+
+ )}
+ />
{
)}
/>
- (
-
- CA Certificate (Optional)
-
-
-
-
- Custom CA certificate for self-signed certificates (PEM format). https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#rest-server
-
-
-
- )}
- />
- (
-
-
-
-
-
- Skip TLS Certificate Verification
-
- Disable TLS certificate verification. This is insecure and should only be used for testing. To trust a self-signed certificate without skipping verification, paste the CA certificate in the CA Certificate field.
-
-
-
- )}
- />
>
);
};
diff --git a/app/schemas/restic.ts b/app/schemas/restic.ts
index 1b41a5a7..96b692a8 100644
--- a/app/schemas/restic.ts
+++ b/app/schemas/restic.ts
@@ -62,13 +62,53 @@ export const rcloneRepositoryConfigSchema = type({
path: "string",
}).and(baseRepositoryConfigSchema);
+const pemCertificateType = type("string").narrow((value, ctx) => {
+ if (!value || value.trim() === "") {
+ return true;
+ }
+
+ const trimmed = value.trim();
+
+ // Check for BEGIN and END markers
+ if (!trimmed.includes("-----BEGIN CERTIFICATE-----") || !trimmed.includes("-----END CERTIFICATE-----")) {
+ return ctx.error("Certificate must be in PEM format with BEGIN and END markers");
+ }
+
+ // Extract content between markers
+ const beginMarker = "-----BEGIN CERTIFICATE-----";
+ const endMarker = "-----END CERTIFICATE-----";
+ const beginIndex = trimmed.indexOf(beginMarker);
+ const endIndex = trimmed.indexOf(endMarker);
+
+ if (beginIndex === -1 || endIndex === -1 || endIndex <= beginIndex) {
+ return ctx.error("Invalid PEM certificate structure");
+ }
+
+ // Extract base64 content
+ const base64Content = trimmed.substring(beginIndex + beginMarker.length, endIndex).replace(/\s/g, "");
+
+ // Validate base64 format
+ const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
+ if (!base64Regex.test(base64Content)) {
+ return ctx.error("Certificate contains invalid base64 characters");
+ }
+
+ if (base64Content.length === 0) {
+ return ctx.error("Certificate content is empty");
+ }
+
+ return true;
+});
+
+const optionalPemCertificate = pemCertificateType.optional();
+
export const restRepositoryConfigSchema = type({
backend: "'rest'",
url: "string",
username: "string?",
password: "string?",
path: "string?",
- cacert: "string?",
+ cacert: optionalPemCertificate as any,
insecureTls: "boolean?",
}).and(baseRepositoryConfigSchema);
From 1a1243619501760cbbcc420a21618ee5ec4e9e5e Mon Sep 17 00:00:00 2001
From: tvories
Date: Sun, 21 Dec 2025 18:48:11 -0700
Subject: [PATCH 05/12] fix: add property existence checks before accessing
repository config fields
---
app/client/modules/repositories/tabs/info.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/client/modules/repositories/tabs/info.tsx b/app/client/modules/repositories/tabs/info.tsx
index c5b6697b..ad980487 100644
--- a/app/client/modules/repositories/tabs/info.tsx
+++ b/app/client/modules/repositories/tabs/info.tsx
@@ -116,7 +116,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
- {repository.type === "rest" && (
+ {repository.type === "rest" && "cacert" in repository.config && (
<>
CA Certificate
@@ -131,7 +131,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
TLS Certificate Validation
- {repository.config.insecureTls ? (
+ {"insecureTls" in repository.config && repository.config.insecureTls ? (
disabled
) : (
enabled
From 08a19ed38db2de1a096ce02bc1f586b195d59503 Mon Sep 17 00:00:00 2001
From: tvories
Date: Tue, 30 Dec 2025 15:07:22 -0700
Subject: [PATCH 06/12] fix: PR suggestions
---
.../components/create-repository-form.tsx | 1 -
.../repository-forms/rest-repository-form.tsx | 99 +++++--------------
app/schemas/restic.ts | 42 +-------
app/server/utils/restic.ts | 2 +-
4 files changed, 25 insertions(+), 119 deletions(-)
diff --git a/app/client/modules/repositories/components/create-repository-form.tsx b/app/client/modules/repositories/components/create-repository-form.tsx
index 01a2d270..08fa6e0b 100644
--- a/app/client/modules/repositories/components/create-repository-form.tsx
+++ b/app/client/modules/repositories/components/create-repository-form.tsx
@@ -72,7 +72,6 @@ export const CreateRepositoryForm = ({
const form = useForm({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues,
- mode: "onTouched",
resetOptions: {
keepDefaultValues: true,
keepDirtyValues: false,
diff --git a/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx b/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx
index d0ff8395..4317f98d 100644
--- a/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx
+++ b/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx
@@ -1,4 +1,3 @@
-import { useState, useRef } from "react";
import type { UseFormReturn } from "react-hook-form";
import {
FormControl,
@@ -22,10 +21,6 @@ type Props = {
export const RestRepositoryForm = ({ form }: Props) => {
const insecureTls = form.watch("insecureTls");
const cacert = form.watch("cacert");
- const [showCertTooltip, setShowCertTooltip] = useState(false);
- const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 });
- const tooltipTimeoutRef = useRef(null);
- const tooltipHideTimeoutRef = useRef(null);
return (
<>
@@ -91,81 +86,33 @@ export const RestRepositoryForm = ({ form }: Props) => {
CA Certificate (Optional)
-
+
Custom CA certificate for self-signed certificates (PEM format).{" "}
diff --git a/app/schemas/restic.ts b/app/schemas/restic.ts
index 96b692a8..1b41a5a7 100644
--- a/app/schemas/restic.ts
+++ b/app/schemas/restic.ts
@@ -62,53 +62,13 @@ export const rcloneRepositoryConfigSchema = type({
path: "string",
}).and(baseRepositoryConfigSchema);
-const pemCertificateType = type("string").narrow((value, ctx) => {
- if (!value || value.trim() === "") {
- return true;
- }
-
- const trimmed = value.trim();
-
- // Check for BEGIN and END markers
- if (!trimmed.includes("-----BEGIN CERTIFICATE-----") || !trimmed.includes("-----END CERTIFICATE-----")) {
- return ctx.error("Certificate must be in PEM format with BEGIN and END markers");
- }
-
- // Extract content between markers
- const beginMarker = "-----BEGIN CERTIFICATE-----";
- const endMarker = "-----END CERTIFICATE-----";
- const beginIndex = trimmed.indexOf(beginMarker);
- const endIndex = trimmed.indexOf(endMarker);
-
- if (beginIndex === -1 || endIndex === -1 || endIndex <= beginIndex) {
- return ctx.error("Invalid PEM certificate structure");
- }
-
- // Extract base64 content
- const base64Content = trimmed.substring(beginIndex + beginMarker.length, endIndex).replace(/\s/g, "");
-
- // Validate base64 format
- const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
- if (!base64Regex.test(base64Content)) {
- return ctx.error("Certificate contains invalid base64 characters");
- }
-
- if (base64Content.length === 0) {
- return ctx.error("Certificate content is empty");
- }
-
- return true;
-});
-
-const optionalPemCertificate = pemCertificateType.optional();
-
export const restRepositoryConfigSchema = type({
backend: "'rest'",
url: "string",
username: "string?",
password: "string?",
path: "string?",
- cacert: optionalPemCertificate as any,
+ cacert: "string?",
insecureTls: "boolean?",
}).and(baseRepositoryConfigSchema);
diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts
index 8768c162..b7044c46 100644
--- a/app/server/utils/restic.ts
+++ b/app/server/utils/restic.ts
@@ -886,7 +886,7 @@ export const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record
await fs.unlink(env.GOOGLE_APPLICATION_CREDENTIALS).catch(() => {});
}
- if (config.backend === "rest" && env.RESTIC_CACERT) {
+ if (env.RESTIC_CACERT) {
await fs.unlink(env.RESTIC_CACERT).catch(() => {});
}
};
From e0027b7668bb9e62b6c70af8507843b3e7f36475 Mon Sep 17 00:00:00 2001
From: Nicolas Meienberger
Date: Fri, 2 Jan 2026 17:37:38 +0100
Subject: [PATCH 07/12] refactor: code style and api client
---
app/client/api-client/types.gen.ts | 26 +++++++++++
.../repository-forms/rest-repository-form.tsx | 43 +++++++------------
app/client/modules/repositories/tabs/info.tsx | 42 ++++++++----------
app/server/utils/restic.ts | 29 +++++++------
4 files changed, 76 insertions(+), 64 deletions(-)
diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts
index 9146e070..44b2b3c5 100644
--- a/app/client/api-client/types.gen.ts
+++ b/app/client/api-client/types.gen.ts
@@ -838,7 +838,9 @@ export type ListRepositoriesResponses = {
} | {
backend: 'rest';
url: string;
+ cacert?: string;
customPassword?: string;
+ insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@@ -917,7 +919,9 @@ export type CreateRepositoryData = {
} | {
backend: 'rest';
url: string;
+ cacert?: string;
customPassword?: string;
+ insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@@ -1058,7 +1062,9 @@ export type GetRepositoryResponses = {
} | {
backend: 'rest';
url: string;
+ cacert?: string;
customPassword?: string;
+ insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@@ -1164,7 +1170,9 @@ export type UpdateRepositoryResponses = {
} | {
backend: 'rest';
url: string;
+ cacert?: string;
customPassword?: string;
+ insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@@ -1487,7 +1495,9 @@ export type ListBackupSchedulesResponses = {
} | {
backend: 'rest';
url: string;
+ cacert?: string;
customPassword?: string;
+ insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@@ -1749,7 +1759,9 @@ export type GetBackupScheduleResponses = {
} | {
backend: 'rest';
url: string;
+ cacert?: string;
customPassword?: string;
+ insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@@ -1992,7 +2004,9 @@ export type GetBackupScheduleForVolumeResponses = {
} | {
backend: 'rest';
url: string;
+ cacert?: string;
customPassword?: string;
+ insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@@ -2211,6 +2225,7 @@ export type GetScheduleNotificationsResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
+ accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@@ -2310,6 +2325,7 @@ export type UpdateScheduleNotificationsResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
+ accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@@ -2420,7 +2436,9 @@ export type GetScheduleMirrorsResponses = {
} | {
backend: 'rest';
url: string;
+ cacert?: string;
customPassword?: string;
+ insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@@ -2526,7 +2544,9 @@ export type UpdateScheduleMirrorsResponses = {
} | {
backend: 'rest';
url: string;
+ cacert?: string;
customPassword?: string;
+ insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@@ -2646,6 +2666,7 @@ export type ListNotificationDestinationsResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
+ accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@@ -2716,6 +2737,7 @@ export type CreateNotificationDestinationData = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
+ accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@@ -2785,6 +2807,7 @@ export type CreateNotificationDestinationResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
+ accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@@ -2901,6 +2924,7 @@ export type GetNotificationDestinationResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
+ accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@@ -2971,6 +2995,7 @@ export type UpdateNotificationDestinationData = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
+ accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@@ -3050,6 +3075,7 @@ export type UpdateNotificationDestinationResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
+ accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
diff --git a/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx b/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx
index 4317f98d..287519fb 100644
--- a/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx
+++ b/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx
@@ -13,6 +13,7 @@ import { Textarea } from "../../../../components/ui/textarea";
import { Checkbox } from "../../../../components/ui/checkbox";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../components/ui/tooltip";
import type { RepositoryFormValues } from "../create-repository-form";
+import { cn } from "~/client/lib/utils";
type Props = {
form: UseFormReturn;
@@ -52,25 +53,20 @@ export const RestRepositoryForm = ({ form }: Props) => {
disabled={!!cacert}
onCheckedChange={(checked) => {
field.onChange(checked);
- if (checked) {
- form.setValue("cacert", "");
- }
}}
/>
- {cacert && (
-
-
- This option is disabled because a CA certificate is provided. Remove the CA certificate to skip
- TLS validation instead.
-
-
- )}
+
+
+ This option is disabled because a CA certificate is provided. Remove the CA certificate to skip TLS
+ validation instead.
+
+
- Skip TLS Certificate Verification
+ Skip TLS certificate verification
Disable TLS certificate verification if rest server is https and uses a self-signed certificate. This is
insecure and should only be used for testing.
@@ -94,28 +90,19 @@ export const RestRepositoryForm = ({ form }: Props) => {
rows={6}
disabled={insecureTls}
{...field}
- value={insecureTls ? "" : field.value}
- onChange={(e) => {
- field.onChange(e);
- if (e.target.value) {
- form.setValue("insecureTls", false);
- }
- }}
/>
- {insecureTls && (
-
-
- CA certificate is disabled because TLS validation is being skipped. Uncheck "Skip TLS Certificate
- Verification" to provide a custom CA certificate.
-
-
- )}
+
+
+ CA certificate is disabled because TLS validation is being skipped. Uncheck "Skip TLS Certificate
+ Verification" to provide a custom CA certificate.
+
+
- Custom CA certificate for self-signed certificates (PEM format).{" "}
+ Custom CA certificate for self-signed certificates (PEM format).
{
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
- {repository.type === "rest" && "cacert" in repository.config && (
- <>
-
-
CA Certificate
-
- {repository.config.cacert ? (
- configured
- ) : (
- not configured
- )}
-
-
-
-
TLS Certificate Validation
-
- {"insecureTls" in repository.config && repository.config.insecureTls ? (
- disabled
- ) : (
- enabled
- )}
-
-
- >
+ {repository.config.backend === "rest" && repository.config.cacert && (
+
+
CA Certificate
+
+ configured
+
+
+ )}
+ {repository.config.backend === "rest" && "insecureTls" in repository.config && (
+
+
TLS Certificate Validation
+
+ {repository.config.insecureTls ? (
+ disabled
+ ) : (
+ enabled
+ )}
+
+
)}
diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts
index b7044c46..f7670b09 100644
--- a/app/server/utils/restic.ts
+++ b/app/server/utils/restic.ts
@@ -157,7 +157,7 @@ export const buildEnv = async (config: RepositoryConfig) => {
}
if (config.cacert) {
const decryptedCert = await cryptoUtils.resolveSecret(config.cacert);
- const certPath = path.join("/tmp", `rest-server-cacert-${crypto.randomBytes(8).toString("hex")}.pem`);
+ const certPath = path.join("/tmp", `zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`);
await fs.writeFile(certPath, decryptedCert, { mode: 0o600 });
env.RESTIC_CACERT = certPath;
}
@@ -855,8 +855,8 @@ const copy = async (
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(sourceConfig, sourceEnv);
- await cleanupTemporaryKeys(destConfig, destEnv);
+ await cleanupTemporaryKeys(sourceEnv);
+ await cleanupTemporaryKeys(destEnv);
const { stdout, stderr } = res;
@@ -872,17 +872,20 @@ const copy = async (
};
};
-export const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record) => {
- if (config.backend === "sftp") {
- if (env._SFTP_KEY_PATH) {
- await fs.unlink(env._SFTP_KEY_PATH).catch(() => {});
- }
- if (env._SFTP_KNOWN_HOSTS_PATH) {
- await fs.unlink(env._SFTP_KNOWN_HOSTS_PATH).catch(() => {});
- }
- } else if (config.isExistingRepository && config.customPassword && env.RESTIC_PASSWORD_FILE) {
+export const cleanupTemporaryKeys = async (env: Record) => {
+ if (env._SFTP_KEY_PATH) {
+ await fs.unlink(env._SFTP_KEY_PATH).catch(() => {});
+ }
+
+ if (env._SFTP_KNOWN_HOSTS_PATH) {
+ await fs.unlink(env._SFTP_KNOWN_HOSTS_PATH).catch(() => {});
+ }
+
+ if (env.RESTIC_PASSWORD_FILE) {
await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {});
- } else if (config.backend === "gcs" && env.GOOGLE_APPLICATION_CREDENTIALS) {
+ }
+
+ if (env.GOOGLE_APPLICATION_CREDENTIALS) {
await fs.unlink(env.GOOGLE_APPLICATION_CREDENTIALS).catch(() => {});
}
From b8fe2a985b9af41c6aabe1e89aafd691bd89480e Mon Sep 17 00:00:00 2001
From: Nico <47644445+nicotsx@users.noreply.github.com>
Date: Fri, 2 Jan 2026 18:13:19 +0100
Subject: [PATCH 08/12] feat: insecure tls & cacert for self-hosted repos
(#277)
* feat: insecure tls & cacert for self-hosted repos
* fix: extra arg
---
app/client/components/ui/collapsible.tsx | 98 +++++++++++++++
.../components/create-repository-form.tsx | 3 +
.../repository-forms/advanced-tls-form.tsx | 112 ++++++++++++++++++
.../components/repository-forms/index.ts | 1 +
.../repository-forms/rest-repository-form.tsx | 84 -------------
app/client/modules/repositories/tabs/info.tsx | 10 +-
app/schemas/restic.ts | 4 +-
app/server/modules/lifecycle/migration.ts | 2 +-
.../repositories/repositories.service.ts | 7 +-
app/server/utils/restic.ts | 50 ++++----
bun.lock | 3 +
package.json | 1 +
12 files changed, 259 insertions(+), 116 deletions(-)
create mode 100644 app/client/components/ui/collapsible.tsx
create mode 100644 app/client/modules/repositories/components/repository-forms/advanced-tls-form.tsx
diff --git a/app/client/components/ui/collapsible.tsx b/app/client/components/ui/collapsible.tsx
new file mode 100644
index 00000000..5e13272d
--- /dev/null
+++ b/app/client/components/ui/collapsible.tsx
@@ -0,0 +1,98 @@
+import * as React from "react";
+import { ChevronDown } from "lucide-react";
+import { cn } from "~/client/lib/utils";
+
+interface CollapsibleProps extends React.HTMLAttributes {
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ defaultOpen?: boolean;
+}
+
+const CollapsibleContext = React.createContext<{
+ open: boolean;
+ setOpen: React.Dispatch>;
+}>({
+ open: false,
+ setOpen: () => {},
+});
+
+const Collapsible = React.forwardRef(
+ ({ 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) => {
+ const newValue = typeof value === "function" ? value(open) : value;
+ if (!isControlled) {
+ setUncontrolledOpen(newValue);
+ }
+ onOpenChange?.(newValue);
+ },
+ [isControlled, open, onOpenChange],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+ },
+);
+Collapsible.displayName = "Collapsible";
+
+interface CollapsibleTriggerProps extends React.ButtonHTMLAttributes {}
+
+const CollapsibleTrigger = React.forwardRef(
+ ({ className, children, ...props }, ref) => {
+ const { open, setOpen } = React.useContext(CollapsibleContext);
+
+ return (
+ svg]:rotate-180",
+ className,
+ )}
+ data-state={open ? "open" : "closed"}
+ onClick={() => setOpen(!open)}
+ {...props}
+ >
+ {children}
+
+
+ );
+ },
+);
+CollapsibleTrigger.displayName = "CollapsibleTrigger";
+
+interface CollapsibleContentProps extends React.HTMLAttributes {}
+
+const CollapsibleContent = React.forwardRef(
+ ({ className, children, ...props }, ref) => {
+ const { open } = React.useContext(CollapsibleContext);
+
+ return (
+
+ {open && children}
+
+ );
+ },
+);
+CollapsibleContent.displayName = "CollapsibleContent";
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent };
diff --git a/app/client/modules/repositories/components/create-repository-form.tsx b/app/client/modules/repositories/components/create-repository-form.tsx
index 08fa6e0b..827cb2cf 100644
--- a/app/client/modules/repositories/components/create-repository-form.tsx
+++ b/app/client/modules/repositories/components/create-repository-form.tsx
@@ -31,6 +31,7 @@ import {
RcloneRepositoryForm,
RestRepositoryForm,
SftpRepositoryForm,
+ AdvancedForm,
} from "./repository-forms";
export const formSchema = type({
@@ -268,6 +269,8 @@ export const CreateRepositoryForm = ({
{watchedBackend === "rest" && }
{watchedBackend === "sftp" && }
+
+
{mode === "update" && (
diff --git a/app/client/modules/repositories/components/repository-forms/advanced-tls-form.tsx b/app/client/modules/repositories/components/repository-forms/advanced-tls-form.tsx
new file mode 100644
index 00000000..3d45e3ce
--- /dev/null
+++ b/app/client/modules/repositories/components/repository-forms/advanced-tls-form.tsx
@@ -0,0 +1,112 @@
+import type { UseFormReturn } from "react-hook-form";
+import {
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "../../../../components/ui/form";
+import { Textarea } from "../../../../components/ui/textarea";
+import { Checkbox } from "../../../../components/ui/checkbox";
+import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../components/ui/tooltip";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../components/ui/collapsible";
+import type { RepositoryFormValues } from "../create-repository-form";
+import { cn } from "~/client/lib/utils";
+
+type Props = {
+ form: UseFormReturn;
+};
+
+export const AdvancedForm = ({ form }: Props) => {
+ const insecureTls = form.watch("insecureTls");
+ const cacert = form.watch("cacert");
+
+ return (
+
+
+ Advanced Settings
+
+
+ (
+
+
+
+
+
+ {
+ field.onChange(checked);
+ }}
+ />
+
+
+
+
+ This option is disabled because a CA certificate is provided. Remove the CA certificate to skip
+ TLS validation instead.
+
+
+
+
+
+ Skip TLS certificate verification
+
+ Disable TLS certificate verification for HTTPS connections with self-signed certificates. This is
+ insecure and should only be used for testing.
+
+
+
+ )}
+ />
+ (
+
+ CA Certificate (Optional)
+
+
+
+
+
+
+
+
+
+ CA certificate is disabled because TLS validation is being skipped. Uncheck "Skip TLS Certificate
+ Verification" to provide a custom CA certificate.
+
+
+
+
+
+ Custom CA certificate for self-signed certificates (PEM format). This applies to HTTPS
+ connections.
+
+ Learn more
+
+
+
+
+ )}
+ />
+
+
+ );
+};
diff --git a/app/client/modules/repositories/components/repository-forms/index.ts b/app/client/modules/repositories/components/repository-forms/index.ts
index 740ec8fd..2c279f39 100644
--- a/app/client/modules/repositories/components/repository-forms/index.ts
+++ b/app/client/modules/repositories/components/repository-forms/index.ts
@@ -6,3 +6,4 @@ export { AzureRepositoryForm } from "./azure-repository-form";
export { RcloneRepositoryForm } from "./rclone-repository-form";
export { RestRepositoryForm } from "./rest-repository-form";
export { SftpRepositoryForm } from "./sftp-repository-form";
+export { AdvancedForm } from "./advanced-tls-form";
diff --git a/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx b/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx
index 287519fb..401b099c 100644
--- a/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx
+++ b/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx
@@ -9,20 +9,13 @@ import {
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
-import { Textarea } from "../../../../components/ui/textarea";
-import { Checkbox } from "../../../../components/ui/checkbox";
-import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../components/ui/tooltip";
import type { RepositoryFormValues } from "../create-repository-form";
-import { cn } from "~/client/lib/utils";
type Props = {
form: UseFormReturn;
};
export const RestRepositoryForm = ({ form }: Props) => {
- const insecureTls = form.watch("insecureTls");
- const cacert = form.watch("cacert");
-
return (
<>
{
)}
/>
- (
-
-
-
-
-
- {
- field.onChange(checked);
- }}
- />
-
-
-
-
- This option is disabled because a CA certificate is provided. Remove the CA certificate to skip TLS
- validation instead.
-
-
-
-
-
- Skip TLS certificate verification
-
- Disable TLS certificate verification if rest server is https and uses a self-signed certificate. This is
- insecure and should only be used for testing.
-
-
-
- )}
- />
- (
-
- CA Certificate (Optional)
-
-
-
-
-
-
-
-
-
- CA certificate is disabled because TLS validation is being skipped. Uncheck "Skip TLS Certificate
- Verification" to provide a custom CA certificate.
-
-
-
-
-
- Custom CA certificate for self-signed certificates (PEM format).
-
- Learn more
-
-
-
-
- )}
- />
{
const hasChanges =
name !== repository.name || compressionMode !== ((repository.compressionMode as CompressionMode) || "off");
+ const config = repository.config as RepositoryConfig;
+
return (
<>
@@ -116,7 +118,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
- {repository.config.backend === "rest" && repository.config.cacert && (
+ {config.cacert && (
CA Certificate
@@ -124,11 +126,11 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
)}
- {repository.config.backend === "rest" && "insecureTls" in repository.config && (
+ {"insecureTls" in config && (
TLS Certificate Validation
- {repository.config.insecureTls ? (
+ {config.insecureTls ? (
disabled
) : (
enabled
diff --git a/app/schemas/restic.ts b/app/schemas/restic.ts
index 1b41a5a7..f6001fb1 100644
--- a/app/schemas/restic.ts
+++ b/app/schemas/restic.ts
@@ -17,6 +17,8 @@ export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
const baseRepositoryConfigSchema = type({
isExistingRepository: "boolean?",
customPassword: "string?",
+ cacert: "string?",
+ insecureTls: "boolean?",
});
export const s3RepositoryConfigSchema = type({
@@ -68,8 +70,6 @@ export const restRepositoryConfigSchema = type({
username: "string?",
password: "string?",
path: "string?",
- cacert: "string?",
- insecureTls: "boolean?",
}).and(baseRepositoryConfigSchema);
export const sftpRepositoryConfigSchema = type({
diff --git a/app/server/modules/lifecycle/migration.ts b/app/server/modules/lifecycle/migration.ts
index 32fb6a52..b324a543 100644
--- a/app/server/modules/lifecycle/migration.ts
+++ b/app/server/modules/lifecycle/migration.ts
@@ -77,7 +77,7 @@ const migrateSnapshotsToShortIdTag = async (): Promise => {
logger.info(`Migrating snapshots for schedule '${schedule.name}' from tag '${oldTag}' to '${newTag}'`);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(repository.config, env);
+ await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic tag failed: ${res.stderr}`);
diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts
index 01b1983a..ea0bf792 100644
--- a/app/server/modules/repositories/repositories.service.ts
+++ b/app/server/modules/repositories/repositories.service.ts
@@ -34,6 +34,10 @@ const encryptConfig = async (config: RepositoryConfig): Promise {
if (config.password) {
env.RESTIC_REST_PASSWORD = await cryptoUtils.resolveSecret(config.password);
}
- if (config.cacert) {
- const decryptedCert = await cryptoUtils.resolveSecret(config.cacert);
- const certPath = path.join("/tmp", `zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`);
- await fs.writeFile(certPath, decryptedCert, { mode: 0o600 });
- env.RESTIC_CACERT = certPath;
- }
- if (config.insecureTls) {
- env._REST_INSECURE_TLS = "true";
- }
break;
}
case "sftp": {
@@ -214,6 +205,17 @@ export const buildEnv = async (config: RepositoryConfig) => {
}
}
+ if (config.cacert) {
+ const decryptedCert = await cryptoUtils.resolveSecret(config.cacert);
+ const certPath = path.join("/tmp", `zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`);
+ await fs.writeFile(certPath, decryptedCert, { mode: 0o600 });
+ env.RESTIC_CACERT = certPath;
+ }
+
+ if (config.insecureTls) {
+ env._INSECURE_TLS = "true";
+ }
+
return env;
};
@@ -230,7 +232,7 @@ const init = async (config: RepositoryConfig) => {
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic init failed: ${res.stderr}`);
@@ -358,7 +360,7 @@ const backup = async (
finally: async () => {
includeFile && (await fs.unlink(includeFile).catch(() => {}));
excludeFile && (await fs.unlink(excludeFile).catch(() => {}));
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
},
});
@@ -456,7 +458,7 @@ const restore = async (
logger.debug(`Executing: restic ${args.join(" ")}`);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic restore failed: ${res.stderr}`);
@@ -517,7 +519,7 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
@@ -566,7 +568,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic forget failed: ${res.stderr}`);
@@ -588,7 +590,7 @@ const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[])
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
@@ -637,7 +639,7 @@ const tagSnapshots = async (
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic snapshot tagging failed: ${res.stderr}`);
@@ -687,7 +689,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic ls failed: ${res.stderr}`);
@@ -738,7 +740,7 @@ const unlock = async (config: RepositoryConfig) => {
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic unlock failed: ${res.stderr}`);
@@ -762,7 +764,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
const { stdout, stderr } = res;
@@ -795,7 +797,7 @@ const repairIndex = async (config: RepositoryConfig) => {
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(config, env);
+ await cleanupTemporaryKeys(env);
const { stdout, stderr } = res;
@@ -881,7 +883,7 @@ export const cleanupTemporaryKeys = async (env: Record) => {
await fs.unlink(env._SFTP_KNOWN_HOSTS_PATH).catch(() => {});
}
- if (env.RESTIC_PASSWORD_FILE) {
+ if (env.RESTIC_PASSWORD_FILE && env.RESTIC_PASSWORD_FILE !== RESTIC_PASS_FILE) {
await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {});
}
@@ -901,9 +903,13 @@ export const addCommonArgs = (args: string[], env: Record) => {
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
}
- if (env._REST_INSECURE_TLS === "true") {
+ if (env._INSECURE_TLS === "true") {
args.push("--insecure-tls");
}
+
+ if (env.RESTIC_CACERT) {
+ args.push("--cacert", env.RESTIC_CACERT);
+ }
};
export const restic = {
diff --git a/bun.lock b/bun.lock
index 936ed491..63e98aba 100644
--- a/bun.lock
+++ b/bun.lock
@@ -13,6 +13,7 @@
"@inquirer/prompts": "^8.0.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
@@ -323,6 +324,8 @@
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
+ "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
+
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
diff --git a/package.json b/package.json
index 385dfc3c..44501a2e 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"@inquirer/prompts": "^8.0.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
From 1b88fa6bd6070afb336f16b83f52f3456915328e Mon Sep 17 00:00:00 2001
From: Nico <47644445+nicotsx@users.noreply.github.com>
Date: Fri, 2 Jan 2026 22:10:27 +0100
Subject: [PATCH 09/12] style: responsive fixes (#282)
* style(backups): use container queries to avoid overflow when menu is open
* style: fix breadcrumbs breaking the layout on smaller devices
* style: improve wording and error displays
* chore: wording
---
app/client/components/app-breadcrumb.tsx | 2 +-
app/client/components/layout.tsx | 6 +--
app/client/components/ui/breadcrumb.tsx | 16 ++++---
app/client/components/ui/card.tsx | 16 +++----
.../components/create-schedule-form.tsx | 12 +++---
.../components/schedule-mirrors-config.tsx | 8 ++--
.../schedule-notifications-config.tsx | 14 ++++---
.../backups/components/schedule-summary.tsx | 42 ++++++++++---------
.../backups/components/snapshot-timeline.tsx | 8 ++--
app/client/modules/backups/routes/backups.tsx | 2 +-
.../components/create-repository-form.tsx | 5 ++-
app/client/modules/repositories/tabs/info.tsx | 2 +-
.../modules/repositories/tabs/snapshots.tsx | 6 +--
.../modules/settings/routes/settings.tsx | 8 ++--
14 files changed, 78 insertions(+), 69 deletions(-)
diff --git a/app/client/components/app-breadcrumb.tsx b/app/client/components/app-breadcrumb.tsx
index b6c32b29..6f602f62 100644
--- a/app/client/components/app-breadcrumb.tsx
+++ b/app/client/components/app-breadcrumb.tsx
@@ -38,7 +38,7 @@ export function AppBreadcrumb() {
}
return (
-
+
{breadcrumbs.map((breadcrumb, index) => {
const isLast = index === breadcrumbs.length - 1;
diff --git a/app/client/components/layout.tsx b/app/client/components/layout.tsx
index 8142c252..6030391d 100644
--- a/app/client/components/layout.tsx
+++ b/app/client/components/layout.tsx
@@ -43,8 +43,8 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
-
-
+
+
@@ -76,7 +76,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
-
+
diff --git a/app/client/components/ui/breadcrumb.tsx b/app/client/components/ui/breadcrumb.tsx
index 4d0d3eab..e1fb0843 100644
--- a/app/client/components/ui/breadcrumb.tsx
+++ b/app/client/components/ui/breadcrumb.tsx
@@ -4,8 +4,8 @@ import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "~/client/lib/utils";
-function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
- return
;
+function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
+ return
;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
@@ -13,7 +13,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
) {
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
- return ;
+ return ;
}
function BreadcrumbLink({
@@ -35,7 +35,11 @@ function BreadcrumbLink({
const Comp = asChild ? Slot : "a";
return (
-
+
);
}
@@ -46,7 +50,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
role="link"
aria-disabled="true"
aria-current="page"
- className={cn("text-foreground font-normal", className)}
+ className={cn("text-foreground font-normal truncate", className)}
{...props}
/>
);
diff --git a/app/client/components/ui/card.tsx b/app/client/components/ui/card.tsx
index 697b9eef..20f21ad1 100644
--- a/app/client/components/ui/card.tsx
+++ b/app/client/components/ui/card.tsx
@@ -10,14 +10,14 @@ function Card({ className, children, ...props }: React.ComponentProps<"div">) {
{...props}
>
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
{children}
diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx
index 38437f7f..63d3a8e0 100644
--- a/app/client/modules/backups/components/create-schedule-form.tsx
+++ b/app/client/modules/backups/components/create-schedule-form.tsx
@@ -195,12 +195,12 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
Schedule automated backups of
{volume.name} to a secure repository.
-
+
(
-
+
Backup name
@@ -215,7 +215,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
control={form.control}
name="repositoryId"
render={({ field }) => (
-
+
Backup repository
@@ -300,7 +300,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
control={form.control}
name="weeklyDay"
render={({ field }) => (
-
+
Execution day
@@ -327,7 +327,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
control={form.control}
name="monthlyDays"
render={({ field }) => (
-
+
Days of the month
@@ -512,7 +512,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
Retention policy
Define how many snapshots to keep. Leave empty to keep all.
-
+
Mirror Repositories
-
+
Configure secondary repositories where snapshots will be automatically copied after each backup
@@ -270,9 +270,9 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit
Repository
- Enabled
- Last Copy
-
+ Enabled
+ Last Copy
+
diff --git a/app/client/modules/backups/components/schedule-notifications-config.tsx b/app/client/modules/backups/components/schedule-notifications-config.tsx
index 820e72b7..0a1d4f37 100644
--- a/app/client/modules/backups/components/schedule-notifications-config.tsx
+++ b/app/client/modules/backups/components/schedule-notifications-config.tsx
@@ -152,7 +152,9 @@ export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props)
Notifications
- Configure which notifications to send for this backup schedule
+
+ Configure which notifications to send for this backup schedule
+
{!isAddingNew && availableDestinations.length > 0 && (
setIsAddingNew(true)}>
@@ -198,11 +200,11 @@ export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props)
Destination
- Start
- Success
- Warnings
- Failures
-
+ Start
+ Success
+ Warnings
+ Failures
+
diff --git a/app/client/modules/backups/components/schedule-summary.tsx b/app/client/modules/backups/components/schedule-summary.tsx
index 5022f416..2036ccfa 100644
--- a/app/client/modules/backups/components/schedule-summary.tsx
+++ b/app/client/modules/backups/components/schedule-summary.tsx
@@ -81,7 +81,7 @@ export const ScheduleSummary = (props: Props) => {
-
+
{schedule.name}
@@ -96,7 +96,7 @@ export const ScheduleSummary = (props: Props) => {
-
-
+
{schedule.lastBackupStatus === "in_progress" ? (
-
+
- Stop backup
+ Stop backup
) : (
-
+
- Backup now
+ Backup now
)}
{schedule.retentionPolicy && (
@@ -123,28 +123,28 @@ export const ScheduleSummary = (props: Props) => {
size="sm"
loading={runForget.isPending}
onClick={() => setShowForgetConfirm(true)}
- className="w-full sm:w-auto"
+ className="w-full @md:w-auto"
>
- Run cleanup
+ Run cleanup
)}
- setIsEditMode(true)} className="w-full sm:w-auto">
+ setIsEditMode(true)} className="w-full @md:w-auto">
- Edit schedule
+ Edit schedule
setShowDeleteConfirm(true)}
- className="text-destructive hover:text-destructive w-full sm:w-auto"
+ className="text-destructive hover:text-destructive w-full @md:w-auto"
>
- Delete
+ Delete
-
+
Schedule
{summary.scheduleLabel}
@@ -178,19 +178,21 @@ export const ScheduleSummary = (props: Props) => {
{schedule.lastBackupStatus === "warning" && (
-
+
Warning Details
-
+
{schedule.lastBackupError ??
"Last backup completed with warnings. Check your container logs for more details."}
)}
- {schedule.lastBackupError && (
-
-
Error Details
-
{schedule.lastBackupError}
+ {schedule.lastBackupError && schedule.lastBackupStatus === "error" && (
+
+
Error details
+
+ {schedule.lastBackupError}
+
)}
diff --git a/app/client/modules/backups/components/snapshot-timeline.tsx b/app/client/modules/backups/components/snapshot-timeline.tsx
index 4bdce96c..7ed68ef4 100644
--- a/app/client/modules/backups/components/snapshot-timeline.tsx
+++ b/app/client/modules/backups/components/snapshot-timeline.tsx
@@ -1,5 +1,5 @@
import { cn } from "~/client/lib/utils";
-import { Card } from "~/client/components/ui/card";
+import { Card, CardContent } from "~/client/components/ui/card";
import { ByteSize } from "~/client/components/bytes-size";
import { useEffect } from "react";
import type { ListSnapshotsResponse } from "~/client/api-client";
@@ -24,9 +24,9 @@ export const SnapshotTimeline = (props: Props) => {
if (error) {
return (
-
-
Error loading snapshots: {error}
-
+
+ {error}
+
);
}
diff --git a/app/client/modules/backups/routes/backups.tsx b/app/client/modules/backups/routes/backups.tsx
index 76f0c714..49b5ca54 100644
--- a/app/client/modules/backups/routes/backups.tsx
+++ b/app/client/modules/backups/routes/backups.tsx
@@ -117,7 +117,7 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
-
+
{items.map((id) => {
const schedule = scheduleMap.get(id);
if (!schedule) return null;
diff --git a/app/client/modules/repositories/components/create-repository-form.tsx b/app/client/modules/repositories/components/create-repository-form.tsx
index 827cb2cf..becbf94d 100644
--- a/app/client/modules/repositories/components/create-repository-form.tsx
+++ b/app/client/modules/repositories/components/create-repository-form.tsx
@@ -226,12 +226,13 @@ export const CreateRepositoryForm = ({
- Use Zerobyte's password
+ Use the existing recovery key
Enter password manually
- Choose whether to use Zerobyte's master password or enter a custom password for the existing repository.
+ Choose whether to use Zerobyte's recovery key (which you downloaded when creating your account) or enter
+ a custom password for the existing repository.
diff --git a/app/client/modules/repositories/tabs/info.tsx b/app/client/modules/repositories/tabs/info.tsx
index 135264f7..2b62871a 100644
--- a/app/client/modules/repositories/tabs/info.tsx
+++ b/app/client/modules/repositories/tabs/info.tsx
@@ -147,7 +147,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
Last Error
-
{repository.lastError}
+
{repository.lastError}
)}
diff --git a/app/client/modules/repositories/tabs/snapshots.tsx b/app/client/modules/repositories/tabs/snapshots.tsx
index dea2a5d8..8b124755 100644
--- a/app/client/modules/repositories/tabs/snapshots.tsx
+++ b/app/client/modules/repositories/tabs/snapshots.tsx
@@ -46,13 +46,13 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
- Repository Error
+ Repository error
This repository is in an error state and cannot be accessed.
{repository.lastError && (
-
-
{repository.lastError}
+
)}
diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx
index 7bbd0dfd..aca29aaf 100644
--- a/app/client/modules/settings/routes/settings.tsx
+++ b/app/client/modules/settings/routes/settings.tsx
@@ -206,7 +206,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
Backup Recovery Key
-
Download your Restic password file for disaster recovery
+
Download your recovery key for Restic backups
@@ -219,15 +219,15 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
- Download Restic Password
+ Download recovery key
- Download Restic Password
+ Download Recovery Key
- For security reasons, please enter your account password to download the Restic password file.
+ For security reasons, please enter your account password to download the recovery key file.
From b0a1e4b3d783472b53f0bb46884c1e56ce51b426 Mon Sep 17 00:00:00 2001
From: Nico <47644445+nicotsx@users.noreply.github.com>
Date: Fri, 2 Jan 2026 23:07:14 +0100
Subject: [PATCH 10/12] fix: also migrate tag for schedule mirrors (#283)
---
app/server/modules/lifecycle/migration.ts | 66 +++++++++++++++++------
1 file changed, 51 insertions(+), 15 deletions(-)
diff --git a/app/server/modules/lifecycle/migration.ts b/app/server/modules/lifecycle/migration.ts
index b324a543..0acfde73 100644
--- a/app/server/modules/lifecycle/migration.ts
+++ b/app/server/modules/lifecycle/migration.ts
@@ -1,6 +1,6 @@
import { eq } from "drizzle-orm";
import { db } from "../../db/db";
-import { repositoriesTable } from "../../db/schema";
+import { backupScheduleMirrorsTable, repositoriesTable, type Repository } from "../../db/schema";
import { logger } from "../../utils/logger";
import { hasMigrationCheckpoint, recordMigrationCheckpoint } from "./checkpoint";
import { toMessage } from "~/server/utils/errors";
@@ -51,6 +51,32 @@ export const retagSnapshots = async () => {
logger.info(`Snapshots retagging migration (${MIGRATION_VERSION}) complete.`);
};
+const migrateTag = async (
+ oldTag: string,
+ newTag: string,
+ repository: Repository,
+ scheduleName: string,
+): Promise => {
+ const repoUrl = buildRepoUrl(repository.config);
+ const env = await buildEnv(repository.config);
+
+ const args = ["--repo", repoUrl, "tag", "--tag", oldTag, "--add", newTag, "--remove", oldTag];
+
+ addCommonArgs(args, env);
+
+ logger.info(`Migrating snapshots for schedule '${scheduleName}' from tag '${oldTag}' to '${newTag}'`);
+ const res = await safeSpawn({ command: "restic", args, env });
+ await cleanupTemporaryKeys(env);
+
+ if (res.exitCode !== 0) {
+ logger.error(`Restic tag failed: ${res.stderr}`);
+ return toMessage(res.stderr);
+ }
+
+ logger.info(`Migrated snapshots for schedule '${scheduleName}' from tag '${oldTag}' to '${newTag}'`);
+ return null;
+};
+
const migrateSnapshotsToShortIdTag = async (): Promise => {
const errors: Array<{ name: string; error: string }> = [];
const backupSchedules = await db.query.backupSchedulesTable.findMany({});
@@ -68,23 +94,33 @@ const migrateSnapshotsToShortIdTag = async (): Promise => {
continue;
}
- const repoUrl = buildRepoUrl(repository.config);
- const env = await buildEnv(repository.config);
-
- const args = ["--repo", repoUrl, "tag", "--tag", oldTag, "--add", newTag, "--remove", oldTag];
-
- addCommonArgs(args, env);
-
- logger.info(`Migrating snapshots for schedule '${schedule.name}' from tag '${oldTag}' to '${newTag}'`);
- const res = await safeSpawn({ command: "restic", args, env });
- await cleanupTemporaryKeys(env);
-
- if (res.exitCode !== 0) {
- logger.error(`Restic tag failed: ${res.stderr}`);
- errors.push({ name: `schedule:${schedule.name}`, error: `Restic tag command failed: ${toMessage(res.stderr)}` });
+ const error = await migrateTag(oldTag, newTag, repository, schedule.name);
+ if (error) {
+ errors.push({ name: `schedule:${schedule.name}`, error });
continue;
}
+ const mirrors = await db
+ .select()
+ .from(backupScheduleMirrorsTable)
+ .where(eq(backupScheduleMirrorsTable.scheduleId, schedule.id));
+
+ for (const mirror of mirrors) {
+ const mirrorRepo = await db.query.repositoriesTable.findFirst({
+ where: eq(repositoriesTable.id, mirror.repositoryId),
+ });
+
+ if (!mirrorRepo) {
+ errors.push({ name: `schedule-mirror:${schedule.name}`, error: `Associated mirror repository not found` });
+ continue;
+ }
+
+ const mirrorError = await migrateTag(oldTag, newTag, mirrorRepo, `${schedule.name} (mirror)`);
+ if (mirrorError) {
+ errors.push({ name: `schedule-mirror:${schedule.name}`, error: mirrorError });
+ }
+ }
+
logger.info(`Migrated snapshots for schedule '${schedule.name}' from tag '${oldTag}' to '${newTag}'`);
}
From 3eb07ef924f0cca2e491b5257e0bd1acb9fab1e0 Mon Sep 17 00:00:00 2001
From: Nicolas Meienberger
Date: Sat, 3 Jan 2026 00:16:42 +0100
Subject: [PATCH 11/12] refactor: allow the server to start, even in case of
migration failure
---
app/server/modules/lifecycle/migration.ts | 76 +++++++++++++----------
1 file changed, 42 insertions(+), 34 deletions(-)
diff --git a/app/server/modules/lifecycle/migration.ts b/app/server/modules/lifecycle/migration.ts
index 0acfde73..4f740ab8 100644
--- a/app/server/modules/lifecycle/migration.ts
+++ b/app/server/modules/lifecycle/migration.ts
@@ -40,14 +40,18 @@ export const retagSnapshots = async () => {
const allErrors = [...result.errors];
if (allErrors.length > 0) {
+ logger.error(`Migration ${MIGRATION_VERSION} completed with errors: ${allErrors.length} items failed.`);
+ logger.error(
+ `Some snapshots could not be retagged. Please check the logs for details. Fix any repository in error state and re-start zerobyte to retry the migration for failed items.`,
+ );
for (const err of allErrors) {
logger.error(`Migration failure - ${err.name}: ${err.error}`);
}
- throw new MigrationError(MIGRATION_VERSION, allErrors);
+
+ return;
}
await recordMigrationCheckpoint(MIGRATION_VERSION);
-
logger.info(`Snapshots retagging migration (${MIGRATION_VERSION}) complete.`);
};
@@ -82,46 +86,50 @@ const migrateSnapshotsToShortIdTag = async (): Promise => {
const backupSchedules = await db.query.backupSchedulesTable.findMany({});
for (const schedule of backupSchedules) {
- const oldTag = schedule.id.toString();
- const newTag = schedule.shortId;
+ try {
+ const oldTag = schedule.id.toString();
+ const newTag = schedule.shortId;
- const repository = await db.query.repositoriesTable.findFirst({
- where: eq(repositoriesTable.id, schedule.repositoryId),
- });
-
- if (!repository) {
- errors.push({ name: `schedule:${schedule.name}`, error: `Associated repository not found` });
- continue;
- }
-
- const error = await migrateTag(oldTag, newTag, repository, schedule.name);
- if (error) {
- errors.push({ name: `schedule:${schedule.name}`, error });
- continue;
- }
-
- const mirrors = await db
- .select()
- .from(backupScheduleMirrorsTable)
- .where(eq(backupScheduleMirrorsTable.scheduleId, schedule.id));
-
- for (const mirror of mirrors) {
- const mirrorRepo = await db.query.repositoriesTable.findFirst({
- where: eq(repositoriesTable.id, mirror.repositoryId),
+ const repository = await db.query.repositoriesTable.findFirst({
+ where: eq(repositoriesTable.id, schedule.repositoryId),
});
- if (!mirrorRepo) {
- errors.push({ name: `schedule-mirror:${schedule.name}`, error: `Associated mirror repository not found` });
+ if (!repository) {
+ errors.push({ name: `schedule:${schedule.name}`, error: `Associated repository not found` });
continue;
}
- const mirrorError = await migrateTag(oldTag, newTag, mirrorRepo, `${schedule.name} (mirror)`);
- if (mirrorError) {
- errors.push({ name: `schedule-mirror:${schedule.name}`, error: mirrorError });
+ const error = await migrateTag(oldTag, newTag, repository, schedule.name);
+ if (error) {
+ errors.push({ name: `schedule:${schedule.name}`, error });
+ continue;
}
- }
- logger.info(`Migrated snapshots for schedule '${schedule.name}' from tag '${oldTag}' to '${newTag}'`);
+ const mirrors = await db
+ .select()
+ .from(backupScheduleMirrorsTable)
+ .where(eq(backupScheduleMirrorsTable.scheduleId, schedule.id));
+
+ for (const mirror of mirrors) {
+ const mirrorRepo = await db.query.repositoriesTable.findFirst({
+ where: eq(repositoriesTable.id, mirror.repositoryId),
+ });
+
+ if (!mirrorRepo) {
+ errors.push({ name: `schedule-mirror:${schedule.name}`, error: `Associated mirror repository not found` });
+ continue;
+ }
+
+ const mirrorError = await migrateTag(oldTag, newTag, mirrorRepo, `${schedule.name} (mirror)`);
+ if (mirrorError) {
+ errors.push({ name: `schedule-mirror:${schedule.name}`, error: mirrorError });
+ }
+ }
+
+ logger.info(`Migrated snapshots for schedule '${schedule.name}' from tag '${oldTag}' to '${newTag}'`);
+ } catch (err) {
+ errors.push({ name: `schedule:${schedule.name}`, error: toMessage(err) });
+ }
}
return { success: errors.length === 0, errors };
From 1fff5f53c51e2160c48462524895fa454f6826a4 Mon Sep 17 00:00:00 2001
From: Nicolas Meienberger
Date: Sat, 3 Jan 2026 00:24:57 +0100
Subject: [PATCH 12/12] chore: fix migration version
---
app/server/core/constants.ts | 2 +-
app/server/modules/lifecycle/migration.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/server/core/constants.ts b/app/server/core/constants.ts
index e0134cb1..003b8a39 100644
--- a/app/server/core/constants.ts
+++ b/app/server/core/constants.ts
@@ -6,4 +6,4 @@ export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass";
export const DEFAULT_EXCLUDES = [DATABASE_URL, RESTIC_PASS_FILE, REPOSITORY_BASE];
-export const REQUIRED_MIGRATIONS = ["v0.21.0"];
+export const REQUIRED_MIGRATIONS = []; // ["v0.21.1"] add this once re-tagging migration is removed
diff --git a/app/server/modules/lifecycle/migration.ts b/app/server/modules/lifecycle/migration.ts
index 4f740ab8..50fa9e5a 100644
--- a/app/server/modules/lifecycle/migration.ts
+++ b/app/server/modules/lifecycle/migration.ts
@@ -7,7 +7,7 @@ import { toMessage } from "~/server/utils/errors";
import { safeSpawn } from "~/server/utils/spawn";
import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "~/server/utils/restic";
-const MIGRATION_VERSION = "v0.21.0";
+const MIGRATION_VERSION = "v0.21.1";
interface MigrationResult {
success: boolean;