refactor: stylistic changes
This commit is contained in:
parent
da7e5a7b38
commit
42cecbe868
2 changed files with 78 additions and 35 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { Copy, Plus, RefreshCw, Trash2 } from "lucide-react";
|
import { Copy, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -22,7 +22,6 @@ import {
|
||||||
getScheduleMirrorsOptions,
|
getScheduleMirrorsOptions,
|
||||||
getMirrorCompatibilityOptions,
|
getMirrorCompatibilityOptions,
|
||||||
getMirrorSyncStatusOptions,
|
getMirrorSyncStatusOptions,
|
||||||
getMirrorSyncStatusQueryKey,
|
|
||||||
updateScheduleMirrorsMutation,
|
updateScheduleMirrorsMutation,
|
||||||
syncMirrorMutation,
|
syncMirrorMutation,
|
||||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
|
@ -32,10 +31,10 @@ import { RepositoryIcon } from "~/client/components/repository-icon";
|
||||||
import { StatusDot } from "~/client/components/status-dot";
|
import { StatusDot } from "~/client/components/status-dot";
|
||||||
import { ByteSize } from "~/client/components/bytes-size";
|
import { ByteSize } from "~/client/components/bytes-size";
|
||||||
import { useServerEvents } from "~/client/hooks/use-server-events";
|
import { useServerEvents } from "~/client/hooks/use-server-events";
|
||||||
import { format, formatDistanceToNow } from "date-fns";
|
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import type { GetScheduleMirrorsResponse } from "~/client/api-client";
|
import type { GetScheduleMirrorsResponse } from "~/client/api-client";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
scheduleShortId: string;
|
scheduleShortId: string;
|
||||||
|
|
@ -69,14 +68,29 @@ const buildAssignments = (mirrors: GetScheduleMirrorsResponse) =>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, repositories, initialData }: Props) => {
|
export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, repositories, initialData }: Props) => {
|
||||||
const queryClient = useQueryClient();
|
const { formatDateTime, formatTimeAgo } = useTimeFormat();
|
||||||
const [assignments, setAssignments] = useState<Map<string, MirrorAssignment>>(() => buildAssignments(initialData));
|
const [assignments, setAssignments] = useState<Map<string, MirrorAssignment>>(() => buildAssignments(initialData));
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||||
const [syncDialogMirrorId, setSyncDialogMirrorId] = useState<string | null>(null);
|
const [syncDialogMirrorId, setSyncDialogMirrorId] = useState<string | null>(null);
|
||||||
const [selectedSnapshotIds, setSelectedSnapshotIds] = useState<Set<string>>(new Set());
|
const [selectedSnapshotIds, setSelectedSnapshotIds] = useState<Set<string>>(new Set());
|
||||||
|
const [syncDialogOpen, setSyncDialogOpen] = useState(false);
|
||||||
const { addEventListener } = useServerEvents();
|
const { addEventListener } = useServerEvents();
|
||||||
|
|
||||||
|
const closeSyncDialog = () => {
|
||||||
|
setSyncDialogOpen(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setSyncDialogMirrorId(null);
|
||||||
|
setSelectedSnapshotIds(new Set());
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSyncDialog = (mirrorShortId: string) => {
|
||||||
|
setSyncDialogOpen(true);
|
||||||
|
setSelectedSnapshotIds(new Set());
|
||||||
|
setSyncDialogMirrorId(mirrorShortId);
|
||||||
|
};
|
||||||
|
|
||||||
const { data: currentMirrors } = useSuspenseQuery({
|
const { data: currentMirrors } = useSuspenseQuery({
|
||||||
...getScheduleMirrorsOptions({ path: { shortId: scheduleShortId } }),
|
...getScheduleMirrorsOptions({ path: { shortId: scheduleShortId } }),
|
||||||
});
|
});
|
||||||
|
|
@ -106,17 +120,11 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
|
||||||
|
|
||||||
const { data: syncStatus, isLoading: isSyncStatusLoading } = useQuery({
|
const { data: syncStatus, isLoading: isSyncStatusLoading } = useQuery({
|
||||||
...getMirrorSyncStatusOptions({
|
...getMirrorSyncStatusOptions({
|
||||||
path: { shortId: scheduleShortId, mirrorShortId: syncDialogMirrorId! },
|
path: { shortId: scheduleShortId, mirrorShortId: syncDialogMirrorId ?? "" },
|
||||||
}),
|
}),
|
||||||
enabled: syncDialogMirrorId !== null,
|
enabled: syncDialogMirrorId !== null,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (syncStatus?.missingSnapshots) {
|
|
||||||
setSelectedSnapshotIds(new Set(syncStatus.missingSnapshots.map((s) => s.short_id)));
|
|
||||||
}
|
|
||||||
}, [syncStatus]);
|
|
||||||
|
|
||||||
const toggleSnapshotSelection = (shortId: string) => {
|
const toggleSnapshotSelection = (shortId: string) => {
|
||||||
setSelectedSnapshotIds((prev) => {
|
setSelectedSnapshotIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
|
|
@ -142,7 +150,7 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
|
||||||
...syncMirrorMutation(),
|
...syncMirrorMutation(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Full sync started");
|
toast.success("Full sync started");
|
||||||
setSyncDialogMirrorId(null);
|
closeSyncDialog();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Failed to start sync", {
|
toast.error("Failed to start sync", {
|
||||||
|
|
@ -195,11 +203,6 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
queryClient.removeQueries({
|
|
||||||
queryKey: getMirrorSyncStatusQueryKey({
|
|
||||||
path: { shortId: scheduleShortId, mirrorShortId: event.repositoryId },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
{ signal: abortController.signal },
|
{ signal: abortController.signal },
|
||||||
);
|
);
|
||||||
|
|
@ -289,7 +292,7 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
|
||||||
return "Syncing...";
|
return "Syncing...";
|
||||||
}
|
}
|
||||||
if (assignment.lastCopyAt) {
|
if (assignment.lastCopyAt) {
|
||||||
return formatDistanceToNow(new Date(assignment.lastCopyAt), { addSuffix: true });
|
return formatTimeAgo(assignment.lastCopyAt);
|
||||||
}
|
}
|
||||||
return "Never";
|
return "Never";
|
||||||
};
|
};
|
||||||
|
|
@ -307,6 +310,8 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
|
||||||
return "Never copied";
|
return "Never copied";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedMirrorRepo = repositories.find((r) => r.shortId === syncDialogMirrorId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -443,14 +448,14 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setSyncDialogMirrorId(repository.shortId)}
|
onClick={() => openSyncDialog(repository.shortId)}
|
||||||
disabled={isSyncing(assignment) || hasChanges}
|
disabled={isSyncing(assignment) || hasChanges}
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn("h-4 w-4", isSyncing(assignment) && "animate-spin")} />
|
<RefreshCw className={cn("h-4 w-4", isSyncing(assignment) && "animate-spin")} />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Sync all snapshots</TooltipContent>
|
<TooltipContent>Sync more snapshots</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -481,22 +486,16 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={syncDialogMirrorId !== null} onOpenChange={(open) => !open && setSyncDialogMirrorId(null)}>
|
<Dialog open={syncDialogOpen} onOpenChange={(open) => !open && closeSyncDialog()}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Sync snapshots</DialogTitle>
|
<DialogTitle>Sync snapshots</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{syncDialogMirrorId &&
|
{`Sync missing snapshots to ${selectedMirrorRepo?.name || "mirror repository"}.`}
|
||||||
(() => {
|
|
||||||
const mirrorRepo = repositories.find((r) => r.shortId === syncDialogMirrorId);
|
|
||||||
return mirrorRepo
|
|
||||||
? `Sync all missing snapshots to "${mirrorRepo.name}"`
|
|
||||||
: "Sync all missing snapshots to mirror";
|
|
||||||
})()}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{isSyncStatusLoading ? (
|
{isSyncStatusLoading && !syncStatus ? (
|
||||||
<div className="py-6 text-center text-muted-foreground text-sm">Loading snapshot status...</div>
|
<div className="py-6 text-center text-muted-foreground text-sm">Loading snapshot status...</div>
|
||||||
) : syncStatus && syncStatus.missingSnapshots.length === 0 ? (
|
) : syncStatus && syncStatus.missingSnapshots.length === 0 ? (
|
||||||
<div className="py-6 text-center text-muted-foreground text-sm">
|
<div className="py-6 text-center text-muted-foreground text-sm">
|
||||||
|
|
@ -536,9 +535,7 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-xs">{snapshot.short_id}</TableCell>
|
<TableCell className="font-mono text-xs">{snapshot.short_id}</TableCell>
|
||||||
<TableCell className="text-sm">
|
<TableCell className="text-sm">{formatDateTime(new Date(snapshot.time))}</TableCell>
|
||||||
{format(new Date(snapshot.time), "yyyy-MM-dd HH:mm")}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-sm">
|
<TableCell className="text-right text-sm">
|
||||||
<ByteSize bytes={snapshot.size} base={1024} />
|
<ByteSize bytes={snapshot.size} base={1024} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -551,7 +548,7 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setSyncDialogMirrorId(null)}>
|
<Button variant="outline" onClick={closeSyncDialog}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import waitForExpect from "wait-for-expect";
|
||||||
import { backupsService } from "../backups.service";
|
import { backupsService } from "../backups.service";
|
||||||
import { createTestVolume } from "~/test/helpers/volume";
|
import { createTestVolume } from "~/test/helpers/volume";
|
||||||
import { createTestBackupSchedule } from "~/test/helpers/backup";
|
import { createTestBackupSchedule } from "~/test/helpers/backup";
|
||||||
|
|
@ -148,8 +149,7 @@ describe("getMirrorSyncStatus", () => {
|
||||||
|
|
||||||
describe("syncMirror", () => {
|
describe("syncMirror", () => {
|
||||||
test("should trigger sync and return success", async () => {
|
test("should trigger sync and return success", async () => {
|
||||||
const { mockCopy } = setup();
|
setup();
|
||||||
const copyMock = mockCopy();
|
|
||||||
const volume = await createTestVolume();
|
const volume = await createTestVolume();
|
||||||
const repository = await createTestRepository();
|
const repository = await createTestRepository();
|
||||||
const mirrorRepository = await createTestRepository();
|
const mirrorRepository = await createTestRepository();
|
||||||
|
|
@ -185,6 +185,52 @@ describe("syncMirror", () => {
|
||||||
).rejects.toThrow("Mirror is already syncing");
|
).rejects.toThrow("Mirror is already syncing");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should reject concurrent sync requests once a sync has started", async () => {
|
||||||
|
const { mockCopy } = setup();
|
||||||
|
const copyMock = mockCopy();
|
||||||
|
let releaseCopy: (() => void) | undefined;
|
||||||
|
const copyStarted = new Promise<void>((resolve) => {
|
||||||
|
copyMock.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((copyResolve) => {
|
||||||
|
releaseCopy = () => copyResolve({ success: true, output: "" });
|
||||||
|
resolve();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const volume = await createTestVolume();
|
||||||
|
const repository = await createTestRepository();
|
||||||
|
const mirrorRepository = await createTestRepository();
|
||||||
|
const schedule = await createTestBackupSchedule({
|
||||||
|
volumeId: volume.id,
|
||||||
|
repositoryId: repository.id,
|
||||||
|
});
|
||||||
|
await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
backupsService.syncMirror(schedule.shortId, mirrorRepository.shortId as ShortId, ["snap1"]),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
|
||||||
|
await copyStarted;
|
||||||
|
|
||||||
|
await waitForExpect(async () => {
|
||||||
|
const mirrors = await backupsService.getMirrors(schedule.shortId);
|
||||||
|
expect(mirrors[0]?.lastCopyStatus).toBe("in_progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
backupsService.syncMirror(schedule.shortId, mirrorRepository.shortId as ShortId, ["snap1"]),
|
||||||
|
).rejects.toThrow("Mirror is already syncing");
|
||||||
|
|
||||||
|
releaseCopy?.();
|
||||||
|
|
||||||
|
await waitForExpect(async () => {
|
||||||
|
const mirrors = await backupsService.getMirrors(schedule.shortId);
|
||||||
|
expect(mirrors[0]?.lastCopyStatus).toBe("success");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("should throw if mirror is not configured for the schedule", async () => {
|
test("should throw if mirror is not configured for the schedule", async () => {
|
||||||
setup();
|
setup();
|
||||||
const volume = await createTestVolume();
|
const volume = await createTestVolume();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue