mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-08-27 19:36:29 +00:00
feat: misc ui improvements
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<MethodOptions, HttpRule>(
|
||||
"google.api.http",
|
||||
MethodOptions,
|
||||
() => ({ no: 72295728, kind: "message", T: HttpRule }),
|
||||
);
|
||||
@@ -311,7 +311,7 @@ const hookTypes: {
|
||||
name={[field.name, "actionGotify", "baseUrl"]}
|
||||
rules={[
|
||||
requiredField("gotify base URL is required"),
|
||||
{ type: "url" },
|
||||
{ type: "string" },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
|
||||
@@ -85,7 +85,7 @@ export const OperationList = ({
|
||||
return (
|
||||
<OperationRow
|
||||
alertApi={alertApi!}
|
||||
key={op.id + "-" + index}
|
||||
key={op.id}
|
||||
operation={op}
|
||||
showPlan={showPlan || false}
|
||||
/>
|
||||
|
||||
@@ -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 = ({
|
||||
<Tree<OpTreeNode>
|
||||
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[] => {
|
||||
|
||||
@@ -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 (
|
||||
<Modal
|
||||
open={true}
|
||||
@@ -289,15 +349,30 @@ const RestoreModal = ({
|
||||
labelCol={{ span: 6 }}
|
||||
wrapperCol={{ span: 16 }}
|
||||
>
|
||||
<Form.Item
|
||||
label="Restore to path"
|
||||
name="target"
|
||||
required={true}
|
||||
rules={[{ required: true, message: "Please enter a restore path" }]}
|
||||
>
|
||||
<URIAutocomplete onBlur={() => form.validateFields()} />
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
You may set the path to an empty string to restore to your Downloads
|
||||
folder.
|
||||
</p>
|
||||
<Form.Item label="Restore to path" name="target" rules={[]}>
|
||||
<URIAutocomplete
|
||||
placeholder="Restoring to Downloads"
|
||||
defaultValue={defaultPath}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const basename = (path: string) => {
|
||||
const idx = path.lastIndexOf(pathSeparator);
|
||||
if (idx === -1) {
|
||||
return path;
|
||||
}
|
||||
return path.slice(0, idx + 1);
|
||||
};
|
||||
|
||||
@@ -55,13 +55,13 @@ export const URIAutocomplete = (props: React.PropsWithChildren<any>) => {
|
||||
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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ? "\\" : "/";
|
||||
Reference in New Issue
Block a user