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 ( - + ); }); 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 ( -
+

Loading files...

); @@ -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 : "-"} - - -
-
- - -
); })} @@ -167,27 +209,99 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
- + {selectedIds.size > 0 && ( +
+
+
+ + {selectedIds.size} selected +
+
+ + +
+
+
+ )} + + - 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. + + +
+ +
+ + + + +
+
); }; 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) + +