fix(prod): ESM transpile, healthcheck, hosting UX, settings, error msgs

- next.config.ts: add react-markdown + entire unified/remark/rehype
  ecosystem to transpilePackages — fixes TypeError 'z'/'j'/'aa' prod
  crashes caused by ESM-only packages not being bundled for webpack
- Dockerfile: bake HEALTHCHECK --start-period=60s on 127.0.0.1 so
  rolling deploys pass on first health probe (was failing on ::1 IPv6)
- Hosting tab: full rewrite — live URL chip, copy button, redeploy
  button, inline log viewer, domain list, empty state with prompt
  nudge. Single-card layout replaces master-detail for 1-3 endpoints.
- Settings page: new /project/:id/settings route with danger zone +
  typed "delete" confirmation for project deletion
- Status pill: "View logs" link appears on build failures
- URL chips: collapse extras into "+N more" pill when >2 visible
- Chat errors: structured "Tool error:" prefix; network errors
  distinguished from server errors

Made-with: Cursor
This commit is contained in:
2026-04-30 17:12:48 -07:00
parent 73b672f2c9
commit 41fbed31f3
7 changed files with 736 additions and 350 deletions

View File

@@ -59,5 +59,13 @@ EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Use 127.0.0.1 explicitly — "localhost" resolves to ::1 (IPv6) first
# inside Alpine, but Next.js only binds 0.0.0.0 (IPv4), causing
# Coolify's health-check wget to get "Connection refused" even though
# the server is healthy. start-period covers the DB-init DDL in
# entrypoint.sh (~5-10s) plus Next.js startup (~1-2s).
HEALTHCHECK --interval=10s --timeout=5s --start-period=60s --retries=10 \
CMD wget -qO- http://127.0.0.1:3000/ > /dev/null || exit 1
ENTRYPOINT ["./entrypoint.sh"]

View File

