feat: Replace per-preview Caddy sidecars with shared gateway architecture

Refactor preview deployments to use a single shared Caddy gateway container
with subdomain routing (<previewId>.localhost:<port>) instead of one Caddy
sidecar and one port per preview. Adds dev/preview modes, git worktree
support for branch checkouts, and auto-start on phase:pending_review.

- Add GatewayManager for shared Caddy lifecycle + Caddyfile generation
- Add git worktree helpers for preview mode branch checkouts
- Add dev mode: volume-mount + dev server image instead of build
- Remove per-preview Caddy sidecar and port publishing
- Use shared cw-preview-net Docker network with container name DNS
- Auto-start previews when phase enters pending_review
- Delete unused PreviewPanel.tsx
- Update all tests (40 pass), docs, events, CLI, tRPC, frontend
This commit is contained in:
Lukas May
2026-03-05 12:22:29 +01:00
parent 0ff65b0b02
commit 143aad58e8
21 changed files with 1198 additions and 721 deletions

View File

@@ -1,176 +0,0 @@
import { useState } from "react";
import {
Loader2,
ExternalLink,
Square,
RotateCcw,
CircleDot,
CircleX,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
interface PreviewPanelProps {
initiativeId: string;
phaseId?: string;
projectId: string;
branch: string;
}
export function PreviewPanel({
initiativeId,
phaseId,
projectId,
branch,
}: PreviewPanelProps) {
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
// Check for existing previews for this initiative
const previewsQuery = trpc.listPreviews.useQuery(
{ initiativeId },
{ refetchInterval: activePreviewId ? 3000 : false },
);
const existingPreview = previewsQuery.data?.find(
(p) => p.phaseId === phaseId || (!phaseId && p.initiativeId === initiativeId),
);
const previewStatusQuery = trpc.getPreviewStatus.useQuery(
{ previewId: activePreviewId ?? existingPreview?.id ?? "" },
{
enabled: !!(activePreviewId ?? existingPreview?.id),
refetchInterval: 3000,
},
);
const preview = previewStatusQuery.data ?? existingPreview;
const startMutation = trpc.startPreview.useMutation({
onSuccess: (data) => {
setActivePreviewId(data.id);
toast.success(`Preview running at http://localhost:${data.port}`);
},
onError: (err) => {
toast.error(`Preview failed: ${err.message}`);
},
});
const stopMutation = trpc.stopPreview.useMutation({
onSuccess: () => {
setActivePreviewId(null);
toast.success("Preview stopped");
previewsQuery.refetch();
},
onError: (err) => {
toast.error(`Failed to stop preview: ${err.message}`);
},
});
const handleStart = () => {
startMutation.mutate({ initiativeId, phaseId, projectId, branch });
};
const handleStop = () => {
const id = activePreviewId ?? existingPreview?.id;
if (id) {
stopMutation.mutate({ previewId: id });
}
};
// Building state
if (startMutation.isPending) {
return (
<div className="flex items-center gap-3 rounded-lg border border-status-active-border bg-status-active-bg px-4 py-3">
<Loader2 className="h-4 w-4 animate-spin text-status-active-dot" />
<div className="flex-1">
<p className="text-sm font-medium text-status-active-fg">
Building preview...
</p>
<p className="text-xs text-status-active-fg/70">
Building containers and starting services
</p>
</div>
</div>
);
}
// Running state
if (preview && (preview.status === "running" || preview.status === "building")) {
const url = `http://localhost:${preview.port}`;
const isBuilding = preview.status === "building";
return (
<div
className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${
isBuilding
? "border-status-active-border bg-status-active-bg"
: "border-status-success-border bg-status-success-bg"
}`}
>
{isBuilding ? (
<Loader2 className="h-4 w-4 animate-spin text-status-active-dot" />
) : (
<CircleDot className="h-4 w-4 text-status-success-dot" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">
{isBuilding ? "Building..." : "Preview running"}
</p>
{!isBuilding && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-1"
>
{url}
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
<Button
size="sm"
variant="outline"
onClick={handleStop}
disabled={stopMutation.isPending}
className="shrink-0"
>
<Square className="h-3 w-3 mr-1" />
Stop
</Button>
</div>
);
}
// Failed state
if (preview && preview.status === "failed") {
return (
<div className="flex items-center gap-3 rounded-lg border border-status-error-border bg-status-error-bg px-4 py-3">
<CircleX className="h-4 w-4 text-status-error-dot" />
<div className="flex-1">
<p className="text-sm font-medium text-status-error-fg">
Preview failed
</p>
</div>
<Button size="sm" variant="outline" onClick={handleStart}>
<RotateCcw className="h-3 w-3 mr-1" />
Retry
</Button>
</div>
);
}
// No preview — show start button
return (
<Button
size="sm"
variant="outline"
onClick={handleStart}
disabled={startMutation.isPending}
>
<ExternalLink className="h-3.5 w-3.5 mr-1" />
Start Preview
</Button>
);
}

View File

@@ -94,7 +94,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
const startPreview = trpc.startPreview.useMutation({
onSuccess: (data) => {
setActivePreviewId(data.id);
toast.success(`Preview running at http://localhost:${data.port}`);
toast.success(`Preview running at ${data.url}`);
},
onError: (err) => toast.error(`Preview failed: ${err.message}`),
});
@@ -119,7 +119,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
: preview?.status === "failed"
? ("failed" as const)
: ("idle" as const),
url: preview?.port ? `http://localhost:${preview.port}` : undefined,
url: preview?.url ?? undefined,
onStart: () =>
startPreview.mutate({
initiativeId,