feat: Infrastructure section with 6 sub-sections (Builds, Databases, Services, Environment, Domains, Logs)
- Sidebar Infrastructure replaced with 6 named rows linking to /infrastructure?tab= - New /infrastructure page with left sub-nav and per-tab content panels: Builds — lists deployed Coolify apps with live status Databases — coming soon placeholder Services — coming soon placeholder Environment — variable table with masked values (scaffold) Domains — lists configured domains with SSL status Logs — dark terminal panel, ready to stream - Dim state on rows reflects whether data exists (e.g. no domains = dim) Made-with: Cursor
This commit is contained in:
353
app/[workspace]/project/[projectId]/infrastructure/page.tsx
Normal file
353
app/[workspace]/project/[projectId]/infrastructure/page.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState, useEffect } from "react";
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface InfraApp {
|
||||
name: string;
|
||||
domain?: string | null;
|
||||
coolifyServiceUuid?: string | null;
|
||||
}
|
||||
|
||||
interface ProjectData {
|
||||
giteaRepo?: string;
|
||||
giteaRepoUrl?: string;
|
||||
apps?: InfraApp[];
|
||||
}
|
||||
|
||||
// ── Tab definitions ───────────────────────────────────────────────────────────
|
||||
|
||||
const TABS = [
|
||||
{ id: "builds", label: "Builds", icon: "⬡" },
|
||||
{ id: "databases", label: "Databases", icon: "◫" },
|
||||
{ id: "services", label: "Services", icon: "◎" },
|
||||
{ id: "environment", label: "Environment", icon: "≡" },
|
||||
{ id: "domains", label: "Domains", icon: "◬" },
|
||||
{ id: "logs", label: "Logs", icon: "≈" },
|
||||
] as const;
|
||||
|
||||
type TabId = typeof TABS[number]["id"];
|
||||
|
||||
// ── Shared empty state ────────────────────────────────────────────────────────
|
||||
|
||||
function ComingSoonPanel({ icon, title, description }: { icon: string; title: string; description: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, display: "flex", flexDirection: "column",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
padding: 60, textAlign: "center", gap: 16,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: 14, background: "#f0ece4",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "1.5rem", color: "#b5b0a6",
|
||||
}}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "1rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>{title}</div>
|
||||
<div style={{ fontSize: "0.82rem", color: "#a09a90", maxWidth: 340, lineHeight: 1.6 }}>{description}</div>
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: 8, padding: "8px 18px",
|
||||
background: "#1a1a1a", color: "#fff",
|
||||
borderRadius: 7, fontSize: "0.78rem", fontWeight: 500,
|
||||
opacity: 0.4, cursor: "default",
|
||||
}}>
|
||||
Coming soon
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Builds tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function BuildsTab({ project }: { project: ProjectData | null }) {
|
||||
const apps = project?.apps ?? [];
|
||||
if (apps.length === 0) {
|
||||
return (
|
||||
<ComingSoonPanel
|
||||
icon="⬡"
|
||||
title="No deployments yet"
|
||||
description="Once your apps are deployed via Coolify, build history and deployment logs will appear here."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 720 }}>
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||
Deployed Apps
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{apps.map(app => (
|
||||
<div key={app.name} style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<span style={{ fontSize: "0.85rem", color: "#a09a90" }}>⬡</span>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a" }}>{app.name}</div>
|
||||
{app.domain && (
|
||||
<div style={{ fontSize: "0.72rem", color: "#a09a90", marginTop: 2 }}>{app.domain}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2e7d32", display: "inline-block" }} />
|
||||
<span style={{ fontSize: "0.73rem", color: "#6b6560" }}>Running</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Databases tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
function DatabasesTab() {
|
||||
return (
|
||||
<ComingSoonPanel
|
||||
icon="◫"
|
||||
title="Databases"
|
||||
description="Provision and manage PostgreSQL, Redis, and other databases for your project. Connection strings and credentials will be auto-injected into your environment."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Services tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ServicesTab() {
|
||||
return (
|
||||
<ComingSoonPanel
|
||||
icon="◎"
|
||||
title="Services"
|
||||
description="Background workers, email delivery, queues, file storage, and third-party integrations will be configured and monitored here."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Environment tab ───────────────────────────────────────────────────────────
|
||||
|
||||
function EnvironmentTab({ project }: { project: ProjectData | null }) {
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 720 }}>
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||
Environment Variables & Secrets
|
||||
</div>
|
||||
<div style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
overflow: "hidden", marginBottom: 20,
|
||||
}}>
|
||||
{/* Header row */}
|
||||
<div style={{
|
||||
display: "grid", gridTemplateColumns: "1fr 1fr auto",
|
||||
padding: "10px 18px", background: "#faf8f5",
|
||||
borderBottom: "1px solid #e8e4dc",
|
||||
fontSize: "0.68rem", fontWeight: 700, color: "#a09a90",
|
||||
letterSpacing: "0.06em", textTransform: "uppercase",
|
||||
}}>
|
||||
<span>Key</span><span>Value</span><span />
|
||||
</div>
|
||||
{/* Placeholder rows */}
|
||||
{["DATABASE_URL", "NEXTAUTH_SECRET", "GITEA_API_TOKEN"].map(k => (
|
||||
<div key={k} style={{
|
||||
display: "grid", gridTemplateColumns: "1fr 1fr auto",
|
||||
padding: "11px 18px", borderBottom: "1px solid #f0ece4",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#1a1a1a" }}>{k}</span>
|
||||
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#b5b0a6", letterSpacing: 2 }}>••••••••</span>
|
||||
<button style={{ background: "none", border: "none", cursor: "pointer", color: "#a09a90", fontSize: "0.72rem", padding: "2px 6px" }}>Edit</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ padding: "11px 18px", borderTop: "1px solid #f0ece4" }}>
|
||||
<button style={{
|
||||
background: "none", border: "1px dashed #d4cfc8", borderRadius: 6,
|
||||
padding: "6px 14px", fontSize: "0.75rem", color: "#a09a90",
|
||||
cursor: "pointer", width: "100%",
|
||||
}}>
|
||||
+ Add variable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: "0.75rem", color: "#b5b0a6", lineHeight: 1.6 }}>
|
||||
Variables are encrypted at rest and auto-injected into deployed containers. Secrets are never exposed in logs.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Domains tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function DomainsTab({ project }: { project: ProjectData | null }) {
|
||||
const apps = (project?.apps ?? []).filter(a => a.domain);
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 720 }}>
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||
Domains & SSL
|
||||
</div>
|
||||
{apps.length > 0 ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 20 }}>
|
||||
{apps.map(app => (
|
||||
<div key={app.name} style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.8rem", color: "#1a1a1a", fontWeight: 500 }}>
|
||||
{app.domain}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.7rem", color: "#a09a90", marginTop: 3 }}>{app.name}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2e7d32", display: "inline-block" }} />
|
||||
<span style={{ fontSize: "0.73rem", color: "#6b6560" }}>SSL active</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: "#fff", border: "1px dashed #d4cfc8", borderRadius: 10,
|
||||
padding: "32px 24px", textAlign: "center", marginBottom: 20,
|
||||
}}>
|
||||
<div style={{ fontSize: "0.82rem", color: "#a09a90" }}>No custom domains configured</div>
|
||||
<div style={{ fontSize: "0.73rem", color: "#b5b0a6", marginTop: 6 }}>Deploy an app first, then point a domain here.</div>
|
||||
</div>
|
||||
)}
|
||||
<button style={{
|
||||
background: "#1a1a1a", color: "#fff", border: "none",
|
||||
borderRadius: 8, padding: "9px 20px",
|
||||
fontSize: "0.78rem", fontWeight: 500, cursor: "pointer",
|
||||
opacity: 0.5,
|
||||
}}>
|
||||
+ Add domain
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Logs tab ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function LogsTab({ project }: { project: ProjectData | null }) {
|
||||
const apps = project?.apps ?? [];
|
||||
if (apps.length === 0) {
|
||||
return (
|
||||
<ComingSoonPanel
|
||||
icon="≈"
|
||||
title="No logs yet"
|
||||
description="Runtime logs, request traces, and error reports from your deployed services will stream here."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 900 }}>
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||
Runtime Logs
|
||||
</div>
|
||||
<div style={{
|
||||
background: "#1e1e1e", borderRadius: 10, padding: "20px 24px",
|
||||
fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#d4d4d4",
|
||||
lineHeight: 1.6, minHeight: 200,
|
||||
}}>
|
||||
<div style={{ color: "#6a9955" }}>{"# Logs will stream here once connected to Coolify"}</div>
|
||||
<div style={{ color: "#569cd6", marginTop: 8 }}>{"→ Select a service to tail its log output"}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Inner page ────────────────────────────────────────────────────────────────
|
||||
|
||||
function InfrastructurePageInner() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
|
||||
const activeTab = (searchParams.get("tab") ?? "builds") as TabId;
|
||||
const [project, setProject] = useState<ProjectData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}/apps`)
|
||||
.then(r => r.json())
|
||||
.then(d => setProject({ apps: d.apps ?? [], giteaRepo: d.giteaRepo, giteaRepoUrl: d.giteaRepoUrl }))
|
||||
.catch(() => {});
|
||||
}, [projectId]);
|
||||
|
||||
const setTab = (id: TabId) => {
|
||||
router.push(`/${workspace}/project/${projectId}/infrastructure?tab=${id}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100%", fontFamily: "Outfit, sans-serif", overflow: "hidden" }}>
|
||||
|
||||
{/* ── Left sub-nav ── */}
|
||||
<div style={{
|
||||
width: 190, flexShrink: 0,
|
||||
borderRight: "1px solid #e8e4dc",
|
||||
background: "#faf8f5",
|
||||
display: "flex", flexDirection: "column",
|
||||
padding: "16px 8px",
|
||||
gap: 2,
|
||||
overflow: "auto",
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
|
||||
letterSpacing: "0.1em", textTransform: "uppercase",
|
||||
padding: "0 8px 10px",
|
||||
}}>
|
||||
Infrastructure
|
||||
</div>
|
||||
{TABS.map(tab => {
|
||||
const active = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setTab(tab.id)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 9,
|
||||
padding: "7px 10px", borderRadius: 6,
|
||||
background: active ? "#f0ece4" : "transparent",
|
||||
border: "none", cursor: "pointer", width: "100%", textAlign: "left",
|
||||
color: active ? "#1a1a1a" : "#6b6560",
|
||||
fontSize: "0.8rem", fontWeight: active ? 600 : 450,
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||
>
|
||||
<span style={{ fontSize: "0.75rem", opacity: 0.65, width: 16, textAlign: "center" }}>{tab.icon}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
|
||||
{activeTab === "builds" && <BuildsTab project={project} />}
|
||||
{activeTab === "databases" && <DatabasesTab />}
|
||||
{activeTab === "services" && <ServicesTab />}
|
||||
{activeTab === "environment" && <EnvironmentTab project={project} />}
|
||||
{activeTab === "domains" && <DomainsTab project={project} />}
|
||||
{activeTab === "logs" && <LogsTab project={project} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Export ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function InfrastructurePage() {
|
||||
return (
|
||||
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "Outfit, sans-serif", fontSize: "0.85rem" }}>Loading…</div>}>
|
||||
<InfrastructurePageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -350,21 +350,48 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
|
||||
|
||||
{/* ── Infrastructure ── */}
|
||||
<SectionHeading label="Infrastructure" collapsed={collapsed} />
|
||||
{infraApps.length > 0 ? (
|
||||
infraApps.map(app => (
|
||||
<SectionRow
|
||||
key={app.name}
|
||||
icon="◈"
|
||||
label={app.domain ?? app.name}
|
||||
href={app.domain ? `https://${app.domain}` : undefined}
|
||||
icon="⬡"
|
||||
label="Builds"
|
||||
href={`${base}/infrastructure?tab=builds`}
|
||||
dim={infraApps.length === 0}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<SectionRow
|
||||
icon="◫"
|
||||
label="Databases"
|
||||
href={`${base}/infrastructure?tab=databases`}
|
||||
dim
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<SectionRow
|
||||
icon="◎"
|
||||
label="Services"
|
||||
href={`${base}/infrastructure?tab=services`}
|
||||
dim
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<SectionRow
|
||||
icon="≡"
|
||||
label="Environment"
|
||||
href={`${base}/infrastructure?tab=environment`}
|
||||
dim
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<SectionRow
|
||||
icon="◬"
|
||||
label="Domains"
|
||||
href={`${base}/infrastructure?tab=domains`}
|
||||
dim={!infraApps.some(a => a.domain)}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<SectionRow
|
||||
icon="≈"
|
||||
label="Logs"
|
||||
href={`${base}/infrastructure?tab=logs`}
|
||||
dim={infraApps.length === 0}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
))
|
||||
) : project?.giteaRepo ? (
|
||||
<SectionRow icon="◈" label="Gitea connected" href={project.giteaRepoUrl} collapsed={collapsed} />
|
||||
) : (
|
||||
<SectionRow icon="◈" label="Not deployed" dim collapsed={collapsed} />
|
||||
)}
|
||||
|
||||
<SectionDivider />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user