mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-09-21 07:25:38 +00:00
feat: present list of operations on plan view
This commit is contained in:
@@ -135,6 +135,7 @@ func (s *Server) GetOperationEvents(_ *emptypb.Empty, stream v1.ResticUI_GetOper
|
||||
errorChan := make(chan error)
|
||||
defer close(errorChan)
|
||||
callback := func(eventType oplog.EventType, op *v1.Operation) {
|
||||
zap.S().Debug("Sending an event")
|
||||
var eventTypeMapped v1.OperationEventType
|
||||
switch eventType {
|
||||
case oplog.EventTypeOpCreated:
|
||||
@@ -151,13 +152,14 @@ func (s *Server) GetOperationEvents(_ *emptypb.Empty, stream v1.ResticUI_GetOper
|
||||
Operation: op,
|
||||
}
|
||||
|
||||
if err := stream.Send(event); err != nil {
|
||||
errorChan <- fmt.Errorf("failed to send event: %w", err)
|
||||
}
|
||||
go func() {
|
||||
if err := stream.Send(event); err != nil {
|
||||
errorChan <- fmt.Errorf("failed to send event: %w", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
s.oplog.Subscribe(&callback)
|
||||
defer s.oplog.Unsubscribe(&callback)
|
||||
|
||||
select {
|
||||
case <-stream.Context().Done():
|
||||
return nil
|
||||
|
||||
@@ -15,8 +15,8 @@ var ErrConfigNotFound = fmt.Errorf("config not found")
|
||||
var configDirFlag = flag.String("config_dir", "", "The directory to store the config file")
|
||||
|
||||
var Default ConfigStore = &CachingValidatingStore{
|
||||
ConfigStore: &YamlFileStore{
|
||||
Path: path.Join(configDir(*configDirFlag), "config.yaml"),
|
||||
ConfigStore: &JsonFileStore{
|
||||
Path: path.Join(configDir(*configDirFlag), "config.json"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -11,17 +10,16 @@ import (
|
||||
v1 "github.com/garethgeorge/resticui/gen/go/v1"
|
||||
"github.com/google/renameio"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
yaml "gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type YamlFileStore struct {
|
||||
type JsonFileStore struct {
|
||||
Path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ ConfigStore = &YamlFileStore{}
|
||||
var _ ConfigStore = &JsonFileStore{}
|
||||
|
||||
func (f *YamlFileStore) Get() (*v1.Config, error) {
|
||||
func (f *JsonFileStore) Get() (*v1.Config, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
@@ -33,12 +31,6 @@ func (f *YamlFileStore) Get() (*v1.Config, error) {
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
|
||||
data, err = yamlToJson(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse YAML config: %w", err)
|
||||
}
|
||||
|
||||
var config v1.Config
|
||||
|
||||
if err = protojson.Unmarshal(data, &config); err != nil {
|
||||
@@ -52,7 +44,7 @@ func (f *YamlFileStore) Get() (*v1.Config, error) {
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func (f *YamlFileStore) Update(config *v1.Config) error {
|
||||
func (f *JsonFileStore) Update(config *v1.Config) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
@@ -65,11 +57,6 @@ func (f *YamlFileStore) Update(config *v1.Config) error {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
data, err = jsonToYaml(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to convert config to yaml: %w", err)
|
||||
}
|
||||
|
||||
err = os.MkdirAll(filepath.Dir(f.Path), 0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
@@ -82,23 +69,3 @@ func (f *YamlFileStore) Update(config *v1.Config) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func jsonToYaml(data []byte) ([]byte, error) {
|
||||
var config interface{}
|
||||
err := json.Unmarshal(data, &config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
return yaml.Marshal(config)
|
||||
}
|
||||
|
||||
func yamlToJson(data []byte) ([]byte, error) {
|
||||
var config interface{}
|
||||
err := yaml.Unmarshal(data, &config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
return json.Marshal(config)
|
||||
}
|
||||
@@ -71,6 +71,12 @@ func validatePlan(plan *v1.Plan, repos map[string]*v1.Repo) error {
|
||||
err = multierror.Append(err, fmt.Errorf("path is required"))
|
||||
}
|
||||
|
||||
for idx, p := range plan.Paths {
|
||||
if p == "" {
|
||||
err = multierror.Append(err, fmt.Errorf("path[%d] cannot be empty", idx))
|
||||
}
|
||||
}
|
||||
|
||||
if plan.Repo == "" {
|
||||
err = multierror.Append(err,fmt.Errorf("repo is required"))
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/garethgeorge/resticui/internal/database/indexutil"
|
||||
"github.com/garethgeorge/resticui/internal/database/serializationutil"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
@@ -57,8 +58,40 @@ func NewOpLog(databasePath string) (*OpLog, error) {
|
||||
SystemBucket, OpLogBucket, RepoIndexBucket, PlanIndexBucket, IndexedSnapshotsSetBucket,
|
||||
} {
|
||||
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
|
||||
return fmt.Errorf("error creating bucket %s: %s", string(bucket), err)
|
||||
return fmt.Errorf("creating bucket %s: %s", string(bucket), err)
|
||||
}
|
||||
|
||||
// Validate the operation log on startup.
|
||||
sysBucket := tx.Bucket(SystemBucket)
|
||||
opLogBucket := tx.Bucket(OpLogBucket)
|
||||
c := opLogBucket.Cursor()
|
||||
if lastValidated := sysBucket.Get([]byte("last_validated")); lastValidated != nil {
|
||||
c.Seek(lastValidated)
|
||||
}
|
||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||
op := &v1.Operation{}
|
||||
if err := proto.Unmarshal(v, op); err != nil {
|
||||
zap.L().Error("error unmarshalling operation, there may be corruption in the oplog", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if op.Status == v1.OperationStatus_STATUS_INPROGRESS {
|
||||
op.Status = v1.OperationStatus_STATUS_ERROR
|
||||
op.DisplayMessage = "Operation timeout."
|
||||
bytes, err := proto.Marshal(op)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshalling operation: %w", err)
|
||||
}
|
||||
if err := opLogBucket.Put(k, bytes); err != nil {
|
||||
return fmt.Errorf("putting operation into bucket: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if lastValidated, _ := c.Last(); lastValidated != nil {
|
||||
if err := sysBucket.Put([]byte("last_validated"), lastValidated); err != nil {
|
||||
return fmt.Errorf("checkpointing last_validated key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -88,12 +121,11 @@ func (o *OpLog) Add(op *v1.Operation) error {
|
||||
if err == nil {
|
||||
o.notifyHelper(EventTypeOpCreated, op)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (o *OpLog) BulkAdd(ops []*v1.Operation) {
|
||||
o.db.Update(func(tx *bolt.Tx) error {
|
||||
func (o *OpLog) BulkAdd(ops []*v1.Operation) error {
|
||||
err := o.db.Update(func(tx *bolt.Tx) error {
|
||||
for _, op := range ops {
|
||||
if err := o.addOperationHelper(tx, op); err != nil {
|
||||
return err
|
||||
@@ -101,6 +133,12 @@ func (o *OpLog) BulkAdd(ops []*v1.Operation) {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
for _, op := range ops {
|
||||
o.notifyHelper(EventTypeOpCreated, op)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (o *OpLog) addOperationHelper(tx *bolt.Tx, op *v1.Operation) error {
|
||||
@@ -239,6 +277,7 @@ func (o *OpLog) GetByRepo(repoId string, filter Filter) ([]*v1.Operation, error)
|
||||
var ops []*v1.Operation
|
||||
if err := o.db.View(func(tx *bolt.Tx) error {
|
||||
ids := indexutil.IndexSearchByteValue(tx.Bucket(RepoIndexBucket), []byte(repoId)).ToSlice()
|
||||
ids = filter(ids)
|
||||
|
||||
b := tx.Bucket(OpLogBucket)
|
||||
for _, id := range ids {
|
||||
@@ -260,6 +299,7 @@ func (o *OpLog) GetByPlan(planId string, filter Filter) ([]*v1.Operation, error)
|
||||
var ops []*v1.Operation
|
||||
if err := o.db.View(func(tx *bolt.Tx) error {
|
||||
ids := indexutil.IndexSearchByteValue(tx.Bucket(PlanIndexBucket), []byte(planId)).ToSlice()
|
||||
ids = filter(ids)
|
||||
|
||||
b := tx.Bucket(OpLogBucket)
|
||||
for _, id := range ids {
|
||||
|
||||
@@ -93,7 +93,7 @@ func backupHelper(ctx context.Context, orchestrator *Orchestrator, plan *v1.Plan
|
||||
op := &v1.Operation{
|
||||
PlanId: plan.Id,
|
||||
RepoId: plan.Repo,
|
||||
UnixTimeStartMs: time.Now().Unix(),
|
||||
UnixTimeStartMs: curTimeMillis(),
|
||||
Status: v1.OperationStatus_STATUS_INPROGRESS,
|
||||
Op: backupOp,
|
||||
}
|
||||
@@ -105,16 +105,28 @@ func backupHelper(ctx context.Context, orchestrator *Orchestrator, plan *v1.Plan
|
||||
return fmt.Errorf("failed to get repo %q: %w", plan.Repo, err)
|
||||
}
|
||||
|
||||
if _, err := repo.Backup(ctx, plan, func(entry *restic.BackupProgressEntry) {
|
||||
lastSent := time.Now() // debounce progress updates, these can endup being very frequent.
|
||||
summary, err := repo.Backup(ctx, plan, func(entry *restic.BackupProgressEntry) {
|
||||
if time.Since(lastSent) < 200 * time.Millisecond {
|
||||
return
|
||||
}
|
||||
lastSent = time.Now()
|
||||
|
||||
backupOp.OperationBackup.LastStatus = entry.ToProto()
|
||||
if err := orchestrator.oplog.Update(op); err != nil {
|
||||
zap.S().Errorf("failed to update oplog with progress for backup: %v", err)
|
||||
}
|
||||
zap.L().Debug("Backup progress", zap.Float64("progress", entry.PercentDone))
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to backup repo %q: %w", plan.Repo, err)
|
||||
}
|
||||
|
||||
backupOp.OperationBackup.LastStatus = summary.ToProto()
|
||||
if err := orchestrator.oplog.Update(op); err != nil {
|
||||
return fmt.Errorf("update oplog with summary for backup: %v", err)
|
||||
}
|
||||
|
||||
zap.L().Info("Backup complete", zap.String("plan", plan.Id))
|
||||
return nil
|
||||
})
|
||||
@@ -134,7 +146,7 @@ func WithOperation(oplog *oplog.OpLog, op *v1.Operation, do func() error) error
|
||||
op.Status = v1.OperationStatus_STATUS_ERROR
|
||||
op.DisplayMessage = err.Error()
|
||||
}
|
||||
op.UnixTimeEndMs = time.Now().Unix()
|
||||
op.UnixTimeEndMs = curTimeMillis()
|
||||
if op.Status == v1.OperationStatus_STATUS_INPROGRESS {
|
||||
op.Status = v1.OperationStatus_STATUS_SUCCESS
|
||||
}
|
||||
@@ -142,4 +154,9 @@ func WithOperation(oplog *oplog.OpLog, op *v1.Operation, do func() error) error
|
||||
return multierror.Append(err, fmt.Errorf("failed to update operation in oplog: %w", e))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func curTimeMillis() int64 {
|
||||
t := time.Now()
|
||||
return t.Unix() * 1000 + int64(t.Nanosecond() / 1000000)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import React from "react";
|
||||
import { Operation, OperationStatus } from "../../gen/ts/v1/operations.pb";
|
||||
import { Col, Collapse, Empty, List, Progress, Row, Typography } from "antd";
|
||||
import { AlertOutlined, DatabaseOutlined } from "@ant-design/icons";
|
||||
import { BackupProgressEntry } from "../../gen/ts/v1/restic.pb";
|
||||
|
||||
export const OperationList = ({
|
||||
operations,
|
||||
}: React.PropsWithoutRef<{ operations: Operation[] }>) => {
|
||||
interface OpWrapper {
|
||||
startTimeMs: number;
|
||||
operation: Operation;
|
||||
}
|
||||
const ops = operations.map((operation) => {
|
||||
return {
|
||||
time: parseInt(operation.unixTimeStartMs!),
|
||||
operation,
|
||||
};
|
||||
});
|
||||
|
||||
ops.sort((a, b) => b.time - a.time);
|
||||
|
||||
const elems = ops.map(({ operation }) => (
|
||||
<OperationRow operation={operation} />
|
||||
));
|
||||
|
||||
if (ops.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
description="No operations yet."
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
></Empty>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
size="small"
|
||||
dataSource={ops}
|
||||
renderItem={(item, index) => (
|
||||
<OperationRow key={item.operation.id!} operation={item.operation} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const OperationRow = ({
|
||||
operation,
|
||||
}: React.PropsWithoutRef<{ operation: Operation }>) => {
|
||||
let contents: React.ReactNode;
|
||||
|
||||
let color = "grey";
|
||||
if (operation.status === OperationStatus.STATUS_SUCCESS) {
|
||||
color = "green";
|
||||
} else if (operation.status === OperationStatus.STATUS_ERROR) {
|
||||
color = "red";
|
||||
} else if (operation.status === OperationStatus.STATUS_INPROGRESS) {
|
||||
color = "blue";
|
||||
}
|
||||
|
||||
if (operation.operationBackup) {
|
||||
const backupOp = operation.operationBackup;
|
||||
let desc = `Backup at ${formatTime(operation.unixTimeStartMs!)}`;
|
||||
if (operation.status !== OperationStatus.STATUS_INPROGRESS) {
|
||||
desc += ` and finished at ${formatTime(operation.unixTimeEndMs!)}`;
|
||||
} else {
|
||||
desc += " and is still running.";
|
||||
}
|
||||
|
||||
return (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={desc}
|
||||
avatar={<DatabaseOutlined style={{ color }} />}
|
||||
description={
|
||||
<>
|
||||
<Collapse
|
||||
size="small"
|
||||
defaultActiveKey={
|
||||
operation.status === OperationStatus.STATUS_INPROGRESS
|
||||
? [1]
|
||||
: undefined
|
||||
}
|
||||
items={[
|
||||
{
|
||||
key: 1,
|
||||
label: "Details",
|
||||
children: (
|
||||
<BackupOperationStatus status={backupOp.lastStatus} />
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
} else if (operation.operationIndexSnapshot) {
|
||||
const snapshotOp = operation.operationIndexSnapshot;
|
||||
return (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<>Snapshot at {formatTime(snapshotOp.snapshot!.unixTimeMs!)}</>
|
||||
}
|
||||
avatar={<DatabaseOutlined style={{ color }} />}
|
||||
description={<>A snapshot. More info needed</>}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
} else if (operation.displayMessage) {
|
||||
return (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={<>Message</>}
|
||||
avatar={<AlertOutlined style={{ color }} />}
|
||||
description={operation.displayMessage}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (time: number | string) => {
|
||||
if (typeof time === "string") {
|
||||
time = parseInt(time);
|
||||
}
|
||||
const d = new Date();
|
||||
d.setTime(time);
|
||||
return d.toLocaleString();
|
||||
};
|
||||
|
||||
const BackupOperationStatus = ({
|
||||
status,
|
||||
}: {
|
||||
status?: BackupProgressEntry;
|
||||
}) => {
|
||||
if (!status) {
|
||||
return <>No status yet.</>;
|
||||
}
|
||||
|
||||
if (status.status) {
|
||||
const st = status.status;
|
||||
const progress =
|
||||
Math.round(
|
||||
(parseInt(st.bytesDone!) / Math.max(parseInt(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(st.bytesDone)}/{formatBytes(st.totalBytes)}
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Typography.Text strong>Files Done/Total</Typography.Text>
|
||||
<br />
|
||||
{st.filesDone}/{st.totalFiles}
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
} else if (status.summary) {
|
||||
const sum = status.summary;
|
||||
return (
|
||||
<>
|
||||
<Typography.Text>
|
||||
<Typography.Text strong>Snapshot ID: </Typography.Text>
|
||||
{sum.snapshotId}
|
||||
</Typography.Text>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Files Added</Typography.Text>
|
||||
<br />
|
||||
{sum.filesNew}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Files Changed</Typography.Text>
|
||||
<br />
|
||||
{sum.filesChanged}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Files Unmodified</Typography.Text>
|
||||
<br />
|
||||
{sum.filesChanged}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Bytes Added</Typography.Text>
|
||||
<br />
|
||||
{formatBytes(sum.dataAdded)}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Total Bytes Processed</Typography.Text>
|
||||
<br />
|
||||
{formatBytes(sum.totalBytesProcessed)}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Typography.Text strong>Total Files Processed</Typography.Text>
|
||||
<br />
|
||||
{sum.totalFilesProcessed}
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
console.error("GOT UNEXPECTED STATUS: ", status);
|
||||
return <>No fields set. This shouldn't happen</>;
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes?: number | string) => {
|
||||
if (!bytes) {
|
||||
return 0;
|
||||
}
|
||||
if (typeof bytes === "string") {
|
||||
bytes = parseInt(bytes);
|
||||
}
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
let unit = 0;
|
||||
while (bytes > 1024) {
|
||||
bytes /= 1024;
|
||||
unit++;
|
||||
}
|
||||
return `${Math.round(bytes * 100) / 100} ${units[unit]}`;
|
||||
};
|
||||
+127
-7
@@ -5,20 +5,140 @@ import {
|
||||
OperationEventType,
|
||||
OperationStatus,
|
||||
} from "../../gen/ts/v1/operations.pb";
|
||||
import { ResticUI } from "../../gen/ts/v1/service.pb";
|
||||
import { GetOperationsRequest, ResticUI } from "../../gen/ts/v1/service.pb";
|
||||
import { EventEmitter } from "events";
|
||||
import { useAlertApi } from "../components/Alerts";
|
||||
|
||||
export const operationEmitter = new EventEmitter();
|
||||
const subscribers: ((event: OperationEvent) => void)[] = [];
|
||||
|
||||
// Start fetching and emitting operations.
|
||||
(async () => {
|
||||
await ResticUI.GetOperationEvents(
|
||||
{},
|
||||
(event: OperationEvent) => {
|
||||
operationEmitter.emit("operation", event);
|
||||
while (true) {
|
||||
let nextConnWaitUntil = new Date().getTime() + 5000;
|
||||
try {
|
||||
await ResticUI.GetOperationEvents(
|
||||
{},
|
||||
(event: OperationEvent) => {
|
||||
console.log("operation event", event);
|
||||
subscribers.forEach((subscriber) => subscriber(event));
|
||||
},
|
||||
{
|
||||
pathPrefix: "/api",
|
||||
}
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.error("operations stream died with exception: ", e);
|
||||
}
|
||||
await new Promise((accept, _) =>
|
||||
setTimeout(accept, nextConnWaitUntil - new Date().getTime())
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
export const getOperations = async ({
|
||||
planId,
|
||||
repoId,
|
||||
lastN,
|
||||
}: GetOperationsRequest): Promise<Operation[]> => {
|
||||
const opList = await ResticUI.GetOperations(
|
||||
{
|
||||
planId,
|
||||
repoId,
|
||||
lastN,
|
||||
},
|
||||
{
|
||||
pathPrefix: "/api",
|
||||
}
|
||||
);
|
||||
})();
|
||||
return opList.operations || [];
|
||||
};
|
||||
|
||||
export const subscribeToOperations = (
|
||||
callback: (event: OperationEvent) => void
|
||||
) => {
|
||||
subscribers.push(callback);
|
||||
};
|
||||
|
||||
export const unsubscribeFromOperations = (
|
||||
callback: (event: OperationEvent) => void
|
||||
) => {
|
||||
const index = subscribers.indexOf(callback);
|
||||
if (index > -1) {
|
||||
subscribers[index] = subscribers[subscribers.length - 1];
|
||||
subscribers.pop();
|
||||
}
|
||||
};
|
||||
|
||||
export const buildOperationListListener = (
|
||||
req: GetOperationsRequest,
|
||||
callback: (event: OperationEvent | null, list: Operation[]) => void
|
||||
) => {
|
||||
let operations: Operation[] = [];
|
||||
|
||||
(async () => {
|
||||
const opsFromServer = await getOperations(req);
|
||||
operations = opsFromServer.filter(
|
||||
(o) => !operations.find((op) => op.id === o.id)
|
||||
);
|
||||
operations.sort((a, b) => {
|
||||
return parseInt(a.id!) - parseInt(b.id!);
|
||||
});
|
||||
|
||||
callback(null, operations);
|
||||
})();
|
||||
|
||||
return (event: OperationEvent) => {
|
||||
const op = event.operation!;
|
||||
const type = event.type!;
|
||||
if (!!req.planId && op.planId !== req.planId) {
|
||||
return;
|
||||
}
|
||||
if (!!req.repoId && op.repoId !== req.repoId) {
|
||||
return;
|
||||
}
|
||||
if (type === OperationEventType.EVENT_UPDATED) {
|
||||
const index = operations.findIndex((o) => o.id === op.id);
|
||||
if (index > -1) {
|
||||
operations[index] = op;
|
||||
} else {
|
||||
operations.push(op);
|
||||
operations.sort((a, b) => {
|
||||
return parseInt(a.id!) - parseInt(b.id!);
|
||||
});
|
||||
}
|
||||
} else if (type === OperationEventType.EVENT_CREATED) {
|
||||
operations.push(op);
|
||||
}
|
||||
|
||||
callback(event, operations);
|
||||
};
|
||||
};
|
||||
|
||||
// OperationsStateTracker tracks the state of operations starting with an initial query
|
||||
export class OperationListSubscriber {
|
||||
private listener: ((event: OperationEvent) => void) | null = null;
|
||||
private operations: Operation[] = [];
|
||||
private eventEmitter = new EventEmitter();
|
||||
constructor(private req: GetOperationsRequest) {
|
||||
this.listener = (event: OperationEvent) => {
|
||||
this.eventEmitter.emit("changed");
|
||||
};
|
||||
subscribeToOperations(this.listener);
|
||||
getOperations(req).then((ops) => {
|
||||
this.operations = ops;
|
||||
this.eventEmitter.emit("changed");
|
||||
});
|
||||
}
|
||||
|
||||
getOperations() {
|
||||
return this.operations;
|
||||
}
|
||||
|
||||
onChange(callback: () => void) {
|
||||
this.eventEmitter.on("changed", callback);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
unsubscribeFromOperations(this.listener!);
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export const AddPlanModal = ({
|
||||
showModal(null);
|
||||
|
||||
alertsApi.success(
|
||||
"Plan deleted from config, but not from restic repo. Snapshots will remain in storage until manually deleted."
|
||||
"Plan deleted from config, but not from restic repo. Snapshots will remain in storage and operations will be tracked until manually deleted. Reusing a deleted plan ID is not recommended if backups have already been performed."
|
||||
);
|
||||
} catch (e: any) {
|
||||
alertsApi.error("Operation failed: " + e.message, 15);
|
||||
@@ -115,13 +115,14 @@ export const AddPlanModal = ({
|
||||
<Modal
|
||||
open={true}
|
||||
onCancel={handleCancel}
|
||||
title="Add Plan"
|
||||
title={template ? "Update Plan" : "Add Plan"}
|
||||
footer={[
|
||||
<Button loading={confirmLoading} key="back" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>,
|
||||
template != null ? (
|
||||
<Button
|
||||
key="delete"
|
||||
type="primary"
|
||||
danger
|
||||
loading={confirmLoading}
|
||||
@@ -168,7 +169,10 @@ export const AddPlanModal = ({
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder={"plan" + ((config?.plans?.length || 0) + 1)} />
|
||||
<Input
|
||||
placeholder={"plan" + ((config?.plans?.length || 0) + 1)}
|
||||
disabled={!!template}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Plan.repo */}
|
||||
@@ -189,6 +193,7 @@ export const AddPlanModal = ({
|
||||
options={repos.map((repo) => ({
|
||||
value: repo.id,
|
||||
}))}
|
||||
disabled={!!template}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ export const AddRepoModel = ({
|
||||
</Button>,
|
||||
template != null ? (
|
||||
<Button
|
||||
key="delete"
|
||||
type="primary"
|
||||
danger
|
||||
loading={confirmLoading}
|
||||
|
||||
@@ -6,16 +6,15 @@ import {
|
||||
CheckCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
import { Breadcrumb, Layout, Menu, Spin, message, theme } from "antd";
|
||||
import { Layout, Menu, Spin, theme } from "antd";
|
||||
import { configState, fetchConfig } from "../state/config";
|
||||
import { useRecoilState } from "recoil";
|
||||
import { Config } from "../../gen/ts/v1/config.pb";
|
||||
import { AlertContextProvider, useAlertApi } from "../components/Alerts";
|
||||
import { useAlertApi } from "../components/Alerts";
|
||||
import { useShowModal } from "../components/ModalManager";
|
||||
import { AddPlanModal } from "./AddPlanModel";
|
||||
import { AddRepoModel } from "./AddRepoModel";
|
||||
import { MainContentArea, useSetContent } from "../components/MainContentArea";
|
||||
import { GettingStartedGuide } from "../components/GettingStartedGuide";
|
||||
import { MainContentArea, useSetContent } from "./MainContentArea";
|
||||
import { PlanView } from "./PlanView";
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
@@ -36,12 +35,13 @@ export const App: React.FC = () => {
|
||||
fetchConfig()
|
||||
.then((config) => {
|
||||
setConfig(config);
|
||||
showModal(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
alertApi.error(err.message, 0);
|
||||
})
|
||||
.finally(() => {
|
||||
showModal(null);
|
||||
alertApi.error(
|
||||
"Failed to fetch initial config, typically this means the UI could not connect to the backend"
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ export const GettingStartedGuide = () => {
|
||||
<Divider orientation="left">Tips</Divider>
|
||||
<ul>
|
||||
<li>
|
||||
Backup your ResticUI configuration - your ResticUI config holds all of
|
||||
Backup your ResticUI configuration: your ResticUI config holds all of
|
||||
your repos, plans, and the passwords to decrypt them. When you have
|
||||
ResticUI configured to your liking make sure to store a copy of your
|
||||
config (or minimally a copy of your passwords) in a safe location e.g.
|
||||
@@ -8,12 +8,13 @@ import { useRecoilValue } from "recoil";
|
||||
import { configState } from "../state/config";
|
||||
import { useAlertApi } from "../components/Alerts";
|
||||
import { ResticUI } from "../../gen/ts/v1/service.pb";
|
||||
import { Operation } from "../../gen/ts/v1/operations.pb";
|
||||
import {
|
||||
Operation,
|
||||
OperationEvent,
|
||||
OperationEventType,
|
||||
} from "../../gen/ts/v1/operations.pb";
|
||||
import { operationEmitter } from "../state/oplog";
|
||||
buildOperationListListener,
|
||||
subscribeToOperations,
|
||||
unsubscribeFromOperations,
|
||||
} from "../state/oplog";
|
||||
import { OperationList } from "../components/OperationList";
|
||||
|
||||
export const PlanView = ({ plan }: React.PropsWithChildren<{ plan: Plan }>) => {
|
||||
const showModal = useShowModal();
|
||||
@@ -21,41 +22,16 @@ export const PlanView = ({ plan }: React.PropsWithChildren<{ plan: Plan }>) => {
|
||||
const [operations, setOperations] = useState<Operation[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const ops = await ResticUI.GetOperations(
|
||||
{ planId: plan.id },
|
||||
{ pathPrefix: "/api" }
|
||||
);
|
||||
if (!ops.operations) throw new Error("No operations returned");
|
||||
setOperations(ops.operations);
|
||||
} catch (e: any) {
|
||||
alertsApi.error("Failed to fetch operations: " + e.message);
|
||||
const listener = buildOperationListListener(
|
||||
{ planId: plan.id, lastN: "100" },
|
||||
(event, operations) => {
|
||||
setOperations([...operations]);
|
||||
}
|
||||
})();
|
||||
|
||||
const listener = (opEvent: OperationEvent) => {
|
||||
setOperations((operations) => {
|
||||
if (opEvent.type === OperationEventType.EVENT_CREATED) {
|
||||
operations.push(opEvent.operation!);
|
||||
} else if (opEvent.type === OperationEventType.EVENT_UPDATED) {
|
||||
// We iterate from the back since the most recent operations are at the end and
|
||||
// only recent ops receive updates.
|
||||
for (let i = operations.length - 1; i >= 0; i--) {
|
||||
if (operations[i].id === opEvent.operation?.id) {
|
||||
operations[i] = opEvent.operation!;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return operations;
|
||||
});
|
||||
};
|
||||
|
||||
operationEmitter.on("operation", listener);
|
||||
);
|
||||
subscribeToOperations(listener);
|
||||
|
||||
return () => {
|
||||
operationEmitter.removeListener("operation", listener);
|
||||
unsubscribeFromOperations(listener);
|
||||
};
|
||||
}, [plan.id]);
|
||||
|
||||
@@ -102,22 +78,8 @@ export const PlanView = ({ plan }: React.PropsWithChildren<{ plan: Plan }>) => {
|
||||
Prune Now
|
||||
</Button>
|
||||
</Flex>
|
||||
<OperationsPanel operations={operations} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const OperationsPanel = ({ operations }: { operations: Operation[] }) => {
|
||||
return (
|
||||
<>
|
||||
<h2>Operations List</h2>
|
||||
{operations.map((op) => {
|
||||
return (
|
||||
<div key={op.id}>
|
||||
<h3>{op.id}</h3>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<OperationList operations={operations} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user