mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 05:01:37 +02:00
Reporter IndividualGhost1905 followed up after the #1378 / #1343 backfill landed and pointed at the next inconsistency: the per- archive delete dialog has had a "Also remove this print from Quick Stats" checkbox since #1343, but the "Purge Old" button and the scheduled daily auto-purge sweeper both ignored that choice and hard-deleted unconditionally. From the user side this looked like "automatically deleted from statistics without any warning" — half- true, and the inconsistency was real either way. The actual current shape (before this fix): - POST /archives/purge -> archive_purge_service.purge_older_than -> ArchiveService.delete_archive (hard). Archive row dropped. Linked PrintLogEntry rows have ON DELETE SET NULL so they survive as orphans with archive_id=NULL. Quick Stats keeps the filament / cost / energy contribution because the log rows are still there, but the archive-list-iterating widgets (Filament Trends, By Material, Color Distribution, Printer Stats) lose the row, and Time Accuracy loses its join target. Visibly inconsistent. - Scheduled _maybe_run_auto_purge -> same code path, same effect. - Single-archive DELETE /archives/{id} -> already takes purge_stats=true|false (default false=soft) and routes either soft_delete_archive (keeps everything, flips deleted_at) or deletes PrintLogEntry rows first + hard-deletes archive. The fix threads the same purge_stats flag through every bulk surface with soft as the default, matching the single-archive default: Backend: - archive_purge_service.purge_older_than(..., purge_stats=False) -> per-row soft_delete_archive when False, per-row PrintLogEntry deletion + delete_archive when True. Each runs in its own session (same pattern the sweeper already used). - preview_purge gains the same kwarg so the eligible-count matches what an actual run would touch: soft mode excludes already-soft-deleted rows, hard mode counts them as eligible for promotion. - get_settings / set_settings now persist archive_auto_purge_stats (default False). _maybe_run_auto_purge reads it on every tick. - ArchivePurgeRequest / ArchivePurgeResponse / ArchivePurgeSettings schemas extended. - Route /archives/purge accepts the body flag, /purge/preview accepts the query param, /purge/settings GET + PUT echo the setting. Frontend: - "Purge old archives" modal: new "Also remove from statistics" checkbox under the preview, unchecked by default. Plumbed into the preview query key + the execute mutation. - Settings -> Archives auto-purge card: matching toggle next to the days slider, disabled when auto-purge itself is off. - api.previewArchivePurge / api.executeArchivePurge accept the flag; ArchivePurgeSettings type gains purge_stats. - All 8 locales (en, de, fr, it, ja, pt-BR, zh-CN, zh-TW) get new purgeStatsLabel / purgeStatsHint / purgeStatsDescription keys, plus rewritten effect / warning copy in archivePurge and archiveAutoPurge to reflect that the default no longer "permanently removes from the database" but instead hides the row + removes files while keeping Quick Stats intact. i18n parity check clean: 4814 keys across all 8 locales, no fallback. Behaviour change for existing users on auto-purge: the sweeper used to hard-delete by default and now soft-deletes by default. After the upgrade those installs start *preserving* more data in Quick Stats rather than losing it — safer direction of the two, but worth the explicit call-out. Anyone who wants the old behaviour ticks the new toggle once and it persists.
190 lines
8.1 KiB
TypeScript
190 lines
8.1 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { AlertTriangle, Loader2, Trash2, X } from 'lucide-react';
|
|
|
|
import { api } from '../api/client';
|
|
import { Button } from './Button';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import { formatFileSize } from '../utils/file';
|
|
|
|
interface PurgeArchivesModalProps {
|
|
onClose: () => void;
|
|
initialDays?: number;
|
|
}
|
|
|
|
const DEFAULT_DAYS = 365;
|
|
|
|
export function PurgeArchivesModal({ onClose, initialDays }: PurgeArchivesModalProps) {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const { showToast } = useToast();
|
|
|
|
const [days, setDays] = useState(initialDays ?? DEFAULT_DAYS);
|
|
// #1390: matches the single-archive delete dialog's "Also remove from
|
|
// statistics" checkbox. Default off — soft-delete, Quick Stats preserved.
|
|
const [purgeStats, setPurgeStats] = useState(false);
|
|
|
|
const [debouncedDays, setDebouncedDays] = useState(days);
|
|
useEffect(() => {
|
|
const handle = window.setTimeout(() => setDebouncedDays(days), 300);
|
|
return () => window.clearTimeout(handle);
|
|
}, [days]);
|
|
|
|
const previewQuery = useQuery({
|
|
queryKey: ['archive-purge-preview', debouncedDays, purgeStats],
|
|
queryFn: () => api.previewArchivePurge(debouncedDays, purgeStats),
|
|
enabled: debouncedDays >= 1,
|
|
});
|
|
|
|
const purgeMutation = useMutation({
|
|
mutationFn: () => api.executeArchivePurge(days, purgeStats),
|
|
onSuccess: (res) => {
|
|
showToast(t('archivePurge.toast.success', { count: res.deleted }), 'success');
|
|
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
|
queryClient.invalidateQueries({ queryKey: ['archive-stats'] });
|
|
onClose();
|
|
},
|
|
onError: (e: Error) => showToast(e.message || t('archivePurge.toast.failed'), 'error'),
|
|
});
|
|
|
|
useEffect(() => {
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && !purgeMutation.isPending) onClose();
|
|
};
|
|
window.addEventListener('keydown', handleKey);
|
|
return () => window.removeEventListener('keydown', handleKey);
|
|
}, [onClose, purgeMutation.isPending]);
|
|
|
|
const preview = previewQuery.data;
|
|
const count = preview?.count ?? 0;
|
|
const totalBytes = preview?.total_bytes ?? 0;
|
|
const canConfirm = count > 0 && !purgeMutation.isPending;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-lg w-full">
|
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
|
<Trash2 className="w-5 h-5" />
|
|
{t('archivePurge.title')}
|
|
</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
|
aria-label={t('common.close')}
|
|
disabled={purgeMutation.isPending}
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-4 space-y-4">
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
{t('archivePurge.description')}
|
|
</p>
|
|
|
|
<div>
|
|
<label htmlFor="archive-purge-days" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
{t('archivePurge.ageLabel')}
|
|
</label>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
id="archive-purge-days"
|
|
type="number"
|
|
min={1}
|
|
max={3650}
|
|
value={days}
|
|
onChange={(e) => setDays(Math.max(1, Math.min(3650, parseInt(e.target.value || '0', 10) || 0)))}
|
|
className="w-24 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm px-2 py-1 text-gray-900 dark:text-gray-100"
|
|
/>
|
|
<span className="text-sm text-gray-600 dark:text-gray-400">{t('archivePurge.days')}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/30 p-3">
|
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300 mb-2">
|
|
{t('archivePurge.effectsTitle')}
|
|
</div>
|
|
<ul className="text-xs text-gray-700 dark:text-gray-300 space-y-1 list-disc pl-4">
|
|
<li>{t('archivePurge.effect1')}</li>
|
|
<li>{t('archivePurge.effect2')}</li>
|
|
<li>{t('archivePurge.effect3')}</li>
|
|
<li>{t('archivePurge.effect4')}</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div className="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 p-3">
|
|
{previewQuery.isLoading || previewQuery.isFetching ? (
|
|
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
|
<Loader2 className="w-4 h-4 animate-spin" /> {t('archivePurge.previewLoading')}
|
|
</div>
|
|
) : previewQuery.isError ? (
|
|
<div className="text-sm text-red-600 dark:text-red-400">
|
|
{(previewQuery.error as Error | null)?.message ?? t('archivePurge.previewFailed')}
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-gray-900 dark:text-gray-100">
|
|
<div className="font-medium">
|
|
{t('archivePurge.previewSummary', { count, size: formatFileSize(totalBytes) })}
|
|
</div>
|
|
{preview?.sample_filenames && preview.sample_filenames.length > 0 && (
|
|
<ul className="mt-2 text-xs text-gray-600 dark:text-gray-400 space-y-0.5 list-disc pl-4">
|
|
{preview.sample_filenames.map((name) => (
|
|
<li key={name} className="truncate">{name}</li>
|
|
))}
|
|
{count > preview.sample_filenames.length && (
|
|
<li className="list-none italic text-gray-500">
|
|
{t('archivePurge.andMore', { count: count - preview.sample_filenames.length })}
|
|
</li>
|
|
)}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<label className="flex gap-2 items-start rounded border border-gray-200 dark:border-gray-700 px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
|
<input
|
|
type="checkbox"
|
|
checked={purgeStats}
|
|
onChange={(e) => setPurgeStats(e.target.checked)}
|
|
disabled={purgeMutation.isPending}
|
|
className="mt-0.5 shrink-0"
|
|
/>
|
|
<span className="text-xs text-gray-700 dark:text-gray-300">
|
|
<span className="font-medium block mb-0.5">{t('archivePurge.purgeStatsLabel')}</span>
|
|
<span className="text-gray-500 dark:text-gray-400">{t('archivePurge.purgeStatsHint')}</span>
|
|
</span>
|
|
</label>
|
|
|
|
<div className="flex gap-2 items-start text-xs text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded px-3 py-2">
|
|
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
|
<span>{t('archivePurge.warning')}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
|
|
<Button variant="secondary" onClick={onClose} disabled={purgeMutation.isPending}>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button
|
|
variant="danger"
|
|
disabled={!canConfirm}
|
|
onClick={() => purgeMutation.mutate()}
|
|
>
|
|
{purgeMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 animate-spin mr-1" />
|
|
{t('archivePurge.purging')}
|
|
</>
|
|
) : (
|
|
t('archivePurge.confirmCta', { count })
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|