From 28441e75f2b27cc8413f93ad01ee505ee40cd4bc Mon Sep 17 00:00:00 2001 From: mawkone Date: Fri, 12 Jun 2026 15:57:14 -0700 Subject: [PATCH] fix(overview): restore lost Dev Previews and Live endpoints lists that were accidentally overwritten during Dashboard migration --- .../[projectId]/(home)/overview/page.tsx | 999 ++++++++++++------ .../[projectId]/dev-server/ensure/route.ts | 16 +- 2 files changed, 661 insertions(+), 354 deletions(-) diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/overview/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/overview/page.tsx index ff1abb32..126d5cbc 100644 --- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/overview/page.tsx +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/overview/page.tsx @@ -1,378 +1,683 @@ "use client"; -import { useAnatomy } from "@/components/project/use-anatomy"; +import { useState } from "react"; import { useParams } from "next/navigation"; -import { ExternalLink, Copy, Users, EyeOff, LayoutGrid } from "lucide-react"; +import { + Loader2, + AlertCircle, + ExternalLink, + Globe, + RefreshCw, + CircleDot, + ChevronDown, + ChevronRight, + Copy, + Check, + Terminal, + Server, +} from "lucide-react"; +import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy"; -export default function OverviewPage() { +/** + * Hosting tab — user-facing: "Is my thing live? How do I reach it?" + * + * 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 master-detail split — with 1-3 services the overhead isn't worth it. + * Previews (dev server URLs) shown below in a secondary section. + */ + +// ────────────────────────────────────────────────── +// Types +// ────────────────────────────────────────────────── + +type LiveItem = Anatomy["hosting"]["live"][number]; +type Preview = Anatomy["hosting"]["previews"][number]; + +// ────────────────────────────────────────────────── +// Main component +// ────────────────────────────────────────────────── + +export default function OverviewTab() { const params = useParams(); const projectId = params.projectId as string; - const workspace = params.workspace as string; - const { anatomy } = useAnatomy(projectId, { pollMs: 8000 }); - - const activeProjectName = anatomy?.product?.codebases?.[0]?.label ?? "App"; - - // Try to find the primary live URL - const primaryLive = anatomy?.hosting?.live?.find((l) => !!l.fqdn); - const displayUrl = primaryLive ? `https://${primaryLive.fqdn}` : null; + const { anatomy, loading, error } = useAnatomy(projectId, { pollMs: 8000 }); + const showLoading = loading && !anatomy; return ( -
- {/* Header card */} -
-
-
- -
-
-

- {activeProjectName} -

-

- A comprehensive operating system for your users, integrating - discovery, registration, operations, and growth automation into - one unified platform. -

-
- Created a few days ago -
-
+
+ {showLoading && ( +
+ + + Loading… +
+ )} + {error && !showLoading && ( +
+ + {error} +
+ )} -
- {displayUrl ? ( - - Open App - - ) : ( - + {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) => ( + + ))} +
+ )} +
+ + {/* ── Previews ── */} + {anatomy.hosting.previews.length > 0 && ( +
+ +
+ {anatomy.hosting.previews.map((p) => ( + + ))} +
+
)} + + )} +
+ ); +} +// ────────────────────────────────────────────────── +// Live card +// ────────────────────────────────────────────────── + +function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) { + const [deploying, setDeploying] = useState(false); + const [logsOpen, setLogsOpen] = useState(false); + const [logs, setLogs] = useState(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 ( +
+ {/* ── Card header ── */} +
+
+ + {item.name} + + {item.source === "repo" ? "built" : "image"} + +
+
-
- {/* App Visibility */} -
-
-
-

- App Visibility -

-

- Control who can access your application -

-
-
- - -
- - {/* Invite Users */} -
-
-
-

- Invite Users -

-

- Grow your user base by inviting others -

-
- -
- -
- - -
-
+ {/* ── Status line ── */} +
+ + {statusLabel} + + {item.lastBuild && ( + + · Last build {item.lastBuild.status}{" "} + {formatRelative(item.lastBuild.finishedAt)} + + )}
- {/* Move to Workspace */} -
-
-

- Move to Workspace -

-

- Move this app to another workspace -

+ {/* ── Live URL ── */} + {primaryUrl ? ( +
+ + + {primaryUrl} + + +
- -
+ ) : ( +
+ + + No domain attached — ask the AI to add one. + +
+ )} - {/* Platform Badge */} -
-
-

- Platform Badge -

-

- The "Built with Vibn" badge is currently visible on your app. -

