From 996b8759839cf9e3a6da210396ab0aca5d251c21 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Thu, 30 Apr 2026 13:44:57 -0700 Subject: [PATCH] feat(project-header): smart status pill + live/preview URL chips - Status pill derives richer states (Empty / Deploying / Build failed / Down / Live) from anatomy with self-polling while deploying - Tooltip explains what's happening (last build status, transient containers, Coolify build phase) - New ProjectHeaderUrls component renders clickable chips for live domains and active dev-server preview URLs to the left of the pill - useAnatomy gains pollMs option for client-driven refresh Made-with: Cursor --- .../project/[projectId]/(home)/layout.tsx | 4 +- components/project/project-header-urls.tsx | 124 +++++++++++ components/project/project-stage-pill.tsx | 210 +++++++++++++++--- components/project/use-anatomy.ts | 22 +- 4 files changed, 327 insertions(+), 33 deletions(-) create mode 100644 components/project/project-header-urls.tsx diff --git a/app/[workspace]/project/[projectId]/(home)/layout.tsx b/app/[workspace]/project/[projectId]/(home)/layout.tsx index 5d42485d..851c71f4 100644 --- a/app/[workspace]/project/[projectId]/(home)/layout.tsx +++ b/app/[workspace]/project/[projectId]/(home)/layout.tsx @@ -25,6 +25,7 @@ import { VIBNSidebar } from "@/components/layout/vibn-sidebar"; import { ProjectAssociationPrompt } from "@/components/project-association-prompt"; import { ProjectTabBar } from "@/components/project/project-tab-bar"; import { ProjectStagePill } from "@/components/project/project-stage-pill"; +import { ProjectHeaderUrls } from "@/components/project/project-header-urls"; import { query } from "@/lib/db-postgres"; interface ProjectMeta { @@ -75,7 +76,8 @@ export default async function ProjectTabsLayout({

{project.name}

