mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(backups): scheduled database backups via cron tick
- database_services gains backup_schedule + next_backup_at (goose migration, sqlc schema, new queries). - PUT /databases/:id accepts backup_schedule — validated cron expression, empty string disables; invalid returns 400. - Cron scheduler tick now also folds due databases into the existing snapshot pipeline (createBackupProcess). - Databases page backups section gets a schedule row showing current expr and next run. - resolveBackupConfig reports real next_backup/enabled instead of a fake last+24h estimate. - Verified e2e: '* * * * *' produced three consecutive completed archives containing the seeded payload; invalid schedule rejected. Authored By: TDvorak <[email protected]>
This commit is contained in:
1 parent
aeb91b03e9
commit
08d92fb10f
13 files changed
+387
-99
No files matched your search
@@ -20,6 +20,7 @@ import {
|
||||
revokeAgentToken,
|
||||
listDatabases,
|
||||
databaseAction,
|
||||
updateDatabaseBackupSchedule,
|
||||
createDatabaseBackup,
|
||||
restoreDatabaseBackup,
|
||||
listServicesByProject,
|
||||
@@ -1680,6 +1681,11 @@ export function DatabasesPage() {
|
||||
{backupMutation.isPending ? 'Starting…' : 'New backup'}
|
||||
</button>
|
||||
</div>
|
||||
<BackupScheduleRow
|
||||
databaseId={db.id ?? ''}
|
||||
schedule={db.backup_schedule ?? ''}
|
||||
nextBackupAt={db.next_backup_at}
|
||||
/>
|
||||
{backups.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-muted)]">No backups yet.</p>
|
||||
) : (
|
||||
@@ -1827,3 +1833,55 @@ function BindDatabasePanel({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupScheduleRow({
|
||||
databaseId,
|
||||
schedule,
|
||||
nextBackupAt,
|
||||
}: {
|
||||
databaseId: string;
|
||||
schedule: string;
|
||||
nextBackupAt?: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [value, setValue] = useState(schedule);
|
||||
const mutation = useMutation({
|
||||
mutationFn: (expr: string) => updateDatabaseBackupSchedule(databaseId, expr),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['databases'] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="cron schedule, e.g. 0 3 * * *"
|
||||
className="w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs mono text-[var(--text-primary)]"
|
||||
/>
|
||||
<button
|
||||
onClick={() => mutation.mutate(value.trim())}
|
||||
disabled={mutation.isPending || value.trim() === schedule}
|
||||
className="px-2.5 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40"
|
||||
>
|
||||
{mutation.isPending ? 'Saving…' : 'Save schedule'}
|
||||
</button>
|
||||
{schedule && (
|
||||
<button
|
||||
onClick={() => { setValue(''); mutation.mutate(''); }}
|
||||
disabled={mutation.isPending}
|
||||
className="px-2.5 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40"
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
{schedule ? `auto-backup ${schedule}${nextBackupAt ? ` — next ${formatRelative(nextBackupAt)}` : ''}` : 'manual only'}
|
||||
</span>
|
||||
{mutation.isError && (
|
||||
<span className="text-xs text-[var(--error)]">
|
||||
{mutation.error instanceof Error ? mutation.error.message : 'Failed to save schedule'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user