mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 09:05:17 -07:00
feat(history): add "Clear failed" button to wipe failed generations (#412)
When the model wasn't loaded, the app was closed mid-run, or a generation otherwise errored out, the resulting "Failed" rows accumulate in history and there was no way to remove them in bulk — individual delete was the only option. Adds a header row above the history list (only rendered when at least one failed generation is present) with a "Clear failed" button that opens a confirmation dialog, then calls a new DELETE /history/failed endpoint which sweeps all status='failed' rows (plus their version files / audio files on disk). Closes jamiepine/voicebox#410 Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
73170d0e92
commit
0aa19a9994
@@ -45,6 +45,7 @@ import { apiClient } from '@/lib/api/client';
|
|||||||
import type { EffectConfig, GenerationVersionResponse, HistoryResponse } from '@/lib/api/types';
|
import type { EffectConfig, GenerationVersionResponse, HistoryResponse } from '@/lib/api/types';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import {
|
import {
|
||||||
|
useClearFailedGenerations,
|
||||||
useDeleteGeneration,
|
useDeleteGeneration,
|
||||||
useExportGeneration,
|
useExportGeneration,
|
||||||
useExportGenerationAudio,
|
useExportGenerationAudio,
|
||||||
@@ -124,6 +125,8 @@ export function HistoryTable() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deleteGeneration = useDeleteGeneration();
|
const deleteGeneration = useDeleteGeneration();
|
||||||
|
const clearFailed = useClearFailedGenerations();
|
||||||
|
const [clearFailedDialogOpen, setClearFailedDialogOpen] = useState(false);
|
||||||
const exportGeneration = useExportGeneration();
|
const exportGeneration = useExportGeneration();
|
||||||
const exportGenerationAudio = useExportGenerationAudio();
|
const exportGenerationAudio = useExportGenerationAudio();
|
||||||
const importGeneration = useImportGeneration();
|
const importGeneration = useImportGeneration();
|
||||||
@@ -157,11 +160,11 @@ export function HistoryTable() {
|
|||||||
const pendingCount = useGenerationStore((state) => state.pendingGenerationIds.size);
|
const pendingCount = useGenerationStore((state) => state.pendingGenerationIds.size);
|
||||||
const prevPendingCountRef = useRef(pendingCount);
|
const prevPendingCountRef = useRef(pendingCount);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (deleteGeneration.isSuccess || importGeneration.isSuccess) {
|
if (deleteGeneration.isSuccess || importGeneration.isSuccess || clearFailed.isSuccess) {
|
||||||
setPage(0);
|
setPage(0);
|
||||||
setAllHistory([]);
|
setAllHistory([]);
|
||||||
}
|
}
|
||||||
}, [deleteGeneration.isSuccess, importGeneration.isSuccess]);
|
}, [deleteGeneration.isSuccess, importGeneration.isSuccess, clearFailed.isSuccess]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// A generation finished (pending count decreased) — scroll back to show it
|
// A generation finished (pending count decreased) — scroll back to show it
|
||||||
@@ -415,6 +418,27 @@ export function HistoryTable() {
|
|||||||
|
|
||||||
const history = allHistory;
|
const history = allHistory;
|
||||||
const hasMore = allHistory.length < total;
|
const hasMore = allHistory.length < total;
|
||||||
|
const failedCount = history.filter((g) => g.status === 'failed').length;
|
||||||
|
|
||||||
|
const handleClearFailedConfirm = () => {
|
||||||
|
clearFailed.mutate(undefined, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setClearFailedDialogOpen(false);
|
||||||
|
toast({
|
||||||
|
title: 'Cleared failed generations',
|
||||||
|
description: `${data.deleted} failed ${data.deleted === 1 ? 'generation' : 'generations'} removed.`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setClearFailedDialogOpen(false);
|
||||||
|
toast({
|
||||||
|
title: 'Failed to clear',
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0 relative">
|
<div className="flex flex-col h-full min-h-0 relative">
|
||||||
@@ -424,6 +448,23 @@ export function HistoryTable() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{failedCount > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-1 pb-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{failedCount} failed {failedCount === 1 ? 'generation' : 'generations'}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => setClearFailedDialogOpen(true)}
|
||||||
|
disabled={clearFailed.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3 mr-1.5" />
|
||||||
|
{clearFailed.isPending ? 'Clearing...' : 'Clear failed'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isScrolled && (
|
{isScrolled && (
|
||||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||||
)}
|
)}
|
||||||
@@ -759,6 +800,31 @@ export function HistoryTable() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={clearFailedDialogOpen} onOpenChange={setClearFailedDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Clear failed generations</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will permanently delete {failedCount} failed{' '}
|
||||||
|
{failedCount === 1 ? 'generation' : 'generations'} from your history. This cannot be
|
||||||
|
undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setClearFailedDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleClearFailedConfirm}
|
||||||
|
disabled={clearFailed.isPending}
|
||||||
|
>
|
||||||
|
{clearFailed.isPending ? 'Clearing...' : 'Clear all'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -270,6 +270,12 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clearFailedGenerations(): Promise<{ deleted: number }> {
|
||||||
|
return this.request<{ deleted: number }>(`/history/failed`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async exportGeneration(generationId: string): Promise<Blob> {
|
async exportGeneration(generationId: string): Promise<Blob> {
|
||||||
const url = `${this.getBaseUrl()}/history/${generationId}/export`;
|
const url = `${this.getBaseUrl()}/history/${generationId}/export`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ export function useDeleteGeneration() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useClearFailedGenerations() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => apiClient.clearFailedGenerations(),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useExportGeneration() {
|
export function useExportGeneration() {
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ async def import_generation(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/history/failed")
|
||||||
|
async def clear_failed_generations(db: Session = Depends(get_db)):
|
||||||
|
"""Delete every generation with status='failed'. Used by the UI's 'Clear failed' button (#410)."""
|
||||||
|
count = await history.delete_failed_generations(db)
|
||||||
|
return {"deleted": count}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/history/{generation_id}", response_model=models.HistoryResponse)
|
@router.get("/history/{generation_id}", response_model=models.HistoryResponse)
|
||||||
async def get_generation(
|
async def get_generation(
|
||||||
generation_id: str,
|
generation_id: str,
|
||||||
|
|||||||
@@ -264,6 +264,43 @@ async def delete_generation(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_failed_generations(db: Session) -> int:
|
||||||
|
"""
|
||||||
|
Delete every generation whose status is 'failed'.
|
||||||
|
|
||||||
|
Used by the "Clear failed" action in the UI so users can tidy up
|
||||||
|
history after the model wasn't loaded, the app was closed mid-run,
|
||||||
|
or a generation otherwise errored out (see issue #410).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of generations deleted.
|
||||||
|
"""
|
||||||
|
from . import versions as versions_mod
|
||||||
|
|
||||||
|
failed = db.query(DBGeneration).filter(DBGeneration.status == "failed").all()
|
||||||
|
count = 0
|
||||||
|
for generation in failed:
|
||||||
|
# Clean up version files/rows first.
|
||||||
|
versions_mod.delete_versions_for_generation(generation.id, db)
|
||||||
|
|
||||||
|
# Remove the main audio file if it somehow made it to disk.
|
||||||
|
if generation.audio_path:
|
||||||
|
audio_path = config.resolve_storage_path(generation.audio_path)
|
||||||
|
if audio_path is not None and audio_path.exists():
|
||||||
|
try:
|
||||||
|
audio_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
# Best-effort cleanup — don't abort the whole sweep
|
||||||
|
# if a single file can't be removed.
|
||||||
|
pass
|
||||||
|
|
||||||
|
db.delete(generation)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
async def delete_generations_by_profile(
|
async def delete_generations_by_profile(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
|
|||||||
Reference in New Issue
Block a user