feat: misc ui improvements

This commit is contained in:
garethgeorge
2024-06-25 12:14:55 -07:00
parent 32223f4fc3
commit ee9945d102
8 changed files with 144 additions and 65 deletions
+5 -6
View File
@@ -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
}
-32
View File
@@ -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 }),
);
+1 -1
View File
@@ -311,7 +311,7 @@ const hookTypes: {
name={[field.name, "actionGotify", "baseUrl"]}
rules={[
requiredField("gotify base URL is required"),
{ type: "url" },
{ type: "string" },
]}
>
<Input
+1 -1
View File
@@ -85,7 +85,7 @@ export const OperationList = ({
return (
<OperationRow
alertApi={alertApi!}
key={op.id + "-" + index}
key={op.id}
operation={op}
showPlan={showPlan || false}
/>
+50 -14
View File
@@ -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[] => {
+83 -8
View File
@@ -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);
};
+3 -3
View File
@@ -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"
)
);
}
}
+1
View File
@@ -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 ? "\\" : "/";