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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user