feat: present list of operations on plan view

This commit is contained in:
garethgeorge
2023-11-15 21:37:22 -08:00
parent 94eff94be1
commit 985c0f7a20
14 changed files with 474 additions and 121 deletions
+6 -4
View File
@@ -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
+2 -2
View File
@@ -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)
}
+6
View File
@@ -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"))
}
+44 -4
View File
@@ -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 {
+21 -4
View File
@@ -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)
}
+233
View File
@@ -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
View File
@@ -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!);
}
}
+8 -3
View File
@@ -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>
+1
View File
@@ -142,6 +142,7 @@ export const AddRepoModel = ({
</Button>,
template != null ? (
<Button
key="delete"
type="primary"
danger
loading={confirmLoading}
+7 -7
View File
@@ -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"
);
});
}, []);
@@ -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.
+14 -52
View File
@@ -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} />
</>
);
};