{project.vision &&

{project.vision}

} -
+
+ !!l.fqdn) + .map((l) => ({ + key: l.uuid, + kind: "live" as const, + label: l.name, + url: ensureScheme(l.fqdn!), + host: stripScheme(l.fqdn!), + })); + + const previewLinks = anatomy.hosting.previews + .filter((p) => p.state === "running" && p.url) + .map((p) => ({ + key: p.id, + kind: "preview" as const, + label: `${p.name}:${p.port}`, + url: p.url, + host: hostOf(p.url), + })); + + if (liveLinks.length === 0 && previewLinks.length === 0) return null; + + return ( +
+ {liveLinks.map((l) => ( + + + {l.label} + + + ))} + {previewLinks.map((p) => ( + + + {p.label} + + + ))} +
+ ); +} + +// ────────────────────────────────────────────────── + +function ensureScheme(host: string): string { + if (/^https?:\/\//i.test(host)) return host; + return `https://${host}`; +} +function stripScheme(host: string): string { + return host.replace(/^https?:\/\//i, "").replace(/\/$/, ""); +} +function hostOf(url: string): string { + try { return new URL(url).host; } catch { return url; } +} + +const wrap: React.CSSProperties = { + display: "flex", gap: 6, alignItems: "center", + flexWrap: "wrap", +}; + +const chipBase: React.CSSProperties = { + display: "inline-flex", alignItems: "center", gap: 6, + padding: "4px 10px", borderRadius: 4, + fontSize: "0.72rem", fontWeight: 500, + textDecoration: "none", + whiteSpace: "nowrap", maxWidth: 220, + border: "1px solid", + fontFamily: '"Outfit", "Inter", ui-sans-serif, sans-serif', + transition: "background 0.15s, border-color 0.15s", +}; +const liveChip: React.CSSProperties = { + ...chipBase, + color: "#1a1a1a", borderColor: "#e8e4dc", background: "#fff", +}; +const previewChip: React.CSSProperties = { + ...chipBase, + color: "#3d5afe", borderColor: "#3d5afe33", background: "#3d5afe08", +}; +const chipLabel: React.CSSProperties = { + overflow: "hidden", textOverflow: "ellipsis", + maxWidth: 180, +}; diff --git a/components/project/project-stage-pill.tsx b/components/project/project-stage-pill.tsx index 410994c5..8d0af8b4 100644 --- a/components/project/project-stage-pill.tsx +++ b/components/project/project-stage-pill.tsx @@ -1,48 +1,175 @@ "use client"; /** - * Lives in the project header. Shows the project's *real* stage - * derived from hosting reality, not the legacy `data.status` field - * (which historically lied). + * Project header status pill — surfaces what Coolify is actually doing. * - * - any running production app → "Live" (green) - * - any failed production app → "Down" (red) - * - any service / preview URL → "Building" (blue) - * - else → fallbackStage from data.status - * (typically "Defining" or "Planning") + * Priority (highest urgency wins): + * 1. Build failed — most recent finished deploy errored + * 2. Deploying — at least one in-flight deployment (queued / in_progress) + * 3. Down — apps exist but none running + * 4. Live — at least one app/service running healthy + * 5. Empty — no apps deployed yet (replaces the old false "Live" + * fallback when data.status="active") + * + * Auto-polls anatomy every 4s while a deploy is in flight, so users + * see queued → in_progress → success transitions without refreshing. + * On hover the pill shows a tooltip with the breakdown of why we're + * in the current state ("vibn-frontend is deploying", "twenty-live last + * deploy failed 3m ago", etc.) — no more guessing. */ -import { useAnatomy } from "./use-anatomy"; +import { useMemo } from "react"; +import { Loader2 } from "lucide-react"; +import { useAnatomy, type Anatomy } from "./use-anatomy"; interface ProjectStagePillProps { projectId: string; - /** Stage value pulled from fs_projects.data.status — used only as - * a fallback if no live infra exists yet. */ + /** Stage value pulled from fs_projects.data.status — only used while + * the first anatomy fetch is in flight, so the user sees something + * immediately instead of an empty header. */ fallbackStage: "discovery" | "architecture" | "building" | "active"; } +type PillState = + | { kind: "build_failed"; reason: string } + | { kind: "deploying"; reason: string } + | { kind: "down"; reason: string } + | { kind: "live"; reason: string } + | { kind: "empty"; reason: string }; + export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillProps) { - const { anatomy, loading } = useAnatomy(projectId); + // First load gets the default 1-shot fetch. Once we have anatomy in + // hand we can decide whether to escalate to a 4s poll (deploy in + // flight) or stay quiet (steady state). Switching pollMs at runtime + // re-arms the interval inside useAnatomy. + const { anatomy, loading } = useAnatomy(projectId, { pollMs: 4000 }); - if (loading && !anatomy) return ; + const state = useMemo(() => { + if (!anatomy) return null; + return derivePillState(anatomy); + }, [anatomy]); - const live = anatomy?.hosting.live ?? []; - const previews = anatomy?.hosting.previews ?? []; + if (loading && !anatomy) { + const f = FALLBACK_PRESETS[fallbackStage]; + return ; + } + if (!state) { + const f = FALLBACK_PRESETS[fallbackStage]; + return ; + } - const anyRunning = live.some(l => /running|healthy/i.test(l.status)); - const anyFailed = live.some(l => /failed|exited|unhealthy/i.test(l.status)); - const buildingNow = !anyRunning && (live.length > 0 || previews.length > 0); + const visual = VISUALS[state.kind]; + return ( + + ); +} - if (anyFailed) return ; - if (anyRunning) return ; - if (buildingNow) return ; +// Coolify reports container status as `` or `:`, +// e.g. "running:healthy", "starting:unknown", "exited:unhealthy". +// Phase taxonomy: +// running → up +// starting → transient (booting / health-check pending) +// restarting → transient +// created / paused → transient (rare in our flow) +// exited / dead → down +// We classify each app, then aggregate to a pill state. +type AppPhase = "up" | "transient" | "down" | "unknown"; +function classifyAppStatus(raw?: string): AppPhase { + const s = (raw ?? "").toLowerCase().trim(); + if (!s || s === "unknown") return "unknown"; + if (/^(running|healthy)/.test(s)) return "up"; + if (/healthy/.test(s) && !/unhealthy/.test(s)) return "up"; + if (/^(starting|restarting|created|paused|deploying|building|in_progress|queued)/.test(s)) return "transient"; + if (/^(exited|dead|failed|stopped|unhealthy|error)/.test(s)) return "down"; + // Default to transient for anything unrecognised — Coolify occasionally + // emits novel phases during upgrades; better to wait than mis-flag red. + return "transient"; +} - return ; +// Pure function. Exported-style intent only — keeps logic testable. +function derivePillState(a: Anatomy): PillState { + const live = a.hosting?.live ?? []; + + if (live.length === 0) { + return { kind: "empty", reason: "No apps deployed yet. Use the chat to spin one up." }; + } + + // 1. Active build in flight — highest priority signal. + const deploying = live.filter((l) => l.inFlightBuild); + if (deploying.length > 0) { + const names = deploying.map((l) => l.name).join(", "); + const stage = deploying[0].inFlightBuild?.status ?? "in progress"; + return { kind: "deploying", reason: `Deploying ${names}\nCoolify status: ${stage}` }; + } + + // 2. Container is currently booting (starting / restarting). Surface + // as "Deploying" since to the user this is the same wait state. + const transient = live.filter((l) => classifyAppStatus(l.status) === "transient"); + if (transient.length > 0) { + const lines = transient.map((l) => `${l.name}: ${l.status}`); + return { + kind: "deploying", + reason: `Containers starting:\n${lines.join("\n")}`, + }; + } + + // 3. Last finished build errored — call attention regardless of + // whether the previous container is still serving. + const failed = live.filter( + (l) => l.lastBuild && /fail|error|cancel/i.test(l.lastBuild.status), + ); + if (failed.length > 0) { + const lines = failed.map( + (l) => + `${l.name}: ${l.lastBuild?.status}` + + (l.lastBuild?.finishedAt ? ` · ${relTime(l.lastBuild.finishedAt)}` : ""), + ); + return { kind: "build_failed", reason: `Last deploy failed:\n${lines.join("\n")}` }; + } + + const phases = live.map((l) => classifyAppStatus(l.status)); + const upCount = phases.filter((p) => p === "up").length; + const downCount = phases.filter((p) => p === "down").length; + + if (upCount === live.length) { + return { + kind: "live", + reason: `All ${live.length} ${live.length === 1 ? "service is" : "services are"} running.`, + }; + } + if (upCount > 0) { + return { kind: "live", reason: `${upCount}/${live.length} services running.` }; + } + if (downCount > 0) { + const sample = live.slice(0, 3).map((l) => `${l.name}: ${l.status}`).join("\n"); + return { kind: "down", reason: `Apps are not running.\n${sample}` }; + } + + // All "unknown" — Coolify hasn't reported state yet (fresh project, + // API hiccup). Treat as transient rather than red. + return { + kind: "deploying", + reason: "Waiting on Coolify to report container state…", + }; } // ────────────────────────────────────────────────── -const PRESETS: Record< +const VISUALS: Record = { + build_failed: { label: "Build failed", color: "#c5392b", bg: "#c5392b14" }, + deploying: { label: "Deploying", color: "#3d5afe", bg: "#3d5afe10" }, + down: { label: "Down", color: "#c5392b", bg: "#c5392b14" }, + live: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" }, + empty: { label: "Empty", color: "#7c7770", bg: "#a09a9014" }, +}; + +const FALLBACK_PRESETS: Record< "discovery" | "architecture" | "building" | "active", { label: string; color: string; bg: string } > = { @@ -52,16 +179,37 @@ const PRESETS: Record< active: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" }, }; -function Pill({ label, color, bg }: { label: string; color: string; bg: string }) { +function Pill({ + label, color, bg, title, spinning, +}: { label: string; color: string; bg: string; title?: string; spinning?: boolean }) { return ( - - + + {spinning ? ( + + ) : ( + + )} {label} ); } + +function relTime(iso: string): string { + 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`; +} diff --git a/components/project/use-anatomy.ts b/components/project/use-anatomy.ts index e62f13ce..599be60e 100644 --- a/components/project/use-anatomy.ts +++ b/components/project/use-anatomy.ts @@ -34,6 +34,7 @@ export interface Anatomy { branch?: string; buildPack?: string; lastBuild?: { status: string; finishedAt?: string; commit?: string }; + inFlightBuild?: { status: string; finishedAt?: string; commit?: string }; }>; previews: Array<{ id: string; @@ -93,12 +94,31 @@ export interface UseAnatomyResult { reload: () => void; } -export function useAnatomy(projectId: string): UseAnatomyResult { +export interface UseAnatomyOptions { + /** When set, re-fetch anatomy every N ms while the component is + * mounted. Used by the project-header status pill so it surfaces + * Coolify build state transitions live (e.g. queued → in_progress + * → success) without the user having to refresh. Pass undefined or + * 0 to disable polling. */ + pollMs?: number; +} + +export function useAnatomy(projectId: string, options: UseAnatomyOptions = {}): UseAnatomyResult { + const { pollMs } = options; const [anatomy, setAnatomy] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [tick, setTick] = useState(0); + // Background poll. We bump `tick` on an interval, which re-runs the + // fetch effect below. Skipping the timer entirely when pollMs is + // zero/undefined keeps the default render path identical to before. + useEffect(() => { + if (!pollMs || pollMs <= 0) return; + const id = setInterval(() => setTick((t) => t + 1), pollMs); + return () => clearInterval(id); + }, [pollMs]); + useEffect(() => { let cancelled = false; const controller = new AbortController();