diff --git a/ROADMAP.md b/ROADMAP.md index b4ebf52..5cf2063 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -214,9 +214,11 @@ pieces exist; this phase closes the gaps that block real usage. - Variables discovered from `${VAR}`/`${VAR:-default}` placeholders. - Catalog UI: screenshots, badges, import dialogs (GitHub + paste), install wizard → creates one service per Compose service. -- [ ] **Service creation flows on canvas**: Add Service menu with Git repo / - Docker image / database / worker / cron / template entry points — - currently only some paths are reachable. +- [x] **Service creation flows on canvas**: Add Service covers Image, Git + repo (provider → repo → branch → connect+webhook), Database (engine + + plan → real `POST /databases` provisioning, not a hollow service row), + worker/cron types, and a Template entry that jumps to the catalog with + the project preselected (`/templates?project=`). - [ ] **Variables UX**: secret masking, per-environment values, bulk paste (`KEY=VAL` lines), validation errors inline. - [ ] **Deploy lifecycle polish**: build → deploy status propagation to canvas diff --git a/app/frontend/src/features/templates/pages/TemplatesPage.tsx b/app/frontend/src/features/templates/pages/TemplatesPage.tsx index 4821955..abb632a 100644 --- a/app/frontend/src/features/templates/pages/TemplatesPage.tsx +++ b/app/frontend/src/features/templates/pages/TemplatesPage.tsx @@ -377,7 +377,7 @@ export function TemplatesPage() { const [categoryFilter, setCategoryFilter] = useState(''); const [searchFilter, setSearchFilter] = useState(''); const [selectedTemplateIdState, setSelectedTemplateId] = useState(null); - const [deployProjectIdState, setDeployProjectId] = useState(''); + const [deployProjectIdState, setDeployProjectId] = useState(searchParams.get('project') ?? ''); const [deployNameByTemplate, setDeployNameByTemplate] = useState>({}); const [variableValuesByTemplate, setVariableValuesByTemplate] = useState< Record> diff --git a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx index 66d1b60..59595ea 100644 --- a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx +++ b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx @@ -5,6 +5,7 @@ import { ApiError, connectGitRepository, createGitWebhook, + createManagedDatabase, createService, getProjectById, listConnectedGitRepositories, @@ -14,6 +15,7 @@ import { listServiceLogs, listServiceVariables, listServicesByProject, + type CreateDatabaseInput, type CreateServiceInput, } from '@/lib/api-client'; import { getDemoProjectById, getDemoServicesByProject, getDemoVariablesByProject } from '@/lib/demo-data'; @@ -57,12 +59,26 @@ const serviceEnvironments = ['production', 'preview', 'development'] as const; type CreateServiceSubmit = CreateServiceInput & { gitProviderId?: string; gitRepoFullName?: string; + database?: { engine: CreateDatabaseInput['type']; plan: CreateDatabaseInput['plan'] }; }; +const databaseEngines = [ + { value: 'postgresql', label: 'PostgreSQL' }, + { value: 'mysql', label: 'MySQL' }, + { value: 'mariadb', label: 'MariaDB' }, + { value: 'redis', label: 'Redis' }, + { value: 'dragonfly', label: 'Dragonfly' }, + { value: 'mongodb', label: 'MongoDB' }, + { value: 'clickhouse', label: 'ClickHouse' }, +] as const; + +const databasePlans = ['hobby', 'starter', 'standard', 'business'] as const; + function ServiceCreateDialog(props: { open: boolean; onClose: () => void; onSubmit: (payload: CreateServiceSubmit) => void; + onBrowseTemplates: () => void; loading: boolean; errorMessage?: string; }) { @@ -77,6 +93,10 @@ function ServiceCreateDialog(props: { const [providerId, setProviderId] = useState(''); const [repoFullName, setRepoFullName] = useState(''); const [repoSearch, setRepoSearch] = useState(''); + const [dbEngine, setDbEngine] = useState<(typeof databaseEngines)[number]['value']>('postgresql'); + const [dbPlan, setDbPlan] = useState<(typeof databasePlans)[number]>('hobby'); + + const isDatabase = form.type === 'database'; const providersQuery = useQuery({ queryKey: ['git-providers'], @@ -110,7 +130,7 @@ function ServiceCreateDialog(props: { const canSubmit = Boolean(form.name.trim()) && - (source === 'image' || Boolean(repoFullName)); + (isDatabase ? Boolean(dbEngine) : source === 'image' || Boolean(repoFullName)); if (!props.open) return null; @@ -172,11 +192,48 @@ function ServiceCreateDialog(props: { + {isDatabase ? ( +
+
+
+ + +
+
+ + +
+
+

+ Provisions a managed database container. Connection details appear under Databases once running. +

+
+ ) : (
-
+
+
+ )} - {source === 'image' ? ( + {isDatabase ? null : source === 'image' ? (
@@ -428,7 +495,16 @@ export function ProjectWorkspacePage() { const createServiceMutation = useMutation({ mutationFn: async (payload: CreateServiceSubmit) => { - const { gitProviderId, gitRepoFullName, ...serviceInput } = payload; + const { gitProviderId, gitRepoFullName, database, ...serviceInput } = payload; + if (database) { + await createManagedDatabase({ + name: serviceInput.name, + type: database.engine, + plan: database.plan, + region: 'local', + }); + return null; + } const service = await createService(projectId, serviceInput); if (gitProviderId && gitRepoFullName) { let repoId: string | undefined; @@ -881,6 +957,10 @@ export function ProjectWorkspacePage() { loading={createServiceMutation.isPending} errorMessage={createServiceMutation.error ? (createServiceMutation.error as Error).message : undefined} onSubmit={(payload) => createServiceMutation.mutate(payload)} + onBrowseTemplates={() => { + setCreateOpen(false); + navigate(`/templates?project=${projectId}`); + }} /> )} diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 334213e..a189115 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1231,6 +1231,22 @@ export async function listGitBranches( return payload.branches ?? []; } +export type CreateDatabaseInput = components['schemas']['CreateDatabaseRequest']; +export type DatabaseEntity = components['schemas']['Database']; + +export async function createManagedDatabase( + input: CreateDatabaseInput, +): Promise<{ id: string; status: string }> { + const payload = await requestJson<{ id?: string; status?: string }>('/databases', { + method: 'POST', + body: JSON.stringify(input), + }); + if (!payload.id) { + throw new ApiError('Database response is invalid', 500); + } + return { id: payload.id, status: payload.status ?? 'building' }; +} + export type ConnectGitRepositoryInput = components['schemas']['ConnectGitRepoRequest']; export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest']; export type GitWebhookEntity = components['schemas']['GitWebhook'];