diff --git a/Dockerfile b/Dockerfile index 9056dcd..b4258bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,19 @@ -# Multi-stage, one image, both planes -- which one runs is picked at container -# start via APP (core|cloud), not at build time. This keeps a single build -# cacheable across both instead of rebuilding the whole workspace twice. + FROM node:20-alpine AS deps WORKDIR /w COPY package.json package-lock.json ./ COPY packages/shared/package.json packages/shared/package.json COPY apps/core/package.json apps/core/package.json COPY apps/cloud/package.json apps/cloud/package.json +COPY apps/community/package.json apps/community/package.json +COPY apps/community-provider/package.json apps/community-provider/package.json +COPY apps/community-admin/package.json apps/community-admin/package.json +COPY apps/marketing/package.json apps/marketing/package.json RUN npm install FROM deps AS runtime WORKDIR /w COPY . . COPY --from=deps /w/node_modules /w/node_modules -EXPOSE 8080 8083 -CMD sh -c 'npm run build -w @xcloudify/${APP:?set APP=core|cloud} && npm run preview -w @xcloudify/${APP} -- --host 0.0.0.0 --port ${PORT:-8080}' +EXPOSE 8080 8081 8082 8083 8084 8086 +CMD sh -c 'npm run build -w @xcloudify/${APP:?set APP=core|cloud|community|community-provider|community-admin|marketing} && npm run preview -w @xcloudify/${APP} -- --host 0.0.0.0 --port ${PORT:-8080}' diff --git a/apps/marketing/.env.example b/apps/marketing/.env.example new file mode 100644 index 0000000..431825b --- /dev/null +++ b/apps/marketing/.env.example @@ -0,0 +1,9 @@ +CLOUD_URL=http://localhost:8080 +COMMUNITY_URL=http://localhost:8081 +CORE_URL=http://localhost:8083 +COMMUNITY_API_BASE_URL=http://localhost:5002/api +# true|false to force the floating dev portal switcher on/off; unset follows +# Vite's DEV flag (on for `vite dev`, off for a production build/preview). +SHOW_DEV_NAV= + +ALLOWED_HOST=localhost diff --git a/apps/marketing/docker-compose.yml b/apps/marketing/docker-compose.yml new file mode 100644 index 0000000..53cf92b --- /dev/null +++ b/apps/marketing/docker-compose.yml @@ -0,0 +1,20 @@ + +services: + marketing: + build: + context: ../.. + dockerfile: Dockerfile + container_name: xcloudify-marketing + environment: + APP: marketing + PORT: "8082" + ALLOWED_HOST: ${ALLOWED_HOST:-localhost} + VITE_CLOUD_URL: ${CLOUD_URL:-} + VITE_COMMUNITY_URL: ${COMMUNITY_URL:-} + VITE_CORE_URL: ${CORE_URL:-} + VITE_COMMUNITY_API_BASE_URL: ${COMMUNITY_API_BASE_URL:-http://localhost:5002/api} + # Shows the floating dev portal switcher regardless of build mode -- + # unset falls back to Vite's DEV flag (see DevPortalSwitcher.tsx). + VITE_SHOW_DEV_NAV: ${SHOW_DEV_NAV:-} + ports: + - "8082:8082" diff --git a/apps/marketing/index.html b/apps/marketing/index.html new file mode 100644 index 0000000..64bcaa7 --- /dev/null +++ b/apps/marketing/index.html @@ -0,0 +1,13 @@ + + + + + + xcloudify + + + +
+ + + diff --git a/apps/marketing/package.json b/apps/marketing/package.json new file mode 100644 index 0000000..2c600b0 --- /dev/null +++ b/apps/marketing/package.json @@ -0,0 +1,12 @@ +{ + "name": "@xcloudify/marketing", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { "@xcloudify/shared": "*" } +} diff --git a/apps/marketing/postcss.config.js b/apps/marketing/postcss.config.js new file mode 100644 index 0000000..99417eb --- /dev/null +++ b/apps/marketing/postcss.config.js @@ -0,0 +1 @@ +export default { plugins: { tailwindcss: {}, autoprefixer: {} } }; diff --git a/apps/marketing/src/App.tsx b/apps/marketing/src/App.tsx new file mode 100644 index 0000000..f81f108 --- /dev/null +++ b/apps/marketing/src/App.tsx @@ -0,0 +1,29 @@ +/** + * Public marketing site. No auth, no session, no sidebar -- it is a separate + * deployable from the portals precisely so it can be served publicly without + * carrying any authenticated surface. + * + * The one live thing it does is the latency demo, which calls the community + * plane's anonymous endpoints directly (nodes/available + ping-session) and + * then measures browser-to-worker RTT over a real WebSocket. That is real + * infrastructure, not a mock. + */ +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { HomePage } from "@/components/HomePage"; +import { DevPortalSwitcher } from "./DevPortalSwitcher"; +import NotFound from "@/pages/NotFound"; + +export default function App() { + return ( + + + + + } /> + } /> + + + + ); +} diff --git a/apps/marketing/src/DevPortalSwitcher.tsx b/apps/marketing/src/DevPortalSwitcher.tsx new file mode 100644 index 0000000..773c4d9 --- /dev/null +++ b/apps/marketing/src/DevPortalSwitcher.tsx @@ -0,0 +1,75 @@ +/** + * Dev-only quick nav. The public page has no portal-chooser section -- + * every "sign in" CTA on HomePage links straight to cloud (see goToCloud + * there), and core has no public login at all. During local/staging work + * this is the only on-page way to reach core or community without typing + * the URL by hand. + * + * Visibility is VITE_SHOW_DEV_NAV (set via SHOW_DEV_NAV in .env / compose), + * not just Vite's built-in DEV flag -- the Docker path always runs `vite + * build` + `vite preview`, a production build, where DEV is false even on a + * box you're using for local testing. Leave the env var unset and it still + * falls back to DEV, so a plain `vite dev` shows it with zero config; set it + * explicitly to force the button on (or off) regardless of build mode. + */ +import { useState } from "react"; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Boxes, Cloud as CloudIcon, Users, Wrench } from "lucide-react"; + +type DevTarget = { label: string; href?: string; icon: React.ComponentType }; + +const DEV_TARGETS: DevTarget[] = [ + { label: "Core — operator console", href: import.meta.env.VITE_CORE_URL, icon: Boxes }, + { label: "Cloud portal", href: import.meta.env.VITE_CLOUD_URL, icon: CloudIcon }, + { label: "Community portal", href: import.meta.env.VITE_COMMUNITY_URL, icon: Users }, +]; + +const explicitShowDevNav = import.meta.env.VITE_SHOW_DEV_NAV; +const SHOW_DEV_NAV = + explicitShowDevNav != null ? explicitShowDevNav === "true" : import.meta.env.DEV; + +export function DevPortalSwitcher() { + const [open, setOpen] = useState(false); + + if (!SHOW_DEV_NAV) return null; + + return ( +
+ + + + {!open && ( + + )} + + + + Dev + + + + Jump to a running portal + + + {DEV_TARGETS.map(({ label, href, icon: Icon }) => ( + href && (window.location.href = href)} + className="gap-2" + > + + {label} + {!href && not set} + + ))} + + +
+ ); +} diff --git a/apps/marketing/src/main.tsx b/apps/marketing/src/main.tsx new file mode 100644 index 0000000..b569bfb --- /dev/null +++ b/apps/marketing/src/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "@/index.css"; + +createRoot(document.getElementById("root")!).render(); diff --git a/apps/marketing/tailwind.config.ts b/apps/marketing/tailwind.config.ts new file mode 100644 index 0000000..db5435b --- /dev/null +++ b/apps/marketing/tailwind.config.ts @@ -0,0 +1,5 @@ +// Re-exports the shared theme so all four apps look identical. Content globs +// are relative to this file, so each app scans its own src plus the shared +// component library. +import shared from "../../packages/shared/tailwind.config"; +export default shared; diff --git a/apps/marketing/tsconfig.json b/apps/marketing/tsconfig.json new file mode 100644 index 0000000..60472a7 --- /dev/null +++ b/apps/marketing/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", + "skipLibCheck": true, "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "isolatedModules": true, + "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", + "strict": false, "noUnusedLocals": false, "noUnusedParameters": false, + "noImplicitAny": false, "strictNullChecks": false, "allowJs": true, + "baseUrl": ".", "paths": { "@/*": ["../../packages/shared/src/*"] } + }, + "include": ["src", "../../packages/shared/src"] +} diff --git a/apps/marketing/vite.config.ts b/apps/marketing/vite.config.ts new file mode 100644 index 0000000..cb6410a --- /dev/null +++ b/apps/marketing/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import path from "path"; + +// Every app resolves "@/" to the SHARED package, not its own src. That is +// what lets ~110 shared components keep their existing imports untouched +// while each app stays a thin entry point that only picks which portals to +// mount. App-local files import relatively. +const PORT = parseInt(process.env.PORT ?? "8082", 10); + +export default defineConfig({ + server: { host: "::", port: PORT, allowedHosts: (process.env.ALLOWED_HOST ?? "localhost").split(",") }, + preview: { host: "::", port: PORT, allowedHosts: (process.env.ALLOWED_HOST ?? "localhost").split(",") }, + plugins: [react()], + resolve: { alias: { "@": path.resolve(__dirname, "../../packages/shared/src") } }, +}); diff --git a/packages/shared/src/components/HomePage.tsx b/packages/shared/src/components/HomePage.tsx new file mode 100644 index 0000000..8c84adf --- /dev/null +++ b/packages/shared/src/components/HomePage.tsx @@ -0,0 +1,484 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useNavigate } from "react-router-dom"; +import { + Shield, Zap, Globe, Users, ArrowRight, Check, Boxes, + Cloud as CloudIcon, Server, Network, Database, ChevronDown, +} from "lucide-react"; +import { CoreArchitectureDiagram } from "@/components/marketing/CoreArchitectureDiagram"; +import { LivePingDemo } from "@/components/marketing/LivePingDemo"; + +type PlatformTab = "core" | "cloud" | "community"; + +const PLATFORM_MODES: { value: PlatformTab; icon: typeof Boxes; label: string }[] = [ + { value: "core", icon: Boxes, label: "Core" }, + { value: "cloud", icon: CloudIcon, label: "Cloud" }, + { value: "community", icon: Users, label: "Community" }, +]; + +const HERO_STACK_ROWS = [ + { icon: Server, label: "Compute" }, + { icon: Network, label: "Network" }, + { icon: Database, label: "Storage" }, + { icon: Boxes, label: "Nodes" }, +]; + +const HERO_MODE_BARS = [ + { label: "Core", pct: 70 }, + { label: "Cloud", pct: 45 }, + { label: "Community", pct: 85 }, +]; + +const CORE_POINTS = [ + "One primitive for compute, network, and storage", + "Runs underneath any orchestrator you already use", + "Fewer nodes for the same headroom", +]; + +const CLOUD_FEATURES = [ + { icon: Shield, title: "OIDC login", desc: "Single sign-on, no shared credentials to leak." }, + { icon: Globe, title: "Projects & VDCs", desc: "Organize resources the way your org is structured." }, + { icon: Users, title: "Role-based access", desc: "Invite teammates as readers or writers, per VDC." }, +]; + +const COMMUNITY_POINTS = [ + "Real machines, not a mockup", + "Latency measured directly browser → worker", + "No signup required to try it", +]; + +const CAPABILITIES = [ + { + icon: Shield, + title: "Enterprise Security", + description: "Military-grade security with end-to-end encryption and compliance" + }, + { + icon: Zap, + title: "Lightning Fast", + description: "Deploy and scale workloads instantly across global infrastructure" + }, + { + icon: Globe, + title: "Global Reach", + description: "Multi-region deployment with edge computing capabilities" + }, + { + icon: Users, + title: "Team Collaboration", + description: "Built for teams with role-based access and real-time collaboration" + } +]; + +const BENEFITS = [ + "99.99% uptime SLA", + "Auto-scaling infrastructure", + "24/7 expert support", + "Enterprise-grade security", + "Global CDN network", + "One-click deployments" +]; + +function SectionHeading({ title, description }: { title: string; description: string }) { + return ( +
+

