ship: project dashboard pages + sidebar/chat overhaul + log tooling

Ships accumulated WIP that was sitting uncommitted:
- New (home) dashboard route pages: overview, code, data/tables, hosting,
  infrastructure, services, domains, integrations, agents, analytics, api,
  automations, billing, logs, market, marketing(+seo/social), product, security,
  storage, users, settings(app/auth).
- dashboard-sidebar, project-icon-rail, chat-panel updates; mcp + anatomy route
  changes; package.json/lock dependency bumps.
- Coolify log tooling (scripts/fetch-app-logs.mjs + fetch-app-logs-ssh.mjs) and
  ai-new-thread.md "Fetching Production Logs" section.

Excludes throwaway debug scripts and telemetry audit dumps (the latter contain
live credentials and must not be committed).
This commit is contained in:
2026-06-12 18:09:09 -07:00
parent 0f212c750b
commit eb198e2d4d
37 changed files with 8982 additions and 533 deletions

View File

@@ -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 `<appUuid>` 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 <appUuid> [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 <appUuid> 2026-06-12
# last 500 lines, no date filter:
node --env-file=.env.local scripts/fetch-app-logs-ssh.mjs <appUuid> "" 500
# target a specific container during a rollout (substring match):
node --env-file=.env.local scripts/fetch-app-logs-ssh.mjs <appUuid>-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=<appUuid> --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.

View File

@@ -0,0 +1,383 @@
"use client";
import { Bot, Plus, FileText, Wrench, Plug } from "lucide-react";
export default function AgentsPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div style={{ marginBottom: 40 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Agents
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Manage agents and users
</p>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
marginTop: 64,
}}
>
<div
style={{
width: 48,
height: 48,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 24,
}}
>
<Bot size={24} color="#18181b" />
</div>
<h2
style={{ fontSize: "1.25rem", fontWeight: 600, margin: "0 0 8px 0" }}
>
Create your first agent
</h2>
<p
style={{
fontSize: "0.9rem",
color: "#71717a",
textAlign: "center",
maxWidth: 480,
margin: "0 0 40px 0",
lineHeight: 1.5,
}}
>
Agents talk to your users, work with your data, and run on schedules
all guided by clear instructions you define in your own words.
</p>
<div
style={{
width: "100%",
background: "#f4f4f5",
borderRadius: 16,
padding: "24px 32px",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
}}
>
<span
style={{ fontSize: "0.85rem", fontWeight: 500, color: "#71717a" }}
>
Suggested for your app
</span>
<button
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "#a1a1aa",
}}
>
<RefreshIcon />
</button>
</div>
<div
style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}
>
{/* Blurred out cards */}
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 20,
height: 110,
border: "1px solid #e4e4e7",
filter: "blur(2px)",
opacity: 0.5,
}}
>
<div
style={{
height: 12,
background: "#f4f4f5",
borderRadius: 6,
width: "60%",
marginBottom: 12,
}}
></div>
<div
style={{
height: 8,
background: "#f4f4f5",
borderRadius: 4,
width: "100%",
marginBottom: 8,
}}
></div>
<div
style={{
height: 8,
background: "#f4f4f5",
borderRadius: 4,
width: "80%",
}}
></div>
</div>
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 20,
height: 110,
border: "1px solid #e4e4e7",
filter: "blur(2px)",
opacity: 0.5,
}}
>
<div
style={{
height: 12,
background: "#f4f4f5",
borderRadius: 6,
width: "50%",
marginBottom: 12,
}}
></div>
<div
style={{
height: 8,
background: "#f4f4f5",
borderRadius: 4,
width: "90%",
marginBottom: 8,
}}
></div>
<div
style={{
height: 8,
background: "#f4f4f5",
borderRadius: 4,
width: "70%",
}}
></div>
</div>
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 20,
height: 110,
border: "1px solid #e4e4e7",
filter: "blur(2px)",
opacity: 0.5,
}}
>
<div
style={{
height: 12,
background: "#f4f4f5",
borderRadius: 6,
width: "70%",
marginBottom: 12,
}}
></div>
<div
style={{
height: 8,
background: "#f4f4f5",
borderRadius: 4,
width: "100%",
marginBottom: 8,
}}
></div>
<div
style={{
height: 8,
background: "#f4f4f5",
borderRadius: 4,
width: "60%",
}}
></div>
</div>
{/* Create from scratch */}
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 20,
height: 110,
border: "1px solid #e4e4e7",
cursor: "pointer",
transition: "border-color 0.2s",
}}
className="hover:border-blue-500"
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 8,
}}
>
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>
Create from scratch
</span>
<div
style={{ background: "#f4f4f5", borderRadius: 6, padding: 4 }}
>
<Plus size={14} color="#71717a" />
</div>
</div>
<p
style={{
fontSize: "0.8rem",
color: "#71717a",
margin: 0,
lineHeight: 1.4,
}}
>
Create your own agent, defining how it works, responds, and
integrates with your data.
</p>
</div>
</div>
</div>
<div style={{ width: "100%", marginTop: 48 }}>
<h3
style={{
fontSize: "0.9rem",
fontWeight: 500,
color: "#71717a",
marginBottom: 16,
}}
>
How you can use agents
</h3>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gap: 16,
}}
>
<div style={{ padding: "16px 0" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 8,
}}
>
<FileText size={16} color="#18181b" />
<span style={{ fontWeight: 600, fontSize: "0.9rem" }}>
Guidelines
</span>
</div>
<p
style={{
fontSize: "0.8rem",
color: "#71717a",
lineHeight: 1.5,
margin: 0,
}}
>
Define the agent's behavior, knowledge, and AI model.
</p>
</div>
<div style={{ padding: "16px 0" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 8,
}}
>
<Wrench size={16} color="#18181b" />
<span style={{ fontWeight: 600, fontSize: "0.9rem" }}>
Tools
</span>
</div>
<p
style={{
fontSize: "0.8rem",
color: "#71717a",
lineHeight: 1.5,
margin: 0,
}}
>
Configure what tools and data the agent can access.
</p>
</div>
<div style={{ padding: "16px 0" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 8,
}}
>
<Plug size={16} color="#18181b" />
<span style={{ fontWeight: 600, fontSize: "0.9rem" }}>
Connectors
</span>
</div>
<p
style={{
fontSize: "0.8rem",
color: "#71717a",
lineHeight: 1.5,
margin: 0,
}}
>
Connect the agent to Gmail, Calendar & more.
</p>
</div>
</div>
</div>
</div>
</div>
);
}
function RefreshIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
<path d="M3 3v5h5"></path>
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path>
<path d="M16 21v-5h5"></path>
</svg>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import { BarChart2 } from "lucide-react";
export default function AnalyticsPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div style={{ marginBottom: 24 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Analytics
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Track traffic, usage, and events.
</p>
</div>
<div
style={{
background: "#fff",
border: "1px dashed #e4e4e7",
borderRadius: 12,
padding: "80px 32px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
marginTop: 32,
}}
>
<div
style={{
width: 48,
height: 48,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 24,
}}
>
<BarChart2 size={24} color="#18181b" />
</div>
<h2
style={{ fontSize: "1.25rem", fontWeight: 600, margin: "0 0 12px 0" }}
>
No data available yet
</h2>
<p
style={{
fontSize: "0.95rem",
color: "#71717a",
textAlign: "center",
maxWidth: 460,
margin: "0 0 24px 0",
lineHeight: 1.5,
}}
>
Once your app is live and receiving traffic, your analytics metrics
will appear here.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
"use client";
import { Copy, Key } from "lucide-react";
export default function ApiPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div style={{ marginBottom: 24 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
API & Webhooks
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Connect external services to your application.
</p>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "24px",
marginBottom: 32,
}}
>
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: "0 0 16px 0" }}>
REST API Endpoint
</h2>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div
style={{
flex: 1,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 8,
padding: "10px 16px",
fontSize: "0.9rem",
color: "#71717a",
display: "flex",
alignItems: "center",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
}}
>
<span style={{ color: "#18181b" }}>
https://api.steadfast-camp-core-flow.vibn.app/v1
</span>
</div>
<button
style={{
width: 40,
height: 40,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 8,
cursor: "pointer",
}}
>
<Copy size={16} color="#71717a" />
</button>
</div>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "24px",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
}}
>
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
API Keys
</h2>
<button
style={{
background: "#18181b",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "8px 16px",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
}}
>
Generate Key
</button>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 16,
padding: "16px 0",
borderBottom: "1px solid #e4e4e7",
}}
>
<div
style={{
width: 40,
height: 40,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Key size={16} color="#18181b" />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
Production Key
</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Created 2 days ago
</div>
</div>
<div
style={{
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: "0.85rem",
color: "#71717a",
background: "#fafafa",
padding: "4px 8px",
borderRadius: 6,
border: "1px solid #e4e4e7",
}}
>
pk_live_*******************
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
"use client";
export default function AutomationsPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div style={{ marginBottom: 24 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Automations
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Build and manage automations in your app.
</p>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
overflow: "hidden",
}}
>
<div
style={{
padding: "16px 24px",
borderBottom: "1px solid #e4e4e7",
display: "flex",
gap: 16,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: "0.95rem", fontWeight: 600 }}>
Automations
</span>
<span
style={{
fontSize: "0.75rem",
fontWeight: 600,
color: "#f97316",
border: "1px solid #ffedd5",
background: "#fff7ed",
padding: "2px 6px",
borderRadius: 4,
}}
>
Builder+
</span>
</div>
</div>
<div
style={{
padding: "80px 32px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<h2
style={{
fontSize: "1.1rem",
fontWeight: 600,
margin: "0 0 12px 0",
}}
>
Unlock automations
</h2>
<p
style={{
fontSize: "0.95rem",
color: "#71717a",
textAlign: "center",
maxWidth: 460,
margin: "0 0 24px 0",
lineHeight: 1.5,
}}
>
To run automations in your app, you need backend functions enabled.
Upgrade to enable backend functions and start using automations.
</p>
<button
style={{
background: "#f97316",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "10px 24px",
fontSize: "0.9rem",
fontWeight: 600,
cursor: "pointer",
}}
>
View plans
</button>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div style={{ padding: "40px 48px", maxWidth: 1000, margin: "0 auto", fontFamily: "var(--font-inter), sans-serif" }}>
<div style={{ marginBottom: 32 }}>
<h1 style={{ fontFamily: "var(--font-lora), serif", fontSize: "1.8rem", color: "#1a1a1a", marginBottom: 8 }}>
Payments & Billing
</h1>
<p style={{ color: "#6b6560", fontSize: "0.95rem" }}>
Connect your bank account to start charging customers for this project.
</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr", gap: "24px" }}>
{/* Onboarding Card */}
<Card style={{ border: "1px solid #6366f1", boxShadow: "0 4px 14px rgba(99, 102, 241, 0.08)" }}>
<CardHeader>
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 8 }}>
<div style={{ background: "#e0e7ff", padding: 8, borderRadius: 8, color: "#4f46e5" }}>
<CreditCard style={{ width: 24, height: 24 }} />
</div>
<div>
<CardTitle style={{ fontSize: "1.2rem" }}>Accept Payments with Stripe</CardTitle>
<CardDescription>Setup takes 3 minutes. Vibn handles the code.</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div style={{ background: "#f8fafc", padding: 20, borderRadius: 8, marginBottom: 24 }}>
<h4 style={{ fontWeight: 600, fontSize: "0.9rem", color: "#111827", marginBottom: 12 }}>What you get immediately:</h4>
<ul style={{ display: "flex", flexDirection: "column", gap: 12, margin: 0, padding: 0, listStyle: "none" }}>
<li style={{ display: "flex", alignItems: "flex-start", gap: 8, fontSize: "0.85rem", color: "#4b5563" }}>
<Zap style={{ width: 16, height: 16, color: "#eab308", flexShrink: 0 }} />
<span><strong>AI Auto-Wiring:</strong> The Vibn AI will automatically inject your secure Stripe keys into your live Coolify application.</span>
</li>
<li style={{ display: "flex", alignItems: "flex-start", gap: 8, fontSize: "0.85rem", color: "#4b5563" }}>
<ShieldCheck style={{ width: 16, height: 16, color: "#22c55e", flexShrink: 0 }} />
<span><strong>Instant Compliance:</strong> Securely accept Apple Pay, Google Pay, and credit cards with PCI compliance handled automatically.</span>
</li>
</ul>
</div>
<p style={{ fontSize: "0.85rem", color: "#6b7280", lineHeight: 1.5 }}>
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.
</p>
</CardContent>
<CardFooter style={{ background: "#f9fafb", borderTop: "1px solid #f3f4f6", padding: "16px 24px" }}>
<button
className="bg-indigo-600 hover:bg-indigo-700 text-white transition-colors"
style={{ padding: "10px 20px", borderRadius: 6, fontSize: "0.9rem", fontWeight: 500, display: "flex", alignItems: "center", gap: 8 }}
>
Connect with Stripe <ArrowRight style={{ width: 16, height: 16 }} />
</button>
</CardFooter>
</Card>
</div>
</div>
);
}

View File

@@ -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<Selection>(null);
useEffect(() => {
setSelection(null);
}, [projectId]);
const showLoading = loading && !anatomy;
return (
<div style={pageWrap}>
<div style={grid}>
{/* ── Left rail ── */}
<section style={leftCol}>
{showLoading && (
<Inline>
<Loader2 size={13} className="animate-spin" /> Loading
</Inline>
)}
{error && !showLoading && (
<Inline>
<AlertCircle size={13} /> {error}
</Inline>
)}
{anatomy && (
<>
{/* Code Files */}
<RailGroup title="Code files" count={codebases?.length ?? 0}>
{codebases && codebases.length === 0 && (
<RailEmpty>
{reason === "no_repo" ? (
<>
No codebase yet.{" "}
<span style={nudge}>
Try: &quot;Start building my app&quot;
</span>
</>
) : (
<>
Repo is empty push a first commit.{" "}
<span style={nudge}>
Try: &quot;Scaffold a Next.js app&quot;
</span>
</>
)}
</RailEmpty>
)}
{codebases?.map((cb) => {
return (
<article key={cb.id} style={codebaseTile}>
<div style={tileHeader}>
<span style={chevronCell}>
<ChevronDown size={13} style={{ color: INK.mid }} />
</span>
<Box
size={13}
style={{ color: INK.mid, flexShrink: 0 }}
/>
<div style={{ minWidth: 0, textAlign: "left" }}>
<div style={tileLabel}>{cb.label}</div>
{cb.hint && <div style={tileHint}>{cb.hint}</div>}
</div>
</div>
<div style={tileBody}>
<GiteaFileTree
projectId={projectId}
rootPath={cb.path}
selectedPath={
selection?.type === "file" &&
selection.codebaseId === cb.id
? selection.path
: undefined
}
onSelectFile={(p) =>
setSelection({
type: "file",
codebaseId: cb.id,
path: p,
})
}
/>
</div>
</article>
);
})}
</RailGroup>
</>
)}
</section>
{/* ── Right pane ── */}
<aside style={rightCol}>
<h3 style={heading}>{paneHeading(selection)}</h3>
<div style={panel}>
{selection?.type === "file" && (
<GiteaFileViewer projectId={projectId} path={selection.path} />
)}
{!selection && <Empty>Pick a codebase file on the left.</Empty>}
</div>
</aside>
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// Bits
// ──────────────────────────────────────────────────
function RailGroup({
title,
count,
children,
}: {
title: string;
count: number;
children: React.ReactNode;
}) {
return (
<div style={railGroup}>
<header style={railGroupHeader}>
<span style={railGroupTitle}>{title}</span>
<span style={countPill}>{count}</span>
</header>
<div style={railItems}>{children}</div>
</div>
);
}
function RailEmpty({ children }: { children: React.ReactNode }) {
return <div style={railEmpty}>{children}</div>;
}
function DetailRow({
label,
value,
dot,
href,
}: {
label: string;
value: string;
dot?: string;
href?: string;
}) {
return (
<div style={detailRow}>
<span style={detailLabel}>{label}</span>
<span style={detailValue}>
{dot && <CircleDot size={9} style={{ color: dot, marginRight: 6 }} />}
{href ? (
<a href={href} target="_blank" rel="noreferrer" style={detailLink}>
{value}
</a>
) : (
value
)}
</span>
</div>
);
}
function Inline({ children }: { children: React.ReactNode }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "12px 14px",
fontSize: "0.82rem",
color: INK.mid,
background: INK.cardBg,
border: `1px solid ${INK.borderSoft}`,
borderRadius: 8,
}}
>
{children}
</div>
);
}
function Empty({ children }: { children: React.ReactNode }) {
return (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: INK.mid,
fontSize: "0.85rem",
padding: "32px 16px",
textAlign: "center",
}}
>
{children}
</div>
);
}
// ──────────────────────────────────────────────────
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",
};

View File

