feat(previews): preview environments UI + fix latent insert bugs

- 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 <[email protected]>
This commit is contained in:
Tomas Dvorak committed 2026-09-17 18:00:18 +02:00
1 parent 07ac54aa05
commit 8ecc8dcea5
5 files changed
+228 -17

No files matched your search

+13 -4
View File
@@ -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) | | 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 | | 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 | | 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 | | Security scans, vulnerabilities, compliance/GDPR reports | done | **missing** | README lists it |
| Audit logs | done | done — `/settings/audit-logs` filterable page | done | | 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 | | 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 edit/enable-toggle, manual Run, execution history with logs. (Not added
to Add Service: jobs exec into an existing service's container, so they to Add Service: jobs exec into an existing service's container, so they
live on the service, not as a standalone type.) live on the service, not as a standalone type.)
- [ ] **Preview environments** (`/preview-environments/*`): per-PR/per-branch - [x] **Preview environments UI**: service-detail "Previews" section — list
preview creation, promote-to-production action, expiry + cleanup job. per service, create (branch/PR/TTL), promote-to-production with
Decide first whether this stays Phase 2 or moves with env support (§2.4). 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 - [x] **Audit logs**: real page at `/settings/audit-logs` (linked from the
Settings page) — filterable table by resource, action, actor (email), Settings page) — filterable table by resource, action, actor (email),
and time range, with pagination. Backend widened accordingly: the list and time range, with pagination. Backend widened accordingly: the list
+8 -8
View File
@@ -199,10 +199,10 @@ func handleCreateDeployment(c *gin.Context) {
} }
_, err = db.(*database.DB).Exec( _, err = db.(*database.DB).Exec(
`INSERT INTO deployments `INSERT INTO deployments
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at) (id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt, 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 { if err != nil {
@@ -533,10 +533,10 @@ func handleRollbackDeployment(c *gin.Context) {
} }
_, err = db.(*database.DB).Exec( _, err = db.(*database.DB).Exec(
`INSERT INTO deployments `INSERT INTO deployments
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at) (id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
rollback.ID, rollback.ServiceID, rollback.CommitHash, rollback.Status, rollback.ID, rollback.ServiceID, fmt.Sprintf("rollback-%d", now.Unix()), rollback.CommitHash, rollback.Status,
rollback.ImageName, rollback.ImageTag, rollback.CreatedAt, rollback.UpdatedAt, rollback.ImageName, rollback.ImageTag, rollback.CreatedAt, rollback.UpdatedAt,
) )
@@ -201,7 +201,7 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
// Check if service exists and belongs to the project // Check if service exists and belongs to the project
var service Service var service Service
err = db.(*database.DB).QueryRow( 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, req.ServiceID, req.ProjectID,
).Scan(&service.ID, &service.Name, &service.Type) ).Scan(&service.ID, &service.Name, &service.Type)
@@ -586,10 +586,11 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
} }
deploymentID := uuid.New() deploymentID := uuid.New()
promotionVersion := fmt.Sprintf("promote-%s-%d", strings.ReplaceAll(env.BranchName, "/", "-"), time.Now().Unix())
_, err = db.(*database.DB).Exec( _, err = db.(*database.DB).Exec(
`INSERT INTO deployments (id, service_id, status, created_at, updated_at) `INSERT INTO deployments (id, service_id, version, status, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())`, VALUES ($1, $2, $3, $4, NOW(), NOW())`,
deploymentID, env.ServiceID, "pending", deploymentID, env.ServiceID, promotionVersion, "pending",
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"})
@@ -17,6 +17,10 @@ import {
deleteCronJob, deleteCronJob,
listCronExecutions, listCronExecutions,
triggerCronJob, triggerCronJob,
listPreviewEnvironments,
createPreviewEnvironment,
deletePreviewEnvironment,
promotePreviewEnvironment,
rollbackDeployment, rollbackDeployment,
updateServiceVariables, updateServiceVariables,
type CronJobEntity, type CronJobEntity,
@@ -49,9 +53,11 @@ import {
Box, Box,
Play, Play,
ChevronDown, ChevronDown,
GitPullRequest,
ExternalLink,
} from 'lucide-react'; } 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 }> = [ const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [
{ key: 'metrics', label: 'Metrics', icon: 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: 'config', label: 'Config', icon: Sliders },
{ key: 'variables', label: 'Variables', icon: KeyRound }, { key: 'variables', label: 'Variables', icon: KeyRound },
{ key: 'cron', label: 'Cron', icon: Clock }, { key: 'cron', label: 'Cron', icon: Clock },
{ key: 'previews', label: 'Previews', icon: GitPullRequest },
{ key: 'settings', label: 'Settings', icon: Settings }, { key: 'settings', label: 'Settings', icon: Settings },
]; ];
@@ -195,6 +202,38 @@ export function ServiceDetailPage() {
mutationFn: (job: CronJobEntity) => updateCronJob(job.id as string, { enabled: !job.enabled }), mutationFn: (job: CronJobEntity) => updateCronJob(job.id as string, { enabled: !job.enabled }),
onSuccess: invalidateCron, 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({ const triggerCronMutation = useMutation({
mutationFn: (id: string) => triggerCronJob(id), mutationFn: (id: string) => triggerCronJob(id),
onSuccess: () => { onSuccess: () => {
@@ -1285,6 +1324,135 @@ export function ServiceDetailPage() {
</div> </div>
)} )}
{activeSection === 'previews' && (
<div className="panel p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<GitPullRequest size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Preview Environments</h2>
<p className="text-sm text-[var(--text-secondary)]">Ephemeral per-branch deployments of this service</p>
</div>
</div>
<button
onClick={() => setPreviewForm({ branch: '', prNumber: '', ttlHours: '72' })}
className="flex items-center gap-2 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)] transition-colors"
>
<Plus size={12} /> New preview
</button>
</div>
{previewForm && (
<div className="mb-6 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-4 space-y-3">
<div className="flex flex-wrap items-end gap-3">
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
Branch
<input
value={previewForm.branch}
onChange={(e) => setPreviewForm({ ...previewForm, branch: e.target.value })}
placeholder="feature/my-branch"
className="w-52 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-raised)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
PR number (optional)
<input
value={previewForm.prNumber}
onChange={(e) => setPreviewForm({ ...previewForm, prNumber: e.target.value })}
placeholder="123"
className="w-24 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-raised)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
TTL hours
<input
value={previewForm.ttlHours}
onChange={(e) => setPreviewForm({ ...previewForm, ttlHours: e.target.value })}
placeholder="72"
className="w-24 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-raised)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
/>
</label>
<button
onClick={() => createPreviewMutation.mutate()}
disabled={!previewForm.branch.trim() || createPreviewMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-40"
>
{createPreviewMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Plus size={11} />} Create
</button>
<button
onClick={() => setPreviewForm(null)}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] hover:border-[var(--border-default)]"
>
Cancel
</button>
</div>
{createPreviewMutation.isError && (
<p className="text-xs text-[var(--error)]">
{createPreviewMutation.error instanceof Error ? createPreviewMutation.error.message : 'Create failed'}
</p>
)}
</div>
)}
{previewsQuery.isLoading ? (
<p className="text-sm text-[var(--text-muted)]">Loading previews…</p>
) : previews.length === 0 ? (
<p className="text-sm text-[var(--text-muted)]">No preview environments for this service.</p>
) : (
<div className="space-y-2">
{previews.map((env) => (
<div key={env.id} className="flex flex-wrap items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--text-primary)]">{env.environment}</span>
<StatusBadge status={env.status ?? 'stopped'} />
</div>
<p className="mt-0.5 text-xs text-[var(--text-muted)]">
{env.branch_name}
{env.pr_number ? ` · PR #${env.pr_number}` : ''}
{env.expires_at ? ` · expires ${formatRelative(env.expires_at)}` : ''}
</p>
</div>
{env.url && env.status === 'running' && (
<a
href={env.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline"
>
<ExternalLink size={11} /> Open
</a>
)}
{env.status === 'running' && (
<button
onClick={() => { if (window.confirm('Promote this preview to production?')) promotePreviewMutation.mutate(env.id ?? ''); }}
disabled={promotePreviewMutation.isPending}
className="px-2.5 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40"
>
Promote
</button>
)}
<button
onClick={() => { if (window.confirm(`Delete preview ${env.environment}?`)) deletePreviewMutation.mutate(env.id ?? ''); }}
disabled={deletePreviewMutation.isPending}
className="px-2.5 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--error)] hover:border-[var(--error)] disabled:opacity-40"
>
Delete
</button>
</div>
))}
</div>
)}
{(deletePreviewMutation.isError || promotePreviewMutation.isError) && (
<p className="mt-3 text-xs text-[var(--error)]">
{((deletePreviewMutation.error ?? promotePreviewMutation.error) as Error)?.message ?? 'Operation failed'}
</p>
)}
</div>
)}
{activeSection === 'settings' && ( {activeSection === 'settings' && (
<div className="panel p-6"> <div className="panel p-6">
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
+33
View File
@@ -1449,3 +1449,36 @@ export async function markNotificationRead(id: string): Promise<void> {
export async function markAllNotificationsRead(): Promise<void> { export async function markAllNotificationsRead(): Promise<void> {
await requestJson('/notifications/read-all', { method: 'POST' }); await requestJson('/notifications/read-all', { method: 'POST' });
} }
export type PreviewEnvironmentEntity = components['schemas']['PreviewEnvironment'];
export async function listPreviewEnvironments(projectId: string): Promise<PreviewEnvironmentEntity[]> {
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<PreviewEnvironmentEntity> {
return requestJson<PreviewEnvironmentEntity>(
`/projects/${encodeURIComponent(projectId)}/preview-environments`,
{ method: 'POST', body: JSON.stringify(input) },
);
}
export async function deletePreviewEnvironment(id: string): Promise<void> {
await requestJson(`/preview-environments/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
export async function promotePreviewEnvironment(
id: string,
input: components['schemas']['PromotePreviewEnvironmentRequest'],
): Promise<void> {
await requestJson(`/preview-environments/${encodeURIComponent(id)}/promote`, {
method: 'POST',
body: JSON.stringify(input),
});
}