@@ -3,267 +3,274 @@
import { useState } from "react";
import { useParams } from "next/navigation";
import {
Loader2, AlertCircle, ExternalLink, Cloud, Container, Zap, CircleDot,
Loader2, AlertCircle, ExternalLink, Globe, RefreshCw,
CircleDot, ChevronDown, ChevronRight, Copy, Check,
Terminal, Server,
} from "lucide-react";
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
/**
* Hosting tab — runtime + reachability, unified.
* Hosting tab — user-facing: "Is my thing live? How do I reach it?"
*
* Two sub-areas in the left rail:
* - Live = every running endpoint (repo-built apps + image-based
* services). Each item shows its source badge, a status
* dot, attached domain, and last-build time inline.
* - Previews = active dev container preview URLs.
* One endpoint = one card. Each card shows:
* - Live URL (open in new tab)
* - Status dot + plain-language status
* - Redeploy button
* - Domain(s) list
* - Last build (time + status)
* - Expandable recent logs
*
* No separate Build, Domains or Services categories — those concepts
* live as properties on each Live item (build = lastBuild, domain =
* fqdn/domains[]).
* No master-detail split — with 1-3 services the overhead isn't worth it.
* Previews (dev server URLs) shown below in a secondary section.
*/
type Selection =
| { kind: "live"; uuid: string }
| { kind: "preview"; id: string }
| null;
// ──────────────────────────────────────────────────
// Types
// ──────────────────────────────────────────────────
type LiveItem = Anatomy["hosting"]["live"][number];
type Preview = Anatomy["hosting"]["previews"][number];
// ──────────────────────────────────────────────────
// Main component
// ──────────────────────────────────────────────────
export default function HostingTab() {
const params = useParams();
const projectId = params.projectId as string;
const { anatomy, loading, error } = useAnatomy(projectId);
const [selection, setSelection] = useState<Selection>(null);
const { anatomy, loading, error } = useAnatomy(projectId, { pollMs: 8000 });
const showLoading = loading && !anatomy;
return (
<div style={pageWrap}>
<div style={grid}>
{/* ── Left rail ── */}
<section style={leftCol}>
{showLoading && (
<Inline><Loader2 size={13} className="animate-spin" /> Loading</Inline>
)}
{error && !showLoading && (
<Inline><AlertCircle size={13} /> {error}</Inline>
)}
{anatomy && (
<>
<RailGroup
title="Live"
count={anatomy.hosting.live.length}
emptyHint="Anything you deploy — your app or an image like Twenty CRM — shows up here."
>
{anatomy.hosting.live.map(item => {
const active = selection?.kind === "live" && selection.uuid === item.uuid;
const Icon = item.source === "repo" ? Cloud : Container;
return (
<button
key={item.uuid}
type="button"
onClick={() => setSelection({ kind: "live", uuid: item.uuid })}
style={{
...railItem,
borderColor: active ? INK.ink : INK.borderSoft,
boxShadow: active ? `0 0 0 1px ${INK.ink}` : "none",
background: active ? "#fffdf8" : INK.cardBg,
}}
aria-pressed={active}
>
<Icon size={13} style={{ color: INK.mid, flexShrink: 0 }} />
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
<div style={tileLabel}>{item.name}</div>
<div style={tileSubLine}>
<span style={sourceBadge(item.source)}>
{item.source === "repo" ? "repo" : "image"}
</span>
<span style={tileHint}>
{item.fqdn ?? item.sourceLabel}
</span>
</div>
<div style={tileMetaLine}>
{item.source === "repo" && item.lastBuild
? `${item.lastBuild.status} · ${formatRelative(item.lastBuild.finishedAt)}`
: item.source === "image" && item.sourceLabel}
</div>
</div>
<CircleDot size={9} style={{ color: statusColor(item.status), flexShrink: 0 }} />
</button>
);
})}
</RailGroup>
{showLoading && (
<div style={centeredMsg}>
<Loader2 size={16} className="animate-spin" style={{ color: INK.muted }} />
<span style={{ color: INK.muted, fontSize: "0.85rem" }}>Loading</span>
</div>
)}
{error && !showLoading && (
<div style={centeredMsg}>
<AlertCircle size={15} style={{ color: DANGER }} />
<span style={{ fontSize: "0.85rem", color: DANGER }}>{error}</span>
</div>
)}
<RailGroup
title="Previews"
count={anatomy.hosting.previews.length}
emptyHint="Dev servers started from chat get a temporary preview URL here."
>
{anatomy.hosting.previews.map(p => {
const active = selection?.kind === "preview" && selection.id === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => setSelection({ kind: "preview", id: p.id })}
style={{
...railItem,
borderColor: active ? INK.ink : INK.borderSoft,
boxShadow: active ? `0 0 0 1px ${INK.ink}` : "none",
background: active ? "#fffdf8" : INK.cardBg,
}}
aria-pressed={active}
>
<Zap size={13} style={{ color: INK.mid, flexShrink: 0 }} />
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
<div style={tileLabel}>{p.name} · port {p.port}</div>
<div style={tileHint}>{hostOf(p.url)}</div>
</div>
<CircleDot
size={9}
style={{ color: p.state === "running" ? "#2e7d32" : "#a09a90", flexShrink: 0 }}
/>
</button>
);
})}
</RailGroup>
</>
)}
</section>
{anatomy && (
<>
{/* ── Live endpoints ── */}
<section>
<SectionHeader title="Live" count={anatomy.hosting.live.length} />
{anatomy.hosting.live.length === 0 ? (
<EmptySection
icon={<Server size={20} style={{ color: INK.muted }} />}
title="Nothing deployed yet"
hint="Ask the AI to deploy your app and it will appear here."
promptSuggestion="Deploy my app to production"
/>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{anatomy.hosting.live.map(item => (
<LiveCard key={item.uuid} item={item} projectId={projectId} />
))}
</div>
)}
</section>
{/* ── Right pane ── */}
<aside style={rightCol}>
<h3 style={heading}>{paneHeading(selection, anatomy)}</h3>
<div style={panel}>
{anatomy && selection
? <Detail selection={selection} anatomy={anatomy} />
: <Empty>Pick something on the left to see its details.</Empty>}
{/* ── Previews ── */}
{anatomy.hosting.previews.length > 0 && (
<section style={{ marginTop: 40 }}>
<SectionHeader title="Dev Previews" count={anatomy.hosting.previews.length} />
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{anatomy.hosting.previews.map(p => (
<PreviewRow key={p.id} preview={p} />
))}
</div>
</section>
)}
</>
)}
</div>
);
}
// ──────────────────────────────────────────────────
// Live card
// ──────────────────────────────────────────────────
function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
const [deploying, setDeploying] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const [logs, setLogs] = useState<string | null>(null);
const [logsLoading, setLogsLoading] = useState(false);
const [copied, setCopied] = useState(false);
const primaryUrl = item.fqdn ? `https://${item.fqdn}` : null;
const phase = classifyPhase(item.status);
const { color: statusColor, label: statusLabel } = phaseDisplay(phase, item);
const redeploy = async () => {
if (deploying) return;
setDeploying(true);
try {
await fetch(`/api/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "apps.deploy",
params: { uuid: item.uuid, projectId },
}),
});
} finally {
setTimeout(() => setDeploying(false), 3000);
}
};
const openLogs = async () => {
if (!logsOpen) {
setLogsOpen(true);
setLogsLoading(true);
try {
const r = await fetch(`/api/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "apps.logs",
params: { uuid: item.uuid, lines: 60 },
}),
});
const d = await r.json();
setLogs(typeof d.result === "string" ? d.result : JSON.stringify(d.result ?? d.error, null, 2));
} catch {
setLogs("Failed to load logs.");
} finally {
setLogsLoading(false);
}
} else {
setLogsOpen(false);
}
};
const copyUrl = () => {
if (!primaryUrl) return;
navigator.clipboard.writeText(primaryUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div style={card}>
{/* ── Card header ── */}
<div style={cardHeader}>
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0, flex: 1 }}>
<CircleDot size={11} style={{ color: statusColor, flexShrink: 0 }} />
<span style={cardTitle}>{item.name}</span>
<span style={sourcePill(item.source)}>{item.source === "repo" ? "built" : "image"}</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button
onClick={redeploy}
disabled={deploying}
style={actionBtn}
title="Redeploy now"
>
{deploying
? <Loader2 size={13} className="animate-spin" />
: <RefreshCw size={13} />}
{deploying ? "Deploying…" : "Redeploy"}
</button>
</div>
</div>
{/* ── Status line ── */}
<div style={statusLine}>
<span style={{ color: statusColor, fontWeight: 600 }}>{statusLabel}</span>
{item.lastBuild && (
<span style={{ color: INK.muted }}>
· Last build {item.lastBuild.status} {formatRelative(item.lastBuild.finishedAt)}
</span>
)}
</div>
{/* ── Live URL ── */}
{primaryUrl ? (
<div style={urlRow}>
<Globe size={13} style={{ color: "#2e7d32", flexShrink: 0 }} />
<a href={primaryUrl} target="_blank" rel="noreferrer" style={urlLink}>
{primaryUrl}
</a>
<ExternalLink size={11} style={{ color: INK.muted, flexShrink: 0 }} />
<button onClick={copyUrl} style={iconBtn} title="Copy URL">
{copied ? <Check size={12} style={{ color: "#2e7d32" }} /> : <Copy size={12} />}
</button>
</div>
) : (
<div style={urlRow}>
<Globe size={13} style={{ color: INK.muted, flexShrink: 0 }} />
<span style={{ color: INK.muted, fontSize: "0.82rem", fontStyle: "italic" }}>
No domain attached ask the AI to add one.
</span>
</div>
)}
{/* ── Extra domains ── */}
{item.domains.length > 1 && (
<div style={{ paddingLeft: 23, display: "flex", flexDirection: "column", gap: 4, marginTop: 4 }}>
{item.domains.slice(1).map(d => (
<a
key={d}
href={`https://${d}`}
target="_blank"
rel="noreferrer"
style={{ ...urlLink, fontSize: "0.78rem", color: INK.mid }}
>
{d} <ExternalLink size={10} style={{ display: "inline", verticalAlign: "middle" }} />
</a>
))}
</div>
)}
{/* ── Logs toggle ── */}
<div style={{ marginTop: 14, borderTop: `1px solid ${INK.borderSoft}`, paddingTop: 10 }}>
<button onClick={openLogs} style={logsToggleBtn}>
<Terminal size={12} />
{logsOpen ? "Hide logs" : "Show recent logs"}
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
{logsOpen && (
<div style={logsBox}>
{logsLoading
? <span style={{ color: INK.muted, fontSize: "0.8rem" }}>Loading</span>
: <pre style={logsPre}>{logs || "(no logs)"}</pre>}
</div>
</aside>
)}
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// Detail pane
// Preview row
// ──────────────────────────────────────────────────
function Detail({ selection, anatomy }: { selection: Selection; anatomy: Anatomy }) {
if (!selection) return null;
if (selection.kind === "live") {
const item = anatomy.hosting.live.find(l => l.uuid === selection.uuid);
if (!item) return <Empty>This endpoint is no longer in the project.</Empty>;
return (
<DetailLayout>
<DetailRow label="Source" value={item.source === "repo" ? `Built from ${item.sourceLabel}` : `Image ${item.sourceLabel}`} />
<DetailRow label="Status" value={item.status} dot={statusColor(item.status)} />
{item.branch && <DetailRow label="Branch" value={item.branch} />}
{item.buildPack && <DetailRow label="Pack" value={item.buildPack} />}
{item.lastBuild && (
<DetailRow
label="Last build"
value={`${item.lastBuild.status}${item.lastBuild.finishedAt ? " · " + formatRelative(item.lastBuild.finishedAt) : ""}`}
dot={statusColor(item.lastBuild.status)}
/>
)}
{item.domains.length === 0 && (
<DetailRow label="Domain" value="— (no domain attached)" />
)}
{item.domains.map(d => (
<DetailRow key={d} label="Domain" value={d} href={`https://${d}`} />
))}
</DetailLayout>
);
}
if (selection.kind === "preview") {
const p = anatomy.hosting.previews.find(p => p.id === selection.id);
if (!p) return <Empty>This preview URL is no longer active.</Empty>;
return (
<DetailLayout>
<DetailRow label="State" value={p.state} dot={p.state === "running" ? "#2e7d32" : "#a09a90"} />
<DetailRow label="Port" value={String(p.port)} />
<DetailRow label="URL" value={hostOf(p.url)} href={p.url} />
<DetailRow label="Started" value={formatRelative(p.startedAt)} />
</DetailLayout>
);
}
return null;
}
function paneHeading(s: Selection, a: Anatomy | null): string {
if (!s || !a) return "Details";
if (s.kind === "live") return `Details · ${a.hosting.live.find(x => x.uuid === s.uuid)?.name ?? "Endpoint"}`;
if (s.kind === "preview") return `Details · ${a.hosting.previews.find(x => x.id === s.id)?.name ?? "Preview"}`;
return "Details";
}
// ──────────────────────────────────────────────────
// Bits
// ──────────────────────────────────────────────────
function RailGroup({
title, count, emptyHint, children,
}: {
title: string; count: number; emptyHint: string; children: React.ReactNode;
}) {
function PreviewRow({ preview }: { preview: Preview }) {
const running = preview.state === "running";
return (
<div style={railGroup}>
<header style={railGroupHeader}>
<span style={railGroupTitle}>{title}</span>
<span style={countPill}>{count}</span>
</header>
{count === 0 ? (
<div style={railEmpty}>{emptyHint}</div>
) : (
<div style={railItems}>{children}</div>
)}
</div>
);
}
function DetailLayout({ children }: { children: React.ReactNode }) {
return <div style={{ display: "flex", flexDirection: "column", gap: 1 }}>{children}</div>;
}
function DetailRow({
label, value, dot, href,
}: { label: string; value: string; dot?: string; href?: string }) {
return (
<div style={detailRow}>
<span style={detailLabel}>{label}</span>
<span style={detailValue}>
{dot && <CircleDot size={9} style={{ color: dot, marginRight: 6 }} />}
{href ? (
<a href={href} target="_blank" rel="noreferrer" style={detailLink}>
{value} <ExternalLink size={11} />
<div style={{ ...card, padding: "12px 16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<CircleDot size={10} style={{ color: running ? "#2e7d32" : INK.muted, flexShrink: 0 }} />
<span style={{ fontSize: "0.85rem", fontWeight: 600, color: INK.ink }}>{preview.name}</span>
<span style={{ fontSize: "0.75rem", color: INK.mid }}>port {preview.port}</span>
{preview.url && running && (
<a href={preview.url} target="_blank" rel="noreferrer" style={urlLink}>
{preview.url} <ExternalLink size={10} style={{ display: "inline", verticalAlign: "middle" }} />
</a>
) : value}
</span>
</div>
);
}
function Inline({ children }: { children: React.ReactNode }) {
return (
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "12px 14px", fontSize: "0.82rem", color: INK.mid,
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 8,
}}>
{children}
</div>
);
}
function Empty({ children }: { children: React.ReactNode }) {
return (
<div style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
color: INK.mid, fontSize: "0.85rem", padding: "32px 16px", textAlign: "center",
}}>
{children}
)}
<span style={{ marginLeft: "auto", fontSize: "0.75rem", color: INK.muted }}>
Started {formatRelative(preview.startedAt)}
</span>
</div>
</div>
);
}
@@ -272,20 +279,31 @@ function Empty({ children }: { children: React.ReactNode }) {
// Helpers
// ──────────────────────────────────────────────────
function hostOf(url: string) {
try { return new URL(url).host; } catch { return url; }
}
function statusColor(status: string) {
type Phase = "up" | "deploying" | "down" | "unknown";
function classifyPhase(status: string | undefined): Phase {
const s = (status ?? "").toLowerCase();
if (s.includes("running") || s.includes("healthy") || s.includes("success")) return "#2e7d32";
if (s.includes("starting") || s.includes("deploying") || s.includes("queued") || s.includes("in_progress")) return "#d4a04a";
if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy") || s.includes("error")) return "#c5392b";
return "#a09a90";
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 (/^(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"})` };
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" };
}
}
function formatRelative(iso: string | undefined) {
if (!iso) return "never";
if (!iso) return "";
const ms = Date.now() - new Date(iso).getTime();
if (Number.isNaN(ms)) return "";
if (Number.isNaN(ms)) return "";
const min = Math.floor(ms / 60_000);
if (min < 1) return "just now";
if (min < 60) return `${min}m ago`;
@@ -293,19 +311,36 @@ function formatRelative(iso: string | undefined) {
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
function sourceBadge(source: "repo" | "image"): React.CSSProperties {
const isRepo = source === "repo";
return {
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,
};
// ──────────────────────────────────────────────────
// Sub-components
// ──────────────────────────────────────────────────
function SectionHeader({ title, count }: { title: string; count: number }) {
return (
<div style={sectionHeader}>
<span style={sectionTitle}>{title}</span>
<span style={countPill}>{count}</span>
</div>
);
}
function EmptySection({ icon, title, hint, promptSuggestion }: {
icon: React.ReactNode; title: string; hint: string; promptSuggestion?: string;
}) {
return (
<div style={emptyBox}>
<div style={{ marginBottom: 10 }}>{icon}</div>
<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 && (
<div style={promptChip}>
<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>
);
}
// ──────────────────────────────────────────────────
@@ -321,84 +356,107 @@ const INK = {
cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
} as const;
const GREEN = "#2e7d32";
const AMBER = "#d4a04a";
const DANGER = "#c5392b";
// ──────────────────────────────────────────────────
// Styles
// ──────────────────────────────────────────────────
const pageWrap: React.CSSProperties = {
padding: "28px 48px 48px",
padding: "28px 48px 64px",
fontFamily: INK.fontSans,
color: INK.ink,
maxWidth: 860,
};
const grid: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "minmax(280px, 360px) minmax(0, 1fr)",
gap: 28,
maxWidth: 1400,
margin: "0 auto",
alignItems: "stretch",
const centeredMsg: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 10, padding: "24px 0",
};
const leftCol: React.CSSProperties = {
minWidth: 0, display: "flex", flexDirection: "column", gap: 18,
const sectionHeader: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 8, marginBottom: 14,
};
const rightCol: React.CSSProperties = {
minWidth: 0, display: "flex", flexDirection: "column",
};
const heading: React.CSSProperties = {
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em",
textTransform: "uppercase", color: INK.muted, margin: "0 0 14px",
};
const railGroup: React.CSSProperties = { display: "flex", flexDirection: "column" };
const railGroupHeader: React.CSSProperties = {
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "0 4px 8px",
};
const railGroupTitle: React.CSSProperties = {
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em",
const sectionTitle: React.CSSProperties = {
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",
};
const railItems: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 8 };
const railItem: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 10,
width: "100%", padding: "10px 12px",
border: `1px solid ${INK.borderSoft}`, borderRadius: 8,
cursor: "pointer", font: "inherit", color: "inherit",
transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s",
const card: React.CSSProperties = {
background: INK.cardBg,
border: `1px solid ${INK.border}`,
borderRadius: 10,
padding: "18px 20px",
};
const railEmpty: React.CSSProperties = {
padding: "10px 12px", fontSize: "0.74rem", color: INK.muted,
fontStyle: "italic", border: `1px dashed ${INK.borderSoft}`, borderRadius: 8,
lineHeight: 1.4,
};
const tileLabel: React.CSSProperties = {
fontSize: "0.85rem", fontWeight: 600, color: INK.ink, marginBottom: 2,
};
const tileSubLine: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 6, minWidth: 0,
};
const tileHint: React.CSSProperties = {
fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4,
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
};
const tileMetaLine: React.CSSProperties = {
fontSize: "0.7rem", color: INK.muted, lineHeight: 1.4, marginTop: 2,
};
const panel: React.CSSProperties = {
background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10,
padding: 16, flex: 1, minHeight: 0, display: "flex", flexDirection: "column",
};
const detailRow: React.CSSProperties = {
const cardHeader: React.CSSProperties = {
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "12px 4px", borderBottom: `1px solid ${INK.borderSoft}`,
gap: 12, marginBottom: 6,
};
const detailLabel: React.CSSProperties = {
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.06em",
textTransform: "uppercase", color: INK.muted,
const cardTitle: React.CSSProperties = {
fontSize: "0.95rem", fontWeight: 700, color: INK.ink,
};
const detailValue: React.CSSProperties = {
fontSize: "0.85rem", color: INK.ink, display: "inline-flex", alignItems: "center",
const statusLine: React.CSSProperties = {
fontSize: "0.8rem", color: INK.mid, marginBottom: 12,
display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap",
};
const detailLink: React.CSSProperties = {
color: INK.ink, textDecoration: "none", display: "inline-flex", alignItems: "center", gap: 6,
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 = {
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",
};
const promptChip: React.CSSProperties = {
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",
textTransform: "uppercase",
color: isRepo ? "#2e6d2e" : "#3b5a78",
background: isRepo ? "#eaf3e8" : "#e9eff5",
padding: "1px 6px", borderRadius: 4, flexShrink: 0,
};
}

View File

@@ -0,0 +1,230 @@
"use client";
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Settings, Trash2, AlertTriangle, Loader2, ArrowLeft } from "lucide-react";
import Link from "next/link";
/**
* Project settings page.
* Accessible via the gear icon in the project header.
*
* Sections:
* - General (name, description — future)
* - Danger zone: delete project
*/
export default function ProjectSettingsPage() {
const params = useParams();
const router = useRouter();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const [deletePhase, setDeletePhase] = useState<"idle" | "confirm" | "deleting" | "done">("idle");
const [confirmInput, setConfirmInput] = useState("");
const [deleteError, setDeleteError] = useState<string | null>(null);
const projectBackUrl = `/${workspace}/project/${projectId}/plan`;
const handleDelete = async () => {
if (deletePhase === "idle") {
setDeletePhase("confirm");
return;
}
if (deletePhase !== "confirm") return;
if (confirmInput.toLowerCase() !== "delete") return;
setDeletePhase("deleting");
setDeleteError(null);
try {
const r = await fetch("/api/projects/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || "Delete failed");
setDeletePhase("done");
setTimeout(() => router.push(`/${workspace}/projects`), 1500);
} catch (e) {
setDeleteError(e instanceof Error ? e.message : String(e));
setDeletePhase("confirm");
}
};
return (
<div style={pageWrap}>
{/* Back link */}
<Link href={projectBackUrl} style={backLink}>
<ArrowLeft size={14} /> Back to project
</Link>
<h1 style={pageTitle}>
<Settings size={18} /> Project settings
</h1>
{/* ── Danger zone ── */}
<section style={dangerSection}>
<h2 style={sectionTitle}>
<AlertTriangle size={15} style={{ color: DANGER }} />
Danger zone
</h2>
<div style={dangerCard}>
<div style={dangerCardBody}>
<div>
<div style={dangerItemTitle}>Delete this project</div>
<div style={dangerItemDesc}>
Removes all project data from Vibn. Coolify services and databases
are <strong>not</strong> automatically stopped use the chat to clean those
up first, or remove them from Coolify directly.
</div>
</div>
{deletePhase === "idle" && (
<button onClick={handleDelete} style={dangerBtn}>
<Trash2 size={13} /> Delete project
</button>
)}
{deletePhase === "confirm" && (
<div style={confirmBox}>
<div style={{ fontSize: "0.82rem", color: DANGER, fontWeight: 600, marginBottom: 8 }}>
Type <strong>delete</strong> to confirm
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
autoFocus
value={confirmInput}
onChange={e => setConfirmInput(e.target.value)}
onKeyDown={e => e.key === "Enter" && confirmInput.toLowerCase() === "delete" && handleDelete()}
placeholder="delete"
style={confirmInput_}
/>
<button
onClick={handleDelete}
disabled={confirmInput.toLowerCase() !== "delete"}
style={{
...dangerBtn,
opacity: confirmInput.toLowerCase() !== "delete" ? 0.4 : 1,
}}
>
<Trash2 size={13} /> Confirm delete
</button>
<button
onClick={() => { setDeletePhase("idle"); setConfirmInput(""); setDeleteError(null); }}
style={cancelBtn}
>
Cancel
</button>
</div>
{deleteError && (
<div style={{ marginTop: 8, fontSize: "0.8rem", color: DANGER }}>{deleteError}</div>
)}
</div>
)}
{deletePhase === "deleting" && (
<button style={{ ...dangerBtn, opacity: 0.6 }} disabled>
<Loader2 size={13} className="animate-spin" /> Deleting
</button>
)}
{deletePhase === "done" && (
<div style={{ fontSize: "0.85rem", color: "#2e7d32", fontWeight: 600 }}>
Project deleted. Redirecting
</div>
)}
</div>
</div>
</section>
</div>
);
}
// ──────────────────────────────────────────────────
// Tokens
// ──────────────────────────────────────────────────
const DANGER = "#c5392b";
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
border: "#e8e4dc",
borderSoft: "#efebe1",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
} as const;
// ──────────────────────────────────────────────────
// Styles
// ──────────────────────────────────────────────────
const pageWrap: React.CSSProperties = {
padding: "28px 48px 64px",
fontFamily: INK.fontSans,
color: INK.ink,
maxWidth: 720,
};
const backLink: React.CSSProperties = {
display: "inline-flex", alignItems: "center", gap: 6,
fontSize: "0.8rem", color: INK.mid, textDecoration: "none",
marginBottom: 24,
};
const pageTitle: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 10,
fontSize: "1.25rem", fontWeight: 700, color: INK.ink,
marginBottom: 36, marginTop: 0,
};
const dangerSection: React.CSSProperties = { marginTop: 32 };
const sectionTitle: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 8,
fontSize: "0.72rem", fontWeight: 700, letterSpacing: "0.12em",
textTransform: "uppercase", color: INK.muted,
marginBottom: 12,
};
const dangerCard: React.CSSProperties = {
border: `1px solid #f0cac5`,
borderRadius: 10,
background: "#fffaf9",
};
const dangerCardBody: React.CSSProperties = {
padding: "18px 20px",
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 24,
flexWrap: "wrap",
};
const dangerItemTitle: React.CSSProperties = {
fontWeight: 600, fontSize: "0.9rem", color: INK.ink, marginBottom: 4,
};
const dangerItemDesc: React.CSSProperties = {
fontSize: "0.8rem", color: INK.mid, lineHeight: 1.55, maxWidth: 380,
};
const dangerBtn: React.CSSProperties = {
display: "inline-flex", alignItems: "center", gap: 6,
padding: "7px 14px", border: `1px solid ${DANGER}`,
borderRadius: 6, background: "#fff", cursor: "pointer",
font: "inherit", fontSize: "0.8rem", fontWeight: 600, color: DANGER,
whiteSpace: "nowrap", flexShrink: 0,
};
const cancelBtn: React.CSSProperties = {
display: "inline-flex", alignItems: "center",
padding: "7px 12px", border: `1px solid ${INK.border}`,
borderRadius: 6, background: "#fff", cursor: "pointer",
font: "inherit", fontSize: "0.8rem", color: INK.mid,
whiteSpace: "nowrap",
};
const confirmBox: React.CSSProperties = { display: "flex", flexDirection: "column" };
const confirmInput_: React.CSSProperties = {
padding: "7px 10px",
border: `1px solid ${DANGER}`,
borderRadius: 6,
font: "inherit",
fontSize: "0.85rem",
outline: "none",
width: 100,
};

View File

@@ -7,17 +7,17 @@
* - Live chips → every Coolify endpoint with an attached fqdn
* - Prev. chips → every running dev-server preview
*
* If a live endpoint has no fqdn yet (fresh deploy, domain not set)
* it's omitted — there's nothing to link to. Stopped previews are
* also omitted (their URL would NXDOMAIN).
* When there are more than MAX_VISIBLE total links, extras collapse
* into a "+N more" pill (shows the full list in a tooltip via title).
*
* Polls anatomy at the same cadence as the status pill so URLs
* appear/disappear in real time as deploys finish or previews boot.
* Polls anatomy at the same cadence as the status pill.
*/
import { ExternalLink, Globe, Zap } from "lucide-react";
import { useAnatomy } from "./use-anatomy";
const MAX_VISIBLE = 2;
interface Props {
projectId: string;
}
@@ -46,38 +46,38 @@ export function ProjectHeaderUrls({ projectId }: Props) {
host: hostOf(p.url),
}));
if (liveLinks.length === 0 && previewLinks.length === 0) return null;
const allLinks = [...liveLinks, ...previewLinks];
if (allLinks.length === 0) return null;
const visible = allLinks.slice(0, MAX_VISIBLE);
const hidden = allLinks.slice(MAX_VISIBLE);
return (
<div style={wrap}>
{liveLinks.map((l) => (
{visible.map((l) => (
<a
key={l.key}
href={l.url}
target="_blank"
rel="noreferrer"
style={liveChip}
title={`Open ${l.label}${l.host}`}
style={l.kind === "live" ? liveChip : previewChip}
title={`${l.label}${l.host}`}
>
<Globe size={11} style={{ flexShrink: 0 }} />
{l.kind === "live"
? <Globe size={11} style={{ flexShrink: 0 }} />
: <Zap size={11} style={{ flexShrink: 0 }} />}
<span style={chipLabel}>{l.label}</span>
<ExternalLink size={10} style={{ flexShrink: 0, opacity: 0.7 }} />
</a>
))}
{previewLinks.map((p) => (
<a
key={p.key}
href={p.url}
target="_blank"
rel="noreferrer"
style={previewChip}
title={`Open preview ${p.label}${p.host}`}
{hidden.length > 0 && (
<span
style={overflowPill}
title={hidden.map(l => `${l.label}: ${l.url}`).join("\n")}
>
<Zap size={11} style={{ flexShrink: 0 }} />
<span style={chipLabel}>{p.label}</span>
<ExternalLink size={10} style={{ flexShrink: 0, opacity: 0.7 }} />
</a>
))}
+{hidden.length} more
</span>
)}
</div>
);
}
@@ -122,3 +122,10 @@ const chipLabel: React.CSSProperties = {
overflow: "hidden", textOverflow: "ellipsis",
maxWidth: 180,
};
const overflowPill: React.CSSProperties = {
...chipBase,
borderColor: "#e8e4dc",
color: "#a09a90",
background: "#f8f5f0",
cursor: "default",
};

View File

@@ -19,7 +19,7 @@
*/
import { useMemo } from "react";
import { Loader2 } from "lucide-react";
import { Loader2, ExternalLink } from "lucide-react";
import { useAnatomy, type Anatomy } from "./use-anatomy";
interface ProjectStagePillProps {
@@ -59,14 +59,37 @@ export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillP
}
const visual = VISUALS[state.kind];
// For build failures, surface a "View logs" link next to the pill so
// the user can immediately see why the deploy broke.
const coolifyDeployUrl = anatomy?.hosting.live[0]?.uuid
? `${typeof window !== "undefined" ? "" : ""}` // resolved client-side
: null;
return (
<Pill
label={visual.label}
color={visual.color}
bg={visual.bg}
title={state.reason}
spinning={state.kind === "deploying"}
/>
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<Pill
label={visual.label}
color={visual.color}
bg={visual.bg}
title={state.reason}
spinning={state.kind === "deploying"}
/>
{state.kind === "build_failed" && anatomy?.hosting.live[0] && (
<a
href={`${process.env.NEXT_PUBLIC_COOLIFY_URL ?? ""}`}
target="_blank"
rel="noreferrer"
title="Open Coolify to view build logs"
style={{
display: "inline-flex", alignItems: "center", gap: 3,
fontSize: "0.68rem", color: "#c5392b",
textDecoration: "none", opacity: 0.8,
}}
>
Logs <ExternalLink size={9} />
</a>
)}
</span>
);
}

View File

@@ -480,7 +480,11 @@ export function ChatPanel() {
)
);
} else if (ev.type === "error") {
assistantContent += `\n\n⚠ ${ev.error}`;
const errText = ev.error || "Unknown error";
const isToolErr = /tool|mcp|coolify|gitea/i.test(errText);
assistantContent += isToolErr
? `\n\n⚠ **Tool error:** ${errText}`
: `\n\n⚠ ${errText}`;
setMessages((prev) => {
const next = [...prev];
if (msgIndex >= 0 && next[msgIndex]) {
@@ -525,10 +529,15 @@ export function ChatPanel() {
return next;
});
} else {
const errMsg = e instanceof Error ? e.message : String(e);
const isNetwork = /fetch|network|failed to fetch/i.test(errMsg);
const friendlyError = isNetwork
? "⚠️ Network error — check your connection and try again."
: `⚠️ Something went wrong: ${errMsg.slice(0, 200)}\n\nYou can try again or start a new message.`;
setMessages((prev) => {
const next = [...prev];
if (msgIndex >= 0 && next[msgIndex]) {
next[msgIndex] = { ...next[msgIndex], content: "⚠️ Failed to get response. Please try again." };
next[msgIndex] = { ...next[msgIndex], content: friendlyError };
}
return next;
});

View File

@@ -21,6 +21,57 @@ const nextConfig: NextConfig = {
// ("non-ecmascript placeable asset"). Externalize so they're loaded
// at runtime via Node's require, the same way @prisma/client works.
serverExternalPackages: ["@prisma/client", "prisma", "ssh2", "cpu-features"],
// react-markdown and its entire unified/remark/rehype ecosystem are
// ESM-only (type:"module", no CJS fallback). Next.js webpack can't
// resolve them without explicit transpilation — manifests as
// "TypeError: Cannot read properties of undefined (reading 'z')" in
// the minified production bundle.
transpilePackages: [
"react-markdown",
"remark-gfm",
"remark-parse",
"remark-rehype",
"unified",
"vfile",
"vfile-message",
"mdast-util-from-markdown",
"mdast-util-to-markdown",
"mdast-util-gfm",
"mdast-util-gfm-table",
"mdast-util-gfm-task-list-item",
"mdast-util-gfm-strikethrough",
"mdast-util-gfm-autolink-literal",
"mdast-util-gfm-footnote",
"micromark",
"micromark-core-commonmark",
"micromark-extension-gfm",
"micromark-util-combine-extensions",
"micromark-util-character",
"micromark-util-chunked",
"micromark-util-classify-character",
"micromark-util-decode-string",
"micromark-util-encode",
"micromark-util-html-tag-name",
"micromark-util-normalize-identifier",
"micromark-util-resolve-all",
"micromark-util-sanitize-uri",
"micromark-util-subtokenize",
"micromark-util-types",
"micromark-util-symbol",
"micromark-util-decode-numeric-character-reference",
"hast-util-to-jsx-runtime",
"hast-util-whitespace",
"hast-util-from-parse5",
"property-information",
"space-separated-tokens",
"comma-separated-tokens",
"decode-named-character-reference",
"character-entities",
"unist-util-position",
"unist-util-stringify-position",
"unist-util-visit",
"unist-util-is",
],
typescript: {
ignoreBuildErrors: true,
},