186 lines
7.1 KiB
React
186 lines
7.1 KiB
React
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|||
|
|
import { Play, Wrench } from 'lucide-react';
|
|||
|
|
import { toast } from 'sonner';
|
|||
|
|
import { api } from '@/api';
|
|||
|
|
import { Button } from '@/components/ui/button';
|
|||
|
|
import { Badge } from '@/components/ui/badge';
|
|||
|
|
import { Input } from '@/components/ui/input';
|
|||
|
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
|||
|
|
import { FieldRow, Toggle, formatDateTime } from './adminShared';
|
|||
|
|
|
|||
|
|
export default function CleanupPanel() {
|
|||
|
|
const [status, setStatus] = useState(null);
|
|||
|
|
const [form, setForm] = useState({
|
|||
|
|
import_sessions_enabled: true,
|
|||
|
|
temp_exports_enabled: true,
|
|||
|
|
temp_export_max_age_hours: 2,
|
|||
|
|
backup_partials_enabled: true,
|
|||
|
|
import_history_enabled: false,
|
|||
|
|
import_history_max_age_days: 365,
|
|||
|
|
});
|
|||
|
|
const [saving, setSaving] = useState(false);
|
|||
|
|
const [running, setRunning] = useState(false);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
|
|||
|
|
const load = useCallback(async () => {
|
|||
|
|
try {
|
|||
|
|
const data = await api.adminCleanup();
|
|||
|
|
setStatus(data);
|
|||
|
|
if (data.settings) setForm(data.settings);
|
|||
|
|
} catch (err) {
|
|||
|
|
toast.error(err.message || 'Failed to load cleanup settings.');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
useEffect(() => { load(); }, [load]);
|
|||
|
|
|
|||
|
|
const set = (k, v) => setForm(prev => ({ ...prev, [k]: v }));
|
|||
|
|
|
|||
|
|
async function handleSave() {
|
|||
|
|
setSaving(true);
|
|||
|
|
try {
|
|||
|
|
const next = await api.saveAdminCleanup(form);
|
|||
|
|
if (next) setForm(next);
|
|||
|
|
toast.success('Cleanup settings saved.');
|
|||
|
|
} catch (err) {
|
|||
|
|
toast.error(err.message || 'Failed to save cleanup settings.');
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleRunNow() {
|
|||
|
|
setRunning(true);
|
|||
|
|
try {
|
|||
|
|
const result = await api.runAdminCleanup();
|
|||
|
|
setStatus(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
last_run_at: result.ran_at,
|
|||
|
|
last_result: result.tasks,
|
|||
|
|
}));
|
|||
|
|
toast.success('Cleanup tasks completed.');
|
|||
|
|
} catch (err) {
|
|||
|
|
toast.error(err.message || 'Cleanup run failed.');
|
|||
|
|
} finally {
|
|||
|
|
setRunning(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resultLine(label, task, countKey) {
|
|||
|
|
if (!task || task[countKey] == null) return null;
|
|||
|
|
return `${label}: ${task[countKey]}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const resultLines = status?.last_result ? [
|
|||
|
|
resultLine('Import sessions pruned', status.last_result.import_sessions, 'pruned'),
|
|||
|
|
resultLine('Temp export files removed', status.last_result.temp_export_files, 'removed'),
|
|||
|
|
resultLine('Backup partials removed', status.last_result.backup_partials, 'removed'),
|
|||
|
|
resultLine('Import history rows pruned', status.last_result.import_history, 'pruned'),
|
|||
|
|
].filter(Boolean) : [];
|
|||
|
|
|
|||
|
|
if (loading) {
|
|||
|
|
return (
|
|||
|
|
<Card>
|
|||
|
|
<CardContent className="py-8 text-center text-muted-foreground text-sm">
|
|||
|
|
Loading cleanup settings…
|
|||
|
|
</CardContent>
|
|||
|
|
</Card>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Card>
|
|||
|
|
<CardHeader className="pb-4">
|
|||
|
|
<div className="flex items-center justify-between gap-3">
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<Wrench className="h-5 w-5 text-muted-foreground" />
|
|||
|
|
<div>
|
|||
|
|
<CardTitle>Cleanup / Maintenance</CardTitle>
|
|||
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|||
|
|
Automatic daily cleanup: expired import sessions, stale export temp files, orphaned backup partials. Runs at 6:00 AM.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 shrink-0">Auto</Badge>
|
|||
|
|
</div>
|
|||
|
|
</CardHeader>
|
|||
|
|
|
|||
|
|
<CardContent className="space-y-6">
|
|||
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|||
|
|
<div className="rounded-lg border border-border bg-muted/20 p-3">
|
|||
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Last Run</p>
|
|||
|
|
<p className="text-sm font-medium mt-1">{status?.last_run_at ? formatDateTime(status.last_run_at) : '—'}</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="rounded-lg border border-border bg-muted/20 p-3">
|
|||
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Last Result</p>
|
|||
|
|
{resultLines.length > 0 ? (
|
|||
|
|
<ul className="mt-1 space-y-0.5">
|
|||
|
|
{resultLines.map(line => (
|
|||
|
|
<li key={line} className="text-xs text-muted-foreground">{line}</li>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
) : (
|
|||
|
|
<p className="text-sm font-medium mt-1 text-muted-foreground/60">No runs recorded yet</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<p className="text-sm font-medium">Task Settings</p>
|
|||
|
|
{[
|
|||
|
|
['import_sessions_enabled', 'Prune expired import sessions (24h TTL)'],
|
|||
|
|
['temp_exports_enabled', 'Remove stale SQLite export temp files'],
|
|||
|
|
['backup_partials_enabled', 'Remove orphaned backup .partial / .upload files'],
|
|||
|
|
].map(([key, label]) => (
|
|||
|
|
<FieldRow key={key} label={label}>
|
|||
|
|
<Toggle checked={!!form[key]} onChange={v => set(key, v)} label={label} />
|
|||
|
|
</FieldRow>
|
|||
|
|
))}
|
|||
|
|
|
|||
|
|
<FieldRow label="Temp file max age (hours, 1–72)">
|
|||
|
|
<Input
|
|||
|
|
type="number" min="1" max="72"
|
|||
|
|
value={form.temp_export_max_age_hours}
|
|||
|
|
onChange={e => set('temp_export_max_age_hours', parseInt(e.target.value, 10) || 2)}
|
|||
|
|
disabled={!form.temp_exports_enabled}
|
|||
|
|
className="max-w-[120px] h-8 text-sm"
|
|||
|
|
/>
|
|||
|
|
</FieldRow>
|
|||
|
|
|
|||
|
|
<FieldRow label="Trim import history rows (off by default)">
|
|||
|
|
<Toggle checked={!!form.import_history_enabled} onChange={v => set('import_history_enabled', v)} label="Trim import history rows" />
|
|||
|
|
</FieldRow>
|
|||
|
|
|
|||
|
|
{form.import_history_enabled && (
|
|||
|
|
<>
|
|||
|
|
<FieldRow label="Import history max age (days, 30–3650)">
|
|||
|
|
<Input
|
|||
|
|
type="number" min="30" max="3650"
|
|||
|
|
value={form.import_history_max_age_days}
|
|||
|
|
onChange={e => set('import_history_max_age_days', parseInt(e.target.value, 10) || 365)}
|
|||
|
|
className="max-w-[120px] h-8 text-sm"
|
|||
|
|
/>
|
|||
|
|
</FieldRow>
|
|||
|
|
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-4 py-3 text-sm text-amber-600 dark:text-amber-400">
|
|||
|
|
<strong>Warning:</strong> Import history trimming permanently deletes audit records older than {form.import_history_max_age_days} days. This cannot be undone.
|
|||
|
|
</div>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
|
|||
|
|
<Button onClick={handleSave} disabled={saving || running}>
|
|||
|
|
{saving ? 'Saving…' : 'Save Settings'}
|
|||
|
|
</Button>
|
|||
|
|
<Button variant="outline" onClick={handleRunNow} disabled={saving || running}>
|
|||
|
|
<Play className="h-4 w-4" />
|
|||
|
|
{running ? 'Running…' : 'Run Cleanup Now'}
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</CardContent>
|
|||
|
|
</Card>
|
|||
|
|
);
|
|||
|
|
}
|