fix(preview): do not murder dev servers that take longer than 2 seconds to compile webpack
This commit is contained in:
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user