mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(databases): backup import — upload .tar.gz as a restore point
D5. POST /databases/:id/backups/import accepts a gzipped tar archive (multipart `file` field or raw application/gzip body, 2 GiB ceiling), stages it to a temp file, streams it into the backup volume via the started holder container, and registers a completed backup row — a first-class restore point that also ships offsite when a target is assigned. External databases rejected. Also fixes `containr databases restore` — it never sent database_id, which the API requires, so every CLI restore failed validation. CLI: databases import-backup <id> <file|-> (stdin support via new client.DoRaw). UI: Import button in the backups row. OpenAPI + docs. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
109f79268f
commit
15045b8dd3
11 files changed
+409
-10
No files matched your search
@@ -31,6 +31,7 @@ import {
|
||||
restoreDatabaseBackup,
|
||||
createManagedDatabase,
|
||||
registerExternalDatabase,
|
||||
importDatabaseBackup,
|
||||
updateDatabase,
|
||||
listBackupTargets,
|
||||
createBackupTarget,
|
||||
@@ -130,6 +131,7 @@ import {
|
||||
HeartPulse,
|
||||
Cloud,
|
||||
ExternalLink,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
|
||||
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
||||
@@ -2120,6 +2122,10 @@ export function DatabasesPage() {
|
||||
updateDatabase(id, { backup_target_id: targetId }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const importBackupMutation = useMutation({
|
||||
mutationFn: ({ id, file }: { id: string; file: File }) => importDatabaseBackup(id, file),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const testByIdMutation = useMutation({
|
||||
mutationFn: (id: string) => testDatabaseConnectionByID(id),
|
||||
onSuccess: (result, id) => setExternalTestResult((prev) => ({ ...prev, [id]: result })),
|
||||
@@ -2358,6 +2364,24 @@ export function DatabasesPage() {
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<label
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium border border-[var(--border-subtle)] text-[var(--text-secondary)] transition-all ${importBackupMutation.isPending ? 'opacity-50' : 'cursor-pointer hover:text-[var(--text-primary)]'}`}
|
||||
title="Import a .tar.gz archive as a restore point"
|
||||
>
|
||||
<Upload size={11} />
|
||||
{importBackupMutation.isPending ? 'Importing…' : 'Import'}
|
||||
<input
|
||||
type="file"
|
||||
accept=".tar.gz,application/gzip"
|
||||
className="hidden"
|
||||
disabled={importBackupMutation.isPending}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) importBackupMutation.mutate({ id: db.id ?? '', file: f });
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => backupMutation.mutate(db.id ?? '')}
|
||||
disabled={!running || backupMutation.isPending}
|
||||
|
||||
@@ -3612,6 +3612,78 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/databases/{id}/backups/import": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Import a backup archive
|
||||
* @description Upload a `.tar.gz` archive (multipart field `file` or a raw application/gzip body) to create a new restore point. The archive lands in the backup volume and, when a backup target is assigned, is also shipped offsite. Managed databases only; 2 GiB ceiling.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Database ID */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
/** Format: binary */
|
||||
file?: string;
|
||||
};
|
||||
"application/gzip": string;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Backup imported */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
backup_id?: string;
|
||||
size?: string;
|
||||
status?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Invalid archive */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Archive too large */
|
||||
413: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/databases/{id}/backups/{bid}/download": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -1727,6 +1727,21 @@ export async function restoreDatabaseBackup(id: string, backupId: string): Promi
|
||||
});
|
||||
}
|
||||
|
||||
export async function importDatabaseBackup(id: string, file: File): Promise<{ backup_id: string; size: string }> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const resp = await fetch(`${getApiBaseUrl()}/databases/${encodeURIComponent(id)}/backups/import`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: form,
|
||||
});
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
throw new ApiError(payload.error || 'Import failed', resp.status);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function createManagedDatabase(
|
||||
input: CreateDatabaseInput,
|
||||
): Promise<{ id: string; status: string }> {
|
||||
|
||||
Reference in new issue
Block a user