mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-08-28 11:56:28 +00:00
feat: improved stats visualization with graphs and cleanup operation filtering
This commit is contained in:
@@ -190,7 +190,6 @@ func backupHelper(ctx context.Context, t Task, orchestrator *Orchestrator, plan
|
||||
orchestrator.ScheduleTask(NewOneoffForgetTask(orchestrator, plan, op.SnapshotId, at), TaskPriorityForget)
|
||||
}
|
||||
orchestrator.ScheduleTask(NewOneoffIndexSnapshotsTask(orchestrator, plan.Repo, at), TaskPriorityIndexSnapshots)
|
||||
orchestrator.ScheduleTask(NewOneoffStatsTask(orchestrator, plan, op.SnapshotId, at), TaskPriorityStats)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ const (
|
||||
// - it has a forgotten snapshot associated with it
|
||||
gcHistoryAge = 30 * 24 * time.Hour
|
||||
gcHistoryMaxCount = 1000
|
||||
// keep stats operations for 1 year (they're small and useful for long term trends)
|
||||
gcHistoryStatsAge = 365 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type CollectGarbageTask struct {
|
||||
@@ -72,9 +74,11 @@ func (t *CollectGarbageTask) gcOperations() error {
|
||||
operationsByPlan := make(map[string][]gcOpInfo)
|
||||
if err := oplog.ForAll(func(op *v1.Operation) error {
|
||||
if op.SnapshotId == "" || snapshotIsForgotten[op.SnapshotId] {
|
||||
_, isStats := op.Op.(*v1.Operation_OperationStats)
|
||||
operationsByPlan[op.PlanId] = append(operationsByPlan[op.PlanId], gcOpInfo{
|
||||
id: op.Id,
|
||||
timestamp: op.UnixTimeStartMs,
|
||||
isStats: isStats,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
@@ -94,7 +98,11 @@ func (t *CollectGarbageTask) gcOperations() error {
|
||||
|
||||
// check if each operation timestamp is old.
|
||||
for _, opInfo := range opInfos {
|
||||
if curTime-opInfo.timestamp > gcHistoryAge.Milliseconds() {
|
||||
maxAgeForType := gcHistoryAge.Milliseconds()
|
||||
if opInfo.isStats {
|
||||
maxAgeForType = gcHistoryStatsAge.Milliseconds()
|
||||
}
|
||||
if curTime-opInfo.timestamp > maxAgeForType {
|
||||
gcOps = append(gcOps, opInfo.id)
|
||||
}
|
||||
}
|
||||
@@ -122,4 +130,5 @@ func (t *CollectGarbageTask) OperationId() int64 {
|
||||
type gcOpInfo struct {
|
||||
id int64 // operation ID
|
||||
timestamp int64 // unix time milliseconds
|
||||
isStats bool // true if this is a stats operation
|
||||
}
|
||||
|
||||
@@ -176,6 +176,9 @@ func (t *PruneTask) Run(ctx context.Context) error {
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
t.orch.ScheduleTask(NewOneoffStatsTask(t.orch, t.plan, time.Now()), TaskPriorityStats)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -8,32 +8,25 @@ import (
|
||||
|
||||
v1 "github.com/garethgeorge/backrest/gen/go/v1"
|
||||
"github.com/garethgeorge/backrest/internal/hook"
|
||||
"github.com/garethgeorge/backrest/internal/oplog"
|
||||
"github.com/garethgeorge/backrest/internal/oplog/indexutil"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var statBytesThreshold int64 = 10 * 1024 * 1024 * 1024 // 10 GB added.
|
||||
var statOperationsThreshold int = 100 // run a stat command every 100 operations.
|
||||
|
||||
// StatsTask tracks a restic stats operation.
|
||||
type StatsTask struct {
|
||||
TaskWithOperation
|
||||
plan *v1.Plan
|
||||
linkSnapshot string // snapshot to link the task to (if any)
|
||||
at *time.Time
|
||||
plan *v1.Plan
|
||||
at *time.Time
|
||||
}
|
||||
|
||||
var _ Task = &StatsTask{}
|
||||
|
||||
func NewOneoffStatsTask(orchestrator *Orchestrator, plan *v1.Plan, linkSnapshot string, at time.Time) *StatsTask {
|
||||
func NewOneoffStatsTask(orchestrator *Orchestrator, plan *v1.Plan, at time.Time) *StatsTask {
|
||||
return &StatsTask{
|
||||
TaskWithOperation: TaskWithOperation{
|
||||
orch: orchestrator,
|
||||
},
|
||||
plan: plan,
|
||||
at: &at,
|
||||
linkSnapshot: linkSnapshot,
|
||||
plan: plan,
|
||||
at: &at,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,58 +34,14 @@ func (t *StatsTask) Name() string {
|
||||
return fmt.Sprintf("stats for plan %q", t.plan.Id)
|
||||
}
|
||||
|
||||
func (t *StatsTask) shouldRun() (bool, error) {
|
||||
var bytesSinceLastStat int64 = -1
|
||||
var howFarBack int = 0
|
||||
if err := t.orch.OpLog.ForEachByRepo(t.plan.Repo, indexutil.Reversed(indexutil.CollectAll()), func(op *v1.Operation) error {
|
||||
if op.Status == v1.OperationStatus_STATUS_PENDING || op.Status == v1.OperationStatus_STATUS_INPROGRESS {
|
||||
return nil
|
||||
}
|
||||
howFarBack++
|
||||
if _, ok := op.Op.(*v1.Operation_OperationStats); ok {
|
||||
if bytesSinceLastStat == -1 {
|
||||
bytesSinceLastStat = 0
|
||||
}
|
||||
return oplog.ErrStopIteration
|
||||
} else if backup, ok := op.Op.(*v1.Operation_OperationBackup); ok && backup.OperationBackup.LastStatus != nil {
|
||||
if summary, ok := backup.OperationBackup.LastStatus.Entry.(*v1.BackupProgressEntry_Summary); ok {
|
||||
bytesSinceLastStat += summary.Summary.DataAdded
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return false, fmt.Errorf("iterate oplog: %w", err)
|
||||
}
|
||||
|
||||
zap.L().Debug("distance since last stat", zap.Int64("bytes", bytesSinceLastStat), zap.String("repo", t.plan.Repo), zap.Int("opsBack", howFarBack))
|
||||
if howFarBack >= statOperationsThreshold {
|
||||
zap.S().Debugf("distance since last stat (%v) is exceeds threshold (%v)", howFarBack, statOperationsThreshold)
|
||||
return true, nil
|
||||
}
|
||||
if bytesSinceLastStat == -1 || bytesSinceLastStat > statBytesThreshold {
|
||||
zap.S().Debugf("bytes since last stat (%v) exceeds threshold (%v)", bytesSinceLastStat, statBytesThreshold)
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (t *StatsTask) Next(now time.Time) *time.Time {
|
||||
ret := t.at
|
||||
if ret != nil {
|
||||
t.at = nil
|
||||
|
||||
shouldRun, err := t.shouldRun()
|
||||
if err != nil {
|
||||
zap.S().Errorf("task %v failed to check if it should run: %v", t.Name(), err)
|
||||
}
|
||||
if !shouldRun {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := t.setOperation(&v1.Operation{
|
||||
PlanId: t.plan.Id,
|
||||
RepoId: t.plan.Repo,
|
||||
SnapshotId: t.linkSnapshot,
|
||||
UnixTimeStartMs: timeToUnixMillis(*ret),
|
||||
Status: v1.OperationStatus_STATUS_PENDING,
|
||||
Op: &v1.Operation_OperationStats{},
|
||||
|
||||
Generated
+1131
-31
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,9 @@
|
||||
"@bufbuild/protobuf": "^1.6.0",
|
||||
"@connectrpc/connect": "^1.2.0",
|
||||
"@connectrpc/connect-web": "^1.2.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/material": "^5.15.11",
|
||||
"@mui/x-charts": "^6.19.5",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "^18.2.37",
|
||||
|
||||
@@ -3,50 +3,22 @@ import {
|
||||
Operation,
|
||||
OperationEvent,
|
||||
OperationEventType,
|
||||
OperationForget,
|
||||
OperationRunHook,
|
||||
OperationStatus,
|
||||
} from "../../gen/ts/v1/operations_pb";
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Collapse,
|
||||
Empty,
|
||||
List,
|
||||
Progress,
|
||||
Row,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import {
|
||||
PaperClipOutlined,
|
||||
SaveOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
RobotOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { BackupProgressEntry, ResticSnapshot } from "../../gen/ts/v1/restic_pb";
|
||||
import {
|
||||
BackupInfo,
|
||||
BackupInfoCollector,
|
||||
DisplayType,
|
||||
detailsForOperation,
|
||||
displayTypeToString,
|
||||
getOperations,
|
||||
getTypeForDisplay,
|
||||
subscribeToOperations,
|
||||
unsubscribeFromOperations,
|
||||
} from "../state/oplog";
|
||||
import { SnapshotBrowser } from "./SnapshotBrowser";
|
||||
import {
|
||||
formatBytes,
|
||||
formatTime,
|
||||
normalizeSnapshotId,
|
||||
} from "../lib/formatting";
|
||||
import _ from "lodash";
|
||||
import { GetOperationsRequest, LogDataRequest } from "../../gen/ts/v1/service_pb";
|
||||
import { GetOperationsRequest } from "../../gen/ts/v1/service_pb";
|
||||
import { useAlertApi } from "./Alerts";
|
||||
import { MessageInstance } from "antd/es/message/interface";
|
||||
import { backrestService } from "../api";
|
||||
import { OperationRow } from "./OperationRow";
|
||||
|
||||
// OperationList displays a list of operations that are either fetched based on 'req' or passed in via 'useBackups'.
|
||||
// If showPlan is provided the planId will be displayed next to each operation in the operation list.
|
||||
@@ -59,7 +31,7 @@ export const OperationList = ({
|
||||
req?: GetOperationsRequest;
|
||||
useBackups?: BackupInfo[];
|
||||
showPlan?: boolean,
|
||||
filter?: (op: Operation) => boolean,
|
||||
filter?: (op: Operation) => boolean, // if provided, only operations that pass this filter will be displayed.
|
||||
}>) => {
|
||||
const alertApi = useAlertApi();
|
||||
|
||||
@@ -74,7 +46,7 @@ export const OperationList = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const backupCollector = new BackupInfoCollector();
|
||||
const backupCollector = new BackupInfoCollector(filter);
|
||||
const lis = (opEvent: OperationEvent) => {
|
||||
if (!!req.planId && opEvent.operation!.planId !== req.planId) {
|
||||
return;
|
||||
@@ -91,7 +63,7 @@ export const OperationList = ({
|
||||
subscribeToOperations(lis);
|
||||
|
||||
backupCollector.subscribe(_.debounce(() => {
|
||||
let backups = backupCollector.getAll(false);
|
||||
let backups = backupCollector.getAll();
|
||||
backups.sort((a, b) => {
|
||||
return b.startTimeMs - a.startTimeMs;
|
||||
});
|
||||
@@ -122,10 +94,7 @@ export const OperationList = ({
|
||||
);
|
||||
}
|
||||
|
||||
let operations = backups.flatMap((b) => b.operations);
|
||||
if (filter) {
|
||||
operations = operations.filter(filter);
|
||||
}
|
||||
let operations = backups.flatMap((b) => b.operations)
|
||||
operations.sort((a, b) => {
|
||||
return Number(b.unixTimeStartMs - a.unixTimeStartMs)
|
||||
});
|
||||
@@ -146,385 +115,3 @@ export const OperationList = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const OperationRow = ({
|
||||
operation,
|
||||
alertApi,
|
||||
showPlan,
|
||||
}: React.PropsWithoutRef<{ operation: Operation, alertApi?: MessageInstance, showPlan: boolean }>) => {
|
||||
const details = detailsForOperation(operation);
|
||||
const displayType = getTypeForDisplay(operation);
|
||||
let avatar: React.ReactNode;
|
||||
switch (displayType) {
|
||||
case DisplayType.BACKUP:
|
||||
avatar = (
|
||||
<SaveOutlined
|
||||
style={{ color: details.color }}
|
||||
spin={operation.status === OperationStatus.STATUS_INPROGRESS}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DisplayType.FORGET:
|
||||
avatar = (
|
||||
<DeleteOutlined
|
||||
style={{ color: details.color }}
|
||||
spin={operation.status === OperationStatus.STATUS_INPROGRESS}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DisplayType.SNAPSHOT:
|
||||
avatar = <PaperClipOutlined style={{ color: details.color }} />;
|
||||
break;
|
||||
case DisplayType.RESTORE:
|
||||
avatar = <DownloadOutlined style={{ color: details.color }} />;
|
||||
break;
|
||||
case DisplayType.PRUNE:
|
||||
avatar = <DeleteOutlined style={{ color: details.color }} />;
|
||||
break;
|
||||
case DisplayType.RUNHOOK:
|
||||
avatar = <RobotOutlined style={{ color: details.color }} />;
|
||||
|
||||
}
|
||||
|
||||
const opName = displayTypeToString(getTypeForDisplay(operation));
|
||||
let title = (
|
||||
<>
|
||||
{showPlan ? operation.planId + " - " : undefined} {formatTime(Number(operation.unixTimeStartMs))} - {opName}{" "}
|
||||
<span className="backrest operation-details">{details.displayState}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (operation.status === OperationStatus.STATUS_PENDING || operation.status == OperationStatus.STATUS_INPROGRESS) {
|
||||
title = <>
|
||||
{title}
|
||||
<Button type="link" size="small" onClick={() => {
|
||||
backrestService.cancel({ value: operation.id! }).then(() => {
|
||||
alertApi?.success("Requested to cancel operation");
|
||||
}).catch((e) => {
|
||||
alertApi?.error("Failed to cancel operation: " + e.message);
|
||||
});
|
||||
}}>[Cancel Operation]</Button>
|
||||
</>
|
||||
}
|
||||
|
||||
let body: React.ReactNode | undefined;
|
||||
|
||||
if (operation.op.case === "operationBackup") {
|
||||
const backupOp = operation.op.value;
|
||||
const items: { key: number, label: string, children: React.ReactNode }[] = [
|
||||
{
|
||||
key: 1,
|
||||
label: "Backup Details",
|
||||
children: <BackupOperationStatus status={backupOp.lastStatus} />,
|
||||
},
|
||||
];
|
||||
|
||||
if (backupOp.errors.length > 0) {
|
||||
items.splice(0, 0, {
|
||||
key: 2,
|
||||
label: "Item Errors",
|
||||
children: <pre>{backupOp.errors.map(e => "Error on item: " + e.item).join("\n")}</pre>,
|
||||
});
|
||||
}
|
||||
|
||||
body = (
|
||||
<>
|
||||
<Collapse
|
||||
size="small"
|
||||
destroyInactivePanel
|
||||
defaultActiveKey={[1]}
|
||||
items={items}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (operation.op.case === "operationIndexSnapshot") {
|
||||
const snapshotOp = operation.op.value;
|
||||
body = (
|
||||
<SnapshotInfo
|
||||
snapshot={snapshotOp.snapshot!}
|
||||
repoId={operation.repoId!}
|
||||
planId={operation.planId}
|
||||
/>
|
||||
);
|
||||
} else if (operation.op.case === "operationForget") {
|
||||
const forgetOp = operation.op.value;
|
||||
body = <ForgetOperationDetails forgetOp={forgetOp} />
|
||||
} else if (operation.op.case === "operationPrune") {
|
||||
const prune = operation.op.value;
|
||||
body = (
|
||||
<Collapse
|
||||
size="small"
|
||||
destroyInactivePanel
|
||||
items={[
|
||||
{
|
||||
key: 1,
|
||||
label: "Prune Output",
|
||||
children: <pre>{prune.output}</pre>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
} else if (operation.op.case === "operationRestore") {
|
||||
const restore = operation.op.value;
|
||||
body = (
|
||||
<>
|
||||
Restore {restore.path} to {restore.target}
|
||||
{details.percentage !== undefined ? (
|
||||
<Progress percent={details.percentage || 0} status="active" />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
} else if (operation.op.case === "operationRunHook") {
|
||||
const hook = operation.op.value;
|
||||
body = <RunHookOperationStatus op={operation} />
|
||||
}
|
||||
|
||||
if (operation.displayMessage) {
|
||||
body = (
|
||||
<>
|
||||
<pre>{details.state}: {operation.displayMessage}</pre>
|
||||
{body}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<List.Item>
|
||||
<List.Item.Meta title={title} avatar={avatar} description={body} />
|
||||
</List.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const SnapshotInfo = ({
|
||||
snapshot,
|
||||
repoId,
|
||||
planId,
|
||||
}: {
|
||||
snapshot: ResticSnapshot;
|
||||
repoId: string;
|
||||
planId?: string;
|
||||
}) => {
|
||||
return (
|
||||
<Collapse
|
||||
size="small"
|
||||
defaultActiveKey={[1]}
|
||||
items={[
|
||||
{
|
||||
key: 1,
|
||||
label: "Snapshot Details",
|
||||
children: (
|
||||
<>
|
||||
<Typography.Text>
|
||||
<Typography.Text strong>Snapshot ID: </Typography.Text>
|
||||
{normalizeSnapshotId(snapshot.id!)}
|
||||
</Typography.Text>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Host</Typography.Text>
|
||||
<br />
|
||||
{snapshot.hostname}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Username</Typography.Text>
|
||||
<br />
|
||||
{snapshot.hostname}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Tags</Typography.Text>
|
||||
<br />
|
||||
{snapshot.tags?.join(", ")}
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
label: "Browse and Restore Files in Backup",
|
||||
children: (
|
||||
<SnapshotBrowser
|
||||
snapshotId={snapshot.id!}
|
||||
repoId={repoId}
|
||||
planId={planId}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const BackupOperationStatus = ({
|
||||
status,
|
||||
}: {
|
||||
status?: BackupProgressEntry;
|
||||
}) => {
|
||||
if (!status) {
|
||||
return <>No status yet.</>;
|
||||
}
|
||||
|
||||
if (status.entry.case === "status") {
|
||||
const st = status.entry.value;
|
||||
const progress =
|
||||
Math.round(
|
||||
(Number(st.bytesDone) / Math.max(Number(st.totalBytes), 1)) * 1000
|
||||
) / 10;
|
||||
return (
|
||||
<>
|
||||
<Progress percent={progress} status="active" />
|
||||
<br />
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Typography.Text strong>Bytes Done/Total</Typography.Text>
|
||||
<br />
|
||||
{formatBytes(Number(st.bytesDone))}/{formatBytes(Number(st.totalBytes))}
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Typography.Text strong>Files Done/Total</Typography.Text>
|
||||
<br />
|
||||
{Number(st.filesDone)}/{Number(st.totalFiles)}
|
||||
</Col>
|
||||
</Row>
|
||||
{st.currentFile && st.currentFile.length > 0 ? (
|
||||
<pre>Current file: {st.currentFile.join("\n")}</pre>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
} else if (status.entry.case === "summary") {
|
||||
const sum = status.entry.value;
|
||||
return (
|
||||
<>
|
||||
<Typography.Text>
|
||||
<Typography.Text strong>Snapshot ID: </Typography.Text>
|
||||
{normalizeSnapshotId(sum.snapshotId!)}
|
||||
</Typography.Text>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Files Added</Typography.Text>
|
||||
<br />
|
||||
{sum.filesNew.toString()}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Files Changed</Typography.Text>
|
||||
<br />
|
||||
{sum.filesChanged.toString()}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Files Unmodified</Typography.Text>
|
||||
<br />
|
||||
{sum.filesUnmodified.toString()}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Bytes Added</Typography.Text>
|
||||
<br />
|
||||
{formatBytes(Number(sum.dataAdded))}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Total Bytes Processed</Typography.Text>
|
||||
<br />
|
||||
{formatBytes(Number(sum.totalBytesProcessed))}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Total Files Processed</Typography.Text>
|
||||
<br />
|
||||
{sum.totalFilesProcessed.toString()}
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
console.error("GOT UNEXPECTED STATUS: ", status);
|
||||
return <>No fields set. This shouldn't happen</>;
|
||||
}
|
||||
};
|
||||
|
||||
const ForgetOperationDetails = ({ forgetOp }: { forgetOp: OperationForget }) => {
|
||||
const policy = forgetOp.policy! || {};
|
||||
const policyDesc = [];
|
||||
if (policy.keepLastN) {
|
||||
policyDesc.push(`Keep Last ${policy.keepLastN} Snapshots`);
|
||||
}
|
||||
if (policy.keepHourly) {
|
||||
policyDesc.push(`Keep Hourly for ${policy.keepHourly} Hours`);
|
||||
}
|
||||
if (policy.keepDaily) {
|
||||
policyDesc.push(`Keep Daily for ${policy.keepDaily} Days`);
|
||||
}
|
||||
if (policy.keepWeekly) {
|
||||
policyDesc.push(`Keep Weekly for ${policy.keepWeekly} Weeks`);
|
||||
}
|
||||
if (policy.keepMonthly) {
|
||||
policyDesc.push(`Keep Monthly for ${policy.keepMonthly} Months`);
|
||||
}
|
||||
if (policy.keepYearly) {
|
||||
policyDesc.push(`Keep Yearly for ${policy.keepYearly} Years`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
size="small"
|
||||
destroyInactivePanel
|
||||
items={[
|
||||
{
|
||||
key: 1,
|
||||
label: "Removed " + forgetOp.forget?.length + " Snapshots",
|
||||
children: <>
|
||||
Removed snapshots:
|
||||
<pre>{forgetOp.forget?.map((f) => (
|
||||
<div key={f.id}>
|
||||
{"removed snapshot " + normalizeSnapshotId(f.id!) + " taken at " + formatTime(Number(f.unixTimeMs))} <br />
|
||||
</div>
|
||||
))}</pre>
|
||||
Policy:
|
||||
<ul>
|
||||
{policyDesc.map((desc, idx) => (
|
||||
<li key={idx}>{desc}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const RunHookOperationStatus = ({ op }: { op: Operation }) => {
|
||||
if (op.op.case !== "operationRunHook") {
|
||||
return <>Wrong operation type</>;
|
||||
}
|
||||
|
||||
const hook = op.op.value;
|
||||
|
||||
return <>
|
||||
<Collapse size="small" destroyInactivePanel items={[
|
||||
{
|
||||
key: 1,
|
||||
label: "Logs for hook " + hook.name,
|
||||
children: <>
|
||||
<BigOperationDataVerbatim logref={hook.outputLogref} />
|
||||
</>
|
||||
},
|
||||
]} />
|
||||
</>
|
||||
}
|
||||
|
||||
// TODO: refactor this to use the provider pattern
|
||||
const BigOperationDataVerbatim = ({ logref }: { logref: string }) => {
|
||||
const [output, setOutput] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logref) {
|
||||
return;
|
||||
}
|
||||
backrestService.getLogs(new LogDataRequest({
|
||||
ref: logref,
|
||||
})).then((resp) => {
|
||||
setOutput(new TextDecoder("utf-8").decode(resp.value));
|
||||
}).catch((e) => {
|
||||
console.error("Failed to fetch hook output: ", e);
|
||||
});
|
||||
}, [logref]);
|
||||
|
||||
return <pre>{output}</pre>;
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Operation,
|
||||
OperationEvent,
|
||||
OperationEventType,
|
||||
OperationForget,
|
||||
OperationRunHook,
|
||||
OperationStatus,
|
||||
} from "../../gen/ts/v1/operations_pb";
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Collapse,
|
||||
Empty,
|
||||
List,
|
||||
Progress,
|
||||
Row,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import {
|
||||
PaperClipOutlined,
|
||||
SaveOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
RobotOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { BackupProgressEntry, ResticSnapshot } from "../../gen/ts/v1/restic_pb";
|
||||
import {
|
||||
DisplayType,
|
||||
detailsForOperation,
|
||||
displayTypeToString,
|
||||
getTypeForDisplay,
|
||||
} from "../state/oplog";
|
||||
import { SnapshotBrowser } from "./SnapshotBrowser";
|
||||
import {
|
||||
formatBytes,
|
||||
formatTime,
|
||||
normalizeSnapshotId,
|
||||
} from "../lib/formatting";
|
||||
import _ from "lodash";
|
||||
import { LogDataRequest } from "../../gen/ts/v1/service_pb";
|
||||
import { MessageInstance } from "antd/es/message/interface";
|
||||
import { backrestService } from "../api";
|
||||
|
||||
|
||||
export const OperationRow = ({
|
||||
operation,
|
||||
alertApi,
|
||||
showPlan,
|
||||
}: React.PropsWithoutRef<{ operation: Operation, alertApi?: MessageInstance, showPlan: boolean }>) => {
|
||||
const details = detailsForOperation(operation);
|
||||
const displayType = getTypeForDisplay(operation);
|
||||
let avatar: React.ReactNode;
|
||||
switch (displayType) {
|
||||
case DisplayType.BACKUP:
|
||||
avatar = (
|
||||
<SaveOutlined
|
||||
style={{ color: details.color }}
|
||||
spin={operation.status === OperationStatus.STATUS_INPROGRESS}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DisplayType.FORGET:
|
||||
avatar = (
|
||||
<DeleteOutlined
|
||||
style={{ color: details.color }}
|
||||
spin={operation.status === OperationStatus.STATUS_INPROGRESS}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DisplayType.SNAPSHOT:
|
||||
avatar = <PaperClipOutlined style={{ color: details.color }} />;
|
||||
break;
|
||||
case DisplayType.RESTORE:
|
||||
avatar = <DownloadOutlined style={{ color: details.color }} />;
|
||||
break;
|
||||
case DisplayType.PRUNE:
|
||||
avatar = <DeleteOutlined style={{ color: details.color }} />;
|
||||
break;
|
||||
case DisplayType.RUNHOOK:
|
||||
avatar = <RobotOutlined style={{ color: details.color }} />;
|
||||
break;
|
||||
case DisplayType.STATS:
|
||||
avatar = <InfoCircleOutlined style={{ color: details.color }} />;
|
||||
break;
|
||||
}
|
||||
|
||||
const opName = displayTypeToString(getTypeForDisplay(operation));
|
||||
let title = (
|
||||
<>
|
||||
{showPlan ? operation.planId + " - " : undefined} {formatTime(Number(operation.unixTimeStartMs))} - {opName}{" "}
|
||||
<span className="backrest operation-details">{details.displayState}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (operation.status === OperationStatus.STATUS_PENDING || operation.status == OperationStatus.STATUS_INPROGRESS) {
|
||||
title = <>
|
||||
{title}
|
||||
<Button type="link" size="small" onClick={() => {
|
||||
backrestService.cancel({ value: operation.id! }).then(() => {
|
||||
alertApi?.success("Requested to cancel operation");
|
||||
}).catch((e) => {
|
||||
alertApi?.error("Failed to cancel operation: " + e.message);
|
||||
});
|
||||
}}>[Cancel Operation]</Button>
|
||||
</>
|
||||
}
|
||||
|
||||
let body: React.ReactNode | undefined;
|
||||
|
||||
if (operation.op.case === "operationBackup") {
|
||||
const backupOp = operation.op.value;
|
||||
const items: { key: number, label: string, children: React.ReactNode }[] = [
|
||||
{
|
||||
key: 1,
|
||||
label: "Backup Details",
|
||||
children: <BackupOperationStatus status={backupOp.lastStatus} />,
|
||||
},
|
||||
];
|
||||
|
||||
if (backupOp.errors.length > 0) {
|
||||
items.splice(0, 0, {
|
||||
key: 2,
|
||||
label: "Item Errors",
|
||||
children: <pre>{backupOp.errors.map(e => "Error on item: " + e.item).join("\n")}</pre>,
|
||||
});
|
||||
}
|
||||
|
||||
body = (
|
||||
<>
|
||||
<Collapse
|
||||
size="small"
|
||||
destroyInactivePanel
|
||||
defaultActiveKey={[1]}
|
||||
items={items}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (operation.op.case === "operationIndexSnapshot") {
|
||||
const snapshotOp = operation.op.value;
|
||||
body = (
|
||||
<SnapshotInfo
|
||||
snapshot={snapshotOp.snapshot!}
|
||||
repoId={operation.repoId!}
|
||||
planId={operation.planId}
|
||||
/>
|
||||
);
|
||||
} else if (operation.op.case === "operationForget") {
|
||||
const forgetOp = operation.op.value;
|
||||
body = <ForgetOperationDetails forgetOp={forgetOp} />
|
||||
} else if (operation.op.case === "operationPrune") {
|
||||
const prune = operation.op.value;
|
||||
body = (
|
||||
<Collapse
|
||||
size="small"
|
||||
destroyInactivePanel
|
||||
items={[
|
||||
{
|
||||
key: 1,
|
||||
label: "Prune Output",
|
||||
children: <pre>{prune.output}</pre>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
} else if (operation.op.case === "operationRestore") {
|
||||
const restore = operation.op.value;
|
||||
body = (
|
||||
<>
|
||||
Restore {restore.path} to {restore.target}
|
||||
{details.percentage !== undefined ? (
|
||||
<Progress percent={details.percentage || 0} status="active" />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
} else if (operation.op.case === "operationRunHook") {
|
||||
const hook = operation.op.value;
|
||||
body = <RunHookOperationStatus op={operation} />
|
||||
}
|
||||
|
||||
if (operation.displayMessage) {
|
||||
body = (
|
||||
<>
|
||||
<pre>{details.state}: {operation.displayMessage}</pre>
|
||||
{body}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<List.Item>
|
||||
<List.Item.Meta title={title} avatar={avatar} description={body} />
|
||||
</List.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const SnapshotInfo = ({
|
||||
snapshot,
|
||||
repoId,
|
||||
planId,
|
||||
}: {
|
||||
snapshot: ResticSnapshot;
|
||||
repoId: string;
|
||||
planId?: string;
|
||||
}) => {
|
||||
return (
|
||||
<Collapse
|
||||
size="small"
|
||||
defaultActiveKey={[1]}
|
||||
items={[
|
||||
{
|
||||
key: 1,
|
||||
label: "Snapshot Details",
|
||||
children: (
|
||||
<>
|
||||
<Typography.Text>
|
||||
<Typography.Text strong>Snapshot ID: </Typography.Text>
|
||||
{normalizeSnapshotId(snapshot.id!)}
|
||||
</Typography.Text>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Host</Typography.Text>
|
||||
<br />
|
||||
{snapshot.hostname}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Username</Typography.Text>
|
||||
<br />
|
||||
{snapshot.hostname}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Tags</Typography.Text>
|
||||
<br />
|
||||
{snapshot.tags?.join(", ")}
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
label: "Browse and Restore Files in Backup",
|
||||
children: (
|
||||
<SnapshotBrowser
|
||||
snapshotId={snapshot.id!}
|
||||
repoId={repoId}
|
||||
planId={planId}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const BackupOperationStatus = ({
|
||||
status,
|
||||
}: {
|
||||
status?: BackupProgressEntry;
|
||||
}) => {
|
||||
if (!status) {
|
||||
return <>No status yet.</>;
|
||||
}
|
||||
|
||||
if (status.entry.case === "status") {
|
||||
const st = status.entry.value;
|
||||
const progress =
|
||||
Math.round(
|
||||
(Number(st.bytesDone) / Math.max(Number(st.totalBytes), 1)) * 1000
|
||||
) / 10;
|
||||
return (
|
||||
<>
|
||||
<Progress percent={progress} status="active" />
|
||||
<br />
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Typography.Text strong>Bytes Done/Total</Typography.Text>
|
||||
<br />
|
||||
{formatBytes(Number(st.bytesDone))}/{formatBytes(Number(st.totalBytes))}
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Typography.Text strong>Files Done/Total</Typography.Text>
|
||||
<br />
|
||||
{Number(st.filesDone)}/{Number(st.totalFiles)}
|
||||
</Col>
|
||||
</Row>
|
||||
{st.currentFile && st.currentFile.length > 0 ? (
|
||||
<pre>Current file: {st.currentFile.join("\n")}</pre>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
} else if (status.entry.case === "summary") {
|
||||
const sum = status.entry.value;
|
||||
return (
|
||||
<>
|
||||
<Typography.Text>
|
||||
<Typography.Text strong>Snapshot ID: </Typography.Text>
|
||||
{normalizeSnapshotId(sum.snapshotId!)}
|
||||
</Typography.Text>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Files Added</Typography.Text>
|
||||
<br />
|
||||
{sum.filesNew.toString()}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Files Changed</Typography.Text>
|
||||
<br />
|
||||
{sum.filesChanged.toString()}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Files Unmodified</Typography.Text>
|
||||
<br />
|
||||
{sum.filesUnmodified.toString()}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Bytes Added</Typography.Text>
|
||||
<br />
|
||||
{formatBytes(Number(sum.dataAdded))}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Total Bytes Processed</Typography.Text>
|
||||
<br />
|
||||
{formatBytes(Number(sum.totalBytesProcessed))}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Total Files Processed</Typography.Text>
|
||||
<br />
|
||||
{sum.totalFilesProcessed.toString()}
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
console.error("GOT UNEXPECTED STATUS: ", status);
|
||||
return <>No fields set. This shouldn't happen</>;
|
||||
}
|
||||
};
|
||||
|
||||
const ForgetOperationDetails = ({ forgetOp }: { forgetOp: OperationForget }) => {
|
||||
const policy = forgetOp.policy! || {};
|
||||
const policyDesc = [];
|
||||
if (policy.keepLastN) {
|
||||
policyDesc.push(`Keep Last ${policy.keepLastN} Snapshots`);
|
||||
}
|
||||
if (policy.keepHourly) {
|
||||
policyDesc.push(`Keep Hourly for ${policy.keepHourly} Hours`);
|
||||
}
|
||||
if (policy.keepDaily) {
|
||||
policyDesc.push(`Keep Daily for ${policy.keepDaily} Days`);
|
||||
}
|
||||
if (policy.keepWeekly) {
|
||||
policyDesc.push(`Keep Weekly for ${policy.keepWeekly} Weeks`);
|
||||
}
|
||||
if (policy.keepMonthly) {
|
||||
policyDesc.push(`Keep Monthly for ${policy.keepMonthly} Months`);
|
||||
}
|
||||
if (policy.keepYearly) {
|
||||
policyDesc.push(`Keep Yearly for ${policy.keepYearly} Years`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
size="small"
|
||||
destroyInactivePanel
|
||||
items={[
|
||||
{
|
||||
key: 1,
|
||||
label: "Removed " + forgetOp.forget?.length + " Snapshots",
|
||||
children: <>
|
||||
Removed snapshots:
|
||||
<pre>{forgetOp.forget?.map((f) => (
|
||||
<div key={f.id}>
|
||||
{"removed snapshot " + normalizeSnapshotId(f.id!) + " taken at " + formatTime(Number(f.unixTimeMs))} <br />
|
||||
</div>
|
||||
))}</pre>
|
||||
Policy:
|
||||
<ul>
|
||||
{policyDesc.map((desc, idx) => (
|
||||
<li key={idx}>{desc}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const RunHookOperationStatus = ({ op }: { op: Operation }) => {
|
||||
if (op.op.case !== "operationRunHook") {
|
||||
return <>Wrong operation type</>;
|
||||
}
|
||||
|
||||
const hook = op.op.value;
|
||||
|
||||
return <>
|
||||
<Collapse size="small" destroyInactivePanel items={[
|
||||
{
|
||||
key: 1,
|
||||
label: "Logs for hook " + hook.name,
|
||||
children: <>
|
||||
<BigOperationDataVerbatim logref={hook.outputLogref} />
|
||||
</>
|
||||
},
|
||||
]} />
|
||||
</>
|
||||
}
|
||||
|
||||
// TODO: refactor this to use the provider pattern
|
||||
const BigOperationDataVerbatim = ({ logref }: { logref: string }) => {
|
||||
const [output, setOutput] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logref) {
|
||||
return;
|
||||
}
|
||||
backrestService.getLogs(new LogDataRequest({
|
||||
ref: logref,
|
||||
})).then((resp) => {
|
||||
setOutput(new TextDecoder("utf-8").decode(resp.value));
|
||||
}).catch((e) => {
|
||||
console.error("Failed to fetch hook output: ", e);
|
||||
});
|
||||
}, [logref]);
|
||||
|
||||
return <pre>{output}</pre>;
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export const OperationTree = ({
|
||||
backupCollector.bulkAddOperations(ops);
|
||||
})
|
||||
.catch((e) => {
|
||||
alertApi!.error("Failed to fetch operations: " + e.message);
|
||||
alertApi!.error("Failed to fetch operations: " + e.messag);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeFromOperations(lis);
|
||||
@@ -314,7 +314,7 @@ const BackupView = ({ backup }: { backup?: BackupInfo }) => {
|
||||
{backup.status !== OperationStatus.STATUS_PENDING && backup.status != OperationStatus.STATUS_INPROGRESS ? deleteButton : null}
|
||||
</div>
|
||||
</div>
|
||||
<OperationList key={backup.id} useBackups={[backup]} filter={(op) => op && !shouldHideOperation(op)} />
|
||||
<OperationList key={backup.id} useBackups={[backup]} />
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -8,6 +8,7 @@ import "react-js-cron/dist/styles.css";
|
||||
import { ConfigProvider as AntdConfigProvider, theme } from "antd";
|
||||
import { ConfigContextProvider } from "./components/ConfigProvider";
|
||||
import { MainContentProvider } from "./views/MainContentArea";
|
||||
import { ThemeProvider, createTheme } from "@mui/material";
|
||||
|
||||
const Root = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
@@ -34,8 +35,14 @@ el &&
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Root>
|
||||
<App />
|
||||
</Root>
|
||||
<ThemeProvider theme={createTheme({
|
||||
palette: {
|
||||
mode: "dark",
|
||||
},
|
||||
})}>
|
||||
<Root>
|
||||
<App />
|
||||
</Root>
|
||||
</ThemeProvider>
|
||||
</AntdConfigProvider>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const formatBytes = (bytes?: number | string) => {
|
||||
if (!bytes) {
|
||||
return 0;
|
||||
return "0B";
|
||||
}
|
||||
if (typeof bytes === "string") {
|
||||
bytes = parseInt(bytes);
|
||||
|
||||
+14
-15
@@ -123,7 +123,6 @@ export interface BackupInfo {
|
||||
backupLastStatus?: BackupProgressEntry;
|
||||
snapshotInfo?: ResticSnapshot;
|
||||
forgotten: boolean;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
// BackupInfoCollector maps multiple operations to single aggregate 'BackupInfo' objects.
|
||||
@@ -136,6 +135,12 @@ export class BackupInfoCollector {
|
||||
private backupByOpId: Map<bigint, BackupInfo> = new Map();
|
||||
private backupBySnapshotId: Map<string, BackupInfo> = new Map();
|
||||
|
||||
/**
|
||||
*
|
||||
* @param filter a function that returns true if an operation should be displayed, false otherwise.
|
||||
*/
|
||||
constructor(private filter: (op: Operation) => boolean = (op) => !shouldHideOperation(op)) { }
|
||||
|
||||
private createBackup(operations: Operation[]): BackupInfo {
|
||||
// deduplicate and sort operations.
|
||||
operations.sort((a, b) => {
|
||||
@@ -155,7 +160,7 @@ export class BackupInfoCollector {
|
||||
displayType = getTypeForDisplay(operations[0]);
|
||||
}
|
||||
|
||||
// use the latest status that is not cancelled.
|
||||
// use the latest status that is not a hidden status
|
||||
let statusIdx = operations.length - 1;
|
||||
let status = OperationStatus.STATUS_SYSTEM_CANCELLED;
|
||||
while (statusIdx !== -1) {
|
||||
@@ -174,7 +179,6 @@ export class BackupInfoCollector {
|
||||
let backupLastStatus = undefined;
|
||||
let snapshotInfo = undefined;
|
||||
let forgotten = false;
|
||||
let hidden = true;
|
||||
for (const op of operations) {
|
||||
if (op.op.case === "operationBackup") {
|
||||
backupLastStatus = op.op.value.lastStatus;
|
||||
@@ -182,9 +186,6 @@ export class BackupInfoCollector {
|
||||
snapshotInfo = op.op.value.snapshot;
|
||||
forgotten = op.op.value.forgot || false;
|
||||
}
|
||||
if (hidden && !shouldHideOperation(op)) {
|
||||
hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -198,7 +199,6 @@ export class BackupInfoCollector {
|
||||
backupLastStatus,
|
||||
snapshotInfo,
|
||||
forgotten,
|
||||
hidden,
|
||||
snapshotId: operations[0].snapshotId,
|
||||
planId: operations[0].planId,
|
||||
repoId: operations[0].repoId,
|
||||
@@ -233,7 +233,10 @@ export class BackupInfoCollector {
|
||||
}
|
||||
}
|
||||
|
||||
public addOperation(event: OperationEventType, op: Operation): BackupInfo {
|
||||
public addOperation(event: OperationEventType, op: Operation): BackupInfo | null {
|
||||
if (!this.filter(op)) {
|
||||
return null;
|
||||
}
|
||||
const backupInfo = this.addHelper(op);
|
||||
this.listeners.forEach((l) => l(event, [backupInfo]));
|
||||
return backupInfo;
|
||||
@@ -253,6 +256,7 @@ export class BackupInfoCollector {
|
||||
}
|
||||
|
||||
public bulkAddOperations(ops: Operation[]): BackupInfo[] {
|
||||
ops = ops.filter(this.filter);
|
||||
let grouped = _.groupBy(ops, (op) =>
|
||||
op.snapshotId ? op.snapshotId : op.id
|
||||
);
|
||||
@@ -284,17 +288,12 @@ export class BackupInfoCollector {
|
||||
return info;
|
||||
}
|
||||
|
||||
public getAll(filter: boolean = true): BackupInfo[] {
|
||||
public getAll(): BackupInfo[] {
|
||||
const arr = [
|
||||
...this.backupByOpId.values(),
|
||||
...this.backupBySnapshotId.values(),
|
||||
];
|
||||
if (!filter) {
|
||||
return arr.filter((b) => !b.forgotten);
|
||||
}
|
||||
return arr.filter(
|
||||
(b) => !b.forgotten && !b.hidden && !shouldHideStatus(b.status)
|
||||
);
|
||||
return arr.filter((b) => !b.forgotten);
|
||||
}
|
||||
|
||||
public subscribe(
|
||||
|
||||
@@ -91,6 +91,7 @@ export const PlanView = ({ plan }: React.PropsWithChildren<{ plan: Plan }>) => {
|
||||
/>
|
||||
</>
|
||||
),
|
||||
destroyInactiveTabPane: true,
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
@@ -100,10 +101,11 @@ export const PlanView = ({ plan }: React.PropsWithChildren<{ plan: Plan }>) => {
|
||||
<h2>Backup Action History</h2>
|
||||
<OperationList
|
||||
req={new GetOperationsRequest({ planId: plan.id!, lastN: BigInt(MAX_OPERATION_HISTORY) })}
|
||||
filter={(operation) => shouldHideStatus(operation.status)}
|
||||
filter={(op) => !shouldHideStatus(op.status)}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
destroyInactiveTabPane: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
+136
-52
@@ -5,40 +5,22 @@ import { OperationList } from "../components/OperationList";
|
||||
import { OperationTree } from "../components/OperationTree";
|
||||
import { MAX_OPERATION_HISTORY, STATS_OPERATION_HISTORY } from "../constants";
|
||||
import { GetOperationsRequest } from "../../gen/ts/v1/service_pb";
|
||||
import { getOperations } from "../state/oplog";
|
||||
import { BackupInfo, BackupInfoCollector, getOperations, shouldHideStatus } from "../state/oplog";
|
||||
import { RepoStats } from "../../gen/ts/v1/restic_pb";
|
||||
import { formatBytes, formatTime } from "../lib/formatting";
|
||||
import { Operation } from "../../gen/ts/v1/operations_pb";
|
||||
import { formatBytes, formatDate, formatTime } from "../lib/formatting";
|
||||
import { Operation, OperationStats, OperationStatus } from "../../gen/ts/v1/operations_pb";
|
||||
import { backrestService } from "../api";
|
||||
import { StringValue } from "@bufbuild/protobuf";
|
||||
import { SpinButton } from "../components/SpinButton";
|
||||
import { ConfigContext } from "antd/es/config-provider";
|
||||
import { useConfig } from "../components/ConfigProvider";
|
||||
import { useAlertApi } from "../components/Alerts";
|
||||
import { LineChart } from "@mui/x-charts";
|
||||
|
||||
|
||||
export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statsOperation, setStatsOperation] = useState<Operation | null>(null);
|
||||
const [config, setConfig] = useConfig();
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setStatsOperation(null);
|
||||
getOperations(new GetOperationsRequest({ repoId: repo.id!, lastN: BigInt(STATS_OPERATION_HISTORY) })).then((operations) => {
|
||||
for (const op of operations) {
|
||||
if (op.op.case === "operationStats") {
|
||||
const stats = op.op.value.stats;
|
||||
if (stats) {
|
||||
setStatsOperation(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
}).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [repo.id]);
|
||||
|
||||
// Task handlers
|
||||
const handleIndexNow = async () => {
|
||||
await backrestService.indexSnapshots(new StringValue({ value: repo.id! }));
|
||||
@@ -56,23 +38,13 @@ export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => {
|
||||
}
|
||||
repo = repoInConfig;
|
||||
|
||||
if (loading) {
|
||||
return <Spin />;
|
||||
}
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: "1",
|
||||
label: "Stats",
|
||||
children: (
|
||||
<>
|
||||
{statsOperation === null ? <Empty description="No data. Have you run a backup yet?" /> :
|
||||
<>
|
||||
<h3>Repo stats computed on {formatTime(Number(statsOperation.unixTimeStartMs))}</h3>
|
||||
{statsOperation.op.case === "operationStats" && <StatsTable stats={statsOperation.op.value.stats!} />}
|
||||
<small>Stats are refreshed periodically in the background as new data is added (e.g. every 10GB added or every 50 operations).</small>
|
||||
</>
|
||||
}
|
||||
<StatsPanel repoId={repo.id!} />
|
||||
</>
|
||||
),
|
||||
destroyInactiveTabPane: true,
|
||||
@@ -99,6 +71,7 @@ export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => {
|
||||
<OperationList
|
||||
req={new GetOperationsRequest({ repoId: repo.id!, lastN: BigInt(MAX_OPERATION_HISTORY) })}
|
||||
showPlan={true}
|
||||
filter={(op) => !shouldHideStatus(op.status)}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@@ -127,21 +100,132 @@ export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => {
|
||||
);
|
||||
};
|
||||
|
||||
const StatsTable = ({ stats }: { stats: RepoStats }) => {
|
||||
return <Row>
|
||||
<Col style={{ paddingRight: "20px" }}>
|
||||
<p><strong>Total Size: </strong></p>
|
||||
<p><strong>Total Size Uncompressed: </strong></p>
|
||||
<p><strong>Blob Count: </strong></p>
|
||||
<p><strong>Snapshot Count: </strong></p>
|
||||
<p><strong>Compression Ratio: </strong></p>
|
||||
</Col>
|
||||
<Col>
|
||||
<p>{formatBytes(Number(stats.totalSize))}</p>
|
||||
<p>{formatBytes(Number(stats.totalUncompressedSize))}</p>
|
||||
<p>{Number(stats.totalBlobCount)} blobs</p>
|
||||
<p>{Number(stats.snapshotCount)} snapshots</p>
|
||||
<p>{Math.round(stats.compressionRatio * 1000) / 1000}</p>
|
||||
</Col>
|
||||
</Row>
|
||||
const StatsPanel = ({ repoId }: { repoId: string }) => {
|
||||
const [operations, setOperations] = useState<Operation[]>([]);
|
||||
const alertApi = useAlertApi();
|
||||
|
||||
useEffect(() => {
|
||||
if (!repoId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backupCollector = new BackupInfoCollector((op) => {
|
||||
return op.status === OperationStatus.STATUS_SUCCESS && op.op.case === "operationStats" && !!op.op.value.stats
|
||||
});
|
||||
|
||||
getOperations(new GetOperationsRequest({ repoId: repoId, lastN: BigInt(MAX_OPERATION_HISTORY) }))
|
||||
.then((ops) => {
|
||||
backupCollector.bulkAddOperations(ops);
|
||||
|
||||
const operations = backupCollector.getAll().flatMap((b) => b.operations);
|
||||
operations.sort((a, b) => {
|
||||
return Number(b.unixTimeEndMs - a.unixTimeEndMs);
|
||||
});
|
||||
setOperations(operations);
|
||||
})
|
||||
.catch((e) => {
|
||||
alertApi!.error("Failed to fetch operations: " + e.message);
|
||||
});
|
||||
}, [repoId]);
|
||||
|
||||
if (operations.length === 0) {
|
||||
return <Empty description="No stats available. Have you run a prune operation yet?" />
|
||||
}
|
||||
|
||||
const dataset: {
|
||||
time: number,
|
||||
totalSizeMb: number,
|
||||
compressionRatio: number,
|
||||
snapshotCount: number,
|
||||
totalBlobCount: number,
|
||||
}[] = operations.map((op) => {
|
||||
const stats = (op.op.value! as OperationStats).stats!;
|
||||
return {
|
||||
time: Number(op.unixTimeEndMs!),
|
||||
totalSizeMb: Number(stats.totalSize) / 1000000,
|
||||
compressionRatio: Number(stats.compressionRatio),
|
||||
snapshotCount: Number(stats.snapshotCount),
|
||||
totalBlobCount: Number(stats.totalBlobCount),
|
||||
}
|
||||
});
|
||||
|
||||
const minTime = Math.min(...dataset.map((d) => d.time));
|
||||
const maxTime = Math.max(...dataset.map((d) => d.time));
|
||||
|
||||
return <>
|
||||
<Row>
|
||||
<Col span={12}>
|
||||
<LineChart
|
||||
xAxis={[{
|
||||
dataKey: "time",
|
||||
valueFormatter: (v) => formatDate(v as number),
|
||||
min: minTime,
|
||||
max: maxTime,
|
||||
}]}
|
||||
series={[
|
||||
{
|
||||
dataKey: "totalSizeMb",
|
||||
label: "Total Size (MB)",
|
||||
valueFormatter: (v: any) => formatBytes(v * 1000000 as number),
|
||||
},
|
||||
]}
|
||||
height={300}
|
||||
dataset={dataset}
|
||||
/>
|
||||
|
||||
<LineChart
|
||||
xAxis={[{
|
||||
dataKey: "time",
|
||||
valueFormatter: (v) => formatDate(v as number),
|
||||
min: minTime,
|
||||
max: maxTime,
|
||||
}]}
|
||||
series={[
|
||||
{
|
||||
dataKey: "compressionRatio",
|
||||
label: "Compression Ratio",
|
||||
},
|
||||
]}
|
||||
height={300}
|
||||
dataset={dataset}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<LineChart
|
||||
xAxis={[{
|
||||
dataKey: "time",
|
||||
valueFormatter: (v) => formatDate(v as number),
|
||||
min: minTime,
|
||||
max: maxTime,
|
||||
}]}
|
||||
series={[
|
||||
{
|
||||
dataKey: "snapshotCount",
|
||||
label: "Snapshot Count",
|
||||
},
|
||||
]}
|
||||
height={300}
|
||||
dataset={dataset}
|
||||
/>
|
||||
|
||||
<LineChart
|
||||
xAxis={[{
|
||||
dataKey: "time",
|
||||
valueFormatter: (v) => formatDate(v as number),
|
||||
min: minTime,
|
||||
max: maxTime,
|
||||
}]}
|
||||
series={[
|
||||
{
|
||||
dataKey: "totalBlobCount",
|
||||
label: "Blob Count",
|
||||
},
|
||||
]}
|
||||
height={300}
|
||||
dataset={dataset}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user