+
@@ -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 5339dc21..a906ca66 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(() => {});
+ }
}
}),
);