From 41fbed31f32491d158596602937ff41136324b5e Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Thu, 30 Apr 2026 17:12:48 -0700 Subject: [PATCH] fix(prod): ESM transpile, healthcheck, hosting UX, settings, error msgs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Dockerfile | 8 + .../[projectId]/(home)/hosting/page.tsx | 692 ++++++++++-------- .../project/[projectId]/settings/page.tsx | 230 ++++++ components/project/project-header-urls.tsx | 53 +- components/project/project-stage-pill.tsx | 39 +- components/vibn-chat/chat-panel.tsx | 13 +- next.config.ts | 51 ++ 7 files changed, 736 insertions(+), 350 deletions(-) create mode 100644 app/[workspace]/project/[projectId]/settings/page.tsx diff --git a/Dockerfile b/Dockerfile index a7d1ad94..d4d6b310 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx b/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx index 86e877d9..c4cf775c 100644 --- a/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx +++ b/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx @@ -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(null); - + const { anatomy, loading, error } = useAnatomy(projectId, { pollMs: 8000 }); const showLoading = loading && !anatomy; return (
-
- {/* ── Left rail ── */} -
- {showLoading && ( - Loading… - )} - {error && !showLoading && ( - {error} - )} - {anatomy && ( - <> - - {anatomy.hosting.live.map(item => { - const active = selection?.kind === "live" && selection.uuid === item.uuid; - const Icon = item.source === "repo" ? Cloud : Container; - return ( - - ); - })} - + {showLoading && ( +
+ + Loading… +
+ )} + {error && !showLoading && ( +
+ + {error} +
+ )} - - {anatomy.hosting.previews.map(p => { - const active = selection?.kind === "preview" && selection.id === p.id; - return ( - - ); - })} - - - )} -
+ {anatomy && ( + <> + {/* ── Live endpoints ── */} +
+ + {anatomy.hosting.live.length === 0 ? ( + } + title="Nothing deployed yet" + hint="Ask the AI to deploy your app and it will appear here." + promptSuggestion="Deploy my app to production" + /> + ) : ( +
+ {anatomy.hosting.live.map(item => ( + + ))} +
+ )} +
- {/* ── Right pane ── */} - + )}
); } // ────────────────────────────────────────────────── -// 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 This endpoint is no longer in the project.; - return ( - - - - {item.branch && } - {item.buildPack && } - {item.lastBuild && ( - - )} - {item.domains.length === 0 && ( - - )} - {item.domains.map(d => ( - - ))} - - ); - } - - if (selection.kind === "preview") { - const p = anatomy.hosting.previews.find(p => p.id === selection.id); - if (!p) return This preview URL is no longer active.; - return ( - - - - - - - ); - } - - 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 ( -
-
- {title} - {count} -
- {count === 0 ? ( -
{emptyHint}
- ) : ( -
{children}
- )} -
- ); -} - -function DetailLayout({ children }: { children: React.ReactNode }) { - return
{children}
; -} - -function DetailRow({ - label, value, dot, href, -}: { label: string; value: string; dot?: string; href?: string }) { - return ( -
- {label} - - {dot && } - {href ? ( - - {value} +
+ - ); -} - -function Inline({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} - -function Empty({ children }: { children: React.ReactNode }) { - return ( -
- {children} + )} + + Started {formatRelative(preview.startedAt)} + +
); } @@ -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 ( +
+ {title} + {count} +
+ ); +} + +function EmptySection({ icon, title, hint, promptSuggestion }: { + icon: React.ReactNode; title: string; hint: string; promptSuggestion?: string; +}) { + return ( +
+
{icon}
+
{title}
+
{hint}
+ {promptSuggestion && ( +
+ Try asking: + "{promptSuggestion}" +
+ )} +
+ ); } // ────────────────────────────────────────────────── @@ -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, + }; +} diff --git a/app/[workspace]/project/[projectId]/settings/page.tsx b/app/[workspace]/project/[projectId]/settings/page.tsx new file mode 100644 index 00000000..15fb3428 --- /dev/null +++ b/app/[workspace]/project/[projectId]/settings/page.tsx @@ -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(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 ( +
+ {/* Back link */} + + Back to project + + +

+ Project settings +

+ + {/* ── Danger zone ── */} +
+

+ + Danger zone +

+ +
+
+
+
Delete this project
+
+ Removes all project data from Vibn. Coolify services and databases + are not automatically stopped — use the chat to clean those + up first, or remove them from Coolify directly. +
+
+ + {deletePhase === "idle" && ( + + )} + + {deletePhase === "confirm" && ( +
+
+ Type delete to confirm +
+
+ setConfirmInput(e.target.value)} + onKeyDown={e => e.key === "Enter" && confirmInput.toLowerCase() === "delete" && handleDelete()} + placeholder="delete" + style={confirmInput_} + /> + + +
+ {deleteError && ( +
{deleteError}
+ )} +
+ )} + + {deletePhase === "deleting" && ( + + )} + + {deletePhase === "done" && ( +
+ Project deleted. Redirecting… +
+ )} +
+
+
+
+ ); +} + +// ────────────────────────────────────────────────── +// 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, +}; diff --git a/components/project/project-header-urls.tsx b/components/project/project-header-urls.tsx index e9020327..e42f88df 100644 --- a/components/project/project-header-urls.tsx +++ b/components/project/project-header-urls.tsx @@ -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 (
- {liveLinks.map((l) => ( + {visible.map((l) => ( - + {l.kind === "live" + ? + : } {l.label} ))} - {previewLinks.map((p) => ( - 0 && ( + `${l.label}: ${l.url}`).join("\n")} > - - {p.label} - - - ))} + +{hidden.length} more + + )}
); } @@ -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", +}; diff --git a/components/project/project-stage-pill.tsx b/components/project/project-stage-pill.tsx index 8d0af8b4..28a5baf7 100644 --- a/components/project/project-stage-pill.tsx +++ b/components/project/project-stage-pill.tsx @@ -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 ( - + + + {state.kind === "build_failed" && anatomy?.hosting.live[0] && ( + + Logs + + )} + ); } diff --git a/components/vibn-chat/chat-panel.tsx b/components/vibn-chat/chat-panel.tsx index e95e41db..6b236992 100644 --- a/components/vibn-chat/chat-panel.tsx +++ b/components/vibn-chat/chat-panel.tsx @@ -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; }); diff --git a/next.config.ts b/next.config.ts index 451131d4..befe9623 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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, },