fix(preview): do not murder dev servers that take longer than 2 seconds to compile webpack

This commit is contained in:
2026-06-12 11:36:34 -07:00
parent 2e66ea087b
commit 3833ba5dd2
2 changed files with 272 additions and 120 deletions

View File

@@ -3,9 +3,18 @@
import { useState } from "react"; import { useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { import {
Loader2, AlertCircle, ExternalLink, Globe, RefreshCw, Loader2,
CircleDot, ChevronDown, ChevronRight, Copy, Check, AlertCircle,
Terminal, Server, ExternalLink,
Globe,
RefreshCw,
CircleDot,
ChevronDown,
ChevronRight,
Copy,
Check,
Terminal,
Server,
} from "lucide-react"; } from "lucide-react";
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy"; import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
@@ -45,8 +54,14 @@ export default function HostingTab() {
<div style={pageWrap}> <div style={pageWrap}>
{showLoading && ( {showLoading && (
<div style={centeredMsg}> <div style={centeredMsg}>
<Loader2 size={16} className="animate-spin" style={{ color: INK.muted }} /> <Loader2
<span style={{ color: INK.muted, fontSize: "0.85rem" }}>Loading</span> size={16}
className="animate-spin"
style={{ color: INK.muted }}
/>
<span style={{ color: INK.muted, fontSize: "0.85rem" }}>
Loading
</span>
</div> </div>
)} )}
{error && !showLoading && ( {error && !showLoading && (
@@ -69,8 +84,10 @@ export default function HostingTab() {
promptSuggestion="Deploy my app to production" promptSuggestion="Deploy my app to production"
/> />
) : ( ) : (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}> <div
{anatomy.hosting.live.map(item => ( style={{ display: "flex", flexDirection: "column", gap: 16 }}
>
{anatomy.hosting.live.map((item) => (
<LiveCard key={item.uuid} item={item} projectId={projectId} /> <LiveCard key={item.uuid} item={item} projectId={projectId} />
))} ))}
</div> </div>
@@ -80,9 +97,14 @@ export default function HostingTab() {
{/* ── Previews ── */} {/* ── Previews ── */}
{anatomy.hosting.previews.length > 0 && ( {anatomy.hosting.previews.length > 0 && (
<section style={{ marginTop: 40 }}> <section style={{ marginTop: 40 }}>
<SectionHeader title="Dev Previews" count={anatomy.hosting.previews.length} /> <SectionHeader
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> title="Dev Previews"
{anatomy.hosting.previews.map(p => ( count={anatomy.hosting.previews.length}
/>
<div
style={{ display: "flex", flexDirection: "column", gap: 10 }}
>
{anatomy.hosting.previews.map((p) => (
<PreviewRow key={p.id} preview={p} /> <PreviewRow key={p.id} preview={p} />
))} ))}
</div> </div>
@@ -140,7 +162,11 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
}), }),
}); });
const d = await r.json(); 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 { } catch {
setLogs("Failed to load logs."); setLogs("Failed to load logs.");
} finally { } finally {
@@ -162,10 +188,20 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
<div style={card}> <div style={card}>
{/* ── Card header ── */} {/* ── Card header ── */}
<div style={cardHeader}> <div style={cardHeader}>
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0, flex: 1 }}> <div
style={{
display: "flex",
alignItems: "center",
gap: 10,
minWidth: 0,
flex: 1,
}}
>
<CircleDot size={11} style={{ color: statusColor, flexShrink: 0 }} /> <CircleDot size={11} style={{ color: statusColor, flexShrink: 0 }} />
<span style={cardTitle}>{item.name}</span> <span style={cardTitle}>{item.name}</span>
<span style={sourcePill(item.source)}>{item.source === "repo" ? "built" : "image"}</span> <span style={sourcePill(item.source)}>
{item.source === "repo" ? "built" : "image"}
</span>
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}> <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button <button
@@ -174,9 +210,11 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
style={actionBtn} style={actionBtn}
title="Redeploy now" title="Redeploy now"
> >
{deploying {deploying ? (
? <Loader2 size={13} className="animate-spin" /> <Loader2 size={13} className="animate-spin" />
: <RefreshCw size={13} />} ) : (
<RefreshCw size={13} />
)}
{deploying ? "Deploying…" : "Redeploy"} {deploying ? "Deploying…" : "Redeploy"}
</button> </button>
</div> </div>
@@ -184,10 +222,13 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
{/* ── Status line ── */} {/* ── Status line ── */}
<div style={statusLine}> <div style={statusLine}>
<span style={{ color: statusColor, fontWeight: 600 }}>{statusLabel}</span> <span style={{ color: statusColor, fontWeight: 600 }}>
{statusLabel}
</span>
{item.lastBuild && ( {item.lastBuild && (
<span style={{ color: INK.muted }}> <span style={{ color: INK.muted }}>
· Last build {item.lastBuild.status} {formatRelative(item.lastBuild.finishedAt)} · Last build {item.lastBuild.status}{" "}
{formatRelative(item.lastBuild.finishedAt)}
</span> </span>
)} )}
</div> </div>
@@ -201,13 +242,23 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
</a> </a>
<ExternalLink size={11} style={{ color: INK.muted, flexShrink: 0 }} /> <ExternalLink size={11} style={{ color: INK.muted, flexShrink: 0 }} />
<button onClick={copyUrl} style={iconBtn} title="Copy URL"> <button onClick={copyUrl} style={iconBtn} title="Copy URL">
{copied ? <Check size={12} style={{ color: "#2e7d32" }} /> : <Copy size={12} />} {copied ? (
<Check size={12} style={{ color: "#2e7d32" }} />
) : (
<Copy size={12} />
)}
</button> </button>
</div> </div>
) : ( ) : (
<div style={urlRow}> <div style={urlRow}>
<Globe size={13} style={{ color: INK.muted, flexShrink: 0 }} /> <Globe size={13} style={{ color: INK.muted, flexShrink: 0 }} />
<span style={{ color: INK.muted, fontSize: "0.82rem", fontStyle: "italic" }}> <span
style={{
color: INK.muted,
fontSize: "0.82rem",
fontStyle: "italic",
}}
>
No domain attached ask the AI to add one. No domain attached ask the AI to add one.
</span> </span>
</div> </div>
@@ -215,8 +266,16 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
{/* ── Extra domains ── */} {/* ── Extra domains ── */}
{item.domains.length > 1 && ( {item.domains.length > 1 && (
<div style={{ paddingLeft: 23, display: "flex", flexDirection: "column", gap: 4, marginTop: 4 }}> <div
{item.domains.slice(1).map(d => ( style={{
paddingLeft: 23,
display: "flex",
flexDirection: "column",
gap: 4,
marginTop: 4,
}}
>
{item.domains.slice(1).map((d) => (
<a <a
key={d} key={d}
href={`https://${d}`} href={`https://${d}`}
@@ -224,14 +283,24 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
rel="noreferrer" rel="noreferrer"
style={{ ...urlLink, fontSize: "0.78rem", color: INK.mid }} style={{ ...urlLink, fontSize: "0.78rem", color: INK.mid }}
> >
{d} <ExternalLink size={10} style={{ display: "inline", verticalAlign: "middle" }} /> {d}{" "}
<ExternalLink
size={10}
style={{ display: "inline", verticalAlign: "middle" }}
/>
</a> </a>
))} ))}
</div> </div>
)} )}
{/* ── Logs toggle ── */} {/* ── Logs toggle ── */}
<div style={{ marginTop: 14, borderTop: `1px solid ${INK.borderSoft}`, paddingTop: 10 }}> <div
style={{
marginTop: 14,
borderTop: `1px solid ${INK.borderSoft}`,
paddingTop: 10,
}}
>
<button onClick={openLogs} style={logsToggleBtn}> <button onClick={openLogs} style={logsToggleBtn}>
<Terminal size={12} /> <Terminal size={12} />
{logsOpen ? "Hide logs" : "Show recent logs"} {logsOpen ? "Hide logs" : "Show recent logs"}
@@ -240,9 +309,13 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
{logsOpen && ( {logsOpen && (
<div style={logsBox}> <div style={logsBox}>
{logsLoading {logsLoading ? (
? <span style={{ color: INK.muted, fontSize: "0.8rem" }}>Loading</span> <span style={{ color: INK.muted, fontSize: "0.8rem" }}>
: <pre style={logsPre}>{logs || "(no logs)"}</pre>} Loading
</span>
) : (
<pre style={logsPre}>{logs || "(no logs)"}</pre>
)}
</div> </div>
)} )}
</div> </div>
@@ -259,17 +332,39 @@ function PreviewRow({ preview }: { preview: Preview }) {
return ( return (
<div style={{ ...card, padding: "12px 16px" }}> <div style={{ ...card, padding: "12px 16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}> <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<CircleDot size={10} style={{ color: running ? "#2e7d32" : INK.muted, flexShrink: 0 }} /> <CircleDot
<span style={{ fontSize: "0.85rem", fontWeight: 600, color: INK.ink }}>{preview.name}</span> size={10}
<span style={{ fontSize: "0.75rem", color: INK.mid }}>port {preview.port}</span> style={{ color: running ? "#10b981" : INK.muted, flexShrink: 0 }}
{preview.url && running && ( />
<a href={preview.url} target="_blank" rel="noreferrer" style={urlLink}> <span style={{ fontSize: "0.85rem", fontWeight: 600, color: INK.ink }}>
{preview.url} <ExternalLink size={10} style={{ display: "inline", verticalAlign: "middle" }} /> {preview.name}
</a>
)}
<span style={{ marginLeft: "auto", fontSize: "0.75rem", color: INK.muted }}>
Started {formatRelative(preview.startedAt)}
</span> </span>
<span style={{ fontSize: "0.75rem", color: INK.mid }}>
port {preview.port}
</span>
{preview.url && running && (
<div
style={{
marginLeft: "auto",
display: "flex",
gap: 8,
alignItems: "center",
}}
>
<a
href={preview.url}
target="_blank"
rel="noreferrer"
style={{ ...urlLink, marginLeft: 0 }}
>
{preview.url.replace(/^https?:\/\//, "")}{" "}
<ExternalLink
size={10}
style={{ display: "inline", verticalAlign: "middle" }}
/>
</a>
</div>
)}
</div> </div>
</div> </div>
); );
@@ -285,18 +380,34 @@ function classifyPhase(status: string | undefined): Phase {
const s = (status ?? "").toLowerCase(); const s = (status ?? "").toLowerCase();
if (!s || s === "unknown") return "unknown"; if (!s || s === "unknown") return "unknown";
if (/^(running|healthy)/.test(s)) return "up"; 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"; if (/^(exited|dead|failed|stopped|unhealthy|error)/.test(s)) return "down";
return "unknown"; return "unknown";
} }
function phaseDisplay(phase: Phase, item: LiveItem): { color: string; label: string } { function phaseDisplay(
if (item.inFlightBuild) return { color: AMBER, label: `Deploying (${item.inFlightBuild.status ?? "in progress"})` }; phase: Phase,
item: LiveItem,
): { color: string; label: string } {
if (item.inFlightBuild)
return {
color: AMBER,
label: `Deploying (${item.inFlightBuild.status ?? "in progress"})`,
};
switch (phase) { switch (phase) {
case "up": return { color: GREEN, label: "Live" }; case "up":
case "deploying": return { color: AMBER, label: "Starting…" }; return { color: GREEN, label: "Live" };
case "down": return { color: DANGER, label: "Down" }; case "deploying":
default: return { color: INK.muted, label: "Unknown" }; 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 }: { function EmptySection({
icon: React.ReactNode; title: string; hint: string; promptSuggestion?: string; icon,
title,
hint,
promptSuggestion,
}: {
icon: React.ReactNode;
title: string;
hint: string;
promptSuggestion?: string;
}) { }) {
return ( return (
<div style={emptyBox}> <div style={emptyBox}>
<div style={{ marginBottom: 10 }}>{icon}</div> <div style={{ marginBottom: 10 }}>{icon}</div>
<div style={{ fontWeight: 600, fontSize: "0.9rem", color: INK.ink, marginBottom: 6 }}>{title}</div> <div
<div style={{ fontSize: "0.82rem", color: INK.mid, marginBottom: promptSuggestion ? 14 : 0 }}>{hint}</div> style={{
fontWeight: 600,
fontSize: "0.9rem",
color: INK.ink,
marginBottom: 6,
}}
>
{title}
</div>
<div
style={{
fontSize: "0.82rem",
color: INK.mid,
marginBottom: promptSuggestion ? 14 : 0,
}}
>
{hint}
</div>
{promptSuggestion && ( {promptSuggestion && (
<div style={promptChip}> <div style={promptChip}>
<span style={{ fontSize: "0.7rem", color: INK.muted, marginRight: 6 }}>Try asking:</span> <span
<span style={{ fontStyle: "italic", fontSize: "0.8rem", color: INK.mid }}>"{promptSuggestion}"</span> style={{ fontSize: "0.7rem", color: INK.muted, marginRight: 6 }}
>
Try asking:
</span>
<span
style={{ fontStyle: "italic", fontSize: "0.8rem", color: INK.mid }}
>
"{promptSuggestion}"
</span>
</div> </div>
)} )}
</div> </div>
@@ -356,9 +500,9 @@ const INK = {
cardBg: "#fff", cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif', fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
} as const; } as const;
const GREEN = "#2e7d32"; const GREEN = "#10b981";
const AMBER = "#d4a04a"; const AMBER = "#f59e0b";
const DANGER = "#c5392b"; const DANGER = "#ef4444";
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
// Styles // Styles
@@ -371,18 +515,31 @@ const pageWrap: React.CSSProperties = {
maxWidth: 860, maxWidth: 860,
}; };
const centeredMsg: React.CSSProperties = { 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 = { const sectionHeader: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 8, marginBottom: 14, display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 14,
}; };
const sectionTitle: React.CSSProperties = { const sectionTitle: React.CSSProperties = {
fontSize: "0.68rem", fontWeight: 700, letterSpacing: "0.12em", fontSize: "0.68rem",
textTransform: "uppercase", color: INK.muted, fontWeight: 700,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: INK.muted,
}; };
const countPill: React.CSSProperties = { const countPill: React.CSSProperties = {
fontSize: "0.7rem", fontWeight: 600, color: INK.mid, fontSize: "0.7rem",
padding: "1px 7px", borderRadius: 999, background: "#f3eee4", fontWeight: 600,
color: INK.mid,
padding: "1px 7px",
borderRadius: 999,
background: "#f3eee4",
}; };
const card: React.CSSProperties = { const card: React.CSSProperties = {
background: INK.cardBg, background: INK.cardBg,
@@ -391,72 +548,54 @@ const card: React.CSSProperties = {
padding: "18px 20px", padding: "18px 20px",
}; };
const cardHeader: React.CSSProperties = { const cardHeader: React.CSSProperties = {
display: "flex", alignItems: "center", justifyContent: "space-between", display: "flex",
gap: 12, marginBottom: 6, alignItems: "center",
justifyContent: "space-between",
gap: 12,
marginBottom: 6,
}; };
const cardTitle: React.CSSProperties = { const cardTitle: React.CSSProperties = {
fontSize: "0.95rem", fontWeight: 700, color: INK.ink, fontSize: "0.95rem",
}; fontWeight: 700,
const statusLine: React.CSSProperties = { color: INK.ink,
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",
};
const logsPre: React.CSSProperties = { const logsPre: React.CSSProperties = {
margin: 0, fontFamily: "ui-monospace, monospace", margin: 0,
fontSize: "0.72rem", color: "#d4d0c8", lineHeight: 1.6, fontFamily: "ui-monospace, monospace",
whiteSpace: "pre-wrap", wordBreak: "break-all", fontSize: "0.72rem",
color: "#d4d0c8",
lineHeight: 1.6,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
}; };
const emptyBox: React.CSSProperties = { const emptyBox: React.CSSProperties = {
border: `1px dashed ${INK.border}`, borderRadius: 10, border: `1px dashed ${INK.border}`,
padding: "36px 28px", textAlign: "center", borderRadius: 10,
display: "flex", flexDirection: "column", alignItems: "center", padding: "36px 28px",
textAlign: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
}; };
const promptChip: React.CSSProperties = { const promptChip: React.CSSProperties = {
display: "inline-flex", alignItems: "center", display: "inline-flex",
background: "#f3eee4", borderRadius: 6, alignItems: "center",
padding: "6px 12px", fontSize: "0.8rem", background: "#f3eee4",
borderRadius: 6,
padding: "6px 12px",
fontSize: "0.8rem",
}; };
function sourcePill(source: "repo" | "image"): React.CSSProperties { function sourcePill(source: "repo" | "image"): React.CSSProperties {
const isRepo = source === "repo"; const isRepo = source === "repo";
return { return {
fontSize: "0.62rem", fontWeight: 700, letterSpacing: "0.08em", fontSize: "0.62rem",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase", textTransform: "uppercase",
color: isRepo ? "#2e6d2e" : "#3b5a78", color: isRepo ? "#2e6d2e" : "#3b5a78",
background: isRepo ? "#eaf3e8" : "#e9eff5", background: isRepo ? "#eaf3e8" : "#e9eff5",
padding: "1px 6px", borderRadius: 4, flexShrink: 0, padding: "1px 6px",
borderRadius: 4,
flexShrink: 0,
}; };
} }

View File

@@ -807,7 +807,10 @@ async function loadPreviews(projectId: string): Promise<Preview[]> {
try { try {
const controller = new AbortController(); 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, { const ping = await fetch(r.preview_url, {
method: "HEAD", method: "HEAD",
signal: controller.signal, signal: controller.signal,
@@ -833,14 +836,24 @@ async function loadPreviews(projectId: string): Promise<Preview[]> {
activePreviews.push(r); activePreviews.push(r);
} }
} catch (e: any) { } catch (e: any) {
// If the fetch completely fails (e.g. timeout, DNS failure), it's dead. // If the fetch aborts due to our 2s timeout, the server is just slow
console.warn( // (likely doing a cold Webpack compile). DO NOT mark it as a zombie!
`[anatomy] Preview zombie detected for ${r.preview_url} (${e.message}). Marking stopped.`, // Only kill it if we get a hard DNS/network error that isn't a timeout.
); if (
await query( e.name === "AbortError" ||
`UPDATE fs_dev_servers SET state = 'stopped' WHERE id = $1`, e.type === "aborted" ||
[r.id], e.message?.includes("timeout")
).catch(() => {}); ) {
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(() => {});
}
} }
}), }),
); );