mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +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>
|
||||
);
|
||||
}
|
||||
@@ -7059,6 +7059,13 @@ export interface components {
|
||||
plan?: "hobby" | "starter" | "standard" | "business";
|
||||
/** @description Provisioning region */
|
||||
region?: string;
|
||||
/** @description Cron expression for automatic backups, empty when unset */
|
||||
backup_schedule?: string;
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Next scheduled backup run
|
||||
*/
|
||||
next_backup_at?: string;
|
||||
metrics?: components["schemas"]["DatabaseMetrics"];
|
||||
backups?: components["schemas"]["DatabaseBackupConfig"];
|
||||
settings?: components["schemas"]["DatabaseSettings"];
|
||||
@@ -7097,6 +7104,8 @@ export interface components {
|
||||
* @enum {string}
|
||||
*/
|
||||
plan?: "hobby" | "starter" | "standard" | "business";
|
||||
/** @description Cron expression for automatic backups; empty string clears the schedule */
|
||||
backup_schedule?: string | null;
|
||||
};
|
||||
DatabaseActionRequest: {
|
||||
/**
|
||||
|
||||
@@ -1295,6 +1295,13 @@ export async function databaseAction(id: string, action: 'start' | 'stop' | 'res
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateDatabaseBackupSchedule(id: string, schedule: string): Promise<void> {
|
||||
await requestJson(`/databases/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ backup_schedule: schedule }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createDatabaseBackup(id: string): Promise<void> {
|
||||
await requestJson(`/databases/${encodeURIComponent(id)}/backup`, {
|
||||
method: 'POST',
|
||||
|
||||
Reference in new issue
Block a user