{title}

+

{description}

+
+ ); +} + +export const HomePage = () => { + const navigate = useNavigate(); + // The marketing site hosts no authenticated surface; every "sign in" CTA + // is a real link out to wherever the cloud portal is actually deployed -- + // that's the plane customers sign up on day to day (see the Cloud tab + // below). A no-op with no configured URL is fine here: on any real + // deploy this is set, and locally the dev switcher covers the gap. + const cloudUrl = import.meta.env.VITE_CLOUD_URL as string | undefined; + const goToCloud = () => { + if (cloudUrl) window.location.href = cloudUrl; + }; + const [platformTab, setPlatformTab] = useState("core"); + + const goToPlatform = (tab: PlatformTab) => { + setPlatformTab(tab); + document.getElementById("platform")?.scrollIntoView({ behavior: "smooth", block: "start" }); + }; + + return ( +
+ {/* Background texture */} +
+
+ {/* Navigation */} + + + {/* Hero Section */} +
+
+
+ + Core · Cloud · Community + + +

+ One infrastructure platform. + Run it your way. +

+ +

+ The same compute, network, and storage primitives — self-hosted as + core, consumed managed as cloud, or leased from community hardware. + Pick the model that fits, switch anytime. +

+ +
+ + +
+ +
+ {PLATFORM_MODES.map((mode) => ( + + ))} +
+
+ +
+
+
+ +
+ + + + console.xcloudify +
+ +
+ {HERO_STACK_ROWS.map((row) => ( +
+ + {row.label} +
+ ))} +
+
+
+ {HERO_MODE_BARS.map((m) => ( +
+
{m.label}
+
+
+
+
+ ))} +
+
+ {[0, 1, 2].map((i) => ( +
+ +
+
+
+ ))} +
+
+ + +
+
+
+
+ + {/* Platform: three modalities */} +
+
+ + + setPlatformTab(v as PlatformTab)}> + + {PLATFORM_MODES.map((mode) => ( + + {mode.label} + + ))} + + + +
+
+

