mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-05-04 12:00:36 +00:00
fix some more bugs and refine the sidenav
This commit is contained in:
@@ -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},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user