mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(databases): bind managed DB connection URL to a service
Databases page cards gain a Bind action: pick project → service → variable key (default DATABASE_URL) and the connection_url is injected as a secret env var via the existing variables endpoint. Verified e2e: merged PUT lands the real URL and preserves masked secret values. Authored By: TDvorak <[email protected]>
This commit is contained in:
1 parent
bcd3a83aa4
commit
aeb91b03e9
2 files changed
+123
-4
No files matched your search
+4
-4
@@ -322,10 +322,10 @@ All of these have working APIs and no UI. Cheapest feature wins in the repo.
|
|||||||
`?installation_id=` return by calling `POST /git/github-app/connect`.
|
`?installation_id=` return by calling `POST /git/github-app/connect`.
|
||||||
(Verified at typecheck/lint level; real GitHub App redirect e2e tracked
|
(Verified at typecheck/lint level; real GitHub App redirect e2e tracked
|
||||||
under the OAuth/provider-credentials item in Phase 4.)
|
under the OAuth/provider-credentials item in Phase 4.)
|
||||||
- [ ] **Database → service binding**: creating a managed DB does not wire it
|
- [x] **Database → service binding**: `/databases` cards have a Bind action —
|
||||||
into a service. Add a bind action that injects the resolved
|
pick project → service → variable key (default `DATABASE_URL`) and the
|
||||||
`connection_url` as an env var (e.g. `DATABASE_URL`) on a chosen
|
`connection_url` is injected as a secret env var. Verified e2e: merged
|
||||||
service, so apps can actually consume provisioned databases.
|
PUT lands the URL and preserves masked secrets verbatim.
|
||||||
- [x] **Backup export**: `GET /databases/:id/backups/:bid/download` streams
|
- [x] **Backup export**: `GET /databases/:id/backups/:bid/download` streams
|
||||||
the archive out of the `containr-db-backups` volume via a stopped utility
|
the archive out of the `containr-db-backups` volume via a stopped utility
|
||||||
container + `CopyFromContainer` (daemon logs mangle non-UTF-8, so `cat`
|
container + `CopyFromContainer` (daemon logs mangle non-UTF-8, so `cat`
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ import {
|
|||||||
databaseAction,
|
databaseAction,
|
||||||
createDatabaseBackup,
|
createDatabaseBackup,
|
||||||
restoreDatabaseBackup,
|
restoreDatabaseBackup,
|
||||||
|
listServicesByProject,
|
||||||
|
listServiceVariables,
|
||||||
|
updateServiceVariables,
|
||||||
getGitHubAppInstallUrl,
|
getGitHubAppInstallUrl,
|
||||||
connectGitHubApp,
|
connectGitHubApp,
|
||||||
updateCurrentUserProfile,
|
updateCurrentUserProfile,
|
||||||
@@ -1514,6 +1517,7 @@ const dbStatusClass: Record<string, string> = {
|
|||||||
export function DatabasesPage() {
|
export function DatabasesPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [bindDb, setBindDb] = useState<DatabaseEntity | null>(null);
|
||||||
const databasesQuery = useQuery({
|
const databasesQuery = useQuery({
|
||||||
queryKey: ['databases'],
|
queryKey: ['databases'],
|
||||||
queryFn: listDatabases,
|
queryFn: listDatabases,
|
||||||
@@ -1625,6 +1629,14 @@ export function DatabasesPage() {
|
|||||||
>
|
>
|
||||||
<RefreshCw size={11} /> Restart
|
<RefreshCw size={11} /> Restart
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setBindDb(bindDb?.id === db.id ? null : db)}
|
||||||
|
disabled={!db.connection_url}
|
||||||
|
title={db.connection_url ? 'Inject connection URL into a service' : 'No connection URL available'}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
<Link2 size={11} /> Bind
|
||||||
|
</button>
|
||||||
{actionMutation.isError && (
|
{actionMutation.isError && (
|
||||||
<span className="text-xs text-[var(--error)]">
|
<span className="text-xs text-[var(--error)]">
|
||||||
{actionMutation.error instanceof Error ? actionMutation.error.message : 'Action failed'}
|
{actionMutation.error instanceof Error ? actionMutation.error.message : 'Action failed'}
|
||||||
@@ -1632,6 +1644,13 @@ export function DatabasesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{bindDb?.id === db.id && db.connection_url && (
|
||||||
|
<BindDatabasePanel
|
||||||
|
connectionUrl={db.connection_url}
|
||||||
|
onClose={() => setBindDb(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{db.metrics && (
|
{db.metrics && (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
{([
|
{([
|
||||||
@@ -1708,3 +1727,103 @@ export function DatabasesPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BindDatabasePanel({
|
||||||
|
connectionUrl,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
connectionUrl: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [projectId, setProjectId] = useState('');
|
||||||
|
const [serviceId, setServiceId] = useState('');
|
||||||
|
const [varKey, setVarKey] = useState('DATABASE_URL');
|
||||||
|
const [bound, setBound] = useState(false);
|
||||||
|
|
||||||
|
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: listProjects });
|
||||||
|
const servicesQuery = useQuery({
|
||||||
|
queryKey: ['services', projectId],
|
||||||
|
queryFn: () => listServicesByProject(projectId),
|
||||||
|
enabled: projectId !== '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const bindMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const existing = await listServiceVariables(serviceId);
|
||||||
|
const merged = existing
|
||||||
|
.filter((v) => v.key !== varKey)
|
||||||
|
.map((v) => ({ key: v.key, value: v.value, is_secret: v.isSecret }));
|
||||||
|
merged.push({ key: varKey, value: connectionUrl, is_secret: true });
|
||||||
|
await updateServiceVariables(serviceId, merged);
|
||||||
|
},
|
||||||
|
onSuccess: () => setBound(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const services = servicesQuery.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-default)] bg-[var(--surface-raised)] p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-medium text-[var(--text-primary)]">Bind to service</p>
|
||||||
|
<button onClick={onClose} className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]">Close</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">
|
||||||
|
Injects this database's connection URL as an environment variable on the selected service.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
|
||||||
|
Project
|
||||||
|
<select
|
||||||
|
value={projectId}
|
||||||
|
onChange={(e) => { setProjectId(e.target.value); setServiceId(''); }}
|
||||||
|
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<option value="">Select…</option>
|
||||||
|
{(projectsQuery.data ?? []).map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
|
||||||
|
Service
|
||||||
|
<select
|
||||||
|
value={serviceId}
|
||||||
|
onChange={(e) => setServiceId(e.target.value)}
|
||||||
|
disabled={!projectId}
|
||||||
|
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs text-[var(--text-primary)] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<option value="">Select…</option>
|
||||||
|
{services.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
|
||||||
|
Variable key
|
||||||
|
<input
|
||||||
|
value={varKey}
|
||||||
|
onChange={(e) => setVarKey(e.target.value)}
|
||||||
|
className="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)] w-40"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => bindMutation.mutate()}
|
||||||
|
disabled={!serviceId || !varKey.trim() || bindMutation.isPending}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-xs font-medium disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{bindMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Link2 size={11} />} Bind
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{bound && (
|
||||||
|
<p className="text-xs text-[var(--success)]">
|
||||||
|
Bound — {varKey} is set on the service. Redeploy the service for it to take effect.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{bindMutation.isError && (
|
||||||
|
<p className="text-xs text-[var(--error)]">
|
||||||
|
{bindMutation.error instanceof Error ? bindMutation.error.message : 'Bind failed'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user