diff --git a/apps/web/app/(auth)/signin/page.tsx b/apps/web/app/(auth)/signin/page.tsx index 049865c..cc23a46 100644 --- a/apps/web/app/(auth)/signin/page.tsx +++ b/apps/web/app/(auth)/signin/page.tsx @@ -1,11 +1,17 @@ "use client"; import { useState } from "react"; +import dynamic from "next/dynamic"; import { signIn } from "next-auth/react"; import { useRouter } from "next/navigation"; import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Code, ArrowLeft, Mail, Lock, Loader2 } from "lucide-react"; -export default function SignIn() { +function SignInPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [isLoading, setIsLoading] = useState(false); @@ -25,159 +31,187 @@ export default function SignIn() { }); if (result?.error) { - setError("Invalid credentials"); + setError("Invalid email or password. Please try again."); } else { - router.push("/"); + router.push("/dashboard"); router.refresh(); } } catch (error: unknown) { console.error("Sign in error:", error); - setError("An error occurred. Please try again."); + setError("An unexpected error occurred. Please try again."); } finally { setIsLoading(false); } }; - const handleOAuthSignIn = async (provider: string) => { - setIsLoading(true); - await signIn(provider, { callbackUrl: "/" }); - }; - return ( -
-
-
-

- Sign in to your account -

-

- Or{" "} - - create a new account - -

-
-
- {error && ( -
-
-
-

{error}

-
+
+ {/* Animated Background */} +
+
+
+
+
+ + {/* Header */} +
+
+
+ +
+
-
- )} -
-
- - setEmail(e.target.value)} - /> -
-
- - setPassword(e.target.value)} - /> -
+ Dev8.dev + +
+
+
-
- + {/* Sign In Form */} +
+
+
+

+ Welcome back +

+

Sign in to access your workspace

-
-
-
-
+ + + Sign In + Enter your credentials to continue + + + {error && ( +
+

{error}

+
+ )} + + +
+ +
+ + setEmail(e.target.value)} + required + disabled={isLoading} + className="pl-10 bg-input border-border focus:border-primary focus:ring-primary" + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + required + disabled={isLoading} + className="pl-10 bg-input border-border focus:border-primary focus:ring-primary" + /> +
+
+ + + + +
+
+
+
+
+ Or continue with +
-
- - Or continue with - + +
+ +
-
- -
- - - -
-
- + Sign up + +

+ + +
); } + +export default dynamic(() => Promise.resolve(SignInPage), { ssr: false }); diff --git a/apps/web/app/(auth)/signup/page.tsx b/apps/web/app/(auth)/signup/page.tsx index 457015d..5649b62 100644 --- a/apps/web/app/(auth)/signup/page.tsx +++ b/apps/web/app/(auth)/signup/page.tsx @@ -4,8 +4,13 @@ import { useState } from "react"; import { signIn } from "next-auth/react"; import { useRouter } from "next/navigation"; import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Code, ArrowLeft, Mail, Lock, User, Loader2, CheckCircle } from "lucide-react"; -export default function SignUp() { +export default function SignUpPage() { const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -27,6 +32,12 @@ export default function SignUp() { return; } + if (password.length < 8) { + setError("Password must be at least 8 characters long"); + setIsLoading(false); + return; + } + try { const response = await fetch("/api/auth/register", { method: "POST", @@ -44,206 +55,235 @@ export default function SignUp() { const data = await response.json(); if (!response.ok) { - setError(data.error || "An error occurred"); + setError(data.error || "An error occurred during registration."); return; } - setSuccess("Account created successfully! You can now sign in."); + setSuccess("Account created successfully! Redirecting to sign in..."); - // Optionally auto-sign in the user setTimeout(() => { router.push("/signin"); }, 2000); } catch (error: unknown) { console.error("Sign up error:", error); - setError("An error occurred. Please try again."); + setError("An unexpected error occurred. Please try again."); } finally { setIsLoading(false); } }; - const handleOAuthSignIn = async (provider: string) => { - setIsLoading(true); - await signIn(provider, { callbackUrl: "/" }); - }; - return ( -
-
-
-

- Create your account -

-

- Or{" "} - - sign in to your existing account +

+ {/* Animated Background */} +
+
+
+
+
+ + {/* Header */} +
+
+
+ +
+ +
+ Dev8.dev -

+ +
-
- {error && ( -
-
-
-

{error}

+
+ + {/* Sign Up Form */} +
+
+
+

+ Create your account +

+

Start coding in the cloud in seconds

+
+ + + + Sign Up + Enter your details to get started + + + {error && ( +
+

{error}

-
-
- )} - {success && ( -
-
-
-

- {success} -

+ )} + + {success && ( +
+
+ +

{success}

+
-
-
- )} -
-
- - setName(e.target.value)} - /> -
-
- - setEmail(e.target.value)} - /> -
-
- - setPassword(e.target.value)} - /> -
-
- - setConfirmPassword(e.target.value)} - /> -
-
+ )} -
- -
+ +
+ +
+ + setName(e.target.value)} + required + disabled={isLoading} + className="pl-10 bg-input border-border focus:border-primary focus:ring-primary" + /> +
+
+ +
+ +
+ + setEmail(e.target.value)} + required + disabled={isLoading} + className="pl-10 bg-input border-border focus:border-primary focus:ring-primary" + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + required + disabled={isLoading} + className="pl-10 bg-input border-border focus:border-primary focus:ring-primary" + /> +
+
+ +
+ +
+ + setConfirmPassword(e.target.value)} + required + disabled={isLoading} + className="pl-10 bg-input border-border focus:border-primary focus:ring-primary" + /> +
+
+ + + -
-
-
-
+
+
+
+
+
+ Or continue with +
-
- - Or continue with - + +
+ +
-
- -
- - - -
-
- + Sign in + +

