feat: add get disk usage and toast infomation

This commit is contained in:
Samuel N Cui
2023-10-20 20:22:07 +08:00
parent e18b4e905a
commit ed5ed8c7a1
21 changed files with 1064 additions and 567 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"github.com/samber/lo"
"github.com/samuelncui/yatm/entity"
"github.com/samuelncui/yatm/library"
)
@@ -24,14 +25,28 @@ func (api *API) FileGet(ctx context.Context, req *entity.FileGetRequest) (*entit
return nil, err
}
children, err := api.lib.ListWithSize(ctx, req.Id)
if err != nil {
return nil, err
}
return &entity.FileGetReply{
reply := &entity.FileGetReply{
File: file,
Positions: convertPositions(positions...),
Children: convertFiles(children...),
}, nil
}
if req.GetNeedSize() {
children, err := api.lib.ListWithSize(ctx, req.Id)
if err != nil {
return nil, err
}
reply.Children = convertFiles(children...)
if reply.File != nil {
reply.File.Size += lo.Sum(lo.Map(children, func(file *library.File, _ int) int64 { return file.Size }))
}
} else {
children, err := api.lib.List(ctx, req.Id)
if err != nil {
return nil, err
}
reply.Children = convertFiles(children...)
}
return reply, nil
}

31
apis/source_get_size.go Normal file
View File

@@ -0,0 +1,31 @@
package apis
import (
"context"
"os"
"path"
"path/filepath"
"github.com/samuelncui/yatm/entity"
)
func (api *API) SourceGetSize(ctx context.Context, req *entity.SourceGetSizeRequest) (*entity.SourceGetSizeReply, error) {
if req.Path == "./" {
req.Path = ""
}
var size int64
if err := filepath.Walk(path.Join(api.sourceBase, req.Path), func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
size += info.Size()
}
return err
}); err != nil {
return nil, err
}
return &entity.SourceGetSizeReply{Size: size}, nil
}

View File