Sits under your own cloud stack

+

+ Core is identity-free infrastructure — compute, network, and + storage as one set of primitives underneath whatever orchestrator + you already run. +

+
    + {CORE_POINTS.map((point) => ( +
  • + + {point} +
  • + ))} +
+
+
+ + + + + +
+
+
+ + +
+
+

The managed control plane for teams

+

+ Cloud is what most customers use day to day: sign in, get a + project provisioned automatically, organize work into virtual + data centers, and invite teammates with read or write roles + scoped per VDC. +

+ +
+
+ {CLOUD_FEATURES.map((f) => ( +
+
+ +
+
+

{f.title}

+

{f.desc}

+
+
+ ))} +
+
+
+ + +
+
+

Leased hardware, real machines

+

+ Community is spare capacity from third-party operators: register + your own machine to earn from it, or lease someone else's after + checking the latency yourself. +

+
    + {COMMUNITY_POINTS.map((point) => ( +
  • + + {point} +
  • + ))} +
+
+
+ +
+
+
+
+
+
+ + {/* Capabilities Grid */} +
+ + +
+ {CAPABILITIES.map((feature) => ( + + +
+ +
+

{feature.title}

+

+ {feature.description} +

+
+
+ ))} +
+
+ + {/* Benefits Section */} +
+
+
+
+

