Ships accumulated WIP that was sitting uncommitted: - New (home) dashboard route pages: overview, code, data/tables, hosting, infrastructure, services, domains, integrations, agents, analytics, api, automations, billing, logs, market, marketing(+seo/social), product, security, storage, users, settings(app/auth). - dashboard-sidebar, project-icon-rail, chat-panel updates; mcp + anatomy route changes; package.json/lock dependency bumps. - Coolify log tooling (scripts/fetch-app-logs.mjs + fetch-app-logs-ssh.mjs) and ai-new-thread.md "Fetching Production Logs" section. Excludes throwaway debug scripts and telemetry audit dumps (the latter contain live credentials and must not be committed).
684 lines
19 KiB
TypeScript
684 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 OverviewTab() {
|
|
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 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>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 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,
|
|
};
|
|
}
|