mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(databases): S3-compatible backup targets — offsite archives + remote restore
Phase D3. Managed databases can ship backup archives to a configured S3-compatible destination (AWS S3, MinIO, Garage, …) while the local copy remains intact. - backup_targets table: owner-scoped endpoint/bucket/region/prefix + encrypted access/secret keys (enc:v1:, never returned, has_credentials) - create/update probe bucket access before storing; empty keys on update keep stored credentials; delete refused (409) while referenced - databases.backup_target_id assigns a target; external DBs rejected - backup pipeline uploads via a holder container over docker cp streams; remote failure keeps the local archive (logged), row gets remote:true - restore prefers the local archive; on a missing local copy it downloads the remote object into the backup volume first - holder containers must be started before archive ops — docker cp does not resolve volume mounts on never-started (Created) containers, and removing the source mid-stream stalls the reader without EOF - CLI: backup-targets list|add|update|remove|test + databases update --backup-target; MCP: 5 new tools; Settings UI card + per-database target picker + offsite badge on backups; OpenAPI + llms.txt + CLI.md 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
b7131965b1
commit
6f43bbf8f6
25 files changed
+1901
-32
No files matched your search
@@ -32,6 +32,10 @@ import {
|
||||
createManagedDatabase,
|
||||
registerExternalDatabase,
|
||||
updateDatabase,
|
||||
listBackupTargets,
|
||||
createBackupTarget,
|
||||
deleteBackupTarget,
|
||||
testBackupTarget,
|
||||
testDatabaseConnection,
|
||||
testDatabaseConnectionByID,
|
||||
type ExternalDatabaseInput,
|
||||
@@ -1568,6 +1572,139 @@ function RegistriesSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function BackupTargetsSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const targetsQuery = useQuery({
|
||||
queryKey: ['backup-targets'],
|
||||
queryFn: listBackupTargets,
|
||||
});
|
||||
const [form, setForm] = useState({ name: '', endpoint: '', bucket: '', region: '', prefix: '', accessKey: '', secretKey: '', noTls: false });
|
||||
const [testResult, setTestResult] = useState<Record<string, { ok: boolean; latency_ms?: number; error?: string }>>({});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['backup-targets'] });
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createBackupTarget({
|
||||
name: form.name.trim(),
|
||||
endpoint: form.endpoint.trim(),
|
||||
bucket: form.bucket.trim(),
|
||||
region: form.region.trim() || undefined,
|
||||
prefix: form.prefix.trim() || undefined,
|
||||
access_key: form.accessKey || undefined,
|
||||
secret_key: form.secretKey || undefined,
|
||||
use_tls: !form.noTls,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setForm({ name: '', endpoint: '', bucket: '', region: '', prefix: '', accessKey: '', secretKey: '', noTls: false });
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteBackupTarget(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const testMutation = useMutation({
|
||||
mutationFn: (id: string) => testBackupTarget(id),
|
||||
onSuccess: (result, id) => setTestResult((prev) => ({ ...prev, [id]: result })),
|
||||
onError: (error, id) =>
|
||||
setTestResult((prev) => ({ ...prev, [id]: { ok: false, error: error instanceof Error ? error.message : 'probe failed' } })),
|
||||
});
|
||||
|
||||
const targets = targetsQuery.data ?? [];
|
||||
const fieldCls = 'h-9 flex-1 min-w-[130px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all';
|
||||
|
||||
return (
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<HardDrive size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Backup targets</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">S3-compatible destinations for database archives — keys stored encrypted</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Name (e.g. garage)" className={fieldCls} />
|
||||
<input value={form.endpoint} onChange={(e) => setForm((f) => ({ ...f, endpoint: e.target.value }))} placeholder="Endpoint host:port" className={fieldCls} />
|
||||
<input value={form.bucket} onChange={(e) => setForm((f) => ({ ...f, bucket: e.target.value }))} placeholder="Bucket" className="h-9 w-36 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all" />
|
||||
<input value={form.accessKey} onChange={(e) => setForm((f) => ({ ...f, accessKey: e.target.value }))} placeholder="Access key" className={fieldCls} />
|
||||
<input type="password" value={form.secretKey} onChange={(e) => setForm((f) => ({ ...f, secretKey: e.target.value }))} placeholder="Secret key" className={fieldCls} />
|
||||
<label className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||
<input type="checkbox" checked={form.noTls} onChange={(e) => setForm((f) => ({ ...f, noTls: e.target.checked }))} className="accent-[var(--accent-primary)]" />
|
||||
No TLS
|
||||
</label>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending || !form.name.trim() || !form.endpoint.trim() || !form.bucket.trim()}
|
||||
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
{createMutation.isPending ? 'Checking…' : 'Add target'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createMutation.isError ? (
|
||||
<p className="text-xs text-[var(--error)]">
|
||||
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add target'}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{targetsQuery.isLoading ? (
|
||||
<div className="py-4 text-center">
|
||||
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
) : targets.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
No backup targets. Database archives stay in the local backups volume until a target is configured.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
|
||||
{targets.map((t) => (
|
||||
<div key={t.id} className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="min-w-0">
|
||||
<span className="text-[var(--text-primary)]">{t.name}</span>
|
||||
<span className="ml-2 mono text-[var(--text-muted)]">{t.endpoint}/{t.bucket}{t.prefix ? `/${t.prefix}` : ''}</span>
|
||||
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
|
||||
{t.has_credentials ? 'keys set' : 'anonymous'}{t.use_tls ? '' : ' · no TLS'}
|
||||
</span>
|
||||
{testResult[t.id ?? ''] && (
|
||||
<span className={`ml-2 ${testResult[t.id ?? '']?.ok ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
|
||||
{testResult[t.id ?? '']?.ok ? `reachable (${testResult[t.id ?? '']?.latency_ms ?? 0}ms)` : `unreachable — ${testResult[t.id ?? '']?.error ?? 'probe failed'}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={() => testMutation.mutate(t.id ?? '')}
|
||||
disabled={testMutation.isPending}
|
||||
className="text-[var(--accent-primary)] hover:underline disabled:opacity-50"
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete backup target ${t.name}?`)) {
|
||||
deleteMutation.mutate(t.id ?? '');
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-[var(--error)] hover:underline disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -1742,6 +1879,7 @@ export function SettingsPage() {
|
||||
{/* Personal Access Tokens */}
|
||||
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
||||
<RegistriesSection />
|
||||
<BackupTargetsSection />
|
||||
|
||||
{/* Platform Settings - owner only */}
|
||||
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
||||
@@ -1918,6 +2056,11 @@ export function DatabasesPage() {
|
||||
refetchInterval: 15_000,
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
const backupTargetsQuery = useQuery({
|
||||
queryKey: ['backup-targets'],
|
||||
queryFn: listBackupTargets,
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['databases'] });
|
||||
const createMutation = useMutation({
|
||||
@@ -1972,6 +2115,11 @@ export function DatabasesPage() {
|
||||
updateDatabase(id, { public_port: publicPort }),
|
||||
onSuccess: () => window.setTimeout(invalidate, 1500),
|
||||
});
|
||||
const backupTargetMutation = useMutation({
|
||||
mutationFn: ({ id, targetId }: { id: string; targetId: string }) =>
|
||||
updateDatabase(id, { backup_target_id: targetId }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const testByIdMutation = useMutation({
|
||||
mutationFn: (id: string) => testDatabaseConnectionByID(id),
|
||||
onSuccess: (result, id) => setExternalTestResult((prev) => ({ ...prev, [id]: result })),
|
||||
@@ -2196,6 +2344,20 @@ export function DatabasesPage() {
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Backups</p>
|
||||
{signedIn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{(backupTargetsQuery.data?.length ?? 0) > 0 && (
|
||||
<select
|
||||
value={db.backup_target_id ?? ''}
|
||||
onChange={(e) => backupTargetMutation.mutate({ id: db.id ?? '', targetId: e.target.value })}
|
||||
title="Offsite archive destination"
|
||||
className="h-7 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-xs text-[var(--text-secondary)]"
|
||||
>
|
||||
<option value="">local only</option>
|
||||
{(backupTargetsQuery.data ?? []).map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name} ({t.bucket})</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={() => backupMutation.mutate(db.id ?? '')}
|
||||
disabled={!running || backupMutation.isPending}
|
||||
@@ -2205,6 +2367,7 @@ export function DatabasesPage() {
|
||||
<Archive size={11} />
|
||||
{backupMutation.isPending ? 'Starting…' : 'New backup'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<BackupScheduleRow
|
||||
@@ -2221,6 +2384,7 @@ export function DatabasesPage() {
|
||||
<span className="text-[var(--text-secondary)]">{formatRelative(b.created_at)}</span>
|
||||
<span className="mono text-[var(--text-muted)]">{b.size}</span>
|
||||
<span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span>
|
||||
{b.remote && <span className="rounded bg-[var(--surface-card)] px-1.5 py-0.5 text-[var(--accent-primary)]" title="Also shipped to the configured backup target">offsite</span>}
|
||||
{b.status === 'completed' && (
|
||||
<span className="ml-auto flex items-center gap-3">
|
||||
<a
|
||||
|
||||
Reference in new issue
Block a user