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:
Nico 2026-02-12 22:27:30 +01:00 committed by GitHub
parent 7ebce1166b
commit ec4cf938bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 208 additions and 136 deletions

View file

@ -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:

View file

@ -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 };
}

View file

@ -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

View file

@ -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>

View file

@ -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;

View file

@ -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"}
>

View file

@ -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" />

View file

@ -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 }) })}>

View file

@ -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" />

View file

@ -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()

View file

@ -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();

View file

@ -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,

View file

@ -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,

View file

@ -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),
),
);
},
};