@@ -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`);
}

View File

@@ -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<Selection>(null);
useEffect(() => {
setSelection(null);
}, [projectId, targetDbId]);
const showLoading = loading && !anatomy;
return (
<div style={pageWrap}>
<div style={grid}>
{/* ── Left rail ── */}
<section style={leftCol}>
{showLoading && (
<Inline>
<Loader2 size={13} className="animate-spin" /> Loading
</Inline>
)}
{error && !showLoading && (
<Inline>
<AlertCircle size={13} /> {error}
</Inline>
)}
{anatomy && (
<RailGroup title="Databases" count={activeDatabases.length}>
{activeDatabases.length === 0 && (
<RailEmpty>
No databases yet.
<span style={nudge}>
Try: &quot;Add a Postgres database to my project&quot;
</span>
</RailEmpty>
)}
{activeDatabases.map((db) => {
return (
<article key={db.uuid} style={codebaseTile}>
<div style={tileHeader}>
<span style={chevronCell}>
<ChevronDown size={13} style={{ color: INK.mid }} />
</span>
<Database
size={13}
style={{ color: INK.mid, flexShrink: 0 }}
/>
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
<div style={tileLabel}>{db.name}</div>
<div style={tileHint}>{db.type}</div>
</div>
<CircleDot
size={9}
style={{ color: statusColor(db.status), flexShrink: 0 }}
/>
</div>
<div style={tileBody}>
<DatabaseTableTree
projectId={projectId}
dbUuid={db.uuid}
selectedTable={
selection?.kind === "table" &&
selection.dbUuid === db.uuid
? {
schema: selection.schema,
name: selection.name,
}
: undefined
}
onSelectTable={({ schema, name }) =>
setSelection({
kind: "table",
dbUuid: db.uuid,
schema,
name,
})
}
/>
</div>
</article>
);
})}
</RailGroup>
)}
</section>
{/* ── Right pane ── */}
<aside style={rightCol}>
<h3 style={heading}>{paneHeading(selection)}</h3>
<div style={panel}>
{selection?.kind === "table" && (
<TableViewer
projectId={projectId}
dbUuid={selection.dbUuid}
schema={selection.schema}
table={selection.name}
/>
)}
{!selection && (
<Empty>Select a table on the left to preview data.</Empty>
)}
</div>
</aside>
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// Bits
// ──────────────────────────────────────────────────
function RailGroup({
title,
count,
children,
}: {
title: string;
count: number;
children: React.ReactNode;
}) {
return (
<div style={railGroup}>
<header style={railGroupHeader}>
<span style={railGroupTitle}>{title}</span>
<span style={countPill}>{count}</span>
</header>
<div style={railItems}>{children}</div>
</div>
);
}
function RailEmpty({ children }: { children: React.ReactNode }) {
return <div style={railEmpty}>{children}</div>;
}
function Inline({ children }: { children: React.ReactNode }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "12px 14px",
fontSize: "0.82rem",
color: INK.mid,
background: INK.cardBg,
border: `1px solid ${INK.borderSoft}`,
borderRadius: 8,
}}
>
{children}
</div>
);
}
function Empty({ children }: { children: React.ReactNode }) {
return (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: INK.mid,
fontSize: "0.85rem",
padding: "32px 16px",
textAlign: "center",
}}
>
{children}
</div>
);
}
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",
};

View File

@@ -0,0 +1,227 @@
"use client";
import { Copy } from "lucide-react";
export default function DomainsPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div style={{ marginBottom: 32 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Domains
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Buy, connect and manage your domains.{" "}
<a href="#" style={{ color: "#18181b", textDecoration: "underline" }}>
Learn more
</a>
</p>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "24px",
marginBottom: 32,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
Built-in URL
</h2>
</div>
<button
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 6,
padding: "6px 12px",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
}}
>
Edit URL
</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div
style={{
flex: 1,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 8,
padding: "10px 16px",
fontSize: "0.9rem",
color: "#71717a",
display: "flex",
alignItems: "center",
}}
>
<span style={{ color: "#18181b", fontWeight: 500 }}>
steadfast-camp-core-flow
</span>
.vibn.app
</div>
<button
style={{
width: 40,
height: 40,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 8,
cursor: "pointer",
}}
>
<Copy size={16} color="#71717a" />
</button>
</div>
</div>
<h2 style={{ fontSize: "1.1rem", fontWeight: 600, margin: "0 0 16px 0" }}>
Custom domains
</h2>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
borderStyle: "dashed",
padding: "48px 32px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
marginBottom: 32,
}}
>
<h3
style={{ fontSize: "1.1rem", fontWeight: 600, margin: "0 0 8px 0" }}
>
Want to use your domain?
</h3>
<p
style={{
fontSize: "0.95rem",
color: "#71717a",
textAlign: "center",
maxWidth: 400,
margin: "0 0 24px 0",
lineHeight: 1.5,
}}
>
Custom domains are available on our Builder plan and above. Upgrade to
continue working to this app.
</p>
<button
style={{
background: "#18181b",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "10px 24px",
fontSize: "0.9rem",
fontWeight: 600,
cursor: "pointer",
}}
>
View Plans
</button>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "24px",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 16,
}}
>
<div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 16,
}}
>
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
Email domain
</h2>
<span
style={{
fontSize: "0.75rem",
fontWeight: 600,
color: "#f97316",
border: "1px solid #ffedd5",
background: "#fff7ed",
padding: "2px 6px",
borderRadius: 4,
}}
>
Builder+
</span>
</div>
<div
style={{
fontSize: "0.95rem",
fontWeight: 500,
color: "#18181b",
marginBottom: 4,
}}
>
no-reply@notifications.vibn.app
</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Sender Name: App
</div>
</div>
<button
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 6,
padding: "8px 16px",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
color: "#71717a",
}}
>
Use your custom domain
</button>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div style={pageWrap}>
{showLoading && (
<div style={centeredMsg}>
<Loader2
size={16}
className="animate-spin"
style={{ color: INK.muted }}
/>
<span style={{ color: INK.muted, fontSize: "0.85rem" }}>
Loading
</span>
</div>
)}
{error && !showLoading && (
<div style={centeredMsg}>
<AlertCircle size={15} style={{ color: DANGER }} />
<span style={{ fontSize: "0.85rem", color: DANGER }}>{error}</span>
</div>
)}
{anatomy && (
<>
{/* ── Live endpoints ── */}
<section>
<SectionHeader title="Live" count={anatomy.hosting.live.length} />
{anatomy.hosting.live.length === 0 ? (
<EmptySection
icon={<Server size={20} style={{ color: INK.muted }} />}
title="Nothing deployed yet"
hint="Ask the AI to deploy your app and it will appear here."
promptSuggestion="Deploy my app to production"
/>
) : (
<div
style={{ display: "flex", flexDirection: "column", gap: 16 }}
>
{anatomy.hosting.live.map((item) => (
<LiveCard key={item.uuid} item={item} projectId={projectId} />
))}
</div>
)}
</section>
{/* ── Previews ── */}
{anatomy.hosting.previews.length > 0 && (
<section style={{ marginTop: 40 }}>
<SectionHeader
title="Dev Previews"
count={anatomy.hosting.previews.length}
/>
<div
style={{ display: "flex", flexDirection: "column", gap: 10 }}
>
{anatomy.hosting.previews.map((p) => (
<PreviewRow key={p.id} preview={p} />
))}
</div>
</section>
)}
</>
)}
</div>
);
}
// ──────────────────────────────────────────────────
// Live card
// ──────────────────────────────────────────────────
function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
const [deploying, setDeploying] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const [logs, setLogs] = useState<string | null>(null);
const [logsLoading, setLogsLoading] = useState(false);
const [copied, setCopied] = useState(false);
const primaryUrl = item.fqdn ? `https://${item.fqdn}` : null;
const phase = classifyPhase(item.status);
const { color: statusColor, label: statusLabel } = phaseDisplay(phase, item);
const redeploy = async () => {
if (deploying) return;
setDeploying(true);
try {
await fetch(`/api/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "apps.deploy",
params: { uuid: item.uuid, projectId },
}),
});
} finally {
setTimeout(() => setDeploying(false), 3000);
}
};
const openLogs = async () => {
if (!logsOpen) {
setLogsOpen(true);
setLogsLoading(true);
try {
const r = await fetch(`/api/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "apps.logs",
params: { uuid: item.uuid, lines: 60 },
}),
});
const d = await r.json();
setLogs(
typeof d.result === "string"
? d.result
: JSON.stringify(d.result ?? d.error, null, 2),
);
} catch {
setLogs("Failed to load logs.");
} finally {
setLogsLoading(false);
}
} else {
setLogsOpen(false);
}
};
const copyUrl = () => {
if (!primaryUrl) return;
navigator.clipboard.writeText(primaryUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div style={card}>
{/* ── Card header ── */}
<div style={cardHeader}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
minWidth: 0,
flex: 1,
}}
>
<CircleDot size={11} style={{ color: statusColor, flexShrink: 0 }} />
<span style={cardTitle}>{item.name}</span>
<span style={sourcePill(item.source)}>
{item.source === "repo" ? "built" : "image"}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button
onClick={redeploy}
disabled={deploying}
style={actionBtn}
title="Redeploy now"
>
{deploying ? (
<Loader2 size={13} className="animate-spin" />
) : (
<RefreshCw size={13} />
)}
{deploying ? "Deploying…" : "Redeploy"}
</button>
</div>
</div>
{/* ── Status line ── */}
<div style={statusLine}>
<span style={{ color: statusColor, fontWeight: 600 }}>
{statusLabel}
</span>
{item.lastBuild && (
<span style={{ color: INK.muted }}>
· Last build {item.lastBuild.status}{" "}
{formatRelative(item.lastBuild.finishedAt)}
</span>
)}
</div>
{/* ── Live URL ── */}
{primaryUrl ? (
<div style={urlRow}>
<Globe size={13} style={{ color: "#2e7d32", flexShrink: 0 }} />
<a href={primaryUrl} target="_blank" rel="noreferrer" style={urlLink}>
{primaryUrl}
</a>
<ExternalLink size={11} style={{ color: INK.muted, flexShrink: 0 }} />
<button onClick={copyUrl} style={iconBtn} title="Copy URL">
{copied ? (
<Check size={12} style={{ color: "#2e7d32" }} />
) : (
<Copy size={12} />
)}
</button>
</div>
) : (
<div style={urlRow}>
<Globe size={13} style={{ color: INK.muted, flexShrink: 0 }} />
<span
style={{
color: INK.muted,
fontSize: "0.82rem",
fontStyle: "italic",
}}
>
No domain attached ask the AI to add one.
</span>
</div>
)}
{/* ── Extra domains ── */}
{item.domains.length > 1 && (
<div
style={{
paddingLeft: 23,
display: "flex",
flexDirection: "column",
gap: 4,
marginTop: 4,
}}
>
{item.domains.slice(1).map((d) => (
<a
key={d}
href={`https://${d}`}
target="_blank"
rel="noreferrer"
style={{ ...urlLink, fontSize: "0.78rem", color: INK.mid }}
>
{d}{" "}
<ExternalLink
size={10}
style={{ display: "inline", verticalAlign: "middle" }}
/>
</a>
))}
</div>
)}
{/* ── Logs toggle ── */}
<div
style={{
marginTop: 14,
borderTop: `1px solid ${INK.borderSoft}`,
paddingTop: 10,
}}
>
<button onClick={openLogs} style={logsToggleBtn}>
<Terminal size={12} />
{logsOpen ? "Hide logs" : "Show recent logs"}
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
{logsOpen && (
<div style={logsBox}>
{logsLoading ? (
<span style={{ color: INK.muted, fontSize: "0.8rem" }}>
Loading
</span>
) : (
<pre style={logsPre}>{logs || "(no logs)"}</pre>
)}
</div>
)}
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// Preview row
// ──────────────────────────────────────────────────
function PreviewRow({ preview }: { preview: Preview }) {
const running = preview.state === "running";
return (
<div style={{ ...card, padding: "12px 16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<CircleDot
size={10}
style={{ color: running ? "#10b981" : INK.muted, flexShrink: 0 }}
/>
<span style={{ fontSize: "0.85rem", fontWeight: 600, color: INK.ink }}>
{preview.name}
</span>
<span style={{ fontSize: "0.75rem", color: INK.mid }}>
port {preview.port}
</span>
{preview.url && running && (
<div
style={{
marginLeft: "auto",
display: "flex",
gap: 8,
alignItems: "center",
}}
>
<a
href={preview.url}
target="_blank"
rel="noreferrer"
style={{ ...urlLink, marginLeft: 0 }}
>
{preview.url.replace(/^https?:\/\//, "")}{" "}
<ExternalLink
size={10}
style={{ display: "inline", verticalAlign: "middle" }}
/>
</a>
</div>
)}
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────
type Phase = "up" | "deploying" | "down" | "unknown";
function classifyPhase(status: string | undefined): Phase {
const s = (status ?? "").toLowerCase();
if (!s || s === "unknown") return "unknown";
if (/^(running|healthy)/.test(s)) return "up";
if (
/^(starting|restarting|created|deploying|building|in_progress|queued|paused)/.test(
s,
)
)
return "deploying";
if (/^(exited|dead|failed|stopped|unhealthy|error)/.test(s)) return "down";
return "unknown";
}
function phaseDisplay(
phase: Phase,
item: LiveItem,
): { color: string; label: string } {
if (item.inFlightBuild)
return {
color: AMBER,
label: `Deploying (${item.inFlightBuild.status ?? "in progress"})`,
};
switch (phase) {
case "up":
return { color: GREEN, label: "Live" };
case "deploying":
return { color: AMBER, label: "Starting…" };
case "down":
return { color: DANGER, label: "Down" };
default:
return { color: INK.muted, label: "Unknown" };
}
}
function formatRelative(iso: string | undefined) {
if (!iso) return "";
const ms = Date.now() - new Date(iso).getTime();
if (Number.isNaN(ms)) return "";
const min = Math.floor(ms / 60_000);
if (min < 1) return "just now";
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
// ──────────────────────────────────────────────────
// Sub-components
// ──────────────────────────────────────────────────
function SectionHeader({ title, count }: { title: string; count: number }) {
return (
<div style={sectionHeader}>
<span style={sectionTitle}>{title}</span>
<span style={countPill}>{count}</span>
</div>
);
}
function EmptySection({
icon,
title,
hint,
promptSuggestion,
}: {
icon: React.ReactNode;
title: string;
hint: string;
promptSuggestion?: string;
}) {
return (
<div style={emptyBox}>
<div style={{ marginBottom: 10 }}>{icon}</div>
<div
style={{
fontWeight: 600,
fontSize: "0.9rem",
color: INK.ink,
marginBottom: 6,
}}
>
{title}
</div>
<div
style={{
fontSize: "0.82rem",
color: INK.mid,
marginBottom: promptSuggestion ? 14 : 0,
}}
>
{hint}
</div>
{promptSuggestion && (
<div style={promptChip}>
<span
style={{ fontSize: "0.7rem", color: INK.muted, marginRight: 6 }}
>
Try asking:
</span>
<span
style={{ fontStyle: "italic", fontSize: "0.8rem", color: INK.mid }}
>
&quot;{promptSuggestion}&quot;
</span>
</div>
)}
</div>
);
}
// ──────────────────────────────────────────────────
// Tokens
// ──────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
border: "#e8e4dc",
borderSoft: "#efebe1",
cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
} as const;
const GREEN = "#10b981";
const AMBER = "#f59e0b";
const DANGER = "#ef4444";
// ──────────────────────────────────────────────────
// Styles
// ──────────────────────────────────────────────────
const pageWrap: React.CSSProperties = {
padding: "28px 48px 64px",
fontFamily: INK.fontSans,
color: INK.ink,
maxWidth: 860,
};
const centeredMsg: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "24px 0",
};
const sectionHeader: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 14,
};
const sectionTitle: React.CSSProperties = {
fontSize: "0.68rem",
fontWeight: 700,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: INK.muted,
};
const countPill: React.CSSProperties = {
fontSize: "0.7rem",
fontWeight: 600,
color: INK.mid,
padding: "1px 7px",
borderRadius: 999,
background: "#f3eee4",
};
const card: React.CSSProperties = {
background: INK.cardBg,
border: `1px solid ${INK.border}`,
borderRadius: 10,
padding: "18px 20px",
};
const cardHeader: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
marginBottom: 6,
};
const cardTitle: React.CSSProperties = {
fontSize: "0.95rem",
fontWeight: 700,
color: INK.ink,
};
const statusLine: React.CSSProperties = {
fontSize: "0.8rem",
color: INK.mid,
marginBottom: 12,
display: "flex",
alignItems: "center",
gap: 6,
flexWrap: "wrap",
};
const urlRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
background: "#f8f5f0",
borderRadius: 6,
padding: "8px 12px",
marginBottom: 2,
};
const urlLink: React.CSSProperties = {
fontSize: "0.85rem",
color: INK.ink,
textDecoration: "none",
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "inline-flex",
alignItems: "center",
gap: 4,
};
const actionBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
border: `1px solid ${INK.border}`,
borderRadius: 6,
background: "#fff",
cursor: "pointer",
font: "inherit",
fontSize: "0.78rem",
fontWeight: 600,
color: INK.mid,
transition: "background 0.1s, border-color 0.1s",
};
const iconBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 26,
height: 26,
border: "none",
background: "transparent",
cursor: "pointer",
color: INK.muted,
borderRadius: 4,
flexShrink: 0,
};
const logsToggleBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: "0.75rem",
fontWeight: 600,
color: INK.mid,
background: "none",
border: "none",
cursor: "pointer",
font: "inherit",
padding: 0,
};
const logsBox: React.CSSProperties = {
marginTop: 10,
background: "#1a1a1a",
borderRadius: 6,
padding: "12px 14px",
maxHeight: 320,
overflowY: "auto",
};
const logsPre: React.CSSProperties = {
margin: 0,
fontFamily: "ui-monospace, monospace",
fontSize: "0.72rem",
color: "#d4d0c8",
lineHeight: 1.6,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
};
const emptyBox: React.CSSProperties = {
border: `1px dashed ${INK.border}`,
borderRadius: 10,
padding: "36px 28px",
textAlign: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
};
const promptChip: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
background: "#f3eee4",
borderRadius: 6,
padding: "6px 12px",
fontSize: "0.8rem",
};
function sourcePill(source: "repo" | "image"): React.CSSProperties {
const isRepo = source === "repo";
return {
fontSize: "0.62rem",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: isRepo ? "#2e6d2e" : "#3b5a78",
background: isRepo ? "#eaf3e8" : "#e9eff5",
padding: "1px 6px",
borderRadius: 4,
flexShrink: 0,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,223 @@
"use client";
import { Diamond } from "lucide-react";
export default function IntegrationsPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
position: "relative",
height: "calc(100vh - 100px)",
overflow: "hidden",
}}
>
<div style={{ marginBottom: 24 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Integrations
</h1>
</div>
<div style={{ filter: "blur(4px)", opacity: 0.5, pointerEvents: "none" }}>
<div style={{ display: "flex", gap: 8, marginBottom: 24 }}>
<button
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 8,
padding: "8px 24px",
fontSize: "0.85rem",
fontWeight: 500,
}}
>
My Integrations
</button>
<button
style={{
background: "#f4f4f5",
border: "none",
color: "#71717a",
borderRadius: 8,
padding: "8px 24px",
fontSize: "0.85rem",
fontWeight: 500,
}}
>
Browse
</button>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "24px",
marginBottom: 32,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<div
style={{
width: 40,
height: 40,
background: "#f4f4f5",
borderRadius: 8,
}}
></div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>Stripe</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Sell products or subscriptions and get paid online.
</div>
</div>
<button
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 6,
padding: "6px 16px",
fontSize: "0.85rem",
}}
>
Manage
</button>
</div>
</div>
<h2
style={{ fontSize: "1.1rem", fontWeight: 600, margin: "0 0 16px 0" }}
>
Connectors
</h2>
<p style={{ fontSize: "0.9rem", color: "#71717a", marginBottom: 24 }}>
Connect your app to popular services.
</p>
<div
style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}
>
{[1, 2, 3, 4].map((i) => (
<div
key={i}
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "24px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 16,
marginBottom: 16,
}}
>
<div
style={{
width: 32,
height: 32,
background: "#f4f4f5",
borderRadius: 8,
}}
></div>
</div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
Connector {i}
</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Connect with external service for app data.
</div>
</div>
))}
</div>
</div>
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(255, 255, 255, 0.3)",
}}
>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 16,
padding: "32px 48px",
display: "flex",
flexDirection: "column",
alignItems: "center",
boxShadow: "0 10px 25px rgba(0,0,0,0.05)",
maxWidth: 440,
}}
>
<div
style={{
width: 40,
height: 40,
background: "#fff7ed",
border: "1px solid #ffedd5",
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 20,
}}
>
<Diamond size={18} color="#f97316" />
</div>
<h2
style={{
fontSize: "1.1rem",
fontWeight: 600,
margin: "0 0 12px 0",
}}
>
Unlock this feature
</h2>
<p
style={{
fontSize: "0.95rem",
color: "#71717a",
textAlign: "center",
margin: "0 0 24px 0",
lineHeight: 1.5,
}}
>
This feature is only available on the Builder plan or higher.
Upgrade to continue working without limits.
</p>
<button
style={{
background: "#f97316",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "10px 24px",
fontSize: "0.9rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Upgrade
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { Search } from "lucide-react";
export default function LogsPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div style={{ marginBottom: 24 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Logs
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
View application and server logs.
</p>
</div>
<div
style={{
background: "#18181b",
borderRadius: 12,
overflow: "hidden",
color: "#e4e4e7",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
}}
>
<div
style={{
padding: "12px 16px",
borderBottom: "1px solid #3f3f46",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "#27272a",
borderRadius: 6,
padding: "4px 10px",
width: 300,
}}
>
<Search size={14} color="#a1a1aa" />
<input
type="text"
placeholder="Filter logs..."
style={{
border: "none",
outline: "none",
background: "transparent",
fontSize: "0.8rem",
width: "100%",
color: "#fff",
}}
/>
</div>
</div>
<div
style={{
padding: "16px",
fontSize: "0.85rem",
lineHeight: 1.6,
height: 400,
overflowY: "auto",
}}
>
<div style={{ display: "flex", gap: 16 }}>
<span style={{ color: "#71717a" }}>14:32:01</span>
<span style={{ color: "#10b981" }}>[info]</span>
<span>Server started on port 3000</span>
</div>
<div style={{ display: "flex", gap: 16 }}>
<span style={{ color: "#71717a" }}>14:32:05</span>
<span style={{ color: "#10b981" }}>[info]</span>
<span>Database connected successfully</span>
</div>
<div style={{ display: "flex", gap: 16 }}>
<span style={{ color: "#71717a" }}>14:45:12</span>
<span style={{ color: "#3b82f6" }}>[http]</span>
<span>GET /api/users 200 OK - 45ms</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div style={{ padding: "40px 48px", maxWidth: 1200, margin: "0 auto", fontFamily: "var(--font-inter), sans-serif" }}>
<div style={{ marginBottom: 32 }}>
<h1 style={{ fontFamily: "var(--font-lora), serif", fontSize: "1.8rem", color: "#1a1a1a", marginBottom: 8 }}>
Market Intelligence
</h1>
<p style={{ color: "#6b6560", fontSize: "0.95rem" }}>
Real-time TAM, verified leads, and competitor teardowns from the Vibn Data Co-op.
</p>
</div>
<Suspense fallback={<div className="flex justify-center p-12"><Loader2 className="animate-spin w-8 h-8 text-gray-400" /></div>}>
<MarketDataDisplay projectId={projectId} />
</Suspense>
</div>
);
}
async function MarketDataDisplay({ projectId }: { projectId: string }) {
const data = await getMarketData(projectId);
if (!data.aggregations && data.leads.length === 0) {
return (
<Card>
<CardContent className="py-16 text-center" style={{ paddingTop: '4rem', paddingBottom: '4rem', textAlign: 'center' }}>
<Database style={{ width: 48, height: 48, margin: '0 auto 16px', color: '#d0ccc4' }} />
<h3 style={{ fontSize: '1.125rem', fontWeight: 500, color: '#111827', marginBottom: '8px' }}>No Market Data Yet</h3>
<p style={{ color: '#6b7280', maxWidth: '28rem', margin: '0 auto' }}>
Ask the Vibn AI to run market research for your niche to populate this dashboard with leads, competitors, and SEO insights.
</p>
</CardContent>
</Card>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: "32px" }}>
{/* Overview Cards */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))", gap: "24px" }}>
<Card>
<CardHeader style={{ paddingBottom: '8px' }}>
<CardTitle style={{ fontSize: '0.875rem', fontWeight: 500, color: '#6b7280', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Users style={{ width: 16, height: 16 }} /> Total Addressable Market
</CardTitle>
</CardHeader>
<CardContent>
<div style={{ fontSize: '1.875rem', fontWeight: 600, color: '#111827' }}>
{data.aggregations?.total_market_size?.toLocaleString() || "..."}
</div>
<p style={{ fontSize: '0.75rem', color: '#6b7280', marginTop: '4px' }}>Verified businesses in selected region</p>
</CardContent>
</Card>
<Card>
<CardHeader style={{ paddingBottom: '8px' }}>
<CardTitle style={{ fontSize: '0.875rem', fontWeight: 500, color: '#6b7280', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Target style={{ width: 16, height: 16 }} /> Qualified Leads Captured
</CardTitle>
</CardHeader>
<CardContent>
<div style={{ fontSize: '1.875rem', fontWeight: 600, color: '#111827' }}>
{data.leads.length}
</div>
<p style={{ fontSize: '0.75rem', color: '#6b7280', marginTop: '4px' }}>Ready for cold outreach</p>
</CardContent>
</Card>
<Card>
<CardHeader style={{ paddingBottom: '8px' }}>
<CardTitle style={{ fontSize: '0.875rem', fontWeight: 500, color: '#6b7280', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Search style={{ width: 16, height: 16 }} /> Tech Debt Indicator
</CardTitle>
</CardHeader>
<CardContent>
<div style={{ fontSize: '1.875rem', fontWeight: 600, color: '#111827' }}>
{data.aggregations ? Math.round((data.aggregations.websites_count / data.aggregations.total_market_size) * 100) : 0}%
</div>
<p style={{ fontSize: '0.75rem', color: '#6b7280', marginTop: '4px' }}>Of TAM have a website</p>
</CardContent>
</Card>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "32px" }}>
{/* Pain Points */}
{data.aggregations && (
<Card>
<CardHeader>
<CardTitle>Customer Pain Points</CardTitle>
<CardDescription>Extracted from Google Reviews</CardDescription>
</CardHeader>
<CardContent>
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}>
{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]) => (
<span key={topic} style={{ padding: "4px 12px", background: "#f0ede8", color: "#6b6560", fontSize: "0.75rem", fontWeight: 500, borderRadius: "9999px" }}>
{topic} ({(count as number).toLocaleString()})
</span>
))}
</div>
</CardContent>
</Card>
)}
{/* Sub-niches */}
{data.aggregations && (
<Card>
<CardHeader>
<CardTitle>Market Sub-Niches</CardTitle>
<CardDescription>Breakdown of primary category</CardDescription>
</CardHeader>
<CardContent>
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{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]) => (
<div key={topic} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "0.875rem" }}>
<span style={{ color: "#374151", textTransform: "capitalize" }}>{topic.replace(/_/g, ' ')}</span>
<span style={{ fontWeight: 500 }}>{(count as number).toLocaleString()}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
{/* Competitors */}
{data.competitors.length > 0 && (
<Card>
<CardHeader>
<CardTitle>SaaS Competitors & Ad Spend</CardTitle>
<CardDescription>Top incumbents and their Google Ads budget</CardDescription>
</CardHeader>
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", fontSize: "0.875rem", textAlign: "left" }}>
<thead style={{ fontSize: "0.75rem", color: "#6b7280", textTransform: "uppercase", background: "#f9fafb", borderBottom: "1px solid #e5e7eb" }}>
<tr>
<th style={{ padding: "12px 24px" }}>Domain</th>
<th style={{ padding: "12px 24px" }}>Monthly Ad Spend</th>
<th style={{ padding: "12px 24px" }}>Organic Traffic</th>
<th style={{ padding: "12px 24px" }}>Top Paid Keywords</th>
</tr>
</thead>
<tbody>
{data.competitors.map((comp: any) => {
const paidKw = typeof comp.top_paid_keywords === 'string' ? JSON.parse(comp.top_paid_keywords) : comp.top_paid_keywords;
return (
<tr key={comp.domain} style={{ background: "#fff", borderBottom: "1px solid #e5e7eb" }}>
<td style={{ padding: "16px 24px", fontWeight: 500, color: "#111827" }}>{comp.domain}</td>
<td style={{ padding: "16px 24px", color: "#dc2626", fontWeight: 500 }}>
${Math.round(comp.ad_spend_usd).toLocaleString()}
</td>
<td style={{ padding: "16px 24px" }}>
{Math.round(comp.organic_traffic).toLocaleString()} /mo
</td>
<td style={{ padding: "16px 24px" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
{(paidKw || []).slice(0, 3).map((kw: string) => (
<span key={kw} style={{ padding: "2px 8px", background: "#eff6ff", color: "#1d4ed8", fontSize: "0.625rem", borderRadius: "4px" }}>
{kw}
</span>
))}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
)}
{/* Leads Table */}
{data.leads.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Verified Leads</CardTitle>
<CardDescription>First {data.leads.length} contacts matching your target market</CardDescription>
</CardHeader>
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", fontSize: "0.875rem", textAlign: "left" }}>
<thead style={{ fontSize: "0.75rem", color: "#6b7280", textTransform: "uppercase", background: "#f9fafb", borderBottom: "1px solid #e5e7eb" }}>
<tr>
<th style={{ padding: "12px 24px" }}>Business Name</th>
<th style={{ padding: "12px 24px" }}>Location</th>
<th style={{ padding: "12px 24px" }}>Rating</th>
<th style={{ padding: "12px 24px" }}>Contact</th>
</tr>
</thead>
<tbody>
{data.leads.map((lead: any) => {
const emails = typeof lead.emails === 'string' ? JSON.parse(lead.emails) : lead.emails;
return (
<tr key={lead.place_id} style={{ background: "#fff", borderBottom: "1px solid #e5e7eb" }}>
<td style={{ padding: "16px 24px", fontWeight: 500, color: "#111827" }}>
{lead.name}
{lead.website && (
<a href={lead.website.startsWith('http') ? lead.website : `https://${lead.website}`} target="_blank" rel="noreferrer" style={{ display: "block", color: "#2563eb", fontSize: "0.75rem", marginTop: "4px", textDecoration: "none" }}>
{lead.website.replace(/^https?:\/\//, '')}
</a>
)}
</td>
<td style={{ padding: "16px 24px" }}>
{lead.city}, {lead.region}
</td>
<td style={{ padding: "16px 24px" }}>
{lead.rating ? `${lead.rating} ⭐ (${lead.reviews_count})` : 'N/A'}
</td>
<td style={{ padding: "16px 24px" }}>
<div style={{ fontSize: "0.75rem", color: "#4b5563" }}>
{lead.phone && <div style={{ marginBottom: "4px" }}>{lead.phone}</div>}
{(emails || []).map((e: string) => (
<a key={e} href={`mailto:${e}`} style={{ display: "block", color: "#2563eb", textDecoration: "none" }}>
{e}
</a>
))}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
)}
{/* ───────────────────────────────────────────────────────────── */}
{/* GO-TO-MARKET (GTM) STRATEGY ENGINE */}
{/* ───────────────────────────────────────────────────────────── */}
<div style={{ marginTop: "48px", borderTop: "1px solid #e5e7eb", paddingTop: "32px" }}>
<div style={{ marginBottom: 24, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<h2 style={{ fontFamily: "var(--font-lora), serif", fontSize: "1.5rem", color: "#111827", marginBottom: 4 }}>
Go-To-Market Strategy
</h2>
<p style={{ color: "#6b7280", fontSize: "0.875rem" }}>
Synthesize market data into an actionable marketing and positioning plan.
</p>
</div>
<button
style={{
background: "linear-gradient(to bottom right, #4f46e5, #312e81)",
color: "#fff",
padding: "8px 16px",
borderRadius: 6,
fontSize: "0.875rem",
fontWeight: 500,
boxShadow: "0 2px 4px rgba(79, 70, 229, 0.2)",
border: "none",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 8
}}
onClick={() => alert("This will deduct 500 AI Credits and generate the GTM strategy.")}
>
<span style={{ fontSize: "1.1rem", lineHeight: 1 }}></span> Generate GTM Plan (500 Credits)
</button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "24px", opacity: 0.5, pointerEvents: "none" }}>
<Card>
<CardHeader>
<CardTitle>Brand Positioning</CardTitle>
<CardDescription>Value prop, target persona, and wedge strategy.</CardDescription>
</CardHeader>
<CardContent>
<div style={{ background: "#f9fafb", padding: 24, borderRadius: 8, textAlign: "center", border: "1px dashed #d1d5db" }}>
<p style={{ fontSize: "0.875rem", color: "#6b7280" }}>Generate a plan to reveal the positioning strategy.</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>SEO & Content Engine</CardTitle>
<CardDescription>Keyword gaps and initial blog architecture.</CardDescription>
</CardHeader>
<CardContent>
<div style={{ background: "#f9fafb", padding: 24, borderRadius: 8, textAlign: "center", border: "1px dashed #d1d5db" }}>
<p style={{ fontSize: "0.875rem", color: "#6b7280" }}>Generate a plan to reveal keyword targets.</p>
</div>
</CardContent>
</Card>
</div>
<div style={{ marginTop: "24px", opacity: 0.5, pointerEvents: "none" }}>
<Card>
<CardHeader>
<CardTitle style={{ display: "flex", alignItems: "center", gap: 8 }}>
Social Media Automation
<span style={{ fontSize: "0.65rem", background: "#f3f4f6", padding: "2px 6px", borderRadius: 4, fontWeight: 600, color: "#4b5563" }}>POWERED BY MISSINGLETTR</span>
</CardTitle>
<CardDescription>A 3-month automated drip campaign based on your positioning.</CardDescription>
</CardHeader>
<CardContent>
<div style={{ background: "#f9fafb", padding: 48, borderRadius: 8, textAlign: "center", border: "1px dashed #d1d5db" }}>
<p style={{ fontSize: "0.875rem", color: "#6b7280" }}>Generate a plan to automatically orchestrate your social media strategy via Missinglettr.</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -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`);
}

View File

@@ -0,0 +1,366 @@
"use client";
import { ListFilter } from "lucide-react";
export default function SeoPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 24,
}}
>
<div>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
SEO & GEO
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Improve how your app appears in search results and AI answers.
</p>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span
style={{ fontSize: "0.9rem", color: "#18181b", fontWeight: 500 }}
>
Enable SEO for this app
</span>
<div
style={{
width: 44,
height: 24,
background: "#18181b",
borderRadius: 12,
position: "relative",
cursor: "pointer",
}}
>
<div
style={{
width: 20,
height: 20,
background: "#fff",
borderRadius: "50%",
position: "absolute",
right: 2,
top: 2,
}}
></div>
</div>
</div>
</div>
<div
style={{
display: "flex",
gap: 24,
marginBottom: 24,
borderBottom: "1px solid #e4e4e7",
}}
>
<button
style={{
background: "none",
border: "none",
borderBottom: "2px solid #18181b",
padding: "8px 0",
fontSize: "0.9rem",
fontWeight: 500,
color: "#18181b",
cursor: "pointer",
}}
>
Overview
</button>
<button
style={{
background: "none",
border: "none",
padding: "8px 0",
fontSize: "0.9rem",
fontWeight: 500,
color: "#71717a",
cursor: "pointer",
}}
>
Meta tags
</button>
<button
style={{
background: "none",
border: "none",
padding: "8px 0",
fontSize: "0.9rem",
fontWeight: 500,
color: "#71717a",
cursor: "pointer",
}}
>
Advanced Settings
</button>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "48px 32px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
marginBottom: 32,
}}
>
<div
style={{
width: 40,
height: 40,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 16,
}}
>
<ListFilter size={18} color="#18181b" />
</div>
<h2
style={{ fontSize: "1.1rem", fontWeight: 600, margin: "0 0 8px 0" }}
>
Run an SEO & GEO scan
</h2>
<p
style={{
fontSize: "0.9rem",
color: "#71717a",
textAlign: "center",
maxWidth: 400,
margin: "0 0 24px 0",
}}
>
Scan your app for SEO basics and GEO details. Get a prioritized
checklist to fix issues in minutes.
</p>
<button
style={{
background: "#18181b",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "10px 24px",
fontSize: "0.9rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Run Scan
</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "20px 24px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div
style={{ fontSize: "0.95rem", fontWeight: 600, marginBottom: 4 }}
>
AI Assistant Discovery
</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Help AI search engines understand and recommend your app
</div>
</div>
<div
style={{
width: 44,
height: 24,
background: "#e4e4e7",
borderRadius: 12,
position: "relative",
cursor: "pointer",
}}
>
<div
style={{
width: 20,
height: 20,
background: "#fff",
borderRadius: "50%",
position: "absolute",
left: 2,
top: 2,
boxShadow: "0 1px 2px rgba(0,0,0,0.1)",
}}
></div>
</div>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "20px 24px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div
style={{ fontSize: "0.95rem", fontWeight: 600, marginBottom: 4 }}
>
Generate robots.txt
</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Off: serve your deployed public/robots.txt if shipped, otherwise
return 404.
</div>
</div>
<div
style={{
width: 44,
height: 24,
background: "#18181b",
borderRadius: 12,
position: "relative",
cursor: "pointer",
}}
>
<div
style={{
width: 20,
height: 20,
background: "#fff",
borderRadius: "50%",
position: "absolute",
right: 2,
top: 2,
}}
></div>
</div>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "20px 24px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div
style={{ fontSize: "0.95rem", fontWeight: 600, marginBottom: 4 }}
>
Generate sitemap.xml
</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Off: serve your deployed public/sitemap.xml if shipped, otherwise
return 404.
</div>
</div>
<div
style={{
width: 44,
height: 24,
background: "#18181b",
borderRadius: 12,
position: "relative",
cursor: "pointer",
}}
>
<div
style={{
width: 20,
height: 20,
background: "#fff",
borderRadius: "50%",
position: "absolute",
right: 2,
top: 2,
}}
></div>
</div>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "20px 24px",
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
}}
>
<div style={{ maxWidth: 600 }}>
<div
style={{ fontSize: "0.95rem", fontWeight: 600, marginBottom: 4 }}
>
Auto-generate per-page breadcrumbs
</div>
<div
style={{ fontSize: "0.85rem", color: "#71717a", lineHeight: 1.5 }}
>
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.
</div>
</div>
<div
style={{
width: 44,
height: 24,
background: "#18181b",
borderRadius: 12,
position: "relative",
cursor: "pointer",
flexShrink: 0,
}}
>
<div
style={{
width: 20,
height: 20,
background: "#fff",
borderRadius: "50%",
position: "absolute",
right: 2,
top: 2,
}}
></div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { Share2 } from "lucide-react";
export default function SocialPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div style={{ marginBottom: 24 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Social Content
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Manage social sharing campaigns and meta tags.
</p>
</div>
<div
style={{
background: "#fff",
border: "1px dashed #e4e4e7",
borderRadius: 12,
padding: "80px 32px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
marginTop: 32,
}}
>
<div
style={{
width: 48,
height: 48,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 24,
}}
>
<Share2 size={24} color="#18181b" />
</div>
<h2
style={{ fontSize: "1.25rem", fontWeight: 600, margin: "0 0 12px 0" }}
>
Social Campaign Manager
</h2>
<p
style={{
fontSize: "0.95rem",
color: "#71717a",
textAlign: "center",
maxWidth: 460,
margin: "0 0 24px 0",
lineHeight: 1.5,
}}
>
Automatically generate and schedule social media content across
platforms based on your app's pages.
</p>
<button
style={{
background: "#18181b",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "10px 24px",
fontSize: "0.9rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Connect Social Accounts
</button>
</div>
</div>
);
}

View File

@@ -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 (
<div style={pageWrap}>
{showLoading && (
<div style={centeredMsg}>
<Loader2
size={16}
className="animate-spin"
style={{ color: INK.muted }}
/>
<span style={{ color: INK.muted, fontSize: "0.85rem" }}>
Loading
</span>
</div>
)}
{error && !showLoading && (
<div style={centeredMsg}>
<AlertCircle size={15} style={{ color: DANGER }} />
<span style={{ fontSize: "0.85rem", color: DANGER }}>{error}</span>
</div>
)}
{anatomy && (
<>
{/* ── Live endpoints ── */}
<section>
<SectionHeader title="Live" count={anatomy.hosting.live.length} />
{anatomy.hosting.live.length === 0 ? (
<EmptySection
icon={<Server size={20} style={{ color: INK.muted }} />}
title="Nothing deployed yet"
hint="Ask the AI to deploy your app and it will appear here."
promptSuggestion="Deploy my app to production"
/>
) : (
<div
style={{ display: "flex", flexDirection: "column", gap: 16 }}
>
{anatomy.hosting.live.map((item) => (
<LiveCard key={item.uuid} item={item} projectId={projectId} />
))}
</div>
)}
</section>
{/* ── Previews ── */}
{anatomy.hosting.previews.length > 0 && (
<section style={{ marginTop: 40 }}>
<SectionHeader
title="Dev Previews"
count={anatomy.hosting.previews.length}
/>
<div
style={{ display: "flex", flexDirection: "column", gap: 10 }}
>
{anatomy.hosting.previews.map((p) => (
<PreviewRow key={p.id} preview={p} />
))}
</div>
</section>
)}
</>
)}
</div>
);
}
// ──────────────────────────────────────────────────
// Live card
// ──────────────────────────────────────────────────
function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
const [deploying, setDeploying] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const [logs, setLogs] = useState<string | null>(null);
const [logsLoading, setLogsLoading] = useState(false);
const [copied, setCopied] = useState(false);
const primaryUrl = item.fqdn ? `https://${item.fqdn}` : null;
const phase = classifyPhase(item.status);
const { color: statusColor, label: statusLabel } = phaseDisplay(phase, item);
const redeploy = async () => {
if (deploying) return;
setDeploying(true);
try {
await fetch(`/api/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "apps.deploy",
params: { uuid: item.uuid, projectId },
}),
});
} finally {
setTimeout(() => setDeploying(false), 3000);
}
};
const openLogs = async () => {
if (!logsOpen) {
setLogsOpen(true);
setLogsLoading(true);
try {
const r = await fetch(`/api/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "apps.logs",
params: { uuid: item.uuid, lines: 60 },
}),
});
const d = await r.json();
setLogs(
typeof d.result === "string"
? d.result
: JSON.stringify(d.result ?? d.error, null, 2),
);
} catch {
setLogs("Failed to load logs.");
} finally {
setLogsLoading(false);
}
} else {
setLogsOpen(false);
}
};
const copyUrl = () => {
if (!primaryUrl) return;
navigator.clipboard.writeText(primaryUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div style={card}>
{/* ── Card header ── */}
<div style={cardHeader}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
minWidth: 0,
flex: 1,
}}
>
<CircleDot size={11} style={{ color: statusColor, flexShrink: 0 }} />
<span style={cardTitle}>{item.name}</span>
<span style={sourcePill(item.source)}>
{item.source === "repo" ? "built" : "image"}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button
onClick={redeploy}
disabled={deploying}
style={actionBtn}
title="Redeploy now"
>
{deploying ? (
<Loader2 size={13} className="animate-spin" />
) : (
<RefreshCw size={13} />
)}
{deploying ? "Deploying…" : "Redeploy"}
</button>
</div>
</div>
{/* ── Status line ── */}
<div style={statusLine}>
<span style={{ color: statusColor, fontWeight: 600 }}>
{statusLabel}
</span>
{item.lastBuild && (
<span style={{ color: INK.muted }}>
· Last build {item.lastBuild.status}{" "}
{formatRelative(item.lastBuild.finishedAt)}
</span>
)}
</div>
{/* ── Live URL ── */}
{primaryUrl ? (
<div style={urlRow}>
<Globe size={13} style={{ color: "#2e7d32", flexShrink: 0 }} />
<a href={primaryUrl} target="_blank" rel="noreferrer" style={urlLink}>
{primaryUrl}
</a>
<ExternalLink size={11} style={{ color: INK.muted, flexShrink: 0 }} />
<button onClick={copyUrl} style={iconBtn} title="Copy URL">
{copied ? (
<Check size={12} style={{ color: "#2e7d32" }} />
) : (
<Copy size={12} />
)}
</button>
</div>
) : (
<div style={urlRow}>
<Globe size={13} style={{ color: INK.muted, flexShrink: 0 }} />
<span
style={{
color: INK.muted,
fontSize: "0.82rem",
fontStyle: "italic",
}}
>
No domain attached ask the AI to add one.
</span>
</div>
)}
{/* ── Extra domains ── */}
{item.domains.length > 1 && (
<div
style={{
paddingLeft: 23,
display: "flex",
flexDirection: "column",
gap: 4,
marginTop: 4,
}}
>
{item.domains.slice(1).map((d) => (
<a
key={d}
href={`https://${d}`}
target="_blank"
rel="noreferrer"
style={{ ...urlLink, fontSize: "0.78rem", color: INK.mid }}
>
{d}{" "}
<ExternalLink
size={10}
style={{ display: "inline", verticalAlign: "middle" }}
/>
</a>
))}
</div>
)}
{/* ── Logs toggle ── */}
<div
style={{
marginTop: 14,
borderTop: `1px solid ${INK.borderSoft}`,
paddingTop: 10,
}}
>
<button onClick={openLogs} style={logsToggleBtn}>
<Terminal size={12} />
{logsOpen ? "Hide logs" : "Show recent logs"}
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
{logsOpen && (
<div style={logsBox}>
{logsLoading ? (
<span style={{ color: INK.muted, fontSize: "0.8rem" }}>
Loading
</span>
) : (
<pre style={logsPre}>{logs || "(no logs)"}</pre>
)}
</div>
)}
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// Preview row
// ──────────────────────────────────────────────────
function PreviewRow({ preview }: { preview: Preview }) {
const running = preview.state === "running";
return (
<div style={{ ...card, padding: "12px 16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<CircleDot
size={10}
style={{ color: running ? "#10b981" : INK.muted, flexShrink: 0 }}
/>
<span style={{ fontSize: "0.85rem", fontWeight: 600, color: INK.ink }}>
{preview.name}
</span>
<span style={{ fontSize: "0.75rem", color: INK.mid }}>
port {preview.port}
</span>
{preview.url && running && (
<div
style={{
marginLeft: "auto",
display: "flex",
gap: 8,
alignItems: "center",
}}
>
<a
href={preview.url}
target="_blank"
rel="noreferrer"
style={{ ...urlLink, marginLeft: 0 }}
>
{preview.url.replace(/^https?:\/\//, "")}{" "}
<ExternalLink
size={10}
style={{ display: "inline", verticalAlign: "middle" }}
/>
</a>
</div>
)}
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────
type Phase = "up" | "deploying" | "down" | "unknown";
function classifyPhase(status: string | undefined): Phase {
const s = (status ?? "").toLowerCase();
if (!s || s === "unknown") return "unknown";
if (/^(running|healthy)/.test(s)) return "up";
if (
/^(starting|restarting|created|deploying|building|in_progress|queued|paused)/.test(
s,
)
)
return "deploying";
if (/^(exited|dead|failed|stopped|unhealthy|error)/.test(s)) return "down";
return "unknown";
}
function phaseDisplay(
phase: Phase,
item: LiveItem,
): { color: string; label: string } {
if (item.inFlightBuild)
return {
color: AMBER,
label: `Deploying (${item.inFlightBuild.status ?? "in progress"})`,
};
switch (phase) {
case "up":
return { color: GREEN, label: "Live" };
case "deploying":
return { color: AMBER, label: "Starting…" };
case "down":
return { color: DANGER, label: "Down" };
default:
return { color: INK.muted, label: "Unknown" };
}
}
function formatRelative(iso: string | undefined) {
if (!iso) return "";
const ms = Date.now() - new Date(iso).getTime();
if (Number.isNaN(ms)) return "";
const min = Math.floor(ms / 60_000);
if (min < 1) return "just now";
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
// ──────────────────────────────────────────────────
// Sub-components
// ──────────────────────────────────────────────────
function SectionHeader({ title, count }: { title: string; count: number }) {
return (
<div style={sectionHeader}>
<span style={sectionTitle}>{title}</span>
<span style={countPill}>{count}</span>
</div>
);
}
function EmptySection({
icon,
title,
hint,
promptSuggestion,
}: {
icon: React.ReactNode;
title: string;
hint: string;
promptSuggestion?: string;
}) {
return (
<div style={emptyBox}>
<div style={{ marginBottom: 10 }}>{icon}</div>
<div
style={{
fontWeight: 600,
fontSize: "0.9rem",
color: INK.ink,
marginBottom: 6,
}}
>
{title}
</div>
<div
style={{
fontSize: "0.82rem",
color: INK.mid,
marginBottom: promptSuggestion ? 14 : 0,
}}
>
{hint}
</div>
{promptSuggestion && (
<div style={promptChip}>
<span
style={{ fontSize: "0.7rem", color: INK.muted, marginRight: 6 }}
>
Try asking:
</span>
<span
style={{ fontStyle: "italic", fontSize: "0.8rem", color: INK.mid }}
>
&quot;{promptSuggestion}&quot;
</span>
</div>
)}
</div>
);
}
// ──────────────────────────────────────────────────
// Tokens
// ──────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
border: "#e8e4dc",
borderSoft: "#efebe1",
cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
} as const;
const GREEN = "#10b981";
const AMBER = "#f59e0b";
const DANGER = "#ef4444";
// ──────────────────────────────────────────────────
// Styles
// ──────────────────────────────────────────────────
const pageWrap: React.CSSProperties = {
padding: "28px 48px 64px",
fontFamily: INK.fontSans,
color: INK.ink,
maxWidth: 860,
};
const centeredMsg: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "24px 0",
};
const sectionHeader: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 14,
};
const sectionTitle: React.CSSProperties = {
fontSize: "0.68rem",
fontWeight: 700,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: INK.muted,
};
const countPill: React.CSSProperties = {
fontSize: "0.7rem",
fontWeight: 600,
color: INK.mid,
padding: "1px 7px",
borderRadius: 999,
background: "#f3eee4",
};
const card: React.CSSProperties = {
background: INK.cardBg,
border: `1px solid ${INK.border}`,
borderRadius: 10,
padding: "18px 20px",
};
const cardHeader: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
marginBottom: 6,
};
const cardTitle: React.CSSProperties = {
fontSize: "0.95rem",
fontWeight: 700,
color: INK.ink,
};
const statusLine: React.CSSProperties = {
fontSize: "0.8rem",
color: INK.mid,
marginBottom: 12,
display: "flex",
alignItems: "center",
gap: 6,
flexWrap: "wrap",
};
const urlRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
background: "#f8f5f0",
borderRadius: 6,
padding: "8px 12px",
marginBottom: 2,
};
const urlLink: React.CSSProperties = {
fontSize: "0.85rem",
color: INK.ink,
textDecoration: "none",
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "inline-flex",
alignItems: "center",
gap: 4,
};
const actionBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
border: `1px solid ${INK.border}`,
borderRadius: 6,
background: "#fff",
cursor: "pointer",
font: "inherit",
fontSize: "0.78rem",
fontWeight: 600,
color: INK.mid,
transition: "background 0.1s, border-color 0.1s",
};
const iconBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 26,
height: 26,
border: "none",
background: "transparent",
cursor: "pointer",
color: INK.muted,
borderRadius: 4,
flexShrink: 0,
};
const logsToggleBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: "0.75rem",
fontWeight: 600,
color: INK.mid,
background: "none",
border: "none",
cursor: "pointer",
font: "inherit",
padding: 0,
};
const logsBox: React.CSSProperties = {
marginTop: 10,
background: "#1a1a1a",
borderRadius: 6,
padding: "12px 14px",
maxHeight: 320,
overflowY: "auto",
};
const logsPre: React.CSSProperties = {
margin: 0,
fontFamily: "ui-monospace, monospace",
fontSize: "0.72rem",
color: "#d4d0c8",
lineHeight: 1.6,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
};
const emptyBox: React.CSSProperties = {
border: `1px dashed ${INK.border}`,
borderRadius: 10,
padding: "36px 28px",
textAlign: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
};
const promptChip: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
background: "#f3eee4",
borderRadius: 6,
padding: "6px 12px",
fontSize: "0.8rem",
};
function sourcePill(source: "repo" | "image"): React.CSSProperties {
const isRepo = source === "repo";
return {
fontSize: "0.62rem",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: isRepo ? "#2e6d2e" : "#3b5a78",
background: isRepo ? "#eaf3e8" : "#e9eff5",
padding: "1px 6px",
borderRadius: 4,
flexShrink: 0,
};
}

View File

@@ -121,7 +121,7 @@ export default function PlanTab() {
<div style={railItems}>
<RailItem
id="objective"
label="Project Objective"
label="Product Brief"
icon={<Target />}
selectedId={selectedId}
onClick={setSelectedId}
@@ -282,7 +282,7 @@ function ObjectivePanel({
<div style={panel}>
<div style={panelHeader}>
<div>
<h2 style={panelTitle}>Project Objective</h2>
<h2 style={panelTitle}>Product Brief</h2>
<p style={panelDesc}>
The high-level business case and elevator pitch.
</p>

View File

@@ -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<Set<string>>(new Set());
const [selection, setSelection] = useState<Selection>(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 (
<div style={pageWrap}>
<div style={grid}>
{/* ── Left rail ── */}
<section style={leftCol}>
{showLoading && (
<Inline><Loader2 size={13} className="animate-spin" /> Loading</Inline>
)}
{error && !showLoading && (
<Inline><AlertCircle size={13} /> {error}</Inline>
)}
{anatomy && (
<>
{/* Codebases */}
<RailGroup title="Codebases" count={codebases?.length ?? 0}>
{codebases && codebases.length === 0 && (
<RailEmpty>
{reason === "no_repo"
? <>No codebase yet. <span style={nudge}>Try: &quot;Start building my app&quot;</span></>
: <>Repo is empty push a first commit. <span style={nudge}>Try: &quot;Scaffold a Next.js app&quot;</span></>}
</RailEmpty>
)}
{codebases?.map(cb => {
const isOpen = expanded.has(cb.id);
return (
<article key={cb.id} style={codebaseTile}>
<button
type="button"
onClick={() => toggleCodebase(cb.id)}
style={tileHeader}
aria-expanded={isOpen}
>
<span style={chevronCell}>
{isOpen
? <ChevronDown size={13} style={{ color: INK.mid }} />
: <ChevronRight size={13} style={{ color: INK.mid }} />}
</span>
<Box size={13} style={{ color: INK.mid, flexShrink: 0 }} />
<div style={{ minWidth: 0, textAlign: "left" }}>
<div style={tileLabel}>{cb.label}</div>
{cb.hint && <div style={tileHint}>{cb.hint}</div>}
</div>
</button>
{isOpen && (
<div style={tileBody}>
<GiteaFileTree
projectId={projectId}
rootPath={cb.path}
selectedPath={
selection?.type === "file" && selection.codebaseId === cb.id
? selection.path
: undefined
}
onSelectFile={(p) =>
setSelection({ type: "file", codebaseId: cb.id, path: p })
}
/>
</div>
)}
</article>
);
})}
</RailGroup>
{/* Images */}
<RailGroup title="Images" count={images?.length ?? 0}>
{images && images.length === 0 && (
<RailEmpty>
Self-hosted tools (Twenty CRM, n8n, Plausible) you run appear here.
<span style={nudge}>Try: &quot;Install Twenty CRM for my project&quot;</span>
</RailEmpty>
)}
{images?.map(img => (
<button
key={img.uuid}
type="button"
onClick={() => setSelection({ type: "image", uuid: img.uuid })}
style={{
...flatTile,
borderColor: selection?.type === "image" && selection.uuid === img.uuid ? INK.ink : INK.borderSoft,
boxShadow: selection?.type === "image" && selection.uuid === img.uuid ? `0 0 0 1px ${INK.ink}` : "none",
background: selection?.type === "image" && selection.uuid === img.uuid ? "#fffdf8" : INK.cardBg,
}}
aria-pressed={selection?.type === "image" && selection.uuid === img.uuid}
>
<Container size={13} style={{ color: INK.mid, flexShrink: 0 }} />
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
<div style={tileLabel}>{img.name}</div>
<div style={tileHint}>
{img.image}{img.version ? `:${img.version}` : ""}
</div>
</div>
{img.status && <CircleDot size={9} style={{ color: statusColor(img.status), flexShrink: 0 }} />}
</button>
))}
</RailGroup>
</>
)}
</section>
{/* ── Right pane ── */}
<aside style={rightCol}>
<h3 style={heading}>{paneHeading(selection)}</h3>
<div style={panel}>
{selection?.type === "file" && (
<GiteaFileViewer projectId={projectId} path={selection.path} />
)}
{selection?.type === "image" && anatomy && (
<ImageDetail uuid={selection.uuid} anatomy={anatomy} />
)}
{!selection && (
<Empty>Pick a codebase file or an image on the left.</Empty>
)}
</div>
</aside>
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// 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 <Empty>This image is no longer in the project.</Empty>;
const live = anatomy.hosting.live.find(l => l.uuid === uuid);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 1 }}>
<DetailRow label="Image" value={img.image} />
<DetailRow label="Version" value={img.version || "latest"} />
<DetailRow label="Type" value={img.serviceType ?? "—"} />
<DetailRow
label="Status"
value={img.status ?? "unknown"}
dot={statusColor(img.status ?? "")}
/>
{live?.fqdn && (
<DetailRow label="URL" value={live.fqdn} href={`https://${live.fqdn}`} />
)}
</div>
);
}
// ──────────────────────────────────────────────────
// Bits
// ──────────────────────────────────────────────────
function RailGroup({
title, count, children,
}: { title: string; count: number; children: React.ReactNode }) {
return (
<div style={railGroup}>
<header style={railGroupHeader}>
<span style={railGroupTitle}>{title}</span>
<span style={countPill}>{count}</span>
</header>
<div style={railItems}>{children}</div>
</div>
);
}
function RailEmpty({ children }: { children: React.ReactNode }) {
return <div style={railEmpty}>{children}</div>;
}
function DetailRow({
label, value, dot, href,
}: { label: string; value: string; dot?: string; href?: string }) {
return (
<div style={detailRow}>
<span style={detailLabel}>{label}</span>
<span style={detailValue}>
{dot && <CircleDot size={9} style={{ color: dot, marginRight: 6 }} />}
{href ? (
<a href={href} target="_blank" rel="noreferrer" style={detailLink}>{value}</a>
) : value}
</span>
</div>
);
}
function Inline({ children }: { children: React.ReactNode }) {
return (
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "12px 14px", fontSize: "0.82rem", color: INK.mid,
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 8,
}}>
{children}
</div>
);
}
function Empty({ children }: { children: React.ReactNode }) {
return (
<div style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
color: INK.mid, fontSize: "0.85rem", padding: "32px 16px", textAlign: "center",
}}>
{children}
</div>
);
}
// ──────────────────────────────────────────────────
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",
};

View File

@@ -0,0 +1,119 @@
"use client";
import { Shield, Settings } from "lucide-react";
export default function SecurityPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 24,
}}
>
<div>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Security
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Manage your permissions and security rules.{" "}
<a
href="#"
style={{ color: "#18181b", textDecoration: "underline" }}
>
Learn more
</a>
</p>
</div>
<button
style={{
width: 36,
height: 36,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 8,
cursor: "pointer",
}}
>
<Settings size={16} color="#18181b" />
</button>
</div>
<div
style={{
background: "#fff",
border: "1px dashed #e4e4e7",
borderRadius: 12,
padding: "80px 32px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
marginTop: 32,
}}
>
<div
style={{
width: 48,
height: 48,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 24,
}}
>
<Shield size={24} color="#18181b" />
</div>
<h2
style={{ fontSize: "1.25rem", fontWeight: 600, margin: "0 0 12px 0" }}
>
Check the security of your app
</h2>
<p
style={{
fontSize: "0.95rem",
color: "#71717a",
textAlign: "center",
maxWidth: 460,
margin: "0 0 24px 0",
lineHeight: 1.5,
}}
>
Review your configuration, identify potential risks, and learn how to
strengthen your app's protection
</p>
<button
style={{
background: "#18181b",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "10px 24px",
fontSize: "0.9rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Check Security
</button>
</div>
</div>
);
}

View File

@@ -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 (
<div style={pageWrap}>
{showLoading && (
<div style={centeredMsg}>
<Loader2
size={16}
className="animate-spin"
style={{ color: INK.muted }}
/>
<span style={{ color: INK.muted, fontSize: "0.85rem" }}>
Loading
</span>
</div>
)}
{error && !showLoading && (
<div style={centeredMsg}>
<AlertCircle size={15} style={{ color: DANGER }} />
<span style={{ fontSize: "0.85rem", color: DANGER }}>{error}</span>
</div>
)}
{anatomy && (
<>
{/* ── Live endpoints ── */}
<section>
<SectionHeader title="Live" count={anatomy.hosting.live.length} />
{anatomy.hosting.live.length === 0 ? (
<EmptySection
icon={<Server size={20} style={{ color: INK.muted }} />}
title="Nothing deployed yet"
hint="Ask the AI to deploy your app and it will appear here."
promptSuggestion="Deploy my app to production"
/>
) : (
<div
style={{ display: "flex", flexDirection: "column", gap: 16 }}
>
{anatomy.hosting.live.map((item) => (
<LiveCard key={item.uuid} item={item} projectId={projectId} />
))}
</div>
)}
</section>
{/* ── Previews ── */}
{anatomy.hosting.previews.length > 0 && (
<section style={{ marginTop: 40 }}>
<SectionHeader
title="Dev Previews"
count={anatomy.hosting.previews.length}
/>
<div
style={{ display: "flex", flexDirection: "column", gap: 10 }}
>
{anatomy.hosting.previews.map((p) => (
<PreviewRow key={p.id} preview={p} />
))}
</div>
</section>
)}
</>
)}
</div>
);
}
// ──────────────────────────────────────────────────
// Live card
// ──────────────────────────────────────────────────
function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
const [deploying, setDeploying] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const [logs, setLogs] = useState<string | null>(null);
const [logsLoading, setLogsLoading] = useState(false);
const [copied, setCopied] = useState(false);
const primaryUrl = item.fqdn ? `https://${item.fqdn}` : null;
const phase = classifyPhase(item.status);
const { color: statusColor, label: statusLabel } = phaseDisplay(phase, item);
const redeploy = async () => {
if (deploying) return;
setDeploying(true);
try {
await fetch(`/api/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "apps.deploy",
params: { uuid: item.uuid, projectId },
}),
});
} finally {
setTimeout(() => setDeploying(false), 3000);
}
};
const openLogs = async () => {
if (!logsOpen) {
setLogsOpen(true);
setLogsLoading(true);
try {
const r = await fetch(`/api/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "apps.logs",
params: { uuid: item.uuid, lines: 60 },
}),
});
const d = await r.json();
setLogs(
typeof d.result === "string"
? d.result
: JSON.stringify(d.result ?? d.error, null, 2),
);
} catch {
setLogs("Failed to load logs.");
} finally {
setLogsLoading(false);
}
} else {
setLogsOpen(false);
}
};
const copyUrl = () => {
if (!primaryUrl) return;
navigator.clipboard.writeText(primaryUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div style={card}>
{/* ── Card header ── */}
<div style={cardHeader}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
minWidth: 0,
flex: 1,
}}
>
<CircleDot size={11} style={{ color: statusColor, flexShrink: 0 }} />
<span style={cardTitle}>{item.name}</span>
<span style={sourcePill(item.source)}>
{item.source === "repo" ? "built" : "image"}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button
onClick={redeploy}
disabled={deploying}
style={actionBtn}
title="Redeploy now"
>
{deploying ? (
<Loader2 size={13} className="animate-spin" />
) : (
<RefreshCw size={13} />
)}
{deploying ? "Deploying…" : "Redeploy"}
</button>
</div>
</div>
{/* ── Status line ── */}
<div style={statusLine}>
<span style={{ color: statusColor, fontWeight: 600 }}>
{statusLabel}
</span>
{item.lastBuild && (
<span style={{ color: INK.muted }}>
· Last build {item.lastBuild.status}{" "}
{formatRelative(item.lastBuild.finishedAt)}
</span>
)}
</div>
{/* ── Live URL ── */}
{primaryUrl ? (
<div style={urlRow}>
<Globe size={13} style={{ color: "#2e7d32", flexShrink: 0 }} />
<a href={primaryUrl} target="_blank" rel="noreferrer" style={urlLink}>
{primaryUrl}
</a>
<ExternalLink size={11} style={{ color: INK.muted, flexShrink: 0 }} />
<button onClick={copyUrl} style={iconBtn} title="Copy URL">
{copied ? (
<Check size={12} style={{ color: "#2e7d32" }} />
) : (
<Copy size={12} />
)}
</button>
</div>
) : (
<div style={urlRow}>
<Globe size={13} style={{ color: INK.muted, flexShrink: 0 }} />
<span
style={{
color: INK.muted,
fontSize: "0.82rem",
fontStyle: "italic",
}}
>
No domain attached ask the AI to add one.
</span>
</div>
)}
{/* ── Extra domains ── */}
{item.domains.length > 1 && (
<div
style={{
paddingLeft: 23,
display: "flex",
flexDirection: "column",
gap: 4,
marginTop: 4,
}}
>
{item.domains.slice(1).map((d) => (
<a
key={d}
href={`https://${d}`}
target="_blank"
rel="noreferrer"
style={{ ...urlLink, fontSize: "0.78rem", color: INK.mid }}
>
{d}{" "}
<ExternalLink
size={10}
style={{ display: "inline", verticalAlign: "middle" }}
/>
</a>
))}
</div>
)}
{/* ── Logs toggle ── */}
<div
style={{
marginTop: 14,
borderTop: `1px solid ${INK.borderSoft}`,
paddingTop: 10,
}}
>
<button onClick={openLogs} style={logsToggleBtn}>
<Terminal size={12} />
{logsOpen ? "Hide logs" : "Show recent logs"}
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
{logsOpen && (
<div style={logsBox}>
{logsLoading ? (
<span style={{ color: INK.muted, fontSize: "0.8rem" }}>
Loading
</span>
) : (
<pre style={logsPre}>{logs || "(no logs)"}</pre>
)}
</div>
)}
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// Preview row
// ──────────────────────────────────────────────────
function PreviewRow({ preview }: { preview: Preview }) {
const running = preview.state === "running";
return (
<div style={{ ...card, padding: "12px 16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<CircleDot
size={10}
style={{ color: running ? "#10b981" : INK.muted, flexShrink: 0 }}
/>
<span style={{ fontSize: "0.85rem", fontWeight: 600, color: INK.ink }}>
{preview.name}
</span>
<span style={{ fontSize: "0.75rem", color: INK.mid }}>
port {preview.port}
</span>
<span
style={{
fontSize: "0.75rem",
color: running ? "#10b981" : INK.muted,
fontWeight: 500,
background: running ? "#ecfdf5" : "#f4f4f5",
padding: "2px 8px",
borderRadius: 12,
marginLeft: 8,
}}
>
{preview.state}
</span>
{preview.url && running && (
<div
style={{
marginLeft: "auto",
display: "flex",
gap: 8,
alignItems: "center",
}}
>
<a
href={preview.url}
target="_blank"
rel="noreferrer"
style={{ ...urlLink, marginLeft: 0 }}
>
{preview.url.replace(/^https?:\/\//, "")}{" "}
<ExternalLink
size={10}
style={{ display: "inline", verticalAlign: "middle" }}
/>
</a>
</div>
)}
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────
type Phase = "up" | "deploying" | "down" | "unknown";
function classifyPhase(status: string | undefined): Phase {
const s = (status ?? "").toLowerCase();
if (!s || s === "unknown") return "unknown";
if (/^(running|healthy)/.test(s)) return "up";
if (
/^(starting|restarting|created|deploying|building|in_progress|queued|paused)/.test(
s,
)
)
return "deploying";
if (/^(exited|dead|failed|stopped|unhealthy|error)/.test(s)) return "down";
return "unknown";
}
function phaseDisplay(
phase: Phase,
item: LiveItem,
): { color: string; label: string } {
if (item.inFlightBuild)
return {
color: AMBER,
label: `Deploying (${item.inFlightBuild.status ?? "in progress"})`,
};
switch (phase) {
case "up":
return { color: GREEN, label: "Live" };
case "deploying":
return { color: AMBER, label: "Starting…" };
case "down":
return { color: DANGER, label: "Down" };
default:
return { color: INK.muted, label: "Unknown" };
}
}
function formatRelative(iso: string | undefined) {
if (!iso) return "";
const ms = Date.now() - new Date(iso).getTime();
if (Number.isNaN(ms)) return "";
const min = Math.floor(ms / 60_000);
if (min < 1) return "just now";
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
// ──────────────────────────────────────────────────
// Sub-components
// ──────────────────────────────────────────────────
function SectionHeader({ title, count }: { title: string; count: number }) {
return (
<div style={sectionHeader}>
<span style={sectionTitle}>{title}</span>
<span style={countPill}>{count}</span>
</div>
);
}
function EmptySection({
icon,
title,
hint,
promptSuggestion,
}: {
icon: React.ReactNode;
title: string;
hint: string;
promptSuggestion?: string;
}) {
return (
<div style={emptyBox}>
<div style={{ marginBottom: 10 }}>{icon}</div>
<div
style={{
fontWeight: 600,
fontSize: "0.9rem",
color: INK.ink,
marginBottom: 6,
}}
>
{title}
</div>
<div
style={{
fontSize: "0.82rem",
color: INK.mid,
marginBottom: promptSuggestion ? 14 : 0,
}}
>
{hint}
</div>
{promptSuggestion && (
<div style={promptChip}>
<span
style={{ fontSize: "0.7rem", color: INK.muted, marginRight: 6 }}
>
Try asking:
</span>
<span
style={{ fontStyle: "italic", fontSize: "0.8rem", color: INK.mid }}
>
&quot;{promptSuggestion}&quot;
</span>
</div>
)}
</div>
);
}
// ──────────────────────────────────────────────────
// Tokens
// ──────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
border: "#e8e4dc",
borderSoft: "#efebe1",
cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
} as const;
const GREEN = "#10b981";
const AMBER = "#f59e0b";
const DANGER = "#ef4444";
// ──────────────────────────────────────────────────
// Styles
// ──────────────────────────────────────────────────
const pageWrap: React.CSSProperties = {
padding: "28px 48px 64px",
fontFamily: INK.fontSans,
color: INK.ink,
maxWidth: 860,
};
const centeredMsg: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "24px 0",
};
const sectionHeader: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 14,
};
const sectionTitle: React.CSSProperties = {
fontSize: "0.68rem",
fontWeight: 700,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: INK.muted,
};
const countPill: React.CSSProperties = {
fontSize: "0.7rem",
fontWeight: 600,
color: INK.mid,
padding: "1px 7px",
borderRadius: 999,
background: "#f3eee4",
};
const card: React.CSSProperties = {
background: INK.cardBg,
border: `1px solid ${INK.border}`,
borderRadius: 10,
padding: "18px 20px",
};
const cardHeader: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
marginBottom: 6,
};
const cardTitle: React.CSSProperties = {
fontSize: "0.95rem",
fontWeight: 700,
color: INK.ink,
};
const statusLine: React.CSSProperties = {
fontSize: "0.8rem",
color: INK.mid,
marginBottom: 12,
display: "flex",
alignItems: "center",
gap: 6,
flexWrap: "wrap",
};
const urlRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
background: "#f8f5f0",
borderRadius: 6,
padding: "8px 12px",
marginBottom: 2,
};
const urlLink: React.CSSProperties = {
fontSize: "0.85rem",
color: INK.ink,
textDecoration: "none",
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "inline-flex",
alignItems: "center",
gap: 4,
};
const actionBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
border: `1px solid ${INK.border}`,
borderRadius: 6,
background: "#fff",
cursor: "pointer",
font: "inherit",
fontSize: "0.78rem",
fontWeight: 600,
color: INK.mid,
transition: "background 0.1s, border-color 0.1s",
};
const iconBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 26,
height: 26,
border: "none",
background: "transparent",
cursor: "pointer",
color: INK.muted,
borderRadius: 4,
flexShrink: 0,
};
const logsToggleBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: "0.75rem",
fontWeight: 600,
color: INK.mid,
background: "none",
border: "none",
cursor: "pointer",
font: "inherit",
padding: 0,
};
const logsBox: React.CSSProperties = {
marginTop: 10,
background: "#1a1a1a",
borderRadius: 6,
padding: "12px 14px",
maxHeight: 320,
overflowY: "auto",
};
const logsPre: React.CSSProperties = {
margin: 0,
fontFamily: "ui-monospace, monospace",
fontSize: "0.72rem",
color: "#d4d0c8",
lineHeight: 1.6,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
};
const emptyBox: React.CSSProperties = {
border: `1px dashed ${INK.border}`,
borderRadius: 10,
padding: "36px 28px",
textAlign: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
};
const promptChip: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
background: "#f3eee4",
borderRadius: 6,
padding: "6px 12px",
fontSize: "0.8rem",
};
function sourcePill(source: "repo" | "image"): React.CSSProperties {
const isRepo = source === "repo";
return {
fontSize: "0.62rem",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: isRepo ? "#2e6d2e" : "#3b5a78",
background: isRepo ? "#eaf3e8" : "#e9eff5",
padding: "1px 6px",
borderRadius: 4,
flexShrink: 0,
};
}

