feat: show progress indicator on mirrors (#499)
* feat: show progress indicator on mirrors refactor: remove unnecessary useEffects * refactor: pr feedbacks
This commit is contained in:
parent
7ebce1166b
commit
ec4cf938bc
14 changed files with 208 additions and 136 deletions
|
|
@ -3311,7 +3311,7 @@ export type GetScheduleMirrorsResponses = {
|
|||
enabled: boolean;
|
||||
lastCopyAt: number | null;
|
||||
lastCopyError: string | null;
|
||||
lastCopyStatus: "error" | "success" | null;
|
||||
lastCopyStatus: "error" | "in_progress" | "success" | null;
|
||||
repository: {
|
||||
compressionMode: "auto" | "max" | "off" | null;
|
||||
config:
|
||||
|
|
@ -3531,7 +3531,7 @@ export type UpdateScheduleMirrorsResponses = {
|
|||
enabled: boolean;
|
||||
lastCopyAt: number | null;
|
||||
lastCopyError: string | null;
|
||||
lastCopyStatus: "error" | "success" | null;
|
||||
lastCopyStatus: "error" | "in_progress" | "success" | null;
|
||||
repository: {
|
||||
compressionMode: "auto" | "max" | "off" | null;
|
||||
config:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
BackupCompletedEventDto,
|
||||
|
|
@ -29,7 +29,7 @@ export interface MirrorEvent {
|
|||
scheduleId: number;
|
||||
repositoryId: string;
|
||||
repositoryName: string;
|
||||
status?: "success" | "error";
|
||||
status?: "success" | "error" | "in_progress";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
|
@ -205,7 +205,7 @@ export function useServerEvents() {
|
|||
};
|
||||
}, [queryClient]);
|
||||
|
||||
const addEventListener = (event: ServerEventType, handler: EventHandler) => {
|
||||
const addEventListener = useCallback((event: ServerEventType, handler: EventHandler) => {
|
||||
if (!handlersRef.current.has(event)) {
|
||||
handlersRef.current.set(event, new Set());
|
||||
}
|
||||
|
|
@ -214,7 +214,7 @@ export function useServerEvents() {
|
|||
return () => {
|
||||
handlersRef.current.get(event)?.delete(handler);
|
||||
};
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { addEventListener };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { parseError } from "~/client/lib/errors";
|
|||
import type { Repository } from "~/client/lib/types";
|
||||
import { RepositoryIcon } from "~/client/components/repository-icon";
|
||||
import { StatusDot } from "~/client/components/status-dot";
|
||||
import { useServerEvents } from "~/client/hooks/use-server-events";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import type { GetScheduleMirrorsResponse } from "~/client/api-client";
|
||||
|
|
@ -34,28 +35,31 @@ type MirrorAssignment = {
|
|||
repositoryId: string;
|
||||
enabled: boolean;
|
||||
lastCopyAt: number | null;
|
||||
lastCopyStatus: "success" | "error" | null;
|
||||
lastCopyStatus: "success" | "error" | "in_progress" | null;
|
||||
lastCopyError: string | null;
|
||||
};
|
||||
|
||||
const buildAssignments = (mirrors: GetScheduleMirrorsResponse) => {
|
||||
const map = new Map<string, MirrorAssignment>();
|
||||
for (const mirror of mirrors) {
|
||||
map.set(mirror.repositoryId, {
|
||||
repositoryId: mirror.repositoryId,
|
||||
enabled: mirror.enabled,
|
||||
lastCopyAt: mirror.lastCopyAt,
|
||||
lastCopyStatus: mirror.lastCopyStatus,
|
||||
lastCopyError: mirror.lastCopyError,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
};
|
||||
const isSyncing = (assignment: MirrorAssignment) => assignment.lastCopyStatus === "in_progress";
|
||||
|
||||
const buildAssignments = (mirrors: GetScheduleMirrorsResponse) =>
|
||||
new Map<string, MirrorAssignment>(
|
||||
mirrors.map((mirror) => [
|
||||
mirror.repositoryId,
|
||||
{
|
||||
repositoryId: mirror.repositoryId,
|
||||
enabled: mirror.enabled,
|
||||
lastCopyAt: mirror.lastCopyAt,
|
||||
lastCopyStatus: mirror.lastCopyStatus,
|
||||
lastCopyError: mirror.lastCopyError,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, repositories, initialData }: Props) => {
|
||||
const [assignments, setAssignments] = useState<Map<string, MirrorAssignment>>(() => buildAssignments(initialData));
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||
const { addEventListener } = useServerEvents();
|
||||
|
||||
const { data: currentMirrors } = useSuspenseQuery({
|
||||
...getScheduleMirrorsOptions({ path: { scheduleId: scheduleId.toString() } }),
|
||||
|
|
@ -94,6 +98,42 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit
|
|||
return map;
|
||||
}, [compatibility]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeStarted = addEventListener("mirror:started", (data) => {
|
||||
const event = data as { scheduleId: number; repositoryId: string };
|
||||
if (event.scheduleId !== scheduleId) return;
|
||||
setAssignments((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(event.repositoryId);
|
||||
if (!existing) return prev;
|
||||
next.set(event.repositoryId, { ...existing, lastCopyStatus: "in_progress", lastCopyError: null });
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
const unsubscribeCompleted = addEventListener("mirror:completed", (data) => {
|
||||
const event = data as { scheduleId: number; repositoryId: string; status?: "success" | "error"; error?: string };
|
||||
if (event.scheduleId !== scheduleId) return;
|
||||
setAssignments((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(event.repositoryId);
|
||||
if (!existing) return prev;
|
||||
next.set(event.repositoryId, {
|
||||
...existing,
|
||||
lastCopyStatus: event.status ?? existing.lastCopyStatus,
|
||||
lastCopyError: event.error ?? null,
|
||||
lastCopyAt: Date.now(),
|
||||
});
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeStarted();
|
||||
unsubscribeCompleted();
|
||||
};
|
||||
}, [addEventListener, scheduleId]);
|
||||
|
||||
const addRepository = (repositoryId: string) => {
|
||||
const newAssignments = new Map(assignments);
|
||||
newAssignments.set(repositoryId, {
|
||||
|
|
@ -144,20 +184,8 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit
|
|||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (currentMirrors) {
|
||||
const map = new Map<string, MirrorAssignment>();
|
||||
for (const mirror of currentMirrors) {
|
||||
map.set(mirror.repositoryId, {
|
||||
repositoryId: mirror.repositoryId,
|
||||
enabled: mirror.enabled,
|
||||
lastCopyAt: mirror.lastCopyAt,
|
||||
lastCopyStatus: mirror.lastCopyStatus,
|
||||
lastCopyError: mirror.lastCopyError,
|
||||
});
|
||||
}
|
||||
setAssignments(map);
|
||||
setHasChanges(false);
|
||||
}
|
||||
setAssignments(buildAssignments(currentMirrors));
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const selectableRepositories =
|
||||
|
|
@ -176,13 +204,27 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit
|
|||
.map((id) => repositories?.find((r) => r.id === id))
|
||||
.filter((r) => r !== undefined);
|
||||
|
||||
const getStatusVariant = (status: "success" | "error" | null) => {
|
||||
const getStatusVariant = (status: string | null) => {
|
||||
if (status === "success") return "success";
|
||||
if (status === "error") return "error";
|
||||
if (status === "in_progress") return "info";
|
||||
return "neutral";
|
||||
};
|
||||
|
||||
const getLabel = (assignment: MirrorAssignment) => {
|
||||
if (isSyncing(assignment)) {
|
||||
return "Syncing...";
|
||||
}
|
||||
if (assignment.lastCopyAt) {
|
||||
return formatDistanceToNow(new Date(assignment.lastCopyAt), { addSuffix: true });
|
||||
}
|
||||
return "Never";
|
||||
};
|
||||
|
||||
const getStatusLabel = (assignment: MirrorAssignment) => {
|
||||
if (isSyncing(assignment)) {
|
||||
return "Mirror sync in progress";
|
||||
}
|
||||
if (assignment.lastCopyStatus === "error" && assignment.lastCopyError) {
|
||||
return assignment.lastCopyError;
|
||||
}
|
||||
|
|
@ -310,19 +352,14 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit
|
|||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{assignment.lastCopyAt ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot
|
||||
variant={getStatusVariant(assignment.lastCopyStatus)}
|
||||
label={getStatusLabel(assignment)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(assignment.lastCopyAt), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Never</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot
|
||||
variant={getStatusVariant(assignment.lastCopyStatus)}
|
||||
label={getStatusLabel(assignment)}
|
||||
animated={isSyncing(assignment)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{getLabel(assignment)}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useEffect } from "react";
|
||||
import type { ListSnapshotsResponse } from "~/client/api-client";
|
||||
import { ByteSize } from "~/client/components/bytes-size";
|
||||
import { Card, CardContent } from "~/client/components/ui/card";
|
||||
|
|
@ -17,12 +16,6 @@ interface Props {
|
|||
export const SnapshotTimeline = (props: Props) => {
|
||||
const { snapshots, snapshotId, loading, onSnapshotSelect, error } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (!snapshotId && snapshots.length > 0) {
|
||||
onSnapshotSelect(snapshots[snapshots.length - 1].short_id);
|
||||
}
|
||||
}, [snapshotId, snapshots, onSnapshotSelect]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from "@dnd-kit/core";
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, rectSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CalendarClock, Plus } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent } from "~/client/components/ui/card";
|
||||
|
|
@ -27,12 +27,8 @@ export function BackupsPage() {
|
|||
...listBackupSchedulesOptions(),
|
||||
});
|
||||
|
||||
const [items, setItems] = useState(schedules?.map((s) => s.id) ?? []);
|
||||
useEffect(() => {
|
||||
if (schedules) {
|
||||
setItems(schedules.map((s) => s.id));
|
||||
}
|
||||
}, [schedules]);
|
||||
const [localItems, setLocalItems] = useState<number[] | null>(null);
|
||||
const items = localItems ?? schedules?.map((s) => s.id) ?? [];
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
|
|
@ -51,10 +47,28 @@ export function BackupsPage() {
|
|||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
setItems((items) => {
|
||||
const oldIndex = items.indexOf(active.id as number);
|
||||
const newIndex = items.indexOf(over.id as number);
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
setLocalItems((currentItems) => {
|
||||
const baseItems = currentItems ?? schedules?.map((s) => s.id) ?? [];
|
||||
const activeId = active.id as number;
|
||||
const overId = over.id as number;
|
||||
let oldIndex = baseItems.indexOf(activeId);
|
||||
let newIndex = baseItems.indexOf(overId);
|
||||
|
||||
if (oldIndex === -1 || newIndex === -1) {
|
||||
const freshItems = schedules?.map((s) => s.id) ?? [];
|
||||
oldIndex = freshItems.indexOf(activeId);
|
||||
newIndex = freshItems.indexOf(overId);
|
||||
|
||||
if (oldIndex === -1 || newIndex === -1) {
|
||||
return currentItems;
|
||||
}
|
||||
|
||||
const newItems = arrayMove(freshItems, oldIndex, newIndex);
|
||||
reorderMutation.mutate({ body: { scheduleIds: newItems } });
|
||||
return newItems;
|
||||
}
|
||||
|
||||
const newItems = arrayMove(baseItems, oldIndex, newIndex);
|
||||
reorderMutation.mutate({ body: { scheduleIds: newItems } });
|
||||
|
||||
return newItems;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { type } from "arktype";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { deepClean } from "~/utils/object";
|
||||
|
|
@ -119,15 +118,6 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
|
|||
const { watch } = form;
|
||||
const watchedType = watch("type");
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialValues) {
|
||||
form.reset({
|
||||
name: form.getValues().name || "",
|
||||
...defaultValuesForType[watchedType as keyof typeof defaultValuesForType],
|
||||
});
|
||||
}
|
||||
}, [watchedType, form, initialValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
|
||||
|
|
@ -138,12 +128,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
|
|||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="My notification"
|
||||
max={32}
|
||||
min={2}
|
||||
/>
|
||||
<Input {...field} placeholder="My notification" max={32} min={2} />
|
||||
</FormControl>
|
||||
<FormDescription>Unique identifier for this notification destination.</FormDescription>
|
||||
<FormMessage />
|
||||
|
|
@ -158,7 +143,15 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
|
|||
<FormItem>
|
||||
<FormLabel>Type</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (!initialValues) {
|
||||
form.reset({
|
||||
name: form.getValues().name || "",
|
||||
...defaultValuesForType[value as keyof typeof defaultValuesForType],
|
||||
});
|
||||
}
|
||||
}}
|
||||
value={field.value}
|
||||
disabled={mode === "update"}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { type } from "arktype";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Save } from "lucide-react";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
|
@ -88,15 +88,6 @@ export const CreateRepositoryForm = ({
|
|||
|
||||
const { capabilities } = useSystemInfo();
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
name: form.getValues().name,
|
||||
isExistingRepository: form.getValues().isExistingRepository,
|
||||
customPassword: form.getValues().customPassword,
|
||||
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
|
||||
});
|
||||
}, [watchedBackend, form]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
|
||||
|
|
@ -126,7 +117,18 @@ export const CreateRepositoryForm = ({
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Backend</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.reset({
|
||||
name: form.getValues().name,
|
||||
isExistingRepository: form.getValues().isExistingRepository,
|
||||
customPassword: form.getValues().customPassword,
|
||||
...defaultValuesForType[value as keyof typeof defaultValuesForType],
|
||||
});
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a backend" />
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { getRepositoryOptions, listSnapshotsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { Suspense } from "react";
|
||||
import { getRepositoryOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
|
||||
import { RepositoryInfoTabContent } from "../tabs/info";
|
||||
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
|
||||
export default function RepositoryDetailsPage({ repositoryId }: { repositoryId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { tab } = useSearch({ from: "/(dashboard)/repositories/$repositoryId" });
|
||||
const activeTab = tab || "info";
|
||||
|
|
@ -17,10 +15,6 @@ export default function RepositoryDetailsPage({ repositoryId }: { repositoryId:
|
|||
...getRepositoryOptions({ path: { id: repositoryId } }),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void queryClient.prefetchQuery(listSnapshotsOptions({ path: { id: data.id } }));
|
||||
}, [queryClient, data.id]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs value={activeTab} onValueChange={(value) => navigate({ to: ".", search: (s) => ({ ...s, tab: value }) })}>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { arktypeResolver } from "@hookform/resolvers/arktype";
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import { type } from "arktype";
|
||||
import { CheckCircle, Loader2, Plug, Save, XCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { deepClean } from "~/utils/object";
|
||||
|
|
@ -62,20 +62,11 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||
},
|
||||
});
|
||||
|
||||
const { watch, getValues } = form;
|
||||
const { getValues, watch } = form;
|
||||
|
||||
const { capabilities } = useSystemInfo();
|
||||
const watchedBackend = watch("backend");
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "create") {
|
||||
form.reset({
|
||||
name: form.getValues().name,
|
||||
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
|
||||
});
|
||||
}
|
||||
}, [watchedBackend, form, mode]);
|
||||
|
||||
const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
const testBackendConnection = useMutation({
|
||||
|
|
@ -119,12 +110,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Volume name"
|
||||
maxLength={32}
|
||||
minLength={2}
|
||||
/>
|
||||
<Input {...field} placeholder="Volume name" maxLength={32} minLength={2} />
|
||||
</FormControl>
|
||||
<FormDescription>Unique identifier for the volume.</FormDescription>
|
||||
<FormMessage />
|
||||
|
|
@ -138,7 +124,18 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Backend</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (mode === "create") {
|
||||
form.reset({
|
||||
name: form.getValues().name,
|
||||
...defaultValuesForType[value as keyof typeof defaultValuesForType],
|
||||
});
|
||||
}
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a backend" />
|
||||
|
|
|
|||
|
|
@ -346,7 +346,7 @@ export const backupScheduleMirrorsTable = sqliteTable(
|
|||
.references(() => repositoriesTable.id, { onDelete: "cascade" }),
|
||||
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
lastCopyAt: int("last_copy_at", { mode: "number" }),
|
||||
lastCopyStatus: text("last_copy_status").$type<"success" | "error">(),
|
||||
lastCopyStatus: text("last_copy_status").$type<"success" | "error" | "in_progress">(),
|
||||
lastCopyError: text("last_copy_error"),
|
||||
createdAt: int("created_at", { mode: "number" })
|
||||
.notNull()
|
||||
|
|
|
|||
|
|
@ -296,6 +296,37 @@ describe("mirror operations", () => {
|
|||
expect(updatedMirror?.lastCopyAt).not.toBeNull();
|
||||
});
|
||||
|
||||
test("should finalize mirror status when mirror settings are updated during copy", async () => {
|
||||
// arrange
|
||||
const volume = await createTestVolume();
|
||||
const sourceRepository = await createTestRepository();
|
||||
const mirrorRepository = await createTestRepository();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
volumeId: volume.id,
|
||||
repositoryId: sourceRepository.id,
|
||||
});
|
||||
|
||||
const originalMirror = await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
|
||||
|
||||
resticCopyMock.mockImplementationOnce(async () => {
|
||||
await backupsService.updateMirrors(schedule.id, {
|
||||
mirrors: [{ repositoryId: mirrorRepository.id, enabled: true }],
|
||||
});
|
||||
return { success: true, output: "" };
|
||||
});
|
||||
|
||||
// act
|
||||
await backupsExecutionService.copyToMirrors(schedule.id, sourceRepository, null);
|
||||
|
||||
// assert
|
||||
const mirrors = await backupsService.getMirrors(schedule.id);
|
||||
expect(mirrors).toHaveLength(1);
|
||||
expect(mirrors[0]?.id).not.toBe(originalMirror.id);
|
||||
expect(mirrors[0]?.lastCopyStatus).toBe("success");
|
||||
expect(mirrors[0]?.lastCopyError).toBeNull();
|
||||
expect(mirrors[0]?.lastCopyAt).not.toBeNull();
|
||||
});
|
||||
|
||||
test("should update mirror status on failure", async () => {
|
||||
// arrange
|
||||
const volume = await createTestVolume();
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ const scheduleMirrorSchema = type({
|
|||
repositoryId: "string",
|
||||
enabled: "boolean",
|
||||
lastCopyAt: "number | null",
|
||||
lastCopyStatus: "'success' | 'error' | null",
|
||||
lastCopyStatus: "'success' | 'error' | 'in_progress' | null",
|
||||
lastCopyError: "string | null",
|
||||
createdAt: "number",
|
||||
repository: repositorySchema,
|
||||
|
|
|
|||
|
|
@ -367,7 +367,6 @@ const copyToSingleMirror = async (
|
|||
schedule: BackupSchedule,
|
||||
sourceRepository: Repository,
|
||||
mirror: {
|
||||
id: number;
|
||||
repositoryId: string;
|
||||
repository: Repository;
|
||||
},
|
||||
|
|
@ -384,6 +383,11 @@ const copyToSingleMirror = async (
|
|||
repositoryName: mirror.repository.name,
|
||||
});
|
||||
|
||||
await mirrorQueries.updateStatus(scheduleId, mirror.repositoryId, {
|
||||
lastCopyStatus: "in_progress",
|
||||
lastCopyError: null,
|
||||
});
|
||||
|
||||
const releaseSource = await repoMutex.acquireShared(sourceRepository.id, `mirror_source:${scheduleId}`);
|
||||
const releaseMirror = await repoMutex.acquireShared(mirror.repository.id, `mirror:${scheduleId}`);
|
||||
|
||||
|
|
@ -403,7 +407,7 @@ const copyToSingleMirror = async (
|
|||
});
|
||||
}
|
||||
|
||||
await mirrorQueries.updateStatus(mirror.id, {
|
||||
await mirrorQueries.updateStatus(scheduleId, mirror.repositoryId, {
|
||||
lastCopyAt: Date.now(),
|
||||
lastCopyStatus: "success",
|
||||
lastCopyError: null,
|
||||
|
|
@ -422,7 +426,7 @@ const copyToSingleMirror = async (
|
|||
const errorMessage = toMessage(error);
|
||||
logger.error(`[Background] Failed to copy to mirror repository ${mirror.repository.name}: ${errorMessage}`);
|
||||
|
||||
await mirrorQueries.updateStatus(mirror.id, {
|
||||
await mirrorQueries.updateStatus(scheduleId, mirror.repositoryId, {
|
||||
lastCopyAt: Date.now(),
|
||||
lastCopyStatus: "error",
|
||||
lastCopyError: errorMessage,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@ import { db } from "../../db/db";
|
|||
import { backupSchedulesTable, backupScheduleMirrorsTable } from "../../db/schema";
|
||||
|
||||
export type BackupStatusType = "in_progress" | "success" | "warning" | "error";
|
||||
export type MirrorStatusType = "success" | "error";
|
||||
export type MirrorStatusType = "in_progress" | "success" | "error";
|
||||
|
||||
type MirrorStatusUpdate = {
|
||||
lastCopyAt?: number | null;
|
||||
lastCopyStatus?: MirrorStatusType;
|
||||
lastCopyError?: string | null;
|
||||
};
|
||||
|
||||
export const scheduleQueries = {
|
||||
findById: async (scheduleId: number, organizationId: string) => {
|
||||
|
|
@ -53,15 +59,16 @@ export const mirrorQueries = {
|
|||
});
|
||||
},
|
||||
|
||||
updateStatus: async (
|
||||
mirrorId: number,
|
||||
status: {
|
||||
lastCopyAt: number;
|
||||
lastCopyStatus: MirrorStatusType;
|
||||
lastCopyError: string | null;
|
||||
},
|
||||
) => {
|
||||
return db.update(backupScheduleMirrorsTable).set(status).where(eq(backupScheduleMirrorsTable.id, mirrorId));
|
||||
updateStatus: async (scheduleId: number, repositoryId: string, status: MirrorStatusUpdate) => {
|
||||
return db
|
||||
.update(backupScheduleMirrorsTable)
|
||||
.set(status)
|
||||
.where(
|
||||
and(
|
||||
eq(backupScheduleMirrorsTable.scheduleId, scheduleId),
|
||||
eq(backupScheduleMirrorsTable.repositoryId, repositoryId),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue