diff --git a/ai-new-thread.md b/ai-new-thread.md index 2995934..82250a0 100644 --- a/ai-new-thread.md +++ b/ai-new-thread.md @@ -202,3 +202,59 @@ early-failures are silently swallowed (failure PATCHes omit the `x-agent-runner- **Earlier (still true):** `vibncode://` deep link scheme is registered in `src-tauri/Info.plist`; Rust clippy is treated as errors on commit. + +--- + +## 7. Fetching Production Logs (Coolify apps) + +The Coolify dashboard (`https://coolify.vibnai.com/...`) is login-walled, so to read an app's logs +programmatically use one of the two paths below. Both read credentials from `vibn-frontend/.env.local` +(`COOLIFY_URL`, `COOLIFY_API_TOKEN`, and `COOLIFY_SSH_HOST` / `COOLIFY_SSH_PORT` / `COOLIFY_SSH_USER` / +`COOLIFY_SSH_PRIVATE_KEY_B64`). + +**The `` is the last path segment of the Coolify app URL:** +`.../application/y4cscsc8s08c8808go0448s0` -> appUuid = `y4cscsc8s08c8808go0448s0`. + +| App | appUuid | Build pack | Notes | +|---|---|---|---| +| `vibn-frontend` | `y4cscsc8s08c8808go0448s0` | dockerfile | Next.js, port 3000, fqdn vibnai.com | +| `vibn-telemetry` | `hou4vy5mtyg5mrx3w4nl2lxv` | dockerfile | port 4000; usage data lives in its **DB**, not stdout | + +### Method A - Coolify REST API (simplest) +`GET {COOLIFY_URL}/api/v1/applications/{uuid}/logs?lines=N` with `Authorization: Bearer {COOLIFY_API_TOKEN}`. +Returns `{ logs: "..." }`. Works for dockerfile / nixpacks / static apps; returns **empty for `dockercompose`** +(Coolify can't pick which service to tail). Helper script: +```bash +cd vibn-frontend +node scripts/fetch-app-logs.mjs [lines] # reads .env.local itself +``` + +### Method B - SSH + `docker logs` (full history, timestamps, date filter) +Use when the REST endpoint returns little/nothing (compose apps, or quiet services). Connects to the host with +the `ssh2` lib and runs `docker logs` against the app's container(s). Coolify names containers +`{appUuid}-{hash}`; a zero-downtime deploy briefly leaves TWO containers (old draining + new). Helper script: +```bash +cd vibn-frontend +# everything since the start of a UTC day (note: logs are UTC): +node --env-file=.env.local scripts/fetch-app-logs-ssh.mjs 2026-06-12 +# last 500 lines, no date filter: +node --env-file=.env.local scripts/fetch-app-logs-ssh.mjs "" 500 +# target a specific container during a rollout (substring match): +node --env-file=.env.local scripts/fetch-app-logs-ssh.mjs -003647723804 2026-06-12 +``` +Under the hood (reusable one-off): `runOnCoolifyHost()` in `lib/coolify-ssh.ts`, or for the compose-aware +unified fetcher, `getApplicationRuntimeLogs()` in `lib/coolify-logs.ts` (API first, SSH `docker logs` fallback). +List an app's containers: `docker ps -a --filter name= --format '{{.Names}}\t{{.Status}}'`. + +### Important caveat - container logs are NOT "usage logs" +Both the Next.js frontend (production server) and the telemetry service only emit **startup lines + explicit +`console.error`** to stdout - they do NOT log per-request activity. So `docker logs` is the right tool for +**deploy health and crashes/errors**, but for actual product **usage** you must query the data store: +- **Telemetry / usage** is written to Postgres by `vibn-telemetry-service` (its own `DATABASE_URL`). The existing + extractors (`vibn-frontend/scripts/extract-live-telemetry.ts`, `extract-ui-telemetry.ts`) pull telemetry by + querying `fs_chat_threads` / `fs_chat_messages` via `DATABASE_URL` - copy their pattern for date-ranged usage. +- **Runtime errors** at scale are captured by **Sentry** (auto-provisioned per project), not container stdout. + +### Verifying a deploy landed +`GET /api/v1/applications/{uuid}` returns `status` (`running:healthy` when good). On a fresh deploy the new +container shows `Up About a minute (healthy)` and the previous one disappears once draining completes. diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/agents/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/agents/page.tsx new file mode 100644 index 0000000..58997ea --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/agents/page.tsx @@ -0,0 +1,383 @@ +"use client"; + +import { Bot, Plus, FileText, Wrench, Plug } from "lucide-react"; + +export default function AgentsPage() { + return ( +
+
+

+ Agents +

+

+ Manage agents and users +

+
+ +
+
+ +
+

+ Create your first agent +

+

+ Agents talk to your users, work with your data, and run on schedules – + all guided by clear instructions you define in your own words. +

+ +
+
+ + Suggested for your app + + +
+ +
+ {/* Blurred out cards */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {/* Create from scratch */} +
+
+ + Create from scratch + +
+ +
+
+

+ Create your own agent, defining how it works, responds, and + integrates with your data. +

+
+
+
+ +
+

+ How you can use agents +

+
+
+
+ + + Guidelines + +
+

+ Define the agent's behavior, knowledge, and AI model. +

+
+
+
+ + + Tools + +
+

+ Configure what tools and data the agent can access. +

+
+
+
+ + + Connectors + +
+

+ Connect the agent to Gmail, Calendar & more. +

+
+
+
+
+
+ ); +} + +function RefreshIcon() { + return ( + + + + + + + ); +} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/analytics/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/analytics/page.tsx new file mode 100644 index 0000000..5cd3740 --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/analytics/page.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { BarChart2 } from "lucide-react"; + +export default function AnalyticsPage() { + return ( +
+
+

+ Analytics +

+

+ Track traffic, usage, and events. +

+
+ +
+
+ +
+

+ No data available yet +

+

+ Once your app is live and receiving traffic, your analytics metrics + will appear here. +

+
+
+ ); +} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/api/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/api/page.tsx new file mode 100644 index 0000000..e346e9f --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/api/page.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { Copy, Key } from "lucide-react"; + +export default function ApiPage() { + return ( +
+
+

+ API & Webhooks +

+

+ Connect external services to your application. +

+
+ +
+

+ REST API Endpoint +

+
+
+ + https://api.steadfast-camp-core-flow.vibn.app/v1 + +
+ +
+
+ +
+
+

+ API Keys +

+ +
+ +
+
+ +
+
+
+ Production Key +
+
+ Created 2 days ago +
+
+
+ pk_live_******************* +
+
+
+
+ ); +} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/automations/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/automations/page.tsx new file mode 100644 index 0000000..df38a45 --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/automations/page.tsx @@ -0,0 +1,109 @@ +"use client"; + +export default function AutomationsPage() { + return ( +
+
+

+ Automations +

+

+ Build and manage automations in your app. +

+
+ +
+
+
+ + Automations + + + Builder+ + +
+
+ +
+

+ Unlock automations +

+

+ To run automations in your app, you need backend functions enabled. + Upgrade to enable backend functions and start using automations. +

+ +
+
+
+ ); +} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/billing/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/billing/page.tsx new file mode 100644 index 0000000..787c8be --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/billing/page.tsx @@ -0,0 +1,66 @@ +import { Suspense } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"; +import { Loader2, CreditCard, ArrowRight, ShieldCheck, Zap } from "lucide-react"; + +export default async function BillingPage(props: { params: Promise<{ projectId: string }> }) { + const { projectId } = await props.params; + + return ( +
+
+

+ Payments & Billing +

+

+ Connect your bank account to start charging customers for this project. +

+
+ +
+ + {/* Onboarding Card */} + + +
+
+ +
+
+ Accept Payments with Stripe + Setup takes 3 minutes. Vibn handles the code. +
+
+
+ +
+

What you get immediately:

+
    +
  • + + AI Auto-Wiring: The Vibn AI will automatically inject your secure Stripe keys into your live Coolify application. +
  • +
  • + + Instant Compliance: Securely accept Apple Pay, Google Pay, and credit cards with PCI compliance handled automatically. +
  • +
+
+ +

+ By connecting, you agree to Stripe's Services Agreement. Vibn takes a small 1% platform fee on successful transactions to keep the AI platform running. +

+
+ + + +
+ +
+
+ ); +} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/code/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/code/page.tsx new file mode 100644 index 0000000..3084a54 --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/code/page.tsx @@ -0,0 +1,442 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { + Loader2, + AlertCircle, + ChevronDown, + ChevronRight, + Box, + Container, + CircleDot, +} from "lucide-react"; +import { GiteaFileTree } from "@/components/project/gitea-file-tree"; +import { GiteaFileViewer } from "@/components/project/gitea-file-viewer"; +import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy"; + +/** + * Product tab — everything that makes up the thing being shipped. + * + * Left rail (top → bottom): + * 1. Codebases — Gitea repos, each tile expands inline into a file + * tree; clicking a file previews it on the right. + * 2. Images — Coolify services backed by an upstream Docker image + * (Twenty CRM, n8n…). Clicking shows image meta on the right. + * + * Dev containers do not appear here — they are the AI's workshop, not + * part of the product surface. + */ + +type Selection = { type: "file"; codebaseId: string; path: string } | null; + +export default function CodeTab() { + const params = useParams(); + const projectId = params.projectId as string; + const { anatomy, loading, error } = useAnatomy(projectId); + + const codebases = anatomy?.product.codebases ?? null; + const reason = anatomy?.codebasesReason; + + const [selection, setSelection] = useState(null); + + useEffect(() => { + setSelection(null); + }, [projectId]); + + const showLoading = loading && !anatomy; + + return ( +
+
+ {/* ── Left rail ── */} +
+ {showLoading && ( + + Loading… + + )} + {error && !showLoading && ( + + {error} + + )} + + {anatomy && ( + <> + {/* Code Files */} + + {codebases && codebases.length === 0 && ( + + {reason === "no_repo" ? ( + <> + No codebase yet.{" "} + + Try: "Start building my app" + + + ) : ( + <> + Repo is empty — push a first commit.{" "} + + Try: "Scaffold a Next.js app" + + + )} + + )} + {codebases?.map((cb) => { + return ( +
+
+ + + + +
+
{cb.label}
+ {cb.hint &&
{cb.hint}
} +
+
+
+ + setSelection({ + type: "file", + codebaseId: cb.id, + path: p, + }) + } + /> +
+
+ ); + })} +
+ + )} +
+ + {/* ── Right pane ── */} + +
+
+ ); +} + +// ────────────────────────────────────────────────── +// Bits +// ────────────────────────────────────────────────── + +function RailGroup({ + title, + count, + children, +}: { + title: string; + count: number; + children: React.ReactNode; +}) { + return ( +
+
+ {title} + {count} +
+
{children}
+
+ ); +} + +function RailEmpty({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function DetailRow({ + label, + value, + dot, + href, +}: { + label: string; + value: string; + dot?: string; + href?: string; +}) { + return ( +
+ {label} + + {dot && } + {href ? ( + + {value} + + ) : ( + value + )} + +
+ ); +} + +function Inline({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function Empty({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +// ────────────────────────────────────────────────── + +function paneHeading(s: Selection): string { + if (!s) return "Preview"; + if (s.type === "file") return `Preview · ${shortPath(s.path)}`; + return "Preview"; +} +function shortPath(p: string) { + const parts = p.split("/"); + if (parts.length <= 2) return p; + return ".../" + parts.slice(-2).join("/"); +} +function statusColor(status: string) { + const s = status.toLowerCase(); + if (s.includes("running") || s.includes("healthy")) return "#2e7d32"; + if (s.includes("starting") || s.includes("deploying")) return "#d4a04a"; + if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy")) + return "#c5392b"; + return "#a09a90"; +} + +// ────────────────────────────────────────────────── +// 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 pageWrap: React.CSSProperties = { + padding: "28px 48px 48px", + fontFamily: INK.fontSans, + color: INK.ink, +}; +const grid: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "minmax(280px, 360px) minmax(0, 1fr)", + gap: 28, + maxWidth: 1400, + margin: "0 auto", + alignItems: "stretch", +}; +const leftCol: React.CSSProperties = { + minWidth: 0, + display: "flex", + flexDirection: "column", + gap: 18, +}; +const rightCol: React.CSSProperties = { + minWidth: 0, + display: "flex", + flexDirection: "column", +}; +const heading: React.CSSProperties = { + fontSize: "0.72rem", + fontWeight: 600, + letterSpacing: "0.12em", + textTransform: "uppercase", + color: INK.muted, + margin: "0 0 14px", +}; +const railGroup: React.CSSProperties = { + display: "flex", + flexDirection: "column", +}; +const railGroupHeader: React.CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "0 4px 8px", +}; +const railGroupTitle: React.CSSProperties = { + fontSize: "0.68rem", + fontWeight: 600, + 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 railItems: React.CSSProperties = { + display: "flex", + flexDirection: "column", + gap: 10, +}; +const railEmpty: React.CSSProperties = { + padding: "10px 12px", + fontSize: "0.74rem", + color: INK.muted, + border: `1px dashed ${INK.borderSoft}`, + borderRadius: 8, + lineHeight: 1.6, +}; +const nudge: React.CSSProperties = { + display: "block", + marginTop: 6, + fontStyle: "normal", + background: "#f3eee4", + borderRadius: 4, + padding: "3px 8px", + fontSize: "0.72rem", + color: "#7a6a50", +}; +const flatTile: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 10, + width: "100%", + padding: "12px 14px", + background: INK.cardBg, + border: `1px solid ${INK.borderSoft}`, + borderRadius: 10, + cursor: "pointer", + font: "inherit", + color: "inherit", + transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s", +}; +const codebaseTile: React.CSSProperties = { + background: INK.cardBg, + border: `1px solid ${INK.borderSoft}`, + borderRadius: 10, + overflow: "hidden", +}; +const tileHeader: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 8, + width: "100%", + padding: "12px 14px", + background: "transparent", + border: "none", + font: "inherit", + color: "inherit", +}; +const tileLabel: React.CSSProperties = { + fontSize: "0.85rem", + fontWeight: 600, + color: INK.ink, + marginBottom: 2, +}; +const tileHint: React.CSSProperties = { + fontSize: "0.74rem", + color: INK.mid, + lineHeight: 1.4, +}; +const tileBody: React.CSSProperties = { + padding: "8px 10px 12px", + borderTop: `1px solid ${INK.borderSoft}`, +}; +const chevronCell: React.CSSProperties = { + width: 14, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, +}; +const panel: React.CSSProperties = { + background: INK.cardBg, + border: `1px solid ${INK.border}`, + borderRadius: 10, + padding: 16, + flex: 1, + minHeight: 0, + display: "flex", + flexDirection: "column", +}; +const detailRow: React.CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "12px 4px", + borderBottom: `1px solid ${INK.borderSoft}`, +}; +const detailLabel: React.CSSProperties = { + fontSize: "0.72rem", + fontWeight: 600, + letterSpacing: "0.06em", + textTransform: "uppercase", + color: INK.muted, +}; +const detailValue: React.CSSProperties = { + fontSize: "0.85rem", + color: INK.ink, + display: "inline-flex", + alignItems: "center", +}; +const detailLink: React.CSSProperties = { + color: INK.ink, + textDecoration: "underline", +}; diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/data/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/data/page.tsx new file mode 100644 index 0000000..d03e2de --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/data/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +export default async function DataPage({ + params, +}: { + params: Promise<{ workspace: string; projectId: string }>; +}) { + const { workspace, projectId } = await params; + redirect(`/${workspace}/project/${projectId}/data/tables`); +} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/data/tables/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/data/tables/page.tsx new file mode 100644 index 0000000..f6c7f7d --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/data/tables/page.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useSearchParams } from "next/navigation"; +import { + Loader2, + AlertCircle, + ChevronDown, + ChevronRight, + Database, + CircleDot, +} from "lucide-react"; +import { DatabaseTableTree } from "@/components/project/database-table-tree"; +import { TableViewer } from "@/components/project/table-viewer"; +import { useAnatomy } from "@/components/project/use-anatomy"; + +type Selection = { + kind: "table"; + dbUuid: string; + schema: string; + name: string; +} | null; + +export default function DataTablesPage() { + const params = useParams(); + const searchParams = useSearchParams(); + const projectId = params.projectId as string; + const targetDbId = searchParams.get("db"); + + const { anatomy, loading, error } = useAnatomy(projectId); + + const databases = anatomy?.infrastructure?.databases ?? []; + + // If targetDbId is in the URL, only show that database. + // Otherwise, default to the first database in the list if available. + const activeDbId = + targetDbId || (databases.length > 0 ? databases[0].uuid : null); + const activeDatabases = databases.filter((db) => db.uuid === activeDbId); + + const [selection, setSelection] = useState(null); + + useEffect(() => { + setSelection(null); + }, [projectId, targetDbId]); + + const showLoading = loading && !anatomy; + + return ( +
+
+ {/* ── Left rail ── */} +
+ {showLoading && ( + + Loading… + + )} + {error && !showLoading && ( + + {error} + + )} + + {anatomy && ( + + {activeDatabases.length === 0 && ( + + No databases yet. + + Try: "Add a Postgres database to my project" + + + )} + {activeDatabases.map((db) => { + return ( +
+
+ + + + +
+
{db.name}
+
{db.type}
+
+ +
+
+ + setSelection({ + kind: "table", + dbUuid: db.uuid, + schema, + name, + }) + } + /> +
+
+ ); + })} +
+ )} +
+ + {/* ── Right pane ── */} + +
+
+ ); +} + +// ────────────────────────────────────────────────── +// Bits +// ────────────────────────────────────────────────── + +function RailGroup({ + title, + count, + children, +}: { + title: string; + count: number; + children: React.ReactNode; +}) { + return ( +
+
+ {title} + {count} +
+
{children}
+
+ ); +} + +function RailEmpty({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function Inline({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function Empty({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function paneHeading(s: Selection): string { + if (!s) return "Preview"; + if (s.kind === "table") + return `Preview · ${s.schema === "public" ? s.name : `${s.schema}.${s.name}`}`; + return "Preview"; +} + +function statusColor(status: string) { + const s = (status ?? "").toLowerCase(); + if (s.includes("running") || s.includes("healthy")) return "#2e7d32"; + if (s.includes("starting") || s.includes("deploying")) return "#d4a04a"; + if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy")) + return "#c5392b"; + return "#a09a90"; +} + +// ────────────────────────────────────────────────── +// 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 pageWrap: React.CSSProperties = { + padding: "28px 48px 48px", + fontFamily: INK.fontSans, + color: INK.ink, +}; +const grid: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "minmax(280px, 360px) minmax(0, 1fr)", + gap: 28, + maxWidth: 1400, + margin: "0 auto", + alignItems: "stretch", +}; +const leftCol: React.CSSProperties = { + minWidth: 0, + display: "flex", + flexDirection: "column", + gap: 18, +}; +const rightCol: React.CSSProperties = { + minWidth: 0, + display: "flex", + flexDirection: "column", +}; +const heading: React.CSSProperties = { + fontSize: "0.72rem", + fontWeight: 600, + letterSpacing: "0.12em", + textTransform: "uppercase", + color: INK.muted, + margin: "0 0 14px", +}; +const railGroup: React.CSSProperties = { + display: "flex", + flexDirection: "column", +}; +const railGroupHeader: React.CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "0 4px 8px", +}; +const railGroupTitle: React.CSSProperties = { + fontSize: "0.68rem", + fontWeight: 600, + 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 railItems: React.CSSProperties = { + display: "flex", + flexDirection: "column", + gap: 10, +}; +const railEmpty: React.CSSProperties = { + padding: "10px 12px", + fontSize: "0.74rem", + color: INK.muted, + border: `1px dashed ${INK.borderSoft}`, + borderRadius: 8, + lineHeight: 1.6, +}; +const nudge: React.CSSProperties = { + display: "block", + marginTop: 6, + fontStyle: "normal", + background: "#f3eee4", + borderRadius: 4, + padding: "3px 8px", + fontSize: "0.72rem", + color: "#7a6a50", +}; +const codebaseTile: React.CSSProperties = { + background: INK.cardBg, + border: `1px solid ${INK.borderSoft}`, + borderRadius: 10, + overflow: "hidden", +}; +const tileHeader: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 8, + width: "100%", + padding: "12px 14px", + background: "transparent", + border: "none", + font: "inherit", + color: "inherit", +}; +const tileLabel: React.CSSProperties = { + fontSize: "0.85rem", + fontWeight: 600, + color: INK.ink, + marginBottom: 2, +}; +const tileHint: React.CSSProperties = { + fontSize: "0.74rem", + color: INK.mid, + lineHeight: 1.4, + textTransform: "capitalize", +}; +const tileBody: React.CSSProperties = { + padding: "8px 10px 12px", + borderTop: `1px solid ${INK.borderSoft}`, +}; +const chevronCell: React.CSSProperties = { + width: 14, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, +}; +const panel: React.CSSProperties = { + background: INK.cardBg, + border: `1px solid ${INK.border}`, + borderRadius: 10, + padding: 16, + flex: 1, + minHeight: 0, + display: "flex", + flexDirection: "column", +}; diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/domains/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/domains/page.tsx new file mode 100644 index 0000000..f3fd1dd --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/domains/page.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { Copy } from "lucide-react"; + +export default function DomainsPage() { + return ( +
+
+

+ Domains +

+

+ Buy, connect and manage your domains.{" "} + + Learn more + +

+
+ +
+
+
+

+ Built-in URL +

+
+ +
+
+
+ + steadfast-camp-core-flow + + .vibn.app +
+ +
+
+ +

+ Custom domains +

+
+

+ Want to use your domain? +

+

+ Custom domains are available on our Builder plan and above. Upgrade to + continue working to this app. +

+ +
+ +
+
+
+
+

+ Email domain +

+ + Builder+ + +
+
+ no-reply@notifications.vibn.app +
+
+ Sender Name: App +
+
+ +
+
+
+ ); +} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx new file mode 100644 index 0000000..919d631 --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx @@ -0,0 +1,683 @@ +"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 ( +
+ {showLoading && ( +
+ + + Loading… + +
+ )} + {error && !showLoading && ( +
+ + {error} +
+ )} + + {anatomy && ( + <> + {/* ── Live endpoints ── */} +
+ + {anatomy.hosting.live.length === 0 ? ( + } + title="Nothing deployed yet" + hint="Ask the AI to deploy your app and it will appear here." + promptSuggestion="Deploy my app to production" + /> + ) : ( +
+ {anatomy.hosting.live.map((item) => ( + + ))} +
+ )} +
+ + {/* ── Previews ── */} + {anatomy.hosting.previews.length > 0 && ( +
+ +
+ {anatomy.hosting.previews.map((p) => ( + + ))} +
+
+ )} + + )} +
+ ); +} + +// ────────────────────────────────────────────────── +// Live card +// ────────────────────────────────────────────────── + +function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) { + const [deploying, setDeploying] = useState(false); + const [logsOpen, setLogsOpen] = useState(false); + const [logs, setLogs] = useState(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 ( +
+ {/* ── Card header ── */} +
+
+ + {item.name} + + {item.source === "repo" ? "built" : "image"} + +
+
+ +
+
+ + {/* ── Status line ── */} +
+ + {statusLabel} + + {item.lastBuild && ( + + · Last build {item.lastBuild.status}{" "} + {formatRelative(item.lastBuild.finishedAt)} + + )} +
+ + {/* ── Live URL ── */} + {primaryUrl ? ( +
+ + + {primaryUrl} + + + +
+ ) : ( +
+ + + No domain attached — ask the AI to add one. + +
+ )} + + {/* ── Extra domains ── */} + {item.domains.length > 1 && ( +
+ {item.domains.slice(1).map((d) => ( + + {d}{" "} + + + ))} +
+ )} + + {/* ── Logs toggle ── */} +
+ + + {logsOpen && ( +
+ {logsLoading ? ( + + Loading… + + ) : ( +
{logs || "(no logs)"}
+ )} +
+ )} +
+
+ ); +} + +// ────────────────────────────────────────────────── +// Preview row +// ────────────────────────────────────────────────── + +function PreviewRow({ preview }: { preview: Preview }) { + const running = preview.state === "running"; + return ( +
+
+ + + {preview.name} + + + port {preview.port} + + {preview.url && running && ( + + )} +
+
+ ); +} + +// ────────────────────────────────────────────────── +// 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 ( +
+ {title} + {count} +
+ ); +} + +function EmptySection({ + icon, + title, + hint, + promptSuggestion, +}: { + icon: React.ReactNode; + title: string; + hint: string; + promptSuggestion?: string; +}) { + return ( +
+
{icon}
+
+ {title} +
+
+ {hint} +
+ {promptSuggestion && ( +
+ + Try asking: + + + "{promptSuggestion}" + +
+ )} +
+ ); +} + +// ────────────────────────────────────────────────── +// 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, + }; +} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/infrastructure/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/infrastructure/page.tsx new file mode 100644 index 0000000..3479c77 --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/infrastructure/page.tsx @@ -0,0 +1,1035 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { + Loader2, AlertCircle, Database, KeyRound, CircleDot, + ShieldCheck, Mail, CreditCard, Sparkles, HardDrive, + ExternalLink, Pencil, RotateCw, + ChevronDown, ChevronRight, +} from "lucide-react"; +import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy"; +import { DatabaseTableTree } from "@/components/project/database-table-tree"; +import { TableViewer } from "@/components/project/table-viewer"; + +/** + * Infrastructure tab — supporting plumbing the product runs on. + * + * Six fixed sub-areas, always visible (even when empty) so a founder + * learns the model on a brand-new project: + * + * Databases · Auth · Email · Payments · Models · Storage · Secrets + * + * Categories explicitly NOT shown (yet): + * - SMS, Analytics, Search, Monitoring — removed Apr 29 2026 to keep + * the surface focused on what's actually wired today. Add back when + * each gets real UX (not just env-var detection). + * + * Storage is special: it's the workspace's bundled GCS-via-HMAC bucket + * (S3-compatible) — not a third-party detection. Every workspace gets + * one auto-provisioned, so the tile is always there with status. + */ + +type ProviderCategory = Anatomy["infrastructure"]["providers"][number]["category"]; + +type CategoryKey = + | "databases" + | "auth" | "email" | "payments" | "llm" + | "storage" + | "secrets"; + +type Selection = + | { kind: "category"; category: CategoryKey } + | { kind: "database"; uuid: string } + | { kind: "table"; dbUuid: string; schema: string; name: string } + | { kind: "provider"; id: string } + | { kind: "storage" } + | { kind: "secrets"; resourceUuid: string } + | null; + +interface CategoryDef { + key: CategoryKey; + label: string; + icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>; + blurb: string; + examples: string[]; + /** Vendor → external dashboard URL */ + dashboards?: Record; +} + +const INFRA_NUDGE = { + databases: 'No database yet. Try: "Add a Postgres database to my project"', + auth: 'No auth provider connected. Try: "Add Google OAuth to my app"', + email: 'No email provider. Try: "Set up email sending with Resend"', + payments: 'No payment provider. Try: "Connect Stripe to my project"', + llm: 'No LLM connected. Try: "Add an OpenAI key to this project"', + secrets: 'No secrets stored yet. Try: "Add my Stripe secret key"', +} as const; + +const CATEGORIES: CategoryDef[] = [ + { + key: "databases", label: "Databases", icon: Database, + blurb: "Postgres, Redis, Mongo, ClickHouse — anything your app reads or writes from.", + examples: ["PostgreSQL", "Redis", "MongoDB", "ClickHouse", "MySQL"], + }, + { + key: "auth", label: "Auth", icon: ShieldCheck, + blurb: "Identity, sessions, SSO. Detected from env-var keys (CLERK_*, AUTH0_*, NEXTAUTH_*…).", + examples: ["Clerk", "Auth0", "Supabase Auth", "NextAuth", "WorkOS", "SuperTokens"], + dashboards: { Clerk: "https://dashboard.clerk.com", Auth0: "https://manage.auth0.com", "Supabase Auth": "https://supabase.com/dashboard", WorkOS: "https://dashboard.workos.com" }, + }, + { + key: "email", label: "Email", icon: Mail, + blurb: "Transactional + outbound email. SMS is intentionally not here yet.", + examples: ["Resend", "Postmark", "SendGrid", "Mailgun", "AWS SES", "Loops"], + dashboards: { Resend: "https://resend.com/emails", Postmark: "https://account.postmarkapp.com", SendGrid: "https://app.sendgrid.com", Mailgun: "https://app.mailgun.com", "AWS SES": "https://console.aws.amazon.com/ses" }, + }, + { + key: "payments", label: "Payments", icon: CreditCard, + blurb: "Stripe / Paddle / LemonSqueezy. Connect Stripe to enable webhook + checkout setup (coming soon).", + examples: ["Stripe", "LemonSqueezy", "Paddle"], + dashboards: { Stripe: "https://dashboard.stripe.com", LemonSqueezy: "https://app.lemonsqueezy.com", Paddle: "https://vendors.paddle.com" }, + }, + { + key: "llm", label: "Models", icon: Sparkles, + blurb: "AI models the project calls. BYOK (Bring Your Own Key) lets you use your own provider keys instead of platform defaults.", + examples: ["OpenAI", "Anthropic", "Google AI", "Mistral", "Cohere", "Groq", "OpenRouter"], + dashboards: { OpenAI: "https://platform.openai.com", Anthropic: "https://console.anthropic.com", "Google AI": "https://aistudio.google.com", Mistral: "https://console.mistral.ai", Groq: "https://console.groq.com", OpenRouter: "https://openrouter.ai" }, + }, + { + key: "storage", label: "Storage", icon: HardDrive, + blurb: "Vibn provisions an S3-compatible bucket per workspace. Every project in the workspace shares it.", + examples: ["GCS bucket (S3-compatible)"], + }, + { + key: "secrets", label: "Secrets", icon: KeyRound, + blurb: "Every env var across every app + service. Values are never read here; rotate via the AI chat.", + examples: [], + }, +]; + +function categoryDef(key: CategoryKey): CategoryDef { + return CATEGORIES.find(c => c.key === key)!; +} + +export default function InfrastructureTab() { + const params = useParams(); + const projectId = params.projectId as string; + const { anatomy, loading, error } = useAnatomy(projectId); + + const [selection, setSelection] = useState(null); + /** Which database tiles are expanded inline to show their tables. */ + const [expandedDbs, setExpandedDbs] = useState>(new Set()); + + const toggleDb = (uuid: string) => { + setExpandedDbs(prev => { + const next = new Set(prev); + if (next.has(uuid)) next.delete(uuid); else next.add(uuid); + return next; + }); + }; + + // Auto-expand the first database tile once anatomy lands so the user + // sees tables immediately on a project that has one DB. + useEffect(() => { + if (anatomy && anatomy.infrastructure.databases[0] && expandedDbs.size === 0) { + setExpandedDbs(new Set([anatomy.infrastructure.databases[0].uuid])); + } + }, [anatomy, expandedDbs.size]); + + const showLoading = loading && !anatomy; + + return ( +
+
+ {/* ── Left rail ── */} +
+ {showLoading && ( + Loading… + )} + {error && !showLoading && ( + {error} + )} + + {anatomy && CATEGORIES.map(def => ( + + ))} +
+ + {/* ── Right pane ── */} + +
+
+ ); +} + +// ────────────────────────────────────────────────── +// Left-rail group per category +// ────────────────────────────────────────────────── + +function CategoryRail({ + def, anatomy, selection, onSelect, + projectId, expandedDbs, onToggleDb, +}: { + def: CategoryDef; + anatomy: Anatomy; + selection: Selection; + onSelect: (s: Selection) => void; + projectId: string; + expandedDbs: Set; + onToggleDb: (uuid: string) => void; +}) { + const Icon = def.icon; + const headerActive = selection?.kind === "category" && selection.category === def.key; + + // Storage is a single-tile category backed by workspace state + if (def.key === "storage") { + const s = anatomy.infrastructure.bundledStorage; + const present = s.status !== "unprovisioned"; + const active = selection?.kind === "storage"; + return ( +
+ onSelect({ kind: "category", category: def.key })} /> + {present && ( +
+ +
+ )} +
+ ); + } + + // Databases render as expandable cards (like Codebases on Product) so + // the table list lives inline; click a table to preview rows on the right. + if (def.key === "databases") { + const dbs = anatomy.infrastructure.databases; + return ( +
+ onSelect({ kind: "category", category: def.key })} /> + {dbs.length > 0 && ( +
+ {dbs.map(db => { + const open = expandedDbs.has(db.uuid); + const tileSelected = + selection?.kind === "database" && selection.uuid === db.uuid; + const selectedTable = + selection?.kind === "table" && selection.dbUuid === db.uuid + ? { schema: selection.schema, name: selection.name } + : undefined; + + return ( +
+ + {open && ( +
+ + onSelect({ kind: "table", dbUuid: db.uuid, schema, name }) + } + /> +
+ )} +
+ ); + })} +
+ )} +
+ ); + } + + const items = itemsForCategory(def.key, anatomy); + const count = + def.key === "secrets" + ? anatomy.infrastructure.secrets.total + : items.length; + + return ( +
+ onSelect({ kind: "category", category: def.key })} /> + {items.length > 0 && ( +
+ {items.map(item => renderRailItem(def.key, item, selection, onSelect))} +
+ )} +
+ ); +} + +function CategoryHeader({ + def, count, active, onClick, +}: { + def: CategoryDef; count: number; active: boolean; onClick: () => void; +}) { + const Icon = def.icon; + return ( + + ); +} + +function itemsForCategory(key: CategoryKey, a: Anatomy): unknown[] { + if (key === "databases") return a.infrastructure.databases; + if (key === "secrets") return a.infrastructure.secrets.byResource; + if (key === "storage") return []; + return a.infrastructure.providers.filter(p => p.category === key); +} + +function renderRailItem( + key: CategoryKey, + item: unknown, + selection: Selection, + onSelect: (s: Selection) => void +) { + if (key === "databases") { + const db = item as Anatomy["infrastructure"]["databases"][number]; + const active = selection?.kind === "database" && selection.uuid === db.uuid; + return ( + + ); + } + if (key === "secrets") { + const r = item as Anatomy["infrastructure"]["secrets"]["byResource"][number]; + const active = selection?.kind === "secrets" && selection.resourceUuid === r.resourceUuid; + return ( + + ); + } + // Provider + const p = item as Anatomy["infrastructure"]["providers"][number]; + const active = selection?.kind === "provider" && selection.id === p.id; + const totalKeys = p.attachments.reduce((n, a) => n + a.keys.length, 0); + return ( + + ); +} + +// ────────────────────────────────────────────────── +// Right-pane content +// ────────────────────────────────────────────────── + +function Overview({ + anatomy, onJump, +}: { anatomy: Anatomy; onJump: (s: Selection) => void }) { + const dbCount = anatomy.infrastructure.databases.length; + const providerCount = anatomy.infrastructure.providers.length; + const secretsCount = anatomy.infrastructure.secrets.total; + const storage = anatomy.infrastructure.bundledStorage; + + return ( +
+
+ dbCount && onJump({ kind: "database", uuid: anatomy.infrastructure.databases[0].uuid })} + /> + providerCount && onJump({ kind: "provider", id: anatomy.infrastructure.providers[0].id })} + /> + onJump({ kind: "storage" })} + /> + secretsCount && onJump({ kind: "secrets", resourceUuid: anatomy.infrastructure.secrets.byResource[0].resourceUuid })} + /> +
+
+ ); +} + +function CategoryDetail({ + def, anatomy, onJump, +}: { def: CategoryDef; anatomy: Anatomy; onJump: (s: Selection) => void }) { + const items = itemsForCategory(def.key, anatomy); + const count = + def.key === "secrets" ? anatomy.infrastructure.secrets.total : + def.key === "storage" ? (anatomy.infrastructure.bundledStorage.status === "unprovisioned" ? 0 : 1) : + items.length; + + // Per-category functional CTAs (no explainer prose). + let actionRow: React.ReactNode = null; + if (def.key === "payments" && !items.length) { + actionRow = ( + + ); + } else if (def.key === "storage") { + actionRow = ( + + ); + } + + return ( +
+ {actionRow} + +
+ + Connected{" "} + ({count}) + + {count === 0 ? ( +
+ {INFRA_NUDGE[def.key as keyof typeof INFRA_NUDGE] ?? "None yet."} +
+ ) : def.key === "secrets" ? ( +
+ {anatomy.infrastructure.secrets.byResource.map(r => ( +
+ {r.resourceName} + {r.count} keys · {r.resourceKind} +
+ ))} +
+ ) : def.key === "databases" ? ( +
+ {anatomy.infrastructure.databases.map(db => ( +
+ {db.name} + {db.type} · {db.status} +
+ ))} +
+ ) : def.key === "storage" ? ( +
+
+ Workspace bucket + {anatomy.infrastructure.bundledStorage.bucketName ?? "—"} +
+
+ ) : ( +
+ {(items as Anatomy["infrastructure"]["providers"]).map(p => ( +
+ {p.vendor} + + {p.attachments.length} resource{p.attachments.length === 1 ? "" : "s"} + +
+ ))} +
+ )} +
+
+ ); +} + +function DatabaseDetail({ uuid, anatomy }: { uuid: string; anatomy: Anatomy }) { + const db = anatomy.infrastructure.databases.find(d => d.uuid === uuid); + if (!db) return This database is no longer in the project.; + + return ( +
+ + + + {db.publicPort != null && } + {db.internalAddress && } + +
+ Connection env +
+ {db.consumerEnvKey}={""} +
+
+
+ ); +} + +function ProviderDetail({ id, anatomy }: { id: string; anatomy: Anatomy }) { + const p = anatomy.infrastructure.providers.find(x => x.id === id); + if (!p) return This provider is no longer detected.; + const def = categoryDef(p.category); + const dashboard = def.dashboards?.[p.vendor]; + const totalKeys = p.attachments.reduce((n, a) => n + a.keys.length, 0); + + // Stripe-specific connection callout + const isStripe = p.vendor === "Stripe"; + + return ( +
+ + + + + {dashboard && ( + + Open {p.vendor} dashboard + + )} + +
+ Detected here + {p.attachments.map(att => ( +
+
+ {att.resourceName} {att.resourceKind} +
+
+ {att.keys.map(k => {k})} +
+
+ ))} +
+
+ ); +} + +function StorageDetail({ anatomy }: { anatomy: Anatomy }) { + const s = anatomy.infrastructure.bundledStorage; + + if (s.status === "unprovisioned") { + return No bucket provisioned yet.; + } + + return ( +
+ + {s.bucketName && } + {s.region && } + {s.hmacAccessId && } + +
+ Connection env +
+ +{`STORAGE_ENDPOINT=https://storage.googleapis.com +STORAGE_REGION=${s.region ?? "auto"} +STORAGE_BUCKET=${s.bucketName ?? ""} +STORAGE_ACCESS_KEY_ID=${s.hmacAccessId ?? ""} +STORAGE_SECRET_ACCESS_KEY=`} + +
+
+ + {s.errorMessage && ( +
+ + {s.errorMessage} +
+ )} +
+ ); +} + +function SecretsDetail({ + resourceUuid, anatomy, +}: { resourceUuid: string; anatomy: Anatomy }) { + const r = anatomy.infrastructure.secrets.byResource.find(x => x.resourceUuid === resourceUuid); + if (!r) return This resource is no longer in the project.; + + // Group keys by detected provider so the user sees Stripe / Resend / + // OpenAI bunched together with an Other catch-all for unrecognised keys. + const providerByKey = new Map(); + for (const p of anatomy.infrastructure.providers) { + const att = p.attachments.find(a => a.resourceUuid === resourceUuid); + if (!att) continue; + for (const k of att.keys) { + providerByKey.set(k, { vendor: p.vendor, category: categoryDef(p.category as CategoryKey).label }); + } + } + + const groups = new Map(); + for (const k of r.keys) { + const tag = providerByKey.get(k); + const groupKey = tag ? `${tag.vendor}` : "Other"; + if (!groups.has(groupKey)) { + groups.set(groupKey, { label: tag ? `${tag.vendor} · ${tag.category}` : "Other (project-defined)", keys: [] }); + } + groups.get(groupKey)!.keys.push(k); + } + + return ( +
+ + + + +
+ Keys +
+ {[...groups.values()].map(g => ( +
+
{g.label}
+ {g.keys.map(k => ( +
+ + {k} + +
+ + +
+
+ ))} +
+ ))} +
+
+
+ ); +} + +// ────────────────────────────────────────────────── +// Tiny helpers +// ────────────────────────────────────────────────── + +function paneHeading(s: Selection, a: Anatomy | null): string { + if (!a) return "Details"; + if (!s) return "Overview"; + if (s.kind === "category") return `About · ${categoryDef(s.category).label}`; + if (s.kind === "database") return `Database · ${a.infrastructure.databases.find(x => x.uuid === s.uuid)?.name ?? ""}`; + if (s.kind === "table") return `Preview · ${s.schema === "public" ? s.name : `${s.schema}.${s.name}`}`; + if (s.kind === "provider") return `Provider · ${a.infrastructure.providers.find(x => x.id === s.id)?.vendor ?? ""}`; + if (s.kind === "storage") return "Storage · Workspace bucket"; + if (s.kind === "secrets") return `Secrets · ${a.infrastructure.secrets.byResource.find(x => x.resourceUuid === s.resourceUuid)?.resourceName ?? ""}`; + return "Details"; +} + +function statusColor(status: string) { + const s = (status ?? "").toLowerCase(); + if (s.includes("running") || s.includes("healthy")) return "#2e7d32"; + if (s.includes("starting") || s.includes("deploying")) return "#d4a04a"; + if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy")) return "#c5392b"; + return "#a09a90"; +} +function storageColor(status: string) { + if (status === "ready") return "#2e7d32"; + if (status === "pending" || status === "partial") return "#d4a04a"; + if (status === "error") return "#c5392b"; + return "#a09a90"; +} + +function tileButtonStyle(active: boolean): React.CSSProperties { + return { + ...railItem, + borderColor: active ? INK.ink : INK.borderSoft, + boxShadow: active ? `0 0 0 1px ${INK.ink}` : "none", + background: active ? "#fffdf8" : INK.cardBg, + }; +} + +function KvRow({ + label, value, dot, mono, +}: { label: string; value: string; dot?: string; mono?: boolean }) { + return ( +
+ {label} + + {dot && } + + {value} + + +
+ ); +} + +function SectionTitle({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function Para({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) { + return

{children}

; +} + +function Inline({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function Empty({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function OverviewStat({ + label, value, onClick, +}: { label: string; value: number; onClick?: () => void }) { + return ( + + ); +} + +// ────────────────────────────────────────────────── +// 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 pageWrap: React.CSSProperties = { + padding: "28px 48px 48px", + fontFamily: INK.fontSans, + color: INK.ink, +}; +const grid: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "minmax(280px, 360px) minmax(0, 1fr)", + gap: 28, + maxWidth: 1400, + margin: "0 auto", + alignItems: "stretch", +}; +const leftCol: React.CSSProperties = { + minWidth: 0, display: "flex", flexDirection: "column", gap: 14, +}; +const rightCol: React.CSSProperties = { + minWidth: 0, display: "flex", flexDirection: "column", +}; +const heading: React.CSSProperties = { + fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em", + textTransform: "uppercase", color: INK.muted, margin: "0 0 14px", +}; +const railGroup: React.CSSProperties = { display: "flex", flexDirection: "column" }; +const railGroupHeader: React.CSSProperties = { + display: "flex", alignItems: "center", justifyContent: "space-between", + padding: "6px 8px", borderRadius: 6, + cursor: "pointer", font: "inherit", color: "inherit", +}; +const railGroupTitle: React.CSSProperties = { + fontSize: "0.68rem", fontWeight: 600, 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 railItems: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 6, marginTop: 4 }; +const railItem: React.CSSProperties = { + display: "flex", alignItems: "center", gap: 10, + width: "100%", padding: "10px 12px", + border: `1px solid ${INK.borderSoft}`, borderRadius: 8, + cursor: "pointer", font: "inherit", color: "inherit", + transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s", +}; +const railEmptyButton: React.CSSProperties = { + width: "100%", textAlign: "left", + padding: "8px 12px", fontSize: "0.74rem", color: INK.muted, + background: "transparent", + border: `1px dashed ${INK.borderSoft}`, borderRadius: 8, + lineHeight: 1.45, cursor: "pointer", marginTop: 4, + fontFamily: "inherit", +}; +const tileBody: React.CSSProperties = { minWidth: 0, textAlign: "left", flex: 1 }; +const dbCard: React.CSSProperties = { + background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, + borderRadius: 10, overflow: "hidden", +}; +const dbCardHeader: React.CSSProperties = { + display: "flex", alignItems: "center", gap: 8, width: "100%", + padding: "10px 12px", background: "transparent", border: "none", + cursor: "pointer", font: "inherit", color: "inherit", +}; +const dbCardBody: React.CSSProperties = { + padding: "8px 10px 12px", borderTop: `1px solid ${INK.borderSoft}`, +}; +const chevronCell: React.CSSProperties = { + width: 14, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0, +}; +const tileLabel: React.CSSProperties = { + fontSize: "0.85rem", fontWeight: 600, color: INK.ink, marginBottom: 2, +}; +const tileHint: React.CSSProperties = { + fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4, textTransform: "capitalize", +}; +const iconStyle: React.CSSProperties = { color: INK.mid, flexShrink: 0 }; +const panel: React.CSSProperties = { + background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10, + padding: 18, flex: 1, minHeight: 0, display: "flex", flexDirection: "column", + overflowY: "auto", +}; +const detailRow: React.CSSProperties = { + display: "flex", alignItems: "center", justifyContent: "space-between", + padding: "10px 4px", borderBottom: `1px solid ${INK.borderSoft}`, +}; +const detailLabel: React.CSSProperties = { + fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.06em", + textTransform: "uppercase", color: INK.muted, +}; +const detailValue: React.CSSProperties = { + fontSize: "0.85rem", color: INK.ink, display: "inline-flex", alignItems: "center", +}; +const sectionTitle: React.CSSProperties = { + fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.08em", + textTransform: "uppercase", color: INK.muted, marginBottom: 8, +}; +const para: React.CSSProperties = { + margin: 0, fontSize: "0.85rem", color: INK.ink, lineHeight: 1.55, +}; +const overviewGrid: React.CSSProperties = { + display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 10, +}; +const overviewStat: React.CSSProperties = { + display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 4, + padding: "14px 16px", border: `1px solid ${INK.borderSoft}`, borderRadius: 10, + background: INK.cardBg, color: INK.ink, font: "inherit", +}; +const overviewStatValue: React.CSSProperties = { + fontSize: "1.6rem", fontWeight: 600, lineHeight: 1, +}; +const overviewStatLabel: React.CSSProperties = { + fontSize: "0.7rem", color: INK.muted, letterSpacing: "0.08em", textTransform: "uppercase", +}; +const chipRow: React.CSSProperties = { display: "flex", flexWrap: "wrap", gap: 6 }; +const chip: React.CSSProperties = { + fontSize: "0.74rem", color: INK.mid, + padding: "3px 9px", borderRadius: 999, + background: "#fafaf6", border: `1px solid ${INK.borderSoft}`, +}; +const listBox: React.CSSProperties = { + display: "flex", flexDirection: "column", + border: `1px solid ${INK.borderSoft}`, borderRadius: 8, overflow: "hidden", +}; +const listRow: React.CSSProperties = { + display: "flex", alignItems: "center", justifyContent: "space-between", + padding: "10px 12px", fontSize: "0.82rem", + borderBottom: `1px solid ${INK.borderSoft}`, +}; +const emptyBox: React.CSSProperties = { + padding: "12px 14px", fontSize: "0.82rem", color: INK.mid, + background: "#fafaf6", border: `1px dashed ${INK.borderSoft}`, borderRadius: 8, + lineHeight: 1.5, +}; +const errorBox: React.CSSProperties = { + padding: "12px 14px", fontSize: "0.82rem", color: "#7a1f15", + background: "#fbe9e7", border: `1px solid #f4c2bc`, borderRadius: 8, + lineHeight: 1.5, +}; +const codeBox: React.CSSProperties = { + padding: "10px 12px", + background: "#fafaf6", + border: `1px solid ${INK.borderSoft}`, borderRadius: 8, + overflowX: "auto", + whiteSpace: "pre", +}; +const code: React.CSSProperties = { + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + fontSize: "0.78rem", color: INK.ink, whiteSpace: "pre", +}; +const inlineCode: React.CSSProperties = { + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + fontSize: "0.78rem", padding: "1px 5px", + background: "#fafaf6", border: `1px solid ${INK.borderSoft}`, borderRadius: 4, +}; +const dashboardLink: React.CSSProperties = { + display: "inline-flex", alignItems: "center", gap: 6, + padding: "8px 12px", borderRadius: 8, + background: INK.ink, color: "#fff", fontSize: "0.82rem", fontWeight: 600, + textDecoration: "none", alignSelf: "flex-start", +}; +const ctaButton: React.CSSProperties = { + alignSelf: "flex-start", + padding: "8px 14px", borderRadius: 8, + background: INK.ink, color: "#fff", fontSize: "0.82rem", fontWeight: 600, + border: "none", cursor: "pointer", font: "inherit", +}; +const attachmentBlock: React.CSSProperties = { + padding: "10px 12px", marginTop: 8, + background: "#fafaf6", borderRadius: 8, +}; +const attachmentHeader: React.CSSProperties = { + fontSize: "0.82rem", fontWeight: 600, color: INK.ink, marginBottom: 6, + display: "flex", alignItems: "center", gap: 8, +}; +const attachmentBadge: React.CSSProperties = { + fontSize: "0.62rem", fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase", + color: INK.mid, background: "#ece6da", padding: "1px 6px", borderRadius: 4, +}; +const keyList: React.CSSProperties = { + display: "flex", flexWrap: "wrap", gap: 4, +}; +const keyChip: React.CSSProperties = { + fontSize: "0.7rem", color: INK.mid, padding: "2px 6px", + background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 4, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', +}; +const secretGroupHeader: React.CSSProperties = { + padding: "8px 12px", fontSize: "0.7rem", color: INK.muted, + letterSpacing: "0.08em", textTransform: "uppercase", + background: "#fafaf6", +}; +const secretRow: React.CSSProperties = { + display: "flex", alignItems: "center", justifyContent: "space-between", + padding: "8px 12px", + borderTop: `1px solid ${INK.borderSoft}`, +}; +const secretActions: React.CSSProperties = { + display: "flex", gap: 4, +}; +const iconButton: React.CSSProperties = { + display: "inline-flex", alignItems: "center", justifyContent: "center", + width: 24, height: 24, padding: 0, + background: "transparent", border: `1px solid ${INK.borderSoft}`, + borderRadius: 4, cursor: "not-allowed", color: INK.muted, + font: "inherit", +}; diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/integrations/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/integrations/page.tsx new file mode 100644 index 0000000..80f0be1 --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/integrations/page.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { Diamond } from "lucide-react"; + +export default function IntegrationsPage() { + return ( +
+
+

+ Integrations +

+
+ +
+
+ + +
+ +
+
+
+
+
Stripe
+
+ Sell products or subscriptions and get paid online. +
+
+ +
+
+ +

+ Connectors +

+

+ Connect your app to popular services. +

+ +
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+
+ Connector {i} +
+
+ Connect with external service for app data. +
+
+ ))} +
+
+ +
+
+
+ +
+

+ Unlock this feature +

+

+ This feature is only available on the Builder plan or higher. + Upgrade to continue working without limits. +

+ +
+
+
+ ); +} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/logs/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/logs/page.tsx new file mode 100644 index 0000000..6b2de0e --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/logs/page.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Search } from "lucide-react"; + +export default function LogsPage() { + return ( +
+
+

+ Logs +

+

+ View application and server logs. +

+
+ +
+
+
+ + +
+
+
+
+ 14:32:01 + [info] + Server started on port 3000 +
+
+ 14:32:05 + [info] + Database connected successfully +
+
+ 14:45:12 + [http] + GET /api/users 200 OK - 45ms +
+
+
+
+ ); +} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/market/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/market/page.tsx new file mode 100644 index 0000000..b6fb9e0 --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/market/page.tsx @@ -0,0 +1,351 @@ +import { BigQuery } from '@google-cloud/bigquery'; +import { Suspense } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Loader2, Users, Target, Search, Database } from "lucide-react"; + +async function getMarketData(projectId: string) { + let bqOptions: any = { projectId: process.env.GCP_PROJECT_ID || 'master-ai-484822' }; + if (process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64) { + try { + const saStr = Buffer.from(process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64, 'base64').toString('utf8'); + bqOptions.credentials = JSON.parse(saStr); + bqOptions.projectId = bqOptions.credentials.project_id; + } catch (e) {} + } + const bigquery = new BigQuery(bqOptions); + + try { + const [leads] = await bigquery.query({ + query: `SELECT * FROM \`master-ai-484822.vibn_market_data.market_leads\` WHERE project_id = @projectId OR project_id = 'SYSTEM_BACKFILL' LIMIT 50`, + params: { projectId } + }); + + const [aggregations] = await bigquery.query({ + query: `SELECT * FROM \`master-ai-484822.vibn_market_data.market_aggregations\` ORDER BY last_updated DESC LIMIT 1` + }); + + const [competitors] = await bigquery.query({ + query: `SELECT * FROM \`master-ai-484822.vibn_market_data.software_providers_seo\` ORDER BY last_updated DESC LIMIT 10` + }); + + return { leads, aggregations: aggregations[0], competitors }; + } catch (err) { + console.error("BigQuery Error:", err); + return { leads: [], aggregations: null, competitors: [] }; + } +} + +export default async function MarketPage(props: { params: Promise<{ projectId: string }> }) { + const { projectId } = await props.params; + + return ( +
+
+

+ Market Intelligence +

+

+ Real-time TAM, verified leads, and competitor teardowns from the Vibn Data Co-op. +

+
+ +
}> + + + + ); +} + +async function MarketDataDisplay({ projectId }: { projectId: string }) { + const data = await getMarketData(projectId); + + if (!data.aggregations && data.leads.length === 0) { + return ( + + + +

No Market Data Yet

+

+ Ask the Vibn AI to run market research for your niche to populate this dashboard with leads, competitors, and SEO insights. +

+
+
+ ); + } + + return ( +
+ {/* Overview Cards */} +
+ + + + Total Addressable Market + + + +
+ {data.aggregations?.total_market_size?.toLocaleString() || "..."} +
+

Verified businesses in selected region

+
+
+ + + + + Qualified Leads Captured + + + +
+ {data.leads.length} +
+

Ready for cold outreach

+
+
+ + + + + Tech Debt Indicator + + + +
+ {data.aggregations ? Math.round((data.aggregations.websites_count / data.aggregations.total_market_size) * 100) : 0}% +
+

Of TAM have a website

+
+
+
+ +
+ {/* Pain Points */} + {data.aggregations && ( + + + Customer Pain Points + Extracted from Google Reviews + + +
+ {Object.entries(typeof data.aggregations.customer_pain_points === 'string' ? JSON.parse(data.aggregations.customer_pain_points) : data.aggregations.customer_pain_points || {}) + .sort(([, a], [, b]) => (b as number) - (a as number)) + .slice(0, 15) + .map(([topic, count]) => ( + + {topic} ({(count as number).toLocaleString()}) + + ))} +
+
+
+ )} + + {/* Sub-niches */} + {data.aggregations && ( + + + Market Sub-Niches + Breakdown of primary category + + +
+ {Object.entries(typeof data.aggregations.sub_niches === 'string' ? JSON.parse(data.aggregations.sub_niches) : data.aggregations.sub_niches || {}) + .sort(([, a], [, b]) => (b as number) - (a as number)) + .slice(0, 6) + .map(([topic, count]) => ( +
+ {topic.replace(/_/g, ' ')} + {(count as number).toLocaleString()} +
+ ))} +
+
+
+ )} +
+ + {/* Competitors */} + {data.competitors.length > 0 && ( + + + SaaS Competitors & Ad Spend + Top incumbents and their Google Ads budget + +
+ + + + + + + + + + + {data.competitors.map((comp: any) => { + const paidKw = typeof comp.top_paid_keywords === 'string' ? JSON.parse(comp.top_paid_keywords) : comp.top_paid_keywords; + return ( + + + + + + + ); + })} + +
DomainMonthly Ad SpendOrganic TrafficTop Paid Keywords
{comp.domain} + ${Math.round(comp.ad_spend_usd).toLocaleString()} + + {Math.round(comp.organic_traffic).toLocaleString()} /mo + +
+ {(paidKw || []).slice(0, 3).map((kw: string) => ( + + {kw} + + ))} +
+
+
+
+ )} + + {/* Leads Table */} + {data.leads.length > 0 && ( + + + Verified Leads + First {data.leads.length} contacts matching your target market + +
+ + + + + + + + + + + {data.leads.map((lead: any) => { + const emails = typeof lead.emails === 'string' ? JSON.parse(lead.emails) : lead.emails; + return ( + + + + + + + ); + })} + +
Business NameLocationRatingContact
+ {lead.name} + {lead.website && ( + + {lead.website.replace(/^https?:\/\//, '')} + + )} + + {lead.city}, {lead.region} + + {lead.rating ? `${lead.rating} ⭐ (${lead.reviews_count})` : 'N/A'} + +
+ {lead.phone &&
{lead.phone}
} + {(emails || []).map((e: string) => ( + + {e} + + ))} +
+
+
+
+ )} + + {/* ───────────────────────────────────────────────────────────── */} + {/* GO-TO-MARKET (GTM) STRATEGY ENGINE */} + {/* ───────────────────────────────────────────────────────────── */} +
+
+
+

+ Go-To-Market Strategy +

+

+ Synthesize market data into an actionable marketing and positioning plan. +

+
+ +
+ +
+ + + Brand Positioning + Value prop, target persona, and wedge strategy. + + +
+

Generate a plan to reveal the positioning strategy.

+
+
+
+ + + + SEO & Content Engine + Keyword gaps and initial blog architecture. + + +
+

Generate a plan to reveal keyword targets.

+
+
+
+
+ +
+ + + + Social Media Automation + POWERED BY MISSINGLETTR + + A 3-month automated drip campaign based on your positioning. + + +
+

Generate a plan to automatically orchestrate your social media strategy via Missinglettr.

+
+
+
+
+
+
+ ); +} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/marketing/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/marketing/page.tsx new file mode 100644 index 0000000..ec0ee46 --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/marketing/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +export default async function MarketingPage({ params }: { params: Promise<{ workspace: string; projectId: string }> }) { + const { workspace, projectId } = await params; + redirect(`/${workspace}/project/${projectId}/marketing/seo`); +} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/marketing/seo/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/marketing/seo/page.tsx new file mode 100644 index 0000000..28dc940 --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/marketing/seo/page.tsx @@ -0,0 +1,366 @@ +"use client"; + +import { ListFilter } from "lucide-react"; + +export default function SeoPage() { + return ( +
+
+
+

+ SEO & GEO +

+

+ Improve how your app appears in search results and AI answers. +

+
+
+ + Enable SEO for this app + +
+
+
+
+
+ +
+ + + +
+ +
+
+ +
+

+ Run an SEO & GEO scan +

+

+ Scan your app for SEO basics and GEO details. Get a prioritized + checklist to fix issues in minutes. +

+ +
+ +
+
+
+
+ AI Assistant Discovery +
+
+ Help AI search engines understand and recommend your app +
+
+
+
+
+
+ +
+
+
+ Generate robots.txt +
+
+ Off: serve your deployed public/robots.txt if shipped, otherwise + return 404. +
+
+
+
+
+
+ +
+
+
+ Generate sitemap.xml +
+
+ Off: serve your deployed public/sitemap.xml if shipped, otherwise + return 404. +
+
+
+
+
+
+ +
+
+
+ Auto-generate per-page breadcrumbs +
+
+ Build a fresh BreadcrumbList for each route instead of using the + same persisted list site-wide. Turn off if you hand crafted your + breadcrumb schema and want it served verbatim. +
+
+
+
+
+
+
+
+ ); +} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/marketing/social/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/marketing/social/page.tsx new file mode 100644 index 0000000..bce3688 --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/marketing/social/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { Share2 } from "lucide-react"; + +export default function SocialPage() { + return ( +
+
+

+ Social Content +

+

+ Manage social sharing campaigns and meta tags. +

+
+ +
+
+ +
+

+ Social Campaign Manager +

+

+ Automatically generate and schedule social media content across + platforms based on your app's pages. +

+ +
+
+ ); +} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/overview/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/overview/page.tsx new file mode 100644 index 0000000..126d5cb --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/overview/page.tsx @@ -0,0 +1,683 @@ +"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 ( +
+ {showLoading && ( +
+ + + Loading… + +
+ )} + {error && !showLoading && ( +
+ + {error} +
+ )} + + {anatomy && ( + <> + {/* ── Live endpoints ── */} +
+ + {anatomy.hosting.live.length === 0 ? ( + } + title="Nothing deployed yet" + hint="Ask the AI to deploy your app and it will appear here." + promptSuggestion="Deploy my app to production" + /> + ) : ( +
+ {anatomy.hosting.live.map((item) => ( + + ))} +
+ )} +
+ + {/* ── Previews ── */} + {anatomy.hosting.previews.length > 0 && ( +
+ +
+ {anatomy.hosting.previews.map((p) => ( + + ))} +
+
+ )} + + )} +
+ ); +} + +// ────────────────────────────────────────────────── +// Live card +// ────────────────────────────────────────────────── + +function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) { + const [deploying, setDeploying] = useState(false); + const [logsOpen, setLogsOpen] = useState(false); + const [logs, setLogs] = useState(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 ( +
+ {/* ── Card header ── */} +
+
+ + {item.name} + + {item.source === "repo" ? "built" : "image"} + +
+
+ +
+
+ + {/* ── Status line ── */} +
+ + {statusLabel} + + {item.lastBuild && ( + + · Last build {item.lastBuild.status}{" "} + {formatRelative(item.lastBuild.finishedAt)} + + )} +
+ + {/* ── Live URL ── */} + {primaryUrl ? ( +
+ + + {primaryUrl} + + + +
+ ) : ( +
+ + + No domain attached — ask the AI to add one. + +
+ )} + + {/* ── Extra domains ── */} + {item.domains.length > 1 && ( +
+ {item.domains.slice(1).map((d) => ( + + {d}{" "} + + + ))} +
+ )} + + {/* ── Logs toggle ── */} +
+ + + {logsOpen && ( +
+ {logsLoading ? ( + + Loading… + + ) : ( +
{logs || "(no logs)"}
+ )} +
+ )} +
+
+ ); +} + +// ────────────────────────────────────────────────── +// Preview row +// ────────────────────────────────────────────────── + +function PreviewRow({ preview }: { preview: Preview }) { + const running = preview.state === "running"; + return ( +
+
+ + + {preview.name} + + + port {preview.port} + + {preview.url && running && ( + + )} +
+
+ ); +} + +// ────────────────────────────────────────────────── +// 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 ( +
+ {title} + {count} +
+ ); +} + +function EmptySection({ + icon, + title, + hint, + promptSuggestion, +}: { + icon: React.ReactNode; + title: string; + hint: string; + promptSuggestion?: string; +}) { + return ( +
+
{icon}
+
+ {title} +
+
+ {hint} +
+ {promptSuggestion && ( +
+ + Try asking: + + + "{promptSuggestion}" + +
+ )} +
+ ); +} + +// ────────────────────────────────────────────────── +// 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, + }; +} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/plan/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/plan/page.tsx index 43941b9..094b446 100644 --- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/plan/page.tsx +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/plan/page.tsx @@ -121,7 +121,7 @@ export default function PlanTab() {
} selectedId={selectedId} onClick={setSelectedId} @@ -282,7 +282,7 @@ function ObjectivePanel({
-

Project Objective

+

Product Brief

The high-level business case and elevator pitch.

diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/product/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/product/page.tsx new file mode 100644 index 0000000..b1f2cc1 --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/product/page.tsx @@ -0,0 +1,396 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { + Loader2, AlertCircle, ChevronDown, ChevronRight, + Box, Container, CircleDot, +} from "lucide-react"; +import { GiteaFileTree } from "@/components/project/gitea-file-tree"; +import { GiteaFileViewer } from "@/components/project/gitea-file-viewer"; +import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy"; + +/** + * Product tab — everything that makes up the thing being shipped. + * + * Left rail (top → bottom): + * 1. Codebases — Gitea repos, each tile expands inline into a file + * tree; clicking a file previews it on the right. + * 2. Images — Coolify services backed by an upstream Docker image + * (Twenty CRM, n8n…). Clicking shows image meta on the right. + * + * Dev containers do not appear here — they are the AI's workshop, not + * part of the product surface. + */ + +type Selection = + | { type: "file"; codebaseId: string; path: string } + | { type: "image"; uuid: string } + | null; + +export default function ProductTab() { + const params = useParams(); + const projectId = params.projectId as string; + const { anatomy, loading, error } = useAnatomy(projectId); + + const codebases = anatomy?.product.codebases ?? null; + const images = anatomy?.product.images ?? null; + const reason = anatomy?.codebasesReason; + + const [expanded, setExpanded] = useState>(new Set()); + const [selection, setSelection] = useState(null); + + useEffect(() => { + if (codebases && codebases[0]) { + setExpanded(prev => (prev.size === 0 ? new Set([codebases[0].id]) : prev)); + } + }, [codebases]); + + useEffect(() => { + setSelection(null); + setExpanded(new Set()); + }, [projectId]); + + const toggleCodebase = (id: string) => { + setExpanded(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const showLoading = loading && !anatomy; + + return ( +
+
+ {/* ── Left rail ── */} +
+ {showLoading && ( + Loading… + )} + {error && !showLoading && ( + {error} + )} + + {anatomy && ( + <> + {/* Codebases */} + + {codebases && codebases.length === 0 && ( + + {reason === "no_repo" + ? <>No codebase yet. Try: "Start building my app" + : <>Repo is empty — push a first commit. Try: "Scaffold a Next.js app"} + + )} + {codebases?.map(cb => { + const isOpen = expanded.has(cb.id); + return ( +
+ + {isOpen && ( +
+ + setSelection({ type: "file", codebaseId: cb.id, path: p }) + } + /> +
+ )} +
+ ); + })} +
+ + {/* Images */} + + {images && images.length === 0 && ( + + Self-hosted tools (Twenty CRM, n8n, Plausible…) you run appear here. + Try: "Install Twenty CRM for my project" + + )} + {images?.map(img => ( + + ))} + + + )} +
+ + {/* ── Right pane ── */} + +
+
+ ); +} + +// ────────────────────────────────────────────────── +// Image details (right pane) +// ────────────────────────────────────────────────── + +function ImageDetail({ uuid, anatomy }: { uuid: string; anatomy: Anatomy }) { + const img = anatomy.product.images.find(i => i.uuid === uuid); + if (!img) return This image is no longer in the project.; + const live = anatomy.hosting.live.find(l => l.uuid === uuid); + + return ( +
+ + + + + {live?.fqdn && ( + + )} +
+ ); +} + +// ────────────────────────────────────────────────── +// Bits +// ────────────────────────────────────────────────── + +function RailGroup({ + title, count, children, +}: { title: string; count: number; children: React.ReactNode }) { + return ( +
+
+ {title} + {count} +
+
{children}
+
+ ); +} + +function RailEmpty({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function DetailRow({ + label, value, dot, href, +}: { label: string; value: string; dot?: string; href?: string }) { + return ( +
+ {label} + + {dot && } + {href ? ( + {value} + ) : value} + +
+ ); +} + +function Inline({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function Empty({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +// ────────────────────────────────────────────────── + +function paneHeading(s: Selection): string { + if (!s) return "Preview"; + if (s.type === "file") return `Preview · ${shortPath(s.path)}`; + return "Image"; +} +function shortPath(p: string) { + const parts = p.split("/"); + if (parts.length <= 2) return p; + return ".../" + parts.slice(-2).join("/"); +} +function statusColor(status: string) { + const s = status.toLowerCase(); + if (s.includes("running") || s.includes("healthy")) return "#2e7d32"; + if (s.includes("starting") || s.includes("deploying")) return "#d4a04a"; + if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy")) return "#c5392b"; + return "#a09a90"; +} + +// ────────────────────────────────────────────────── +// 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 pageWrap: React.CSSProperties = { + padding: "28px 48px 48px", + fontFamily: INK.fontSans, + color: INK.ink, +}; +const grid: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "minmax(280px, 360px) minmax(0, 1fr)", + gap: 28, + maxWidth: 1400, + margin: "0 auto", + alignItems: "stretch", +}; +const leftCol: React.CSSProperties = { + minWidth: 0, display: "flex", flexDirection: "column", gap: 18, +}; +const rightCol: React.CSSProperties = { + minWidth: 0, display: "flex", flexDirection: "column", +}; +const heading: React.CSSProperties = { + fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em", + textTransform: "uppercase", color: INK.muted, margin: "0 0 14px", +}; +const railGroup: React.CSSProperties = { display: "flex", flexDirection: "column" }; +const railGroupHeader: React.CSSProperties = { + display: "flex", alignItems: "center", justifyContent: "space-between", + padding: "0 4px 8px", +}; +const railGroupTitle: React.CSSProperties = { + fontSize: "0.68rem", fontWeight: 600, 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 railItems: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 10 }; +const railEmpty: React.CSSProperties = { + padding: "10px 12px", fontSize: "0.74rem", color: INK.muted, + border: `1px dashed ${INK.borderSoft}`, borderRadius: 8, + lineHeight: 1.6, +}; +const nudge: React.CSSProperties = { + display: "block", marginTop: 6, fontStyle: "normal", + background: "#f3eee4", borderRadius: 4, padding: "3px 8px", + fontSize: "0.72rem", color: "#7a6a50", +}; +const flatTile: React.CSSProperties = { + display: "flex", alignItems: "center", gap: 10, + width: "100%", padding: "12px 14px", + background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10, + cursor: "pointer", font: "inherit", color: "inherit", + transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s", +}; +const codebaseTile: React.CSSProperties = { + background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10, overflow: "hidden", +}; +const tileHeader: React.CSSProperties = { + display: "flex", alignItems: "center", gap: 8, width: "100%", + padding: "12px 14px", background: "transparent", border: "none", + cursor: "pointer", font: "inherit", color: "inherit", +}; +const tileLabel: React.CSSProperties = { + fontSize: "0.85rem", fontWeight: 600, color: INK.ink, marginBottom: 2, +}; +const tileHint: React.CSSProperties = { fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4 }; +const tileBody: React.CSSProperties = { + padding: "8px 10px 12px", borderTop: `1px solid ${INK.borderSoft}`, +}; +const chevronCell: React.CSSProperties = { + width: 14, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0, +}; +const panel: React.CSSProperties = { + background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10, + padding: 16, flex: 1, minHeight: 0, display: "flex", flexDirection: "column", +}; +const detailRow: React.CSSProperties = { + display: "flex", alignItems: "center", justifyContent: "space-between", + padding: "12px 4px", borderBottom: `1px solid ${INK.borderSoft}`, +}; +const detailLabel: React.CSSProperties = { + fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.06em", + textTransform: "uppercase", color: INK.muted, +}; +const detailValue: React.CSSProperties = { + fontSize: "0.85rem", color: INK.ink, display: "inline-flex", alignItems: "center", +}; +const detailLink: React.CSSProperties = { + color: INK.ink, textDecoration: "underline", +}; diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/security/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/security/page.tsx new file mode 100644 index 0000000..a76f5e7 --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/security/page.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { Shield, Settings } from "lucide-react"; + +export default function SecurityPage() { + return ( +
+
+
+

+ Security +

+

+ Manage your permissions and security rules.{" "} + + Learn more + +

+
+ +
+ +
+
+ +
+

+ Check the security of your app +

+

+ Review your configuration, identify potential risks, and learn how to + strengthen your app's protection +

+ +
+
+ ); +} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/services/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/services/page.tsx new file mode 100644 index 0000000..ab25c71 --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/services/page.tsx @@ -0,0 +1,696 @@ +"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 ServicesPage() { + const params = useParams(); + const projectId = params.projectId as string; + const { anatomy, loading, error } = useAnatomy(projectId, { pollMs: 8000 }); + const showLoading = loading && !anatomy; + + return ( +
+ {showLoading && ( +
+ + + Loading… + +
+ )} + {error && !showLoading && ( +
+ + {error} +
+ )} + + {anatomy && ( + <> + {/* ── Live endpoints ── */} +
+ + {anatomy.hosting.live.length === 0 ? ( + } + title="Nothing deployed yet" + hint="Ask the AI to deploy your app and it will appear here." + promptSuggestion="Deploy my app to production" + /> + ) : ( +
+ {anatomy.hosting.live.map((item) => ( + + ))} +
+ )} +
+ + {/* ── Previews ── */} + {anatomy.hosting.previews.length > 0 && ( +
+ +
+ {anatomy.hosting.previews.map((p) => ( + + ))} +
+
+ )} + + )} +
+ ); +} + +// ────────────────────────────────────────────────── +// Live card +// ────────────────────────────────────────────────── + +function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) { + const [deploying, setDeploying] = useState(false); + const [logsOpen, setLogsOpen] = useState(false); + const [logs, setLogs] = useState(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 ( +
+ {/* ── Card header ── */} +
+
+ + {item.name} + + {item.source === "repo" ? "built" : "image"} + +
+
+ +
+
+ + {/* ── Status line ── */} +
+ + {statusLabel} + + {item.lastBuild && ( + + · Last build {item.lastBuild.status}{" "} + {formatRelative(item.lastBuild.finishedAt)} + + )} +
+ + {/* ── Live URL ── */} + {primaryUrl ? ( +
+ + + {primaryUrl} + + + +
+ ) : ( +
+ + + No domain attached — ask the AI to add one. + +
+ )} + + {/* ── Extra domains ── */} + {item.domains.length > 1 && ( +
+ {item.domains.slice(1).map((d) => ( + + {d}{" "} + + + ))} +
+ )} + + {/* ── Logs toggle ── */} +
+ + + {logsOpen && ( +
+ {logsLoading ? ( + + Loading… + + ) : ( +
{logs || "(no logs)"}
+ )} +
+ )} +
+
+ ); +} + +// ────────────────────────────────────────────────── +// Preview row +// ────────────────────────────────────────────────── + +function PreviewRow({ preview }: { preview: Preview }) { + const running = preview.state === "running"; + return ( +
+
+ + + {preview.name} + + + port {preview.port} + + + {preview.state} + + {preview.url && running && ( + + )} +
+
+ ); +} + +// ────────────────────────────────────────────────── +// 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 ( +
+ {title} + {count} +
+ ); +} + +function EmptySection({ + icon, + title, + hint, + promptSuggestion, +}: { + icon: React.ReactNode; + title: string; + hint: string; + promptSuggestion?: string; +}) { + return ( +
+
{icon}
+
+ {title} +
+
+ {hint} +
+ {promptSuggestion && ( +
+ + Try asking: + + + "{promptSuggestion}" + +
+ )} +
+ ); +} + +// ────────────────────────────────────────────────── +// 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, + }; +} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/settings/app/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/settings/app/page.tsx new file mode 100644 index 0000000..438e8be --- /dev/null +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/settings/app/page.tsx @@ -0,0 +1,101 @@ +"use client"; + +export default function AppSettingsPage() { + return ( +
+
+

+ App Settings +

+

+ General configuration for your application. +

+
+ +
+
+ + +
+
+ +