"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 && (
)}
{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"}
{deploying ? (
) : (
)}
{deploying ? "Deploying…" : "Redeploy"}
{/* ── Status line ── */}
{statusLabel}
{item.lastBuild && (
· Last build {item.lastBuild.status}{" "}
{formatRelative(item.lastBuild.finishedAt)}
)}
{/* ── Live URL ── */}
{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 ? "Hide logs" : "Show recent logs"}
{logsOpen ? : }
{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 && (
)}
);
}
// ──────────────────────────────────────────────────
// 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,
};
}