From 53bfbeec948983ba9950f0d5df671a84dfecf562 Mon Sep 17 00:00:00 2001 From: Gareth George Date: Mon, 20 Apr 2026 16:47:14 -0700 Subject: [PATCH] fix some more bugs and refine the sidenav --- internal/api/syncapi/syncclient.go | 2 +- internal/config/validate_test.go | 153 ++++++++++++++++++ webui/src/app/App.tsx | 85 +++++----- .../features/dashboard/SummaryDashboard.tsx | 21 +++ webui/src/features/plans/AddPlanModal.tsx | 29 +++- 5 files changed, 249 insertions(+), 41 deletions(-) create mode 100644 internal/config/validate_test.go diff --git a/internal/api/syncapi/syncclient.go b/internal/api/syncapi/syncclient.go index 72fe012f..76374c32 100644 --- a/internal/api/syncapi/syncclient.go +++ b/internal/api/syncapi/syncclient.go @@ -386,7 +386,7 @@ func (c *syncSessionHandlerClient) HandleRequestOperations(ctx context.Context, ReceiveOperations: &v1sync.SyncStreamItem_SyncActionReceiveOperations{ Event: &v1.OperationEvent{ Event: &v1.OperationEvent_CreatedOperations{ - CreatedOperations: &v1.OperationList{Operations: batch}, + CreatedOperations: &v1.OperationList{Operations: newOps}, }, }, }, diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go new file mode 100644 index 00000000..0ad28b9a --- /dev/null +++ b/internal/config/validate_test.go @@ -0,0 +1,153 @@ +package config + +import ( + "testing" + + v1 "github.com/garethgeorge/backrest/gen/go/v1" +) + +func TestCleanupOrphanedRemoteReposAndPlans(t *testing.T) { + tests := []struct { + name string + config *v1.Config + wantRepoIDs []string + wantPlanIDs []string + }{ + { + name: "no remote repos, nothing removed", + config: &v1.Config{ + Repos: []*v1.Repo{ + {Id: "local-repo", Uri: "file:///tmp/repo", Guid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + }, + Plans: []*v1.Plan{ + {Id: "plan1", Repo: "local-repo", Paths: []string{"/data"}}, + }, + }, + wantRepoIDs: []string{"local-repo"}, + wantPlanIDs: []string{"plan1"}, + }, + { + name: "remote repo with valid peer is kept", + config: &v1.Config{ + Repos: []*v1.Repo{ + {Id: "local-repo", Uri: "file:///tmp/repo", Guid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + {Id: "remote-repo", Uri: "file:///tmp/remote", Guid: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", OriginInstanceId: "server-a"}, + }, + Plans: []*v1.Plan{ + {Id: "plan1", Repo: "local-repo", Paths: []string{"/data"}}, + {Id: "plan2", Repo: "remote-repo", Paths: []string{"/data"}}, + }, + Multihost: &v1.Multihost{ + KnownHosts: []*v1.Multihost_Peer{ + {InstanceId: "server-a", Keyid: "key-a", InstanceUrl: "http://server-a:9898"}, + }, + }, + }, + wantRepoIDs: []string{"local-repo", "remote-repo"}, + wantPlanIDs: []string{"plan1", "plan2"}, + }, + { + name: "remote repo orphaned when peer removed", + config: &v1.Config{ + Repos: []*v1.Repo{ + {Id: "local-repo", Uri: "file:///tmp/repo", Guid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + {Id: "remote-repo", Uri: "file:///tmp/remote", Guid: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", OriginInstanceId: "server-a"}, + }, + Plans: []*v1.Plan{ + {Id: "plan1", Repo: "local-repo", Paths: []string{"/data"}}, + {Id: "plan2", Repo: "remote-repo", Paths: []string{"/data"}}, + }, + Multihost: &v1.Multihost{}, + }, + wantRepoIDs: []string{"local-repo"}, + wantPlanIDs: []string{"plan1"}, + }, + { + name: "authorized client peer keeps remote repo", + config: &v1.Config{ + Repos: []*v1.Repo{ + {Id: "remote-repo", Uri: "file:///tmp/remote", Guid: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", OriginInstanceId: "client-b"}, + }, + Plans: []*v1.Plan{ + {Id: "plan1", Repo: "remote-repo", Paths: []string{"/data"}}, + }, + Multihost: &v1.Multihost{ + AuthorizedClients: []*v1.Multihost_Peer{ + {InstanceId: "client-b", Keyid: "key-b"}, + }, + }, + }, + wantRepoIDs: []string{"remote-repo"}, + wantPlanIDs: []string{"plan1"}, + }, + { + name: "multiple orphaned repos and plans cleaned up", + config: &v1.Config{ + Repos: []*v1.Repo{ + {Id: "local", Uri: "file:///tmp/repo", Guid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + {Id: "remote-a", Uri: "file:///tmp/a", Guid: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", OriginInstanceId: "gone-server"}, + {Id: "remote-b", Uri: "file:///tmp/b", Guid: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", OriginInstanceId: "gone-server"}, + }, + Plans: []*v1.Plan{ + {Id: "local-plan", Repo: "local", Paths: []string{"/data"}}, + {Id: "plan-a", Repo: "remote-a", Paths: []string{"/data"}}, + {Id: "plan-b", Repo: "remote-b", Paths: []string{"/data"}}, + }, + Multihost: &v1.Multihost{}, + }, + wantRepoIDs: []string{"local"}, + wantPlanIDs: []string{"local-plan"}, + }, + { + name: "plan referencing local repo not removed even if remote repos cleaned", + config: &v1.Config{ + Repos: []*v1.Repo{ + {Id: "local", Uri: "file:///tmp/repo", Guid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + {Id: "remote", Uri: "file:///tmp/remote", Guid: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", OriginInstanceId: "gone"}, + }, + Plans: []*v1.Plan{ + {Id: "kept-plan", Repo: "local", Paths: []string{"/data"}}, + {Id: "removed-plan", Repo: "remote", Paths: []string{"/data"}}, + }, + Multihost: &v1.Multihost{}, + }, + wantRepoIDs: []string{"local"}, + wantPlanIDs: []string{"kept-plan"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cleanupOrphanedRemoteReposAndPlans(tc.config) + + gotRepoIDs := make([]string, len(tc.config.Repos)) + for i, r := range tc.config.Repos { + gotRepoIDs[i] = r.Id + } + + gotPlanIDs := make([]string, len(tc.config.Plans)) + for i, p := range tc.config.Plans { + gotPlanIDs[i] = p.Id + } + + if !sliceEqual(gotRepoIDs, tc.wantRepoIDs) { + t.Errorf("repos = %v, want %v", gotRepoIDs, tc.wantRepoIDs) + } + if !sliceEqual(gotPlanIDs, tc.wantPlanIDs) { + t.Errorf("plans = %v, want %v", gotPlanIDs, tc.wantPlanIDs) + } + }) + } +} + +func sliceEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/webui/src/app/App.tsx b/webui/src/app/App.tsx index 6dc2c5ee..db131a94 100644 --- a/webui/src/app/App.tsx +++ b/webui/src/app/App.tsx @@ -372,7 +372,12 @@ const SidebarContent = ({ onClose }: { onClose?: () => void }) => { if (!config) return null; const configPlans = config.plans || []; - const configRepos = config.repos || []; + const localRepos = (config.repos || []).filter( + (r) => !r.originInstanceId, + ); + const remoteRepos = (config.repos || []).filter( + (r) => !!r.originInstanceId, + ); return ( void }) => { - {/* Nested Repos for Peer */} - {(remoteConfig?.repos || []).map((repo) => { + {/* Nested Repos for Peer — listed from knownRepos (READ_OPERATIONS), edit from remoteConfig (READ_CONFIG) */} + {peerState.knownRepos.map((repo: RepoMetadata) => { const repoPath = `/peer/${peerState.peerInstanceId}/repo/${repo.id}`; const active = isActive(repoPath); + const editableRepo = remoteConfig?.repos?.find((r: Repo) => r.guid === repo.guid); return ( void }) => { {repo.id} - - { - e.stopPropagation(); - handleRemoteRepoEdit(repo); - }} + {editableRepo && ( + - - - + { + e.stopPropagation(); + handleRemoteRepoEdit(editableRepo); + }} + > + + + + )} ); })} - {/* Nested Plans for Peer */} - {(remoteConfig?.plans || []).map((plan) => { + {/* Nested Plans for Peer — listed from knownPlans, edit from remoteConfig */} + {peerState.knownPlans.map((planMeta: PlanMetadata) => { + const editablePlan = remoteConfig?.plans?.find((p: Plan) => p.id === planMeta.id); return ( void }) => { - {plan.id} + {planMeta.id} - - { - e.stopPropagation(); - handleRemotePlanEdit(plan); - }} + {editablePlan && ( + - - - + { + e.stopPropagation(); + handleRemotePlanEdit(editablePlan); + }} + > + + + + )} ); })} diff --git a/webui/src/features/dashboard/SummaryDashboard.tsx b/webui/src/features/dashboard/SummaryDashboard.tsx index 5908e343..4b5acd8a 100644 --- a/webui/src/features/dashboard/SummaryDashboard.tsx +++ b/webui/src/features/dashboard/SummaryDashboard.tsx @@ -449,6 +449,27 @@ const PeerStateTile = ({ peerState }: { peerState: PeerState }) => { /> } /> + {peerState.knownRepos.length > 0 && ( + + {peerState.knownRepos.map((repo) => ( + + {repo.id} + + ))} + + } + /> + )} diff --git a/webui/src/features/plans/AddPlanModal.tsx b/webui/src/features/plans/AddPlanModal.tsx index 297d2273..65a15f73 100644 --- a/webui/src/features/plans/AddPlanModal.tsx +++ b/webui/src/features/plans/AddPlanModal.tsx @@ -229,9 +229,17 @@ export const AddPlanModal = ({ template, onSaveOverride }: { template: Plan | nu } }; - const repos = config?.repos || []; + const allRepos = config?.repos || []; + const localRepos = allRepos.filter((r) => !r.originInstanceId); + const remoteRepos = allRepos.filter((r) => !!r.originInstanceId); const repoOptions = createListCollection({ - items: repos.map((r) => ({ label: r.id, value: r.id })), + items: [ + ...localRepos.map((r) => ({ label: r.id, value: r.id })), + ...remoteRepos.map((r) => ({ + label: `${r.id} (from ${r.originInstanceId})`, + value: r.id, + })), + ], }); const sections: SectionDef[] = [ @@ -334,7 +342,22 @@ export const AddPlanModal = ({ template, onSaveOverride }: { template: Plan | nu {/* @ts-ignore */} - {repoOptions.items.map((item: any) => ( + {localRepos.length > 0 && remoteRepos.length > 0 && ( + + Local + + )} + {repoOptions.items.slice(0, localRepos.length).map((item: any) => ( + + {item.label} + + ))} + {remoteRepos.length > 0 && ( + + Remote + + )} + {repoOptions.items.slice(localRepos.length).map((item: any) => ( {item.label}