674 lines
19 KiB
TypeScript
674 lines
19 KiB
TypeScript
"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 HostingTab() {
|
|
const params = useParams();
|
|
const projectId = params.projectId as string;
|
|
const { anatomy, loading, error } = useAnatomy(projectId, { pollMs: 8000 });
|
|
const showLoading = loading && !anatomy;
|
|
|
|
return (
|
|
<div style={pageWrap}>
|
|
{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>
|
|
)}
|
|
|
|
{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>
|
|
|
|
{/* ── 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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Preview row
|
|
// ──────────────────────────────────────────────────
|
|
|
|
function PreviewRow({ preview }: { preview: Preview }) {
|
|
const running = preview.state === "running";
|
|
return (
|
|
<div style={{ ...card, padding: "12px 16px" }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
<CircleDot
|
|
size={10}
|
|
style={{ color: running ? "#10b981" : 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 && (
|
|
<div
|
|
style={{
|
|
marginLeft: "auto",
|
|
display: "flex",
|
|
gap: 8,
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<a
|
|
href={preview.url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
style={{ ...urlLink, marginLeft: 0 }}
|
|
>
|
|
{preview.url.replace(/^https?:\/\//, "")}{" "}
|
|
<ExternalLink
|
|
size={10}
|
|
style={{ display: "inline", verticalAlign: "middle" }}
|
|
/>
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 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 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>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Tokens
|
|
// ──────────────────────────────────────────────────
|
|
|
|
const INK = {
|
|
ink: "#111827",
|
|
mid: "#4b5563",
|
|
muted: "#9ca3af",
|
|
border: "#e5e7eb",
|
|
borderSoft: "#f3f4f6",
|
|
cardBg: "#fff",
|
|
fontSans: '"Inter", ui-sans-serif, system-ui, -apple-system, 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.9rem",
|
|
fontWeight: 600,
|
|
color: THEME.ink,
|
|
marginBottom: 8,
|
|
};
|
|
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,
|
|
};
|
|
}
|