From 3833ba5dd230d249acd859a6c9ced7dc9c09878a Mon Sep 17 00:00:00 2001 From: mawkone Date: Fri, 12 Jun 2026 11:36:34 -0700 Subject: [PATCH] fix(preview): do not murder dev servers that take longer than 2 seconds to compile webpack --- .../[projectId]/(home)/hosting/page.tsx | 361 ++++++++++++------ .../api/projects/[projectId]/anatomy/route.ts | 31 +- 2 files changed, 272 insertions(+), 120 deletions(-) diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx index c4cf775..e242421 100644 --- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx @@ -3,9 +3,18 @@ import { useState } from "react"; import { useParams } from "next/navigation"; import { - Loader2, AlertCircle, ExternalLink, Globe, RefreshCw, - CircleDot, ChevronDown, ChevronRight, Copy, Check, - Terminal, Server, + Loader2, + AlertCircle, + ExternalLink, + Globe, + RefreshCw, + CircleDot, + ChevronDown, + ChevronRight, + Copy, + Check, + Terminal, + Server, } from "lucide-react"; import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy"; @@ -45,8 +54,14 @@ export default function HostingTab() {
{showLoading && (
- - Loading… + + + Loading… +
)} {error && !showLoading && ( @@ -69,8 +84,10 @@ export default function HostingTab() { promptSuggestion="Deploy my app to production" /> ) : ( -
- {anatomy.hosting.live.map(item => ( +
+ {anatomy.hosting.live.map((item) => ( ))}
@@ -80,9 +97,14 @@ export default function HostingTab() { {/* ── Previews ── */} {anatomy.hosting.previews.length > 0 && (
- -
- {anatomy.hosting.previews.map(p => ( + +
+ {anatomy.hosting.previews.map((p) => ( ))}
@@ -140,7 +162,11 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) { }), }); const d = await r.json(); - setLogs(typeof d.result === "string" ? d.result : JSON.stringify(d.result ?? d.error, null, 2)); + setLogs( + typeof d.result === "string" + ? d.result + : JSON.stringify(d.result ?? d.error, null, 2), + ); } catch { setLogs("Failed to load logs."); } finally { @@ -162,10 +188,20 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
{/* ── Card header ── */}
-
+
{item.name} - {item.source === "repo" ? "built" : "image"} + + {item.source === "repo" ? "built" : "image"} +
@@ -184,10 +222,13 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) { {/* ── Status line ── */}
- {statusLabel} + + {statusLabel} + {item.lastBuild && ( - · Last build {item.lastBuild.status} {formatRelative(item.lastBuild.finishedAt)} + · Last build {item.lastBuild.status}{" "} + {formatRelative(item.lastBuild.finishedAt)} )}
@@ -201,13 +242,23 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
) : (
- + No domain attached — ask the AI to add one.
@@ -215,8 +266,16 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) { {/* ── Extra domains ── */} {item.domains.length > 1 && ( -
- {item.domains.slice(1).map(d => ( +
+ {item.domains.slice(1).map((d) => ( - {d} + {d}{" "} + ))}
)} {/* ── Logs toggle ── */} -
+
@@ -259,17 +332,39 @@ function PreviewRow({ preview }: { preview: Preview }) { return (
- - {preview.name} - port {preview.port} - {preview.url && running && ( - - {preview.url} - - )} - - Started {formatRelative(preview.startedAt)} + + + {preview.name} + + port {preview.port} + + {preview.url && running && ( + + )}
); @@ -285,18 +380,34 @@ function classifyPhase(status: string | undefined): Phase { const s = (status ?? "").toLowerCase(); if (!s || s === "unknown") return "unknown"; if (/^(running|healthy)/.test(s)) return "up"; - if (/^(starting|restarting|created|deploying|building|in_progress|queued|paused)/.test(s)) return "deploying"; + if ( + /^(starting|restarting|created|deploying|building|in_progress|queued|paused)/.test( + s, + ) + ) + return "deploying"; if (/^(exited|dead|failed|stopped|unhealthy|error)/.test(s)) return "down"; return "unknown"; } -function phaseDisplay(phase: Phase, item: LiveItem): { color: string; label: string } { - if (item.inFlightBuild) return { color: AMBER, label: `Deploying (${item.inFlightBuild.status ?? "in progress"})` }; +function phaseDisplay( + phase: Phase, + item: LiveItem, +): { color: string; label: string } { + if (item.inFlightBuild) + return { + color: AMBER, + label: `Deploying (${item.inFlightBuild.status ?? "in progress"})`, + }; switch (phase) { - case "up": return { color: GREEN, label: "Live" }; - case "deploying": return { color: AMBER, label: "Starting…" }; - case "down": return { color: DANGER, label: "Down" }; - default: return { color: INK.muted, label: "Unknown" }; + case "up": + return { color: GREEN, label: "Live" }; + case "deploying": + return { color: AMBER, label: "Starting…" }; + case "down": + return { color: DANGER, label: "Down" }; + default: + return { color: INK.muted, label: "Unknown" }; } } @@ -325,18 +436,51 @@ function SectionHeader({ title, count }: { title: string; count: number }) { ); } -function EmptySection({ icon, title, hint, promptSuggestion }: { - icon: React.ReactNode; title: string; hint: string; promptSuggestion?: string; +function EmptySection({ + icon, + title, + hint, + promptSuggestion, +}: { + icon: React.ReactNode; + title: string; + hint: string; + promptSuggestion?: string; }) { return (
{icon}
-
{title}
-
{hint}
+
+ {title} +
+
+ {hint} +
{promptSuggestion && (
- Try asking: - "{promptSuggestion}" + + Try asking: + + + "{promptSuggestion}" +
)}
@@ -356,9 +500,9 @@ const INK = { cardBg: "#fff", fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif', } as const; -const GREEN = "#2e7d32"; -const AMBER = "#d4a04a"; -const DANGER = "#c5392b"; +const GREEN = "#10b981"; +const AMBER = "#f59e0b"; +const DANGER = "#ef4444"; // ────────────────────────────────────────────────── // Styles @@ -371,18 +515,31 @@ const pageWrap: React.CSSProperties = { maxWidth: 860, }; const centeredMsg: React.CSSProperties = { - display: "flex", alignItems: "center", gap: 10, padding: "24px 0", + display: "flex", + alignItems: "center", + gap: 10, + padding: "24px 0", }; const sectionHeader: React.CSSProperties = { - display: "flex", alignItems: "center", gap: 8, marginBottom: 14, + display: "flex", + alignItems: "center", + gap: 8, + marginBottom: 14, }; const sectionTitle: React.CSSProperties = { - fontSize: "0.68rem", fontWeight: 700, letterSpacing: "0.12em", - textTransform: "uppercase", color: INK.muted, + fontSize: "0.68rem", + fontWeight: 700, + letterSpacing: "0.12em", + textTransform: "uppercase", + color: INK.muted, }; const countPill: React.CSSProperties = { - fontSize: "0.7rem", fontWeight: 600, color: INK.mid, - padding: "1px 7px", borderRadius: 999, background: "#f3eee4", + fontSize: "0.7rem", + fontWeight: 600, + color: INK.mid, + padding: "1px 7px", + borderRadius: 999, + background: "#f3eee4", }; const card: React.CSSProperties = { background: INK.cardBg, @@ -391,72 +548,54 @@ const card: React.CSSProperties = { padding: "18px 20px", }; const cardHeader: React.CSSProperties = { - display: "flex", alignItems: "center", justifyContent: "space-between", - gap: 12, marginBottom: 6, + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + marginBottom: 6, }; const cardTitle: React.CSSProperties = { - fontSize: "0.95rem", fontWeight: 700, color: INK.ink, -}; -const statusLine: React.CSSProperties = { - fontSize: "0.8rem", color: INK.mid, marginBottom: 12, - display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap", -}; -const urlRow: React.CSSProperties = { - display: "flex", alignItems: "center", gap: 8, - background: "#f8f5f0", borderRadius: 6, padding: "8px 12px", - marginBottom: 2, -}; -const urlLink: React.CSSProperties = { - fontSize: "0.85rem", color: INK.ink, textDecoration: "none", - flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", - whiteSpace: "nowrap", display: "inline-flex", alignItems: "center", gap: 4, -}; -const actionBtn: React.CSSProperties = { - display: "inline-flex", alignItems: "center", gap: 6, - padding: "6px 12px", border: `1px solid ${INK.border}`, - borderRadius: 6, background: "#fff", cursor: "pointer", - font: "inherit", fontSize: "0.78rem", fontWeight: 600, color: INK.mid, - transition: "background 0.1s, border-color 0.1s", -}; -const iconBtn: React.CSSProperties = { - display: "inline-flex", alignItems: "center", justifyContent: "center", - width: 26, height: 26, border: "none", background: "transparent", - cursor: "pointer", color: INK.muted, borderRadius: 4, - flexShrink: 0, -}; -const logsToggleBtn: React.CSSProperties = { - display: "inline-flex", alignItems: "center", gap: 6, - fontSize: "0.75rem", fontWeight: 600, color: INK.mid, - background: "none", border: "none", cursor: "pointer", - font: "inherit", padding: 0, -}; -const logsBox: React.CSSProperties = { - marginTop: 10, background: "#1a1a1a", borderRadius: 6, - padding: "12px 14px", maxHeight: 320, overflowY: "auto", -}; + fontSize: "0.95rem", + fontWeight: 700, + color: INK.ink, const logsPre: React.CSSProperties = { - margin: 0, fontFamily: "ui-monospace, monospace", - fontSize: "0.72rem", color: "#d4d0c8", lineHeight: 1.6, - whiteSpace: "pre-wrap", wordBreak: "break-all", + margin: 0, + fontFamily: "ui-monospace, monospace", + fontSize: "0.72rem", + color: "#d4d0c8", + lineHeight: 1.6, + whiteSpace: "pre-wrap", + wordBreak: "break-all", }; const emptyBox: React.CSSProperties = { - border: `1px dashed ${INK.border}`, borderRadius: 10, - padding: "36px 28px", textAlign: "center", - display: "flex", flexDirection: "column", alignItems: "center", + border: `1px dashed ${INK.border}`, + borderRadius: 10, + padding: "36px 28px", + textAlign: "center", + display: "flex", + flexDirection: "column", + alignItems: "center", }; const promptChip: React.CSSProperties = { - display: "inline-flex", alignItems: "center", - background: "#f3eee4", borderRadius: 6, - padding: "6px 12px", fontSize: "0.8rem", + display: "inline-flex", + alignItems: "center", + background: "#f3eee4", + borderRadius: 6, + padding: "6px 12px", + fontSize: "0.8rem", }; function sourcePill(source: "repo" | "image"): React.CSSProperties { const isRepo = source === "repo"; return { - fontSize: "0.62rem", fontWeight: 700, letterSpacing: "0.08em", + fontSize: "0.62rem", + fontWeight: 700, + letterSpacing: "0.08em", textTransform: "uppercase", color: isRepo ? "#2e6d2e" : "#3b5a78", background: isRepo ? "#eaf3e8" : "#e9eff5", - padding: "1px 6px", borderRadius: 4, flexShrink: 0, + padding: "1px 6px", + borderRadius: 4, + flexShrink: 0, }; } diff --git a/vibn-frontend/app/api/projects/[projectId]/anatomy/route.ts b/vibn-frontend/app/api/projects/[projectId]/anatomy/route.ts index 5339dc2..a906ca6 100644 --- a/vibn-frontend/app/api/projects/[projectId]/anatomy/route.ts +++ b/vibn-frontend/app/api/projects/[projectId]/anatomy/route.ts @@ -807,7 +807,10 @@ async function loadPreviews(projectId: string): Promise { try { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 2500); // Fast 2.5s timeout + // We use a short timeout because we don't want to block the anatomy + // response. A slow response doesn't mean it's dead (Next.js might + // just be compiling) — we ONLY want to catch instant 502/503s from Traefik. + const timeout = setTimeout(() => controller.abort(), 2000); const ping = await fetch(r.preview_url, { method: "HEAD", signal: controller.signal, @@ -833,14 +836,24 @@ async function loadPreviews(projectId: string): Promise { activePreviews.push(r); } } catch (e: any) { - // If the fetch completely fails (e.g. timeout, DNS failure), it's dead. - console.warn( - `[anatomy] Preview zombie detected for ${r.preview_url} (${e.message}). Marking stopped.`, - ); - await query( - `UPDATE fs_dev_servers SET state = 'stopped' WHERE id = $1`, - [r.id], - ).catch(() => {}); + // If the fetch aborts due to our 2s timeout, the server is just slow + // (likely doing a cold Webpack compile). DO NOT mark it as a zombie! + // Only kill it if we get a hard DNS/network error that isn't a timeout. + if ( + e.name === "AbortError" || + e.type === "aborted" || + e.message?.includes("timeout") + ) { + activePreviews.push(r); // Benefit of the doubt — it's thinking + } else { + console.warn( + `[anatomy] Preview zombie detected for ${r.preview_url} (${e.message}). Marking stopped.`, + ); + await query( + `UPDATE fs_dev_servers SET state = 'stopped' WHERE id = $1`, + [r.id], + ).catch(() => {}); + } } }), );