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:
@@ -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"]
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
230
app/[workspace]/project/[projectId]/settings/page.tsx
Normal file
230
app/[workspace]/project/[projectId]/settings/page.tsx
Normal 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,
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user