@@ -26,9 +26,6 @@ func (api *API) SourceList(ctx context.Context, req *entity.SourceListRequest) (
filteredParts = append(filteredParts, part)
}
// buf, _ := json.Marshal(filteredParts)
// logrus.WithContext(ctx).Infof("parts= %s", buf)
current := ""
chain := make([]*entity.SourceFile, 0, len(filteredParts))
for _, part := range filteredParts {

View File

@@ -6,6 +6,7 @@ After=network.target
[Service]
User=root
Type=simple
UMask=0002
WorkingDirectory=/opt/yatm
ExecStart=/opt/yatm/yatm-httpd
Restart=always

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@ service Service {
rpc JobGetLog(JobGetLogRequest) returns (JobGetLogReply) {}
rpc SourceList(SourceListRequest) returns (SourceListReply) {}
rpc SourceGetSize(SourceGetSizeRequest) returns (SourceGetSizeReply) {}
rpc DeviceList(DeviceListRequest) returns (DeviceListReply) {}
@@ -38,6 +39,8 @@ service Service {
message FileGetRequest {
int64 id = 1;
optional bool needSize = 17;
}
message FileGetReply {
@@ -186,6 +189,14 @@ message SourceListReply {
repeated source.SourceFile children = 17;
}
message SourceGetSizeRequest {
string path = 1;
}
message SourceGetSizeReply {
int64 size = 1;
}
message DeviceListRequest {}
message DeviceListReply {

View File

@@ -38,6 +38,7 @@ type ServiceClient interface {
JobDisplay(ctx context.Context, in *JobDisplayRequest, opts ...grpc.CallOption) (*JobDisplayReply, error)
JobGetLog(ctx context.Context, in *JobGetLogRequest, opts ...grpc.CallOption) (*JobGetLogReply, error)
SourceList(ctx context.Context, in *SourceListRequest, opts ...grpc.CallOption) (*SourceListReply, error)
SourceGetSize(ctx context.Context, in *SourceGetSizeRequest, opts ...grpc.CallOption) (*SourceGetSizeReply, error)
DeviceList(ctx context.Context, in *DeviceListRequest, opts ...grpc.CallOption) (*DeviceListReply, error)
LibraryExport(ctx context.Context, in *LibraryExportRequest, opts ...grpc.CallOption) (*LibraryExportReply, error)
LibraryTrim(ctx context.Context, in *LibraryTrimRequest, opts ...grpc.CallOption) (*LibraryTrimReply, error)
@@ -195,6 +196,15 @@ func (c *serviceClient) SourceList(ctx context.Context, in *SourceListRequest, o
return out, nil
}
func (c *serviceClient) SourceGetSize(ctx context.Context, in *SourceGetSizeRequest, opts ...grpc.CallOption) (*SourceGetSizeReply, error) {
out := new(SourceGetSizeReply)
err := c.cc.Invoke(ctx, "/service.Service/SourceGetSize", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *serviceClient) DeviceList(ctx context.Context, in *DeviceListRequest, opts ...grpc.CallOption) (*DeviceListReply, error) {
out := new(DeviceListReply)
err := c.cc.Invoke(ctx, "/service.Service/DeviceList", in, out, opts...)
@@ -242,6 +252,7 @@ type ServiceServer interface {
JobDisplay(context.Context, *JobDisplayRequest) (*JobDisplayReply, error)
JobGetLog(context.Context, *JobGetLogRequest) (*JobGetLogReply, error)
SourceList(context.Context, *SourceListRequest) (*SourceListReply, error)
SourceGetSize(context.Context, *SourceGetSizeRequest) (*SourceGetSizeReply, error)
DeviceList(context.Context, *DeviceListRequest) (*DeviceListReply, error)
LibraryExport(context.Context, *LibraryExportRequest) (*LibraryExportReply, error)
LibraryTrim(context.Context, *LibraryTrimRequest) (*LibraryTrimReply, error)
@@ -300,6 +311,9 @@ func (UnimplementedServiceServer) JobGetLog(context.Context, *JobGetLogRequest)
func (UnimplementedServiceServer) SourceList(context.Context, *SourceListRequest) (*SourceListReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method SourceList not implemented")
}
func (UnimplementedServiceServer) SourceGetSize(context.Context, *SourceGetSizeRequest) (*SourceGetSizeReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method SourceGetSize not implemented")
}
func (UnimplementedServiceServer) DeviceList(context.Context, *DeviceListRequest) (*DeviceListReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeviceList not implemented")
}
@@ -610,6 +624,24 @@ func _Service_SourceList_Handler(srv interface{}, ctx context.Context, dec func(
return interceptor(ctx, in, info, handler)
}
func _Service_SourceGetSize_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SourceGetSizeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ServiceServer).SourceGetSize(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/service.Service/SourceGetSize",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ServiceServer).SourceGetSize(ctx, req.(*SourceGetSizeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Service_DeviceList_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeviceListRequest)
if err := dec(in); err != nil {
@@ -735,6 +767,10 @@ var Service_ServiceDesc = grpc.ServiceDesc{
MethodName: "SourceList",
Handler: _Service_SourceList_Handler,
},
{
MethodName: "SourceGetSize",
Handler: _Service_SourceGetSize_Handler,
},
{
MethodName: "DeviceList",
Handler: _Service_DeviceList_Handler,

View File

@@ -38,6 +38,7 @@
"react-intl": "^6.4.7",
"react-is": "^18.2.0",
"react-router-dom": "^6.4.5",
"react-toastify": "^9.1.3",
"react-virtuoso": "^4.6.1",
"sort-by": "^1.2.0"
},

View File

@@ -1,6 +1,5 @@
import { FileData, FileArray, FileAction } from "@samuelncui/chonky";
import { defineFileAction } from "@samuelncui/chonky";
import { ChonkyActions } from "@samuelncui/chonky";
import { ChonkyActions, defineFileAction } from "@samuelncui/chonky";
type RenameFileState = {
contextMenuTriggerFile: FileData;
@@ -9,6 +8,14 @@ type RenameFileState = {
selectedFilesForAction: FileArray;
};
export const CreateFolder = defineFileAction({
...ChonkyActions.CreateFolder,
button: {
...ChonkyActions.CreateFolder.button,
// iconOnly: true,
},
} as FileAction);
export const RenameFileAction = defineFileAction({
id: "rename_file",
requiresSelection: true,
@@ -17,7 +24,18 @@ export const RenameFileAction = defineFileAction({
toolbar: true,
contextMenu: true,
group: "Actions",
icon: "edit",
icon: "mui-rename",
},
__extraStateType: {} as RenameFileState,
} as FileAction);
export const GetDataUsageAction = defineFileAction({
id: "get_data_usage",
button: {
name: "Data Usage",
toolbar: true,
icon: "mui-data-usage",
// iconOnly: true,
},
__extraStateType: {} as RenameFileState,
} as FileAction);
@@ -36,6 +54,7 @@ export const CreateBackupJobAction = defineFileAction({
button: {
name: "Create Backup Job",
toolbar: true,
icon: "mui-fiber-new",
},
} as FileAction);
@@ -44,6 +63,7 @@ export const CreateRestoreJobAction = defineFileAction({
button: {
name: "Create Restore Job",
toolbar: true,
icon: "mui-fiber-new",
},
} as FileAction);
@@ -52,5 +72,6 @@ export const TrimLibraryAction = defineFileAction({
button: {
name: "Trim Library",
toolbar: true,
icon: "mui-cleaning",
},
} as FileAction);

View File

@@ -19,6 +19,16 @@ export const fileBase: string = (() => {
return apiBase.replace("/services", "/files");
})();
export const cli = (() => {
return new ServiceClient(
new GrpcWebFetchTransport({
baseUrl: apiBase,
format: "binary",
}),
);
})();
(window as any).cli = cli;
export const Root: FileData = {
id: "0",
name: "Root",
@@ -29,20 +39,12 @@ export const Root: FileData = {
droppable: true,
};
const transport = new GrpcWebFetchTransport({
baseUrl: apiBase,
format: "binary",
});
export const cli = new ServiceClient(transport);
(window as any).cli = cli;
export function convertFiles(files: Array<File>): FileData[] {
export function convertFiles(files: Array<File>, dirWithSize: boolean = false): FileData[] {
return files.map((file) => {
const isDir = (file.mode & MODE_DIR) > 0;
return {
id: getID(file),
id: `${file.id}`,
name: file.name,
ext: extname(file.name),
isDir,
@@ -51,7 +53,7 @@ export function convertFiles(files: Array<File>): FileData[] {
selectable: true,
draggable: true,
droppable: isDir,
size: Number(file.size),
size: !isDir || dirWithSize ? Number(file.size) : undefined,
modDate: moment.unix(Number(file.modTime)).toDate(),
};
});
@@ -62,7 +64,7 @@ export function convertSourceFiles(files: Array<SourceFile>): FileData[] {
const isDir = (file.mode & MODE_DIR) > 0;
return {
id: getID(file),
id: file.path,
name: file.name,
ext: extname(file.name),
isDir,
@@ -71,7 +73,7 @@ export function convertSourceFiles(files: Array<SourceFile>): FileData[] {
selectable: true,
draggable: true,
droppable: false,
size: Number(file.size),
size: isDir ? undefined : Number(file.size),
modDate: moment.unix(Number(file.modTime)).toDate(),
};
});
@@ -134,93 +136,3 @@ function extname(filename: string): string {
}
return filename.slice(idx);
}
function getID(file: File | SourceFile): string {
if ("id" in file) {
return `${file.id}`;
}
return file.path;
}
// export interface GetFileResponse {
// file: File;
// positions: Position[];
// children: FileArray<File>;
// }
// export const getFile = async (id: string) => {
// const result = await fetch(`${Domain}/api/v1/file/${id}`);
// const body: GetFileResponse = await result.json();
// return body;
// };
// export interface ListFileParentsResponse {
// parents: FileArray<File>;
// }
// export const listFileParents = async (id: string) => {
// const result = await fetch(`${Domain}/api/v1/file/${id}/_parent`);
// const body: ListFileParentsResponse = await result.json();
// return [Root, ...body.parents];
// };
// export interface SetFileResponse {
// file?: File;
// result?: string;
// }
// export const editFile = async (id: string, payload: Partial<File>) => {
// const result = await fetch(`${Domain}/api/v1/file/${id}`, {
// method: "POST",
// headers: {
// "Content-Type": "application/json",
// },
// body: JSON.stringify(payload),
// });
// const body: SetFileResponse = await result.json();
// return body;
// };
// export const createFolder = async (
// parentID: string,
// payload: Partial<File>
// ) => {
// const result = await fetch(`${Domain}/api/v1/file/${parentID}/`, {
// method: "PUT",
// headers: {
// "Content-Type": "application/json",
// },
// body: JSON.stringify(payload),
// });
// const body: SetFileResponse = await result.json();
// return body.file;
// };
// export const deleteFolder = async (ids: string[]) => {
// const result = await fetch(`${Domain}/api/v1/file/`, {
// method: "DELETE",
// headers: {
// "Content-Type": "application/json",
// },
// body: JSON.stringify({ fileids: ids }),
// });
// const body: SetFileResponse = await result.json();
// return body;
// };
// interface GetTapeResponse {
// tape: Tape;
// }
// export const getTape = async (id: number) => {
// const result = await fetch(`${Domain}/api/v1/tape/${id}`);
// const body: GetTapeResponse = await result.json();
// return body;
// };
// interface GetSourceResponse {
// file: File;
// chain: File[];
// children: FileArray<File>;
// }
// export const getSource = async (path: string) => {
// const result = await fetch(`${Domain}/api/v1/source/${path}`);
// const body: GetSourceResponse = await result.json();
// return body;
// };

View File

@@ -1,9 +1,11 @@
import { Fragment, useCallback, ChangeEvent } from "react";
import { Routes, Route, Link, useNavigate, Navigate, useLocation } from "react-router-dom";
import { useCallback, ChangeEvent } from "react";
import { Routes, Route, useNavigate, Navigate, useLocation } from "react-router-dom";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import { createTheme, ThemeProvider, styled } from "@mui/material/styles";
import { createTheme, styled, ThemeProvider } from "@mui/material/styles";
import { ToastContainer, toast } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import { FileBrowser, FileBrowserType } from "./pages/file";
import { BackupBrowser, BackupType } from "./pages/backup";
@@ -37,6 +39,11 @@ const Delay = ({ inner }: { inner: JSX.Element }) => {
return ok ? inner : null;
};
const ErrorMessage = styled("p")({
margin: 0,
textAlign: "left",
});
const App = () => {
const location = useLocation();
const navigate = useNavigate();
@@ -47,6 +54,35 @@ const App = () => {
[navigate],
);
useEffect(() => {
const origin = window.onunhandledrejection;
window.onunhandledrejection = (error) => {
if (error.reason.name !== "RpcError") {
return;
}
console.log("rpc request have error:", error);
toast.error(
<div>
<ErrorMessage>
<b>RPC Request Error</b>
</ErrorMessage>
<ErrorMessage>
<b>Method: </b>
{error.reason.methodName}
</ErrorMessage>
<ErrorMessage>
<b>Message: </b>
{error.reason.message}
</ErrorMessage>
</div>,
);
};
return () => {
window.onunhandledrejection = origin;
};
}, []);
return (
<div id="app">
<ThemeProvider theme={theme}>
@@ -76,6 +112,7 @@ const App = () => {
</Route>
</Routes>
</ThemeProvider>
<ToastContainer autoClose={10000} />
</div>
);
};

View File

@@ -3,13 +3,10 @@ import { assert } from "@protobuf-ts/runtime";
import format from "format-duration";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
import { toast } from "react-toastify";
import { styled } from "@mui/material/styles";
import Grid from "@mui/material/Grid";
import Box from "@mui/material/Box";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemText from "@mui/material/ListItemText";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
@@ -239,7 +236,7 @@ const RollbackFileList = ({ onClose, jobID, state }: { onClose: () => void; jobI
await cli.jobEditState({ id: jobID, state: { state: { oneofKind: "archive", archive: { ...state, sources } } } });
await refresh();
alert(`Rollback to file '${path}' success!`);
toast.success(`Rollback to file '${path}' success!`);
},
[state, refresh],
);

View File

@@ -1,4 +1,4 @@
import { memo } from "react";
import { memo, useMemo } from "react";
import Typography from "@mui/material/Typography";
import { FileArray } from "@samuelncui/chonky";
@@ -8,11 +8,31 @@ export interface ToobarInfoProps {
files?: FileArray;
}
export const ToobarInfo: React.FC<ToobarInfoProps> = memo((props) => {
export const ToobarInfo: React.FC<ToobarInfoProps> = memo(({ files }) => {
const [size, notFinished] = useMemo(() => {
let size = 0;
let notFinished = false;
for (const file of files || []) {
if (!file) {
continue;
}
if (file.size === undefined) {
notFinished = true;
continue;
}
size += file.size;
}
return [size, notFinished];
}, [files]);
return (
<div className="chonky-infoContainer">
<Typography variant="body1" className="chonky-infoText">
{formatFilesize((props.files || []).reduce((total, file) => total + (file?.size ? file.size : 0), 0))}
{notFinished && "? "}
{formatFilesize(size)}
</Typography>
</div>
);

View File

@@ -10,6 +10,8 @@ import type { LibraryExportReply } from "./service";
import type { LibraryExportRequest } from "./service";
import type { DeviceListReply } from "./service";
import type { DeviceListRequest } from "./service";
import type { SourceGetSizeReply } from "./service";
import type { SourceGetSizeRequest } from "./service";
import type { SourceListReply } from "./service";
import type { SourceListRequest } from "./service";
import type { JobGetLogReply } from "./service";
@@ -113,6 +115,10 @@ export interface IServiceClient {
* @generated from protobuf rpc: SourceList(service.SourceListRequest) returns (service.SourceListReply);
*/
sourceList(input: SourceListRequest, options?: RpcOptions): UnaryCall<SourceListRequest, SourceListReply>;
/**
* @generated from protobuf rpc: SourceGetSize(service.SourceGetSizeRequest) returns (service.SourceGetSizeReply);
*/
sourceGetSize(input: SourceGetSizeRequest, options?: RpcOptions): UnaryCall<SourceGetSizeRequest, SourceGetSizeReply>;
/**
* @generated from protobuf rpc: DeviceList(service.DeviceListRequest) returns (service.DeviceListReply);
*/
@@ -247,25 +253,32 @@ export class ServiceClient implements IServiceClient, ServiceInfo {
const method = this.methods[15], opt = this._transport.mergeOptions(options);
return stackIntercept<SourceListRequest, SourceListReply>("unary", this._transport, method, opt, input);
}
/**
* @generated from protobuf rpc: SourceGetSize(service.SourceGetSizeRequest) returns (service.SourceGetSizeReply);
*/
sourceGetSize(input: SourceGetSizeRequest, options?: RpcOptions): UnaryCall<SourceGetSizeRequest, SourceGetSizeReply> {
const method = this.methods[16], opt = this._transport.mergeOptions(options);
return stackIntercept<SourceGetSizeRequest, SourceGetSizeReply>("unary", this._transport, method, opt, input);
}
/**
* @generated from protobuf rpc: DeviceList(service.DeviceListRequest) returns (service.DeviceListReply);
*/
deviceList(input: DeviceListRequest, options?: RpcOptions): UnaryCall<DeviceListRequest, DeviceListReply> {
const method = this.methods[16], opt = this._transport.mergeOptions(options);
const method = this.methods[17], opt = this._transport.mergeOptions(options);
return stackIntercept<DeviceListRequest, DeviceListReply>("unary", this._transport, method, opt, input);
}
/**
* @generated from protobuf rpc: LibraryExport(service.LibraryExportRequest) returns (service.LibraryExportReply);
*/
libraryExport(input: LibraryExportRequest, options?: RpcOptions): UnaryCall<LibraryExportRequest, LibraryExportReply> {
const method = this.methods[17], opt = this._transport.mergeOptions(options);
const method = this.methods[18], opt = this._transport.mergeOptions(options);
return stackIntercept<LibraryExportRequest, LibraryExportReply>("unary", this._transport, method, opt, input);
}
/**
* @generated from protobuf rpc: LibraryTrim(service.LibraryTrimRequest) returns (service.LibraryTrimReply);
*/
libraryTrim(input: LibraryTrimRequest, options?: RpcOptions): UnaryCall<LibraryTrimRequest, LibraryTrimReply> {
const method = this.methods[18], opt = this._transport.mergeOptions(options);
const method = this.methods[19], opt = this._transport.mergeOptions(options);
return stackIntercept<LibraryTrimRequest, LibraryTrimReply>("unary", this._transport, method, opt, input);
}
}

View File

@@ -35,6 +35,10 @@ export interface FileGetRequest {
* @generated from protobuf field: int64 id = 1;
*/
id: bigint;
/**
* @generated from protobuf field: optional bool needSize = 17;
*/
needSize?: boolean;
}
/**
* @generated from protobuf message service.FileGetReply
@@ -395,6 +399,24 @@ export interface SourceListReply {
*/
children: SourceFile[];
}
/**
* @generated from protobuf message service.SourceGetSizeRequest
*/
export interface SourceGetSizeRequest {
/**
* @generated from protobuf field: string path = 1;
*/
path: string;
}
/**
* @generated from protobuf message service.SourceGetSizeReply
*/
export interface SourceGetSizeReply {
/**
* @generated from protobuf field: int64 size = 1;
*/
size: bigint;
}
/**
* @generated from protobuf message service.DeviceListRequest
*/
@@ -449,7 +471,8 @@ export interface LibraryTrimReply {
class FileGetRequest$Type extends MessageType<FileGetRequest> {
constructor() {
super("service.FileGetRequest", [
{ no: 1, name: "id", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }
{ no: 1, name: "id", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ },
{ no: 17, name: "needSize", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }
]);
}
create(value?: PartialMessage<FileGetRequest>): FileGetRequest {
@@ -467,6 +490,9 @@ class FileGetRequest$Type extends MessageType<FileGetRequest> {
case /* int64 id */ 1:
message.id = reader.int64().toBigInt();
break;
case /* optional bool needSize */ 17:
message.needSize = reader.bool();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
@@ -482,6 +508,9 @@ class FileGetRequest$Type extends MessageType<FileGetRequest> {
/* int64 id = 1; */
if (message.id !== 0n)
writer.tag(1, WireType.Varint).int64(message.id);
/* optional bool needSize = 17; */
if (message.needSize !== undefined)
writer.tag(17, WireType.Varint).bool(message.needSize);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
@@ -2099,6 +2128,100 @@ class SourceListReply$Type extends MessageType<SourceListReply> {
*/
export const SourceListReply = new SourceListReply$Type();
// @generated message type with reflection information, may provide speed optimized methods
class SourceGetSizeRequest$Type extends MessageType<SourceGetSizeRequest> {
constructor() {
super("service.SourceGetSizeRequest", [
{ no: 1, name: "path", kind: "scalar", T: 9 /*ScalarType.STRING*/ }
]);
}
create(value?: PartialMessage<SourceGetSizeRequest>): SourceGetSizeRequest {
const message = { path: "" };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
if (value !== undefined)
reflectionMergePartial<SourceGetSizeRequest>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: SourceGetSizeRequest): SourceGetSizeRequest {
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* string path */ 1:
message.path = reader.string();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: SourceGetSizeRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
/* string path = 1; */
if (message.path !== "")
writer.tag(1, WireType.LengthDelimited).string(message.path);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message service.SourceGetSizeRequest
*/
export const SourceGetSizeRequest = new SourceGetSizeRequest$Type();
// @generated message type with reflection information, may provide speed optimized methods
class SourceGetSizeReply$Type extends MessageType<SourceGetSizeReply> {
constructor() {
super("service.SourceGetSizeReply", [
{ no: 1, name: "size", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }
]);
}
create(value?: PartialMessage<SourceGetSizeReply>): SourceGetSizeReply {
const message = { size: 0n };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
if (value !== undefined)
reflectionMergePartial<SourceGetSizeReply>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: SourceGetSizeReply): SourceGetSizeReply {
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* int64 size */ 1:
message.size = reader.int64().toBigInt();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: SourceGetSizeReply, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
/* int64 size = 1; */
if (message.size !== 0n)
writer.tag(1, WireType.Varint).int64(message.size);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message service.SourceGetSizeReply
*/
export const SourceGetSizeReply = new SourceGetSizeReply$Type();
// @generated message type with reflection information, may provide speed optimized methods
class DeviceListRequest$Type extends MessageType<DeviceListRequest> {
constructor() {
super("service.DeviceListRequest", []);
@@ -2373,6 +2496,7 @@ export const Service = new ServiceType("service.Service", [
{ name: "JobDisplay", options: {}, I: JobDisplayRequest, O: JobDisplayReply },
{ name: "JobGetLog", options: {}, I: JobGetLogRequest, O: JobGetLogReply },
{ name: "SourceList", options: {}, I: SourceListRequest, O: SourceListReply },
{ name: "SourceGetSize", options: {}, I: SourceGetSizeRequest, O: SourceGetSizeReply },
{ name: "DeviceList", options: {}, I: DeviceListRequest, O: DeviceListReply },
{ name: "LibraryExport", options: {}, I: LibraryExportRequest, O: LibraryExportReply },
{ name: "LibraryTrim", options: {}, I: LibraryTrimRequest, O: LibraryTrimReply }

View File

@@ -1,30 +1,38 @@
import { setChonkyDefaults } from "@samuelncui/chonky";
import * as React from "react";
import { ChonkyIconProps, setChonkyDefaults } from "@samuelncui/chonky";
import { ChonkyIconFA } from "@samuelncui/chonky-icon-fontawesome";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons/faPencilAlt";
import { unstable_ClassNameGenerator as ClassNameGenerator } from "@mui/material/className";
import { styled } from "@mui/material/styles";
const ExternalIcons: Record<string, any> = {
edit: faPencilAlt,
};
import DataUsageIcon from "@mui/icons-material/DataUsage";
import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutline";
import FiberNewIcon from "@mui/icons-material/FiberNew";
import CleaningServicesIcon from "@mui/icons-material/CleaningServices";
const MUIStyled = (Icon: typeof DataUsageIcon) => styled(Icon)({ verticalAlign: "-0.2em", fontSize: "1.1rem" });
const MUIIconMap = {
"mui-data-usage": MUIStyled(DataUsageIcon),
"mui-rename": MUIStyled(DriveFileRenameOutlineIcon),
"mui-fiber-new": MUIStyled(FiberNewIcon),
"mui-cleaning": MUIStyled(CleaningServicesIcon),
} as const;
setChonkyDefaults({
iconComponent: (props) => {
const icon = ExternalIcons[props.icon] as any;
if (!!icon) {
const faProps = {
...props,
icon: icon,
} as const;
return <FontAwesomeIcon {...faProps} />;
iconComponent: React.memo((props) => {
const { icon, ...otherProps } = props;
const MUIIcon = MUIIconMap[icon as keyof typeof MUIIconMap];
if (!!MUIIcon) {
const { fixedWidth: _, ...props } = otherProps;
return <MUIIcon {...props} />;
}
return <ChonkyIconFA {...props} />;
},
}) as React.FC<ChonkyIconProps>,
});
import { unstable_ClassNameGenerator as ClassNameGenerator } from "@mui/material/className";
ClassNameGenerator.configure(
// Do something with the componentName
(componentName: string) => `app-${componentName}`,

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useMemo, useCallback, FC, useRef, RefObject } from "react";
import { toast } from "react-toastify";
import Grid from "@mui/material/Grid";
import Box from "@mui/material/Box";
@@ -10,19 +11,23 @@ import { Root } from "../api";
import { AddFileAction, RefreshListAction, CreateBackupJobAction } from "../actions";
import { JobArchiveParam, JobCreateRequest, Source } from "../entity";
import { chonkyI18n } from "../tools";
import { ToobarInfo } from "../components/toolbarInfo";
const useBackupSourceBrowser = (source: RefObject<FileBrowserHandle>) => {
const useBackupSourceBrowser = (targetFiles: FileArray, source: RefObject<FileBrowserHandle>) => {
const [files, setFiles] = useState<FileArray>(Array(1).fill(null));
const [folderChain, setFolderChan] = useState<FileArray>([Root]);
const [folderChain, setFolderChain] = useState<FileArray>([Root]);
const openFolder = useCallback((path: string) => {
(async () => {
const result = await cli.sourceList({ path }).response;
const openFolder = useCallback(
(path: string) => {
(async () => {
const result = await cli.sourceList({ path }).response;
setFiles(convertSourceFiles(result.children));
setFolderChan(convertSourceFiles(result.chain));
})();
}, []);
setFiles(convertSourceFiles(result.children));
setFolderChain(convertSourceFiles(result.chain));
})();
},
[targetFiles, setFiles, setFolderChain],
);
useEffect(() => openFolder(""), []);
const onFileAction = useCallback(
@@ -49,7 +54,14 @@ const useBackupSourceBrowser = (source: RefObject<FileBrowserHandle>) => {
return;
}
source.current.requestFileAction(AddFileAction, data.payload);
const selectedFiles = data.payload.selectedFiles.map((file) => ({
...file,
name: file.id,
openable: false,
draggable: false,
}));
source.current.requestFileAction(AddFileAction, { ...data.payload, selectedFiles });
return;
}
},
@@ -59,7 +71,21 @@ const useBackupSourceBrowser = (source: RefObject<FileBrowserHandle>) => {
const fileActions = useMemo(() => [ChonkyActions.StartDragNDrop, RefreshListAction], []);
return {
files,
files: useMemo(() => {
const targetFileIDs = new Set((targetFiles.filter((f) => !!f) as FileData[]).map((f) => f.id));
const getDragable = !!folderChain.find((file) => file && targetFileIDs.has(file.id))
? (_: FileData) => false
: (file: FileData) => !targetFileIDs.has(file.id);
return files.map((file) => {
if (!file) {
return file;
}
const draggable = getDragable(file);
return { ...file, droppable: false, draggable, selectable: draggable };
});
}, [files, folderChain, targetFiles]),
folderChain,
onFileAction,
fileActions,
@@ -69,19 +95,37 @@ const useBackupSourceBrowser = (source: RefObject<FileBrowserHandle>) => {
};
};
const targetFolderChain = [
{
id: "backup_waitlist",
name: "Backup Waitlist",
isDir: true,
openable: true,
selectable: true,
draggable: true,
droppable: true,
},
] as FileArray;
const useBackupTargetBrowser = () => {
const [files, setFiles] = useState<FileArray>(Array(0));
const [folderChain, setFolderChan] = useState<FileArray>([
{
id: "backup_waitlist",
name: "Backup Waitlist",
isDir: true,
openable: true,
selectable: true,
draggable: true,
droppable: true,
const onFileSizeUpdated = useCallback(
(id: string, size: number) => {
setFiles(
(files.filter((file) => !!file) as FileData[]).map((file: FileData) => {
if (file.id === id) {
return { ...file, size };
}
return file;
}),
);
},
]);
[files, setFiles],
);
const onFileSizeUpdatedRef = useRef(onFileSizeUpdated);
onFileSizeUpdatedRef.current = onFileSizeUpdated;
const onFileAction = useCallback(
(data: ChonkyFileActionData) => {
@@ -93,10 +137,23 @@ const useBackupTargetBrowser = () => {
})();
return;
case AddFileAction.id:
setFiles([
...files,
...((data.payload as any)?.selectedFiles as FileData[]).map((file) => ({ ...file, name: file.id, openable: false, draggable: false })),
]);
const addedFiles = (data.payload as any)?.selectedFiles as FileData[];
setFiles([...files, ...addedFiles]);
(async () => {
for (const file of addedFiles) {
if (!file) {
continue;
}
if (file.size !== undefined) {
continue;
}
const reply = await cli.sourceGetSize({ path: file.id }).response;
onFileSizeUpdatedRef.current(file.id, Number(reply.size));
}
})();
return;
case CreateBackupJobAction.id:
(async () => {
@@ -124,7 +181,7 @@ const useBackupTargetBrowser = () => {
const req = makeArchiveParam(1n, { sources });
console.log(req, await cli.jobCreate(req).response);
alert("Create Backup Job Success!");
toast.success("Create Backup Job Success!");
})();
return;
}
@@ -136,7 +193,7 @@ const useBackupTargetBrowser = () => {
return {
files,
folderChain,
folderChain: targetFolderChain,
onFileAction,
fileActions,
defaultFileViewActionId: ChonkyActions.EnableListView.id,
@@ -149,8 +206,8 @@ export const BackupType = "backup";
export const BackupBrowser = () => {
const target = useRef<FileBrowserHandle>(null);
const sourceProps = useBackupSourceBrowser(target);
const targetProps = useBackupTargetBrowser();
const sourceProps = useBackupSourceBrowser(targetProps.files, target);
return (
<Box className="browser-box">
@@ -166,7 +223,9 @@ export const BackupBrowser = () => {
<Grid className="browser" item xs={6}>
<FileBrowser {...targetProps} ref={target}>
<FileNavbar />
<FileToolbar />
<FileToolbar>
<ToobarInfo files={targetProps.files} />
</FileToolbar>
<FileList />
<FileContextMenu />
</FileBrowser>

View File

@@ -7,7 +7,7 @@ import { ChonkyActions, ChonkyFileActionData } from "@samuelncui/chonky";
import { cli, convertFiles } from "../api";
import { Root } from "../api";
import { RenameFileAction, RefreshListAction } from "../actions";
import { RenameFileAction, RefreshListAction, GetDataUsageAction, CreateFolder } from "../actions";
import { ToobarInfo } from "../components/toolbarInfo";
import { useDetailModal, DetailModal } from "./file-detail";
@@ -49,13 +49,16 @@ const useFileBrowser = (storageKey: string, refreshAll: () => Promise<void>, ope
return last.id;
}, [folderChain]);
const openFolder = useCallback(async (id: string) => {
const [file, folderChain] = await Promise.all([cli.fileGet({ id: BigInt(id) }).response, cli.fileListParents({ id: BigInt(id) }).response]);
const openFolder = useCallback(
async (id: string, needSize: boolean = false) => {
const [file, folderChain] = await Promise.all([cli.fileGet({ id: BigInt(id), needSize }).response, cli.fileListParents({ id: BigInt(id) }).response]);
setFiles(convertFiles(file.children));
setFolderChan([Root, ...convertFiles(folderChain.parents)]);
localStorage.setItem(storageKey, id);
}, []);
setFiles(convertFiles(file.children, needSize));
setFolderChan([Root, ...convertFiles(folderChain.parents, needSize)]);
localStorage.setItem(storageKey, id);
},
[setFiles, setFolderChan],
);
useEffect(() => {
(async () => {
const storagedID = localStorage.getItem(storageKey);
@@ -74,7 +77,6 @@ const useFileBrowser = (storageKey: string, refreshAll: () => Promise<void>, ope
const onFileAction = useCallback(
(data: ChonkyFileActionData) => {
// console.log(data);
switch (data.id) {
case ChonkyActions.OpenFiles.id:
(async () => {
@@ -125,7 +127,7 @@ const useFileBrowser = (storageKey: string, refreshAll: () => Promise<void>, ope
await refreshAll();
})();
return;
case ChonkyActions.CreateFolder.id:
case CreateFolder.id:
(async () => {
const name = prompt("Provide the name for your new folder:");
if (!name) {
@@ -144,6 +146,9 @@ const useFileBrowser = (storageKey: string, refreshAll: () => Promise<void>, ope
await refreshAll();
})();
return;
case GetDataUsageAction.id:
openFolder(currentID, true);
return;
case RefreshListAction.id:
openFolder(currentID);
@@ -153,7 +158,10 @@ const useFileBrowser = (storageKey: string, refreshAll: () => Promise<void>, ope
[openFolder, openDetailModel, refreshAll, currentID],
);
const fileActions = useMemo(() => [ChonkyActions.CreateFolder, ChonkyActions.DeleteFiles, ChonkyActions.MoveFiles, RenameFileAction, RefreshListAction], []);
const fileActions = useMemo(
() => [CreateFolder, GetDataUsageAction, ChonkyActions.DeleteFiles, ChonkyActions.MoveFiles, RenameFileAction, RefreshListAction],
[],
);
const totalSize = useMemo(() => {
return files.reduce((total, file) => total + (file?.size ? file.size : 0), 0);
}, [files]);
@@ -186,7 +194,6 @@ export const FileBrowser = () => {
}, 10000);
return () => clearInterval(interval);
}, []);
useEffect(() => {});
return (
<Box className="browser-box">
@@ -195,7 +202,7 @@ export const FileBrowser = () => {
<ChonckFileBrowser instanceId="left" ref={instances.left} {...leftProps}>
<FileNavbar />
<FileToolbar>
<ToobarInfo {...leftProps} />
<ToobarInfo files={leftProps.files} />
</FileToolbar>
<FileList />
<FileContextMenu />
@@ -205,7 +212,7 @@ export const FileBrowser = () => {
<ChonckFileBrowser instanceId="right" ref={instances.right} {...rightProps}>
<FileNavbar />
<FileToolbar>
<ToobarInfo {...rightProps} />
<ToobarInfo files={rightProps.files} />
</FileToolbar>
<FileList />
<FileContextMenu />

View File

@@ -98,11 +98,8 @@ export const JobsBrowser = () => {
refreshRef.current = refresh;
useEffect(() => {
var timer: NodeJS.Timeout;
(async () => {
await refreshRef.current(true);
timer = setInterval(() => refreshRef.current(), 2000);
})();
refreshRef.current(true);
const timer = setInterval(() => refreshRef.current(), 2000);
return () => {
if (!timer) {

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useMemo, useCallback, FC, useRef, RefObject } from "react";
import { toast } from "react-toastify";
import Grid from "@mui/material/Grid";
import Box from "@mui/material/Box";
@@ -8,20 +9,35 @@ import { ChonkyActions, ChonkyFileActionData, FileData } from "@samuelncui/chonk
import { ToobarInfo } from "../components/toolbarInfo";
import { Root, cli, convertFiles } from "../api";
import { AddFileAction, RefreshListAction, CreateRestoreJobAction } from "../actions";
import { AddFileAction, RefreshListAction, CreateRestoreJobAction, GetDataUsageAction } from "../actions";
import { JobCreateRequest, JobRestoreParam, Source } from "../entity";
import { chonkyI18n } from "../tools";
const useRestoreSourceBrowser = (target: RefObject<FileBrowserHandle>) => {
const useRestoreSourceBrowser = (targetFiles: FileArray, target: RefObject<FileBrowserHandle>) => {
const [files, setFiles] = useState<FileArray>(Array(1).fill(null));
const [folderChain, setFolderChan] = useState<FileArray>([Root]);
const currentID = useMemo(() => {
if (folderChain.length === 0) {
return "0";
}
const openFolder = useCallback(async (id: string) => {
const [file, folderChain] = await Promise.all([cli.fileGet({ id: BigInt(id) }).response, cli.fileListParents({ id: BigInt(id) }).response]);
const last = folderChain.slice(-1)[0];
if (!last) {
return "0";
}
setFiles(convertFiles(file.children).map((file) => ({ ...file, droppable: false })));
setFolderChan([Root, ...convertFiles(folderChain.parents)]);
}, []);
return last.id;
}, [folderChain]);
const openFolder = useCallback(
async (id: string, needSize: boolean = false) => {
const [file, folderChain] = await Promise.all([cli.fileGet({ id: BigInt(id), needSize }).response, cli.fileListParents({ id: BigInt(id) }).response]);
setFiles(convertFiles(file.children, needSize).map((file) => ({ ...file, droppable: false })));
setFolderChan([Root, ...convertFiles(folderChain.parents, needSize)]);
},
[setFiles, setFolderChan],
);
useEffect(() => {
openFolder(Root.id);
}, []);
@@ -44,39 +60,52 @@ const useRestoreSourceBrowser = (target: RefObject<FileBrowserHandle>) => {
}
})();
return;
case GetDataUsageAction.id:
openFolder(currentID, true);
return;
case ChonkyActions.EndDragNDrop.id:
(async () => {
if (!target.current) {
return;
}
if (!target.current) {
return;
}
const base = folderChain
.filter((file): file is FileData => !!file && file.id !== "0")
.map((file) => file.name)
.join("/");
const base = folderChain
.filter((file): file is FileData => !!file && file.id !== "0")
.map((file) => file.name)
.join("/");
const selectedFiles = data.payload.selectedFiles.map((file) => ({
...file,
name: base ? base + "/" + file.name : file.name,
openable: false,
draggable: false,
}));
await target.current.requestFileAction(AddFileAction, { ...data.payload, selectedFiles });
})();
const selectedFiles = data.payload.selectedFiles.map((file) => ({
...file,
name: base ? base + "/" + file.name : file.name,
openable: false,
draggable: false,
}));
target.current.requestFileAction(AddFileAction, { ...data.payload, selectedFiles });
return;
}
console.log("source done", data);
},
[openFolder, target, folderChain],
[openFolder, target, folderChain, currentID],
);
const fileActions = useMemo(() => [ChonkyActions.StartDragNDrop, RefreshListAction], []);
const fileActions = useMemo(() => [GetDataUsageAction, ChonkyActions.StartDragNDrop, RefreshListAction], []);
return {
files,
files: useMemo(() => {
const targetFileIDs = new Set((targetFiles.filter((f) => !!f) as FileData[]).map((f) => f.id));
const getDragable = !!folderChain.find((file) => file && targetFileIDs.has(file.id))
? (_: FileData) => false
: (file: FileData) => !targetFileIDs.has(file.id);
return files.map((file) => {
if (!file) {
return file;
}
const draggable = getDragable(file);
return { ...file, droppable: false, draggable, selectable: draggable };
});
}, [files, folderChain, targetFiles]),
folderChain,
onFileAction,
fileActions,
@@ -86,19 +115,37 @@ const useRestoreSourceBrowser = (target: RefObject<FileBrowserHandle>) => {
};
};
const targetFolderChain = [
{
id: "restore_waitlist",
name: "Restore Waitlist",
isDir: true,
openable: true,
selectable: true,
draggable: true,
droppable: true,
},
] as FileArray;
const useRestoreTargetBrowser = () => {
const [files, setFiles] = useState<FileArray>(Array(0));
const [folderChain, setFolderChan] = useState<FileArray>([
{
id: "restore_waitlist",
name: "Restore Waitlist",
isDir: true,
openable: true,
selectable: true,
draggable: true,
droppable: true,
const onFileSizeUpdated = useCallback(
(id: string, size: number) => {
setFiles(
(files.filter((file) => !!file) as FileData[]).map((file: FileData) => {
if (file.id === id) {
return { ...file, size };
}
return file;
}),
);
},
]);
[files, setFiles],
);
const onFileSizeUpdatedRef = useRef(onFileSizeUpdated);
onFileSizeUpdatedRef.current = onFileSizeUpdated;
const onFileAction = useCallback(
(data: ChonkyFileActionData) => {
@@ -110,13 +157,30 @@ const useRestoreTargetBrowser = () => {
})();
return;
case AddFileAction.id:
setFiles([...files, ...((data.payload as any)?.selectedFiles as FileData[])]);
const addedFiles = (data.payload as any)?.selectedFiles as FileData[];
setFiles([...files, ...addedFiles]);
(async () => {
for (const file of addedFiles) {
if (!file) {
continue;
}
if (file.size !== undefined) {
continue;
}
const reply = await cli.fileGet({ id: BigInt(file.id), needSize: true }).response;
onFileSizeUpdatedRef.current(file.id, Number(reply.file?.size));
}
})();
return;
case CreateRestoreJobAction.id:
(async () => {
const fileIds = files.filter((file): file is FileData => !!file && file.id !== "0").map((file) => BigInt(file.id));
console.log(await cli.jobCreate(makeParam(1n, { fileIds })).response);
alert("Create Restore Job Success!");
toast.success("Create Restore Job Success!");
})();
return;
}
@@ -128,7 +192,7 @@ const useRestoreTargetBrowser = () => {
return {
files,
folderChain,
folderChain: targetFolderChain,
onFileAction,
fileActions,
defaultFileViewActionId: ChonkyActions.EnableListView.id,
@@ -141,8 +205,8 @@ export const RestoreType = "restore";
export const RestoreBrowser = () => {
const target = useRef<FileBrowserHandle>(null);
const sourceProps = useRestoreSourceBrowser(target);
const targetProps = useRestoreTargetBrowser();
const sourceProps = useRestoreSourceBrowser(targetProps.files, target);
return (
<Box className="browser-box">

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useMemo, useCallback, FC, useRef, RefObject } from "react";
import { toast } from "react-toastify";
import Grid from "@mui/material/Grid";
import Box from "@mui/material/Box";
@@ -111,7 +112,7 @@ const useTapesSourceBrowser = (source: RefObject<FileBrowserHandle>) => {
}
console.log(await cli.libraryTrim({ trimFile: true, trimPosition: true }).response);
alert("Trim Library Success!");
toast.success("Trim Library Success!");
})();
return;
}