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
This commit is contained in:
@@ -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({
|
||||
<h1 style={projectTitle}>{project.name}</h1>
|
||||
{project.vision && <p style={projectVisionText}>{project.vision}</p>}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, flexShrink: 0, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
<ProjectHeaderUrls projectId={projectId} />
|
||||
<ProjectStagePill projectId={projectId} fallbackStage={project.stage} />
|
||||
<Link
|
||||
href={`/${workspace}/project/${projectId}/settings`}
|
||||
|
||||
124
components/project/project-header-urls.tsx
Normal file
124
components/project/project-header-urls.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Project header URL chips — surfaces the user's "front door" URLs
|
||||
* next to the status pill so they're one click away from any tab.
|
||||
*
|
||||
* - Live chips → every Coolify endpoint with an attached fqdn
|
||||
* - Prev. chips → every running dev-server preview
|
||||
*
|
||||
* If a live endpoint has no fqdn yet (fresh deploy, domain not set)
|
||||
* it's omitted — there's nothing to link to. Stopped previews are
|
||||
* also omitted (their URL would NXDOMAIN).
|
||||
*
|
||||
* Polls anatomy at the same cadence as the status pill so URLs
|
||||
* appear/disappear in real time as deploys finish or previews boot.
|
||||
*/
|
||||
|
||||
import { ExternalLink, Globe, Zap } from "lucide-react";
|
||||
import { useAnatomy } from "./use-anatomy";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function ProjectHeaderUrls({ projectId }: Props) {
|
||||
const { anatomy } = useAnatomy(projectId, { pollMs: 4000 });
|
||||
if (!anatomy) return null;
|
||||
|
||||
const liveLinks = anatomy.hosting.live
|
||||
.filter((l) => !!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 (
|
||||
<div style={wrap}>
|
||||
{liveLinks.map((l) => (
|
||||
<a
|
||||
key={l.key}
|
||||
href={l.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={liveChip}
|
||||
title={`Open ${l.label} → ${l.host}`}
|
||||
>
|
||||
<Globe size={11} style={{ flexShrink: 0 }} />
|
||||
<span style={chipLabel}>{l.label}</span>
|
||||
<ExternalLink size={10} style={{ flexShrink: 0, opacity: 0.7 }} />
|
||||
</a>
|
||||
))}
|
||||
{previewLinks.map((p) => (
|
||||
<a
|
||||
key={p.key}
|
||||
href={p.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={previewChip}
|
||||
title={`Open preview ${p.label} → ${p.host}`}
|
||||
>
|
||||
<Zap size={11} style={{ flexShrink: 0 }} />
|
||||
<span style={chipLabel}>{p.label}</span>
|
||||
<ExternalLink size={10} style={{ flexShrink: 0, opacity: 0.7 }} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -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 <Pill {...PRESETS[fallbackStage]} />;
|
||||
const state = useMemo<PillState | null>(() => {
|
||||
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 <Pill label={f.label} color={f.color} bg={f.bg} title="Loading project status…" />;
|
||||
}
|
||||
if (!state) {
|
||||
const f = FALLBACK_PRESETS[fallbackStage];
|
||||
return <Pill label={f.label} color={f.color} bg={f.bg} title="Project status unavailable." />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Pill
|
||||
label={visual.label}
|
||||
color={visual.color}
|
||||
bg={visual.bg}
|
||||
title={state.reason}
|
||||
spinning={state.kind === "deploying"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (anyFailed) return <Pill label="Down" color="#c5392b" bg="#c5392b14" />;
|
||||
if (anyRunning) return <Pill label="Live" color="#2e7d32" bg="#2e7d3210" />;
|
||||
if (buildingNow) return <Pill label="Building" color="#3d5afe" bg="#3d5afe10" />;
|
||||
// Coolify reports container status as `<phase>` or `<phase>:<health>`,
|
||||
// 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 <Pill {...PRESETS[fallbackStage]} />;
|
||||
// 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<PillState["kind"], { label: string; color: string; bg: string }> = {
|
||||
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 (
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 6,
|
||||
padding: "4px 10px", borderRadius: 4,
|
||||
fontSize: "0.7rem", fontWeight: 600, letterSpacing: "0.02em",
|
||||
color, background: bg, whiteSpace: "nowrap",
|
||||
}}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color }} />
|
||||
<span
|
||||
title={title}
|
||||
style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 6,
|
||||
padding: "4px 10px", borderRadius: 4,
|
||||
fontSize: "0.7rem", fontWeight: 600, letterSpacing: "0.02em",
|
||||
color, background: bg, whiteSpace: "nowrap",
|
||||
cursor: title ? "help" : "default",
|
||||
}}
|
||||
>
|
||||
{spinning ? (
|
||||
<Loader2 size={9} className="animate-spin" style={{ color }} />
|
||||
) : (
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color }} />
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
@@ -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<Anatomy | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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();
|
||||
|
||||
Reference in New Issue
Block a user