+ + +
); diff --git a/apps/web/app/ai-agents/page.tsx b/apps/web/app/ai-agents/page.tsx new file mode 100644 index 0000000..f4d1bbd --- /dev/null +++ b/apps/web/app/ai-agents/page.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { Sidebar } from "@/components/sidebar"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Bot, ServerCog, Loader2, CheckCircle2, XCircle, CircleDot } from "lucide-react"; + +interface Agent { + id: string; + name: string; + status: "connected" | "disconnected" | "warning"; +} + +interface McpConfig { + url: string; + apiKey: string; +} + +export default function AiAgentsPage() { + const router = useRouter(); + const { data: session, status } = useSession(); + const [mounted, setMounted] = useState(false); + + const [agents, setAgents] = useState([]); + const [loadingAgents, setLoadingAgents] = useState(true); + const [savingAgentId, setSavingAgentId] = useState(null); + + const [config, setConfig] = useState({ url: "", apiKey: "" }); + const [savingConfig, setSavingConfig] = useState(false); + + const [recent, setRecent] = useState([]); + + useEffect(() => setMounted(true), []); + + useEffect(() => { + if (status === "loading") return; + if (!session) router.push("/signin"); + }, [status, session, router]); + + // Fetch dynamic data + useEffect(() => { + async function fetchData() { + try { + setLoadingAgents(true); + const [a, c] = await Promise.all([ + fetch("/api/ai/agents").then((r) => r.json()), + fetch("/api/ai/mcp-config").then((r) => r.json()), + ]); + setAgents(a.agents ?? []); + setConfig({ url: c.url ?? "", apiKey: c.apiKey ?? "" }); + setRecent(c.recent ?? []); + } catch (e) { + console.error(e); + } finally { + setLoadingAgents(false); + } + } + if (mounted) fetchData(); + }, [mounted]); + + async function toggleAgent(agent: Agent) { + setSavingAgentId(agent.id); + try { + const res = await fetch("/api/ai/agents", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: agent.id, action: agent.status === "connected" ? "disconnect" : "connect" }), + }); + const data = await res.json(); + setAgents(data.agents); + } catch (e) { + console.error(e); + } finally { + setSavingAgentId(null); + } + } + + async function saveConfig() { + setSavingConfig(true); + try { + const res = await fetch("/api/ai/mcp-config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + }); + const data = await res.json(); + setRecent(data.recent ?? []); + } catch (e) { + console.error(e); + } finally { + setSavingConfig(false); + } + } + + function StatusDot({ s }: { s: Agent["status"] }) { + const color = s === "connected" ? "bg-emerald-500" : s === "warning" ? "bg-amber-500" : "bg-rose-500"; + return ; + } + + if (!mounted || status === "loading") { + return ( +
+
+ +
Loading AI Agents...
+
+
+ ); + } + + if (!session) return null; + + return ( +
+
+
+
+
+
+ + + +
+
+ {/* Header area to mirror dashboard top spacing */} +
+

AI Agents

+
+ +
R
+
+
+ +
+ {/* Left: Agents list (2 cols) */} + +
+
+ +

AI Coding Agents

+
+ +
+ {(loadingAgents ? [1,2,3].map(n => ({ id: String(n), name: "", status: "disconnected" as const })) : agents).map((agent, idx) => ( +
+
+
+ +
+
+
{agent.name || "Loading..."}
+
+
+
+ + +
+
+ ))} +
+
+
+ + {/* Right: MCP server config */} + +
+
+ +

MCP Server Configuration

+
+
+ + setConfig({ ...config, url: e.target.value })} /> +
+
+ + setConfig({ ...config, apiKey: e.target.value })} /> +
+
+ +
+
+
+
+ + {/* Recent configs */} + +
+

Recent MCP Server Configurations

