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}