-
Notifications
You must be signed in to change notification settings - Fork 142
feat: show progress indicator on mirrors #499
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
refactor: remove unnecessary useEffects
WalkthroughThis PR adds "in_progress" status support for mirror synchronization operations. It expands status types across server and client layers, implements real-time server event handling in the UI to reflect ongoing mirror operations, and refactors form reset logic from useEffect dependencies to event handlers. Changes
Possibly related PRs
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/client/modules/backups/components/schedule-mirrors-config.tsx (1)
68-72:⚠️ Potential issue | 🟡 MinorPotential race: React Query refetch may overwrite SSE-driven
in_progressstate.When the SSE sets
lastCopyStatusto"in_progress", a subsequent React Query refetch ofcurrentMirrors(e.g., on window focus) could overwrite the assignment map with stale server data that doesn't yet reflectin_progress, causing the progress indicator to briefly disappear.One approach would be to merge rather than replace — e.g., skip overwriting entries currently marked
in_progressunless the server data also showsin_progressor a terminal status. That said, this is a timing edge case and unlikely to be disruptive in practice.
🤖 Fix all issues with AI agents
In `@app/client/modules/backups/components/schedule-mirrors-config.tsx`:
- Around line 355-360: The StatusDot is currently left to its internal default
when not syncing which causes it to animate for terminal variants; change the
prop so animation only occurs while syncing by explicitly passing a boolean:
compute animated via isSyncing(assignment) (e.g.,
animated={isSyncing(assignment)}) or check both syncing and variant from
getStatusVariant(assignment.lastCopyStatus) so that success/error terminal
variants get animated=false and only the syncing state sets animated=true;
update the StatusDot usage in schedule-mirrors-config.tsx accordingly.
- Around line 101-135: The SSE subscription is reinitialized because
addEventListener (from useServerEvents) is not memoized and changes each render;
update the useServerEvents hook to wrap and return addEventListener with
useCallback so it has a stable identity, ensure any internal dependencies are
listed in that useCallback dependency array, and then keep the current useEffect
in schedule-mirrors-config.tsx using addEventListener and scheduleId as
dependencies so subscriptions only change when truly needed.
In `@app/client/modules/backups/routes/backups.tsx`:
- Around line 50-54: The drag-reorder can pass -1 to arrayMove when active.id or
over.id aren’t found in baseItems; inside the setLocalItems updater (symbols:
setLocalItems, baseItems, arrayMove, active.id, over.id) check for missing IDs
and avoid calling arrayMove with -1: compute oldIndex/newIndex, and if either is
-1 then either rebuild baseItems from schedules?.map(s=>s.id) and recompute
indices, or if still missing simply return currentItems to noop the update;
ensure you use the same updater return type so no other state changes occur.
🧹 Nitpick comments (5)
app/client/modules/volumes/components/create-volume-form.tsx (1)
127-138: Good refactor: moving form reset fromuseEffectto the event handler.This is a cleaner pattern that avoids unnecessary render cycles and makes the reset logic explicit and traceable.
One minor note: in create mode,
field.onChange(value)on Line 129 is immediately overwritten byform.reset(...)on Line 131. It's harmless but redundant for that branch. Consider moving it into anelse:Optional: avoid redundant field.onChange in create mode
onValueChange={(value) => { - field.onChange(value); if (mode === "create") { form.reset({ name: form.getValues().name, ...defaultValuesForType[value as keyof typeof defaultValuesForType], }); + } else { + field.onChange(value); } }}app/client/modules/repositories/components/create-repository-form.tsx (1)
120-131: Nit:field.onChange(value)on Line 122 is redundant.
form.reset(...)on Line 123 already sets thebackendfield via thedefaultValuesForTypespread, so the explicitfield.onChange(value)call is overwritten immediately. Removing it would reduce confusion about which call actually takes effect.Suggested diff
<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} >app/client/modules/backups/routes/backups.tsx (1)
30-31:localItemsis never reset, causing stale UI after server-side data changes.Once the user drags (setting
localItems), the deriveditemslist will always uselocalItemsand never reflect server-side changes (e.g., newly created or deleted schedules) until the component remounts. Consider resettinglocalItemstonullwhenschedulesdata changes, so the component falls back to the server-derived order.Suggested approach
One option is to use a key or reset mechanism. A simple approach:
+import { useState, useEffect } from "react"; ... const [localItems, setLocalItems] = useState<number[] | null>(null); const items = localItems ?? schedules?.map((s) => s.id) ?? []; +// Reset local drag state when server data changes +useEffect(() => { + setLocalItems(null); +}, [schedules]);Alternatively, you could key the component on a derived value from
schedules(e.g., length or a hash of IDs) to force a remount when the set of schedules changes.app/server/modules/backups/backups.execution.ts (1)
369-436: Consider recovery for mirrors stuck inin_progressafter a crash.If the process crashes between setting
in_progress(line 379) and the catch/success handlers, the mirror will remain stuck inin_progressindefinitely. The backup schedule has a recovery mechanism (per learnings from PR#463— thestopBackupfunction resets stuck states), but mirrors lack an equivalent.This could be addressed in a follow-up with a startup sweep that resets any mirrors still in
in_progressto"error", similar to the pattern used for backup schedules. Based on learnings, the stopBackup function intentionally updates the schedule status in a finally block even when no backup is running, as a recovery mechanism to reset schedules stuck in "in_progress" state when backups crash unexpectedly.app/client/modules/backups/components/schedule-mirrors-config.tsx (1)
207-212: Consider using theMirrorAssignment["lastCopyStatus"]type instead ofstring | null.This would provide compile-time safety ensuring only valid status values are passed and the function stays in sync with
MirrorAssignment.Suggested change
-const getStatusVariant = (status: string | null) => { +const getStatusVariant = (status: MirrorAssignment["lastCopyStatus"]) => {
| 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]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
fd "use-server-events.ts" --exec cat {}Repository: nicotsx/zerobyte
Length of output: 6190
addEventListener is not memoized, causing the SSE subscription to reinitialize on every render.
The addEventListener function returned from useServerEvents() is created fresh on every render without useCallback wrapping. Including it in the dependency array at line 133 ([addEventListener, scheduleId]) causes the useEffect to re-run on every component render, unsubscribing and resubscribing to events each time. This creates a brief window where handlers are inactive, risking missed events during the re-subscription cycle.
Wrap addEventListener in useCallback within the useServerEvents hook to provide a stable reference across renders.
🤖 Prompt for AI Agents
In `@app/client/modules/backups/components/schedule-mirrors-config.tsx` around
lines 101 - 135, The SSE subscription is reinitialized because addEventListener
(from useServerEvents) is not memoized and changes each render; update the
useServerEvents hook to wrap and return addEventListener with useCallback so it
has a stable identity, ensure any internal dependencies are listed in that
useCallback dependency array, and then keep the current useEffect in
schedule-mirrors-config.tsx using addEventListener and scheduleId as
dependencies so subscriptions only change when truly needed.
| <div className="flex items-center gap-2"> | ||
| <StatusDot | ||
| variant={getStatusVariant(assignment.lastCopyStatus)} | ||
| label={getStatusLabel(assignment)} | ||
| animated={isSyncing(assignment) ? true : undefined} | ||
| /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
StatusDot will animate for success and error variants by default — likely unintended.
When isSyncing is false, animated is passed as undefined. Looking at StatusDot, animated ?? true is the default for success, error, and info variants, so the dot will keep pinging even after sync completes. You probably want a static dot for terminal states and animation only during syncing.
Suggested fix
<StatusDot
variant={getStatusVariant(assignment.lastCopyStatus)}
label={getStatusLabel(assignment)}
- animated={isSyncing(assignment) ? true : undefined}
+ animated={isSyncing(assignment)}
/>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <div className="flex items-center gap-2"> | |
| <StatusDot | |
| variant={getStatusVariant(assignment.lastCopyStatus)} | |
| label={getStatusLabel(assignment)} | |
| animated={isSyncing(assignment) ? true : undefined} | |
| /> | |
| <div className="flex items-center gap-2"> | |
| <StatusDot | |
| variant={getStatusVariant(assignment.lastCopyStatus)} | |
| label={getStatusLabel(assignment)} | |
| animated={isSyncing(assignment)} | |
| /> |
🤖 Prompt for AI Agents
In `@app/client/modules/backups/components/schedule-mirrors-config.tsx` around
lines 355 - 360, The StatusDot is currently left to its internal default when
not syncing which causes it to animate for terminal variants; change the prop so
animation only occurs while syncing by explicitly passing a boolean: compute
animated via isSyncing(assignment) (e.g., animated={isSyncing(assignment)}) or
check both syncing and variant from getStatusVariant(assignment.lastCopyStatus)
so that success/error terminal variants get animated=false and only the syncing
state sets animated=true; update the StatusDot usage in
schedule-mirrors-config.tsx accordingly.
| setLocalItems((currentItems) => { | ||
| const baseItems = currentItems ?? schedules?.map((s) => s.id) ?? []; | ||
| const oldIndex = baseItems.indexOf(active.id as number); | ||
| const newIndex = baseItems.indexOf(over.id as number); | ||
| const newItems = arrayMove(baseItems, oldIndex, newIndex); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
indexOf returns -1 if the dragged ID is missing from baseItems, producing unexpected behavior in arrayMove.
If baseItems is derived from the stale localItems (which could happen after schedules change server-side), active.id or over.id might not be found, resulting in oldIndex or newIndex being -1. arrayMove with -1 would produce an incorrect reordering.
Suggested guard
setLocalItems((currentItems) => {
const baseItems = currentItems ?? schedules?.map((s) => s.id) ?? [];
const oldIndex = baseItems.indexOf(active.id as number);
const newIndex = baseItems.indexOf(over.id as number);
+ if (oldIndex === -1 || newIndex === -1) return currentItems;
const newItems = arrayMove(baseItems, oldIndex, newIndex);
reorderMutation.mutate({ body: { scheduleIds: newItems } });
return newItems;
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| setLocalItems((currentItems) => { | |
| const baseItems = currentItems ?? schedules?.map((s) => s.id) ?? []; | |
| const oldIndex = baseItems.indexOf(active.id as number); | |
| const newIndex = baseItems.indexOf(over.id as number); | |
| const newItems = arrayMove(baseItems, oldIndex, newIndex); | |
| setLocalItems((currentItems) => { | |
| const baseItems = currentItems ?? schedules?.map((s) => s.id) ?? []; | |
| const oldIndex = baseItems.indexOf(active.id as number); | |
| const newIndex = baseItems.indexOf(over.id as number); | |
| if (oldIndex === -1 || newIndex === -1) return currentItems; | |
| const newItems = arrayMove(baseItems, oldIndex, newIndex); |
🤖 Prompt for AI Agents
In `@app/client/modules/backups/routes/backups.tsx` around lines 50 - 54, The
drag-reorder can pass -1 to arrayMove when active.id or over.id aren’t found in
baseItems; inside the setLocalItems updater (symbols: setLocalItems, baseItems,
arrayMove, active.id, over.id) check for missing IDs and avoid calling arrayMove
with -1: compute oldIndex/newIndex, and if either is -1 then either rebuild
baseItems from schedules?.map(s=>s.id) and recompute indices, or if still
missing simply return currentItems to noop the update; ensure you use the same
updater return type so no other state changes occur.
Closes #474 #128
Summary by CodeRabbit
New Features
Improvements