"use client"; import { useState } from "react"; import { useParams } from "next/navigation"; 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"; /** * 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 ServicesPage() { const params = useParams(); const projectId = params.projectId as string; const { anatomy, loading, error } = useAnatomy(projectId, { pollMs: 8000 }); const showLoading = loading && !anatomy; return (
{showLoading && (
Loading…
)} {error && !showLoading && (
{error}
)} {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"}
{/* ── Status line ── */}
{statusLabel} {item.lastBuild && ( · Last build {item.lastBuild.status}{" "} {formatRelative(item.lastBuild.finishedAt)} )}
{/* ── Live URL ── */} {primaryUrl ? (
{primaryUrl}
) : (
No domain attached — ask the AI to add one.
)} {/* ── Extra domains ── */} {item.domains.length > 1 && (
{item.domains.slice(1).map((d) => ( {d}{" "} ))}
)} {/* ── Logs toggle ── */}
{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.state} {preview.url && running && (
{preview.url.replace(/^https?:\/\//, "")}{" "}
)}
); } // ────────────────────────────────────────────────── // 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, }; }