From 8ecc8dcea5e73ef92bbff06142d4df09aed57fde Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 17 Sep 2026 18:00:18 +0200 Subject: [PATCH] feat(previews): preview environments UI + fix latent insert bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Service detail gains a Previews section: list, create (branch/PR/TTL), promote-to-production, delete — all confirmed actions. Verified e2e create→list→promote→delete. - fix: preview create scanned services.type which real services leave NULL (writes go to service_type); every create 404'd. Now COALESCE(type, service_type, '') matching the rest of the codebase. - fix: deployments.version is NOT NULL but three inserts omitted it — handleCreateDeployment, rollback, and preview promote all failed on any fresh-install schema. All three now write a version. - Roadmap notes preview envs are bookkeeping-only; real branch deploy is tracked separately. Authored By: TDvorak --- ROADMAP.md | 17 +- app/backend/internal/api/deployments.go | 16 +- .../internal/api/preview_environments.go | 9 +- .../service/pages/ServiceDetailPage.tsx | 170 +++++++++++++++++- app/frontend/src/lib/api-client.ts | 33 ++++ 5 files changed, 228 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d0b4071..9f155c8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -48,7 +48,7 @@ The backend exposes **~95 route registrations**; the frontend consumes | GitHub App install flow | done (`install_url` + connect endpoint) | done — Install button + `installation_id` return handling | done (real-provider e2e pending credentials) | | Cron jobs (CRUD, trigger, executions) | done — real scheduler + `docker exec` capture | done — Cron tab on service detail | not documented | | Managed databases (CRUD, actions, backup/restore) | done | done — `/databases` page (conn info, actions, backups) | mentioned in README | -| Preview environments (CRUD, promote, cleanup) | done | **missing** | not documented | +| Preview environments (CRUD, promote, cleanup) | done (bookkeeping only — no real container deploy; URL is a placeholder) | done — Previews section on service detail | not documented | | Security scans, vulnerabilities, compliance/GDPR reports | done | **missing** | README lists it | | Audit logs | done | done — `/settings/audit-logs` filterable page | done | | Autoscaling policies + manual scale | done | **missing** | `docs/guides/AUTOSCALING.md` describes UI that does not exist | @@ -275,9 +275,18 @@ All of these have working APIs and no UI. Cheapest feature wins in the repo. edit/enable-toggle, manual Run, execution history with logs. (Not added to Add Service: jobs exec into an existing service's container, so they live on the service, not as a standalone type.) -- [ ] **Preview environments** (`/preview-environments/*`): per-PR/per-branch - preview creation, promote-to-production action, expiry + cleanup job. - Decide first whether this stays Phase 2 or moves with env support (§2.4). +- [x] **Preview environments UI**: service-detail "Previews" section — list + per service, create (branch/PR/TTL), promote-to-production with + confirm, delete with confirm. Verified e2e: create→list→promote→delete. + Fixed two latent backend bugs: create scanned NULL `services.type` + (every real service failed) and three deployment inserts omitted the + NOT NULL `version` column (create + rollback + promote all broke on + fresh installs). +- [ ] **Preview environments runtime**: preview creation writes bookkeeping + rows and a fake `*.preview.containr.local` URL — nothing is actually + deployed. Real preview = build the branch + deploy container + route a + real subdomain + TTL cleanup that tears down containers. Sizeable; + schedule with env support (§2.4). - [x] **Audit logs**: real page at `/settings/audit-logs` (linked from the Settings page) — filterable table by resource, action, actor (email), and time range, with pagination. Backend widened accordingly: the list diff --git a/app/backend/internal/api/deployments.go b/app/backend/internal/api/deployments.go index e517e93..dc3bf5b 100644 --- a/app/backend/internal/api/deployments.go +++ b/app/backend/internal/api/deployments.go @@ -199,10 +199,10 @@ func handleCreateDeployment(c *gin.Context) { } _, err = db.(*database.DB).Exec( - `INSERT INTO deployments - (id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, - d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt, + `INSERT INTO deployments + (id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + d.ID, d.ServiceID, fmt.Sprintf("v%d", now.Unix()), d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt, ) if err != nil { @@ -533,10 +533,10 @@ func handleRollbackDeployment(c *gin.Context) { } _, err = db.(*database.DB).Exec( - `INSERT INTO deployments - (id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, - rollback.ID, rollback.ServiceID, rollback.CommitHash, rollback.Status, + `INSERT INTO deployments + (id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + rollback.ID, rollback.ServiceID, fmt.Sprintf("rollback-%d", now.Unix()), rollback.CommitHash, rollback.Status, rollback.ImageName, rollback.ImageTag, rollback.CreatedAt, rollback.UpdatedAt, ) diff --git a/app/backend/internal/api/preview_environments.go b/app/backend/internal/api/preview_environments.go index c0e92a9..e897255 100644 --- a/app/backend/internal/api/preview_environments.go +++ b/app/backend/internal/api/preview_environments.go @@ -201,7 +201,7 @@ func handleCreatePreviewEnvironment(c *gin.Context) { // Check if service exists and belongs to the project var service Service err = db.(*database.DB).QueryRow( - "SELECT id, name, type FROM services WHERE id = $1 AND project_id = $2", + "SELECT id, name, COALESCE(type, service_type, '') FROM services WHERE id = $1 AND project_id = $2", req.ServiceID, req.ProjectID, ).Scan(&service.ID, &service.Name, &service.Type) @@ -586,10 +586,11 @@ func handlePromotePreviewEnvironment(c *gin.Context) { } deploymentID := uuid.New() + promotionVersion := fmt.Sprintf("promote-%s-%d", strings.ReplaceAll(env.BranchName, "/", "-"), time.Now().Unix()) _, err = db.(*database.DB).Exec( - `INSERT INTO deployments (id, service_id, status, created_at, updated_at) - VALUES ($1, $2, $3, NOW(), NOW())`, - deploymentID, env.ServiceID, "pending", + `INSERT INTO deployments (id, service_id, version, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, NOW(), NOW())`, + deploymentID, env.ServiceID, promotionVersion, "pending", ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"}) diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index b10da9f..13cd744 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -17,6 +17,10 @@ import { deleteCronJob, listCronExecutions, triggerCronJob, + listPreviewEnvironments, + createPreviewEnvironment, + deletePreviewEnvironment, + promotePreviewEnvironment, rollbackDeployment, updateServiceVariables, type CronJobEntity, @@ -49,9 +53,11 @@ import { Box, Play, ChevronDown, + GitPullRequest, + ExternalLink, } from 'lucide-react'; -type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'settings'; +type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'previews' | 'settings'; const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [ { key: 'metrics', label: 'Metrics', icon: Activity }, @@ -59,6 +65,7 @@ const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Act { key: 'config', label: 'Config', icon: Sliders }, { key: 'variables', label: 'Variables', icon: KeyRound }, { key: 'cron', label: 'Cron', icon: Clock }, + { key: 'previews', label: 'Previews', icon: GitPullRequest }, { key: 'settings', label: 'Settings', icon: Settings }, ]; @@ -195,6 +202,38 @@ export function ServiceDetailPage() { mutationFn: (job: CronJobEntity) => updateCronJob(job.id as string, { enabled: !job.enabled }), onSuccess: invalidateCron, }); + + // --- Preview environments --- + const [previewForm, setPreviewForm] = useState<{ branch: string; prNumber: string; ttlHours: string } | null>(null); + const previewsQuery = useQuery({ + queryKey: ['project-preview-envs', projectId], + queryFn: () => listPreviewEnvironments(projectId), + enabled: Boolean(projectId) && !isDemoMode && activeSection === 'previews', + refetchInterval: 15_000, + }); + const invalidatePreviews = () => queryClient.invalidateQueries({ queryKey: ['project-preview-envs', projectId] }); + const createPreviewMutation = useMutation({ + mutationFn: async () => { + if (!previewForm) return; + await createPreviewEnvironment(projectId, { + project_id: projectId, + service_id: serviceId, + branch_name: previewForm.branch.trim(), + pr_number: previewForm.prNumber ? Number(previewForm.prNumber) : undefined, + ttl_hours: previewForm.ttlHours ? Number(previewForm.ttlHours) : undefined, + }); + }, + onSuccess: () => { setPreviewForm(null); invalidatePreviews(); }, + }); + const deletePreviewMutation = useMutation({ + mutationFn: (id: string) => deletePreviewEnvironment(id), + onSuccess: invalidatePreviews, + }); + const promotePreviewMutation = useMutation({ + mutationFn: (id: string) => promotePreviewEnvironment(id, { target_environment: 'production' }), + onSuccess: invalidatePreviews, + }); + const previews = (previewsQuery.data ?? []).filter((p) => p.service_id === serviceId); const triggerCronMutation = useMutation({ mutationFn: (id: string) => triggerCronJob(id), onSuccess: () => { @@ -1285,6 +1324,135 @@ export function ServiceDetailPage() { )} + {activeSection === 'previews' && ( +
+
+
+
+ +
+
+

Preview Environments

+

Ephemeral per-branch deployments of this service

+
+
+ +
+ + {previewForm && ( +
+
+ + + + + +
+ {createPreviewMutation.isError && ( +

+ {createPreviewMutation.error instanceof Error ? createPreviewMutation.error.message : 'Create failed'} +

+ )} +
+ )} + + {previewsQuery.isLoading ? ( +

Loading previews…

+ ) : previews.length === 0 ? ( +

No preview environments for this service.

+ ) : ( +
+ {previews.map((env) => ( +
+
+
+ {env.environment} + +
+

+ {env.branch_name} + {env.pr_number ? ` · PR #${env.pr_number}` : ''} + {env.expires_at ? ` · expires ${formatRelative(env.expires_at)}` : ''} +

+
+ {env.url && env.status === 'running' && ( + + Open + + )} + {env.status === 'running' && ( + + )} + +
+ ))} +
+ )} + {(deletePreviewMutation.isError || promotePreviewMutation.isError) && ( +

+ {((deletePreviewMutation.error ?? promotePreviewMutation.error) as Error)?.message ?? 'Operation failed'} +

+ )} +
+ )} + {activeSection === 'settings' && (
diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 34abe55..1293a9f 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1449,3 +1449,36 @@ export async function markNotificationRead(id: string): Promise { export async function markAllNotificationsRead(): Promise { await requestJson('/notifications/read-all', { method: 'POST' }); } + +export type PreviewEnvironmentEntity = components['schemas']['PreviewEnvironment']; + +export async function listPreviewEnvironments(projectId: string): Promise { + const payload = await requestJson<{ preview_environments?: PreviewEnvironmentEntity[] }>( + `/projects/${encodeURIComponent(projectId)}/preview-environments`, + ); + return payload.preview_environments ?? []; +} + +export async function createPreviewEnvironment( + projectId: string, + input: components['schemas']['CreatePreviewEnvironmentRequest'], +): Promise { + return requestJson( + `/projects/${encodeURIComponent(projectId)}/preview-environments`, + { method: 'POST', body: JSON.stringify(input) }, + ); +} + +export async function deletePreviewEnvironment(id: string): Promise { + await requestJson(`/preview-environments/${encodeURIComponent(id)}`, { method: 'DELETE' }); +} + +export async function promotePreviewEnvironment( + id: string, + input: components['schemas']['PromotePreviewEnvironmentRequest'], +): Promise { + await requestJson(`/preview-environments/${encodeURIComponent(id)}/promote`, { + method: 'POST', + body: JSON.stringify(input), + }); +}