View File

@@ -0,0 +1,101 @@
"use client";
export default function AppSettingsPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div style={{ marginBottom: 24 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
App Settings
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
General configuration for your application.
</p>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<label style={{ fontSize: "0.9rem", fontWeight: 500 }}>
App Name
</label>
<input
type="text"
defaultValue="CampCore"
style={{
padding: "10px 14px",
border: "1px solid #e4e4e7",
borderRadius: 8,
fontSize: "0.9rem",
outline: "none",
}}
/>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<label style={{ fontSize: "0.9rem", fontWeight: 500 }}>
Description
</label>
<textarea
rows={3}
defaultValue="A comprehensive operating system for camp and youth activity providers."
style={{
padding: "10px 14px",
border: "1px solid #e4e4e7",
borderRadius: 8,
fontSize: "0.9rem",
outline: "none",
resize: "none",
}}
/>
</div>
<div style={{ borderTop: "1px solid #e4e4e7", margin: "12px 0" }}></div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
}}
>
<div>
<h3
style={{
fontSize: "1rem",
fontWeight: 600,
margin: "0 0 4px 0",
color: "#ef4444",
}}
>
Delete Application
</h3>
<p style={{ fontSize: "0.85rem", color: "#71717a", margin: 0 }}>
Permanently delete this app and all of its data.
</p>
</div>
<button
style={{
background: "#fee2e2",
color: "#ef4444",
border: "1px solid #fca5a5",
borderRadius: 8,
padding: "8px 16px",
fontSize: "0.85rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Delete App
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
export default function AuthSettingsPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div style={{ marginBottom: 24 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Authentication
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Configure how users sign in to your app.
</p>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "24px",
marginBottom: 24,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
}}
>
<div>
<div
style={{ fontSize: "0.95rem", fontWeight: 600, marginBottom: 4 }}
>
Email & Password
</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Allow users to sign up with an email and password.
</div>
</div>
<div
style={{
width: 44,
height: 24,
background: "#18181b",
borderRadius: 12,
position: "relative",
cursor: "pointer",
}}
>
<div
style={{
width: 20,
height: 20,
background: "#fff",
borderRadius: "50%",
position: "absolute",
right: 2,
top: 2,
}}
></div>
</div>
</div>
<div style={{ borderTop: "1px solid #e4e4e7", margin: "16px 0" }}></div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div
style={{ fontSize: "0.95rem", fontWeight: 600, marginBottom: 4 }}
>
Google OAuth
</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Allow users to sign in with their Google account.
</div>
</div>
<div
style={{
width: 44,
height: 24,
background: "#e4e4e7",
borderRadius: 12,
position: "relative",
cursor: "pointer",
}}
>
<div
style={{
width: 20,
height: 20,
background: "#fff",
borderRadius: "50%",
position: "absolute",
left: 2,
top: 2,
boxShadow: "0 1px 2px rgba(0,0,0,0.1)",
}}
></div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,233 +1,6 @@
"use client";
import { redirect } from "next/navigation";
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Settings, Trash2, AlertTriangle, Loader2, ArrowLeft } from "lucide-react";
import { WorkspaceKeysPanel } from "@/components/workspace/WorkspaceKeysPanel";
import Link from "next/link";
/**
* Project settings page.
* Accessible via the gear icon in the project header.
*
* Sections:
* - General (name, description — future)
* - Danger zone: delete project
*/
export default function ProjectSettingsPage() {
const params = useParams();
const router = useRouter();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const [deletePhase, setDeletePhase] = useState<"idle" | "confirm" | "deleting" | "done">("idle");
const [confirmInput, setConfirmInput] = useState("");
const [deleteError, setDeleteError] = useState<string | null>(null);
const projectBackUrl = `/${workspace}/project/${projectId}/plan`;
const handleDelete = async () => {
if (deletePhase === "idle") {
setDeletePhase("confirm");
return;
}
if (deletePhase !== "confirm") return;
if (confirmInput.toLowerCase() !== "delete") return;
setDeletePhase("deleting");
setDeleteError(null);
try {
const r = await fetch("/api/projects/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || "Delete failed");
setDeletePhase("done");
setTimeout(() => router.push(`/${workspace}/projects`), 1500);
} catch (e) {
setDeleteError(e instanceof Error ? e.message : String(e));
setDeletePhase("confirm");
}
};
return (
<div style={pageWrap}>
{/* Back link */}
<Link href={projectBackUrl} style={backLink}>
<ArrowLeft size={14} /> Back to project
</Link>
<h1 style={pageTitle}>
<Settings size={18} /> Project settings
</h1>
<div style={{ marginBottom: 40 }}><WorkspaceKeysPanel workspaceSlug={workspace} /></div>
{/* ── Danger zone ── */}
<section style={dangerSection}>
<h2 style={sectionTitle}>
<AlertTriangle size={15} style={{ color: DANGER }} />
Danger zone
</h2>
<div style={dangerCard}>
<div style={dangerCardBody}>
<div>
<div style={dangerItemTitle}>Delete this project</div>
<div style={dangerItemDesc}>
Removes all project data from Vibn. Coolify services and databases
are <strong>not</strong> automatically stopped use the chat to clean those
up first, or remove them from Coolify directly.
</div>
</div>
{deletePhase === "idle" && (
<button onClick={handleDelete} style={dangerBtn}>
<Trash2 size={13} /> Delete project
</button>
)}
{deletePhase === "confirm" && (
<div style={confirmBox}>
<div style={{ fontSize: "0.82rem", color: DANGER, fontWeight: 600, marginBottom: 8 }}>
Type <strong>delete</strong> to confirm
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
autoFocus
value={confirmInput}
onChange={e => setConfirmInput(e.target.value)}
onKeyDown={e => e.key === "Enter" && confirmInput.toLowerCase() === "delete" && handleDelete()}
placeholder="delete"
style={confirmInput_}
/>
<button
onClick={handleDelete}
disabled={confirmInput.toLowerCase() !== "delete"}
style={{
...dangerBtn,
opacity: confirmInput.toLowerCase() !== "delete" ? 0.4 : 1,
}}
>
<Trash2 size={13} /> Confirm delete
</button>
<button
onClick={() => { setDeletePhase("idle"); setConfirmInput(""); setDeleteError(null); }}
style={cancelBtn}
>
Cancel
</button>
</div>
{deleteError && (
<div style={{ marginTop: 8, fontSize: "0.8rem", color: DANGER }}>{deleteError}</div>
)}
</div>
)}
{deletePhase === "deleting" && (
<button style={{ ...dangerBtn, opacity: 0.6 }} disabled>
<Loader2 size={13} className="animate-spin" /> Deleting
</button>
)}
{deletePhase === "done" && (
<div style={{ fontSize: "0.85rem", color: "#2e7d32", fontWeight: 600 }}>
Project deleted. Redirecting
</div>
)}
</div>
</div>
</section>
</div>
);
export default async function SettingsPage({ params }: { params: Promise<{ workspace: string; projectId: string }> }) {
const { workspace, projectId } = await params;
redirect(`/${workspace}/project/${projectId}/settings/app`);
}
// ──────────────────────────────────────────────────
// Tokens
// ──────────────────────────────────────────────────
const DANGER = "#c5392b";
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
border: "#e8e4dc",
borderSoft: "#efebe1",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
} as const;
// ──────────────────────────────────────────────────
// Styles
// ──────────────────────────────────────────────────
const pageWrap: React.CSSProperties = {
padding: "28px 48px 64px",
fontFamily: INK.fontSans,
color: INK.ink,
maxWidth: 720,
};
const backLink: React.CSSProperties = {
display: "inline-flex", alignItems: "center", gap: 6,
fontSize: "0.8rem", color: INK.mid, textDecoration: "none",
marginBottom: 24,
};
const pageTitle: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 10,
fontSize: "1.25rem", fontWeight: 700, color: INK.ink,
marginBottom: 36, marginTop: 0,
};
const dangerSection: React.CSSProperties = { marginTop: 32 };
const sectionTitle: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 8,
fontSize: "0.72rem", fontWeight: 700, letterSpacing: "0.12em",
textTransform: "uppercase", color: INK.muted,
marginBottom: 12,
};
const dangerCard: React.CSSProperties = {
border: `1px solid #f0cac5`,
borderRadius: 10,
background: "#fffaf9",
};
const dangerCardBody: React.CSSProperties = {
padding: "18px 20px",
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 24,
flexWrap: "wrap",
};
const dangerItemTitle: React.CSSProperties = {
fontWeight: 600, fontSize: "0.9rem", color: INK.ink, marginBottom: 4,
};
const dangerItemDesc: React.CSSProperties = {
fontSize: "0.8rem", color: INK.mid, lineHeight: 1.55, maxWidth: 380,
};
const dangerBtn: React.CSSProperties = {
display: "inline-flex", alignItems: "center", gap: 6,
padding: "7px 14px", border: `1px solid ${DANGER}`,
borderRadius: 6, background: "#fff", cursor: "pointer",
font: "inherit", fontSize: "0.8rem", fontWeight: 600, color: DANGER,
whiteSpace: "nowrap", flexShrink: 0,
};
const cancelBtn: React.CSSProperties = {
display: "inline-flex", alignItems: "center",
padding: "7px 12px", border: `1px solid ${INK.border}`,
borderRadius: 6, background: "#fff", cursor: "pointer",
font: "inherit", fontSize: "0.8rem", color: INK.mid,
whiteSpace: "nowrap",
};
const confirmBox: React.CSSProperties = { display: "flex", flexDirection: "column" };
const confirmInput_: React.CSSProperties = {
padding: "7px 10px",
border: `1px solid ${DANGER}`,
borderRadius: 6,
font: "inherit",
fontSize: "0.85rem",
outline: "none",
width: 100,
};

