feat: public_port for managed databases — opt-in 0.0.0.0 bind

Managed databases have always published their port on 127.0.0.1 only.
public_port opts into binding on all interfaces so the database is
reachable off-host. Toggling via PUT recreates the container — the
named data volume survives.

- create accepts public_port; PUT /databases/{id} toggles it
- external rows reject the flag (no container exists)
- CLI: databases create --public, databases update --public/--public=false
- MCP: databases_update tool; UI: create checkbox + per-row toggle

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-10-08 22:19:29 +02:00
1 parent 5dc9b5f115
commit b7131965b1
16 files changed
+235 -45

No files matched your search

+27 -2
View File
@@ -31,6 +31,7 @@ import {
restoreDatabaseBackup,
createManagedDatabase,
registerExternalDatabase,
updateDatabase,
testDatabaseConnection,
testDatabaseConnectionByID,
type ExternalDatabaseInput,
@@ -1906,7 +1907,7 @@ export function DatabasesPage() {
const [bindDb, setBindDb] = useState<DatabaseEntity | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [createMode, setCreateMode] = useState<'managed' | 'external'>('managed');
const [createForm, setCreateForm] = useState({ name: '', type: 'postgresql', plan: 'hobby', region: 'local' });
const [createForm, setCreateForm] = useState({ name: '', type: 'postgresql', plan: 'hobby', region: 'local', public: false });
const [externalForm, setExternalForm] = useState({ name: '', type: 'postgresql', host: '', port: '', database: '', username: '', password: '', ssl: false });
const [externalProbe, setExternalProbe] = useState<{ ok: boolean; latency_ms?: number; error?: string } | null>(null);
const [externalTestResult, setExternalTestResult] = useState<Record<string, { ok: boolean; latency_ms?: number; error?: string }>>({});
@@ -1926,10 +1927,11 @@ export function DatabasesPage() {
type: createForm.type as CreateDatabaseInput['type'],
plan: createForm.plan as CreateDatabaseInput['plan'],
region: createForm.region.trim() || 'local',
public_port: createForm.public,
}),
onSuccess: () => {
setCreateOpen(false);
setCreateForm({ name: '', type: 'postgresql', plan: 'hobby', region: 'local' });
setCreateForm({ name: '', type: 'postgresql', plan: 'hobby', region: 'local', public: false });
setCreateError(null);
invalidate();
},
@@ -1965,6 +1967,11 @@ export function DatabasesPage() {
onSuccess: (result) => setExternalProbe(result),
onError: (error) => setExternalProbe({ ok: false, error: error instanceof Error ? error.message : 'probe failed' }),
});
const publicPortMutation = useMutation({
mutationFn: ({ id, publicPort }: { id: string; publicPort: boolean }) =>
updateDatabase(id, { public_port: publicPort }),
onSuccess: () => window.setTimeout(invalidate, 1500),
});
const testByIdMutation = useMutation({
mutationFn: (id: string) => testDatabaseConnectionByID(id),
onSuccess: (result, id) => setExternalTestResult((prev) => ({ ...prev, [id]: result })),
@@ -2132,6 +2139,13 @@ export function DatabasesPage() {
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"
>
<RefreshCw size={11} /> Restart
</button>
<button
onClick={() => { if (window.confirm(`${db.public_port ? 'Stop exposing' : 'Expose'} ${db.name} on all interfaces? The container will be recreated; data persists.`)) publicPortMutation.mutate({ id: db.id ?? '', publicPort: !db.public_port }) }}
disabled={publicPortMutation.isPending}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border text-xs font-medium transition-colors disabled:opacity-40 ${db.public_port ? 'border-[var(--warning)] text-[var(--warning)]' : 'border-[var(--border-subtle)] text-[var(--text-secondary)] hover:border-[var(--border-default)]'}`}
>
<Link2 size={11} /> {db.public_port ? 'Public ✓' : 'Local only'}
</button>
</>
)}
@@ -2368,6 +2382,7 @@ export function DatabasesPage() {
</div>
</>
) : (
<>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</label>
@@ -2403,6 +2418,16 @@ export function DatabasesPage() {
/>
</div>
</div>
<label className="flex items-center gap-2 text-xs text-[var(--text-secondary)]">
<input
type="checkbox"
checked={createForm.public}
onChange={(e) => setCreateForm((f) => ({ ...f, public: e.target.checked }))}
className="accent-[var(--accent-primary)]"
/>
Expose port on all interfaces (default: localhost only)
</label>
</>
)}
{createError ? (
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-3.5 py-2.5 text-xs text-[var(--error)]">{createError}</div>