feat: improved stats visualization with graphs and cleanup operation filtering

This commit is contained in:
Gareth George
2024-02-28 19:27:55 -08:00
parent 45fdb9e437
commit 80a90fa1f8
14 changed files with 1755 additions and 582 deletions
-1
View File
@@ -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
}
+10 -1
View File
@@ -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
}
+3
View File
@@ -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
}
+5 -56
View File
@@ -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{},
+1131 -31
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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",
+6 -419
View File
@@ -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>;
}
+431
View File
@@ -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>;
}
+2 -2
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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(
+3 -1
View File
@@ -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
View File
@@ -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>
</>
}