Skip to content

Commit

Permalink
feat: misc ui improvements
Browse files Browse the repository at this point in the history
  • Loading branch information
garethgeorge committed Jun 25, 2024
1 parent 6f9816e commit 9067027
Show file tree
Hide file tree
Showing 8 changed files with 144 additions and 65 deletions.
11 changes: 5 additions & 6 deletions internal/api/backresthandler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
}
Expand Down
32 changes: 0 additions & 32 deletions webui/gen/ts/google/api/annotations_pb.ts

This file was deleted.

2 changes: 1 addition & 1 deletion webui/src/components/HooksFormList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ const hookTypes: {
name={[field.name, "actionGotify", "baseUrl"]}
rules={[
requiredField("gotify base URL is required"),
{ type: "url" },
{ type: "string" },
]}
>
<Input
Expand Down
2 changes: 1 addition & 1 deletion webui/src/components/OperationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export const OperationList = ({
return (
<OperationRow
alertApi={alertApi!}
key={op.id + "-" + index}
key={op.id}
operation={op}
showPlan={showPlan || false}
/>
Expand Down
64 changes: 50 additions & 14 deletions webui/src/components/OperationTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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[] => {
Expand Down
91 changes: 83 additions & 8 deletions webui/src/components/SnapshotBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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);
};
6 changes: 3 additions & 3 deletions webui/src/components/URIAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
);
}
}
Expand Down
1 change: 1 addition & 0 deletions webui/src/state/buildcfg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? "\\" : "/";

0 comments on commit 9067027

Please sign in to comment.