mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(backups): archive download endpoint + GitHub App install surfacing
- GET /databases/:id/backups/:bid/download streams the archive out of the containr-db-backups volume via a stopped utility container and CopyFromContainer; daemon logs sanitize non-UTF-8 so binary through container logs was not viable. Verified e2e: byte-identical payload, 404 missing/cross-user, 401 unauthenticated. - Settings Git Providers gains an Install GitHub App action that opens the install_url and completes the installation_id return via connect endpoint. - ROADMAP: mark both items done with verification evidence. Authored By: TDvorak <[email protected]>
This commit is contained in:
1 parent
18fe56f948
commit
bcd3a83aa4
8 files changed
+304
-24
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
getApiBaseUrl,
|
||||
getAgentPublicBaseUrl,
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
databaseAction,
|
||||
createDatabaseBackup,
|
||||
restoreDatabaseBackup,
|
||||
getGitHubAppInstallUrl,
|
||||
connectGitHubApp,
|
||||
updateCurrentUserProfile,
|
||||
type AgentAuthTokenCreated,
|
||||
type DatabaseEntity,
|
||||
@@ -813,11 +815,44 @@ const gitProviderTypes = [
|
||||
|
||||
function GitProvidersSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const providersQuery = useQuery({
|
||||
queryKey: ['git-providers'],
|
||||
queryFn: listGitProviders,
|
||||
});
|
||||
|
||||
// GitHub redirects back here with ?installation_id=<id> after app install.
|
||||
const installIdParam = searchParams.get('installation_id');
|
||||
const connectAppMutation = useMutation({
|
||||
mutationFn: (installationId: number) => connectGitHubApp(installationId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['git-providers'] });
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete('installation_id');
|
||||
next.delete('setup_action');
|
||||
setSearchParams(next, { replace: true });
|
||||
},
|
||||
});
|
||||
const connectFiredFor = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const id = Number(installIdParam);
|
||||
if (id > 0 && connectFiredFor.current !== id) {
|
||||
connectFiredFor.current = id;
|
||||
connectAppMutation.mutate(id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- fire once per installation id
|
||||
}, [installIdParam]);
|
||||
|
||||
const [installUrlError, setInstallUrlError] = useState<string | null>(null);
|
||||
const installApp = async () => {
|
||||
setInstallUrlError(null);
|
||||
try {
|
||||
window.open(await getGitHubAppInstallUrl(), '_blank', 'noopener');
|
||||
} catch (err) {
|
||||
setInstallUrlError(err instanceof Error ? err.message : 'GitHub App is not configured');
|
||||
}
|
||||
};
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: 'github' as (typeof gitProviderTypes)[number]['value'],
|
||||
displayName: '',
|
||||
@@ -863,16 +898,41 @@ function GitProvidersSection() {
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setFormOpen((open) => !open)}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<Link2 size={14} />
|
||||
{formOpen ? 'Close' : 'Connect'}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => void installApp()}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
<GitBranch size={14} />
|
||||
Install GitHub App
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFormOpen((open) => !open)}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<Link2 size={14} />
|
||||
{formOpen ? 'Close' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{installUrlError && (
|
||||
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
|
||||
{installUrlError}
|
||||
</div>
|
||||
)}
|
||||
{connectAppMutation.isPending && (
|
||||
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--surface-muted)] text-sm text-[var(--text-secondary)]">
|
||||
Completing GitHub App installation…
|
||||
</div>
|
||||
)}
|
||||
{connectAppMutation.isError && (
|
||||
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
|
||||
{connectAppMutation.error instanceof Error ? connectAppMutation.error.message : 'Failed to connect GitHub App'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{providersQuery.isLoading ? (
|
||||
<div className="py-6 text-center">
|
||||
<Loader2 size={20} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
@@ -1611,13 +1671,21 @@ export function DatabasesPage() {
|
||||
<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.status === 'completed' && (
|
||||
<button
|
||||
onClick={() => { if (window.confirm(`Restore ${db.name} from this backup? Current data will be overwritten.`)) restoreMutation.mutate({ id: db.id ?? '', backupId: b.id ?? '' }); }}
|
||||
disabled={restoreMutation.isPending}
|
||||
className="ml-auto text-[var(--accent-primary)] hover:underline disabled:opacity-40"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<span className="ml-auto flex items-center gap-3">
|
||||
<a
|
||||
href={`${getApiBaseUrl()}/databases/${encodeURIComponent(db.id ?? '')}/backups/${encodeURIComponent(b.id ?? '')}/download`}
|
||||
className="text-[var(--accent-primary)] hover:underline"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<button
|
||||
onClick={() => { if (window.confirm(`Restore ${db.name} from this backup? Current data will be overwritten.`)) restoreMutation.mutate({ id: db.id ?? '', backupId: b.id ?? '' }); }}
|
||||
disabled={restoreMutation.isPending}
|
||||
className="text-[var(--accent-primary)] hover:underline disabled:opacity-40"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1710,6 +1710,63 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/databases/{id}/backups/{bid}/download": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Download a completed backup archive */
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
bid: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Backup archive stream */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/gzip": string;
|
||||
};
|
||||
};
|
||||
/** @description Backup not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Backup not completed */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/git/providers": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -5783,6 +5840,7 @@ export interface paths {
|
||||
parameters: {
|
||||
query?: {
|
||||
project_id?: string;
|
||||
service_id?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -1326,6 +1326,25 @@ export type ConnectGitRepositoryInput = components['schemas']['ConnectGitRepoReq
|
||||
export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest'];
|
||||
export type GitWebhookEntity = components['schemas']['GitWebhook'];
|
||||
|
||||
export async function getGitHubAppInstallUrl(): Promise<string> {
|
||||
const payload = await requestJson<{ install_url?: string }>('/git/github-app/install-url');
|
||||
if (!payload.install_url) {
|
||||
throw new ApiError('GitHub App install URL unavailable', 500);
|
||||
}
|
||||
return payload.install_url;
|
||||
}
|
||||
|
||||
export async function connectGitHubApp(installationId: number, displayName?: string): Promise<GitProviderEntity> {
|
||||
const payload = await requestJson<{ provider?: GitProviderEntity }>('/git/github-app/connect', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ installation_id: installationId, display_name: displayName }),
|
||||
});
|
||||
if (!payload.provider?.id) {
|
||||
throw new ApiError('GitHub App connect response is invalid', 500);
|
||||
}
|
||||
return payload.provider;
|
||||
}
|
||||
|
||||
export async function connectGitRepository(
|
||||
input: ConnectGitRepositoryInput,
|
||||
): Promise<GitRepositoryEntity> {
|
||||
|
||||
Reference in new issue
Block a user