+
    + {recent.length === 0 ? ( +
  • No recent configurations.
  • + ) : ( + recent.map((r, i) =>
  • {r}
  • ) + )} +
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/api/_state/workspaces.ts b/apps/web/app/api/_state/workspaces.ts new file mode 100644 index 0000000..8cd9577 --- /dev/null +++ b/apps/web/app/api/_state/workspaces.ts @@ -0,0 +1,78 @@ +// Shared in-memory workspace state for dev/demo. Not for production use. + +export type WorkspaceState = { + id: string; + name: string; + provider: string; // aws | gcp | azure | local + size: string; // small | medium | large + region: string; // us-east | etc + status: "running" | "stopped"; + metrics: { + cpu: number; // percent + memory: { usedGb: number; totalGb: number }; + disk: { usedGb: number; totalGb: number }; + network: { inMb: number; outMb: number }; + }; + terminal: string[]; + snapshots: Array<{ id: string; createdAt: number; location: string }>; + assistant: { tips: string[]; note: string }; + lastUpdate: number; +}; + +const store = new Map(); + +let seed = Date.now() % 100000; +function rnd() { + seed = (seed * 1664525 + 1013904223) % 4294967296; + return seed / 4294967296; +} + +export function jitter(n: number, pct = 0.15, min = 0, max = Number.POSITIVE_INFINITY) { + const j = 1 + (rnd() * 2 - 1) * pct; + const v = Math.max(min, Math.min(max, n * j)); + return Math.round(v * 100) / 100; +} + +function defaultTips(name: string) { + return [ + "You can improve startup time by updating packages.", + "Enable hot-reload caching for faster builds.", + `Run tests in watch mode inside ${name} for quicker feedback.`, + ]; +} + +export function ensureWorkspace(id: string): WorkspaceState { + const key = String(id); + if (store.has(key)) return store.get(key)!; + const sizes = { small: { cpu: 2, ram: 4 }, medium: { cpu: 4, ram: 8 }, large: { cpu: 8, ram: 16 } } as const; + const keys = Object.keys(sizes) as Array; + const pickSize = keys[Math.floor(rnd() * keys.length)] ?? "small"; + const ws: WorkspaceState = { + id: key, + name: `my-nextjs-app-${key}`, + provider: "aws", + size: String(pickSize), + region: "us-east-1", + status: "running", + metrics: { + cpu: Math.round(25 + rnd() * 40), + memory: { usedGb: Math.round(2 + rnd() * 6), totalGb: sizes[pickSize].ram }, + disk: { usedGb: Math.round(20 + rnd() * 40), totalGb: 100 }, + network: { inMb: Math.round(80 + rnd() * 80), outMb: Math.round(120 + rnd() * 120) }, + }, + terminal: [ + `ritesh@cloudidex:~$ npm run dev`, + `> web@ dev`, + `Server ready on http://localhost:3000 🚀`, + ], + snapshots: [], + assistant: { tips: defaultTips(`app-${key}`), note: "Predicted CPU load ~60% in next 10 mins" }, + lastUpdate: Date.now(), + }; + store.set(key, ws); + return ws; +} + +export function getStore() { + return store; +} diff --git a/apps/web/app/api/account/connections/route.ts b/apps/web/app/api/account/connections/route.ts new file mode 100644 index 0000000..d311b24 --- /dev/null +++ b/apps/web/app/api/account/connections/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { createAuthConfig } from "@/lib/auth-config"; + +type Conn = { provider: string; connected: boolean; available: boolean }; + +export async function GET() { + try { + // Determine provider availability from env + const googleAvailable = Boolean(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET); + const githubAvailable = Boolean(process.env.AUTH_GITHUB_ID && process.env.AUTH_GITHUB_SECRET); + + // Derive connections for the signed-in user from the Accounts table (if signed in) + const session = await getServerSession(createAuthConfig()); + + let connected = new Set(); + if (session?.user?.id) { + try { + const accounts = await prisma.account.findMany({ + where: { userId: session.user.id }, + select: { provider: true }, + }); + connected = new Set(accounts.map((a) => a.provider.toLowerCase())); + } catch (e) { + console.error("/api/account/connections prisma error", e); + } + } + + const providers: Conn[] = [ + { provider: "Google", connected: connected.has("google"), available: googleAvailable }, + { provider: "GitHub", connected: connected.has("github"), available: githubAvailable }, + ]; + + return NextResponse.json({ connections: providers, updatedAt: new Date().toISOString() }); + } catch (e) { + console.error("/api/account/connections route error", e); + // Always return JSON to avoid client JSON parsing errors + return NextResponse.json( + { connections: [], error: "failed_to_load_connections" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/account/delete/route.ts b/apps/web/app/api/account/delete/route.ts new file mode 100644 index 0000000..e865cbb --- /dev/null +++ b/apps/web/app/api/account/delete/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; + +export async function POST() { + // TODO: hook into your real user deletion logic + // For now, just simulate success so the button works end-to-end + return NextResponse.json({ ok: true }, { status: 200 }); +} diff --git a/apps/web/app/api/account/password/route.ts b/apps/web/app/api/account/password/route.ts new file mode 100644 index 0000000..d2ec0d7 --- /dev/null +++ b/apps/web/app/api/account/password/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; + +export async function POST(req: Request) { + // This is a placeholder implementation; validate and change password in your auth system here + const { current, next } = await req.json(); + if (!next || next.length < 8) { + return NextResponse.json({ ok: false, error: "Password too short" }, { status: 400 }); + } + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/app/api/ai/agents/route.ts b/apps/web/app/api/ai/agents/route.ts new file mode 100644 index 0000000..c828af0 --- /dev/null +++ b/apps/web/app/api/ai/agents/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; + +type Agent = { id: string; name: string; status: "connected" | "disconnected" | "warning" }; + +// In-memory store for demo; replace with DB/service +let agents: Agent[] = [ + { id: "code-expo-pilot", name: "Code Expo Pilot", status: "connected" }, + { id: "cloud-code-copilot", name: "Cloud Code Copilot", status: "disconnected" }, + { id: "custom-agents", name: "Custom Agents", status: "warning" }, +]; + +export async function GET() { + return NextResponse.json({ agents }); +} + +export async function POST(req: Request) { + const { id, action } = await req.json(); + agents = agents.map((a) => + a.id === id + ? { + ...a, + status: action === "connect" ? "connected" : action === "disconnect" ? "disconnected" : a.status, + } + : a + ); + return NextResponse.json({ ok: true, agents }); +} diff --git a/apps/web/app/api/ai/mcp-config/route.ts b/apps/web/app/api/ai/mcp-config/route.ts new file mode 100644 index 0000000..1be0711 --- /dev/null +++ b/apps/web/app/api/ai/mcp-config/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; + +let config = { url: "", apiKey: "" }; +let recent: string[] = [ + "10 Oct - AWS Workspace - Auto Snapshot", + "09 Oct - GCP Workspace - Manual Backup", + "08 Oct - Azure VM - Auto Snapshot", +]; + +export async function GET() { + return NextResponse.json({ ...config, recent }); +} + +export async function PUT(req: Request) { + const body = await req.json(); + config = { url: body.url ?? "", apiKey: body.apiKey ?? "" }; + if (config.url) { + recent = [ + `${new Date().toLocaleDateString("en-GB", { day: "2-digit", month: "short" })} - ${config.url} - Saved`, + ...recent, + ].slice(0, 8); + } + return NextResponse.json({ ok: true, ...config, recent }); +} diff --git a/apps/web/app/api/billing/invoice/route.ts b/apps/web/app/api/billing/invoice/route.ts new file mode 100644 index 0000000..d86f4e4 --- /dev/null +++ b/apps/web/app/api/billing/invoice/route.ts @@ -0,0 +1,9 @@ +export async function GET() { + const content = `Invoice\nPlan: Pro Developer Plan\nAmount: 4820.00 INR\nDate: ${new Date().toISOString()}\n(This is a placeholder invoice. Replace with PDF generation.)`; + return new Response(content, { + headers: { + "Content-Type": "text/plain", + "Content-Disposition": `attachment; filename=invoice-${new Date().toISOString().slice(0, 7)}.txt`, + }, + }); +} diff --git a/apps/web/app/api/billing/route.ts b/apps/web/app/api/billing/route.ts new file mode 100644 index 0000000..8319c68 --- /dev/null +++ b/apps/web/app/api/billing/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; + +// In-memory dynamic data to simulate real-time updates +let state = { + monthTotal: 4820, + computeCost: 3200, + storageCost: 950, + networkCost: 670, + compute: { instances: 12, vcpuHours: 390, gpuHours: 24, region: "us-east-1" }, + storage: { totalGb: 120, snapshots: 16, avgOpsPerDay: 10000 }, + network: { dataOutGb: 420, bandwidthMb: 310, regionsActive: 3 }, +}; + +function jitter(n: number, delta: number) { + const d = (Math.random() - 0.5) * 2 * delta; + return Math.max(0, Math.round((n + d) * 100) / 100); +} + +export async function GET() { + // Nudge values a bit to simulate changes + state.computeCost = jitter(state.computeCost, 5); + state.storageCost = jitter(state.storageCost, 2); + state.networkCost = jitter(state.networkCost, 2); + state.monthTotal = Math.round((state.computeCost + state.storageCost + state.networkCost) * 100) / 100; + + state.compute.vcpuHours = Math.round(state.compute.vcpuHours + Math.random() * 3); + state.storage.avgOpsPerDay = Math.round(state.storage.avgOpsPerDay + (Math.random() - 0.5) * 100); + state.network.bandwidthMb = Math.round(state.network.bandwidthMb + (Math.random() - 0.5) * 5); + + const today = new Date(); + const start = new Date(today.getFullYear(), today.getMonth(), 1); + const end = new Date(today.getFullYear(), today.getMonth() + 1, 0); + + return NextResponse.json({ + monthTotal: state.monthTotal, + computeCost: state.computeCost, + storageCost: state.storageCost, + networkCost: state.networkCost, + cycle: { + start: start.toLocaleDateString("en-US", { month: "short", day: "numeric" }), + end: end.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }), + }, + computeUsage: state.compute, + storageUsage: state.storage, + networkUsage: state.network, + details: { + plan: "Pro Developer Plan", + accountEmail: "ritesh@cloudidex.com", + payment: "Visa **** 4872", + nextInvoice: new Date(today.getFullYear(), today.getMonth() + 1, 1).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }), + }, + updatedAt: new Date().toISOString(), + }); +} diff --git a/apps/web/app/api/reporting/route.ts b/apps/web/app/api/reporting/route.ts new file mode 100644 index 0000000..81dc26a --- /dev/null +++ b/apps/web/app/api/reporting/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; + +type Range = "last_24h" | "last_7d" | "last_30d" | "this_month"; + +let seed = Date.now() % 1000; +function rnd() { + // simple deterministic PRNG for jitter + seed = (seed * 9301 + 49297) % 233280; + return seed / 233280; +} + +function jitter(n: number, pct = 0.1) { + const j = 1 + (rnd() * 2 - 1) * pct; + return Math.max(0, Math.round(n * j)); +} + +function makeTimeseries(points: number, base: number, volatility = 0.15) { + const out: Array<{ t: number; v: number }> = []; + let v = base; + for (let i = points - 1; i >= 0; i--) { + v = Math.max(0, v + (rnd() * 2 - 1) * base * volatility); + out.push({ t: Date.now() - i * 60 * 60 * 1000, v: Math.round(v) }); + } + return out; +} + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const range = (searchParams.get("range") as Range) || "last_7d"; + + const points = range === "last_24h" ? 24 : range === "last_7d" ? 7 * 24 : 30 * 24; + + const activeUsers = jitter(1280, 0.12); + const builds = jitter(420, 0.2); + const errors = jitter(18, 0.4); + const cpu = Math.min(100, Math.max(3, Math.round(30 + rnd() * 50))); + const memory = Math.min(100, Math.max(8, Math.round(40 + rnd() * 40))); + const network = jitter(320, 0.25); // Mbps + + const topProjects = [ + { name: "ai-search-service", usage: jitter(34, 0.3) }, + { name: "web-frontend", usage: jitter(28, 0.3) }, + { name: "worker-queue", usage: jitter(22, 0.3) }, + { name: "analytics-pipeline", usage: jitter(16, 0.3) }, + ]; + + const timeseries = { + cpu: makeTimeseries(points, 55, 0.25), + mem: makeTimeseries(points, 60, 0.2), + net: makeTimeseries(points, 300, 0.35), + builds: makeTimeseries(points, 18, 0.5), + errors: makeTimeseries(points, 1.2, 0.8), + }; + + return NextResponse.json({ + range, + summary: { activeUsers, builds, errors, cpu, memory, network }, + topProjects, + timeseries, + updatedAt: Date.now(), + }); +} diff --git a/apps/web/app/api/templates/route.ts b/apps/web/app/api/templates/route.ts new file mode 100644 index 0000000..4a22ca2 --- /dev/null +++ b/apps/web/app/api/templates/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; + +export async function POST(req: Request) { + try { + const body = await req.json(); + // TODO: persist to database/service + return NextResponse.json({ ok: true, template: body }, { status: 201 }); + } catch (err) { + return NextResponse.json({ ok: false, error: "Invalid request" }, { status: 400 }); + } +} diff --git a/apps/web/app/api/workspaces/[id]/action/route.ts b/apps/web/app/api/workspaces/[id]/action/route.ts new file mode 100644 index 0000000..8a088f2 --- /dev/null +++ b/apps/web/app/api/workspaces/[id]/action/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { ensureWorkspace } from "@/app/api/_state/workspaces"; + +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const ws = ensureWorkspace(id); + const { action } = await req.json(); + if (action === "restart") { + ws.status = "running"; + ws.terminal.push("Restarting services...", "Server ready on http://localhost:3000 🚀"); + } else if (action === "stop") { + ws.status = "stopped"; + ws.terminal.push("Shutting down..."); + } else if (action === "start") { + ws.status = "running"; + ws.terminal.push("Starting workspace..."); + } + ws.terminal = ws.terminal.slice(-120); + return NextResponse.json({ ok: true, status: ws.status }); +} diff --git a/apps/web/app/api/workspaces/[id]/details/route.ts b/apps/web/app/api/workspaces/[id]/details/route.ts new file mode 100644 index 0000000..d4d1e0b --- /dev/null +++ b/apps/web/app/api/workspaces/[id]/details/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { ensureWorkspace } from "@/app/api/_state/workspaces"; + +export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const ws = ensureWorkspace(id); + return NextResponse.json({ + id: ws.id, + name: ws.name, + provider: ws.provider, + size: ws.size, + region: ws.region, + status: ws.status, + assistant: ws.assistant, + updatedAt: Date.now(), + }); +} diff --git a/apps/web/app/api/workspaces/[id]/metrics/route.ts b/apps/web/app/api/workspaces/[id]/metrics/route.ts new file mode 100644 index 0000000..0058b63 --- /dev/null +++ b/apps/web/app/api/workspaces/[id]/metrics/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { ensureWorkspace, jitter } from "@/app/api/_state/workspaces"; + +export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const ws = ensureWorkspace(id); + // update with gentle jitter + ws.metrics.cpu = Math.max(1, Math.min(100, Math.round(jitter(ws.metrics.cpu, 0.2)))); + ws.metrics.memory.usedGb = Math.max(1, Math.min(ws.metrics.memory.totalGb, Math.round(jitter(ws.metrics.memory.usedGb, 0.15)))); + ws.metrics.disk.usedGb = Math.max(5, Math.min(ws.metrics.disk.totalGb, Math.round(jitter(ws.metrics.disk.usedGb, 0.1)))); + ws.metrics.network.inMb = Math.max(10, Math.round(jitter(ws.metrics.network.inMb, 0.3))); + ws.metrics.network.outMb = Math.max(10, Math.round(jitter(ws.metrics.network.outMb, 0.3))); + ws.lastUpdate = Date.now(); + + return NextResponse.json({ ...ws.metrics, updatedAt: ws.lastUpdate }); +} diff --git a/apps/web/app/api/workspaces/[id]/snapshots/route.ts b/apps/web/app/api/workspaces/[id]/snapshots/route.ts new file mode 100644 index 0000000..c86e9da --- /dev/null +++ b/apps/web/app/api/workspaces/[id]/snapshots/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { ensureWorkspace } from "@/app/api/_state/workspaces"; + +export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const ws = ensureWorkspace(id); + return NextResponse.json({ snapshots: ws.snapshots }); +} + +export async function POST(_: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const ws = ensureWorkspace(id); + const snapId = `${Date.now()}`; + ws.snapshots.unshift({ id: snapId, createdAt: Date.now(), location: `s3://cloudidex/backups/${ws.id}/${snapId}` }); + // keep only last 8 + ws.snapshots = ws.snapshots.slice(0, 8); + return NextResponse.json({ ok: true, snapshots: ws.snapshots }); +} diff --git a/apps/web/app/api/workspaces/[id]/terminal/route.ts b/apps/web/app/api/workspaces/[id]/terminal/route.ts new file mode 100644 index 0000000..2726f4a --- /dev/null +++ b/apps/web/app/api/workspaces/[id]/terminal/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { ensureWorkspace } from "@/app/api/_state/workspaces"; + +const sampleLines = [ + "Compiling...", + "Bundling client...", + "Bundling server...", + "Server ready on http://localhost:3000 🚀", + "GET / 200 38ms", + "GET /api/health 200 12ms", + "Hot reload applied", +]; + +export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const ws = ensureWorkspace(id); + // append 0-2 random lines + const count = Math.floor(Math.random() * 3); + for (let i = 0; i < count; i++) { + const idx = Math.floor(Math.random() * sampleLines.length); + const line = sampleLines[idx] ?? ""; + ws.terminal.push(line); + } + // keep last 120 lines + if (ws.terminal.length > 120) ws.terminal = ws.terminal.slice(-120); + return NextResponse.json({ lines: ws.terminal, updatedAt: Date.now() }); +} diff --git a/apps/web/app/api/workspaces/estimate/route.ts b/apps/web/app/api/workspaces/estimate/route.ts new file mode 100644 index 0000000..4e3c71e --- /dev/null +++ b/apps/web/app/api/workspaces/estimate/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; + +type Body = { + provider: string; + size: "small" | "medium" | "large"; + region?: string; + hoursPerDay?: number; // optional usage for estimate +}; + +const BASE_PRICING = { + aws: { small: 0.07, medium: 0.16, large: 0.36 }, + gcp: { small: 0.065, medium: 0.15, large: 0.34 }, + azure: { small: 0.075, medium: 0.17, large: 0.38 }, + local: { small: 0.02, medium: 0.05, large: 0.1 }, +} as const; // USD per hour + +export async function POST(req: Request) { + const body = (await req.json()) as Body; + const provider = (body.provider || "aws") as keyof typeof BASE_PRICING; + const size = (body.size || "small") as keyof (typeof BASE_PRICING)["aws"]; + const hrs = typeof body.hoursPerDay === "number" ? Math.max(0, Math.min(24, body.hoursPerDay)) : 8; + + const hourly = BASE_PRICING[provider][size]; + const daily = hourly * hrs; + const monthly = daily * 30; + const currency = "USD"; + + return NextResponse.json({ + provider, + size, + hoursPerDay: hrs, + cost: { hourly, daily, monthly, currency }, + updatedAt: Date.now(), + }); +} diff --git a/apps/web/app/api/workspaces/options/route.ts b/apps/web/app/api/workspaces/options/route.ts new file mode 100644 index 0000000..ad6d3f8 --- /dev/null +++ b/apps/web/app/api/workspaces/options/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; + +const OPTIONS = { + providers: [ + { id: "aws", name: "AWS" }, + { id: "gcp", name: "GCP" }, + { id: "azure", name: "Azure" }, + { id: "local", name: "Local" }, + ], + images: [ + { id: "ubuntu-22", label: "Ubuntu 22.04" }, + { id: "ubuntu-20", label: "Ubuntu 20.04" }, + { id: "debian-12", label: "Debian 12" }, + { id: "docker", label: "Dockerfile" }, + ], + sizes: [ + { id: "small", cpu: 2, ramGb: 4 }, + { id: "medium", cpu: 4, ramGb: 8 }, + { id: "large", cpu: 8, ramGb: 16 }, + ], + regions: [ + { id: "us-east", label: "US East" }, + { id: "us-west", label: "US West" }, + { id: "eu-west", label: "EU West" }, + { id: "ap-south", label: "AP South" }, + ], +}; + +export async function GET() { + return NextResponse.json(OPTIONS); +} diff --git a/apps/web/app/api/workspaces/route.ts b/apps/web/app/api/workspaces/route.ts new file mode 100644 index 0000000..d3e9d29 --- /dev/null +++ b/apps/web/app/api/workspaces/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; + +type WS = { id: number; name: string; status: "running" | "stopped" }; + +let workspaces: WS[] = [ + { id: 1, name: "Workspace #1 - AWS EC2 XL", status: "running" }, + { id: 2, name: "Workspace #2 - GCP VM", status: "stopped" }, + { id: 3, name: "Workspace #3 - Azure VM", status: "running" }, +]; + +export async function GET() { + // add tiny random toggles to look alive + if (Math.random() < 0.1) { + const i = Math.floor(Math.random() * workspaces.length); + const w = workspaces[i]; + if (w) workspaces[i] = { ...w, status: w.status === "running" ? "stopped" : "running" }; + } + return NextResponse.json({ workspaces, updatedAt: new Date().toISOString() }); +} + +export async function POST(req: Request) { + const body = await req.json(); + const { id, action } = body; + if (action === "toggle") { + workspaces = workspaces.map((w) => (w.id === Number(id) ? { ...w, status: w.status === "running" ? "stopped" : "running" } : w)); + } else if (action === "clone") { + const src = workspaces.find((w) => w.id === Number(id)); + if (src) { + const maxId = workspaces.reduce((m, w) => Math.max(m, w.id), 0); + workspaces.push({ id: maxId + 1, name: src.name + " (Clone)", status: "stopped" }); + } + } else if (action === "delete") { + workspaces = workspaces.filter((w) => w.id !== Number(id)); + } else if (action === "create") { + // Create a new workspace from provided payload + const maxId = workspaces.reduce((m, w) => Math.max(m, w.id), 0); + const name: string = body?.name || `Workspace #${maxId + 1}`; + const provider: string = body?.provider ?? "multi"; + const image: string = body?.image ?? "docker"; + const size: string = body?.size ?? "small"; + const created: WS = { id: maxId + 1, name: `${name} - ${provider.toUpperCase()} (${image}/${size})`, status: "running" }; + workspaces = [...workspaces, created]; + } + return NextResponse.json({ ok: true, workspaces }); +} diff --git a/apps/web/app/billing-usage/page.tsx b/apps/web/app/billing-usage/page.tsx new file mode 100644 index 0000000..8b2a556 --- /dev/null +++ b/apps/web/app/billing-usage/page.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { Sidebar } from "@/components/sidebar"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Loader2, CreditCard, Database, Cpu, Globe2 } from "lucide-react"; + +interface BillingData { + monthTotal: number; + computeCost: number; + storageCost: number; + networkCost: number; + cycle: { start: string; end: string }; + computeUsage: { instances: number; vcpuHours: number; gpuHours: number; region: string }; + storageUsage: { totalGb: number; snapshots: number; avgOpsPerDay: number }; + networkUsage: { dataOutGb: number; bandwidthMb: number; regionsActive: number }; + details: { plan: string; accountEmail: string; payment: string; nextInvoice: string }; + updatedAt: string; +} + +const inr = new Intl.NumberFormat("en-IN", { + style: "currency", + currency: "INR", + maximumFractionDigits: 2, +}); + +export default function BillingUsagePage() { + const router = useRouter(); + const { data: session, status } = useSession(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (status === "loading") return; + if (!session) router.push("/signin"); + }, [status, session, router]); + + useEffect(() => { + let timer: ReturnType | undefined; + async function load() { + try { + const res = await fetch("/api/billing", { cache: "no-store" }); + const j = (await res.json()) as BillingData; + setData(j); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + } + if (status === "authenticated") { + load(); + timer = setInterval(load, 10000); // realtime-ish polling + } + return () => { + if (timer) clearInterval(timer); + }; + }, [status]); + + if (status === "loading" || loading) { + return ( +
+
+ Loading billing data... +
+
+ ); + } + + if (!session || !data) return null; + + async function downloadInvoice() { + try { + const res = await fetch("/api/billing/invoice", { method: "GET" }); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `invoice-${new Date().toISOString().slice(0, 7)}.txt`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (e) { + console.error(e); + alert("Could not download invoice."); + } + } + + return ( +
+
+
+
+
+
+ + + +
+
+ {/* Header */} +
+
Cloud-IDEX → Billing / Usage Dashboard
+
+ +
R
+
+
+ + {/* Monthly Billing Summary & Trend */} +
+ +
+
+ Monthly Billing Summary +
+
+ Total Cost (This Month): {inr.format(data.monthTotal)} +
+
+ • Compute: {inr.format(data.computeCost)} • Storage: {inr.format(data.storageCost)} • Network: {inr.format(data.networkCost)} +
+
Billing Cycle: {data.cycle.start} – {data.cycle.end}
+
+
+ +
+
Cost Trend (Last 6 Months)
+
Bar / Line Chart Placeholder
+
+
+
+ + {/* Usage Breakdown by Resource */} +
+ 📊 + Usage Breakdown by Resource +
+
+ +
+
Compute Usage
+
Instances: {data.computeUsage.instances}
+
Total vCPU Hours: {data.computeUsage.vcpuHours} hrs
+
GPU Usage: {data.computeUsage.gpuHours} hrs
+
Region: {data.computeUsage.region}
+
+
+ + +
+
Storage Usage
+
Total S3 Storage: {data.storageUsage.totalGb} GB
+
Snapshots: {data.storageUsage.snapshots}
+
Average Read/Write Ops: {data.storageUsage.avgOpsPerDay.toLocaleString()} / day
+
+
+ + +
+
Network Usage
+
Data Transfer Out: {data.networkUsage.dataOutGb} GB
+
Bandwidth Avg: {data.networkUsage.bandwidthMb} MB/s
+
Regions Active: {data.networkUsage.regionsActive}
+
+
+
+ + {/* Monthly Cost Distribution + Billing Details */} +
+ +
+
Monthly Cost Distribution
+
+ Stacked Bar Chart (Compute / Storage / Network) +
+
+
+ + +
+
Billing Details
+
Plan: {data.details.plan}
+
Billing Account: {data.details.accountEmail}
+
Payment Method: {data.details.payment}
+
Next Invoice: {data.details.nextInvoice}
+
+ +
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/components/theme-provider.tsx b/apps/web/app/components/theme-provider.tsx new file mode 100644 index 0000000..1eebc9d --- /dev/null +++ b/apps/web/app/components/theme-provider.tsx @@ -0,0 +1,10 @@ +'use client' + +import * as React from 'react' +import { ThemeProvider as NextThemesProvider } from 'next-themes' + +type Props = React.ComponentProps + +export function ThemeProvider({ children, ...props }: Props) { + return {children} +} diff --git a/apps/web/app/components/ui/accordion.tsx b/apps/web/app/components/ui/accordion.tsx new file mode 100644 index 0000000..e538a33 --- /dev/null +++ b/apps/web/app/components/ui/accordion.tsx @@ -0,0 +1,66 @@ +'use client' + +import * as React from 'react' +import * as AccordionPrimitive from '@radix-ui/react-accordion' +import { ChevronDownIcon } from 'lucide-react' + +import { cn } from '@/lib/utils' + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180', + className, + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/apps/web/app/components/ui/alert-dialog.tsx b/apps/web/app/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..9704452 --- /dev/null +++ b/apps/web/app/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +'use client' + +import * as React from 'react' +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' + +import { cn } from '@/lib/utils' +import { buttonVariants } from '@/components/ui/button' + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/apps/web/app/components/ui/alert.tsx b/apps/web/app/components/ui/alert.tsx new file mode 100644 index 0000000..e6751ab --- /dev/null +++ b/apps/web/app/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', + { + variants: { + variant: { + default: 'bg-card text-card-foreground', + destructive: + 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/apps/web/app/components/ui/aspect-ratio.tsx b/apps/web/app/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..40bb120 --- /dev/null +++ b/apps/web/app/components/ui/aspect-ratio.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio' + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return +} + +export { AspectRatio } diff --git a/apps/web/app/components/ui/avatar.tsx b/apps/web/app/components/ui/avatar.tsx new file mode 100644 index 0000000..aa98465 --- /dev/null +++ b/apps/web/app/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +'use client' + +import * as React from 'react' +import * as AvatarPrimitive from '@radix-ui/react-avatar' + +import { cn } from '@/lib/utils' + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/apps/web/app/components/ui/badge.tsx b/apps/web/app/components/ui/badge.tsx new file mode 100644 index 0000000..fc4126b --- /dev/null +++ b/apps/web/app/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const badgeVariants = cva( + 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90', + secondary: + 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', + destructive: + 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<'span'> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : 'span' + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/apps/web/app/components/ui/breadcrumb.tsx b/apps/web/app/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..1750ff2 --- /dev/null +++ b/apps/web/app/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { ChevronRight, MoreHorizontal } from 'lucide-react' + +import { cn } from '@/lib/utils' + +function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) { + return