View File

@@ -0,0 +1,89 @@
"use client";
import { HardDrive } from "lucide-react";
export default function StoragePage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div style={{ marginBottom: 24 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Storage
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Manage your cloud storage buckets and assets.
</p>
</div>
<div
style={{
background: "#fff",
border: "1px dashed #e4e4e7",
borderRadius: 12,
padding: "80px 32px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
marginTop: 32,
}}
>
<div
style={{
width: 48,
height: 48,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 24,
}}
>
<HardDrive size={24} color="#18181b" />
</div>
<h2
style={{ fontSize: "1.25rem", fontWeight: 600, margin: "0 0 12px 0" }}
>
No buckets found
</h2>
<p
style={{
fontSize: "0.95rem",
color: "#71717a",
textAlign: "center",
maxWidth: 460,
margin: "0 0 24px 0",
lineHeight: 1.5,
}}
>
Create an S3-compatible storage bucket to start uploading user files,
avatars, and application assets.
</p>
<button
style={{
background: "#18181b",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "10px 24px",
fontSize: "0.9rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Create Bucket
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,247 @@
"use client";
import { Search, ChevronDown, ListFilter } from "lucide-react";
export default function UsersPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 24,
}}
>
<div>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Users
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Manage the app's users and their roles
</p>
</div>
<div style={{ display: "flex", gap: 12 }}>
<button
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 36,
height: 36,
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 8,
cursor: "pointer",
}}
>
<ListFilter size={16} color="#18181b" />
</button>
<button
style={{
background: "#18181b",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "0 16px",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
height: 36,
}}
>
Invite User
</button>
</div>
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 24 }}>
<button
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 8,
padding: "8px 48px",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
boxShadow: "0 1px 2px rgba(0,0,0,0.05)",
}}
>
Users
</button>
<button
style={{
background: "#f4f4f5",
border: "none",
color: "#71717a",
borderRadius: 8,
padding: "8px 48px",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
}}
>
Pending requests
</button>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
overflow: "hidden",
}}
>
<div
style={{
padding: "16px",
borderBottom: "1px solid #e4e4e7",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
Users
</h2>
<div style={{ display: "flex", gap: 12 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 6,
padding: "6px 12px",
width: 240,
}}
>
<Search size={14} color="#a1a1aa" />
<input
type="text"
placeholder="Search by Email or Name"
style={{
border: "none",
outline: "none",
background: "transparent",
fontSize: "0.8rem",
width: "100%",
}}
/>
</div>
<button
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 6,
padding: "6px 12px",
fontSize: "0.8rem",
fontWeight: 500,
cursor: "pointer",
}}
>
all roles <ChevronDown size={14} />
</button>
</div>
</div>
<table
style={{
width: "100%",
borderCollapse: "collapse",
textAlign: "left",
fontSize: "0.85rem",
}}
>
<thead>
<tr
style={{
background: "#fafafa",
borderBottom: "1px solid #e4e4e7",
}}
>
<th
style={{
padding: "12px 16px",
fontWeight: 500,
color: "#71717a",
width: "40%",
}}
>
Name
</th>
<th
style={{
padding: "12px 16px",
fontWeight: 500,
color: "#71717a",
width: "20%",
}}
>
Role
</th>
<th
style={{
padding: "12px 16px",
fontWeight: 500,
color: "#71717a",
width: "40%",
}}
>
Email
</th>
</tr>
</thead>
<tbody>
<tr>
<td
style={{ padding: "16px", borderBottom: "1px solid #e4e4e7" }}
>
<div style={{ fontWeight: 500, color: "#18181b" }}>
Mark Henderson
</div>
<div
style={{ color: "#71717a", fontSize: "0.8rem", marginTop: 2 }}
>
Owner
</div>
</td>
<td
style={{
padding: "16px",
borderBottom: "1px solid #e4e4e7",
fontWeight: 500,
}}
>
admin
</td>
<td
style={{
padding: "16px",
borderBottom: "1px solid #e4e4e7",
color: "#18181b",
}}
>
markhenderson1977@gmail.com
</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -5311,20 +5311,17 @@ async function toolDevServerStart(
workspace: principal.workspace,
});
// Instead of firing-and-forgetting, we now wait for the server to ACTUALLY
// spin up and serve HTTP traffic before we return success to the AI.
// This allows the AI to see the exact health check failure synchronously.
let isHealthy = false;
// We mark it healthy immediately. Webpack compiles are taking too long
// on cold boots and causing the probe to fail and the AI to retry endlessly.
// The Traefik router will hold the connection open for the user until it responds.
let isHealthy = true;
let failureOutput = "";
try {
await probeDevServerReadiness(project.id, row.id, row.port);
isHealthy = true;
} catch (probeErr: any) {
isHealthy = false;
failureOutput = probeErr.message || String(probeErr);
console.error("[dev_server.start] Synchronous probe failed:", probeErr);
}
// We still fire the probe in the background so it eventually logs if it fails,
// but we don't await it.
probeDevServerReadiness(project.id, row.id, row.port).catch((err) => {
console.error("[dev_server.start] Async probe failed later:", err);
});
if (!isHealthy) {
let recentLogs = "";

View File

@@ -793,80 +793,7 @@ async function loadPreviews(projectId: string): Promise<Preview[]> {
[projectId],
);
// Filter out zombies: if a server is marked 'running' but the URL returns a 50x
// Gateway error or times out, the process died. We mark it stopped so the
// UI can trigger an auto-restart.
const activePreviews: typeof rows = [];
await Promise.all(
rows.map(async (r) => {
if (r.state !== "running") {
activePreviews.push(r);
return;
}
try {
const controller = new AbortController();
// We use a short timeout because we don't want to block the anatomy
// response. A slow response doesn't mean it's dead (Next.js might
// just be compiling) — we ONLY want to catch instant 502/503s from Traefik.
const timeout = setTimeout(() => controller.abort(), 2000);
const ping = await fetch(r.preview_url, {
method: "HEAD",
signal: controller.signal,
});
clearTimeout(timeout);
// 502/503/504 means Traefik is up but the container isn't answering.
// 404 means Traefik doesn't even know about the route.
if (
ping.status === 502 ||
ping.status === 503 ||
ping.status === 504 ||
ping.status === 404
) {
// Give freshly booted servers a 60-second grace period before murdering them.
// Traefik sometimes returns 502 for a few seconds right after the internal probe succeeds.
const ageMs = Date.now() - new Date(r.started_at).getTime();
if (ageMs < 60000) {
activePreviews.push(r);
return;
}
console.warn(
`[anatomy] Preview zombie detected for ${r.preview_url} (HTTP ${ping.status}). Marking stopped.`,
);
await query(
`UPDATE fs_dev_servers SET state = 'stopped' WHERE id = $1`,
[r.id],
).catch(() => {});
} else {
activePreviews.push(r);
}
} catch (e: any) {
// If the fetch aborts due to our 2s timeout, the server is just slow
// (likely doing a cold Webpack compile). DO NOT mark it as a zombie!
// Only kill it if we get a hard DNS/network error that isn't a timeout.
if (
e.name === "AbortError" ||
e.type === "aborted" ||
e.message?.includes("timeout")
) {
activePreviews.push(r); // Benefit of the doubt — it's thinking
} else {
console.warn(
`[anatomy] Preview zombie detected for ${r.preview_url} (${e.message}). Marking stopped.`,
);
await query(
`UPDATE fs_dev_servers SET state = 'stopped' WHERE id = $1`,
[r.id],
).catch(() => {});
}
}
}),
);
return sortDevPreviewsFrontendFirst(activePreviews).map((r) => ({
return sortDevPreviewsFrontendFirst(rows).map((r) => ({
id: r.id,
name: r.name,
command: r.command ?? undefined,

View File

@@ -1,80 +1,341 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Code2,
Search,
LayoutGrid,
ClipboardList,
Globe,
Database,
BarChart2,
Globe,
Plug,
ShieldCheck,
Code2,
Terminal,
Settings,
CreditCard,
PlaneTakeoff,
ChevronDown,
ChevronRight,
Users,
HardDrive,
Blocks,
} from "lucide-react";
export function DashboardSidebar({ workspace, projectId, children }: { workspace: string, projectId: string, children: React.ReactNode }) {
import { useAnatomy } from "@/components/project/use-anatomy";
export function DashboardSidebar({
workspace,
projectId,
children,
}: {
workspace: string;
projectId: string;
children: React.ReactNode;
}) {
const pathname = usePathname() ?? "";
const projectBase = `/${workspace}/project/${projectId}`;
const isPreview = pathname === `${projectBase}/preview` || pathname.startsWith(`${projectBase}/preview/`);
const isPreview =
pathname === `${projectBase}/preview` ||
pathname.startsWith(`${projectBase}/preview/`);
const [expandedSections, setExpandedSections] = useState<
Record<string, boolean>
>({
settings: true,
data: true,
});
const [searchQuery, setSearchQuery] = useState("");
const { anatomy } = useAnatomy(projectId);
const databases = anatomy?.infrastructure?.databases ?? [];
if (isPreview) {
return <>{children}</>;
}
const items = [
{ segment: "plan", label: "Plan", Icon: ClipboardList },
{ segment: "market", label: "Market", Icon: PlaneTakeoff },
{ segment: "product", label: "Code", Icon: Code2, aliases: ["code"] },
{ segment: "hosting", label: "Hosting", Icon: Globe },
{ segment: "infrastructure", label: "Infra", Icon: Database },
{ segment: "billing", label: "Billing", Icon: CreditCard },
{ segment: "settings", label: "Settings", Icon: Settings },
const handleSectionClick = (segment: string) => {
if (!expandedSections[segment]) {
setExpandedSections((prev) => ({ ...prev, [segment]: true }));
}
};
const menuItems = [
{ segment: "overview", label: "Overview", Icon: LayoutGrid },
{ segment: "plan", label: "Plan & Specs", Icon: ClipboardList },
{ segment: "code", label: "Code", Icon: Code2 },
{
segment: "data",
label: "Data",
Icon: Database,
hasChildren: true,
children: databases.map((db) => ({
segment: `data/tables?db=${db.uuid}`,
label: db.name,
})),
},
{ segment: "storage", label: "Storage", Icon: HardDrive },
{ segment: "services", label: "Services", Icon: Blocks },
{ segment: "users", label: "Auth / Users", Icon: Users },
{ segment: "integrations", label: "Integrations", Icon: Plug },
{ segment: "security", label: "Security", Icon: ShieldCheck },
{ segment: "logs", label: "Logs", Icon: Terminal },
{ segment: "domains", label: "Domains", Icon: Globe },
{
segment: "analytics",
label: "Analytics",
Icon: BarChart2,
badge: "Soon",
},
{
segment: "marketing",
label: "Marketing",
Icon: BarChart2,
badge: "New",
hasChildren: true,
children: [
{ segment: "marketing/seo", label: "SEO & GEO" },
{ segment: "marketing/social", label: "Social content" },
],
},
{
segment: "settings",
label: "Settings",
Icon: Settings,
hasChildren: true,
children: [
{ segment: "settings/app", label: "App Settings" },
{ segment: "settings/auth", label: "Authentication" },
],
},
];
const filteredItems = menuItems.filter(
(item) =>
item.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
(item.children &&
item.children.some((child) =>
child.label.toLowerCase().includes(searchQuery.toLowerCase()),
)),
);
return (
<div style={{ display: "flex", flex: 1, minHeight: 0, minWidth: 0 }}>
<div style={{
width: 240,
borderRight: "1px solid #e4e4e7",
background: "#fafafa",
display: "flex",
flexDirection: "column",
padding: "16px 12px",
gap: 4,
overflowY: "auto"
}}>
<div style={{ fontSize: "0.75rem", fontWeight: 600, color: "#a1a1aa", padding: "0 8px 8px", textTransform: "uppercase", letterSpacing: "0.05em" }}>
<div
style={{
width: 250,
borderRight: "1px solid #e4e4e7",
background: "#ffffff",
display: "flex",
flexDirection: "column",
padding: "16px 12px",
gap: 4,
overflowY: "auto",
}}
>
<div
style={{
fontSize: "0.85rem",
fontWeight: 600,
color: "#18181b",
padding: "0 8px 12px",
}}
>
Dashboard
</div>
{items.map(item => {
const active = pathname === `${projectBase}/${item.segment}` || (item.aliases && item.aliases.some(a => pathname === `${projectBase}/${a}`));
return (
<Link
key={item.segment}
href={`${projectBase}/${item.segment}`}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
borderRadius: 8,
fontSize: "0.85rem",
fontWeight: 500,
color: active ? "#18181b" : "#52525b",
background: active ? "#fff" : "transparent",
boxShadow: active ? "0 1px 2px rgba(0,0,0,0.05)" : "none",
border: active ? "1px solid #e4e4e7" : "1px solid transparent",
textDecoration: "none",
transition: "all 0.15s ease"
}}
>
<item.Icon size={16} color={active ? "#18181b" : "#a1a1aa"} />
{item.label}
</Link>
);
})}
{/* Search Bar */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "#f4f4f5",
borderRadius: 8,
padding: "6px 10px",
marginBottom: 12,
margin: "0 4px 12px 4px",
}}
>
<Search size={14} color="#a1a1aa" />
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
border: "none",
background: "transparent",
outline: "none",
width: "100%",
fontSize: "0.8rem",
color: "#18181b",
}}
/>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
{filteredItems.map((item) => {
const isMainActive =
pathname === `${projectBase}/${item.segment}` ||
pathname.startsWith(`${projectBase}/${item.segment}/`);
const isExpanded = expandedSections[item.segment];
return (
<div key={item.segment}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "8px 10px",
borderRadius: 8,
cursor: "pointer",
background:
isMainActive && !item.hasChildren
? "#eff6ff"
: "transparent",
color:
isMainActive && !item.hasChildren ? "#1d4ed8" : "#52525b",
transition: "all 0.1s ease",
}}
onClick={() => {
if (item.hasChildren) {
setExpandedSections((prev) => ({
...prev,
[item.segment]: !prev[item.segment],
}));
}
}}
>
{item.hasChildren ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
flex: 1,
}}
>
<item.Icon size={16} />
<span style={{ fontSize: "0.85rem", fontWeight: 500 }}>
{item.label}
</span>
</div>
) : (
<Link
href={`${projectBase}/${item.segment}`}
style={{
display: "flex",
alignItems: "center",
gap: 10,
flex: 1,
textDecoration: "none",
color: "inherit",
}}
>
<item.Icon size={16} />
<span style={{ fontSize: "0.85rem", fontWeight: 500 }}>
{item.label}
</span>
</Link>
)}
<div
style={{ display: "flex", alignItems: "center", gap: 6 }}
>
{item.badge && (
<span
style={{
background: "#eef2ff",
color: "#4f46e5",
fontSize: "0.65rem",
fontWeight: 600,
padding: "2px 6px",
borderRadius: 999,
}}
>
{item.badge}
</span>
)}
{item.hasChildren &&
(isExpanded ? (
<ChevronDown size={14} color="#a1a1aa" />
) : (
<ChevronRight size={14} color="#a1a1aa" />
))}
</div>
</div>
{/* Render Children if expanded */}
{item.hasChildren && isExpanded && item.children && (
<div
style={{
display: "flex",
flexDirection: "column",
marginTop: 2,
}}
>
{item.children.map((child) => {
const href = child.segment.includes("?")
? `${projectBase}/${child.segment.split("?")[0]}?${child.segment.split("?")[1]}`
: `${projectBase}/${child.segment}`;
let isChildActive = false;
if (child.segment.includes("?")) {
const [basePath, searchStr] = child.segment.split("?");
isChildActive =
pathname === `${projectBase}/${basePath}` &&
(typeof window !== "undefined"
? window.location.search.includes(searchStr)
: false);
} else {
isChildActive =
pathname === `${projectBase}/${child.segment}`;
}
return (
<Link
key={child.segment}
href={href}
style={{
display: "flex",
alignItems: "center",
padding: "6px 10px 6px 14px",
marginLeft: "18px",
borderRadius: "0 8px 8px 0",
fontSize: "0.8rem",
fontWeight: 500,
textDecoration: "none",
color: isChildActive ? "#18181b" : "#52525b",
background: "transparent",
transition: "all 0.1s ease",
borderLeft: isChildActive
? "2px solid #18181b"
: "2px solid transparent",
}}
>
{child.label}
</Link>
);
})}
</div>
)}
</div>
);
})}
</div>
</div>
<div style={{ flex: 1, minWidth: 0, overflow: "auto", background: "#fff", display: "flex", flexDirection: "column" }}>
<div
style={{
flex: 1,
minWidth: 0,
overflow: "auto",
background: "#fff",
display: "flex",
flexDirection: "column",
}}
>
{children}
</div>
</div>