+ Built for Modern Teams +

+

+ From startups to Fortune 500 companies, XCloudify provides the tools + and infrastructure you need to succeed in the cloud-first world. +

+ +
+ {BENEFITS.map((benefit) => ( +
+
+ +
+ {benefit} +
+ ))} +
+ + +
+ +
+ + +
+
99.99%
+
Uptime SLA
+
+
+
< 100ms
+
Global Latency
+
+
+
24/7
+
Expert Support
+
+
+
+
+
+
+
+ + {/* CTA Section */} +
+
+
+

+ Ready to Get Started? +

+

+ Join thousands of developers already building on XCloudify +

+ +
+
+
+ + {/* Footer */} +
+
+
+
+
XCloudify
+

+ One infrastructure platform — run it yourself as core, consume it + managed as cloud, or lease community hardware. +

+
+
+
Platform
+ +
+
+
Get started
+
    +
  • + +
  • +
  • Capabilities
  • +
+
+
+
+ © {new Date().getFullYear()} XCloudify. All rights reserved. + Community hardware is operator-owned — see the marketplace for details. +
+
+
+
+
+ ); +}; diff --git a/packages/shared/src/components/marketing/CoreArchitectureDiagram.tsx b/packages/shared/src/components/marketing/CoreArchitectureDiagram.tsx new file mode 100644 index 0000000..851df5c --- /dev/null +++ b/packages/shared/src/components/marketing/CoreArchitectureDiagram.tsx @@ -0,0 +1,85 @@ +import { Boxes, Database, Network, Server } from "lucide-react"; + +const SELF_MANAGED_LAYERS = [ + "Your applications", + "Load balancer", + "Orchestrator (Kubernetes)", + "Storage layer — self-managed", + "Network / SDN — self-managed", +]; + +const CORE_LAYERS = ["Your applications", "Orchestrator (Kubernetes, or bring your own)"]; + +/** + * Visualizes why xCloudify core exists: it collapses the storage/network + * layers teams otherwise hand-roll into one primitive underneath whatever + * orchestrator they already run, so the same headroom needs fewer nodes. + */ +export function CoreArchitectureDiagram() { + return ( +
+
+

Building it yourself

+
+ {SELF_MANAGED_LAYERS.map((label) => ( +
+ {label} +
+ ))} +
+
+
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+

+ 12 nodes, each running a slice of every layer +

+
+
+ +
+

With xCloudify Core

+
+ {CORE_LAYERS.map((label) => ( +
+ {label} +
+ ))} +
+ +
+ + xCloudify Core +
+
+ + compute + + + network + + + storage + +
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+

+ 4 nodes, same headroom +

+
+
+
+ ); +} diff --git a/packages/shared/src/components/marketing/LivePingDemo.tsx b/packages/shared/src/components/marketing/LivePingDemo.tsx new file mode 100644 index 0000000..d670110 --- /dev/null +++ b/packages/shared/src/components/marketing/LivePingDemo.tsx @@ -0,0 +1,204 @@ +import { useCallback, useEffect, useState } from "react"; +import { Cpu, Gauge, MapPin, MemoryStick, ShieldCheck, Wifi, WifiOff } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { communityApi, CommunityNode, measurePing } from "@/services/communityApi"; + +interface PingResult { + medianMs: number | null; + path: string; + error?: string; +} + +/** + * Decorative browser<->worker link. Dashed and static when there is no + * community plane to reach; pulsing when there is, so the demo's own visual + * matches what it is honestly able to claim in the copy above it. + */ +function ConnectionIllustration({ reachable }: { reachable: boolean }) { + return ( + + ); +} + +export function LivePingDemo() { + const [nodes, setNodes] = useState([]); + const [loading, setLoading] = useState(true); + const [offline, setOffline] = useState(false); + const [pings, setPings] = useState>({}); + + const load = useCallback(async () => { + setLoading(true); + try { + setNodes(await communityApi.listAvailableNodes()); + setOffline(false); + } catch (err) { + // A network-level failure just means this install isn't running a + // community plane -- expected on plenty of deploys. Fold it into the + // offline illustration below instead of surfacing the raw fetch + // error, but keep it in the console for anyone actually debugging. + console.error("LivePingDemo: could not reach the community plane", err); + setOffline(true); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const runPing = async (node: CommunityNode) => { + setPings((p) => ({ ...p, [node.id]: "pending" })); + try { + const session = await communityApi.createPingSession(node.id); + const result = await measurePing(session); + setPings((p) => ({ + ...p, + [node.id]: { medianMs: result.medianMs, path: result.path }, + })); + } catch (err) { + setPings((p) => ({ + ...p, + [node.id]: { + medianMs: null, + path: "unknown", + error: err instanceof Error ? err.message : String(err), + }, + })); + } + }; + + return ( +
+
+ +

+ {offline + ? "This demo pings real community-hosted machines directly from your browser, not through our servers — it just needs a community plane to talk to, and this install isn't running one." + : "These are real, community-hosted machines on the marketplace right now. Ping one straight from your browser — no signup required. We only introduce the two ends; the measurement runs over a direct peer-to-peer connection to the machine, not through our servers."} +

+
+ + {loading &&

Loading live nodes…

} + + {!loading && offline && ( + + + +
+ + Demo unavailable on this install +
+

+ No community plane is configured here, so there's nothing to ping yet. +

+
+
+ )} + + {!loading && !offline && nodes.length === 0 && ( + + + +

+ No community nodes are online right now — check back shortly. +

+
+
+ )} + +
+ {!offline && nodes.slice(0, 6).map((node) => { + const ping = pings[node.id]; + return ( + + + {node.name} + {node.trust_tier} + + +
+ + + {node.specs.cpu_cores ?? "?"} cores + + + + {Math.round((node.specs.memory_mb ?? 0) / 1024)} GB + + + + {node.city ? `${node.city}, ${node.country}` : node.country} + +
+ +
+ + {ping === "pending" && Measuring…} + {ping && ping !== "pending" && ping.medianMs !== null && ( + + {ping.medianMs.toFixed(0)} ms + via {ping.path} + + )} + {ping && ping !== "pending" && ping.error && ( + {ping.error} + )} + {!ping && ( + + {node.pingable === false ? "Worker offline" : "Not measured yet"} + + )} +
+ + +
+
+ ); + })} +
+
+ ); +}