fix some more bugs and refine the sidenav

This commit is contained in:
Gareth George
2026-04-20 16:47:14 -07:00
parent 94cc50e51c
commit 53bfbeec94
5 changed files with 249 additions and 41 deletions
+1 -1
View File
@@ -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},
},
},
},
+153
View File
@@ -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
}
+48 -37
View File
@@ -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 (
<Box
@@ -563,10 +568,11 @@ const SidebarContent = ({ onClose }: { onClose?: () => void }) => {
</Text>
</Flex>
{/* 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 (
<Flex
key={repo.guid}
@@ -591,31 +597,34 @@ const SidebarContent = ({ onClose }: { onClose?: () => void }) => {
<Text fontSize="sm" flex="1" wordBreak="break-word">
{repo.id}
</Text>
<Box
opacity={0}
_groupHover={{ opacity: 1 }}
transition="opacity 0.2s"
>
<IconButton
size="xs"
variant="ghost"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
handleRemoteRepoEdit(repo);
}}
{editableRepo && (
<Box
opacity={0}
_groupHover={{ opacity: 1 }}
transition="opacity 0.2s"
>
<FiEdit2 />
</IconButton>
</Box>
<IconButton
size="xs"
variant="ghost"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
handleRemoteRepoEdit(editableRepo);
}}
>
<FiEdit2 />
</IconButton>
</Box>
)}
</Flex>
);
})}
{/* 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 (
<Flex
key={plan.id}
key={planMeta.id}
align="center"
pl={12}
pr={2}
@@ -627,24 +636,26 @@ const SidebarContent = ({ onClose }: { onClose?: () => void }) => {
<FiCalendar />
</Box>
<Text fontSize="sm" flex="1" wordBreak="break-word">
{plan.id}
{planMeta.id}
</Text>
<Box
opacity={0}
_groupHover={{ opacity: 1 }}
transition="opacity 0.2s"
>
<IconButton
size="xs"
variant="ghost"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
handleRemotePlanEdit(plan);
}}
{editablePlan && (
<Box
opacity={0}
_groupHover={{ opacity: 1 }}
transition="opacity 0.2s"
>
<FiEdit2 />
</IconButton>
</Box>
<IconButton
size="xs"
variant="ghost"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
handleRemotePlanEdit(editablePlan);
}}
>
<FiEdit2 />
</IconButton>
</Box>
)}
</Flex>
);
})}
@@ -449,6 +449,27 @@ const PeerStateTile = ({ peerState }: { peerState: PeerState }) => {
/>
}
/>
{peerState.knownRepos.length > 0 && (
<DataListItem
label="Shared Repos"
value={
<Flex gap={1} flexWrap="wrap">
{peerState.knownRepos.map((repo) => (
<Box
key={repo.id}
px={2}
py={0.5}
bg="bg.muted"
borderRadius="sm"
fontSize="xs"
>
{repo.id}
</Box>
))}
</Flex>
}
/>
)}
</DataListRoot>
</Card.Body>
</Card.Root>
+26 -3
View File
@@ -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
</SelectTrigger>
{/* @ts-ignore */}
<SelectContent>
{repoOptions.items.map((item: any) => (
{localRepos.length > 0 && remoteRepos.length > 0 && (
<CText fontSize="xs" fontWeight="bold" color="fg.muted" px={2} py={1}>
Local
</CText>
)}
{repoOptions.items.slice(0, localRepos.length).map((item: any) => (
<SelectItem item={item} key={item.value}>
{item.label}
</SelectItem>
))}
{remoteRepos.length > 0 && (
<CText fontSize="xs" fontWeight="bold" color="fg.muted" px={2} py={1} mt={1} borderTopWidth="1px" borderColor="border">
Remote
</CText>
)}
{repoOptions.items.slice(localRepos.length).map((item: any) => (
<SelectItem item={item} key={item.value}>
{item.label}
</SelectItem>