diff --git a/ROADMAP.md b/ROADMAP.md index 518ba97..670f419 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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`. (Verified at typecheck/lint level; real GitHub App redirect e2e tracked under the OAuth/provider-credentials item in Phase 4.) -- [ ] **Database → service binding**: creating a managed DB does not wire it - into a service. Add a bind action that injects the resolved - `connection_url` as an env var (e.g. `DATABASE_URL`) on a chosen - service, so apps can actually consume provisioned databases. +- [x] **Database → service binding**: `/databases` cards have a Bind action — + pick project → service → variable key (default `DATABASE_URL`) and the + `connection_url` is injected as a secret env var. Verified e2e: merged + PUT lands the URL and preserves masked secrets verbatim. - [x] **Backup export**: `GET /databases/:id/backups/:bid/download` streams the archive out of the `containr-db-backups` volume via a stopped utility container + `CopyFromContainer` (daemon logs mangle non-UTF-8, so `cat` diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index 0a5b5c3..a9bd14d 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -22,6 +22,9 @@ import { databaseAction, createDatabaseBackup, restoreDatabaseBackup, + listServicesByProject, + listServiceVariables, + updateServiceVariables, getGitHubAppInstallUrl, connectGitHubApp, updateCurrentUserProfile, @@ -1514,6 +1517,7 @@ const dbStatusClass: Record = { export function DatabasesPage() { const queryClient = useQueryClient(); const [expandedId, setExpandedId] = useState(null); + const [bindDb, setBindDb] = useState(null); const databasesQuery = useQuery({ queryKey: ['databases'], queryFn: listDatabases, @@ -1625,6 +1629,14 @@ export function DatabasesPage() { > Restart + {actionMutation.isError && ( {actionMutation.error instanceof Error ? actionMutation.error.message : 'Action failed'} @@ -1632,6 +1644,13 @@ export function DatabasesPage() { )} + {bindDb?.id === db.id && db.connection_url && ( + setBindDb(null)} + /> + )} + {db.metrics && (
{([ @@ -1708,3 +1727,103 @@ export function DatabasesPage() {
); } + +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 ( +
+
+

Bind to service

+ +
+

+ Injects this database's connection URL as an environment variable on the selected service. +

+
+ + + + +
+ {bound && ( +

+ Bound — {varKey} is set on the service. Redeploy the service for it to take effect. +

+ )} + {bindMutation.isError && ( +

+ {bindMutation.error instanceof Error ? bindMutation.error.message : 'Bind failed'} +

+ )} +
+ ); +}