diff --git a/internal/api/backresthandler.go b/internal/api/backresthandler.go index f66b6776..6f42800b 100644 --- a/internal/api/backresthandler.go +++ b/internal/api/backresthandler.go @@ -388,16 +388,15 @@ func (s BackrestHandler) DoRepoTask(ctx context.Context, req *connect.Request[v1 func (s *BackrestHandler) Restore(ctx context.Context, req *connect.Request[v1.RestoreSnapshotRequest]) (*connect.Response[emptypb.Empty], error) { if req.Msg.Target == "" { - req.Msg.Target = path.Join(os.Getenv("HOME"), "Downloads") + req.Msg.Target = path.Join(os.Getenv("HOME"), "Downloads", fmt.Sprintf("restic-restore-%v", time.Now().Format("2006-01-02T15-04-05"))) } if req.Msg.Path == "" { req.Msg.Path = "/" } - target := path.Join(req.Msg.Target, fmt.Sprintf("restic-restore-%v", time.Now().Format("2006-01-02T15-04-05"))) - _, err := os.Stat(target) - if !errors.Is(err, os.ErrNotExist) { - return nil, fmt.Errorf("restore target dir %q already exists", req.Msg.Target) + // prevent restoring to a directory that already exists + if _, err := os.Stat(req.Msg.Target); err == nil { + return nil, fmt.Errorf("target directory %q already exists", req.Msg.Target) } at := time.Now() @@ -406,7 +405,7 @@ func (s *BackrestHandler) Restore(ctx context.Context, req *connect.Request[v1.R if err != nil { return nil, fmt.Errorf("failed to get flow ID for snapshot %q: %w", req.Msg.SnapshotId, err) } - s.orchestrator.ScheduleTask(tasks.NewOneoffRestoreTask(req.Msg.RepoId, req.Msg.PlanId, flowID, at, req.Msg.SnapshotId, req.Msg.Path, target), tasks.TaskPriorityInteractive+tasks.TaskPriorityDefault) + s.orchestrator.ScheduleTask(tasks.NewOneoffRestoreTask(req.Msg.RepoId, req.Msg.PlanId, flowID, at, req.Msg.SnapshotId, req.Msg.Path, req.Msg.Target), tasks.TaskPriorityInteractive+tasks.TaskPriorityDefault) return connect.NewResponse(&emptypb.Empty{}), nil } diff --git a/webui/gen/ts/google/api/annotations_pb.ts b/webui/gen/ts/google/api/annotations_pb.ts deleted file mode 100644 index c582629a..00000000 --- a/webui/gen/ts/google/api/annotations_pb.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2015 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// @generated by protoc-gen-es v1.7.2 with parameter "target=ts" -// @generated from file google/api/annotations.proto (package google.api, syntax proto3) -/* eslint-disable */ -// @ts-nocheck - -import { MethodOptions, proto3 } from "@bufbuild/protobuf"; -import { HttpRule } from "./http_pb.js"; - -/** - * See `HttpRule`. - * - * @generated from extension: google.api.HttpRule http = 72295728; - */ -export const http = proto3.makeExtension( - "google.api.http", - MethodOptions, - () => ({ no: 72295728, kind: "message", T: HttpRule }), -); diff --git a/webui/src/components/HooksFormList.tsx b/webui/src/components/HooksFormList.tsx index 37a25423..098fd820 100644 --- a/webui/src/components/HooksFormList.tsx +++ b/webui/src/components/HooksFormList.tsx @@ -311,7 +311,7 @@ const hookTypes: { name={[field.name, "actionGotify", "baseUrl"]} rules={[ requiredField("gotify base URL is required"), - { type: "url" }, + { type: "string" }, ]} > diff --git a/webui/src/components/OperationTree.tsx b/webui/src/components/OperationTree.tsx index 3c768fcb..a8ec95f3 100644 --- a/webui/src/components/OperationTree.tsx +++ b/webui/src/components/OperationTree.tsx @@ -81,8 +81,8 @@ export const OperationTree = ({ }); }, [JSON.stringify(req)]); - const treeData = useMemo(() => { - return buildTreePlan(backups); + const [treeData, defaultExpanded] = useMemo(() => { + return buildTreeInstanceID(backups); }, [backups]); if (backups.length === 0) { @@ -97,9 +97,7 @@ export const OperationTree = ({ treeData={treeData} showIcon - defaultExpandedKeys={backups - .slice(0, Math.min(10, backups.length)) - .map((b) => b.id!)} + defaultExpandedKeys={defaultExpanded} onSelect={(keys, info) => { if (info.selectedNodes.length === 0) return; const backup = info.selectedNodes[0].backup; @@ -214,42 +212,80 @@ export const OperationTree = ({ ); }; -const buildTreePlan = (operations: BackupInfo[]): OpTreeNode[] => { +const buildTreeInstanceID = ( + operations: BackupInfo[] +): [OpTreeNode[], React.Key[]] => { const grouped = _.groupBy(operations, (op) => { - return op.operations[0].planId!; + return op.operations[0].instanceId!; }); + const expanded: React.Key[] = []; const entries: OpTreeNode[] = _.map(grouped, (value, key) => { + const [children, childrenExpanded] = buildTreePlan(value); + expanded.push(...childrenExpanded); return { key: key, title: key, - children: buildTreeDay(key, value), + children: children, }; }); if (entries.length === 1) { - return entries[0].children!; + return [entries[0].children!, expanded]; } entries.sort(sortByKeyReverse); - return entries; + return [entries, expanded]; +}; + +const buildTreePlan = ( + operations: BackupInfo[] +): [OpTreeNode[], React.Key[]] => { + const grouped = _.groupBy(operations, (op) => { + return op.operations[0].planId!; + }); + const expanded: React.Key[] = []; + const entries: OpTreeNode[] = _.map(grouped, (value, key) => { + const [children, childrenExpanded] = buildTreeDay(key, value); + expanded.push(...childrenExpanded); + return { + key: key, + title: key, + children: children, + }; + }); + if (entries.length === 1) { + return [entries[0].children!, expanded]; + } + entries.sort(sortByKeyReverse); + return [entries, expanded]; }; const buildTreeDay = ( keyPrefix: string, operations: BackupInfo[] -): OpTreeNode[] => { +): [OpTreeNode[], React.Key[]] => { const grouped = _.groupBy(operations, (op) => { return localISOTime(op.displayTime).substring(0, 10); }); - const entries = _.map(grouped, (value, key) => { + const children = buildTreeLeaf(value); return { key: keyPrefix + key, title: formatDate(value[0].displayTime), - children: buildTreeLeaf(value), + children: children, }; }); entries.sort(sortByKey); - return entries; + + const expanded: React.Key[] = []; + let visibleChildCount = 0; + for (const e of entries) { + expanded.push(e.key); + visibleChildCount += e.children!.length; + if (visibleChildCount > 5) { + break; + } + } + return [entries, expanded]; }; const buildTreeLeaf = (operations: BackupInfo[]): OpTreeNode[] => { diff --git a/webui/src/components/SnapshotBrowser.tsx b/webui/src/components/SnapshotBrowser.tsx index 258d960f..a8914951 100644 --- a/webui/src/components/SnapshotBrowser.tsx +++ b/webui/src/components/SnapshotBrowser.tsx @@ -13,11 +13,18 @@ import { FolderOutlined, } from "@ant-design/icons"; import { useShowModal } from "./ModalManager"; -import { formatBytes, normalizeSnapshotId } from "../lib/formatting"; +import { + formatBytes, + formatDate, + formatTime, + normalizeSnapshotId, +} from "../lib/formatting"; import { URIAutocomplete } from "./URIAutocomplete"; import { validateForm } from "../lib/formutil"; import { backrestService } from "../api"; import { ConfirmButton } from "./SpinButton"; +import { StringValue } from "@bufbuild/protobuf"; +import { pathSeparator } from "../state/buildcfg"; const SnapshotBrowserContext = React.createContext<{ snapshotId: string; @@ -261,6 +268,59 @@ const RestoreModal = ({ } }; + const defaultPath = useMemo(() => { + if (path === pathSeparator) { + return ""; + } + return path + "-backrest-restore-" + normalizeSnapshotId(snapshotId); + }, [path]); + + let targetPath = Form.useWatch("target", form); + useEffect(() => { + if (!targetPath) { + return; + } + (async () => { + try { + if (targetPath.endsWith(pathSeparator)) { + targetPath = targetPath.slice(0, -1); + } + + const dirname = basename(targetPath); + const files = await backrestService.pathAutocomplete( + new StringValue({ value: dirname }) + ); + + for (const file of files.values) { + if (dirname + file === targetPath) { + form.setFields([ + { + name: "target", + errors: [ + "target path already exists, you must pick an empty path.", + ], + }, + ]); + return; + } + } + form.setFields([ + { + name: "target", + errors: [], + }, + ]); + } catch (e: any) { + form.setFields([ + { + name: "target", + errors: [e.message], + }, + ]); + } + })(); + }, [targetPath]); + return ( - - form.validateFields()} /> +

+ If restoring to a specific path, ensure that the path does not already + exist or that you are comfortable overwriting the data at that + location. +

+

+ You may set the path to an empty string to restore to your Downloads + folder. +

+ +
); }; + +const basename = (path: string) => { + const idx = path.lastIndexOf(pathSeparator); + if (idx === -1) { + return path; + } + return path.slice(0, idx + 1); +}; diff --git a/webui/src/components/URIAutocomplete.tsx b/webui/src/components/URIAutocomplete.tsx index 52775d99..35010736 100644 --- a/webui/src/components/URIAutocomplete.tsx +++ b/webui/src/components/URIAutocomplete.tsx @@ -55,13 +55,13 @@ export const URIAutocomplete = (props: React.PropsWithChildren) => { if (isWindows) { if (value.match(/^[a-zA-Z]:\\$/)) { return Promise.reject( - new Error("Path must start with a drive letter e.g. C:\\"), + new Error("Path must start with a drive letter e.g. C:\\") ); } else if (value.includes("/")) { return Promise.reject( new Error( - "Path must use backslashes e.g. C:\\Users\\MyUsers\\Documents", - ), + "Path must use backslashes e.g. C:\\Users\\MyUsers\\Documents" + ) ); } } diff --git a/webui/src/state/buildcfg.ts b/webui/src/state/buildcfg.ts index 704a7089..e754efe7 100644 --- a/webui/src/state/buildcfg.ts +++ b/webui/src/state/buildcfg.ts @@ -4,3 +4,4 @@ export const uiBuildVersion = ( process.env.BACKREST_BUILD_VERSION || "dev-snapshot-build" ).trim(); export const isDevBuild = uiBuildVersion === "dev-snapshot-build"; +export const pathSeparator = isWindows ? "\\" : "/"; \ No newline at end of file