View File

@@ -37,6 +37,9 @@ export function ProjectIconRail({ workspace, projectId, actions }: Props) {
fontSize: "0.75rem",
fontWeight: 500,
borderRadius: 6,
display: "flex",
alignItems: "center",
height: 24, // Explicitly match device toggles height
textDecoration: "none",
background: isPreviewActive ? "#ffffff" : "transparent",
color: isPreviewActive ? "#18181b" : "#71717a",
@@ -53,6 +56,9 @@ export function ProjectIconRail({ workspace, projectId, actions }: Props) {
fontSize: "0.75rem",
fontWeight: 500,
borderRadius: 6,
display: "flex",
alignItems: "center",
height: 24, // Explicitly match device toggles height
textDecoration: "none",
background: !isPreviewActive ? "#ffffff" : "transparent",
color: !isPreviewActive ? "#18181b" : "#71717a",
@@ -237,6 +243,16 @@ function PreviewDeviceToggles() {
}}
/>
<span
style={{
opacity: 0.5,
flexShrink: 0,
paddingLeft: 4,
fontFamily: "var(--font-mono), monospace",
}}
>
/
</span>
<div
style={{
flex: 1,
@@ -246,9 +262,14 @@ function PreviewDeviceToggles() {
alignItems: "center",
}}
>
<select
value={currentPath}
onChange={(e) => setCurrentPath(e.target.value)}
<input
type="text"
list="common-routes"
value={currentPath === "/" ? "" : currentPath.replace(/^\//, "")}
onChange={(e) =>
setCurrentPath("/" + e.target.value.replace(/^\//, ""))
}
placeholder="path (e.g. dashboard)"
style={{
background: "transparent",
border: "none",
@@ -259,19 +280,22 @@ function PreviewDeviceToggles() {
textOverflow: "ellipsis",
fontFamily: "var(--font-mono), monospace",
paddingRight: 16,
appearance: "none",
cursor: "pointer",
}}
>
<option value="/">/</option>
<option value="/dashboard">/dashboard</option>
<option value="/login">/login</option>
<option value="/signup">/signup</option>
<option value="/about">/about</option>
<option value="/contact">/contact</option>
<option value="/pricing">/pricing</option>
<option value="/settings">/settings</option>
</select>
onKeyDown={(e) => {
if (e.key === "Enter") {
triggerRefresh(); // force reload iframe
}
}}
/>
<datalist id="common-routes">
<option value="dashboard" />
<option value="login" />
<option value="signup" />
<option value="about" />
<option value="contact" />
<option value="pricing" />
<option value="settings" />
</datalist>
<ChevronDown
size={12}
style={{
@@ -401,12 +425,11 @@ const bar: React.CSSProperties = {
alignItems: "center",
flex: 1,
minWidth: 0,
height: "56px", // Explicitly set height
height: "100%", // Inherit height from parent
padding: "0 16px",
gap: 12,
boxSizing: "border-box",
background: "#fafafa",
borderBottom: "1px solid #e4e4e7",
background: "#faf8f5",
fontFamily: '"Outfit", "Inter", ui-sans-serif, sans-serif',
};

View File

@@ -24,6 +24,7 @@ import {
Square,
MousePointerClick,
Paperclip,
ChevronLeft,
} from "lucide-react";
import { ProjectIconRail } from "@/components/project/project-icon-rail";
import {
@@ -165,63 +166,63 @@ function friendlyToolName(name: string): string {
"request.visual.qa": "Running visual QA",
// Core Platform Tools
"projects.list": "📂 Listing workspace projects",
"projects.get": "🗒️ Retrieving project spec sheets",
"workspace.describe": "💼 Fetching workspace details",
"gitea.credentials": "🔑 Resolving repository Git credentials",
"projects.list": "Listing workspace projects",
"projects.get": "Retrieving project spec sheets",
"workspace.describe": "Fetching workspace details",
"gitea.credentials": "Resolving repository Git credentials",
"shell.exec": "Running command",
ship: "Shipping",
"generate.media": "📸 Generating visual media assets",
"get.design.template": "📐 Retrieving design templates",
"apps.templates.scaffold": "🧱 Scaffolding bento-grid layouts",
"generate.media": "Generating visual media assets",
"get.design.template": "Retrieving design templates",
"apps.templates.scaffold": "Scaffolding bento-grid layouts",
// App & Database Deployment Tools
"apps.list": "🖥️ Listing deployed web services",
"apps.get": "🔍 Checking application build status",
"apps.list": "Listing deployed web services",
"apps.get": "Checking application build status",
"apps.create": "Creating app",
"apps.update": "⚙️ Updating application settings",
"apps.delete": "Removing deployed application",
"apps.update": "Updating application settings",
"apps.delete": "Removing deployed application",
"apps.deploy": "Deploying app",
"apps.deployments": "📜 Fetching recent deployment history",
"apps.envs.list": "🔒 Loading environment variables",
"apps.envs.upsert": "🔑 Injecting environment variables",
"apps.envs.delete": "🗑️ Removing environment variables",
"apps.domains.list": "🌐 Checking application domain routing",
"apps.domains.set": "🔗 Binding custom domains",
"apps.logs": "📋 Fetching application logs",
"apps.exec": "🐚 Running command inside container",
"databases.list": "🛢️ Listing database clusters",
"databases.create": "🛢️ Provisioning database service",
"databases.get": "🔌 Retrieving database connection credentials",
"databases.update": "⚙️ Updating database configuration",
"databases.delete": "🗑️ Removing database service",
"apps.deployments": "Fetching recent deployment history",
"apps.envs.list": "Loading environment variables",
"apps.envs.upsert": "Injecting environment variables",
"apps.envs.delete": "Removing environment variables",
"apps.domains.list": "Checking application domain routing",
"apps.domains.set": "Binding custom domains",
"apps.logs": "Fetching application logs",
"apps.exec": "Running command inside container",
"databases.list": "Listing database clusters",
"databases.create": "Provisioning database service",
"databases.get": "Retrieving database connection credentials",
"databases.update": "Updating database configuration",
"databases.delete": "Removing database service",
// Domain & Git Tools
"domains.search": "🔎 Searching open domain names",
"domains.list": "🌐 Listing registered domains",
"domains.get": "📄 Retrieving domain details",
"domains.register": "💳 Registering domain name",
"domains.attach": "🔌 Attaching domain reverse-proxy rules",
"gitea.repos.list": "📦 Listing Gitea repositories",
"gitea.repo.get": "🔍 Loading Gitea repository info",
"gitea.repo.create": "🏗️ Initializing Gitea repository",
"gitea.file.read": "📖 Reading file from Gitea",
"gitea.file.write": "💾 Saving file to Gitea",
"gitea.file.delete": "🗑️ Deleting file from Gitea",
"gitea.branches.list": "🌿 Checking repository branches",
"gitea.branch.create": "🌱 Creating Git branch",
"devcontainer.ensure": "🐋 Spinning up secure Alpine dev container",
"devcontainer.status": "💓 Probing dev container liveness",
"devcontainer.suspend": "💤 Suspending dev container",
"domains.search": "Searching open domain names",
"domains.list": "Listing registered domains",
"domains.get": "Retrieving domain details",
"domains.register": "Registering domain name",
"domains.attach": "Attaching domain reverse-proxy rules",
"gitea.repos.list": "Listing Gitea repositories",
"gitea.repo.get": "Loading Gitea repository info",
"gitea.repo.create": "Initializing Gitea repository",
"gitea.file.read": "Reading file from Gitea",
"gitea.file.write": "Saving file to Gitea",
"gitea.file.delete": "Deleting file from Gitea",
"gitea.branches.list": "Checking repository branches",
"gitea.branch.create": "Creating Git branch",
"devcontainer.ensure": "Spinning up secure Alpine dev container",
"devcontainer.status": "Probing dev container liveness",
"devcontainer.suspend": "Suspending dev container",
// Planning / Specs Tools
"plan.get": "📋 Loading specifications checklist",
"plan.vision.set": "🎯 Saving feature product specification",
"plan.idea.add": "💡 Adding planning ideation",
"plan.task.add": " Adding task to development roadmap",
"plan.task.edit": "✏️ Updating development roadmap task",
"plan.task.complete": "Toggling checklist milestone as completed",
"plan.document.update": "📝 Updating specs documentation",
"plan.get": "Loading specifications checklist",
"plan.vision.set": "Saving feature product specification",
"plan.idea.add": "Adding planning ideation",
"plan.task.add": "Adding task to development roadmap",
"plan.task.edit": "Updating development roadmap task",
"plan.task.complete": "Toggling checklist milestone as completed",
"plan.document.update": "Updating specs documentation",
};
return map[dotted] || dotted;
@@ -1026,6 +1027,13 @@ export function ChatPanel({
);
const [showThreads, setShowThreads] = useState(false);
const [mcpToken, setMcpToken] = useState<string | null>(null);
const [isChatMinimized, setIsChatMinimized] = useState<boolean>(false);
// Auto-minimize when navigating to dashboard, auto-open when navigating to preview
useEffect(() => {
setIsChatMinimized(!pathname.includes("/preview"));
}, [pathname]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// AbortController for the in-flight /api/chat fetch. Lives in a ref
@@ -2088,12 +2096,9 @@ export function ChatPanel({
}}
onInput={(e) => {
const el = e.currentTarget;
const newlines = (el.value.match(/\n/g) || []).length;
if ((el as any).lastNewlines !== newlines) {
(el as any).lastNewlines = newlines;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 240) + "px";
}
// Only resize if height actually changed
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 240) + "px";
}}
/>
<div
@@ -2319,7 +2324,7 @@ export function ChatPanel({
alignItems: "stretch",
flexShrink: 0,
height: 48,
borderBottom: "1px solid #e8e4dc",
borderBottom: "1px solid #e4e4e7",
background: "#faf8f5",
boxSizing: "border-box",
}}
@@ -2334,7 +2339,7 @@ export function ChatPanel({
padding: "0 12px",
gap: 6,
boxSizing: "border-box",
borderRight: "1px solid #e8e4dc",
borderRight: "1px solid #e4e4e7",
}}
>
<div
@@ -2354,25 +2359,23 @@ export function ChatPanel({
minWidth: 0,
}}
>
{workspace ? (
<Link
href={`/${workspace}/projects`}
title="All projects"
style={{ flexShrink: 0, display: "flex" }}
>
<img
src="/vibn-black-circle-logo.png"
alt="VIBN"
width={26}
height={26}
style={{
borderRadius: 7,
objectFit: "cover",
display: "block",
}}
/>
</Link>
) : null}
<Link
href={workspace ? `/${workspace}/projects` : "/"}
title="All projects"
style={{ flexShrink: 0, display: "flex" }}
>
<img
src="/vibn-black-circle-logo.png"
alt="VIBN"
width={26}
height={26}
style={{
borderRadius: 7,
objectFit: "cover",
display: "block",
}}
/>
</Link>
<button
type="button"
onClick={() => setShowThreads((v) => !v)}
@@ -2493,21 +2496,80 @@ export function ChatPanel({
flexDirection: "row",
minHeight: 0,
minWidth: 0,
position: "relative", // Ensure relative positioning for z-index stacking context
}}
>
<div
style={{
width: 380,
width: isChatMinimized ? 52 : 380,
flexShrink: 0,
borderRight: "1px solid #e8e4dc",
borderRight: "1px solid #e4e4e7",
background: "#fff",
display: "flex",
flexDirection: "column",
minHeight: 0,
minWidth: 0,
transition: "width 0.2s ease-in-out",
overflow: "visible", // Changed from hidden to visible so the collapse button can hang over the edge
position: "relative",
zIndex: 40, // High z-index to pop over the right panel
}}
>
{structuralChatBody}
<div
style={{
position: "absolute",
zIndex: 50,
right: -12,
top: 12,
}}
>
<button
onClick={() => setIsChatMinimized(!isChatMinimized)}
style={{
background: "#fff",
border: "1px solid #e4e4e7",
width: 24,
height: 24,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
color: "#a1a1aa",
boxShadow: "0 2px 4px rgba(0,0,0,0.05)",
}}
title={isChatMinimized ? "Expand Chat" : "Minimize Chat"}
>
{isChatMinimized ? (
<ChevronRight size={14} />
) : (
<ChevronLeft size={14} />
)}
</button>
</div>
{isChatMinimized ? (
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
paddingTop: 16,
}}
></div>
) : (
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{structuralChatBody}
</div>
)}
</div>
<div
key={pathname}
@@ -2519,6 +2581,8 @@ export function ChatPanel({
background: "#faf8f5",
display: "flex",
flexDirection: "column",
position: "relative",
zIndex: 10, // Lower z-index so the collapse button stays on top
}}
>
{artifactSlot}
@@ -2604,25 +2668,23 @@ export function ChatPanel({
minWidth: 0,
}}
>
{workspace ? (
<Link
href={`/${workspace}/projects`}
title="All projects"
style={{ flexShrink: 0, display: "flex" }}
>
<img
src="/vibn-black-circle-logo.png"
alt="VIBN"
width={26}
height={26}
style={{
borderRadius: 7,
objectFit: "cover",
display: "block",
}}
/>
</Link>
) : null}
<Link
href={workspace ? `/${workspace}/projects` : "/"}
title="All projects"
style={{ flexShrink: 0, display: "flex" }}
>
<img
src="/vibn-black-circle-logo.png"
alt="VIBN"
width={26}
height={26}
style={{
borderRadius: 7,
objectFit: "cover",
display: "block",
}}
/>
</Link>
<button
type="button"
onClick={() => setShowThreads((v) => !v)}

File diff suppressed because it is too large Load Diff

View File

@@ -53,7 +53,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"daisyui": "^5.5.1-beta.2",
"dotenv": "^17.2.3",
"dotenv": "^17.4.2",
"firebase": "^12.5.0",
"form-data": "^4.0.5",
"google-auth-library": "^10.5.0",
@@ -64,7 +64,7 @@
"next-auth": "^4.24.13",
"next-themes": "^0.4.6",
"pdf-parse": "^1.1.1",
"pg": "^8.16.3",
"pg": "^8.21.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env node
/**
* Fetch a Coolify app's FULL container logs via SSH + `docker logs`.
*
* More powerful than the REST API path (scripts/fetch-app-logs.mjs): it includes
* timestamps, supports a `--since` date filter, and works for both dockerfile and
* dockercompose apps. Use this when the REST `/logs` endpoint returns little or
* nothing (e.g. quiet services, or compose apps Coolify can't tail via the API).
*
* Usage (from the vibn-frontend/ directory):
* node --env-file=.env.local scripts/fetch-app-logs-ssh.mjs <appUuid> [sinceISO] [tail]
*
* Examples:
* # Everything since the start of today (UTC)
* node --env-file=.env.local scripts/fetch-app-logs-ssh.mjs hou4vy5mtyg5mrx3w4nl2lxv 2026-06-12
* # Last 500 lines, no date filter
* node --env-file=.env.local scripts/fetch-app-logs-ssh.mjs hou4vy5mtyg5mrx3w4nl2lxv "" 500
*
* The <appUuid> is the last path segment of the Coolify app URL.
* Env (from vibn-frontend/.env.local):
* COOLIFY_SSH_HOST, COOLIFY_SSH_PORT, COOLIFY_SSH_USER, COOLIFY_SSH_PRIVATE_KEY_B64
*/
import ssh2 from "ssh2";
const { Client } = ssh2;
const uuid = process.argv[2];
const since = process.argv[3] || ""; // optional ISO date, e.g. 2026-06-12
const tail = process.argv[4] || "2000";
if (!uuid) {
console.error(
"Usage: node --env-file=.env.local scripts/fetch-app-logs-ssh.mjs <appUuid> [sinceISO] [tail]",
);
process.exit(1);
}
const keyB64 = process.env.COOLIFY_SSH_PRIVATE_KEY_B64;
if (!process.env.COOLIFY_SSH_HOST || !keyB64) {
console.error("Missing COOLIFY_SSH_HOST / COOLIFY_SSH_PRIVATE_KEY_B64 in .env.local");
process.exit(1);
}
const cfg = {
host: process.env.COOLIFY_SSH_HOST,
port: Number(process.env.COOLIFY_SSH_PORT ?? 22),
username: process.env.COOLIFY_SSH_USER ?? "vibn-logs",
privateKey: Buffer.from(keyB64, "base64").toString("utf8"),
readyTimeout: 8000,
};
function runRemote(cmd) {
return new Promise((resolve, reject) => {
const conn = new Client();
let out = "";
let errOut = "";
conn
.on("ready", () => {
conn.exec(cmd, (err, stream) => {
if (err) {
conn.end();
return reject(err);
}
stream
.on("close", (code) => {
conn.end();
resolve({ code, out, errOut });
})
.on("data", (d) => (out += d.toString()))
.stderr.on("data", (d) => (errOut += d.toString()));
});
})
.on("error", reject)
.connect(cfg);
});
}
// Resolve the container by name (Coolify names them with the app UUID), then
// dump its logs. The $(...) here runs on the REMOTE host, not locally.
const sinceFlag = since ? `--since ${since}` : "";
const cmd =
`cid=$(docker ps -a --filter name=${uuid} --format '{{.Names}}' | head -1); ` +
`if [ -z "$cid" ]; then echo "NO_CONTAINER for ${uuid}"; exit 2; fi; ` +
`echo "# container=$cid"; ` +
`docker logs --timestamps ${sinceFlag} --tail ${tail} "$cid" 2>&1`;
const { code, out, errOut } = await runRemote(cmd);
if (out) process.stdout.write(out);
if (errOut) process.stderr.write(errOut);
process.exit(code ?? 0);

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env node
/**
* Fetch a Coolify application's runtime (container stdout/stderr) logs via the
* Coolify REST API — no dashboard login needed.
*
* Usage (from the vibn-frontend/ directory):
* node scripts/fetch-app-logs.mjs <appUuid> [lines]
*
* The <appUuid> is the last path segment of the Coolify app URL, e.g.
* https://coolify.vibnai.com/project/.../application/hou4vy5mtyg5mrx3w4nl2lxv
* ^^^^^^^^^^^^^^^^^^^^^^^^ appUuid
*
* Reads COOLIFY_URL + COOLIFY_API_TOKEN from vibn-frontend/.env.local.
* Note: Coolify's /logs endpoint returns the *last N lines* of container output,
* not a date range — pull a generous N and filter by date client-side if needed.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function loadEnv(file) {
const out = {};
try {
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/);
if (!m) continue;
let v = m[2];
if (
(v.startsWith('"') && v.endsWith('"')) ||
(v.startsWith("'") && v.endsWith("'"))
)
v = v.slice(1, -1);
out[m[1]] = v;
}
} catch {}
return out;
}
const env = { ...loadEnv(path.join(__dirname, "../.env.local")), ...process.env };
const COOLIFY_URL = env.COOLIFY_URL || "https://coolify.vibnai.com";
const TOKEN = env.COOLIFY_API_TOKEN;
const uuid = process.argv[2];
const lines = Math.max(1, Math.min(parseInt(process.argv[3] || "1000", 10), 5000));
if (!uuid) {
console.error("Usage: node scripts/fetch-app-logs.mjs <appUuid> [lines]");
process.exit(1);
}
if (!TOKEN) {
console.error("Missing COOLIFY_API_TOKEN in vibn-frontend/.env.local");
process.exit(1);
}
const url = `${COOLIFY_URL}/api/v1/applications/${uuid}/logs?lines=${lines}`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
});
if (!res.ok) {
console.error(`Coolify API ${res.status} on ${url}:\n${await res.text()}`);
process.exit(1);
}
const data = await res.json();
process.stdout.write((data?.logs ?? "").toString());
process.stdout.write("\n");