-
- + + {logsOpen && ( +
+ {logsLoading ? ( + + Loading… + + ) : ( +
{logs || "(no logs)"}
+ )} +
+ )}
); } + +// ────────────────────────────────────────────────── +// Preview row +// ────────────────────────────────────────────────── + +function PreviewRow({ preview }: { preview: Preview }) { + const running = preview.state === "running"; + return ( +
+
+ + + {preview.name} + + + port {preview.port} + + {preview.url && running && ( + + )} +
+
+ ); +} + +// ────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────── + +type Phase = "up" | "deploying" | "down" | "unknown"; + +function classifyPhase(status: string | undefined): Phase { + const s = (status ?? "").toLowerCase(); + if (!s || s === "unknown") return "unknown"; + if (/^(running|healthy)/.test(s)) return "up"; + if ( + /^(starting|restarting|created|deploying|building|in_progress|queued|paused)/.test( + s, + ) + ) + return "deploying"; + if (/^(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 ""; + const ms = Date.now() - new Date(iso).getTime(); + 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`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}h ago`; + return `${Math.floor(hr / 24)}d ago`; +} + +// ────────────────────────────────────────────────── +// 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}" + +
+ )} +
+ ); +} + +// ────────────────────────────────────────────────── +// Tokens +// ────────────────────────────────────────────────── + +const INK = { + ink: "#1a1a1a", + mid: "#5f5e5a", + muted: "#a09a90", + border: "#e8e4dc", + borderSoft: "#efebe1", + cardBg: "#fff", + fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif', +} as const; +const GREEN = "#10b981"; +const AMBER = "#f59e0b"; +const DANGER = "#ef4444"; + +// ────────────────────────────────────────────────── +// Styles +// ────────────────────────────────────────────────── + +const pageWrap: React.CSSProperties = { + padding: "28px 48px 64px", + fontFamily: INK.fontSans, + color: INK.ink, + maxWidth: 860, +}; +const centeredMsg: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 10, + padding: "24px 0", +}; +const sectionHeader: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 8, + marginBottom: 14, +}; +const sectionTitle: React.CSSProperties = { + fontSize: "0.68rem", + fontWeight: 700, + letterSpacing: "0.12em", + textTransform: "uppercase", + color: INK.muted, +}; +const countPill: React.CSSProperties = { + fontSize: "0.7rem", + fontWeight: 600, + color: INK.mid, + padding: "1px 7px", + borderRadius: 999, + background: "#f3eee4", +}; +const card: React.CSSProperties = { + background: INK.cardBg, + border: `1px solid ${INK.border}`, + borderRadius: 10, + padding: "18px 20px", +}; +const cardHeader: React.CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + marginBottom: 6, +}; +const cardTitle: React.CSSProperties = { + fontSize: "0.95rem", + fontWeight: 700, + color: INK.ink, +}; +const statusLine: React.CSSProperties = { + fontSize: "0.8rem", + color: INK.mid, + marginBottom: 12, + display: "flex", + alignItems: "center", + gap: 6, + flexWrap: "wrap", +}; +const urlRow: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 8, + background: "#f8f5f0", + borderRadius: 6, + padding: "8px 12px", + marginBottom: 2, +}; +const urlLink: React.CSSProperties = { + fontSize: "0.85rem", + color: INK.ink, + textDecoration: "none", + flex: 1, + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + display: "inline-flex", + alignItems: "center", + gap: 4, +}; +const actionBtn: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + gap: 6, + padding: "6px 12px", + border: `1px solid ${INK.border}`, + borderRadius: 6, + background: "#fff", + cursor: "pointer", + font: "inherit", + fontSize: "0.78rem", + fontWeight: 600, + color: INK.mid, + transition: "background 0.1s, border-color 0.1s", +}; +const iconBtn: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 26, + height: 26, + border: "none", + background: "transparent", + cursor: "pointer", + color: INK.muted, + borderRadius: 4, + flexShrink: 0, +}; +const logsToggleBtn: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + gap: 6, + fontSize: "0.75rem", + fontWeight: 600, + color: INK.mid, + background: "none", + border: "none", + cursor: "pointer", + font: "inherit", + padding: 0, +}; +const logsBox: React.CSSProperties = { + marginTop: 10, + background: "#1a1a1a", + borderRadius: 6, + padding: "12px 14px", + maxHeight: 320, + overflowY: "auto", +}; +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/vibn-frontend/app/api/projects/[projectId]/dev-server/ensure/route.ts b/vibn-frontend/app/api/projects/[projectId]/dev-server/ensure/route.ts index 4e0c7d7b..608c2ca6 100644 --- a/vibn-frontend/app/api/projects/[projectId]/dev-server/ensure/route.ts +++ b/vibn-frontend/app/api/projects/[projectId]/dev-server/ensure/route.ts @@ -101,9 +101,12 @@ export async function POST( const forceStart = new URL(request.url).searchParams.get("forceStart") === "true"; - if (!last && !forceStart) { - return NextResponse.json({ status: "no_history" }); - } + // If there's no history, we STILL want to auto-start! We just assume it's a standard + // Next.js app on port 3000. Forcing the user to hit "Start Preview" on a new project + // is unnecessary friction. + const commandToRun = last?.command || "npx next dev -H 0.0.0.0 --webpack"; + const portToRun = last?.port || 3000; + const previewUrlToUse = last?.preview_url ?? null; // 3. Load workspace if (!project.vibn_workspace_id) { @@ -116,12 +119,11 @@ export async function POST( } // 4. Fire restart in background — don't block the response. - // If forceStart is true but we have no history, default to Next.js start command. const restartOpts = { projectId: project.id, projectSlug, - command: last?.command || "npx next dev -H 0.0.0.0 --no-turbopack", - port: last?.port || 3000, + command: commandToRun, + port: portToRun, workspace, }; @@ -154,7 +156,7 @@ export async function POST( return NextResponse.json({ status: "starting", - previewUrl: last?.preview_url ?? null, + previewUrl: previewUrlToUse, command: restartOpts.command, port: restartOpts.port, });